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