mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-22 19:08:29 +00:00
XFA: choice lists, signature, password, image and barcode fields; PNG and GIF pictures (#414)
* XFA: choice lists, signature, password, image and barcode fields; PNG and GIF pictures The xfa crate lays out the field kinds that were left blank or shown as text fields: - Choice lists become combo boxes (editable with textEntry) or list boxes (open="always"; multi-select with open="multiSelect"), with the shown items and the saved values of <items save="1"> as /Opt pairs, /V, /I and /DV as saved values, several on separate lines in the data. Field::saved_value maps shown text to saved values for the layout and the script tree alike, so scripts read what is saved. - Password fields are password text fields; signature fields are /Sig fields the app can sign. - Image fields show the picture the data (base64) or the template holds; barcode fields show their value as text, with a warning that no bars are drawn. - PNG and GIF pictures (fields and draws) are decoded to Flate image XObjects with a soft mask for transparency; JPEGs stay as they were. Sizes come from the headers, so layout never decodes; decoding is capped at 40 million pixels and failures are warnings, not errors. Engine: multi-select list values from scripts and data are one per line. README and parity notes updated (form.xfa-dynamic). Hardened after an adversarial review pass: picture sizes refused from the header past 20 million pixels or 65,535 a side, the GIF decoder given limits (a frame far larger than its screen is refused before allocation), template pictures read whole up to 32 MB, pictures decoded once and remembered in the catalog by content hash so re-layouts reuse them, data pictures that aren't images reported by field; choice values matched against saved values first, several values deduplicated and capped, /I ascending and unique, /DV through the same mapping, <value> children read, the item cap reported; the reopen path takes another viewer's choice values (several per line); CMYK JPEGs inverted only with Adobe APP14. * D5: README for cos and engine, and an honest cos status line Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets. Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes. Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true. Co-Authored-By: Claude Opus 5 <[email protected]> * Preserve alternate appearances and indirect push-button values Keep rollover, down and custom appearance entries when normal appearances are regenerated, without modifying shared dictionaries. Resolve indirect button caption and layout values. Cover full and incremental saved output, and wait for raster readiness in the existing Linux scroll test. Co-authored-by: Codex <[email protected]> * M7: select and edit grouped Form XObject figures Co-Authored-By: Codex <[email protected]> * M14: let neutral Windows locales fall through to display language Co-authored-by: Codex <[email protected]> * pdfcraft-cli --help lists `mcp` only when the build has it Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed `pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command". USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead of calling it an unknown command. - tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and mcp_without_the_feature_says_it_was_left_out (without the feature) Co-Authored-By: Claude Opus 5.5 <[email protected]> * engine README: the execution plan is local-only, so don't link a path that doesn't exist * Merge land/450 into land/414; XFA pictures are found again by a SHA-256 on their XObject The image cache was a /PCXfaImages map in the catalog, keyed by DefaultHasher (not stable across Rust releases) and read back from the file. Each image XObject now records the SHA-256 of its picture's bytes, and a re-layout finds the XObjects on the pages it replaces by that hash; nothing goes in the catalog. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Tanish Manku <[email protected]> Co-authored-by: Claude Opus 5 <[email protected]> Co-authored-by: sanyamsingh001 <[email protected]> Co-authored-by: Codex <[email protected]> Co-authored-by: Flowershangfromthebranches <[email protected]> Co-authored-by: Codex <[email protected]> Co-authored-by: Streetblock <[email protected]> Co-authored-by: lloyd <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
19 files changed
+1243
-76
No files matched your search
Generated
+3
@@ -3845,9 +3845,12 @@ name = "pdfcraft-xfa"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"roxmltree",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
|
||||
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
|
||||
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
@@ -1979,6 +1979,17 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<Stri
|
||||
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
|
||||
let Some(f) = fields.iter().find(|f| f.name == name) else { continue };
|
||||
let new = match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => {
|
||||
// Saved values, one per line for a multi-select list; shown text stands for
|
||||
// its saved value (an older viewer may have written it).
|
||||
let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone());
|
||||
let picked: Vec<String> = if f.has(pdfcraft_forms::flags::MULTI_SELECT) {
|
||||
t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect()
|
||||
} else {
|
||||
vec![saved(t.trim())]
|
||||
};
|
||||
(f.value != picked).then_some(FieldValue::Choice(picked))
|
||||
}
|
||||
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
|
||||
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
|
||||
|
||||
@@ -1964,6 +1964,68 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
|
||||
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
|
||||
let data = "<form><page1><country>Japan</country><langs>English\nSpanish</langs></page1></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
|
||||
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}"));
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
// The forms layer sees a combo box with its options and the data's saved value, a
|
||||
// multi-select list with both picks, an editable combo, a password field and a signature.
|
||||
let country = field(&s, "country");
|
||||
assert_eq!((country.kind, country.value.clone()), (K::Combo, vec!["JP".to_string()]));
|
||||
assert_eq!(country.options, vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())]);
|
||||
let langs = field(&s, "langs");
|
||||
assert_eq!(langs.kind, K::List);
|
||||
assert!(langs.has(pdfcraft_forms::flags::MULTI_SELECT));
|
||||
assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]);
|
||||
assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT));
|
||||
assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD));
|
||||
assert_eq!(field(&s, "sign").kind, K::Signature);
|
||||
// Scripts read a choice list's saved value.
|
||||
assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id));
|
||||
// Choosing another item recalculates; the list takes several values; a typed value is kept
|
||||
// in the editable list and refused in the fixed one.
|
||||
s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap();
|
||||
assert_eq!(field(&s, "summary").value, vec!["Country: CA".to_string()]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "langs".into(), value: FieldValue::Choice(vec!["French".into(), "Spanish".into()]) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed".into()) }).unwrap();
|
||||
assert!(s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["Mars".into()]) }).is_err());
|
||||
// Saved: the datasets hold the saved values, several on separate lines; reopened, the
|
||||
// fields and the calculation are what they were.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
let d = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]")), Some("CA"));
|
||||
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]")), Some("French\nSpanish"));
|
||||
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].other[0]")), Some("typed"));
|
||||
let id2 = s.open("again.pdf", None, saved.clone(), None).unwrap();
|
||||
let d2 = s.get(id2).unwrap();
|
||||
let f2 = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap().value.clone();
|
||||
assert_eq!(f2("country"), vec!["CA".to_string()]);
|
||||
assert_eq!(f2("langs"), vec!["French".to_string(), "Spanish".to_string()]);
|
||||
assert_eq!(f2("summary"), vec!["Country: CA".to_string()]);
|
||||
// The PNG of the image field and the GIF of the draw are pictures on the page.
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
// Filled in by another viewer since: saved values, several on separate lines, and shown
|
||||
// text (an older viewer's), are taken on reopen; shown text that means the current value
|
||||
// is not a change.
|
||||
let mut cos = pdfcraft_cos::Document::open(saved).unwrap();
|
||||
pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]"), "English\nSpanish").unwrap();
|
||||
pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]"), "Canada").unwrap();
|
||||
let edited = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
|
||||
let id3 = s.open("elsewhere.pdf", None, edited, None).unwrap();
|
||||
let d3 = s.get(id3).unwrap();
|
||||
let f3 = |n: &str| d3.form.iter().find(|f| f.name == n).unwrap().value.clone();
|
||||
assert_eq!(f3("langs"), vec!["English".to_string(), "Spanish".to_string()]);
|
||||
assert_eq!(f3("country"), vec!["CA".to_string()]);
|
||||
let taken: Vec<&String> = d3.info.warnings.iter().filter(|w| w.contains("another viewer")).collect();
|
||||
assert_eq!(taken.len(), 1, "{:?}", d3.info.warnings);
|
||||
assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
|
||||
@@ -208,6 +208,8 @@ struct FieldInfo {
|
||||
kind: pdfcraft_forms::FieldKind,
|
||||
value: Vec<String>,
|
||||
on_state: String,
|
||||
/// List boxes that take several values (one per line in the data).
|
||||
multi: bool,
|
||||
}
|
||||
|
||||
fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap<String, FieldInfo> {
|
||||
@@ -215,7 +217,8 @@ fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap<String, FieldInfo> {
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let on_state = f.widgets.first().and_then(|w| w.on_state.clone()).unwrap_or_else(|| "1".into());
|
||||
(f.name, FieldInfo { kind: f.kind, value: f.value, on_state })
|
||||
let multi = f.kind == pdfcraft_forms::FieldKind::List && f.has(pdfcraft_forms::flags::MULTI_SELECT);
|
||||
(f.name, FieldInfo { kind: f.kind, value: f.value, on_state, multi })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -380,11 +383,13 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
// Read-only fields still take values from scripts (calculated totals are read-only).
|
||||
K::PushButton | K::Signature => return false,
|
||||
K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(),
|
||||
_ => vec![value.to_string()],
|
||||
};
|
||||
let same = match info.kind {
|
||||
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
|
||||
K::Radio => info.value.first() == new_value.first(),
|
||||
K::List if info.multi => info.value == new_value,
|
||||
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
|
||||
};
|
||||
if same {
|
||||
|
||||
@@ -1474,3 +1474,209 @@ fn check_box_redraw_keeps_down_states_it_still_draws() {
|
||||
assert!(d.contains(on.as_bytes()) && d.contains(b"Off"));
|
||||
assert!(!d.contains(b"Gone"), "a state the box no longer draws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filling_and_authoring_keep_shared_appearances_and_drop_stale_alternates() {
|
||||
let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")];
|
||||
let cases = [
|
||||
(NewField::Text { multiline: false }, true),
|
||||
(NewField::Text { multiline: false }, false),
|
||||
(NewField::CheckBox, false),
|
||||
(NewField::Button { caption: "Button".into() }, false),
|
||||
(NewField::Signature, false),
|
||||
];
|
||||
for (kind, fill) in cases {
|
||||
for indirect in [false, true] {
|
||||
let mut doc = one_page();
|
||||
let first = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 630.0], &kind, Some("First")).unwrap();
|
||||
let other = add_field(&mut doc, 0, [50.0, 550.0, 250.0, 580.0], &kind, Some("Other")).unwrap();
|
||||
let first_widget = field(&fields(&doc), &first).widgets[0].obj;
|
||||
let other_widget = field(&fields(&doc), &other).widgets[0].obj;
|
||||
let mut entries = doc.get(first_widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
|
||||
for (key, bytes) in alternates {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 10.into()]));
|
||||
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec())));
|
||||
entries.set(key.to_vec(), Object::Ref(r));
|
||||
}
|
||||
entries.set(b"VendorState".to_vec(), Object::name("Retained"));
|
||||
let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) };
|
||||
for widget in [first_widget, other_widget] {
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap();
|
||||
}
|
||||
let original = reopen(&doc);
|
||||
for full in [false, true] {
|
||||
let mut doc = original.clone();
|
||||
let other_widget = field(&fields(&doc), &other).widgets[0].obj;
|
||||
let source = doc.get(other_widget).as_dict().unwrap().get(b"AP").unwrap().clone();
|
||||
let before = doc.resolve(&source);
|
||||
if fill {
|
||||
set_value(&mut doc, &first, &FieldValue::Text("Updated value".into())).unwrap();
|
||||
} else {
|
||||
set_props(&mut doc, &first, &FieldProps { tooltip: Some("Updated help".into()), ..FieldProps::default() }).unwrap();
|
||||
}
|
||||
assert_eq!(doc.resolve(&source), before, "a shared AP must not change");
|
||||
let bytes = if full {
|
||||
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
} else {
|
||||
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
|
||||
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
|
||||
let original = doc.bytes();
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
|
||||
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
|
||||
bytes
|
||||
};
|
||||
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let all = fields(&doc);
|
||||
let first = field(&all, &first);
|
||||
let other = field(&all, &other);
|
||||
let first_ap = doc.resolve(doc.get(first.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap());
|
||||
let other_ap = doc.resolve(doc.get(other.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap());
|
||||
let first_ap = first_ap.as_dict().unwrap();
|
||||
let other_ap = other_ap.as_dict().unwrap();
|
||||
assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"), "normal appearance was regenerated");
|
||||
assert_eq!(other_ap.len(), 4);
|
||||
// The redrawn widget keeps unknown entries, but not down/rollover looks that
|
||||
// would show its old value or caption on press or hover.
|
||||
assert_eq!(
|
||||
first_ap.len(),
|
||||
2,
|
||||
"{kind:?} fill={fill} indirect={indirect} full={full}: {:?}",
|
||||
first_ap.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::<Vec<_>>()
|
||||
);
|
||||
for (key, bytes) in alternates {
|
||||
assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key));
|
||||
let object = doc.resolve(other_ap.get(key).unwrap());
|
||||
let Object::Stream(stream) = &*object else { panic!("alternate appearance") };
|
||||
assert_eq!(stream.decoded().unwrap(), bytes);
|
||||
}
|
||||
assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..]));
|
||||
assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..]));
|
||||
if fill {
|
||||
assert_eq!(first.value, ["Updated value"]);
|
||||
assert!(ap(&doc, &first.widgets[0]).contains("(Updated value) Tj"));
|
||||
assert!(other.value.is_empty());
|
||||
} else {
|
||||
assert_eq!(first.tooltip.as_deref(), Some("Updated help"));
|
||||
assert_eq!(other.tooltip, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushbutton_redraw_resolves_caption_and_layout_after_save_and_reopen() {
|
||||
fn saved(doc: &Document, full: bool) -> Document {
|
||||
let bytes = if full {
|
||||
pdfcraft_cos::write_full(doc, &SaveOptions::default()).unwrap()
|
||||
} else {
|
||||
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
|
||||
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
|
||||
let original = doc.bytes();
|
||||
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
|
||||
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
|
||||
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
|
||||
bytes
|
||||
};
|
||||
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
Document::open(Arc::new(bytes)).unwrap()
|
||||
}
|
||||
|
||||
for indirect_caption in [false, true] {
|
||||
for indirect_layout in [false, true] {
|
||||
for icon_only in [false, true] {
|
||||
let mut doc = one_page();
|
||||
let name =
|
||||
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 650.0], &NewField::Button { caption: "Original caption".into() }, Some("Picture"))
|
||||
.unwrap();
|
||||
let widget = field(&fields(&doc), &name).widgets[0].obj;
|
||||
let caption = Object::String(pdfcraft_cos::PdfString::literal(b"Original caption".to_vec()));
|
||||
let caption = if indirect_caption { Object::Ref(doc.add(caption)) } else { caption };
|
||||
let layout = Object::Int(i64::from(icon_only));
|
||||
let layout = if indirect_layout { Object::Ref(doc.add(layout)) } else { layout };
|
||||
let mut icon_dict = Dict::new();
|
||||
icon_dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
icon_dict.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
icon_dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 4.into(), 2.into()]));
|
||||
let icon_bytes = b"0 0 4 2 re f\n";
|
||||
let icon = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(icon_dict, icon_bytes.to_vec())));
|
||||
let mut mk = Dict::new();
|
||||
mk.set(b"CA".to_vec(), caption);
|
||||
mk.set(b"TP".to_vec(), layout);
|
||||
mk.set(b"I".to_vec(), Object::Ref(icon));
|
||||
mk.set(b"VendorNote".to_vec(), Object::name("Retained"));
|
||||
let mk = doc.add(Object::Dict(mk));
|
||||
doc.update_dict(widget, |d| d.set(b"MK".to_vec(), Object::Ref(mk))).unwrap();
|
||||
|
||||
// Import the operands from a real saved file before an unrelated public edit.
|
||||
let original = saved(&doc, true);
|
||||
for full in [false, true] {
|
||||
let mut doc = original.clone();
|
||||
let widget = field(&fields(&doc), &name).widgets[0].obj;
|
||||
let mk_before = doc.get(widget).as_dict().unwrap().get(b"MK").unwrap().clone();
|
||||
let original_mk = doc.resolve(&mk_before);
|
||||
set_props(&mut doc, &name, &FieldProps { tooltip: Some("Picture help".into()), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"MK"), Some(&mk_before));
|
||||
assert_eq!(doc.resolve(&mk_before), original_mk, "redraw must not rewrite the source MK");
|
||||
let doc = saved(&doc, full);
|
||||
let f = field(&fields(&doc), &name).clone();
|
||||
assert_eq!(f.tooltip.as_deref(), Some("Picture help"));
|
||||
let w = &f.widgets[0];
|
||||
let wd = doc.get(w.obj);
|
||||
let mk_object = doc.resolve(wd.as_dict().unwrap().get(b"MK").unwrap());
|
||||
let mk = mk_object.as_dict().unwrap();
|
||||
assert_eq!(mk.get(b"CA").unwrap().as_ref().is_some(), indirect_caption);
|
||||
assert_eq!(doc.resolve(mk.get(b"CA").unwrap()).as_string().unwrap().bytes, b"Original caption");
|
||||
assert_eq!(mk.get(b"TP").unwrap().as_ref().is_some(), indirect_layout);
|
||||
assert_eq!(doc.resolve(mk.get(b"TP").unwrap()).as_int(), Some(i64::from(icon_only)));
|
||||
assert_eq!(mk.name(b"VendorNote"), Some(&b"Retained"[..]));
|
||||
let appearance = ap(&doc, w);
|
||||
assert_eq!(appearance.contains("(Original caption) Tj"), !icon_only, "{appearance}");
|
||||
assert_eq!(appearance.matches("/Icon Do").count(), 1, "{appearance}");
|
||||
assert!(appearance.contains("24.000000 0 0 24.000000 52.000 1.000 cm"), "{appearance}");
|
||||
let n = wd.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
|
||||
let appearance_object = doc.get(n);
|
||||
let Object::Stream(stream) = &*appearance_object else { panic!("appearance") };
|
||||
let resource_icon =
|
||||
stream.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().get(b"Icon").unwrap();
|
||||
assert_eq!(resource_icon, mk.get(b"I").unwrap());
|
||||
let icon_object = doc.resolve(resource_icon);
|
||||
let Object::Stream(icon) = &*icon_object else { panic!("icon") };
|
||||
assert_eq!(icon.decoded().unwrap(), icon_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A check box's down appearances survive a redraw for the states it still draws (same names),
|
||||
/// and a down appearance for a state it no longer has is dropped rather than left stale.
|
||||
#[test]
|
||||
fn check_box_redraw_keeps_down_states_it_still_draws() {
|
||||
let mut doc = one_page();
|
||||
let name = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::CheckBox, Some("agree")).unwrap();
|
||||
let widget = field(&fields(&doc), &name).widgets[0].obj;
|
||||
let on = field(&fields(&doc), &name).widgets[0].on_state.clone().unwrap_or_else(|| "Yes".into());
|
||||
let mut ap = doc.get(widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
|
||||
let mut down = Dict::new();
|
||||
for state in [on.as_bytes(), b"Off".as_slice(), b"Gone".as_slice()] {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 20.into()]));
|
||||
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"0 0 20 20 re f\n".to_vec())));
|
||||
down.set(state.to_vec(), Object::Ref(r));
|
||||
}
|
||||
ap.set(b"D".to_vec(), Object::Dict(down));
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap))).unwrap();
|
||||
set_props(&mut doc, &name, &FieldProps { tooltip: Some("Tick to agree".into()), ..FieldProps::default() }).unwrap();
|
||||
let ap = doc.get(widget).as_dict().unwrap().get(b"AP").map(|a| doc.resolve(a)).unwrap();
|
||||
let d = ap.as_dict().unwrap().get(b"D").map(|d| doc.resolve(d)).expect("down states kept");
|
||||
let d = d.as_dict().unwrap();
|
||||
assert!(d.contains(on.as_bytes()) && d.contains(b"Off"));
|
||||
assert!(!d.contains(b"Gone"), "a state the box no longer draws");
|
||||
}
|
||||
@@ -15,4 +15,7 @@ pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
roxmltree = "0.20"
|
||||
base64 = "0.22"
|
||||
png = "0.18"
|
||||
sha2 = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["gif"] }
|
||||
thiserror = { workspace = true }
|
||||
@@ -17,7 +17,7 @@ the automation tools) works on it unchanged. The engine does this when it opens
|
||||
page areas, content areas and media; measurements in mm, cm, in, pt and px; fonts, paragraphs,
|
||||
margins, borders (edge order rules), captions with reserves, check-button items, pictures,
|
||||
rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields),
|
||||
inline JPEG images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
|
||||
inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
|
||||
leaders, `columnWidths` and `colSpan`.
|
||||
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
|
||||
container without a width is as wide as its content; flow across content areas and pages with
|
||||
@@ -112,8 +112,13 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
||||
allocates is not capped (the same holds for AcroForm JavaScript).
|
||||
- **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm;
|
||||
only their data is read and written.
|
||||
- Choice lists are text fields; signature, image, barcode and password fields are left blank;
|
||||
PNG and GIF images, `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`,
|
||||
font metrics are the approximate Helvetica ones, and line heights are 1.15 × size.
|
||||
- **Field kinds.** Choice lists become combo boxes (editable with `textEntry`) or list boxes
|
||||
(`open="always"`, multi-select with `open="multiSelect"`), with the shown items and the saved
|
||||
values of `<items save="1">` as the options; the data holds saved values, several on
|
||||
separate lines. Password fields are password text fields. Signature fields are `/Sig` fields
|
||||
the app can sign. Image fields show the picture the data or template holds (JPEG, PNG, GIF)
|
||||
but a new picture can't be chosen; barcode fields show their value as text, not as bars.
|
||||
- Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font
|
||||
metrics are the approximate Helvetica ones, and line heights are 1.15 × size.
|
||||
- Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and
|
||||
fidelity has been checked by eye on real government forms, not pixel-compared.
|
||||
@@ -47,6 +47,16 @@ impl DataNode {
|
||||
pub fn text_at(&self, path: &[(String, usize)]) -> Option<&str> {
|
||||
self.get(path).map(|n| n.text.as_str())
|
||||
}
|
||||
|
||||
/// The value at `path`: its text, or, for a container whose children are all `value`
|
||||
/// elements (one way a multi-select list's picks are stored), those on separate lines.
|
||||
pub fn value_at(&self, path: &[(String, usize)]) -> Option<String> {
|
||||
let n = self.get(path)?;
|
||||
if n.children.is_empty() || !n.children.iter().all(|c| c.name == "value") {
|
||||
return Some(n.text.clone());
|
||||
}
|
||||
Some(n.children.iter().map(|c| c.text.trim()).filter(|t| !t.is_empty()).take(1000).collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_node(n: roxmltree::Node, depth: usize) -> DataNode {
|
||||
|
||||
@@ -208,6 +208,62 @@ endif</script></event></field>
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A 2 × 2 RGBA PNG: red, green / blue, transparent.
|
||||
pub fn tiny_png() -> Vec<u8> {
|
||||
TINY_PNG.to_vec()
|
||||
}
|
||||
|
||||
const TINY_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x02\x00\x00\x00\x02\x08\x06\x00\x00\x00\x72\xb6\x0d\x24\x00\x00\x00\x13\x49\x44\x41\x54\x78\xda\x63\xf8\xcf\xc0\xf0\x1f\x0c\x81\x34\x88\x60\x00\x00\x3f\xd2\x05\xfb\x7f\xe6\x6a\x2b\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
|
||||
|
||||
/// A 1 × 1 white GIF.
|
||||
pub const TINY_GIF: &[u8] = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
|
||||
|
||||
/// A form with the other field kinds: `country`, a drop-down whose shown names save as codes
|
||||
/// (`Canada`/`CA`, `France`/`FR`, `Japan`/`JP`; default `FR`); `langs`, a multi-select list
|
||||
/// box (`English`, `French`, `Spanish`); `other`, an editable drop-down (`A`, `B`); `pin`, a
|
||||
/// password field; `sign`, a signature field; `photo`, an image field whose template picture
|
||||
/// is [`tiny_png`]; `code`, a barcode field with value `12345`; `logo`, a draw showing
|
||||
/// [`TINY_GIF`]; `summary`, calculated (JavaScript) from `country`. `data` is the children of
|
||||
/// `xfa:data` (empty for none).
|
||||
pub fn fields_template(data: &str) -> String {
|
||||
use base64::Engine;
|
||||
let png = base64::engine::general_purpose::STANDARD.encode(tiny_png());
|
||||
let gif = base64::engine::general_purpose::STANDARD.encode(TINY_GIF);
|
||||
let datasets = if data.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets>")
|
||||
};
|
||||
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="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page1" layout="tb">
|
||||
<field name="country" w="3in" h="0.3in"><ui><choiceList/></ui><caption placement="left" reserve="1in"><value><text>Country</text></value></caption>
|
||||
<items><text>Canada</text><text>France</text><text>Japan</text></items>
|
||||
<items save="1" presence="hidden"><text>CA</text><text>FR</text><text>JP</text></items>
|
||||
<value><text>FR</text></value></field>
|
||||
<field name="langs" w="3in" h="0.8in"><ui><choiceList open="multiSelect"/></ui><caption placement="left" reserve="1in"><value><text>Languages</text></value></caption>
|
||||
<items><text>English</text><text>French</text><text>Spanish</text></items></field>
|
||||
<field name="other" w="3in" h="0.3in"><ui><choiceList open="onEntry" textEntry="1"/></ui><items><text>A</text><text>B</text></items></field>
|
||||
<field name="pin" w="2in" h="0.3in"><ui><passwordEdit/></ui><caption placement="left" reserve="0.5in"><value><text>PIN</text></value></caption></field>
|
||||
<field name="sign" w="3in" h="0.6in"><ui><signature/></ui><caption placement="top" reserve="0.2in"><value><text>Sign here</text></value></caption><border><edge/></border></field>
|
||||
<field name="photo" w="1in" h="1in"><ui><imageEdit/></ui><value><image contentType="image/png">{png}</image></value></field>
|
||||
<field name="code" w="2in" h="0.5in"><ui><barcode type="code128A"/></ui><value><text>12345</text></value><border><edge/></border></field>
|
||||
<draw name="logo" w="0.5in" h="0.5in"><value><image contentType="image/gif">{gif}</image></value></draw>
|
||||
<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>
|
||||
<calculate><script contentType="application/x-javascript">"Country: " + (country.rawValue || "none")</script></calculate></field>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
{datasets}
|
||||
</xdp:xdp>
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields.
|
||||
pub fn shell(xdp: &str) -> Vec<u8> {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Pictures in templates and data: JPEG (embedded as is), PNG and GIF (decoded to 8-bit
|
||||
//! samples with a soft mask for transparency). Sizes are read from the headers without
|
||||
//! decoding, so layout stays cheap; decoding happens once, when the PDF is written.
|
||||
|
||||
/// Most pixels an image may have once decoded: decoding keeps up to 8 bytes per pixel alive
|
||||
/// (the decoder's buffer, the colour samples and the coverage), so this is 160 MB at most.
|
||||
pub const MAX_PIXELS: u64 = 20_000_000;
|
||||
/// Widest or tallest an image may be (PNG headers can claim billions).
|
||||
const MAX_SIDE: u32 = 65_535;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Jpeg,
|
||||
Png,
|
||||
Gif,
|
||||
}
|
||||
|
||||
/// What the bytes are, by their signature (the declared content type is often wrong).
|
||||
pub fn kind(data: &[u8]) -> Option<Kind> {
|
||||
if data.starts_with(&[0xFF, 0xD8]) {
|
||||
Some(Kind::Jpeg)
|
||||
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some(Kind::Png)
|
||||
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
||||
Some(Kind::Gif)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixel size, from the header alone; `None` when it can't be read or the image is bigger
|
||||
/// than can be decoded (so layout gives such a picture its box, not a page-high strip).
|
||||
pub fn size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
header_size(data).filter(|&(w, h)| fits(w, h))
|
||||
}
|
||||
|
||||
fn fits(w: u32, h: u32) -> bool {
|
||||
w <= MAX_SIDE && h <= MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_PIXELS
|
||||
}
|
||||
|
||||
/// What the header claims, unchecked.
|
||||
fn header_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
match kind(data)? {
|
||||
Kind::Jpeg => crate::pdf::jpeg_size(data),
|
||||
Kind::Png => {
|
||||
// The IHDR chunk is first: length, "IHDR", width, height (big-endian).
|
||||
if data.get(12..16)? != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let be = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let (w, h) = (be(16)?, be(20)?);
|
||||
(w > 0 && h > 0).then_some((w, h))
|
||||
}
|
||||
Kind::Gif => {
|
||||
let le = |at: usize| data.get(at..at + 2).map(|b| u32::from(u16::from_le_bytes([b[0], b[1]])));
|
||||
let (w, h) = (le(6)?, le(8)?);
|
||||
(w > 0 && h > 0).then_some((w, h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples ready for an image XObject.
|
||||
#[derive(Debug)]
|
||||
pub struct Decoded {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `DeviceGray` or `DeviceRGB`.
|
||||
pub color_space: &'static str,
|
||||
/// 8-bit samples, row by row.
|
||||
pub samples: Vec<u8>,
|
||||
/// 8-bit coverage per pixel when any pixel is not opaque.
|
||||
pub alpha: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn too_big() -> String {
|
||||
format!("an image has more than {} million pixels and is left out", MAX_PIXELS / 1_000_000)
|
||||
}
|
||||
|
||||
/// Decode a PNG or GIF (its first frame). JPEGs are embedded as they are and are not decoded
|
||||
/// here. The error is the warning to show.
|
||||
pub fn decode(data: &[u8]) -> Result<Decoded, String> {
|
||||
let bad = |m: String| format!("an image could not be decoded and is left out: {m}");
|
||||
let (w, h) = header_size(data).ok_or_else(|| bad("unreadable header".into()))?;
|
||||
if !fits(w, h) {
|
||||
return Err(too_big());
|
||||
}
|
||||
match kind(data) {
|
||||
Some(Kind::Png) => {
|
||||
let mut dec = png::Decoder::new(std::io::Cursor::new(data));
|
||||
dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = dec.read_info().map_err(|e| bad(e.to_string()))?;
|
||||
// The header's size is what the buffer is sized by: checked above.
|
||||
let (fw, fh) = (reader.info().width, reader.info().height);
|
||||
if !fits(fw, fh) {
|
||||
return Err(too_big());
|
||||
}
|
||||
let mut buf = vec![0; reader.output_buffer_size().ok_or_else(|| bad("image too large".into()))?];
|
||||
let frame = reader.next_frame(&mut buf).map_err(|e| bad(e.to_string()))?;
|
||||
let samples = buf.get(..frame.buffer_size()).ok_or_else(|| bad("short image data".into()))?;
|
||||
let (channels, color_space) = match frame.color_type {
|
||||
png::ColorType::Grayscale => (1, "DeviceGray"),
|
||||
png::ColorType::GrayscaleAlpha => (2, "DeviceGray"),
|
||||
png::ColorType::Rgb => (3, "DeviceRGB"),
|
||||
png::ColorType::Rgba => (4, "DeviceRGB"),
|
||||
png::ColorType::Indexed => return Err(bad("unexpected palette output".into())),
|
||||
};
|
||||
let has_alpha = channels == 2 || channels == 4;
|
||||
let colour_n = channels - usize::from(has_alpha);
|
||||
let mut colour = Vec::with_capacity(samples.len() / channels * colour_n);
|
||||
let mut alpha = Vec::with_capacity(if has_alpha { samples.len() / channels } else { 0 });
|
||||
for px in samples.chunks_exact(channels) {
|
||||
colour.extend_from_slice(px.get(..colour_n).unwrap_or_default());
|
||||
if has_alpha && let Some(&a) = px.get(colour_n) {
|
||||
alpha.push(a);
|
||||
}
|
||||
}
|
||||
let alpha = (has_alpha && alpha.iter().any(|a| *a != 255)).then_some(alpha);
|
||||
Ok(Decoded { width: frame.width, height: frame.height, color_space, samples: colour, alpha })
|
||||
}
|
||||
Some(Kind::Gif) => {
|
||||
// The frame may be larger than the logical screen the header gives: the decoder is
|
||||
// told the limits, so it refuses before allocating.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(data));
|
||||
reader.set_format(image::ImageFormat::Gif);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_SIDE);
|
||||
limits.max_image_height = Some(MAX_SIDE);
|
||||
limits.max_alloc = Some(MAX_PIXELS * 4);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?;
|
||||
let rgba = img.into_rgba8();
|
||||
let (width, height) = rgba.dimensions();
|
||||
if !fits(width, height) {
|
||||
return Err(too_big());
|
||||
}
|
||||
let px = rgba.as_raw().as_chunks::<4>().0;
|
||||
let gray = px.iter().all(|p| p[0] == p[1] && p[1] == p[2]);
|
||||
let samples: Vec<u8> = if gray { px.iter().map(|p| p[0]).collect() } else { px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() };
|
||||
let alpha = px.iter().any(|p| p[3] != 255).then(|| px.iter().map(|p| p[3]).collect());
|
||||
Ok(Decoded { width, height, color_space: if gray { "DeviceGray" } else { "DeviceRGB" }, samples, alpha })
|
||||
}
|
||||
Some(Kind::Jpeg) | None => Err(bad("not a PNG or GIF".into())),
|
||||
}
|
||||
}
|
||||
+105
-25
@@ -10,6 +10,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::data::{DataNode, is_on, iso_to_pattern, som_to_path};
|
||||
use crate::model::*;
|
||||
@@ -26,6 +28,8 @@ const MAX_DEPTH: usize = 64;
|
||||
const MAX_MEASURES: usize = 2_000_000;
|
||||
/// Most initial instances of one repeating subform.
|
||||
const MAX_INSTANCES: usize = 50;
|
||||
/// Largest picture taken from the data (decoded bytes), as for template pictures.
|
||||
const MAX_DATA_IMAGE: usize = 32 << 20;
|
||||
/// Placeholder for the page count in text laid out before the count is known.
|
||||
const PAGE_COUNT_MARK: char = '\u{E000}';
|
||||
|
||||
@@ -41,6 +45,17 @@ pub enum Item {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum WidgetKind {
|
||||
Text,
|
||||
/// A text field whose characters are shown as asterisks.
|
||||
Password,
|
||||
/// A drop-down (`list_box` false) or list box: `options` are (saved value, shown text).
|
||||
Choice {
|
||||
options: Vec<(String, String)>,
|
||||
list_box: bool,
|
||||
multi: bool,
|
||||
editable: bool,
|
||||
},
|
||||
/// A signature field, to be signed in the app.
|
||||
Signature,
|
||||
/// A date field with its Acrobat format pattern (`yyyy-mm-dd`).
|
||||
Date(String),
|
||||
CheckBox {
|
||||
@@ -98,6 +113,7 @@ pub struct Widget {
|
||||
pub max_chars: Option<usize>,
|
||||
pub read_only: bool,
|
||||
pub border: WidgetBorder,
|
||||
/// The field's value: saved (bound) values for choice lists, newline-separated when several.
|
||||
pub value: Option<String>,
|
||||
/// The template's default value (what a reset restores); data values are not defaults.
|
||||
pub default: Option<String>,
|
||||
@@ -727,7 +743,7 @@ impl Layouter<'_> {
|
||||
match v {
|
||||
Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)),
|
||||
Value::Rich(r) => text::measure_height(r, w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)),
|
||||
Value::Image { data, .. } => crate::pdf::jpeg_size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }),
|
||||
Value::Image { data, .. } => crate::image::size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }),
|
||||
Value::Line { .. } | Value::Rectangle(_) | Value::Empty => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -1021,16 +1037,7 @@ impl Layouter<'_> {
|
||||
let inner = rect.inset(&d.common.margin);
|
||||
match &d.value {
|
||||
Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para),
|
||||
Value::Image { content_type, data } => {
|
||||
let fitted = match crate::pdf::jpeg_size(data) {
|
||||
Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => {
|
||||
let k = (inner.w / iw as f64).min(inner.h / ih as f64);
|
||||
Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k)
|
||||
}
|
||||
_ => inner,
|
||||
};
|
||||
self.push(Item::Image { rect: fitted, data: Arc::new(data.clone()), content_type: content_type.clone() })
|
||||
}
|
||||
Value::Image { content_type, data } => self.draw_image(data, content_type, inner),
|
||||
Value::Line { slope_up, edge } => {
|
||||
if !edge.visible() {
|
||||
return Ok(());
|
||||
@@ -1095,6 +1102,18 @@ impl Layouter<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A picture scaled to fit `inner`, keeping its shape, from its top left.
|
||||
fn draw_image(&mut self, data: &[u8], content_type: &str, inner: Rect) -> Result<(), XfaError> {
|
||||
let fitted = match crate::image::size(data) {
|
||||
Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => {
|
||||
let k = (inner.w / iw as f64).min(inner.h / ih as f64);
|
||||
Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k)
|
||||
}
|
||||
_ => inner,
|
||||
};
|
||||
self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() })
|
||||
}
|
||||
|
||||
fn unique_name(&mut self, base: &str) -> String {
|
||||
let base = clean_name(base);
|
||||
let n = self.names.entry(base.clone()).or_insert(0);
|
||||
@@ -1146,9 +1165,63 @@ impl Layouter<'_> {
|
||||
let para = ctx.para.apply(&cap.para);
|
||||
self.draw_value_text(&cap.value, cr, &font, ¶)?;
|
||||
}
|
||||
let som_path = || {
|
||||
if fctx.som.is_empty() {
|
||||
format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field"))
|
||||
} else {
|
||||
format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field"))
|
||||
}
|
||||
};
|
||||
match f.ui {
|
||||
Ui::Signature | Ui::ImageEdit | Ui::Barcode | Ui::Unknown => {
|
||||
self.warn_once(format!("{:?} fields are not supported yet and are left blank", f.ui));
|
||||
Ui::Unknown => {
|
||||
self.warn_once("a field of an unknown kind is left blank");
|
||||
return Ok(());
|
||||
}
|
||||
Ui::ImageEdit => {
|
||||
// The picture the data holds (base64, as Designer saves it) or the template's.
|
||||
let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into());
|
||||
let text: Vec<u8> = self
|
||||
.data
|
||||
.and_then(|d| d.text_at(&som_to_path(&som_path())))
|
||||
.map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect())
|
||||
.unwrap_or_default();
|
||||
let from_data = if text.is_empty() {
|
||||
None
|
||||
} else if text.len() > MAX_DATA_IMAGE / 3 * 4 + 4 {
|
||||
self.warn_once(format!("{field_name}: the picture in the data is larger than 32 MB and is left out"));
|
||||
None
|
||||
} else {
|
||||
match base64::engine::general_purpose::STANDARD.decode(&text) {
|
||||
Ok(d) if crate::image::kind(&d).is_some() => Some(d),
|
||||
Ok(_) => {
|
||||
self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG or GIF and is left out"));
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
self.warn_once(format!("{field_name}: the data holds no readable picture (not base64) and is left out"));
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let box_ = ui.inset(&f.ui_margin);
|
||||
match (&from_data, &f.value) {
|
||||
(Some(d), _) => self.draw_image(d, "", box_)?,
|
||||
(None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?,
|
||||
_ => {}
|
||||
}
|
||||
self.warn_once("image fields show their picture; a new picture can't be chosen yet");
|
||||
return Ok(());
|
||||
}
|
||||
Ui::Barcode => {
|
||||
// The value as text, where the bars would be.
|
||||
self.warn_once("barcode fields show their value as text, not as bars");
|
||||
let value =
|
||||
self.data.and_then(|d| d.text_at(&som_to_path(&som_path()))).map(str::to_string).or_else(|| f.value.plain()).unwrap_or_default();
|
||||
if !value.trim().is_empty() {
|
||||
let mut para = fctx.para.clone();
|
||||
para.h_align = HAlign::Center;
|
||||
self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
@@ -1167,16 +1240,13 @@ impl Layouter<'_> {
|
||||
_ => fctx.para.h_align,
|
||||
};
|
||||
let name = self.unique_name(f.common.name.as_deref().unwrap_or("field"));
|
||||
let som = if fctx.som.is_empty() {
|
||||
format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field"))
|
||||
} else {
|
||||
format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field"))
|
||||
};
|
||||
let som = som_path();
|
||||
let read_only = matches!(f.access, Access::ReadOnly | Access::Protected);
|
||||
// The data's value wins over the template's default.
|
||||
let data_value: Option<String> =
|
||||
self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let template_default = f.value.plain().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
self.data.and_then(|d| d.value_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
// A choice list's default is a saved value too.
|
||||
let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty());
|
||||
let plain_value = data_value.clone().or_else(|| template_default.clone());
|
||||
let (kind, rect, border, value, tooltip) = match f.ui {
|
||||
Ui::CheckButton => {
|
||||
@@ -1219,12 +1289,22 @@ impl Layouter<'_> {
|
||||
let shown = plain_value.as_ref().map(|v| if data_value.is_some() { iso_to_pattern(v, &pattern) } else { v.clone() });
|
||||
(WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone())
|
||||
}
|
||||
_ => {
|
||||
if f.ui == Ui::ChoiceList {
|
||||
self.warn_once("drop-down lists are shown as text fields");
|
||||
}
|
||||
(WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone())
|
||||
Ui::ChoiceList => {
|
||||
let options: Vec<(String, String)> = f
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, shown)| (f.item_values.get(i).cloned().unwrap_or_else(|| shown.clone()), shown.clone()))
|
||||
.collect();
|
||||
// The data holds saved values (several on separate lines); shown text is taken
|
||||
// too, as a user typing into an editable list leaves it.
|
||||
let value = plain_value.as_ref().map(|v| f.saved_value(v));
|
||||
let kind = WidgetKind::Choice { options, list_box: f.choice.list_box, multi: f.choice.multi, editable: f.choice.editable };
|
||||
(kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone())
|
||||
}
|
||||
Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()),
|
||||
Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()),
|
||||
_ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()),
|
||||
};
|
||||
if rect.w < 1.0 || rect.h < 1.0 {
|
||||
return Ok(());
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod data;
|
||||
pub mod fixtures;
|
||||
pub mod image;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
pub mod packets;
|
||||
|
||||
+54
-1
@@ -378,6 +378,17 @@ pub enum Ui {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// How a `<choiceList>` presents its items.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ChoiceList {
|
||||
/// `open="always"` or `"multiSelect"`: a list box that shows its items; else a drop-down.
|
||||
pub list_box: bool,
|
||||
/// `open="multiSelect"`: several items may be chosen (newline-separated in the data).
|
||||
pub multi: bool,
|
||||
/// `textEntry="1"`: the user may type a value that is not an item.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Script {
|
||||
pub activity: String,
|
||||
@@ -408,8 +419,13 @@ pub struct Field {
|
||||
pub multiline: bool,
|
||||
pub caption: Option<Caption>,
|
||||
pub value: Value,
|
||||
/// Check buttons: the on value then the off value; choice lists: the items.
|
||||
/// Check buttons: the on value then the off value; choice lists: the items as shown.
|
||||
pub items: Vec<String>,
|
||||
/// Choice lists: the bound values saved to the data (`<items save="1">`), one per shown
|
||||
/// item; empty when the shown text is the value.
|
||||
pub item_values: Vec<String>,
|
||||
/// Choice lists: how the list opens.
|
||||
pub choice: ChoiceList,
|
||||
pub max_chars: Option<usize>,
|
||||
pub tooltip: Option<String>,
|
||||
pub access: Access,
|
||||
@@ -427,6 +443,43 @@ pub struct Field {
|
||||
pub numeric: bool,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// A choice list's value as saved to the data: an item's shown text becomes its saved
|
||||
/// value (an editable list keeps typed text); several values stay on separate lines.
|
||||
/// Other fields keep `v`.
|
||||
pub fn saved_value(&self, v: &str) -> String {
|
||||
if self.ui != Ui::ChoiceList {
|
||||
return v.to_string();
|
||||
}
|
||||
// The data holds saved values: one of those is itself, even when another item shows
|
||||
// the same text (Designer's "specify item values" lists often show 0, 1, 2 and save
|
||||
// 1, 2, 3).
|
||||
let saved = |one: &str| {
|
||||
if self.item_values.is_empty() || self.item_values.iter().any(|s| s == one) {
|
||||
return one.to_string();
|
||||
}
|
||||
self.items.iter().position(|shown| shown == one).and_then(|i| self.item_values.get(i)).cloned().unwrap_or_else(|| one.to_string())
|
||||
};
|
||||
if self.choice.multi {
|
||||
// Each value once, and never more than there are items.
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for l in v.lines().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
let s = saved(l);
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
if out.len() >= self.items.len().max(1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.join("\n")
|
||||
} else {
|
||||
// One value: the first line, should the data hold several.
|
||||
saved(v.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Draw {
|
||||
pub common: Common,
|
||||
|
||||
+66
-8
@@ -13,6 +13,8 @@ const MAX_DEPTH: usize = 64;
|
||||
const MAX_NODES: u32 = 2_000_000;
|
||||
/// Largest decoded inline image.
|
||||
const MAX_IMAGE: usize = 32 << 20;
|
||||
/// Most items a choice list keeps.
|
||||
const MAX_ITEMS: usize = 1000;
|
||||
/// Longest text run kept.
|
||||
const MAX_TEXT: usize = 1 << 20;
|
||||
|
||||
@@ -50,6 +52,23 @@ fn children<'a, 'd>(n: XmlNode<'a, 'd>, name: &'a str) -> impl Iterator<Item = X
|
||||
n.children().filter(move |c| c.is_element() && c.tag_name().name() == name)
|
||||
}
|
||||
|
||||
/// The text of an image element: base64 is read whole up to the image cap (`text_of` stops at
|
||||
/// 1 MiB, which would cut a photo short).
|
||||
fn image_text_of(n: XmlNode) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
for d in n.descendants() {
|
||||
if d.is_text()
|
||||
&& let Some(t) = d.text()
|
||||
{
|
||||
out.extend(t.bytes().filter(|b| !b.is_ascii_whitespace()));
|
||||
if out.len() > MAX_IMAGE / 3 * 4 + 4 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn text_of(n: XmlNode) -> String {
|
||||
let mut s = String::new();
|
||||
for d in n.descendants() {
|
||||
@@ -302,7 +321,10 @@ fn parse_value(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Value {
|
||||
return Value::Text(text_of(c));
|
||||
}
|
||||
"image" => {
|
||||
let raw: Vec<u8> = text_of(c).bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
let Some(raw) = image_text_of(c) else {
|
||||
warnings.push("an image is larger than 32 MB; it is left out".into());
|
||||
return Value::Empty;
|
||||
};
|
||||
let data = if c.attribute("transferEncoding").is_none_or(|e| e == "base64") {
|
||||
match base64::engine::general_purpose::STANDARD.decode(&raw) {
|
||||
Ok(d) => d,
|
||||
@@ -353,13 +375,35 @@ fn parse_caption(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Option<Capti
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_items(n: XmlNode) -> Vec<String> {
|
||||
// Several <items> lists may exist (display and bound values); the first one is what the
|
||||
// widget shows / the check button's on and off values.
|
||||
let Some(items) = children(n, "items").find(|i| i.attribute("presence") != Some("hidden")).or_else(|| child(n, "items")) else {
|
||||
return Vec::new();
|
||||
/// The field's item lists: what is shown (the first `<items>` that is not the saved values,
|
||||
/// also a check button's on and off values) and, for choice lists, the values saved to the
|
||||
/// data (`<items save="1">`, usually hidden), empty when the shown text is the value.
|
||||
fn parse_items(n: XmlNode, warnings: &mut Vec<String>) -> (Vec<String>, Vec<String>) {
|
||||
let lists: Vec<XmlNode> = children(n, "items").collect();
|
||||
let is_saved = |i: &XmlNode| i.attribute("save") == Some("1") || i.attribute("presence") == Some("hidden");
|
||||
let mut over = false;
|
||||
let mut texts = |i: &XmlNode| -> Vec<String> {
|
||||
let all: Vec<String> = i.children().filter(|c| c.is_element()).map(text_of).take(MAX_ITEMS + 1).collect();
|
||||
if all.len() > MAX_ITEMS {
|
||||
over = true;
|
||||
}
|
||||
all.into_iter().take(MAX_ITEMS).collect()
|
||||
};
|
||||
items.children().filter(|c| c.is_element()).map(text_of).take(1000).collect()
|
||||
let shown = lists.iter().find(|i| !is_saved(i)).or(lists.first());
|
||||
let saved = lists.iter().find(|i| is_saved(i) && shown.is_none_or(|s| s != *i));
|
||||
let shown: Vec<String> = shown.map(&mut texts).unwrap_or_default();
|
||||
let mut saved: Vec<String> = saved.map(&mut texts).unwrap_or_default();
|
||||
if over && warnings.len() < 100 {
|
||||
warnings.push(format!(
|
||||
"{} has more than {MAX_ITEMS} items; the rest are left out",
|
||||
n.attribute("name").map_or_else(|| "a choice list".to_string(), |name| format!("the choice list {name}"))
|
||||
));
|
||||
}
|
||||
// A saved list shorter than the shown one is padded with the shown text.
|
||||
if !saved.is_empty() && saved.len() < shown.len() {
|
||||
saved.extend(shown.iter().skip(saved.len()).cloned());
|
||||
}
|
||||
(shown, saved)
|
||||
}
|
||||
|
||||
fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
||||
@@ -384,6 +428,18 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
||||
let scripts = parse_events(n);
|
||||
let numeric = matches!(kind, Ui::NumericEdit)
|
||||
|| value_el.is_some_and(|v| v.children().any(|c| c.is_element() && matches!(c.tag_name().name(), "decimal" | "integer" | "float")));
|
||||
let (items, item_values) = parse_items(n, warnings);
|
||||
let choice = match (kind == Ui::ChoiceList, ui_el) {
|
||||
(true, Some(e)) => {
|
||||
let open = e.attribute("open").unwrap_or("onEntry");
|
||||
ChoiceList {
|
||||
list_box: matches!(open, "always" | "multiSelect"),
|
||||
multi: open == "multiSelect",
|
||||
editable: e.attribute("textEntry").is_some_and(|t| t == "1" || t == "true"),
|
||||
}
|
||||
}
|
||||
_ => ChoiceList::default(),
|
||||
};
|
||||
let validate_el = child(n, "validate");
|
||||
let validate_message = validate_el
|
||||
.and_then(|v| child(v, "message"))
|
||||
@@ -401,7 +457,9 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
||||
multiline: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")),
|
||||
caption: parse_caption(child(n, "caption"), warnings),
|
||||
value: parse_value(value_el, warnings),
|
||||
items: parse_items(n),
|
||||
items,
|
||||
item_values,
|
||||
choice,
|
||||
max_chars,
|
||||
tooltip: child(n, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
access: match n.attribute("access") {
|
||||
|
||||
+220
-31
@@ -29,11 +29,39 @@ pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> {
|
||||
jpeg_info(data).map(|(w, h, c, _)| (w, h, c))
|
||||
}
|
||||
|
||||
/// The SHA-256 of a picture's bytes, to find its XObject again.
|
||||
fn content_hash(data: &[u8]) -> [u8; 32] {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(data).into()
|
||||
}
|
||||
|
||||
fn hex32(h: &[u8; 32]) -> Vec<u8> {
|
||||
h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect()
|
||||
}
|
||||
|
||||
fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> {
|
||||
let mut out = [0u8; 32];
|
||||
if s.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) {
|
||||
*o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs
|
||||
/// store inverted values).
|
||||
fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> {
|
||||
if data.get(0..2)? != [0xFF, 0xD8] {
|
||||
return None;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
let mut guard = 0;
|
||||
let mut adobe = false;
|
||||
while i + 4 <= data.len() && guard < 10_000 {
|
||||
guard += 1;
|
||||
if *data.get(i)? != 0xFF {
|
||||
@@ -50,12 +78,15 @@ fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> {
|
||||
continue;
|
||||
}
|
||||
let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize;
|
||||
if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") {
|
||||
adobe = true;
|
||||
}
|
||||
let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC);
|
||||
if sof {
|
||||
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32;
|
||||
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32;
|
||||
let comps = *data.get(i + 9)?;
|
||||
return (w > 0 && h > 0).then_some((w, h, comps));
|
||||
return (w > 0 && h > 0).then_some((w, h, comps, adobe));
|
||||
}
|
||||
i = i.checked_add(2)?.checked_add(len.max(2))?;
|
||||
}
|
||||
@@ -125,8 +156,20 @@ pub struct Written {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A
|
||||
/// re-layout (after every script event) finds the XObjects on the pages it replaces by this,
|
||||
/// and reuses them instead of decoding and embedding the same picture again. Nothing is kept
|
||||
/// in the catalog, and a match is by the picture's own hash, never by a map read from the file.
|
||||
const IMAGE_SHA_KEY: &[u8] = b"PCXfaSHA256";
|
||||
/// Most pages searched for earlier pictures.
|
||||
const MAX_PAGES_SEARCHED: usize = 10_000;
|
||||
/// Most pictures remembered.
|
||||
const MAX_REMEMBERED_IMAGES: usize = 1000;
|
||||
|
||||
struct Emitter<'a> {
|
||||
doc: &'a mut Document,
|
||||
/// Image XObjects by content hash: those from earlier passes, then this one's.
|
||||
images: HashMap<[u8; 32], ObjRef>,
|
||||
fields: Vec<ObjRef>,
|
||||
radio_groups: HashMap<String, (ObjRef, Vec<ObjRef>)>,
|
||||
/// The on state selected in each radio group, from the data.
|
||||
@@ -173,11 +216,14 @@ impl Emitter<'_> {
|
||||
}
|
||||
let mut top_level = true;
|
||||
match &w.kind {
|
||||
WidgetKind::Text | WidgetKind::Date(_) => {
|
||||
WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => {
|
||||
d.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
if w.multiline {
|
||||
ff |= 1 << 12;
|
||||
}
|
||||
if w.kind == WidgetKind::Password {
|
||||
ff |= 1 << 13;
|
||||
}
|
||||
if let Some(m) = w.max_chars {
|
||||
d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64));
|
||||
}
|
||||
@@ -202,6 +248,63 @@ impl Emitter<'_> {
|
||||
d.set(b"AA".to_vec(), Object::Dict(aa));
|
||||
}
|
||||
}
|
||||
WidgetKind::Choice { options, list_box, multi, editable } => {
|
||||
d.set(b"FT".to_vec(), Object::name("Ch"));
|
||||
if !list_box {
|
||||
ff |= 1 << 17;
|
||||
if *editable {
|
||||
ff |= 1 << 18;
|
||||
}
|
||||
}
|
||||
if *multi {
|
||||
ff |= 1 << 21;
|
||||
}
|
||||
let opt: Vec<Object> = options
|
||||
.iter()
|
||||
.map(|(saved, shown)| {
|
||||
if saved == shown {
|
||||
Object::String(PdfString::text(shown))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(saved)), Object::String(PdfString::text(shown))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let q = match w.h_align {
|
||||
crate::model::HAlign::Center => 1,
|
||||
crate::model::HAlign::Right => 2,
|
||||
_ => 0,
|
||||
};
|
||||
if q != 0 {
|
||||
d.set(b"Q".to_vec(), Object::Int(q));
|
||||
}
|
||||
let values = |v: &str| -> Object {
|
||||
let picked: Vec<&str> = if *multi { v.lines().filter(|l| !l.is_empty()).collect() } else { vec![v] };
|
||||
match picked.as_slice() {
|
||||
[one] => Object::String(PdfString::text(one)),
|
||||
many => Object::Array(many.iter().map(|s| Object::String(PdfString::text(s))).collect()),
|
||||
}
|
||||
};
|
||||
if let Some(v) = &w.value {
|
||||
d.set(b"V".to_vec(), values(v));
|
||||
// The selected indices (ISO 32000-2 Table 231): ascending, each once. Where
|
||||
// two options save the same value the first is meant; the data can't say.
|
||||
let picked: Vec<&str> = if *multi { v.lines().filter(|l| !l.is_empty()).collect() } else { vec![v.as_str()] };
|
||||
let mut idx: Vec<usize> = picked.iter().filter_map(|p| options.iter().position(|(saved, _)| saved == p)).collect();
|
||||
idx.sort_unstable();
|
||||
idx.dedup();
|
||||
if !idx.is_empty() {
|
||||
d.set(b"I".to_vec(), Object::Array(idx.into_iter().map(|i| Object::Int(i as i64)).collect()));
|
||||
}
|
||||
}
|
||||
if let Some(v) = &w.default {
|
||||
// Already a saved value (the layout maps defaults as it maps data).
|
||||
d.set(b"DV".to_vec(), values(v));
|
||||
}
|
||||
}
|
||||
WidgetKind::Signature => {
|
||||
d.set(b"FT".to_vec(), Object::name("Sig"));
|
||||
}
|
||||
WidgetKind::CheckBox { on, .. } => {
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
let on = state_name(on);
|
||||
@@ -309,6 +412,85 @@ impl Emitter<'_> {
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
/// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs and GIFs decoded
|
||||
/// to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning,
|
||||
/// for anything else.
|
||||
fn image_xobject(&mut self, data: &[u8], content_type: &str) -> Option<ObjRef> {
|
||||
let hash = content_hash(data);
|
||||
if let Some(&r) = self.images.get(&hash)
|
||||
&& let Object::Stream(s) = &*self.doc.get(r)
|
||||
&& s.dict.name(b"Subtype") == Some(b"Image")
|
||||
{
|
||||
return Some(r);
|
||||
}
|
||||
let r = self.new_image_xobject(data, content_type, &hash)?;
|
||||
if self.images.len() < MAX_REMEMBERED_IMAGES {
|
||||
self.images.insert(hash, r);
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
fn new_image_xobject(&mut self, data: &[u8], content_type: &str, hash: &[u8; 32]) -> Option<ObjRef> {
|
||||
let what = if content_type.is_empty() { "picture" } else { content_type };
|
||||
let mut d = Dict::new();
|
||||
d.set(IMAGE_SHA_KEY.to_vec(), Object::String(PdfString::literal(hex32(hash))));
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
match crate::image::kind(data) {
|
||||
Some(crate::image::Kind::Jpeg) => {
|
||||
let Some((iw, ih, comps, adobe)) = jpeg_info(data) else {
|
||||
self.warnings.push(format!("a {what} image is not a readable JPEG and is left out"));
|
||||
return None;
|
||||
};
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(iw)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(ih)));
|
||||
d.set(
|
||||
b"ColorSpace".to_vec(),
|
||||
Object::name(match comps {
|
||||
1 => "DeviceGray",
|
||||
4 => "DeviceCMYK",
|
||||
_ => "DeviceRGB",
|
||||
}),
|
||||
);
|
||||
if comps == 4 && adobe {
|
||||
// Adobe APP14 JPEGs store inverted CMYK.
|
||||
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect()));
|
||||
}
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
Some(self.doc.add(Object::Stream(Stream::from_raw(d, data.to_vec()))))
|
||||
}
|
||||
Some(crate::image::Kind::Png | crate::image::Kind::Gif) => {
|
||||
let img = match crate::image::decode(data) {
|
||||
Ok(img) => img,
|
||||
Err(why) => {
|
||||
self.warnings.push(why);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(img.width)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(img.height)));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name(img.color_space));
|
||||
if let Some(alpha) = &img.alpha {
|
||||
let mut m = Dict::new();
|
||||
m.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
m.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
m.set(b"Width".to_vec(), Object::Int(i64::from(img.width)));
|
||||
m.set(b"Height".to_vec(), Object::Int(i64::from(img.height)));
|
||||
m.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
let mr = self.doc.add(Object::Stream(Stream::flate(m, alpha)));
|
||||
d.set(b"SMask".to_vec(), Object::Ref(mr));
|
||||
}
|
||||
Some(self.doc.add(Object::Stream(Stream::flate(d, &img.samples))))
|
||||
}
|
||||
None => {
|
||||
self.warnings.push(format!("a {what} image is not a JPEG, PNG or GIF and is left out"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result<ObjRef, XfaError> {
|
||||
let page_ref = self.doc.add(Object::Dict(Dict::new()));
|
||||
let mut content: Vec<u8> = Vec::new();
|
||||
@@ -346,33 +528,7 @@ impl Emitter<'_> {
|
||||
content.extend_from_slice(b" Tj ET\n");
|
||||
}
|
||||
Item::Image { rect, data, content_type } => {
|
||||
let Some((iw, ih, comps)) = jpeg_frame(data) else {
|
||||
self.warnings.push(format!(
|
||||
"a {} image is not a JPEG and is left out",
|
||||
if content_type.is_empty() { "picture" } else { content_type.as_str() }
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(iw)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(ih)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(
|
||||
b"ColorSpace".to_vec(),
|
||||
Object::name(match comps {
|
||||
1 => "DeviceGray",
|
||||
4 => "DeviceCMYK",
|
||||
_ => "DeviceRGB",
|
||||
}),
|
||||
);
|
||||
if comps == 4 {
|
||||
// Adobe APP14 JPEGs store inverted CMYK.
|
||||
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect()));
|
||||
}
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
let r = self.doc.add(Object::Stream(Stream::from_raw(d, data.as_ref().clone())));
|
||||
let Some(r) = self.image_xobject(data, content_type) else { continue };
|
||||
let name = format!("Im{i}");
|
||||
xobjects.set(name.clone().into_bytes(), Object::Ref(r));
|
||||
content.extend(format!("q {} 0 0 {} {} {} cm /{name} Do Q\n", n(rect.w), n(rect.h), n(rect.x), n(h - rect.bottom())).bytes());
|
||||
@@ -413,6 +569,30 @@ impl Emitter<'_> {
|
||||
|
||||
/// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The
|
||||
/// XFA packets and everything else in the catalog stay.
|
||||
/// The image XObjects earlier passes wrote for pictures, by the SHA-256 each records: those on
|
||||
/// the pages under `pages` (the flat page list [`write_form`] writes).
|
||||
fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> {
|
||||
let mut out = HashMap::new();
|
||||
let kids = doc.get(pages).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
for page in kids.iter().filter_map(Object::as_ref).take(MAX_PAGES_SEARCHED) {
|
||||
let page = doc.get(page);
|
||||
let Some(resources) = page.as_dict().and_then(|p| p.get(b"Resources")).map(|r| doc.resolve(r)) else { continue };
|
||||
let Some(xobjects) = resources.as_dict().and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)) else { continue };
|
||||
for r in xobjects.as_dict().into_iter().flat_map(|x| x.iter()).filter_map(|(_, v)| v.as_ref()) {
|
||||
if out.len() >= MAX_REMEMBERED_IMAGES {
|
||||
return out;
|
||||
}
|
||||
if let Object::Stream(s) = &*doc.get(r)
|
||||
&& s.dict.name(b"Subtype") == Some(b"Image")
|
||||
&& let Some(hash) = s.dict.get(IMAGE_SHA_KEY).and_then(Object::as_string).and_then(|h| parse_hex32(&h.bytes))
|
||||
{
|
||||
out.insert(hash, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError> {
|
||||
let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?;
|
||||
let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?;
|
||||
@@ -428,8 +608,17 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError>
|
||||
r
|
||||
}
|
||||
};
|
||||
let mut em =
|
||||
Emitter { doc, fields: Vec::new(), radio_groups: HashMap::new(), radio_selected: HashMap::new(), field_count: 0, warnings: Vec::new() };
|
||||
// Pictures embedded by earlier passes, from the pages this pass replaces.
|
||||
let images = earlier_images(doc, pages_ref);
|
||||
let mut em = Emitter {
|
||||
doc,
|
||||
images,
|
||||
fields: Vec::new(),
|
||||
radio_groups: HashMap::new(),
|
||||
radio_selected: HashMap::new(),
|
||||
field_count: 0,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let mut kids = Vec::new();
|
||||
for page in &form.pages {
|
||||
kids.push(Object::Ref(em.page(page, pages_ref)?));
|
||||
|
||||
@@ -241,15 +241,15 @@ impl Walker<'_> {
|
||||
|
||||
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
|
||||
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
|
||||
let data_value = self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let data_value = self.data.and_then(|d| d.value_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let value = match (radio, data_value) {
|
||||
// A radio button's value is its on value when the group picked it.
|
||||
(Some(selected), _) => {
|
||||
let on = f.items.first().cloned().unwrap_or_default();
|
||||
if selected == on { on } else { String::new() }
|
||||
}
|
||||
(None, Some(v)) => v,
|
||||
(None, None) => f.value.plain().map(|v| v.trim().to_string()).unwrap_or_default(),
|
||||
(None, Some(v)) => f.saved_value(&v),
|
||||
(None, None) => f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(),
|
||||
};
|
||||
self.push_events(&som, &f.scripts, f.calculate.as_ref(), f.validate.as_ref(), f.validate_message.as_deref());
|
||||
FormNode {
|
||||
|
||||
+280
-1
@@ -7,8 +7,9 @@ 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::{scripted_template, shell, static_shell, template, template_with_data};
|
||||
use crate::fixtures::{TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png};
|
||||
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};
|
||||
|
||||
@@ -762,3 +763,281 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
|
||||
let form = layout_xml(&fields_template("")).unwrap();
|
||||
let p1 = &form.pages[0];
|
||||
let w = widgets(p1);
|
||||
let country = w.iter().find(|w| w.name == "country").unwrap();
|
||||
assert_eq!(
|
||||
country.kind,
|
||||
WidgetKind::Choice {
|
||||
options: vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())],
|
||||
list_box: false,
|
||||
multi: false,
|
||||
editable: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(country.value.as_deref(), Some("FR"), "the template default, as a saved value");
|
||||
let langs = w.iter().find(|w| w.name == "langs").unwrap();
|
||||
assert!(
|
||||
matches!(&langs.kind, WidgetKind::Choice { options, list_box: true, multi: true, editable: false } if options.len() == 3 && options[0] == ("English".into(), "English".into()))
|
||||
);
|
||||
let other = w.iter().find(|w| w.name == "other").unwrap();
|
||||
assert!(matches!(&other.kind, WidgetKind::Choice { list_box: false, multi: false, editable: true, .. }));
|
||||
assert_eq!(w.iter().find(|w| w.name == "pin").unwrap().kind, WidgetKind::Password);
|
||||
assert_eq!(w.iter().find(|w| w.name == "sign").unwrap().kind, WidgetKind::Signature);
|
||||
assert!(w.iter().all(|w| w.name != "photo" && w.name != "code"), "image and 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.iter().any(|w| w.contains("image fields")), "{:?}", form.warnings);
|
||||
|
||||
// Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password
|
||||
// field, and Flate images (the PNG with a soft mask).
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
|
||||
let report = render_into(&mut doc).unwrap();
|
||||
assert_eq!(report.fields, 6, "country, langs, other, pin, sign, summary");
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let fields = pdfcraft_forms_free_fields(&doc);
|
||||
let field = |n: &str| fields.iter().find(|(name, _)| name == n).map(|(_, d)| d.clone()).unwrap_or_else(|| panic!("no field {n}"));
|
||||
let country = field("country");
|
||||
assert_eq!(country.name(b"FT"), Some(b"Ch".as_slice()));
|
||||
assert_eq!(country.int(b"Ff"), Some(1 << 17), "a combo box");
|
||||
let opt = doc.resolve(country.get(b"Opt").unwrap()).as_array().unwrap().clone();
|
||||
assert_eq!(opt.len(), 3);
|
||||
let pair = doc.resolve(&opt[1]).as_array().unwrap().clone();
|
||||
assert_eq!(pair[0].as_string().unwrap().to_text(), "FR");
|
||||
assert_eq!(pair[1].as_string().unwrap().to_text(), "France");
|
||||
assert_eq!(country.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("FR"));
|
||||
assert_eq!(country.get(b"DV").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("FR"));
|
||||
let langs = field("langs");
|
||||
assert_eq!(langs.int(b"Ff"), Some(1 << 21), "a multi-select list box");
|
||||
let opt = doc.resolve(langs.get(b"Opt").unwrap()).as_array().unwrap().clone();
|
||||
assert_eq!(opt[0].as_string().unwrap().to_text(), "English", "same shown text and value: a plain string");
|
||||
assert_eq!(field("other").int(b"Ff"), Some((1 << 17) | (1 << 18)), "an editable combo box");
|
||||
assert_eq!(field("pin").int(b"Ff"), Some(1 << 13), "a password field");
|
||||
assert_eq!(field("sign").name(b"FT"), Some(b"Sig".as_slice()));
|
||||
let streams: Vec<pdfcraft_cos::Stream> = doc
|
||||
.object_numbers()
|
||||
.into_iter()
|
||||
.filter_map(|n| if let Object::Stream(st) = &*doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }) { Some(st.clone()) } else { None })
|
||||
.filter(|s| s.dict.name(b"Subtype") == Some(b"Image"))
|
||||
.collect();
|
||||
assert_eq!(streams.len(), 3, "the PNG, its soft mask, the GIF");
|
||||
let png = streams.iter().find(|s| s.dict.contains(b"SMask")).expect("the PNG has a soft mask");
|
||||
assert_eq!(png.dict.name(b"ColorSpace"), Some(b"DeviceRGB".as_slice()));
|
||||
assert_eq!(png.dict.name(b"Filter"), Some(b"FlateDecode".as_slice()));
|
||||
assert_eq!(png.decoded().unwrap().len(), 12, "2 × 2 RGB samples");
|
||||
let gif = streams.iter().find(|s| s.dict.int(b"Width") == Some(1)).expect("the 1 × 1 GIF");
|
||||
assert_eq!(gif.dict.name(b"ColorSpace"), Some(b"DeviceGray".as_slice()), "a white pixel is gray");
|
||||
assert_eq!(gif.decoded().unwrap(), vec![255]);
|
||||
}
|
||||
|
||||
/// The terminal field dictionaries by name (one level: this fixture has no hierarchy).
|
||||
fn pdfcraft_forms_free_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> {
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
|
||||
fields
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let d = doc.resolve(f).as_dict()?.clone();
|
||||
let name = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text()))?;
|
||||
Some((name, d))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_values_come_from_the_data_as_saved_values_and_several_for_lists() {
|
||||
// Shown text in the data is taken too (an editable list the user typed into saves text).
|
||||
let xml = fields_template("<form><page1><country>Japan</country><langs>English\nSpanish</langs><other>typed</other></page1></form>");
|
||||
let form = layout_xml(&xml).unwrap();
|
||||
let w = widgets(&form.pages[0]);
|
||||
assert_eq!(w.iter().find(|w| w.name == "country").unwrap().value.as_deref(), Some("JP"));
|
||||
assert_eq!(w.iter().find(|w| w.name == "langs").unwrap().value.as_deref(), Some("English\nSpanish"));
|
||||
assert_eq!(w.iter().find(|w| w.name == "other").unwrap().value.as_deref(), Some("typed"));
|
||||
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let fields = pdfcraft_forms_free_fields(&doc);
|
||||
let langs = &fields.iter().find(|(n, _)| n == "langs").unwrap().1;
|
||||
let v = doc.resolve(langs.get(b"V").unwrap()).as_array().unwrap().clone();
|
||||
assert_eq!(v.iter().map(|s| s.as_string().unwrap().to_text()).collect::<Vec<_>>(), vec!["English", "Spanish"]);
|
||||
let i = doc.resolve(langs.get(b"I").unwrap()).as_array().unwrap().clone();
|
||||
assert_eq!(i.iter().map(|o| o.as_int().unwrap()).collect::<Vec<_>>(), vec![0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated() {
|
||||
use crate::image::{Kind, decode, kind, size};
|
||||
assert_eq!(kind(&tiny_png()), Some(Kind::Png));
|
||||
assert_eq!(size(&tiny_png()), Some((2, 2)));
|
||||
assert_eq!(kind(TINY_GIF), Some(Kind::Gif));
|
||||
assert_eq!(size(TINY_GIF), Some((1, 1)));
|
||||
assert_eq!(kind(b"BM..."), None);
|
||||
assert_eq!(size(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), None, "a header cut short");
|
||||
let decoded = decode(&tiny_png()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height, decoded.color_space), (2, 2, "DeviceRGB"));
|
||||
assert_eq!(decoded.samples, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]);
|
||||
assert_eq!(decoded.alpha, Some(vec![255, 255, 255, 0]));
|
||||
assert_eq!(decode(TINY_GIF).unwrap().samples, vec![255]);
|
||||
// A PNG claiming 100,000 × 100,000 pixels is refused from its header, before any buffer.
|
||||
let mut huge = tiny_png();
|
||||
huge[16..20].copy_from_slice(&100_000u32.to_be_bytes());
|
||||
huge[20..24].copy_from_slice(&100_000u32.to_be_bytes());
|
||||
assert_eq!(size(&huge), None, "no size for layout: the box is used");
|
||||
assert!(decode(&huge).unwrap_err().contains("million pixels"));
|
||||
// Truncated and corrupt data are errors, not panics.
|
||||
let cut = &tiny_png()[..30];
|
||||
assert!(decode(cut).is_err());
|
||||
let mut bad = tiny_png();
|
||||
for b in bad.iter_mut().skip(40) {
|
||||
*b ^= 0x55;
|
||||
}
|
||||
assert!(decode(&bad).is_err() || decode(&bad).is_ok());
|
||||
let mut gif = TINY_GIF.to_vec();
|
||||
gif.truncate(12);
|
||||
assert!(decode(&gif).is_err());
|
||||
// A form whose picture is broken still lays out, with a warning, and keeps the rest.
|
||||
let xml = fields_template("")
|
||||
.replace("image/gif", "image/gif\" transferEncoding=\"none")
|
||||
.replace("<image contentType=\"image/png\">", "<image contentType=\"image/png\">AAAA");
|
||||
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
|
||||
let report = render_into(&mut doc).unwrap();
|
||||
assert_eq!(report.fields, 6);
|
||||
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 or GIF"), ("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));
|
||||
}
|
||||
@@ -5142,8 +5142,8 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
|
||||
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite"]
|
||||
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save"]
|
||||
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; choosing a new picture for an image field; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
|
||||
Reference in new issue
Block a user