1use zpdf_core::{Matrix, PdfDict, PdfName, PdfObject, Rect};
31use zpdf_parser::PdfFile;
32
33use crate::forms::GeneratedAppearance;
34
35const 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;
41const MAX_APPEARANCE_BYTES: usize = 1 << 20; pub 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 if subtype == "FreeText" {
68 return free_text(file, dict, rect);
69 }
70
71 if subtype == "Stamp" {
75 return stamp(file, dict, rect);
76 }
77
78 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 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 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
128fn highlight(file: &PdfFile, dict: &PdfDict, out: &mut Vec<u8>) -> bool {
133 let Some(quads) = read_quadpoints(file, dict) else {
134 return false;
135 };
136 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 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"); let mut any = false;
187 let mut squiggle_budget = MAX_POLY_POINTS;
191 for q in &quads {
192 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 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
224fn 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 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 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 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 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 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
426fn caret(file: &PdfFile, dict: &PdfDict, rect: Rect, out: &mut Vec<u8>) -> bool {
431 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 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
454fn 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 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; }
474 let inset = if stroke.is_some() { bw / 2.0 } else { 0.0 };
480
481 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
515fn 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 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; }
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
556fn 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 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"); let border = explicit_border_width(file, dict).filter(|w| *w > 0.0);
580 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 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 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 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 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 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 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
739fn 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
751fn 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
761fn 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_icon(out, b, &c),
786 }
787 true
788}
789
790fn 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
803fn icon_lw(s: f64, frac: f64) -> f64 {
806 (s * frac).clamp(0.3, 6.0)
807}
808
809fn at(b: Rect, u: f64, v: f64) -> (f64, f64) {
812 (b.x0 + u * b.width(), b.y0 + v * b.height())
813}
814
815fn 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
831fn 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 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 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 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
868fn 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 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 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
906fn 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
917fn 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)); emit_seg(out, at(b, 0.74, 0.30), at(b, 0.66, 0.22)); emit_seg(out, at(b, 0.84, 0.20), at(b, 0.76, 0.12));
932}
933
934fn 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
960fn 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
973fn 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 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 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 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 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
1081fn 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
1108fn stamp_colour(name: &str) -> Vec<f64> {
1110 match name {
1111 "Approved" | "Accepted" | "Completed" | "Final" | "Reviewed" => {
1112 vec![0.13, 0.55, 0.20] }
1114 "Experimental" | "Sold" | "ForPublicRelease" | "InformationOnly" | "PreliminaryResults"
1115 | "Witness" | "InitialHere" | "SignHere" | "Received" => {
1116 vec![0.12, 0.22, 0.55] }
1118 _ => vec![0.72, 0.13, 0.13],
1122 }
1123}
1124
1125fn 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
1148fn 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
1166fn push_round_rect(out: &mut Vec<u8>, r: Rect, rad: f64) {
1169 const K: f64 = 0.552_284_75; 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
1229fn read_name(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
1231 dict.get(key).and_then(|o| name_of(file, o))
1232}
1233
1234#[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, }
1266}
1267
1268fn 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
1285fn 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
1297fn 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); let (wx, wy) = (-uy, ux); let lw = bw.max(0.5);
1319 let al = (lw * 3.0).clamp(6.0, 30.0); let aw = al * 0.5; let r = (lw * 1.5).clamp(3.0, 15.0); let pt = |x: f64, y: f64| format!("{} {}", fmt(x), fmt(y));
1324
1325 match style {
1326 LineEnding::OpenArrow | LineEnding::ROpenArrow => {
1327 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 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 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
1406fn 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"); return;
1418 }
1419 }
1420 out.extend_from_slice(path);
1421 push(out, "S\n"); }
1423
1424fn drawing_rect(file: &PdfFile, dict: &PdfDict, rect: Rect, bw: f64) -> Option<Rect> {
1431 let mut r = rect;
1432 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 (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
1466fn 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
1476fn push_ellipse(out: &mut Vec<u8>, r: Rect) {
1478 const K: f64 = 0.552_284_75; 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
1504fn 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 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 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
1535fn 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
1566fn 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
1594fn 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
1613fn 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
1623fn 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
1633fn 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
1650fn border_width(file: &PdfFile, dict: &PdfDict) -> f64 {
1652 explicit_border_width(file, dict).unwrap_or(1.0)
1653}
1654
1655fn 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
1683fn norm(v: (f64, f64)) -> f64 {
1689 (v.0 * v.0 + v.1 * v.1).sqrt()
1690}
1691
1692fn sub(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
1694 (a.0 - b.0, a.1 - b.1)
1695}
1696
1697#[derive(Debug)]
1704struct OrientedQuad {
1705 corners: [(f64, f64); 4],
1706 b0: (f64, f64),
1707 b1: (f64, f64),
1708 up: (f64, f64),
1709}
1710
1711fn 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 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 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 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 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
1771fn 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
1782fn 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 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], [10.0, 12.0, 110.0, 12.0, 110.0, 32.0, 10.0, 32.0], ] {
1815 let oq = oriented_quad(&q).expect("non-degenerate");
1816 assert!(approx(oq.b0.1, 12.0) && approx(oq.b1.1, 12.0), "{oq:?}");
1818 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 assert!(oriented_quad(&[0.0, 0.0, 1.0, 0.0, 2.0, 0.0, 3.0, 0.0]).is_none());
1827 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 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 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 let r2 = inset_convex_quad(c, 100.0);
1892 assert!(approx(r2[0], (4.0, 4.0)), "clamped corner: {r2:?}");
1893 assert_eq!(inset_convex_quad(c, 0.0), c);
1895 }
1896
1897 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 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 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 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 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 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 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 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 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 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 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 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 let ins = content("Insert");
2177 assert!(
2178 ins.contains("f\n") && !ins.contains("B\n"),
2179 "insert triangle: {ins}"
2180 );
2181 let key = content("Key");
2183 assert!(
2184 key.contains(" c\n") && key.contains(" l S\n"),
2185 "key ring + stem: {key}"
2186 );
2187 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 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 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 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 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 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 let mut quads = String::new();
2334 for i in 0..6000 {
2335 let x = (i % 50) as f64 * 10.0;
2336 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}