pdfboss_render/lib.rs
1//! Page rasterization for pdfboss: paths, fills, strokes, clipping, color
2//! spaces, images and glyph outlines, rendered to an RGBA8 pixmap and
3//! encodable as PNG.
4//!
5//! Glyph painting is staged behind [`GlyphPainting`] tiers: embedded
6//! TrueType only, then every embedded font program (TrueType, CFF, Type1,
7//! Type3), and finally `Full`, which additionally substitutes a
8//! replacement face for a non-embedded simple font (see `crate::glyph` and
9//! `crate::substitute` for the loader and the request/provider plumbing).
10//! A substitute face comes from either a caller-supplied directory
11//! ([`SubstituteSource::Dir`]) or the compiled-in OFL Croscore set
12//! ([`SubstituteSource::Builtin`]), the latter gated behind this crate's
13//! `substitute-fonts` Cargo feature and queryable at runtime via
14//! [`builtin_fonts_available`]. Advance widths for a substituted
15//! standard-14 font additionally consult Adobe Core-14 AFM tables
16//! (`pdfboss_encoding::standard_14_width`) ahead of the substitute's own
17//! `hmtx`, behind only the PDF's own `/Widths`.
18//!
19//! v1 limitations: `/Symbol` and `/ZapfDingbats` have no license-clean
20//! substitute, so they stay unpainted at every tier rather than borrowing
21//! an unrelated face's glyphs (their text still advances, via the
22//! metrics-only loader in `crate::glyph`); and a "bold" *sans* substitute
23//! request is not visually distinct from regular weight (Arimo is a
24//! `[wght]` variable font, rendered at its Regular instance -- only italic
25//! varies, via a separate static face).
26
27// The rasterizer modules are consumed by the content-stream executor; the
28// `dead_code` allowances below disappear once it is wired up.
29mod cff;
30#[allow(dead_code)]
31mod color;
32mod encode;
33mod executor;
34mod extract;
35mod glyph;
36mod image;
37mod jpeg;
38#[allow(dead_code)]
39mod path;
40#[allow(dead_code)]
41mod raster;
42mod shading;
43#[allow(dead_code)]
44mod stroke;
45#[allow(dead_code)]
46mod substitute;
47mod truetype;
48mod type1;
49mod type3;
50
51use std::path::{Path, PathBuf};
52use std::sync::Arc;
53
54use pdfboss_core::{AsyncObjectSource, Document, Error, OcState, Page, Result};
55
56pub use extract::{extract_page_images, extract_page_images_with};
57
58/// An RGBA8 raster image with straight (non-premultiplied) alpha, row-major
59/// from the top-left.
60#[derive(Debug, Clone, PartialEq)]
61pub struct Pixmap {
62 pub width: u32,
63 pub height: u32,
64 /// Pixel data, `width * height * 4` bytes (RGBA per pixel).
65 pub data: Vec<u8>,
66}
67
68/// How much CPU the PNG encoder spends shrinking the file. Every level
69/// round-trips the exact same pixels; only encode time and file size move.
70/// `Balanced` is what [`Pixmap::encode_png`] has always used.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub enum PngCompression {
73 /// Uncompressed: fastest, largest files.
74 None,
75 /// Very fast with a decent ratio.
76 Fast,
77 /// Balances encode speed and file size.
78 #[default]
79 Balanced,
80 /// Smallest files, much slower.
81 Best,
82}
83
84impl PngCompression {
85 fn to_encoding(self) -> png::Compression {
86 match self {
87 PngCompression::None => png::Compression::NoCompression,
88 PngCompression::Fast => png::Compression::Fast,
89 PngCompression::Balanced => png::Compression::Balanced,
90 PngCompression::Best => png::Compression::High,
91 }
92 }
93}
94
95/// The file format [`Pixmap::encode`] writes. PNG keeps every channel and
96/// carries its compression level; PPM and BMP are a header plus one packing
97/// pass over the pixels, dropping alpha; JPEG is lossy at its quality.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum ImageFormat {
100 /// PNG, RGBA 8-bit, at the given compression level.
101 Png(PngCompression),
102 /// Binary PPM (`P6`): RGB rows, top-down, no padding.
103 Ppm,
104 /// Windows BMP: 24-bit BGR rows, bottom-up, padded to four bytes.
105 Bmp,
106 /// Baseline JPEG, 4:4:4, at `quality` 1 to 100 (clamped); alpha dropped.
107 Jpeg {
108 /// The conventional quality knob: 50 uses the standard tables as
109 /// printed, 100 keeps every coefficient, lower values coarsen.
110 quality: u8,
111 },
112}
113
114impl ImageFormat {
115 /// The JPEG quality [`ImageFormat::from_name`] hands out.
116 pub const DEFAULT_JPEG_QUALITY: u8 = 90;
117
118 /// The format a file extension or a Python string names, case
119 /// insensitively: `png` (at the default compression level), `ppm`,
120 /// `bmp`, or `jpeg` / `jpg` (at [`ImageFormat::DEFAULT_JPEG_QUALITY`]).
121 pub fn from_name(name: &str) -> Option<ImageFormat> {
122 match name.to_ascii_lowercase().as_str() {
123 "png" => Some(ImageFormat::Png(PngCompression::default())),
124 "ppm" => Some(ImageFormat::Ppm),
125 "bmp" => Some(ImageFormat::Bmp),
126 "jpeg" | "jpg" => Some(ImageFormat::Jpeg {
127 quality: ImageFormat::DEFAULT_JPEG_QUALITY,
128 }),
129 _ => None,
130 }
131 }
132}
133
134impl Pixmap {
135 /// Creates a fully transparent pixmap.
136 pub fn new(w: u32, h: u32) -> Pixmap {
137 Pixmap {
138 width: w,
139 height: h,
140 data: vec![0; w as usize * h as usize * 4],
141 }
142 }
143
144 /// Fills every pixel with `rgba`.
145 pub fn fill(&mut self, rgba: [u8; 4]) {
146 for px in self.data.as_chunks_mut::<4>().0 {
147 *px = rgba;
148 }
149 }
150
151 /// Encodes the pixmap as a PNG image, at the default compression level.
152 pub fn encode_png(&self) -> Result<Vec<u8>> {
153 self.encode_png_with(PngCompression::default())
154 }
155
156 /// Encodes the pixmap as a PNG image at the given compression level.
157 pub fn encode_png_with(&self, compression: PngCompression) -> Result<Vec<u8>> {
158 fn err(e: png::EncodingError) -> Error {
159 Error::Other(format!("png encode: {e}"))
160 }
161 if compression == PngCompression::Balanced {
162 return Ok(encode::encode_rgba(self.width, self.height, &self.data));
163 }
164 let mut out = Vec::new();
165 let mut enc = png::Encoder::new(&mut out, self.width, self.height);
166 enc.set_color(png::ColorType::Rgba);
167 enc.set_depth(png::BitDepth::Eight);
168 enc.set_compression(compression.to_encoding());
169 let mut writer = enc.write_header().map_err(err)?;
170 writer.write_image_data(&self.data).map_err(err)?;
171 writer.finish().map_err(err)?;
172 Ok(out)
173 }
174
175 /// Encodes the pixmap in `format`.
176 pub fn encode(&self, format: ImageFormat) -> Result<Vec<u8>> {
177 match format {
178 ImageFormat::Png(compression) => self.encode_png_with(compression),
179 ImageFormat::Ppm => Ok(encode::encode_ppm(self.width, self.height, &self.data)),
180 ImageFormat::Bmp => Ok(encode::encode_bmp(self.width, self.height, &self.data)),
181 ImageFormat::Jpeg { quality } => {
182 jpeg::encode_jpeg(self.width, self.height, &self.data, quality)
183 }
184 }
185 }
186
187 /// Encodes the pixmap as PNG and writes it to `path`.
188 pub fn save_png(&self, path: impl AsRef<Path>) -> Result<()> {
189 std::fs::write(path, self.encode_png()?)?;
190 Ok(())
191 }
192}
193
194/// How aggressively the rasterizer turns text into filled outlines. Each tier is
195/// a strict superset of the previous one; the difference is only observable once
196/// the corresponding glyph loaders exist.
197#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
198pub enum GlyphPainting {
199 /// Only embedded TrueType (`glyf`) outlines — the cheapest tier.
200 EmbeddedTrueTypeOnly,
201 /// Every embedded program: TrueType, CFF, Type1 and Type3. No bundled assets.
202 #[default]
203 AllEmbedded,
204 /// Also substitute bundled or caller-provided faces for non-embedded
205 /// fonts, and per glyph for codes an embedded simple font's program
206 /// lacks.
207 Full,
208}
209
210impl GlyphPainting {
211 /// Whether this tier paints every embedded program (CFF, Type1, Type3),
212 /// not just embedded TrueType.
213 pub fn paints_all_embedded(self) -> bool {
214 !matches!(self, GlyphPainting::EmbeddedTrueTypeOnly)
215 }
216}
217
218/// Where non-embedded glyph substitution (the `Full` [`GlyphPainting`] tier)
219/// draws replacement faces from. The default, `None`, substitutes nothing --
220/// `Full` behaves exactly like `AllEmbedded` until a caller opts in.
221#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
222pub enum SubstituteSource {
223 /// No substitution: non-embedded fonts stay unpainted.
224 #[default]
225 None,
226 /// Compiled-in faces: the OFL Croscore set (Arimo/Tinos/Cousine,
227 /// metric-compatible with Helvetica/Times/Courier) bundled via
228 /// `include_bytes!` behind the `substitute-fonts` Cargo feature -- see
229 /// [`builtin_fonts_available`] and `crate::substitute::BuiltinProvider`.
230 /// Built without that feature, there are no compiled-in faces to hand
231 /// out: `Builtin` degrades to no provider at all, so `Full` behaves
232 /// exactly like `AllEmbedded` for non-embedded fonts, the same as
233 /// `SubstituteSource::None`.
234 Builtin,
235 /// Faces read from a directory at render time (e.g. an installed
236 /// `pdfboss-fonts` package), one file per style -- see
237 /// `substitute::face_filename`.
238 Dir(PathBuf),
239}
240
241/// Options controlling a single page render.
242#[derive(Clone, Debug, Default)]
243pub struct RenderOptions {
244 /// Which font programs the rasterizer will paint.
245 pub glyph_painting: GlyphPainting,
246 /// Where `Full`-tier substitution draws replacement faces from. Ignored
247 /// at every other tier.
248 pub substitutes: SubstituteSource,
249 /// The document's optional-content visibility (ISO 32000-1 §8.11):
250 /// content in groups the default configuration turns off is not
251 /// painted, counted in [`RenderReport::hidden`]. The synchronous entry
252 /// points fill this from the document when it is `None`; an
253 /// asynchronous caller builds it itself (e.g.
254 /// `AsyncDocument::oc_state`), and leaving it `None` there renders
255 /// every layer.
256 pub oc: Option<Arc<OcState>>,
257 /// Fonts loaded once for the document instead of once per page: a
258 /// caller rendering a whole document passes one [`RenderCache`] to
259 /// every page, and each font program — outline parsing included —
260 /// loads once. `None` keeps every load page-local. Keyed by the font
261 /// dictionary's object reference, so identical resource names on
262 /// different pages never collide, exactly like text extraction's
263 /// `FontCache`.
264 pub cache: Option<Arc<RenderCache>>,
265}
266
267/// Cross-page render state: fonts by their dictionary's object reference,
268/// and parsed `ICCBased` colorspace outcomes by their profile stream's.
269/// `Send + Sync`, so a parallel page walk may share one.
270#[derive(Default)]
271pub struct RenderCache {
272 fonts: std::sync::Mutex<pdfboss_core::FastMap<FontKey, executor::SharedGlyphFont>>,
273 /// Shared as one handle with every page's executor, which otherwise
274 /// builds a render-local one — the same two-level shape as `fonts`
275 /// without a second lookup tier.
276 colorspaces: Arc<color::IccCache>,
277}
278
279/// What a cached font load depended on besides the dictionary itself: the
280/// painting tier and the substitute source. One cache handle may serve
281/// renders with different options — a load made at `all-embedded` paints
282/// nothing for a non-embedded font and must not be handed to a `full`
283/// render, so the options are part of the key rather than an invariant the
284/// caller polices.
285#[derive(PartialEq, Eq, Hash, Clone)]
286pub(crate) struct FontKey {
287 pub(crate) font: pdfboss_core::ObjRef,
288 pub(crate) painting: GlyphPainting,
289 pub(crate) substitutes: SubstituteSource,
290}
291
292impl std::fmt::Debug for RenderCache {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 let fonts = self.fonts.lock().map(|m| m.len()).unwrap_or(0);
295 f.debug_struct("RenderCache")
296 .field("fonts", &fonts)
297 .finish()
298 }
299}
300
301impl RenderCache {
302 pub(crate) fn font(&self, key: &FontKey) -> Option<executor::SharedGlyphFont> {
303 self.fonts.lock().ok()?.get(key).cloned()
304 }
305
306 /// First writer wins, exactly like the text-side cache: concurrent
307 /// workers may load the same font twice and the copies are
308 /// interchangeable.
309 pub(crate) fn store(&self, key: FontKey, font: executor::SharedGlyphFont) {
310 if let Ok(mut fonts) = self.fonts.lock() {
311 fonts.entry(key).or_insert(font);
312 }
313 }
314}
315
316/// Whether this binary was built with the `substitute-fonts` feature, i.e.
317/// whether `SubstituteSource::Builtin` has compiled-in faces to hand out.
318/// Callers (e.g. the CLI) use this to give an actionable message when `Full`
319/// is requested with no `--font-dir` and no compiled-in set, rather than
320/// silently rendering as if `Full` had never been asked for.
321pub fn builtin_fonts_available() -> bool {
322 cfg!(feature = "substitute-fonts")
323}
324
325/// Renders a page at `scale` onto a white background. The pixel size is
326/// `ceil(crop_w * scale) x ceil(crop_h * scale)` (after `/Rotate`), and the
327/// base transform maps the crop box to device space with a y-flip and the
328/// page rotation applied.
329///
330/// Rendering is lenient: content pdfboss cannot read is skipped rather than
331/// failing the render, so a page can come back blank without an error. Use
332/// [`render_page_reporting`] to find out what was dropped.
333pub fn render_page(doc: &Document, page: &Page, scale: f32) -> Result<Pixmap> {
334 render_page_with_options(doc, page, scale, &RenderOptions::default())
335}
336
337/// Renders a page like [`render_page`], honoring `opts` (currently the glyph
338/// painting tier). See [`render_page`] for the geometry contract and for
339/// what leniency means for the pixels you get back.
340pub fn render_page_with_options(
341 doc: &Document,
342 page: &Page,
343 scale: f32,
344 opts: &RenderOptions,
345) -> Result<Pixmap> {
346 executor::render_page_reporting(doc, page, scale, opts).map(|(pix, _)| pix)
347}
348
349/// Renders a page like [`render_page_with_options`], additionally returning
350/// a [`RenderReport`] describing any content that had to be dropped or
351/// approximated. Use this when a silently blank page would be misleading.
352pub fn render_page_reporting(
353 doc: &Document,
354 page: &Page,
355 scale: f32,
356 opts: &RenderOptions,
357) -> Result<(Pixmap, RenderReport)> {
358 executor::render_page_reporting(doc, page, scale, opts)
359}
360
361/// Renders a page like [`render_page_reporting`] against any object source,
362/// awaiting whatever I/O the source needs — this is the asynchronous entry
363/// point, and the synchronous ones above are this implementation over
364/// `pdfboss_core::Immediate` (with [`RenderOptions::oc`] filled from the
365/// document when the caller left it unset), so under the same options the
366/// two APIs cannot render differently.
367///
368/// The source is taken by value and the page by reference, which is the
369/// combination a consumer needs to spawn the result: the future is `Send`
370/// over a source that is `Send + Sync`, and `'static` as long as the borrow
371/// of `page` is created inside the consumer's own `async move` block, which
372/// owns the page. See `pdfboss_core::source`'s "Signing a shared algorithm".
373pub async fn render_page_reporting_with<S: AsyncObjectSource>(
374 src: S,
375 page: &Page,
376 scale: f32,
377 opts: &RenderOptions,
378) -> Result<(Pixmap, RenderReport)> {
379 executor::render_page_reporting_with(src, page, scale, opts).await
380}
381
382/// Upper bound on the distinct entries a [`RenderReport`] keeps. Repeats of
383/// the same kind and reason only raise an existing entry's count, so this
384/// bounds the report's memory for any page: a stream drawing the same
385/// undecodable image a million times costs one entry, and a stream inventing
386/// endlessly *different* failures stops growing the list here and counts the
387/// rest in [`RenderReport::unlisted`].
388const MAX_SKIPPED: usize = 64;
389
390/// Which piece of page content a render could not reproduce.
391#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
392#[non_exhaustive]
393pub enum SkippedKind {
394 /// The page's own content stream: nothing on the page was drawn.
395 PageContents,
396 /// An image XObject or an inline image.
397 Image,
398 /// A form XObject, and with it everything nested inside it.
399 Form,
400 /// A `Do` whose XObject resource is missing, is not a stream, or has no
401 /// subtype this renderer knows how to draw.
402 XObject,
403 /// A shading (`sh` or a shading pattern) this renderer could not
404 /// load: a missing resource or a structural failure. All seven
405 /// shading types paint.
406 Shading,
407 /// A pattern fill or stroke, painted as flat mid-gray instead of the
408 /// pattern's own content.
409 Pattern,
410 /// A mask that was ignored, so content the author masked out painted
411 /// solid: an image `/SMask` or `/Mask`, or an `/ExtGState` `/SMask`.
412 SoftMask,
413 /// A `/BM` blend mode painted as `Normal`. No longer produced — every
414 /// ISO 32000 blend mode paints — but retained so report consumers
415 /// keep compiling against the same set of kinds.
416 BlendMode,
417 /// An annotation appearance stream that was declared but could not be
418 /// painted: unreadable, unparsable, or with no selectable state.
419 /// Annotations that declare no appearance, and ones flagged Hidden or
420 /// NoView, paint nothing by design and are not reported.
421 Annotation,
422 /// A character code a *painting* font has no glyph for: the code
423 /// advanced the text position but painted nothing. A single-byte code
424 /// 0x20 is exempt — a space paints nothing whether or not the font maps
425 /// it — while a two-byte 0x20 is a real CID and is reported. Text whose
426 /// font paints at no tier (the [`GlyphPainting`] tier, or a load
427 /// failure) is configured behavior and stays unreported; such a font
428 /// still loads its metrics so the text advances.
429 Glyph,
430 /// Text shown in a clipping rendering mode (`Tr` 4-7, ISO 32000-1
431 /// §9.3.6). The painting half of the mode is honored, but the glyph
432 /// outlines never join the clipping path, so content the author
433 /// clipped to the text paints unclipped.
434 TextClip,
435}
436
437impl SkippedKind {
438 /// The noun this kind reads as in [`RenderReport::summary`] and
439 /// [`RenderReport::warnings`], pluralized for `n`.
440 fn noun(self, n: u64) -> &'static str {
441 let one = n == 1;
442 match self {
443 SkippedKind::PageContents if one => "content stream",
444 SkippedKind::PageContents => "content streams",
445 SkippedKind::Image if one => "image",
446 SkippedKind::Image => "images",
447 SkippedKind::Form if one => "form XObject",
448 SkippedKind::Form => "form XObjects",
449 SkippedKind::XObject if one => "XObject",
450 SkippedKind::XObject => "XObjects",
451 SkippedKind::Shading if one => "shading",
452 SkippedKind::Shading => "shadings",
453 SkippedKind::Pattern if one => "pattern",
454 SkippedKind::Pattern => "patterns",
455 SkippedKind::SoftMask if one => "mask",
456 SkippedKind::SoftMask => "masks",
457 SkippedKind::BlendMode if one => "blend mode",
458 SkippedKind::BlendMode => "blend modes",
459 SkippedKind::Annotation if one => "annotation",
460 SkippedKind::Annotation => "annotations",
461 SkippedKind::Glyph if one => "glyph",
462 SkippedKind::Glyph => "glyphs",
463 SkippedKind::TextClip if one => "text clip",
464 SkippedKind::TextClip => "text clips",
465 }
466 }
467}
468
469/// Why a piece of page content was dropped or approximated during
470/// rasterization.
471///
472/// Rendering is lenient: content pdfboss cannot read is skipped so the rest
473/// of the page still rasterizes. This enum records *why*, so callers can
474/// tell an intentionally blank page from a page whose content pdfboss could
475/// not read.
476#[derive(Clone, Debug, PartialEq, Eq)]
477#[non_exhaustive]
478pub enum SkipReason {
479 /// The stream's `/Filter` chain names a filter pdfboss does not decode.
480 UnsupportedFilter(String),
481 /// Reading the stream failed, carrying the underlying message: a filter
482 /// that ran but gave up (corrupt data, size limit, ...), or a syntax
483 /// error in a content stream.
484 DecodeFailed(String),
485 /// Filters applied cleanly but the bytes could not be interpreted (bad
486 /// image dimensions, unparsable content stream, unsupported JPEG, ...).
487 Undecodable,
488 /// The stream held fewer samples than the image's dimensions and bit
489 /// depth demand; the missing region painted as zero samples.
490 Truncated,
491 /// A resource the operator names is absent, or is not the kind of object
492 /// the operator needs.
493 Missing,
494 /// pdfboss understands the construct but does not paint it yet, so it
495 /// was omitted or approximated.
496 Unsupported,
497 /// A nesting or size guard stopped the render at this point.
498 LimitExceeded,
499 /// A loaded font has no glyph for a character code the page draws, so
500 /// the code advanced the text position without painting. One value per
501 /// distinct `(font, code)` pair, so the report counts occurrences
502 /// instead of listing them.
503 NoGlyph {
504 /// The character code exactly as the show operator carried it.
505 code: u32,
506 /// The font's `/BaseFont` name, or its `Tf` resource name when the
507 /// dictionary has none.
508 font: String,
509 },
510}
511
512impl std::fmt::Display for SkipReason {
513 /// The reason as the clause after the colon of a warning line, e.g.
514 /// `unsupported filter /Crypt`.
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 match self {
517 SkipReason::UnsupportedFilter(name) => write!(f, "unsupported filter /{name}"),
518 SkipReason::DecodeFailed(msg) => f.write_str(msg),
519 SkipReason::Undecodable => f.write_str("the data could not be interpreted"),
520 SkipReason::Truncated => f.write_str("sample data ended early; the rest painted blank"),
521 SkipReason::Missing => f.write_str("the resource is missing"),
522 SkipReason::Unsupported => f.write_str("not supported yet"),
523 SkipReason::LimitExceeded => f.write_str("a nesting limit stopped the render here"),
524 SkipReason::NoGlyph { code, font } => {
525 write!(f, "no glyph for code {code} in /{font}")
526 }
527 }
528 }
529}
530
531/// One kind of content dropped for one reason, with how often it happened.
532#[derive(Clone, Debug, PartialEq, Eq)]
533#[non_exhaustive]
534pub struct SkippedContent {
535 /// What was dropped.
536 pub kind: SkippedKind,
537 /// Why it was dropped.
538 pub reason: SkipReason,
539 /// How many times this exact kind/reason pair came up in the render.
540 pub count: u64,
541}
542
543/// What a page render could not reproduce faithfully: content dropped
544/// outright (an undecodable image, an unreadable form) and content painted
545/// as an approximation (a pattern fill as flat gray). Empty means every
546/// construct the render encountered was painted as the page describes it.
547///
548/// Two things are deliberately *not* reported, because they are configured
549/// behavior rather than a failure: text left unpainted by the requested
550/// [`GlyphPainting`] tier — its font loads metrics only, so the text still
551/// advances but draws nothing — and content clipped or transformed off the
552/// page. A code a *painting* font has no glyph for is a real loss and IS
553/// reported, as [`SkippedKind::Glyph`].
554#[derive(Clone, Debug, Default, PartialEq, Eq)]
555#[non_exhaustive]
556pub struct RenderReport {
557 /// Distinct drops in the order first encountered, at most 64 entries
558 /// (see `count` for repeats and [`RenderReport::unlisted`] for the
559 /// overflow).
560 pub skipped: Vec<SkippedContent>,
561 /// Drops that arrived after `skipped` reached its 64-entry cap and so
562 /// are counted but not described.
563 pub unlisted: u64,
564 /// Content the document's optional-content configuration turns off
565 /// (ISO 32000-1 §8.11): one count per `BDC /OC` span whose own
566 /// membership evaluated hidden, per XObject with a hidden `/OC` entry,
567 /// and per annotation with a hidden `/OC` entry. Configured behavior,
568 /// not a loss, so it plays no part in [`RenderReport::is_empty`],
569 /// [`RenderReport::summary`], or [`RenderReport::warnings`].
570 pub hidden: u64,
571}
572
573impl RenderReport {
574 /// Whether the page rasterized with nothing dropped or approximated.
575 pub fn is_empty(&self) -> bool {
576 self.skipped.is_empty() && self.unlisted == 0
577 }
578
579 /// A one-line human summary counting drops per kind, or `None` when
580 /// nothing was dropped: `"2 images, 1 shading skipped"`.
581 pub fn summary(&self) -> Option<String> {
582 if self.is_empty() {
583 return None;
584 }
585 let mut totals: Vec<(SkippedKind, u64)> = Vec::new();
586 for item in &self.skipped {
587 match totals.iter_mut().find(|(kind, _)| *kind == item.kind) {
588 Some((_, n)) => *n = n.saturating_add(item.count),
589 None => totals.push((item.kind, item.count)),
590 }
591 }
592 let mut parts: Vec<String> = totals
593 .iter()
594 .map(|(kind, n)| format!("{n} {}", kind.noun(*n)))
595 .collect();
596 if self.unlisted > 0 {
597 parts.push(format!("{} more", self.unlisted));
598 }
599 Some(format!("{} skipped", parts.join(", ")))
600 }
601
602 /// One human-readable line per distinct drop, for callers that warn
603 /// about them: `"1 image skipped: unsupported filter /Crypt"`.
604 pub fn warnings(&self) -> Vec<String> {
605 let mut out: Vec<String> = self
606 .skipped
607 .iter()
608 .map(|item| {
609 format!(
610 "{} {} skipped: {}",
611 item.count,
612 item.kind.noun(item.count),
613 item.reason
614 )
615 })
616 .collect();
617 if self.unlisted > 0 {
618 out.push(format!(
619 "{} further drops not described (report limit reached)",
620 self.unlisted
621 ));
622 }
623 out
624 }
625
626 /// Records one drop, merging it into an existing entry when the same
627 /// kind and reason already happened. Beyond [`MAX_SKIPPED`] distinct
628 /// entries the drop is only counted, so a page drawing endlessly varied
629 /// broken content cannot grow this report without bound.
630 pub(crate) fn record(&mut self, kind: SkippedKind, reason: SkipReason) {
631 if let Some(item) = self
632 .skipped
633 .iter_mut()
634 .find(|item| item.kind == kind && item.reason == reason)
635 {
636 item.count = item.count.saturating_add(1);
637 return;
638 }
639 if self.skipped.len() >= MAX_SKIPPED {
640 self.unlisted = self.unlisted.saturating_add(1);
641 return;
642 }
643 self.skipped.push(SkippedContent {
644 kind,
645 reason,
646 count: 1,
647 });
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn new_pixmap_is_transparent() {
657 let pix = Pixmap::new(3, 2);
658 assert_eq!(pix.width, 3);
659 assert_eq!(pix.height, 2);
660 assert_eq!(pix.data.len(), 24);
661 assert!(pix.data.iter().all(|&b| b == 0));
662 }
663
664 #[test]
665 fn fill_sets_every_pixel() {
666 let mut pix = Pixmap::new(2, 2);
667 pix.fill([1, 2, 3, 4]);
668 assert_eq!(pix.data, [1, 2, 3, 4].repeat(4));
669 }
670
671 #[test]
672 fn compression_levels_map_to_their_encoder_settings() {
673 // png::Compression derives no PartialEq, hence matches!.
674 assert!(matches!(
675 PngCompression::None.to_encoding(),
676 png::Compression::NoCompression
677 ));
678 assert!(matches!(
679 PngCompression::Fast.to_encoding(),
680 png::Compression::Fast
681 ));
682 assert!(matches!(
683 PngCompression::Balanced.to_encoding(),
684 png::Compression::Balanced
685 ));
686 assert!(matches!(
687 PngCompression::Best.to_encoding(),
688 png::Compression::High
689 ));
690 }
691
692 #[test]
693 fn balanced_is_the_default_compression() {
694 assert_eq!(PngCompression::default(), PngCompression::Balanced);
695 }
696
697 #[test]
698 fn png_round_trip_preserves_pixels() {
699 let mut pix = Pixmap::new(3, 2);
700 for (i, b) in pix.data.iter_mut().enumerate() {
701 *b = (i * 11 % 256) as u8;
702 }
703 let bytes = pix.encode_png().expect("encode");
704 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
705
706 let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
707 let mut reader = decoder.read_info().expect("read_info");
708 let mut buf = vec![0u8; reader.output_buffer_size().expect("size")];
709 let info = reader.next_frame(&mut buf).expect("frame");
710 assert_eq!(info.width, 3);
711 assert_eq!(info.height, 2);
712 assert_eq!(info.color_type, png::ColorType::Rgba);
713 assert_eq!(info.bit_depth, png::BitDepth::Eight);
714 assert_eq!(&buf[..info.buffer_size()], &pix.data[..]);
715 }
716
717 #[test]
718 fn report_merges_repeats_and_counts_per_kind() {
719 let mut report = RenderReport::default();
720 assert!(report.is_empty());
721 report.record(SkippedKind::Image, SkipReason::Undecodable);
722 report.record(SkippedKind::Image, SkipReason::Undecodable);
723 report.record(
724 SkippedKind::Image,
725 SkipReason::UnsupportedFilter("Crypt".to_string()),
726 );
727 report.record(SkippedKind::Shading, SkipReason::Unsupported);
728
729 assert!(!report.is_empty());
730 assert_eq!(report.skipped.len(), 3, "same kind and reason merge");
731 assert_eq!(report.skipped[0].count, 2);
732 // The summary counts per kind, so the two image reasons add up.
733 assert_eq!(
734 report.summary().as_deref(),
735 Some("3 images, 1 shading skipped"),
736 );
737 assert_eq!(
738 report.warnings(),
739 vec![
740 "2 images skipped: the data could not be interpreted".to_string(),
741 "1 image skipped: unsupported filter /Crypt".to_string(),
742 "1 shading skipped: not supported yet".to_string(),
743 ],
744 );
745 }
746
747 #[test]
748 fn report_stops_listing_at_the_cap_but_keeps_counting() {
749 let mut report = RenderReport::default();
750 for i in 0..MAX_SKIPPED + 5 {
751 report.record(SkippedKind::Image, SkipReason::DecodeFailed(i.to_string()));
752 }
753 assert_eq!(report.skipped.len(), MAX_SKIPPED);
754 assert_eq!(report.unlisted, 5);
755 assert_eq!(
756 report.summary().as_deref(),
757 Some("64 images, 5 more skipped"),
758 );
759 }
760
761 #[test]
762 fn save_png_writes_decodable_file() {
763 let mut pix = Pixmap::new(4, 4);
764 pix.fill([10, 20, 30, 255]);
765 let dir = std::env::temp_dir().join("pdfboss-render-test");
766 std::fs::create_dir_all(&dir).unwrap();
767 let path = dir.join("pix.png");
768 pix.save_png(&path).expect("save");
769 let bytes = std::fs::read(&path).unwrap();
770 assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
771 std::fs::remove_file(&path).ok();
772 }
773}