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}