Skip to main content

zenith_render/pdf/
document.rs

1//! Top-level PDF document assembly: page boxes, object-id allocation, resource
2//! materialization, and the deterministic trailer.
3
4use pdf_writer::types::{ActionType, AnnotationType, FunctionShadingType};
5use pdf_writer::{Filter, Finish, Pdf, Rect as PdfRect, Ref, Str};
6use zenith_core::{AssetProvider, FontProvider};
7use zenith_scene::Scene;
8
9use super::content::{
10    ALPHA_PREFIX, FONT_PREFIX, IMAGE_PREFIX, LinkAnnot, PageResources, SHADING_PREFIX, name,
11    translate,
12};
13use super::font::{self, FontPlan};
14use super::gradient::AxialGradient;
15
16/// Options controlling PDF emission.
17#[derive(Clone, Copy)]
18pub struct PdfOptions {
19    /// Subset embedded fonts to just the glyphs used (`true`, default → small
20    /// files) or embed the whole font program (`false`). Either way the text is
21    /// selectable and searchable.
22    pub subset: bool,
23}
24
25impl Default for PdfOptions {
26    fn default() -> Self {
27        Self { subset: true }
28    }
29}
30
31/// Render `scene` to a deterministic vector PDF (a single page).
32///
33/// `fonts` resolves glyph outlines for any `DrawGlyphRun`; `assets` resolves
34/// raster bytes for any `DrawImage`. The output carries print box metadata
35/// (MediaBox / TrimBox / BleedBox / CropBox) and native DeviceCMYK colors for
36/// CMYK-origin tokens. Identical input yields byte-identical output: no
37/// timestamps, no document id, ordered iteration throughout.
38///
39/// Mirrors the shape of [`crate::render_png`] (`scene`, `fonts`, `assets`).
40///
41/// This is a thin single-page wrapper over [`render_pdf_multi`]; the
42/// one-scene path through that function yields byte-identical output to a
43/// historical single-page implementation (catalog=1, pages=2, page=3,
44/// content=4, resources from 5).
45#[must_use]
46pub fn render_pdf(scene: &Scene, fonts: &dyn FontProvider, assets: &dyn AssetProvider) -> Vec<u8> {
47    render_pdf_multi(std::slice::from_ref(scene), fonts, assets)
48}
49
50/// Like [`render_pdf`] but with explicit [`PdfOptions`] (e.g. font subsetting).
51#[must_use]
52pub fn render_pdf_with(
53    scene: &Scene,
54    fonts: &dyn FontProvider,
55    assets: &dyn AssetProvider,
56    options: PdfOptions,
57) -> Vec<u8> {
58    render_pdf_multi_with(std::slice::from_ref(scene), fonts, assets, options)
59}
60
61/// Render `scenes` (one per document page, in order) to a single deterministic
62/// multi-page vector PDF, sharing one sequential object-id space.
63///
64/// Object ids are allocated by an ordered walk: catalog=1, page-tree=2, then
65/// for each scene in order its page dict, content stream, and resource objects
66/// (ExtGStates, gradient shadings + functions, images + SMasks) from one shared
67/// monotonic counter starting at 3. With a single scene this reproduces the
68/// historical single-page numbering exactly, so additive multi-page support is
69/// byte-identical when only one page is present.
70///
71/// Print box metadata, DeviceCMYK colors, and full determinism (no timestamps,
72/// no document id, ordered iteration throughout) match [`render_pdf`].
73#[must_use]
74pub fn render_pdf_multi(
75    scenes: &[Scene],
76    fonts: &dyn FontProvider,
77    assets: &dyn AssetProvider,
78) -> Vec<u8> {
79    render_pdf_multi_with(scenes, fonts, assets, PdfOptions::default())
80}
81
82/// Like [`render_pdf_multi`] but with explicit [`PdfOptions`].
83///
84/// Allocation order: `catalog=1`, `page_tree=2`, then a shared **font block**
85/// (`REFS_PER_FONT` ids per embedded font), then per-page (page dict, content
86/// stream, link-annotation dicts, then resource objects). A document with no
87/// selectable text embeds no fonts and has no links, so the font block is empty
88/// and the id stream + bytes are identical to the historical output (the
89/// additive invariant).
90#[must_use]
91pub fn render_pdf_multi_with(
92    scenes: &[Scene],
93    fonts: &dyn FontProvider,
94    assets: &dyn AssetProvider,
95    options: PdfOptions,
96) -> Vec<u8> {
97    let mut pdf = Pdf::new();
98
99    let catalog_id = Ref::new(1);
100    let page_tree_id = Ref::new(2);
101
102    // Build the document-wide font plan from every selectable glyph run, then
103    // reserve its object-id block immediately after the catalog + page tree.
104    let usage = font::collect_usage(scenes);
105    let font_plan = font::build_plan(&usage, fonts, options.subset);
106    let font_base: i32 = 3;
107    let mut next: i32 = font_base + (font_plan.fonts.len() as i32) * font::REFS_PER_FONT;
108    let mut alloc = || {
109        let r = Ref::new(next);
110        next += 1;
111        r
112    };
113
114    // Translate each scene and reserve all of its object ids in order, so the
115    // page-tree's /Kids can list every page id before any page body is written.
116    let mut pages: Vec<PreparedPage<'_>> = Vec::with_capacity(scenes.len());
117    for scene in scenes {
118        pages.push(prepare_page(scene, fonts, assets, &font_plan, &mut alloc));
119    }
120
121    // ── Catalog + page tree ──────────────────────────────────────────────
122    pdf.catalog(catalog_id).pages(page_tree_id);
123    pdf.pages(page_tree_id)
124        .kids(pages.iter().map(|p| p.page_id))
125        .count(pages.len() as i32);
126
127    // ── Embedded fonts (shared across pages) ─────────────────────────────
128    for (idx, font) in font_plan.fonts.iter().enumerate() {
129        let refs = font::font_refs_at(font_base, idx);
130        font::write_font(&mut pdf, font, &refs);
131    }
132
133    // ── Per-page bodies ──────────────────────────────────────────────────
134    for prepared in pages {
135        write_prepared_page(&mut pdf, page_tree_id, prepared);
136    }
137
138    pdf.finish()
139}
140
141/// One scene translated to its content stream and resources, with every object
142/// id already reserved from the shared allocator (so id ordering is fixed
143/// before any object body is emitted).
144struct PreparedPage<'a> {
145    page_id: Ref,
146    content_id: Ref,
147    content: Vec<u8>,
148    scene: &'a Scene,
149    res: PageResources,
150    /// One ref per link annotation, in `res.links` order.
151    annot_ids: Vec<Ref>,
152    alpha_ids: Vec<Ref>,
153    gradient_refs: Vec<GradientRefs>,
154    image_refs: Vec<ImageRefs>,
155}
156
157/// Translate one scene and reserve all of its object ids from `alloc` in the
158/// fixed order: page dict, content stream, then resources (ExtGStates, gradient
159/// shadings each with their function/subfunctions, images each with optional
160/// SMask). Matches the historical single-page allocation order.
161fn prepare_page<'a>(
162    scene: &'a Scene,
163    fonts: &dyn FontProvider,
164    assets: &dyn AssetProvider,
165    font_plan: &FontPlan,
166    alloc: &mut impl FnMut() -> Ref,
167) -> PreparedPage<'a> {
168    let page_id = alloc();
169    let content_id = alloc();
170
171    // Translate the scene to a content stream + the resources it references.
172    let (content, res) = translate(scene, fonts, assets, font_plan);
173
174    // Reserve one ref per link annotation (in res.links order), before the
175    // resource refs, so the page's /Annots array can list them.
176    let annot_ids: Vec<Ref> = res.links.iter().map(|_| alloc()).collect();
177
178    // Allocate refs for every resource up front so the page's resource dict can
179    // reference them. Order is fixed: ExtGStates, then gradient shadings (each
180    // with its function), then images (each with optional SMask).
181    let alpha_ids: Vec<Ref> = res.alphas.iter().map(|_| alloc()).collect();
182    let gradient_refs: Vec<GradientRefs> = res
183        .gradients
184        .iter()
185        .map(|g| {
186            let shading = alloc();
187            let function = alloc();
188            // A multi-stop gradient (> 2 stops) needs one exponential
189            // subfunction per segment, stitched together. Allocate those refs
190            // here so the whole document uses one clean sequential id space.
191            let seg_count = g.stops.len().saturating_sub(1);
192            let sub_functions = if g.stops.len() > 2 {
193                (0..seg_count).map(|_| alloc()).collect()
194            } else {
195                Vec::new()
196            };
197            GradientRefs {
198                shading,
199                function,
200                sub_functions,
201            }
202        })
203        .collect();
204    let image_refs: Vec<ImageRefs> = res
205        .images
206        .iter()
207        .map(|img| ImageRefs {
208            image: alloc(),
209            smask: if img.alpha_flate.is_some() {
210                Some(alloc())
211            } else {
212                None
213            },
214        })
215        .collect();
216
217    PreparedPage {
218        page_id,
219        content_id,
220        content: content.finish().into_vec(),
221        scene,
222        res,
223        annot_ids,
224        alpha_ids,
225        gradient_refs,
226        image_refs,
227    }
228}
229
230/// Emit one prepared page's object bodies (page dict, content stream, resource
231/// objects) using its pre-reserved ids. `/Parent` of the page dict is the
232/// shared page-tree id.
233fn write_prepared_page(pdf: &mut Pdf, page_tree_id: Ref, prepared: PreparedPage<'_>) {
234    let PreparedPage {
235        page_id,
236        content_id,
237        content,
238        scene,
239        res,
240        annot_ids,
241        alpha_ids,
242        gradient_refs,
243        image_refs,
244    } = prepared;
245
246    // ── Page dict + boxes + resource dict ────────────────────────────────
247    write_page(
248        pdf,
249        PageWrite {
250            page_id,
251            page_tree_id,
252            content_id,
253            scene,
254            res: &res,
255            annot_ids: &annot_ids,
256            alpha_ids: &alpha_ids,
257            gradient_refs: &gradient_refs,
258            image_refs: &image_refs,
259        },
260    );
261
262    // ── Content stream ───────────────────────────────────────────────────
263    pdf.stream(content_id, &content);
264
265    // ── Link annotations ─────────────────────────────────────────────────
266    write_link_annotations(pdf, scene, &res.links, &annot_ids);
267
268    // ── Resource objects ─────────────────────────────────────────────────
269    write_alpha_states(pdf, &res, &alpha_ids);
270    write_gradients(pdf, &res, &gradient_refs);
271    write_images(pdf, &res, &image_refs);
272}
273
274/// Write one `/Link` annotation per collected [`LinkAnnot`], converting the
275/// scene-space rect (top-left origin, y-down) to PDF user space (bottom-left,
276/// y-up) and attaching a URI action. Borders are suppressed so the link is
277/// invisible (only the hit region is active), matching common web→PDF output.
278fn write_link_annotations(pdf: &mut Pdf, scene: &Scene, links: &[LinkAnnot], annot_ids: &[Ref]) {
279    let h = scene.height as f32;
280    for (link, id) in links.iter().zip(annot_ids) {
281        let x0 = link.x0 as f32;
282        let x1 = link.x1 as f32;
283        // Scene y grows downward; flip both edges. y0 (top) → larger PDF y.
284        let y_top = h - link.y0 as f32;
285        let y_bottom = h - link.y1 as f32;
286        let mut annot = pdf.annotation(*id);
287        annot.subtype(AnnotationType::Link);
288        annot.rect(PdfRect::new(x0, y_bottom, x1, y_top));
289        // No visible border.
290        annot.border(0.0, 0.0, 0.0, None);
291        annot
292            .action()
293            .action_type(ActionType::Uri)
294            .uri(Str(link.url.as_bytes()));
295        annot.finish();
296    }
297}
298
299/// Indirect references backing one axial gradient: its shading dict and its
300/// stitching/exponential color function.
301struct GradientRefs {
302    shading: Ref,
303    function: Ref,
304    /// One exponential subfunction ref per gradient segment, used only when the
305    /// gradient has more than two stops (stitched via `function`).
306    sub_functions: Vec<Ref>,
307}
308
309/// Indirect references backing one embedded image: the RGB image XObject and an
310/// optional alpha SMask image XObject.
311struct ImageRefs {
312    image: Ref,
313    smask: Option<Ref>,
314}
315
316/// Borrow/scalar context for [`write_page`], bundled into a `Copy` struct so
317/// the function stays within the argument-count budget without an `#[allow]`.
318/// `Ref` is `Copy` and the slice fields are shared borrows, so the whole struct
319/// is `Copy`.
320#[derive(Clone, Copy)]
321struct PageWrite<'a> {
322    page_id: Ref,
323    page_tree_id: Ref,
324    content_id: Ref,
325    scene: &'a Scene,
326    res: &'a PageResources,
327    annot_ids: &'a [Ref],
328    alpha_ids: &'a [Ref],
329    gradient_refs: &'a [GradientRefs],
330    image_refs: &'a [ImageRefs],
331}
332
333fn write_page(pdf: &mut Pdf, ctx: PageWrite<'_>) {
334    let PageWrite {
335        page_id,
336        page_tree_id,
337        content_id,
338        scene,
339        res,
340        annot_ids,
341        alpha_ids,
342        gradient_refs,
343        image_refs,
344    } = ctx;
345    let w = scene.width as f32;
346    let h = scene.height as f32;
347    let media = PdfRect::new(0.0, 0.0, w, h);
348
349    let mut page = pdf.page(page_id);
350    page.parent(page_tree_id);
351    page.media_box(media);
352
353    // Print boxes. When a trim box is present (bleed active), the trim rect is
354    // converted from scene (top-left, y-down) coords to PDF (bottom-left, y-up):
355    // a scene rect [tx, ty, tw, th] becomes PDF [tx, H-(ty+th), tx+tw, H-ty].
356    // BleedBox / CropBox = MediaBox (the canvas already includes the bleed).
357    // With no trim, all four boxes equal the MediaBox.
358    match scene.trim {
359        Some(t) => {
360            let x0 = t.x as f32;
361            let x1 = (t.x + t.w) as f32;
362            let y0 = (scene.height - (t.y + t.h)) as f32;
363            let y1 = (scene.height - t.y) as f32;
364            page.trim_box(PdfRect::new(x0, y0, x1, y1));
365            page.bleed_box(media);
366            page.crop_box(media);
367        }
368        None => {
369            page.trim_box(media);
370            page.bleed_box(media);
371            page.crop_box(media);
372        }
373    }
374
375    page.contents(content_id);
376
377    // Link annotations (clickable hyperlinks). Absent → no /Annots key, so a
378    // page without links is byte-identical to the historical output.
379    if !annot_ids.is_empty() {
380        page.annotations(annot_ids.iter().copied());
381    }
382
383    // Resource dictionary referencing every interned resource by its stable
384    // `<prefix><index>` name.
385    let mut resources = page.resources();
386    if !res.font_indices.is_empty() {
387        let mut fonts = resources.fonts();
388        for &idx in &res.font_indices {
389            let nm = name(FONT_PREFIX, idx);
390            fonts.pair(nm.as_name(), font::font_refs_at(3, idx).type0_ref());
391        }
392        fonts.finish();
393    }
394    if !res.alphas.is_empty() {
395        let mut gs = resources.ext_g_states();
396        for (i, r) in alpha_ids.iter().enumerate() {
397            let nm = name(ALPHA_PREFIX, i);
398            gs.pair(nm.as_name(), *r);
399        }
400        gs.finish();
401    }
402    if !res.gradients.is_empty() {
403        let mut sh = resources.shadings();
404        for (i, gr) in gradient_refs.iter().enumerate() {
405            let nm = name(SHADING_PREFIX, i);
406            sh.pair(nm.as_name(), gr.shading);
407        }
408        sh.finish();
409    }
410    if !res.images.is_empty() {
411        let mut xo = resources.x_objects();
412        for (i, ir) in image_refs.iter().enumerate() {
413            let nm = name(IMAGE_PREFIX, i);
414            xo.pair(nm.as_name(), ir.image);
415        }
416        xo.finish();
417    }
418    resources.finish();
419    page.finish();
420}
421
422/// Write one `/ExtGState` per interned alpha, carrying both `ca` (fill) and
423/// `CA` (stroke) so a single state serves filled and stroked draws.
424fn write_alpha_states(pdf: &mut Pdf, res: &PageResources, alpha_ids: &[Ref]) {
425    for (a, r) in res.alphas.iter().zip(alpha_ids) {
426        let factor = f32::from(*a) / 255.0;
427        let mut gs = pdf.ext_graphics(*r);
428        gs.non_stroking_alpha(factor);
429        gs.stroking_alpha(factor);
430        gs.finish();
431    }
432}
433
434/// Write each axial gradient as a Type 2 shading whose color function is a Type
435/// 3 stitching function over Type 2 (linear, exponent 1) exponential
436/// subfunctions — one per adjacent stop pair. Stops are DeviceRGB.
437fn write_gradients(pdf: &mut Pdf, res: &PageResources, refs: &[GradientRefs]) {
438    for (g, gr) in res.gradients.iter().zip(refs) {
439        write_gradient_function(pdf, gr, g);
440
441        let mut shading = pdf.function_shading(gr.shading);
442        shading.shading_type(FunctionShadingType::Axial);
443        shading.color_space().device_rgb();
444        shading.coords(g.coords);
445        shading.function(gr.function);
446        // Clamp (don't extend) beyond the endpoints so the shading fills the
447        // clipped shape with the edge colors, matching CSS `Pad` spread.
448        shading.extend([true, true]);
449        shading.finish();
450    }
451}
452
453/// Write the color function for `g`. With exactly two stops a single Type 2
454/// exponential (linear) function is emitted at `gr.function`; with more stops a
455/// Type 3 stitching function at `gr.function` combines one exponential
456/// subfunction per segment (refs in `gr.sub_functions`).
457fn write_gradient_function(pdf: &mut Pdf, gr: &GradientRefs, g: &AxialGradient) {
458    // Two-stop (or defensively fewer): a single linear exponential function.
459    if g.stops.len() <= 2 {
460        let c0 = g.stops.first().map(|s| s.1).unwrap_or([0.0, 0.0, 0.0]);
461        let c1 = g.stops.get(1).map(|s| s.1).unwrap_or(c0);
462        write_linear_segment(pdf, gr.function, c0, c1);
463        return;
464    }
465
466    // > 2 stops: one linear exponential per segment, stitched together.
467    for (k, sub) in gr.sub_functions.iter().enumerate() {
468        let c0 = g.stops.get(k).map(|s| s.1).unwrap_or([0.0, 0.0, 0.0]);
469        let c1 = g.stops.get(k + 1).map(|s| s.1).unwrap_or(c0);
470        write_linear_segment(pdf, *sub, c0, c1);
471    }
472
473    // Interior stop offsets become the stitching bounds; each subfunction's
474    // input is encoded over [0, 1].
475    let last = g.stops.len() - 1;
476    let bounds: Vec<f32> = g
477        .stops
478        .get(1..last)
479        .unwrap_or(&[])
480        .iter()
481        .map(|s| s.0)
482        .collect();
483    let mut encode: Vec<f32> = Vec::with_capacity(gr.sub_functions.len() * 2);
484    for _ in &gr.sub_functions {
485        encode.push(0.0);
486        encode.push(1.0);
487    }
488
489    let mut stitch = pdf.stitching_function(gr.function);
490    stitch.domain([0.0, 1.0]);
491    stitch.range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]);
492    stitch.functions(gr.sub_functions.iter().copied());
493    stitch.bounds(bounds);
494    stitch.encode(encode);
495    stitch.finish();
496}
497
498/// Write a single Type 2 (exponential, `N = 1` linear) function in DeviceRGB
499/// mapping `[0, 1]` from color `c0` to `c1`.
500fn write_linear_segment(pdf: &mut Pdf, id: Ref, c0: [f32; 3], c1: [f32; 3]) {
501    let mut f = pdf.exponential_function(id);
502    f.domain([0.0, 1.0]);
503    f.range([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]);
504    f.c0(c0);
505    f.c1(c1);
506    f.n(1.0);
507    f.finish();
508}
509
510/// Write each image as a FlateDecode DeviceRGB XObject, with an optional
511/// FlateDecode DeviceGray SMask for transparency.
512fn write_images(pdf: &mut Pdf, res: &PageResources, refs: &[ImageRefs]) {
513    for (img, ir) in res.images.iter().zip(refs) {
514        let w = img.width as i32;
515        let h = img.height as i32;
516
517        let mut xobj = pdf.image_xobject(ir.image, &img.rgb_flate);
518        xobj.filter(Filter::FlateDecode);
519        xobj.width(w);
520        xobj.height(h);
521        xobj.color_space().device_rgb();
522        xobj.bits_per_component(8);
523        if let Some(smask) = ir.smask {
524            xobj.s_mask(smask);
525        }
526        xobj.finish();
527
528        if let (Some(smask), Some(alpha)) = (ir.smask, &img.alpha_flate) {
529            let mut sm = pdf.image_xobject(smask, alpha);
530            sm.filter(Filter::FlateDecode);
531            sm.width(w);
532            sm.height(h);
533            sm.color_space().device_gray();
534            sm.bits_per_component(8);
535            sm.finish();
536        }
537    }
538}