Skip to main content

zpdf_document/
annot_appearance.rs

1//! Appearance generation for markup & geometric annotations whose producer
2//! shipped no `/AP` stream (PDF 32000-1 §12.5.6).
3//!
4//! Acrobat writes an appearance stream for every annotation, but many other
5//! producers leave the visual representation implicit in the annotation's
6//! geometry properties (`/QuadPoints`, `/Vertices`, `/L`, `/InkList`, `/C`,
7//! `/IC`, …). A viewer is expected to synthesize the appearance from those.
8//! This mirrors the form-widget generator in [`crate::forms`]: it returns a
9//! [`GeneratedAppearance`] (a form XObject — `/BBox`, `/Matrix`, `/Resources`
10//! and a content byte stream) that the interpreter's annotation painter
11//! replays exactly like a real `/AP /N`, so **both render backends draw it
12//! with no backend changes**.
13//!
14//! The synthesized content is emitted directly in default user space (the same
15//! space as the annotation's `/Rect` and geometry arrays), and the `/BBox` is
16//! set equal to the `/Rect`. The painter therefore maps `/BBox` onto `/Rect`
17//! as the identity and clips to the rectangle — geometry that extends past
18//! `/Rect` (as it should not, for a conforming file) is clipped, never scaled.
19//!
20//! Supported subtypes: text markup (`Highlight` / `Underline` / `StrikeOut` /
21//! `Squiggly`, from `/QuadPoints`), geometric markup (`Square` / `Circle` /
22//! `Line` / `Polygon` / `PolyLine` / `Ink`), `FreeText`, a conservative `Link`
23//! border, `Text` note icons (a small vector glyph chosen by `/Name`), `Stamp`
24//! rubber-stamp badges (a bordered label decoded from `/Name`), a `Caret`
25//! insertion mark, and `Redact` redaction-region marks. Everything else (an
26//! existing `/AP`, `Widget`, `Popup`, a PDF 2.0 `Projection` — which has no
27//! defined default appearance — …) is left to its own appearance or the widget
28//! generator.
29
30use zpdf_core::{Matrix, PdfDict, PdfName, PdfObject, Rect};
31use zpdf_parser::PdfFile;
32
33use crate::forms::GeneratedAppearance;
34
35/// Caps bounding the synthesized content size against malformed / adversarial
36/// geometry arrays (consistent with the existing anti-hang budgets).
37const MAX_QUADS: usize = 20_000;
38const MAX_POLY_POINTS: usize = 100_000;
39const MAX_INK_PATHS: usize = 10_000;
40const MAX_SQUIGGLE_SEGMENTS: usize = 4_000;
41/// Hard ceiling on the synthesized content-stream size. Legitimate markup is a
42/// few KB; this bounds adversarial geometry (mirrors the widget generator's
43/// `MAX_VALUE_CHARS`). A generated appearance over this is dropped entirely.
44const MAX_APPEARANCE_BYTES: usize = 1 << 20; // 1 MiB
45
46/// Build a generated appearance for a markup / geometric annotation that has no
47/// `/AP`, or `None` when the subtype is unsupported or nothing should be drawn.
48/// `dict` is the annotation dictionary and `rect` its `/Rect`.
49pub fn generate_annotation_appearance(
50    file: &PdfFile,
51    dict: &PdfDict,
52    subtype: &str,
53    rect: Rect,
54) -> Option<GeneratedAppearance> {
55    let rect = rect.normalize();
56    if !(rect.width().is_finite() && rect.height().is_finite())
57        || rect.width() <= 0.0
58        || rect.height() <= 0.0
59    {
60        return None;
61    }
62
63    // FreeText draws wrapped text (plus an optional background, border and
64    // callout) and needs font resources, so it builds its own appearance
65    // directly rather than going through the shared markup/geometry wrapper
66    // below. It reuses the AcroForm text-layout engine in [`crate::forms`].
67    if subtype == "FreeText" {
68        return free_text(file, dict, rect);
69    }
70
71    // A Stamp draws a labeled badge and needs a font resource, so (like
72    // FreeText) it builds its own appearance rather than going through the
73    // shared markup/geometry wrapper below.
74    if subtype == "Stamp" {
75        return stamp(file, dict, rect);
76    }
77
78    // The whole-annotation constant opacity (/CA) becomes an ExtGState alpha.
79    let ca = read_num(file, dict, "CA").map(|v| v.clamp(0.0, 1.0));
80
81    let mut body = Vec::new();
82    let mut multiply = false;
83
84    let drew = match subtype {
85        "Highlight" => {
86            // Highlights composite onto the page with Multiply so the marked
87            // text shows through (matching Acrobat's generated appearance).
88            multiply = true;
89            highlight(file, dict, &mut body)
90        }
91        "Underline" => text_markup(file, dict, &mut body, Markup::Underline),
92        "StrikeOut" => text_markup(file, dict, &mut body, Markup::StrikeOut),
93        "Squiggly" => text_markup(file, dict, &mut body, Markup::Squiggly),
94        "Square" => square(file, dict, rect, &mut body),
95        "Circle" => circle(file, dict, rect, &mut body),
96        "Line" => line(file, dict, &mut body),
97        "Polygon" => polyline(file, dict, &mut body, true),
98        "PolyLine" => polyline(file, dict, &mut body, false),
99        "Ink" => ink(file, dict, &mut body),
100        "Link" => link(file, dict, rect, &mut body),
101        "Text" => text_icon(file, dict, rect, &mut body),
102        "Caret" => caret(file, dict, rect, &mut body),
103        "Redact" => redact(file, dict, rect, &mut body),
104        _ => false,
105    };
106    if !drew || body.is_empty() || body.len() > MAX_APPEARANCE_BYTES {
107        return None;
108    }
109
110    // Wrap in q/Q and apply the blend/opacity ExtGState if one is needed.
111    let gs = build_gs(multiply, ca);
112    let mut content = Vec::new();
113    push(&mut content, "q\n");
114    if gs.is_some() {
115        push(&mut content, "/GS0 gs\n");
116    }
117    content.extend_from_slice(&body);
118    push(&mut content, "Q\n");
119
120    Some(GeneratedAppearance {
121        bbox: rect,
122        matrix: Matrix::identity(),
123        resources: build_resources(gs),
124        content,
125    })
126}
127
128// ---------------------------------------------------------------------------
129// Subtype generators (each pushes content and reports whether it drew anything)
130// ---------------------------------------------------------------------------
131
132fn highlight(file: &PdfFile, dict: &PdfDict, out: &mut Vec<u8>) -> bool {
133    let Some(quads) = read_quadpoints(file, dict) else {
134        return false;
135    };
136    // No spec default; yellow is the universal convention for a highlight.
137    let Some(color) = markup_color(file, dict, "C", vec![1.0, 1.0, 0.0]) else {
138        return false;
139    };
140    let Some(op) = color_op(&color, false) else {
141        return false;
142    };
143    push(out, &op);
144    push(out, "\n");
145    let mut any = false;
146    // Fill the actual (possibly rotated/sheared) quadrilateral of each marked
147    // run, not its axis-aligned bounding box — so rotated text highlights along
148    // the baseline. All sub-paths are accumulated into one `f` so overlapping
149    // quads composite once (important under the /Multiply blend, below).
150    for q in &quads {
151        let Some(oq) = oriented_quad(q) else {
152            continue;
153        };
154        let c = oq.corners;
155        push(out, &format!("{} {} m\n", fmt(c[0].0), fmt(c[0].1)));
156        for p in &c[1..] {
157            push(out, &format!("{} {} l\n", fmt(p.0), fmt(p.1)));
158        }
159        push(out, "h\n");
160        any = true;
161    }
162    if any {
163        push(out, "f\n");
164    }
165    any
166}
167
168enum Markup {
169    Underline,
170    StrikeOut,
171    Squiggly,
172}
173
174fn text_markup(file: &PdfFile, dict: &PdfDict, out: &mut Vec<u8>, kind: Markup) -> bool {
175    let Some(quads) = read_quadpoints(file, dict) else {
176        return false;
177    };
178    let Some(color) = markup_color(file, dict, "C", vec![0.0]) else {
179        return false;
180    };
181    let Some(op) = color_op(&color, true) else {
182        return false;
183    };
184    push(out, &op);
185    push(out, "\n1 J 1 j\n"); // round caps / joins
186    let mut any = false;
187    // Shared squiggle-segment budget across all quads — without it, MAX_QUADS ×
188    // MAX_SQUIGGLE_SEGMENTS line ops could synthesize a multi-GB stream from one
189    // annotation (mirrors `ink`'s shared budget).
190    let mut squiggle_budget = MAX_POLY_POINTS;
191    for q in &quads {
192        // Work in the quad's own baseline frame so the mark follows rotated /
193        // skewed text. `up` runs from the baseline (bottom) edge to the top
194        // edge; its length is the run height.
195        let Some(oq) = oriented_quad(q) else {
196            continue;
197        };
198        let h = norm(oq.up);
199        if h <= 0.0 {
200            continue;
201        }
202        let lw = (h * 0.06).clamp(0.4, 4.0);
203        push(out, &format!("{} w\n", fmt(lw)));
204        // A point on the line at fraction `frac` of the height above the bottom.
205        let along = |p: (f64, f64), frac: f64| (p.0 + oq.up.0 * frac, p.1 + oq.up.1 * frac);
206        match kind {
207            Markup::Underline => emit_seg(out, along(oq.b0, 0.12), along(oq.b1, 0.12)),
208            Markup::StrikeOut => emit_seg(out, along(oq.b0, 0.45), along(oq.b1, 0.45)),
209            Markup::Squiggly => {
210                if squiggle_budget == 0 {
211                    break;
212                }
213                let amp = (h * 0.08).clamp(0.6, 2.5);
214                let cap = squiggle_budget.min(MAX_SQUIGGLE_SEGMENTS);
215                let used = squiggle(out, &oq, amp, cap);
216                squiggle_budget = squiggle_budget.saturating_sub(used);
217            }
218        }
219        any = true;
220    }
221    any
222}
223
224/// Emit a single stroked segment `a → b`.
225fn emit_seg(out: &mut Vec<u8>, a: (f64, f64), b: (f64, f64)) {
226    push(
227        out,
228        &format!(
229            "{} {} m {} {} l S\n",
230            fmt(a.0),
231            fmt(a.1),
232            fmt(b.0),
233            fmt(b.1)
234        ),
235    );
236}
237
238fn square(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
239    let bw = border_width(file, dict);
240    let fill = read_color(file, dict, "IC");
241    // Border colour /C; if neither colour is given, fall back to a black border
242    // so the annotation is at least visible.
243    let stroke = read_color(file, dict, "C").or_else(|| fill.is_none().then(|| vec![0.0]));
244    let Some(dr) = drawing_rect(file, dict, rect, bw) else {
245        return false;
246    };
247    emit_shape_setup(out, &fill, &stroke, bw);
248    push(
249        out,
250        &format!(
251            "{} {} {} {} re {}\n",
252            fmt(dr.x0),
253            fmt(dr.y0),
254            fmt(dr.width()),
255            fmt(dr.height()),
256            paint_op(fill.is_some(), stroke.is_some())
257        ),
258    );
259    true
260}
261
262fn circle(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
263    let bw = border_width(file, dict);
264    let fill = read_color(file, dict, "IC");
265    let stroke = read_color(file, dict, "C").or_else(|| fill.is_none().then(|| vec![0.0]));
266    let Some(dr) = drawing_rect(file, dict, rect, bw) else {
267        return false;
268    };
269    emit_shape_setup(out, &fill, &stroke, bw);
270    push_ellipse(out, dr);
271    push(out, paint_op(fill.is_some(), stroke.is_some()));
272    push(out, "\n");
273    true
274}
275
276fn line(file: &PdfFile, dict: &PdfDict, out: &mut Vec<u8>) -> bool {
277    let Some(l) = read_nums(file, dict, "L") else {
278        return false;
279    };
280    if l.len() != 4 || !l.iter().all(|v| v.is_finite()) {
281        return false;
282    }
283    let Some(stroke) = markup_color(file, dict, "C", vec![0.0]) else {
284        return false;
285    };
286    let Some(op) = color_op(&stroke, true) else {
287        return false;
288    };
289    let bw = border_width(file, dict).max(0.5);
290    push(out, &op);
291    push(out, "\n");
292    push(out, &format!("{} w 1 J\n", fmt(bw)));
293    let (a, b) = ((l[0], l[1]), (l[2], l[3]));
294    push(
295        out,
296        &format!(
297            "{} {} m {} {} l S\n",
298            fmt(a.0),
299            fmt(a.1),
300            fmt(b.0),
301            fmt(b.1)
302        ),
303    );
304    // Line-ending styles (/LE [start end]); a closed head is filled with the
305    // interior colour /IC when present, else left hollow (stroke only).
306    let (le_start, le_end) = read_line_endings(file, dict);
307    let ic = read_color(file, dict, "IC");
308    emit_line_ending(out, a, sub(b, a), le_start, bw, &ic);
309    emit_line_ending(out, b, sub(a, b), le_end, bw, &ic);
310    true
311}
312
313fn polyline(file: &PdfFile, dict: &PdfDict, out: &mut Vec<u8>, closed: bool) -> bool {
314    let Some(v) = read_nums(file, dict, "Vertices") else {
315        return false;
316    };
317    if v.len() < 4 || !v.iter().all(|x| x.is_finite()) {
318        return false;
319    }
320    let n = (v.len() / 2).min(MAX_POLY_POINTS);
321    // A polygon may have an interior colour; a polyline is stroke-only.
322    let fill = if closed {
323        read_color(file, dict, "IC")
324    } else {
325        None
326    };
327    let stroke = read_color(file, dict, "C").or_else(|| fill.is_none().then(|| vec![0.0]));
328    let bw = border_width(file, dict).max(0.5);
329    emit_shape_setup(out, &fill, &stroke, bw);
330    push(out, "1 J 1 j\n");
331    push(out, &format!("{} {} m\n", fmt(v[0]), fmt(v[1])));
332    for i in 1..n {
333        push(out, &format!("{} {} l\n", fmt(v[2 * i]), fmt(v[2 * i + 1])));
334    }
335    if closed {
336        push(out, "h\n");
337    }
338    push(out, paint_op(fill.is_some(), stroke.is_some()));
339    push(out, "\n");
340    // An open PolyLine carries line-ending styles at its first and last
341    // vertices (a closed Polygon has no free ends).
342    if !closed && n >= 2 {
343        let (le_start, le_end) = read_line_endings(file, dict);
344        let pt = |i: usize| (v[2 * i], v[2 * i + 1]);
345        let (p0, p1) = (pt(0), pt(1));
346        let (pl, pl_prev) = (pt(n - 1), pt(n - 2));
347        let ic = read_color(file, dict, "IC");
348        emit_line_ending(out, p0, sub(p1, p0), le_start, bw, &ic);
349        emit_line_ending(out, pl, sub(pl_prev, pl), le_end, bw, &ic);
350    }
351    true
352}
353
354fn ink(file: &PdfFile, dict: &PdfDict, out: &mut Vec<u8>) -> bool {
355    let Some(lists) = read_array(file, dict, "InkList") else {
356        return false;
357    };
358    let Some(stroke) = markup_color(file, dict, "C", vec![0.0]) else {
359        return false;
360    };
361    let Some(op) = color_op(&stroke, true) else {
362        return false;
363    };
364    let bw = border_width(file, dict).max(0.5);
365    let mut paths = String::new();
366    let mut any = false;
367    let mut budget = MAX_POLY_POINTS;
368    for path_obj in lists.iter().take(MAX_INK_PATHS) {
369        let Some(pts) = nums_of(file, path_obj) else {
370            continue;
371        };
372        let n = (pts.len() / 2).min(budget);
373        if n < 2 || !pts.iter().take(2 * n).all(|v| v.is_finite()) {
374            continue;
375        }
376        budget = budget.saturating_sub(n);
377        paths.push_str(&format!("{} {} m\n", fmt(pts[0]), fmt(pts[1])));
378        for i in 1..n {
379            paths.push_str(&format!("{} {} l\n", fmt(pts[2 * i]), fmt(pts[2 * i + 1])));
380        }
381        any = true;
382    }
383    if !any {
384        return false;
385    }
386    push(out, &op);
387    push(out, "\n");
388    push(out, &format!("{} w 1 J 1 j\n", fmt(bw)));
389    out.extend_from_slice(paths.as_bytes());
390    push(out, "S\n");
391    true
392}
393
394fn link(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
395    // Links are navigation, not marks; most viewers draw nothing. Only draw a
396    // border when the file explicitly gives a colour AND an explicit non-zero
397    // width — no width-1 default, which would box every hyperlink.
398    let Some(color) = read_color(file, dict, "C") else {
399        return false;
400    };
401    let Some(bw) = explicit_border_width(file, dict).filter(|w| *w > 0.0) else {
402        return false;
403    };
404    let Some(dr) = drawing_rect(file, dict, rect, bw) else {
405        return false;
406    };
407    let Some(op) = color_op(&color, true) else {
408        return false;
409    };
410    push(out, &op);
411    push(out, "\n");
412    push(out, &format!("{} w\n", fmt(bw)));
413    push(
414        out,
415        &format!(
416            "{} {} {} {} re S\n",
417            fmt(dr.x0),
418            fmt(dr.y0),
419            fmt(dr.width()),
420            fmt(dr.height())
421        ),
422    );
423    true
424}
425
426/// `Caret` (12.5.6.11): a filled insertion mark drawn in the `/RD`-inset sub-
427/// rectangle of `/Rect`. We draw a notched upward wedge ("‸") in `/C` (default
428/// black; a present-empty `/C []` is transparent → nothing). `/Sy` (None / P
429/// paragraph symbol) is not distinguished — the wedge serves both.
430fn caret(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
431    // A caret carries no border; inset by `/RD` only (bw = 0).
432    let Some(dr) = drawing_rect(file, dict, rect, 0.0) else {
433        return false;
434    };
435    let Some(color) = markup_color(file, dict, "C", vec![0.0]) else {
436        return false;
437    };
438    let Some(op) = color_op(&color, false) else {
439        return false;
440    };
441    let (x0, y0, w, h) = (dr.x0, dr.y0, dr.width(), dr.height());
442    let cx = x0 + w / 2.0;
443    push(out, &op);
444    push(out, "\n");
445    // bottom-left → apex (top-centre) → bottom-right → notch (centre, 30 % up).
446    push(out, &format!("{} {} m\n", fmt(x0), fmt(y0)));
447    push(out, &format!("{} {} l\n", fmt(cx), fmt(y0 + h)));
448    push(out, &format!("{} {} l\n", fmt(x0 + w), fmt(y0)));
449    push(out, &format!("{} {} l\n", fmt(cx), fmt(y0 + h * 0.30)));
450    push(out, "f\n");
451    true
452}
453
454/// `Redact` (12.5.6.23): mark the regions slated for redaction. We render the
455/// *marked* state (we never remove content): the `/QuadPoints` regions — or the
456/// whole `/Rect` if absent — filled with the interior colour `/IC` when given
457/// and outlined in `/C` (default black so an un-coloured mark stays visible).
458/// `/OverlayText` / `/RO` (the post-redaction overlay) are intentionally not
459/// rendered, since the underlying content has not actually been removed.
460fn redact(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
461    let bw = border_width(file, dict).max(0.5);
462    let fill = read_color(file, dict, "IC");
463    // Outline `/C`: a present-empty `/C []` is spec-transparent (§12.5.6.2) → no
464    // outline; an absent `/C` falls back to black only when there is no interior
465    // fill, so an un-coloured mark stays visible. (Mirrors `markup_color`, which
466    // Caret uses — keeps the two new generators consistent.)
467    let stroke = match dict.get("C") {
468        Some(_) => read_color(file, dict, "C"),
469        None => fill.is_none().then(|| vec![0.0]),
470    };
471    if fill.is_none() && stroke.is_none() {
472        return false; // explicitly transparent mark — nothing to draw
473    }
474    // The stroke is centred on the path, so inset the region by half the border
475    // when there is an outline — otherwise the outer half straddles the
476    // /Rect-equal /BBox the painter clips to and the outline renders too thin.
477    // (The /Rect fallback gets the same inset from `drawing_rect`.) A fill-only
478    // mark is not inset, so it covers the whole region.
479    let inset = if stroke.is_some() { bw / 2.0 } else { 0.0 };
480
481    // Build the region path(s) first so colour setup is emitted only when there
482    // is something to paint.
483    let mut paths = String::new();
484    if let Some(quads) = read_quadpoints(file, dict) {
485        for q in quads.iter() {
486            let Some(oq) = oriented_quad(q) else {
487                continue;
488            };
489            let c = inset_convex_quad(oq.corners, inset);
490            paths.push_str(&format!("{} {} m\n", fmt(c[0].0), fmt(c[0].1)));
491            for p in &c[1..] {
492                paths.push_str(&format!("{} {} l\n", fmt(p.0), fmt(p.1)));
493            }
494            paths.push_str("h\n");
495        }
496    } else if let Some(dr) = drawing_rect(file, dict, rect, inset * 2.0) {
497        paths.push_str(&format!(
498            "{} {} {} {} re\n",
499            fmt(dr.x0),
500            fmt(dr.y0),
501            fmt(dr.width()),
502            fmt(dr.height())
503        ));
504    }
505    if paths.is_empty() {
506        return false;
507    }
508    emit_shape_setup(out, &fill, &stroke, bw);
509    out.extend_from_slice(paths.as_bytes());
510    push(out, paint_op(fill.is_some(), stroke.is_some()));
511    push(out, "\n");
512    true
513}
514
515/// Inset a convex, counter-clockwise quad toward its interior by `d` (a miter
516/// offset at each vertex), so a stroke of width `2·d` centred on the inset edges
517/// stays within the original quad — used to keep a `Redact` outline inside the
518/// `/Rect`-equal `/BBox` the painter clips to. `d` is clamped to 40 % of the
519/// shortest edge so a wide border cannot invert a small quad; a degenerate
520/// vertex (near-straight edge or reflex angle) keeps its original corner.
521fn inset_convex_quad(c: [(f64, f64); 4], d: f64) -> [(f64, f64); 4] {
522    let min_edge = (0..4)
523        .map(|i| norm(sub(c[(i + 1) % 4], c[i])))
524        .fold(f64::INFINITY, f64::min);
525    let d = d.min(0.4 * min_edge);
526    if d <= 0.0 || !d.is_finite() {
527        return c;
528    }
529    // Interior (left, for CCW) unit normal of the edge a→b.
530    let left_normal = |a: (f64, f64), b: (f64, f64)| {
531        let (dx, dy) = (b.0 - a.0, b.1 - a.1);
532        let len = (dx * dx + dy * dy).sqrt();
533        (len > 0.0).then(|| (-dy / len, dx / len))
534    };
535    let mut out = c;
536    for i in 0..4 {
537        let (prev, cur, next) = (c[(i + 3) % 4], c[i], c[(i + 1) % 4]);
538        let (Some(n1), Some(n2)) = (left_normal(prev, cur), left_normal(cur, next)) else {
539            continue;
540        };
541        let denom = 1.0 + (n1.0 * n2.0 + n1.1 * n2.1);
542        if denom.abs() < 1e-6 {
543            continue; // near-straight / reflex vertex: leave it
544        }
545        let off = (
546            cur.0 + d * (n1.0 + n2.0) / denom,
547            cur.1 + d * (n1.1 + n2.1) / denom,
548        );
549        if off.0.is_finite() && off.1.is_finite() {
550            out[i] = off;
551        }
552    }
553    out
554}
555
556// ---------------------------------------------------------------------------
557// FreeText (12.5.6.6) — wrapped text drawn directly on the page
558// ---------------------------------------------------------------------------
559
560/// Build a generated appearance for a `FreeText` annotation with no `/AP`:
561/// `/Contents` laid out per `/DA` (font / size / colour) and `/Q`, an optional
562/// `/C` background, an optional border, and an optional `/CL` callout line with
563/// an `/LE` ending. Text layout reuses the AcroForm engine in [`crate::forms`].
564fn free_text(file: &PdfFile, dict: &PdfDict, rect: Rect) -> Option<GeneratedAppearance> {
565    let (w, h) = (rect.width(), rect.height());
566    if w <= 1.0 || h <= 1.0 {
567        return None;
568    }
569
570    // /RD = [left top right bottom] insets between /Rect and the text region.
571    let (il, it, ir, ib) = match read_nums(file, dict, "RD") {
572        Some(rd) if rd.len() == 4 && rd.iter().all(|v| v.is_finite() && *v >= 0.0) => {
573            (rd[0], rd[1], rd[2], rd[3])
574        }
575        _ => (0.0, 0.0, 0.0, 0.0),
576    };
577
578    let background = read_color(file, dict, "C"); // FreeText /C = background colour
579    let border = explicit_border_width(file, dict).filter(|w| *w > 0.0);
580    // Cap pathological /Contents lengths before they reach the word-wrapper
581    // (shared with the widget generator).
582    let contents = read_text_string(file, dict, "Contents").map(|s| {
583        s.chars()
584            .take(crate::forms::MAX_APPEARANCE_TEXT_CHARS)
585            .collect::<String>()
586    });
587    let has_text = contents
588        .as_deref()
589        .map(|s| !s.trim().is_empty())
590        .unwrap_or(false);
591    let callout = read_nums(file, dict, "CL")
592        .filter(|c| (c.len() == 4 || c.len() == 6) && c.iter().all(|v| v.is_finite()));
593
594    if !has_text && background.is_none() && border.is_none() && callout.is_none() {
595        return None;
596    }
597
598    // Font / DA resolution (reusing the AcroForm text engine).
599    let da = crate::forms::parse_da(&read_text_string(file, dict, "DA").unwrap_or_default());
600    let font_res_name = da
601        .font
602        .as_deref()
603        .filter(|n| crate::forms::is_safe_resource_name(n))
604        .unwrap_or("Helv")
605        .to_string();
606    let dr_fonts = read_dr_fonts(file, dict);
607    let base_font = crate::forms::resolve_base_font(dr_fonts.as_ref(), &font_res_name);
608
609    let ca = read_num(file, dict, "CA").map(|v| v.clamp(0.0, 1.0));
610    let gs = build_gs(false, ca);
611
612    let mut content = Vec::new();
613    push(&mut content, "q\n");
614    if gs.is_some() {
615        push(&mut content, "/GS0 gs\n");
616    }
617
618    // Callout line first, so the text box paints over its tail. Drawn in user
619    // space; for a conforming file /Rect encloses the callout (the painter
620    // clips the form to /Rect either way).
621    if let Some(cl) = &callout {
622        let (le_start, _) = read_line_endings(file, dict);
623        let lw = border.unwrap_or(1.0).max(0.5);
624        push(&mut content, "0 G\n");
625        push(&mut content, &format!("{} w 1 J 1 j\n", fmt(lw)));
626        push(&mut content, &format!("{} {} m\n", fmt(cl[0]), fmt(cl[1])));
627        let mut k = 2;
628        while k + 1 < cl.len() {
629            push(
630                &mut content,
631                &format!("{} {} l\n", fmt(cl[k]), fmt(cl[k + 1])),
632            );
633            k += 2;
634        }
635        push(&mut content, "S\n");
636        let (p0, p1) = ((cl[0], cl[1]), (cl[2], cl[3]));
637        emit_line_ending(&mut content, p0, sub(p1, p0), le_start, lw, &None);
638    }
639
640    // Background fill over the whole rect (user space).
641    if let Some(bg) = &background {
642        if let Some(opc) = color_op(bg, false) {
643            push(&mut content, &opc);
644            push(&mut content, "\n");
645            push(
646                &mut content,
647                &format!(
648                    "{} {} {} {} re f\n",
649                    fmt(rect.x0),
650                    fmt(rect.y0),
651                    fmt(w),
652                    fmt(h)
653                ),
654            );
655        }
656    }
657
658    // Border: a black stroke inset by half its width (no dedicated colour field
659    // exists for FreeText; black matches viewer convention).
660    if let Some(bw) = border {
661        let half = bw / 2.0;
662        let (bx0, by0) = (rect.x0 + half, rect.y0 + half);
663        let (bx1, by1) = (rect.x1 - half, rect.y1 - half);
664        if bx1 - bx0 > 0.0 && by1 - by0 > 0.0 {
665            push(&mut content, "0 G\n");
666            push(&mut content, &format!("{} w\n", fmt(bw)));
667            push(
668                &mut content,
669                &format!(
670                    "{} {} {} {} re S\n",
671                    fmt(bx0),
672                    fmt(by0),
673                    fmt(bx1 - bx0),
674                    fmt(by1 - by0)
675                ),
676            );
677        }
678    }
679
680    // Text, laid out in a local frame translated to the text region's
681    // lower-left corner (so the reused layout works in box-local coordinates),
682    // clipped to that region.
683    if has_text {
684        let text = contents.unwrap_or_default();
685        let q = read_num(file, dict, "Q").map(|v| v as i64).unwrap_or(0);
686        let iw = (w - il - ir).max(1.0);
687        let ih = (h - it - ib).max(1.0);
688        const PAD: f64 = 2.0;
689        push(&mut content, "q\n");
690        push(
691            &mut content,
692            &format!("1 0 0 1 {} {} cm\n", fmt(rect.x0 + il), fmt(rect.y0 + ib)),
693        );
694        push(
695            &mut content,
696            &format!("0 0 {} {} re W n\n", fmt(iw), fmt(ih)),
697        );
698        push(&mut content, "BT\n");
699        crate::forms::multiline_layout(
700            &mut content,
701            &text,
702            &da,
703            &base_font,
704            &font_res_name,
705            iw,
706            ih,
707            PAD,
708            q,
709        );
710        push(&mut content, "ET\n");
711        push(&mut content, "Q\n");
712    }
713    push(&mut content, "Q\n");
714
715    if content.len() > MAX_APPEARANCE_BYTES {
716        return None;
717    }
718
719    // Resources: a /Font dict (when text is drawn) plus the optional GS0.
720    let mut resources = if has_text {
721        crate::forms::build_resources(dr_fonts.as_ref(), &font_res_name)
722    } else {
723        PdfDict::new()
724    };
725    if let Some(gs) = gs {
726        let mut egs = PdfDict::new();
727        egs.insert(PdfName::new("GS0"), PdfObject::Dict(gs));
728        resources.insert(PdfName::new("ExtGState"), PdfObject::Dict(egs));
729    }
730
731    Some(GeneratedAppearance {
732        bbox: rect,
733        matrix: Matrix::identity(),
734        resources,
735        content,
736    })
737}
738
739/// A PDF text string (`/Contents`, `/DA`), resolving one level of indirection.
740fn read_text_string(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
741    let obj = match dict.get(key)? {
742        PdfObject::Ref(r) => file.resolve(*r).ok()?,
743        other => other.clone(),
744    };
745    match obj {
746        PdfObject::String(s) => Some(crate::forms::pdf_string_to_unicode(s.as_bytes())),
747        _ => None,
748    }
749}
750
751/// The annotation's `/DR /Font` dictionary, if any.
752fn read_dr_fonts(file: &PdfFile, dict: &PdfDict) -> Option<PdfDict> {
753    let dr = read_subdict(file, dict, "DR")?;
754    match dr.get("Font")? {
755        PdfObject::Dict(d) => Some(d.clone()),
756        PdfObject::Ref(r) => file.resolve(*r).ok()?.as_dict().ok().cloned(),
757        _ => None,
758    }
759}
760
761// ---------------------------------------------------------------------------
762// Text annotation icons (§12.5.6.4) — a small vector glyph chosen by /Name
763// ---------------------------------------------------------------------------
764
765/// Draw a `Text` (note) annotation's icon. The producer's `/Name` selects the
766/// glyph (defaulting to the dog-eared note); `/C` tints it. The icon is a
767/// centred square within `/Rect`, so it stays recognizable for whatever rect
768/// the file gave — a conforming Text annotation's rect is already a small,
769/// roughly square anchor for a fixed-size icon.
770fn text_icon(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
771    let Some(b) = icon_box(rect) else {
772        return false;
773    };
774    let c = read_color(file, dict, "C");
775    let name = read_name(file, dict, "Name").unwrap_or_else(|| "Note".to_string());
776    match name.as_str() {
777        "Help" => help_icon(out, b, &c),
778        "Insert" => insert_icon(out, b, &c),
779        "Key" => key_icon(out, b, &c),
780        "Check" | "Checkmark" => check_icon(out, b, &c),
781        "Cross" => cross_icon(out, b, &c),
782        // Note (the spec default), Comment, Paragraph, NewParagraph and any
783        // unknown name fall back to the dog-eared note — a universally legible
784        // "there is a comment here" marker.
785        _ => note_icon(out, b, &c),
786    }
787    true
788}
789
790/// The centred square drawing area for a text icon: side = `min(width, height)`
791/// of `/Rect`, inset by a 10% margin. `None` when the rect is too small to
792/// carry a recognizable glyph.
793fn icon_box(rect: Rect) -> Option<Rect> {
794    let side = rect.width().min(rect.height());
795    if side <= 3.0 {
796        return None;
797    }
798    let (cx, cy) = ((rect.x0 + rect.x1) / 2.0, (rect.y0 + rect.y1) / 2.0);
799    let r = side / 2.0 * 0.90;
800    Some(Rect::new(cx - r, cy - r, cx + r, cy + r))
801}
802
803/// A stroke width for an icon of side `s` at fraction `frac`, kept visible but
804/// proportionate.
805fn icon_lw(s: f64, frac: f64) -> f64 {
806    (s * frac).clamp(0.3, 6.0)
807}
808
809/// Map a unit-square coordinate (`u` rightward, `v` upward, both in `[0,1]`)
810/// into the icon's square box.
811fn at(b: Rect, u: f64, v: f64) -> (f64, f64) {
812    (b.x0 + u * b.width(), b.y0 + v * b.height())
813}
814
815/// Emit a polyline through unit-square points (`m` then `l`s, optionally `h`).
816fn poly(out: &mut Vec<u8>, b: Rect, pts: &[(f64, f64)], close: bool) {
817    let Some((&first, rest)) = pts.split_first() else {
818        return;
819    };
820    let (x, y) = at(b, first.0, first.1);
821    push(out, &format!("{} {} m\n", fmt(x), fmt(y)));
822    for &(u, v) in rest {
823        let (x, y) = at(b, u, v);
824        push(out, &format!("{} {} l\n", fmt(x), fmt(y)));
825    }
826    if close {
827        push(out, "h\n");
828    }
829}
830
831/// The dog-eared note: filled paper (`/C`, default note yellow) with a dark
832/// outline, a folded corner, and three lines of "text".
833fn note_icon(out: &mut Vec<u8>, b: Rect, c: &Option<Vec<f64>>) {
834    let s = b.width();
835    let lw = icon_lw(s, 0.035);
836    let paper = c.clone().unwrap_or_else(|| vec![1.0, 0.93, 0.40]);
837    let ink = vec![0.25];
838    // Page body, the top-right corner cut away for the fold.
839    emit_shape_setup(out, &Some(paper), &Some(ink.clone()), lw);
840    poly(
841        out,
842        b,
843        &[
844            (0.15, 0.12),
845            (0.85, 0.12),
846            (0.85, 0.64),
847            (0.64, 0.85),
848            (0.15, 0.85),
849        ],
850        true,
851    );
852    push(out, "B\n");
853    // The folded-over corner, a lighter triangle.
854    emit_shape_setup(out, &Some(vec![0.80]), &Some(ink.clone()), lw);
855    poly(out, b, &[(0.64, 0.85), (0.64, 0.64), (0.85, 0.64)], true);
856    push(out, "B\n");
857    // Three lines of "text" (the last one short).
858    if let Some(op) = color_op(&ink, true) {
859        push(out, &op);
860        push(out, "\n");
861    }
862    push(out, &format!("{} w 1 J\n", fmt(lw)));
863    for (y, x1) in [(0.62, 0.73), (0.50, 0.73), (0.38, 0.55)] {
864        emit_seg(out, at(b, 0.27, y), at(b, x1, y));
865    }
866}
867
868/// A circle enclosing a question mark.
869fn help_icon(out: &mut Vec<u8>, b: Rect, c: &Option<Vec<f64>>) {
870    let s = b.width();
871    let ink = c.clone().unwrap_or_else(|| vec![0.0]);
872    let p = |u: f64, v: f64| {
873        let (x, y) = at(b, u, v);
874        format!("{} {}", fmt(x), fmt(y))
875    };
876    if let Some(op) = color_op(&ink, true) {
877        push(out, &op);
878        push(out, "\n");
879    }
880    push(out, &format!("{} w 1 J 1 j\n", fmt(icon_lw(s, 0.06))));
881    let (c0, c1) = (at(b, 0.10, 0.10), at(b, 0.90, 0.90));
882    push_ellipse(out, Rect::new(c0.0, c0.1, c1.0, c1.1));
883    push(out, "S\n");
884    // The hook + stem of the "?", stroked.
885    push(out, &format!("{} w\n", fmt(icon_lw(s, 0.075))));
886    push(out, &format!("{} m\n", p(0.35, 0.58)));
887    push(
888        out,
889        &format!("{} {} {} c\n", p(0.34, 0.78), p(0.66, 0.78), p(0.63, 0.56)),
890    );
891    push(
892        out,
893        &format!("{} {} {} c\n", p(0.61, 0.47), p(0.50, 0.50), p(0.50, 0.40)),
894    );
895    push(out, "S\n");
896    // The dot beneath it, filled.
897    if let Some(op) = color_op(&ink, false) {
898        push(out, &op);
899        push(out, "\n");
900    }
901    let (d0, d1) = (at(b, 0.455, 0.22), at(b, 0.545, 0.31));
902    push_ellipse(out, Rect::new(d0.0, d0.1, d1.0, d1.1));
903    push(out, "f\n");
904}
905
906/// An upward insertion caret (filled triangle).
907fn insert_icon(out: &mut Vec<u8>, b: Rect, c: &Option<Vec<f64>>) {
908    let ink = c.clone().unwrap_or_else(|| vec![0.0]);
909    if let Some(op) = color_op(&ink, false) {
910        push(out, &op);
911        push(out, "\n");
912    }
913    poly(out, b, &[(0.5, 0.86), (0.80, 0.24), (0.20, 0.24)], true);
914    push(out, "f\n");
915}
916
917/// A key: a stroked ring head, a diagonal stem and two teeth.
918fn key_icon(out: &mut Vec<u8>, b: Rect, c: &Option<Vec<f64>>) {
919    let s = b.width();
920    let ink = c.clone().unwrap_or_else(|| vec![0.0]);
921    if let Some(op) = color_op(&ink, true) {
922        push(out, &op);
923        push(out, "\n");
924    }
925    push(out, &format!("{} w 1 J 1 j\n", fmt(icon_lw(s, 0.085))));
926    let (r0, r1) = (at(b, 0.14, 0.50), at(b, 0.50, 0.86));
927    push_ellipse(out, Rect::new(r0.0, r0.1, r1.0, r1.1));
928    push(out, "S\n");
929    emit_seg(out, at(b, 0.44, 0.58), at(b, 0.84, 0.20)); // stem
930    emit_seg(out, at(b, 0.74, 0.30), at(b, 0.66, 0.22)); // teeth
931    emit_seg(out, at(b, 0.84, 0.20), at(b, 0.76, 0.12));
932}
933
934/// A check mark (two-segment stroke).
935fn check_icon(out: &mut Vec<u8>, b: Rect, c: &Option<Vec<f64>>) {
936    let s = b.width();
937    let ink = c.clone().unwrap_or_else(|| vec![0.0]);
938    if let Some(op) = color_op(&ink, true) {
939        push(out, &op);
940        push(out, "\n");
941    }
942    push(out, &format!("{} w 1 J 1 j\n", fmt(icon_lw(s, 0.11))));
943    let a = at(b, 0.20, 0.50);
944    let m = at(b, 0.42, 0.26);
945    let e = at(b, 0.82, 0.74);
946    push(
947        out,
948        &format!(
949            "{} {} m {} {} l {} {} l S\n",
950            fmt(a.0),
951            fmt(a.1),
952            fmt(m.0),
953            fmt(m.1),
954            fmt(e.0),
955            fmt(e.1)
956        ),
957    );
958}
959
960/// An "X" (two crossed strokes).
961fn cross_icon(out: &mut Vec<u8>, b: Rect, c: &Option<Vec<f64>>) {
962    let s = b.width();
963    let ink = c.clone().unwrap_or_else(|| vec![0.0]);
964    if let Some(op) = color_op(&ink, true) {
965        push(out, &op);
966        push(out, "\n");
967    }
968    push(out, &format!("{} w 1 J\n", fmt(icon_lw(s, 0.11))));
969    emit_seg(out, at(b, 0.24, 0.24), at(b, 0.76, 0.76));
970    emit_seg(out, at(b, 0.24, 0.76), at(b, 0.76, 0.24));
971}
972
973// ---------------------------------------------------------------------------
974// Stamp annotations (§12.5.6.12) — a labeled rubber-stamp badge from /Name
975// ---------------------------------------------------------------------------
976
977/// Build a generated appearance for a `Stamp` annotation with no `/AP`: a
978/// rounded-rectangle border around the label that `/Name` decodes to
979/// (`NotApproved` → "NOT APPROVED"; the spec default is `Draft`). The colour is
980/// `/C`, else a per-name convention (green for affirmative, blue for neutral,
981/// red for cautionary). The label is set in Helvetica-Bold, centred and sized
982/// to fill the badge. Like a real rubber stamp, the interior is left unfilled
983/// so the page shows through.
984fn stamp(file: &PdfFile, dict: &PdfDict, rect: Rect) -> Option<GeneratedAppearance> {
985    let (w, h) = (rect.width(), rect.height());
986    if w <= 6.0 || h <= 6.0 {
987        return None;
988    }
989    let name = read_name(file, dict, "Name").unwrap_or_else(|| "Draft".to_string());
990    let label = stamp_label(&name);
991    if label.is_empty() {
992        return None;
993    }
994    let colour = read_color(file, dict, "C").unwrap_or_else(|| stamp_colour(&name));
995
996    let inset = (w.min(h) * 0.08).clamp(2.0, 10.0);
997    let badge = Rect::new(
998        rect.x0 + inset,
999        rect.y0 + inset,
1000        rect.x1 - inset,
1001        rect.y1 - inset,
1002    );
1003    let (bw, bh) = (badge.x1 - badge.x0, badge.y1 - badge.y0);
1004    if bw <= 1.0 || bh <= 1.0 {
1005        return None;
1006    }
1007    let border = (w.min(h) * 0.035).clamp(1.0, 4.0);
1008    let radius = (w.min(h) * 0.12).clamp(2.0, 12.0);
1009
1010    // Size the label to fill the badge interior (after the border + padding).
1011    // The padding is per-axis: a wide, short stamp (e.g. a banner) must not let
1012    // the horizontal inset crush the available height (and vice-versa).
1013    let inner_w = (bw - 2.0 * (border + bw * 0.04)).max(1.0);
1014    let inner_h = (bh - 2.0 * (border + bh * 0.06)).max(1.0);
1015    let unit_w = helv_bold_width(&label, 1.0);
1016    let size = if unit_w > 0.0 {
1017        (inner_w / unit_w).min(inner_h * 0.72)
1018    } else {
1019        inner_h * 0.72
1020    }
1021    .clamp(3.0, 400.0);
1022    let label_w = helv_bold_width(&label, size);
1023
1024    let ca = read_num(file, dict, "CA").map(|v| v.clamp(0.0, 1.0));
1025    let gs = build_gs(false, ca);
1026
1027    let mut content = Vec::new();
1028    push(&mut content, "q\n");
1029    if gs.is_some() {
1030        push(&mut content, "/GS0 gs\n");
1031    }
1032    // Badge border.
1033    if let Some(op) = color_op(&colour, true) {
1034        push(&mut content, &op);
1035        push(&mut content, "\n");
1036    }
1037    push(&mut content, &format!("{} w 1 j\n", fmt(border)));
1038    push_round_rect(&mut content, badge, radius);
1039    push(&mut content, "S\n");
1040
1041    // Centred label, drawn in a frame translated to the badge's lower-left and
1042    // vertically centred by cap height (all-caps text has no descender).
1043    if let Some(op) = color_op(&colour, false) {
1044        push(&mut content, &op);
1045        push(&mut content, "\n");
1046    }
1047    let tx = ((bw - label_w) / 2.0).max(0.0);
1048    let ty = (bh / 2.0 - 0.35 * size).max(0.0);
1049    push(&mut content, "q\n");
1050    push(
1051        &mut content,
1052        &format!("1 0 0 1 {} {} cm\n", fmt(badge.x0), fmt(badge.y0)),
1053    );
1054    push(&mut content, "BT\n");
1055    push(&mut content, &format!("/F0 {} Tf\n", fmt(size)));
1056    push(
1057        &mut content,
1058        &format!("1 0 0 1 {} {} Tm\n", fmt(tx), fmt(ty)),
1059    );
1060    // `label` is `[A-Z0-9 ]` only (see `stamp_label`), so it needs no PDF
1061    // literal-string escaping and measures with WinAnsi == ASCII metrics.
1062    push(&mut content, "(");
1063    push(&mut content, &label);
1064    push(&mut content, ") Tj\n");
1065    push(&mut content, "ET\n");
1066    push(&mut content, "Q\n");
1067    push(&mut content, "Q\n");
1068
1069    if content.len() > MAX_APPEARANCE_BYTES {
1070        return None;
1071    }
1072
1073    Some(GeneratedAppearance {
1074        bbox: rect,
1075        matrix: Matrix::identity(),
1076        resources: stamp_resources(gs),
1077        content,
1078    })
1079}
1080
1081/// Decode a stamp `/Name` into a spaced, uppercase label, splitting camelCase
1082/// and digit boundaries and keeping only ASCII letters/digits — so the result
1083/// is always a safe PDF literal string and measures with ASCII metrics.
1084/// `NotApproved` → "NOT APPROVED", `TopSecret` → "TOP SECRET", `Draft` → "DRAFT".
1085fn stamp_label(name: &str) -> String {
1086    let mut out = String::new();
1087    let mut prev_lower_or_digit = false;
1088    for ch in name.chars().take(64) {
1089        if matches!(ch, ' ' | '_' | '-') {
1090            if !out.is_empty() && !out.ends_with(' ') {
1091                out.push(' ');
1092            }
1093            prev_lower_or_digit = false;
1094            continue;
1095        }
1096        if !ch.is_ascii_alphanumeric() {
1097            continue;
1098        }
1099        if ch.is_ascii_uppercase() && prev_lower_or_digit && !out.ends_with(' ') {
1100            out.push(' ');
1101        }
1102        out.push(ch.to_ascii_uppercase());
1103        prev_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
1104    }
1105    out.trim().to_string()
1106}
1107
1108/// The conventional colour for a standard stamp `/Name`.
1109fn stamp_colour(name: &str) -> Vec<f64> {
1110    match name {
1111        "Approved" | "Accepted" | "Completed" | "Final" | "Reviewed" => {
1112            vec![0.13, 0.55, 0.20] // affirmative — green
1113        }
1114        "Experimental" | "Sold" | "ForPublicRelease" | "InformationOnly" | "PreliminaryResults"
1115        | "Witness" | "InitialHere" | "SignHere" | "Received" => {
1116            vec![0.12, 0.22, 0.55] // neutral — blue
1117        }
1118        // NotApproved, Void, Rejected, Confidential, TopSecret, Expired, AsIs,
1119        // NotForPublicRelease, Departmental, ForComment, Draft and any unknown
1120        // name → cautionary red.
1121        _ => vec![0.72, 0.13, 0.13],
1122    }
1123}
1124
1125/// Width of an all-caps label in Helvetica-Bold text-space units at `size`
1126/// (standard-14 metrics; a 0.5-em estimate for any non-WinAnsi character).
1127fn helv_bold_width(text: &str, size: f64) -> f64 {
1128    let metrics = zpdf_font::standard_fonts::lookup("Helvetica-Bold");
1129    let mut total = 0.0;
1130    for ch in text.chars() {
1131        let w1000 = match metrics {
1132            Some(m) => {
1133                let code = u8::try_from(ch as u32).unwrap_or(b'?') as usize;
1134                let w = m.widths[code] as f64;
1135                if w == 0.0 {
1136                    500.0
1137                } else {
1138                    w
1139                }
1140            }
1141            None => 500.0,
1142        };
1143        total += w1000 / 1000.0 * size;
1144    }
1145    total
1146}
1147
1148/// The stamp appearance `/Resources`: a `/F0` Helvetica-Bold font plus the
1149/// optional opacity `GS0`.
1150fn stamp_resources(gs: Option<PdfDict>) -> PdfDict {
1151    let mut fonts = PdfDict::new();
1152    fonts.insert(
1153        PdfName::new("F0"),
1154        PdfObject::Dict(crate::forms::standard_font_dict("Helvetica-Bold")),
1155    );
1156    let mut res = PdfDict::new();
1157    res.insert(PdfName::new("Font"), PdfObject::Dict(fonts));
1158    if let Some(gs) = gs {
1159        let mut egs = PdfDict::new();
1160        egs.insert(PdfName::new("GS0"), PdfObject::Dict(gs));
1161        res.insert(PdfName::new("ExtGState"), PdfObject::Dict(egs));
1162    }
1163    res
1164}
1165
1166/// Append a rounded rectangle (four straight edges + four Bézier corner arcs),
1167/// no paint op. The radius is clamped to half the smaller side.
1168fn push_round_rect(out: &mut Vec<u8>, r: Rect, rad: f64) {
1169    const K: f64 = 0.552_284_75; // 4/3 * (sqrt(2) - 1)
1170    let rad = rad.min(r.width() / 2.0).min(r.height() / 2.0).max(0.0);
1171    let k = rad * K;
1172    let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);
1173    push(out, &format!("{} {} m\n", fmt(x0 + rad), fmt(y0)));
1174    push(out, &format!("{} {} l\n", fmt(x1 - rad), fmt(y0)));
1175    push(
1176        out,
1177        &format!(
1178            "{} {} {} {} {} {} c\n",
1179            fmt(x1 - rad + k),
1180            fmt(y0),
1181            fmt(x1),
1182            fmt(y0 + rad - k),
1183            fmt(x1),
1184            fmt(y0 + rad)
1185        ),
1186    );
1187    push(out, &format!("{} {} l\n", fmt(x1), fmt(y1 - rad)));
1188    push(
1189        out,
1190        &format!(
1191            "{} {} {} {} {} {} c\n",
1192            fmt(x1),
1193            fmt(y1 - rad + k),
1194            fmt(x1 - rad + k),
1195            fmt(y1),
1196            fmt(x1 - rad),
1197            fmt(y1)
1198        ),
1199    );
1200    push(out, &format!("{} {} l\n", fmt(x0 + rad), fmt(y1)));
1201    push(
1202        out,
1203        &format!(
1204            "{} {} {} {} {} {} c\n",
1205            fmt(x0 + rad - k),
1206            fmt(y1),
1207            fmt(x0),
1208            fmt(y1 - rad + k),
1209            fmt(x0),
1210            fmt(y1 - rad)
1211        ),
1212    );
1213    push(out, &format!("{} {} l\n", fmt(x0), fmt(y0 + rad)));
1214    push(
1215        out,
1216        &format!(
1217            "{} {} {} {} {} {} c\n",
1218            fmt(x0),
1219            fmt(y0 + rad - k),
1220            fmt(x0 + rad - k),
1221            fmt(y0),
1222            fmt(x0 + rad),
1223            fmt(y0)
1224        ),
1225    );
1226    push(out, "h\n");
1227}
1228
1229/// Resolve `/Name` (a possibly-indirect name) to a string.
1230fn read_name(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
1231    dict.get(key).and_then(|o| name_of(file, o))
1232}
1233
1234// ---------------------------------------------------------------------------
1235// Line endings (Table 176) — shared by Line, PolyLine and FreeText callouts
1236// ---------------------------------------------------------------------------
1237
1238/// A line-ending style. Unrecognized / `None` names map to [`LineEnding::None`].
1239#[derive(Clone, Copy, PartialEq)]
1240enum LineEnding {
1241    None,
1242    OpenArrow,
1243    ClosedArrow,
1244    ROpenArrow,
1245    RClosedArrow,
1246    Butt,
1247    Slash,
1248    Square,
1249    Circle,
1250    Diamond,
1251}
1252
1253fn parse_line_ending(name: &str) -> LineEnding {
1254    match name {
1255        "OpenArrow" => LineEnding::OpenArrow,
1256        "ClosedArrow" => LineEnding::ClosedArrow,
1257        "ROpenArrow" => LineEnding::ROpenArrow,
1258        "RClosedArrow" => LineEnding::RClosedArrow,
1259        "Butt" => LineEnding::Butt,
1260        "Slash" => LineEnding::Slash,
1261        "Square" => LineEnding::Square,
1262        "Circle" => LineEnding::Circle,
1263        "Diamond" => LineEnding::Diamond,
1264        _ => LineEnding::None, // "None" and any unknown name
1265    }
1266}
1267
1268/// Read `/LE` as a `(start, end)` style pair. `/LE` is normally a two-name
1269/// array `[start end]` (Line/PolyLine) but may be a bare single name (the
1270/// FreeText callout ending); the second slot then defaults to `None`.
1271fn read_line_endings(file: &PdfFile, dict: &PdfDict) -> (LineEnding, LineEnding) {
1272    if let Some(arr) = read_array(file, dict, "LE") {
1273        let style = |o: Option<&PdfObject>| {
1274            o.and_then(|o| name_of(file, o))
1275                .map_or(LineEnding::None, |s| parse_line_ending(&s))
1276        };
1277        return (style(arr.first()), style(arr.get(1)));
1278    }
1279    if let Some(name) = dict.get("LE").and_then(|o| name_of(file, o)) {
1280        return (parse_line_ending(&name), LineEnding::None);
1281    }
1282    (LineEnding::None, LineEnding::None)
1283}
1284
1285/// Resolve an object (possibly indirect) to a name string.
1286fn name_of(file: &PdfFile, o: &PdfObject) -> Option<String> {
1287    match o {
1288        PdfObject::Name(n) => Some(n.0.clone()),
1289        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
1290            PdfObject::Name(n) => Some(n.0),
1291            _ => None,
1292        },
1293        _ => None,
1294    }
1295}
1296
1297/// Append a line-ending decoration at point `p`. `dir` points from `p` toward
1298/// the line's interior; the decoration is sized from the line width `bw`. Closed
1299/// heads (`ClosedArrow`, `Square`, `Circle`, `Diamond`) are filled with `fill`
1300/// (the `/IC` interior colour) when present, else stroked hollow.
1301fn emit_line_ending(
1302    out: &mut Vec<u8>,
1303    p: (f64, f64),
1304    dir: (f64, f64),
1305    style: LineEnding,
1306    bw: f64,
1307    fill: &Option<Vec<f64>>,
1308) {
1309    if style == LineEnding::None {
1310        return;
1311    }
1312    let len = norm(dir);
1313    if len <= 1e-6 {
1314        return;
1315    }
1316    let (ux, uy) = (dir.0 / len, dir.1 / len); // unit, toward the line interior
1317    let (wx, wy) = (-uy, ux); // unit, perpendicular
1318    let lw = bw.max(0.5);
1319    // Sizes scale with the line width but stay within a visible range.
1320    let al = (lw * 3.0).clamp(6.0, 30.0); // arrowhead length
1321    let aw = al * 0.5; // arrowhead half-width
1322    let r = (lw * 1.5).clamp(3.0, 15.0); // block / circle radius
1323    let pt = |x: f64, y: f64| format!("{} {}", fmt(x), fmt(y));
1324
1325    match style {
1326        LineEnding::OpenArrow | LineEnding::ROpenArrow => {
1327            // Reversed arrow opens away from the line (negative `s`).
1328            let s = if style == LineEnding::ROpenArrow {
1329                -1.0
1330            } else {
1331                1.0
1332            };
1333            let (bx, by) = (p.0 + s * al * ux, p.1 + s * al * uy);
1334            push(
1335                out,
1336                &format!(
1337                    "{} m {} l {} l S\n",
1338                    pt(bx + aw * wx, by + aw * wy),
1339                    pt(p.0, p.1),
1340                    pt(bx - aw * wx, by - aw * wy)
1341                ),
1342            );
1343        }
1344        LineEnding::ClosedArrow | LineEnding::RClosedArrow => {
1345            let s = if style == LineEnding::RClosedArrow {
1346                -1.0
1347            } else {
1348                1.0
1349            };
1350            let (bx, by) = (p.0 + s * al * ux, p.1 + s * al * uy);
1351            let path = format!(
1352                "{} m {} l {} l h\n",
1353                pt(p.0, p.1),
1354                pt(bx + aw * wx, by + aw * wy),
1355                pt(bx - aw * wx, by - aw * wy)
1356            );
1357            paint_closed(out, path.as_bytes(), fill);
1358        }
1359        LineEnding::Butt => emit_seg(
1360            out,
1361            (p.0 + r * wx, p.1 + r * wy),
1362            (p.0 - r * wx, p.1 - r * wy),
1363        ),
1364        LineEnding::Slash => {
1365            // A short segment at 60° to the line through `p`.
1366            const COS60: f64 = 0.5;
1367            const SIN60: f64 = 0.866_025_403_784_438_6;
1368            let (dx, dy) = (ux * COS60 - uy * SIN60, ux * SIN60 + uy * COS60);
1369            emit_seg(
1370                out,
1371                (p.0 + r * dx, p.1 + r * dy),
1372                (p.0 - r * dx, p.1 - r * dy),
1373            );
1374        }
1375        LineEnding::Square => {
1376            let path = format!(
1377                "{} m {} l {} l {} l h\n",
1378                pt(p.0 + r * ux + r * wx, p.1 + r * uy + r * wy),
1379                pt(p.0 + r * ux - r * wx, p.1 + r * uy - r * wy),
1380                pt(p.0 - r * ux - r * wx, p.1 - r * uy - r * wy),
1381                pt(p.0 - r * ux + r * wx, p.1 - r * uy + r * wy)
1382            );
1383            paint_closed(out, path.as_bytes(), fill);
1384        }
1385        LineEnding::Diamond => {
1386            let path = format!(
1387                "{} m {} l {} l {} l h\n",
1388                pt(p.0 + r * ux, p.1 + r * uy),
1389                pt(p.0 + r * wx, p.1 + r * wy),
1390                pt(p.0 - r * ux, p.1 - r * uy),
1391                pt(p.0 - r * wx, p.1 - r * wy)
1392            );
1393            paint_closed(out, path.as_bytes(), fill);
1394        }
1395        LineEnding::Circle => {
1396            // `push_ellipse` writes only ASCII (fmt() numbers + ` m`/` c`/`h`),
1397            // so the bytes go straight to `paint_closed` — no UTF-8 round-trip.
1398            let mut path = Vec::new();
1399            push_ellipse(&mut path, Rect::new(p.0 - r, p.1 - r, p.0 + r, p.1 + r));
1400            paint_closed(out, &path, fill);
1401        }
1402        LineEnding::None => {}
1403    }
1404}
1405
1406/// Paint a constructed closed path: fill with `fill` (then stroke the outline)
1407/// when an interior colour is given, else stroke only. The fill colour is set
1408/// *before* the path so colour operators never sit between path construction
1409/// and painting.
1410fn paint_closed(out: &mut Vec<u8>, path: &[u8], fill: &Option<Vec<f64>>) {
1411    if let Some(c) = fill {
1412        if let Some(op) = color_op(c, false) {
1413            push(out, &op);
1414            push(out, "\n");
1415            out.extend_from_slice(path);
1416            push(out, "B\n"); // fill + stroke
1417            return;
1418        }
1419    }
1420    out.extend_from_slice(path);
1421    push(out, "S\n"); // hollow: stroke the outline only
1422}
1423
1424// ---------------------------------------------------------------------------
1425// Shared emit helpers
1426// ---------------------------------------------------------------------------
1427
1428/// Inset `rect` by any `/RD` rectangle differences, then by half the border
1429/// width so a centred stroke stays inside. `None` if the result is degenerate.
1430fn drawing_rect(file: &PdfFile, dict: &PdfDict, rect: Rect, bw: f64) -> Option<Rect> {
1431    let mut r = rect;
1432    // /RD = [left top right bottom] differences between /Rect and the drawing.
1433    if let Some(rd) = read_nums(file, dict, "RD") {
1434        if rd.len() == 4 && rd.iter().all(|v| v.is_finite() && *v >= 0.0) {
1435            r = Rect::new(r.x0 + rd[0], r.y0 + rd[3], r.x1 - rd[2], r.y1 - rd[1]);
1436        }
1437    }
1438    let half = (bw / 2.0).max(0.0);
1439    let dr = Rect::new(r.x0 + half, r.y0 + half, r.x1 - half, r.y1 - half);
1440    // Signed extents, NOT Rect::width()/height() (which take abs() and would
1441    // accept an inverted rect when the border / RD inset exceeds the rect).
1442    (dr.x1 - dr.x0 > 0.0 && dr.y1 - dr.y0 > 0.0).then_some(dr)
1443}
1444
1445fn emit_shape_setup(
1446    out: &mut Vec<u8>,
1447    fill: &Option<Vec<f64>>,
1448    stroke: &Option<Vec<f64>>,
1449    bw: f64,
1450) {
1451    if let Some(f) = fill {
1452        if let Some(op) = color_op(f, false) {
1453            push(out, &op);
1454            push(out, "\n");
1455        }
1456    }
1457    if let Some(s) = stroke {
1458        if let Some(op) = color_op(s, true) {
1459            push(out, &op);
1460            push(out, "\n");
1461        }
1462    }
1463    push(out, &format!("{} w\n", fmt(bw.max(0.0))));
1464}
1465
1466/// The paint operator for the combination of fill and stroke present.
1467fn paint_op(fill: bool, stroke: bool) -> &'static str {
1468    match (fill, stroke) {
1469        (true, true) => "B",
1470        (true, false) => "f",
1471        (false, true) => "S",
1472        (false, false) => "n",
1473    }
1474}
1475
1476/// Append an ellipse inscribed in `r` as four cubic Bézier arcs (no paint op).
1477fn push_ellipse(out: &mut Vec<u8>, r: Rect) {
1478    const K: f64 = 0.552_284_75; // 4/3 * (sqrt(2) - 1)
1479    let (cx, cy) = ((r.x0 + r.x1) / 2.0, (r.y0 + r.y1) / 2.0);
1480    let (rx, ry) = (r.width() / 2.0, r.height() / 2.0);
1481    let (ox, oy) = (rx * K, ry * K);
1482    push(out, &format!("{} {} m\n", fmt(cx + rx), fmt(cy)));
1483    let c = |o: &mut Vec<u8>, a, b, c2, d, e, f2| {
1484        push(
1485            o,
1486            &format!(
1487                "{} {} {} {} {} {} c\n",
1488                fmt(a),
1489                fmt(b),
1490                fmt(c2),
1491                fmt(d),
1492                fmt(e),
1493                fmt(f2)
1494            ),
1495        );
1496    };
1497    c(out, cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry);
1498    c(out, cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy);
1499    c(out, cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry);
1500    c(out, cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy);
1501    push(out, "h\n");
1502}
1503
1504/// Append a triangle-wave squiggle along the quad's baseline, oscillating
1505/// toward the top edge by `amp`, then stroke it. Works in the quad's own frame
1506/// so it follows rotated/skewed text. Emits at most `max_seg` segments and
1507/// returns the count emitted (the caller decrements a shared budget).
1508fn squiggle(out: &mut Vec<u8>, oq: &OrientedQuad, amp: f64, max_seg: usize) -> usize {
1509    let h = norm(oq.up);
1510    if h <= 0.0 {
1511        return 0;
1512    }
1513    // Unit vectors: `u` toward the top edge, `t` along the baseline (b0 → b1).
1514    let (ux, uy) = (oq.up.0 / h, oq.up.1 / h);
1515    let (tx, ty) = (oq.b1.0 - oq.b0.0, oq.b1.1 - oq.b0.1);
1516    let w = (tx * tx + ty * ty).sqrt();
1517    let period = (amp * 2.0).max(2.0);
1518    let cap = max_seg.max(1) as i64;
1519    let n = ((w / period).ceil() as i64).clamp(1, cap);
1520    // The wave rides a baseline lifted `amp` above the bottom edge, peaking a
1521    // further `amp` toward the top (matching the old axis-aligned amplitude).
1522    let base = (oq.b0.0 + ux * amp, oq.b0.1 + uy * amp);
1523    push(out, &format!("{} {} m\n", fmt(base.0), fmt(base.1)));
1524    for i in 1..=n {
1525        let f = i as f64 / n as f64;
1526        let peak = if i % 2 == 1 { amp } else { 0.0 };
1527        let x = base.0 + tx * f + ux * peak;
1528        let y = base.1 + ty * f + uy * peak;
1529        push(out, &format!("{} {} l\n", fmt(x), fmt(y)));
1530    }
1531    push(out, "S\n");
1532    n as usize
1533}
1534
1535/// Build the `GS0` ExtGState dict carrying the blend mode and/or opacity, or
1536/// `None` when neither is needed.
1537fn build_gs(multiply: bool, ca: Option<f64>) -> Option<PdfDict> {
1538    let need_ca = ca.map(|a| a < 1.0).unwrap_or(false);
1539    if !multiply && !need_ca {
1540        return None;
1541    }
1542    let mut d = PdfDict::new();
1543    if multiply {
1544        d.insert(
1545            PdfName::new("BM"),
1546            PdfObject::Name(PdfName::new("Multiply")),
1547        );
1548    }
1549    if let Some(a) = ca.filter(|a| *a < 1.0) {
1550        d.insert(PdfName::new("ca"), PdfObject::Real(a));
1551        d.insert(PdfName::new("CA"), PdfObject::Real(a));
1552    }
1553    Some(d)
1554}
1555
1556fn build_resources(gs: Option<PdfDict>) -> PdfDict {
1557    let mut res = PdfDict::new();
1558    if let Some(gs) = gs {
1559        let mut egs = PdfDict::new();
1560        egs.insert(PdfName::new("GS0"), PdfObject::Dict(gs));
1561        res.insert(PdfName::new("ExtGState"), PdfObject::Dict(egs));
1562    }
1563    res
1564}
1565
1566// ---------------------------------------------------------------------------
1567// Property readers
1568// ---------------------------------------------------------------------------
1569
1570/// Resolve a possibly-indirect object to a finite number.
1571fn as_num(file: &PdfFile, o: &PdfObject) -> Option<f64> {
1572    let v = match o {
1573        PdfObject::Ref(r) => file.resolve(*r).ok()?.as_f64().ok()?,
1574        other => other.as_f64().ok()?,
1575    };
1576    v.is_finite().then_some(v)
1577}
1578
1579fn read_num(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<f64> {
1580    dict.get(key).and_then(|o| as_num(file, o))
1581}
1582
1583fn read_array(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Vec<PdfObject>> {
1584    match dict.get(key)? {
1585        PdfObject::Array(a) => Some(a.clone()),
1586        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
1587            PdfObject::Array(a) => Some(a),
1588            _ => None,
1589        },
1590        _ => None,
1591    }
1592}
1593
1594/// A flat numeric array (`/QuadPoints`, `/Vertices`, `/L`, `/RD`, an `/InkList`
1595/// sub-path). All elements must be numbers, else `None` (a misaligned array
1596/// would otherwise silently shift coordinates).
1597fn read_nums(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Vec<f64>> {
1598    nums_of(file, dict.get(key)?)
1599}
1600
1601fn nums_of(file: &PdfFile, obj: &PdfObject) -> Option<Vec<f64>> {
1602    let arr = match obj {
1603        PdfObject::Array(a) => a.clone(),
1604        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
1605            PdfObject::Array(a) => a,
1606            _ => return None,
1607        },
1608        _ => return None,
1609    };
1610    arr.iter().map(|o| as_num(file, o)).collect()
1611}
1612
1613/// Read a 1/3/4-component colour array (`/C`, `/IC`), clamped to `[0,1]`. An
1614/// empty array (transparent) or wrong arity yields `None`.
1615fn read_color(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Vec<f64>> {
1616    let nums = read_nums(file, dict, key)?;
1617    match nums.len() {
1618        1 | 3 | 4 => Some(nums.iter().map(|v| v.clamp(0.0, 1.0)).collect()),
1619        _ => None,
1620    }
1621}
1622
1623/// A markup colour (`/C`) with `default` applied only when the key is ABSENT.
1624/// A present-but-empty `/C []` is spec-transparent (PDF 32000-1 §12.5.6.2) — it
1625/// yields `None`, so the caller draws nothing rather than the default colour.
1626fn markup_color(file: &PdfFile, dict: &PdfDict, key: &str, default: Vec<f64>) -> Option<Vec<f64>> {
1627    match dict.get(key) {
1628        None => Some(default),
1629        Some(_) => read_color(file, dict, key),
1630    }
1631}
1632
1633/// `/QuadPoints` as a list of 8-number quads (capped). Truncates a trailing
1634/// partial quad; `None` if there is not even one whole quad.
1635fn read_quadpoints(file: &PdfFile, dict: &PdfDict) -> Option<Vec<[f64; 8]>> {
1636    let nums = read_nums(file, dict, "QuadPoints")?;
1637    let count = (nums.len() / 8).min(MAX_QUADS);
1638    if count == 0 {
1639        return None;
1640    }
1641    let mut quads = Vec::with_capacity(count);
1642    for i in 0..count {
1643        let mut q = [0.0; 8];
1644        q.copy_from_slice(&nums[i * 8..i * 8 + 8]);
1645        quads.push(q);
1646    }
1647    Some(quads)
1648}
1649
1650/// The border width: `/BS /W` wins over `/Border[2]`; default 1.
1651fn border_width(file: &PdfFile, dict: &PdfDict) -> f64 {
1652    explicit_border_width(file, dict).unwrap_or(1.0)
1653}
1654
1655/// Like [`border_width`] but `None` when neither `/BS /W` nor `/Border[2]` is
1656/// present (no width-1 default). Used by Link, where a default border would
1657/// draw a box around every hyperlink — which no mainstream viewer does.
1658fn explicit_border_width(file: &PdfFile, dict: &PdfDict) -> Option<f64> {
1659    if let Some(bs) = read_subdict(file, dict, "BS") {
1660        if let Some(w) = read_num(file, &bs, "W") {
1661            return Some(w.max(0.0));
1662        }
1663    }
1664    if let Some(PdfObject::Array(b)) = dict.get("Border") {
1665        if let Some(w) = b.get(2).and_then(|o| as_num(file, o)) {
1666            return Some(w.max(0.0));
1667        }
1668    }
1669    None
1670}
1671
1672fn read_subdict(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<PdfDict> {
1673    match dict.get(key)? {
1674        PdfObject::Dict(d) => Some(d.clone()),
1675        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
1676            PdfObject::Dict(d) => Some(d),
1677            _ => None,
1678        },
1679        _ => None,
1680    }
1681}
1682
1683// ---------------------------------------------------------------------------
1684// Pure helpers
1685// ---------------------------------------------------------------------------
1686
1687/// Euclidean length of a 2-vector.
1688fn norm(v: (f64, f64)) -> f64 {
1689    (v.0 * v.0 + v.1 * v.1).sqrt()
1690}
1691
1692/// `a - b` as a 2-vector (used as a direction).
1693fn sub(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
1694    (a.0 - b.0, a.1 - b.1)
1695}
1696
1697/// A `/QuadPoints` quadrilateral resolved into a baseline-oriented frame,
1698/// robust to point order and to page rotation/skew. `corners` are in convex
1699/// counter-clockwise order (for filling a `Highlight`); `b0`/`b1` are the
1700/// endpoints of the baseline (bottom) edge and `up` is the vector from the
1701/// bottom edge to the top edge — its length is the run height — used to place
1702/// underline / strike-out / squiggle marks parallel to the baseline.
1703#[derive(Debug)]
1704struct OrientedQuad {
1705    corners: [(f64, f64); 4],
1706    b0: (f64, f64),
1707    b1: (f64, f64),
1708    up: (f64, f64),
1709}
1710
1711/// Resolve one `/QuadPoints` quad (8 numbers) into an [`OrientedQuad`], or
1712/// `None` when the four points are non-finite or degenerate (collinear / zero
1713/// area).
1714fn oriented_quad(q: &[f64; 8]) -> Option<OrientedQuad> {
1715    let pts = [(q[0], q[1]), (q[2], q[3]), (q[4], q[5]), (q[6], q[7])];
1716    if pts.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) {
1717        return None;
1718    }
1719    // Convex order: sort the four corners by angle about their centroid. This
1720    // turns either common /QuadPoints ordering (Acrobat's TL,TR,BL,BR or the
1721    // spec's counter-clockwise order) into a simple, non-self-intersecting
1722    // polygon — and the producer's exact ordering no longer matters.
1723    let cx = pts.iter().map(|p| p.0).sum::<f64>() / 4.0;
1724    let cy = pts.iter().map(|p| p.1).sum::<f64>() / 4.0;
1725    let mut c = pts;
1726    c.sort_by(|a, b| {
1727        (a.1 - cy)
1728            .atan2(a.0 - cx)
1729            .partial_cmp(&(b.1 - cy).atan2(b.0 - cx))
1730            .unwrap_or(std::cmp::Ordering::Equal)
1731    });
1732    // Reject a degenerate quad (shoelace area ≈ 0).
1733    let area = 0.5
1734        * (c[0].0 * c[1].1 - c[1].0 * c[0].1 + c[1].0 * c[2].1 - c[2].0 * c[1].1 + c[2].0 * c[3].1
1735            - c[3].0 * c[2].1
1736            + c[3].0 * c[0].1
1737            - c[0].0 * c[3].1)
1738            .abs();
1739    if area < 1e-6 {
1740        return None;
1741    }
1742    // The two baseline-parallel edges are the longer opposing pair: either
1743    // (c0-c1, c2-c3) or (c1-c2, c3-c0).
1744    let edge = |a: (f64, f64), b: (f64, f64)| norm((a.0 - b.0, a.1 - b.1));
1745    let mid = |a: (f64, f64), b: (f64, f64)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);
1746    let (long0, long1) =
1747        if edge(c[0], c[1]) + edge(c[2], c[3]) >= edge(c[1], c[2]) + edge(c[3], c[0]) {
1748            ((c[0], c[1]), (c[2], c[3]))
1749        } else {
1750            ((c[1], c[2]), (c[3], c[0]))
1751        };
1752    let (m0, m1) = (mid(long0.0, long0.1), mid(long1.0, long1.1));
1753    // Bottom edge = the long edge with the lower midpoint (page y grows upward,
1754    // so "lower on the page" = smaller y). Correct for text rotated within
1755    // (-90°, 90°); a fully inverted run is vanishingly rare and would only shift
1756    // an underline to the opposite long edge (the fill stays correct).
1757    let (bottom, top_mid) = if m0.1 <= m1.1 {
1758        (long0, m1)
1759    } else {
1760        (long1, m0)
1761    };
1762    let bottom_mid = mid(bottom.0, bottom.1);
1763    Some(OrientedQuad {
1764        corners: c,
1765        b0: bottom.0,
1766        b1: bottom.1,
1767        up: (top_mid.0 - bottom_mid.0, top_mid.1 - bottom_mid.1),
1768    })
1769}
1770
1771/// A colour-setting operator: `g`/`rg`/`k` for fill, `G`/`RG`/`K` for stroke.
1772fn color_op(c: &[f64], stroke: bool) -> Option<String> {
1773    let nums = |c: &[f64]| c.iter().map(|v| fmt(*v)).collect::<Vec<_>>().join(" ");
1774    match c.len() {
1775        1 => Some(format!("{} {}", nums(c), if stroke { "G" } else { "g" })),
1776        3 => Some(format!("{} {}", nums(c), if stroke { "RG" } else { "rg" })),
1777        4 => Some(format!("{} {}", nums(c), if stroke { "K" } else { "k" })),
1778        _ => None,
1779    }
1780}
1781
1782/// Format a coordinate for the content stream, guarding non-finite values and
1783/// clamping absurd magnitudes. Real page coordinates are far inside ±1e7;
1784/// anything beyond is clipped to `/Rect` by the form BBox anyway, and the clamp
1785/// keeps a single token short (a near-`f64::MAX` value would otherwise format to
1786/// a ~313-character fixed-point string).
1787fn fmt(v: f64) -> String {
1788    if v.is_finite() {
1789        format!("{:.3}", v.clamp(-1.0e7, 1.0e7))
1790    } else {
1791        "0".to_string()
1792    }
1793}
1794
1795fn push(out: &mut Vec<u8>, s: &str) {
1796    out.extend_from_slice(s.as_bytes());
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801    use super::*;
1802    use crate::test_util::build_pdf;
1803    use crate::PdfDocument;
1804
1805    #[test]
1806    fn oriented_quad_finds_baseline_regardless_of_point_order() {
1807        // An axis-aligned run, 100 wide × 20 tall, bottom edge at y=12.
1808        // Acrobat order (TL, TR, BL, BR) and the spec's CCW order must yield the
1809        // same baseline edge and the same upward vector.
1810        let approx = |a: f64, b: f64| (a - b).abs() < 1e-6;
1811        for q in [
1812            [10.0, 32.0, 110.0, 32.0, 10.0, 12.0, 110.0, 12.0], // Acrobat order
1813            [10.0, 12.0, 110.0, 12.0, 110.0, 32.0, 10.0, 32.0], // CCW order
1814        ] {
1815            let oq = oriented_quad(&q).expect("non-degenerate");
1816            // Bottom edge endpoints have y = 12 (the lower long edge).
1817            assert!(approx(oq.b0.1, 12.0) && approx(oq.b1.1, 12.0), "{oq:?}");
1818            // `up` points straight up by the run height (20).
1819            assert!(approx(oq.up.0, 0.0) && approx(oq.up.1, 20.0), "{oq:?}");
1820        }
1821    }
1822
1823    #[test]
1824    fn oriented_quad_rejects_degenerate() {
1825        // Four collinear points (zero area).
1826        assert!(oriented_quad(&[0.0, 0.0, 1.0, 0.0, 2.0, 0.0, 3.0, 0.0]).is_none());
1827        // A non-finite coordinate.
1828        assert!(oriented_quad(&[0.0, 0.0, f64::NAN, 0.0, 1.0, 1.0, 0.0, 1.0]).is_none());
1829    }
1830
1831    #[test]
1832    fn oriented_quad_tracks_rotation() {
1833        // The axis-aligned run rotated 30° CCW about the origin. The baseline
1834        // frame must rotate with it: the height stays 20 and the up vector is
1835        // the original (0, 20) rotated 30° → (-10, 17.3205…).
1836        let base = [10.0, 32.0, 110.0, 32.0, 10.0, 12.0, 110.0, 12.0];
1837        let (sin, cos) = 30.0_f64.to_radians().sin_cos();
1838        let mut rot = [0.0; 8];
1839        for i in 0..4 {
1840            let (x, y) = (base[2 * i], base[2 * i + 1]);
1841            rot[2 * i] = x * cos - y * sin;
1842            rot[2 * i + 1] = x * sin + y * cos;
1843        }
1844        let oq = oriented_quad(&rot).expect("non-degenerate");
1845        assert!(
1846            (norm(oq.up) - 20.0).abs() < 1e-6,
1847            "height preserved: {oq:?}"
1848        );
1849        assert!((oq.up.0 - (-10.0)).abs() < 1e-6, "up.x: {oq:?}");
1850        assert!(
1851            (oq.up.1 - 17.320_508_075_688_775).abs() < 1e-6,
1852            "up.y: {oq:?}"
1853        );
1854    }
1855
1856    #[test]
1857    fn color_op_arities() {
1858        assert_eq!(color_op(&[0.0], false).unwrap(), "0.000 g");
1859        assert_eq!(
1860            color_op(&[1.0, 0.0, 0.0], true).unwrap(),
1861            "1.000 0.000 0.000 RG"
1862        );
1863        assert_eq!(
1864            color_op(&[0.1, 0.2, 0.3, 0.4], false).unwrap(),
1865            "0.100 0.200 0.300 0.400 k"
1866        );
1867        assert!(color_op(&[0.0, 1.0], false).is_none());
1868    }
1869
1870    #[test]
1871    fn paint_op_selection() {
1872        assert_eq!(paint_op(true, true), "B");
1873        assert_eq!(paint_op(true, false), "f");
1874        assert_eq!(paint_op(false, true), "S");
1875        assert_eq!(paint_op(false, false), "n");
1876    }
1877
1878    #[test]
1879    fn inset_convex_quad_insets_uniformly() {
1880        let approx =
1881            |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
1882        // CCW square [0,0],[10,0],[10,10],[0,10] inset by 2 → corners move 2 in.
1883        let c = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1884        let r = inset_convex_quad(c, 2.0);
1885        assert!(approx(r[0], (2.0, 2.0)), "{r:?}");
1886        assert!(approx(r[1], (8.0, 2.0)), "{r:?}");
1887        assert!(approx(r[2], (8.0, 8.0)), "{r:?}");
1888        assert!(approx(r[3], (2.0, 8.0)), "{r:?}");
1889        // A huge inset is clamped to 40 % of the shortest edge (here 4), so the
1890        // quad can never invert.
1891        let r2 = inset_convex_quad(c, 100.0);
1892        assert!(approx(r2[0], (4.0, 4.0)), "clamped corner: {r2:?}");
1893        // Zero / negative inset is a no-op.
1894        assert_eq!(inset_convex_quad(c, 0.0), c);
1895    }
1896
1897    /// Open a one-page doc whose single annotation is object 4, and return its
1898    /// parsed (generated) appearance via the public annotation path.
1899    fn annot_of(annot_body: &str) -> Option<crate::Annotation> {
1900        let doc = PdfDocument::open(build_pdf(&[
1901            "<< /Type /Catalog /Pages 2 0 R >>",
1902            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1903            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>",
1904            annot_body,
1905        ]))
1906        .expect("open");
1907        let page = doc.page(0).expect("page");
1908        doc.page_annotations(&page).into_iter().next()
1909    }
1910
1911    #[test]
1912    fn highlight_generates_multiply_appearance() {
1913        let a = annot_of(
1914            "<< /Type /Annot /Subtype /Highlight /Rect [10 10 110 30] \
1915             /QuadPoints [10 30 110 30 10 10 110 10] /C [1 1 0] >>",
1916        )
1917        .expect("annotation");
1918        assert!(a.is_viewable(), "a generated appearance is viewable");
1919        let gen = a.generated.as_ref().expect("generated appearance");
1920        let s = String::from_utf8_lossy(&gen.content);
1921        assert!(s.contains("/GS0 gs"), "uses the blend ExtGState: {s}");
1922        assert!(s.contains("1.000 1.000 0.000 rg"), "yellow fill: {s}");
1923        assert!(s.contains("h\nf"), "closes and fills the quad polygon: {s}");
1924        // The ExtGState carries Multiply.
1925        let egs = gen
1926            .resources
1927            .get("ExtGState")
1928            .and_then(|o| o.as_dict().ok())
1929            .unwrap();
1930        let g0 = egs.get("GS0").and_then(|o| o.as_dict().ok()).unwrap();
1931        assert_eq!(g0.get_name("BM").unwrap(), "Multiply");
1932    }
1933
1934    #[test]
1935    fn underline_defaults_to_black_stroke() {
1936        let a = annot_of(
1937            "<< /Type /Annot /Subtype /Underline /Rect [10 10 110 30] \
1938             /QuadPoints [10 30 110 30 10 10 110 10] >>",
1939        )
1940        .expect("annotation");
1941        let gen = a.generated.as_ref().expect("gen");
1942        let s = String::from_utf8_lossy(&gen.content);
1943        assert!(s.contains("0.000 G"), "black stroke colour: {s}");
1944        assert!(
1945            s.contains(" m ") && s.contains(" l S"),
1946            "strokes a line: {s}"
1947        );
1948    }
1949
1950    #[test]
1951    fn existing_ap_is_not_overridden() {
1952        // A Square WITH a valid /AP keeps its stored appearance (no generation).
1953        let doc = PdfDocument::open(build_pdf(&[
1954            "<< /Type /Catalog /Pages 2 0 R >>",
1955            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1956            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>",
1957            "<< /Type /Annot /Subtype /Square /Rect [10 10 110 110] /AP << /N 5 0 R >> >>",
1958            "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 23 >>\nstream\n\
1959             1 0 0 rg 0 0 10 10 re f\nendstream",
1960        ]))
1961        .expect("open");
1962        let page = doc.page(0).expect("page");
1963        let a = doc
1964            .page_annotations(&page)
1965            .into_iter()
1966            .next()
1967            .expect("annotation");
1968        assert!(a.appearance.is_some(), "valid /AP resolved");
1969        assert!(
1970            a.generated.is_none(),
1971            "generation suppressed when /AP present"
1972        );
1973    }
1974
1975    #[test]
1976    fn unsupported_subtype_generates_nothing() {
1977        // `/Movie` has no geometry-derived appearance (unlike Text/Stamp, which
1978        // now draw an icon / badge).
1979        let a =
1980            annot_of("<< /Type /Annot /Subtype /Movie /Rect [10 10 30 30] >>").expect("annotation");
1981        assert!(a.generated.is_none());
1982        assert!(!a.is_viewable(), "no appearance, not viewable");
1983    }
1984
1985    #[test]
1986    fn text_note_icon_draws_filled_paper() {
1987        // A Text annotation with no /Name defaults to the dog-eared note, filled
1988        // with /C and outlined / lined in dark gray.
1989        let a = annot_of("<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /C [1 1 0] >>")
1990            .expect("annotation");
1991        assert!(a.is_viewable(), "generated note icon is viewable");
1992        let gen = a.generated.as_ref().expect("generated appearance");
1993        let s = String::from_utf8_lossy(&gen.content);
1994        assert!(
1995            s.contains("1.000 1.000 0.000 rg"),
1996            "yellow paper from /C: {s}"
1997        );
1998        assert!(s.contains("0.250 G"), "dark gray ink: {s}");
1999        assert!(s.contains("B\n"), "fills + strokes the page: {s}");
2000    }
2001
2002    #[test]
2003    fn text_default_colour_is_note_yellow() {
2004        // No /C → the default note yellow, not transparent.
2005        let a =
2006            annot_of("<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] >>").expect("annotation");
2007        let gen = a.generated.as_ref().expect("gen");
2008        let s = String::from_utf8_lossy(&gen.content);
2009        assert!(
2010            s.contains("1.000 0.930 0.400 rg"),
2011            "default note yellow: {s}"
2012        );
2013    }
2014
2015    #[test]
2016    fn text_help_icon_draws_circle() {
2017        let a = annot_of("<< /Type /Annot /Subtype /Text /Name /Help /Rect [10 10 34 34] >>")
2018            .expect("annotation");
2019        let gen = a.generated.as_ref().expect("gen");
2020        let s = String::from_utf8_lossy(&gen.content);
2021        // The enclosing circle is an ellipse (Bézier arcs); the dot is filled.
2022        assert!(s.contains(" c\n"), "curved circle/hook: {s}");
2023        assert!(s.contains("f\n"), "filled dot: {s}");
2024    }
2025
2026    #[test]
2027    fn text_check_icon_strokes_two_segments() {
2028        let a = annot_of(
2029            "<< /Type /Annot /Subtype /Text /Name /Check /Rect [10 10 34 34] /C [0 0.5 0] >>",
2030        )
2031        .expect("annotation");
2032        let gen = a.generated.as_ref().expect("gen");
2033        let s = String::from_utf8_lossy(&gen.content);
2034        assert!(s.contains("0.000 0.500 0.000 RG"), "uses /C ink: {s}");
2035        assert!(
2036            s.contains(" m ") && s.contains(" l ") && s.contains(" l S"),
2037            "check stroke: {s}"
2038        );
2039    }
2040
2041    #[test]
2042    fn text_icon_too_small_generates_nothing() {
2043        let a =
2044            annot_of("<< /Type /Annot /Subtype /Text /Rect [10 10 12 12] >>").expect("annotation");
2045        assert!(a.generated.is_none(), "a sub-3pt rect has no icon");
2046    }
2047
2048    #[test]
2049    fn stamp_label_decodes_camel_case() {
2050        assert_eq!(stamp_label("NotApproved"), "NOT APPROVED");
2051        assert_eq!(stamp_label("TopSecret"), "TOP SECRET");
2052        assert_eq!(stamp_label("ForPublicRelease"), "FOR PUBLIC RELEASE");
2053        assert_eq!(stamp_label("AsIs"), "AS IS");
2054        assert_eq!(stamp_label("Draft"), "DRAFT");
2055        // Already-spaced / punctuated input collapses to single spaces and drops
2056        // unsafe characters (so the result is always a safe PDF literal).
2057        assert_eq!(stamp_label("For (comment)"), "FOR COMMENT");
2058        assert_eq!(stamp_label(""), "");
2059    }
2060
2061    #[test]
2062    fn stamp_draws_bordered_label() {
2063        let a = annot_of("<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [20 20 160 70] >>")
2064            .expect("annotation");
2065        assert!(a.is_viewable(), "generated stamp is viewable");
2066        let gen = a.generated.as_ref().expect("gen");
2067        let s = String::from_utf8_lossy(&gen.content);
2068        assert!(s.contains("(APPROVED) Tj"), "draws the label: {s}");
2069        assert!(s.contains("/F0 "), "uses the bold font resource: {s}");
2070        // Approved → green border + text.
2071        assert!(s.contains("0.130 0.550 0.200 RG"), "green border: {s}");
2072        assert!(s.contains("0.130 0.550 0.200 rg"), "green text: {s}");
2073        // A rounded-rect border (arcs) stroked, no interior fill.
2074        assert!(
2075            s.contains(" c\n") && s.contains("h\nS"),
2076            "rounded border stroked: {s}"
2077        );
2078        assert!(gen.resources.get("Font").is_some(), "font resource present");
2079    }
2080
2081    #[test]
2082    fn stamp_default_name_is_draft() {
2083        let a = annot_of("<< /Type /Annot /Subtype /Stamp /Rect [20 20 160 70] >>")
2084            .expect("annotation");
2085        let gen = a.generated.as_ref().expect("gen");
2086        let s = String::from_utf8_lossy(&gen.content);
2087        assert!(s.contains("(DRAFT) Tj"), "default /Name is Draft: {s}");
2088        assert!(
2089            s.contains("0.720 0.130 0.130 RG"),
2090            "Draft is cautionary red: {s}"
2091        );
2092    }
2093
2094    #[test]
2095    fn stamp_colour_override_from_c() {
2096        let a = annot_of(
2097            "<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [20 20 160 70] /C [1 0 0] >>",
2098        )
2099        .expect("annotation");
2100        let gen = a.generated.as_ref().expect("gen");
2101        let s = String::from_utf8_lossy(&gen.content);
2102        assert!(
2103            s.contains("1.000 0.000 0.000 RG"),
2104            "/C overrides the green: {s}"
2105        );
2106        assert!(
2107            !s.contains("0.130 0.550 0.200"),
2108            "no convention colour when /C given: {s}"
2109        );
2110    }
2111
2112    #[test]
2113    fn stamp_opacity_uses_extgstate() {
2114        let a = annot_of(
2115            "<< /Type /Annot /Subtype /Stamp /Name /Confidential /Rect [20 20 200 80] /CA 0.5 >>",
2116        )
2117        .expect("annotation");
2118        let gen = a.generated.as_ref().expect("gen");
2119        let s = String::from_utf8_lossy(&gen.content);
2120        assert!(s.contains("/GS0 gs"), "applies the opacity ExtGState: {s}");
2121        let egs = gen
2122            .resources
2123            .get("ExtGState")
2124            .and_then(|o| o.as_dict().ok())
2125            .expect("ExtGState");
2126        let g0 = egs.get("GS0").and_then(|o| o.as_dict().ok()).expect("GS0");
2127        assert_eq!(g0.get("ca").and_then(|o| o.as_f64().ok()), Some(0.5));
2128    }
2129
2130    #[test]
2131    fn text_opacity_uses_extgstate() {
2132        // A Text icon routes through the shared q/Q + ExtGState wrapper (a
2133        // different path from Stamp); confirm /CA reaches it.
2134        let a = annot_of("<< /Type /Annot /Subtype /Text /Rect [10 10 34 34] /CA 0.4 >>")
2135            .expect("annotation");
2136        let gen = a.generated.as_ref().expect("gen");
2137        let s = String::from_utf8_lossy(&gen.content);
2138        assert!(s.contains("/GS0 gs"), "applies the opacity ExtGState: {s}");
2139        let egs = gen
2140            .resources
2141            .get("ExtGState")
2142            .and_then(|o| o.as_dict().ok())
2143            .expect("ExtGState");
2144        let g0 = egs.get("GS0").and_then(|o| o.as_dict().ok()).expect("GS0");
2145        assert_eq!(g0.get("ca").and_then(|o| o.as_f64().ok()), Some(0.4));
2146        assert_eq!(g0.get("CA").and_then(|o| o.as_f64().ok()), Some(0.4));
2147    }
2148
2149    #[test]
2150    fn text_empty_colour_still_draws_note() {
2151        // Unlike markup (`/C []` is transparent), a Text icon with an empty /C
2152        // still draws — falling back to the default note yellow.
2153        let a = annot_of("<< /Type /Annot /Subtype /Text /Rect [10 10 34 34] /C [] >>")
2154            .expect("annotation");
2155        let gen = a
2156            .generated
2157            .as_ref()
2158            .expect("empty /C still draws the note icon");
2159        let s = String::from_utf8_lossy(&gen.content);
2160        assert!(
2161            s.contains("1.000 0.930 0.400 rg"),
2162            "default note yellow: {s}"
2163        );
2164    }
2165
2166    #[test]
2167    fn text_insert_key_cross_route_to_their_glyphs() {
2168        let content = |name: &str| {
2169            let a = annot_of(&format!(
2170                "<< /Type /Annot /Subtype /Text /Name /{name} /Rect [10 10 34 34] >>"
2171            ))
2172            .expect("annotation");
2173            String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content).into_owned()
2174        };
2175        // Insert: a filled triangle — `f`, and crucially NOT the note's `B`.
2176        let ins = content("Insert");
2177        assert!(
2178            ins.contains("f\n") && !ins.contains("B\n"),
2179            "insert triangle: {ins}"
2180        );
2181        // Key: a stroked ring (Bézier `c`) plus straight segments.
2182        let key = content("Key");
2183        assert!(
2184            key.contains(" c\n") && key.contains(" l S\n"),
2185            "key ring + stem: {key}"
2186        );
2187        // Cross: two stroked segments, no fill at all.
2188        let cross = content("Cross");
2189        assert!(
2190            cross.matches(" l S\n").count() >= 2 && !cross.contains("f\n"),
2191            "cross is two strokes: {cross}"
2192        );
2193    }
2194
2195    #[test]
2196    fn text_checkmark_alias_matches_check() {
2197        // `Checkmark` is an alias for `Check`; it must reach the check stroke,
2198        // not fall through to the note fill.
2199        let a = annot_of("<< /Type /Annot /Subtype /Text /Name /Checkmark /Rect [10 10 34 34] >>")
2200            .expect("annotation");
2201        let s = String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content);
2202        assert!(
2203            s.contains(" l S\n") && !s.contains("B\n"),
2204            "Checkmark routes to the check icon: {s}"
2205        );
2206    }
2207
2208    #[test]
2209    fn stamp_too_small_rect_generates_nothing() {
2210        let a = annot_of("<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [10 10 14 14] >>")
2211            .expect("annotation");
2212        assert!(a.generated.is_none(), "a <=6pt stamp rect has no badge");
2213    }
2214
2215    #[test]
2216    fn stamp_non_decodable_name_generates_nothing() {
2217        // A /Name with no ASCII alphanumerics strips to an empty label, so no
2218        // badge is drawn (rather than an empty `() Tj`).
2219        let a = annot_of("<< /Type /Annot /Subtype /Stamp /Name /--- /Rect [20 20 160 70] >>")
2220            .expect("annotation");
2221        assert!(
2222            a.generated.is_none(),
2223            "a name that strips to empty draws nothing"
2224        );
2225    }
2226
2227    #[test]
2228    fn round_rect_clamps_radius_on_a_flat_rect() {
2229        // A wide, short rect with an oversized radius: the internal clamp must
2230        // keep every emitted coordinate inside the rectangle (no corner-arc
2231        // overshoot). Numbers are emitted in x, y, x, y… order.
2232        let mut out = Vec::new();
2233        push_round_rect(&mut out, Rect::new(0.0, 0.0, 100.0, 4.0), 50.0);
2234        let s = String::from_utf8(out).expect("ascii");
2235        let nums: Vec<f64> = s
2236            .split_whitespace()
2237            .filter_map(|t| t.parse::<f64>().ok())
2238            .collect();
2239        assert!(!nums.is_empty(), "emitted coordinates: {s}");
2240        for (i, v) in nums.iter().enumerate() {
2241            if i % 2 == 0 {
2242                assert!((0.0..=100.0).contains(v), "x within width: {v}");
2243            } else {
2244                assert!((0.0..=4.0).contains(v), "y clamped to height: {v}");
2245            }
2246        }
2247    }
2248
2249    #[test]
2250    fn square_with_interior_colour_fills_and_strokes() {
2251        let a = annot_of(
2252            "<< /Type /Annot /Subtype /Square /Rect [10 10 110 110] \
2253             /IC [0 0 1] /C [1 0 0] /BS << /W 2 >> >>",
2254        )
2255        .expect("annotation");
2256        let gen = a.generated.as_ref().expect("gen");
2257        let s = String::from_utf8_lossy(&gen.content);
2258        assert!(s.contains("0.000 0.000 1.000 rg"), "blue interior: {s}");
2259        assert!(s.contains("1.000 0.000 0.000 RG"), "red border: {s}");
2260        assert!(s.contains("2.000 w"), "border width: {s}");
2261        assert!(
2262            s.trim_end().ends_with('B') || s.contains(" B\n"),
2263            "fill+stroke op: {s}"
2264        );
2265    }
2266
2267    #[test]
2268    fn link_without_colour_is_invisible() {
2269        let a = annot_of("<< /Type /Annot /Subtype /Link /Rect [10 10 110 30] /Border [0 0 1] >>")
2270            .expect("annotation");
2271        assert!(a.generated.is_none(), "no /C → no visible border");
2272    }
2273
2274    #[test]
2275    fn degenerate_rect_is_rejected() {
2276        let a = annot_of(
2277            "<< /Type /Annot /Subtype /Highlight /Rect [10 10 10 30] \
2278             /QuadPoints [10 30 10 30 10 10 10 10] /C [1 1 0] >>",
2279        )
2280        .expect("annotation");
2281        assert!(a.generated.is_none(), "zero-width rect generates nothing");
2282    }
2283
2284    #[test]
2285    fn inverted_inset_rect_draws_nothing() {
2286        // A 20×20 Square with a 100pt border: the half-border inset inverts the
2287        // drawing rect. The signed-extent guard must reject it (not paint a box).
2288        let a = annot_of(
2289            "<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] \
2290             /IC [0 1 0] /BS << /W 100 >> >>",
2291        )
2292        .expect("annotation");
2293        assert!(
2294            a.generated.is_none(),
2295            "border wider than rect → nothing drawn"
2296        );
2297    }
2298
2299    #[test]
2300    fn empty_color_array_is_transparent() {
2301        // /C [] is spec-transparent: no opaque-yellow fallback.
2302        let a = annot_of(
2303            "<< /Type /Annot /Subtype /Highlight /Rect [10 10 110 30] \
2304             /QuadPoints [10 30 110 30 10 10 110 10] /C [] >>",
2305        )
2306        .expect("annotation");
2307        assert!(a.generated.is_none(), "empty /C draws nothing");
2308    }
2309
2310    #[test]
2311    fn link_needs_explicit_border() {
2312        let none = annot_of("<< /Type /Annot /Subtype /Link /Rect [10 10 110 30] /C [0 0 1] >>")
2313            .expect("annotation");
2314        assert!(
2315            none.generated.is_none(),
2316            "/C but no explicit border → invisible"
2317        );
2318
2319        let drawn = annot_of(
2320            "<< /Type /Annot /Subtype /Link /Rect [10 10 110 30] /C [0 0 1] /Border [0 0 2] >>",
2321        )
2322        .expect("annotation");
2323        assert!(
2324            drawn.generated.is_some(),
2325            "/C + explicit non-zero border → drawn"
2326        );
2327    }
2328
2329    #[test]
2330    fn squiggly_with_many_quads_is_bounded() {
2331        // Without a shared segment budget this would synthesize a multi-GB
2332        // stream. It must complete and stay within the byte ceiling.
2333        let mut quads = String::new();
2334        for i in 0..6000 {
2335            let x = (i % 50) as f64 * 10.0;
2336            // A wide quad so each squiggle wants its full per-quad segment cap.
2337            quads.push_str(&format!("{x} 20 {} 20 {x} 10 {} 10 ", x + 500.0, x + 500.0));
2338        }
2339        let body = format!(
2340            "<< /Type /Annot /Subtype /Squiggly /Rect [0 0 600 30] \
2341             /QuadPoints [{quads}] /C [0 0 0] >>"
2342        );
2343        let a = annot_of(&body).expect("annotation");
2344        if let Some(gen) = &a.generated {
2345            assert!(
2346                gen.content.len() <= super::MAX_APPEARANCE_BYTES,
2347                "bounded content: {} bytes",
2348                gen.content.len()
2349            );
2350        }
2351    }
2352
2353    #[test]
2354    fn rotated_highlight_fills_oriented_polygon() {
2355        // A 45°-ish skewed quad: the fill must be a closed polygon through the
2356        // four corners, never an axis-aligned `re`.
2357        let a = annot_of(
2358            "<< /Type /Annot /Subtype /Highlight /Rect [10 10 120 120] \
2359             /QuadPoints [20 100 100 60 10 40 90 0] /C [1 1 0] >>",
2360        )
2361        .expect("annotation");
2362        let gen = a.generated.as_ref().expect("gen");
2363        let s = String::from_utf8_lossy(&gen.content);
2364        assert!(!s.contains(" re"), "no axis-aligned rectangle: {s}");
2365        assert!(
2366            s.contains(" m\n") && s.contains(" l\n"),
2367            "polygon path: {s}"
2368        );
2369        assert!(s.contains("h\nf"), "closed and filled: {s}");
2370    }
2371
2372    #[test]
2373    fn line_open_arrow_strokes_a_head() {
2374        let a = annot_of(
2375            "<< /Type /Annot /Subtype /Line /Rect [0 0 200 200] /L [20 20 180 180] \
2376             /C [0 0 0] /LE [/OpenArrow /None] >>",
2377        )
2378        .expect("annotation");
2379        let gen = a.generated.as_ref().expect("gen");
2380        let s = String::from_utf8_lossy(&gen.content);
2381        assert!(s.contains("20.000 20.000 m"), "draws the line: {s}");
2382        // The arrowhead's tip is the first point, emitted as a line-to in the
2383        // open `bl m tip l br l S` path — unique to the ending.
2384        assert!(
2385            s.contains("20.000 20.000 l"),
2386            "open arrowhead at the start: {s}"
2387        );
2388    }
2389
2390    #[test]
2391    fn line_closed_arrow_fills_with_interior_colour() {
2392        let a = annot_of(
2393            "<< /Type /Annot /Subtype /Line /Rect [0 0 200 200] /L [20 20 180 180] \
2394             /C [0 0 0] /IC [1 0 0] /LE [/None /ClosedArrow] >>",
2395        )
2396        .expect("annotation");
2397        let gen = a.generated.as_ref().expect("gen");
2398        let s = String::from_utf8_lossy(&gen.content);
2399        // Closed arrow at the end point, filled with /IC then stroked (`B`).
2400        assert!(
2401            s.contains("180.000 180.000 m"),
2402            "closed arrowhead at the end: {s}"
2403        );
2404        assert!(
2405            s.contains("1.000 0.000 0.000 rg"),
2406            "interior fill colour: {s}"
2407        );
2408        assert!(s.contains("B\n"), "fill + stroke: {s}");
2409    }
2410
2411    #[test]
2412    fn polyline_carries_line_endings() {
2413        let a = annot_of(
2414            "<< /Type /Annot /Subtype /PolyLine /Rect [0 0 200 200] \
2415             /Vertices [20 20 100 20 100 100] /C [0 0 0] /LE [/Diamond /Butt] >>",
2416        )
2417        .expect("annotation");
2418        let gen = a.generated.as_ref().expect("gen");
2419        let s = String::from_utf8_lossy(&gen.content);
2420        // The polyline path itself, plus a diamond at the first vertex (closed
2421        // path → stroked hollow) and a butt cap at the last.
2422        assert!(s.contains("20.000 20.000 m"), "polyline drawn: {s}");
2423        assert!(
2424            s.contains("20.000 23.000 l"),
2425            "diamond at the first vertex: {s}"
2426        );
2427        assert!(
2428            s.contains("103.000 100.000 m 97.000 100.000 l S"),
2429            "butt cap at the last vertex: {s}"
2430        );
2431    }
2432
2433    #[test]
2434    fn freetext_draws_background_and_text() {
2435        let a = annot_of(
2436            "<< /Type /Annot /Subtype /FreeText /Rect [40 40 190 140] \
2437             /Contents (Hello world) /DA (/Helv 12 Tf 0 0 1 rg) /C [1 1 0] >>",
2438        )
2439        .expect("annotation");
2440        assert!(
2441            a.is_viewable(),
2442            "FreeText with a generated appearance is viewable"
2443        );
2444        let gen = a.generated.as_ref().expect("gen");
2445        let s = String::from_utf8_lossy(&gen.content);
2446        assert!(s.contains("1.000 1.000 0.000 rg"), "yellow background: {s}");
2447        assert!(s.contains("re f"), "fills the rect: {s}");
2448        assert!(
2449            s.contains("1 0 0 1 40.000 40.000 cm"),
2450            "translates to the rect: {s}"
2451        );
2452        assert!(s.contains("/Helv 12.00 Tf"), "DA font/size: {s}");
2453        assert!(s.contains("0.0000 0.0000 1.0000 rg"), "DA text colour: {s}");
2454        assert!(s.contains("(Hello world) Tj"), "draws the text: {s}");
2455        assert!(gen.resources.get("Font").is_some(), "font resource present");
2456    }
2457
2458    #[test]
2459    fn freetext_callout_draws_polyline_and_arrow() {
2460        let a = annot_of(
2461            "<< /Type /Annot /Subtype /FreeText /Rect [40 40 190 140] /Contents (note) \
2462             /DA (/Helv 10 Tf 0 g) /CL [50 60 120 120] /LE /OpenArrow >>",
2463        )
2464        .expect("annotation");
2465        let gen = a.generated.as_ref().expect("gen");
2466        let s = String::from_utf8_lossy(&gen.content);
2467        assert!(
2468            s.contains("50.000 60.000 m"),
2469            "callout starts at /CL[0]: {s}"
2470        );
2471        assert!(
2472            s.contains("120.000 120.000 l"),
2473            "callout reaches the box: {s}"
2474        );
2475        assert!(
2476            s.contains("50.000 60.000 l"),
2477            "open arrow tip at the callout start: {s}"
2478        );
2479        assert!(s.contains("(note) Tj"), "draws the text: {s}");
2480    }
2481
2482    #[test]
2483    fn freetext_with_nothing_to_draw_generates_nothing() {
2484        // No /Contents, /C, border or /CL → nothing to synthesize.
2485        let a = annot_of("<< /Type /Annot /Subtype /FreeText /Rect [40 40 190 140] >>")
2486            .expect("annotation");
2487        assert!(a.generated.is_none(), "empty FreeText draws nothing");
2488        assert!(!a.is_viewable());
2489    }
2490
2491    #[test]
2492    fn caret_draws_filled_wedge() {
2493        // /Rect [10 10 30 40] → x0=10 y0=10 w=20 h=30, apex at (20, 40).
2494        let a =
2495            annot_of("<< /Type /Annot /Subtype /Caret /Rect [10 10 30 40] >>").expect("annotation");
2496        assert!(a.is_viewable(), "generated caret is viewable");
2497        let s = String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content);
2498        assert!(s.contains("0.000 g"), "black fill by default: {s}");
2499        assert!(s.contains("10.000 10.000 m"), "wedge base start: {s}");
2500        assert!(s.contains("20.000 40.000 l"), "apex at top-centre: {s}");
2501        assert!(s.contains("f\n"), "fills the wedge: {s}");
2502    }
2503
2504    #[test]
2505    fn caret_honours_rd_inset_and_colour() {
2506        // /RD insets the caret rect; /C [1 0 0] tints it red.
2507        let a = annot_of(
2508            "<< /Type /Annot /Subtype /Caret /Rect [0 0 40 40] \
2509             /RD [10 10 10 10] /C [1 0 0] >>",
2510        )
2511        .expect("annotation");
2512        let s = String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content);
2513        assert!(s.contains("1.000 0.000 0.000 rg"), "red fill: {s}");
2514        // Inset rect is [10 10 30 30] → apex (20, 30).
2515        assert!(
2516            s.contains("10.000 10.000 m") && s.contains("20.000 30.000 l"),
2517            "{s}"
2518        );
2519    }
2520
2521    #[test]
2522    fn caret_empty_colour_is_transparent() {
2523        // A present-empty /C [] is spec-transparent → nothing drawn.
2524        let a = annot_of("<< /Type /Annot /Subtype /Caret /Rect [10 10 30 40] /C [] >>")
2525            .expect("annotation");
2526        assert!(a.generated.is_none(), "empty /C draws nothing");
2527    }
2528
2529    #[test]
2530    fn redact_marks_quadpoints_regions() {
2531        // /QuadPoints region, filled /IC + outlined /C → a closed polygon B.
2532        let a = annot_of(
2533            "<< /Type /Annot /Subtype /Redact /Rect [10 10 110 30] \
2534             /QuadPoints [10 30 110 30 10 10 110 10] /IC [0 0 0] /C [1 0 0] >>",
2535        )
2536        .expect("annotation");
2537        assert!(a.is_viewable());
2538        let s = String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content);
2539        assert!(s.contains("0.000 0.000 0.000 rg"), "IC fill: {s}");
2540        assert!(s.contains("1.000 0.000 0.000 RG"), "C outline: {s}");
2541        assert!(
2542            s.contains("h\nB"),
2543            "closes and fills+strokes the region: {s}"
2544        );
2545    }
2546
2547    #[test]
2548    fn redact_empty_colour_is_transparent_outline() {
2549        // A present-empty /C [] is spec-transparent: with an /IC fill, the region
2550        // is filled but NOT outlined (no black-outline fallback).
2551        let a = annot_of(
2552            "<< /Type /Annot /Subtype /Redact /Rect [10 10 110 60] \
2553             /QuadPoints [10 60 110 60 10 10 110 10] /IC [0 1 0] /C [] >>",
2554        )
2555        .expect("annotation");
2556        let s = String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content);
2557        assert!(s.contains("0.000 1.000 0.000 rg"), "IC fill present: {s}");
2558        assert!(
2559            !s.contains(" RG"),
2560            "no outline colour for transparent /C: {s}"
2561        );
2562        assert!(s.contains("h\nf"), "fills only (no stroke): {s}");
2563    }
2564
2565    #[test]
2566    fn redact_transparent_and_no_fill_draws_nothing() {
2567        // Present-empty /C [] and no /IC → nothing to paint at all.
2568        let a = annot_of(
2569            "<< /Type /Annot /Subtype /Redact /Rect [10 10 110 60] \
2570             /QuadPoints [10 60 110 60 10 10 110 10] /C [] >>",
2571        )
2572        .expect("annotation");
2573        assert!(
2574            a.generated.is_none(),
2575            "fully transparent Redact draws nothing"
2576        );
2577    }
2578
2579    #[test]
2580    fn redact_falls_back_to_rect_outline() {
2581        // No /QuadPoints, no /IC, no /C → outline the whole /Rect in black.
2582        let a = annot_of("<< /Type /Annot /Subtype /Redact /Rect [10 10 110 60] >>")
2583            .expect("annotation");
2584        let s = String::from_utf8_lossy(&a.generated.as_ref().expect("gen").content);
2585        assert!(s.contains("0.000 G"), "black outline default: {s}");
2586        assert!(s.contains(" re\nS"), "strokes the rect: {s}");
2587    }
2588
2589    #[test]
2590    fn projection_generates_no_default_appearance() {
2591        // PDF 2.0 Projection has no defined default appearance (only its own /AP).
2592        let a = annot_of("<< /Type /Annot /Subtype /Projection /Rect [10 10 30 30] >>")
2593            .expect("annotation");
2594        assert!(
2595            a.generated.is_none(),
2596            "no synthesized appearance for Projection"
2597        );
2598        assert!(!a.is_viewable());
2599    }
2600}