mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
* D5: README for cos and engine, and an honest cos status line Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets. Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes. Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Preserve alternate appearances and indirect push-button values Keep rollover, down and custom appearance entries when normal appearances are regenerated, without modifying shared dictionaries. Resolve indirect button caption and layout values. Cover full and incremental saved output, and wait for raster readiness in the existing Linux scroll test. Co-authored-by: Codex <codex@openai.com> * M14: let neutral Windows locales fall through to display language Co-authored-by: Codex <codex@openai.com> * pdfcraft-cli --help lists `mcp` only when the build has it Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed `pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command". USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead of calling it an unknown command. - tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and mcp_without_the_feature_says_it_was_left_out (without the feature) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * engine README: the execution plan is local-only, so don't link a path that doesn't exist --------- Co-authored-by: Tanish Manku <tmanku.dev@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Streetblock <github@streetblock.de> Co-authored-by: lloyd <lloyd@stackworx.io> Co-authored-by: Brandon Thomas <echelon@gmail.com>
922 lines
46 KiB
Rust
922 lines
46 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").map(|a| doc.resolve(a)).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], start: LineEnding::None, end: LineEnding::OpenArrow },
|
|
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());
|
|
}
|
|
|
|
/// #260 (page 7): Acrobat draws an Ink annotation as a smooth curve through its `/InkList`
|
|
/// points, where PdfCraft drew straight segments between them. Three or more points now make a
|
|
/// Catmull-Rom spline through every point (as cubic Béziers); two points stay a line, one a dot.
|
|
#[test]
|
|
fn ink_strokes_are_smooth_curves_through_their_points() {
|
|
let mut doc = fixture();
|
|
let strokes = vec![vec![[0.0, 0.0], [10.0, 10.0], [20.0, 0.0]], vec![[30.0, 0.0], [40.0, 10.0]], vec![[50.0, 50.0]]];
|
|
let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes }), &meta("ink")).unwrap();
|
|
let ap = ap_content(&doc, &list(&doc, 0)[i]);
|
|
// Tangents at each point run parallel to the chord between its neighbours (the ends use
|
|
// their own point): (0,0)→(10,10) leaves along (10,10) and arrives along (20,0).
|
|
assert!(ap.contains("0 0 m\n1.667 1.667 6.667 10 10 10 c\n13.333 10 18.333 1.667 20 0 c\nS\n"), "{ap}");
|
|
assert!(ap.contains("30 0 m\n40 10 l\nS\n"), "{ap}");
|
|
assert!(ap.contains("50 50 m\n50.01 50 l\nS\n"), "{ap}");
|
|
// /Rect holds the whole curve: rising to (20, 20) and dropping to (21, 0), it swings above
|
|
// y 20 (its control point is at y 21.667), beyond the points' own bounds plus the margin.
|
|
let rise = Shape::Ink { strokes: vec![vec![[0.0, 0.0], [10.0, 10.0], [20.0, 20.0], [21.0, 0.0]]] };
|
|
let j = add_annotation(&mut doc, &new(0, rise), &meta("rise")).unwrap();
|
|
let top = rect(&list(&doc, 0)[j])[3];
|
|
assert!(top >= 21.667 + 2.0 - 0.01, "/Rect top {top}");
|
|
}
|
|
|
|
#[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], start: LineEnding::None, end: LineEnding::None },
|
|
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), None, &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, 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], start: LineEnding::None, end: LineEnding::None }),
|
|
&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, 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,
|
|
ending: LineEnding::OpenArrow,
|
|
},
|
|
),
|
|
&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, None, &meta("")).unwrap();
|
|
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, 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]], start: LineEnding::None, end: LineEnding::None }),
|
|
&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,
|
|
ending: LineEnding::OpenArrow,
|
|
},
|
|
),
|
|
&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]], start: LineEnding::None, end: LineEnding::None },
|
|
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")));
|
|
}
|
|
|
|
/// A one-page document from object bodies (object 1 is the catalog, 3 the page).
|
|
fn document_of(objs: &[&[u8]]) -> Document {
|
|
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")
|
|
}
|
|
|
|
#[test]
|
|
fn comments_without_appearances_get_one_for_display_only() {
|
|
let doc = document_of(&[
|
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
|
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>",
|
|
b"<< /Type /Annot /Subtype /FreeText /Rect [48 582 285 632] /Contents (FreeText annotation with valid /DA string.) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>",
|
|
b"<< /Type /Annot /Subtype /Ink /Rect [70 392 275 472] /InkList [[80 402 120 452 160 412 210 462 260 417]] /Border [0 0 2] /C [.49 .18 .07] >>",
|
|
b"<< /Type /Annot /Subtype /Text /Rect [400 717 420 737] /C [1 1 0] /Contents (A note) >>",
|
|
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /AP << /N 11 0 R >> >>",
|
|
b"<< /Type /Annot /Subtype /Link /Rect [48 717 308 741] /Border [0 0 1] /C [0 0 1] >>",
|
|
b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (f) /Rect [10 100 100 120] >>",
|
|
b"<< /Type /Annot /Subtype /Sound /Rect [10 200 30 220] >>",
|
|
b"<< /Type /XObject /Subtype /Form /BBox [10 10 50 50] /Length 0 >>\nstream\n\nendstream",
|
|
]);
|
|
let display = with_missing_appearances(&doc).expect("three comments need an appearance");
|
|
let has_ap = |doc: &Document| list(doc, 0).iter().map(|d| d.get(b"AP").is_some()).collect::<Vec<_>>();
|
|
assert_eq!(has_ap(&doc), [false, false, false, true, false, false, false], "the document itself is untouched");
|
|
assert_eq!(has_ap(&display), [true, true, true, true, false, false, false], "FreeText, Ink and Text get one; the rest don't");
|
|
let shown = list(&display, 0);
|
|
let free_text = ap_content(&display, &shown[0]);
|
|
assert!(free_text.contains("re f") && free_text.contains("Tj"), "background box and text: {free_text}");
|
|
assert_eq!(shown[3].get(b"AP"), list(&doc, 0)[3].get(b"AP"), "an existing appearance is kept");
|
|
// The display copy survives a save and reopen (what the renderer reads).
|
|
assert_eq!(has_ap(&reopen(&display)), [true, true, true, true, false, false, false]);
|
|
// Nothing to add: no copy.
|
|
let complete = document_of(&[
|
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
|
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
|
b"<< /Type /Annot /Subtype /Link /Rect [48 717 308 741] >>",
|
|
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /AP << /N 6 0 R >> >>",
|
|
b"<< /Type /XObject /Subtype /Form /BBox [10 10 50 50] /Length 0 >>\nstream\n\nendstream",
|
|
]);
|
|
assert!(with_missing_appearances(&complete).is_none());
|
|
}
|
|
|
|
fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict {
|
|
let mut d = pdfcraft_cos::Dict::new();
|
|
d.set(b"Subtype".to_vec(), Object::name("Line"));
|
|
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 300.0, 300.0].into_iter().map(Object::Real).collect()));
|
|
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
|
|
d.set(b"L".to_vec(), Object::Array(coords.into_iter().map(Object::Real).collect()));
|
|
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name(ending)]));
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn every_line_ending_is_drawn() {
|
|
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
|
|
let (mut open, mut reverse) = (String::new(), String::new());
|
|
for ending in LineEnding::ALL {
|
|
let mut doc = fixture();
|
|
add_annotation(
|
|
&mut doc,
|
|
&new(0, Shape::Line { from: [80.0, 100.0], to: [200.0, 160.0], start: LineEnding::None, end: ending }),
|
|
&meta("ending"),
|
|
)
|
|
.unwrap();
|
|
let doc = reopen(&doc);
|
|
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
|
let content = ap_content(&doc, &line);
|
|
assert!(!content.is_empty(), "{ending:?}");
|
|
let paints = content.contains("h B") || content.contains("B\n");
|
|
assert_eq!(paints, filled.contains(&ending), "{ending:?} fill: {content}");
|
|
if ending != LineEnding::None {
|
|
let r = rect(&line);
|
|
assert!(r[0] < 70.0 && r[2] > 210.0, "{ending:?} rectangle includes the ending: {r:?}");
|
|
}
|
|
if ending == LineEnding::OpenArrow {
|
|
open = content.clone();
|
|
}
|
|
if ending == LineEnding::ROpenArrow {
|
|
reverse = content;
|
|
}
|
|
}
|
|
assert_ne!(open, reverse, "a reverse arrow points the other way");
|
|
|
|
let mut doc = fixture();
|
|
let index = add_annotation(
|
|
&mut doc,
|
|
&new(0, Shape::Line { from: [40.0, 40.0], to: [140.0, 40.0], start: LineEnding::None, end: LineEnding::None }),
|
|
&meta("plain"),
|
|
)
|
|
.unwrap();
|
|
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
|
|
set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
|
|
let mut doc = reopen(&doc);
|
|
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
|
let after = rect(&line);
|
|
assert!(after[1] < before[1] - 5.0 && after[3] > before[3] + 5.0, "the square is not clipped: {before:?} → {after:?}");
|
|
assert!(ap_content(&doc, &line).contains("re h B"), "square ending");
|
|
|
|
add_annotation(
|
|
&mut doc,
|
|
&new(0, Shape::PolyLine { vertices: vec![[40.0, 200.0], [90.0, 260.0], [150.0, 200.0]], start: LineEnding::Circle, end: LineEnding::Slash }),
|
|
&meta("poly"),
|
|
)
|
|
.unwrap();
|
|
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,
|
|
ending: LineEnding::Diamond,
|
|
},
|
|
),
|
|
&meta("call"),
|
|
)
|
|
.unwrap();
|
|
let doc = reopen(&doc);
|
|
let all = list(&doc, 0);
|
|
let poly = all.iter().find(|d| d.name(b"Subtype") == Some(b"PolyLine")).expect("polyline");
|
|
let poly_ap = ap_content(&doc, poly);
|
|
assert!(poly_ap.contains("B\n") && poly_ap.contains(" l S"), "circle fills and the slash only strokes: {poly_ap}");
|
|
let call = all.iter().find(|d| d.name(b"IT") == Some(b"FreeTextCallout")).expect("callout");
|
|
assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice()));
|
|
assert!(ap_content(&doc, call).contains("h B"), "diamond callout");
|
|
|
|
assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
|
|
assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
|
|
}
|
|
|
|
#[test]
|
|
fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() {
|
|
let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")];
|
|
for indirect in [false, true] {
|
|
let mut doc = fixture();
|
|
let first = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("first")).unwrap();
|
|
let other = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [120.0, 10.0, 220.0, 60.0] }), &meta("other")).unwrap();
|
|
let (_, first_ref) = annot_ref(&mut doc, 0, first).unwrap();
|
|
let (_, other_ref) = annot_ref(&mut doc, 0, other).unwrap();
|
|
let mut entries = annot_dict(&doc, first_ref).get(b"AP").unwrap().as_dict().unwrap().clone();
|
|
for (key, bytes) in alternates {
|
|
let mut d = Dict::new();
|
|
d.set(b"Type".to_vec(), Object::name("XObject"));
|
|
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
|
d.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 20.0, 10.0]));
|
|
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec())));
|
|
entries.set(key.to_vec(), Object::Ref(r));
|
|
}
|
|
entries.set(b"VendorState".to_vec(), Object::name("Retained"));
|
|
let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) };
|
|
for r in [first_ref, other_ref] {
|
|
doc.update_dict(r, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap();
|
|
}
|
|
let mut doc = reopen(&doc);
|
|
let other_dict = list(&doc, 0)[other].clone();
|
|
let source = other_dict.get(b"AP").unwrap();
|
|
let before = doc.resolve(source);
|
|
set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
|
assert_eq!(doc.resolve(source), before, "a shared AP must not change");
|
|
for full in [false, true] {
|
|
let bytes = if full {
|
|
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
|
|
} else {
|
|
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
|
|
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
|
|
let original = doc.bytes();
|
|
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
|
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
|
|
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
|
|
bytes
|
|
};
|
|
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
|
let doc = Document::open(Arc::new(bytes)).unwrap();
|
|
let all = list(&doc, 0);
|
|
let first_ap = doc.resolve(all[first].get(b"AP").unwrap());
|
|
let other_ap = doc.resolve(all[other].get(b"AP").unwrap());
|
|
let first_ap = first_ap.as_dict().unwrap();
|
|
let other_ap = other_ap.as_dict().unwrap();
|
|
assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"));
|
|
// The restyled annotation keeps unknown entries, but not down/rollover looks in its
|
|
// old colour.
|
|
assert_eq!(first_ap.len(), 2);
|
|
assert_eq!(other_ap.len(), 4);
|
|
for (key, bytes) in alternates {
|
|
assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key));
|
|
let object = doc.resolve(other_ap.get(key).unwrap());
|
|
let Object::Stream(stream) = &*object else { panic!("alternate appearance") };
|
|
assert_eq!(stream.decoded().unwrap(), bytes);
|
|
}
|
|
assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..]));
|
|
assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..]));
|
|
assert!(ap_content(&doc, &all[first]).contains("1 0 0 RG"));
|
|
assert!(ap_content(&doc, &all[other]).contains("0 0.4 1 RG"));
|
|
}
|
|
}
|
|
}
|