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}