mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
4148 lines
240 KiB
Rust
4148 lines
240 KiB
Rust
use std::sync::Arc;
|
||
|
||
use pdfcraft_cos::{Dict, 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"));
|
||
// Each one its own object: Acrobat shows nothing in a text field it redraws after typing
|
||
// when the /DR font is written inline (#3441).
|
||
for (name, font) in fonts.as_dict().unwrap().iter() {
|
||
let r = font.as_ref().unwrap_or_else(|| panic!("{} is an object", String::from_utf8_lossy(name)));
|
||
assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"Type")), Some(&b"Font"[..]));
|
||
}
|
||
// 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")));
|
||
}
|
||
|
||
/// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for
|
||
/// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it
|
||
/// takes no more text than it shows; the other direction's policy, or the default, doesn't.
|
||
#[test]
|
||
fn fields_that_dont_scroll_are_written_with_do_not_scroll() {
|
||
let field = |name: &str, ui: &str| format!(r#"<field name="{name}" w="2in" h="0.4in"><ui>{ui}</ui></field>"#);
|
||
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">
|
||
{}{}{}{}{}{}{}
|
||
</subform></template></xdp:xdp>"##,
|
||
field("line", r#"<textEdit hScrollPolicy="off"/>"#),
|
||
field("lines", r#"<textEdit multiLine="1" vScrollPolicy="off"/>"#),
|
||
field("number", r#"<numericEdit hScrollPolicy="off"/>"#),
|
||
field("when", r#"<dateTimeEdit hScrollPolicy="off"/>"#),
|
||
field("linesAcross", r#"<textEdit multiLine="1" hScrollPolicy="off"/>"#),
|
||
field("lineDown", r#"<textEdit vScrollPolicy="off"/>"#),
|
||
field("free", r#"<textEdit/>"#),
|
||
);
|
||
let mut doc = Document::open(Arc::new(shell(&xdp))).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 mut no_scroll = std::collections::BTreeMap::new();
|
||
for r in fields.iter().filter_map(Object::as_ref) {
|
||
let f = doc.get(r);
|
||
let d = f.as_dict().unwrap();
|
||
let name = d.get(b"T").and_then(Object::as_string).map(|t| t.to_text()).unwrap_or_default();
|
||
no_scroll.insert(name, d.int(b"Ff").unwrap_or(0) & (1 << 23) != 0);
|
||
}
|
||
let expected: std::collections::BTreeMap<String, bool> =
|
||
[("line", true), ("lines", true), ("number", true), ("when", true), ("linesAcross", false), ("lineDown", false), ("free", false)]
|
||
.into_iter()
|
||
.map(|(n, v)| (n.to_string(), v))
|
||
.collect();
|
||
assert_eq!(no_scroll, expected);
|
||
}
|
||
|
||
/// A check box or radio button laid out from a round check button (`shape="round"`) carries
|
||
/// `ROUND_KEY`, so the forms layer draws its background and border round; a square one (the
|
||
/// default) doesn't.
|
||
#[test]
|
||
fn round_check_buttons_are_marked_for_a_round_appearance() {
|
||
let check = |name: &str, shape: &str, items: &str| {
|
||
format!(r#"<field name="{name}" w="1in" h="0.3in"><ui><checkButton{shape}/></ui><items>{items}</items></field>"#)
|
||
};
|
||
let on_off = "<integer>1</integer><integer>0</integer>";
|
||
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">
|
||
{}{}<exclGroup name="pick" layout="tb">{}{}</exclGroup>
|
||
</subform></template></xdp:xdp>"##,
|
||
check("roundBox", r#" shape="round""#, on_off),
|
||
check("squareBox", "", on_off),
|
||
check("roundRadio", r#" shape="round""#, "<text>a</text>"),
|
||
check("squareRadio", r#" shape="square""#, "<text>b</text>"),
|
||
);
|
||
let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
|
||
render_into(&mut doc).unwrap();
|
||
// Every widget among the form's fields and their kids (a radio group's buttons), by the
|
||
// last step of its SOM path.
|
||
let catalog = doc.get(doc.root().unwrap());
|
||
let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||
let mut todo: Vec<_> =
|
||
doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect();
|
||
let mut round = std::collections::BTreeMap::new();
|
||
while let Some(r) = todo.pop() {
|
||
let node = doc.get(r);
|
||
let d = node.as_dict().unwrap();
|
||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)) {
|
||
todo.extend(kids.as_array().unwrap().iter().filter_map(Object::as_ref));
|
||
continue;
|
||
}
|
||
let som = d.get(SOM_KEY).and_then(Object::as_string).map(|s| s.to_text()).unwrap_or_default();
|
||
let name = som.rsplit('.').next().unwrap_or_default().split('[').next().unwrap_or_default().to_string();
|
||
round.insert(name, matches!(d.get(crate::pdf::ROUND_KEY), Some(Object::Bool(true))));
|
||
}
|
||
let expected: std::collections::BTreeMap<String, bool> = [("roundBox", true), ("squareBox", false), ("roundRadio", true), ("squareRadio", false)]
|
||
.into_iter()
|
||
.map(|(n, r)| (n.to_string(), r))
|
||
.collect();
|
||
assert_eq!(round, expected);
|
||
}
|
||
|
||
/// A template whose one draw holds `body` as rich text, in a box `w` wide.
|
||
fn rich_draw_form(w: &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" layout="tb">
|
||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||
<draw name="note" w="{w}"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>{body}</p></body></exData></value></draw>
|
||
</subform></template></xdp:xdp>"##
|
||
)
|
||
}
|
||
|
||
/// The link annotations on the pages of `doc`: each one's URI and rectangle.
|
||
fn text_links(doc: &Document) -> Vec<(String, [f64; 4])> {
|
||
let catalog = doc.get(doc.root().unwrap());
|
||
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
|
||
let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone();
|
||
let mut out = Vec::new();
|
||
for page in &kids {
|
||
let page = doc.resolve(page);
|
||
let Some(annots) = page.as_dict().unwrap().get(b"Annots").map(|a| doc.resolve(a).as_array().unwrap().clone()) else { continue };
|
||
for a in &annots {
|
||
let a = doc.resolve(a);
|
||
let d = a.as_dict().unwrap();
|
||
if d.name(b"Subtype") != Some(&b"Link"[..]) {
|
||
continue;
|
||
}
|
||
let action = doc.resolve(d.get(b"A").unwrap());
|
||
let uri =
|
||
action.as_dict().unwrap().get(b"URI").and_then(Object::as_string).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap();
|
||
let r: Vec<f64> = doc.resolve(d.get(b"Rect").unwrap()).as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
out.push((uri, [r[0], r[1], r[2], r[3]]));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// An `<a href>` in rich text keeps where it links to on every run of its text, styled or not;
|
||
/// the text around it and a named anchor without an address link nowhere.
|
||
#[test]
|
||
fn rich_text_links_keep_their_address() {
|
||
let (tpl, _) = crate::parse(&rich_draw_form(
|
||
"4in",
|
||
r#"Write to <a href=" mailto:forms@example.com ">forms@<b>example</b>.com</a> or see <a name="top">the top</a>."#,
|
||
))
|
||
.unwrap();
|
||
let Node::Draw(d) = &tpl.root.children[0] else { panic!("the draw") };
|
||
let Value::Rich(rich) = &d.value else { panic!("rich text") };
|
||
let runs: Vec<(&str, Option<&str>)> = rich.paragraphs.iter().flat_map(|p| &p.runs).map(|r| (r.text.as_str(), r.link.as_deref())).collect();
|
||
assert_eq!(
|
||
runs,
|
||
[
|
||
("Write to ", None),
|
||
("forms@", Some("mailto:forms@example.com")),
|
||
("example", Some("mailto:forms@example.com")),
|
||
(".com", Some("mailto:forms@example.com")),
|
||
(" or see ", None),
|
||
("the top", None),
|
||
(".", None),
|
||
]
|
||
);
|
||
}
|
||
|
||
/// A link's address is held once, however many runs, words and lines its text takes: every
|
||
/// piece shares it rather than copying it, so a long address over many words stays one string.
|
||
#[test]
|
||
fn a_links_pieces_share_one_address() {
|
||
let words = "one two three four five six seven eight nine ten ".repeat(8);
|
||
let href = format!("https://example.com/{}", "x".repeat(1500));
|
||
let xdp = rich_draw_form("2in", &format!(r#"<a href="{href}">{words}<b>bold</b> {words}</a>"#));
|
||
let form = layout_xml(&xdp).unwrap();
|
||
let links: Vec<std::sync::Arc<str>> =
|
||
form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Text(s) = i { s.link.clone() } else { None }).collect();
|
||
assert!(links.len() > 10, "the link's text takes many lines: {}", links.len());
|
||
assert!(links.iter().all(|l| std::sync::Arc::ptr_eq(l, &links[0]) && **l == *href), "every piece shares one address");
|
||
}
|
||
|
||
/// A link's text becomes a link annotation on the page, one over each line it takes when it
|
||
/// wraps, each over its own text; a second layout replaces them rather than adding more.
|
||
#[test]
|
||
fn links_in_rich_text_become_link_annotations_one_per_line() {
|
||
let body = r#"See <a href="https://example.com/forms/help">the help pages for this form, which explain every block</a> first."#;
|
||
let mut doc = Document::open(Arc::new(shell(&rich_draw_form("2in", body)))).unwrap();
|
||
let report = render_into(&mut doc).unwrap();
|
||
assert!(report.warnings.iter().all(|w| !w.contains("link")), "{:?}", report.warnings);
|
||
let links = text_links(&doc);
|
||
assert!(links.len() >= 2, "the link wraps onto at least two lines: {links:?}");
|
||
assert!(links.iter().all(|(uri, _)| uri == "https://example.com/forms/help"));
|
||
// One per line: each below the one before, all inside the 2-inch box (from x = 36pt).
|
||
for pair in links.windows(2) {
|
||
assert!(pair[1].1[3] <= pair[0].1[1] + 0.01, "a line below the one before: {pair:?}");
|
||
}
|
||
for (_, r) in &links {
|
||
assert!(r[0] >= 36.0 - 0.01 && r[2] <= 36.0 + 144.0 + 0.01 && r[2] > r[0] && r[3] > r[1], "{r:?}");
|
||
}
|
||
// The first line's link starts after "See ", not at the box's edge.
|
||
assert!(links[0].1[0] > 36.0 + 5.0, "{:?}", links[0].1);
|
||
// A second layout replaces the pages, and their links with them.
|
||
let tpl = crate::script::template_of(&doc).unwrap();
|
||
rerender(&mut doc, &tpl).unwrap();
|
||
assert_eq!(text_links(&doc), links, "laid out again: the same links, not twice as many");
|
||
// Saved and opened again, they are still there.
|
||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||
assert_eq!(text_links(&Document::open(Arc::new(bytes)).unwrap()), links);
|
||
|
||
// A link whose text changes style partway is one link on its line, over all its text.
|
||
let mut doc =
|
||
Document::open(Arc::new(shell(&rich_draw_form("4in", r#"Write to <a href="mailto:forms@example.com">forms@<b>example</b>.com</a> today."#))))
|
||
.unwrap();
|
||
render_into(&mut doc).unwrap();
|
||
let links = text_links(&doc);
|
||
assert_eq!(links.len(), 1, "{links:?}");
|
||
let arial = Font { typeface: "Arial".into(), size: 10.0, ..Default::default() };
|
||
let plain = crate::text::Face::of(&arial);
|
||
let bold = crate::text::Face::of(&Font { bold: true, ..arial.clone() });
|
||
let width = plain.width("forms@") + bold.width("example") + plain.width(".com");
|
||
let r = links[0].1;
|
||
assert!((r[2] - r[0] - width).abs() < 0.01, "over the whole address: {} against {width}", r[2] - r[0]);
|
||
}
|
||
|
||
/// Two pieces of one link side by side on a line join into one link, but a rule drawn between
|
||
/// them keeps them apart, so the link never covers what lies between its pieces; an underline
|
||
/// (a level line drawn with its text) doesn't.
|
||
#[test]
|
||
fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() {
|
||
let piece = |name: &str, x: &str, underline: bool| {
|
||
let u = if underline { ";text-decoration:underline" } else { "" };
|
||
format!(
|
||
r#"<draw name="{name}" x="{x}" y="0in" w="0.4in" h="0.3in"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p style="margin:0{u}"><a href="https://example.com/form">Go</a></p></body></exData></value></draw>"#
|
||
)
|
||
};
|
||
let rule = r#"<draw name="rule" x="0.21in" y="0in" w="0in" h="0.3in"><value><line><edge/></line></value></draw>"#;
|
||
let level_rule = r#"<draw name="dash" x="0.2in" y="0.1in" w="0.04in" h="0in"><value><line><edge/></line></value></draw>"#;
|
||
let form = |between: &str, underline: bool| {
|
||
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="position">
|
||
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||
{}{between}{}
|
||
</subform></template></xdp:xdp>"##,
|
||
piece("first", "0in", underline),
|
||
piece("second", "0.25in", underline)
|
||
)
|
||
};
|
||
let links = |xdp: &str| {
|
||
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
|
||
render_into(&mut doc).unwrap();
|
||
text_links(&doc).len()
|
||
};
|
||
assert_eq!(links(&form("", false)), 1, "side by side, the pieces join");
|
||
assert_eq!(links(&form("", true)), 1, "an underline doesn't part them");
|
||
assert_eq!(links(&form(rule, false)), 2, "a rule between them does");
|
||
assert_eq!(links(&form(level_rule, false)), 2, "and so does a level one, which isn't an underline");
|
||
}
|
||
|
||
/// Only web and email addresses become links. Scripts, files, data, relative paths, web
|
||
/// addresses without a host, and empty or overlong addresses stay plain text (still drawn), with
|
||
/// one warning for the form.
|
||
#[test]
|
||
fn links_to_anything_but_web_and_email_addresses_stay_plain_text() {
|
||
let long = format!("https://example.com/{}", "a".repeat(2100));
|
||
let body = format!(
|
||
r#"<a href="javascript:app.alert(1)">one</a> <a href="file:///etc/passwd">two</a> <a href="data:text/html,x">three</a> <a href="help.html">four</a> <a href="HTTPS:">five</a> <a href="{long}">six</a> <a href="MailTo:a@example.com">seven</a> <a href="https:///path">eight</a> <a href="https://user@:8080/x">nine</a> <a href="https://\example.com/path">nine and a half</a> <a href="https://user@example.com:8443/x">ten</a>"#
|
||
);
|
||
let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap();
|
||
let report = render_into(&mut doc).unwrap();
|
||
assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::<Vec<_>>(), ["MailTo:a@example.com", "https://user@example.com:8443/x"]);
|
||
let warned: Vec<&String> = report.warnings.iter().filter(|w| w.contains("link")).collect();
|
||
assert_eq!(warned.len(), 1, "one warning for the form: {:?}", report.warnings);
|
||
assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}");
|
||
}
|
||
|
||
#[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 & <Co>
\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()
|
||
}
|
||
|
||
/// Choice lists, a password, a signature and pictures in the template become their kinds of
|
||
/// widget, and their AcroForm fields and image XObjects once written.
|
||
#[test]
|
||
fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
|
||
assert_choice_widget_layout();
|
||
assert_choice_fields_and_picture_streams();
|
||
}
|
||
|
||
fn assert_choice_widget_layout() {
|
||
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);
|
||
}
|
||
|
||
fn assert_choice_fields_and_picture_streams() {
|
||
// 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");
|
||
// The signature field is named by its SOM path, under a generated field for each step above it.
|
||
assert!(fields.iter().all(|(name, _)| name != "sign"));
|
||
let named = full_names(&doc);
|
||
let sign = named.iter().find(|(name, _)| name == "form[0].page1[0].sign[0]").map(|(_, d)| d).expect("named by its SOM path");
|
||
assert_eq!(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));
|
||
}
|
||
|
||
/// #4937: LiveCycle wrote BMPs whose header gives the pixel data's offset as 0. Read literally,
|
||
/// the pixels start at the file header, so the image is drawn shifted and wrapped around.
|
||
#[test]
|
||
fn a_bmp_whose_pixel_offset_points_into_its_headers_is_read_from_after_them() {
|
||
use crate::image::{bmp_with_pixel_offset, decode, size};
|
||
for palette in [false, true] {
|
||
let intact = tiny_bmp(palette);
|
||
assert!(matches!(bmp_with_pixel_offset(&intact), std::borrow::Cow::Borrowed(_)), "an intact file is not copied");
|
||
for bad in [0u32, 14, 53] {
|
||
let mut bmp = intact.clone();
|
||
bmp[10..14].copy_from_slice(&bad.to_le_bytes());
|
||
let d = decode(&bmp).unwrap();
|
||
assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "offset {bad}, palette: {palette}");
|
||
assert_eq!(size(&bmp), Some((2, 1)));
|
||
assert_eq!(bmp_with_pixel_offset(&bmp).as_ref(), &intact[..], "offset {bad}, palette: {palette}");
|
||
}
|
||
}
|
||
// Too short to hold the pixels after the headers: left alone, not grown.
|
||
let mut cut = tiny_bmp(true);
|
||
cut[10..14].copy_from_slice(&0u32.to_le_bytes());
|
||
cut.truncate(60);
|
||
assert_eq!(bmp_with_pixel_offset(&cut).as_ref(), &cut[..]);
|
||
// A truncated header, and bytes that are not a BMP: left alone.
|
||
assert_eq!(bmp_with_pixel_offset(b"BM |