Skip to main content

stet_graphics/
document_structure.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Document-level structural data — the parallel IR to
6//! [`DisplayList`](crate::display_list::DisplayList).
7//!
8//! Where `DisplayList` carries per-page paint operations, [`DocumentStructure`]
9//! carries the document-scoped metadata that ends up in the PDF catalog, info
10//! dictionary, outline tree, page annotation arrays, and so on:
11//! `/DOCINFO`, `/OUT`, `/ANN`, `/DEST`, `/PAGE`/`/PAGES`,
12//! `/VIEWERPREFERENCES`, `/Metadata`, `/FORM`, `/EMBED`.
13//!
14//! Two producers populate this type:
15//!
16//! - The PostScript interpreter, when `pdfmark` operators fire — see
17//!   `stet-ops/src/pdfmark_ops.rs`. The buffer hangs off
18//!   `stet_core::context::Context::doc_structure`.
19//! - `stet-pdf-reader`, when round-tripping a PDF's structural API into
20//!   an output PDF.
21//!
22//! One consumer: the PDF output device (`stet-pdf::PdfDevice`) drains the
23//! buffer at end-of-job and writes the records into the output PDF. Non-PDF
24//! output devices ignore it, so structural data is a no-op for PNG / viewer
25//! output.
26
27/// One accumulated structural record. Each variant corresponds to a
28/// PDF catalog / info / page entry the writer knows how to emit.
29///
30/// Marked `#[non_exhaustive]`: cross-crate `match` sites need a
31/// wildcard arm so future record types (Tagged PDF, etc.) land additively.
32#[derive(Clone, Debug)]
33#[non_exhaustive]
34pub enum StructuralRecord {
35    /// `/DOCINFO` — entries to merge into the PDF Info dictionary.
36    DocInfo(DocInfoRecord),
37    /// `/OUT` — one bookmark entry, contributing to the document's
38    /// outline tree.
39    Outline(OutlineRecord),
40    /// `/ANN` — one page annotation (link, sticky note, free-text, …).
41    Annotation(AnnotationRecord),
42    /// `/DEST` — one named destination contributing to /Names /Dests.
43    Dest(DestRecord),
44    /// `/PAGE` (single-page override) or `/PAGES` (document-wide
45    /// default for keys that aren't already overridden on a specific
46    /// page).
47    PageOverride(PageOverrideRecord),
48    /// `/VIEWERPREFERENCES` — catalog-level viewer preferences plus
49    /// the `/PageLayout` and `/PageMode` overrides that live directly
50    /// on `/Catalog` rather than nested under `/ViewerPreferences`.
51    ViewerPrefs(ViewerPrefsRecord),
52    /// `/Metadata` — XMP metadata stream attached to `/Catalog`.
53    Metadata(MetadataRecord),
54    /// `/FORM` — document-level AcroForm dict. Multiple records merge
55    /// last-wins key-by-key; the `/Fields` array is implicit (built from
56    /// `/Widget` annotations at write time).
57    Form(FormRecord),
58    /// `/EMBED` — one embedded file attachment. Multiple records
59    /// accumulate; the writer assembles a `/Names /EmbeddedFiles`
60    /// name tree and references it from `/Catalog`.
61    Embed(EmbedRecord),
62    /// `/OUTPUTINTENT` — one `/Catalog /OutputIntents` entry naming the
63    /// destination color rendering condition for this PDF (PDF/X /
64    /// PDF/A). The writer emits one PDF object per record plus an array
65    /// referencing them all from `/Catalog /OutputIntents`. Multiple
66    /// records produce multiple intents — the array's order matches the
67    /// record order, and consumers traditionally honor the first
68    /// matching subtype.
69    OutputIntent(OutputIntentRecord),
70}
71
72/// Document-level structural records, parallel to
73/// [`DisplayList`](crate::display_list::DisplayList).
74///
75/// The PostScript interpreter populates this via `pdfmark` operators; the
76/// PDF reader populates it from a parsed document's structural API. The
77/// buffer is document-global (not VM-level), so `save` / `restore` do
78/// **not** roll it back — records issued before a `restore` survive.
79#[derive(Default, Clone, Debug)]
80pub struct DocumentStructure {
81    records: Vec<StructuralRecord>,
82    /// Count of completed `showpage` calls so far. The interpreter's
83    /// `showpage` continuation increments this. Page-scoped records
84    /// (annotations, page boxes) that omit an explicit `/Page` key
85    /// default to `current_page + 1` — i.e. the page currently being
86    /// assembled. So after N showpages, `current_page == N` and the
87    /// page-being-assembled is `N + 1`.
88    ///
89    /// PostScript-interpreter concern; non-PS producers can leave this
90    /// at the default `0` and set explicit page indices on each record.
91    pub current_page: u32,
92}
93
94impl DocumentStructure {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Append a record; ordering is preserved.
100    pub fn push(&mut self, record: StructuralRecord) {
101        self.records.push(record);
102    }
103
104    /// Read-only view of accumulated records.
105    pub fn records(&self) -> &[StructuralRecord] {
106        &self.records
107    }
108
109    /// Take ownership of the records, leaving the buffer empty. Used by
110    /// the PDF output device once at end of job.
111    pub fn drain(&mut self) -> Vec<StructuralRecord> {
112        std::mem::take(&mut self.records)
113    }
114
115    /// True when no records have been pushed.
116    pub fn is_empty(&self) -> bool {
117        self.records.is_empty()
118    }
119}
120
121/// `/DOCINFO` payload — `Option<String>` for every key so absent entries
122/// don't overwrite values from another producer (or the device's
123/// auto-generated defaults). `creation_date` and `mod_date` accept either
124/// a parsed [`PdfDate`] or a passthrough string the writer emits verbatim.
125#[derive(Clone, Debug, Default)]
126pub struct DocInfoRecord {
127    pub title: Option<String>,
128    pub author: Option<String>,
129    pub subject: Option<String>,
130    pub keywords: Option<String>,
131    pub creator: Option<String>,
132    pub producer: Option<String>,
133    pub creation_date: Option<DocDate>,
134    pub mod_date: Option<DocDate>,
135    /// Trapped: PDF spec requires /True, /False, or /Unknown.
136    pub trapped: Option<TrappedState>,
137}
138
139/// `/Trapped` value as written to the Info dict.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum TrappedState {
143    True,
144    False,
145    Unknown,
146}
147
148impl DocInfoRecord {
149    /// Return the `/CreationDate` value formatted as a PDF date string,
150    /// or `None` when no creation date is set.
151    pub fn creation_date_string(&self) -> Option<String> {
152        self.creation_date.as_ref().map(DocDate::to_pdf_string)
153    }
154
155    /// Return the `/ModDate` value formatted as a PDF date string, or
156    /// `None` when no mod date is set.
157    pub fn mod_date_string(&self) -> Option<String> {
158        self.mod_date.as_ref().map(DocDate::to_pdf_string)
159    }
160}
161
162impl DocDate {
163    /// Render the date as a PDF date string. `Raw` round-trips the
164    /// producer's bytes verbatim; `Parsed` reformats from the
165    /// structural form.
166    pub fn to_pdf_string(&self) -> String {
167        match self {
168            DocDate::Raw(s) => s.clone(),
169            DocDate::Parsed(d) => {
170                let mut out = format!(
171                    "D:{:04}{:02}{:02}{:02}{:02}{:02}",
172                    d.year, d.month, d.day, d.hour, d.minute, d.second
173                );
174                match d.tz_sign {
175                    TzSign::Utc => out.push('Z'),
176                    TzSign::East => out.push_str(&format!("+{:02}'{:02}'", d.tz_hour, d.tz_minute)),
177                    TzSign::West => out.push_str(&format!("-{:02}'{:02}'", d.tz_hour, d.tz_minute)),
178                    TzSign::Unknown => {}
179                }
180                out
181            }
182        }
183    }
184}
185
186/// A document date entry. The writer can either round-trip a raw string
187/// (already in PDF date syntax) or format a parsed [`PdfDate`].
188#[derive(Clone, Debug)]
189#[non_exhaustive]
190pub enum DocDate {
191    /// Raw string the producer issued — passed through verbatim. Used
192    /// when the input is already in PDF date format and round-tripping
193    /// the bytes preserves precision and timezone offset exactly.
194    Raw(String),
195    /// Parsed structural form. Reserved for future phases that
196    /// normalise dates; Phase 1 stores everything as `Raw`.
197    Parsed(PdfDate),
198}
199
200/// Parsed PDF date string of the form `D:YYYYMMDDHHmmSSOHH'mm'`, where
201/// `O` is one of `+`, `-`, or `Z` for the offset sign. All fields after
202/// the year are optional in the PDF spec; missing components default to
203/// the values shown in [`PdfDate::default`].
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub struct PdfDate {
206    pub year: u16,
207    pub month: u8,
208    pub day: u8,
209    pub hour: u8,
210    pub minute: u8,
211    pub second: u8,
212    pub tz_sign: TzSign,
213    pub tz_hour: u8,
214    pub tz_minute: u8,
215}
216
217/// Sign of a PDF date timezone offset.
218#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219#[non_exhaustive]
220pub enum TzSign {
221    /// `+` — east of UTC.
222    East,
223    /// `-` — west of UTC.
224    West,
225    /// `Z` — UTC.
226    Utc,
227    /// Offset omitted entirely — treat as local time per PDF spec.
228    Unknown,
229}
230
231impl Default for PdfDate {
232    fn default() -> Self {
233        Self {
234            year: 0,
235            month: 1,
236            day: 1,
237            hour: 0,
238            minute: 0,
239            second: 0,
240            tz_sign: TzSign::Unknown,
241            tz_hour: 0,
242            tz_minute: 0,
243        }
244    }
245}
246
247impl PdfDate {
248    /// Parse a PDF date string. Accepts the canonical `D:YYYY[MMDDHHmmSS[O[HH'[mm']]]]`
249    /// shape. The `D:` prefix is required; everything after the year is
250    /// optional and missing fields use [`PdfDate::default`] values.
251    /// Returns `None` on malformed input.
252    pub fn parse(s: &str) -> Option<Self> {
253        let body = s.strip_prefix("D:")?;
254        let bytes = body.as_bytes();
255        if bytes.len() < 4 || !bytes[..4].iter().all(|b| b.is_ascii_digit()) {
256            return None;
257        }
258        let year: u16 = std::str::from_utf8(&bytes[..4]).ok()?.parse().ok()?;
259        let mut date = PdfDate {
260            year,
261            ..PdfDate::default()
262        };
263        let mut i = 4;
264
265        let take_pair = |idx: &mut usize, max: u8| -> Option<u8> {
266            if *idx + 2 > bytes.len() {
267                return None;
268            }
269            let pair = std::str::from_utf8(&bytes[*idx..*idx + 2]).ok()?;
270            if !pair.chars().all(|c| c.is_ascii_digit()) {
271                return None;
272            }
273            let v: u8 = pair.parse().ok()?;
274            if v > max {
275                return None;
276            }
277            *idx += 2;
278            Some(v)
279        };
280
281        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
282            date.month = take_pair(&mut i, 12)?;
283        }
284        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
285            date.day = take_pair(&mut i, 31)?;
286        }
287        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
288            date.hour = take_pair(&mut i, 23)?;
289        }
290        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
291            date.minute = take_pair(&mut i, 59)?;
292        }
293        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
294            date.second = take_pair(&mut i, 59)?;
295        }
296
297        if i < bytes.len() {
298            match bytes[i] {
299                b'Z' => {
300                    date.tz_sign = TzSign::Utc;
301                }
302                b'+' => {
303                    date.tz_sign = TzSign::East;
304                    i += 1;
305                    date.tz_hour = take_pair(&mut i, 23)?;
306                    if i < bytes.len() && bytes[i] == b'\'' {
307                        i += 1;
308                        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
309                            date.tz_minute = take_pair(&mut i, 59)?;
310                        }
311                    }
312                }
313                b'-' => {
314                    date.tz_sign = TzSign::West;
315                    i += 1;
316                    date.tz_hour = take_pair(&mut i, 23)?;
317                    if i < bytes.len() && bytes[i] == b'\'' {
318                        i += 1;
319                        if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
320                            date.tz_minute = take_pair(&mut i, 59)?;
321                        }
322                    }
323                }
324                _ => return None,
325            }
326        }
327
328        Some(date)
329    }
330}
331
332// ----- Outlines ------------------------------------------------------------
333
334/// One `/OUT` entry. Each record contributes a bookmark node to the
335/// document outline tree the PDF writer assembles at end-of-job.
336#[derive(Clone, Debug)]
337pub struct OutlineRecord {
338    /// `/Title` — required user-visible label.
339    pub title: String,
340    /// `/Page`, `/Dest`, or `/Action` — what clicking the bookmark
341    /// resolves to. `None` is allowed (bookmark is a non-navigable
342    /// label).
343    pub destination: Option<OutlineDestination>,
344    /// `/Count` — Adobe nesting hint. Positive: this bookmark is
345    /// expanded with `count` direct children that immediately follow.
346    /// Negative: collapsed with `|count|` children. Zero / absent:
347    /// leaf. `None` = absent.
348    pub count: Option<i32>,
349    /// `/OutlineLevel` — stet extension. Explicit nesting level
350    /// (1-based; 1 is top-level). When at least one record uses this,
351    /// the tree builder switches to level-based parenting and ignores
352    /// `count`.
353    pub outline_level: Option<u32>,
354    /// `/Color` — RGB triple in `[0, 1]`, optional.
355    pub color: Option<[f64; 3]>,
356    /// `/F` — style flags: bit 0 = italic, bit 1 = bold (matches the
357    /// PDF 1.4 outline `/F` field).
358    pub flags: Option<u32>,
359}
360
361/// What a bookmark entry navigates to when clicked.
362#[derive(Clone, Debug)]
363#[non_exhaustive]
364pub enum OutlineDestination {
365    /// `/Page N /View [...]` — explicit page + view spec.
366    PageView { page: u32, view: ViewSpec },
367    /// `/Dest /Name` — reference to a named destination registered
368    /// elsewhere.
369    NamedDest(String),
370    /// `/Action <<...>>` — passthrough action dict.
371    Action(OutlineAction),
372}
373
374/// Outline view spec, mirroring PDF's `/Dest` array shape.
375#[derive(Clone, Copy, Debug)]
376#[non_exhaustive]
377pub enum ViewSpec {
378    /// `[/XYZ left top zoom]` — null components keep the current value.
379    Xyz {
380        left: Option<f64>,
381        top: Option<f64>,
382        zoom: Option<f64>,
383    },
384    /// `[/Fit]`.
385    Fit,
386    /// `[/FitH top]`.
387    FitH(Option<f64>),
388    /// `[/FitV left]`.
389    FitV(Option<f64>),
390    /// `[/FitR left bottom right top]`.
391    FitR {
392        left: f64,
393        bottom: f64,
394        right: f64,
395        top: f64,
396    },
397    /// `[/FitB]`.
398    FitB,
399    /// `[/FitBH top]`.
400    FitBH(Option<f64>),
401    /// `[/FitBV left]`.
402    FitBV(Option<f64>),
403}
404
405impl Default for ViewSpec {
406    fn default() -> Self {
407        ViewSpec::Xyz {
408            left: None,
409            top: None,
410            zoom: None,
411        }
412    }
413}
414
415/// Outline-action passthrough. Despite the name, this enum is shared
416/// across every place an "action dict" appears — outline `/Action`,
417/// link annotation `/A`, page `/AA` open / close — because the on-the-
418/// wire shape is identical.
419#[derive(Clone, Debug)]
420#[non_exhaustive]
421pub enum OutlineAction {
422    /// `<< /S /URI /URI (string) >>`.
423    Uri(String),
424    /// `<< /S /GoTo /D <name-or-array> >>` — the destination is either
425    /// a named destination (`Named`) or an explicit page+view
426    /// (`Explicit`).
427    GoTo(GoToTarget),
428    /// `<< /S /JavaScript /JS (string) >>` — pass-through. stet does
429    /// **not** execute JavaScript; the bytes are round-tripped verbatim
430    /// so a downstream viewer (Acrobat, Foxit) that does run JS can
431    /// pick them up.
432    JavaScript(String),
433    /// `<< /S /Named /N /<name> >>` — a built-in viewer command
434    /// (e.g. `/NextPage`, `/PrevPage`, `/FirstPage`, `/LastPage`,
435    /// `/Print`, `/Find`, …). The producer-supplied name is round-
436    /// tripped verbatim; viewers that don't recognise it ignore the
437    /// action.
438    Named(String),
439}
440
441/// `/GoTo` action target.
442#[derive(Clone, Debug)]
443#[non_exhaustive]
444pub enum GoToTarget {
445    /// `/D /SomeName` — resolved against the document's name tree.
446    Named(String),
447    /// `/D [N /Fit]` — explicit 1-based page + view spec.
448    Explicit { page: u32, view: ViewSpec },
449}
450
451/// One node in the assembled outline tree.
452#[derive(Clone, Debug)]
453pub struct OutlineNode {
454    pub record: OutlineRecord,
455    pub children: Vec<OutlineNode>,
456}
457
458/// Build an outline tree from a flat sequence of [`OutlineRecord`]s.
459///
460/// Two authoring conventions are supported and detected automatically:
461///
462/// 1. **Level-based** (stet extension): when *any* record carries
463///    `outline_level`, the builder uses those levels exclusively and
464///    ignores `count`. Each level-1 record opens a new top-level
465///    branch; deeper records become descendants of the most recent
466///    record at the level immediately above them.
467/// 2. **Count-based** (Adobe convention): the default. Each record
468///    declares how many direct children follow it via `count`
469///    (positive = expanded, negative = collapsed; sign affects display
470///    but not topology). Records with `count.is_none()` or `count == 0`
471///    are leaves.
472///
473/// Mixed input — some records use `outline_level`, others use
474/// `count` — falls into the level-based path; `count` on
475/// level-tagged records is preserved on each node so the writer can
476/// still emit Adobe-style `/Count` initial-display hints.
477pub fn build_outline_tree(records: &[OutlineRecord]) -> Vec<OutlineNode> {
478    if records.is_empty() {
479        return Vec::new();
480    }
481    let any_level = records.iter().any(|r| r.outline_level.is_some());
482    if any_level {
483        build_level_based(records)
484    } else {
485        build_count_based(records)
486    }
487}
488
489fn build_count_based(records: &[OutlineRecord]) -> Vec<OutlineNode> {
490    let mut idx = 0;
491    let mut roots = Vec::new();
492    while idx < records.len() {
493        roots.push(consume_count_node(records, &mut idx));
494    }
495    roots
496}
497
498fn consume_count_node(records: &[OutlineRecord], idx: &mut usize) -> OutlineNode {
499    let record = records[*idx].clone();
500    let child_count = record.count.unwrap_or(0).unsigned_abs() as usize;
501    *idx += 1;
502    let mut children = Vec::with_capacity(child_count);
503    for _ in 0..child_count {
504        if *idx >= records.len() {
505            break;
506        }
507        children.push(consume_count_node(records, idx));
508    }
509    OutlineNode { record, children }
510}
511
512fn build_level_based(records: &[OutlineRecord]) -> Vec<OutlineNode> {
513    // `stack[i]` is the in-progress sibling list at depth `i + 1`. When
514    // a record at depth d arrives we close out everything deeper than
515    // d-1 (folding child lists into their parents) before pushing.
516    let mut roots: Vec<OutlineNode> = Vec::new();
517    let mut stack: Vec<Vec<OutlineNode>> = Vec::new();
518    let mut depths: Vec<u32> = Vec::new();
519
520    for record in records {
521        let mut depth = record.outline_level.unwrap_or(1).max(1);
522        // Disallow gaps: clamp the requested depth to one deeper than
523        // the deepest currently open node, falling back to 1 when the
524        // stack is empty.
525        let max_allowed = depths.last().copied().unwrap_or(0) + 1;
526        if depth > max_allowed {
527            depth = max_allowed;
528        }
529        // Fold every open level deeper than (or equal to) `depth` back
530        // into its parent so the new record can sit at `depth`.
531        while let Some(&top_depth) = depths.last() {
532            if top_depth < depth {
533                break;
534            }
535            let folded = stack.pop().unwrap_or_default();
536            depths.pop();
537            attach_children(&mut roots, &mut stack, folded);
538        }
539        let node = OutlineNode {
540            record: record.clone(),
541            children: Vec::new(),
542        };
543        if depth == 1 {
544            roots.push(node);
545            stack.push(Vec::new());
546            depths.push(1);
547        } else {
548            stack.last_mut().unwrap().push(node);
549            stack.push(Vec::new());
550            depths.push(depth);
551        }
552    }
553    while let Some(level_children) = stack.pop() {
554        depths.pop();
555        attach_children(&mut roots, &mut stack, level_children);
556    }
557    roots
558}
559
560fn attach_children(
561    roots: &mut [OutlineNode],
562    stack: &mut [Vec<OutlineNode>],
563    children: Vec<OutlineNode>,
564) {
565    if children.is_empty() {
566        return;
567    }
568    let parent = match stack.last_mut() {
569        Some(siblings) => siblings.last_mut(),
570        None => roots.last_mut(),
571    };
572    if let Some(p) = parent {
573        p.children = children;
574    }
575}
576
577// ----- Annotations ---------------------------------------------------------
578
579/// One `/ANN` entry. Each record contributes a single annotation
580/// (`/Annot`) to one page's `/Annots` array. `page` is 1-based; `0` is
581/// reserved for "no explicit page" (the writer substitutes the page
582/// being assembled at the time the record fired).
583#[derive(Clone, Debug)]
584pub struct AnnotationRecord {
585    /// Page the annotation lives on (1-based). Set by the operator: an
586    /// explicit `/Page` (or `/SrcPg` alias) wins; otherwise the writer
587    /// falls back to `current_page + 1` from
588    /// [`DocumentStructure::current_page`].
589    pub page: u32,
590    /// `/Rect [llx lly urx ury]` — default user-space bounds. Required
591    /// per PDF spec; defaulted to the empty rect on malformed input so
592    /// the annotation at least has *somewhere* to land.
593    pub rect: [f64; 4],
594    /// Optional `/Color` triple in `[0, 1]` (PDF /C entry).
595    pub color: Option<[f64; 3]>,
596    /// Optional border specification. Translates to /Border on output.
597    pub border: Option<Border>,
598    /// Optional `/Title` (annotator name) — meaningful for `/Text` and
599    /// `/FreeText`.
600    pub title: Option<String>,
601    /// Optional `/Contents` — meaningful for `/Text` and `/FreeText`;
602    /// also accepted as a tooltip on `/Link`.
603    pub contents: Option<String>,
604    /// Subtype-specific payload.
605    pub subtype: AnnotationSubtype,
606}
607
608/// Per-subtype annotation payload. Each variant carries the keys
609/// specific to that subtype; shared keys (rect, color, border, title,
610/// contents, page) live on the parent [`AnnotationRecord`].
611#[derive(Clone, Debug)]
612#[non_exhaustive]
613pub enum AnnotationSubtype {
614    /// `/Subtype /Link` — clickable region. Target is an action,
615    /// explicit page+view, or a named destination; exactly one of the
616    /// three is expected.
617    Link {
618        target: Option<AnnotationTarget>,
619        /// `/H` highlight mode: `/N` (none), `/I` (invert), `/O`
620        /// (outline), `/P` (push). Optional.
621        highlight: Option<LinkHighlight>,
622    },
623    /// `/Subtype /Text` — sticky-note annotation.
624    Text {
625        /// `/Open` (boolean, default false).
626        open: bool,
627        /// `/Name` icon — /Comment, /Note (default), /Key, /Help,
628        /// /NewParagraph, /Paragraph, /Insert.
629        icon: TextAnnotationIcon,
630    },
631    /// `/Subtype /FreeText` — free-floating text annotation rendered
632    /// directly on the page.
633    FreeText {
634        /// `/DA` default appearance string. Optional but most viewers
635        /// require it to render anything; if absent, stet emits a sane
636        /// default (`0 0 0 rg /Helv 10 Tf`).
637        default_appearance: Option<String>,
638        /// `/Q` quadding: 0=left, 1=center, 2=right. Optional.
639        quadding: Option<u32>,
640    },
641    /// `/Subtype /Widget` — interactive form field. Author-only: stet
642    /// doesn't render or run forms interactively, but it emits the
643    /// PDF AcroForm structure so downstream viewers (Acrobat, Okular,
644    /// pdf.js) can. The widget annotation and its leaf field dict are
645    /// merged into a single PDF object — common when a field has
646    /// exactly one widget — and the field-tree builder in
647    /// `crates/stet-pdf/src/form_fields.rs` handles the multi-widget
648    /// (radio group) and dotted-name parent cases.
649    Widget(WidgetAnnotation),
650}
651
652/// `/Widget` annotation payload — also acts as the field dict when the
653/// widget is a single-leaf field (the common case). Multiple widgets
654/// sharing the same dotted [`field_name`](Self::field_name) become
655/// `/Kids` of an implicit parent field at write time (radio groups).
656#[derive(Clone, Debug, Default)]
657pub struct WidgetAnnotation {
658    /// `/T` — fully qualified field name. Dot-separated segments imply
659    /// nesting (`order.shipping.street` → parents `order` →
660    /// `order.shipping` and a leaf `street`). The PDF emitter renders
661    /// only the last segment as `/T`; PDF resolves the full name by
662    /// walking the `/Parent` chain.
663    pub field_name: String,
664    /// `/FT` field type. Optional — when absent the field inherits its
665    /// type from the parent. Required on root fields.
666    pub field_type: Option<FieldType>,
667    /// `/V` field value — variant shape depends on `/FT`. Optional.
668    pub value: Option<FieldValue>,
669    /// `/DV` default value — same shape rules as `value`.
670    pub default_value: Option<FieldValue>,
671    /// `/Ff` field flags (PDF 1.7 spec § 12.7.3.1). Bit semantics
672    /// vary by `/FT`; passed through verbatim.
673    pub flags: Option<i32>,
674    /// `/MaxLen` — text-field-only character limit.
675    pub max_len: Option<i32>,
676    /// `/Opt` — choice-field options. Each entry is either a single
677    /// display string (export = display) or `[export display]` pair.
678    pub options: Option<Vec<ChoiceOption>>,
679    /// `/Q` quadding: 0=left, 1=center, 2=right.
680    pub quadding: Option<i32>,
681    /// `/DA` default appearance string. Falls back to the form-level
682    /// `/DA` (or `0 0 0 rg /Helv 10 Tf` when neither is set) at write
683    /// time.
684    pub default_appearance: Option<String>,
685}
686
687/// Field type per PDF 1.7 spec § 12.7.4. The variant maps directly to
688/// the `/FT` name in the output PDF.
689#[derive(Clone, Copy, Debug, PartialEq, Eq)]
690#[non_exhaustive]
691pub enum FieldType {
692    /// `/Btn` — pushbuttons, checkboxes, radio buttons.
693    Btn,
694    /// `/Tx` — text fields.
695    Tx,
696    /// `/Ch` — choice fields (combo boxes, list boxes).
697    Ch,
698    /// `/Sig` — signature fields.
699    Sig,
700}
701
702/// Field value — variant shape depends on the field's `/FT`. The
703/// emitter writes the corresponding PDF object kind for each variant.
704#[derive(Clone, Debug)]
705#[non_exhaustive]
706pub enum FieldValue {
707    /// Text string — used for `/Tx` and single-select `/Ch` fields.
708    Text(String),
709    /// Name — used for `/Btn` checkboxes (`/Yes` / `/Off`) and radio
710    /// groups (the chosen kid's appearance state).
711    Name(String),
712    /// Array of text strings — used for multi-select `/Ch` fields.
713    TextArray(Vec<String>),
714}
715
716/// One `/Opt` entry on a choice field. PDF allows two shapes: a single
717/// string (export = display) or `[export display]` for distinct values.
718#[derive(Clone, Debug)]
719pub struct ChoiceOption {
720    /// Internal value persisted in the PDF when this option is selected.
721    pub export: String,
722    /// Human-readable label shown to the user. Equal to `export` when
723    /// the producer used the single-string form.
724    pub display: String,
725}
726
727/// `/Link` highlight mode — controls the visual feedback when the
728/// user activates the link region.
729#[derive(Clone, Copy, Debug, PartialEq, Eq)]
730#[non_exhaustive]
731pub enum LinkHighlight {
732    None,
733    Invert,
734    Outline,
735    Push,
736}
737
738/// Standard `/Text` annotation icon names. Anything outside this set
739/// falls back to `/Note`.
740#[derive(Clone, Copy, Debug, PartialEq, Eq)]
741#[non_exhaustive]
742pub enum TextAnnotationIcon {
743    Comment,
744    Note,
745    Key,
746    Help,
747    NewParagraph,
748    Paragraph,
749    Insert,
750}
751
752impl Default for TextAnnotationIcon {
753    fn default() -> Self {
754        TextAnnotationIcon::Note
755    }
756}
757
758/// `/Border` array `[Hradius Vradius Width]`. PDF spec also allows a
759/// dash pattern fourth entry; we capture it but only emit when present.
760#[derive(Clone, Debug, Default)]
761pub struct Border {
762    pub h_radius: f64,
763    pub v_radius: f64,
764    pub width: f64,
765    pub dash: Option<Vec<f64>>,
766}
767
768/// What an annotation activates. Mirrors [`OutlineDestination`] but
769/// kept distinct because annotations can carry richer action data
770/// (e.g. JavaScript) and have their own resolution rules.
771#[derive(Clone, Debug)]
772#[non_exhaustive]
773pub enum AnnotationTarget {
774    /// Explicit `/Page N /View [...]`. `page` is 1-based.
775    PageView { page: u32, view: ViewSpec },
776    /// `/Dest /Name` — named destination resolved against the
777    /// document's name tree.
778    NamedDest(String),
779    /// `/Action <<...>>` passthrough.
780    Action(OutlineAction),
781}
782
783// ----- Named destinations --------------------------------------------------
784
785/// One `/DEST` entry — registers a named destination in the document's
786/// `/Names /Dests` name tree. PDF outline entries and link annotations
787/// resolve the matching `name` against this tree.
788#[derive(Clone, Debug)]
789pub struct DestRecord {
790    /// `/Dest` — the destination name (interned bytes; UTF-8 lossy).
791    pub name: String,
792    /// `/Page` — 1-based target page.
793    pub page: u32,
794    /// `/View` — view spec; default `[/XYZ null null null]`.
795    pub view: ViewSpec,
796}
797
798// ----- Page boxes & page overrides -----------------------------------------
799
800/// One `/PAGE` (single-page override) or `/PAGES` (document-wide
801/// default) entry. The writer applies the keys to the per-page dict
802/// at build time; `/PAGE` wins over `/PAGES` for any key that's set
803/// on both, and an explicit `/PAGE` for page N wins over the implicit
804/// "current page" target.
805#[derive(Clone, Debug)]
806pub struct PageOverrideRecord {
807    /// Scope of the override.
808    pub scope: PageOverrideScope,
809    /// `/CropBox`, `/BleedBox`, `/TrimBox`, `/ArtBox` rectangles in
810    /// default user space — `[llx, lly, urx, ury]`.
811    pub boxes: PageBoxes,
812    /// `/Rotate` — 0, 90, 180, or 270. Other values land here as-is
813    /// and are dropped at write time.
814    pub rotate: Option<i32>,
815    /// `/AA` — additional-actions dict. Page-open (`/O`) fires when
816    /// the page becomes visible; page-close (`/C`) fires when the
817    /// user navigates away.
818    pub additional_actions: Option<PageAdditionalActions>,
819}
820
821/// Page-level `/AA` (additional actions) — open and close hooks the
822/// PDF viewer fires when a page becomes / leaves visible. Either
823/// hook is optional; both are passed through verbatim from the
824/// producer's action dict.
825#[derive(Clone, Debug, Default)]
826pub struct PageAdditionalActions {
827    /// `/O` — fired when the page becomes visible.
828    pub on_open: Option<OutlineAction>,
829    /// `/C` — fired when the page leaves visibility.
830    pub on_close: Option<OutlineAction>,
831}
832
833impl PageAdditionalActions {
834    pub fn is_empty(&self) -> bool {
835        self.on_open.is_none() && self.on_close.is_none()
836    }
837
838    /// Merge `other` under `self` — `self`'s `Some` actions win.
839    pub fn merge_over(&self, other: &PageAdditionalActions) -> PageAdditionalActions {
840        PageAdditionalActions {
841            on_open: self.on_open.clone().or_else(|| other.on_open.clone()),
842            on_close: self.on_close.clone().or_else(|| other.on_close.clone()),
843        }
844    }
845}
846
847/// One `/EMBED` entry — a single attached file. The writer emits one
848/// `/Filespec` dict + one `/EmbeddedFile` stream per record and assembles
849/// them into a `/Names /EmbeddedFiles` name tree.
850#[derive(Clone, Debug)]
851pub struct EmbedRecord {
852    /// `/FS` — file specification string (typically the original
853    /// filename). Required.
854    pub filename: String,
855    /// `/DataSource` — raw file contents. Required. PostScript
856    /// strings can hold arbitrary bytes, so binary attachments
857    /// (PNGs, ZIPs, …) round-trip without re-encoding.
858    pub data: Vec<u8>,
859    /// `/UF` — unicode filename. PDF spec recommends both `/F` and
860    /// `/UF`; when absent, the writer reuses `filename`.
861    pub unicode_filename: Option<String>,
862    /// `/Desc` — human-readable description.
863    pub description: Option<String>,
864    /// `/AFRelationship` — relationship of this attachment to the
865    /// document content. Allow-list: `Source`, `Data`, `Alternative`,
866    /// `Supplement`, `EncryptedPayload`, `Unspecified`.
867    pub af_relationship: Option<String>,
868    /// `/MIMEType` (PDF 1.7) — MIME type of the attached file.
869    /// Optional; viewers that respect it use it to pick the right
870    /// "open with" handler.
871    pub mime_type: Option<String>,
872}
873
874/// Whether a [`PageOverrideRecord`] targets one specific page or the
875/// whole document.
876#[derive(Clone, Copy, Debug, PartialEq, Eq)]
877#[non_exhaustive]
878pub enum PageOverrideScope {
879    /// `/PAGE` — single-page override. `1`-based page index.
880    Single(u32),
881    /// `/PAGES` — document-wide defaults applied to every page that
882    /// doesn't have an explicit `/PAGE` value for that same key.
883    All,
884}
885
886/// Per-page box rectangles. Each entry is `Option<[llx, lly, urx, ury]>`;
887/// `None` means "leave the device default in place".
888#[derive(Clone, Copy, Debug, Default)]
889pub struct PageBoxes {
890    pub crop_box: Option<[f64; 4]>,
891    pub bleed_box: Option<[f64; 4]>,
892    pub trim_box: Option<[f64; 4]>,
893    pub art_box: Option<[f64; 4]>,
894}
895
896// ----- Viewer prefs + metadata ---------------------------------------------
897
898/// One `/VIEWERPREFERENCES` payload. All keys are optional; later
899/// records override earlier ones key-by-key. The "page layout" and
900/// "page mode" entries technically live on `/Catalog` directly (not
901/// under `/ViewerPreferences`) but Adobe pdfmark groups them with the
902/// rest of the viewer-control bag, so stet does too.
903#[derive(Clone, Debug, Default)]
904pub struct ViewerPrefsRecord {
905    pub hide_toolbar: Option<bool>,
906    pub hide_menubar: Option<bool>,
907    pub hide_window_ui: Option<bool>,
908    pub fit_window: Option<bool>,
909    pub center_window: Option<bool>,
910    pub display_doc_title: Option<bool>,
911    /// `/NonFullScreenPageMode` — one of `UseNone`, `UseOutlines`,
912    /// `UseThumbs`, `UseOC`. Stored as the raw bytes for forward-
913    /// compatibility with values stet doesn't recognise.
914    pub non_full_screen_page_mode: Option<String>,
915    /// `/Direction` — `L2R` or `R2L`.
916    pub direction: Option<String>,
917    /// Catalog-level `/PageLayout`: `SinglePage`, `OneColumn`,
918    /// `TwoColumnLeft`, `TwoColumnRight`, `TwoPageLeft`, `TwoPageRight`.
919    pub page_layout: Option<String>,
920    /// Catalog-level `/PageMode`: `UseNone`, `UseOutlines`,
921    /// `UseThumbs`, `FullScreen`, `UseOC`, `UseAttachments`. Wins over
922    /// the `UseOutlines` default the writer applies when `/OUT`
923    /// records exist.
924    pub page_mode: Option<String>,
925}
926
927impl ViewerPrefsRecord {
928    /// Merge `other` into `self` — `self`'s `Some` values win when both
929    /// records set the same key. Used to layer multiple
930    /// `/VIEWERPREFERENCES` blocks into one effective record.
931    pub fn merge_over(&self, other: &ViewerPrefsRecord) -> ViewerPrefsRecord {
932        ViewerPrefsRecord {
933            hide_toolbar: self.hide_toolbar.or(other.hide_toolbar),
934            hide_menubar: self.hide_menubar.or(other.hide_menubar),
935            hide_window_ui: self.hide_window_ui.or(other.hide_window_ui),
936            fit_window: self.fit_window.or(other.fit_window),
937            center_window: self.center_window.or(other.center_window),
938            display_doc_title: self.display_doc_title.or(other.display_doc_title),
939            non_full_screen_page_mode: self
940                .non_full_screen_page_mode
941                .clone()
942                .or_else(|| other.non_full_screen_page_mode.clone()),
943            direction: self.direction.clone().or_else(|| other.direction.clone()),
944            page_layout: self
945                .page_layout
946                .clone()
947                .or_else(|| other.page_layout.clone()),
948            page_mode: self.page_mode.clone().or_else(|| other.page_mode.clone()),
949        }
950    }
951
952    /// True when no field has a value — the writer skips the catalog
953    /// entry entirely in this case.
954    pub fn nested_is_empty(&self) -> bool {
955        self.hide_toolbar.is_none()
956            && self.hide_menubar.is_none()
957            && self.hide_window_ui.is_none()
958            && self.fit_window.is_none()
959            && self.center_window.is_none()
960            && self.display_doc_title.is_none()
961            && self.non_full_screen_page_mode.is_none()
962            && self.direction.is_none()
963    }
964}
965
966/// One `/Metadata` entry — an XMP stream attached to the document's
967/// `/Catalog`. The writer wraps the bytes in a `/Type /Metadata
968/// /Subtype /XML` stream object.
969#[derive(Clone, Debug)]
970pub struct MetadataRecord {
971    /// Raw XMP XML bytes — round-tripped verbatim.
972    pub xmp_bytes: Vec<u8>,
973}
974
975// ----- AcroForm ------------------------------------------------------------
976
977/// `/FORM` payload — document-level AcroForm dict. All fields are
978/// optional; `/Fields` is implicit (built from `/Widget` annotations at
979/// write time). Multiple `/FORM` records merge last-wins via
980/// [`FormRecord::merge_over`].
981#[derive(Clone, Debug, Default)]
982pub struct FormRecord {
983    /// `/NeedAppearances` — when true, the viewer regenerates appearance
984    /// streams on open. stet defaults to `true` at write time when the
985    /// producer doesn't set this; that lets viewers (Acrobat, Okular,
986    /// pdf.js) draw form fields without us authoring appearance streams.
987    pub need_appearances: Option<bool>,
988    /// `/SigFlags` — signature flags. Bit 0: SignaturesExist. Bit 1:
989    /// AppendOnly. Pass-through; stet doesn't synthesise signatures.
990    pub sig_flags: Option<i32>,
991    /// `/CO` — calculate-order array of fully-qualified field names.
992    /// Used when calc-script-driven fields depend on each other.
993    pub calc_order: Option<Vec<String>>,
994    /// `/DA` — document-level default appearance string for fields that
995    /// don't set their own.
996    pub default_appearance: Option<String>,
997    /// `/Q` — document-level quadding default.
998    pub quadding: Option<i32>,
999}
1000
1001impl FormRecord {
1002    /// Merge `self` over `other` — `self`'s `Some` fields win.
1003    pub fn merge_over(&self, other: &FormRecord) -> FormRecord {
1004        FormRecord {
1005            need_appearances: self.need_appearances.or(other.need_appearances),
1006            sig_flags: self.sig_flags.or(other.sig_flags),
1007            calc_order: self.calc_order.clone().or_else(|| other.calc_order.clone()),
1008            default_appearance: self
1009                .default_appearance
1010                .clone()
1011                .or_else(|| other.default_appearance.clone()),
1012            quadding: self.quadding.or(other.quadding),
1013        }
1014    }
1015}
1016
1017/// `/OUTPUTINTENT` payload — one PDF/X or PDF/A OutputIntent declaring the
1018/// destination color rendering condition for the document. The writer
1019/// embeds [`dest_output_profile`] as an `/ICCBased` stream object and
1020/// produces an entry in `/Catalog /OutputIntents`.
1021///
1022/// `subtype` is the `/S` name (e.g. `b"GTS_PDFX"`, `b"GTS_PDFA1"`). It's
1023/// stored as raw bytes so the writer can pass through unknown subtypes
1024/// verbatim without losing information.
1025///
1026/// The text fields (`output_condition_identifier`, `output_condition`,
1027/// `registry_name`, `info`) are PDF text strings; they round-trip as
1028/// raw bytes to preserve any non-UTF-8 encoding the producer used.
1029#[derive(Clone, Debug)]
1030pub struct OutputIntentRecord {
1031    pub subtype: Vec<u8>,
1032    pub output_condition_identifier: Option<Vec<u8>>,
1033    pub output_condition: Option<Vec<u8>>,
1034    pub registry_name: Option<Vec<u8>>,
1035    pub info: Option<Vec<u8>>,
1036    /// Decompressed ICC profile bytes from the source PDF's
1037    /// `/DestOutputProfile` stream. `None` when the source intent had
1038    /// no embedded profile (rare for PDF/X but legal per PDF spec).
1039    pub dest_output_profile: Option<std::sync::Arc<Vec<u8>>>,
1040    /// Number of color components in the destination profile (1 for
1041    /// Gray, 3 for RGB, 4 for CMYK). Derived from the ICC header at
1042    /// parse time; the writer emits it as the `/N` entry on the
1043    /// `/ICCBased` profile stream.
1044    pub n: u32,
1045}
1046
1047impl PageBoxes {
1048    /// Merge `other` into `self` — `self`'s entries win when both are
1049    /// `Some`. Used by the writer to layer per-page `/PAGE` over
1050    /// document-wide `/PAGES` defaults.
1051    pub fn merge_over(&self, other: &PageBoxes) -> PageBoxes {
1052        PageBoxes {
1053            crop_box: self.crop_box.or(other.crop_box),
1054            bleed_box: self.bleed_box.or(other.bleed_box),
1055            trim_box: self.trim_box.or(other.trim_box),
1056            art_box: self.art_box.or(other.art_box),
1057        }
1058    }
1059
1060    pub fn is_empty(&self) -> bool {
1061        self.crop_box.is_none()
1062            && self.bleed_box.is_none()
1063            && self.trim_box.is_none()
1064            && self.art_box.is_none()
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    #[test]
1073    fn date_parse_full() {
1074        let d = PdfDate::parse("D:20261231120000-05'00'").unwrap();
1075        assert_eq!(d.year, 2026);
1076        assert_eq!(d.month, 12);
1077        assert_eq!(d.day, 31);
1078        assert_eq!(d.hour, 12);
1079        assert_eq!(d.tz_sign, TzSign::West);
1080        assert_eq!(d.tz_hour, 5);
1081    }
1082
1083    #[test]
1084    fn date_parse_utc() {
1085        let d = PdfDate::parse("D:20260101000000Z").unwrap();
1086        assert_eq!(d.tz_sign, TzSign::Utc);
1087        assert_eq!(d.year, 2026);
1088    }
1089
1090    #[test]
1091    fn date_parse_year_only() {
1092        let d = PdfDate::parse("D:2026").unwrap();
1093        assert_eq!(d.year, 2026);
1094        assert_eq!(d.month, 1);
1095        assert_eq!(d.day, 1);
1096    }
1097
1098    #[test]
1099    fn date_parse_no_prefix() {
1100        assert!(PdfDate::parse("20260101").is_none());
1101    }
1102
1103    #[test]
1104    fn date_parse_garbage() {
1105        assert!(PdfDate::parse("D:abcd").is_none());
1106    }
1107
1108    #[test]
1109    fn buffer_round_trip() {
1110        let mut buf = DocumentStructure::new();
1111        assert!(buf.is_empty());
1112        buf.push(StructuralRecord::DocInfo(DocInfoRecord {
1113            title: Some("Hello".into()),
1114            ..DocInfoRecord::default()
1115        }));
1116        assert_eq!(buf.records().len(), 1);
1117        let drained = buf.drain();
1118        assert_eq!(drained.len(), 1);
1119        assert!(buf.is_empty());
1120    }
1121
1122    fn outline(title: &str, count: Option<i32>, level: Option<u32>) -> OutlineRecord {
1123        OutlineRecord {
1124            title: title.into(),
1125            destination: None,
1126            count,
1127            outline_level: level,
1128            color: None,
1129            flags: None,
1130        }
1131    }
1132
1133    #[test]
1134    fn outline_empty_input() {
1135        let tree = build_outline_tree(&[]);
1136        assert!(tree.is_empty());
1137    }
1138
1139    #[test]
1140    fn outline_count_based_three_with_two_kids_each() {
1141        let records = vec![
1142            outline("A", Some(2), None),
1143            outline("A.1", None, None),
1144            outline("A.2", None, None),
1145            outline("B", Some(2), None),
1146            outline("B.1", None, None),
1147            outline("B.2", None, None),
1148            outline("C", Some(2), None),
1149            outline("C.1", None, None),
1150            outline("C.2", None, None),
1151        ];
1152        let tree = build_outline_tree(&records);
1153        assert_eq!(tree.len(), 3);
1154        for (i, root) in tree.iter().enumerate() {
1155            assert_eq!(root.children.len(), 2, "root {i} should have 2 kids");
1156        }
1157        assert_eq!(tree[0].record.title, "A");
1158        assert_eq!(tree[0].children[0].record.title, "A.1");
1159        assert_eq!(tree[2].children[1].record.title, "C.2");
1160    }
1161
1162    #[test]
1163    fn outline_count_based_collapsed_negative() {
1164        let records = vec![
1165            outline("A", Some(-2), None),
1166            outline("A.1", None, None),
1167            outline("A.2", None, None),
1168        ];
1169        let tree = build_outline_tree(&records);
1170        assert_eq!(tree.len(), 1);
1171        assert_eq!(tree[0].children.len(), 2);
1172    }
1173
1174    #[test]
1175    fn outline_count_based_nested_grandchildren() {
1176        let records = vec![
1177            outline("A", Some(1), None),
1178            outline("A.1", Some(2), None),
1179            outline("A.1.1", None, None),
1180            outline("A.1.2", None, None),
1181        ];
1182        let tree = build_outline_tree(&records);
1183        assert_eq!(tree.len(), 1);
1184        assert_eq!(tree[0].children.len(), 1);
1185        assert_eq!(tree[0].children[0].children.len(), 2);
1186    }
1187
1188    #[test]
1189    fn outline_level_based_1_2_2_1_2_3_3_1() {
1190        let records = vec![
1191            outline("A", None, Some(1)),
1192            outline("A.1", None, Some(2)),
1193            outline("A.2", None, Some(2)),
1194            outline("B", None, Some(1)),
1195            outline("B.1", None, Some(2)),
1196            outline("B.1.1", None, Some(3)),
1197            outline("B.1.2", None, Some(3)),
1198            outline("C", None, Some(1)),
1199        ];
1200        let tree = build_outline_tree(&records);
1201        assert_eq!(tree.len(), 3);
1202        assert_eq!(tree[0].record.title, "A");
1203        assert_eq!(tree[0].children.len(), 2);
1204        assert_eq!(tree[1].record.title, "B");
1205        assert_eq!(tree[1].children.len(), 1);
1206        assert_eq!(tree[1].children[0].children.len(), 2);
1207        assert_eq!(tree[1].children[0].children[1].record.title, "B.1.2");
1208        assert_eq!(tree[2].record.title, "C");
1209        assert!(tree[2].children.is_empty());
1210    }
1211
1212    #[test]
1213    fn outline_level_skip_clamps_to_next_depth() {
1214        let records = vec![
1215            outline("Root", None, Some(1)),
1216            outline("Child", None, Some(5)),
1217        ];
1218        let tree = build_outline_tree(&records);
1219        assert_eq!(tree.len(), 1);
1220        assert_eq!(tree[0].children.len(), 1);
1221        assert_eq!(tree[0].children[0].record.title, "Child");
1222    }
1223
1224    #[test]
1225    fn outline_mixed_input_uses_level_path() {
1226        let records = vec![
1227            outline("Bare", Some(2), None),
1228            outline("Tagged-1", None, Some(1)),
1229            outline("Tagged-2", None, Some(2)),
1230        ];
1231        let tree = build_outline_tree(&records);
1232        assert_eq!(tree.len(), 2);
1233        assert!(tree[0].children.is_empty());
1234        assert_eq!(tree[1].children.len(), 1);
1235    }
1236}