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