Skip to main content

lightweight_pdf/
render.rs

1//! Translates `lightweight-pdf-layout`'s `RenderNode` tree into `lightweight-pdf-writer`
2//! content-stream operations — the facade is where layout output meets the
3//! PDF writer (`plan/00a-contracts-and-artifacts.md` point 3: `lightweight-pdf-writer`
4//! never sees `RenderNode` itself). Also converts the internal top-down
5//! layout coordinate system to PDF's bottom-left origin, and (Phase 4)
6//! drives font subsetting: layout first (needs advances for whatever
7//! Unicode text the document contains), then walk the finished render tree
8//! to learn exactly which characters were used, then subset each font
9//! down to just those glyphs before writing the PDF.
10//!
11//! `render()`/`render_with_diagnostics()` need the bundled default fonts
12//! (`default-fonts` feature) and are `#[cfg]`-gated on it accordingly;
13//! `render_with_fonts()`/`render_with_fonts_and_diagnostics()` take an
14//! already-built `FontRegistry` (e.g. `FontRegistry::with_fonts(...)`, see
15//! `fonts.rs`) and work regardless of that feature — so without
16//! `default-fonts`, this module's private render pipeline is still
17//! reachable through those two, not dead code.
18
19use crate::fonts::FontRegistry;
20use crate::images::{self, ImageEmbedError};
21use lightweight_pdf_core::{Align, Color, Document, FontKey, Watermark};
22use lightweight_pdf_layout::{paginate, LayoutCtx, LayoutWarning, PageRender, Rect, RenderNode};
23use lightweight_pdf_writer::{CidFont, ContentBuilder, PdfDocument, PdfPage, Rgb};
24use std::collections::{BTreeMap, BTreeSet, HashMap};
25
26#[derive(Debug)]
27pub enum RenderError {
28    Font(lightweight_pdf_fonts::FontError),
29    Image(ImageEmbedError),
30}
31
32impl core::fmt::Display for RenderError {
33    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
34        match self {
35            RenderError::Font(e) => write!(f, "font error: {e}"),
36            RenderError::Image(e) => write!(f, "image error: {e}"),
37        }
38    }
39}
40
41impl From<lightweight_pdf_fonts::FontError> for RenderError {
42    fn from(e: lightweight_pdf_fonts::FontError) -> Self {
43        RenderError::Font(e)
44    }
45}
46
47impl From<ImageEmbedError> for RenderError {
48    fn from(e: ImageEmbedError) -> Self {
49        RenderError::Image(e)
50    }
51}
52
53fn to_rgb(c: Color) -> Rgb {
54    Rgb(c.0, c.1, c.2)
55}
56
57fn align_offset(align: Align, available: f32, used: f32) -> f32 {
58    match align {
59        Align::Start => 0.0,
60        Align::Center => ((available - used) / 2.0).max(0.0),
61        Align::End => (available - used).max(0.0),
62    }
63}
64
65/// `page_height - top_left_y - height` — converts a layout-space box's
66/// top-left/height into the bottom-left `y` PDF rectangles expect.
67fn pdf_rect_y(page_height: f32, y_top: f32, height: f32) -> f32 {
68    page_height - y_top - height
69}
70
71fn collect_chars_in_node(node: &RenderNode, used: &mut HashMap<FontKey, BTreeSet<char>>) {
72    match node {
73        RenderNode::Empty | RenderNode::Rect { .. } | RenderNode::Line { .. } | RenderNode::Image { .. } => {}
74        RenderNode::Group { children, .. } => {
75            for child in children {
76                collect_chars_in_node(child, used);
77            }
78        }
79        RenderNode::TextLines { style, lines, .. } => {
80            let set = used.entry(style.font).or_default();
81            for line in lines {
82                set.extend(line.chars());
83            }
84        }
85    }
86}
87
88fn collect_chars_in_page(page: &PageRender, used: &mut HashMap<FontKey, BTreeSet<char>>) {
89    if let Some(header) = &page.header {
90        collect_chars_in_node(header, used);
91    }
92    collect_chars_in_node(&page.body, used);
93    if let Some(footer) = &page.footer {
94        collect_chars_in_node(footer, used);
95    }
96}
97
98/// A font actually embedded in the output: its PDF resource/font index,
99/// the character-to-CID mapping content streams encode text with (CID ==
100/// the subset's own glyph ID, `CIDToGIDMap /Identity`), and the per-GID
101/// widths/ascent needed to position text — taken from the *subset* itself
102/// so positioning always matches exactly what got embedded.
103struct EmbeddedFont {
104    index: usize,
105    char_to_gid: BTreeMap<char, u16>,
106    ascent_1000: f32,
107    widths_1000_by_gid: Vec<f32>,
108}
109
110fn encode_cid(line: &str, char_to_gid: &BTreeMap<char, u16>) -> Vec<u8> {
111    let mut bytes = Vec::with_capacity(line.chars().count() * 2);
112    for ch in line.chars() {
113        let gid = char_to_gid.get(&ch).copied().unwrap_or(0); // .notdef fallback
114        bytes.extend_from_slice(&gid.to_be_bytes());
115    }
116    bytes
117}
118
119fn line_width_pt(font: &EmbeddedFont, size: f32, line: &str) -> f32 {
120    let sum_1000: f32 = line
121        .chars()
122        .map(|ch| {
123            let gid = font.char_to_gid.get(&ch).copied().unwrap_or(0);
124            font.widths_1000_by_gid.get(gid as usize).copied().unwrap_or(0.0)
125        })
126        .sum();
127    sum_1000 / 1000.0 * size
128}
129
130fn render_node(
131    node: &RenderNode,
132    page_height: f32,
133    embedded: &HashMap<FontKey, EmbeddedFont>,
134    pdf: &mut PdfDocument,
135    cb: &mut ContentBuilder,
136) -> Result<(), RenderError> {
137    match node {
138        RenderNode::Empty => {}
139        RenderNode::Group {
140            area,
141            clip,
142            background,
143            border,
144            children,
145        } => {
146            cb.save();
147            if *clip {
148                cb.clip_rect(area.x, pdf_rect_y(page_height, area.y, area.height), area.width, area.height);
149            }
150            if let Some(bg) = background {
151                cb.fill_rect(
152                    area.x,
153                    pdf_rect_y(page_height, area.y, area.height),
154                    area.width,
155                    area.height,
156                    to_rgb(*bg),
157                );
158            }
159            for child in children {
160                render_node(child, page_height, embedded, pdf, cb)?;
161            }
162            if let Some(b) = border {
163                cb.stroke_rect(
164                    area.x,
165                    pdf_rect_y(page_height, area.y, area.height),
166                    area.width,
167                    area.height,
168                    b.width,
169                    to_rgb(b.color),
170                );
171            }
172            cb.restore();
173        }
174        RenderNode::Rect { area, background, border } => {
175            if let Some(bg) = background {
176                cb.fill_rect(
177                    area.x,
178                    pdf_rect_y(page_height, area.y, area.height),
179                    area.width,
180                    area.height,
181                    to_rgb(*bg),
182                );
183            }
184            if let Some(b) = border {
185                cb.stroke_rect(
186                    area.x,
187                    pdf_rect_y(page_height, area.y, area.height),
188                    area.width,
189                    area.height,
190                    b.width,
191                    to_rgb(b.color),
192                );
193            }
194        }
195        RenderNode::Line {
196            x1,
197            y1,
198            x2,
199            y2,
200            thickness,
201            color,
202        } => {
203            cb.line(*x1, page_height - *y1, *x2, page_height - *y2, *thickness, to_rgb(*color));
204        }
205        RenderNode::TextLines {
206            area,
207            style,
208            lines,
209            line_height_pt,
210        } => {
211            let Some(font) = embedded.get(&style.font) else {
212                return Ok(()); // font had nothing usable subset (shouldn't happen for a font that produced text, defensive only)
213            };
214            let resource = PdfDocument::font_resource_name(font.index);
215            let ascent_pt = font.ascent_1000 / 1000.0 * style.size;
216            for (i, line) in lines.iter().enumerate() {
217                if line.is_empty() {
218                    continue;
219                }
220                let line_top = area.y + i as f32 * line_height_pt;
221                let baseline_pdf_y = page_height - (line_top + ascent_pt);
222                let line_width = line_width_pt(font, style.size, line);
223                let x = area.x + align_offset(style.align, area.width, line_width);
224                let bytes = encode_cid(line, &font.char_to_gid);
225                cb.text(&resource, style.size, x, baseline_pdf_y, to_rgb(style.color), &bytes);
226            }
227        }
228        RenderNode::Image {
229            area,
230            bytes,
231            format,
232            width_px,
233            height_px,
234            components,
235        } => {
236            let mut pdf_image = images::build_pdf_image(bytes, *format, *components)?;
237            pdf_image.width_px = *width_px;
238            pdf_image.height_px = *height_px;
239            let index = pdf.add_image(pdf_image);
240            let resource = PdfDocument::image_resource_name(index);
241            cb.draw_image(
242                &resource,
243                area.x,
244                pdf_rect_y(page_height, area.y, area.height),
245                area.width,
246                area.height,
247            );
248        }
249    }
250    Ok(())
251}
252
253/// Draws the document-wide watermark centered on `body_area`, clipped to
254/// it — never the header/footer bands (`plan/phases/phase-6-business-
255/// polish.md` step 2's explicit requirement). A missing font entry (the
256/// watermark's chars somehow weren't subset) is a silent no-op rather than
257/// an error: a decorative stamp failing to draw must not fail the whole
258/// render.
259fn draw_watermark(
260    watermark: &Watermark,
261    body_area: Rect,
262    page_height: f32,
263    embedded: &HashMap<FontKey, EmbeddedFont>,
264    cb: &mut ContentBuilder,
265) {
266    let Some(font) = embedded.get(&watermark.font) else {
267        return;
268    };
269    let resource = PdfDocument::font_resource_name(font.index);
270    let bytes = encode_cid(&watermark.text, &font.char_to_gid);
271    let half_width = line_width_pt(font, watermark.size, &watermark.text) / 2.0;
272    let cx = body_area.x + body_area.width / 2.0;
273    let cy_layout = body_area.y + body_area.height / 2.0;
274    let cy_pdf = page_height - cy_layout;
275
276    cb.save();
277    cb.clip_rect(
278        body_area.x,
279        pdf_rect_y(page_height, body_area.y, body_area.height),
280        body_area.width,
281        body_area.height,
282    );
283    cb.text_rotated(
284        &resource,
285        watermark.size,
286        cx,
287        cy_pdf,
288        watermark.rotation_deg,
289        half_width,
290        to_rgb(watermark.color),
291        &bytes,
292    );
293    cb.restore();
294}
295
296fn render_document(doc: &Document, fonts: &FontRegistry) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError> {
297    let ctx = LayoutCtx { resolver: fonts };
298    let paginated = paginate(doc, &ctx);
299
300    let mut used_chars: HashMap<FontKey, BTreeSet<char>> = HashMap::new();
301    for page in &paginated.pages {
302        collect_chars_in_page(page, &mut used_chars);
303    }
304    if let Some(watermark) = &doc.watermark {
305        used_chars.entry(watermark.font).or_default().extend(watermark.text.chars());
306    }
307
308    let mut pdf = PdfDocument::new();
309    let mut embedded: HashMap<FontKey, EmbeddedFont> = HashMap::new();
310    for (key, entry) in fonts.font_entries() {
311        let Some(chars) = used_chars.get(&key) else {
312            continue; // this weight was never referenced in the document
313        };
314        let subset = lightweight_pdf_fonts::subset_font(&entry.data, chars)?;
315        let metrics = entry.metrics();
316        let char_to_gid = subset.char_to_gid.clone();
317        let widths_1000_by_gid = subset.widths_1000.clone();
318        let index = pdf.add_font(CidFont {
319            base_font: entry.base_font_name.to_string(),
320            subset_bytes: subset.font_data,
321            widths: subset.widths_1000,
322            ascent: metrics.ascent,
323            descent: metrics.descent,
324            cap_height: metrics.cap_height,
325            italic_angle: metrics.italic_angle,
326            bbox: metrics.bbox,
327            is_italic: metrics.is_italic,
328            is_bold: metrics.is_bold,
329            to_unicode: subset.char_to_gid.iter().map(|(&ch, &gid)| (gid, ch)).collect(),
330        });
331        embedded.insert(
332            key,
333            EmbeddedFont {
334                index,
335                char_to_gid,
336                ascent_1000: metrics.ascent,
337                widths_1000_by_gid,
338            },
339        );
340    }
341
342    for page in &paginated.pages {
343        let mut cb = ContentBuilder::new();
344        cb.save();
345        cb.clip_rect(0.0, 0.0, paginated.page_width, paginated.page_height);
346        // Watermark first (bottom layer, `05-overflow-and-robustness.md`):
347        // normal content always draws on top of it afterwards, which is
348        // what guarantees it never makes text unreadable.
349        if let Some(watermark) = &doc.watermark {
350            draw_watermark(watermark, paginated.body_area, paginated.page_height, &embedded, &mut cb);
351        }
352        if let Some(header) = &page.header {
353            render_node(header, paginated.page_height, &embedded, &mut pdf, &mut cb)?;
354        }
355        render_node(&page.body, paginated.page_height, &embedded, &mut pdf, &mut cb)?;
356        if let Some(footer) = &page.footer {
357            render_node(footer, paginated.page_height, &embedded, &mut pdf, &mut cb)?;
358        }
359        cb.restore();
360        pdf.add_page(PdfPage {
361            width: paginated.page_width,
362            height: paginated.page_height,
363            content: cb.into_bytes(),
364        });
365    }
366
367    Ok((pdf.write(), paginated.warnings))
368}
369
370/// Extension trait adding `render()`/`render_with_diagnostics()` (bundled
371/// default fonts) and `render_with_fonts()`/`render_with_fonts_and_diagnostics()`
372/// (caller-supplied fonts, see `fonts.rs::FontRegistry::with_fonts()`) to
373/// `lightweight_pdf_core::Document`. Lives here (not in `lightweight-pdf-core`)
374/// because rendering needs layout, fonts and the PDF writer —
375/// `lightweight-pdf-core` must not depend on any of them (ADR-002). This is
376/// the point at which `render()` becomes public (ADR-002).
377pub trait DocumentExt {
378    // Without `default-fonts` there is no bundled font source, so these two
379    // are simply not part of the trait rather than failing at runtime.
380    #[cfg(feature = "default-fonts")]
381    fn render(&self) -> Result<Vec<u8>, RenderError>;
382    #[cfg(feature = "default-fonts")]
383    fn render_with_diagnostics(&self) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError>;
384
385    fn render_with_fonts(&self, fonts: &FontRegistry) -> Result<Vec<u8>, RenderError>;
386    fn render_with_fonts_and_diagnostics(&self, fonts: &FontRegistry) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError>;
387}
388
389impl DocumentExt for Document {
390    #[cfg(feature = "default-fonts")]
391    fn render(&self) -> Result<Vec<u8>, RenderError> {
392        let (bytes, _warnings) = self.render_with_diagnostics()?;
393        Ok(bytes)
394    }
395
396    #[cfg(feature = "default-fonts")]
397    fn render_with_diagnostics(&self) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError> {
398        let fonts = FontRegistry::with_defaults()?;
399        render_document(self, &fonts)
400    }
401
402    fn render_with_fonts(&self, fonts: &FontRegistry) -> Result<Vec<u8>, RenderError> {
403        let (bytes, _warnings) = self.render_with_fonts_and_diagnostics(fonts)?;
404        Ok(bytes)
405    }
406
407    fn render_with_fonts_and_diagnostics(&self, fonts: &FontRegistry) -> Result<(Vec<u8>, Vec<LayoutWarning>), RenderError> {
408        render_document(self, fonts)
409    }
410}