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