Skip to main content

stet_core/
pdfmark.rs

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