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::{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.
168pub fn render_page(doc: &Document, page: &Page, scale: f32) -> Result<Pixmap> {
169    render_page_with_options(doc, page, scale, &RenderOptions::default())
170}
171
172/// Renders a page like [`render_page`], honoring `opts` (currently the glyph
173/// painting tier). See [`render_page`] for the geometry contract.
174pub fn render_page_with_options(
175    doc: &Document,
176    page: &Page,
177    scale: f32,
178    opts: &RenderOptions,
179) -> Result<Pixmap> {
180    executor::render_page_with_options(doc, page, scale, opts)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn new_pixmap_is_transparent() {
189        let pix = Pixmap::new(3, 2);
190        assert_eq!(pix.width, 3);
191        assert_eq!(pix.height, 2);
192        assert_eq!(pix.data.len(), 24);
193        assert!(pix.data.iter().all(|&b| b == 0));
194    }
195
196    #[test]
197    fn fill_sets_every_pixel() {
198        let mut pix = Pixmap::new(2, 2);
199        pix.fill([1, 2, 3, 4]);
200        assert_eq!(pix.data, [1, 2, 3, 4].repeat(4));
201    }
202
203    #[test]
204    fn png_round_trip_preserves_pixels() {
205        let mut pix = Pixmap::new(3, 2);
206        for (i, b) in pix.data.iter_mut().enumerate() {
207            *b = (i * 11 % 256) as u8;
208        }
209        let bytes = pix.encode_png().expect("encode");
210        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
211
212        let decoder = png::Decoder::new(std::io::Cursor::new(&bytes));
213        let mut reader = decoder.read_info().expect("read_info");
214        let mut buf = vec![0u8; reader.output_buffer_size().expect("size")];
215        let info = reader.next_frame(&mut buf).expect("frame");
216        assert_eq!(info.width, 3);
217        assert_eq!(info.height, 2);
218        assert_eq!(info.color_type, png::ColorType::Rgba);
219        assert_eq!(info.bit_depth, png::BitDepth::Eight);
220        assert_eq!(&buf[..info.buffer_size()], &pix.data[..]);
221    }
222
223    #[test]
224    fn save_png_writes_decodable_file() {
225        let mut pix = Pixmap::new(4, 4);
226        pix.fill([10, 20, 30, 255]);
227        let dir = std::env::temp_dir().join("pdfboss-render-test");
228        std::fs::create_dir_all(&dir).unwrap();
229        let path = dir.join("pix.png");
230        pix.save_png(&path).expect("save");
231        let bytes = std::fs::read(&path).unwrap();
232        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
233        std::fs::remove_file(&path).ok();
234    }
235}