Files
pdfcraft/crates/annot/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 d3db99818f Rename PrintCraft to PdfCraft (#174)
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:57 -04:00

660 lines
33 KiB
Rust

use std::sync::Arc;
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental};
use super::*;
/// Two pages. Page 1 has an inline Square annotation in a direct /Annots array; page 2 has an
/// indirect /Annots array holding a Stamp (a type we cannot redraw).
fn fixture() -> Document {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>", // 1
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>", // 2
b"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>] >>", // 3
b"<< /Type /Page /Parent 2 0 R /Annots 5 0 R >>", // 4
b"[6 0 R]", // 5
b"<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /Name /Approved /Foo (kept) >>", // 6
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).expect("opens")
}
fn meta(id: &str) -> Meta {
Meta { date: Some("D:20261001120000Z".into()), id: id.into() }
}
fn new(page: usize, shape: Shape) -> NewAnnotation {
NewAnnotation {
page,
shape,
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None },
contents: "hello".into(),
author: "Ada".into(),
}
}
/// Save incrementally and reopen, so every check runs against what a reader would see.
fn reopen(doc: &Document) -> Document {
let bytes = write_incremental(doc, &SaveOptions::default()).expect("writes");
// A second, independent parser must accept the result too.
hayro_syntax::Pdf::new(bytes.clone()).expect("hayro-syntax parses the output");
Document::open(Arc::new(bytes)).expect("reopens")
}
fn list(doc: &Document, page: usize) -> Vec<Dict> {
let p = page_refs(doc).unwrap()[page];
annots(doc, p).iter().map(|o| doc.resolve(o).as_dict().cloned().unwrap()).collect()
}
fn ap_content(doc: &Document, d: &Dict) -> String {
let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("has /AP /N");
let obj = doc.get(n);
let Object::Stream(s) = &*obj else { panic!("AP is not a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
fn text(d: &Dict, k: &[u8]) -> String {
d.get(k).and_then(|o| o.as_string()).map(|s| s.to_text()).unwrap_or_default()
}
fn rect(d: &Dict) -> Vec<f64> {
d.get(b"Rect").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect()
}
#[test]
fn every_tool_creates_a_drawable_comment() {
let shapes = [
Shape::Note { at: [100.0, 700.0], icon: NoteIcon::Comment },
Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[72.0, 712.0, 200.0, 712.0, 72.0, 700.0, 200.0, 700.0]] },
Shape::TextMarkup { kind: Markup::Underline, quads: vec![[72.0, 612.0, 200.0, 612.0, 72.0, 600.0, 200.0, 600.0]] },
Shape::TextMarkup { kind: Markup::StrikeOut, quads: vec![[72.0, 512.0, 200.0, 512.0, 72.0, 500.0, 200.0, 500.0]] },
Shape::TextMarkup { kind: Markup::Squiggly, quads: vec![[72.0, 412.0, 200.0, 412.0, 72.0, 400.0, 200.0, 400.0]] },
Shape::Rectangle { rect: [300.0, 300.0, 200.0, 200.0] },
Shape::Oval { rect: [300.0, 300.0, 400.0, 350.0] },
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], arrow: true },
Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 30.0], [30.0, 10.0]], vec![[40.0, 40.0]]] },
Shape::TextBox { rect: [300.0, 600.0, 500.0, 650.0], font_size: 14.0 },
];
let mut doc = fixture();
for (i, s) in shapes.iter().enumerate() {
let idx = add_annotation(&mut doc, &new(0, s.clone()), &meta(&format!("id-{i}"))).expect("adds");
assert!(idx >= 1, "appended after the existing inline annotation");
}
let doc = reopen(&doc);
let all = list(&doc, 0);
// The inline square, 10 comments and the sticky note's pop-up.
assert_eq!(all.len(), 12);
let subtypes: Vec<&[u8]> = all.iter().map(|d| d.name(b"Subtype").unwrap()).collect();
assert_eq!(
subtypes,
[
&b"Square"[..],
b"Text",
b"Popup",
b"Highlight",
b"Underline",
b"StrikeOut",
b"Squiggly",
b"Square",
b"Circle",
b"Line",
b"Ink",
b"FreeText"
]
);
for d in all.iter().filter(|d| d.contains(b"NM")) {
assert_eq!(text(d, b"T"), "Ada");
assert_eq!(text(d, b"Contents"), "hello");
assert!(d.reference(b"P").is_some(), "/P points at the page");
assert!(!ap_content(&doc, d).is_empty(), "{:?} has an appearance", d.name(b"Subtype"));
}
// Highlight multiplies; the rectangle was normalized.
let hl = &all[3];
let ap = hl.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let res = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap();
let gs = res.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap().clone();
assert_eq!(gs.name(b"BM"), Some(&b"Multiply"[..]));
assert_eq!(rect(&all[7]), [200.0, 200.0, 300.0, 300.0]);
// The arrow's rectangle includes the arrowhead and the stroke.
let r = rect(&all[9]);
assert!(r[0] < 50.0 && r[2] > 150.0 + 1.0);
// The text box draws its contents in Helvetica.
assert!(ap_content(&doc, &all[11]).contains("(hello) Tj"));
assert!(ap_content(&doc, &all[11]).contains("/Helv 14 Tf"));
// The note is a fixed-size icon with a closed pop-up.
assert_eq!(all[1].int(b"F"), Some(28));
let entries = annots(&doc, page_refs(&doc).unwrap()[0]);
assert_eq!(all[2].reference(b"Parent"), entries[1].as_ref());
assert_eq!(all[1].reference(b"Popup"), entries[2].as_ref());
}
#[test]
fn indirect_annots_array_is_updated_in_place() {
let mut doc = fixture();
add_annotation(&mut doc, &new(1, Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }), &meta("x")).unwrap();
let doc = reopen(&doc);
let page = doc.get(page_refs(&doc).unwrap()[1]);
assert_eq!(page.as_dict().unwrap().reference(b"Annots"), Some(ObjRef::new(5, 0)));
assert_eq!(doc.get(ObjRef::new(5, 0)).as_array().unwrap().len(), 2);
}
#[test]
fn invalid_geometry_is_rejected() {
let mut doc = fixture();
let bad = [
Shape::TextMarkup { kind: Markup::Highlight, quads: vec![] },
Shape::Rectangle { rect: [0.0, 0.0, 0.5, 10.0] },
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], arrow: false },
Shape::Ink { strokes: vec![vec![]] },
Shape::Note { at: [f64::NAN, 0.0], icon: NoteIcon::Note },
];
for s in bad {
assert!(matches!(add_annotation(&mut doc, &new(0, s.clone()), &meta("x")), Err(AnnotError::Invalid(_))), "{s:?}");
}
assert_eq!(add_annotation(&mut doc, &new(5, Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }), &meta("x")), Err(AnnotError::NoSuchPage(5)));
assert!(!doc.is_modified(), "failed adds change nothing");
}
#[test]
fn replies_and_status_thread_onto_the_parent() {
let mut doc = fixture();
// Replying to the inline square promotes it to an indirect object.
let r = add_reply(&mut doc, 0, 0, "Agreed", "Bob", &meta("r1")).unwrap();
let s = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("r2")).unwrap();
assert_eq!((r, s), (1, 2));
let doc = reopen(&doc);
let p = page_refs(&doc).unwrap()[0];
let entries = annots(&doc, p);
let parent = entries[0].as_ref().expect("promoted to an indirect object");
let all = list(&doc, 0);
assert_eq!(all[1].reference(b"IRT"), Some(parent));
assert_eq!(text(&all[1], b"Contents"), "Agreed");
assert_eq!(rect(&all[1]), [10.0, 10.0, 50.0, 50.0]);
assert_eq!(ap_content(&doc, &all[1]), "", "replies draw nothing");
assert_eq!(text(&all[2], b"State"), "Accepted");
assert_eq!(text(&all[2], b"StateModel"), "Review");
assert_eq!(text(&all[2], b"Contents"), "Accepted set by Bob");
}
#[test]
fn delete_takes_popup_and_reply_chain() {
let mut doc = fixture();
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap();
add_reply(&mut doc, 0, reply, "reply to reply", "Ada", &meta("r2")).unwrap();
add_reply(&mut doc, 0, 0, "on the square", "Ada", &meta("r3")).unwrap();
assert_eq!(list(&doc, 0).len(), 6);
delete_annotation(&mut doc, 0, note).unwrap();
let doc = reopen(&doc);
let left: Vec<String> =
list(&doc, 0).iter().map(|d| format!("{}:{}", String::from_utf8_lossy(d.name(b"Subtype").unwrap()), text(d, b"Contents"))).collect();
assert_eq!(left, ["Square:", "Text:on the square"]);
let mut doc = doc;
assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 }));
}
#[test]
fn deleting_the_last_comment_removes_annots() {
let mut doc = fixture();
delete_annotation(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
assert!(!doc.get(page_refs(&doc).unwrap()[0]).as_dict().unwrap().contains(b"Annots"));
}
#[test]
fn move_shifts_all_geometry_and_the_popup() {
let mut doc = fixture();
let q = [72.0, 712.0, 200.0, 712.0, 72.0, 700.0, 200.0, 700.0];
let h = add_annotation(&mut doc, &new(0, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![q] }), &meta("h")).unwrap();
let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 20.0]]] }), &meta("i")).unwrap();
let n = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Note }), &meta("n")).unwrap();
for idx in [h, i, n] {
move_annotation(&mut doc, 0, idx, 10.0, -5.0, &meta("")).unwrap();
}
let doc = reopen(&doc);
let all = list(&doc, 0);
let qp: Vec<f64> = all[h].get(b"QuadPoints").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(qp, [82.0, 707.0, 210.0, 707.0, 82.0, 695.0, 210.0, 695.0]);
let ink = all[i].get(b"InkList").unwrap().as_array().unwrap()[0].as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect::<Vec<_>>();
assert_eq!(ink, [20.0, 5.0, 30.0, 15.0]);
assert_eq!(rect(&all[n]), [110.0, 75.0, 130.0, 95.0]);
let popup = &all[n + 1];
assert_eq!(rect(popup)[0], 140.0);
}
#[test]
fn restyle_and_resize_redraw_the_appearance() {
let mut doc = fixture();
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), &meta("")).unwrap();
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
let ap = ap_content(&doc2, d);
assert!(ap.contains("1 0 0 RG") && ap.contains("4 w") && ap.contains("/GS0 gs"), "{ap}");
assert!(ap.contains("2 2 196 96 re"), "inset by half the border: {ap}");
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
let before = list(&doc, 1)[0].clone();
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
assert_eq!(list(&doc, 1)[0], before);
// Lines can't be resized as rectangles.
let l = add_annotation(&mut doc, &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], arrow: false }), &meta("l")).unwrap();
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
}
#[test]
fn text_box_text_and_colour_changes_are_drawn() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[t];
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
assert_eq!(text(d, b"DA"), "1 0 0 rg /Helv 12 Tf");
let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
let raw = s.decoded().unwrap();
// WinAnsi bytes, with the parentheses escaped.
let needle = b"(Caf\xe9 \\(draft\\) \x97 100%) Tj";
assert!(raw.windows(needle.len()).any(|w| w == needle), "{}", String::from_utf8_lossy(&raw));
assert!(String::from_utf8_lossy(&raw).contains("1 0 0 rg"));
// Rich text follows the plain text and the style.
assert_eq!(text(d, b"DS"), "font: Helvetica 12pt; text-align:left; color:#FF0000");
let rc = text(d, b"RC");
assert!(rc.starts_with("<?xml") && rc.contains("<p dir=\"ltr\">Caf\u{e9} (draft) \u{2014} 100%</p>") && rc.contains("color:#FF0000"), "{rc}");
}
/// The rectangle a text box needs for `text` (the appearance's padding plus the 2 pt slack):
/// the wrap width and top edge stay, the height fits the wrapped lines.
fn expected_box(top: f64, w: f64, text: &str, size: f64, border: f64) -> [f64; 4] {
let pad = 2.0 + border;
let lines = appearance::wrap(text, size, w - 2.0 * pad).len().max(1) as f64;
[0.0, top - lines * size * 1.2 - 2.0 * pad - 2.0, w, 0.0]
}
#[test]
fn editing_a_text_box_refits_its_rectangle() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 304.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
// The style's border is 2 pt wide, so the text is padded by 2 + 2.
let long = "the quick brown fox jumps over the lazy dog and keeps going";
set_contents(&mut doc, 0, t, long, &meta("")).unwrap();
let d = &list(&doc, 0)[t];
let r = rect(d);
assert_eq!((r[0], r[2], r[3]), (100.0, 304.0, 692.0), "width and top edge stay");
let want = expected_box(692.0, 204.0, long, 12.0, 2.0);
assert_eq!(r[1], want[1], "the height fits the wrapped lines");
// The appearance covers the new rectangle, and every wrapped line is drawn.
let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let bbox: Vec<f64> = doc.get(n).as_dict().unwrap().get(b"BBox").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(bbox, r);
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 204.0 - 2.0 * 4.0).len(), "nothing is clipped");
// Shorter text shrinks the box back to one line.
set_contents(&mut doc, 0, t, "Hi", &meta("")).unwrap();
let r = rect(&list(&doc, 0)[t]);
assert_eq!(r, [100.0, expected_box(692.0, 204.0, "Hi", 12.0, 2.0)[1], 304.0, 692.0]);
}
#[test]
fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
let mut doc = fixture();
let c = add_annotation(
&mut doc,
&new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }),
&meta("c"),
)
.unwrap();
let long = "the quick brown fox jumps over the lazy dog and keeps going";
set_contents(&mut doc, 0, c, long, &meta("")).unwrap();
let d = &list(&doc, 0)[c];
let outer = rect(d);
let f = |k: &[u8]| -> Vec<f64> { d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() };
let cl = f(b"CL");
// The leader line keeps its start and knee; the attach point follows the re-fitted box.
assert_eq!(&cl[..4], &[200.0, 520.0, 260.0, 560.0]);
let want = expected_box(612.0, 200.0, long, 12.0, 2.0);
assert_eq!((cl[4], cl[5]), callout_attach([300.0, want[1], 500.0, 612.0], [260.0, 560.0]).into());
// The text box (recovered from /Rect and /RD) keeps its width and top edge.
let tb = [outer[0] + f(b"RD")[0], outer[1] + f(b"RD")[1], outer[2] - f(b"RD")[2], outer[3] - f(b"RD")[3]];
assert_eq!((tb[0], tb[2], tb[3]), (300.0, 500.0, 612.0));
assert_eq!(tb[1], want[1]);
// /Rect still grows to hold the leader line: its padded bounds are [186, 506, 274, 574].
assert_eq!((outer[0], outer[2], outer[3]), (186.0, 500.0, 612.0));
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 200.0 - 2.0 * 4.0).len(), "nothing is clipped");
}
#[test]
fn a_locked_text_box_still_refits_its_text() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 304.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
set_locked(&mut doc, 0, t, true).unwrap();
// Resizing is refused, but the text (and its re-fit) stays editable, as in Acrobat.
assert!(matches!(set_rect(&mut doc, 0, t, [0.0, 0.0, 50.0, 50.0], &meta("")), Err(AnnotError::Invalid(_))));
let long = "the quick brown fox jumps over the lazy dog";
set_contents(&mut doc, 0, t, long, &meta("")).unwrap();
let r = rect(&list(&doc, 0)[t]);
assert_eq!(r[3], 692.0);
let want = expected_box(692.0, 204.0, long, 12.0, 2.0);
assert_eq!(r[1], want[1], "the box grew to fit the wrapped lines");
}
#[test]
fn unknown_keys_survive_edits() {
let mut doc = fixture();
move_annotation(&mut doc, 1, 0, 1.0, 1.0, &meta("")).unwrap();
set_contents(&mut doc, 1, 0, "note", &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 1)[0];
assert_eq!(text(d, b"Foo"), "kept");
assert_eq!(d.name(b"Name"), Some(&b"Approved"[..]));
assert_eq!(rect(d), [101.0, 101.0, 201.0, 151.0]);
}
#[test]
fn wrapping_and_widths() {
assert!(appearance::text_width("MMMM", 10.0) > appearance::text_width("iiii", 10.0) * 2.0);
let lines = appearance::wrap("the quick brown fox jumps over the lazy dog", 12.0, 80.0);
assert!(lines.len() > 2);
assert!(lines.iter().all(|l| appearance::text_width(l, 12.0) <= 80.0));
assert_eq!(appearance::wrap("a\nb", 12.0, 100.0), ["a", "b"]);
// A word longer than the line is broken.
let long = appearance::wrap("Supercalifragilisticexpialidocious", 12.0, 40.0);
assert!(long.len() > 3 && long.concat() == "Supercalifragilisticexpialidocious");
assert_eq!(n(1.0), "1");
assert_eq!(n(-0.0001), "0");
assert_eq!(n(2.50), "2.5");
}
#[test]
fn fill_and_sign_items_are_drawn() {
let mut doc = fixture();
let add = |doc: &mut Document, shape: Shape, contents: &str| {
let style = Style::default_for(&shape);
add_annotation(doc, &NewAnnotation { page: 1, shape, style, contents: contents.into(), author: "Ada".into() }, &meta("x")).unwrap()
};
let t = add(&mut doc, Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 }, "Ada Lovelace");
let marks: Vec<usize> = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line]
.into_iter()
.enumerate()
.map(|(k, m)| add(&mut doc, Shape::Mark { rect: [100.0 + 20.0 * k as f64, 600.0, 114.0 + 20.0 * k as f64, 614.0], mark: m }, ""))
.collect();
let s = add(&mut doc, Shape::Signature { strokes: vec![vec![[10.0, 10.0], [30.0, 20.0], [50.0, 10.0]]] }, "");
let doc = reopen(&doc);
let all = list(&doc, 1);
assert_eq!(all[t].name(b"IT"), Some(&b"FreeTextTypeWriter"[..]));
assert!(ap_content(&doc, &all[t]).contains("(Ada Lovelace) Tj"));
for (k, m) in marks.iter().enumerate() {
assert_eq!(all[*m].name(b"Subtype"), Some(&b"Stamp"[..]));
let ap = ap_content(&doc, &all[*m]);
assert!(ap.contains(if k == 2 { " c" } else { " l" }), "mark {k}: {ap}");
}
assert_eq!(text(&all[marks[0]], b"Subj"), "Checkmark");
assert_eq!(text(&all[s], b"Subj"), "Signature");
// Other stamps still can't be restyled; ours can.
let mut doc = doc;
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap();
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err());
}
#[test]
fn author_subject_and_note_icon_change() {
let mut doc = fixture();
let n = add_annotation(&mut doc, &new(0, Shape::Note { at: [10.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
let before = ap_content(&doc, &list(&doc, 0)[n]);
set_info(&mut doc, 0, n, Some("Grace"), Some("Question"), Some(NoteIcon::Help), &meta("")).unwrap();
let d = &list(&doc, 0)[n];
assert_eq!((text(d, b"T").as_str(), text(d, b"Subj").as_str()), ("Grace", "Question"));
assert_eq!(d.name(b"Name"), Some(&b"Help"[..]));
assert_ne!(ap_content(&doc, d), before, "the icon was redrawn");
assert!(matches!(set_info(&mut doc, 0, 0, None, None, Some(NoteIcon::Key), &meta("")), Err(AnnotError::Invalid(_))));
}
#[test]
fn stamps_draw_their_label_and_by_line() {
let mut doc = fixture();
let shape =
Shape::Stamp { rect: [100.0, 100.0, 260.0, 142.0], stamp: StampKind::DynApproved, by: Some("By Ada at 2:14 pm, Oct 02, 2026".into()) };
let i = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() },
&Meta::default(),
)
.unwrap();
let s = &summaries(&doc)[0];
assert_eq!((s.subtype.as_str(), s.index), ("Stamp", i));
let ap = ap_content(&doc, &list(&doc, 0)[i]);
assert!(ap.contains("(APPROVED) Tj") && ap.contains("(By Ada at 2:14 pm, Oct 02, 2026) Tj") && ap.contains("/HelvB"), "{ap}");
let sign = Shape::Stamp { rect: [100.0, 300.0, 220.0, 330.0], stamp: StampKind::SignHere, by: None };
let j = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&sign), shape: sign, contents: String::new(), author: "Ada".into() },
&Meta::default(),
)
.unwrap();
assert!(ap_content(&doc, &list(&doc, 0)[j]).contains("(SIGN HERE) Tj"));
assert_eq!(StampKind::from_name(b"Approved"), Some(StampKind::Approved), "standard names");
assert!(StampKind::ALL.iter().all(|k| k.size().0 >= 80.0));
}
#[test]
fn links_are_added_edited_listed_and_removed() {
use crate::links::{self, Highlight, LinkAction, LinkStyle};
let mut doc = fixture();
let before = links::list(&doc).len();
let i = links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap();
let style = LinkStyle { visible: true, color: [1.0, 0.0, 0.0], width: 2.0, dashed: true, underline: false, highlight: Highlight::Outline };
links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page(0), &style).unwrap();
let doc = reopen(&doc);
let all = links::list(&doc);
assert_eq!(all.len(), before + 2);
let web = all.iter().find(|l| l.page == 0 && l.index == i).unwrap();
assert_eq!(
(web.action.clone(), web.style.visible, web.style.highlight),
(LinkAction::Uri("https://example.org".into()), false, Highlight::Invert)
);
let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page(0)).unwrap().clone();
assert_eq!(page.style, style);
let mut doc = doc;
links::set(&mut doc, 1, page.index, Some([0.0, 0.0, 50.0, 20.0]), Some(&LinkAction::Uri("mailto:ada@example.org".into())), None).unwrap();
let changed = links::list(&doc).into_iter().find(|l| l.page == 1 && l.index == page.index).unwrap();
assert_eq!((changed.rect, changed.action), ([0.0, 0.0, 50.0, 20.0], LinkAction::Uri("mailto:ada@example.org".into())));
links::delete(&mut doc, 1, page.index).unwrap();
assert!(links::delete(&mut doc, 0, 999).is_err(), "no such annotation");
let n = links::remove_all(&mut doc, None).unwrap();
assert!(n >= 1 && links::list(&doc).is_empty());
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
}
#[test]
fn urls_are_found_in_text() {
let t: Vec<char> = "See https://example.org/a?b=1, or www.rust-lang.org. Not a.b or http:/x.".chars().collect();
let found: Vec<String> = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect();
assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]);
}
#[test]
fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
let mut doc = fixture();
let tri = vec![[100.0, 100.0], [200.0, 100.0], [150.0, 180.0]];
let i = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: false }), &meta("p")).unwrap();
let c = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: true }), &meta("c")).unwrap();
let l =
add_annotation(&mut doc, &new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]] }), &meta("l")).unwrap();
let co = add_annotation(
&mut doc,
&new(0, Shape::Callout { rect: [300.0, 500.0, 420.0, 540.0], knee: [260.0, 520.0], point: [220.0, 460.0], font_size: 10.0 }),
&meta("co"),
)
.unwrap();
let k = add_annotation(&mut doc, &new(0, Shape::Caret { rect: [50.0, 600.0, 60.0, 612.0] }), &meta("k")).unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let p = &a[i];
assert_eq!(p.name(b"Subtype"), Some(&b"Polygon"[..]));
assert_eq!(text(p, b"Subj"), "Polygon");
let ap = ap_content(&doc, p);
assert!(ap.contains("100 100 m") && ap.contains("150 180 l") && ap.contains("h\nS"), "{ap}");
let r = rect(p);
assert!(r[0] <= 99.0 && r[3] >= 181.0, "{r:?}");
let cl = &a[c];
assert_eq!((text(cl, b"Subj").as_str(), cl.name(b"IT")), ("Cloud", Some(&b"PolygonCloud"[..])));
let ap = ap_content(&doc, cl);
assert!(ap.matches(" c\n").count() >= 6, "bumps along every edge: {ap}");
// Bumps bulge outwards: the outline reaches below the bottom edge.
let r = rect(cl);
assert!(r[1] < 100.0 - appearance::cloud_radius(2.0), "{r:?}");
let pl = &a[l];
assert_eq!((pl.name(b"Subtype"), text(pl, b"Subj").as_str()), (Some(&b"PolyLine"[..]), "Polygonal Line"));
let ap = ap_content(&doc, pl);
assert!(ap.contains("400 300 l") && !ap.contains("h\n"), "open path: {ap}");
let callout = &a[co];
assert_eq!(callout.name(b"IT"), Some(&b"FreeTextCallout"[..]));
let line: Vec<f64> = callout.get(b"CL").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(line, vec![220.0, 460.0, 260.0, 520.0, 300.0, 520.0], "attached to the box's left side");
let r = rect(callout);
assert!(r[0] < 220.0 && r[1] < 460.0 && r[2] >= 420.0 && r[3] >= 540.0, "{r:?}");
let ap = ap_content(&doc, callout);
assert!(ap.contains("220 460 m") && ap.contains("(hello) Tj"), "{ap}");
let caret = &a[k];
assert_eq!((caret.name(b"Subtype"), text(caret, b"Subj").as_str()), (Some(&b"Caret"[..]), "Inserted Text"));
assert!(ap_content(&doc, caret).contains(" c h f"));
// Resizing a callout moves its text box; the leader re-attaches.
let mut doc = doc;
set_rect(&mut doc, 0, co, [100.0, 500.0, 200.0, 540.0], &meta("x")).unwrap();
let callout = &list(&doc, 0)[co];
let line: Vec<f64> = callout.get(b"CL").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(&line[4..], &[200.0, 520.0], "now the right side");
assert!(ap_content(&doc, callout).contains("(hello) Tj"));
// Moving shifts vertices.
move_annotation(&mut doc, 0, i, 10.0, 0.0, &meta("m")).unwrap();
let v: Vec<f64> = list(&doc, 0)[i].get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(v[0], 110.0);
for bad in [
Shape::Polygon { vertices: vec![[0.0, 0.0], [10.0, 10.0]], cloud: false },
Shape::PolyLine { vertices: vec![[0.0, 0.0]] },
Shape::Caret { rect: [0.0, 0.0, 0.0, 10.0] },
] {
assert!(add_annotation(&mut doc, &new(0, bad), &meta("b")).is_err());
}
}
#[test]
fn cloud_paths_bulge_outwards_either_way_round() {
let ccw = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let cw: Vec<(f64, f64)> = ccw.iter().rev().copied().collect();
for pts in [ccw.to_vec(), cw] {
let p = cloud_ys(&appearance::cloud_path(&pts, 5.0));
assert!(p.iter().any(|y| *y < -3.0) && p.iter().any(|y| *y > 103.0), "{p:?}");
assert!(p.iter().all(|y| *y > -10.0 && *y < 110.0));
}
}
/// Every y coordinate of the curve control points in a cloud path.
fn cloud_ys(path: &str) -> Vec<f64> {
path.lines()
.filter(|l| l.ends_with(" c"))
.flat_map(|l| {
let v: Vec<f64> = l.split_whitespace().filter_map(|t| t.parse().ok()).collect();
[v[1], v[3]]
})
.collect()
}
#[test]
fn replace_text_groups_a_strikeout_and_a_caret() {
let mut doc = fixture();
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let strike = &a[i];
assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..])));
let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..])));
assert!(caret.reference(b"IRT").is_some());
let r = rect(caret);
assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}");
// Deleting the strikeout takes the caret with it (the group).
let mut doc = doc;
delete_annotation(&mut doc, 0, i).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err());
}
#[test]
fn files_attach_as_comments() {
let mut doc = fixture();
let data = b"col1,col2\n1,2\n".to_vec();
let shape = Shape::Attachment { at: [300.0, 500.0], icon: AttachIcon::Paperclip, file: "data.csv".into(), data: data.clone() };
let i = add_annotation(
&mut doc,
&NewAnnotation { page: 0, shape, style: Style::default(), contents: String::new(), author: "Ada".into() },
&meta("f"),
)
.unwrap();
let doc = reopen(&doc);
let a = &list(&doc, 0)[i];
assert_eq!((a.name(b"Subtype"), a.name(b"Name")), (Some(&b"FileAttachment"[..]), Some(&b"Paperclip"[..])));
assert_eq!(text(a, b"Contents"), "data.csv", "the file name describes it");
let fs = doc.resolve(a.get(b"FS").unwrap()).as_dict().cloned().unwrap();
assert_eq!(text(&fs, b"UF"), "data.csv");
let ef = fs.get(b"EF").and_then(|e| e.as_dict()).and_then(|e| e.reference(b"F")).unwrap();
let Object::Stream(s) = &*doc.get(ef) else { panic!() };
assert_eq!(s.decoded().unwrap(), data);
assert!(ap_content(&doc, a).contains(" c\n") || ap_content(&doc, a).contains(" c "), "a drawn paperclip");
let mut doc = doc;
let bad = Shape::Attachment { at: [0.0, 0.0], icon: AttachIcon::Tag, file: " ".into(), data: Vec::new() };
assert!(
add_annotation(
&mut doc,
&NewAnnotation { page: 0, shape: bad, style: Style::default(), contents: String::new(), author: String::new() },
&meta("x")
)
.is_err()
);
}
#[test]
fn the_eraser_cuts_strokes_and_removes_empty_drawings() {
let mut doc = fixture();
let line = |y: f64| (0..=10).map(|k| [100.0 + k as f64 * 20.0, y]).collect::<Vec<_>>();
let shape = Shape::Ink { strokes: vec![line(500.0), line(400.0)] };
let i = add_annotation(&mut doc, &new(0, shape), &meta("i")).unwrap();
// A vertical swipe through the middle of both lines.
assert!(erase_ink(&mut doc, 0, i, &[[200.0, 550.0], [200.0, 350.0]], 5.0, &meta("e")).unwrap());
let d = &list(&doc, 0)[i];
let strokes = d.get(b"InkList").unwrap().as_array().unwrap().len();
assert_eq!(strokes, 4, "each line cut in two");
assert!(!erase_ink(&mut doc, 0, i, &[[600.0, 100.0]], 5.0, &meta("e")).unwrap(), "nothing under the eraser");
// Rubbing out everything deletes the drawing.
let all: Vec<[f64; 2]> = (0..=20).map(|k| [100.0 + k as f64 * 10.0, 500.0]).chain((0..=20).map(|k| [100.0 + k as f64 * 10.0, 400.0])).collect();
erase_ink(&mut doc, 0, i, &all[..21], 8.0, &meta("e")).unwrap();
erase_ink(&mut doc, 0, i, &all[21..], 8.0, &meta("e")).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Ink")));
}