Skip to main content

oxideav_pdf/
annotations.rs

1//! Round-32 — general annotations writer (ISO 32000-1 §12.5).
2//!
3//! Symmetric writer side of the round-26 generic annotation reader
4//! ([`crate::reader::annotations`]). Where round 25 emitted only
5//! `/Subtype /Link` and round 31 emitted `/Subtype /Widget`, round 32
6//! covers the rest of the §12.5.6 subtype taxonomy that authoring tools
7//! actually use in the wild:
8//!
9//! * **`/Text`** sticky-note (§12.5.6.4, Table 172) —
10//!   [`AnnotationKind::Text`]: `/Contents`, `/Name` icon, `/Open`.
11//! * **`/FreeText`** in-page text overlay (§12.5.6.6, Table 174) —
12//!   [`AnnotationKind::FreeText`]: `/Contents`, `/DA` default appearance,
13//!   `/Q` quadding.
14//! * **`/Stamp`** rubber-stamp (§12.5.6.13, Table 184) —
15//!   [`AnnotationKind::Stamp`]: `/Name` icon identifier, optional
16//!   `/Contents` description.
17//! * **`/Highlight`** / **`/Underline`** / **`/Squiggly`** /
18//!   **`/StrikeOut`** text-markup family (§12.5.6.10, Table 179) —
19//!   [`AnnotationKind::Highlight`] et al.: `/QuadPoints`.
20//! * **`/Link`** (§12.5.6.5, Table 173) —
21//!   [`AnnotationKind::Link`]: external URI (re-uses the same shape as
22//!   round 25's [`crate::LinkAnnotationSpec`]).
23//! * **`/Square`** / **`/Circle`** geometric markup (§12.5.6.8,
24//!   Table 177) — [`AnnotationKind::Square`] /
25//!   [`AnnotationKind::Circle`]: `/IC` interior colour, `/BS /W` line
26//!   width.
27//! * **`/Ink`** freehand scribble (§12.5.6.13, Table 185) —
28//!   [`AnnotationKind::Ink`]: `/InkList` an array of stroke
29//!   point-sequences.
30//!
31//! Round 227 extends the writer with three more §12.5.6 subtypes that
32//! the round-197 reader already decodes, closing the symmetry for the
33//! markup-line family:
34//!
35//! * **`/Line`** straight-line markup (§12.5.6.7, Table 175) —
36//!   [`AnnotationKind::Line`]: required `/L` two-endpoint array, plus
37//!   the Table 175 optional fields (`/LE` line-ending pair, `/IC`
38//!   interior colour, `/LL` / `/LLE` / `/LLO` leader-line geometry,
39//!   `/Cap` caption flag, `/IT` intent name).
40//! * **`/Polygon`** and **`/PolyLine`** polygon / polyline markup
41//!   (§12.5.6.9, Table 178) — [`AnnotationKind::Polygon`] /
42//!   [`AnnotationKind::PolyLine`]: `/Vertices` flat vertex array plus
43//!   the Table 178 optional fields (`/LE` line-ending pair — PolyLine
44//!   only per spec, `/IC` interior colour, `/IT` intent name).
45//!
46//! Round 232 closes the **markup-editing pair** the round-197 reader
47//! already decodes:
48//!
49//! * **`/Caret`** text-edit caret (§12.5.6.11, Table 180) —
50//!   [`AnnotationKind::Caret`]: `/RD` rectangle differences (the
51//!   caret figure inset inside the outer `/Rect`), `/Sy` symbol name
52//!   (`P` for the paragraph-mark glyph, `None` for the bare caret).
53//! * **`/Popup`** text-editing window (§12.5.6.14, Table 183) —
54//!   [`AnnotationKind::Popup`]: `/Parent` indirect reference to the
55//!   parent markup annotation (encoded by index into the same
56//!   `annotations` slice so the writer can resolve it to the actual
57//!   on-wire object id after every annotation has been allocated),
58//!   plus the `/Open` initial-visibility flag.
59//!
60//! Round 238 folds the **embedded-file marker** subtype into the
61//! generic annotation surface so callers no longer have to drop down
62//! to the round-33 attachments writer when they only need one file
63//! pinned to a page:
64//!
65//! * **`/FileAttachment`** (§12.5.6.15, Table 184) —
66//!   [`AnnotationKind::FileAttachment`]: writer additionally emits a
67//!   `/Type /EmbeddedFile` stream (§7.11.4 Table 45) + a
68//!   `/Type /Filespec` dict (§7.11.3 Table 44) + a catalog
69//!   `/Names → /EmbeddedFiles` entry (§7.7.4 + §7.9.6) per
70//!   FileAttachment annotation, then wires the annotation's `/FS`
71//!   entry to the filespec. The round-33 `read_pdf_attachments`
72//!   enumerator therefore sees the same files round-tripped.
73//!
74//! Round 245 closes the writer-side symmetry for the round-209
75//! reader's **multimedia-anchor** family by adding the simpler of the
76//! three subtypes — the §13.3 sound object is a self-describing
77//! stream + metadata dict, and a `/Sound` annotation is just a pinned
78//! reference to one:
79//!
80//! * **`/Sound`** (§12.5.6.16, Table 185) —
81//!   [`AnnotationKind::Sound`]: writer additionally emits a
82//!   `/Type /Sound` stream object (§13.3, Table 294) carrying the raw
83//!   sample bytes plus the `/R` sample rate, `/C` channel count, `/B`
84//!   bits-per-sample, and `/E` encoding metadata; the annotation's
85//!   `/Sound` entry resolves to that stream's indirect reference, and
86//!   the `/Name` icon (`Speaker` default per Table 185) selects the
87//!   on-page glyph the viewer renders. The round-209
88//!   `read_pdf_annotations` enumerator surfaces the same dict back
89//!   verbatim.
90//!
91//! Round 252 closes the writer-side symmetry for the round-204 reader's
92//! **fixed-print** annotation:
93//!
94//! * **`/Watermark`** (§12.5.6.22, Table 190 + Table 191) —
95//!   [`AnnotationKind::Watermark`]: writer emits the bare
96//!   `/Subtype /Watermark` annotation plus an optional `/FixedPrint`
97//!   sub-dict (`/Type /FixedPrint` + `/Matrix` six-number affine
98//!   transform + `/H` / `/V` printed-media translation percentages).
99//!   Table 191 makes every entry but `/Type` optional with explicit
100//!   defaults (`/Matrix` = identity, `/H` = `/V` = 0); the writer omits
101//!   the defaults so a round-trip through the round-204
102//!   `read_pdf_annotations` enumerator yields the same
103//!   "absent → default" reader contract producer files use. The
104//!   sub-dict is emitted inline (no separate indirect object) because
105//!   Table 191 doesn't require it to be indirect and inline keeps the
106//!   wire bytes smaller for the common fixed-print marker.
107//!
108//! Round 257 closes the writer-side symmetry for the round-215 reader's
109//! **production-printer-mark** annotation:
110//!
111//! * **`/PrinterMark`** (§12.5.6.20, Table 362) —
112//!   [`AnnotationKind::PrinterMark`]: writer emits the bare
113//!   `/Subtype /PrinterMark` annotation plus the optional `/MN`
114//!   mark-name Name (`ColorBar` / `RegistrationTarget` / `CutMark` /
115//!   `PageInformation`, …). Table 362 makes `/MN` optional; the
116//!   writer omits the entry when the caller passes `None` so a
117//!   round-trip through the round-215 `read_pdf_annotations`
118//!   enumerator yields the same "absent → None" reader shape. An
119//!   empty `Some(String::new())` is rejected at validation time per
120//!   §7.3.5 (Name tokens must be at least one byte). The Table-363
121//!   `/MarkStyle` and `/Colorants` entries hang off the form-XObject
122//!   appearance stream referenced from `/AP /N` (not the annotation
123//!   dict itself), and stay routed through the §8.10 Form XObject
124//!   walker — out of scope for this round just as they are for the
125//!   round-215 reader.
126//!
127//! The writer also carries every cross-subtype Table 164 field
128//! ([`Annotation::author`], `/M` modified-date, `/F` flags, `/C`
129//! colour, `/Border`).
130//!
131//! Provenance: ISO 32000-1 §12.5 (annotation framework), §12.5.2
132//! (annotation dict common fields, Table 164), and the individual
133//! §12.5.6.X subtype tables enumerated above. No third-party PDF
134//! source consulted.
135
136use oxideav_scene::Scene;
137
138use crate::attachments::{
139    emit_embedded_file_stream, emit_embedded_files_name_tree, emit_filespec_dict, Attachment,
140};
141use crate::error::PdfError;
142use crate::info::{build_info_dict, has_metadata};
143use crate::objects::{Dict, Document, Object, ObjectId};
144use crate::page::{build_pages, PageInput};
145use crate::resources::ResourceCollector;
146use crate::writer::render_frame_for_linearize as render_frame;
147
148// ---------------------------------------------------------------------
149// Public API.
150// ---------------------------------------------------------------------
151
152/// One annotation to attach to a page.
153///
154/// Mirrors the round-26 reader's [`crate::reader::PdfAnnotation`] shape
155/// — the cross-subtype Table 164 fields hang off the struct, the
156/// per-subtype payload off [`Self::kind`].
157#[derive(Debug, Clone)]
158pub struct Annotation {
159    /// 0-based page index — which page the annotation lives on.
160    pub source_page_index: usize,
161    /// `/Rect [llx lly urx ury]` — annotation rectangle in default
162    /// user space (PDF coordinates, origin bottom-left).
163    pub rect: [f32; 4],
164    /// `/T` — author / title-bar string. Most viewers display this in
165    /// the pop-up note's title bar. Optional per Table 164.
166    pub author: Option<String>,
167    /// `/M` — last-modified date string (raw PDF date form
168    /// `D:YYYYMMDDHHmmSSOHH'mm'` per §7.9.4). Caller is responsible
169    /// for the format — the writer passes it through verbatim.
170    pub modified: Option<String>,
171    /// `/F` — annotation flag word (Table 167). Common values:
172    /// 0 = no flags, 4 = Print (bit 3 set). When `None`, the writer
173    /// emits 4 (Print) so the annotation prints by default.
174    pub flags: Option<u32>,
175    /// `/C` — colour. 0/1/3/4 numbers per §12.5.2:
176    /// `[]` = transparent, `[g]` = grey, `[r g b]` = RGB,
177    /// `[c m y k]` = CMYK. `None` ⇒ entry omitted.
178    pub colour: Option<Vec<f32>>,
179    /// `/Border [hradius vradius width]` or `[hr vr w dash]`. When
180    /// `None`, defaults to `[0 0 0]` (no visible border).
181    pub border: Option<Vec<f32>>,
182    /// Per-subtype payload.
183    pub kind: AnnotationKind,
184}
185
186/// Per-subtype annotation payload — round 32 covers the five
187/// most-common interactive PDF annotation families per §12.5.6
188/// (Text, Link, FreeText, Highlight/Underline/Squiggly/StrikeOut,
189/// Stamp) plus three additional ones (Square, Circle, Ink) that
190/// show up in markup-heavy PDFs (review / proof workflows).
191#[derive(Debug, Clone)]
192pub enum AnnotationKind {
193    /// `/Subtype /Text` — sticky-note (§12.5.6.4, Table 172).
194    Text {
195        /// `/Contents` — the user-visible note text.
196        contents: String,
197        /// `/Name` — icon identifier (`Comment`, `Note`, `Help`,
198        /// `NewParagraph`, `Paragraph`, `Insert`). Defaults to `Note`
199        /// per Table 172 when `None`.
200        icon: Option<String>,
201        /// `/Open` — true ⇒ pop-up displayed at document open.
202        open: bool,
203    },
204    /// `/Subtype /Link` — hyperlink (§12.5.6.5, Table 173). Round 32
205    /// covers only the URI form; in-document goto-destination links
206    /// already have the richer [`crate::LinkAnnotationSpec`] surface
207    /// from round 25.
208    Link {
209        /// External URI (`/A << /S /URI /URI (...) >>`).
210        uri: String,
211    },
212    /// `/Subtype /FreeText` — in-page text overlay (§12.5.6.6, Table 174).
213    FreeText {
214        /// `/Contents` — the rendered text.
215        contents: String,
216        /// `/DA` default appearance string (a content-stream snippet
217        /// per §12.7.3.3 — `/Helv 12 Tf 0 g`-style). `None` ⇒ writer
218        /// supplies `(/Helv 12 Tf 0 g)`.
219        default_appearance: Option<String>,
220        /// `/Q` quadding: 0 left, 1 centre, 2 right.
221        quadding: FreeTextQuadding,
222    },
223    /// `/Subtype /Highlight` (§12.5.6.10, Table 179).
224    Highlight {
225        /// `/QuadPoints` — 8N reals per Table 179. Each 8-tuple gives
226        /// the four corners of one highlighted region.
227        quad_points: Vec<[f32; 8]>,
228    },
229    /// `/Subtype /Underline` (§12.5.6.10, Table 179).
230    Underline { quad_points: Vec<[f32; 8]> },
231    /// `/Subtype /Squiggly` (§12.5.6.10, Table 179).
232    Squiggly { quad_points: Vec<[f32; 8]> },
233    /// `/Subtype /StrikeOut` (§12.5.6.10, Table 179).
234    StrikeOut { quad_points: Vec<[f32; 8]> },
235    /// `/Subtype /Stamp` — rubber-stamp (§12.5.6.13, Table 184).
236    Stamp {
237        /// `/Name` — icon identifier. Standard set per Table 184:
238        /// `Approved`, `Experimental`, `NotApproved`, `AsIs`,
239        /// `Expired`, `NotForPublicRelease`, `Confidential`, `Final`,
240        /// `Sold`, `Departmental`, `ForComment`, `TopSecret`, `Draft`,
241        /// `ForPublicRelease`. Defaults to `Draft` per Table 184 when
242        /// `None`.
243        icon: Option<String>,
244        /// `/Contents` — optional description text.
245        contents: Option<String>,
246    },
247    /// `/Subtype /Square` — rectangle markup (§12.5.6.8, Table 177).
248    Square {
249        /// `/IC` interior colour. `None` ⇒ outline-only.
250        interior_colour: Option<Vec<f32>>,
251        /// `/BS /W` — border-style line width. `None` ⇒ omitted
252        /// (viewer-default).
253        line_width: Option<f32>,
254    },
255    /// `/Subtype /Circle` — ellipse markup (§12.5.6.8, Table 177).
256    Circle {
257        /// `/IC` interior colour. `None` ⇒ outline-only.
258        interior_colour: Option<Vec<f32>>,
259        /// `/BS /W` — border-style line width. `None` ⇒ omitted.
260        line_width: Option<f32>,
261    },
262    /// `/Subtype /Ink` — freehand scribble (§12.5.6.13, Table 185).
263    Ink {
264        /// `/InkList` — each inner vec is a single stroke as a flat
265        /// list of `[x0, y0, x1, y1, …]` reals.
266        strokes: Vec<Vec<f32>>,
267    },
268    /// `/Subtype /Line` — straight-line markup (§12.5.6.7, Table 175,
269    /// round 227). Two-endpoint line on the page; the outer
270    /// [`Annotation::rect`] is the bounding box, the `/L` four-real
271    /// array carries the line itself.
272    Line {
273        /// `/L [x1 y1 x2 y2]` — line endpoints in default user space.
274        /// Required per Table 175.
275        endpoints: [f32; 4],
276        /// `/LE [name1 name2]` — two-element line-ending styles
277        /// (Table 176 enumerates `None`, `Square`, `Circle`, `Diamond`,
278        /// `OpenArrow`, `ClosedArrow`, `Butt`, `ROpenArrow`,
279        /// `RClosedArrow`, `Slash`). Defaults to `[/None /None]` per
280        /// Table 175 when `None` (the writer omits the entry, matching
281        /// the round-197 reader's "absent → default" contract).
282        line_endings: Option<[String; 2]>,
283        /// `/IC` interior colour for filled line-ending shapes. Same
284        /// 0/1/3/4-component layout as outer `/C`. `None` ⇒ entry
285        /// omitted.
286        interior_colour: Option<Vec<f32>>,
287        /// `/LL` leader-line length, in default user-space units.
288        /// `None` ⇒ entry omitted (Table 175 default 0).
289        leader_line: Option<f32>,
290        /// `/LLE` leader-line extension length (≥ 0). `None` ⇒ entry
291        /// omitted (Table 175 default 0).
292        leader_line_extension: Option<f32>,
293        /// `/LLO` leader-line offset (PDF 1.7, ≥ 0). `None` ⇒ entry
294        /// omitted.
295        leader_line_offset: Option<f32>,
296        /// `/Cap` — emits `/Cap true` when set. Table 175 default
297        /// `false` ⇒ writer omits the entry on `false` so a
298        /// round-trip through the round-197 reader yields the same
299        /// "absent → false" shape.
300        cap: bool,
301        /// `/IT` intent (`LineArrow` / `LineDimension`). `None` ⇒
302        /// entry omitted.
303        intent: Option<String>,
304    },
305    /// `/Subtype /Polygon` — closed polygon markup (§12.5.6.9,
306    /// Table 178, round 227). Carries the `/Vertices` flat vertex
307    /// array plus the Table 178 optional fields.
308    Polygon {
309        /// `/Vertices [x1 y1 x2 y2 …]` — alternating coordinates in
310        /// default user space. Required per Table 178.
311        vertices: Vec<f32>,
312        /// `/IC` interior colour. Same layout as the outer `/C`. The
313        /// spec lists `/LE` for both Polygon and PolyLine but Table 178
314        /// notes it "Default value: [/None /None]"; the writer omits
315        /// it on Polygon to match the more-conformant
316        /// `/PolyLine`-only practice — callers that need a Polygon
317        /// with explicit line endings should use [`Self::PolyLine`]
318        /// instead.
319        interior_colour: Option<Vec<f32>>,
320        /// `/IT` intent (`PolygonCloud`, `PolygonDimension`). `None`
321        /// ⇒ entry omitted.
322        intent: Option<String>,
323    },
324    /// `/Subtype /PolyLine` — open polyline markup (§12.5.6.9,
325    /// Table 178, round 227). Carries the `/Vertices` flat vertex
326    /// array plus the Table 178 optional fields (`/LE`, `/IC`, `/IT`).
327    PolyLine {
328        /// `/Vertices [x1 y1 x2 y2 …]` — alternating coordinates in
329        /// default user space. Required per Table 178.
330        vertices: Vec<f32>,
331        /// `/LE [name1 name2]` — start/end line endings. Same name
332        /// taxonomy as [`Self::Line`]. `None` ⇒ entry omitted (spec
333        /// default `[/None /None]`).
334        line_endings: Option<[String; 2]>,
335        /// `/IC` interior colour. Same layout as the outer `/C`.
336        /// `None` ⇒ entry omitted.
337        interior_colour: Option<Vec<f32>>,
338        /// `/IT` intent (`PolyLineDimension`). `None` ⇒ entry
339        /// omitted.
340        intent: Option<String>,
341    },
342    /// `/Subtype /Caret` — text-edit caret marker (§12.5.6.11,
343    /// Table 180, round 232). Indicates the presence of text edits at
344    /// the position of the outer [`Annotation::rect`]. Optional
345    /// `/RD` shrinks the caret figure inside the rectangle (e.g. when
346    /// `/Sy /P` displays a paragraph mark whose bounds exceed the bare
347    /// caret); `/Sy` selects the rendered symbol.
348    Caret {
349        /// `/RD` rectangle differences `[left top right bottom]`,
350        /// each ≥ 0. The four values are the inset of the caret
351        /// figure inside the outer `/Rect`. `None` ⇒ entry omitted
352        /// (the caret fills the rectangle).
353        rect_diffs: Option<[f32; 4]>,
354        /// `/Sy` — caret symbol selector per Table 180.
355        symbol: CaretSymbol,
356    },
357    /// `/Subtype /Popup` — text-entry pop-up window (§12.5.6.14,
358    /// Table 183, round 232). A Popup is the editing surface for a
359    /// markup parent (Text, FreeText, Highlight, Caret, …); it carries
360    /// no appearance of its own and exists only to display the
361    /// parent's `/Contents` for editing.
362    ///
363    /// The `/Parent` field is normatively an indirect reference per
364    /// Table 183; the writer takes a 0-based index into the same
365    /// `annotations` slice as [`Self::parent_index`] and resolves it
366    /// to the actual on-wire object id after every annotation has
367    /// been allocated.
368    Popup {
369        /// 0-based index into the `annotations` slice passed to
370        /// [`write_pdf_with_annotations`] identifying the parent
371        /// markup annotation whose `/Contents` / `/M` / `/C` / `/T`
372        /// fields override this Popup's per Table 183. `None` ⇒
373        /// `/Parent` entry omitted (the spec example in §12.5.6.14
374        /// treats this as malformed — a Popup with no parent has no
375        /// editing target — but tolerant readers still surface the
376        /// dict, so the writer permits it).
377        parent_index: Option<usize>,
378        /// `/Open` — `true` ⇒ pop-up displayed at document open. Per
379        /// Table 183 the default is `false`; the writer omits the
380        /// entry on `false` so a round-trip through the round-197
381        /// reader yields the same "absent → false" shape.
382        open: bool,
383    },
384    /// `/Subtype /FileAttachment` — embedded-file marker (§12.5.6.15
385    /// Table 184, round 238). The on-page paperclip / push-pin icon
386    /// for a file embedded inside the PDF.
387    ///
388    /// Writing one of these causes the writer to additionally emit
389    /// (a) a `/Type /EmbeddedFile` stream object carrying
390    /// `file_bytes` (FlateDecode-compressed when smaller),
391    /// (b) a `/Type /Filespec` dictionary naming `file_name` and
392    /// pointing at the stream via `/EF`, and (c) a catalog
393    /// `/Names → /EmbeddedFiles` name tree entry keyed on
394    /// `file_name` so the round-33 `read_pdf_attachments` enumerator
395    /// surfaces the same file. The annotation's `/FS` entry holds
396    /// the indirect reference to the filespec dict per Table 184.
397    FileAttachment {
398        /// `/Name` icon identifier — Table 184 enumerates
399        /// `PushPin` (default), `GraphPushPin`, `PaperclipTag`, and
400        /// the more general `Graph` / `Paperclip` / `Tag` names.
401        /// `None` ⇒ writer emits `/PushPin`.
402        icon: Option<String>,
403        /// User-visible file name written into the filespec's `/F`
404        /// (PDFDocEncoded literal when ASCII) and `/UF` (UTF-16BE
405        /// hex with BOM) entries per §7.11.2 Table 43, and used as
406        /// the name-tree key per §7.7.4 + §7.9.6.
407        file_name: String,
408        /// Body of the `/Type /EmbeddedFile` stream object — the
409        /// raw bytes the viewer will save when the user extracts
410        /// the attachment.
411        file_bytes: Vec<u8>,
412        /// `/Subtype` on the embedded-file stream (a MIME type per
413        /// §7.11.4 Table 45) + `/Desc` text on the filespec dict.
414        /// `None` ⇒ neither entry emitted.
415        mime_type: Option<String>,
416    },
417    /// `/Subtype /Sound` — sound annotation (§12.5.6.16 Table 185,
418    /// round 245). The annotation pins a `/Sound` stream object to a
419    /// page; activation plays the sample data through the viewer's
420    /// audio output. The §13.3 stream (Table 294) is materialised by
421    /// the writer's pre-pass, and the annotation's `/Sound` entry
422    /// resolves to that stream's indirect reference.
423    Sound {
424        /// `/Name` icon identifier — Table 185 names `Speaker`
425        /// (default) and `Mic`. Authoring tools may extend this set;
426        /// `None` ⇒ writer emits `/Speaker`.
427        icon: Option<String>,
428        /// `/R` sampling rate, in samples per second per channel
429        /// (§13.3 Table 294). Required. Common conforming values per
430        /// the §13.3 portability guidance are `8000`, `11025`, and
431        /// `22050`; the writer accepts any positive value.
432        sampling_rate: f32,
433        /// `/C` number of channels (§13.3 Table 294). Default value
434        /// `1`. The §13.3 portability guidance recommends `1` or `2`;
435        /// the writer accepts any value ≥ 1 and omits the entry when
436        /// it equals the spec default to round-trip an
437        /// absent-equals-default reader contract.
438        channels: u32,
439        /// `/B` bits per sample value per channel (§13.3 Table 294).
440        /// Default value `8`. The writer accepts any value ≥ 1 and
441        /// omits the entry when it equals the spec default.
442        bits_per_sample: u32,
443        /// `/E` encoding format for the sample data (§13.3 Table 294).
444        /// Default value [`SoundEncoding::Raw`]. The writer omits the
445        /// entry when this variant is set so a write-then-read cycle
446        /// surfaces an absent-equals-default reader shape.
447        encoding: SoundEncoding,
448        /// Raw sample bytes that form the §13.3 stream body. Byte
449        /// order is big-endian for samples larger than 8 bits per
450        /// the §13.3 packing rule (caller responsibility — the writer
451        /// passes the buffer through verbatim). For stereo samples,
452        /// the caller interleaves left then right per channel per the
453        /// §13.3 interleave rule.
454        sound_samples: Vec<u8>,
455    },
456    /// `/Subtype /PrinterMark` — production printer's mark
457    /// (§12.5.6.20 Table 362, round 257). PDF 1.4. The on-page
458    /// registration target, colour bar, cut mark, or page-information
459    /// bar a print-production tool stamps onto every output sheet.
460    ///
461    /// Per Table 362 the only annotation-dict-local entry is the
462    /// optional `/MN` (mark-name) Name identifying the type of mark
463    /// (e.g. `ColorBar`, `RegistrationTarget`, `CutMark`,
464    /// `PageInformation`). The actual mark graphics live in the
465    /// form-XObject appearance stream referenced from `/AP /N`; the
466    /// `/MarkStyle` and `/Colorants` entries in Table 363 hang off
467    /// that form XObject, not the annot dict — so they are out of
468    /// scope for the round-257 writer just as they are for the
469    /// round-215 reader.
470    ///
471    /// `None` ⇒ writer omits `/MN` entirely, matching the spec's
472    /// "optional" wording and the round-215 reader's "absent → None"
473    /// shape. Per Table 362 a PrinterMark annotation should additionally
474    /// carry `/Type /PrinterMark` (in addition to the §12.5.2 Table 164
475    /// `/Type /Annot`) — the writer emits that marker via the
476    /// `/Subtype` slot, which is what every observed producer relies
477    /// on (the second `/Type` entry is rarely emitted in the wild
478    /// because the §12.5.2 `/Type /Annot` slot already designates the
479    /// dictionary as an annotation).
480    PrinterMark {
481        /// `/MN` — arbitrary Name identifying the kind of mark
482        /// (Table 362). `None` ⇒ entry omitted (the spec makes it
483        /// optional). Common values include `ColorBar`,
484        /// `RegistrationTarget`, `CutMark`, `PageInformation`; the
485        /// spec does not enumerate a closed set, so the writer
486        /// passes any caller-supplied Name through verbatim.
487        ///
488        /// An empty `Some(String::new())` is rejected at validation
489        /// time — a Name token is required to be at least one byte
490        /// per §7.3.5, and a zero-byte mark name would not identify
491        /// any taxonomy entry.
492        mark_name: Option<String>,
493    },
494    /// `/Subtype /Watermark` — fixed-print graphics (§12.5.6.22
495    /// Table 190, round 252). Used for content that prints at a fixed
496    /// size + position regardless of the dimensions of the printed
497    /// page — page-number stamps, copyright marks, "DRAFT" overlays
498    /// laid out per Table 191's media-relative geometry.
499    ///
500    /// Per Table 190 the only sub-entry is the optional `/FixedPrint`
501    /// dict (carried here as [`FixedPrintSpec`]). `None` leaves the
502    /// `/FixedPrint` entry off the annotation dict, matching the
503    /// Table 190 wording: *"If this entry is not present, the
504    /// annotation shall be drawn without any special consideration for
505    /// the dimensions of the target media."*
506    Watermark {
507        /// `/FixedPrint` sub-dict (§12.5.6.22 Table 191). `None` ⇒
508        /// entry omitted (the watermark draws without media-relative
509        /// positioning, per Table 190).
510        fixed_print: Option<FixedPrintSpec>,
511    },
512}
513
514/// `/E` encoding selector for [`AnnotationKind::Sound`] sample data
515/// (ISO 32000-1 §13.3 Table 294). Table 294 lists four values; the
516/// default is [`Self::Raw`] (unsigned in the range 0..=2^B − 1).
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
518pub enum SoundEncoding {
519    /// `/E /Raw` — unsigned values in the range 0..=2^B − 1
520    /// (default per Table 294). The writer omits the `/E` entry on
521    /// this variant so a write-then-read cycle through the round-209
522    /// reader yields the same "absent → Raw" branch.
523    #[default]
524    Raw,
525    /// `/E /Signed` — two's-complement signed values.
526    Signed,
527    /// `/E /muLaw` — μ-law encoded samples (§13.3 portability
528    /// guidance pairs this with `R=8000`, `C=1`, `B=8`).
529    MuLaw,
530    /// `/E /ALaw` — A-law encoded samples.
531    ALaw,
532}
533
534impl SoundEncoding {
535    fn as_name(self) -> Option<&'static str> {
536        match self {
537            // Default per Table 294 — omit the /E entry.
538            Self::Raw => None,
539            Self::Signed => Some("Signed"),
540            Self::MuLaw => Some("muLaw"),
541            Self::ALaw => Some("ALaw"),
542        }
543    }
544}
545
546/// `/FixedPrint` sub-dict for [`AnnotationKind::Watermark`] (ISO 32000-1
547/// §12.5.6.22 Table 191, round 252). Every field is optional with an
548/// explicit Table 191 default; the writer omits each entry whose value
549/// equals the default so a write-then-read cycle through the round-204
550/// `read_pdf_annotations` enumerator yields the same
551/// "absent → default" reader shape producer files use.
552///
553/// Mirrors the reader-side [`crate::FixedPrint`] decoded struct shape
554/// so callers can copy fields directly between the two when manipulating
555/// existing watermarks.
556///
557/// Default-constructed (`FixedPrintSpec::default()`) sets every entry
558/// to `None` so the writer emits the bare `/Type /FixedPrint` marker
559/// dict — the most-minimal way to opt a Watermark in to media-relative
560/// rendering without overriding any geometry.
561#[derive(Debug, Clone, PartialEq, Default)]
562pub struct FixedPrintSpec {
563    /// `/Matrix [a b c d e f]` — affine transform applied to the
564    /// annotation rectangle before rendering. `None` ⇒ writer omits
565    /// the entry (Table 191 default is the identity matrix
566    /// `[1 0 0 1 0 0]`).
567    pub matrix: Option<[f32; 6]>,
568    /// `/H` — horizontal translation as a fraction of the target media
569    /// width (`1.0` = 100 %, `0.0` = 0 %). `None` ⇒ writer omits the
570    /// entry (Table 191 default `0`).
571    pub h: Option<f32>,
572    /// `/V` — vertical translation as a fraction of the target media
573    /// height. `None` ⇒ writer omits the entry (Table 191 default `0`).
574    pub v: Option<f32>,
575}
576
577/// `/Sy` symbol selector for [`AnnotationKind::Caret`] (ISO 32000-1
578/// §12.5.6.11 Table 180). Table 180 lists two values: `P` (a new
579/// paragraph mark should be associated with the caret) and `None`
580/// (no symbol). The default is `None`.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
582pub enum CaretSymbol {
583    /// `/Sy /None` — no symbol displayed (default per Table 180).
584    /// The writer omits the entry when this variant is set so a
585    /// round-trip through the round-197 reader yields the same
586    /// "absent → None" shape.
587    #[default]
588    None,
589    /// `/Sy /P` — the paragraph symbol (¶) is associated with the
590    /// caret. Spec-defined Table 180 value.
591    Paragraph,
592}
593
594impl CaretSymbol {
595    fn as_name(self) -> Option<&'static str> {
596        match self {
597            // Default per Table 180 — omit the /Sy entry.
598            Self::None => None,
599            Self::Paragraph => Some("P"),
600        }
601    }
602}
603
604/// `/Q` quadding (justification) for [`AnnotationKind::FreeText`].
605/// Matches §12.5.6.6 Table 174 numbering.
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
607pub enum FreeTextQuadding {
608    /// 0 — left-justified (default).
609    #[default]
610    Left,
611    /// 1 — centred.
612    Center,
613    /// 2 — right-justified.
614    Right,
615}
616
617impl FreeTextQuadding {
618    fn as_int(self) -> i64 {
619        match self {
620            Self::Left => 0,
621            Self::Center => 1,
622            Self::Right => 2,
623        }
624    }
625}
626
627/// Default appearance string (`/DA`) when an annotation doesn't carry
628/// its own — Helvetica 12pt black per §12.7.3.3.
629const DEFAULT_FREETEXT_DA: &str = "/Helv 12 Tf 0 g";
630
631/// Render a [`Scene`] in pages mode + a slice of [`Annotation`]s and
632/// return the serialised PDF bytes.
633///
634/// Constraints:
635///
636/// * `scene` must be in pages mode (same contract as
637///   [`crate::write_pdf_from_scene`]).
638/// * Each annotation's `source_page_index` must be `< scene.pages.len()`.
639///
640/// Wire-level shape: every annotation becomes one indirect dict carrying
641/// `/Type /Annot /Subtype /X /Rect …` per §12.5.2 Table 164 + the
642/// matching subtype's §12.5.6.X table. Each page's `/Annots` is the
643/// array of references to its annotations.
644pub fn write_pdf_with_annotations(
645    scene: &Scene,
646    annotations: &[Annotation],
647) -> Result<Vec<u8>, PdfError> {
648    let pages = scene
649        .pages
650        .as_ref()
651        .filter(|p| !p.is_empty())
652        .ok_or_else(|| {
653            PdfError::other(
654                "write_pdf_with_annotations: scene is not in pages mode (scene.pages is None or empty)",
655            )
656        })?;
657    let n_pages = pages.len();
658
659    validate_annotations(annotations, n_pages)?;
660
661    struct Rendered<'a> {
662        frame: &'a oxideav_core::vector::VectorFrame,
663        width: f32,
664        height: f32,
665        content_bytes: Vec<u8>,
666        resources: ResourceCollector,
667    }
668    let rendered: Vec<Rendered<'_>> = pages
669        .iter()
670        .map(|page| {
671            let (content_bytes, resources) = render_frame(&page.content);
672            Rendered {
673                frame: &page.content,
674                width: page.width,
675                height: page.height,
676                content_bytes,
677                resources,
678            }
679        })
680        .collect();
681
682    let inputs: Vec<PageInput<'_>> = rendered
683        .into_iter()
684        .map(|r| PageInput {
685            width: r.width,
686            height: r.height,
687            content_bytes: r.content_bytes,
688            resources: r.resources,
689            frame: r.frame,
690        })
691        .collect();
692
693    let mut doc = Document::new();
694    let pages_build = build_pages(&mut doc, inputs);
695
696    if has_metadata(&scene.metadata) {
697        let info_id = doc.add(Object::Dict(build_info_dict(&scene.metadata)));
698        doc.info = Some(info_id);
699    }
700
701    // ---- Pass 1: allocate one id per annotation up front, so the
702    //              Popup subtype's `/Parent` indirect reference
703    //              (§12.5.6.14 Table 183) can resolve to the actual
704    //              on-wire id of its parent markup annotation.
705    let annotation_ids: Vec<ObjectId> = (0..annotations.len()).map(|_| doc.allocate_id()).collect();
706
707    // ---- Pre-pass: emit one `/Type /EmbeddedFile` stream + one
708    //                `/Type /Filespec` dict per [`AnnotationKind::FileAttachment`]
709    //                (§12.5.6.15 Table 184) so the annotation dict's `/FS`
710    //                entry can resolve to a real indirect reference. The
711    //                catalog `/Names → /EmbeddedFiles` name tree is
712    //                materialised after every filespec is in place
713    //                (§7.7.4 + §7.9.6).
714    //
715    // The same pre-pass emits one `/Type /Sound` stream per
716    // [`AnnotationKind::Sound`] (§12.5.6.16 + §13.3 Table 294) so the
717    // annotation dict's `/Sound` entry resolves to a real indirect
718    // reference. The two emit branches are unrelated wire-wise but
719    // share the pre-pass slot so a single iteration covers both.
720    let mut filespec_ids: Vec<Option<ObjectId>> = vec![None; annotations.len()];
721    let mut sound_stream_ids: Vec<Option<ObjectId>> = vec![None; annotations.len()];
722    let mut name_tree_entries: Vec<(String, ObjectId)> = Vec::new();
723    for (i, annot) in annotations.iter().enumerate() {
724        match &annot.kind {
725            AnnotationKind::FileAttachment {
726                file_name,
727                file_bytes,
728                mime_type,
729                ..
730            } => {
731                // Build a transient Attachment so we can re-use the round-33
732                // stream + filespec emitters byte-for-byte. The annotation
733                // marker (`annotation_*` fields) is unused here because this
734                // path already builds the /Subtype /FileAttachment dict
735                // itself via `build_annotation_dict`.
736                let mut attach = Attachment::new(file_name.clone(), file_bytes.clone());
737                if let Some(mime) = mime_type {
738                    attach = attach.with_mime_type(mime.clone());
739                }
740                let stream_id = emit_embedded_file_stream(&mut doc, &attach);
741                let filespec_id = emit_filespec_dict(&mut doc, &attach, stream_id);
742                filespec_ids[i] = Some(filespec_id);
743                name_tree_entries.push((file_name.clone(), filespec_id));
744            }
745            AnnotationKind::Sound {
746                sampling_rate,
747                channels,
748                bits_per_sample,
749                encoding,
750                sound_samples,
751                ..
752            } => {
753                let stream_id = emit_sound_stream(
754                    &mut doc,
755                    *sampling_rate,
756                    *channels,
757                    *bits_per_sample,
758                    *encoding,
759                    sound_samples.clone(),
760                );
761                sound_stream_ids[i] = Some(stream_id);
762            }
763            _ => {}
764        }
765    }
766    // §7.7.4 + §7.9.6 — wire the name tree onto the catalog when at
767    // least one /FileAttachment annotation contributed a filespec.
768    if !name_tree_entries.is_empty() {
769        let names_dict_id = emit_embedded_files_name_tree(&mut doc, &mut name_tree_entries);
770        let catalog = doc.object_mut(pages_build.catalog_id).ok_or_else(|| {
771            PdfError::other(
772                "write_pdf_with_annotations: catalog id missing for /Names patch (FileAttachment)",
773            )
774        })?;
775        if let Object::Dict(d) = catalog {
776            d.set("Names", Object::Reference(names_dict_id));
777        } else {
778            return Err(PdfError::other(
779                "write_pdf_with_annotations: catalog object is not a Dict",
780            ));
781        }
782    }
783
784    // ---- Pre-pass: §12.5.5 normal appearance streams. Each
785    //      geometry-determined annotation kind gets a form-XObject
786    //      appearance whose /BBox is the annotation /Rect, referenced
787    //      from the dict's /AP << /N … >> so conforming readers render
788    //      the authored appearance instead of a handler-invented one.
789    let appearance_ids: Vec<Option<ObjectId>> = annotations
790        .iter()
791        .map(|annot| {
792            build_normal_appearance(annot)
793                .map(|content| emit_appearance_stream(&mut doc, content, annot.rect))
794        })
795        .collect();
796
797    // ---- Pass 2: build each annotation dict + commit it under its
798    //              pre-allocated id, bucketing by source page so the
799    //              `/Annots` array can be patched onto each page after.
800    let mut by_page: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
801    for (i, annot) in annotations.iter().enumerate() {
802        let mut dict = build_annotation_dict(
803            annot,
804            pages_build.page_ids[annot.source_page_index],
805            &annotation_ids,
806            filespec_ids[i],
807            sound_stream_ids[i],
808        )?;
809        if let Some(ap_id) = appearance_ids[i] {
810            dict.set(
811                "AP",
812                Object::Dict(Dict::new().with("N", Object::Reference(ap_id))),
813            );
814        }
815        doc.add_object(annotation_ids[i], Object::Dict(dict));
816        by_page[annot.source_page_index].push(annotation_ids[i]);
817    }
818
819    // ---- Patch each page's /Annots array.
820    for (page_idx, annot_ids) in by_page.iter().enumerate() {
821        if annot_ids.is_empty() {
822            continue;
823        }
824        let page_id = pages_build.page_ids[page_idx];
825        let page_obj = doc.object_mut(page_id).ok_or_else(|| {
826            PdfError::other("write_pdf_with_annotations: page id missing after build_pages")
827        })?;
828        if let Object::Dict(d) = page_obj {
829            d.set(
830                "Annots",
831                Object::Array(annot_ids.iter().map(|i| Object::Reference(*i)).collect()),
832            );
833        } else {
834            return Err(PdfError::other(
835                "write_pdf_with_annotations: page object is not a Dict",
836            ));
837        }
838    }
839
840    let mut out = Vec::with_capacity(4096);
841    doc.write_to(&mut out)?;
842    Ok(out)
843}
844
845// ---------------------------------------------------------------------
846// Internal helpers.
847// ---------------------------------------------------------------------
848
849fn validate_annotations(annotations: &[Annotation], n_pages: usize) -> Result<(), PdfError> {
850    let n_annots = annotations.len();
851    for (i, a) in annotations.iter().enumerate() {
852        if a.source_page_index >= n_pages {
853            return Err(PdfError::other(format!(
854                "write_pdf_with_annotations: annotation #{i} source_page_index {} \
855                 out of range (scene has {n_pages} page(s))",
856                a.source_page_index,
857            )));
858        }
859        match &a.kind {
860            AnnotationKind::Ink { strokes } => {
861                if strokes.is_empty() {
862                    return Err(PdfError::other(format!(
863                        "write_pdf_with_annotations: annotation #{i} /Ink has no strokes",
864                    )));
865                }
866                for (j, s) in strokes.iter().enumerate() {
867                    if s.len() < 2 || s.len() % 2 != 0 {
868                        return Err(PdfError::other(format!(
869                            "write_pdf_with_annotations: annotation #{i} /Ink stroke #{j} \
870                             needs an even number of coords ≥ 2 (got {})",
871                            s.len()
872                        )));
873                    }
874                }
875            }
876            AnnotationKind::Highlight { quad_points }
877            | AnnotationKind::Underline { quad_points }
878            | AnnotationKind::Squiggly { quad_points }
879            | AnnotationKind::StrikeOut { quad_points }
880                if quad_points.is_empty() =>
881            {
882                return Err(PdfError::other(format!(
883                    "write_pdf_with_annotations: annotation #{i} text-markup \
884                     /QuadPoints array is empty",
885                )));
886            }
887            // §12.5.6.9 Table 178: /Vertices is a flat (x, y) list,
888            // so length must be even and ≥ 4 (two vertices for a
889            // degenerate single-edge polyline; closed polygons need
890            // at least three vertices ≥ 6 coords but that's a
891            // higher-level check — Adobe's own polygon flattener
892            // emits two-vertex degenerate cases for collapsed
893            // markup edits).
894            AnnotationKind::Polygon { vertices, .. }
895            | AnnotationKind::PolyLine { vertices, .. }
896                if vertices.len() < 4 || vertices.len() % 2 != 0 =>
897            {
898                return Err(PdfError::other(format!(
899                    "write_pdf_with_annotations: annotation #{i} polygon/polyline \
900                     /Vertices needs an even number of coords ≥ 4 (got {})",
901                    vertices.len()
902                )));
903            }
904            // §12.5.6.11 Table 180 — every /RD component must be ≥ 0
905            // and the inset must fit inside the outer /Rect (the
906            // top+bottom inset shall be < /Rect height, the
907            // left+right inset shall be < /Rect width).
908            AnnotationKind::Caret {
909                rect_diffs: Some(rd),
910                ..
911            } => {
912                if rd.iter().any(|v| *v < 0.0) {
913                    return Err(PdfError::other(format!(
914                        "write_pdf_with_annotations: annotation #{i} /Caret /RD \
915                         components must all be ≥ 0 (got {rd:?})",
916                    )));
917                }
918                let width = a.rect[2] - a.rect[0];
919                let height = a.rect[3] - a.rect[1];
920                if rd[0] + rd[2] >= width || rd[1] + rd[3] >= height {
921                    return Err(PdfError::other(format!(
922                        "write_pdf_with_annotations: annotation #{i} /Caret /RD \
923                         inset must fit inside /Rect (rd={rd:?}, rect={:?})",
924                        a.rect,
925                    )));
926                }
927            }
928            // §12.5.6.14 Table 183 — /Parent is normatively an
929            // indirect reference; the writer takes a 0-based index
930            // into the same annotations slice. The index must be in
931            // range and may not point at the Popup itself (a Popup
932            // can't be its own parent — that would be a self-cycle
933            // on dereference).
934            AnnotationKind::Popup {
935                parent_index: Some(idx),
936                ..
937            } => {
938                if *idx >= n_annots {
939                    return Err(PdfError::other(format!(
940                        "write_pdf_with_annotations: annotation #{i} /Popup parent_index {idx} \
941                         out of range (only {n_annots} annotation(s) supplied)",
942                    )));
943                }
944                if *idx == i {
945                    return Err(PdfError::other(format!(
946                        "write_pdf_with_annotations: annotation #{i} /Popup parent_index points \
947                         at itself; a Popup cannot be its own /Parent (§12.5.6.14)",
948                    )));
949                }
950                // The §12.5.6.14 text describes a Popup as the
951                // editing surface for a *markup* parent — Popup
952                // pointing at another Popup makes no semantic sense
953                // (no parent contents to display).
954                if matches!(annotations[*idx].kind, AnnotationKind::Popup { .. }) {
955                    return Err(PdfError::other(format!(
956                        "write_pdf_with_annotations: annotation #{i} /Popup parent_index {idx} \
957                         points at another /Popup; the parent must be a markup annotation \
958                         per §12.5.6.14",
959                    )));
960                }
961            }
962            // §12.5.6.15 Table 184 — every /FileAttachment carries a
963            // mandatory /FS filespec; an empty `file_name` would
964            // produce a filespec whose /F + /UF are zero-length text
965            // strings, which §7.11.2 forbids (a file name must
966            // identify a file). The byte buffer itself MAY be empty
967            // (a zero-byte attachment is valid per Table 45).
968            AnnotationKind::FileAttachment { file_name, .. } if file_name.is_empty() => {
969                return Err(PdfError::other(format!(
970                    "write_pdf_with_annotations: annotation #{i} /FileAttachment \
971                     file_name is empty (§7.11.2 requires a non-empty file name)",
972                )));
973            }
974            // §13.3 Table 294 — /R sampling rate is required and the
975            // §13.3 text requires it to be a positive samples-per-
976            // second count. /C and /B carry defaults (1 and 8) but
977            // values of 0 would describe a zero-channel or zero-bit
978            // stream that has no playable content. The sample buffer
979            // itself is required (§12.5.6.16 Table 185 marks /Sound
980            // mandatory and §13.3 describes the stream as containing
981            // sample values that define the sound — an empty buffer
982            // would describe a zero-second silence rather than a
983            // playable sound).
984            AnnotationKind::Sound {
985                sampling_rate,
986                channels,
987                bits_per_sample,
988                sound_samples,
989                ..
990            } => {
991                // Use `<=` (rather than `!( > 0.0)`) so the comparison
992                // covers NaN — `NaN <= 0.0` is false, but a NaN sample
993                // rate is non-finite and should still be rejected;
994                // add an explicit `is_finite` guard alongside.
995                if !sampling_rate.is_finite() || *sampling_rate <= 0.0 {
996                    return Err(PdfError::other(format!(
997                        "write_pdf_with_annotations: annotation #{i} /Sound sampling_rate \
998                         must be a positive finite value (got {sampling_rate}) — §13.3 /R is samples/sec",
999                    )));
1000                }
1001                if *channels == 0 {
1002                    return Err(PdfError::other(format!(
1003                        "write_pdf_with_annotations: annotation #{i} /Sound channels must be \
1004                         ≥ 1 (§13.3 /C is the channel count)",
1005                    )));
1006                }
1007                if *bits_per_sample == 0 {
1008                    return Err(PdfError::other(format!(
1009                        "write_pdf_with_annotations: annotation #{i} /Sound bits_per_sample \
1010                         must be ≥ 1 (§13.3 /B is bits per sample value)",
1011                    )));
1012                }
1013                if sound_samples.is_empty() {
1014                    return Err(PdfError::other(format!(
1015                        "write_pdf_with_annotations: annotation #{i} /Sound sound_samples is \
1016                         empty (§12.5.6.16 /Sound stream carries the sample data)",
1017                    )));
1018                }
1019            }
1020            // §12.5.6.22 Table 191 — every /FixedPrint sub-dict value is
1021            // optional with an explicit numeric default. The spec is
1022            // explicit that negative /H or /V values "should not be
1023            // used, since they may cause content to be drawn off the
1024            // page" — we surface that producer guidance as a hard
1025            // writer reject so a downstream PDF renderer sees only
1026            // in-range fixed-print metadata. /Matrix entries that are
1027            // non-finite would produce an undefined affine transform
1028            // (the §8.3.4 transform composition assumes finite reals),
1029            // so a NaN or infinity in any /Matrix slot is also rejected.
1030            AnnotationKind::Watermark {
1031                fixed_print: Some(fp),
1032            } => {
1033                if let Some(m) = fp.matrix {
1034                    if m.iter().any(|v| !v.is_finite()) {
1035                        return Err(PdfError::other(format!(
1036                            "write_pdf_with_annotations: annotation #{i} /Watermark \
1037                             /FixedPrint /Matrix entries must all be finite (got {m:?})",
1038                        )));
1039                    }
1040                }
1041                if let Some(h) = fp.h {
1042                    if !h.is_finite() || h < 0.0 {
1043                        return Err(PdfError::other(format!(
1044                            "write_pdf_with_annotations: annotation #{i} /Watermark \
1045                             /FixedPrint /H must be a finite non-negative number \
1046                             (got {h}) — §12.5.6.22 Table 191 negative-values warning",
1047                        )));
1048                    }
1049                }
1050                if let Some(v) = fp.v {
1051                    if !v.is_finite() || v < 0.0 {
1052                        return Err(PdfError::other(format!(
1053                            "write_pdf_with_annotations: annotation #{i} /Watermark \
1054                             /FixedPrint /V must be a finite non-negative number \
1055                             (got {v}) — §12.5.6.22 Table 191 negative-values warning",
1056                        )));
1057                    }
1058                }
1059            }
1060            // §12.5.6.20 Table 362 — /MN is a PDF Name. §7.3.5 requires
1061            // a Name to be at least one byte; an empty mark name would
1062            // not identify any taxonomy entry and would serialise as a
1063            // bare `/` token that round-trips as the absent-entry case
1064            // (silently dropping the caller's intent). Reject it.
1065            AnnotationKind::PrinterMark {
1066                mark_name: Some(name),
1067            } if name.is_empty() => {
1068                return Err(PdfError::other(format!(
1069                    "write_pdf_with_annotations: annotation #{i} /PrinterMark \
1070                     /MN mark name must be non-empty (§7.3.5 / §12.5.6.20 Table 362)",
1071                )));
1072            }
1073            _ => {}
1074        }
1075    }
1076    Ok(())
1077}
1078
1079fn rect_array(rect: [f32; 4]) -> Object {
1080    Object::Array(rect.iter().map(|v| Object::Real(*v as f64)).collect())
1081}
1082
1083/// Emit one `/Type /XObject /Subtype /Form` appearance stream
1084/// (§12.5.5 — "Each appearance stream is a form XObject") whose
1085/// `/BBox` equals the annotation `/Rect`, so the §12.5.5 placement
1086/// algorithm maps it onto the rectangle by identity and the content
1087/// operators paint directly in default-user-space coordinates.
1088fn emit_appearance_stream(doc: &mut Document, content: Vec<u8>, bbox: [f32; 4]) -> ObjectId {
1089    let dict = Dict::new()
1090        .with("Type", Object::Name("XObject".into()))
1091        .with("Subtype", Object::Name("Form".into()))
1092        .with("BBox", rect_array(bbox));
1093    doc.add(Object::Stream(crate::objects::Stream::new(dict, content)))
1094}
1095
1096/// Append the colour operator for a Table 164-shape colour array
1097/// (0 / 1 / 3 / 4 components — transparent / DeviceGray / DeviceRGB /
1098/// DeviceCMYK) to appearance-stream content. `fill` selects the
1099/// non-stroking (`g` / `rg` / `k`) vs stroking (`G` / `RG` / `K`)
1100/// operator family. Returns `false` (nothing appended) for the
1101/// zero-component "no colour; transparent" form or an arity the table
1102/// doesn't define.
1103fn push_colour_op(out: &mut String, comps: &[f32], fill: bool) -> bool {
1104    use crate::operators::format_real;
1105    let op = match (comps.len(), fill) {
1106        (1, true) => "g",
1107        (1, false) => "G",
1108        (3, true) => "rg",
1109        (3, false) => "RG",
1110        (4, true) => "k",
1111        (4, false) => "K",
1112        _ => return false,
1113    };
1114    for c in comps {
1115        out.push_str(&format_real(f64::from(*c)));
1116        out.push(' ');
1117    }
1118    out.push_str(op);
1119    out.push('\n');
1120    true
1121}
1122
1123/// Cubic-Bézier circle constant: the control-point offset that makes
1124/// four cubic segments approximate a quarter arc, `4·(√2 − 1)/3`.
1125pub(crate) const ARC_KAPPA: f32 = 0.552_284_8;
1126
1127/// §12.5.5 — build the normal-appearance content stream for an
1128/// annotation whose visual is fully determined by its dictionary
1129/// geometry. Returns `None` for kinds whose presentation is
1130/// viewer-supplied (Text note icons, Stamp artwork, Popup windows, …)
1131/// or whose effective paint is empty (no interior colour and a
1132/// zero-width / transparent border).
1133///
1134/// The content paints in default user space (the emitted form's
1135/// `/BBox` is the annotation `/Rect` with an identity `/Matrix`, so
1136/// the §12.5.5 placement is the identity map).
1137fn build_normal_appearance(annot: &Annotation) -> Option<Vec<u8>> {
1138    use crate::operators::format_real;
1139    let fr = |v: f32| format_real(f64::from(v));
1140
1141    // Stroke colour: /C per Table 164 (None ⇒ the conventional black;
1142    // an explicit empty array ⇒ transparent, i.e. no stroke).
1143    let stroke_comps: Option<&[f32]> = match &annot.colour {
1144        Some(c) if c.is_empty() => None,
1145        Some(c) => Some(c.as_slice()),
1146        None => Some(&[0.0f32; 1][..]),
1147    };
1148
1149    match &annot.kind {
1150        AnnotationKind::Square {
1151            interior_colour,
1152            line_width,
1153        }
1154        | AnnotationKind::Circle {
1155            interior_colour,
1156            line_width,
1157        } => {
1158            // §12.5.6.8 — the rectangle / ellipse is inscribed within
1159            // /Rect; §12.5.4 — the border is drawn completely inside
1160            // the annotation rectangle, hence the half-width inset.
1161            let w = line_width.unwrap_or(1.0).max(0.0);
1162            let fill_comps = interior_colour.as_deref().filter(|c| !c.is_empty());
1163            let stroking = w > 0.0 && stroke_comps.is_some();
1164            let filling = fill_comps.is_some();
1165            if !filling && !stroking {
1166                return None;
1167            }
1168            let mut ops = String::new();
1169            let mut painted_colour = false;
1170            if let Some(c) = fill_comps {
1171                painted_colour |= push_colour_op(&mut ops, c, true);
1172            }
1173            if stroking {
1174                if let Some(c) = stroke_comps {
1175                    painted_colour |= push_colour_op(&mut ops, c, false);
1176                }
1177                ops.push_str(&fr(w));
1178                ops.push_str(" w\n");
1179            }
1180            if !painted_colour {
1181                return None;
1182            }
1183            let inset = if stroking { w / 2.0 } else { 0.0 };
1184            let (x0, y0) = (annot.rect[0] + inset, annot.rect[1] + inset);
1185            let (x1, y1) = (annot.rect[2] - inset, annot.rect[3] - inset);
1186            if x1 <= x0 || y1 <= y0 {
1187                return None;
1188            }
1189            if matches!(annot.kind, AnnotationKind::Square { .. }) {
1190                ops.push_str(&format!(
1191                    "{} {} {} {} re\n",
1192                    fr(x0),
1193                    fr(y0),
1194                    fr(x1 - x0),
1195                    fr(y1 - y0)
1196                ));
1197            } else {
1198                // Ellipse inscribed in the (inset) rectangle as four
1199                // cubic quarter-arcs.
1200                let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
1201                let (rx, ry) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);
1202                let (kx, ky) = (rx * ARC_KAPPA, ry * ARC_KAPPA);
1203                ops.push_str(&format!("{} {} m\n", fr(cx + rx), fr(cy)));
1204                for (c1, c2, end) in [
1205                    ((cx + rx, cy + ky), (cx + kx, cy + ry), (cx, cy + ry)),
1206                    ((cx - kx, cy + ry), (cx - rx, cy + ky), (cx - rx, cy)),
1207                    ((cx - rx, cy - ky), (cx - kx, cy - ry), (cx, cy - ry)),
1208                    ((cx + kx, cy - ry), (cx + rx, cy - ky), (cx + rx, cy)),
1209                ] {
1210                    ops.push_str(&format!(
1211                        "{} {} {} {} {} {} c\n",
1212                        fr(c1.0),
1213                        fr(c1.1),
1214                        fr(c2.0),
1215                        fr(c2.1),
1216                        fr(end.0),
1217                        fr(end.1)
1218                    ));
1219                }
1220                ops.push_str("h\n");
1221            }
1222            ops.push_str(match (filling, stroking) {
1223                (true, true) => "B\n",
1224                (true, false) => "f\n",
1225                _ => "S\n",
1226            });
1227            Some(ops.into_bytes())
1228        }
1229        AnnotationKind::Line { endpoints, .. } => {
1230            // §12.5.6.7 — a straight line from (x1,y1) to (x2,y2)
1231            // (the /L entry; /Rect is only the bounding box). The
1232            // Table 176 line-ending glyphs (/LE) are not drawn.
1233            let c = stroke_comps?;
1234            let mut ops = String::new();
1235            if !push_colour_op(&mut ops, c, false) {
1236                return None;
1237            }
1238            ops.push_str(&fr(annotation_border_width(annot)));
1239            ops.push_str(" w\n");
1240            ops.push_str(&format!(
1241                "{} {} m\n{} {} l\nS\n",
1242                fr(endpoints[0]),
1243                fr(endpoints[1]),
1244                fr(endpoints[2]),
1245                fr(endpoints[3])
1246            ));
1247            Some(ops.into_bytes())
1248        }
1249        AnnotationKind::Ink { strokes } => {
1250            // §12.5.6.13 — each /InkList entry is one freehand stroke;
1251            // points are connected by straight lines (the spec permits
1252            // "straight lines or curves").
1253            let c = stroke_comps?;
1254            let mut ops = String::new();
1255            if !push_colour_op(&mut ops, c, false) {
1256                return None;
1257            }
1258            ops.push_str(&fr(annotation_border_width(annot)));
1259            ops.push_str(" w\n1 J 1 j\n"); // round caps + joins
1260            let mut any = false;
1261            for stroke in strokes {
1262                if stroke.len() < 4 {
1263                    continue;
1264                }
1265                any = true;
1266                ops.push_str(&format!("{} {} m\n", fr(stroke[0]), fr(stroke[1])));
1267                for xy in stroke.chunks_exact(2).skip(1) {
1268                    ops.push_str(&format!("{} {} l\n", fr(xy[0]), fr(xy[1])));
1269                }
1270            }
1271            if !any {
1272                return None;
1273            }
1274            ops.push_str("S\n");
1275            Some(ops.into_bytes())
1276        }
1277        AnnotationKind::Polygon {
1278            vertices,
1279            interior_colour,
1280            ..
1281        }
1282        | AnnotationKind::PolyLine {
1283            vertices,
1284            interior_colour,
1285            ..
1286        } => {
1287            // §12.5.6.9 — vertices connected by straight lines; a
1288            // Polygon implicitly closes (first to last vertex) and may
1289            // fill its interior with /IC, a PolyLine stays open (its
1290            // /IC colours only the Table 176 endings, which are not
1291            // drawn here).
1292            if vertices.len() < 4 {
1293                return None;
1294            }
1295            let closed = matches!(annot.kind, AnnotationKind::Polygon { .. });
1296            let fill_comps = if closed {
1297                interior_colour.as_deref().filter(|c| !c.is_empty())
1298            } else {
1299                None
1300            };
1301            let mut ops = String::new();
1302            let mut painted = false;
1303            if let Some(c) = fill_comps {
1304                painted |= push_colour_op(&mut ops, c, true);
1305            }
1306            let stroking = if let Some(c) = stroke_comps {
1307                let ok = push_colour_op(&mut ops, c, false);
1308                if ok {
1309                    ops.push_str(&fr(annotation_border_width(annot)));
1310                    ops.push_str(" w\n");
1311                }
1312                painted |= ok;
1313                ok
1314            } else {
1315                false
1316            };
1317            if !painted {
1318                return None;
1319            }
1320            ops.push_str(&format!("{} {} m\n", fr(vertices[0]), fr(vertices[1])));
1321            for xy in vertices.chunks_exact(2).skip(1) {
1322                ops.push_str(&format!("{} {} l\n", fr(xy[0]), fr(xy[1])));
1323            }
1324            if closed {
1325                ops.push_str("h\n");
1326            }
1327            ops.push_str(match (fill_comps.is_some(), stroking) {
1328                (true, true) => "B\n",
1329                (true, false) => "f\n",
1330                _ => "S\n",
1331            });
1332            Some(ops.into_bytes())
1333        }
1334        AnnotationKind::Highlight { quad_points } => {
1335            // §12.5.6.10 — one filled region per /QuadPoints quad. The
1336            // quad's axis-aligned bounding box is filled (robust to
1337            // the divergent vertex-order conventions producers emit
1338            // for Table 179's counterclockwise rule).
1339            let c = stroke_comps?;
1340            let mut ops = String::new();
1341            if !push_colour_op(&mut ops, c, true) {
1342                return None;
1343            }
1344            let mut any = false;
1345            for q in quad_points {
1346                let Some((x0, y0, x1, y1)) = quad_bbox(q) else {
1347                    continue;
1348                };
1349                any = true;
1350                ops.push_str(&format!(
1351                    "{} {} {} {} re\n",
1352                    fr(x0),
1353                    fr(y0),
1354                    fr(x1 - x0),
1355                    fr(y1 - y0)
1356                ));
1357            }
1358            if !any {
1359                return None;
1360            }
1361            ops.push_str("f\n");
1362            Some(ops.into_bytes())
1363        }
1364        AnnotationKind::Underline { quad_points }
1365        | AnnotationKind::StrikeOut { quad_points }
1366        | AnnotationKind::Squiggly { quad_points } => {
1367            // §12.5.6.10 — the spec leaves the markup geometry to the
1368            // producer; this writer's convention (documented, stable):
1369            // stroke width = 7 % of the quad height; an underline
1370            // sits 10 % above the quad bottom, a strike-out at the
1371            // vertical midpoint, and a squiggly is a sawtooth along
1372            // the bottom with amplitude 10 % of the quad height.
1373            let c = stroke_comps?;
1374            let mut ops = String::new();
1375            if !push_colour_op(&mut ops, c, false) {
1376                return None;
1377            }
1378            let mut any = false;
1379            for q in quad_points {
1380                let Some((x0, y0, x1, y1)) = quad_bbox(q) else {
1381                    continue;
1382                };
1383                let h = y1 - y0;
1384                let w = h * 0.07;
1385                any = true;
1386                ops.push_str(&fr(w.max(0.1)));
1387                ops.push_str(" w\n");
1388                match &annot.kind {
1389                    AnnotationKind::Underline { .. } => {
1390                        let y = y0 + h * 0.1;
1391                        ops.push_str(&format!(
1392                            "{} {} m\n{} {} l\nS\n",
1393                            fr(x0),
1394                            fr(y),
1395                            fr(x1),
1396                            fr(y)
1397                        ));
1398                    }
1399                    AnnotationKind::StrikeOut { .. } => {
1400                        let y = y0 + h * 0.5;
1401                        ops.push_str(&format!(
1402                            "{} {} m\n{} {} l\nS\n",
1403                            fr(x0),
1404                            fr(y),
1405                            fr(x1),
1406                            fr(y)
1407                        ));
1408                    }
1409                    _ => {
1410                        // Squiggly sawtooth: alternate between the
1411                        // quad bottom and bottom + amplitude every
1412                        // `amp` user-space units.
1413                        let amp = (h * 0.1).max(0.1);
1414                        ops.push_str(&format!("{} {} m\n", fr(x0), fr(y0)));
1415                        let mut x = x0 + amp;
1416                        let mut up = true;
1417                        while x < x1 {
1418                            let y = if up { y0 + amp } else { y0 };
1419                            ops.push_str(&format!("{} {} l\n", fr(x), fr(y)));
1420                            up = !up;
1421                            x += amp;
1422                        }
1423                        ops.push_str("S\n");
1424                    }
1425                }
1426            }
1427            if !any {
1428                return None;
1429            }
1430            Some(ops.into_bytes())
1431        }
1432        _ => None,
1433    }
1434}
1435
1436/// The axis-aligned bounding box of one Table 179 `/QuadPoints`
1437/// 8-tuple, or `None` when degenerate (zero width or height) or
1438/// non-finite.
1439fn quad_bbox(q: &[f32; 8]) -> Option<(f32, f32, f32, f32)> {
1440    let xs = [q[0], q[2], q[4], q[6]];
1441    let ys = [q[1], q[3], q[5], q[7]];
1442    let (mut x0, mut x1) = (f32::MAX, f32::MIN);
1443    let (mut y0, mut y1) = (f32::MAX, f32::MIN);
1444    for x in xs {
1445        x0 = x0.min(x);
1446        x1 = x1.max(x);
1447    }
1448    for y in ys {
1449        y0 = y0.min(y);
1450        y1 = y1.max(y);
1451    }
1452    if !(x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite())
1453        || x1 <= x0
1454        || y1 <= y0
1455    {
1456        return None;
1457    }
1458    Some((x0, y0, x1, y1))
1459}
1460
1461/// The stroke width for a line-family appearance: the `/Border` array
1462/// width component when the caller supplied one (Table 164 `[hr vr
1463/// w]`), else the §12.5.4 "neither `Border` nor `BS` present" default
1464/// of 1. A non-finite / negative width clamps to the default.
1465fn annotation_border_width(annot: &Annotation) -> f32 {
1466    match annot.border.as_ref().and_then(|b| b.get(2)).copied() {
1467        Some(w) if w.is_finite() && w > 0.0 => w,
1468        _ => 1.0,
1469    }
1470}
1471
1472fn colour_array(values: &[f32]) -> Object {
1473    Object::Array(values.iter().map(|v| Object::Real(*v as f64)).collect())
1474}
1475
1476fn border_array(values: &[f32]) -> Object {
1477    Object::Array(values.iter().map(|v| Object::Real(*v as f64)).collect())
1478}
1479
1480/// PDF "text string" form per §7.9.2.2.1 — ASCII passes through as a
1481/// literal string; non-ASCII becomes UTF-16BE-with-BOM in a hex
1482/// string. Identical to [`crate::acroform`]'s `text_string`.
1483fn text_string(s: &str) -> Object {
1484    if s.bytes().all(|b| b.is_ascii() && b != 0) {
1485        Object::LiteralString(s.as_bytes().to_vec())
1486    } else {
1487        let mut bytes = vec![0xFE, 0xFF];
1488        for cp in s.encode_utf16() {
1489            bytes.push((cp >> 8) as u8);
1490            bytes.push((cp & 0xFF) as u8);
1491        }
1492        Object::HexString(bytes)
1493    }
1494}
1495
1496fn flatten_quad_points(qp: &[[f32; 8]]) -> Object {
1497    let mut out: Vec<Object> = Vec::with_capacity(qp.len() * 8);
1498    for tuple in qp {
1499        for v in tuple {
1500            out.push(Object::Real(*v as f64));
1501        }
1502    }
1503    Object::Array(out)
1504}
1505
1506fn build_annotation_dict(
1507    annot: &Annotation,
1508    page_id: ObjectId,
1509    annotation_ids: &[ObjectId],
1510    filespec_id: Option<ObjectId>,
1511    sound_stream_id: Option<ObjectId>,
1512) -> Result<Dict, PdfError> {
1513    let mut d = Dict::new()
1514        .with("Type", Object::Name("Annot".into()))
1515        .with("Rect", rect_array(annot.rect))
1516        .with("P", Object::Reference(page_id))
1517        .with("F", Object::Integer(annot.flags.unwrap_or(4) as i64));
1518
1519    if let Some(t) = &annot.author {
1520        d.set("T", text_string(t));
1521    }
1522    if let Some(m) = &annot.modified {
1523        d.set("M", text_string(m));
1524    }
1525    if let Some(c) = &annot.colour {
1526        d.set("C", colour_array(c));
1527    }
1528    if let Some(b) = &annot.border {
1529        d.set("Border", border_array(b));
1530    } else {
1531        d.set(
1532            "Border",
1533            Object::Array(vec![
1534                Object::Integer(0),
1535                Object::Integer(0),
1536                Object::Integer(0),
1537            ]),
1538        );
1539    }
1540
1541    match &annot.kind {
1542        AnnotationKind::Text {
1543            contents,
1544            icon,
1545            open,
1546        } => {
1547            d.set("Subtype", Object::Name("Text".into()));
1548            d.set("Contents", text_string(contents));
1549            d.set(
1550                "Name",
1551                Object::Name(icon.clone().unwrap_or_else(|| "Note".into())),
1552            );
1553            d.set("Open", Object::Bool(*open));
1554        }
1555        AnnotationKind::Link { uri } => {
1556            d.set("Subtype", Object::Name("Link".into()));
1557            let action = Dict::new()
1558                .with("Type", Object::Name("Action".into()))
1559                .with("S", Object::Name("URI".into()))
1560                .with("URI", Object::LiteralString(uri.as_bytes().to_vec()));
1561            d.set("A", Object::Dict(action));
1562        }
1563        AnnotationKind::FreeText {
1564            contents,
1565            default_appearance,
1566            quadding,
1567        } => {
1568            d.set("Subtype", Object::Name("FreeText".into()));
1569            d.set("Contents", text_string(contents));
1570            let da = default_appearance.as_deref().unwrap_or(DEFAULT_FREETEXT_DA);
1571            d.set("DA", Object::LiteralString(da.as_bytes().to_vec()));
1572            d.set("Q", Object::Integer(quadding.as_int()));
1573        }
1574        AnnotationKind::Highlight { quad_points } => {
1575            d.set("Subtype", Object::Name("Highlight".into()));
1576            d.set("QuadPoints", flatten_quad_points(quad_points));
1577        }
1578        AnnotationKind::Underline { quad_points } => {
1579            d.set("Subtype", Object::Name("Underline".into()));
1580            d.set("QuadPoints", flatten_quad_points(quad_points));
1581        }
1582        AnnotationKind::Squiggly { quad_points } => {
1583            d.set("Subtype", Object::Name("Squiggly".into()));
1584            d.set("QuadPoints", flatten_quad_points(quad_points));
1585        }
1586        AnnotationKind::StrikeOut { quad_points } => {
1587            d.set("Subtype", Object::Name("StrikeOut".into()));
1588            d.set("QuadPoints", flatten_quad_points(quad_points));
1589        }
1590        AnnotationKind::Stamp { icon, contents } => {
1591            d.set("Subtype", Object::Name("Stamp".into()));
1592            d.set(
1593                "Name",
1594                Object::Name(icon.clone().unwrap_or_else(|| "Draft".into())),
1595            );
1596            if let Some(c) = contents {
1597                d.set("Contents", text_string(c));
1598            }
1599        }
1600        AnnotationKind::Square {
1601            interior_colour,
1602            line_width,
1603        } => {
1604            d.set("Subtype", Object::Name("Square".into()));
1605            if let Some(ic) = interior_colour {
1606                d.set("IC", colour_array(ic));
1607            }
1608            if let Some(w) = line_width {
1609                let bs = Dict::new()
1610                    .with("Type", Object::Name("Border".into()))
1611                    .with("W", Object::Real(*w as f64));
1612                d.set("BS", Object::Dict(bs));
1613            }
1614        }
1615        AnnotationKind::Circle {
1616            interior_colour,
1617            line_width,
1618        } => {
1619            d.set("Subtype", Object::Name("Circle".into()));
1620            if let Some(ic) = interior_colour {
1621                d.set("IC", colour_array(ic));
1622            }
1623            if let Some(w) = line_width {
1624                let bs = Dict::new()
1625                    .with("Type", Object::Name("Border".into()))
1626                    .with("W", Object::Real(*w as f64));
1627                d.set("BS", Object::Dict(bs));
1628            }
1629        }
1630        AnnotationKind::Ink { strokes } => {
1631            d.set("Subtype", Object::Name("Ink".into()));
1632            let mut inklist: Vec<Object> = Vec::with_capacity(strokes.len());
1633            for stroke in strokes {
1634                let pts: Vec<Object> = stroke.iter().map(|v| Object::Real(*v as f64)).collect();
1635                inklist.push(Object::Array(pts));
1636            }
1637            d.set("InkList", Object::Array(inklist));
1638        }
1639        AnnotationKind::Line {
1640            endpoints,
1641            line_endings,
1642            interior_colour,
1643            leader_line,
1644            leader_line_extension,
1645            leader_line_offset,
1646            cap,
1647            intent,
1648        } => {
1649            d.set("Subtype", Object::Name("Line".into()));
1650            // /L — required four-real array per Table 175.
1651            d.set(
1652                "L",
1653                Object::Array(endpoints.iter().map(|v| Object::Real(*v as f64)).collect()),
1654            );
1655            if let Some(le) = line_endings {
1656                d.set("LE", line_ending_pair(le));
1657            }
1658            if let Some(ic) = interior_colour {
1659                d.set("IC", colour_array(ic));
1660            }
1661            if let Some(ll) = leader_line {
1662                d.set("LL", Object::Real(*ll as f64));
1663            }
1664            if let Some(lle) = leader_line_extension {
1665                d.set("LLE", Object::Real(*lle as f64));
1666            }
1667            if let Some(llo) = leader_line_offset {
1668                d.set("LLO", Object::Real(*llo as f64));
1669            }
1670            // /Cap defaults to false per Table 175 — only emit when
1671            // true so a round-trip through the round-197 reader yields
1672            // an absence-equals-default shape on the inverse direction.
1673            if *cap {
1674                d.set("Cap", Object::Bool(true));
1675            }
1676            if let Some(it) = intent {
1677                d.set("IT", Object::Name(it.clone()));
1678            }
1679        }
1680        AnnotationKind::Polygon {
1681            vertices,
1682            interior_colour,
1683            intent,
1684        } => {
1685            d.set("Subtype", Object::Name("Polygon".into()));
1686            d.set(
1687                "Vertices",
1688                Object::Array(vertices.iter().map(|v| Object::Real(*v as f64)).collect()),
1689            );
1690            if let Some(ic) = interior_colour {
1691                d.set("IC", colour_array(ic));
1692            }
1693            if let Some(it) = intent {
1694                d.set("IT", Object::Name(it.clone()));
1695            }
1696        }
1697        AnnotationKind::PolyLine {
1698            vertices,
1699            line_endings,
1700            interior_colour,
1701            intent,
1702        } => {
1703            d.set("Subtype", Object::Name("PolyLine".into()));
1704            d.set(
1705                "Vertices",
1706                Object::Array(vertices.iter().map(|v| Object::Real(*v as f64)).collect()),
1707            );
1708            if let Some(le) = line_endings {
1709                d.set("LE", line_ending_pair(le));
1710            }
1711            if let Some(ic) = interior_colour {
1712                d.set("IC", colour_array(ic));
1713            }
1714            if let Some(it) = intent {
1715                d.set("IT", Object::Name(it.clone()));
1716            }
1717        }
1718        AnnotationKind::Caret { rect_diffs, symbol } => {
1719            // §12.5.6.11 Table 180.
1720            d.set("Subtype", Object::Name("Caret".into()));
1721            if let Some(rd) = rect_diffs {
1722                d.set(
1723                    "RD",
1724                    Object::Array(rd.iter().map(|v| Object::Real(*v as f64)).collect()),
1725                );
1726            }
1727            // /Sy default is /None per Table 180 ⇒ writer omits the
1728            // entry on `CaretSymbol::None` so a write-then-read cycle
1729            // through the round-197 reader yields the same
1730            // "absent → None symbol" branch.
1731            if let Some(name) = symbol.as_name() {
1732                d.set("Sy", Object::Name(name.into()));
1733            }
1734        }
1735        AnnotationKind::Popup { parent_index, open } => {
1736            // §12.5.6.14 Table 183.
1737            d.set("Subtype", Object::Name("Popup".into()));
1738            if let Some(idx) = parent_index {
1739                // Validation (see validate_annotations) guarantees idx
1740                // is in range — defensive indexing here would only
1741                // mask a future skipped-validation regression.
1742                d.set("Parent", Object::Reference(annotation_ids[*idx]));
1743            }
1744            // /Open default is false per Table 183 ⇒ writer omits the
1745            // entry on `false` so a round-trip through the round-197
1746            // reader yields the same "absent → false" branch.
1747            if *open {
1748                d.set("Open", Object::Bool(true));
1749            }
1750        }
1751        AnnotationKind::FileAttachment { icon, .. } => {
1752            // §12.5.6.15 Table 184.
1753            d.set("Subtype", Object::Name("FileAttachment".into()));
1754            // /FS — indirect reference to the filespec dict materialised
1755            // in the pre-pass above. The unwrap is safe because the
1756            // pre-pass populates `filespec_id` for every FileAttachment
1757            // before this dispatch runs; a None here would signal a
1758            // skipped pre-pass and is treated as a hard internal error
1759            // rather than silently emitting an incomplete dict.
1760            let fs = filespec_id.ok_or_else(|| {
1761                PdfError::other(
1762                    "build_annotation_dict: /FileAttachment is missing its filespec id \
1763                     (pre-pass skipped?)",
1764                )
1765            })?;
1766            d.set("FS", Object::Reference(fs));
1767            // /Name — defaults to /PushPin per Table 184.
1768            let icon_name = icon.clone().unwrap_or_else(|| "PushPin".into());
1769            d.set("Name", Object::Name(icon_name));
1770        }
1771        AnnotationKind::Sound { icon, .. } => {
1772            // §12.5.6.16 Table 185.
1773            d.set("Subtype", Object::Name("Sound".into()));
1774            // /Sound — indirect reference to the §13.3 sound stream
1775            // materialised in the pre-pass. Same defensive contract as
1776            // the FileAttachment /FS handling: a None here would mean
1777            // the pre-pass was skipped and a silent omission would
1778            // produce a malformed annotation per Table 185.
1779            let snd = sound_stream_id.ok_or_else(|| {
1780                PdfError::other(
1781                    "build_annotation_dict: /Sound is missing its stream id \
1782                     (pre-pass skipped?)",
1783                )
1784            })?;
1785            d.set("Sound", Object::Reference(snd));
1786            // /Name — defaults to /Speaker per Table 185.
1787            let icon_name = icon.clone().unwrap_or_else(|| "Speaker".into());
1788            d.set("Name", Object::Name(icon_name));
1789        }
1790        AnnotationKind::PrinterMark { mark_name } => {
1791            // §12.5.6.20 Table 362. Two `/Type`-style markers are
1792            // associated with a PrinterMark dictionary: the outer
1793            // `/Type /Annot` (§12.5.2 Table 164, already set above)
1794            // identifies the dictionary as an annotation, and the
1795            // `/Subtype /PrinterMark` set here selects the §12.5.6.20
1796            // sub-kind. Table 362 lists a *second* `/Type
1797            // /PrinterMark` slot on the dict alongside `/Subtype`; in
1798            // practice no producer emits both because the §12.5.2
1799            // `/Type /Annot` already designates the dictionary as an
1800            // annotation and `/Subtype /PrinterMark` distinguishes it
1801            // — the round-215 reader's `find_entry(annot, "MN")` lookup
1802            // is the wire contract we round-trip, so we omit the
1803            // redundant `/Type /PrinterMark` Table-362 slot and emit
1804            // only `/Subtype`.
1805            d.set("Subtype", Object::Name("PrinterMark".into()));
1806            // /MN — optional mark-name Name (`ColorBar`,
1807            // `RegistrationTarget`, `CutMark`, `PageInformation`, …).
1808            // When `None` the entry is omitted so the round-215
1809            // reader's `match find_entry(annot, "MN")` falls into the
1810            // `_ => None` branch — the absent → None contract.
1811            if let Some(name) = mark_name {
1812                d.set("MN", Object::Name(name.clone()));
1813            }
1814        }
1815        AnnotationKind::Watermark { fixed_print } => {
1816            // §12.5.6.22 Table 190.
1817            d.set("Subtype", Object::Name("Watermark".into()));
1818            // /FixedPrint — optional inline sub-dict (§12.5.6.22
1819            // Table 191). Per Table 190 the absence of the entry means
1820            // the watermark renders without media-relative geometry —
1821            // a None here therefore leaves /FixedPrint off the
1822            // annotation dict.
1823            if let Some(fp) = fixed_print {
1824                d.set("FixedPrint", Object::Dict(build_fixed_print_dict(fp)));
1825            }
1826        }
1827    }
1828
1829    Ok(d)
1830}
1831
1832/// Build the inline `/FixedPrint` sub-dict (§12.5.6.22 Table 191) for a
1833/// [`AnnotationKind::Watermark`]. Default-value omissions per Table 191:
1834/// * `/Matrix` is omitted when the caller passes `None` (Table 191
1835///   default identity); a `Some([1,0,0,1,0,0])` is treated as an
1836///   explicit identity opt-in and the entry is still emitted, since the
1837///   caller distinguished "absent" from "explicitly identity".
1838/// * `/H` is omitted when the caller passes `None` (Table 191 default
1839///   `0`); a `Some(0.0)` is emitted verbatim.
1840/// * `/V` is omitted when the caller passes `None` (Table 191 default
1841///   `0`); a `Some(0.0)` is emitted verbatim.
1842///
1843/// The omissions keep a write-then-read cycle through the round-204
1844/// `read_pdf_annotations` enumerator on the same "absent → default"
1845/// branch the reader uses for producer files that left the defaults
1846/// implicit. The `/Type /FixedPrint` marker is required per Table 191
1847/// and always emitted.
1848fn build_fixed_print_dict(fp: &FixedPrintSpec) -> Dict {
1849    let mut d = Dict::new().with("Type", Object::Name("FixedPrint".into()));
1850    if let Some(m) = fp.matrix {
1851        d.set(
1852            "Matrix",
1853            Object::Array(m.iter().map(|v| Object::Real(*v as f64)).collect()),
1854        );
1855    }
1856    if let Some(h) = fp.h {
1857        d.set("H", Object::Real(h as f64));
1858    }
1859    if let Some(v) = fp.v {
1860        d.set("V", Object::Real(v as f64));
1861    }
1862    d
1863}
1864
1865/// Emit one `/Type /Sound` stream object per §13.3 Table 294 carrying
1866/// the raw sample bytes plus the `/R` sample rate, `/C` channels,
1867/// `/B` bits per sample, and `/E` encoding metadata. Returns the
1868/// stream's indirect-reference id for the caller to wire onto the
1869/// annotation dict's `/Sound` entry.
1870///
1871/// Default-value omissions per Table 294:
1872/// * `/C` is omitted when it equals 1.
1873/// * `/B` is omitted when it equals 8.
1874/// * `/E` is omitted on [`SoundEncoding::Raw`] (the spec default).
1875///
1876/// The omissions keep a write-then-read cycle through the round-209
1877/// `read_pdf_annotations` enumerator on the same "absent → default"
1878/// branch the reader uses for producer files that left the defaults
1879/// implicit.
1880fn emit_sound_stream(
1881    doc: &mut Document,
1882    sampling_rate: f32,
1883    channels: u32,
1884    bits_per_sample: u32,
1885    encoding: SoundEncoding,
1886    sound_samples: Vec<u8>,
1887) -> ObjectId {
1888    let mut dict = Dict::new()
1889        .with("Type", Object::Name("Sound".into()))
1890        // /R is required per Table 294 — always emitted.
1891        .with("R", Object::Real(sampling_rate as f64));
1892    // /C default is 1 ⇒ omit when it equals the default.
1893    if channels != 1 {
1894        dict.set("C", Object::Integer(channels as i64));
1895    }
1896    // /B default is 8 ⇒ omit when it equals the default.
1897    if bits_per_sample != 8 {
1898        dict.set("B", Object::Integer(bits_per_sample as i64));
1899    }
1900    // /E default is /Raw ⇒ omit when it equals the default.
1901    if let Some(name) = encoding.as_name() {
1902        dict.set("E", Object::Name(name.into()));
1903    }
1904    doc.add(Object::Stream(crate::objects::Stream::new(
1905        dict,
1906        sound_samples,
1907    )))
1908}
1909
1910/// Encode a two-element line-ending name pair (§12.5.6.7 Table 176)
1911/// as a `[name1 name2]` PDF array. Used by `/Line` and `/PolyLine`.
1912fn line_ending_pair(pair: &[String; 2]) -> Object {
1913    Object::Array(vec![
1914        Object::Name(pair[0].clone()),
1915        Object::Name(pair[1].clone()),
1916    ])
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::*;
1922
1923    #[test]
1924    fn default_freetext_da_is_helvetica_12pt_black() {
1925        // §12.7.3.3.
1926        assert_eq!(DEFAULT_FREETEXT_DA, "/Helv 12 Tf 0 g");
1927    }
1928
1929    #[test]
1930    fn quadding_int_values_match_table_174() {
1931        assert_eq!(FreeTextQuadding::Left.as_int(), 0);
1932        assert_eq!(FreeTextQuadding::Center.as_int(), 1);
1933        assert_eq!(FreeTextQuadding::Right.as_int(), 2);
1934    }
1935
1936    #[test]
1937    fn rect_array_emits_four_reals() {
1938        match rect_array([1.0, 2.0, 3.0, 4.0]) {
1939            Object::Array(a) => assert_eq!(a.len(), 4),
1940            _ => panic!("expected array"),
1941        }
1942    }
1943
1944    #[test]
1945    fn flatten_quad_points_concatenates_each_tuple() {
1946        let qp = vec![[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], [10.0; 8]];
1947        match flatten_quad_points(&qp) {
1948            Object::Array(a) => assert_eq!(a.len(), 16),
1949            _ => panic!("expected array"),
1950        }
1951    }
1952
1953    #[test]
1954    fn text_string_uses_literal_for_ascii() {
1955        match text_string("hello") {
1956            Object::LiteralString(bytes) => assert_eq!(bytes, b"hello"),
1957            _ => panic!("expected literal string"),
1958        }
1959    }
1960
1961    #[test]
1962    fn text_string_uses_hex_utf16_for_non_ascii() {
1963        match text_string("héllo") {
1964            Object::HexString(bytes) => {
1965                // BOM + UTF-16BE; length must be even and >= 2.
1966                assert!(bytes.len() >= 2);
1967                assert_eq!(&bytes[..2], &[0xFE, 0xFF]);
1968            }
1969            _ => panic!("expected hex string"),
1970        }
1971    }
1972
1973    #[test]
1974    fn line_ending_pair_emits_two_name_objects() {
1975        // §12.5.6.7 Table 176.
1976        match line_ending_pair(&["OpenArrow".to_string(), "ClosedArrow".to_string()]) {
1977            Object::Array(items) => {
1978                assert_eq!(items.len(), 2);
1979                assert!(matches!(items[0], Object::Name(ref n) if n == "OpenArrow"));
1980                assert!(matches!(items[1], Object::Name(ref n) if n == "ClosedArrow"));
1981            }
1982            _ => panic!("expected array"),
1983        }
1984    }
1985
1986    #[test]
1987    fn polygon_polyline_validation_rejects_odd_vertex_count() {
1988        // §12.5.6.9 Table 178 — /Vertices is a flat (x, y) list.
1989        let annots = vec![Annotation {
1990            source_page_index: 0,
1991            rect: [0.0, 0.0, 100.0, 100.0],
1992            author: None,
1993            modified: None,
1994            flags: None,
1995            colour: None,
1996            border: None,
1997            kind: AnnotationKind::Polygon {
1998                vertices: vec![10.0, 10.0, 20.0],
1999                interior_colour: None,
2000                intent: None,
2001            },
2002        }];
2003        assert!(validate_annotations(&annots, 1).is_err());
2004    }
2005
2006    #[test]
2007    fn polygon_polyline_validation_rejects_under_two_vertices() {
2008        let annots = vec![Annotation {
2009            source_page_index: 0,
2010            rect: [0.0, 0.0, 100.0, 100.0],
2011            author: None,
2012            modified: None,
2013            flags: None,
2014            colour: None,
2015            border: None,
2016            kind: AnnotationKind::PolyLine {
2017                vertices: vec![10.0, 10.0],
2018                line_endings: None,
2019                interior_colour: None,
2020                intent: None,
2021            },
2022        }];
2023        assert!(validate_annotations(&annots, 1).is_err());
2024    }
2025
2026    #[test]
2027    fn polygon_polyline_validation_accepts_two_vertex_degenerate_case() {
2028        // Two vertices = single edge — Adobe collapsed-markup edits
2029        // routinely emit this shape, so the writer accepts it.
2030        let annots = vec![Annotation {
2031            source_page_index: 0,
2032            rect: [0.0, 0.0, 100.0, 100.0],
2033            author: None,
2034            modified: None,
2035            flags: None,
2036            colour: None,
2037            border: None,
2038            kind: AnnotationKind::PolyLine {
2039                vertices: vec![10.0, 10.0, 90.0, 90.0],
2040                line_endings: None,
2041                interior_colour: None,
2042                intent: None,
2043            },
2044        }];
2045        assert!(validate_annotations(&annots, 1).is_ok());
2046    }
2047
2048    #[test]
2049    fn caret_symbol_default_is_none_and_omits_sy_entry() {
2050        // §12.5.6.11 Table 180.
2051        assert_eq!(CaretSymbol::default(), CaretSymbol::None);
2052        assert!(CaretSymbol::None.as_name().is_none());
2053        assert_eq!(CaretSymbol::Paragraph.as_name(), Some("P"));
2054    }
2055
2056    #[test]
2057    fn caret_writer_emits_subtype_and_omits_default_fields() {
2058        // §12.5.6.11 Table 180 — bare-caret form: no /RD, no /Sy.
2059        let annot = Annotation {
2060            source_page_index: 0,
2061            rect: [10.0, 20.0, 50.0, 60.0],
2062            author: None,
2063            modified: None,
2064            flags: None,
2065            colour: None,
2066            border: None,
2067            kind: AnnotationKind::Caret {
2068                rect_diffs: None,
2069                symbol: CaretSymbol::None,
2070            },
2071        };
2072        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2073            .unwrap();
2074        let subtype = d
2075            .entries()
2076            .iter()
2077            .find(|(k, _)| k == "Subtype")
2078            .expect("/Subtype emitted");
2079        assert!(matches!(&subtype.1, Object::Name(n) if n == "Caret"));
2080        // Default per Table 180 — /Sy absent.
2081        assert!(!d.entries().iter().any(|(k, _)| k == "Sy"));
2082        // No inset supplied — /RD absent.
2083        assert!(!d.entries().iter().any(|(k, _)| k == "RD"));
2084    }
2085
2086    #[test]
2087    fn caret_writer_emits_sy_p_when_paragraph_set() {
2088        let annot = Annotation {
2089            source_page_index: 0,
2090            rect: [10.0, 20.0, 50.0, 60.0],
2091            author: None,
2092            modified: None,
2093            flags: None,
2094            colour: None,
2095            border: None,
2096            kind: AnnotationKind::Caret {
2097                rect_diffs: Some([1.0, 2.0, 3.0, 4.0]),
2098                symbol: CaretSymbol::Paragraph,
2099            },
2100        };
2101        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2102            .unwrap();
2103        let sy = d
2104            .entries()
2105            .iter()
2106            .find(|(k, _)| k == "Sy")
2107            .expect("/Sy emitted");
2108        assert!(matches!(&sy.1, Object::Name(n) if n == "P"));
2109        let rd = d
2110            .entries()
2111            .iter()
2112            .find(|(k, _)| k == "RD")
2113            .expect("/RD emitted");
2114        match &rd.1 {
2115            Object::Array(items) => assert_eq!(items.len(), 4),
2116            _ => panic!("/RD should be a four-real array"),
2117        }
2118    }
2119
2120    #[test]
2121    fn caret_validation_rejects_negative_rd() {
2122        // §12.5.6.11 Table 180 — each component shall be ≥ 0.
2123        let annots = vec![Annotation {
2124            source_page_index: 0,
2125            rect: [0.0, 0.0, 100.0, 100.0],
2126            author: None,
2127            modified: None,
2128            flags: None,
2129            colour: None,
2130            border: None,
2131            kind: AnnotationKind::Caret {
2132                rect_diffs: Some([-1.0, 0.0, 0.0, 0.0]),
2133                symbol: CaretSymbol::None,
2134            },
2135        }];
2136        let err = validate_annotations(&annots, 1).unwrap_err();
2137        let msg = format!("{err}");
2138        assert!(msg.contains("/RD"), "error mentions /RD: {msg}");
2139    }
2140
2141    #[test]
2142    fn caret_validation_rejects_inset_exceeding_rect() {
2143        // §12.5.6.11 Table 180 — left+right and top+bottom insets
2144        // must each fit inside the outer /Rect.
2145        let annots = vec![Annotation {
2146            source_page_index: 0,
2147            rect: [0.0, 0.0, 10.0, 10.0],
2148            author: None,
2149            modified: None,
2150            flags: None,
2151            colour: None,
2152            border: None,
2153            kind: AnnotationKind::Caret {
2154                rect_diffs: Some([6.0, 6.0, 6.0, 6.0]),
2155                symbol: CaretSymbol::None,
2156            },
2157        }];
2158        assert!(validate_annotations(&annots, 1).is_err());
2159    }
2160
2161    #[test]
2162    fn popup_writer_resolves_parent_index_to_pre_allocated_id() {
2163        // §12.5.6.14 Table 183 — the writer wires /Parent to the
2164        // indirect reference of the annotation at index `parent_index`
2165        // in the same slice.
2166        let annot = Annotation {
2167            source_page_index: 0,
2168            rect: [10.0, 20.0, 110.0, 60.0],
2169            author: None,
2170            modified: None,
2171            flags: None,
2172            colour: None,
2173            border: None,
2174            kind: AnnotationKind::Popup {
2175                parent_index: Some(0),
2176                open: true,
2177            },
2178        };
2179        // Simulate the pass-1 allocations: ids 41 + 42 reserved for
2180        // a two-annotation batch where this Popup is the second
2181        // (index 1) and the parent markup is at index 0 ⇒ /Parent
2182        // should resolve to id 41.
2183        let pre_allocated = vec![ObjectId::new(41), ObjectId::new(42)];
2184        let d =
2185            build_annotation_dict(&annot, ObjectId::new(3), &pre_allocated, None, None).unwrap();
2186        let parent = d
2187            .entries()
2188            .iter()
2189            .find(|(k, _)| k == "Parent")
2190            .expect("/Parent emitted");
2191        match &parent.1 {
2192            Object::Reference(id) => assert_eq!(id.number, 41),
2193            _ => panic!("/Parent should be an indirect reference"),
2194        }
2195        // /Open true ⇒ entry emitted.
2196        let open = d
2197            .entries()
2198            .iter()
2199            .find(|(k, _)| k == "Open")
2200            .expect("/Open emitted");
2201        assert!(matches!(&open.1, Object::Bool(true)));
2202    }
2203
2204    #[test]
2205    fn popup_writer_omits_open_when_default_false() {
2206        // §12.5.6.14 Table 183 — /Open default is false ⇒ writer
2207        // omits the entry on `false`.
2208        let annot = Annotation {
2209            source_page_index: 0,
2210            rect: [10.0, 20.0, 110.0, 60.0],
2211            author: None,
2212            modified: None,
2213            flags: None,
2214            colour: None,
2215            border: None,
2216            kind: AnnotationKind::Popup {
2217                parent_index: None,
2218                open: false,
2219            },
2220        };
2221        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2222            .unwrap();
2223        assert!(!d.entries().iter().any(|(k, _)| k == "Open"));
2224        // No parent supplied ⇒ /Parent absent (the tolerant-reader
2225        // contract surfaces the dict; the spec considers this
2226        // malformed but permitted on the read side).
2227        assert!(!d.entries().iter().any(|(k, _)| k == "Parent"));
2228    }
2229
2230    #[test]
2231    fn popup_validation_rejects_out_of_range_parent_index() {
2232        let annots = vec![Annotation {
2233            source_page_index: 0,
2234            rect: [0.0, 0.0, 100.0, 100.0],
2235            author: None,
2236            modified: None,
2237            flags: None,
2238            colour: None,
2239            border: None,
2240            kind: AnnotationKind::Popup {
2241                parent_index: Some(42),
2242                open: false,
2243            },
2244        }];
2245        let err = validate_annotations(&annots, 1).unwrap_err();
2246        let msg = format!("{err}");
2247        assert!(msg.contains("parent_index"), "error mentions index: {msg}");
2248    }
2249
2250    #[test]
2251    fn popup_validation_rejects_self_parent() {
2252        let annots = vec![Annotation {
2253            source_page_index: 0,
2254            rect: [0.0, 0.0, 100.0, 100.0],
2255            author: None,
2256            modified: None,
2257            flags: None,
2258            colour: None,
2259            border: None,
2260            kind: AnnotationKind::Popup {
2261                parent_index: Some(0),
2262                open: false,
2263            },
2264        }];
2265        assert!(validate_annotations(&annots, 1).is_err());
2266    }
2267
2268    #[test]
2269    fn popup_validation_rejects_popup_parent_pointing_at_popup() {
2270        // §12.5.6.14 — parent must be a markup annotation, not
2271        // another Popup.
2272        let annots = vec![
2273            Annotation {
2274                source_page_index: 0,
2275                rect: [0.0, 0.0, 100.0, 100.0],
2276                author: None,
2277                modified: None,
2278                flags: None,
2279                colour: None,
2280                border: None,
2281                kind: AnnotationKind::Popup {
2282                    parent_index: None,
2283                    open: false,
2284                },
2285            },
2286            Annotation {
2287                source_page_index: 0,
2288                rect: [0.0, 0.0, 100.0, 100.0],
2289                author: None,
2290                modified: None,
2291                flags: None,
2292                colour: None,
2293                border: None,
2294                kind: AnnotationKind::Popup {
2295                    parent_index: Some(0),
2296                    open: false,
2297                },
2298            },
2299        ];
2300        assert!(validate_annotations(&annots, 1).is_err());
2301    }
2302
2303    #[test]
2304    fn line_writer_emits_l_endpoints_and_omits_cap_when_false() {
2305        // §12.5.6.7 Table 175 — /Cap default is false; the writer
2306        // omits the entry to keep the round-trip through the
2307        // round-197 reader's "absent → false" branch tight.
2308        let annot = Annotation {
2309            source_page_index: 0,
2310            rect: [0.0, 0.0, 100.0, 100.0],
2311            author: None,
2312            modified: None,
2313            flags: None,
2314            colour: None,
2315            border: None,
2316            kind: AnnotationKind::Line {
2317                endpoints: [10.0, 20.0, 110.0, 60.0],
2318                line_endings: None,
2319                interior_colour: None,
2320                leader_line: None,
2321                leader_line_extension: None,
2322                leader_line_offset: None,
2323                cap: false,
2324                intent: None,
2325            },
2326        };
2327        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2328            .unwrap();
2329        // /L present with four reals.
2330        let l = d
2331            .entries()
2332            .iter()
2333            .find(|(k, _)| k == "L")
2334            .expect("/L emitted");
2335        match &l.1 {
2336            Object::Array(items) => assert_eq!(items.len(), 4),
2337            _ => panic!("/L should be a four-real array"),
2338        }
2339        // /Cap absent (default false per Table 175).
2340        assert!(!d.entries().iter().any(|(k, _)| k == "Cap"));
2341    }
2342
2343    // ────────────────────────────────────────────────────────────────
2344    // §12.5.6.16 + §13.3 Sound annotation (Table 185 + Table 294).
2345    // ────────────────────────────────────────────────────────────────
2346
2347    #[test]
2348    fn sound_encoding_default_is_raw_and_omits_e_entry() {
2349        // §13.3 Table 294 — /E default is /Raw.
2350        assert_eq!(SoundEncoding::default(), SoundEncoding::Raw);
2351        assert!(SoundEncoding::Raw.as_name().is_none());
2352        assert_eq!(SoundEncoding::Signed.as_name(), Some("Signed"));
2353        assert_eq!(SoundEncoding::MuLaw.as_name(), Some("muLaw"));
2354        assert_eq!(SoundEncoding::ALaw.as_name(), Some("ALaw"));
2355    }
2356
2357    #[test]
2358    fn sound_writer_emits_subtype_and_name_default_speaker() {
2359        // §12.5.6.16 Table 185 — bare Sound annotation: /Sound stream
2360        // ref (synthesised here with a dummy id since the unit test
2361        // skips the pre-pass) plus /Name defaulting to /Speaker.
2362        let annot = Annotation {
2363            source_page_index: 0,
2364            rect: [10.0, 20.0, 30.0, 40.0],
2365            author: None,
2366            modified: None,
2367            flags: None,
2368            colour: None,
2369            border: None,
2370            kind: AnnotationKind::Sound {
2371                icon: None,
2372                sampling_rate: 22050.0,
2373                channels: 1,
2374                bits_per_sample: 8,
2375                encoding: SoundEncoding::Raw,
2376                sound_samples: vec![0x80; 64],
2377            },
2378        };
2379        let d = build_annotation_dict(
2380            &annot,
2381            ObjectId::new(3),
2382            &[ObjectId::new(99)],
2383            None,
2384            Some(ObjectId::new(77)),
2385        )
2386        .unwrap();
2387        let subtype = d
2388            .entries()
2389            .iter()
2390            .find(|(k, _)| k == "Subtype")
2391            .expect("/Subtype emitted");
2392        assert!(matches!(&subtype.1, Object::Name(n) if n == "Sound"));
2393        let snd = d
2394            .entries()
2395            .iter()
2396            .find(|(k, _)| k == "Sound")
2397            .expect("/Sound emitted");
2398        assert!(matches!(&snd.1, Object::Reference(id) if id.number == 77));
2399        let name = d
2400            .entries()
2401            .iter()
2402            .find(|(k, _)| k == "Name")
2403            .expect("/Name emitted");
2404        assert!(matches!(&name.1, Object::Name(n) if n == "Speaker"));
2405    }
2406
2407    #[test]
2408    fn sound_writer_emits_custom_icon_when_supplied() {
2409        // §12.5.6.16 Table 185 — /Name /Mic for a microphone-recorded
2410        // sound annotation.
2411        let annot = Annotation {
2412            source_page_index: 0,
2413            rect: [10.0, 20.0, 30.0, 40.0],
2414            author: None,
2415            modified: None,
2416            flags: None,
2417            colour: None,
2418            border: None,
2419            kind: AnnotationKind::Sound {
2420                icon: Some("Mic".into()),
2421                sampling_rate: 8000.0,
2422                channels: 1,
2423                bits_per_sample: 8,
2424                encoding: SoundEncoding::MuLaw,
2425                sound_samples: vec![0xFF; 16],
2426            },
2427        };
2428        let d = build_annotation_dict(
2429            &annot,
2430            ObjectId::new(3),
2431            &[ObjectId::new(99)],
2432            None,
2433            Some(ObjectId::new(42)),
2434        )
2435        .unwrap();
2436        let name = d
2437            .entries()
2438            .iter()
2439            .find(|(k, _)| k == "Name")
2440            .expect("/Name emitted");
2441        assert!(matches!(&name.1, Object::Name(n) if n == "Mic"));
2442    }
2443
2444    #[test]
2445    fn sound_writer_errors_when_sound_stream_id_missing() {
2446        // Defensive guard — a pre-pass that skipped allocating the
2447        // sound stream must surface a hard error rather than emit a
2448        // dict whose /Sound entry points at nothing.
2449        let annot = Annotation {
2450            source_page_index: 0,
2451            rect: [10.0, 20.0, 30.0, 40.0],
2452            author: None,
2453            modified: None,
2454            flags: None,
2455            colour: None,
2456            border: None,
2457            kind: AnnotationKind::Sound {
2458                icon: None,
2459                sampling_rate: 8000.0,
2460                channels: 1,
2461                bits_per_sample: 8,
2462                encoding: SoundEncoding::Raw,
2463                sound_samples: vec![0; 4],
2464            },
2465        };
2466        let res = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None);
2467        assert!(res.is_err());
2468    }
2469
2470    #[test]
2471    fn sound_validation_rejects_zero_sampling_rate() {
2472        let annots = vec![Annotation {
2473            source_page_index: 0,
2474            rect: [0.0, 0.0, 100.0, 100.0],
2475            author: None,
2476            modified: None,
2477            flags: None,
2478            colour: None,
2479            border: None,
2480            kind: AnnotationKind::Sound {
2481                icon: None,
2482                sampling_rate: 0.0,
2483                channels: 1,
2484                bits_per_sample: 8,
2485                encoding: SoundEncoding::Raw,
2486                sound_samples: vec![0; 4],
2487            },
2488        }];
2489        let err = validate_annotations(&annots, 1).unwrap_err();
2490        let msg = format!("{err}");
2491        assert!(msg.contains("sampling_rate"), "error mentions rate: {msg}");
2492    }
2493
2494    #[test]
2495    fn sound_validation_rejects_zero_channels() {
2496        let annots = vec![Annotation {
2497            source_page_index: 0,
2498            rect: [0.0, 0.0, 100.0, 100.0],
2499            author: None,
2500            modified: None,
2501            flags: None,
2502            colour: None,
2503            border: None,
2504            kind: AnnotationKind::Sound {
2505                icon: None,
2506                sampling_rate: 8000.0,
2507                channels: 0,
2508                bits_per_sample: 8,
2509                encoding: SoundEncoding::Raw,
2510                sound_samples: vec![0; 4],
2511            },
2512        }];
2513        let err = validate_annotations(&annots, 1).unwrap_err();
2514        let msg = format!("{err}");
2515        assert!(msg.contains("channels"), "error mentions channels: {msg}");
2516    }
2517
2518    #[test]
2519    fn sound_validation_rejects_zero_bits_per_sample() {
2520        let annots = vec![Annotation {
2521            source_page_index: 0,
2522            rect: [0.0, 0.0, 100.0, 100.0],
2523            author: None,
2524            modified: None,
2525            flags: None,
2526            colour: None,
2527            border: None,
2528            kind: AnnotationKind::Sound {
2529                icon: None,
2530                sampling_rate: 8000.0,
2531                channels: 1,
2532                bits_per_sample: 0,
2533                encoding: SoundEncoding::Raw,
2534                sound_samples: vec![0; 4],
2535            },
2536        }];
2537        let err = validate_annotations(&annots, 1).unwrap_err();
2538        let msg = format!("{err}");
2539        assert!(
2540            msg.contains("bits_per_sample"),
2541            "error mentions bits: {msg}"
2542        );
2543    }
2544
2545    #[test]
2546    fn sound_validation_rejects_empty_sample_buffer() {
2547        let annots = vec![Annotation {
2548            source_page_index: 0,
2549            rect: [0.0, 0.0, 100.0, 100.0],
2550            author: None,
2551            modified: None,
2552            flags: None,
2553            colour: None,
2554            border: None,
2555            kind: AnnotationKind::Sound {
2556                icon: None,
2557                sampling_rate: 8000.0,
2558                channels: 1,
2559                bits_per_sample: 8,
2560                encoding: SoundEncoding::Raw,
2561                sound_samples: Vec::new(),
2562            },
2563        }];
2564        let err = validate_annotations(&annots, 1).unwrap_err();
2565        let msg = format!("{err}");
2566        assert!(
2567            msg.contains("sound_samples"),
2568            "error mentions buffer: {msg}"
2569        );
2570    }
2571
2572    #[test]
2573    fn sound_validation_rejects_negative_sampling_rate() {
2574        // §13.3 /R is samples/sec — must be positive.
2575        let annots = vec![Annotation {
2576            source_page_index: 0,
2577            rect: [0.0, 0.0, 100.0, 100.0],
2578            author: None,
2579            modified: None,
2580            flags: None,
2581            colour: None,
2582            border: None,
2583            kind: AnnotationKind::Sound {
2584                icon: None,
2585                sampling_rate: -22050.0,
2586                channels: 1,
2587                bits_per_sample: 8,
2588                encoding: SoundEncoding::Raw,
2589                sound_samples: vec![0; 4],
2590            },
2591        }];
2592        let err = validate_annotations(&annots, 1).unwrap_err();
2593        let msg = format!("{err}");
2594        assert!(msg.contains("sampling_rate"), "error mentions rate: {msg}");
2595    }
2596
2597    // ────────────────────────────────────────────────────────────────
2598    // Round 252 — §12.5.6.22 Watermark (Table 190) + §12.5.6.22
2599    // FixedPrint sub-dict (Table 191).
2600    // ────────────────────────────────────────────────────────────────
2601
2602    #[test]
2603    fn fixed_print_spec_default_is_all_absent() {
2604        // The default-constructed FixedPrintSpec has every per-field
2605        // override set to None so the writer emits only the
2606        // `/Type /FixedPrint` marker entry.
2607        let fp = FixedPrintSpec::default();
2608        assert!(fp.matrix.is_none());
2609        assert!(fp.h.is_none());
2610        assert!(fp.v.is_none());
2611        let d = build_fixed_print_dict(&fp);
2612        // Exactly one entry — `/Type /FixedPrint`.
2613        assert_eq!(d.entries().len(), 1);
2614        let (k, v) = &d.entries()[0];
2615        assert_eq!(k, "Type");
2616        assert!(matches!(v, Object::Name(n) if n == "FixedPrint"));
2617    }
2618
2619    #[test]
2620    fn watermark_writer_emits_subtype_and_omits_fixed_print_when_none() {
2621        // §12.5.6.22 Table 190 — bare Watermark annotation with no
2622        // /FixedPrint sub-dict. Per Table 190 the entry "shall be
2623        // drawn without any special consideration for the dimensions
2624        // of the target media" — surface that as the entry being
2625        // absent.
2626        let annot = Annotation {
2627            source_page_index: 0,
2628            rect: [10.0, 20.0, 30.0, 40.0],
2629            author: None,
2630            modified: None,
2631            flags: None,
2632            colour: None,
2633            border: None,
2634            kind: AnnotationKind::Watermark { fixed_print: None },
2635        };
2636        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2637            .unwrap();
2638        let subtype = d
2639            .entries()
2640            .iter()
2641            .find(|(k, _)| k == "Subtype")
2642            .expect("/Subtype emitted");
2643        assert!(matches!(&subtype.1, Object::Name(n) if n == "Watermark"));
2644        assert!(
2645            d.entries().iter().all(|(k, _)| k != "FixedPrint"),
2646            "/FixedPrint should be omitted when fixed_print is None",
2647        );
2648    }
2649
2650    #[test]
2651    fn watermark_writer_emits_fixed_print_with_overrides() {
2652        // §12.5.6.22 Table 191 — explicit /Matrix + /H + /V overrides
2653        // round-trip into a /FixedPrint sub-dict where each entry
2654        // appears verbatim.
2655        let annot = Annotation {
2656            source_page_index: 0,
2657            rect: [10.0, 20.0, 30.0, 40.0],
2658            author: None,
2659            modified: None,
2660            flags: None,
2661            colour: None,
2662            border: None,
2663            kind: AnnotationKind::Watermark {
2664                fixed_print: Some(FixedPrintSpec {
2665                    matrix: Some([2.0, 0.0, 0.0, 2.0, 36.0, 72.0]),
2666                    h: Some(0.5),
2667                    v: Some(0.25),
2668                }),
2669            },
2670        };
2671        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2672            .unwrap();
2673        let fp_obj = d
2674            .entries()
2675            .iter()
2676            .find(|(k, _)| k == "FixedPrint")
2677            .map(|(_, v)| v)
2678            .expect("/FixedPrint emitted");
2679        let Object::Dict(fp) = fp_obj else {
2680            panic!("/FixedPrint should be an inline dict, got {fp_obj:?}");
2681        };
2682        // /Type /FixedPrint marker required per Table 191.
2683        let t = fp
2684            .entries()
2685            .iter()
2686            .find(|(k, _)| k == "Type")
2687            .expect("/Type emitted");
2688        assert!(matches!(&t.1, Object::Name(n) if n == "FixedPrint"));
2689        // /Matrix six-real array.
2690        let m = fp
2691            .entries()
2692            .iter()
2693            .find(|(k, _)| k == "Matrix")
2694            .expect("/Matrix emitted");
2695        let Object::Array(items) = &m.1 else {
2696            panic!("/Matrix should be an array, got {:?}", m.1);
2697        };
2698        assert_eq!(items.len(), 6);
2699        // /H + /V emitted as reals.
2700        assert!(fp
2701            .entries()
2702            .iter()
2703            .any(|(k, v)| k == "H" && matches!(v, Object::Real(r) if (*r - 0.5).abs() < 1e-6)));
2704        assert!(fp
2705            .entries()
2706            .iter()
2707            .any(|(k, v)| k == "V" && matches!(v, Object::Real(r) if (*r - 0.25).abs() < 1e-6)));
2708    }
2709
2710    #[test]
2711    fn watermark_writer_minimum_fixed_print_emits_type_marker_only() {
2712        // §12.5.6.22 Table 191 — a Some(FixedPrintSpec::default()) is
2713        // the minimal opt-in to media-relative rendering and emits
2714        // exactly the `/Type /FixedPrint` marker (no /Matrix, /H, or
2715        // /V overrides).
2716        let annot = Annotation {
2717            source_page_index: 0,
2718            rect: [10.0, 20.0, 30.0, 40.0],
2719            author: None,
2720            modified: None,
2721            flags: None,
2722            colour: None,
2723            border: None,
2724            kind: AnnotationKind::Watermark {
2725                fixed_print: Some(FixedPrintSpec::default()),
2726            },
2727        };
2728        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2729            .unwrap();
2730        let fp_obj = d
2731            .entries()
2732            .iter()
2733            .find(|(k, _)| k == "FixedPrint")
2734            .map(|(_, v)| v)
2735            .expect("/FixedPrint emitted");
2736        let Object::Dict(fp) = fp_obj else {
2737            panic!("/FixedPrint should be an inline dict, got {fp_obj:?}");
2738        };
2739        assert_eq!(fp.entries().len(), 1);
2740        assert!(fp
2741            .entries()
2742            .iter()
2743            .all(|(k, _)| !matches!(k.as_str(), "Matrix" | "H" | "V")));
2744    }
2745
2746    #[test]
2747    fn watermark_validation_rejects_negative_h() {
2748        // Table 191 "negative values should not be used" — surface as
2749        // a writer reject.
2750        let annots = vec![Annotation {
2751            source_page_index: 0,
2752            rect: [0.0, 0.0, 100.0, 100.0],
2753            author: None,
2754            modified: None,
2755            flags: None,
2756            colour: None,
2757            border: None,
2758            kind: AnnotationKind::Watermark {
2759                fixed_print: Some(FixedPrintSpec {
2760                    matrix: None,
2761                    h: Some(-0.1),
2762                    v: None,
2763                }),
2764            },
2765        }];
2766        let err = validate_annotations(&annots, 1).unwrap_err();
2767        let msg = format!("{err}");
2768        assert!(
2769            msg.contains("/H") && msg.contains("non-negative"),
2770            "error mentions /H non-negative requirement: {msg}",
2771        );
2772    }
2773
2774    #[test]
2775    fn watermark_validation_rejects_negative_v() {
2776        let annots = vec![Annotation {
2777            source_page_index: 0,
2778            rect: [0.0, 0.0, 100.0, 100.0],
2779            author: None,
2780            modified: None,
2781            flags: None,
2782            colour: None,
2783            border: None,
2784            kind: AnnotationKind::Watermark {
2785                fixed_print: Some(FixedPrintSpec {
2786                    matrix: None,
2787                    h: None,
2788                    v: Some(-1.0),
2789                }),
2790            },
2791        }];
2792        let err = validate_annotations(&annots, 1).unwrap_err();
2793        let msg = format!("{err}");
2794        assert!(
2795            msg.contains("/V") && msg.contains("non-negative"),
2796            "error mentions /V non-negative requirement: {msg}",
2797        );
2798    }
2799
2800    #[test]
2801    fn watermark_validation_rejects_non_finite_matrix() {
2802        let annots = vec![Annotation {
2803            source_page_index: 0,
2804            rect: [0.0, 0.0, 100.0, 100.0],
2805            author: None,
2806            modified: None,
2807            flags: None,
2808            colour: None,
2809            border: None,
2810            kind: AnnotationKind::Watermark {
2811                fixed_print: Some(FixedPrintSpec {
2812                    matrix: Some([1.0, 0.0, 0.0, f32::NAN, 0.0, 0.0]),
2813                    h: None,
2814                    v: None,
2815                }),
2816            },
2817        }];
2818        let err = validate_annotations(&annots, 1).unwrap_err();
2819        let msg = format!("{err}");
2820        assert!(
2821            msg.contains("/Matrix") && msg.contains("finite"),
2822            "error mentions /Matrix finite requirement: {msg}",
2823        );
2824    }
2825
2826    #[test]
2827    fn watermark_validation_rejects_non_finite_h() {
2828        let annots = vec![Annotation {
2829            source_page_index: 0,
2830            rect: [0.0, 0.0, 100.0, 100.0],
2831            author: None,
2832            modified: None,
2833            flags: None,
2834            colour: None,
2835            border: None,
2836            kind: AnnotationKind::Watermark {
2837                fixed_print: Some(FixedPrintSpec {
2838                    matrix: None,
2839                    h: Some(f32::INFINITY),
2840                    v: None,
2841                }),
2842            },
2843        }];
2844        let err = validate_annotations(&annots, 1).unwrap_err();
2845        let msg = format!("{err}");
2846        assert!(
2847            msg.contains("/H") && msg.contains("finite"),
2848            "error mentions /H finite requirement: {msg}",
2849        );
2850    }
2851
2852    // ────────────────────────────────────────────────────────────────
2853    // Round 257 — §12.5.6.20 PrinterMark (Table 362).
2854    // ────────────────────────────────────────────────────────────────
2855
2856    fn printer_mark_annot(mark_name: Option<&str>) -> Annotation {
2857        Annotation {
2858            source_page_index: 0,
2859            rect: [10.0, 20.0, 30.0, 40.0],
2860            author: None,
2861            modified: None,
2862            flags: None,
2863            colour: None,
2864            border: None,
2865            kind: AnnotationKind::PrinterMark {
2866                mark_name: mark_name.map(str::to_string),
2867            },
2868        }
2869    }
2870
2871    #[test]
2872    fn printer_mark_writer_emits_subtype_and_omits_mn_when_none() {
2873        // §12.5.6.20 Table 362 — bare PrinterMark annotation with no
2874        // /MN entry. The round-215 reader's `match find_entry(annot,
2875        // "MN")` lookup falls into `_ => None` when /MN is absent;
2876        // emit the absent-equals-None shape.
2877        let annot = printer_mark_annot(None);
2878        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2879            .unwrap();
2880        let subtype = d
2881            .entries()
2882            .iter()
2883            .find(|(k, _)| k == "Subtype")
2884            .expect("/Subtype emitted");
2885        assert!(matches!(&subtype.1, Object::Name(n) if n == "PrinterMark"));
2886        assert!(
2887            d.entries().iter().all(|(k, _)| k != "MN"),
2888            "/MN should be omitted when mark_name is None",
2889        );
2890        // §12.5.6.20 Table 362's redundant `/Type /PrinterMark` slot
2891        // is intentionally NOT emitted — the §12.5.2 `/Type /Annot`
2892        // already designates the dictionary as an annotation, and the
2893        // /Subtype lookup is what every observed producer + the
2894        // round-215 reader rely on.
2895        let type_entries: Vec<&Object> = d
2896            .entries()
2897            .iter()
2898            .filter_map(|(k, v)| if k == "Type" { Some(v) } else { None })
2899            .collect();
2900        assert_eq!(type_entries.len(), 1, "exactly one /Type entry");
2901        assert!(matches!(type_entries[0], Object::Name(n) if n == "Annot"));
2902    }
2903
2904    #[test]
2905    fn printer_mark_writer_emits_mn_when_some() {
2906        // §12.5.6.20 Table 362 — `/MN /ColorBar` colour-bar variant.
2907        let annot = printer_mark_annot(Some("ColorBar"));
2908        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2909            .unwrap();
2910        let mn = d
2911            .entries()
2912            .iter()
2913            .find(|(k, _)| k == "MN")
2914            .expect("/MN emitted");
2915        assert!(matches!(&mn.1, Object::Name(n) if n == "ColorBar"));
2916    }
2917
2918    #[test]
2919    fn printer_mark_writer_passes_arbitrary_mark_name_through_verbatim() {
2920        // Table 362 lists no closed taxonomy — pass any caller-supplied
2921        // Name through unchanged so a colour-management tool can match
2922        // its own private mark vocabulary.
2923        let annot = printer_mark_annot(Some("MyProductionTool_CornerCalibrator"));
2924        let d = build_annotation_dict(&annot, ObjectId::new(3), &[ObjectId::new(99)], None, None)
2925            .unwrap();
2926        let mn = d
2927            .entries()
2928            .iter()
2929            .find(|(k, _)| k == "MN")
2930            .expect("/MN emitted");
2931        assert!(
2932            matches!(&mn.1, Object::Name(n) if n == "MyProductionTool_CornerCalibrator"),
2933            "/MN passes any Name through verbatim",
2934        );
2935    }
2936
2937    #[test]
2938    fn printer_mark_validation_rejects_empty_mark_name() {
2939        // §7.3.5 + §12.5.6.20 Table 362 — a /MN Name token must be at
2940        // least one byte; an empty Some("") would serialise as a bare
2941        // `/` token that round-trips as the absent-entry case.
2942        let annots = vec![printer_mark_annot(Some(""))];
2943        let err = validate_annotations(&annots, 1).unwrap_err();
2944        let msg = format!("{err}");
2945        assert!(
2946            msg.contains("/PrinterMark") && msg.contains("/MN"),
2947            "error mentions /PrinterMark /MN: {msg}",
2948        );
2949        assert!(
2950            msg.contains("non-empty"),
2951            "error mentions non-empty requirement: {msg}",
2952        );
2953    }
2954
2955    #[test]
2956    fn printer_mark_validation_accepts_none_and_non_empty_some() {
2957        // The validation guard fires only on `Some(empty)`. Both
2958        // `None` and `Some("CutMark")` pass.
2959        let annots = vec![
2960            printer_mark_annot(None),
2961            printer_mark_annot(Some("CutMark")),
2962            printer_mark_annot(Some("RegistrationTarget")),
2963            printer_mark_annot(Some("PageInformation")),
2964        ];
2965        validate_annotations(&annots, 1).expect("all four PrinterMark variants validate");
2966    }
2967}