Files
pdfcraft/crates/xfa/src/tests.rs
T
Dylan M. Taylor d31a96f46f Merge pull request #4121 from aiongg/fix/coderabbit-c1-js-xfa
fix(js, xfa): bound the text and work that form scripts and XFA data can multiply
2026-10-22 21:39:08 -04:00

2835 lines
161 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental};
use super::*;
use crate::data::{
DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets,
};
use crate::data::{add_data_instances, remove_data_instance, write_data_value};
use crate::fixtures::{
TINY_GIF, bound_date_template, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_bmp, tiny_png,
tiny_tiff, unnamed_hidden_template,
};
use crate::layout::Item;
use crate::layout::WidgetKind;
use crate::model::*;
use crate::script::{NodeKind, Overrides, apply_overrides, fields_by_som, form_tree, overrides, rerender, set_overrides};
/// The widgets laid out on `page`, in order.
fn widgets(page: &Page) -> Vec<&Widget> {
page.items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w.as_ref()) } else { None }).collect()
}
/// The text of each text span laid out on `page`, in order.
fn texts(page: &Page) -> Vec<String> {
page.items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.clone()) } else { None }).collect()
}
/// Measurements in points, inches, millimeters and centimeters convert to points, a bare
/// number is points, and an `em` length, a non-number or a NaN is `None`.
#[test]
fn measurements_convert_to_points() {
assert_eq!(measure("72pt"), Some(72.0));
assert_eq!(measure("1in"), Some(72.0));
assert!((measure("25.4mm").unwrap() - 72.0).abs() < 1e-9);
assert!((measure("2.54cm").unwrap() - 72.0).abs() < 1e-9);
assert_eq!(measure(" 12 "), Some(12.0));
assert_eq!(measure("3em"), None);
assert_eq!(measure("abc"), None);
assert_eq!(measure("NaNpt"), None);
}
/// Two draws of the same text, one at `fontHorizontalScale="80%"`, each 90% as wide as the
/// text is at full width, and a rich-text draw condensed to half by its span style.
fn scaled_text_template() -> (String, &'static str) {
const TEXT: &str = "Approving authority name and title";
let w = pdfcraft_fonts::helvetica_width(TEXT, 10.0) * 0.9;
let xdp = format!(
r##"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<draw name="narrow" w="{w}pt" h="0.5in"><value><text>{TEXT}</text></value><font typeface="Arial" size="10pt" fontHorizontalScale="80%"/></draw>
<draw name="plain" w="{w}pt" h="0.5in"><value><text>{TEXT}</text></value><font typeface="Arial" size="10pt"/></draw>
<draw name="rich" w="7in" h="0.3in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Full <span style="xfa-font-horizontal-scale:50%">half</span></p></body></exData></value><font typeface="Arial" size="10pt"/></draw>
</subform>
</template></xdp:xdp>"##
);
(xdp, TEXT)
}
/// `fontHorizontalScale` is read as a percentage and text is measured at it: the 80% draw
/// fits its box on one line where the full-width one takes two, and a span's own scale
/// applies to that span alone.
#[test]
fn horizontally_scaled_text_is_measured_and_drawn_narrower() {
let (xdp, text) = scaled_text_template();
let (tpl, _) = parse(&xdp).unwrap();
let Node::Draw(narrow) = &tpl.root.children[0] else { panic!() };
assert_eq!(narrow.common.font.h_scale, Some(0.8), "fontHorizontalScale is a percentage");
let form = layout_xml(&xdp).unwrap();
let spans: Vec<&text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect();
let lines = |scale: f64, words: &[&str]| {
let mut b: Vec<i64> = spans
.iter()
.filter(|s| s.face.h_scale == scale && words.iter().any(|w| s.text.contains(w)))
.map(|s| (s.baseline * 100.0).round() as i64)
.collect();
b.dedup();
b.len()
};
let words: Vec<&str> = text.split(' ').collect();
// At 80% the text is 72% of its full width: it fits the 90% box on one line.
assert_eq!(lines(0.8, &words), 1, "{spans:?}");
// At full width it doesn't: two lines.
assert_eq!(lines(1.0, &["Approving", "title"]), 2, "{spans:?}");
// A span's own scale applies to it alone.
let half = spans.iter().find(|s| s.text.contains("half")).expect("the half span");
let full = spans.iter().find(|s| s.text.contains("Full")).expect("the full span");
assert_eq!((half.face.h_scale, full.face.h_scale), (0.5, 1.0));
assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9);
}
/// A growing non-interactive field is measured in the face its text is drawn in: condensed,
/// its text takes fewer lines and the field grows only to hold them.
#[test]
fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() {
// A growing, non-interactive text field (drawn as text by the layout, not as a widget),
// then a draw below it. However condensed the field's text is, the draw sits the same
// distance below its last line: the field is as tall as the lines it shows.
// Long enough to take more than a multi-line field's three lines at either scale.
const TEXT: &str = "Remarks that run over several lines of a narrow box, and then over several more lines again, until the box has grown well past the three lines it starts with";
let gap = |scale: u32| {
let xdp = format!(
r##"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="note" w="100pt" access="nonInteractive"><ui><textEdit multiLine="1"/></ui><value><text>{TEXT}</text></value><font typeface="Arial" size="10pt" fontHorizontalScale="{scale}%"/></field>
<draw name="after" w="100pt" h="20pt"><value><text>AFTER</text></value><font typeface="Arial" size="10pt"/></draw>
</subform>
</template></xdp:xdp>"##
);
let form = layout_xml(&xdp).unwrap();
let spans: Vec<&text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect();
let after = spans.iter().find(|s| s.text.contains("AFTER")).expect("the draw").baseline;
let lines: std::collections::BTreeSet<i64> =
spans.iter().filter(|s| !s.text.contains("AFTER")).map(|s| (s.baseline * 100.0).round() as i64).collect();
let last = *lines.iter().next_back().expect("the field's lines") as f64 / 100.0;
(lines.len(), after - last)
};
let (full_lines, full_gap) = gap(100);
let (narrow_lines, narrow_gap) = gap(60);
assert!(narrow_lines < full_lines, "condensed, the text takes fewer lines: {narrow_lines} vs {full_lines}");
assert!((narrow_gap - full_gap).abs() < 0.01, "the field shrinks with its text: gap {narrow_gap} vs {full_gap}");
}
/// Condensed text is written with `Tz` inside a `q`/`Q` pair of its own, and full-width text
/// sets no scale, so none carries over from the text before it.
#[test]
fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
let (xdp, _) = scaled_text_template();
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
render_into(&mut doc).unwrap();
let root = doc.get(doc.root().unwrap());
let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap());
let kid = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].clone();
let page = doc.resolve(&kid);
let contents = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap());
let Object::Stream(c) = &*contents else { panic!("a content stream") };
let ops = String::from_utf8_lossy(&c.decoded().unwrap()).into_owned();
let scaled: Vec<&str> = ops.lines().filter(|l| l.contains(" Tz ")).collect();
assert!(!scaled.is_empty() && scaled.iter().all(|l| l.starts_with("q BT ") && l.ends_with(" ET Q")), "{ops}");
assert!(scaled.iter().any(|l| l.contains(" 80 Tz ")) && scaled.iter().any(|l| l.contains(" 50 Tz ")), "{ops}");
// Full-width text sets no scale, and none is left over from the condensed text before it.
assert!(ops.lines().filter(|l| l.contains(" Tj ") && !l.contains(" Tz ")).all(|l| l.starts_with("BT ")), "{ops}");
}
/// The sample template parses without warnings into what the layout uses: its page areas,
/// page-number draws, rich-text title, field font, caption and widget border, check box,
/// date field, radio group, table columns and page break all come through.
#[test]
fn the_template_parses_with_fonts_captions_and_rich_text() {
let (tpl, warnings) = parse(&template(2)).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(tpl.root.layout, Layout::Tb);
let ps = tpl.root.page_set.as_ref().unwrap();
assert_eq!(ps.areas.len(), 2);
assert_eq!((ps.areas[0].width, ps.areas[0].height), (612.0, 792.0));
assert_eq!(ps.areas[0].occur_max, Some(1));
assert_eq!(ps.areas[1].occur_max, None);
assert_eq!(ps.areas[0].content[0], Rect::new(36.0, 36.0, 540.0, 720.0));
assert_eq!(tpl.page_roles, vec![("pageNo".to_string(), PageRole::Number), ("pageCount".to_string(), PageRole::Count)]);
let Node::Subform(head) = &tpl.root.children[0] else { panic!() };
assert_eq!(head.layout, Layout::LrTb);
let Node::Draw(title) = &head.children[0] else { panic!() };
let Value::Rich(r) = &title.value else { panic!("rich text") };
assert_eq!(r.paragraphs.len(), 2, "{r:?}");
assert_eq!(r.paragraphs[0].runs[0].bold, Some(true));
assert_eq!(r.plain(), "Sample Form\nSecond line");
let Node::Field(f) = &head.children[2] else { panic!() };
assert_eq!(f.common.name.as_deref(), Some("familyName"));
assert_eq!(f.common.font.typeface.as_deref(), Some("Courier New"));
assert_eq!(f.max_chars, Some(30));
assert_eq!(f.tooltip.as_deref(), Some("Your family name"));
let cap = f.caption.as_ref().unwrap();
assert_eq!((cap.placement, cap.reserve), (Placement::Top, Some(14.4)));
// The widget border: three hidden edges and a visible bottom one.
assert_eq!(f.ui_border.as_ref().unwrap().visible_edges(), [false, false, true, false]);
let Node::Field(check) = &head.children[3] else { panic!() };
assert_eq!((check.ui.clone(), check.items.clone()), (Ui::CheckButton, vec!["1".to_string(), "0".to_string()]));
let Node::Field(date) = &head.children[4] else { panic!() };
assert_eq!((date.ui.clone(), date.picture.as_deref()), (Ui::DateTimeEdit, Some("date{YYYY-MM-DD}")));
let Node::ExclGroup(g) = &head.children[6] else { panic!() };
assert_eq!(g.fields.len(), 2);
let Node::Subform(table) = &tpl.root.children[1] else { panic!() };
assert_eq!(table.column_widths, vec![144.0, 216.0, 180.0]);
assert_eq!(table.overflow_leader.as_deref(), Some("header"));
let Node::Subform(last) = &tpl.root.children[2] else { panic!() };
assert!(last.break_before_page);
}
#[test]
fn rich_text_max_length_reaches_the_field_and_layout() {
for (attribute, expected) in [
(r#"maxLength="5250""#, Some(5250)),
(r#"maxLength=" 10500 ""#, Some(10500)),
(r#"maxLength="0""#, None),
(r#"maxLength="-1""#, None),
(r#"maxLength="invalid""#, None),
(r#"maxLength="999999999999999999999999999999999999""#, None),
("", None),
] {
let xml = template(0).replace(
r#"<text maxChars="30"/>"#,
&format!(r#"<exData contentType="text/html" {attribute}><body xmlns="http://www.w3.org/1999/xhtml"><p>Hello</p></body></exData>"#),
);
let (tpl, _) = parse(&xml).unwrap();
let Node::Subform(head) = &tpl.root.children[0] else { panic!() };
let Node::Field(field) = &head.children[2] else { panic!() };
assert_eq!(field.max_chars, expected, "{attribute}");
assert_eq!(field.value.plain().as_deref(), Some("Hello"));
let form = layout_xml(&xml).unwrap();
let field = form.pages.iter().flat_map(widgets).find(|w| w.name == "familyName").unwrap();
assert_eq!(field.max_chars, expected, "{attribute}");
}
}
#[test]
fn the_xdp_template_wins_over_the_config_packets_template_element() {
// The config packet has a <template> too; the real one is in the xfa-template namespace.
let (tpl, _) = parse(&template(0)).unwrap();
assert_eq!(tpl.root.common.name.as_deref(), Some("form"));
}
#[test]
fn layout_places_captions_fields_and_sizes_containers_to_their_content() {
let form = layout_xml(&template(1)).unwrap();
assert_eq!(form.pages.len(), 2, "head and table on page 1, the last section after its break");
let p1 = &form.pages[0];
let t = texts(p1);
assert!(t.iter().any(|s| s == "Sample Form") && t.iter().any(|s| s == "Second line"), "{t:?}");
assert!(t.iter().any(|s| s == "Family name"), "caption drawn: {t:?}");
assert!(!t.iter().any(|s| s.contains("never shown")), "hidden draws take no space and draw nothing");
assert!(t.iter().any(|s| s == "Page 1 of 2"), "embedded page number and count: {t:?}");
assert!(!texts(&form.pages[1]).iter().any(|s| s.starts_with("Page ")), "the second master has no page-number draw");
let w = widgets(p1);
let family = w.iter().find(|w| w.name == "familyName").unwrap();
// Below the 0.4in title, on a line of its own (3in × 0.5in); the caption takes the top 0.2in.
assert!((family.rect.x - 36.0).abs() < 0.01 && (family.rect.y - 79.2).abs() < 0.01, "{:?}", family.rect);
assert!((family.rect.w - 216.0).abs() < 0.01 && (family.rect.h - 21.6).abs() < 0.01, "{:?}", family.rect);
assert_eq!(family.kind, WidgetKind::Text);
assert_eq!(family.border.shape, BorderShape::Underline);
assert_eq!(family.max_chars, Some(30));
assert_eq!(family.tooltip.as_deref(), Some("Your family name"));
assert_eq!(family.face.family, crate::text::Family::Courier);
assert_eq!(family.som, "form[0].head[0].familyName[0]");
let agree = w.iter().find(|w| w.name == "agree").unwrap();
assert_eq!(agree.kind, WidgetKind::CheckBox { on: "1".into(), round: false });
assert!((agree.rect.w - 10.0).abs() < 0.01, "a 10 pt square: {:?}", agree.rect);
let born = w.iter().find(|w| w.name == "born").unwrap();
assert_eq!(born.kind, WidgetKind::Date("yyyy-mm-dd".into()));
// The radio group has no width: it sits beside the question on the same line.
let q_y = p1
.items
.iter()
.find_map(|i| {
if let Item::Text(s) = i
&& s.text == "Have you ever?"
{
Some(s.baseline)
} else {
None
}
})
.unwrap();
let yes = w.iter().find(|w| matches!(&w.kind, WidgetKind::Radio { group, on, .. } if group == "answer" && on == "Y")).unwrap();
assert!(yes.rect.x > 6.5 * 72.0 && yes.rect.y < q_y, "beside the question, not under it: {:?} vs baseline {q_y}", yes.rect);
let go = w.iter().find(|w| w.name == "go").unwrap();
assert_eq!(go.kind, WidgetKind::Button { caption: "Reset".into() });
assert_eq!(go.action, Some(Action::Reset));
assert_eq!(go.border.fill, Some([212.0 / 255.0, 208.0 / 255.0, 200.0 / 255.0]));
// Table cells take the column widths; the second column has no width of its own.
let what = w.iter().find(|w| w.name == "what0").unwrap();
assert!((what.rect.x - (36.0 + 144.0)).abs() < 0.01 && (what.rect.w - 216.0).abs() < 0.01, "{:?}", what.rect);
assert_eq!(
form.fields,
9,
"familyName, agree, born, yes, no, go, 3 cells; pageNo/pageCount are hidden: {:?}",
w.iter().map(|w| &w.name).collect::<Vec<_>>()
);
}
#[test]
fn tables_break_across_pages_and_repeat_their_header_row() {
let form = layout_xml(&template(40)).unwrap();
assert!(form.pages.len() >= 3, "{} pages", form.pages.len());
// The header row's "Activity" cell appears at the top of every page the table continues on.
for (i, p) in form.pages.iter().enumerate().take(form.pages.len() - 1) {
let headers = p.items.iter().filter(|it| matches!(it, Item::Text(s) if s.text == "Activity")).count();
assert_eq!(headers, 1, "page {}: header rows {headers}", i + 1);
}
// Rows are whole: no cell straddles the bottom of the content area.
for p in &form.pages {
for w in widgets(p) {
assert!(w.rect.bottom() <= 36.0 + 720.0 + 0.01, "{} ends below the content area: {:?}", w.name, w.rect);
}
}
let all: Vec<&str> = form.pages.iter().flat_map(|p| widgets(p).into_iter().map(|w| w.name.as_str()).collect::<Vec<_>>()).collect();
assert_eq!(all.iter().filter(|n| n.starts_with("from")).count(), 40);
assert_eq!(all.len(), all.iter().collect::<std::collections::HashSet<_>>().len(), "field names are unique");
}
#[test]
fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() {
let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap();
assert!(is_dynamic(&doc));
let report = render_into(&mut doc).unwrap();
assert_eq!((report.pages, report.fields), (2, 11), "fields: 5 on the head, the radio group once, 6 cells");
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let root = doc.get(doc.root().unwrap());
let catalog = root.as_dict().unwrap();
let pages = doc.resolve(catalog.get(b"Pages").unwrap());
assert_eq!(pages.as_dict().unwrap().int(b"Count"), Some(2));
let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone();
let page1 = doc.resolve(&kids[0]);
let annots = doc.resolve(page1.as_dict().unwrap().get(b"Annots").unwrap()).as_array().unwrap().len();
assert!(annots >= 8, "{annots} widgets on page 1");
let acro = doc.resolve(catalog.get(b"AcroForm").unwrap());
let acro = acro.as_dict().unwrap();
assert!(acro.contains(b"XFA"), "the XFA packets stay");
assert_eq!(catalog.get(b"NeedsRendering"), Some(&Object::Bool(true)), "still a dynamic form for Adobe's viewers");
let fields = doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap().len();
assert_eq!(fields, 11, "top-level fields (the radio group counts once)");
let dr = doc.resolve(acro.get(b"DR").unwrap());
let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap());
assert!(fonts.as_dict().unwrap().contains(b"Cour") && fonts.as_dict().unwrap().contains(b"ZaDb"));
// A field carries its SOM path for data round trips.
let first = doc.resolve(&doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap()[0]);
assert!(first.as_dict().unwrap().contains(SOM_KEY));
// Not dynamic twice: a second pass is refused rather than doubling the pages.
assert!(!is_dynamic(&doc));
}
/// A dynamic form's own text that the standard fonts can't draw is laid out anyway and reported
/// once, never written as `?` silently.
#[test]
fn form_text_the_standard_fonts_cannot_draw_is_reported() {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<draw w="3in" h="0.3in"><value><text>申请表</text></value></draw><draw w="3in" h="0.3in"><value><text>签名</text></value></draw><draw w="3in" h="0.3in"><value><text>Name</text></value></draw>
</subform></template></xdp:xdp>"##;
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
let report = render_into(&mut doc).unwrap();
let hits: Vec<&String> = report.warnings.iter().filter(|w| w.contains("standard fonts can't draw")).collect();
assert_eq!(hits.len(), 1, "reported once: {:?}", report.warnings);
assert!(hits[0].contains("\"申\" (U+7533)"), "{hits:?}");
// Latin text alone reports nothing.
let latin = xdp.replace("申请表", "Application").replace("签名", "Signature");
let mut doc = Document::open(Arc::new(shell(&latin))).unwrap();
assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts")));
}
#[test]
fn documents_without_xfa_are_not_touched() {
let mut doc = Document::new_empty();
assert!(!is_dynamic(&doc));
assert!(matches!(render_into(&mut doc), Err(XfaError::NotXfa)));
}
#[test]
fn hostile_templates_fail_without_panicking() {
assert!(layout_xml("not xml").is_err());
assert!(layout_xml("<template/>").is_err());
assert!(layout_xml("<template><subform/></template>").is_ok(), "an empty form is one empty page");
// Deep nesting is cut off, not followed.
let deep = format!("<template><subform layout=\"tb\">{}{}</subform></template>", "<subform layout=\"tb\">".repeat(500), "</subform>".repeat(500));
assert!(layout_xml(&deep).is_ok());
// Absurd sizes are clamped; a page can't be a mile wide.
let huge = r##"<template><subform layout="tb"><pageSet><pageArea><medium short="1e9in" long="-5in"/></pageArea></pageSet><draw w="1e12pt" h="1e12pt"><value><text>x</text></value></draw></subform></template>"##;
let f = layout_xml(huge).unwrap();
assert!(f.pages[0].width <= 14_400.0 && f.pages[0].height >= 1.0);
// Too many pages is an error, not an endless loop.
let many = format!(
"<template><subform layout=\"tb\"><pageSet><pageArea><contentArea w=\"100pt\" h=\"100pt\"/><medium short=\"100pt\" long=\"100pt\"/></pageArea></pageSet>{}</subform></template>",
"<draw h=\"90pt\"><value><text>x</text></value></draw>".repeat(MAX_PAGES + 5)
);
assert!(matches!(layout_xml(&many), Err(XfaError::TooLarge(_))));
// A bad image and a bad base64 are warnings, not failures.
let img = r##"<template><subform layout="tb"><draw w="1in" h="1in"><value><image contentType="image/png">!!!notbase64</image></value></draw></subform></template>"##;
let f = layout_xml(img).unwrap();
assert!(!f.warnings.is_empty());
}
#[test]
fn repeated_pictures_are_bounded_in_total_bytes() {
// Each laid-out picture keeps its own copy of the bytes, so one picture in a repeating
// subform used to multiply without bound and exhaust memory (a failed allocation aborts the
// app). "AAAA" base64 decodes to zero bytes; 2_097_152 groups is 6 MiB, under the 32 MiB
// per-picture cap. 50 instances (occur initial, clamped to MAX_INSTANCES) would be ~300 MiB,
// past the aggregate cap, so layout must stop with TooLarge rather than retain it all.
let big = "AAAA".repeat(2_097_152);
let tpl = format!(
"<template><subform layout=\"tb\"><subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>\
<draw w=\"0.2in\" h=\"0.2in\"><value><image contentType=\"image/png\">{big}</image></value></draw>\
</subform></subform></template>"
);
assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(m)) if m.contains("too large")), "the repeated picture must be refused");
// A single small picture still lays out: the cap does not reject an ordinary form.
let small = "AAAA".repeat(8);
let ok = format!(
"<template><subform layout=\"tb\"><draw w=\"1in\" h=\"1in\"><value><image contentType=\"image/png\">{small}</image></value></draw></subform></template>"
);
assert!(layout_xml(&ok).is_ok(), "one small picture must lay out");
}
#[test]
fn empty_repeating_subforms_spend_the_layout_budget() {
// Four nested levels of 50 empty instances are 6.25 million instances that place nothing
// and measure nothing; each one counts against the layout budget.
let level = "<subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>";
let tpl = format!("<template><subform layout=\"tb\">{}{}</subform></template>", level.repeat(4), "</subform>".repeat(4));
assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_))));
// Three levels (125,000 instances) still lay out.
let tpl = format!("<template><subform layout=\"tb\">{}{}</subform></template>", level.repeat(3), "</subform>".repeat(3));
assert!(layout_xml(&tpl).is_ok());
}
#[test]
fn a_packet_stream_listed_many_times_is_read_once() {
let mut doc = Document::open(Arc::new(shell("<template><subform/></template>"))).unwrap();
let packet = pdfcraft_cos::ObjRef::new(5, 0);
let items: Vec<Object> =
(0..1000).flat_map(|_| [Object::String(pdfcraft_cos::PdfString::literal(b"template".to_vec())), Object::Ref(packet)]).collect();
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap();
acro.set(b"XFA".to_vec(), Object::Array(items));
c.set(b"AcroForm".to_vec(), Object::Dict(acro));
})
.unwrap();
let p = read_packets(&doc).unwrap().unwrap();
assert_eq!(p.xdp.matches("<template").count(), 1);
}
#[test]
fn empty_repeating_subforms_drawn_in_a_positioned_root_spend_the_layout_budget() {
// The same four levels inside a positioned root are drawn, not flowed.
let level = "<subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>";
let nest =
|n: usize| format!("<template><subform><subform layout=\"tb\">{}{}</subform></subform></template>", level.repeat(n), "</subform>".repeat(n));
assert!(matches!(layout_xml(&nest(4)), Err(XfaError::TooLarge(_))));
assert!(layout_xml(&nest(3)).is_ok());
}
#[test]
fn the_packets_can_be_one_stream_or_named_parts() {
// Named parts, with the template split from the rest.
let doc = Document::open(Arc::new(shell(&template(0)))).unwrap();
let p = read_packets(&doc).unwrap().unwrap();
assert!(p.needs_rendering && !p.has_fields);
assert!(p.xdp.contains("<template"));
// UTF-16 packets are read too.
let mut utf16 = vec![0xFF, 0xFE];
for u in "<template><subform/></template>".encode_utf16() {
utf16.extend_from_slice(&u.to_le_bytes());
}
let xdp = String::from_utf8_lossy(&utf16).into_owned();
let _ = xdp;
let doc = Document::open(Arc::new(shell_bytes(&utf16))).unwrap();
let p = read_packets(&doc).unwrap().unwrap();
assert!(p.xdp.starts_with("<template>"), "{:?}", &p.xdp[..20.min(p.xdp.len())]);
}
/// Like [`shell`], but the packet bytes are given as-is.
fn shell_bytes(packet: &[u8]) -> Vec<u8> {
let text = shell("PLACEHOLDER");
let needle = b"<< /Length 11 >>\nstream\nPLACEHOLDER\nendstream";
let pos = text.windows(needle.len()).position(|w| w == needle).unwrap();
let mut out = text[..pos].to_vec();
out.extend_from_slice(format!("<< /Length {} >>\nstream\n", packet.len()).as_bytes());
out.extend_from_slice(packet);
out.extend_from_slice(b"\nendstream");
out.extend_from_slice(&text[pos + needle.len()..]);
// The xref offsets after the stream are off; the reader reconstructs them.
out
}
#[test]
fn jpeg_headers_give_the_size() {
// A minimal SOF0 header: 300 × 200, 3 components.
let mut j = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
j.extend_from_slice(&[0; 14]);
j.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0xC8, 0x01, 0x2C, 0x03]);
assert_eq!(pdf::jpeg_size(&j), Some((300, 200)));
assert_eq!(pdf::jpeg_size(b"\x89PNG"), None);
assert_eq!(pdf::jpeg_size(&[0xFF, 0xD8, 0xFF]), None);
}
const DATA: &str = "<form><head><familyName>Singh</familyName><agree>1</agree><born>2001-02-03</born><answer>N</answer></head><table><row><from0>2019</from0><what0>Studied</what0><where0>Delhi</where0></row><row><from0>2021</from0><what0>Worked</what0><where0>Toronto</where0></row><row><from0>2023</from0><what0>Moved</what0><where0>Ottawa</where0></row></table></form>";
/// The datasets packet parses into a tree whose nodes the fields' data paths find.
#[test]
fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() {
let d = parse_datasets(&template_with_data(1, DATA)).expect("datasets");
assert_eq!(d.name, "data");
assert_eq!(d.text_at(&som_to_path("form[0].head[0].familyName[0]")), Some("Singh"));
assert_eq!(d.text_at(&som_to_path("form[0].#subform[0].head[0].answer[0]")), Some("N"), "unnamed containers have no data node");
assert_eq!(d.text_at(&som_to_path("form[0].table[0].row[2].where0[0]")), Some("Ottawa"));
assert_eq!(d.text_at(&som_to_path("form[0].table[0].row[3].where0[0]")), None);
assert_eq!(d.count(&som_to_path("form[0].table[0]"), "row"), 3);
assert_eq!(som_to_path("a[1].b"), vec![("a".to_string(), 1), ("b".to_string(), 0)]);
assert!(parse_datasets("<xdp:xdp xmlns:xdp=\"x\"/>").is_none());
}
/// ISO dates convert to and from the date patterns fields show; text that isn't a date is left
/// alone or refused.
#[test]
fn dates_convert_between_iso_and_acrobat_patterns() {
assert_eq!(iso_to_pattern("2001-02-03", "yyyy-mm-dd"), "2001-02-03");
assert_eq!(iso_to_pattern("2001-02-03", "mm/dd/yyyy"), "02/03/2001");
assert_eq!(iso_to_pattern("2001-02-03", "d.m.yy"), "3.2.01");
assert_eq!(iso_to_pattern("not a date", "mm/dd/yyyy"), "not a date");
assert_eq!(pattern_to_iso("02/03/2001", "mm/dd/yyyy").as_deref(), Some("2001-02-03"));
assert_eq!(pattern_to_iso("3.2.01", "d.m.yy").as_deref(), Some("2001-02-03"));
assert_eq!(pattern_to_iso("2001-02-03", "yyyy-mm-dd").as_deref(), Some("2001-02-03"));
assert_eq!(pattern_to_iso("13/40/2001", "mm/dd/yyyy"), None);
assert_eq!(pattern_to_iso("hello", "mm/dd/yyyy"), None);
}
/// What scripts read as `formattedValue`: a date display picture shows the ISO value in its
/// pattern (the first of several alternatives); other kinds of picture, and values that aren't
/// ISO dates, leave the raw value.
#[test]
fn formatted_values_follow_date_display_pictures() {
assert_eq!(crate::formatted_value("date{YYYYMMDD}", "2026-10-20").as_deref(), Some("20261020"));
assert_eq!(crate::formatted_value("date{MM/DD/YYYY}", "2026-10-20").as_deref(), Some("10/20/2026"));
assert_eq!(crate::formatted_value("date{YYYYMMDD}|date{DDMMMYY}", "2026-10-20").as_deref(), Some("20261020"));
assert_eq!(crate::formatted_value("date{YYYY-MM-DD}", "2026-10-20"), None, "shown as it is");
assert_eq!(crate::formatted_value("num{zzz9}", "2026-10-20"), None);
assert_eq!(crate::formatted_value("date{YYYYMMDD}", "20261020"), None, "not an ISO date");
assert_eq!(crate::formatted_value("date{YYYYMMDD}", ""), None);
// Month and weekday names aren't shown yet: the raw value stands rather than `20 mmm 2026`.
assert_eq!(crate::formatted_value("date{DD MMM YYYY}", "2026-10-20"), None);
assert_eq!(crate::formatted_value("date{EEE MM/DD/YYYY}", "2026-10-20"), None);
// Only the ISO date's own shape is formatted.
assert_eq!(crate::formatted_value("date{YYYYMMDD}", "2026-1-2"), None);
// Parentheses hold the picture's locale and braces its pattern: one without braces has none.
assert_eq!(crate::formatted_value("date(en_US){YYYYMMDD}", "2026-10-20").as_deref(), Some("20261020"));
assert_eq!(crate::formatted_value("date(YYYYMMDD)", "2026-10-20"), None);
}
/// A field's `<format><picture>` is its display picture, kept apart from its `<ui>` edit
/// picture; a field without one has none.
#[test]
fn display_pictures_are_parsed_apart_from_edit_pictures() {
let (tpl, _) = parse(&crate::fixtures::date_row_template()).unwrap();
let Node::Subform(page) = &tpl.root.children[0] else { panic!() };
let Node::Field(from) = &page.children[0] else { panic!() };
assert_eq!((from.picture.as_deref(), from.display_picture.as_deref()), (Some("date{YYYYMMDD}"), Some("date{YYYYMMDD}")));
let Node::Field(days) = &page.children[1] else { panic!() };
assert_eq!(days.display_picture, None);
}
/// A date field's data picture (`<bind><picture>`) is how its value is written in the data:
/// `due` keeps `20261018` there and shows `10/18/2026`. The stored text used to be shown as it
/// was, because the data was taken to hold ISO dates only.
#[test]
fn a_date_is_read_from_the_data_in_its_data_picture() {
let form = layout_xml(&bound_date_template("<form><page><due>20261018</due><when>2026-10-18</when></page></form>")).unwrap();
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let by = |n: &str| all.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no widget {n}"));
assert_eq!(by("due").value.as_deref(), Some("10/18/2026"));
assert_eq!(by("when").value.as_deref(), Some("10/18/2026"), "no data picture: the data holds ISO");
}
/// A SOM path names the template field its segment numbers count to, repeating rows included.
#[test]
fn som_paths_name_the_template_field_their_numbers_count_to() {
let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form">
<field name="x"><bind><picture>first</picture></bind></field>
<subform name="row"><occur min="1" max="-1"/><field name="d"><bind><picture>row</picture></bind></field></subform>
<field name="x"><bind><picture>second</picture></bind></field>
<subform><field name="u"><bind><picture>inner</picture></bind></field></subform>
</subform>
</template></xdp:xdp>"#;
let (tpl, _) = parse(xml).unwrap();
let picture = |som: &str| crate::data::template_field(&tpl, som).and_then(|f| f.data_picture.clone());
// Same-named siblings count up; each SOM number is its own field.
assert_eq!(picture("form[0].x[0]").as_deref(), Some("first"));
assert_eq!(picture("form[0].x[1]").as_deref(), Some("second"));
assert_eq!(picture("form[0].x[2]"), None, "no third x");
// The instances of a repeating subform follow its number: all are the one template node.
assert_eq!(picture("form[0].row[0].d[0]").as_deref(), Some("row"));
assert_eq!(picture("form[0].row[3].d[0]").as_deref(), Some("row"));
// An unnamed subform is looked through, as data paths do.
assert_eq!(picture("form[0].u[0]").as_deref(), Some("inner"));
}
/// Refreshing a value in place uses the field's own template field, not a same-named one's.
#[test]
fn a_refreshed_field_takes_its_value_from_its_own_template_field() {
// Two text fields named `x` with different defaults; the data holds only the first.
let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form">
<field name="x"><ui><textEdit/></ui><value><text>first</text></value></field>
<field name="x"><ui><textEdit/></ui><value><text>second</text></value></field>
</subform>
</template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><x>a</x></form></xfa:data></xfa:datasets></xdp:xdp>"#;
let (tpl, _) = parse(xml).unwrap();
let data = parse_datasets(xml).unwrap();
let mut live = form_tree(&tpl, Some(&data), &Overrides::default()).root;
let value = |live: &crate::script::FormNode, som: &str| node_at(live, som).map(|n| n.value.clone());
assert_eq!(value(&live, "form[0].x[1]").as_deref(), Some("second"));
assert_eq!(crate::script::refresh_value(&mut live, &tpl, Some(&data), "form[0].x[1]"), Some(false), "nothing to change");
assert_eq!(value(&live, "form[0].x[1]").as_deref(), Some("second"), "not the first x's default");
}
/// Scripts read a date as the ISO date another viewer gives them as `rawValue`
/// (`2026-10-18`), whatever the field's data picture writes in the data (`20261018`), so a
/// script's `String(rawValue).length == 10` holds. A field without a data picture keeps the
/// data's ISO date, and refreshing a value in place reads it the same way.
#[test]
fn scripts_read_a_date_as_iso_whatever_its_data_picture_writes() {
let xml = bound_date_template("<form><page><due>20261018</due><when>2026-10-18</when></page></form>");
let (tpl, _) = parse(&xml).unwrap();
let data = parse_datasets(&xml).unwrap();
let mut live = form_tree(&tpl, Some(&data), &Overrides::default()).root;
let value = |live: &crate::script::FormNode, som: &str| node_at(live, som).map(|n| n.value.clone());
assert_eq!(value(&live, "form[0].page[0].due[0]").as_deref(), Some("2026-10-18"));
assert_eq!(value(&live, "form[0].page[0].when[0]").as_deref(), Some("2026-10-18"));
let later = parse_datasets(&bound_date_template("<form><page><due>20261019</due><when>2026-10-18</when></page></form>")).unwrap();
assert_eq!(crate::script::refresh_value(&mut live, &tpl, Some(&later), "form[0].page[0].due[0]"), Some(true));
assert_eq!(value(&live, "form[0].page[0].due[0]").as_deref(), Some("2026-10-19"));
}
/// The node of `live` at `som`.
fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate::script::FormNode> {
if live.som == som {
return Some(live);
}
live.children.iter().find_map(|c| node_at(c, som))
}
/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads
/// back as written.
#[test]
fn a_date_its_data_picture_cant_hold_is_written_as_iso() {
use crate::data::{data_to_iso, iso_to_data};
assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102");
// A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written.
for iso in ["2050-01-02", "1949-12-31"] {
assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso);
assert_eq!(data_to_iso(iso, Some("yymmdd")), iso);
}
}
/// The datasets fill the fields and add the rows of repeating subforms.
#[test]
fn month_and_weekday_names_in_date_patterns() {
// 2001-02-03 was a Saturday.
assert_eq!(iso_to_pattern("2001-02-03", "mmm d, yyyy"), "Feb 3, 2001");
assert_eq!(iso_to_pattern("2001-02-03", "mmmm d, yyyy"), "February 3, 2001");
assert_eq!(iso_to_pattern("2001-02-03", "ddd, mmm d"), "Sat, Feb 3");
assert_eq!(iso_to_pattern("2001-02-03", "dddd, mmmm dd, yyyy"), "Saturday, February 03, 2001");
assert_eq!(pattern_to_iso("Feb 3, 2001", "mmm d, yyyy").as_deref(), Some("2001-02-03"));
assert_eq!(pattern_to_iso("February 3, 2001", "mmmm d, yyyy").as_deref(), Some("2001-02-03"));
assert_eq!(pattern_to_iso("Saturday, February 03, 2001", "dddd, mmmm dd, yyyy").as_deref(), Some("2001-02-03"));
assert_eq!(pattern_to_iso("Smarch 3, 2001", "mmm d, yyyy"), None);
// A token the converter can't render leaves the value as it is.
assert_eq!(iso_to_pattern("2001-02-03", "mmmmm d"), "2001-02-03");
assert_eq!(pattern_to_iso("Feb 3", "mmmmm d"), None);
}
#[test]
fn data_fills_the_fields_and_adds_rows() {
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let by = |n: &str| all.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no widget {n}"));
assert_eq!(by("familyName").value.as_deref(), Some("Singh"));
assert_eq!(by("agree").value.as_deref(), Some("1"), "checked by its on value");
assert_eq!(by("born").value.as_deref(), Some("2001-02-03"));
let no = all.iter().find(|w| matches!(&w.kind, WidgetKind::Radio { on, .. } if on == "N")).unwrap();
let yes = all.iter().find(|w| matches!(&w.kind, WidgetKind::Radio { on, .. } if on == "Y")).unwrap();
assert!(no.value.is_some() && yes.value.is_none(), "the group's data picks the N button");
// One row in the template, three in the data: three rows laid out, each with its values.
let wheres: Vec<&str> = all.iter().filter(|w| w.name.starts_with("where0")).filter_map(|w| w.value.as_deref()).collect();
assert_eq!(wheres, ["Delhi", "Toronto", "Ottawa"]);
assert_eq!(by("where0_3").som, "form[0].table[0].row[2].where0[0]");
assert_eq!(by("agree").items, vec!["1".to_string(), "0".to_string()]);
}
/// The AcroForm's terminal fields as the engine would hand them over.
fn field_data(doc: &Document) -> Vec<FieldDatum> {
let root = doc.get(doc.root().unwrap());
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
let mut out = Vec::new();
for f in fields {
let r = f.as_ref().unwrap();
let d = doc.get(r);
let d = d.as_dict().unwrap();
let name = d.get(b"T").and_then(|t| t.as_string()).map(|s| s.to_text()).unwrap_or_default();
let v = d.get(b"V").map(|v| doc.resolve(v));
let data = match d.name(b"FT") {
Some(b"Tx") => FieldData::Text(v.and_then(|v| v.as_string().map(|s| s.to_text())).unwrap_or_default()),
Some(b"Btn") if d.int(b"Ff").unwrap_or(0) & (1 << 15) != 0 => {
FieldData::Radio(v.and_then(|v| v.as_name().map(|n| String::from_utf8_lossy(n).into_owned())).filter(|n| n != "Off"))
}
Some(b"Btn") if d.int(b"Ff").unwrap_or(0) & (1 << 16) != 0 => FieldData::None,
Some(b"Btn") => FieldData::Check(v.and_then(|v| v.as_name().map(|n| n != b"Off")).unwrap_or(false)),
_ => FieldData::None,
};
out.push(FieldDatum { obj: r, name, data });
}
out
}
#[test]
fn the_datasets_packet_is_written_from_the_fields_and_read_back() {
let mut doc = Document::open(Arc::new(shell(&template_with_data(1, DATA)))).unwrap();
render_into(&mut doc).unwrap();
// The laid-out fields hold the data's values; writing them back reproduces it.
let fields = field_data(&doc);
assert!(fields.iter().any(|f| f.name == "familyName" && f.data == FieldData::Text("Singh".into())), "{fields:?}");
assert!(fields.iter().any(|f| f.name == "answer" && f.data == FieldData::Radio(Some("N".into()))), "{fields:?}");
// The data already holds what the fields hold: nothing to write.
assert_eq!(write_datasets(&mut doc, &fields).unwrap(), DatasetsWrite::default());
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let p = read_packets(&doc).unwrap().unwrap();
let d = parse_datasets(&p.xdp).unwrap();
assert_eq!(d.text_at(&som_to_path("form[0].head[0].familyName[0]")), Some("Singh"));
assert_eq!(d.text_at(&som_to_path("form[0].head[0].agree[0]")), Some("1"));
assert_eq!(d.text_at(&som_to_path("form[0].head[0].answer[0]")), Some("N"));
assert_eq!(d.text_at(&som_to_path("form[0].table[0].row[2].where0[0]")), Some("Ottawa"));
assert_eq!(p.xdp.matches("<xfa:datasets").count(), 1, "the packet is replaced, not duplicated");
// Change a value, write again: the data follows, and reading gives the field its value.
let mut doc = doc;
let mut fields = field_data(&doc);
for f in &mut fields {
if f.name == "familyName" {
f.data = FieldData::Text("Kaur".into());
}
if f.name == "agree" {
f.data = FieldData::Check(false);
}
}
let w = write_datasets(&mut doc, &fields).unwrap();
assert!(w.written && w.warnings.is_empty(), "{w:?}");
let values = read_values(&doc, &fields);
assert!(values.contains(&("familyName".to_string(), FieldData::Text("Kaur".into()))), "{values:?}");
assert!(values.contains(&("agree".to_string(), FieldData::Check(false))), "{values:?}");
// A form without a datasets packet gets one, before the postamble.
let mut doc = Document::open(Arc::new(shell(&template(1)))).unwrap();
render_into(&mut doc).unwrap();
let fields = field_data(&doc);
assert!(write_datasets(&mut doc, &fields).unwrap().written);
let p = read_packets(&doc).unwrap().unwrap();
assert!(parse_datasets(&p.xdp).is_some());
// No XFA at all: nothing to write.
let mut plain = Document::new_empty();
assert!(!write_datasets(&mut plain, &[]).unwrap().written);
}
/// A radio item whose text contains a space keeps that text in the datasets. The appearance
/// state is a PDF name (`New_York`); writing it back used to clear the selection on the next layout.
#[test]
fn spaced_radio_values_stay_in_the_datasets() {
for (value, state) in [("New York", "New_York"), ("Y", "Y")] {
let xdp = format!(
r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="page"><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
<exclGroup name="city" layout="lr-tb">
<field name="first" w="108pt" h="18pt"><ui><checkButton/></ui><items><text>{value}</text></items></field>
<field name="second" w="108pt" h="18pt"><ui><checkButton/></ui><items><text>Boston</text></items></field>
</exclGroup>
<field name="note" w="108pt" h="18pt"><ui><textEdit/></ui></field>
<field name="agree" w="108pt" h="18pt"><ui><checkButton/></ui><items><text>{value}</text><text>No</text></items></field>
</subform>
</template>
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><city>{value}</city><note>before</note><agree>{value}</agree></form></xfa:data></xfa:datasets>
</xdp:xdp>"#
);
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
let report = render_into(&mut doc).unwrap();
assert!(report.warnings.is_empty(), "{value:?} {:?}", report.warnings);
let fields = field_data(&doc);
assert!(fields.iter().any(|f| f.name == "city" && f.data == FieldData::Radio(Some(state.into()))), "{value:?} {fields:?}");
let unchanged = write_datasets(&mut doc, &fields).unwrap();
assert!(!unchanged.written && unchanged.warnings.is_empty(), "{value:?} {unchanged:?}");
let mut fields = fields;
for f in &mut fields {
if f.name == "note" {
f.data = FieldData::Text("after".into());
}
}
let write = write_datasets(&mut doc, &fields).unwrap();
assert!(write.written && write.warnings.is_empty(), "{value:?} {write:?}");
let packets = read_packets(&doc).unwrap().unwrap();
let data = parse_datasets(&packets.xdp).unwrap();
assert_eq!(data.text_at(&som_to_path("form[0].city[0]")), Some(value));
assert_eq!(data.text_at(&som_to_path("form[0].agree[0]")), Some(value));
assert_eq!(data.text_at(&som_to_path("form[0].note[0]")), Some("after"));
let selected: Vec<(String, Option<String>)> = layout_xml(&packets.xdp)
.unwrap()
.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|item| match item {
Item::Widget(w) if w.name == "first" || w.name == "second" => Some((w.name.clone(), w.value.clone())),
_ => None,
})
.collect();
assert_eq!(selected, vec![("first".into(), Some(value.into())), ("second".into(), None)], "{value:?}");
}
}
#[test]
fn static_forms_read_their_values_from_the_datasets() {
let doc = Document::open(Arc::new(static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"))).unwrap();
assert!(!is_dynamic(&doc));
let fields = vec![
FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].name[0]".into(), data: FieldData::Text(String::new()) },
FieldDatum { obj: pdfcraft_cos::ObjRef::new(9, 0), name: "form1[0].page1[0].agree[0]".into(), data: FieldData::Check(false) },
];
let values = read_values(&doc, &fields);
assert_eq!(
values,
vec![
("form1[0].page1[0].name[0]".to_string(), FieldData::Text("Ada".into())),
("form1[0].page1[0].agree[0]".to_string(), FieldData::Check(true))
]
);
assert_eq!(build_data(&doc, &fields), "<xfa:data><form1><page1><name></name><agree></agree></page1></form1></xfa:data>");
}
#[test]
fn nested_width_less_subforms_lay_out_in_polynomial_time() {
// Regression: a container without a width measured its content once for its width and
// again for its height, at every level: 2^depth measurements. Two children per level make
// it worse still. This must finish at once, and lay the innermost fields out.
for depth in [40, 60] {
let open = "<subform layout=\"lr-tb\"><field name=\"a\" w=\"20pt\" h=\"10pt\"/>".repeat(depth);
let xml = format!(
"<template><subform layout=\"tb\" name=\"form\">{open}<field name=\"inner\" w=\"30pt\" h=\"10pt\"/>{}</subform></template>",
"</subform>".repeat(depth)
);
let start = std::time::Instant::now();
let form = layout_xml(&xml).expect("lays out");
assert!(start.elapsed() < std::time::Duration::from_secs(20), "took {:?}", start.elapsed());
let names: Vec<String> = form.pages.iter().flat_map(widgets).map(|w| w.name.clone()).collect();
assert!(names.iter().any(|n| n == "inner") || depth > 60, "{depth}: {names:?}");
assert!(names.len() > 1);
}
}
/// The static fixture's two fields, holding `name` and `agree`.
fn static_fields(name: &str, agree: bool) -> Vec<FieldDatum> {
vec![
FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].name[0]".into(), data: FieldData::Text(name.into()) },
FieldDatum { obj: pdfcraft_cos::ObjRef::new(9, 0), name: "form1[0].page1[0].agree[0]".into(), data: FieldData::Check(agree) },
]
}
/// The decoded XFA stream (object 5 in the static fixture, or whatever `/XFA` now points to).
fn xfa_bytes(doc: &Document) -> Vec<u8> {
let root = doc.get(doc.root().unwrap());
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
let xfa = doc.resolve(acro.as_dict().unwrap().get(b"XFA").unwrap());
let Object::Stream(s) = &*xfa else { panic!("a stream") };
s.decoded_within(1 << 20).unwrap()
}
/// Writing field values into the datasets keeps every data node no field binds to as written,
/// changes the bound values in place and adds a missing one beside them. Writing the same values
/// again changes nothing, and a field bound to structured content is reported, not flattened.
#[test]
fn writing_values_keeps_the_data_no_field_binds_to() {
// Regression: the data element was rebuilt from the fields alone, dropping everything else.
let data = concat!(
"<form1 a=\"1\"><page1><name>Ada</name><notes xmlns:my=\"urn:my\"><my:extra kind='x>y'>keep me</my:extra></notes>",
"<rich><p>bold <b>text</b></p></rich></page1><deep><a><b><c>deep value</c></b></a></deep></form1>",
"<other xmlns=\"urn:other\"><x>unbound</x></other><!-- a comment --><empty/>"
);
let mut doc = Document::open(Arc::new(static_shell(data))).unwrap();
let w = write_datasets(&mut doc, &static_fields("Grace & <Co>\r\n", true)).unwrap();
assert!(w.written && w.warnings.is_empty(), "{w:?}");
let saved = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let xdp = String::from_utf8(xfa_bytes(&saved)).unwrap();
// Everything no field binds to is still there, as written.
for kept in [
"<form1 a=\"1\">",
"<notes xmlns:my=\"urn:my\"><my:extra kind='x>y'>keep me</my:extra></notes>",
"<rich><p>bold <b>text</b></p></rich>",
"<deep><a><b><c>deep value</c></b></a></deep>",
"<other xmlns=\"urn:other\"><x>unbound</x></other><!-- a comment --><empty/>",
"<template xmlns=",
] {
assert!(xdp.contains(kept), "lost {kept:?}: {xdp}");
}
// The bound value changed in place; the missing check box node was added beside it.
assert!(xdp.contains("<name>Grace &amp; &lt;Co&gt;&#xD;\n</name>"), "{xdp}");
let d = parse_datasets(&xdp).unwrap();
assert_eq!(d.text_at(&som_to_path("form1[0].page1[0].name[0]")), Some("Grace & <Co>\r\n"));
assert_eq!(d.text_at(&som_to_path("form1[0].page1[0].agree[0]")), Some("1"));
assert_eq!(xdp.matches("<page1>").count(), 1, "added into the existing node, not a second one");
// Writing the same values again changes nothing.
let mut again = saved;
assert_eq!(write_datasets(&mut again, &static_fields("Grace & <Co>\r\n", true)).unwrap(), DatasetsWrite::default());
// A field bound to a node holding structured content is reported, not flattened.
let mut doc = Document::open(Arc::new(static_shell("<form1><page1><name><p>rich</p></name></page1></form1>"))).unwrap();
let w = write_datasets(&mut doc, &static_fields("plain", false)).unwrap();
assert!(w.warnings.iter().any(|m| m.contains("structured content") && m.contains("name[0]")), "{w:?}");
assert!(String::from_utf8(xfa_bytes(&doc)).unwrap().contains("<name><p>rich</p></name>"));
}
/// A UTF-16 packet in either byte order, with a byte order mark, has its values read exactly and
/// is written back as UTF-16 with its mark. A packet in neither UTF-8 nor UTF-16 is left alone,
/// and that is reported.
#[test]
fn structured_data_whitespace_changes_are_reported_without_flattening() {
let data = "<form1><page1><name>\n<p>Short</p>\n</name><agree/></page1></form1>";
for value in ["Short", "Short\n", "\nShort", " Short ", "\tShort\t"] {
let mut doc = Document::open(Arc::new(static_shell(data))).unwrap();
let before = xfa_bytes(&doc);
let result = write_datasets(&mut doc, &static_fields(value, false)).unwrap();
assert!(!result.written, "{value:?}: {result:?}");
assert_eq!(xfa_bytes(&doc), before, "structured XML must stay intact");
if value == "Short" {
assert!(result.warnings.is_empty(), "existing XML indentation is not a field edit: {result:?}");
} else {
assert!(result.warnings.iter().any(|w| w.contains("structured content")), "{value:?}: {result:?}");
}
}
}
#[test]
fn utf16_packets_are_read_and_written_back_as_utf16() {
let mut doc = Document::open(Arc::new(static_shell("<form1><page1><name>Zoë 日本</name><other>ünbound</other></page1></form1>"))).unwrap();
let text = String::from_utf8(xfa_bytes(&doc)).unwrap();
for (encoding, bom) in [(Encoding::Utf16Le { bom: true }, &[0xFF, 0xFE][..]), (Encoding::Utf16Be { bom: true }, &[0xFE, 0xFF][..])] {
let mut doc16 = doc.clone();
doc16.set(
pdfcraft_cos::ObjRef::new(5, 0),
Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &encode_packet(&text, encoding))),
);
// Read exactly, not as mojibake.
let values = read_values(&doc16, &static_fields("", false));
assert_eq!(values.first(), Some(&("form1[0].page1[0].name[0]".to_string(), FieldData::Text("Zoë 日本".into()))));
let w = write_datasets(&mut doc16, &static_fields("Łódź", false)).unwrap();
assert!(w.written && w.warnings.is_empty(), "{w:?}");
let bytes = xfa_bytes(&doc16);
assert!(bytes.starts_with(bom), "still UTF-16 with its byte order mark");
let (back, enc) = decode_packet(&bytes).unwrap();
assert_eq!(enc, encoding);
assert!(back.contains("<name>Łódź</name>") && back.contains("<other>ünbound</other>"), "{back}");
}
// A packet in neither UTF-8 nor UTF-16 (Latin-1 here) is left alone, and that is reported.
let latin1: Vec<u8> = text.replace("Zoë 日本", "Zo\u{eb}").chars().map(|c| c as u32 as u8).collect();
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &latin1)));
let w = write_datasets(&mut doc, &static_fields("x", true)).unwrap();
assert!(!w.written && !w.warnings.is_empty(), "{w:?}");
assert_eq!(xfa_bytes(&doc), latin1);
}
/// Hundreds of fields with absurd SOM indices finish within seconds, both writing the datasets and
/// building fresh data, create at most the node budget and report what they could not write. A
/// moderate index still works, creating the empty instances before it.
#[test]
fn huge_som_indices_are_capped_not_built() {
// Regression: each index created its missing siblings one count at a time (quadratic), with
// no limit on the total. Many fields with absurd indices must finish at once, reporting
// what they could not write.
let mut doc = Document::open(Arc::new(static_shell("<form1/>"))).unwrap();
let start = std::time::Instant::now();
let fields: Vec<FieldDatum> = (0..300)
.map(|i| FieldDatum {
obj: pdfcraft_cos::ObjRef::new(1000 + i, 0),
name: format!("form1[0].a[{}].b[99999].c[99999].d[{i}]", 9_000 + i),
data: FieldData::Text("v".into()),
})
.collect();
let w = write_datasets(&mut doc, &fields).unwrap();
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
assert!(w.warnings.iter().any(|m| m.contains("repeats too often")), "{w:?}");
let xdp = String::from_utf8(xfa_bytes(&doc)).unwrap();
assert!(xdp.matches("<a>").count() <= 20_000, "at most the node budget is created");
// Building fresh data is capped the same way.
let start = std::time::Instant::now();
let data = build_data(&doc, &fields);
assert!(start.elapsed() < std::time::Duration::from_secs(10));
assert!(data.len() < 2_000_000);
// And a moderate index still works.
let mut doc = Document::open(Arc::new(static_shell("<form1><row><v>1</v></row></form1>"))).unwrap();
let row = |i: usize| FieldDatum {
obj: pdfcraft_cos::ObjRef::new(500, 0),
name: format!("form1[0].row[{i}].v[0]"),
data: FieldData::Text(i.to_string()),
};
let w = write_datasets(&mut doc, &[row(0), row(3)]).unwrap();
assert!(w.written && w.warnings.is_empty(), "{w:?}");
let d = parse_datasets(&String::from_utf8(xfa_bytes(&doc)).unwrap()).unwrap();
assert_eq!(d.count(&som_to_path("form1[0]"), "row"), 4);
assert_eq!(d.text_at(&som_to_path("form1[0].row[3].v[0]")), Some("3"));
assert!(d.get(&som_to_path("form1[0].row[2]")).is_some(), "the instances in between exist, empty");
}
/// The live form tree carries each object's SOM path, value, presence and access, one instance of
/// a repeating row without data, and the template's event, calculate and validate scripts (FormCalc
/// when no content type is given). Data adds rows and values, and an override changes presence.
#[test]
fn the_form_tree_carries_values_presence_instances_and_the_events() {
let xml = scripted_template();
let (tpl, _) = parse(&xml).unwrap();
let crate::script::LiveForm { root, events, truncated } = form_tree(&tpl, None, &Overrides::default());
assert!(!truncated);
assert_eq!((root.name.as_str(), root.som.as_str()), ("form", "form[0]"));
let page = &root.children[0];
assert_eq!(page.som, "form[0].page1[0]");
let by = |n: &str| page.children.iter().find(|c| c.name == n).unwrap_or_else(|| panic!("no {n}"));
assert_eq!((by("price").value.as_str(), by("price").numeric, by("total").access.as_str()), ("5", true, "readOnly"));
assert_eq!(by("details").presence, "hidden");
let table = by("table");
assert_eq!(table.children.len(), 1, "one row without data");
assert!(table.children[0].repeatable && table.children[0].occur_max.is_none());
assert_eq!(table.children[0].children[1].som, "form[0].page1[0].table[0].row[0].amount[0]");
let ev = |som: &str, act: &str| events.iter().find(|e| e.som == som && e.activity == act).unwrap_or_else(|| panic!("no {act} on {som}"));
assert!(ev("form[0].page1[0]", "initialize").script.contains("qty.rawValue = 2"));
assert!(ev("form[0].page1[0].total[0]", "calculate").script.contains("qty.rawValue * price.rawValue"));
assert_eq!(ev("form[0].page1[0].qty[0]", "validate").message.as_deref(), Some("Quantity must be 100 or less"));
assert!(!ev("form[0].page1[0].addRow[0]", "click").formcalc);
assert!(ev("form[0].page1[0].legacy[0]", "click").formcalc, "no contentType means FormCalc");
// Data adds rows and values; overrides change presence.
let data = parse_datasets("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><qty>7</qty><table><row><amount>1</amount></row><row><amount>2</amount></row><row><amount>3</amount></row></table></page1></form></xfa:data></xfa:datasets>").unwrap();
let mut ov = Overrides::default();
ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into());
let root = form_tree(&tpl, Some(&data), &ov).root;
let page = &root.children[0];
assert_eq!(page.children.iter().find(|c| c.name == "qty").unwrap().value, "7");
assert_eq!(page.children.iter().find(|c| c.name == "table").unwrap().children.len(), 3);
assert_eq!(page.children.iter().find(|c| c.name == "details").unwrap().presence, "visible");
assert_eq!(page.children.iter().find(|c| c.name == "details").unwrap().kind, Some(NodeKind::Subform));
}
/// Presence and access overrides are kept on the AcroForm and applied to the template: `note` has
/// no widget while `details` is hidden and gets one once an override shows it and the form is laid
/// out again. Saving and reopening keeps the overrides and one set of fields; storing none removes the key.
#[test]
fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
assert!(overrides(&doc).is_empty());
let names = |doc: &Document| fields_by_som(doc).into_values().collect::<Vec<_>>();
assert!(!names(&doc).iter().any(|n| n == "note"), "details is hidden: {:?}", names(&doc));
let mut ov = Overrides::default();
ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into());
ov.access.insert("form[0].page1[0].qty[0]".into(), "readOnly".into());
set_overrides(&mut doc, &ov).unwrap();
assert_eq!(overrides(&doc), ov);
let (tpl, _) = parse(&scripted_template()).unwrap();
let laid = apply_overrides(&tpl, &ov);
let Node::Subform(page) = &laid.root.children[0] else { panic!() };
let Node::Subform(details) = page.children.iter().find(|c| c.common().name.as_deref() == Some("details")).unwrap() else { panic!() };
assert_eq!(details.common.presence, Presence::Visible);
let Node::Field(qty) = page.children.iter().find(|c| c.common().name.as_deref() == Some("qty")).unwrap() else { panic!() };
assert_eq!(qty.access, Access::ReadOnly);
// Laying out again shows the note field and keeps the others.
let report = rerender(&mut doc, &tpl).unwrap();
let after = names(&doc);
assert!(after.iter().any(|n| n == "note"), "{after:?}");
assert!(after.iter().any(|n| n == "qty"));
assert_eq!(report.fields, after.len());
// Reopening the written bytes finds one set of fields, not two.
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(names(&doc).len(), after.len());
assert_eq!(overrides(&doc), ov);
// Clearing removes the key.
let mut doc = doc;
set_overrides(&mut doc, &Overrides::default()).unwrap();
assert!(overrides(&doc).is_empty());
}
/// Showing a hidden subform through an override leaves its unnamed hidden draw and subform hidden,
/// in the layout and in the live form scripts see: they share the subform's SOM path, so its
/// override is not theirs.
#[test]
fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() {
let (tpl, _) = parse(&unnamed_hidden_template()).unwrap();
let mut ov = Overrides::default();
ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into());
let form = layout(&apply_overrides(&tpl, &ov), None).unwrap();
let shown = texts(&form.pages[0]);
assert!(shown.iter().any(|t| t == "Current caption"), "{shown:?}");
assert!(!shown.iter().any(|t| t == "Old caption" || t == "Old section"), "{shown:?}");
assert_eq!(form.fields, 1);
// Scripts see the same: the hidden draw stays hidden in the live form.
let root = form_tree(&tpl, None, &ov).root;
let details = &root.children[0].children[0];
assert_eq!(details.presence, "visible");
let presence = |text: &str| details.children.iter().find(|c| c.value == text).map(|c| c.presence.clone());
assert_eq!(presence("Current caption").as_deref(), Some("visible"));
assert_eq!(presence("Old caption").as_deref(), Some("hidden"));
assert_eq!(details.children.iter().find(|c| c.kind == Some(NodeKind::Subform)).map(|c| c.presence.as_str()), Some("hidden"));
}
/// Adding and removing data instances edits the datasets packet, creating it when missing: asking
/// for no more rows than exist, or removing one that is not there, changes nothing, and removing a
/// row moves the later ones up. The layout follows the data, and an absurd count is capped, not built.
#[test]
fn data_instances_are_added_and_removed_in_the_packet() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let parent = som_to_path("form[0].page1[0].table[0]");
// No datasets packet yet: one is created with the rows.
assert!(add_data_instances(&mut doc, &parent, "row", 3).unwrap().written);
let data = crate::script::data_of(&doc).unwrap();
assert_eq!(data.count(&parent, "row"), 3);
// Asking for fewer changes nothing; more adds.
assert!(!add_data_instances(&mut doc, &parent, "row", 2).unwrap().written);
assert!(add_data_instances(&mut doc, &parent, "row", 4).unwrap().written);
let mut p = parent.clone();
p.push(("row".into(), 1));
p.push(("amount".into(), 0));
write_data_value(&mut doc, &p, "42").unwrap();
let data = crate::script::data_of(&doc).unwrap();
assert_eq!((data.count(&parent, "row"), data.text_at(&p)), (4, Some("42")));
// Remove the second row: the third's value moves up.
let mut second = parent.clone();
second.push(("row".into(), 1));
assert!(remove_data_instance(&mut doc, &second).unwrap().written);
let data = crate::script::data_of(&doc).unwrap();
assert_eq!(data.count(&parent, "row"), 3);
assert_eq!(data.text_at(&p), None, "the row holding 42 is gone");
// Removing what isn't there is nothing.
let mut tenth = parent.clone();
tenth.push(("row".into(), 9));
assert!(!remove_data_instance(&mut doc, &tenth).unwrap().written);
// The layout follows the data: three rows of fields.
let (tpl, _) = parse(&scripted_template()).unwrap();
rerender(&mut doc, &tpl).unwrap();
let names: Vec<String> = fields_by_som(&doc).into_values().collect();
assert_eq!(names.iter().filter(|n| n.starts_with("amount")).count(), 3, "{names:?}");
// Hostile: absurd indices are capped, not built.
assert!(add_data_instances(&mut doc, &parent, "row", 1_000_000).is_ok());
}
#[test]
fn formcalc_button_idioms_keep_their_native_actions() {
let xml = scripted_template()
.replace("<script>$host.messageBox(\"FormCalc\")</script>", "<script>$host.resetData()</script>")
.replace("xfa.host.messageBox(\"Hello \" + qty.rawValue);", "if (qty.rawValue > 1) { xfa.host.resetData(); }");
let form = layout_xml(&xml).unwrap();
let w: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let by = |n: &str| w.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no {n}"));
assert_eq!(by("legacy").action, Some(Action::Reset), "a one-statement FormCalc idiom is a native action");
assert!(matches!(by("hello").action, Some(Action::Script(_))), "anything else runs as a script: {:?}", by("hello").action);
assert!(matches!(by("addRow").action, Some(Action::Script(_))));
}
/// Five levels of subforms that each repeat 50 times, with a scripted field at the bottom:
/// 50⁵ instances if nothing stopped the walk.
fn nested_repeats_template() -> String {
let mut body = r#"<field name="leaf" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script contentType="application/x-javascript">1</script></calculate></field>"#.to_string();
for level in (0..5).rev() {
body = format!(r#"<subform name="s{level}" layout="tb"><occur min="1" max="-1" initial="50"/>{body}</subform>"#);
}
format!(
r#"<?xml version="1.0"?><xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb"><pageSet><pageArea name="p"><contentArea x="0" y="0" w="8in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>{body}</subform></template></xdp:xdp>"#
)
}
#[test]
fn the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget() {
fn count(n: &crate::script::FormNode) -> usize {
1 + n.children.iter().map(count).sum::<usize>()
}
let (tpl, _) = parse(&nested_repeats_template()).unwrap();
assert!(crate::script::has_scripts(&tpl));
let started = std::time::Instant::now();
let live = form_tree(&tpl, None, &Overrides::default());
assert!(live.truncated, "the walk was cut short");
assert!(count(&live.root) <= crate::script::MAX_FORM_NODES + 1, "{} nodes", count(&live.root));
assert!(live.events.len() <= 20_000);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
// A template without scripts has nothing to run.
let plain_xml = nested_repeats_template().replace(r#"<calculate><script contentType="application/x-javascript">1</script></calculate>"#, "");
let (plain, _) = parse(&plain_xml).unwrap();
assert!(!crate::script::has_scripts(&plain));
}
#[test]
fn data_ops_of_one_event_are_one_datasets_rewrite() {
use crate::data::{DataOp, write_data_ops};
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let before = doc.object_numbers().len();
let ops = vec![
DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: "1".into() },
DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: "7".into() },
DataOp::Instances { parent: som_to_path("form[0].page1[0].table[0]"), name: "row".into(), count: 3 },
DataOp::Value { path: som_to_path("form[0].page1[0].table[0].row[2].amount[0]"), text: "5".into() },
DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[0]")),
];
let r = write_data_ops(&mut doc, &ops, None).unwrap();
assert!(r.written && r.stream.is_some(), "{r:?}");
assert_eq!(doc.object_numbers().len(), before + 1, "one new datasets stream");
let data = crate::data_of(&doc).unwrap();
assert_eq!(data.text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("7"), "the last value wins");
assert_eq!(data.count(&som_to_path("form[0].page1[0].table[0]"), "row"), 2);
assert_eq!(data.text_at(&som_to_path("form[0].page1[0].table[0].row[1].amount[0]")), Some("5"));
// The in-memory model agrees with what was written.
let mut model = DataNode::default();
model.children.push(DataNode { name: "form".into(), ..Default::default() });
for op in &ops {
model.apply(op);
}
assert_eq!(model.text_at(&som_to_path("form[0].page1[0].qty[0]")), Some("7"));
assert_eq!(model.count(&som_to_path("form[0].page1[0].table[0]"), "row"), 2);
assert_eq!(model.text_at(&som_to_path("form[0].page1[0].table[0].row[1].amount[0]")), Some("5"));
// A second write for the same event replaces that stream instead of adding another; an
// empty value clears the node.
let again = write_data_ops(&mut doc, &[DataOp::Value { path: som_to_path("form[0].page1[0].qty[0]"), text: String::new() }], r.stream).unwrap();
assert_eq!(again.stream, r.stream);
assert_eq!(doc.object_numbers().len(), before + 1);
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].qty[0]")), Some(""));
}
#[test]
fn script_buttons_carry_no_javascript_action_for_other_viewers() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let fields = top_fields(&doc);
let add_row = fields.iter().find(|(name, _)| name == "addRow").map(|(_, d)| d.clone()).expect("addRow");
assert!(add_row.contains(crate::CLICK_KEY), "found by its SOM path");
assert!(add_row.get(b"A").is_none() && add_row.get(b"AA").is_none(), "no XFA source as a PDF JavaScript action");
}
/// The generated fields' dictionaries by name (top-level fields only).
fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
let root = doc.root().unwrap();
let catalog = doc.get(root);
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap());
fields
.as_array()
.unwrap()
.iter()
.filter_map(|f| {
let d = doc.resolve(f).as_dict().cloned()?;
let t = d.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text())?;
Some((t, d))
})
.collect()
}
#[test]
fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
let form = layout_xml(&fields_template("")).unwrap();
let p1 = &form.pages[0];
let w = widgets(p1);
let country = w.iter().find(|w| w.name == "country").unwrap();
assert_eq!(
country.kind,
WidgetKind::Choice {
options: vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())],
list_box: false,
multi: false,
editable: false,
}
);
assert_eq!(country.value.as_deref(), Some("FR"), "the template default, as a saved value");
let langs = w.iter().find(|w| w.name == "langs").unwrap();
assert!(
matches!(&langs.kind, WidgetKind::Choice { options, list_box: true, multi: true, editable: false } if options.len() == 3 && options[0] == ("English".into(), "English".into()))
);
let other = w.iter().find(|w| w.name == "other").unwrap();
assert!(matches!(&other.kind, WidgetKind::Choice { list_box: false, multi: false, editable: true, .. }));
assert_eq!(w.iter().find(|w| w.name == "pin").unwrap().kind, WidgetKind::Password);
assert_eq!(w.iter().find(|w| w.name == "sign").unwrap().kind, WidgetKind::Signature);
assert_eq!(w.iter().find(|w| w.name == "photo").unwrap().kind, WidgetKind::Image, "an image field is a button that picks a picture");
assert!(w.iter().all(|w| w.name != "code"), "barcode fields make no widget");
// The PNG in the image field and the GIF in the draw are pictures, fitted to their boxes.
let images: Vec<(&Rect, usize)> =
p1.items.iter().filter_map(|i| if let Item::Image { rect, data, .. } = i { Some((rect, data.len())) } else { None }).collect();
assert_eq!(images.len(), 2, "{images:?}");
assert!((images[0].0.w - 72.0).abs() < 0.01 && (images[0].0.h - 72.0).abs() < 0.01, "a square picture fills a 1in square: {:?}", images[0].0);
assert_eq!(images[1].1, TINY_GIF.len());
let t = texts(p1);
assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}");
assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings);
assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings);
// Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password
// field, and Flate images (the PNG with a soft mask).
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
let report = render_into(&mut doc).unwrap();
assert_eq!(report.fields, 7, "country, langs, other, pin, sign, photo, summary");
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let fields = pdfcraft_forms_free_fields(&doc);
let field = |n: &str| fields.iter().find(|(name, _)| name == n).map(|(_, d)| d.clone()).unwrap_or_else(|| panic!("no field {n}"));
let country = field("country");
assert_eq!(country.name(b"FT"), Some(b"Ch".as_slice()));
assert_eq!(country.int(b"Ff"), Some(1 << 17), "a combo box");
let opt = doc.resolve(country.get(b"Opt").unwrap()).as_array().unwrap().clone();
assert_eq!(opt.len(), 3);
let pair = doc.resolve(&opt[1]).as_array().unwrap().clone();
assert_eq!(pair[0].as_string().unwrap().to_text(), "FR");
assert_eq!(pair[1].as_string().unwrap().to_text(), "France");
assert_eq!(country.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("FR"));
assert_eq!(country.get(b"DV").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("FR"));
let langs = field("langs");
assert_eq!(langs.int(b"Ff"), Some(1 << 21), "a multi-select list box");
let opt = doc.resolve(langs.get(b"Opt").unwrap()).as_array().unwrap().clone();
assert_eq!(opt[0].as_string().unwrap().to_text(), "English", "same shown text and value: a plain string");
assert_eq!(field("other").int(b"Ff"), Some((1 << 17) | (1 << 18)), "an editable combo box");
assert_eq!(field("pin").int(b"Ff"), Some(1 << 13), "a password field");
assert_eq!(field("sign").name(b"FT"), Some(b"Sig".as_slice()));
let streams: Vec<pdfcraft_cos::Stream> = doc
.object_numbers()
.into_iter()
.filter_map(|n| if let Object::Stream(st) = &*doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }) { Some(st.clone()) } else { None })
.filter(|s| s.dict.name(b"Subtype") == Some(b"Image"))
.collect();
assert_eq!(streams.len(), 3, "the PNG, its soft mask, the GIF");
let png = streams.iter().find(|s| s.dict.contains(b"SMask")).expect("the PNG has a soft mask");
assert_eq!(png.dict.name(b"ColorSpace"), Some(b"DeviceRGB".as_slice()));
assert_eq!(png.dict.name(b"Filter"), Some(b"FlateDecode".as_slice()));
assert_eq!(png.decoded().unwrap().len(), 12, "2 × 2 RGB samples");
let gif = streams.iter().find(|s| s.dict.int(b"Width") == Some(1)).expect("the 1 × 1 GIF");
assert_eq!(gif.dict.name(b"ColorSpace"), Some(b"DeviceGray".as_slice()), "a white pixel is gray");
assert_eq!(gif.decoded().unwrap(), vec![255]);
}
/// The terminal field dictionaries by name (one level: this fixture has no hierarchy).
fn pdfcraft_forms_free_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
let root = doc.get(doc.root().unwrap());
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
fields
.iter()
.filter_map(|f| {
let d = doc.resolve(f).as_dict()?.clone();
let name = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text()))?;
Some((name, d))
})
.collect()
}
#[test]
fn choice_values_come_from_the_data_as_saved_values_and_several_for_lists() {
// Shown text in the data is taken too (an editable list the user typed into saves text).
let xml = fields_template("<form><page1><country>Japan</country><langs>English\nSpanish</langs><other>typed</other></page1></form>");
let form = layout_xml(&xml).unwrap();
let w = widgets(&form.pages[0]);
assert_eq!(w.iter().find(|w| w.name == "country").unwrap().value.as_deref(), Some("JP"));
assert_eq!(w.iter().find(|w| w.name == "langs").unwrap().value.as_deref(), Some("English\nSpanish"));
assert_eq!(w.iter().find(|w| w.name == "other").unwrap().value.as_deref(), Some("typed"));
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
render_into(&mut doc).unwrap();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let fields = pdfcraft_forms_free_fields(&doc);
let langs = &fields.iter().find(|(n, _)| n == "langs").unwrap().1;
let v = doc.resolve(langs.get(b"V").unwrap()).as_array().unwrap().clone();
assert_eq!(v.iter().map(|s| s.as_string().unwrap().to_text()).collect::<Vec<_>>(), vec!["English", "Spanish"]);
let i = doc.resolve(langs.get(b"I").unwrap()).as_array().unwrap().clone();
assert_eq!(i.iter().map(|o| o.as_int().unwrap()).collect::<Vec<_>>(), vec![0, 2]);
}
#[test]
fn pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated() {
use crate::image::{Kind, decode, kind, size};
assert_eq!(kind(&tiny_png()), Some(Kind::Png));
assert_eq!(size(&tiny_png()), Some((2, 2)));
assert_eq!(kind(TINY_GIF), Some(Kind::Gif));
assert_eq!(size(TINY_GIF), Some((1, 1)));
assert_eq!(kind(b"BM..."), None);
assert_eq!(size(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), None, "a header cut short");
let decoded = decode(&tiny_png()).unwrap();
assert_eq!((decoded.width, decoded.height, decoded.color_space), (2, 2, "DeviceRGB"));
assert_eq!(decoded.samples, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]);
assert_eq!(decoded.alpha, Some(vec![255, 255, 255, 0]));
assert_eq!(decode(TINY_GIF).unwrap().samples, vec![255]);
// A PNG claiming 100,000 × 100,000 pixels is refused from its header, before any buffer.
let mut huge = tiny_png();
huge[16..20].copy_from_slice(&100_000u32.to_be_bytes());
huge[20..24].copy_from_slice(&100_000u32.to_be_bytes());
assert_eq!(size(&huge), None, "no size for layout: the box is used");
assert!(decode(&huge).unwrap_err().contains("million pixels"));
// Truncated and corrupt data are errors, not panics.
let cut = &tiny_png()[..30];
assert!(decode(cut).is_err());
let mut bad = tiny_png();
for b in bad.iter_mut().skip(40) {
*b ^= 0x55;
}
assert!(decode(&bad).is_err() || decode(&bad).is_ok());
let mut gif = TINY_GIF.to_vec();
gif.truncate(12);
assert!(decode(&gif).is_err());
// A form whose picture is broken still lays out, with a warning, and keeps the rest.
let xml = fields_template("")
.replace("image/gif", "image/gif\" transferEncoding=\"none")
.replace("<image contentType=\"image/png\">", "<image contentType=\"image/png\">AAAA");
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
let report = render_into(&mut doc).unwrap();
assert_eq!(report.fields, 7);
assert!(report.warnings.iter().any(|w| w.contains("image")), "{:?}", report.warnings);
}
#[test]
fn choice_values_are_saved_values_first_deduplicated_and_defaults_are_saved_values_too() {
// Designer's "specify item values": shown 0..3, saved 1..4. The data's 1 means the item
// shown as 0, not the item shown as 1.
let tpl = fields_template("<form><page1><country>1</country></page1></form>")
.replace("<text>Canada</text><text>France</text><text>Japan</text>", "<text>0</text><text>1</text><text>2</text><text>3</text>")
.replace("<text>CA</text><text>FR</text><text>JP</text>", "<text>1</text><text>2</text><text>3</text><text>4</text>")
.replace("<value><text>FR</text></value></field>", "<value><text>1</text></value></field>");
let form = layout_xml(&tpl).unwrap();
let w = widgets(&form.pages[0]);
assert_eq!(w.iter().find(|w| w.name == "country").unwrap().value.as_deref(), Some("1"));
let (t, _) = parse(&tpl).unwrap();
let tree = form_tree(&t, parse_datasets(&tpl).as_ref(), &Overrides::default());
let node = tree.root.children.iter().find(|n| n.name == "page1").unwrap().children.iter().find(|n| n.name == "country").unwrap();
assert_eq!(node.value, "1", "scripts read the saved value");
// Several values: each once, never more than there are items, written as ascending indices;
// a single-select list takes the first line; defaults are saved values.
let tpl = fields_template("<form><page1><country>Japan\nCanada</country><langs>Spanish\nEnglish\nEnglish\nSpanish</langs></page1></form>")
.replace("<value><text>FR</text></value></field>", "<value><text>France</text></value></field>")
.replace(
"<items><text>English</text><text>French</text><text>Spanish</text></items>",
"<items><text>English</text><text>French</text><text>Spanish</text></items><value><text>English\nFrench</text></value>",
);
let form = layout_xml(&tpl).unwrap();
let w = widgets(&form.pages[0]);
assert_eq!(w.iter().find(|w| w.name == "langs").unwrap().value.as_deref(), Some("Spanish\nEnglish"));
assert_eq!(w.iter().find(|w| w.name == "country").unwrap().value.as_deref(), Some("JP"));
assert_eq!(w.iter().find(|w| w.name == "country").unwrap().default.as_deref(), Some("FR"));
let mut doc = Document::open(Arc::new(shell(&tpl))).unwrap();
render_into(&mut doc).unwrap();
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let fields = pdfcraft_forms_free_fields(&doc);
let langs = &fields.iter().find(|(n, _)| n == "langs").unwrap().1;
let i = doc.resolve(langs.get(b"I").unwrap()).as_array().unwrap().clone();
assert_eq!(i.iter().map(|o| o.as_int().unwrap()).collect::<Vec<_>>(), vec![0, 2]);
let dv = doc.resolve(langs.get(b"DV").unwrap()).as_array().unwrap().clone();
assert_eq!(dv.iter().map(|s| s.as_string().unwrap().to_text()).collect::<Vec<_>>(), vec!["English", "French"]);
let country = &fields.iter().find(|(n, _)| n == "country").unwrap().1;
assert_eq!(country.get(b"DV").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("FR"));
assert_eq!(doc.resolve(country.get(b"I").unwrap()).as_array().unwrap().len(), 1);
// Picks stored as <value> children are read too.
let tpl = fields_template("<form><page1><langs><value>English</value><value>Spanish</value></langs></page1></form>");
let form = layout_xml(&tpl).unwrap();
assert_eq!(widgets(&form.pages[0]).iter().find(|w| w.name == "langs").unwrap().value.as_deref(), Some("English\nSpanish"));
// More items than the cap: kept to the cap, and said so.
let many: String = (0..1500).map(|i| format!("<text>i{i}</text>")).collect();
let tpl = fields_template("").replace("<items><text>A</text><text>B</text></items>", &format!("<items>{many}</items>"));
let form = layout_xml(&tpl).unwrap();
let other = widgets(&form.pages[0]).into_iter().find(|w| w.name == "other").unwrap();
assert!(matches!(&other.kind, WidgetKind::Choice { options, .. } if options.len() == 1000));
assert!(form.warnings.iter().any(|w| w.contains("other") && w.contains("1000 items")), "{:?}", form.warnings);
}
#[test]
fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reported() {
use crate::image::{decode, size};
let tpl = fields_template("<form><page1><country>JP</country></page1></form>");
let mut doc = Document::open(Arc::new(shell(&tpl))).unwrap();
render_into(&mut doc).unwrap();
let (t, _) = parse(&tpl).unwrap();
rerender(&mut doc, &t).unwrap();
rerender(&mut doc, &t).unwrap();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let images = doc
.object_numbers()
.into_iter()
.filter(
|n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")),
)
.count();
assert_eq!(images, 3, "the PNG, its soft mask and the GIF, however many times the form was laid out");
// No private map in the catalog: earlier pictures are found on the pages, by their hash.
let root = doc.get(doc.root().unwrap());
assert!(!root.as_dict().unwrap().contains(b"PCXfaImages"));
// Pictures in the data that aren't pictures: a warning naming the field, the template's
// picture shown instead.
for (data, what) in [("Qk0AAAAA", "not a JPEG, PNG, GIF, BMP or TIFF"), ("not base64!", "not base64")] {
let tpl = fields_template(&format!("<form><page1><photo>{data}</photo></page1></form>"));
let form = layout_xml(&tpl).unwrap();
assert!(form.warnings.iter().any(|w| w.contains("photo") && w.contains(what)), "{what}: {:?}", form.warnings);
assert_eq!(form.pages[0].items.iter().filter(|i| matches!(i, Item::Image { .. })).count(), 2);
}
// A hostile PNG header: no size for layout (the draw keeps its box), refused at decode.
let mut tall = tiny_png();
tall[16..20].copy_from_slice(&1u32.to_be_bytes());
tall[20..24].copy_from_slice(&4_000_000_000u32.to_be_bytes());
assert_eq!(size(&tall), None);
assert!(decode(&tall).unwrap_err().contains("million pixels"));
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&tall);
let tpl = fields_template("").replace(
r#"<draw name="logo" w="0.5in" h="0.5in">"#,
&format!(
r#"<draw name="tall" w="2in"><value><image contentType="image/png">{b64}</image></value></draw><draw name="logo" w="0.5in" h="0.5in">"#
),
);
let form = layout_xml(&tpl).unwrap();
assert_eq!(form.pages.len(), 1, "a picture of unknown size takes its box, not a page");
// A GIF whose frame is far larger than its 1 × 1 screen is refused before it is allocated.
let mut gif = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00,\x00\x00\x00\x00".to_vec();
gif.extend_from_slice(&11_000u16.to_le_bytes());
gif.extend_from_slice(&11_000u16.to_le_bytes());
gif.extend_from_slice(b"\x00\x02\x02D\x01\x00;");
let started = std::time::Instant::now();
assert!(decode(&gif).unwrap_err().contains("million pixels"));
assert!(started.elapsed() < std::time::Duration::from_secs(2));
// A template picture over 1 MiB is read whole (text is cut at 1 MiB, pictures are not).
let big = "A".repeat(1_600_000);
let tpl = fields_template("").replace(r#"<image contentType="image/gif">"#, &format!(r#"<image contentType="image/gif">{big}"#));
let (t, _) = parse(&tpl).unwrap();
fn find_image(nodes: &[Node]) -> Option<usize> {
nodes.iter().find_map(|n| match n {
Node::Draw(d) => match &d.value {
Value::Image { data, .. } => Some(data.len()),
_ => None,
},
Node::Subform(s) => find_image(&s.children),
_ => None,
})
}
assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000));
}
/// Designer forms often carry BMP and TIFF pictures, declared as `image/bmp` and `image/tif`
/// (the Government of Canada's IMM 5257 and IMM 5710 have both). They were left out.
#[test]
fn bmp_and_tiff_pictures_are_decoded_and_drawn() {
use crate::image::{Kind, decode, kind, size};
for palette in [false, true] {
let bmp = tiny_bmp(palette);
assert_eq!(kind(&bmp), Some(Kind::Bmp));
assert_eq!(size(&bmp), Some((2, 1)));
let d = decode(&bmp).unwrap();
assert_eq!((d.width, d.height, d.color_space), (2, 1, "DeviceRGB"), "palette: {palette}");
assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "red then blue, palette: {palette}");
assert_eq!(d.alpha, None);
}
for lzw in [false, true] {
let tiff = tiny_tiff(lzw);
assert_eq!(kind(&tiff), Some(Kind::Tiff));
assert_eq!(size(&tiff), Some((1, 1)));
let d = decode(&tiff).unwrap();
assert_eq!((d.width, d.height, d.color_space), (1, 1, "DeviceRGB"), "lzw: {lzw}");
assert_eq!(d.samples, vec![0x12, 0x34, 0x56], "lzw: {lzw}");
}
assert_eq!(kind(b"MM\0*\0\0\0\x08"), Some(Kind::Tiff), "big-endian");
let mut cut = tiny_tiff(true);
cut.truncate(60);
assert!(decode(&cut).is_err());
// In a form: drawn, with no warning, whatever the declared type says.
use base64::Engine;
for (data, declared) in
[(tiny_bmp(false), "image/bmp"), (tiny_bmp(true), "image/x-ms-bmp"), (tiny_tiff(false), "image/tif"), (tiny_tiff(true), "image/tiff")]
{
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
let gif = base64::engine::general_purpose::STANDARD.encode(TINY_GIF);
let xml =
fields_template("").replace(&format!(r#"<image contentType="image/gif">{gif}"#), &format!(r#"<image contentType="{declared}">{b64}"#));
assert!(xml.contains(declared));
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
let report = render_into(&mut doc).unwrap();
assert!(!report.warnings.iter().any(|w| w.contains("left out")), "{declared}: {:?}", report.warnings);
let samples: Vec<u8> = if declared.contains("bmp") { vec![255, 0, 0, 0, 0, 255] } else { vec![0x12, 0x34, 0x56] };
let drawn = doc.object_numbers().into_iter().any(|n| {
matches!(&*doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }), Object::Stream(st)
if st.dict.name(b"Subtype") == Some(b"Image") && st.decoded().ok().as_ref() == Some(&samples))
});
assert!(drawn, "{declared}: its pixels are an image XObject");
}
}
/// A repeating subform drawn inside positioned content (a top-to-bottom table in an area of a
/// positioned subform) gets its `occur` instances, as in Adobe's viewers: real Designer forms
/// have tables like this whose calculations read every row. Before, only one row was laid
/// out, and the field after the table was placed as if there were one.
#[test]
fn repeating_subforms_inside_positioned_content_get_their_instances() {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page1" w="7.5in" h="8in">
<area name="box" x="0.5in" y="1in">
<subform name="wrap" layout="tb" w="4in">
<subform name="table" layout="tb" w="4in">
<subform name="row" w="4in" h="0.3in"><occur min="3" max="-1"/>
<field name="reason" w="4in" h="0.3in"><ui><textEdit/></ui></field>
</subform>
</subform>
<field name="after" w="4in" h="0.3in"><ui><textEdit/></ui></field>
</subform>
</area>
</subform>
</subform>
</template>
</xdp:xdp>
"##;
let form = layout_xml(xdp).unwrap();
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let rows: Vec<&Widget> = all.iter().copied().filter(|w| w.som.ends_with(".reason[0]")).collect();
let soms: Vec<&str> = rows.iter().map(|w| w.som.as_str()).collect();
assert_eq!(
soms,
[
"form[0].page1[0].box[0].wrap[0].table[0].row[0].reason[0]",
"form[0].page1[0].box[0].wrap[0].table[0].row[1].reason[0]",
"form[0].page1[0].box[0].wrap[0].table[0].row[2].reason[0]",
],
"one widget per instance, each with its own SOM index"
);
// The rows are stacked, not drawn on top of each other…
let step = rows[1].rect.y - rows[0].rect.y;
assert!(step.abs() > 1.0, "rows overlap: {:?}", rows.iter().map(|w| w.rect).collect::<Vec<_>>());
assert!(((rows[2].rect.y - rows[1].rect.y) - step).abs() < 0.01, "rows are evenly spaced");
// …and what follows the table is placed after all of them (the table was measured with
// its instances, not with one row).
let after = all.iter().find(|w| w.som.ends_with(".after[0]")).expect("the field after the table");
assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect);
}
/// A positioned subform holding `body`, laid out; its page's items.
fn positioned(body: &str) -> Vec<Item> {
let xdp = format!(
r##"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="box" w="7.5in" h="3in">{body}</subform>
</subform>
</template></xdp:xdp>"##
);
layout_xml(&xdp).unwrap().pages.into_iter().next().unwrap().items
}
/// A text draw without a width is as wide as its text, so right-aligned text starts at its x; a
/// minimum width wider than the text still wins, and text longer than the room left still wraps.
#[test]
fn a_label_without_a_width_is_as_wide_as_its_text() {
const TEXT: &str = "PERSONNEL ONLY: from date (YYYYMMDD):";
let natural = pdfcraft_fonts::helvetica_width(TEXT, 8.0);
// Right-aligned, with a minimum narrower than the text: the box is as wide as the text and
// starts where it is placed, so the text does too (not at the far right of the subform).
let items = positioned(&format!(
r#"<draw name="a" x="1in" y="0" minW="50pt"><value><text>{TEXT}</text></value><font typeface="Arial" size="8pt"/><para hAlign="right"/></draw>"#
));
let span = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).expect("the text");
assert!((span.x - (36.0 + 72.0)).abs() < 0.05, "starts at its x: {}", span.x);
// With a minimum wider than the text, the box is that wide and the text ends at its right.
let items = positioned(&format!(
r#"<draw name="a" x="1in" y="0" minW="{}pt"><value><text>{TEXT}</text></value><font typeface="Arial" size="8pt"/><para hAlign="right"/></draw>"#,
natural + 40.0
));
let span = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).expect("the text");
assert!((span.x + span.face.width(&span.text) - (36.0 + 72.0 + natural + 40.0)).abs() < 0.05, "ends at its right: {}", span.x);
// Text longer than the room left still wraps at the container's width.
let long = "word ".repeat(200);
let items = positioned(&format!(r#"<draw name="a" x="0" y="0"><value><text>{long}</text></value><font typeface="Arial" size="8pt"/></draw>"#));
let lines: std::collections::BTreeSet<i64> =
items.iter().filter_map(|i| if let Item::Text(s) = i { Some((s.baseline * 100.0).round() as i64) } else { None }).collect();
assert!(lines.len() > 1, "wraps: {} line(s)", lines.len());
// And within the room it has: no line's drawn text runs past the subform's right edge.
let right = items
.iter()
.filter_map(|i| if let Item::Text(s) = i { Some(s.x + s.face.width(s.text.trim_end())) } else { None })
.fold(f64::NEG_INFINITY, f64::max);
assert!(right <= 36.0 + 7.5 * 72.0 + 0.05, "the text stays within the available width: {right}");
}
/// A draw without a width and without text (a bordered spacer or rule) keeps the width it has
/// room for: with no text to size it to, it doesn't shrink to its margins.
#[test]
fn an_empty_draw_without_a_width_keeps_the_width_it_has_room_for() {
let items = positioned(r#"<draw name="rule" x="0" y="0" h="12pt"><value><text></text></value><border><edge thickness="1pt"/></border></draw>"#);
let widest =
items.iter().filter_map(|i| if let Item::Line { from, to, .. } = i { Some((to.0 - from.0).abs()) } else { None }).fold(0.0_f64, f64::max);
assert!((widest - 540.0).abs() < 0.5, "the border spans the 7.5in box: {widest}");
}
/// A button without a width is as wide as its caption, not the subform it sits in, unless its
/// minimum width is wider.
#[test]
fn a_button_without_a_width_is_as_wide_as_its_caption() {
// No width, a minimum narrower than its caption: as wide as the caption, not the subform.
let caption = "Open the instructions for this form";
let items = positioned(&format!(
r#"<field name="help" x="1in" y="0" minW="40pt" minH="12pt"><ui><button/></ui><caption><value><text>{caption}</text></value><font typeface="Arial" size="8pt"/></caption></field>"#
));
let button = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).expect("the button");
let natural = pdfcraft_fonts::helvetica_width(caption, 8.0);
assert!(button.rect.w >= natural && button.rect.w < natural + 10.0, "{} for a caption of {natural}", button.rect.w);
// A minimum wider than the caption wins.
let items = positioned(
r#"<field name="help" x="1in" y="0" minW="200pt" minH="12pt"><ui><button/></ui><caption><value><text>Help</text></value><font typeface="Arial" size="8pt"/></caption></field>"#,
);
let button = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).expect("the button");
assert!((button.rect.w - 200.0).abs() < 0.05, "{}", button.rect.w);
}
/// A button without a height is as tall as one line of its caption in the caption's own font,
/// with none of a text field's padding, and never shorter than its minimum height.
#[test]
fn a_button_without_a_height_is_as_tall_as_its_caption() {
// A 10pt field font, an 8pt caption: the button is as tall as its caption's line (no
// text-field padding), and never below its minimum height.
let button = |min_h: &str| {
let items = positioned(&format!(
r#"<field name="help" x="1in" y="0" w="100pt" minH="{min_h}"><ui><button/></ui><font typeface="Times New Roman" size="10pt"/><caption><value><text>Help</text></value><font typeface="Arial" size="8pt"/></caption></field>"#
));
items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w.rect.h) } else { None }).expect("the button")
};
assert!((button("2pt") - text::line_height(8.0)).abs() < 0.05, "{}", button("2pt"));
assert!((button("10.5pt") - 10.5).abs() < 0.05, "{}", button("10.5pt"));
}
/// A button's widget is its whole field, at the field's position and size: the field's margins
/// pad the caption, not the box.
#[test]
fn a_buttons_box_is_the_whole_field_whatever_its_margins() {
// The button's box is its field's border: its margins pad the caption, not the box.
let items = positioned(
r#"<field name="help" x="1in" y="10pt" w="100pt" h="20pt"><ui><button/></ui><margin topInset="5pt" leftInset="3pt"/><border><edge/></border><caption><value><text>Help</text></value><font typeface="Arial" size="8pt"/></caption></field>"#,
);
let button = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w.rect) } else { None }).expect("the button");
assert!((button.w - 100.0).abs() < 0.05 && (button.h - 20.0).abs() < 0.05, "{button:?}");
assert!((button.x - (36.0 + 72.0)).abs() < 0.05, "{button:?}");
}
/// A page area without an `<occur>` repeats for as long as the body needs pages. Before, it was
/// treated like a subform (at most once), so once the first body page was out the layout fell
/// back to the last page area, here one limited to a single use, and drew its content over every
/// body page (and listed a copy of its fields per page).
#[test]
fn page_area_without_occur_serves_every_body_page() {
let section = |n: u32| {
format!(
r#"<subform name="s{n}" layout="tb"><breakBefore targetType="pageArea" startNew="1"/><field name="f{n}" w="2in" h="0.3in"><ui><textEdit/></ui></field></subform>"#
)
};
let xdp = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet>
<pageArea name="Body"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea>
<pageArea name="Once"><occur min="1" max="1"/><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/>
<field name="PrintButton" x="1in" y="1in" w="1in" h="0.3in"><ui><button/></ui></field>
</pageArea>
</pageSet>
{}{}{}{}
</subform>
</template>
</xdp:xdp>
"##,
section(1),
section(2),
section(3),
section(4)
);
let form = layout_xml(&xdp).unwrap();
let soms: Vec<Vec<String>> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect();
let has_button = |page: &Vec<String>| page.iter().any(|s| s.contains("PrintButton"));
// Four body pages, then the once-only page: its button is on that page alone, not over the body.
assert_eq!(form.pages.len(), 5, "{soms:?}");
assert!(soms[..4].iter().all(|p| !has_button(p)), "the single-use page area is not drawn over body pages: {soms:?}");
assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}");
}
/// A page area's `occur min` is a page it must have even when the body never reaches it, and a
/// body that already used it does not get another. An area with no `<occur>` needs none.
#[test]
fn page_area_minimum_adds_pages_after_the_body_only_when_unused() {
let area = |name: &str, occur: &str| {
format!(
r#"<pageArea name="{name}">{occur}<contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea>"#
)
};
let pages = |areas: String| {
let xdp = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb"><pageSet>{areas}</pageSet><field name="f" w="2in" h="0.3in"><ui><textEdit/></ui></field></subform>
</template></xdp:xdp>"##
);
layout_xml(&xdp).unwrap().pages.len()
};
// The body is one page; the required second master adds one, a third with min 2 adds two.
assert_eq!(pages(area("A", "") + &area("B", r#"<occur min="1" max="1"/>"#)), 2);
assert_eq!(pages(area("A", "") + &area("B", r#"<occur min="1" max="1"/>"#) + &area("C", r#"<occur min="2" max="3"/>"#)), 4);
// An area the body already used (it is first, so page 1 is its own) adds nothing.
assert_eq!(pages(area("A", r#"<occur min="1" max="1"/>"#)), 1);
assert_eq!(pages(area("A", r#"<occur min="1" max="1"/>"#) + &area("B", "")), 1);
// No `<occur>` and `min="0"` need no page.
assert_eq!(pages(area("A", "") + &area("B", r#"<occur min="0" max="1"/>"#)), 1);
}
/// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have
/// no segment at all. What the template doesn't have is kept as it is.
#[test]
fn data_paths_leave_areas_out() {
let (tpl, _) = parse(&crate::fixtures::leave_template("")).unwrap();
let path = |som: &str| data_path(&tpl, som).into_iter().map(|(n, i)| format!("{n}[{i}]")).collect::<Vec<_>>().join(".");
// The area `box` (inside an unnamed subform) is dropped; indexes are kept.
assert_eq!(path("form[0].page[0].box[0].table[0].row[2].days[0]"), "form[0].page[0].table[0].row[2].days[0]");
assert_eq!(path("form[0].page[0].box[0].rest[0]"), "form[0].page[0].rest[0]");
// No area on the way: as som_to_path.
assert_eq!(path("form[0].page[0]"), "form[0].page[0]");
// Unknown segments, past a leaf, or a SOM from another template: kept.
assert_eq!(path("form[0].page[0].box[0].nothing[0].x[1]"), "form[0].page[0].nothing[0].x[1]");
assert_eq!(path("form[0].page[0].box[0].rest[0].box[0]"), "form[0].page[0].rest[0].box[0]");
assert_eq!(path("other[0].box[0].rest[0]"), "other[0].box[0].rest[0]");
assert_eq!(data_path(&tpl, ""), Vec::new());
}
/// A template nested thousands of elements deep must be refused. roxmltree recurses with the
/// nesting depth, so it used to overflow the stack - and a stack overflow aborts the process,
/// which no `catch_unwind` can intercept.
#[test]
fn deeply_nested_template_is_refused_instead_of_overflowing_the_stack() {
let mut xml = String::from(r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">"#);
for i in 0..10_000 {
xml.push_str(&format!(r#"<subform name="s{i}">"#));
}
xml.push_str(&"</subform>".repeat(10_000));
xml.push_str("</template>");
assert!(matches!(crate::parse::parse(&xml), Err(crate::XfaError::Malformed(_))), "ten thousand nested subforms must be refused, not parsed");
}
/// The depth scanner skips what is not an element and cannot under-count one.
#[test]
fn nesting_depth_scanner_counts_what_matters() {
use crate::xml_nesting_depth as d;
assert_eq!(d(""), 0);
assert_eq!(d("<a><b><c/></b></a>"), 3);
assert_eq!(d(r#"<?xml version="1.0"?><!-- <x> --><a/>"#), 1);
assert_eq!(d("<a><![CDATA[<not-an-element>]]></a>"), 1);
assert_eq!(d(r#"<a b="<"><c/></a>"#), 2);
assert_eq!(d("<a/>"), 1);
assert_eq!(d("<a><b/></a>"), 2);
assert_eq!(d(&"<empty/>".repeat(2_000)), 1, "elements that close themselves must not accumulate");
}
/// A template holding thousands of self-closing elements is two levels deep, not thousands. Before
/// the scanner knew that, a form with a lot of styling elements was refused as too deeply nested.
#[test]
fn many_self_closing_elements_do_not_accumulate_into_the_bound() {
let mut xml = String::from(r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform layout="tb">"#);
for i in 0..2_000 {
xml.push_str(&format!(r#"<draw name="d{i}"/>"#));
}
xml.push_str("</subform></template>");
assert!(crate::parse::parse(&xml).is_ok(), "two thousand self-closing draws are two levels deep, not two thousand");
}
/// The bound must hold on a worker thread too, whose default stack (2 MiB) is a quarter of the
/// main thread's: a template exactly at the limit parses there without overflowing.
#[test]
fn template_at_the_depth_limit_parses_on_a_worker_thread() {
let depth = crate::MAX_XML_DEPTH - 1;
let mut xml = String::from(r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">"#);
for i in 0..depth {
xml.push_str(&format!(r#"<subform name="s{i}">"#));
}
xml.push_str(&"</subform>".repeat(depth));
xml.push_str("</template>");
let parsed = std::thread::spawn(move || crate::parse::parse(&xml).is_ok()).join();
assert!(matches!(parsed, Ok(true)), "a template at the limit parses on a default-stack thread");
}
/// A form of one page area (7.5in × 10in of content from 0.5in) around `body`.
fn one_area_form(root_attrs: &str, body: &str) -> String {
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" {root_attrs}>
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
{body}
</subform>
</template>
</xdp:xdp>
"##
)
}
/// Every field is inside the content area of its page, and the fields come in template order.
fn assert_paginated(form: &Form, fields: usize, min_pages: usize) {
assert!(form.pages.len() >= min_pages, "{} pages", form.pages.len());
let mut names = Vec::new();
for (i, p) in form.pages.iter().enumerate() {
for w in widgets(p) {
assert!(
w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 36.0 + 720.0 + 0.01,
"page {}: {} is outside the content area: {:?}",
i + 1,
w.name,
w.rect
);
names.push(w.som.clone());
}
}
assert_eq!(names.len(), fields, "{names:?}");
let expected: Vec<String> = (0..fields).map(|i| format!("f{i}[0]")).collect();
for (n, e) in names.iter().zip(&expected) {
assert!(n.ends_with(e), "{n} where {e} belongs: {names:?}");
}
}
#[test]
fn a_positioned_subform_taller_than_a_page_continues_on_the_next_pages() {
// 30 fields 0.9in apart: 27in of positioned content, where a content area holds 10in.
let fields: String =
(0..30).map(|i| format!(r#"<field name="f{i}" x="0.5in" y="{}in" w="4in" h="0.4in"><ui><textEdit/></ui></field>"#, i as f64 * 0.9)).collect();
// As the only child of a flowed root …
let form = layout_xml(&one_area_form(
r#"layout="tb""#,
&format!(r#"<subform name="body"><border><fill><color value="200,200,200"/></fill></border>{fields}</subform>"#),
))
.unwrap();
assert_paginated(&form, 30, 3);
// … its fill is cut at the breaks, a piece on every page, none past the content area.
for (i, p) in form.pages.iter().enumerate() {
let fills: Vec<&Rect> = p.items.iter().filter_map(|it| if let Item::Fill { rect, .. } = it { Some(rect) } else { None }).collect();
assert!(!fills.is_empty(), "page {}: no fill", i + 1);
for r in fills {
assert!(r.bottom() <= 36.0 + 720.0 + 0.01, "page {}: fill {r:?}", i + 1);
}
}
// … after other content on the page …
let form = layout_xml(&one_area_form(
r#"layout="tb""#,
&format!(r#"<draw name="head" w="4in" h="3in"><value><text>Head</text></value></draw><subform name="body">{fields}</subform>"#),
))
.unwrap();
assert_paginated(&form, 30, 3);
assert!(!widgets(&form.pages[0]).is_empty(), "the block starts on the page the heading is on");
// … and as the root itself (positioned is the default layout).
let form = layout_xml(&one_area_form("", &fields)).unwrap();
assert_paginated(&form, 30, 3);
}
#[test]
fn wrapped_and_fixed_height_blocks_taller_than_a_page_are_split_too() {
let fields: String = (0..60).map(|i| format!(r#"<field name="f{i}" w="3.5in" h="0.8in"><ui><textEdit/></ui></field>"#)).collect();
// Left-to-right wrapping: 30 lines of 0.8in.
let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"<subform name="body" layout="lr-tb" w="7.5in">{fields}</subform>"#))).unwrap();
assert_paginated(&form, 60, 3);
// Top-to-bottom with a fixed height the content outgrows.
let form =
layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"<subform name="body" layout="tb" w="7.5in" h="9in">{fields}</subform>"#))).unwrap();
assert_paginated(&form, 60, 5);
}
#[test]
fn a_block_that_fits_a_page_is_still_moved_whole() {
let block = |n: usize| {
format!(r#"<subform name="b{n}" w="7in" h="6in"><field name="f{n}" x="0in" y="5in" w="4in" h="0.4in"><ui><textEdit/></ui></field></subform>"#)
};
let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!("{}{}{}", block(0), block(1), block(2)))).unwrap();
assert_eq!(form.pages.len(), 3);
for p in &form.pages {
let w = widgets(p);
assert_eq!(w.len(), 1);
assert!((w[0].rect.y - (36.0 + 360.0)).abs() < 0.01, "{:?}", w[0].rect);
}
}
#[test]
fn a_field_taller_than_a_page_is_cut_through_without_looping() {
let body = r#"<field name="f0" w="4in" h="25in"><ui><textEdit/></ui></field><field name="f1" w="4in" h="0.4in"><ui><textEdit/></ui></field>"#;
let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
assert_eq!(all.len(), 2);
assert!(form.pages.len() <= 4, "{} pages", form.pages.len());
}
/// A form built the way Designer builds a flowed one: everything in one left-to-right wrapped
/// body, sections of a heading over repeating rows of fields, and a text field that grows.
fn wrapped_form(data: &str) -> String {
let body = r#"<subform name="body" layout="lr-tb" w="7.5in">
<subform name="section" layout="lr-tb" w="7.5in" minH="0.5in">
<draw name="heading" w="7.5in" h="0.2in"><value><text>Entries</text></value></draw>
<subform name="row" layout="lr-tb" w="7.5in" minH="0.5in"><occur max="-1"/>
<field name="what" w="3.5in" h="1in"><ui><textEdit multiLine="1"/></ui></field>
<field name="when" w="0.5in" h="1in"><ui><textEdit/></ui></field>
<field name="why" w="3.5in" h="1in"><ui><textEdit multiLine="1"/></ui></field>
</subform>
</subform>
<field name="notes" w="7.5in" minH="1.5in"><ui><textEdit multiLine="1"/></ui></field>
<field name="after" w="7.5in" h="0.3in"><ui><textEdit/></ui></field>
</subform>"#;
let form = one_area_form(r#"layout="tb""#, body);
let packet =
format!("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets>\n</xdp:xdp>");
form.replace("</xdp:xdp>", &packet)
}
#[test]
fn repeating_rows_in_a_wrapped_container_get_the_instances_the_data_holds() {
let rows: String = (0..4).map(|i| format!("<row><what>what {i}</what><when>{i}</when><why>why {i}</why></row>")).collect();
let form = layout_xml(&wrapped_form(&format!("<form><body><section>{rows}</section><notes>n</notes></body></form>"))).unwrap();
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let whats: Vec<(&str, Option<&str>, f64)> =
all.iter().filter(|w| w.som.ends_with(".what[0]")).map(|w| (w.som.as_str(), w.value.as_deref(), w.rect.y)).collect();
assert_eq!(whats.len(), 4, "{whats:?}");
for (i, (som, value, _)) in whats.iter().enumerate() {
assert_eq!(*som, format!("form[0].body[0].section[0].row[{i}].what[0]"));
assert_eq!(*value, Some(format!("what {i}").as_str()));
}
// One under another, the section growing with them: what follows comes after the last.
assert!(whats.windows(2).all(|p| (p[1].2 - p[0].2 - 72.0).abs() < 0.01), "{whats:?}");
let notes = all.iter().find(|w| w.name == "notes").unwrap();
assert!(notes.rect.y >= whats[3].2 + 72.0 - 0.01, "{:?} after {whats:?}", notes.rect);
// Without data a row is there once.
let form = layout_xml(&wrapped_form("<form/>")).unwrap();
assert_eq!(form.pages.iter().flat_map(widgets).filter(|w| w.name.starts_with("what")).count(), 1);
}
#[test]
fn a_multi_line_field_without_a_height_grows_to_hold_its_text() {
let place = |data: &str| {
let form = layout_xml(&wrapped_form(data)).unwrap();
let on = |name: &str| {
form.pages.iter().enumerate().find_map(|(i, p)| widgets(p).into_iter().find(|w| w.name == name).map(|w| (i, w.rect))).unwrap()
};
(on("notes"), on("after"))
};
// Little text: the minimum height.
let ((_, notes), (_, after)) = place("<form><body><notes>Short.</notes></body></form>");
assert!((notes.h - 108.0).abs() < 0.01, "{notes:?}");
assert!((after.y - notes.bottom()).abs() < 0.01);
// Forty lines: the field holds them, and what follows moves down.
let long: String = (0..40).map(|i| format!("Line {i} of the notes.\n")).collect();
let ((notes_page, grown), (after_page, after)) = place(&format!("<form><body><notes>{long}</notes></body></form>"));
assert!(grown.h > 40.0 * 11.0 && grown.h < 720.0, "{grown:?}");
assert_eq!(notes_page, after_page);
assert!((after.y - grown.bottom()).abs() < 0.01, "{after:?} after {grown:?}");
// A fixed height stays what it is.
let fixed = wrapped_form(&format!("<form><body><notes>{long}</notes></body></form>"))
.replace(r#"minH="1.5in"><ui><textEdit multiLine"#, r#"h="1.5in"><ui><textEdit multiLine"#);
let form = layout_xml(&fixed).unwrap();
let notes = form.pages.iter().flat_map(widgets).find(|w| w.name == "notes").unwrap();
assert!((notes.rect.h - 108.0).abs() < 0.01, "{:?}", notes.rect);
}
#[test]
fn a_page_break_keeps_a_fields_border_with_the_field() {
// 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to
// the next page whole, leaving no piece of its border behind.
let body = r#"<subform name="body" layout="lr-tb" w="7.5in">
<draw name="head" w="7.5in" h="6in"><value><text>Head</text></value></draw>
<field name="f0" w="7.5in" h="5in"><border><edge/><fill><color value="230,230,230"/></fill></border><ui><textEdit multiLine="1"/></ui></field>
<field name="f1" w="7.5in" h="0.4in"><ui><textEdit/></ui></field>
</subform>"#;
let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
assert_paginated(&form, 2, 2);
let below_head = |it: &&Item| match it {
Item::Fill { rect, .. } => rect.bottom() > 36.0 + 432.0 + 1.0,
Item::Line { from, to, .. } => from.1.max(to.1) > 36.0 + 432.0 + 1.0,
_ => false,
};
assert_eq!(form.pages[0].items.iter().filter(below_head).count(), 0, "{:?}", form.pages[0].items);
assert!(
form.pages[1].items.iter().any(|it| matches!(it, Item::Fill { rect, .. } if (rect.y - 36.0).abs() < 0.01 && (rect.h - 360.0).abs() < 0.01))
);
}
#[test]
fn text_too_long_for_its_fixed_height_is_drawn_smaller_instead_of_over_what_follows() {
let words = "certification statement wording ".repeat(12);
let body = format!(
r#"<draw name="note" w="7.5in" h="0.3in"><font typeface="Arial" size="8pt"/><value><text>{words}</text></value></draw>
<draw name="short" w="7.5in" h="0.3in"><font typeface="Arial" size="8pt"/><value><text>Short</text></value></draw>"#
);
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
let spans: Vec<&crate::text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect();
let (short, note): (Vec<_>, Vec<_>) = spans.into_iter().partition(|s| s.text == "Short");
// Three lines at 8pt need 27.6pt; the box is 21.6pt high.
assert!(note.iter().all(|s| s.face.size < 8.0 && s.face.size >= 5.6), "{:?}", note.iter().map(|s| s.face.size).collect::<Vec<_>>());
assert!(note.iter().all(|s| s.baseline <= 36.0 + 21.6 + 0.5), "{:?}", note.iter().map(|s| s.baseline).collect::<Vec<_>>());
assert!(note.iter().all(|s| s.x + s.face.width(&s.text) <= 36.0 + 540.0 + 0.5));
// Text that fits keeps its size.
assert!((short[0].face.size - 8.0).abs() < 1e-9);
}
#[test]
fn a_text_field_that_outgrows_a_page_continues_on_the_next_as_text() {
let long: String = (0..150).map(|i| format!("Line {i} of the notes.\n")).collect();
let form = layout_xml(&wrapped_form(&format!("<form><body><notes>{long}</notes><after>end</after></body></form>"))).unwrap();
assert!(form.pages.len() >= 3, "{} pages", form.pages.len());
assert!(form.warnings.iter().any(|w| w.starts_with("notes: its text is longer than a page")), "{:?}", form.warnings);
// Every line is there, in order, inside the content area of its page; no widget holds it.
let mut lines = Vec::new();
for (i, p) in form.pages.iter().enumerate() {
for it in &p.items {
if let Item::Text(s) = it
&& s.text.starts_with("Line ")
{
assert!(s.baseline > 36.0 && s.baseline <= 36.0 + 720.0 + 0.01, "page {}: {:?} at {}", i + 1, s.text, s.baseline);
lines.push(s.text.clone());
}
}
assert!(widgets(p).iter().all(|w| w.name != "notes"));
}
assert_eq!(lines, (0..150).map(|i| format!("Line {i} of the notes.")).collect::<Vec<_>>());
// What follows comes after the last line, on the last page.
let last = form.pages.last().unwrap();
let after = widgets(last).into_iter().find(|w| w.name == "after").unwrap();
let last_line = last.items.iter().filter_map(|it| if let Item::Text(s) = it { Some(s.baseline) } else { None }).fold(0.0, f64::max);
assert!(after.rect.y >= last_line - 0.01 && after.rect.bottom() <= 36.0 + 720.0 + 0.01, "{:?} after {last_line}", after.rect);
}
#[test]
fn the_form_packet_restores_the_presence_and_access_the_form_was_saved_with() {
// Three rows hidden in the template; a script showed the first two before the form was
// saved, and locked a field.
let row = |n: usize, presence: &str| {
format!(
r#"<subform name="row{n}" layout="lr-tb" w="7.5in"{presence}><field name="who" w="3in" h="0.4in"><ui><textEdit/></ui></field></subform>"#
)
};
let body = format!(
r#"<subform name="body" layout="lr-tb" w="7.5in">{}{}{}{}<subform name="rep" layout="lr-tb" w="7.5in"><occur max="-1"/><field name="x" w="3in" h="0.4in"><ui><textEdit/></ui></field></subform></subform>"#,
row(0, ""),
row(1, r#" presence="hidden""#),
row(2, r#" presence="hidden""#),
row(3, r#" presence="hidden""#)
);
let template = one_area_form(r#"layout="tb""#, &body);
let visible = |xdp: &str| -> Vec<String> { layout_xml(xdp).unwrap().pages.iter().flat_map(widgets).map(|w| w.som.clone()).collect() };
assert_eq!(visible(&template), ["form[0].body[0].row0[0].who[0]", "form[0].body[0].rep[0].x[0]"]);
let saved = r#"<form xmlns="http://www.xfa.org/schema/xfa-form/2.8/" checksum="x"><subform name="form">
<instanceManager name="_body"/>
<subform name="body">
<subform name="row0"><field name="who" access="readOnly"/></subform>
<subform name="row1" presence="visible"><field name="who" access="readOnly"><value override="1"/></field></subform>
<subform name="row2" presence="visible"/>
<subform name="row3" presence="hidden"/>
<subform name="rep"><field name="x"/></subform>
<subform name="rep" presence="hidden"><field name="x" access="readOnly"/></subform>
<subform name="nowhere" presence="hidden"/>
</subform>
<pageSet><pageArea name="page"/></pageSet>
</subform></form>
</xdp:xdp>"#;
let xdp = template.replace("</xdp:xdp>", saved);
assert_eq!(
visible(&xdp),
["form[0].body[0].row0[0].who[0]", "form[0].body[0].row1[0].who[0]", "form[0].body[0].row2[0].who[0]", "form[0].body[0].rep[0].x[0]"],
"a later instance of a repeating subform says nothing about the template's one"
);
let form = layout_xml(&xdp).unwrap();
let locked: Vec<bool> = form.pages.iter().flat_map(widgets).map(|w| w.read_only).collect();
assert_eq!(locked, [true, true, false, false]);
}
#[test]
fn a_flowed_subform_is_as_tall_as_its_content_whatever_its_minimum_height() {
// A bar with a stale 3in `minH` holds one 0.2in line; the row after it follows directly.
let body = |bar: &str| {
format!(
r#"<subform name="body" layout="lr-tb" w="7.5in"><subform name="bar" {bar} w="7.5in" minH="3in"><draw w="7.5in" h="0.2in"><value><text>Attachments</text></value></draw></subform><field name="who" w="3in" h="0.4in"><ui><textEdit/></ui></field></subform>"#
)
};
let top = |bar: &str| {
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body(bar))).unwrap();
let w = widgets(&form.pages[0]);
w[0].rect.y
};
let flowed = top(r#"layout="lr-tb""#);
assert!((top(r#"layout="tb""#) - flowed).abs() < 0.01);
// A positioned subform keeps its minimum: nothing else says how tall it is.
assert!((top("") - flowed - (216.0 - 14.4)).abs() < 0.01, "{} against {flowed}", top(""));
}
#[test]
fn the_form_packet_restores_a_caption_a_script_changed() {
let body = r#"<subform name="body" layout="tb" w="7.5in"><field name="bar" w="2in" h="0.3in"><ui><button/></ui><caption><value><text>Show Bar</text></value></caption></field></subform>"#;
let template = one_area_form(r#"layout="tb""#, body);
let caption = |xdp: &str| match &widgets(&layout_xml(xdp).unwrap().pages[0])[0].kind {
WidgetKind::Button { caption } => caption.clone(),
other => panic!("{other:?}"),
};
assert_eq!(caption(&template), "Show Bar");
let saved = r#"<form xmlns="http://www.xfa.org/schema/xfa-form/2.8/"><subform name="form"><subform name="body">
<field name="bar"><caption><value><text>Hide Bar</text></value></caption></field>
</subform></subform></form>
</xdp:xdp>"#;
assert_eq!(caption(&template.replace("</xdp:xdp>", saved)), "Hide Bar");
}
#[test]
fn a_text_field_without_a_border_of_its_own_has_no_box() {
let body = r#"<subform name="body" layout="tb" w="7.5in"><field name="plain" w="3in" h="0.4in"><ui><textEdit/></ui></field><field name="boxed" w="3in" h="0.4in"><ui><textEdit><border><edge/></border></textEdit></ui></field><field name="tick" w="3in" h="0.4in"><ui><checkButton/></ui></field></subform>"#;
let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
let shapes: Vec<BorderShape> = widgets(&form.pages[0]).iter().map(|w| w.border.shape).collect();
assert_eq!(shapes, [BorderShape::None, BorderShape::Full, BorderShape::Full]);
}
#[test]
fn a_signed_signature_box_shows_the_signature_the_file_holds() {
let mut doc = Document::open(Arc::new(crate::fixtures::signed_shell())).unwrap();
render_into(&mut doc).unwrap();
let catalog = doc.get(doc.root().unwrap());
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
let laid: Vec<_> = fields.iter().map(|f| doc.resolve(f)).filter(|f| f.as_dict().unwrap().contains(SOM_KEY)).collect();
let (signed, unsigned) = (laid[0].as_dict().unwrap(), laid[1].as_dict().unwrap());
// The signature dictionary and the picture are the file's own objects, not copies.
let value = doc.resolve(signed.get(b"V").unwrap());
assert_eq!(value.as_dict().unwrap().name(b"Type"), Some(&b"Sig"[..]));
assert!(signed.get(b"V").unwrap().as_ref().is_some());
let shown = doc.resolve(signed.get(b"AP").unwrap());
let picture = doc.resolve(shown.as_dict().unwrap().get(b"N").unwrap());
assert!(matches!(&*picture, Object::Stream(s) if s.dict.name(b"Subtype") == Some(&b"Form"[..])));
assert_eq!(signed.int(b"Ff").unwrap_or(0) & 1, 1, "a signed box is not signed again");
assert!(!unsigned.contains(b"V") && !unsigned.contains(b"AP"));
}
#[test]
fn a_carriage_return_and_line_feed_are_one_line_break() {
let lines = |t: &str| crate::text::plain(t).paragraphs.len();
assert_eq!(lines("one\r\ntwo\r\nthree"), 3);
assert_eq!(lines("one\ntwo\rthree"), 3);
assert_eq!(lines("one\r\n\r\ntwo"), 3, "an empty line stays");
// As the data holds it: character references, which the XML parser leaves alone.
let form = layout_xml(&wrapped_form("<form><body><notes>one&#xD;&#xA;two&#xD;&#xA;three</notes></body></form>")).unwrap();
let crlf = form.pages.iter().flat_map(widgets).find(|w| w.name == "notes").unwrap().rect.h;
let form = layout_xml(&wrapped_form("<form><body><notes>one&#xA;two&#xA;three</notes></body></form>")).unwrap();
let lf = form.pages.iter().flat_map(widgets).find(|w| w.name == "notes").unwrap().rect.h;
assert!((crlf - lf).abs() < 0.01);
}
#[test]
fn data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them() {
use crate::data::{export_data, import_data};
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap();
render_into(&mut doc).unwrap();
// XML: the data alone, rooted at the form's root data node, as Acrobat's XML export is.
let xml = export_data(&doc, false, "fields.pdf").unwrap();
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<form"), "{xml}");
assert!(xml.contains("<country>JP</country>") && xml.contains("<langs>English\nSpanish</langs>"), "{xml}");
assert!(xml.trim_end().ends_with("</form>"), "{xml}");
assert_eq!(crate::parse_data_file(&xml, Some("form")).unwrap().len(), 1, "the export is importable");
// XDP: the datasets inside an xdp envelope with a reference to the PDF.
let xdp = export_data(&doc, true, "my \"form\".pdf").unwrap();
assert!(xdp.contains("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\">") && xdp.contains("</xdp:xdp>"), "{xdp}");
assert!(xdp.contains("<pdf href=\"my &quot;form&quot;.pdf\" xmlns=\"http://ns.adobe.com/xdp/pdf/\"/>"), "{xdp}");
assert_eq!(parse_datasets(&xdp).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP"));
// A form without a datasets packet exports empty data.
let bare = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
let xml = export_data(&bare, false, "x.pdf").unwrap();
assert!(xml.contains("<xfa:data></xfa:data>") || xml.contains("<xfa:data/>"), "{xml}");
// A datasets packet alone (no template) still exports its data; no XFA at all is an error.
let static_doc = Document::open(Arc::new(static_shell("<form1><page1><name>Ada</name></page1></form1>"))).unwrap();
assert!(export_data(&static_doc, false, "s.pdf").unwrap().contains("<name>Ada</name>"));
assert!(export_data(&static_doc, true, "s.pdf").unwrap().contains("<xfa:datasets"));
// An XFA entry without a template or datasets exports empty data rather than failing.
let plain = Document::open(Arc::new(shell("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>"))).unwrap();
assert!(export_data(&plain, false, "p.pdf").unwrap().contains("<xfa:data"));
// Import: each shape of file replaces the data; what no field binds to in the datasets
// (a data description) stays.
let with_desc = fields_template(data).replace(
"<xfa:data>",
"<dd:dataDescription xmlns:dd=\"http://ns.adobe.com/data-description/\" dd:name=\"form\"><form/></dd:dataDescription><xfa:data>",
);
let shapes = [
format!(
"<?xml version=\"1.0\"?>\n<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><country>CA</country></page1></form></xfa:data></xfa:datasets></xdp:xdp>"
),
format!("<xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><country>CA</country></page1></form></xfa:data></xfa:datasets>"),
format!("\u{feff}<xfa:data xmlns:xfa=\"{XFA_NS}\"><form><page1><country>CA</country></page1></form></xfa:data>"),
"<form><page1><country>CA</country></page1></form>".to_string(),
];
for file in &shapes {
let mut doc = Document::open(Arc::new(shell(&with_desc))).unwrap();
render_into(&mut doc).unwrap();
let r = import_data(&mut doc, file, Some("form"), None).unwrap();
assert_eq!(r.nodes, 3, "{file}");
let d = crate::data_of(&doc).unwrap();
assert_eq!(d.text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "{file}");
assert_eq!(d.text_at(&som_to_path("form[0].page1[0].langs[0]")), None, "the old data is gone: {file}");
let xdp = export_data(&doc, true, "x.pdf").unwrap();
assert!(xdp.contains("dataDescription"), "kept: {xdp}");
assert!(!export_data(&doc, false, "x.pdf").unwrap().contains("dataDescription"), "the XML export is the data alone");
// Laid out again, the form shows the imported value.
let (t, _) = parse(&with_desc).unwrap();
rerender(&mut doc, &t).unwrap();
let fields = pdfcraft_forms_free_fields(&doc);
let c = &fields.iter().find(|(n, _)| n == "country").unwrap().1;
assert_eq!(c.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("CA"), "{file}");
}
// Not XFA data: refused, the document untouched.
let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap();
render_into(&mut doc).unwrap();
for bad in [
"<xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><fields/></xfdf>",
"<fields><field name=\"a\"><value>1</value></field></fields>",
"not xml at all",
"<a><b></a>",
] {
assert!(import_data(&mut doc, bad, Some("form"), None).is_err(), "{bad}");
}
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP"));
// Empty data: the data element is emptied, not removed.
let r = import_data(&mut doc, &format!("<xfa:data xmlns:xfa=\"{XFA_NS}\"/>"), Some("form"), None).unwrap();
assert_eq!(r.nodes, 0);
assert!(crate::data_of(&doc).is_some());
assert_eq!(crate::data_of(&doc).unwrap().children.len(), 0);
// Hostile: a file over the size cap, and one with more nodes than the parser allows.
assert!(import_data(&mut doc, &"x".repeat(17 << 20), None, None).is_err());
let many = format!("<form>{}</form>", "<a/>".repeat(1_100_000));
assert!(import_data(&mut doc, &many, None, None).is_err());
// A self-closing data element opens up for an import.
let empty = fields_template("")
.replace("</template>", "</template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data/></xfa:datasets>");
let mut doc = Document::open(Arc::new(shell(&empty))).unwrap();
render_into(&mut doc).unwrap();
import_data(&mut doc, "<form><page1><country>FR</country></page1></form>", Some("form"), None).unwrap();
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("FR"));
}
const XFA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
#[test]
fn a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type() {
use crate::data::write_data_image;
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
render_into(&mut doc).unwrap();
let path = som_to_path("form[0].page1[0].photo[0]");
// No data node yet: it is made.
let r = write_data_image(&mut doc, &path, "image/gif", TINY_GIF, None).unwrap();
assert!(r.written, "{:?}", r.warnings);
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("<photo xfa:contentType=\"image/gif\">R0lGODlh"), "{xdp}");
// Written again: replaced, not duplicated; a hostile content type is cleaned.
let r = write_data_image(&mut doc, &path, "image/png\"><evil/><x a=\"", &tiny_png(), None).unwrap();
assert!(r.written);
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert_eq!(xdp.matches("<photo ").count(), 1, "{xdp}");
assert!(xdp.contains("<photo xfa:contentType=\"image/pngevil/xa\">iVBORw0KGgo"), "{xdp}");
assert!(!xdp.contains("<evil/>"));
// The form shows it: laid out again, the page's picture is the PNG from the data.
let (t, _) = parse(&fields_template("")).unwrap();
rerender(&mut doc, &t).unwrap();
let d = crate::data_of(&doc).unwrap();
assert!(d.text_at(&path).unwrap().starts_with("iVBORw0KGgo"));
// Too big is refused before anything is written.
assert!(write_data_image(&mut doc, &path, "image/png", &vec![0u8; (8 << 20) + 1], None).is_err());
assert!(write_data_image(&mut doc, &Vec::new(), "image/png", TINY_GIF, None).is_err());
// The image field is a push button marked as one, with no action and nothing drawn.
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let fields = pdfcraft_forms_free_fields(&doc);
let photo = &fields.iter().find(|(n, _)| n == "photo").unwrap().1;
assert_eq!(photo.name(b"FT"), Some(b"Btn".as_slice()));
assert_eq!(photo.int(b"Ff"), Some(1 << 16));
assert_eq!(photo.get(b"PCXfaImage"), Some(&Object::Bool(true)));
assert!(!photo.contains(b"A") && !photo.contains(b"MK"));
}
#[test]
fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unreadable() {
use crate::data::{export_data, import_data, write_data_image};
// A file binding the data namespace to another prefix, with an attribute in it.
let file = format!(
"<x:datasets xmlns:x=\"{XFA_NS}\"><x:data><form x:dataNode=\"dataGroup\"><page1><country>CA</country></page1></form></x:data></x:datasets>"
);
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
render_into(&mut doc).unwrap();
import_data(&mut doc, &file, Some("form"), None).unwrap();
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("<form xfa:dataNode=\"dataGroup\">"), "{xdp}");
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"));
// An xsi:nil declared on an envelope the file's data element doesn't carry.
let file = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><country xsi:nil=\"true\"/><pin>1</pin></page1></form></xfa:data></xfa:datasets></xdp:xdp>";
import_data(&mut doc, file, Some("form"), None).unwrap();
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("xmlns:n0=\"http://www.w3.org/2001/XMLSchema-instance\" n0:nil=\"true\""), "{xdp}");
assert!(crate::data_of(&doc).is_some(), "the packet still parses");
// Our own export, from a packet using another prefix, imports back.
let other = fields_template("<form><page1><pin>7</pin></page1></form>")
.replace("xfa:datasets", "xd:datasets")
.replace("xfa:data>", "xd:data>")
.replace("xmlns:xfa=", "xmlns:xd=");
let mut doc2 = Document::open(Arc::new(shell(&other))).unwrap();
render_into(&mut doc2).unwrap();
let exported = export_data(&doc2, true, "o.pdf").unwrap();
import_data(&mut doc, &exported, Some("form"), None).unwrap();
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].pin[0]")), Some("7"));
// A picture into that other-prefix packet uses its prefix.
let path = som_to_path("form[0].page1[0].photo[0]");
write_data_image(&mut doc2, &path, "image/gif", TINY_GIF, None).unwrap();
let xdp = crate::read_packets(&doc2).unwrap().unwrap().xdp;
assert!(xdp.contains("<photo xd:contentType=\"image/gif\">"), "{xdp}");
assert!(crate::data_of(&doc2).is_some());
// A picture where the data node is a group is refused, the group kept.
let grouped = fields_template("<form><page1><photo><note>keep me</note></photo></page1></form>");
let mut doc3 = Document::open(Arc::new(shell(&grouped))).unwrap();
render_into(&mut doc3).unwrap();
let r = write_data_image(&mut doc3, &path, "image/gif", TINY_GIF, None).unwrap();
assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured")), "{r:?}");
assert!(crate::read_packets(&doc3).unwrap().unwrap().xdp.contains("<note>keep me</note>"));
// A field name XML can't hold is written under the same cleaned name the values use.
let odd = fields_template("").replace("name=\"photo\"", "name=\"Foto_Straße\"");
let mut doc4 = Document::open(Arc::new(shell(&odd))).unwrap();
render_into(&mut doc4).unwrap();
let r = write_data_image(&mut doc4, &som_to_path("form[0].page1[0].Foto_Straße[0]"), "image/gif", TINY_GIF, None).unwrap();
assert!(r.written, "{r:?}");
let xdp = crate::read_packets(&doc4).unwrap().unwrap().xdp;
assert_eq!(xdp.matches("<Foto_Stra_e").count(), 1, "{xdp}");
assert!(xdp.contains("<Foto_Stra_e xfa:contentType=\"image/gif\">R0lG"), "{xdp}");
// What is not this form's data is refused: an SVG, an XDP without datasets, an XDP whose
// config has a <data> element before the datasets, a data document with another root.
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
render_into(&mut doc).unwrap();
for bad in [
"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>".to_string(),
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template/></xdp:xdp>".to_string(),
"<other><page1><country>CA</country></page1></other>".to_string(),
format!("<x:datasets xmlns:x=\"{XFA_NS}\"><x:data><form><page1><country>CA</country></page1></form></x:data></x:datasets>")
.replace("x:data", "x:nodata"),
] {
assert!(import_data(&mut doc, &bad, Some("form"), None).is_err(), "{bad}");
}
let config_first = format!(
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><config xmlns=\"http://www.xfa.org/schema/xci/3.0/\"><present><data>junk</data></present></config><xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><country>CA</country></page1></form></xfa:data></xfa:datasets></xdp:xdp>"
);
import_data(&mut doc, &config_first, Some("form"), None).unwrap();
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"));
// Deep nesting is refused before it is parsed; a data path deeper than the data limit too.
let deep = format!("{}x{}", "<a>".repeat(20_000), "</a>".repeat(20_000));
assert!(import_data(&mut doc, &deep, None, None).is_err());
let deepish = format!("<form>{}x{}</form>", "<a>".repeat(100), "</a>".repeat(100));
assert!(import_data(&mut doc, &deepish, Some("form"), None).is_err());
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "untouched");
// The data holds more rows than are laid out: said, not hidden.
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
let many = scripted_template().replace(
"</template>",
&format!(
"</template><xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><table>{rows}</table></page1></form></xfa:data></xfa:datasets>"
),
);
let form = layout_xml(&many).unwrap();
assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings);
}
#[test]
fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() {
// The page label of IRCC IMM 5532E: x is the centre of the box, so its right edge sits
// 10.795mm right of x, inside the page, not 21.59mm.
let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="page"><contentArea x="0.5in" y="1.5in" w="7.5in" h="8in"/><medium short="8.5in" long="11in"/>
<draw anchorType="topCenter" name="PageOf" x="197.485mm" y="13.97mm" w="21.59mm" h="3.81mm"><value><text>PAGE 1 OF 8</text></value><font size="7pt" typeface="Arial"/><para hAlign="right"/></draw>
<draw anchorType="bottomRight" name="foot" x="8in" y="10.5in" w="1in" h="0.25in"><value><text>Footer</text></value><font size="8pt" typeface="Arial"/></draw>
<field anchorType="topRight" name="ref" x="8in" y="1in" w="2in" h="0.3in"><ui><textEdit/></ui></field>
</pageArea></pageSet>
<draw name="body" w="7.5in" h="0.3in"><value><text>Body</text></value></draw>
</subform>
</template>
</xdp:xdp>
"##;
let form = layout_xml(xml).unwrap();
let page = &form.pages[0];
let span = |t: &str| {
page.items
.iter()
.find_map(|i| {
if let Item::Text(s) = i
&& s.text == t
{
Some(s.clone())
} else {
None
}
})
.unwrap()
};
let label = span("PAGE 1 OF 8");
let right = (197.485 + 21.59 / 2.0) * 72.0 / 25.4;
assert!(
(label.x + label.face.width(&label.text) - right).abs() < 0.5,
"label ends at {}, box at {right}",
label.x + label.face.width(&label.text)
);
let foot = span("Footer");
assert!((foot.x - 7.0 * 72.0).abs() < 0.01, "footer at x {}", foot.x);
assert!(foot.baseline > 10.25 * 72.0 && foot.baseline < 10.5 * 72.0, "footer baseline {}", foot.baseline);
let field = widgets(page).into_iter().find(|w| w.name == "ref").unwrap();
assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect);
}
#[test]
fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() {
use crate::pdf::strip;
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
render_into(&mut doc).unwrap();
assert!(crate::read_packets(&doc).unwrap().is_some());
// Reader usage rights on the catalog go too (changing the form voids them).
let root = doc.root().unwrap();
let mut ur = pdfcraft_cos::Dict::new();
ur.set(b"UR3".to_vec(), Object::Dict(pdfcraft_cos::Dict::new()));
doc.update_dict(root, |d| d.set(b"Perms".to_vec(), Object::Dict(ur))).unwrap();
let r = strip(&mut doc).unwrap();
assert_eq!(r.packets, vec!["template".to_string(), "datasets".to_string()]);
assert_eq!(r.unbound_data, Some(0));
assert_eq!((r.script_buttons, r.image_fields), (0, 1), "{r:?}");
assert!(r.usage_rights);
assert!(r.field_keys >= 7, "{r:?}");
assert!(
r.notes().iter().any(|n| n.contains("template, datasets")) && r.notes().iter().any(|n| n.contains("extended features")),
"{:?}",
r.notes()
);
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(crate::read_packets(&doc).unwrap().is_none(), "no XFA entry");
assert!(!is_dynamic(&doc));
assert!(crate::existing_layout(&doc).is_none(), "the layout mark is gone");
let root = doc.get(doc.root().unwrap());
let catalog = root.as_dict().unwrap();
assert!(!catalog.contains(b"NeedsRendering") && !catalog.contains(b"Perms"));
let acro = doc.resolve(catalog.get(b"AcroForm").unwrap());
let acro = acro.as_dict().unwrap();
assert!(!acro.contains(b"XFA") && !acro.contains(b"PCXfaLayout") && !acro.contains(b"PCXfaOverrides"));
// The fields stay, with their values, and without the private keys.
let fields = pdfcraft_forms_free_fields(&doc);
assert_eq!(fields.len(), 7);
let country = &fields.iter().find(|(n, _)| n == "country").unwrap().1;
assert_eq!(country.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("JP"));
for (name, d) in &fields {
for k in [b"PCSom".as_slice(), b"PCXfaClick", b"PCXfaImage", b"PCItems"] {
assert!(!d.contains(k), "{name} still has {}", String::from_utf8_lossy(k));
}
}
// The pages are untouched.
let pages = doc.resolve(catalog.get(b"Pages").unwrap());
assert_eq!(pages.as_dict().unwrap().int(b"Count"), Some(1));
// Twice is nothing: a document without XFA is left as it is.
let mut doc = doc;
let r = strip(&mut doc).unwrap();
assert_eq!(r, crate::Stripped::default());
assert!(!doc.is_modified());
// Data no field holds is counted; a form whose click buttons ran scripts too; the shape
// check needs no packet decoded and tells a never-laid-out dynamic form from one to strip.
let mut doc = Document::open(Arc::new(shell(&scripted_template().replace("</template>", "</template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><qty>3</qty><extra>kept nowhere</extra><more2>x</more2></page1></form></xfa:data></xfa:datasets>")))).unwrap();
let before = crate::xfa_shape(&doc).unwrap();
assert_eq!(before, crate::XfaShape { needs_rendering: true, has_fields: false, laid_out: false });
assert!(!before.strippable(), "nothing laid out yet: stripping would leave the placeholder page");
render_into(&mut doc).unwrap();
assert!(crate::xfa_shape(&doc).unwrap().strippable());
let r = strip(&mut doc).unwrap();
assert_eq!(r.unbound_data, Some(2), "{r:?}");
assert_eq!(r.script_buttons, 4, "addRow, removeRow, hello, legacy: {r:?}");
assert!(crate::xfa_shape(&doc).is_none());
// A field tree whose dictionaries share one Kids array (every kid listing every other) is
// walked once per dictionary, not once per listing.
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
render_into(&mut doc).unwrap();
let root = doc.root().unwrap();
let acro_ref = doc.get(root).as_dict().unwrap().get(b"AcroForm").and_then(Object::as_ref);
let fields: Vec<Object> = (0..2000).map(|_| Object::Ref(doc.add(Object::Dict(pdfcraft_cos::Dict::new())))).collect();
let kids = doc.add(Object::Array(fields.clone()));
for f in &fields {
if let Some(r) = f.as_ref() {
doc.update_dict(r, |d| {
d.set(b"Kids".to_vec(), Object::Ref(kids));
d.set(b"PCSom".to_vec(), Object::String(pdfcraft_cos::PdfString::text("x")));
})
.unwrap();
}
}
let update = |d: &mut pdfcraft_cos::Dict| d.set(b"Fields".to_vec(), Object::Array(fields.clone()));
match acro_ref {
Some(r) => doc.update_dict(r, update).unwrap(),
None => doc
.update_dict(root, |c| {
let mut a = c.get(b"AcroForm").and_then(|a| a.as_dict().cloned()).unwrap_or_default();
update(&mut a);
c.set(b"AcroForm".to_vec(), Object::Dict(a));
})
.unwrap(),
}
let started = std::time::Instant::now();
let r = strip(&mut doc).unwrap();
assert_eq!(r.field_keys, 2000);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
}
#[test]
fn som_indices_count_earlier_same_named_siblings() {
let draw = |name: Option<&str>| {
Node::Draw(Box::new(Draw { common: Common { name: name.map(str::to_string), ..Default::default() }, ..Default::default() }))
};
let children = [draw(Some("a")), draw(Some("b")), draw(Some("a")), draw(None), draw(Some("a")), draw(Some("b"))];
let sibs = SomIndices::of(&children);
assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::<Vec<_>>(), [0, 0, 1, 0, 2, 1, 0]);
}
/// A TIFF is drawn as viewers show it, turned by its Orientation tag: a 2 × 1 picture (red,
/// blue) tagged 6 (turn 90° clockwise) is 1 × 2, red above blue, in layout and in the PDF.
#[test]
fn a_tiff_picture_is_turned_by_its_orientation_tag() {
let strip: &[u8] = &[255, 0, 0, 0, 0, 255];
let (bps_at, strip_at) = (8 + 2 + 10 * 12 + 4, 8 + 2 + 10 * 12 + 4 + 6);
let mut b = b"II*\0".to_vec();
b.extend_from_slice(&8u32.to_le_bytes());
b.extend_from_slice(&10u16.to_le_bytes());
for (tag, ty, count, value) in [
(256u16, 3u16, 1u32, 2u32),
(257, 3, 1, 1),
(258, 3, 3, bps_at as u32),
(259, 3, 1, 1),
(262, 3, 1, 2),
(273, 4, 1, strip_at as u32),
(274, 3, 1, 6),
(277, 3, 1, 3),
(278, 3, 1, 1),
(279, 4, 1, strip.len() as u32),
] {
b.extend_from_slice(&tag.to_le_bytes());
b.extend_from_slice(&ty.to_le_bytes());
b.extend_from_slice(&count.to_le_bytes());
b.extend_from_slice(&value.to_le_bytes());
}
b.extend_from_slice(&0u32.to_le_bytes());
for _ in 0..3 {
b.extend_from_slice(&8u16.to_le_bytes());
}
b.extend_from_slice(strip);
let d = crate::image::decode(&b).unwrap();
assert_eq!(crate::image::size(&b), Some((1, 2)), "layout size");
assert_eq!((d.width, d.height), (1, 2), "decoded size");
assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255]);
}
/// A draw `w` points wide holding `text` in Arial 10 pt, aligned `h_align`.
fn one_draw(text: &str, w: f64, h_align: &str) -> String {
format!(
r##"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<draw name="label" w="{w}pt" h="0.5in"><value><text>{text}</text></value><font typeface="Arial" size="10pt"/><para hAlign="{h_align}"/></draw>
</subform>
</template></xdp:xdp>"##
)
}
/// The text spans of the only page, with their baselines.
fn draw_spans(xdp: &str) -> Vec<text::Span> {
layout_xml(xdp).unwrap().pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.clone()) } else { None }).collect()
}
/// A label that fits its box but is written with trailing spaces stays on one line and measures
/// as tall as without them; text wider than the box still wraps.
#[test]
fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() {
// The box is just wide enough for "a. DATE"; the label is written "a. DATE " (as forms
// often are). The trailing space is never drawn, so the label stays on one line.
let w = pdfcraft_fonts::helvetica_width("a. DATE", 10.0) + 0.01;
let spans = draw_spans(&one_draw("a. DATE ", w, "left"));
let baselines: std::collections::BTreeSet<i64> = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
assert_eq!(baselines.len(), 1, "{spans:?}");
// Several spaces after it don't count either.
let spans = draw_spans(&one_draw("a. DATE ", w, "left"));
let baselines: std::collections::BTreeSet<i64> = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
assert_eq!(baselines.len(), 1, "{spans:?}");
// Nor do they add a blank line: the text measures as tall as without them.
let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() });
let height = |t: &str| text::measure_height(&text::plain(t), w, &Para::default(), &face, &|_, _| String::new());
assert_eq!(height("a. DATE "), height("a. DATE"));
// Text that is wider than the box still wraps.
let spans = draw_spans(&one_draw("a. DATES ", w, "left"));
let baselines: std::collections::BTreeSet<i64> = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
assert_eq!(baselines.len(), 2, "{spans:?}");
}
/// Centered and right-aligned text is placed by its drawn width, whatever spaces follow it.
#[test]
fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() {
let w = 200.0;
let ink = pdfcraft_fonts::helvetica_width("TITLE", 10.0);
let left_edge = |align: &str, text: &str| draw_spans(&one_draw(text, w, align)).first().map(|s| s.x).unwrap();
// The draw's left edge is the content area's (0.5in); centered and right-aligned text is
// placed by its drawn width, whatever spaces follow it.
assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6);
assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6);
}
/// A word longer than its line is broken by character in work linear in its length.
#[test]
fn a_long_word_breaks_in_linear_work() {
let face = text::Face::of(&Font { typeface: "Arial".into(), size: 1.0, ..Font::default() });
let word = "i".repeat(20_000);
text::MEASURED.with(|m| m.set(0));
let b = text::layout(&text::plain(&word), Rect::new(0.0, 0.0, 1000.0, 0.0), &Para::default(), &face, &|_, _| String::new());
let measured = text::MEASURED.with(|m| m.get());
assert!(b.spans.len() > 2, "{} lines", b.spans.len());
assert_eq!(b.spans.iter().map(|s| s.text.len()).sum::<usize>(), 20_000);
assert!(measured < 200_000, "{measured} bytes measured for a 20000-byte word");
}
/// Rich text that embeds one long field value many times lays out a bounded amount of it.
#[test]
fn embedded_field_values_expand_to_a_bounded_amount_of_text() {
let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() });
let value = "word ".repeat(3_300);
let run = Run { embed: Some("f".into()), ..Run::default() };
let rich = RichText { paragraphs: vec![Paragraph { runs: vec![run; 100], h_align: None }] };
let b = text::layout(&rich, Rect::new(0.0, 0.0, 500.0, 0.0), &Para::default(), &face, &|_, _| value.clone());
let kept: usize = b.spans.iter().map(|s| s.text.len()).sum();
assert!(kept > 0);
assert!(kept <= text::MAX_EMBEDDED, "{kept} bytes of embedded text laid out");
assert!(text::natural_width(&rich, &Para::default(), &face, &|_, _| value.clone()) <= face.width(&value) * 70.0);
}
/// Once the embedded-text budget is nearly spent, a long value embedded again is copied out only
/// as far as the budget reaches, not whole each time.
#[test]
fn embedded_values_are_copied_out_only_as_far_as_the_budget() {
let first: String = "xxxxxxx ".repeat(text::MAX_EMBEDDED / 8 + 1).chars().take(text::MAX_EMBEDDED - 1).collect();
let second = "\u{e9}".repeat(100_000);
let span = |id: &str| format!(r##"<span xfa:embed="#{id}" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/>"##);
let spans = format!("{}{}", span("a"), span("b").repeat(200));
let tpl = format!(
r#"<template><subform layout="tb">
<field id="a" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><value><text>{first}</text></value></field>
<field id="b" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><value><text>{second}</text></value></field>
<draw w="7in" h="9in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>{spans}</p></body></exData></value></draw>
</subform></template>"#
);
crate::layout::EMBED_COPIED.with(|c| c.set(0));
layout_xml(&tpl).unwrap();
let copied = crate::layout::EMBED_COPIED.with(|c| c.get());
// The draw is measured at most seven times before it is laid out once.
assert!(copied <= 8 * text::MAX_EMBEDDED as u64 + 8 * 4 * 201, "{copied} bytes of embedded values copied");
}
/// Repeated subforms each carry their own copy of a long script: the live form holds a bounded
/// amount of script text and says it left the rest out.
#[test]
fn repeated_script_events_hold_a_bounded_amount_of_script() {
let script = format!("var a = 1; //{}", "x".repeat(32 * 1024));
let xml = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<subform name="outer" layout="tb"><occur min="1" max="-1" initial="50"/>
<subform name="inner" layout="tb"><occur min="1" max="-1" initial="50"/>
<field name="f" w="1in" h="0.3in"><ui><textEdit/></ui>
<event activity="initialize"><script contentType="application/x-javascript">{script}</script></event></field>
</subform>
</subform>
</subform>
</template></xdp:xdp>"##
);
let (tpl, _) = parse(&xml).unwrap();
let live = form_tree(&tpl, None, &Overrides::default());
assert!(!live.events.is_empty());
let held: usize = live.events.iter().map(|e| e.script.len()).sum();
assert!(held <= crate::script::MAX_EVENT_SCRIPT_BYTES, "{held} bytes of script held by {} events", live.events.len());
assert!(live.truncated, "{held} bytes, {} events", live.events.len());
}
/// Rows added one at a time (each op asking for one more instance) queue each instance node
/// once, and removals in between don't let the queue grow without bound.
#[test]
fn added_instances_are_queued_once_each() {
use crate::data::{DataOp, ENSURED, write_data_ops};
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
render_into(&mut doc).unwrap();
let table = som_to_path("form[0].page1[0].table[0]");
let ops: Vec<DataOp> = (1..=1000).map(|count| DataOp::Instances { parent: table.clone(), name: "row".into(), count }).collect();
ENSURED.with(|n| n.set(0));
let r = write_data_ops(&mut doc, &ops, None).unwrap();
assert!(r.written, "{r:?}");
assert_eq!(crate::data_of(&doc).unwrap().count(&table, "row"), 1000);
let queued = ENSURED.with(|n| n.get());
assert!(queued <= 2_000, "{queued} instance nodes queued for 1000 rows");
// Removals split the ops into separate passes; together they stay within the budget.
let ops: Vec<DataOp> = (0..300)
.flat_map(|_| {
[
DataOp::Instances { parent: table.clone(), name: "row".into(), count: 1000 },
DataOp::Remove(som_to_path("form[0].page1[0].table[0].row[999]")),
]
})
.collect();
ENSURED.with(|n| n.set(0));
let r = write_data_ops(&mut doc, &ops, None).unwrap();
let queued = ENSURED.with(|n| n.get());
assert!(queued <= 200_000, "{queued} instance nodes queued");
assert!(r.warnings.iter().any(|w| w.contains("instances")), "{r:?}");
}
/// A namespace declared once and inherited by many imported elements is not copied into each
/// past the import's byte budget.
#[test]
fn an_inherited_namespace_does_not_multiply_an_import() {
use crate::data::parse_data_file;
let uri = format!("urn:{}", "n".repeat(70_000));
let xml = format!("<form1 xmlns:p=\"{uri}\">{}</form1>", "<p:a/>".repeat(1_000));
fn ns_bytes(nodes: &[crate::data::ImportNode]) -> usize {
nodes.iter().map(|n| n.ns.as_ref().map_or(0, String::len) + ns_bytes(&n.children)).sum()
}
match parse_data_file(&xml, None) {
Ok(nodes) => assert!(ns_bytes(&nodes) <= crate::data::MAX_IMPORT_BYTES, "{} bytes of namespaces copied", ns_bytes(&nodes)),
Err(e) => assert!(matches!(e, XfaError::TooLarge(_)), "{e:?}"),
}
}
/// A long data name with a high index is not copied into every sibling created before it.
#[test]
fn long_names_repeated_by_an_index_are_capped() {
let doc = Document::open(Arc::new(static_shell("<form1/>"))).unwrap();
let fields = vec![FieldDatum {
obj: pdfcraft_cos::ObjRef::new(1000, 0),
name: format!("form1[0].{}[9999]", "n".repeat(2_000)),
data: FieldData::Text("v".into()),
}];
let data = build_data(&doc, &fields);
assert!(data.len() < 2_000_000, "{} bytes of data built", data.len());
}