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