Skip to main content

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