mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
* M5.6: preserve nested annotation values in FDF exports Copy and renumber retained object graphs without expanding aliases or following indirect cycles recursively. Refuse excessive work and propagate export errors before the destination is written. Cover saved geometry, colour and borders, shared graphs, streams, stale references and refusal through automation tools. Co-Authored-By: Codex <noreply@openai.com> * Bound FDF annotation copying before allocation Keep selected aliases shared, budget retained keys and values before copying, and add direct-payload and alias-count regressions. Retain copied-graph limits and document source-memory scope. * M5.6: bound FDF cache-eviction discovery and field values Extend the concurrent annotation-budget correction while retaining its direct-payload and alias regressions. Record annotation positions instead of resolved bodies so cache eviction cannot pin repeated parsed copies; borrow direct values and budget retained entries before cloning. Count every emitted multi-select field value before allocating its output array. Observed pre-fix allocation-cap and field-limit failures, then fixed tests passed. Final validation: 549 scoped XFDF/engine/automation tests passed, three existing ignored; strict all-targets Clippy, WASM, fmt, layers, assets, parity and diff checks passed. AI assistance: OpenAI Codex for @MMexicann. * M5.6: record bounded FDF export regression evidence Record comment cache-eviction discovery and emitted-field-value budget regressions without changing feature scores or status. Describe copied-graph limits and existing source-parser limits precisely. Final 549 scoped tests, strict Clippy, WASM, fmt, layers, assets and parity passed. AI assistance: OpenAI Codex for @MMexicann. * M5.6: align FDF progress with budgeted discovery Document lazy annotation positions, cache-eviction coverage and copied-graph limits while retaining the original nested-value progress entry and all upstream notes. Estimates unchanged. AI assistance: OpenAI Codex for @MMexicann. * M5.6: describe FDF discovery and copied-graph limits Align exchange documentation with the tested lazy-position discovery implementation and its copied-graph limits. Source parsing/page discovery keep existing limits. AI assistance: OpenAI Codex for @MMexicann. * M5.6: align milestone notes with FDF discovery limits Retain upstream milestone history and describe the tested discovery/copy budgets without increasing estimates or claiming a total source-memory bound. AI assistance: OpenAI Codex for @MMexicann. * M5.6: keep nested page links out of FDF exports Preserve graph aliases and existing budgets; exclude direct and indirect Page/Pages type names. Add saved-PDF regression and bounded type-resolution controls. Both pre-fix failures observed; 46 scoped tests, strict Clippy, WASM and formatting passed. * M5.6: document bounded nested page exclusion Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: record nested page-reference regression evidence Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: retain FDF limits in milestone evidence Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: cite nested page-reference exclusion tests Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits. * M5.6: respect generations while identifying page references Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * XFDF: document stale Type preservation regression Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M5.6: record generation-aware page exclusion evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M5.6: propagate FDF export errors in usage example Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * M5.6: consolidate roadmap change and target fields Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
1593 lines
80 KiB
Rust
1593 lines
80 KiB
Rust
use std::sync::Arc;
|
||
|
||
use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries};
|
||
use pdfcraft_cos::{SaveOptions, write_full, write_incremental};
|
||
use pdfcraft_forms::{NewField, add_field, fields, set_value};
|
||
|
||
use super::*;
|
||
|
||
/// Two blank pages with three fields (text, check box, list).
|
||
fn blank() -> Document {
|
||
let mut doc = Document::new_empty();
|
||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||
let mut kids = Vec::new();
|
||
for _ in 0..2 {
|
||
let mut p = Dict::new();
|
||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||
kids.push(Object::Ref(doc.add(p)));
|
||
}
|
||
doc.update_dict(pages, |d| {
|
||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||
})
|
||
.unwrap();
|
||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("Name")).unwrap();
|
||
add_field(&mut doc, 0, [50.0, 650.0, 64.0, 664.0], &NewField::CheckBox, Some("Agree")).unwrap();
|
||
add_field(
|
||
&mut doc,
|
||
1,
|
||
[50.0, 600.0, 250.0, 660.0],
|
||
&NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true },
|
||
Some("Pick"),
|
||
)
|
||
.unwrap();
|
||
Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
|
||
}
|
||
|
||
/// A deeply nested field tree is refused with an error. The walk used to recurse once per level
|
||
/// with nothing bounding it, so a small file overflowed the stack - and a stack overflow aborts
|
||
/// the process, which no `catch_unwind` can intercept.
|
||
#[test]
|
||
fn deeply_nested_fields_are_refused_instead_of_overflowing_the_stack() {
|
||
let mut xml = String::from(r#"<?xml version="1.0"?><xfdf xmlns="http://ns.adobe.com/xfdf/"><fields>"#);
|
||
for i in 0..10_000 {
|
||
xml.push_str(&format!(r#"<field name="f{i}">"#));
|
||
}
|
||
xml.push_str("<value>v</value>");
|
||
for _ in 0..10_000 {
|
||
xml.push_str("</field>");
|
||
}
|
||
xml.push_str("</fields></xfdf>");
|
||
assert!(matches!(data_values(xml.as_bytes()), Err(DataError::Malformed(_))), "ten thousand nested fields must be refused, not walked");
|
||
}
|
||
|
||
/// Nesting inside the limit still reads, and the dotted field name is built as before.
|
||
#[test]
|
||
fn nested_fields_within_the_limit_still_read() {
|
||
let mut xml = String::from(r#"<?xml version="1.0"?><xfdf xmlns="http://ns.adobe.com/xfdf/"><fields>"#);
|
||
for i in 0..16 {
|
||
xml.push_str(&format!(r#"<field name="f{i}">"#));
|
||
}
|
||
xml.push_str("<value>deep</value>");
|
||
for _ in 0..16 {
|
||
xml.push_str("</field>");
|
||
}
|
||
xml.push_str("</fields></xfdf>");
|
||
let Ok(values) = data_values(xml.as_bytes()) else { panic!("sixteen levels must still read") };
|
||
assert_eq!(values.len(), 1);
|
||
assert_eq!(values[0].0, "f0.f1.f2.f3.f4.f5.f6.f7.f8.f9.f10.f11.f12.f13.f14.f15");
|
||
assert_eq!(values[0].1, vec!["deep".to_string()]);
|
||
}
|
||
|
||
/// The scanner counts a tag that closes itself as one level while it is read, not as a level left
|
||
/// open, so a flat file with thousands of them stays shallow and is not refused.
|
||
#[test]
|
||
fn self_closing_elements_do_not_accumulate_in_the_depth() {
|
||
use crate::xml_nesting_depth as d;
|
||
assert_eq!(d("<a/>"), 1);
|
||
assert_eq!(d("<a><b/></a>"), 2);
|
||
assert_eq!(d(&"<empty/>".repeat(2_000)), 1);
|
||
assert_eq!(d(&format!("<fields>{}</fields>", r#"<field name="a"/>"#.repeat(2_000))), 2);
|
||
}
|
||
|
||
/// The blank document with comments and values.
|
||
fn filled() -> Document {
|
||
let mut doc = blank();
|
||
let meta = |id: &str| Meta { date: Some("D:20261002120000Z".into()), id: id.into() };
|
||
let note = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment };
|
||
add_annotation(
|
||
&mut doc,
|
||
&NewAnnotation {
|
||
page: 0,
|
||
style: Style::default_for(¬e),
|
||
shape: note,
|
||
contents: "Check this <now> & \"later\"".into(),
|
||
author: "Ada".into(),
|
||
},
|
||
&meta("n1"),
|
||
)
|
||
.unwrap();
|
||
let hl = Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[10.0, 110.0, 90.0, 110.0, 10.0, 100.0, 90.0, 100.0]] };
|
||
add_annotation(
|
||
&mut doc,
|
||
&NewAnnotation { page: 1, style: Style::default_for(&hl), shape: hl, contents: String::new(), author: "Bob".into() },
|
||
&meta("h1"),
|
||
)
|
||
.unwrap();
|
||
let ink = Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 30.0], [40.0, 20.0]]] };
|
||
add_annotation(
|
||
&mut doc,
|
||
&NewAnnotation { page: 1, style: Style::default_for(&ink), shape: ink, contents: "scribble".into(), author: "Bob".into() },
|
||
&meta("i1"),
|
||
)
|
||
.unwrap();
|
||
let note_index = summaries(&doc).iter().find(|s| s.subtype == "Text").map(|s| s.index).unwrap();
|
||
add_reply(&mut doc, 0, note_index, "Agreed", "Bob", &meta("r1")).unwrap();
|
||
set_value(&mut doc, "Name", &FieldValue::Text("Ada Lovelace".into())).unwrap();
|
||
set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap();
|
||
set_value(&mut doc, "Pick", &FieldValue::Choice(vec!["A".into(), "C".into()])).unwrap();
|
||
doc
|
||
}
|
||
|
||
fn comment_view(doc: &Document) -> Vec<String> {
|
||
summaries(doc)
|
||
.iter()
|
||
.map(|s| {
|
||
format!(
|
||
"{} p{} {:?} by {:?}: {:?} reply_to={:?}",
|
||
s.subtype,
|
||
s.page,
|
||
s.rect.map(|v| v.round()),
|
||
s.author,
|
||
s.contents,
|
||
s.in_reply_to.is_some()
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn values(doc: &Document) -> Vec<(String, Vec<String>)> {
|
||
fields(doc).into_iter().map(|f| (f.name, f.value)).collect()
|
||
}
|
||
|
||
/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by
|
||
/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its
|
||
/// own `name`: importing it found a comment replying to itself and the pair was lost.
|
||
#[test]
|
||
fn a_replace_text_pair_survives_an_xfdf_round_trip() {
|
||
let mut src = blank();
|
||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||
let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||
let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() };
|
||
add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap();
|
||
assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret");
|
||
|
||
let xfdf = export_xfdf(&src, true, false, "a.pdf");
|
||
assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}");
|
||
assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}");
|
||
assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself");
|
||
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, xfdf.as_bytes()).unwrap();
|
||
assert_eq!(r.comments, 2, "both comments import: {r:?}");
|
||
let found = summaries(&dst);
|
||
let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect();
|
||
assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}");
|
||
assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive");
|
||
}
|
||
|
||
#[test]
|
||
fn xml_exports_drop_xml_noncharacters() {
|
||
let mut src = filled();
|
||
set_value(&mut src, "Name", &FieldValue::Text("a\u{FFFE}b\u{FFFF}c".into())).unwrap();
|
||
let xfdf = export_xfdf(&src, false, true, "f.pdf");
|
||
let doc = roxmltree::Document::parse(&xfdf).unwrap_or_else(|e| panic!("XFDF is not well-formed: {e}: {xfdf:?}"));
|
||
let value = doc
|
||
.descendants()
|
||
.find(|n| n.has_tag_name("field") && n.attribute("name") == Some("Name"))
|
||
.and_then(|n| n.children().find(|c| c.has_tag_name("value")))
|
||
.and_then(|n| n.text());
|
||
assert_eq!(value, Some("abc"));
|
||
let data = export_data(&src, Format::Xml);
|
||
roxmltree::Document::parse(&data).unwrap_or_else(|e| panic!("XML is not well-formed: {e}: {data:?}"));
|
||
}
|
||
|
||
#[test]
|
||
fn xml_exports_preserve_whitespace_in_field_names() {
|
||
let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"];
|
||
let value = "value\twith\nlines\r\nand <XML> & \"quotes\"";
|
||
let mut src = one_field();
|
||
pdfcraft_forms::delete_field(&mut src, "Name").unwrap();
|
||
for (index, name) in names.iter().enumerate() {
|
||
let placeholder = format!("field{index}");
|
||
add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap();
|
||
// Existing PDFs can have leading/trailing whitespace even though add_field trims it.
|
||
let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap();
|
||
src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap();
|
||
set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap();
|
||
}
|
||
let source_bytes = write_full(&src, &SaveOptions::default()).unwrap();
|
||
let src = Document::open(Arc::new(source_bytes.clone())).unwrap();
|
||
for format in [Format::Xml, Format::Xfdf] {
|
||
let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) };
|
||
let xml = roxmltree::Document::parse(&exported).unwrap();
|
||
let exported_names: Vec<_> = if format == Format::Xfdf {
|
||
xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect()
|
||
} else {
|
||
xml.root_element()
|
||
.children()
|
||
.filter(|n| n.is_element())
|
||
.map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap())
|
||
.collect()
|
||
};
|
||
assert_eq!(exported_names, names, "{format:?}: {exported}");
|
||
let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap();
|
||
for name in names {
|
||
set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap();
|
||
}
|
||
let report = import(&mut dst, exported.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, names.len(), "{format:?}: {report:?}");
|
||
assert!(report.rejected.is_empty(), "{report:?}");
|
||
let saved = write_full(&dst, &SaveOptions::default()).unwrap();
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
assert_eq!(values(&reopened), values(&src), "{format:?}");
|
||
}
|
||
assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF");
|
||
}
|
||
|
||
#[test]
|
||
fn xfdf_exports_preserve_whitespace_in_comment_attributes() {
|
||
let mut src = one_field();
|
||
let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment };
|
||
let names = ["note\tline\nidentity", "note line identity"];
|
||
let author = "Ada\tLovelace\nReview";
|
||
let subject = "Subject\twith\nlines\r\nand & quotes\"";
|
||
for name in names {
|
||
add_annotation(
|
||
&mut src,
|
||
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() },
|
||
&Meta { id: name.into(), date: None },
|
||
)
|
||
.unwrap();
|
||
}
|
||
let annotation_refs = |doc: &Document| {
|
||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||
doc.get(page)
|
||
.as_dict()
|
||
.unwrap()
|
||
.get(b"Annots")
|
||
.map(|a| doc.resolve(a))
|
||
.unwrap()
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter_map(Object::as_ref)
|
||
.filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text"))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
for annotation in annotation_refs(&src) {
|
||
src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap();
|
||
}
|
||
let source_bytes = write_full(&src, &SaveOptions::default()).unwrap();
|
||
let mut dst = Document::open(Arc::new(source_bytes)).unwrap();
|
||
let file = "form\twith\nlines.pdf";
|
||
let xfdf = export_xfdf(&dst, true, false, file);
|
||
let xml = roxmltree::Document::parse(&xfdf).unwrap();
|
||
let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect();
|
||
assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::<Vec<_>>(), names);
|
||
for note in notes {
|
||
assert_eq!(note.attribute("title"), Some(author));
|
||
assert_eq!(note.attribute("subject"), Some(subject));
|
||
}
|
||
assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file));
|
||
let before = annotation_refs(&dst);
|
||
let report = import(&mut dst, xfdf.as_bytes()).unwrap();
|
||
assert_eq!(report.comments, names.len());
|
||
assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments");
|
||
let saved = write_full(&dst, &SaveOptions::default()).unwrap();
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
for (annotation, name) in annotation_refs(&reopened).iter().zip(names) {
|
||
let dict = reopened.get(*annotation);
|
||
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name));
|
||
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author));
|
||
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn xfdf_round_trips_comments_and_values() {
|
||
let src = filled();
|
||
let xfdf = export_xfdf(&src, true, true, "form.pdf");
|
||
assert!(xfdf.contains("<text page=\"0\"") && xfdf.contains("<now> & "later""), "{xfdf}");
|
||
assert!(xfdf.contains("<highlight page=\"1\"") && xfdf.contains("coords=\"10,110,90,110,10,100,90,100\""));
|
||
assert!(xfdf.contains("<gesture>10,10;20,30;40,20</gesture>"));
|
||
assert!(xfdf.contains("inreplyto=\""), "replies keep their thread");
|
||
assert!(xfdf.contains("<field name=\"Pick\"><value>A</value><value>C</value></field>"));
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, xfdf.as_bytes()).unwrap();
|
||
assert_eq!((r.comments, r.fields), (4, 3), "{r:?}");
|
||
assert_eq!(comment_view(&dst), comment_view(&src));
|
||
assert_eq!(values(&dst), values(&src));
|
||
// Importing again replaces (same names) instead of duplicating.
|
||
import(&mut dst, xfdf.as_bytes()).unwrap();
|
||
assert_eq!(summaries(&dst).len(), summaries(&src).len());
|
||
// Comments only.
|
||
let only = export_xfdf(&src, true, false, "form.pdf");
|
||
assert!(!only.contains("<fields>"));
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_round_trips_values_and_comments() {
|
||
let src = filled();
|
||
let fdf = export_fdf(&src, true, true, "form.pdf").unwrap();
|
||
assert!(fdf.starts_with(b"%FDF-1.2"));
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, &fdf).unwrap();
|
||
assert_eq!(r.fields, 3);
|
||
assert_eq!(values(&dst), values(&src));
|
||
assert!(r.comments >= 3, "{r:?}");
|
||
assert!(summaries(&dst).iter().any(|s| s.contents.as_deref() == Some("scribble")));
|
||
assert_eq!(comment_view(&dst), comment_view(&src), "replies keep their thread");
|
||
}
|
||
|
||
/// One page with a check box whose on state is the Shift-JIS name 「はい」 (`/#82#CD#82#A2`).
|
||
fn shift_jis_check_box() -> Document {
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||
"<< /Fields [5 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [50 700 64 714] /P 3 0 R /AP << /N << /Off 6 0 R /#82#CD#82#A2 6 0 R >> >> >>",
|
||
"<< /Length 0 >>\nstream\n\nendstream",
|
||
];
|
||
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{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
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)).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn non_utf8_check_box_states_round_trip() {
|
||
let sjis_hai: &[u8] = b"\x82\xcd\x82\xa2";
|
||
let mut src = shift_jis_check_box();
|
||
set_value(&mut src, "agree", &FieldValue::Check(true)).unwrap();
|
||
// FDF writes the state's own bytes, not its #XX text.
|
||
let fdf = export_fdf(&src, false, true, "form.pdf").unwrap();
|
||
let name: &[u8] = b"/#82#CD#82#A2";
|
||
assert!(fdf.windows(name.len()).any(|w| w == name), "{}", String::from_utf8_lossy(&fdf));
|
||
for data in [fdf, export_xfdf(&src, false, true, "form.pdf").into_bytes()] {
|
||
let mut dst = shift_jis_check_box();
|
||
assert_eq!(import(&mut dst, &data).unwrap().fields, 1);
|
||
assert_eq!(values(&dst), values(&src));
|
||
let w = fields(&dst).into_iter().next().unwrap().widgets[0].obj;
|
||
assert_eq!(dst.get(w).as_dict().unwrap().name(b"AS"), Some(sjis_hai));
|
||
}
|
||
}
|
||
|
||
/// Who each comment replies to, by name, with its review state.
|
||
fn threads(doc: &Document) -> Vec<(Option<String>, Option<String>, Option<String>)> {
|
||
summaries(doc).into_iter().map(|s| (s.name, s.in_reply_to, s.state)).collect()
|
||
}
|
||
|
||
/// #333: a note with two replies and an Accepted review status keeps its thread through FDF.
|
||
#[test]
|
||
fn fdf_keeps_replies_and_review_status_on_their_note() {
|
||
let mut src = blank();
|
||
let meta = |id: &str| Meta { date: Some("D:20261002120000Z".into()), id: id.into() };
|
||
let note = Shape::Note { at: [100.0, 100.0], icon: pdfcraft_annot::NoteIcon::Comment };
|
||
add_annotation(
|
||
&mut src,
|
||
&NewAnnotation { page: 1, style: Style::default_for(¬e), shape: note, contents: "Literal Note".into(), author: "Alpha".into() },
|
||
&meta("note"),
|
||
)
|
||
.unwrap();
|
||
let index = summaries(&src).iter().find(|s| s.subtype == "Text").map(|s| s.index).unwrap();
|
||
add_reply(&mut src, 1, index, "Reply One", "Beta", &meta("one")).unwrap();
|
||
add_reply(&mut src, 1, index, "Reply Two", "Gamma", &meta("two")).unwrap();
|
||
pdfcraft_annot::set_review_state(&mut src, 1, index, pdfcraft_annot::ReviewState::Accepted, "Review", &meta("status")).unwrap();
|
||
let want = threads(&src);
|
||
assert_eq!(want.iter().filter(|t| t.1.as_deref() == Some("note")).count(), 3, "{want:?}");
|
||
|
||
let fdf = export_fdf(&src, true, false, "notes.pdf").unwrap();
|
||
// Every cross-reference entry points at its object (other readers trust the table).
|
||
let tail = std::str::from_utf8(&fdf[fdf.windows(5).position(|w| w == b"xref\n").unwrap()..]).unwrap();
|
||
let xref: usize = tail.lines().skip_while(|l| *l != "startxref").nth(1).unwrap().parse().unwrap();
|
||
assert!(fdf[xref..].starts_with(b"xref\n0 6\n"), "{tail}");
|
||
for (num, line) in tail.lines().skip(3).take(5).enumerate() {
|
||
let at: usize = line[..10].parse().unwrap();
|
||
assert!(fdf[at..].starts_with(format!("{} 0 obj\n", num + 1).as_bytes()), "object {}: {line}", num + 1);
|
||
}
|
||
let mut dst = blank();
|
||
assert_eq!(import(&mut dst, &fdf).unwrap().comments, 4);
|
||
assert_eq!(threads(&dst), want);
|
||
assert_eq!(comment_view(&dst), comment_view(&src));
|
||
// Importing again replaces the thread instead of duplicating or detaching it.
|
||
import(&mut dst, &fdf).unwrap();
|
||
assert_eq!(threads(&dst), want);
|
||
}
|
||
|
||
/// FDFs written before #333 (direct comment dictionaries, no /IRT) still import.
|
||
#[test]
|
||
fn fdf_with_direct_comments_still_imports() {
|
||
let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (old) /NM (a) /Page 0 >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
|
||
let mut dst = blank();
|
||
assert_eq!(import(&mut dst, fdf).unwrap().comments, 1);
|
||
assert_eq!(threads(&dst), vec![(Some("a".into()), None, None)]);
|
||
}
|
||
|
||
/// /IRT pointing at itself, at a missing object, at a non-comment or in a cycle neither panics
|
||
/// nor loops; only a real parent is linked.
|
||
#[test]
|
||
fn hostile_fdf_reply_parents_are_ignored() {
|
||
let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [2 0 R 3 0 R 4 0 R 5 0 R 6 0 R] >> >>\nendobj\n\
|
||
2 0 obj\n<< /Subtype /Text /Rect [0 0 9 9] /NM (self) /IRT 2 0 R /Page 0 >>\nendobj\n\
|
||
3 0 obj\n<< /Subtype /Text /Rect [0 0 9 9] /NM (missing) /IRT 99 0 R /Page 0 >>\nendobj\n\
|
||
4 0 obj\n<< /Subtype /Text /Rect [0 0 9 9] /NM (cycle-a) /IRT 5 0 R /Page 0 >>\nendobj\n\
|
||
5 0 obj\n<< /Subtype /Text /Rect [0 0 9 9] /NM (cycle-b) /IRT 4 0 R /Page 0 >>\nendobj\n\
|
||
6 0 obj\n<< /Subtype /Text /Rect [0 0 9 9] /NM (number) /IRT 42 /Page 99 >>\nendobj\n\
|
||
trailer\n<< /Root 1 0 R >>\n%%EOF\n";
|
||
let mut dst = blank();
|
||
assert_eq!(import(&mut dst, fdf).unwrap().comments, 4);
|
||
let t = threads(&dst);
|
||
let parent = |nm: &str| t.iter().find(|x| x.0.as_deref() == Some(nm)).and_then(|x| x.1.clone());
|
||
assert_eq!(parent("self"), None);
|
||
assert_eq!(parent("missing"), None);
|
||
assert_eq!((parent("cycle-a").as_deref(), parent("cycle-b").as_deref()), (Some("cycle-b"), Some("cycle-a")));
|
||
}
|
||
|
||
#[test]
|
||
fn xml_field_names_remain_valid_and_round_trip_without_collisions() {
|
||
assert_eq!(xml_name(".Value"), "_.Value");
|
||
let names = [
|
||
("Area²", "Area_"),
|
||
("Area_", "Area_"),
|
||
("Aµ", "A_"),
|
||
("µRate", "_Rate"),
|
||
("²Rate", "_Rate"),
|
||
("½Amount", "_Amount"),
|
||
("1Rate", "_1Rate"),
|
||
("Café", "Café"),
|
||
("Δείγμα", "Δείγμα"),
|
||
("姓名", "姓名"),
|
||
("٣Value", "_٣Value"),
|
||
("\u{345}Rate", "_\u{345}Rate"),
|
||
("A\u{345}", "A\u{345}"),
|
||
("Alpha_1-2", "Alpha_1-2"),
|
||
("ns:value", "ns_value"),
|
||
("-Value", "_-Value"),
|
||
];
|
||
let mut src = one_field();
|
||
pdfcraft_forms::delete_field(&mut src, "Name").unwrap();
|
||
for (index, (name, _)) in names.iter().enumerate() {
|
||
add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some(name)).unwrap();
|
||
set_value(&mut src, name, &FieldValue::Text(format!("value-{index}"))).unwrap();
|
||
}
|
||
let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap());
|
||
let src = Document::open(source.clone()).unwrap();
|
||
let want = values(&src);
|
||
for format in [Format::Xml, Format::Xdp] {
|
||
let text = export_data(&src, format);
|
||
let xml = roxmltree::Document::parse(&text).unwrap();
|
||
let children: Vec<_> = xml.root_element().children().filter(|n| n.is_element()).collect();
|
||
assert_eq!(children.len(), names.len());
|
||
for (child, (original, tag)) in children.iter().zip(names) {
|
||
assert_eq!(child.tag_name().name(), tag);
|
||
assert_eq!(child.attribute(("http://ns.adobe.com/xfdf-transition/", "original")), (original != tag).then_some(original));
|
||
}
|
||
assert!(!src.is_modified(), "export must not change the source PDF");
|
||
assert_eq!(src.bytes().as_slice(), source.as_slice());
|
||
|
||
let mut dst = Document::open(source.clone()).unwrap();
|
||
for (name, _) in names {
|
||
set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap();
|
||
}
|
||
let report = import(&mut dst, text.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, names.len());
|
||
assert!(report.rejected.is_empty(), "{report:?}");
|
||
let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
|
||
assert_eq!(values(&reopened), want, "{format:?}: sanitized names must preserve distinct original fields");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn form_data_as_xml_csv_and_text() {
|
||
let src = filled();
|
||
let xml = export_data(&src, Format::Xml);
|
||
assert!(xml.contains("<Name>Ada Lovelace</Name>") && xml.contains("<Agree>Yes</Agree>"), "{xml}");
|
||
let txt = export_data(&src, Format::Txt);
|
||
assert_eq!(txt, "Name\tAgree\tPick\nAda Lovelace\tYes\tA, C\n");
|
||
let csv_text = export_data(&src, Format::Csv);
|
||
assert_eq!(csv_text, "Name,Agree,Pick\nAda Lovelace,Yes,\"A, C\"\n");
|
||
for data in [xml, txt] {
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, data.as_bytes()).unwrap();
|
||
assert!(r.fields >= 2, "{r:?}");
|
||
assert_eq!(values(&dst)[0].1, ["Ada Lovelace"]);
|
||
assert_eq!(values(&dst)[1].1, ["Yes"]);
|
||
}
|
||
let mut dst = blank();
|
||
import(&mut dst, b"Name,Agree\n\"Lovelace, Ada\",Off\n").unwrap();
|
||
assert_eq!(values(&dst)[0].1, ["Lovelace, Ada"]);
|
||
}
|
||
|
||
#[test]
|
||
fn csv_values_may_span_lines() {
|
||
// A quoted CSV value may hold line breaks (export_data writes multiline text that way): the
|
||
// record goes on to the closing quote, so later columns still import.
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, b"Name,Agree\r\n\"Ada \"\"the\"\"\r\nCountess\",Yes\r\n").unwrap();
|
||
assert_eq!(r.fields, 2, "{r:?}");
|
||
assert!(values(&dst)[0].1[0].starts_with("Ada \"the\"") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst));
|
||
assert_eq!(values(&dst)[1].1, ["Yes"]);
|
||
// A header without a record is still not a data file.
|
||
assert!(import(&mut blank(), b"Name,Agree\r\n").is_err());
|
||
// An unterminated quote runs to the end of the file and is read leniently, as before.
|
||
assert!(import(&mut blank(), b"Name,Agree\r\n\"Ada, Yes\r\n").is_ok());
|
||
}
|
||
|
||
/// One page with one text field, `Name`.
|
||
fn one_field() -> Document {
|
||
let mut doc = Document::new_empty();
|
||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||
let mut p = Dict::new();
|
||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||
let page = doc.add(p);
|
||
doc.update_dict(pages, |d| {
|
||
d.set(b"Count".to_vec(), Object::Int(1));
|
||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||
})
|
||
.unwrap();
|
||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("Name")).unwrap();
|
||
Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn csv_exports_preserve_carriage_returns_in_names_and_values() {
|
||
for (name, value, expected) in [
|
||
("Name", "plain", "Name\nplain\n"),
|
||
("Name", "Tail\r", "Name\n\"Tail\r\"\n"),
|
||
("Name", "Alpha\rBeta", "Name\n\"Alpha\rBeta\"\n"),
|
||
("Name", "\rLead", "Name\n\"\rLead\"\n"),
|
||
("Name", "\r", "Name\n\"\r\"\n"),
|
||
("Name", "Alpha\r\nBeta", "Name\n\"Alpha\r\nBeta\"\n"),
|
||
("Name", "Alpha\nBeta", "Name\n\"Alpha\nBeta\"\n"),
|
||
("Name", "comma,\r", "Name\n\"comma,\r\"\n"),
|
||
("Name", "\"quoted\"\r", "Name\n\"\"\"quoted\"\"\r\"\n"),
|
||
("Name", "before\r,\"after\"", "Name\n\"before\r,\"\"after\"\"\"\n"),
|
||
("Na\rme", "both\r", "\"Na\rme\"\n\"both\r\"\n"),
|
||
] {
|
||
let mut src = one_field();
|
||
let field = fields(&src)[0].obj;
|
||
src.update_dict(field, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap();
|
||
set_value(&mut src, name, &FieldValue::Text(value.into())).unwrap();
|
||
let source = Arc::new(write_full(&src, &SaveOptions::default()).unwrap());
|
||
let src = Document::open(source.clone()).unwrap();
|
||
assert_eq!(values(&src), [(name.to_string(), vec![value.to_string()])]);
|
||
let csv = export_data(&src, Format::Csv);
|
||
|
||
let mut dst = Document::open(source).unwrap();
|
||
set_value(&mut dst, name, &FieldValue::Text("replaced".into())).unwrap();
|
||
let report = import(&mut dst, csv.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, 1);
|
||
assert!(report.rejected.is_empty(), "{report:?}");
|
||
let reopened = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
|
||
assert_eq!(values(&reopened), [(name.to_string(), vec![value.to_string()])]);
|
||
assert_eq!(csv, expected, "CSV must quote CR as well as LF, commas and quotes");
|
||
}
|
||
}
|
||
|
||
/// #1321: the text export of a one-field form has no tab to tell it from CSV, so a comma in the
|
||
/// value used to split it and only the text before the comma was imported.
|
||
#[test]
|
||
fn one_field_text_data_keeps_its_commas() {
|
||
for value in ["Hello, world", "Say \"hi\", twice"] {
|
||
let mut src = one_field();
|
||
set_value(&mut src, "Name", &FieldValue::Text(value.into())).unwrap();
|
||
let txt = export_data(&src, Format::Txt);
|
||
assert_eq!(txt, format!("Name\n{value}\n"));
|
||
let mut dst = one_field();
|
||
let r = import(&mut dst, txt.as_bytes()).unwrap();
|
||
assert_eq!(r.fields, 1, "{r:?}");
|
||
assert!(r.rejected.is_empty(), "{r:?}");
|
||
assert_eq!(values(&dst)[0].1, [value]);
|
||
}
|
||
}
|
||
|
||
/// A one-column CSV is still CSV: quotes come off, and a quoted value keeps its commas and line
|
||
/// breaks.
|
||
#[test]
|
||
fn one_column_csv_still_reads_its_quotes() {
|
||
let mut src = one_field();
|
||
set_value(&mut src, "Name", &FieldValue::Text("Hello, world".into())).unwrap();
|
||
let csv = export_data(&src, Format::Csv);
|
||
assert_eq!(csv, "Name\n\"Hello, world\"\n");
|
||
let mut dst = one_field();
|
||
assert_eq!(import(&mut dst, csv.as_bytes()).unwrap().fields, 1);
|
||
assert_eq!(values(&dst)[0].1, ["Hello, world"]);
|
||
let mut dst = one_field();
|
||
assert_eq!(import(&mut dst, b"Name\r\n\"Ada \"\"the\"\", \r\nCountess\"\r\n").unwrap().fields, 1);
|
||
assert!(values(&dst)[0].1[0].starts_with("Ada \"the\", ") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst));
|
||
}
|
||
|
||
#[test]
|
||
fn quoted_empty_csv_value_clears_a_field() {
|
||
for data in ["Name\n\"\"\n", "Name\r\n\"\"\r\n", "Name\n\"\"", "Name\n\n\"\"\n", "Name\n\"\"\nGrace\n"] {
|
||
let mut dst = one_field();
|
||
set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap();
|
||
let report = import(&mut dst, data.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, 1, "{data:?}");
|
||
assert!(report.rejected.is_empty(), "{report:?}");
|
||
assert!(values(&dst)[0].1.is_empty(), "{data:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn multi_select_lists_round_trip_through_xml_csv_and_text() {
|
||
let src = filled();
|
||
for format in [Format::Xml, Format::Csv, Format::Txt] {
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, export_data(&src, format).as_bytes()).unwrap();
|
||
assert!(r.rejected.is_empty(), "{format:?}: {r:?}");
|
||
assert_eq!(values(&dst)[2].1, ["A", "C"], "{format:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bad_data_is_refused_with_a_reason() {
|
||
let mut dst = blank();
|
||
assert!(matches!(import(&mut dst, b"<xfdf><annots>"), Err(DataError::Malformed(_))));
|
||
assert_eq!(import(&mut dst, b"Unknown\nvalue\n"), Err(DataError::NothingImported));
|
||
assert_eq!(Format::from_extension("XFDF"), Some(Format::Xfdf));
|
||
assert_eq!(Format::from_extension("docx"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn data_files_merge_into_a_spreadsheet() {
|
||
let src = filled();
|
||
let xfdf = export_xfdf(&src, false, true, "form.pdf");
|
||
let fdf = export_fdf(&src, false, true, "form.pdf").unwrap();
|
||
let mut other = blank();
|
||
set_value(&mut other, "Name", &FieldValue::Text("Grace \"Amazing\" Hopper, RADM".into())).unwrap();
|
||
let pdf = pdfcraft_cos::write_full(&other, &Default::default()).unwrap();
|
||
let rows: Vec<_> = [xfdf.as_bytes(), &fdf[..], &pdf[..]].iter().map(|b| data_values(b).unwrap()).collect();
|
||
assert_eq!(rows[0], rows[1], "XFDF and FDF carry the same values");
|
||
let csv = merge_csv(&rows);
|
||
let lines: Vec<&str> = csv.lines().collect();
|
||
assert_eq!(lines[0], "Name,Agree,Pick");
|
||
assert_eq!(lines[1], "Ada Lovelace,Yes,\"A, C\"");
|
||
assert!(lines[3].starts_with("\"Grace \"\"Amazing\"\" Hopper, RADM\",Off,"), "{csv}");
|
||
assert_eq!(data_values(b"not data"), Err(DataError::UnknownFormat));
|
||
}
|
||
|
||
#[test]
|
||
fn unicode_annotation_colours_are_ignored_without_panicking() {
|
||
for attr in ["color", "interior-color"] {
|
||
let mut doc = blank();
|
||
let xml = format!(
|
||
r##"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><square page="0" rect="10,10,50,50" name="unicode-colour" {attr}="#€€" /></annots></xfdf>"##
|
||
);
|
||
let report = import(&mut doc, xml.as_bytes()).unwrap();
|
||
assert_eq!(report.comments, 1);
|
||
}
|
||
}
|
||
|
||
/// Importing the same note again must not leave the earlier pop-up on the page.
|
||
#[test]
|
||
fn reimporting_a_note_replaces_its_popup() {
|
||
let xfdf = concat!(
|
||
r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><text page="0" name="n1" rect="10,10,30,30"><contents>body</contents>"#,
|
||
r#"<popup page="0" rect="40,10,140,60" open="no"/></text></annots></xfdf>"#,
|
||
);
|
||
let mut doc = blank();
|
||
for _ in 0..3 {
|
||
import(&mut doc, xfdf.as_bytes()).unwrap();
|
||
}
|
||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||
let annots = doc.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).and_then(|a| doc.resolve(&a).as_array().cloned()).unwrap();
|
||
let refs: Vec<ObjRef> = annots.iter().filter_map(|a| a.as_ref()).collect();
|
||
let is_popup = |r: &ObjRef| doc.get(*r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(b"Popup".as_slice());
|
||
let popups: Vec<ObjRef> = refs.iter().copied().filter(is_popup).collect();
|
||
assert_eq!(popups.len(), 1, "one current pop-up, not one per import");
|
||
let parent = doc.get(popups[0]).as_dict().and_then(|d| d.reference(b"Parent")).unwrap();
|
||
assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page");
|
||
}
|
||
|
||
/// A partial import updates the parent without detaching replies and review statuses that
|
||
/// weren't included in the data file. FDF export only carries parents still on a page.
|
||
#[test]
|
||
fn replacing_a_note_keeps_existing_replies_and_review_status() {
|
||
let xfdf = concat!(
|
||
r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>"#,
|
||
r#"<text page="0" name="n1" rect="100,500,120,520"><contents>Updated note</contents></text>"#,
|
||
r#"</annots></xfdf>"#,
|
||
);
|
||
let mut incoming = blank();
|
||
import(&mut incoming, xfdf.as_bytes()).unwrap();
|
||
let mut original = filled();
|
||
let index = summaries(&original).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().index;
|
||
pdfcraft_annot::set_review_state(
|
||
&mut original,
|
||
0,
|
||
index,
|
||
pdfcraft_annot::ReviewState::Accepted,
|
||
"Reviewer",
|
||
&Meta { date: None, id: "status".into() },
|
||
)
|
||
.unwrap();
|
||
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
|
||
for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] {
|
||
// Start with the parent, reply and review status in the existing PDF revision.
|
||
let mut doc = Document::open(original.clone()).unwrap();
|
||
let mut want = threads(&doc);
|
||
want.sort();
|
||
assert_eq!(want.iter().filter(|(_, parent, _)| parent.as_deref() == Some("n1")).count(), 2);
|
||
assert_eq!(import(&mut doc, &data).unwrap().comments, 1);
|
||
let note = summaries(&doc).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap();
|
||
assert_eq!(note.contents.as_deref(), Some("Updated note"));
|
||
|
||
// Check the saved object graph and a subsequent export/import, rather than relying on
|
||
// summaries resolving an obsolete parent's name from an object outside /Annots.
|
||
for incremental in [true, false] {
|
||
let saved = if incremental {
|
||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||
assert!(saved.starts_with(original.as_slice()), "an incremental save preserves the original revision");
|
||
saved
|
||
} else {
|
||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||
};
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
let note = summaries(&reopened).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap();
|
||
assert_eq!(note.contents.as_deref(), Some("Updated note"));
|
||
let fdf = export_fdf(&reopened, true, false, "notes.pdf").unwrap();
|
||
let mut dst = blank();
|
||
import(&mut dst, &fdf).unwrap();
|
||
let mut got = threads(&dst);
|
||
got.sort();
|
||
assert_eq!(got, want, "partial comment import must preserve reply and status threads (incremental={incremental})");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn replacing_an_indirectly_named_note_preserves_its_identity_and_thread() {
|
||
let initial = concat!(
|
||
r#"<xfdf><annots><text page="0" name="n1" rect="10,10,30,30"><contents>Original</contents></text>"#,
|
||
r#"<text page="0" name="r1" rect="40,10,60,30" inreplyto="n1"><contents>Reply</contents></text>"#,
|
||
r#"<text page="0" name="s1" rect="70,10,90,30" inreplyto="n1" state="Accepted" statemodel="Review"/>"#,
|
||
r#"</annots></xfdf>"#,
|
||
);
|
||
let update = br#"<xfdf><annots><text page="0" name="n1" rect="10,10,30,30"><contents>Updated</contents></text></annots></xfdf>"#;
|
||
let mut original = blank();
|
||
for name in ["Name", "Agree", "Pick"] {
|
||
pdfcraft_forms::delete_field(&mut original, name).unwrap();
|
||
}
|
||
import(&mut original, initial.as_bytes()).unwrap();
|
||
let annotation_refs = |doc: &Document| {
|
||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||
doc.get(page)
|
||
.as_dict()
|
||
.unwrap()
|
||
.get(b"Annots")
|
||
.map(|a| doc.resolve(a))
|
||
.unwrap()
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter_map(Object::as_ref)
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let parent = annotation_refs(&original)[0];
|
||
let indirect_name = original.add(PdfString::text("n1"));
|
||
original.update_dict(parent, |d| d.set(b"NM".to_vec(), Object::Ref(indirect_name))).unwrap();
|
||
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
|
||
let mut incoming = blank();
|
||
import(&mut incoming, update).unwrap();
|
||
for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] {
|
||
let mut doc = Document::open(original.clone()).unwrap();
|
||
let refs = annotation_refs(&doc);
|
||
assert_eq!(refs.len(), 3);
|
||
let parent = refs.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap();
|
||
assert!(doc.get(parent).as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some(), "the saved source name is indirect");
|
||
let mut want = threads(&doc);
|
||
want.sort();
|
||
assert_eq!(import(&mut doc, &data).unwrap().comments, 1);
|
||
let updated = annotation_refs(&doc);
|
||
assert_eq!(updated.len(), 3, "a valid indirect name must replace its comment instead of duplicating it");
|
||
assert!(updated.contains(&parent), "replacement preserves the source object's identity");
|
||
assert_eq!(text_of(&doc, doc.get(parent).as_dict().unwrap(), b"Contents").as_deref(), Some("Updated"));
|
||
for child in refs.into_iter().filter(|r| *r != parent) {
|
||
assert_eq!(doc.get(child).as_dict().unwrap().reference(b"IRT"), Some(parent));
|
||
}
|
||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||
assert!(saved.starts_with(original.as_slice()));
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
let mut got = threads(&reopened);
|
||
got.sort();
|
||
assert_eq!(got, want);
|
||
assert_eq!(summaries(&reopened).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().contents.as_deref(), Some("Updated"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn replacement_matches_named_comments_only_on_their_page() {
|
||
let xfdf = concat!(
|
||
r#"<xfdf><annots>"#,
|
||
r#"<text page="0" name="same" rect="10,10,30,30"><contents>First page</contents></text>"#,
|
||
r#"<text page="1" name="same" rect="10,10,30,30"><contents>Second page</contents></text>"#,
|
||
r#"<text page="0" rect="40,40,60,60"><contents>Unnamed</contents></text>"#,
|
||
r#"</annots></xfdf>"#,
|
||
);
|
||
let mut doc = blank();
|
||
import(&mut doc, xfdf.as_bytes()).unwrap();
|
||
import(&mut doc, xfdf.as_bytes()).unwrap();
|
||
let notes = summaries(&doc);
|
||
assert_eq!(notes.len(), 4, "named comments are replaced, unnamed comments are added");
|
||
for (page, text) in [(0, "First page"), (1, "Second page")] {
|
||
let note = notes.iter().find(|s| s.page == page && s.name.as_deref() == Some("same")).unwrap();
|
||
assert_eq!(note.contents.as_deref(), Some(text));
|
||
}
|
||
assert_eq!(notes.iter().filter(|s| s.name.is_none()).count(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn the_last_same_named_record_controls_its_reply_parent() {
|
||
let mut incoming = blank();
|
||
let page = pdfcraft_model::pages(&incoming)[0].obj;
|
||
let mut refs = Vec::new();
|
||
for name in ["parent", "duplicate", "duplicate"] {
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Text"));
|
||
d.set(b"NM".to_vec(), PdfString::text(name));
|
||
d.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0]));
|
||
if refs.len() == 1 {
|
||
d.set(b"IRT".to_vec(), Object::Ref(refs[0]));
|
||
}
|
||
refs.push(incoming.add(d));
|
||
}
|
||
incoming.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(refs.into_iter().map(Object::Ref).collect()))).unwrap();
|
||
for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf").unwrap()] {
|
||
let mut doc = blank();
|
||
import(&mut doc, &data).unwrap();
|
||
let notes = summaries(&doc);
|
||
assert_eq!(notes.len(), 2);
|
||
let last = notes.iter().find(|s| s.name.as_deref() == Some("duplicate")).unwrap();
|
||
assert_eq!(last.in_reply_to, None, "a replaced record's deferred /IRT must not attach the final standalone comment");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn indirectly_named_protected_comments_and_popups_are_not_replaced() {
|
||
let xfdf = br#"<xfdf><annots><redact page="0" name="n1" rect="0,0,1,1" flags="invisible,hidden,noview,print"><contents>Updated</contents></redact></annots></xfdf>"#;
|
||
let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (n1) /Page 0 /Rect [0 0 1 1] /F 39 /Contents (Updated) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
|
||
let annotation_refs = |doc: &Document| {
|
||
pdfcraft_model::pages(doc)[0]
|
||
.dict
|
||
.get(b"Annots")
|
||
.map(|a| doc.resolve(a))
|
||
.unwrap()
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter_map(Object::as_ref)
|
||
.collect::<Vec<_>>()
|
||
};
|
||
for (subtype, flags) in [("Text", LOCKED), ("Redact", 0)] {
|
||
let mut original = blank();
|
||
let page = pdfcraft_model::pages(&original)[0].obj;
|
||
let name = original.add(PdfString::text("n1"));
|
||
let mut protected = Dict::new();
|
||
protected.set(b"Type".to_vec(), Object::name("Annot"));
|
||
protected.set(b"Subtype".to_vec(), Object::name(subtype));
|
||
protected.set(b"NM".to_vec(), Object::Ref(name));
|
||
protected.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0]));
|
||
protected.set(b"F".to_vec(), Object::Int(flags));
|
||
protected.set(b"Contents".to_vec(), PdfString::text("Original"));
|
||
protected.set(b"CustomData".to_vec(), PdfString::text("preserved"));
|
||
let parent = original.add(protected);
|
||
let mut popup = Dict::new();
|
||
popup.set(b"Subtype".to_vec(), Object::name("Popup"));
|
||
popup.set(b"Rect".to_vec(), arr(&[40.0, 10.0, 140.0, 60.0]));
|
||
popup.set(b"Parent".to_vec(), Object::Ref(parent));
|
||
let popup = original.add(popup);
|
||
original.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(popup))).unwrap();
|
||
let mut annots = annotation_refs(&original);
|
||
annots.extend([parent, popup]);
|
||
original.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(annots.into_iter().map(Object::Ref).collect()))).unwrap();
|
||
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
|
||
for data in [xfdf.as_slice(), fdf.as_slice()] {
|
||
let mut doc = Document::open(original.clone()).unwrap();
|
||
let before = annotation_refs(&doc);
|
||
let parent = before.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap();
|
||
let protected = doc.get(parent);
|
||
let popup_ref = protected.as_dict().unwrap().reference(b"Popup").unwrap();
|
||
let popup = doc.get(popup_ref);
|
||
assert!(protected.as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some());
|
||
assert_eq!(import(&mut doc, data).unwrap().comments, 1);
|
||
let after = annotation_refs(&doc);
|
||
assert_eq!(after.len(), before.len() + 1);
|
||
assert!(after.contains(&parent) && after.contains(&popup_ref));
|
||
assert_eq!(doc.get(parent), protected, "the protected {subtype} must not change, including its unknown data");
|
||
assert_eq!(doc.get(popup_ref), popup, "the protected comment's popup must not change");
|
||
let added = after.into_iter().find(|r| !before.contains(r)).unwrap();
|
||
let added = doc.get(added);
|
||
let added = added.as_dict().unwrap();
|
||
assert!(added.get(b"NM").is_none(), "the imported comment must relinquish the protected name");
|
||
assert_eq!(added.int(b"F"), Some(4), "an imported redaction stays printable with Invisible/Hidden/NoView removed");
|
||
let reopened = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||
assert_eq!(reopened.get(parent), protected);
|
||
assert_eq!(reopened.get(popup_ref), popup);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn imported_redact_marks_cannot_replace_a_locked_mark_or_hide_themselves() {
|
||
let mut doc = blank();
|
||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||
// A locked redaction mark the user placed, with a known /NM.
|
||
let mut mark = Dict::new();
|
||
mark.set(b"Type".to_vec(), Object::name("Annot"));
|
||
mark.set(b"Subtype".to_vec(), Object::name("Redact"));
|
||
mark.set(b"NM".to_vec(), PdfString::text("mark1"));
|
||
mark.set(b"Rect".to_vec(), Object::Array(vec![60.into(), 660.into(), 560.into(), 678.into()]));
|
||
mark.set(b"F".to_vec(), Object::Int(128)); // Locked
|
||
let mark_ref = doc.add(Object::Dict(mark));
|
||
let mut annots =
|
||
doc.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).and_then(|a| doc.resolve(&a).as_array().cloned()).unwrap_or_default();
|
||
annots.push(Object::Ref(mark_ref));
|
||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(annots))).unwrap();
|
||
|
||
// A reviewer's XFDF: shrink the locked mark to a 1×1 corner by reusing its /NM, and add a
|
||
// redaction mark imported as Hidden that would remove other content unseen.
|
||
let xfdf = concat!(
|
||
r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>"#,
|
||
r#"<redact page="0" name="mark1" rect="0,0,1,1" title="Reviewer"/>"#,
|
||
r#"<redact page="0" name="ghost" rect="60,600,560,620" flags="hidden" title="Reviewer"/>"#,
|
||
r#"</annots></xfdf>"#,
|
||
);
|
||
import(&mut doc, xfdf.as_bytes()).unwrap();
|
||
|
||
let refs: Vec<ObjRef> = doc
|
||
.get(page)
|
||
.as_dict()
|
||
.and_then(|p| p.get(b"Annots").cloned())
|
||
.and_then(|a| doc.resolve(&a).as_array().cloned())
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|a| a.as_ref())
|
||
.collect();
|
||
// The locked mark is still there, unchanged (same object, same geometry, still its /NM).
|
||
assert!(refs.contains(&mark_ref), "the locked redaction mark was replaced");
|
||
let locked = doc.get(mark_ref).as_dict().cloned().unwrap();
|
||
assert_eq!(nums_of(&doc, locked.get(b"Rect")), vec![60.0, 660.0, 560.0, 678.0], "the locked mark's geometry was changed");
|
||
assert_eq!(locked.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text())).as_deref(), Some("mark1"));
|
||
assert_eq!(locked.int(b"F"), Some(128), "the locked mark lost its Locked flag");
|
||
// Every redaction mark on the page is visible (none Invisible/Hidden/NoView): the import's two
|
||
// marks were added beside the protected one, both drawable for review.
|
||
let redacts: Vec<Dict> =
|
||
refs.iter().map(|r| doc.get(*r).as_dict().cloned().unwrap()).filter(|d| d.name(b"Subtype") == Some(b"Redact".as_slice())).collect();
|
||
assert_eq!(redacts.len(), 3, "the imported marks should be added, not merged away");
|
||
for d in &redacts {
|
||
assert_eq!(d.int(b"F").unwrap_or(0) & (1 | 2 | 32), 0, "a redaction mark is hidden from review: {d:?}");
|
||
}
|
||
}
|
||
|
||
/// The `/Vertices` of every annotation on the document's pages, by subtype.
|
||
fn vertices(doc: &Document) -> Vec<(String, Vec<f64>)> {
|
||
let mut out = Vec::new();
|
||
for p in pdfcraft_model::pages(doc) {
|
||
for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||
let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue };
|
||
let sub = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||
let v = nums_of(doc, d.get(b"Vertices"));
|
||
if !v.is_empty() {
|
||
out.push((sub, v));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// storytold/pdfcraft#809: XFDF exchange dropped polygon and polyline vertices, so the
|
||
/// imported comments had no shape (and no appearance).
|
||
#[test]
|
||
fn xfdf_keeps_polygon_and_polyline_vertices() {
|
||
let mut src = blank();
|
||
let meta = |id: &str| Meta { date: Some("D:20261002120000Z".into()), id: id.into() };
|
||
let polygon = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 120.0], [150.0, 220.5]], cloud: false };
|
||
let polyline = Shape::PolyLine {
|
||
vertices: vec![[50.0, 300.0], [120.0, 380.0], [200.0, 310.0]],
|
||
start: pdfcraft_annot::LineEnding::None,
|
||
end: pdfcraft_annot::LineEnding::ClosedArrow,
|
||
};
|
||
for (page, shape, id) in [(0, polygon, "pg"), (1, polyline, "pl")] {
|
||
let new = NewAnnotation { page, style: Style::default_for(&shape), shape, contents: id.into(), author: "Ada".into() };
|
||
add_annotation(&mut src, &new, &meta(id)).unwrap();
|
||
}
|
||
let xfdf = export_xfdf(&src, true, false, "shapes.pdf");
|
||
assert!(xfdf.contains("<vertices>100,100;200,120;150,220.5</vertices>"), "{xfdf}");
|
||
assert!(xfdf.contains("<vertices>50,300;120,380;200,310</vertices>"), "{xfdf}");
|
||
let mut dst = blank();
|
||
let r = import(&mut dst, xfdf.as_bytes()).unwrap();
|
||
assert_eq!(r.comments, 2, "{r:?}");
|
||
assert_eq!(vertices(&dst), vertices(&src));
|
||
// The imported shapes are drawn again.
|
||
for p in pdfcraft_model::pages(&dst) {
|
||
for a in p.dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||
let d = dst.resolve(&a).as_dict().cloned().unwrap();
|
||
assert!(d.get(b"AP").is_some(), "{d:?}");
|
||
}
|
||
}
|
||
// Acrobat-style input: a stray trailing number is dropped and only whole points are kept.
|
||
let odd = r#"<?xml version="1.0"?><xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>
|
||
<polygon page="0" rect="0,0,50,50" name="odd"><vertices>1,2;3,4;5,6;7</vertices></polygon>
|
||
<polygon page="0" rect="0,0,50,50" name="none"><vertices>junk</vertices></polygon>
|
||
</annots></xfdf>"#;
|
||
let mut dst = blank();
|
||
import(&mut dst, odd.as_bytes()).unwrap();
|
||
assert_eq!(vertices(&dst), vec![("Polygon".to_string(), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]);
|
||
}
|
||
|
||
#[test]
|
||
fn xfdf_keeps_callout_leader_inset_and_ending() {
|
||
let mut src = blank();
|
||
let shape = Shape::Callout {
|
||
rect: [40.0, 300.0, 180.0, 360.0],
|
||
knee: [260.0, 330.0],
|
||
point: [320.0, 200.0],
|
||
font_size: 12.0,
|
||
ending: pdfcraft_annot::LineEnding::ClosedArrow,
|
||
};
|
||
let meta = Meta { date: Some("D:20261002120000Z".into()), id: "c1".into() };
|
||
add_annotation(
|
||
&mut src,
|
||
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "leader".into(), author: "Ada".into() },
|
||
&meta,
|
||
)
|
||
.unwrap();
|
||
let xfdf = export_xfdf(&src, true, false, "form.pdf");
|
||
let mut dst = blank();
|
||
import(&mut dst, xfdf.as_bytes()).unwrap();
|
||
let callout_keys = |doc: &Document| {
|
||
let page = pdfcraft_model::pages(doc).remove(0);
|
||
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||
let d = annots.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).find(|d| d.name(b"Subtype") == Some(b"FreeText")).unwrap();
|
||
(
|
||
nums_of(doc, d.get(b"Rect")),
|
||
nums_of(doc, d.get(b"CL")),
|
||
nums_of(doc, d.get(b"RD")),
|
||
d.name(b"IT").map(<[u8]>::to_vec),
|
||
d.get(b"LE").and_then(|o| o.as_name()).map(<[u8]>::to_vec),
|
||
)
|
||
};
|
||
let want = callout_keys(&src);
|
||
assert_eq!(want.1.len(), 6, "{want:?}");
|
||
assert_eq!(callout_keys(&dst), want, "{xfdf}");
|
||
}
|
||
|
||
/// Run `f` on a thread with a small stack, as a deep recursion would overflow it.
|
||
fn on_small_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||
std::thread::Builder::new().stack_size(256 << 10).spawn(f).unwrap().join().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn deeply_nested_xml_is_refused_before_parsing() {
|
||
// A file nested far deeper than any stack holds used to overflow roxmltree's recursive
|
||
// parser, which aborts the process. It must be refused without recursing.
|
||
let deep = |root: &str| format!("<{root}>{}{}</{root}>", "<a>".repeat(100_000), "</a>".repeat(100_000));
|
||
let xfdf = deep("xfdf");
|
||
let xml = deep("fields");
|
||
let (imported, table, values) = on_small_stack(move || {
|
||
let mut doc = blank();
|
||
(import(&mut doc, xfdf.as_bytes()), import(&mut doc, xml.as_bytes()), data_values(xfdf.as_bytes()))
|
||
});
|
||
for r in [imported.map(|_| ()), table.map(|_| ()), values.map(|_| ())] {
|
||
assert!(matches!(&r, Err(DataError::Malformed(m)) if m.contains("deep")), "{r:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wide_or_commented_xml_is_not_mistaken_for_deep() {
|
||
// Thousands of siblings, self-closing tags, `>` in attribute values, and tags inside
|
||
// comments, CDATA and processing instructions add no depth.
|
||
let mut x = String::from("<?xml version=\"1.0\"?><!-- <a><a><a> --><xfdf><fields>");
|
||
for i in 0..5_000 {
|
||
x.push_str(&format!("<field name=\"f{i} > g\"><value>v</value></field><empty/><![CDATA[<b><b>]]><?pi <c> ?>"));
|
||
}
|
||
x.push_str("</fields></xfdf>");
|
||
assert_eq!(check_form_xml_depth(&x), Ok(()));
|
||
let nested = |n: usize| format!("{}{}", "<a>".repeat(n), "</a>".repeat(n));
|
||
assert_eq!(check_form_xml_depth(&nested(MAX_XML_DEPTH)), Ok(()));
|
||
assert!(check_form_xml_depth(&nested(MAX_XML_DEPTH + 1)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() {
|
||
// Flags are a set of bit masks, not additive integers. A repeated "print" flag
|
||
// must not carry into "nozoom", nor repeated "readonly" into "locked".
|
||
for (flags, expected) in [("print, PRINT, nozoom", 12), ("readonly,readonly", 64), ("print,print,print", 4)] {
|
||
let xfdf = format!(
|
||
r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><square page="0" rect="10,10,50,50" name="flags" flags="{flags}" /></annots></xfdf>"#
|
||
);
|
||
let mut doc = blank();
|
||
assert_eq!(import(&mut doc, xfdf.as_bytes()).unwrap().comments, 1);
|
||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||
let annotations = doc.get(page).as_dict().and_then(|p| p.get(b"Annots")).map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||
let square = annotations.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).find(|d| d.name(b"Subtype") == Some(b"Square")).unwrap();
|
||
assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}");
|
||
}
|
||
}
|
||
|
||
// XML comments and processing instructions are not field text (XML 1.0 §§2.5–2.6).
|
||
fn split_text_values() -> [(&'static str, &'static str); 5] {
|
||
[
|
||
("Ada<!--editor note--> Lovelace", "Ada Lovelace"),
|
||
("<!--editor note-->Ada", "Ada"),
|
||
("Ada<?editor keep?> Lovelace", "Ada Lovelace"),
|
||
(" Å<!--note--><![CDATA[<&>]]><?editor keep?>Ω ", " Å<&>Ω "),
|
||
("<!--empty--><?editor keep?>", ""),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn xfdf_data_values_join_text_around_xml_comments_and_instructions() {
|
||
for (xml_text, expected) in split_text_values() {
|
||
let xml = format!(r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><fields><field name="Name"><value>{xml_text}</value></field></fields></xfdf>"#);
|
||
assert_eq!(data_values(xml.as_bytes()).unwrap(), vec![("Name".into(), vec![expected.to_string()])], "{xml_text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn xfdf_import_keeps_text_around_xml_comments_and_instructions() {
|
||
for (xml_text, expected) in split_text_values() {
|
||
let xml = format!(r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><fields><field name="Name"><value>{xml_text}</value></field></fields></xfdf>"#);
|
||
let mut doc = one_field();
|
||
set_value(&mut doc, "Name", &FieldValue::Text("old value".into())).unwrap();
|
||
let report = import(&mut doc, xml.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, 1);
|
||
assert!(report.rejected.is_empty(), "{report:?}");
|
||
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
let want: Vec<&str> = if expected.is_empty() { vec![] } else { vec![expected] };
|
||
assert_eq!(values(&reopened)[0].1, want, "{xml_text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_keeps_indirect_values_nested_in_annotation_properties() {
|
||
let mut src = blank();
|
||
let page = pdfcraft_model::pages(&src)[0].obj;
|
||
let mut annotation = Dict::new();
|
||
annotation.set(b"Type".to_vec(), Object::name("Annot"));
|
||
annotation.set(b"Subtype".to_vec(), Object::name("Square"));
|
||
annotation.set(b"NM".to_vec(), PdfString::text("indirect-values"));
|
||
let rect = [50.0, 60.0, 100.0, 120.0].map(|v| Object::Ref(src.add(Object::Real(v))));
|
||
annotation.set(b"Rect".to_vec(), Object::Array(rect.into()));
|
||
let color = [0.25, 0.5, 0.75].map(|v| Object::Ref(src.add(Object::Real(v))));
|
||
annotation.set(b"C".to_vec(), Object::Array(color.into()));
|
||
let mut border = Dict::new();
|
||
border.set(b"W".to_vec(), Object::Ref(src.add(Object::Real(2.5))));
|
||
border.set(b"S".to_vec(), Object::name("D"));
|
||
let dash = [3.0, 2.0].map(|v| Object::Ref(src.add(Object::Real(v))));
|
||
border.set(b"D".to_vec(), Object::Ref(src.add(Object::Array(dash.into()))));
|
||
annotation.set(b"BS".to_vec(), Object::Ref(src.add(border)));
|
||
let note = src.add(annotation);
|
||
src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||
|
||
// This is an actual saved PDF: indirect scalar numbers, arrays and dictionaries are legal
|
||
// PDF objects, and source object numbers have no meaning in an exported FDF.
|
||
let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap();
|
||
assert_eq!(summaries(&src)[0].rect, [50.0, 60.0, 100.0, 120.0]);
|
||
assert_eq!(summaries(&src)[0].color, Some([0.25, 0.5, 0.75]));
|
||
let fdf = export_fdf(&src, true, false, "source.pdf").unwrap();
|
||
let exported = Document::open(Arc::new(fdf.clone())).unwrap();
|
||
let body = fdf_body(&exported).unwrap();
|
||
let annots = exported.resolve(body.get(b"Annots").unwrap());
|
||
let note = exported.resolve(&annots.as_array().unwrap()[0]);
|
||
let d = note.as_dict().unwrap();
|
||
assert_eq!(nums_of(&exported, d.get(b"Rect")), [50.0, 60.0, 100.0, 120.0], "{}", String::from_utf8_lossy(&fdf));
|
||
assert_eq!(nums_of(&exported, d.get(b"C")), [0.25, 0.5, 0.75]);
|
||
let bs = exported.resolve(d.get(b"BS").unwrap());
|
||
let bs = bs.as_dict().unwrap();
|
||
assert_eq!(exported.resolve(bs.get(b"W").unwrap()).as_f64(), Some(2.5));
|
||
assert_eq!(nums_of(&exported, bs.get(b"D")), [3.0, 2.0]);
|
||
|
||
let mut dst = blank();
|
||
import(&mut dst, &fdf).unwrap();
|
||
let dst = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
|
||
let notes = summaries(&dst);
|
||
assert_eq!(notes[0].rect, [50.0, 60.0, 100.0, 120.0]);
|
||
assert_eq!(notes[0].color, Some([0.25, 0.5, 0.75]));
|
||
}
|
||
|
||
/// Put extension data on a real exported comment without changing its normal properties.
|
||
fn fdf_note_with_data(doc: &mut Document, data: Object) {
|
||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||
let mut note = Dict::new();
|
||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
|
||
note.set(b"PCData".to_vec(), data);
|
||
let note = doc.add(note);
|
||
doc.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||
}
|
||
|
||
fn exported_note_data(doc: &Document) -> Object {
|
||
let body = fdf_body(doc).unwrap();
|
||
let annots = doc.resolve(body.get(b"Annots").unwrap());
|
||
let note = doc.resolve(&annots.as_array().unwrap()[0]);
|
||
note.as_dict().unwrap().get(b"PCData").unwrap().clone()
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_preserves_shared_cycles_and_nested_streams_without_expansion() {
|
||
let mut src = blank();
|
||
let leaf = src.add(PdfString::text("shared"));
|
||
let mut shared = leaf;
|
||
for _ in 0..35 {
|
||
shared = src.add(Object::Array(vec![Object::Ref(shared), Object::Ref(shared)]));
|
||
}
|
||
let cycle = src.add(Object::Null);
|
||
src.set(cycle, Object::Array(vec![Object::Ref(cycle)]));
|
||
let mut stream_dict = Dict::new();
|
||
stream_dict.set(b"PCMeta".to_vec(), Object::Ref(leaf));
|
||
let stream = src.add(pdfcraft_cos::Stream::from_raw(stream_dict, b"original bytes".to_vec()));
|
||
let data = Object::Array(vec![Object::Ref(shared), Object::Ref(shared), Object::Ref(cycle), Object::Ref(stream)]);
|
||
fdf_note_with_data(&mut src, data);
|
||
let src = Document::open(Arc::new(write_full(&src, &SaveOptions::default()).unwrap())).unwrap();
|
||
let bytes = export_fdf(&src, true, false, "source.pdf").unwrap();
|
||
assert!(bytes.len() < 10_000, "a shared graph must not expand");
|
||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||
let data = exported_note_data(&fdf);
|
||
let data = data.as_array().unwrap();
|
||
assert_eq!(data[0], data[1]);
|
||
let mut current = data[0].clone();
|
||
for _ in 0..35 {
|
||
let next = fdf.resolve(¤t);
|
||
let values = next.as_array().unwrap();
|
||
assert_eq!(values[0], values[1]);
|
||
current = values[0].clone();
|
||
}
|
||
assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "shared");
|
||
let cycle = fdf.resolve(&data[2]);
|
||
assert_eq!(cycle.as_array().unwrap()[0], data[2]);
|
||
let stream = fdf.resolve(&data[3]);
|
||
let Object::Stream(stream) = &*stream else { panic!("copied stream") };
|
||
assert_eq!(&*stream.raw, b"original bytes");
|
||
assert_eq!(fdf.resolve(stream.dict.get(b"PCMeta").unwrap()).as_string().unwrap().to_text(), "shared");
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_walks_long_indirect_chains_and_nulls_absent_or_stale_refs() {
|
||
let mut src = blank();
|
||
let live = src.add(PdfString::text("live"));
|
||
let mut head = live;
|
||
for _ in 0..3_000 {
|
||
head = src.add(Object::Array(vec![Object::Ref(head)]));
|
||
}
|
||
fdf_note_with_data(&mut src, Object::Array(vec![Object::Ref(head), Object::Ref(ObjRef::new(999_999, 0)), Object::Ref(ObjRef::new(live.num, 1))]));
|
||
let fdf = Document::open(Arc::new(export_fdf(&src, true, false, "source.pdf").unwrap())).unwrap();
|
||
let data = exported_note_data(&fdf);
|
||
let data = data.as_array().unwrap();
|
||
assert_eq!(*fdf.resolve(&data[1]), Object::Null);
|
||
assert_eq!(*fdf.resolve(&data[2]), Object::Null);
|
||
let mut current = data[0].clone();
|
||
for _ in 0..3_000 {
|
||
current = fdf.resolve(¤t).as_array().unwrap()[0].clone();
|
||
}
|
||
assert_eq!(fdf.resolve(¤t).as_string().unwrap().to_text(), "live");
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_refuses_excessive_direct_depth_values_and_payload() {
|
||
let mut src = blank();
|
||
let mut deep = Object::Null;
|
||
for _ in 0..70 {
|
||
deep = Object::Array(vec![deep]);
|
||
}
|
||
fdf_note_with_data(&mut src, deep);
|
||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 direct nesting levels")));
|
||
// Not exporting comments must not inspect their extension data.
|
||
assert!(export_fdf(&src, false, true, "source.pdf").is_ok());
|
||
|
||
let mut src = blank();
|
||
fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; MAX_FDF_OBJECTS + 1]));
|
||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values")));
|
||
|
||
let mut src = blank();
|
||
let stream = src.add(pdfcraft_cos::Stream::from_raw(Dict::new(), vec![0; MAX_FDF_BYTES + 1]));
|
||
fdf_note_with_data(&mut src, Object::Ref(stream));
|
||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data")));
|
||
|
||
let mut src = blank();
|
||
// Strings need up to four serialized bytes per source byte. Refuse before copying
|
||
// the direct payload, just as the indirect stream case above refuses its raw data.
|
||
fdf_note_with_data(&mut src, Object::String(PdfString::literal(vec![b'x'; MAX_FDF_BYTES / 4 + 1])));
|
||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("64 MiB data")));
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_refuses_aliases_before_cloning_large_annotation_values() {
|
||
let mut src = blank();
|
||
fdf_note_with_data(&mut src, Object::Array(vec![Object::Null; 1_024]));
|
||
let page = pdfcraft_model::pages(&src)[0].obj;
|
||
let annots = src.get(page).as_dict().unwrap().get(b"Annots").unwrap().clone();
|
||
let note = annots.as_array().unwrap()[0].as_ref().unwrap();
|
||
// The source holds one 1,024-value dictionary. The aliases must not clone its
|
||
// values a hundred thousand times before the comment-count refusal is reached.
|
||
src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note); MAX_FDF_OBJECTS + 1]))).unwrap();
|
||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 comments")));
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_refuses_repeated_saved_annotations_after_cache_eviction() {
|
||
let bytes = {
|
||
let mut src = blank();
|
||
let page = pdfcraft_model::pages(&src)[0].obj;
|
||
let mut notes = Vec::new();
|
||
// More than the editor cache's 1,024 objects. Four passes force reparsing;
|
||
// retaining each resolved body during discovery would pin all four copies.
|
||
for _ in 0..1_025 {
|
||
let mut note = Dict::new();
|
||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
|
||
note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 1_024]));
|
||
notes.push(Object::Ref(src.add(note)));
|
||
}
|
||
let aliases = (0..4).flat_map(|_| notes.iter().cloned()).collect();
|
||
src.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(aliases))).unwrap();
|
||
write_full(&src, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap()
|
||
};
|
||
// Drop the overlay-backed source before reopening: its persistent Arc objects
|
||
// would hide the parsed-object cache eviction exercised by this fixture.
|
||
let src = Document::open(Arc::new(bytes)).unwrap();
|
||
assert!(src.repair_log().is_empty());
|
||
assert_eq!(export_fdf(&src, true, false, "source.pdf"), Err(DataError::ExportLimit("100,000 values")));
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_counts_emitted_field_values_before_allocating_lists() {
|
||
let field_document = |kind: &str, count: usize| {
|
||
let mut doc = Document::new_empty();
|
||
let mut field = Dict::new();
|
||
field.set(b"FT".to_vec(), Object::name(kind));
|
||
field.set(b"T".to_vec(), PdfString::text("Pick"));
|
||
field.set(b"Ff".to_vec(), Object::Int(1 << 21)); // multi-select for a /Ch list
|
||
field.set(b"V".to_vec(), Object::Array(vec![Object::String(PdfString::text("")); count]));
|
||
let field = doc.add(field);
|
||
let mut form = Dict::new();
|
||
form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)]));
|
||
doc.update_dict(doc.root().unwrap(), |root| root.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
|
||
assert_eq!(fields(&doc)[0].value.len(), count);
|
||
doc
|
||
};
|
||
|
||
// A field dictionary, /T string and /V array use three nodes before its entries.
|
||
let doc = field_document("Ch", MAX_FDF_OBJECTS - 3);
|
||
assert_eq!(fields(&doc)[0].kind, FieldKind::List);
|
||
let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap();
|
||
assert_eq!(data_values(&bytes).unwrap()[0].1.len(), MAX_FDF_OBJECTS - 3);
|
||
for count in [MAX_FDF_OBJECTS - 2, MAX_FDF_OBJECTS + 1] {
|
||
let doc = field_document("Ch", count);
|
||
assert!(matches!(export_fdf(&doc, false, true, "field.pdf"), Err(DataError::ExportLimit("100,000 values"))));
|
||
}
|
||
|
||
// A hostile array on a scalar field still emits just its first string. The node
|
||
// budget counts output values, rather than charging entries that are not copied.
|
||
let doc = field_document("Tx", MAX_FDF_OBJECTS + 1);
|
||
assert_eq!(fields(&doc)[0].kind, FieldKind::Text);
|
||
let bytes = export_fdf(&doc, false, true, "field.pdf").unwrap();
|
||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||
let body = fdf_body(&fdf).unwrap();
|
||
let exported = fdf.resolve(body.get(b"Fields").unwrap());
|
||
let exported = fdf.resolve(&exported.as_array().unwrap()[0]);
|
||
assert!(matches!(exported.as_dict().unwrap().get(b"V"), Some(Object::String(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn xml_form_import_preserves_text_around_comments_and_instructions() {
|
||
for (body, expected) in [
|
||
("left<!-- ignored -->right", "leftright"),
|
||
("<!-- first -->left<?note ignored?>middle<!-- last -->right", "leftmiddleright"),
|
||
("<![CDATA[<left>]]><!-- ignored -->&right", "<left>&right"),
|
||
(" left <!-- ignored --> right ", " left right "),
|
||
("<!-- empty --><?note ignored?>", ""),
|
||
] {
|
||
let mut original = one_field();
|
||
let root = original.root().unwrap();
|
||
original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap();
|
||
set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap();
|
||
let source = write_full(&original, &SaveOptions::default()).unwrap();
|
||
let mut doc = Document::open(Arc::new(source.clone())).unwrap();
|
||
let xml = format!("<?xml version=\"1.0\"?><fields><Name>{body}</Name></fields>");
|
||
let report = import(&mut doc, xml.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, 1);
|
||
assert!(report.rejected.is_empty());
|
||
for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap();
|
||
let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] };
|
||
assert_eq!(name.value, want, "XML body: {body}");
|
||
let root = reopened.get(reopened.root().unwrap());
|
||
assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained");
|
||
}
|
||
assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() {
|
||
for indirect_type in [false, true] {
|
||
let source = {
|
||
let mut doc = Document::new_empty();
|
||
let root = doc.root().unwrap();
|
||
let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap();
|
||
let mut pages = Vec::new();
|
||
for _ in 0..3 {
|
||
let contents = doc.add(pdfcraft_cos::Stream::from_raw(
|
||
Dict::new(),
|
||
b"% source-page-content-must-stay-in-the-PDF\nq 0 g 0 0 10 10 re f Q\n".repeat(1_024),
|
||
));
|
||
let mut page = Dict::new();
|
||
let page_type = if indirect_type { Object::Ref(doc.add(Object::name("Page"))) } else { Object::name("Page") };
|
||
page.set(b"Type".to_vec(), page_type);
|
||
page.set(b"Parent".to_vec(), Object::Ref(tree));
|
||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||
page.set(b"Contents".to_vec(), Object::Ref(contents));
|
||
pages.push(doc.add(page));
|
||
}
|
||
let tree_type = if indirect_type { Object::Ref(doc.add(Object::name("Pages"))) } else { Object::name("Pages") };
|
||
doc.update_dict(tree, |dict| {
|
||
dict.set(b"Type".to_vec(), tree_type);
|
||
dict.set(b"Count".to_vec(), Object::Int(3));
|
||
dict.set(b"Kids".to_vec(), Object::Array(pages.iter().copied().map(Object::Ref).collect()));
|
||
})
|
||
.unwrap();
|
||
let mut action = Dict::new();
|
||
action.set(b"S".to_vec(), Object::name("GoTo"));
|
||
action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(pages[1]), Object::name("Fit")]));
|
||
let mut extension = Dict::new();
|
||
extension.set(b"Type".to_vec(), Object::name("PCMetadata"));
|
||
extension.set(b"Target".to_vec(), Object::Ref(pages[1]));
|
||
extension.set(b"Label".to_vec(), PdfString::text("retained metadata"));
|
||
let extension = doc.add(extension);
|
||
let type_cycle = doc.add(Object::Null);
|
||
doc.set(type_cycle, Object::Ref(type_cycle));
|
||
let mut cyclic_type = Dict::new();
|
||
cyclic_type.set(b"Type".to_vec(), Object::Ref(type_cycle));
|
||
cyclic_type.set(b"Label".to_vec(), PdfString::text("cyclic type is not a page"));
|
||
fdf_note_with_data(
|
||
&mut doc,
|
||
Object::Array(vec![Object::Dict(action), Object::Ref(extension), Object::Array(vec![Object::Ref(tree)]), Object::Dict(cyclic_type)]),
|
||
);
|
||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||
};
|
||
let source = Document::open(Arc::new(source)).unwrap();
|
||
assert!(source.repair_log().is_empty());
|
||
assert_eq!(pdfcraft_model::pages(&source).len(), 3);
|
||
let bytes = export_fdf(&source, true, false, "source.pdf").unwrap();
|
||
assert!(bytes.len() < 4_096, "a comment export must not carry source pages or their content ({} bytes)", bytes.len());
|
||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||
let data = exported_note_data(&fdf);
|
||
let data = data.as_array().unwrap();
|
||
let action = data[0].as_dict().unwrap();
|
||
assert_eq!(action.name(b"S"), Some(b"GoTo".as_slice()));
|
||
let destination = action.get(b"D").unwrap().as_array().unwrap();
|
||
assert_eq!(*fdf.resolve(&destination[0]), Object::Null);
|
||
assert_eq!(destination[1], Object::name("Fit"));
|
||
let extension = fdf.resolve(&data[1]);
|
||
let extension = extension.as_dict().unwrap();
|
||
assert_eq!(extension.name(b"Type"), Some(b"PCMetadata".as_slice()));
|
||
assert_eq!(extension.get(b"Target"), Some(&destination[0]), "the excluded page reference remains aliased");
|
||
assert_eq!(extension.get(b"Label").unwrap().as_string().unwrap().to_text(), "retained metadata");
|
||
let tree = data[2].as_array().unwrap();
|
||
assert_eq!(*fdf.resolve(&tree[0]), Object::Null);
|
||
|
||
let cyclic_type = data[3].as_dict().unwrap();
|
||
assert_eq!(cyclic_type.get(b"Label").unwrap().as_string().unwrap().to_text(), "cyclic type is not a page");
|
||
let type_cycle = cyclic_type.get(b"Type").unwrap();
|
||
assert_eq!(&*fdf.resolve(type_cycle), type_cycle);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_bounds_indirect_type_alias_lookups() {
|
||
for extra_aliases in [MAX_DEPTH - 1, MAX_DEPTH] {
|
||
let mut doc = blank();
|
||
let mut page_type = doc.add(Object::name("Page"));
|
||
for _ in 0..extra_aliases {
|
||
page_type = doc.add(Object::Ref(page_type));
|
||
}
|
||
let mut page = Dict::new();
|
||
page.set(b"Type".to_vec(), Object::Ref(page_type));
|
||
fdf_note_with_data(&mut doc, Object::Dict(page));
|
||
let exported = export_fdf(&doc, true, false, "");
|
||
if extra_aliases < MAX_DEPTH {
|
||
let fdf = Document::open(Arc::new(exported.unwrap())).unwrap();
|
||
assert_eq!(exported_note_data(&fdf), Object::Null);
|
||
} else {
|
||
assert!(matches!(exported, Err(DataError::ExportLimit("64 Type reference levels"))));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fdf_export_preserves_metadata_with_stale_type_references() {
|
||
for alias in [false, true] {
|
||
let mut doc = blank();
|
||
let current = doc.add(Object::name("Page"));
|
||
doc.update_dict(doc.root().unwrap(), |dict| dict.set(b"PCPageType".to_vec(), Object::Ref(current))).unwrap();
|
||
let mut doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||
let current = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"PCPageType").unwrap();
|
||
let stale = ObjRef::new(current.num, current.generation + 1);
|
||
let page_type = if alias { doc.add(Object::Ref(stale)) } else { stale };
|
||
let mut metadata = Dict::new();
|
||
metadata.set(b"Type".to_vec(), Object::Ref(page_type));
|
||
metadata.set(b"Label".to_vec(), PdfString::text("preserved despite stale type"));
|
||
fdf_note_with_data(&mut doc, Object::Dict(metadata));
|
||
assert_eq!(doc.generation(stale.num), current.generation);
|
||
// A warm number-keyed cache must not make the stale Type reference live.
|
||
assert_eq!(*doc.try_get(stale.num).unwrap(), Object::name("Page"));
|
||
let bytes = export_fdf(&doc, true, false, "").unwrap();
|
||
let fdf = Document::open(Arc::new(bytes)).unwrap();
|
||
let data = exported_note_data(&fdf);
|
||
let metadata = data.as_dict().expect("stale Type must not classify unrelated metadata as a source page");
|
||
assert_eq!(metadata.get(b"Label").unwrap().as_string().unwrap().to_text(), "preserved despite stale type");
|
||
let mut page_type = fdf.resolve(metadata.get(b"Type").unwrap());
|
||
if alias {
|
||
page_type = fdf.resolve(&page_type);
|
||
}
|
||
assert_eq!(*page_type, Object::Null);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn csv_headers_with_tabs_survive_export_import_and_save() {
|
||
for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] {
|
||
let mut source = one_field();
|
||
add_field(&mut source, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap();
|
||
set_value(&mut source, "Other", &FieldValue::Text("other field".into())).unwrap();
|
||
let field = fields(&source).into_iter().find(|f| f.name == "Name").unwrap().obj;
|
||
source.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap();
|
||
set_value(&mut source, name, &FieldValue::Text("original, \"value\"".into())).unwrap();
|
||
let bytes = write_full(&source, &SaveOptions::default()).unwrap();
|
||
let source = Document::open(Arc::new(bytes.clone())).unwrap();
|
||
let row = data_values(&bytes).unwrap();
|
||
for csv in [export_data(&source, Format::Csv), merge_csv(&[row])] {
|
||
let mut doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||
set_value(&mut doc, name, &FieldValue::Text("replacement".into())).unwrap();
|
||
set_value(&mut doc, "Other", &FieldValue::Text("other replacement".into())).unwrap();
|
||
let report = import(&mut doc, csv.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, 2);
|
||
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
|
||
for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||
assert_eq!(fields(&reopened).into_iter().find(|f| f.name == name).unwrap().value, vec!["original, \"value\""], "{csv:?}");
|
||
assert_eq!(values(&reopened), values(&source), "{csv:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() {
|
||
for name in ["Name", "Na\"me", "\"Name\"", "\"Name"] {
|
||
let mut doc = one_field();
|
||
add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap();
|
||
let field = fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().obj;
|
||
doc.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap();
|
||
let text = format!("{name}\tOther\r\nAda\tother field\r\n");
|
||
let report = import(&mut doc, text.as_bytes()).unwrap();
|
||
assert_eq!(report.fields, 2, "{text:?}");
|
||
assert_eq!(fields(&doc).into_iter().find(|f| f.name == name).unwrap().value, vec!["Ada"]);
|
||
}
|
||
let mut doc = one_field();
|
||
add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap();
|
||
import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap();
|
||
assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]);
|
||
}
|