mod cff;
#[allow(dead_code)]
mod color;
mod executor;
mod glyph;
mod image;
#[allow(dead_code)]
mod path;
#[allow(dead_code)]
mod raster;
#[allow(dead_code)]
mod stroke;
#[allow(dead_code)]
mod substitute;
mod truetype;
mod type1;
mod type3;
use std::path::{Path, PathBuf};
use pdfboss_core::{Document, Error, Page, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct Pixmap {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
impl Pixmap {
pub fn new(w: u32, h: u32) -> Pixmap {
Pixmap {
width: w,
height: h,
data: vec![0; w as usize * h as usize * 4],
}
}
pub fn fill(&mut self, rgba: [u8; 4]) {
for px in self.data.chunks_exact_mut(4) {
px.copy_from_slice(&rgba);
}
}
pub fn encode_png(&self) -> Result<Vec<u8>> {
fn err(e: png::EncodingError) -> Error {
Error::Other(format!("png encode: {e}"))
}
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, self.width, self.height);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header().map_err(err)?;
writer.write_image_data(&self.data).map_err(err)?;
writer.finish().map_err(err)?;
Ok(out)
}
pub fn save_png(&self, path: impl AsRef<Path>) -> Result<()> {
std::fs::write(path, self.encode_png()?)?;
Ok(())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GlyphPainting {
EmbeddedTrueTypeOnly,
#[default]
AllEmbedded,
Full,
}
impl GlyphPainting {
pub fn paints_all_embedded(self) -> bool {
!matches!(self, GlyphPainting::EmbeddedTrueTypeOnly)
}
}
#[derive(Clone, Debug, Default)]
pub enum SubstituteSource {
#[default]
None,
Builtin,
Dir(PathBuf),
}
#[derive(Clone, Debug, Default)]
pub struct RenderOptions {
pub glyph_painting: GlyphPainting,
pub substitutes: SubstituteSource,
}
pub fn builtin_fonts_available() -> bool {
cfg!(feature = "substitute-fonts")
}
pub fn render_page(doc: &Document, page: &Page, scale: f32) -> Result<Pixmap> {
render_page_with_options(doc, page, scale, &RenderOptions::default())
}
pub fn render_page_with_options(
doc: &Document,
page: &Page,
scale: f32,
opts: &RenderOptions,
) -> Result<Pixmap> {
executor::render_page_reporting(doc, page, scale, opts).map(|(pix, _)| pix)
}
pub fn render_page_reporting(
doc: &Document,
page: &Page,
scale: f32,
opts: &RenderOptions,
) -> Result<(Pixmap, RenderReport)> {
executor::render_page_reporting(doc, page, scale, opts)
}
const MAX_SKIPPED: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SkippedKind {
PageContents,
Image,
Form,
XObject,
Shading,
Pattern,
SoftMask,
BlendMode,
Annotation,
}
impl SkippedKind {
fn noun(self, n: u64) -> &'static str {
let one = n == 1;
match self {
SkippedKind::PageContents if one => "content stream",
SkippedKind::PageContents => "content streams",
SkippedKind::Image if one => "image",
SkippedKind::Image => "images",
SkippedKind::Form if one => "form XObject",
SkippedKind::Form => "form XObjects",
SkippedKind::XObject if one => "XObject",
SkippedKind::XObject => "XObjects",
SkippedKind::Shading if one => "shading",
SkippedKind::Shading => "shadings",
SkippedKind::Pattern if one => "pattern",
SkippedKind::Pattern => "patterns",
SkippedKind::SoftMask if one => "mask",
SkippedKind::SoftMask => "masks",
SkippedKind::BlendMode if one => "blend mode",
SkippedKind::BlendMode => "blend modes",
SkippedKind::Annotation if one => "annotation",
SkippedKind::Annotation => "annotations",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkipReason {
UnsupportedFilter(String),
DecodeFailed(String),
Undecodable,
Truncated,
Missing,
Unsupported,
LimitExceeded,
}
impl std::fmt::Display for SkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkipReason::UnsupportedFilter(name) => write!(f, "unsupported filter /{name}"),
SkipReason::DecodeFailed(msg) => f.write_str(msg),
SkipReason::Undecodable => f.write_str("the data could not be interpreted"),
SkipReason::Truncated => f.write_str("sample data ended early; the rest painted blank"),
SkipReason::Missing => f.write_str("the resource is missing"),
SkipReason::Unsupported => f.write_str("not supported yet"),
SkipReason::LimitExceeded => f.write_str("a nesting limit stopped the render here"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkippedContent {
pub kind: SkippedKind,
pub reason: SkipReason,
pub count: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderReport {
pub skipped: Vec<SkippedContent>,
pub unlisted: u64,
}
impl RenderReport {
pub fn is_empty(&self) -> bool {
self.skipped.is_empty() && self.unlisted == 0
}
pub fn summary(&self) -> Option<String> {
if self.is_empty() {
return None;
}
let mut totals: Vec<(SkippedKind, u64)> = Vec::new();
for item in &self.skipped {
match totals.iter_mut().find(|(kind, _)| *kind == item.kind) {
Some((_, n)) => *n = n.saturating_add(item.count),
None => totals.push((item.kind, item.count)),
}
}
let mut parts: Vec<String> = totals
.iter()
.map(|(kind, n)| format!("{n} {}", kind.noun(*n)))
.collect();
if self.unlisted > 0 {
parts.push(format!("{} more", self.unlisted));
}
Some(format!("{} skipped", parts.join(", ")))
}
pub fn warnings(&self) -> Vec<String> {
let mut out: Vec<String> = self
.skipped
.iter()
.map(|item| {
format!(
"{} {} skipped: {}",
item.count,
item.kind.noun(item.count),
item.reason
)
})
.collect();
if self.unlisted > 0 {
out.push(format!(
"{} further drops not described (report limit reached)",
self.unlisted
));
}
out
}
pub(crate) fn record(&mut self, kind: SkippedKind, reason: SkipReason) {
if let Some(item) = self
.skipped
.iter_mut()
.find(|item| item.kind == kind && item.reason == reason)
{
item.count = item.count.saturating_add(1);
return;
}
if self.skipped.len() >= MAX_SKIPPED {
self.unlisted = self.unlisted.saturating_add(1);
return;
}
self.skipped.push(SkippedContent {
kind,
reason,
count: 1,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_pixmap_is_transparent() {
let pix = Pixmap::new(3, 2);
assert_eq!(pix.width, 3);
assert_eq!(pix.height, 2);
assert_eq!(pix.data.len(), 24);
assert!(pix.data.iter().all(|&b| b == 0));
}
#[test]
fn fill_sets_every_pixel() {
let mut pix = Pixmap::new(2, 2);
pix.fill([1, 2, 3, 4]);
assert_eq!(pix.data, [1, 2, 3, 4].repeat(4));
}
#[test]
fn png_round_trip_preserves_pixels() {
let mut pix = Pixmap::new(3, 2);
for (i, b) in pix.data.iter_mut().enumerate() {
*b = (i * 11 % 256) as u8;
}
let bytes = pix.encode_png().expect("encode");
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
let mut reader = decoder.read_info().expect("read_info");
let mut buf = vec![0u8; reader.output_buffer_size().expect("size")];
let info = reader.next_frame(&mut buf).expect("frame");
assert_eq!(info.width, 3);
assert_eq!(info.height, 2);
assert_eq!(info.color_type, png::ColorType::Rgba);
assert_eq!(info.bit_depth, png::BitDepth::Eight);
assert_eq!(&buf[..info.buffer_size()], &pix.data[..]);
}
#[test]
fn report_merges_repeats_and_counts_per_kind() {
let mut report = RenderReport::default();
assert!(report.is_empty());
report.record(SkippedKind::Image, SkipReason::Undecodable);
report.record(SkippedKind::Image, SkipReason::Undecodable);
report.record(
SkippedKind::Image,
SkipReason::UnsupportedFilter("JPXDecode".to_string()),
);
report.record(SkippedKind::Shading, SkipReason::Unsupported);
assert!(!report.is_empty());
assert_eq!(report.skipped.len(), 3, "same kind and reason merge");
assert_eq!(report.skipped[0].count, 2);
assert_eq!(
report.summary().as_deref(),
Some("3 images, 1 shading skipped"),
);
assert_eq!(
report.warnings(),
vec![
"2 images skipped: the data could not be interpreted".to_string(),
"1 image skipped: unsupported filter /JPXDecode".to_string(),
"1 shading skipped: not supported yet".to_string(),
],
);
}
#[test]
fn report_stops_listing_at_the_cap_but_keeps_counting() {
let mut report = RenderReport::default();
for i in 0..MAX_SKIPPED + 5 {
report.record(SkippedKind::Image, SkipReason::DecodeFailed(i.to_string()));
}
assert_eq!(report.skipped.len(), MAX_SKIPPED);
assert_eq!(report.unlisted, 5);
assert_eq!(
report.summary().as_deref(),
Some("64 images, 5 more skipped"),
);
}
#[test]
fn save_png_writes_decodable_file() {
let mut pix = Pixmap::new(4, 4);
pix.fill([10, 20, 30, 255]);
let dir = std::env::temp_dir().join("pdfboss-render-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pix.png");
pix.save_png(&path).expect("save");
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
std::fs::remove_file(&path).ok();
}
}