Merge upstream/main into fix/xfa-open-scripts-complete

Main's DataOp::Value now replaces a multi-select list's value-group picks in the
packet and in the data copy, so the reset writes through it and the separate
DataOp::Reset (and Want::picks) is dropped. The reset keeps the PR's indexed
targets and its pass over fields without widgets, beside main's floating-field
pass (which the unshown pass leaves embedded fields to), and finish() lays out
again for either an owed relayout or a changed embedded value. Parity notes and
evidence take both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410
2026-11-09 10:39:06 +08:00
co-authored by Claude Opus 5.5
654 changed files with 99220 additions and 5792 deletions
+424 -3
View File
@@ -305,6 +305,85 @@ fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() {
}
}
/// A `/Kids` entry that loops back (the field itself, or a field it descends from), or names a root
/// field that doesn't name this one as its `/Parent`, is not a child (ISO 32000-2 §12.7.3,
/// Table 224; §12.7.4.1, Table 226). The field keeps its other kids: here its radio buttons.
#[test]
fn kids_that_loop_back_or_are_roots_are_not_children() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >>".into(), // 3
"<< /Fields [5 0 R 7 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [5 0 R 6 0 R] >>".into(), // 5 lists itself
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 65 715] /P 3 0 R /AP << /N << /S 11 0 R /Off 11 0 R >> >> >>".into(), // 6
"<< /FT /Btn /Ff 49152 /T (left) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 7 lists the root 8
"<< /FT /Btn /Ff 49152 /T (right) /V /Off /Kids [7 0 R 12 0 R] >>".into(), // 8 lists the root 7
"<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 650 65 665] /P 3 0 R /AP << /N << /A 11 0 R /Off 11 0 R >> >> >>".into(), // 9
"<< /T (group) /Kids [13 0 R] >>".into(), // 10
"<< /Length 0 >>
stream
endstream"
.into(), // 11
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [50 600 65 615] /P 3 0 R /AP << /N << /B 11 0 R /Off 11 0 R >> >> >>".into(), // 12
"<< /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >>".into(), // 13
"<< /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >>".into(), // 14 lists its parent
"<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 550 250 570] /P 3 0 R >>".into(), // 15
];
let doc = document(&objs);
let all = fields(&doc);
let listed: Vec<(&str, FieldKind, Vec<ObjRef>)> =
all.iter().map(|f| (f.name.as_str(), f.kind, f.widgets.iter().map(|w| w.obj).collect())).collect();
let r = |num| ObjRef::new(num, 0);
assert_eq!(
listed,
[
("size", FieldKind::Radio, vec![r(6)]),
("left", FieldKind::Radio, vec![r(9)]),
("right", FieldKind::Radio, vec![r(12)]),
("group.inner.deep", FieldKind::Text, vec![r(15)]),
]
);
}
/// A page widget whose `/Parent` names a field is one of that field's widgets even when the
/// field's `/Kids` leave it out: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here
/// `radio` lists itself and one of its two widgets, and `pair` and `other` list each other (roots,
/// so not children) and none of their widgets. Each field gets every widget naming it, once.
#[test]
fn widgets_naming_a_field_that_does_not_list_them_join_it() {
let w = |parent: u32, state: &str, y: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [50 {y} 65 {}] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 10 0 R >> >> >>",
y + 15
)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3
w(5, "A", 700), // 4: not in 5's /Kids
"<< /FT /Btn /Ff 49152 /T (radio) /V /Off /Kids [5 0 R 7 0 R] >>".into(), // 5
"<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 6
w(5, "B", 650), // 7: in 5's /Kids
"<< /FT /Btn /Ff 49152 /T (pair) /V /Off /Kids [9 0 R 11 0 R] >>".into(), // 8 lists the root 9
"<< /FT /Btn /Ff 49152 /T (other) /V /Off /Kids [8 0 R] >>".into(), // 9 lists the root 8
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
w(8, "C", 600), // 11: in 8's /Kids
w(8, "D", 550), // 12: not in 8's /Kids
w(9, "E", 500), // 13: not in 9's /Kids
];
let doc = document(&objs);
let (all, adopted) = enumerate(&doc);
let listed: Vec<(&str, Vec<ObjRef>)> = all.iter().map(|f| (f.name.as_str(), f.widgets.iter().map(|w| w.obj).collect())).collect();
let r = |num| ObjRef::new(num, 0);
assert_eq!(listed, [("radio", vec![r(7), r(4)]), ("pair", vec![r(11), r(12)]), ("other", vec![r(13)])]);
assert_eq!(adopted, 0, "no widget is read as a field of its own");
let states: Vec<_> = field(&all, "radio").widgets.iter().filter_map(|w| w.on_state.as_deref()).collect();
assert_eq!(states, ["B", "A"]);
}
/// Some writers leave every field out of `/Fields` and put the widgets only in the page
/// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway.
/// The pages' widgets are adopted as fields, after the listed tree fields.
@@ -353,6 +432,37 @@ fn fields_listed_only_on_the_pages_are_adopted() {
assert_eq!(adopted_page_fields(&doc), 4);
}
/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary,
/// has no parent: the reference reads as `null`, the same as an absent entry (ISO
/// 32000-2 §7.3.9). It is adopted as a field of its own, under its own name, and can be filled.
#[test]
fn a_widget_whose_parent_is_missing_is_a_field_of_its_own() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V (Oslo) /Parent 40 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 parent not in the file
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Parent 9 0 R /Rect [50 660 250 680] /P 3 0 R >>".into(), // 7 parent is no dictionary
"<< /Type /Annot /Subtype /Widget /Parent 40 0 R /Rect [400 700 415 715] /P 3 0 R >>".into(), // 8 no /FT or /T: not a field
"[1 2 3]".into(), // 9
];
let mut doc = document(&objs);
assert_combined_matches_legacy(&doc, 2);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["city", "zip"]);
let city = field(&all, "city");
assert_eq!(
(city.kind, city.value.as_slice(), city.widgets.len(), city.widgets[0].page),
(FieldKind::Text, &["Oslo".to_string()][..], 1, Some(0))
);
set_value(&mut doc, "zip", &FieldValue::Text("0150".into())).unwrap();
let doc = reopen(&doc);
assert_eq!(field(&fields(&doc), "zip").value, ["0150"]);
}
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
#[test]
@@ -967,6 +1077,61 @@ fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() {
assert!(need_appearances(&doc));
}
/// Thai typed into a field whose `/DA` font (Helvetica, WinAnsi) can't hold it is drawn from the
/// Thai face as Add text draws it: Type 3 glyphs (stream objects of their own, with a ToUnicode
/// map) beside the field's font, which draws the rest, so every viewer shows the same glyphs. The
/// `/DA` is kept, nothing is marked and other viewers aren't asked to redraw. The ink of stacked
/// marks and descenders stays inside the border, an automatic size shrinking to keep it there.
/// A build without a Thai face refuses the text, saying why, and keeps the old value.
#[test]
fn thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(),
"<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 0 Tf 0 g) /V (Ada) /Rect [50 700 250 720] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (big) /DA (/Helv 16 Tf 0 g) /Rect [50 600 350 640] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /Ff 4096 /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 500] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
let thai = "ชื่อ: บุญฤทธิ์ น้ำฎู";
if pdfcraft_fonts::document_thai_font().is_none() {
let err = set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap_err();
assert!(matches!(&err, FormError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err}");
assert_eq!(field(&fields(&doc), "name").value, ["Ada"]);
assert!(!need_appearances(&doc) && undrawn_fields(&doc).is_empty());
return;
}
set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap();
set_value(&mut doc, "big", &FieldValue::Text(thai.into())).unwrap();
set_value(&mut doc, "note", &FieldValue::Text(format!("น้ำฎู {thai}\nปั้น ฎู"))).unwrap();
let doc = reopen(&doc);
assert!(undrawn_fields(&doc).is_empty() && !need_appearances(&doc));
let (lo, hi) = (pdfcraft_fonts::shaped_text::thai_reach("ฎู").unwrap()[0], pdfcraft_fonts::shaped_text::thai_reach("น้ำ").unwrap()[1]);
for (name, height) in [("name", 20.0), ("big", 40.0), ("note", 100.0)] {
let all = fields(&doc);
let w = &field(&all, name).widgets[0];
let wd = doc.get(w.obj).as_dict().unwrap().clone();
assert!(wd.get(b"DA").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().starts_with("/Helv ")), "the /DA is kept");
let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
let ap = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(!ap.contains('?') && ap.contains("/PCTh1 ") && ap.contains("(: ) Tj"), "{ap}");
let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).unwrap();
let font = fonts.get(b"PCTh1").and_then(|f| f.as_dict()).unwrap();
assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..]));
let Object::Stream(cmap) = &*doc.get(font.reference(b"ToUnicode").unwrap()) else { panic!("ToUnicode is a stream object") };
assert!(String::from_utf8_lossy(&cmap.decoded().unwrap()).contains("<0E190E490E33>"), "น้ำ copies as typed");
// The size each line is drawn at, and where: every line's ink is inside the box.
let size: f64 = ap.split(" Tf").next().and_then(|s| s.rsplit(' ').next()).and_then(|s| s.parse().ok()).unwrap();
let ys: Vec<f64> = ap.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect();
let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min));
assert!(top + hi * size <= height + 1e-3 && bottom + lo * size >= -1e-3, "{name}: {ys:?} at {size} in {height}");
}
}
/// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids
/// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off.
#[test]
@@ -1519,7 +1684,7 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
struct Ran;
impl Scripts for Ran {
fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
@@ -1778,6 +1943,59 @@ fn missing_fields_and_built_names_are_flagged() {
assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read");
}
/// An order form's rows, as a public one lays them out: an editable drop-down quantity whose blank
/// option, and starting value, is a single space, a prefilled price, the row's product and a total
/// over the rows. A blank quantity is 0, so its row is 0; a quantity cleared to nothing on a row
/// without a price leaves the product nothing to multiply, and it is 0 too, not 1.
#[test]
fn an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows() {
let tx = |name: &str, y: u32, rest: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [250 {y} 350 {}] /P 3 0 R {rest} >>", y + 20)
};
let qty = |name: &str, y: u32| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 393216 /T ({name}) /Opt [( ) (1) (2) (3)] /V ( ) /Rect [50 {y} 150 {}] /P 3 0 R >>",
y + 20
)
};
let prd = |row: u32| {
format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"PRD\", new Array \\(\"QTY {row}\", \"price {row}\"\\)\\);) >> >>")
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
qty("QTY 0", 700),
tx("price 0", 700, "/V (605)"),
tx("Total 0", 700, &format!("/V (0) {}", prd(0))),
qty("QTY 1", 660),
tx("price 1", 660, "/V (780)"),
tx("Total 1", 660, &format!("/V (0) {}", prd(1))),
qty("QTY 2", 620),
tx("price 2", 620, ""),
tx("Total 2", 620, &format!("/V (0) {}", prd(2))),
tx(
"Total",
580,
r#"/V (0) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array \("Total 0", "Total 1", "Total 2"\)\);) >> >>"#,
),
];
let mut doc = document(&objs);
let totals = |doc: &Document| {
let all = fields(doc);
["Total 0", "Total 1", "Total 2", "Total"].map(|n| field(&all, n).value.first().cloned().unwrap_or_default())
};
recalculate(&mut doc).unwrap();
assert_eq!(totals(&doc), ["0", "0", "0", "0"], "every quantity blank: a single space is worth 0");
set_value(&mut doc, "QTY 0", &FieldValue::Text("2".into())).unwrap();
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "only the chosen row");
set_value(&mut doc, "QTY 2", &FieldValue::Text(String::new())).unwrap();
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "a quantity cleared on a row without a price");
let doc = reopen(&doc);
assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "kept across a save");
}
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
/// the order writes `/CO` afresh.
@@ -2933,20 +3151,22 @@ fn recalc_chain_where_script_changes_checkbox() {
ScriptResult {
rc: true,
value: "calc1".into(),
value_set: true,
changes: vec![FieldChange {
name: "agree".into(),
value: Some(vec!["Yes".into()]),
read_only: None,
required: None,
display: None,
rect: None,
}],
message: None,
}
} else if script == "read_box" {
let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default();
ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: format!("box_was_{box_val}"), value_set: true, changes: Vec::new(), message: None }
} else {
ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None }
ScriptResult { rc: true, value: String::new(), value_set: false, changes: Vec::new(), message: None }
}
}
}
@@ -2967,6 +3187,61 @@ fn recalc_chain_where_script_changes_checkbox() {
assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value");
}
/// A calculation returning a combo option's display text stores the option's export value in /V
/// and selects it in /I, as choosing it does.
#[test]
fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() {
struct Pick;
impl Scripts for Pick {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false };
add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &kind, Some("country")).unwrap();
let obj = field(&fields(&doc), "country").obj;
let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]);
doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "Canada"), pair("fr", "France")]))).unwrap();
set_field_script(&mut doc, "country", "calculate", Some("France")).unwrap();
assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 1);
let dict = doc.get(obj).as_dict().cloned().unwrap();
assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr"))));
assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)])));
assert_eq!(field(&fields(&doc), "country").value, ["fr"]);
assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 0, "the display text names the value it already has");
}
/// An export value names its own option even when an earlier option shows that text, whether a
/// calculation or the user sets it.
#[test]
fn an_export_value_wins_over_an_earlier_options_display_text() {
struct Pick;
impl Scripts for Pick {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None }
}
}
let mut doc = one_page();
let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]);
let mut objs = Vec::new();
for (name, y) in [("calculated", 10.0), ("chosen", 40.0)] {
let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false };
add_field(&mut doc, 0, [10.0, y, 100.0, y + 20.0], &kind, Some(name)).unwrap();
let obj = field(&fields(&doc), name).obj;
doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "fr"), pair("fr", "France")]))).unwrap();
objs.push(obj);
}
set_field_script(&mut doc, "calculated", "calculate", Some("fr")).unwrap();
recalculate_with(&mut doc, &mut Pick).unwrap();
set_value(&mut doc, "chosen", &FieldValue::Choice(vec!["fr".into()])).unwrap();
for obj in objs {
let dict = doc.get(obj).as_dict().cloned().unwrap();
assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr"))));
assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)])));
}
}
#[test]
fn partial_appearance_keeps_custom_default_appearance_and_font_resources() {
let mut doc = fixture();
@@ -3334,6 +3609,97 @@ fn document_scripts_read_each_name_tree_node_once() {
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
#[test]
fn name_tree_nodes_sharing_one_long_kids_array_queue_it_a_bounded_number_of_times() {
// 10,000 nodes share one /Kids array of 210,001 entries: the leaf, every node, and the leaf
// 200,000 times more. Queuing that array once per node would hold two billion entries.
let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) };
let mut doc = Document::new_empty();
let action = doc.add(dict(vec![
(b"S", Object::Name(b"JavaScript".to_vec())),
(b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))),
]));
let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))]));
let kids = doc.add(Object::Null);
let nodes: Vec<Object> = (0..10_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))).collect();
let mut shared = vec![Object::Ref(leaf); 200_000];
shared.extend(nodes);
shared.push(Object::Ref(leaf));
doc.set(kids, Object::Array(shared));
let catalog = doc.add(dict(vec![
(b"Type", Object::Name(b"Catalog".to_vec())),
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])),
]));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
#[test]
fn a_direct_object_in_a_shared_kids_array_is_not_copied_for_every_node() {
// 1,000 nodes share one /Kids array that also holds a direct dictionary with a 2 MiB
// string. Queued with every node's kids, it would be copied 1,000 times (2 GiB) while the
// entry count stayed under its limit. Kids are references, so it is skipped.
let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) };
let mut doc = Document::new_empty();
let action = doc.add(dict(vec![
(b"S", Object::Name(b"JavaScript".to_vec())),
(b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))),
]));
let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))]));
let kids = doc.add(Object::Null);
let big = dict(vec![(b"Pad", Object::String(pdfcraft_cos::PdfString::literal("x".repeat(2 << 20))))]);
let mut shared = vec![big];
shared.extend((0..1_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))));
shared.push(Object::Ref(leaf));
doc.set(kids, Object::Array(shared));
let catalog = doc.add(dict(vec![
(b"Type", Object::Name(b"Catalog".to_vec())),
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])),
]));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
#[test]
fn a_name_tree_node_listed_under_many_generations_is_read_once() {
// `Document::get` finds a node by object number alone, so `4 0 R` and `4 1 R` are one node.
// The root lists one under 10,000 generations: read for each, it would list its script
// 10,000 times and use up the walk's 10,000 visits before the node listed after it.
let kids: Vec<String> = (0..10_000).map(|g| format!("4 {g} R")).collect();
let doc = document(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(),
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
format!("<< /Kids [{} 5 0 R] >>", kids.join(" ")),
"<< /Names [(a) 6 0 R] >>".into(),
"<< /Names [(b) 6 0 R] >>".into(),
"<< /S /JavaScript /JS (var ready = true;) >>".into(),
]);
let scripts = document_scripts_named(&doc);
assert_eq!(scripts.len(), 2, "{} scripts", scripts.len());
let js = "var ready = true;".to_string();
assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]);
}
#[test]
fn name_tree_nodes_sharing_one_names_array_list_its_scripts_once() {
// 9,999 nodes share one /Names array. Read for every node, its scripts would be listed 9,999
// times: ten million scripts and 1.3 GB when it names 1,000.
let nodes: Vec<String> = (0..9_999).map(|i| format!("{} 0 R", i + 6)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".to_string(),
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
format!("<< /Kids [{}] >>", nodes.join(" ")),
"[(a) 5 0 R (b) 5 0 R]".into(),
"<< /S /JavaScript /JS (var ready = true;) >>".into(),
];
objs.extend(nodes.iter().map(|_| "<< /Names 4 0 R >>".to_string()));
let doc = document(&objs);
let scripts = document_scripts_named(&doc);
assert_eq!(scripts.len(), 2, "{} scripts", scripts.len());
let js = "var ready = true;".to_string();
assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]);
}
/// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one,
/// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list
/// box without one, and G only a down appearance (`/D`, no `/N`).
@@ -5487,6 +5853,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
}
#[test]
fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() {
let mut doc = fixture();
let city = field(&fields(&doc), "address.city").clone();
let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap();
set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap();
assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8));
set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
let mut doc = reopen(&doc);
let f = field(&fields(&doc), "address.city").clone();
assert_eq!(f.max_len, None, "no limit, not the parent's 3");
let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int);
assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own");
set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap();
assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters");
// Without a parent limit, clearing still removes the key.
set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap();
set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
let name = field(&fields(&doc), "name").clone();
assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none());
}
#[test]
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
let mut doc = fixture();
@@ -5648,3 +6037,35 @@ fn resetting_many_named_fields_is_not_quadratic() {
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope"));
}
/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C`
/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB.
#[test]
fn a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero() {
let widget = |name: &str, mk: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << {mk} >> /BS << /W 1 /S /S >> >>")
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R] >>".into(),
widget("cmyk", "/BG [0 0 1 null] /BC [1 0 0 null]"),
widget("rgb", "/BG [0 0 1] /BC [1 0 0]"),
];
let mut doc = document(&objs);
let off = |doc: &mut Document, name: &str| {
redraw_field(doc, name).unwrap();
let all = fields(doc);
let w = field(&all, name).widgets[0].obj;
let wd = doc.get(w).as_dict().cloned().unwrap();
let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap());
let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance");
let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
};
let cmyk = off(&mut doc, "cmyk");
assert!(cmyk.contains("0 0 1 0 k") && cmyk.contains("1 0 0 0 K"), "CMYK background and border: {cmyk}");
let rgb = off(&mut doc, "rgb");
assert!(rgb.contains("0 0 1 rg") && rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}");
}