diff --git a/Cargo.lock b/Cargo.lock index 2520b60a4e..bb747cf903 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3822,8 +3822,10 @@ name = "pdfcraft-xfa" version = "0.4.0" dependencies = [ "base64 0.22.1", + "image", "pdfcraft-cos", "pdfcraft-fonts", + "png 0.18.1", "roxmltree", "thiserror 2.0.21", ] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c7ce0650c2..11f50f30ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1885,6 +1885,17 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result { + // 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 = 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)), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6689667c88..ef8f8b2e91 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1652,6 +1652,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 = "
JapanEnglish\nSpanish
"; + 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())); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 50fdec2af0..f8ef3fd2f5 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -208,6 +208,8 @@ struct FieldInfo { kind: pdfcraft_forms::FieldKind, value: Vec, 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 { @@ -215,7 +217,8 @@ fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap { .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 { diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml index 6bb4707dca..1d5727c016 100644 --- a/crates/xfa/Cargo.toml +++ b/crates/xfa/Cargo.toml @@ -15,4 +15,6 @@ pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } roxmltree = "0.20" base64 = "0.22" +png = "0.18" +image = { version = "0.25", default-features = false, features = ["gif"] } thiserror = { workspace = true } diff --git a/crates/xfa/README.md b/crates/xfa/README.md index cfb2dacb12..c32f0df024 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -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 `` 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. diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index ba1ebc8987..78b0cc9e06 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -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 { + 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::>().join("\n")) + } } fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 64b8846985..b42a3d2bbb 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -208,6 +208,62 @@ endif .to_string() } +/// A 2 × 2 RGBA PNG: red, green / blue, transparent. +pub fn tiny_png() -> Vec { + 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!("{data}") + }; + format!( + r##" + + +{datasets} + +"## + ) +} + /// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields. pub fn shell(xdp: &str) -> Vec { let objs: Vec> = vec![ diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs new file mode 100644 index 0000000000..24ae77f559 --- /dev/null +++ b/crates/xfa/src/image.rs @@ -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 { + 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, + /// 8-bit coverage per pixel when any pixel is not opaque. + pub alpha: Option>, +} + +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 { + 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 = 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())), + } +} diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 19b3143a04..fa50113613 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -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, pub read_only: bool, pub border: WidgetBorder, + /// The field's value: saved (bound) values for choice lists, newline-separated when several. pub value: Option, /// The template's default value (what a reset restores); data values are not defaults. pub default: Option, @@ -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 = 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 = - 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(()); diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 115fc50fc0..6b4e31d4e6 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -9,6 +9,7 @@ pub mod data; pub mod fixtures; +pub mod image; pub mod layout; pub mod model; pub mod packets; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index d4332faa51..9b4bdc6d3b 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -378,6 +378,17 @@ pub enum Ui { Unknown, } +/// How a `` 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, 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, + /// Choice lists: the bound values saved to the data (``), one per shown + /// item; empty when the shown text is the value. + pub item_values: Vec, + /// Choice lists: how the list opens. + pub choice: ChoiceList, pub max_chars: Option, pub tooltip: Option, 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 = 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, diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 6fded48e8f..0bf9008157 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -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 Option> { + 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, warnings: &mut Vec) -> Value { return Value::Text(text_of(c)); } "image" => { - let raw: Vec = 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, warnings: &mut Vec) -> Option Vec { - // Several 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 `` 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 (``, usually hidden), empty when the shown text is the value. +fn parse_items(n: XmlNode, warnings: &mut Vec) -> (Vec, Vec) { + let lists: Vec = 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 { + let all: Vec = 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 = shown.map(&mut texts).unwrap_or_default(); + let mut saved: Vec = 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) -> Field { @@ -384,6 +428,18 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> 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) -> 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") { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 538dc21443..49f3e636b4 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -29,11 +29,27 @@ 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)) +} + +/// A hash of a picture's bytes, to find its XObject again. +fn content_hash(data: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + data.len().hash(&mut h); + data.hash(&mut h); + h.finish() +} + +/// 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 +66,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 +144,17 @@ pub struct Written { pub warnings: Vec, } +/// The catalog's record of the image XObjects written for pictures, by content hash, so a +/// re-layout (after every script event) reuses them instead of decoding and embedding the +/// same picture again. +const IMAGES_KEY: &[u8] = b"PCXfaImages"; +/// 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, fields: Vec, radio_groups: HashMap)>, /// The on state selected in each radio group, from the data. @@ -173,11 +201,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 +233,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 = 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 = 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 +397,84 @@ 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 { + 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)?; + 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) -> Option { + let what = if content_type.is_empty() { "picture" } else { content_type }; + 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"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 { let page_ref = self.doc.add(Object::Dict(Dict::new())); let mut content: Vec = Vec::new(); @@ -346,33 +512,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()); @@ -428,8 +568,27 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result 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, by content hash. + let images: HashMap = catalog + .get(IMAGES_KEY) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_dict().cloned()) + .map(|d| { + d.iter() + .filter_map(|(k, v)| Some((u64::from_str_radix(std::str::from_utf8(k).ok()?, 16).ok()?, v.as_ref()?))) + .take(MAX_REMEMBERED_IMAGES) + .collect() + }) + .unwrap_or_default(); + 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)?)); @@ -444,7 +603,15 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result } })?; } - let Emitter { doc, fields, field_count, warnings, .. } = em; + let Emitter { doc, fields, field_count, warnings, images, .. } = em; + if !images.is_empty() { + let mut d = Dict::new(); + for (hash, r) in &images { + d.set(format!("{hash:016x}").into_bytes(), Object::Ref(*r)); + } + catalog.set(IMAGES_KEY.to_vec(), Object::Dict(d)); + catalog_changed = true; + } let count = kids.len(); doc.update_dict(pages_ref, |d| { d.set(b"Type".to_vec(), Object::name("Pages")); diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 53b2cf5077..c2088deee5 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -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 { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index d067b14d40..f189a7faa5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -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,280 @@ 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 = 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("
JapanEnglish\nSpanishtyped
"); + 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!["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![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("", "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("
1
") + .replace("CanadaFranceJapan", "0123") + .replace("CAFRJP", "1234") + .replace("FR", "1"); + 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("
Japan\nCanadaSpanish\nEnglish\nEnglish\nSpanish
") + .replace("FR", "France") + .replace( + "EnglishFrenchSpanish", + "EnglishFrenchSpanishEnglish\nFrench", + ); + 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![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!["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 children are read too. + let tpl = fields_template("
EnglishSpanish
"); + 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!("i{i}")).collect(); + let tpl = fields_template("").replace("AB", &format!("{many}")); + 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("
JP
"); + 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"); + 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!("
{data}
")); + 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#""#, + &format!( + r#"{b64}"# + ), + ); + 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#""#, &format!(r#"{big}"#)); + let (t, _) = parse(&tpl).unwrap(); + fn find_image(nodes: &[Node]) -> Option { + 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)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 16674649de..7631a9aa54 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5130,8 +5130,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"