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