Skip to main content

mos_pdf/
lib.rs

1//! PDF backend for Mosaic (manifest §21.1).
2//!
3//! Emits a fixed-A4 PDF declaring all 14 standard PDF base fonts
4//! (Helvetica/Times/Courier × 4 + Symbol + `ZapfDingbats`). No font
5//! data ships; every glyph outline is supplied by the PDF reader's
6//! built-in Core 14 implementations.
7//!
8//! For each Latin Core 14 face actually used, the backend plans a
9//! per-document `/Encoding` dict that layers a `/Differences` array
10//! on top of `WinAnsiEncoding` to reach the 99 extended glyphs each
11//! AFM carries beyond `WinAnsi` (`Ł`, `ł`, `Ě`, `ě`, `Ő`, `ő`, the
12//! Romanian comma-below set, math operators `−≤≥≠√∂∑∆◊`, `fi`/`fl`).
13//! A matching `/ToUnicode` `CMap` is emitted so the bytes we mint
14//! decode back to real Unicode in copy/paste and search.
15//!
16//! See the private `encoding` module for the planner. PDF/A, tagged
17//! PDF, hyperlinks, bookmarks, and full font embedding (issue #9)
18//! are deferred.
19
20#![doc(
21    html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
22    html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
23)]
24
25#[doc(hidden)]
26pub mod content;
27mod debug;
28#[doc(hidden)]
29pub mod embedded;
30#[doc(hidden)]
31pub mod encoding;
32#[doc(hidden)]
33pub mod images;
34
35use std::collections::HashMap;
36use std::path::Path;
37
38use mos_core::{CoreError, Diagnostic, Result, codes};
39use mos_fonts::EmbeddedFontId;
40use mos_layout::{Base14Font, Font, PageGraph, TextRun};
41use pdf_writer::types::{SystemInfo, UnicodeCmap};
42use pdf_writer::writers::Encoding;
43use pdf_writer::{Finish, Name, Pdf, Rect, Ref, Str, TextStr};
44
45use crate::embedded::{EmbeddedFontPlan, EmbeddedRefs};
46use crate::encoding::{DocEncoding, EncodingPlanner};
47
48/// Identifies Mosaic as the PDF's producing application, written to the
49/// Info dictionary `/Producer` and `/Creator` so a built PDF traces back
50/// to the compiler that bred it (the way ffmpeg/Word/Adobe stamp theirs).
51/// A compile-time constant, so output stays byte-for-byte deterministic:
52/// no wall-clock, host, path, or user data leaks in. The version tracks
53/// the workspace `CARGO_PKG_VERSION` automatically.
54///
55/// Follow-up (intentionally deferred to keep this stamp deterministic):
56/// - `/CreationDate` + `/ModDate` driven by a `SOURCE_DATE_EPOCH`-style
57///   deterministic input (UTC, stable `D:YYYYMMDDHHmmSS'+00'00'` format).
58/// - An XMP metadata packet (catalog `/Metadata`) for PDF/A / Adobe tooling,
59///   kept in sync with this Info dict.
60const PRODUCER: &str = concat!("Mosaic ", env!("CARGO_PKG_VERSION"));
61
62/// Document-level metadata written to the PDF Info dictionary.
63///
64/// Populated by the lowerer from `#set document(...)`. `language` is captured
65/// but not yet emitted; it belongs in the catalog `/Lang` entry.
66///
67/// # Examples
68///
69/// ```
70/// use mos_pdf::PdfMetadata;
71///
72/// let metadata = PdfMetadata {
73///     title: Some("Demo".to_owned()),
74///     author: Some("Mosaic".to_owned()),
75///     language: Some("en".to_owned()),
76/// };
77///
78/// assert_eq!(metadata.title.as_deref(), Some("Demo"));
79/// ```
80#[derive(Debug, Clone, Default)]
81pub struct PdfMetadata {
82    pub title: Option<String>,
83    pub author: Option<String>,
84    pub language: Option<String>,
85}
86
87/// Emit `graph` as a PDF file at `out`. Creates `out`'s parent
88/// directory if it doesn't already exist.
89///
90/// Returns any diagnostics raised during PDF emission; currently
91/// only `MOS0032` (per-font extended-glyph budget exhausted). Layout
92/// diagnostics flow through [`mos_layout::LayoutResult::diagnostics`]
93/// separately; callers (the CLI) typically render both.
94///
95/// # Errors
96///
97/// Returns a wrapped [`Diagnostic`] if writing the file (or creating
98/// its parent directory) fails.
99///
100/// # Examples
101///
102/// ```no_run
103/// use std::path::Path;
104///
105/// use mos_layout::PageGraph;
106/// use mos_pdf::PdfMetadata;
107///
108/// let graph = PageGraph::default();
109/// let metadata = PdfMetadata::default();
110/// let diagnostics = mos_pdf::emit(&graph, &metadata, Path::new("build/main.pdf"))?;
111///
112/// assert!(diagnostics.is_empty());
113/// # Ok::<(), mos_core::CoreError>(())
114/// ```
115pub fn emit(graph: &PageGraph, metadata: &PdfMetadata, out: &Path) -> Result<Vec<Diagnostic>> {
116    let (bytes, diagnostics) = build_pdf(graph, metadata)?;
117    write_pdf(&bytes, out)?;
118    Ok(diagnostics)
119}
120
121/// Emit a PDF with visible layout geometry from the graph's matching report.
122///
123/// Each page retains its original content and coordinates. A 36-point strip
124/// above the original page holds the legend, outside the document's bounds.
125/// The debug canvas is at least 420 points wide to keep the legend legible.
126/// Use the graph and report from the same `LayoutEngine::layout_with_debug`
127/// result. The ordinary [`emit`] output is unaffected.
128///
129/// # Errors
130///
131/// Returns `MOS0051` for mismatched page geometry, or the same font and I/O
132/// errors as [`emit`].
133pub fn emit_debug(
134    graph: &PageGraph,
135    report: &mos_layout::debug::Report,
136    metadata: &PdfMetadata,
137    out: &Path,
138) -> Result<Vec<Diagnostic>> {
139    debug::validate(graph, report)?;
140    let (bytes, diagnostics) = build_pdf_impl(graph, metadata, Some(report))?;
141    write_pdf(&bytes, out)?;
142    Ok(diagnostics)
143}
144
145fn write_pdf(bytes: &[u8], out: &Path) -> Result<()> {
146    if let Some(parent) = out.parent()
147        && !parent.as_os_str().is_empty()
148    {
149        std::fs::create_dir_all(parent).map_err(|err| {
150            io_diagnostic(format!(
151                "could not create output directory `{}`: {err}",
152                mos_core::display_path(parent)
153            ))
154        })?;
155    }
156    std::fs::write(out, bytes).map_err(|err| {
157        io_diagnostic(format!(
158            "could not write PDF to `{}`: {err}",
159            mos_core::display_path(out)
160        ))
161    })?;
162    Ok(())
163}
164
165fn io_diagnostic(message: String) -> CoreError {
166    CoreError::Diagnostic(Box::new(Diagnostic::simple(&codes::MOS0014, None, message)))
167}
168
169/// Build the PDF bytes from `graph`. Pulled out of [`emit`] so tests
170/// can round-trip without touching the filesystem. Returns the bytes
171/// plus any encoding diagnostics (currently `MOS0032` for Base14
172/// `/Differences` overflow). Kept `pub(crate)`; the public surface
173/// includes [`emit`] and [`emit_debug`].
174///
175/// # Errors
176///
177/// Returns an error if font subsetting fails for any embedded face
178/// (only with corrupted font data; the bundled cuts have been
179/// verified).
180pub(crate) fn build_pdf(
181    graph: &PageGraph,
182    metadata: &PdfMetadata,
183) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
184    build_pdf_impl(graph, metadata, None)
185}
186
187fn build_pdf_impl(
188    graph: &PageGraph,
189    metadata: &PdfMetadata,
190    debug: Option<&mos_layout::debug::Report>,
191) -> Result<(Vec<u8>, Vec<Diagnostic>)> {
192    // Phase 1a: scan every run and plan per-face Base14 /Differences
193    // encodings (embedded-font runs are skipped: they take the Type 0
194    // CID path below).
195    let mut diagnostics: Vec<Diagnostic> = Vec::new();
196    let encodings = plan_base14_encodings(graph, &mut diagnostics);
197
198    // Phase 1b: subset every embedded face actually used. One plan
199    // per face referenced; absent if the face never appears in `runs`.
200    // Only embedded-font runs need cloning into the flat slice the
201    // planner consumes: Base14 runs would be filtered out by
202    // `plan_embedded` anyway, so cloning them up front is pure waste
203    // for documents where Base14 dominates.
204    let embedded_runs: Vec<TextRun> = graph
205        .pages
206        .iter()
207        .flat_map(|p| p.runs.iter())
208        .filter(|r| matches!(r.font, Font::Embedded(_)))
209        .cloned()
210        .collect();
211    let embedded_plans: Vec<EmbeddedFontPlan> = embedded::plan_embedded(&embedded_runs)?;
212    let embedded_by_id: HashMap<EmbeddedFontId, &EmbeddedFontPlan> =
213        embedded_plans.iter().map(|p| (p.id, p)).collect();
214
215    // Phase 2: emit. Refs allocated up front so the page tree, font
216    // dicts, encoding dicts, FontFile2 streams, and ToUnicode streams
217    // can cross-reference.
218    let mut pdf = Pdf::new();
219    let mut next_id: i32 = 1;
220    let mut alloc = || {
221        let id = Ref::new(next_id);
222        next_id += 1;
223        id
224    };
225
226    let catalog_id = alloc();
227    let page_tree_id = alloc();
228    let info_id = alloc();
229
230    // One indirect ref per Base14 face, in the order published by
231    // `Font::ALL_BASE14`. Always all 14 entries so every page's
232    // resource dictionary is identical for Base14: preserves byte
233    // stability for Base14-only documents.
234    let base14_refs: Vec<(Font, Ref)> = Font::ALL_BASE14.iter().map(|f| (*f, alloc())).collect();
235
236    // For each Latin face that needs a `/Differences` map, pre-allocate
237    // the indirect refs for the custom encoding dict and the
238    // `/ToUnicode` CMap stream. Symbol/Dingbats and unused faces get
239    // no extra refs. Iterate `Font::ALL_BASE14` (not `&encodings`) so the
240    // `alloc()` order, and therefore the byte layout of the produced
241    // PDF is deterministic across runs.
242    let mut encoding_refs: HashMap<Font, (Ref, Ref)> = HashMap::new();
243    for font in Font::ALL_BASE14 {
244        if let Some(enc) = encodings.get(&font)
245            && enc.has_differences()
246        {
247            let enc_ref = alloc();
248            let cmap_ref = alloc();
249            encoding_refs.insert(font, (enc_ref, cmap_ref));
250        }
251    }
252
253    // One set of 5 refs per embedded face actually referenced.
254    let embedded_refs: HashMap<EmbeddedFontId, EmbeddedRefs> = embedded_plans
255        .iter()
256        .map(|plan| {
257            (
258                plan.id,
259                EmbeddedRefs {
260                    font: alloc(),
261                    cid_font: alloc(),
262                    descriptor: alloc(),
263                    font_file: alloc(),
264                    to_unicode: alloc(),
265                },
266            )
267        })
268        .collect();
269
270    // Allocate one indirect ref per unique image. Compression itself
271    // happens at emit time (see the loop below) so we don't hold every
272    // compressed stream in memory simultaneously: `graph.images` is
273    // already the deduped set, and an image-heavy document can blow
274    // peak RAM if we buffer all compressed copies before writing them.
275    let image_refs: Vec<Ref> = graph.images.iter().map(|_| alloc()).collect();
276
277    let page_refs: Vec<(Ref, Ref)> = graph.pages.iter().map(|_| (alloc(), alloc())).collect();
278
279    // Outline (bookmark) refs follow the document's page/font/image
280    // objects, so a heading-free document — where `outline` is empty and
281    // this is `None` — produces byte-identical output to before outlines
282    // existed. Allocation walks `graph.outline` in document order, so the
283    // root and per-entry ids are deterministic across runs.
284    let outline_refs: Option<(Ref, Vec<Ref>)> = (!graph.outline.is_empty()).then(|| {
285        let root = alloc();
286        let items: Vec<Ref> = graph.outline.iter().map(|_| alloc()).collect();
287        (root, items)
288    });
289    // Independent WinAnsi font: document /Differences may reuse ASCII slots
290    // that the legend needs. Allocate only for debug output.
291    let debug_font_ref = debug.map(|_| alloc());
292    if let Some(font_ref) = debug_font_ref {
293        pdf.type1_font(font_ref)
294            .base_font(Name(b"Helvetica"))
295            .encoding_predefined(Name(b"WinAnsiEncoding"));
296    }
297
298    {
299        let mut catalog = pdf.catalog(catalog_id);
300        catalog.pages(page_tree_id);
301        if let Some((root, _)) = &outline_refs {
302            catalog.outlines(*root);
303        }
304    }
305
306    let page_count = i32::try_from(page_refs.len()).unwrap_or(i32::MAX);
307    pdf.pages(page_tree_id)
308        .kids(page_refs.iter().map(|(p, _)| *p))
309        .count(page_count);
310
311    emit_pages(
312        &mut pdf,
313        graph,
314        &PageEmitContext {
315            page_tree_id,
316            page_refs: &page_refs,
317            base14_refs: &base14_refs,
318            embedded_refs: &embedded_refs,
319            image_refs: &image_refs,
320            encodings: &encodings,
321            embedded_by_id: &embedded_by_id,
322            debug,
323            debug_font_ref,
324        },
325    )?;
326
327    // Emit each Image XObject. Order matches `graph.images` (and
328    // therefore the `alloc()` order above), keeping byte output
329    // deterministic. Each image is compressed in this loop and the
330    // compressed buffer dropped at the end of the iteration, so peak
331    // memory holds at most one compressed image at a time on top of
332    // the (Arc-shared) decoded pixel buffer the handle already owns.
333    emit_images(&mut pdf, graph, &image_refs);
334
335    emit_base14_fonts(&mut pdf, &base14_refs, &encoding_refs);
336
337    // Emit each embedded face's 5-object cluster (Type 0 + CIDFont +
338    // descriptor + FontFile2 stream + ToUnicode CMap).
339    for plan in &embedded_plans {
340        let refs = embedded_refs[&plan.id];
341        embedded::emit_embedded(&mut pdf, plan, refs);
342    }
343
344    // Emit the custom /Encoding dicts and /ToUnicode CMap streams.
345    // Same `Font::ALL_BASE14` walk as the allocation pass above keeps
346    // emit order deterministic.
347    for font in Font::ALL_BASE14 {
348        let Some(enc) = encodings.get(&font) else {
349            continue;
350        };
351        let Some(&(enc_ref, cmap_ref)) = encoding_refs.get(&font) else {
352            continue;
353        };
354        emit_encoding_dict(&mut pdf, enc_ref, enc);
355        emit_to_unicode_cmap(&mut pdf, cmap_ref, enc);
356    }
357
358    {
359        let mut info = pdf.document_info(info_id);
360        if let Some(title) = metadata.title.as_deref() {
361            info.title(TextStr(title));
362        }
363        if let Some(author) = metadata.author.as_deref() {
364            info.author(TextStr(author));
365        }
366        // Provenance stamp: mark Mosaic as the producing application. Both
367        // keys carry the same constant string, so this adds no wall-clock
368        // or environment data and the output stays deterministic.
369        info.producer(TextStr(PRODUCER));
370        info.creator(TextStr(PRODUCER));
371        info.finish();
372    }
373
374    if let Some((root, items)) = &outline_refs {
375        emit_outline(&mut pdf, graph, *root, items, &page_refs);
376    }
377
378    Ok((pdf.finish(), diagnostics))
379}
380
381/// Emit the `/Outlines` tree from `graph.outline` (flat, document order,
382/// each entry carrying a heading `level` 1..=3). Nesting is reconstructed
383/// in a single pass over a level stack of entry indices, then the root
384/// dict and one `/Outline` item per entry are wired with
385/// parent/prev/next/first/last links and page destinations.
386///
387/// `items[i]` is the indirect ref for `graph.outline[i]`; `root` is the
388/// `/Outlines` dict ref. Called only when `graph.outline` is non-empty,
389/// so `items` is non-empty and `roots` always has at least one member.
390fn emit_outline(
391    pdf: &mut Pdf,
392    graph: &PageGraph,
393    root: Ref,
394    items: &[Ref],
395    page_refs: &[(Ref, Ref)],
396) {
397    let entries = &graph.outline;
398    let n = entries.len();
399
400    // Pass 1: reconstruct parent / children / top-level roots from a
401    // level stack. Popping every entry whose level is >= the current
402    // one makes a skipped level (H1→H3) attach under the nearest
403    // shallower ancestor, and a later shallower heading re-parent
404    // correctly. Document order is preserved, so child and sibling
405    // order match the source.
406    let mut parent: Vec<Option<usize>> = vec![None; n];
407    let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
408    let mut roots: Vec<usize> = Vec::new();
409    let mut stack: Vec<usize> = Vec::new();
410    for (i, entry) in entries.iter().enumerate() {
411        while stack
412            .last()
413            .is_some_and(|&top| entries[top].level >= entry.level)
414        {
415            stack.pop();
416        }
417        match stack.last() {
418            Some(&p) => {
419                parent[i] = Some(p);
420                children[p].push(i);
421            }
422            None => roots.push(i),
423        }
424        stack.push(i);
425    }
426
427    // Pass 2: subtree sizes (descendants excluding self) for /Count. A
428    // child always has a higher index than its parent (document order +
429    // the stack discipline), so a reverse walk visits every child before
430    // its parent.
431    let mut descendants: Vec<usize> = vec![0; n];
432    for i in (0..n).rev() {
433        if let Some(p) = parent[i] {
434            descendants[p] += descendants[i] + 1;
435        }
436    }
437
438    // Root /Outlines dict: first/last top-level items plus the full
439    // entry total (positive → every item open, since none are collapsed).
440    {
441        let mut outline = pdf.outline(root);
442        if let (Some(&first), Some(&last)) = (roots.first(), roots.last()) {
443            outline.first(items[first]);
444            outline.last(items[last]);
445        }
446        outline.count(i32::try_from(n).unwrap_or(i32::MAX));
447    }
448
449    for (i, entry) in entries.iter().enumerate() {
450        let mut item = pdf.outline_item(items[i]);
451        item.title(TextStr(&entry.title));
452        item.parent(match parent[i] {
453            Some(p) => items[p],
454            None => root,
455        });
456
457        // Prev/next siblings within the parent's child list (or the root
458        // list for top-level entries).
459        let siblings: &[usize] = match parent[i] {
460            Some(p) => &children[p],
461            None => &roots,
462        };
463        if let Some(pos) = siblings.iter().position(|&s| s == i) {
464            if pos > 0 {
465                item.prev(items[siblings[pos - 1]]);
466            }
467            if pos + 1 < siblings.len() {
468                item.next(items[siblings[pos + 1]]);
469            }
470        }
471
472        // Children first/last + subtree count (positive → open).
473        if let (Some(&first), Some(&last)) = (children[i].first(), children[i].last()) {
474            item.first(items[first]);
475            item.last(items[last]);
476            item.count(i32::try_from(descendants[i]).unwrap_or(i32::MAX));
477        }
478
479        // Destination: land on the heading's page with the top coord
480        // flipped to bottom-origin (same convention as text runs). Bounds
481        // are guaranteed by construction; the `.get` guards skip a
482        // malformed index rather than panicking.
483        if let Some((page_ref, _)) = page_refs.get(entry.page_index)
484            && let Some(page) = graph.pages.get(entry.page_index)
485        {
486            let top = page.height_pt - entry.top_from_top_pt;
487            item.dest().page(*page_ref).xyz(0.0, top, None);
488        }
489    }
490}
491
492struct PageEmitContext<'a> {
493    page_tree_id: Ref,
494    page_refs: &'a [(Ref, Ref)],
495    base14_refs: &'a [(Font, Ref)],
496    embedded_refs: &'a HashMap<EmbeddedFontId, EmbeddedRefs>,
497    image_refs: &'a [Ref],
498    encodings: &'a HashMap<Font, DocEncoding>,
499    embedded_by_id: &'a HashMap<EmbeddedFontId, &'a EmbeddedFontPlan>,
500    debug: Option<&'a mos_layout::debug::Report>,
501    debug_font_ref: Option<Ref>,
502}
503
504fn plan_base14_encodings(
505    graph: &PageGraph,
506    diagnostics: &mut Vec<Diagnostic>,
507) -> HashMap<Font, DocEncoding> {
508    let mut planner = EncodingPlanner::new();
509    for page in &graph.pages {
510        planner.observe_runs(&page.runs);
511    }
512    planner.finalize(diagnostics)
513}
514
515fn emit_pages(pdf: &mut Pdf, graph: &PageGraph, ctx: &PageEmitContext<'_>) -> Result<()> {
516    for (index, (page, (page_id, content_id))) in
517        graph.pages.iter().zip(ctx.page_refs.iter()).enumerate()
518    {
519        let debug_page = ctx.debug.map(|report| &report.pages[index]);
520        let extra_height = if debug_page.is_some() {
521            debug::LEGEND_HEIGHT_PT
522        } else {
523            0.0
524        };
525        let width = if debug_page.is_some() {
526            debug::canvas_width(page.width_pt)
527        } else {
528            page.width_pt
529        };
530        let mut page_obj = pdf.page(*page_id);
531        page_obj.media_box(Rect::new(0.0, 0.0, width, page.height_pt + extra_height));
532        page_obj.parent(ctx.page_tree_id);
533        page_obj.contents(*content_id);
534        {
535            let mut resources = page_obj.resources();
536            {
537                let mut fonts = resources.fonts();
538                for (face, font_id) in ctx.base14_refs {
539                    fonts.pair(Name(face.pdf_resource_name()), *font_id);
540                }
541                if let Some(font_ref) = ctx.debug_font_ref {
542                    fonts.pair(debug::FONT_NAME, font_ref);
543                }
544                for id in EmbeddedFontId::ALL {
545                    if let Some(refs) = ctx.embedded_refs.get(&id) {
546                        fonts.pair(Name(id.pdf_resource_name()), refs.font);
547                    }
548                }
549            }
550            if !graph.images.is_empty() {
551                let mut x_objects = resources.x_objects();
552                for (handle, image_id) in graph.images.iter().zip(ctx.image_refs.iter()) {
553                    let name = images::resource_name(handle);
554                    x_objects.pair(Name(name.as_bytes()), *image_id);
555                }
556            }
557        }
558        page_obj.finish();
559
560        let mut stream_bytes =
561            content::build_content_stream(page.height_pt, page, ctx.encodings, ctx.embedded_by_id)?;
562        if let Some(debug_page) = debug_page {
563            // Isolate both streams: text state (e.g. character spacing) and
564            // drawing state from the document must not affect the legend.
565            stream_bytes.splice(..0, b"q\n".iter().copied());
566            stream_bytes.extend_from_slice(b"\nQ\n");
567            stream_bytes.extend_from_slice(&debug::overlay(debug_page));
568        }
569        pdf.stream(*content_id, &stream_bytes);
570    }
571    Ok(())
572}
573
574fn emit_base14_fonts(
575    pdf: &mut Pdf,
576    base14_refs: &[(Font, Ref)],
577    encoding_refs: &HashMap<Font, (Ref, Ref)>,
578) {
579    for (face, font_id) in base14_refs {
580        let Some(base14) = face.base14() else {
581            continue;
582        };
583        let mut font_dict = pdf.type1_font(*font_id);
584        font_dict.base_font(Name(face.pdf_base_name().as_bytes()));
585        if matches!(base14, Base14Font::Symbol | Base14Font::ZapfDingbats) {
586            continue;
587        }
588        match encoding_refs.get(face) {
589            Some(&(enc_ref, cmap_ref)) => {
590                font_dict.pair(Name(b"Encoding"), enc_ref);
591                font_dict.to_unicode(cmap_ref);
592            }
593            None => {
594                font_dict.encoding_predefined(Name(b"WinAnsiEncoding"));
595            }
596        }
597    }
598}
599
600fn emit_images(pdf: &mut Pdf, graph: &PageGraph, image_refs: &[Ref]) {
601    for (handle, id) in graph.images.iter().zip(image_refs.iter()) {
602        let compressed = images::flate_compress(&handle.rgb8);
603        images::emit_image_xobject(pdf, *id, handle, &compressed);
604    }
605}
606
607/// Emits one PDF indirect object: a custom `/Encoding` dict with
608/// `/BaseEncoding /WinAnsiEncoding` and a `/Differences` array.
609/// `pdf-writer`'s `Differences::consecutive(start, names)` emits the
610/// run-length form `[ start /n1 /n2 /n3 ]`. We use one group per
611/// contiguous run for compactness; isolated slots get their own
612/// single-element group.
613fn emit_encoding_dict(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
614    let mut enc_dict: Encoding<'_> = pdf.indirect(id).start();
615    enc_dict.base_encoding(Name(b"WinAnsiEncoding"));
616    {
617        let mut diffs = enc_dict.differences();
618        let mut i = 0;
619        while i < enc.differences.len() {
620            let (start, _) = enc.differences[i];
621            // Find the end of this contiguous run (slot[j] == slot[j-1] + 1).
622            let mut j = i + 1;
623            while j < enc.differences.len() && enc.differences[j].0 == enc.differences[j - 1].0 + 1
624            {
625                j += 1;
626            }
627            let names = enc.differences[i..j]
628                .iter()
629                .map(|(_, n)| Name(n.as_bytes()));
630            diffs.consecutive(start, names);
631            i = j;
632        }
633    }
634    enc_dict.finish();
635}
636
637/// Emits a `/ToUnicode` `CMap` stream that round-trips every byte
638/// used by `enc` back to its original Unicode codepoint, so
639/// copy-paste and full-text search work for both `WinAnsi` natives
640/// and `/Differences`-remapped slots.
641fn emit_to_unicode_cmap(pdf: &mut Pdf, id: Ref, enc: &DocEncoding) {
642    // The `SystemInfo` here is embedded inside the PostScript-y CMap
643    // stream content (the `%%BeginResource: CMap …` header that
644    // `UnicodeCmap::new` writes). The `/CMapName` and `/CIDSystemInfo`
645    // entries set further down go on the stream dictionary itself.
646    // both are required by PDF 1.7 §9.7.5.4 / §9.10.3 (pdf-writer
647    // documents `.name()` and `.system_info()` as "Required"), even
648    // though readers we've tested tolerate their absence because the
649    // PS content carries the same info.
650    let system_info = SystemInfo {
651        registry: Str(b"Adobe"),
652        ordering: Str(b"UCS"),
653        supplement: 0,
654    };
655    let mut cmap: UnicodeCmap<u8> = UnicodeCmap::new(Name(b"Adobe-Identity-UCS"), system_info);
656    for &(byte, ch) in &enc.to_unicode_entries {
657        cmap.pair(byte, ch);
658    }
659    let cmap_bytes = cmap.finish();
660    let mut cmap_writer = pdf.cmap(id, &cmap_bytes);
661    cmap_writer.name(Name(b"Adobe-Identity-UCS"));
662    cmap_writer.system_info(system_info);
663}
664
665#[cfg(test)]
666mod tests {
667    // No `#![allow]` here. The two filesystem-touching tests
668    // (`emit_writes_file`, `emit_fails_with_mos0014_when_target_is_a_directory`)
669    // return `TestResult` and surface failures via `?` / `ensure!`
670    // instead of `unwrap`/`expect`/`panic!`. The rest return `()`
671    // and use plain `assert!`, which is not covered by
672    // `clippy::panic`.
673    use std::error::Error;
674
675    use lopdf::{Document as LopdfDocument, Object};
676    use mos_layout::{Base14Font, Font, OutlineEntry, Page, PageGraph, TextRun};
677
678    use super::*;
679
680    // Explicit `std::result::Result` because the parent module
681    // imports `mos_core::Result` which only takes one type
682    // parameter.
683    type TestResult = std::result::Result<(), Box<dyn Error>>;
684
685    /// `assert!`-shaped helper that returns `Err` instead of
686    /// panicking, so `-> TestResult` bodies stay clippy-clean under
687    /// `clippy::panic_in_result_fn`. Mirrors the precedent in
688    /// `pdf-base14-metrics/tests/winansi_vendor.rs` and the
689    /// integration test at `tests/extended_latin_roundtrip.rs`.
690    macro_rules! ensure {
691        ($cond:expr, $($arg:tt)*) => {
692            if !$cond {
693                return Err(format!($($arg)*).into());
694            }
695        };
696    }
697
698    fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize {
699        haystack
700            .windows(needle.len())
701            .filter(|w| *w == needle)
702            .count()
703    }
704
705    fn sample_graph() -> PageGraph {
706        PageGraph {
707            pages: vec![Page {
708                number: 1,
709                width_pt: 595.276_f32,
710                height_pt: 841.89_f32,
711                runs: vec![
712                    TextRun {
713                        x_pt: 68.0,
714                        baseline_from_top_pt: 100.0,
715                        size_pt: 20.0,
716                        font: Font::Base14(Base14Font::HelveticaBold),
717                        text: "Title".to_owned(),
718                        actual_text: None,
719                        glyphs: Vec::new(),
720                    },
721                    TextRun {
722                        x_pt: 68.0,
723                        baseline_from_top_pt: 130.0,
724                        size_pt: 11.0,
725                        font: Font::Base14(Base14Font::Helvetica),
726                        text: "Body".to_owned(),
727                        actual_text: None,
728                        glyphs: Vec::new(),
729                    },
730                ],
731                images: Vec::new(),
732            }],
733            images: Vec::new(),
734            outline: Vec::new(),
735        }
736    }
737
738    fn info_string<'info>(
739        info: &'info lopdf::Dictionary,
740        key: &[u8],
741    ) -> std::result::Result<&'info str, Box<dyn Error>> {
742        let Object::String(bytes, _) = info.get(key)? else {
743            return Err(format!(
744                "expected Info key /{} to be a string",
745                String::from_utf8_lossy(key)
746            )
747            .into());
748        };
749        Ok(std::str::from_utf8(bytes)?)
750    }
751
752    #[test]
753    fn build_pdf_starts_with_pdf_header_and_ends_with_eof() {
754        let (bytes, diags) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
755        assert!(bytes.starts_with(b"%PDF-"), "missing PDF header");
756        assert!(
757            bytes.windows(5).any(|w| w == b"%%EOF"),
758            "missing %%EOF marker"
759        );
760        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
761    }
762
763    #[test]
764    fn build_pdf_embeds_text_runs_as_visible_strings() {
765        let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
766        // The Str writer emits ASCII inside `(...)` so we can grep
767        // the raw bytes for the visible payload.
768        assert!(
769            bytes.windows(b"(Title)".len()).any(|w| w == b"(Title)"),
770            "Title not found in stream"
771        );
772        assert!(
773            bytes.windows(b"(Body)".len()).any(|w| w == b"(Body)"),
774            "Body not found in stream"
775        );
776    }
777
778    #[test]
779    fn empty_graph_still_produces_valid_pdf() {
780        let (bytes, _) = build_pdf(&PageGraph::default(), &PdfMetadata::default()).unwrap();
781        assert!(bytes.starts_with(b"%PDF-"));
782    }
783
784    #[test]
785    fn metadata_and_provenance_appear_in_info_dictionary() -> TestResult {
786        let metadata = PdfMetadata {
787            title: Some("My Doc".to_owned()),
788            author: Some("A. Person".to_owned()),
789            language: None,
790        };
791        let (bytes, _) = build_pdf(&sample_graph(), &metadata).unwrap();
792        let doc = LopdfDocument::load_mem(&bytes)?;
793        let Object::Reference(info_id) = doc.trailer.get(b"Info")? else {
794            return Err("expected trailer /Info reference".into());
795        };
796        let info = doc.get_dictionary(*info_id)?;
797
798        ensure!(info_string(info, b"Title")? == "My Doc", "wrong /Title");
799        ensure!(
800            info_string(info, b"Author")? == "A. Person",
801            "wrong /Author"
802        );
803        ensure!(
804            info_string(info, b"Producer")? == PRODUCER,
805            "wrong /Producer"
806        );
807        ensure!(info_string(info, b"Creator")? == PRODUCER, "wrong /Creator");
808        Ok(())
809    }
810
811    #[test]
812    fn actual_text_is_emitted_for_replacement_runs() {
813        let graph = PageGraph {
814            pages: vec![Page {
815                number: 1,
816                width_pt: 595.276_f32,
817                height_pt: 841.89_f32,
818                runs: vec![TextRun {
819                    x_pt: 68.0,
820                    baseline_from_top_pt: 100.0,
821                    size_pt: 12.0,
822                    font: Font::Base14(Base14Font::Courier),
823                    text: "    println".to_owned(),
824                    actual_text: Some("\tprintln".to_owned()),
825                    glyphs: Vec::new(),
826                }],
827                images: Vec::new(),
828            }],
829            images: Vec::new(),
830            outline: Vec::new(),
831        };
832
833        let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
834
835        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
836        assert!(
837            bytes
838                .windows(b"/ActualText".len())
839                .any(|w| w == b"/ActualText"),
840            "missing /ActualText"
841        );
842        assert!(
843            bytes.windows(b"println".len()).any(|w| w == b"println"),
844            "actual text payload missing"
845        );
846    }
847
848    #[test]
849    fn actual_text_wraps_adjacent_fragments_once() {
850        let graph = PageGraph {
851            pages: vec![Page {
852                number: 1,
853                width_pt: 595.276_f32,
854                height_pt: 841.89_f32,
855                runs: vec![
856                    TextRun {
857                        x_pt: 68.0,
858                        baseline_from_top_pt: 100.0,
859                        size_pt: 12.0,
860                        font: Font::Base14(Base14Font::Courier),
861                        text: "    ".to_owned(),
862                        actual_text: Some("\tprintln".to_owned()),
863                        glyphs: Vec::new(),
864                    },
865                    TextRun {
866                        x_pt: 92.0,
867                        baseline_from_top_pt: 100.0,
868                        size_pt: 12.0,
869                        font: Font::Base14(Base14Font::CourierBold),
870                        text: "println".to_owned(),
871                        actual_text: Some("\tprintln".to_owned()),
872                        glyphs: Vec::new(),
873                    },
874                ],
875                images: Vec::new(),
876            }],
877            images: Vec::new(),
878            outline: Vec::new(),
879        };
880
881        let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
882
883        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
884        assert_eq!(count_bytes(&bytes, b"/ActualText"), 1);
885        assert_eq!(count_bytes(&bytes, b"println"), 1);
886    }
887
888    /// A graph containing Polish + Czech text: exercises the
889    /// `/Differences` and `/ToUnicode` emit paths end to end.
890    fn extended_latin_graph() -> PageGraph {
891        PageGraph {
892            pages: vec![Page {
893                number: 1,
894                width_pt: 595.276_f32,
895                height_pt: 841.89_f32,
896                runs: vec![TextRun {
897                    x_pt: 68.0,
898                    baseline_from_top_pt: 100.0,
899                    size_pt: 12.0,
900                    font: Font::Base14(Base14Font::Helvetica),
901                    text: "Łódź Příliš ě".to_owned(),
902                    actual_text: None,
903                    glyphs: Vec::new(),
904                }],
905                images: Vec::new(),
906            }],
907            images: Vec::new(),
908            outline: Vec::new(),
909        }
910    }
911
912    #[test]
913    fn extended_latin_emits_differences_and_to_unicode() {
914        let (bytes, diags) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
915        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
916        // The /Encoding dict carries /BaseEncoding /WinAnsiEncoding.
917        assert!(
918            bytes
919                .windows(b"/BaseEncoding /WinAnsiEncoding".len())
920                .any(|w| w == b"/BaseEncoding /WinAnsiEncoding"),
921            "missing /BaseEncoding"
922        );
923        // The /Differences array contains the AFM glyph names for the
924        // non-WinAnsi codepoints in the sample: Ł→Lslash, ř→rcaron,
925        // ě→ecaron, ź→zacute. (ó/d/í/l/i/š are WinAnsi natives, so
926        // they don't show up in /Differences.)
927        for name in [b"/Lslash" as &[u8], b"/rcaron", b"/ecaron", b"/zacute"] {
928            assert!(
929                bytes.windows(name.len()).any(|w| w == name),
930                "missing {:?} in /Differences",
931                std::str::from_utf8(name).unwrap_or("?")
932            );
933        }
934        // A /ToUnicode CMap was emitted.
935        assert!(
936            bytes
937                .windows(b"/ToUnicode".len())
938                .any(|w| w == b"/ToUnicode"),
939            "missing /ToUnicode reference"
940        );
941        assert!(
942            bytes
943                .windows(b"beginbfchar".len())
944                .any(|w| w == b"beginbfchar"),
945            "missing beginbfchar in CMap"
946        );
947    }
948
949    #[test]
950    fn pure_ascii_graph_keeps_predefined_winansi_shortcut() {
951        // Existing sample_graph() is pure ASCII; no /Differences
952        // should be emitted, the predefined WinAnsi shortcut path is
953        // exercised. This guards against accidental "always emit a
954        // custom encoding" regressions that would balloon every PDF.
955        let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
956        assert!(
957            bytes
958                .windows(b"/Encoding /WinAnsiEncoding".len())
959                .any(|w| w == b"/Encoding /WinAnsiEncoding"),
960            "expected predefined WinAnsi shortcut on ASCII-only doc"
961        );
962        assert!(
963            !bytes
964                .windows(b"/BaseEncoding".len())
965                .any(|w| w == b"/BaseEncoding"),
966            "no custom /Encoding dict expected for ASCII-only doc"
967        );
968    }
969
970    #[test]
971    fn extended_latin_content_stream_uses_remapped_bytes() {
972        // Polish "Ł" lands in the first gap slot (0x7F) by the
973        // allocator's deterministic order. The run also contains
974        // Latin-1 bytes ≥ 0x80 (`ó`, `í`, …) so pdf-writer switches
975        // the string from literal `(...)` to hex `<...>` form;
976        // 0x7F therefore appears in the document as the ASCII pair
977        // `7F`. This is a smoke check that the encoder routed Ł to
978        // a remapped slot rather than substituting `?` (0x3F).
979        //
980        // Both assertions operate on the page content stream slice
981        // only: scanning the whole PDF would let the `/ToUnicode`
982        // CMap (`<7F> <0141>`) satisfy the `7F` needle even if the
983        // content stream had silently substituted to `?`. Surgical
984        // slicing keeps the smoke test honest.
985        let (bytes, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
986        let content_stream = first_content_stream(&bytes).expect("content stream not found");
987        let needle = b"7F";
988        assert!(
989            content_stream.windows(needle.len()).any(|w| w == needle),
990            "content stream should reference remapped slot 0x7F"
991        );
992        let qmark_count = content_stream.split(|&b| b == b'?').count() - 1;
993        assert!(
994            qmark_count < 5,
995            "too many `?` in PDF ({qmark_count}); did Ł/ř/ě/ź get substituted?"
996        );
997    }
998
999    #[test]
1000    fn build_pdf_is_byte_for_byte_deterministic() {
1001        // Regression guard for the HashMap-iteration-order bug that
1002        // shuffled indirect IDs between builds. Two `build_pdf` calls
1003        // on the same graph must produce identical bytes; otherwise
1004        // golden tests and reproducible CI artifacts break.
1005        let (a, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1006        let (b, _) = build_pdf(&extended_latin_graph(), &PdfMetadata::default()).unwrap();
1007        assert_eq!(
1008            a,
1009            b,
1010            "build_pdf is non-deterministic: byte lengths {} vs {}",
1011            a.len(),
1012            b.len()
1013        );
1014    }
1015
1016    /// Locate the first `stream` ... `endstream` body in a PDF byte
1017    /// blob and return the bytes between them. `build_pdf` emits the
1018    /// page content stream before any `/ToUnicode` `CMap` stream
1019    /// (see the object-order comment in [`build_pdf`]), so the first
1020    /// match is always the page content. Markers anchor on the
1021    /// surrounding `\n` so the substring inside `endstream` doesn't
1022    /// false-match the opener.
1023    fn first_content_stream(bytes: &[u8]) -> Option<&[u8]> {
1024        let open = b"\nstream\n";
1025        let close = b"\nendstream";
1026        let open_at = bytes.windows(open.len()).position(|w| w == open)?;
1027        let body = &bytes[open_at + open.len()..];
1028        let close_at = body.windows(close.len()).position(|w| w == close)?;
1029        Some(&body[..close_at])
1030    }
1031
1032    fn unique_temp_path(label: &str) -> std::path::PathBuf {
1033        std::env::temp_dir().join(format!(
1034            "mos-pdf-test-{label}-{}",
1035            std::time::SystemTime::now()
1036                .duration_since(std::time::UNIX_EPOCH)
1037                .map_or(0, |d| d.as_nanos())
1038        ))
1039    }
1040
1041    #[test]
1042    fn emit_writes_file() -> TestResult {
1043        let dir = unique_temp_path("write");
1044        let out = dir.join("out.pdf");
1045        let diags = emit(&sample_graph(), &PdfMetadata::default(), &out)
1046            .map_err(|e| format!("emit: {e:?}"))?;
1047        ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1048        let bytes = std::fs::read(&out)?;
1049        ensure!(bytes.starts_with(b"%PDF-"), "missing PDF header");
1050        std::fs::remove_dir_all(&dir).ok();
1051        Ok(())
1052    }
1053
1054    /// Build a graph with one image: a 4×2 red-and-blue checker
1055    /// flattened to RGB8, sized at 40×20 pt. Reused across multiple
1056    /// emit tests below.
1057    fn image_graph() -> PageGraph {
1058        use mos_layout::{ImageHandle, ImagePlacement};
1059        use std::sync::Arc;
1060        // 4 columns × 2 rows; alternating red/blue cells.
1061        let mut rgb8 = Vec::with_capacity(4 * 2 * 3);
1062        for y in 0..2 {
1063            for x in 0..4 {
1064                if (x + y) % 2 == 0 {
1065                    rgb8.extend_from_slice(&[255, 0, 0]);
1066                } else {
1067                    rgb8.extend_from_slice(&[0, 0, 255]);
1068                }
1069            }
1070        }
1071        let handle = ImageHandle {
1072            id: 0,
1073            resolved_path: "/tmp/checker.png".to_owned(),
1074            pixel_width: 4,
1075            pixel_height: 2,
1076            rgb8: Arc::from(rgb8),
1077        };
1078        PageGraph {
1079            pages: vec![Page {
1080                number: 1,
1081                width_pt: 595.276_f32,
1082                height_pt: 841.89_f32,
1083                runs: Vec::new(),
1084                images: vec![ImagePlacement {
1085                    handle: handle.clone(),
1086                    x_pt: 68.0,
1087                    top_from_top_pt: 100.0,
1088                    width_pt: 40.0,
1089                    height_pt: 20.0,
1090                }],
1091            }],
1092            images: vec![handle],
1093            outline: Vec::new(),
1094        }
1095    }
1096
1097    #[test]
1098    fn image_xobject_carries_width_height_and_devicergb() {
1099        let (bytes, diags) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1100        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1101        // The Image XObject must declare /Subtype /Image, /Width 4,
1102        // /Height 2, /ColorSpace /DeviceRGB, /BitsPerComponent 8, and
1103        // /Filter /FlateDecode.
1104        for needle in [
1105            b"/Subtype /Image" as &[u8],
1106            b"/Width 4",
1107            b"/Height 2",
1108            b"/ColorSpace /DeviceRGB",
1109            b"/BitsPerComponent 8",
1110            b"/Filter /FlateDecode",
1111        ] {
1112            assert!(
1113                bytes.windows(needle.len()).any(|w| w == needle),
1114                "missing {:?} in PDF",
1115                std::str::from_utf8(needle).unwrap_or("?")
1116            );
1117        }
1118    }
1119
1120    #[test]
1121    fn image_placement_emits_do_operator_referencing_xobject() {
1122        let (bytes, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1123        // The page's resource dict must list /Im0; the content stream
1124        // must reference /Im0 via the Do operator.
1125        assert!(
1126            bytes.windows(b"/Im0 ".len()).any(|w| w == b"/Im0 "),
1127            "/Im0 resource name not found"
1128        );
1129        assert!(
1130            bytes.windows(b"/Im0 Do".len()).any(|w| w == b"/Im0 Do"),
1131            "/Im0 Do operator not found in content stream"
1132        );
1133    }
1134
1135    #[test]
1136    fn duplicate_image_emits_one_xobject() {
1137        // Two placements of the same image should still produce one
1138        // shared XObject; the layout pass already dedup'd them, so the
1139        // PDF backend never sees two ImageHandle entries.
1140        use mos_layout::{ImageHandle, ImagePlacement};
1141        use std::sync::Arc;
1142        let handle = ImageHandle {
1143            id: 0,
1144            resolved_path: "/tmp/shared.png".to_owned(),
1145            pixel_width: 1,
1146            pixel_height: 1,
1147            rgb8: Arc::from(vec![10_u8, 20, 30]),
1148        };
1149        let graph = PageGraph {
1150            pages: vec![Page {
1151                number: 1,
1152                width_pt: 595.276_f32,
1153                height_pt: 841.89_f32,
1154                runs: Vec::new(),
1155                images: vec![
1156                    ImagePlacement {
1157                        handle: handle.clone(),
1158                        x_pt: 10.0,
1159                        top_from_top_pt: 50.0,
1160                        width_pt: 5.0,
1161                        height_pt: 5.0,
1162                    },
1163                    ImagePlacement {
1164                        handle: handle.clone(),
1165                        x_pt: 100.0,
1166                        top_from_top_pt: 50.0,
1167                        width_pt: 5.0,
1168                        height_pt: 5.0,
1169                    },
1170                ],
1171            }],
1172            images: vec![handle],
1173            outline: Vec::new(),
1174        };
1175        let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1176        let xobject_marker = b"/Subtype /Image";
1177        let count = bytes
1178            .windows(xobject_marker.len())
1179            .filter(|w| *w == xobject_marker)
1180            .count();
1181        assert_eq!(count, 1, "expected exactly one Image XObject, got {count}");
1182        // Both placements show up as /Im0 Do.
1183        let do_count = bytes
1184            .windows(b"/Im0 Do".len())
1185            .filter(|w| *w == b"/Im0 Do")
1186            .count();
1187        assert_eq!(
1188            do_count, 2,
1189            "expected two /Im0 Do operators, got {do_count}"
1190        );
1191    }
1192
1193    #[test]
1194    fn image_only_pdf_remains_byte_deterministic() {
1195        let (a, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1196        let (b, _) = build_pdf(&image_graph(), &PdfMetadata::default()).unwrap();
1197        assert_eq!(a, b, "image emit must be byte-stable across runs");
1198    }
1199
1200    #[test]
1201    fn emit_fails_with_mos0014_when_target_is_a_directory() -> TestResult {
1202        // Writing a file whose path collides with an existing
1203        // directory must surface as an `MOS0014` diagnostic, not a
1204        // panic or an `Unimplemented` error.
1205        let dir = unique_temp_path("conflict");
1206        std::fs::create_dir_all(&dir)?;
1207        // `dir` itself is the bogus output target; `fs::write` will
1208        // refuse to overwrite a directory.
1209        let result = emit(&sample_graph(), &PdfMetadata::default(), &dir);
1210        std::fs::remove_dir_all(&dir).ok();
1211        let Err(err) = result else {
1212            return Err("expected emit to fail when target is a directory".into());
1213        };
1214        let CoreError::Diagnostic(d) = err else {
1215            return Err("expected Diagnostic, got Unimplemented".into());
1216        };
1217        ensure!(
1218            d.def().code() == codes::MOS0014.code(),
1219            "wrong code: {:?}",
1220            d.def().code()
1221        );
1222        ensure!(
1223            d.message().contains("could not write PDF"),
1224            "message={:?}",
1225            d.message()
1226        );
1227        Ok(())
1228    }
1229
1230    /// A single-page graph carrying the given outline entries. Every
1231    /// entry lands on page 0; the page is A4 so `top_from_top_pt` flips
1232    /// against `841.89`.
1233    fn outline_graph(entries: Vec<OutlineEntry>) -> PageGraph {
1234        PageGraph {
1235            pages: vec![Page {
1236                number: 1,
1237                width_pt: 595.276_f32,
1238                height_pt: 841.89_f32,
1239                runs: Vec::new(),
1240                images: Vec::new(),
1241            }],
1242            images: Vec::new(),
1243            outline: entries,
1244        }
1245    }
1246
1247    fn entry(level: u8, title: &str, top_from_top_pt: f32) -> OutlineEntry {
1248        OutlineEntry {
1249            level,
1250            title: title.to_owned(),
1251            page_index: 0,
1252            top_from_top_pt,
1253        }
1254    }
1255
1256    /// Read `key` from `dict` as an indirect-reference object id.
1257    fn ref_id(
1258        dict: &lopdf::Dictionary,
1259        key: &[u8],
1260    ) -> std::result::Result<(u32, u16), Box<dyn Error>> {
1261        match dict.get(key)? {
1262            Object::Reference(id) => Ok(*id),
1263            other => Err(format!(
1264                "expected /{} to be a reference, got {other:?}",
1265                String::from_utf8_lossy(key)
1266            )
1267            .into()),
1268        }
1269    }
1270
1271    /// Follow catalog → /Outlines and return the root outline dictionary.
1272    fn load_outlines(
1273        doc: &LopdfDocument,
1274    ) -> std::result::Result<&lopdf::Dictionary, Box<dyn Error>> {
1275        let root_id = ref_id(&doc.trailer, b"Root")?;
1276        let catalog = doc.get_dictionary(root_id)?;
1277        let outlines_id = ref_id(catalog, b"Outlines")?;
1278        Ok(doc.get_dictionary(outlines_id)?)
1279    }
1280
1281    #[test]
1282    fn outline_emitted_when_headings_present() -> TestResult {
1283        let graph = outline_graph(vec![entry(1, "One", 100.0), entry(2, "Two", 200.0)]);
1284        let (bytes, diags) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1285        ensure!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
1286        ensure!(
1287            count_bytes(&bytes, b"/Type /Outlines") >= 1,
1288            "missing /Type /Outlines"
1289        );
1290
1291        let doc = LopdfDocument::load_mem(&bytes)?;
1292        let outlines = load_outlines(&doc)?;
1293        // Root /Count is the full entry total (every item open).
1294        let Object::Integer(count) = outlines.get(b"Count")? else {
1295            return Err("missing /Count on root /Outlines".into());
1296        };
1297        ensure!(*count == 2, "wrong root /Count: {count}");
1298        // The single top-level H1 is both first and last root child.
1299        ensure!(
1300            ref_id(outlines, b"First")? == ref_id(outlines, b"Last")?,
1301            "single top-level entry must be both /First and /Last"
1302        );
1303        Ok(())
1304    }
1305
1306    #[test]
1307    fn no_outline_dict_for_heading_free_doc() -> TestResult {
1308        // sample_graph() has an empty outline: no /Outlines objects, and
1309        // the catalog carries no /Outlines key. Guards the byte-identical
1310        // heading-free path.
1311        let (bytes, _) = build_pdf(&sample_graph(), &PdfMetadata::default()).unwrap();
1312        ensure!(
1313            count_bytes(&bytes, b"/Outlines") == 0,
1314            "heading-free doc must emit no /Outlines"
1315        );
1316        let doc = LopdfDocument::load_mem(&bytes)?;
1317        let root_id = ref_id(&doc.trailer, b"Root")?;
1318        let catalog = doc.get_dictionary(root_id)?;
1319        ensure!(
1320            catalog.get(b"Outlines").is_err(),
1321            "catalog must not carry an /Outlines key when there are no headings"
1322        );
1323        Ok(())
1324    }
1325
1326    #[test]
1327    fn outline_nesting_wires_parent_first_last() -> TestResult {
1328        // H1 with two H2 children.
1329        let graph = outline_graph(vec![
1330            entry(1, "Chapter", 100.0),
1331            entry(2, "First", 200.0),
1332            entry(2, "Second", 300.0),
1333        ]);
1334        let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1335        let doc = LopdfDocument::load_mem(&bytes)?;
1336        let outlines = load_outlines(&doc)?;
1337
1338        // The lone H1 is the single root child.
1339        let h1_id = ref_id(outlines, b"First")?;
1340        ensure!(h1_id == ref_id(outlines, b"Last")?, "H1 must be sole root");
1341        let h1 = doc.get_dictionary(h1_id)?;
1342
1343        let first_h2_id = ref_id(h1, b"First")?;
1344        let last_h2_id = ref_id(h1, b"Last")?;
1345        ensure!(first_h2_id != last_h2_id, "the two H2 items must differ");
1346
1347        let first_h2 = doc.get_dictionary(first_h2_id)?;
1348        let last_h2 = doc.get_dictionary(last_h2_id)?;
1349        // Both H2 parents point back at the H1.
1350        ensure!(
1351            ref_id(first_h2, b"Parent")? == h1_id,
1352            "first H2 /Parent wrong"
1353        );
1354        ensure!(
1355            ref_id(last_h2, b"Parent")? == h1_id,
1356            "last H2 /Parent wrong"
1357        );
1358        // The two H2s are prev/next linked in document order.
1359        ensure!(ref_id(first_h2, b"Next")? == last_h2_id, "H2 /Next wrong");
1360        ensure!(ref_id(last_h2, b"Prev")? == first_h2_id, "H2 /Prev wrong");
1361        Ok(())
1362    }
1363
1364    #[test]
1365    fn outline_skipped_level_attaches_to_nearest_ancestor() -> TestResult {
1366        // H1 then H3 (no intervening H2): the H3 must parent under the H1.
1367        let graph = outline_graph(vec![entry(1, "Top", 100.0), entry(3, "Deep", 200.0)]);
1368        let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1369        let doc = LopdfDocument::load_mem(&bytes)?;
1370        let outlines = load_outlines(&doc)?;
1371
1372        let h1_id = ref_id(outlines, b"First")?;
1373        let h1 = doc.get_dictionary(h1_id)?;
1374        let h3_id = ref_id(h1, b"First")?;
1375        let h3 = doc.get_dictionary(h3_id)?;
1376        ensure!(
1377            ref_id(h3, b"Parent")? == h1_id,
1378            "skipped-level H3 must attach to the nearest shallower ancestor (H1)"
1379        );
1380        Ok(())
1381    }
1382
1383    #[test]
1384    fn outline_dest_uses_flipped_top_coordinate() -> TestResult {
1385        let top_from_top = 120.0_f32;
1386        let graph = outline_graph(vec![entry(1, "Heading", top_from_top)]);
1387        let (bytes, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1388        let doc = LopdfDocument::load_mem(&bytes)?;
1389        let outlines = load_outlines(&doc)?;
1390
1391        let item = doc.get_dictionary(ref_id(outlines, b"First")?)?;
1392        let Object::Array(dest) = item.get(b"Dest")? else {
1393            return Err("missing /Dest array on outline item".into());
1394        };
1395        // Dest is [pageRef /XYZ left top zoom]; top sits at index 3 and
1396        // must be page_height - top_from_top_pt (bottom-origin flip).
1397        let Some(Object::Real(top)) = dest.get(3) else {
1398            return Err(format!("dest top not a real: {dest:?}").into());
1399        };
1400        let expected = 841.89_f32 - top_from_top;
1401        let within_tolerance = (*top - expected).abs() < 0.01;
1402        ensure!(
1403            within_tolerance,
1404            "wrong dest top: {top} vs expected {expected}"
1405        );
1406        Ok(())
1407    }
1408
1409    #[test]
1410    fn outline_pdf_is_byte_for_byte_deterministic() {
1411        // The outline emit path must not introduce HashMap-iteration
1412        // nondeterminism: two builds of an outline-bearing graph match.
1413        let graph = outline_graph(vec![
1414            entry(1, "Chapter", 100.0),
1415            entry(2, "First", 200.0),
1416            entry(2, "Second", 300.0),
1417        ]);
1418        let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1419        let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1420        assert_eq!(a, b, "outline emit must be byte-stable across runs");
1421    }
1422
1423    #[test]
1424    fn outline_after_populated_encoding_map_is_byte_deterministic() {
1425        // `outline_graph` is base14-ASCII, so its encoding/embedded HashMaps
1426        // are empty — its determinism test can't prove outline refs stay
1427        // stable when allocated AFTER a HashMap-populated, variable object
1428        // count. `extended_latin_graph` populates `encoding_refs`; stacking
1429        // an outline on it exercises "outline allocated last, after a
1430        // reseeded HashMap" across two builds.
1431        let mut graph = extended_latin_graph();
1432        graph.outline = vec![entry(1, "Chapter", 100.0), entry(2, "Section", 200.0)];
1433        let (a, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1434        let (b, _) = build_pdf(&graph, &PdfMetadata::default()).unwrap();
1435        assert_eq!(a, b, "outline + custom encoding build must be byte-stable");
1436    }
1437}