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}