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