Skip to main content

rustyfi_pdf/
lib.rs

1//! PDF output backend (handlePdf.ml, on top of `pdf-writer`): base-14 Type1
2//! fonts, uncompressed content streams, ttf-parser-backed metrics with
3//! CID-keyed TrueType embedding, raster Image XObjects.
4
5pub mod base14;
6pub mod cid;
7pub mod fonts;
8pub mod ttf;
9
10pub use base14::Base14Metrics;
11pub use cid::{render_pdf_ttf, render_pdf_ttf_with};
12pub use fonts::{FontConfigError, FontFlags, FontRegistry, FontSource};
13pub use ttf::{FontError, TtfFontStore};
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use pdf_writer::types::{ActionType, AnnotationType};
18use pdf_writer::{Content, Filter, Finish, Name, Pdf, Rect, Ref, Str, TextStr};
19use rustyfi_backend::{
20    place_block_at, Annot, AnnotAction, Closing, Color, DocExtras, DocInfo, GraphicsElem,
21    ImageResource, Length, MathGlyph, NamedDest, ObjRepr, OutlineEntry, Page, PageGeometry, Path,
22    PathSeg, PureHorzBox, VertBox,
23};
24
25#[derive(Debug, thiserror::Error)]
26pub enum PdfError {
27    #[error("text {0:?} is not encodable in WinAnsi (milestone-1 base fonts)")]
28    Unencodable(String),
29    #[error("no glyph for {0:?} in the embedded font")]
30    NoGlyph(char),
31    #[error(transparent)]
32    Io(#[from] std::io::Error),
33}
34
35/// Resource names for the three base fonts, indexed by `FontKey`.
36const FONT_RES_NAMES: [&str; 3] = ["F0", "F1", "F2"];
37
38// Shared Image XObject support. `render_pdf` (base-14, below) and
39// `render_pdf_ttf` (CID-keyed TrueType, `cid.rs`) are otherwise separate
40// writers, but an `Image` box is rendered *identically* by both, so that path
41// lives here once and `cid.rs` imports it.
42
43/// Every `ImageId` (raw `usize` index into a `DocumentValue::images`-shaped
44/// table) that appears in at least one placed line across `pages`, OR in a
45/// page's `overlays` deco-graphics underlay (`fire_hooks`'
46/// `page_graphics`) — only an image actually placed on a page gets an
47/// XObject, not one merely decoded.
48///
49/// Scanning the overlay is essential for images drawn *inside a decoration*
50/// (`figbox`'s `+fig-on-right` draws its figure with `draw-text` from the
51/// frame's `deco`, so the box only ever lives in `page_graphics`): each
52/// `include-image` call mints its own `ImageId`, so even when the same file
53/// is also placed normally, the deco's id is distinct and its `/ImN Do`
54/// would dangle.
55///
56/// An `Image` can hide arbitrarily deep: inside a `Tabular` cell, an
57/// `EmbeddedBlock`'s stacked lines, a `Frame`'s contents, a discretionary's
58/// `no_break` slot, or a `draw-text` run nested in a `unite-graphics` group.
59/// This used to be a pair of hand-written mutually recursive scans, and they
60/// had drifted: the box scan's `Graphics` arm inlined its own copy of the
61/// `GraphicsElem::Text` case rather than calling the graphics scan beside it,
62/// so a `draw-text` under a `Group`/`Clip` declared no XObject while
63/// `place_graphics` still emitted its `/ImN Do`. A dangling reference like
64/// that is structurally valid PDF, renders blank, and nothing reports it — so
65/// the enumeration is now `rustyfi_backend::visit`'s generated traversal, and
66/// the single `Image` arm below is the whole of what this function knows
67/// about the shape of the box tree.
68fn used_images(pages: &[Page], overlays: &[Vec<GraphicsElem>]) -> BTreeSet<usize> {
69    let mut used = BTreeSet::new();
70    {
71        let mut note = |bx: &PureHorzBox| {
72            if let PureHorzBox::Image { image, .. } = bx {
73                used.insert(image.0);
74            }
75        };
76        for page in pages {
77            page.visit(&mut note);
78        }
79        for overlay in overlays {
80            for elem in overlay {
81                elem.visit(&mut note);
82            }
83        }
84    }
85    used
86}
87
88/// The PDF resource name for image `id` (e.g. `Im3`) — used verbatim by both
89/// the page's `/Resources /XObject` entry and the content stream's `Do`
90/// operand, which must agree.
91fn image_res_name(id: usize) -> String {
92    format!("Im{id}")
93}
94
95/// Write one Image XObject per id in `used`, returning each id's freshly
96/// allocated indirect reference for the caller's `/XObject` resource
97/// dictionaries.
98///
99/// **JPEG DCTDecode passthrough.** When `im.jpeg_dct` is `Some` (a
100/// baseline/extended-sequential 8-bit JPEG, per
101/// `ImageResource::sniff_baseline_jpeg_dct`), the ORIGINAL, still-DCT-encoded
102/// file bytes are embedded verbatim with `/Filter /DCTDecode` and a
103/// `/ColorSpace` from the JPEG's own component count (`/DeviceGray` for 1,
104/// `/DeviceRGB` for 3), matching upstream's own JPEG special-case. Every
105/// other image is flat, uncompressed 8-bit `DeviceRGB` samples with no
106/// `/Filter` at all.
107fn write_image_xobjects(
108    pdf: &mut Pdf,
109    mut next_ref: impl FnMut() -> Ref,
110    images: &[ImageResource],
111    used: &BTreeSet<usize>,
112) -> BTreeMap<usize, Ref> {
113    let mut refs = BTreeMap::new();
114    for &id in used {
115        let Some(im) = images.get(id) else {
116            // An id past the end of the image table should not happen, but a
117            // page silently missing one image beats a panic.
118            continue;
119        };
120        if im.pdf.is_some() {
121            // An imported PDF page (`load-pdf-image`) is NOT a raster image —
122            // `write_form_xobjects` (below) handles it as a Form XObject.
123            continue;
124        }
125        let r = next_ref();
126        refs.insert(id, r);
127        if let Some(dct) = &im.jpeg_dct {
128            let mut xo = pdf.image_xobject(r, &dct.bytes);
129            xo.filter(Filter::DctDecode);
130            xo.width(im.px_w as i32);
131            xo.height(im.px_h as i32);
132            if dct.components == 1 {
133                xo.color_space().device_gray();
134            } else {
135                xo.color_space().device_rgb();
136            }
137            xo.bits_per_component(8);
138            xo.finish();
139        } else {
140            let mut xo = pdf.image_xobject(r, &im.samples);
141            xo.width(im.px_w as i32);
142            xo.height(im.px_h as i32);
143            xo.color_space().device_rgb();
144            xo.bits_per_component(8);
145            xo.finish();
146        }
147    }
148    refs
149}
150
151// Imported PDF pages as Form XObjects (`load-pdf-image`), shared by both
152// writers like the raster support above.
153
154/// The PDF resource name for form-embedded PDF-page id `id` (e.g. `Fm3`) —
155/// disjoint from `image_res_name`'s `ImN` so Image and Form XObjects never
156/// collide in the shared `/XObject` dictionary, which does not itself
157/// distinguish the two (the `/Subtype` lives inside each stream).
158fn form_res_name(id: usize) -> String {
159    format!("Fm{id}")
160}
161
162/// Re-emit one neutral `ObjRepr` value into a fresh `Obj` writer, remapping
163/// any `Ref(local_id)` through `remap`; an unresolved local id degrades to
164/// `Null` rather than panicking. A `Stream` payload cannot legally appear
165/// here (streams must be indirect objects in PDF; the importer only produces
166/// one at the top level of an indirect entry, reached via `write_pdf_obj`) —
167/// written as `Null` if it somehow does.
168fn write_pdf_obj_value(obj: pdf_writer::Obj<'_>, repr: &ObjRepr, remap: &BTreeMap<u32, Ref>) {
169    match repr {
170        ObjRepr::Null => obj.primitive(pdf_writer::Null),
171        ObjRepr::Bool(b) => obj.primitive(*b),
172        // `pdf-writer` only implements `Primitive` for `i32`; integers in a
173        // resource subtree fit comfortably.
174        ObjRepr::Int(n) => obj.primitive(*n as i32),
175        ObjRepr::Real(r) => obj.primitive(*r as f32),
176        ObjRepr::Name(n) => obj.primitive(Name(n)),
177        ObjRepr::String(s) => obj.primitive(Str(s)),
178        ObjRepr::Ref(local_id) => match remap.get(local_id) {
179            Some(r) => obj.primitive(*r),
180            None => obj.primitive(pdf_writer::Null),
181        },
182        ObjRepr::Array(items) => {
183            let mut arr = obj.array();
184            for item in items {
185                write_pdf_obj_value(arr.push(), item, remap);
186            }
187            arr.finish();
188        }
189        ObjRepr::Dict(entries) => {
190            let mut dict = obj.dict();
191            for (k, v) in entries {
192                write_pdf_obj_value(dict.insert(Name(k)), v, remap);
193            }
194            dict.finish();
195        }
196        ObjRepr::Stream(..) => obj.primitive(pdf_writer::Null),
197    }
198}
199
200/// Write one imported object (the `collect_direct_objects` / `Pdf.addobj`
201/// analogue) at `out_ref`: a `Stream` becomes an indirect stream (dict
202/// entries copied verbatim, minus `/Length` which `pdf-writer` derives),
203/// anything else a plain indirect object.
204fn write_pdf_obj(pdf: &mut Pdf, out_ref: Ref, repr: &ObjRepr, remap: &BTreeMap<u32, Ref>) {
205    match repr {
206        ObjRepr::Stream(entries, bytes) => {
207            let mut stream = pdf.stream(out_ref, bytes);
208            for (k, v) in entries {
209                write_pdf_obj_value(stream.insert(Name(k)), v, remap);
210            }
211            stream.finish();
212        }
213        other => write_pdf_obj_value(pdf.indirect(out_ref), other, remap),
214    }
215}
216
217/// Write one Form XObject per id in `used` whose `ImageResource` carries a
218/// `pdf` payload (`load-pdf-image`); ids without one were already handled by
219/// `write_image_xobjects`.
220///
221/// Per id: allocate a fresh output `Ref` for every non-zero local id in
222/// `PdfPageResource.resources`, write each of those objects with its
223/// references remapped, then emit the page's own content stream as a
224/// `/Subtype /Form` XObject whose `/BBox` is the source `/MediaBox` and whose
225/// `/Resources` is the (also remapped) local-id-0 root dictionary — upstream
226/// `loadPdf.ml`'s `xobject_of_page`.
227fn write_form_xobjects(
228    pdf: &mut Pdf,
229    mut next_ref: impl FnMut() -> Ref,
230    images: &[ImageResource],
231    used: &BTreeSet<usize>,
232) -> BTreeMap<usize, Ref> {
233    let mut refs = BTreeMap::new();
234    for &id in used {
235        let Some(im) = images.get(id) else { continue };
236        let Some(pdf_res) = &im.pdf else { continue };
237
238        let mut remap: BTreeMap<u32, Ref> = BTreeMap::new();
239        let mut root_repr: Option<&ObjRepr> = None;
240        for (local_id, repr) in &pdf_res.resources.0 {
241            if *local_id == 0 {
242                root_repr = Some(repr);
243            } else {
244                remap.entry(*local_id).or_insert_with(&mut next_ref);
245            }
246        }
247        for (local_id, repr) in &pdf_res.resources.0 {
248            if *local_id != 0 {
249                write_pdf_obj(pdf, remap[local_id], repr, &remap);
250            }
251        }
252
253        let form_ref = next_ref();
254        let (x0, y0, x1, y1) = pdf_res.media_box;
255        {
256            let mut fx = pdf.form_xobject(form_ref, &pdf_res.content);
257            fx.bbox(Rect::new(x0 as f32, y0 as f32, x1 as f32, y1 as f32));
258            // Identity `/Matrix`: form space == the source page's own
259            // MediaBox space. The box-to-page scale/translate lives entirely
260            // in the placement `cm` operator (`place_form`).
261            fx.matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
262            if let Some(root) = root_repr {
263                write_pdf_obj_value(fx.insert(Name(b"Resources")), root, &remap);
264            }
265            fx.finish();
266        }
267        refs.insert(id, form_ref);
268    }
269    refs
270}
271
272/// Emit the content-stream operators that place one Image box: `q  w 0 0 h
273/// tx ty cm  /ImN Do  Q` (v0.0.6 `graphicD.ml`'s `pdfops_of_image`).
274///
275/// `ty` is the same already-flipped (page-down to PDF-up) baseline
276/// y-coordinate a text run on this line uses for its `Td`/`next_line`,
277/// because an image XObject's unit square is placed with its *bottom-left*
278/// corner at the `cm` matrix's translation and `PureHorzBox::Image` sits
279/// entirely above the baseline (all height, zero depth, per `linebreak.rs`'s
280/// `layout_line`): the baseline *is* the image's bottom edge.
281fn place_image(content: &mut Content, id: usize, tx: f32, ty: f32, width: f32, height: f32) {
282    content.save_state();
283    content.transform([width, 0.0, 0.0, height, tx, ty]);
284    content.x_object(Name(image_res_name(id).as_bytes()));
285    content.restore_state();
286}
287
288/// Emit the content-stream operators that place one imported-PDF-page Form
289/// box: `q  sx 0 0 sy (tx - sx*x0) (ty - sy*y0) cm  /FmN Do  Q`.
290///
291/// Unlike an Image XObject (unit square, so `place_image`'s matrix is a plain
292/// `[w, 0, 0, h, tx, ty]` scale), a Form XObject draws in its own user space
293/// — the source page's `/MediaBox` coordinates — so the CTM must map that box
294/// onto the placed `(tx, ty, w, h)` box: scale by `w/(x1-x0)`, `h/(y1-y0)`
295/// and translate the MediaBox's own origin `(x0, y0)` to `(tx, ty)`. `/BBox`
296/// clips in the form's own unscaled user space, so this CTM is the only place
297/// the box-to-page scale factor is applied.
298fn place_form(
299    content: &mut Content,
300    id: usize,
301    tx: f32,
302    ty: f32,
303    width: f32,
304    height: f32,
305    media_box: (f64, f64, f64, f64),
306) {
307    let (x0, y0, x1, y1) = media_box;
308    let bbox_w = (x1 - x0) as f32;
309    let bbox_h = (y1 - y0) as f32;
310    let sx = if bbox_w != 0.0 { width / bbox_w } else { 1.0 };
311    let sy = if bbox_h != 0.0 { height / bbox_h } else { 1.0 };
312    content.save_state();
313    content.transform([
314        sx,
315        0.0,
316        0.0,
317        sy,
318        tx - sx * x0 as f32,
319        ty - sy * y0 as f32,
320    ]);
321    content.x_object(Name(form_res_name(id).as_bytes()));
322    content.restore_state();
323}
324
325/// Emit one laid-out `PureHorzBox::Math` run's glyphs as a `BT / Tf / Td /
326/// Tj / ET` group per glyph: each glyph carries its own font/size
327/// (`glyph.info`) and offset (`glyph.dx`/`dy`) relative to the box's placed
328/// anchor `(anchor_x, anchor_y)`, the same already-flipped `(line.x + dx,
329/// paper_h - baseline_y)` a text run's `Td` uses. `glyph.dy > 0` raises it (a
330/// superscript) since PDF y is up — no second flip here, only an add.
331///
332/// `encode` turns one glyph into engine-specific `Tj` bytes: WinAnsi over
333/// `glyph.text` for `render_pdf`, a glyph-id run for `render_pdf_ttf`
334/// (`cid.rs`) that additionally special-cases `glyph.gid.is_some()` (a
335/// raw MATH-table variant glyph, emitted directly rather than re-derived from
336/// `text`) — so the whole glyph, not just `info`/`text`, is threaded through.
337pub(crate) fn place_math(
338    content: &mut Content,
339    glyphs: &[MathGlyph],
340    anchor_x: f32,
341    anchor_y: f32,
342    name_for: &dyn Fn(rustyfi_backend::FontKey) -> String,
343    mut encode: impl FnMut(&MathGlyph) -> Result<Vec<u8>, PdfError>,
344) -> Result<(), PdfError> {
345    for glyph in glyphs {
346        let encoded = encode(glyph)?;
347        let res_name = name_for(glyph.info.font);
348        // Non-black only, matching the `InnerString` arms' `q…Q` guard.
349        let colored = glyph.info.color != Color::Gray(0.0);
350        if colored {
351            content.save_state();
352            set_fill_color(content, glyph.info.color);
353        }
354        content.begin_text();
355        content.set_font(Name(res_name.as_bytes()), glyph.info.size.0 as f32);
356        content.next_line(
357            anchor_x + glyph.dx.0 as f32,
358            anchor_y + glyph.dy.0 as f32 + glyph.info.rising.0 as f32,
359        );
360        content.show(Str(&encoded));
361        content.end_text();
362        if colored {
363            content.restore_state();
364        }
365    }
366    Ok(())
367}
368
369/// Stack an `EmbeddedBlock`'s already-broken `block` lines from its placed
370/// anchor `(tx, ty)`, with the *text* emission (the one thing that differs
371/// between the two writers) threaded through as the `emit_line` callback.
372///
373/// **Top-aligned stand-in.** `place_block_at` (rustyfi-backend) lays `block`
374/// out from a fixed `(0, 0)` page-y-down origin; this shifts that whole stack
375/// so the FIRST content line's baseline sits exactly at the anchor `ty`, with
376/// every later line falling further down the page (subtracted from `ty`,
377/// since PDF y is up). `embed-block-top`'s `adjust_to_first_line` (exact
378/// upstream baseline alignment) is the faithful refinement.
379pub(crate) fn place_embedded_block(
380    block: &[VertBox],
381    tx: f32,
382    ty: f32,
383    anchor_last: bool,
384    mut emit_line: impl FnMut(&PureHorzBox, f32, f32) -> Result<(), PdfError>,
385) -> Result<(), PdfError> {
386    let placed = place_block_at((Length::ZERO, Length::ZERO), block.to_vec());
387    // Which inner line's baseline coincides with the box's inline baseline
388    // `ty`: the FIRST for `embed-block-top`, the LAST for
389    // `embed-block-bottom` (upstream `adjust_to_first_line` /
390    // `adjust_to_last_line`). Every other line is offset by the difference of
391    // their placed baselines (larger `baseline_y` = lower = smaller PDF `y`).
392    let anchor = if anchor_last { placed.last() } else { placed.first() };
393    let Some(anchor) = anchor else {
394        return Ok(());
395    };
396    let anchor_offset = anchor.baseline_y;
397    for line in &placed {
398        let y = ty - (line.baseline_y - anchor_offset).0 as f32;
399        for (dx, cbx) in &line.contents {
400            emit_line(cbx, tx + (line.x + *dx).0 as f32, y)?;
401        }
402    }
403    Ok(())
404}
405
406/// Write one indirect Link annotation object per entry, returning
407/// page-index -> the refs that page's `/Annots` array must list.
408/// Upstream: `Annotation.of_annotation` + `add_to_pdf` (annotation.ml).
409pub(crate) fn write_annotations(
410    pdf: &mut Pdf,
411    mut next_ref: impl FnMut() -> Ref,
412    annots: &[Annot],
413    n_pages: usize,
414) -> BTreeMap<usize, Vec<Ref>> {
415    let mut by_page: BTreeMap<usize, Vec<Ref>> = BTreeMap::new();
416    for a in annots {
417        if a.page >= n_pages {
418            continue; // out-of-range page: skip gracefully
419        }
420        let r = next_ref();
421        let mut ann = pdf.annotation(r);
422        ann.subtype(AnnotationType::Link);
423        let (x1, y1, x2, y2) = a.rect;
424        ann.rect(Rect::new(x1.0 as f32, y1.0 as f32, x2.0 as f32, y2.0 as f32));
425        // Upstream always writes a border — width 0 when None
426        // (annotation.ml's `(Length.zero, None)` arm) — which suppresses the
427        // PDF default 1pt border.
428        let width = a.border.as_ref().map(|(w, _)| w.0 as f32).unwrap_or(0.0);
429        ann.border(0.0, 0.0, width, None);
430        if let Some((_, color)) = &a.border {
431            match *color {
432                Color::Gray(g) => {
433                    ann.color_gray(g as f32);
434                }
435                Color::Rgb(r, g, b) => {
436                    ann.color_rgb(r as f32, g as f32, b as f32);
437                }
438                Color::Cmyk(c, m, y, k) => {
439                    ann.color_cmyk(c as f32, m as f32, y as f32, k as f32);
440                }
441            }
442        }
443        let mut act = ann.action();
444        match &a.action {
445            AnnotAction::Uri(uri) => {
446                act.action_type(ActionType::Uri);
447                act.uri(Str(uri.as_bytes()));
448            }
449            AnnotAction::GotoName(name) => {
450                act.action_type(ActionType::GoTo);
451                act.destination_named(Name(name.as_bytes()));
452            }
453        }
454        act.finish();
455        ann.finish();
456        by_page.entry(a.page).or_default().push(r);
457    }
458    by_page
459}
460
461/// Write the `/Dests` name dictionary (PDF-1.1-style, exactly upstream
462/// namedDest.ml's `Pdf.Dictionary` in the catalog — not a 1.2 name tree).
463/// Each value is `[page /XYZ x y 0]`. Returns None when there is nothing to
464/// write. Duplicate names: last registration wins (BTreeMap dedupe).
465pub(crate) fn write_named_dests(
466    pdf: &mut Pdf,
467    mut next_ref: impl FnMut() -> Ref,
468    dests: &[NamedDest],
469    page_ids: &[Ref],
470) -> Option<Ref> {
471    let mut dedup: BTreeMap<&str, &NamedDest> = BTreeMap::new();
472    for d in dests {
473        if d.page < page_ids.len() {
474            dedup.insert(d.name.as_str(), d);
475        }
476    }
477    if dedup.is_empty() {
478        return None;
479    }
480    let id = next_ref();
481    let mut dict = pdf.destinations(id); // TypedDict<Destination>, chunk.rs:236
482    for (name, d) in dedup {
483        dict.insert(Name(name.as_bytes()))
484            .page(page_ids[d.page])
485            .xyz(d.x.0 as f32, d.y.0 as f32, None);
486    }
487    dict.finish();
488    Some(id)
489}
490
491/// Write the whole `/Outlines` tree from the flat `(level, …)` list
492/// (upstream outline.ml via camlpdf's Pdfmarks.add_bookmarks). Nesting is
493/// derived from `level` exactly like Pdfmarks: an entry is a child of the
494/// nearest preceding entry with a smaller level. `/Count` is the number of
495/// descendants, negated when the item is closed (`is_open == false`);
496/// the root `/Count` counts top-level items. Returns None when empty.
497pub(crate) fn write_outline(
498    pdf: &mut Pdf,
499    mut next_ref: impl FnMut() -> Ref,
500    entries: &[OutlineEntry],
501) -> Option<Ref> {
502    if entries.is_empty() {
503        return None;
504    }
505    let root_id = next_ref();
506    let ids: Vec<Ref> = entries.iter().map(|_| next_ref()).collect();
507
508    let mut parent: Vec<Option<usize>> = vec![None; entries.len()];
509    let mut stack: Vec<usize> = Vec::new(); // indices of open ancestors
510    for i in 0..entries.len() {
511        while let Some(&top) = stack.last() {
512            if entries[top].level < entries[i].level {
513                break;
514            }
515            stack.pop();
516        }
517        parent[i] = stack.last().copied();
518        stack.push(i);
519    }
520    let mut children: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
521    let mut top_level: Vec<usize> = Vec::new();
522    for i in 0..entries.len() {
523        match parent[i] {
524            Some(p) => children[p].push(i),
525            None => top_level.push(i),
526        }
527    }
528    fn descendants(children: &[Vec<usize>], i: usize) -> i32 {
529        children[i].iter().map(|&c| 1 + descendants(children, c)).sum()
530    }
531
532    {
533        let mut root = pdf.outline(root_id);
534        root.first(ids[*top_level.first().unwrap()]);
535        root.last(ids[*top_level.last().unwrap()]);
536        root.count(top_level.len() as i32);
537    }
538    for (i, e) in entries.iter().enumerate() {
539        let mut item = pdf.outline_item(ids[i]);
540        item.title(TextStr(&e.text));
541        item.parent(parent[i].map(|p| ids[p]).unwrap_or(root_id));
542        let sibs: &Vec<usize> = match parent[i] {
543            Some(p) => &children[p],
544            None => &top_level,
545        };
546        let pos = sibs.iter().position(|&x| x == i).unwrap();
547        if pos > 0 {
548            item.prev(ids[sibs[pos - 1]]);
549        }
550        if pos + 1 < sibs.len() {
551            item.next(ids[sibs[pos + 1]]);
552        }
553        if let (Some(&f), Some(&l)) = (children[i].first(), children[i].last()) {
554            item.first(ids[f]);
555            item.last(ids[l]);
556            let n = descendants(&children, i);
557            item.count(if e.is_open { n } else { -n });
558        }
559        item.dest_name(Name(e.dest_name.as_bytes()));
560    }
561    Some(root_id)
562}
563
564/// Emit the PDF `/Info` dictionary at `id` from
565/// `register-document-information`'s registered value.
566/// `pdf.document_info(id)` self-registers with the file trailer (pdf-writer
567/// `structure.rs`), so the caller only allocates `id` and calls this once,
568/// gated on `extras.doc_info.is_some()` — an unregistered document emits no
569/// `/Info` object at all. `/Title`/`/Subject`/`/Author` are written only when
570/// `Some`; `/Keywords` only when non-empty, joined with a single space
571/// (upstream `String.concat " "`, `documentInformationDictionary.ml`).
572/// DOCUMENTED DEVIATION: `/Creator`/`/Producer` are written unconditionally
573/// *once this function runs* (i.e. only when the dict is registered at all) —
574/// upstream emits them on EVERY document, its `/Info` dict always existing.
575pub(crate) fn write_document_info(pdf: &mut Pdf, id: Ref, info: &DocInfo) {
576    let mut w = pdf.document_info(id);
577    if let Some(title) = &info.title {
578        w.title(TextStr(title));
579    }
580    if let Some(subject) = &info.subject {
581        w.subject(TextStr(subject));
582    }
583    if let Some(author) = &info.author {
584        w.author(TextStr(author));
585    }
586    if !info.keywords.is_empty() {
587        let joined = info.keywords.join(" ");
588        w.keywords(TextStr(&joined));
589    }
590    w.creator(TextStr("SATySFi"));
591    w.producer(TextStr("SATySFi"));
592}
593
594/// Serialize typeset pages into a complete PDF document. `images` is the
595/// document-wide image table (`DocumentValue::images`); pass `&[]` for a
596/// text-only document.
597pub fn render_pdf(
598    geometry: &PageGeometry,
599    pages: &[Page],
600    images: &[ImageResource],
601) -> Result<Vec<u8>, PdfError> {
602    render_pdf_with(geometry, pages, images, &DocExtras::default())
603}
604
605/// Same as [`render_pdf`], but also emits the extras (`/Annots`,
606/// `/Dests`, `/Outlines`, per-page deco-graphics underlays) accumulated
607/// while evaluating the document (`DocumentValue::extras`).
608pub fn render_pdf_with(
609    geometry: &PageGeometry,
610    pages: &[Page],
611    images: &[ImageResource],
612    extras: &DocExtras,
613) -> Result<Vec<u8>, PdfError> {
614    let mut pdf = Pdf::new();
615    let mut alloc = 1;
616    let mut next_ref = || {
617        let r = Ref::new(alloc);
618        alloc += 1;
619        r
620    };
621
622    let catalog_id = next_ref();
623    let page_tree_id = next_ref();
624    let font_ids: Vec<Ref> = (0..3).map(|_| next_ref()).collect();
625
626    let used = used_images(pages, &extras.page_graphics);
627    let img_refs = write_image_xobjects(&mut pdf, &mut next_ref, images, &used);
628    let form_refs = write_form_xobjects(&mut pdf, &mut next_ref, images, &used);
629
630    let page_ids: Vec<Ref> = pages.iter().map(|_| next_ref()).collect();
631    let content_ids: Vec<Ref> = pages.iter().map(|_| next_ref()).collect();
632
633    // Annotations, named destinations, outline.
634    let annot_refs = write_annotations(&mut pdf, &mut next_ref, &extras.annotations, pages.len());
635    let dests_id = write_named_dests(&mut pdf, &mut next_ref, &extras.destinations, &page_ids);
636    let outline_id = write_outline(&mut pdf, &mut next_ref, &extras.outline);
637    if let Some(info) = &extras.doc_info {
638        let info_id = next_ref();
639        write_document_info(&mut pdf, info_id, info);
640    }
641
642    {
643        let mut cat = pdf.catalog(catalog_id);
644        cat.pages(page_tree_id);
645        if let Some(d) = dests_id {
646            cat.destinations(d); // /Dests, structure.rs:55
647        }
648        if let Some(o) = outline_id {
649            cat.outlines(o); // /Outlines, structure.rs:62
650        }
651    }
652    {
653        let mut tree = pdf.pages(page_tree_id);
654        tree.kids(page_ids.iter().copied());
655        tree.count(page_ids.len() as i32);
656    }
657
658    for (i, name) in base14::BASE_FONT_NAMES.iter().enumerate() {
659        let mut font = pdf.type1_font(font_ids[i]);
660        font.base_font(Name(name.as_bytes()));
661        font.encoding_predefined(Name(b"WinAnsiEncoding"));
662    }
663
664    let paper_h = geometry.paper_height.0 as f32;
665    let media_box = Rect::new(0.0, 0.0, geometry.paper_width.0 as f32, paper_h);
666
667    for (i, ((page, &page_id), &content_id)) in
668        pages.iter().zip(&page_ids).zip(&content_ids).enumerate()
669    {
670        let overlay = extras.page_graphics.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
671        let content = page_content(page, paper_h, overlay, images)?;
672        pdf.stream(content_id, &content);
673
674        let mut p = pdf.page(page_id);
675        p.media_box(media_box);
676        p.parent(page_tree_id);
677        p.contents(content_id);
678        if let Some(refs) = annot_refs.get(&i) {
679            p.annotations(refs.iter().copied()); // structure.rs:1227
680        }
681        let mut resources = p.resources();
682        let mut fonts = resources.fonts();
683        for (i, res_name) in FONT_RES_NAMES.iter().enumerate() {
684            fonts.pair(Name(res_name.as_bytes()), font_ids[i]);
685        }
686        fonts.finish();
687        // Registered on every page uniformly, the same simplification the
688        // three base fonts above make: an unused resource entry is legal.
689        if !img_refs.is_empty() || !form_refs.is_empty() {
690            let mut x_objects = resources.x_objects();
691            for (&id, &r) in &img_refs {
692                x_objects.pair(Name(image_res_name(id).as_bytes()), r);
693            }
694            for (&id, &r) in &form_refs {
695                x_objects.pair(Name(form_res_name(id).as_bytes()), r);
696            }
697            x_objects.finish();
698        }
699        resources.finish();
700        p.finish();
701    }
702
703    Ok(pdf.finish())
704}
705
706/// Build one page's content stream: `BT … Tf … Td … Tj … ET` runs for text
707/// and `q … cm /ImN Do Q` / `q … cm /FmN Do Q` runs for raster
708/// images / imported PDF pages, with the y axis flipped from page
709/// coordinates (downward) to PDF (upward). `overlay` (deco graphics,
710/// already in absolute PDF y-up page coordinates — see `fire_hooks`) is
711/// drawn FIRST, so it sits under the page's text/images. `images` is only
712/// consulted to tell an ordinary raster `Image` box from an imported-PDF-page
713/// one (`place_image` vs `place_form`).
714fn page_content(
715    page: &Page,
716    paper_h: f32,
717    overlay: &[GraphicsElem],
718    images: &[ImageResource],
719) -> Result<Vec<u8>, PdfError> {
720    let mut content = Content::new();
721    // `place_graphics` emits its `q`/`cm`/`Q` wrapper UNCONDITIONALLY, even
722    // for an empty slice — guard it so an overlay-free page emits nothing.
723    if !overlay.is_empty() {
724        place_graphics(&mut content, overlay, 0.0, 0.0, &mut |c, bx, x, y| {
725            emit_box(c, bx, x, y, images)
726        })?;
727    }
728    for line in &page.lines {
729        let y = paper_h - line.baseline_y.0 as f32;
730        for (dx, bx) in &line.contents {
731            emit_box(&mut content, bx, (line.x + *dx).0 as f32, y, images)?;
732        }
733    }
734    Ok(content.finish().into_vec())
735}
736
737/// Emit one already-placed `PureHorzBox` at absolute PDF-space coordinates
738/// `(tx, ty)` — `tx` the box's left edge, `ty` its baseline, both already in
739/// PDF's y-**up** space (the page-level flip, `y = paper_h - baseline_y`,
740/// already happened in the caller's `ty`). **Reentrant**: a `Tabular` box's
741/// cells hold their own already-laid-out `PureHorzBox` runs, emitted through
742/// this same path recursively.
743///
744/// **The three y-frames, reconciled in one expression.** Page layout is
745/// y-down (flipped into `ty` by the caller, once); a `Tabular` box's own
746/// coordinate frame is y-**up** from its own baseline-left origin; a cell's
747/// `baseline_y` is measured y-up from *that* origin. Since `ty` is already
748/// the box's *own* placed baseline in PDF y-up space, `ty + cell.baseline_y`
749/// is exactly the cell's absolute baseline — no second flip. The rules
750/// (`tab.rules`) go through `place_graphics`, whose own `cm` translate to
751/// `(tx, ty)` puts its box-local y-up path coordinates in that same frame.
752fn emit_box(
753    content: &mut Content,
754    bx: &PureHorzBox,
755    tx: f32,
756    ty: f32,
757    images: &[ImageResource],
758) -> Result<(), PdfError> {
759    match bx {
760        PureHorzBox::InnerString { info, text, .. } => {
761            let encoded = winansi(text)?;
762            let font_idx = (info.font.0 as usize).min(FONT_RES_NAMES.len() - 1);
763            let colored = info.color != Color::Gray(0.0);
764            if colored {
765                content.save_state();
766                set_fill_color(content, info.color);
767            }
768            content.begin_text();
769            content.set_font(
770                Name(FONT_RES_NAMES[font_idx].as_bytes()),
771                info.size.0 as f32,
772            );
773            content.next_line(tx, ty + info.rising.0 as f32);
774            content.show(Str(&encoded));
775            content.end_text();
776            if colored {
777                content.restore_state();
778            }
779        }
780        PureHorzBox::Image {
781            width,
782            height,
783            image,
784        } => {
785            // `load-pdf-image`: an imported PDF page is placed as a Form
786            // XObject (its own MediaBox-to-box CTM), not an Image XObject.
787            match images.get(image.0).and_then(|im| im.pdf.as_ref()) {
788                Some(pdf_res) => place_form(
789                    content,
790                    image.0,
791                    tx,
792                    ty,
793                    width.0 as f32,
794                    height.0 as f32,
795                    pdf_res.media_box,
796                ),
797                None => place_image(content, image.0, tx, ty, width.0 as f32, height.0 as f32),
798            }
799        }
800        PureHorzBox::Graphics { elems, origin_independent, .. } => {
801            // A page-absolute callback (`origin_independent`) already carries
802            // final page coordinates, so anchor at (0,0) — do NOT translate by
803            // the box's placed position (which is often a negative text-origin;
804            // translating shifts a full-page frame background off the page).
805            let (ax, ay) = if *origin_independent { (0.0, 0.0) } else { (tx, ty) };
806            place_graphics(content, elems, ax, ay, &mut |c, bx, x, y| {
807                emit_box(c, bx, x, y, images)
808            })?;
809        }
810        PureHorzBox::Math { glyphs, rules, .. } => {
811            // base-14 never sees `gid: Some(_)` (no provider here overrides
812            // `math_vertical_variant`), so this always encodes `g.text`.
813            let name_for = |k: rustyfi_backend::FontKey| {
814                FONT_RES_NAMES[(k.0 as usize).min(FONT_RES_NAMES.len() - 1)].to_string()
815            };
816            place_math(content, glyphs, tx, ty, &name_for, |g| winansi(&g.text))?;
817            // The fraction bar/radical sign+overbar are `Fill`s, not
818            // glyphs — placed through `place_graphics` at the SAME
819            // already-flipped anchor `place_math` just used for the glyphs
820            // (no second y-flip belongs here).
821            place_graphics(content, rules, tx, ty, &mut |c, bx, x, y| {
822                emit_box(c, bx, x, y, images)
823            })?;
824        }
825        PureHorzBox::Tabular(tab) => {
826            for cell in &tab.cells {
827                for (cdx, cbx) in &cell.contents {
828                    emit_box(
829                        content,
830                        cbx,
831                        tx + (cell.x + *cdx).0 as f32,
832                        ty + cell.baseline_y.0 as f32,
833                        images,
834                    )?;
835                }
836            }
837            place_graphics(content, &tab.rules, tx, ty, &mut |c, bx, x, y| {
838                emit_box(c, bx, x, y, images)
839            })?;
840        }
841        PureHorzBox::EmbeddedBlock { block, anchor_last, .. } => {
842            place_embedded_block(block, tx, ty, *anchor_last, |cbx, x, y| {
843                emit_box(content, cbx, x, y, images)
844            })?;
845        }
846        // An inline frame's contents, on the frame's own baseline. The
847        // frame's deco graphics are NOT emitted here — they were fired
848        // lang-side into `DocExtras::page_graphics` and drawn as the page
849        // underlay (`page_content`'s `overlay` prologue).
850        PureHorzBox::Frame { contents, .. } => {
851            for (dx, cbx) in contents {
852                emit_box(content, cbx, tx + dx.0 as f32, ty, images)?;
853            }
854        }
855        _ => {}
856    }
857    Ok(())
858}
859
860/// Callback `place_graphics` invokes for each box a `GraphicsElem::Text` run
861/// carries, at BOX-LOCAL coordinates — the surrounding `q; cm` translate maps
862/// them onto the page, so implementations just call their own `emit_box`
863/// unchanged.
864pub(crate) type NestedEmitter<'a> =
865    &'a mut dyn FnMut(&mut Content, &PureHorzBox, f32, f32) -> Result<(), PdfError>;
866
867/// Emit `elems` (already box-local — see `PureHorzBox::Graphics`) into
868/// `content`, wrapped in one `save_state`/`transform`/`restore_state` that
869/// translates the whole box to its placed PDF-space anchor `(tx, ty)` — the
870/// **same** already-flipped `(line.x + dx, paper_h - baseline_y)` anchor a
871/// text run on that line uses, so element coordinates stay box-local (exactly
872/// `graphicD.ml`'s per-box `cm` wrapping).
873///
874/// **Coordinate space.** SATySFi graphics are y-**up** (PDF-native) inside a
875/// `Path`/`Subpath`'s own coordinates, but *page* layout is y-**down**
876/// (`y = paper_h - baseline_y`); that flip already happened in the anchor
877/// `(tx, ty)` passed in here, via the `cm` translate below — never per
878/// coordinate — so a naive per-coordinate re-flip inside `emit_path` would
879/// mirror every path vertically. Don't add one.
880///
881/// **`GraphicsElem::Text` and the CTM.** A `draw-text` run's boxes are
882/// emitted via `emit_nested` at BOX-LOCAL coordinates `(pt.x + dx, pt.y)`,
883/// *inside* the `q; cm` translate below, so PDF text ops (`BT`/`Td`/`Tj`)
884/// compose with the CTM the same way a filled path does. Adding an absolute
885/// anchor or a second y-flip here would double-place the run — don't.
886pub(crate) fn place_graphics(
887    content: &mut Content,
888    elems: &[GraphicsElem],
889    tx: f32,
890    ty: f32,
891    emit_nested: NestedEmitter<'_>,
892) -> Result<(), PdfError> {
893    content.save_state();
894    content.transform([1.0, 0.0, 0.0, 1.0, tx, ty]);
895    for elem in elems {
896        // Not ink: `fire_hooks` already consumed it into
897        // `DocExtras::destinations`, and `/Dests` is a catalog entry rather
898        // than a content-stream op. Skipped ahead of the per-element `q`/`Q`.
899        if matches!(elem, GraphicsElem::Destination { .. }) {
900            continue;
901        }
902        content.save_state();
903        match elem {
904            // Upstream fills with the even-odd rule (`op_f'`,
905            // `graphicD.ml:246`), not nonzero-winding — matters for
906            // self-intersecting/nested subpaths (e.g. a frame = outer ⊕
907            // inner rectangle).
908            GraphicsElem::Fill(color, path) => {
909                set_fill_color(content, *color);
910                emit_path(content, path);
911                content.fill_even_odd();
912            }
913            GraphicsElem::Stroke(width, color, path) => {
914                set_stroke_color(content, *color);
915                content.set_line_width(width.0 as f32);
916                emit_path(content, path);
917                content.stroke();
918            }
919            // `dashed-stroke`: identical to `Stroke` plus a `d` dash-array op
920            // (upstream `pdfops_of_dashed_stroke`, `graphicD.ml:231`).
921            GraphicsElem::DashedStroke(width, dash, color, path) => {
922                set_stroke_color(content, *color);
923                content.set_line_width(width.0 as f32);
924                content.set_dash_pattern([dash.0 .0 as f32, dash.1 .0 as f32], dash.2 .0 as f32);
925                emit_path(content, path);
926                content.stroke();
927            }
928            // `draw-text`: re-enter the writer's own per-box emission at
929            // box-local coordinates `pt + dx` — see "Text and the CTM" above.
930            GraphicsElem::Text { pt, contents, transform, .. } => {
931                match transform {
932                    None => {
933                        for (dx, bx) in contents {
934                            emit_nested(content, bx, (pt.0 + *dx).0 as f32, pt.1 .0 as f32)?;
935                        }
936                    }
937                    // Rotated/scaled run: push a `cm` carrying the 2×2 matrix
938                    // (row-major `(a,b,c,d)` → PDF `[a c b d]`) plus the `pt`
939                    // translation, then emit each box at its LOCAL offset
940                    // `(dx, 0)` inside it.
941                    Some((a, b, c, d)) => {
942                        content.transform([
943                            *a as f32,
944                            *c as f32,
945                            *b as f32,
946                            *d as f32,
947                            pt.0 .0 as f32,
948                            pt.1 .0 as f32,
949                        ]);
950                        for (dx, bx) in contents {
951                            emit_nested(content, bx, (*dx).0 as f32, 0.0)?;
952                        }
953                    }
954                }
955            }
956            // 0.1's `graphics` collection container nodes. Never reached
957            // by any 0.0.6 program (no 0.0.6-visible prim constructs
958            // `Group`/`Clip`).
959            GraphicsElem::Group(inner) => {
960                // Zero anchor: the outer q/cm(tx,ty) frame is already active,
961                // and a nested translate of (0,0) is what upstream's flat
962                // `List.concat` renders to.
963                place_graphics(content, inner, 0.0, 0.0, &mut *emit_nested)?;
964            }
965            GraphicsElem::Clip(path, inner) => {
966                // `graphicD.ml:323-336`: q; path; W' n; contents; Q — the
967                // per-element q…Q wrapper above already provides the q/Q.
968                emit_path(content, path);
969                content.clip_even_odd();
970                content.end_path();
971                place_graphics(content, inner, 0.0, 0.0, &mut *emit_nested)?;
972            }
973            // Skipped above; this arm is only for exhaustiveness.
974            GraphicsElem::Destination { .. } => {}
975        }
976        content.restore_state();
977    }
978    content.restore_state();
979    Ok(())
980}
981
982pub(crate) fn set_fill_color(content: &mut Content, color: Color) {
983    match color {
984        Color::Gray(g) => content.set_fill_gray(g as f32),
985        Color::Rgb(r, g, b) => content.set_fill_rgb(r as f32, g as f32, b as f32),
986        Color::Cmyk(c, m, y, k) => content.set_fill_cmyk(c as f32, m as f32, y as f32, k as f32),
987    };
988}
989
990fn set_stroke_color(content: &mut Content, color: Color) {
991    match color {
992        Color::Gray(g) => content.set_stroke_gray(g as f32),
993        Color::Rgb(r, g, b) => content.set_stroke_rgb(r as f32, g as f32, b as f32),
994        Color::Cmyk(c, m, y, k) => {
995            content.set_stroke_cmyk(c as f32, m as f32, y as f32, k as f32)
996        }
997    };
998}
999
1000/// Emit one `Path`'s subpaths as `m`/`l`/`c`/`h` operators (per
1001/// `graphicD.ml`'s `pdfops_of_path`). Closings: `Open` emits nothing, `Line`
1002/// emits `close_path` (`h`), `Bezier(c1, c2)` a final `cubic_to(c1, c2,
1003/// start)` then `close_path`.
1004fn emit_path(content: &mut Content, path: &Path) {
1005    for sub in &path.subpaths {
1006        content.move_to(sub.start.0 .0 as f32, sub.start.1 .0 as f32);
1007        for seg in &sub.segs {
1008            match seg {
1009                PathSeg::Line(pt) => {
1010                    content.line_to(pt.0 .0 as f32, pt.1 .0 as f32);
1011                }
1012                PathSeg::Bezier(c1, c2, dest) => {
1013                    content.cubic_to(
1014                        c1.0 .0 as f32,
1015                        c1.1 .0 as f32,
1016                        c2.0 .0 as f32,
1017                        c2.1 .0 as f32,
1018                        dest.0 .0 as f32,
1019                        dest.1 .0 as f32,
1020                    );
1021                }
1022            }
1023        }
1024        match sub.closing {
1025            Closing::Open => {}
1026            Closing::Line => {
1027                content.close_path();
1028            }
1029            Closing::Bezier(c1, c2) => {
1030                content.cubic_to(
1031                    c1.0 .0 as f32,
1032                    c1.1 .0 as f32,
1033                    c2.0 .0 as f32,
1034                    c2.1 .0 as f32,
1035                    sub.start.0 .0 as f32,
1036                    sub.start.1 .0 as f32,
1037                );
1038                content.close_path();
1039            }
1040        }
1041    }
1042}
1043
1044/// Encode to WinAnsi. Accepts ASCII 32..=126 (what the metrics tables cover);
1045/// anything else is an error rather than mojibake.
1046fn winansi(text: &str) -> Result<Vec<u8>, PdfError> {
1047    let mut out = Vec::with_capacity(text.len());
1048    for c in text.chars() {
1049        let code = c as u32;
1050        if (32..=126).contains(&code) {
1051            out.push(code as u8);
1052        } else {
1053            return Err(PdfError::Unencodable(text.to_string()));
1054        }
1055    }
1056    Ok(out)
1057}