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