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