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}