diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5bf87735b5..fabf9e80df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2474,7 +2474,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); if image_field { let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those"))); + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); }; pdfcraft_forms::set_button_icon(doc, name, img, px)?; let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); @@ -3192,6 +3192,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), pdfcraft_xfa::image::Kind::Png => Some("image/png"), pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), + pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0f75139717..13ae3085af 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4467,6 +4467,11 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\"")); assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo"); // Only pictures the form can show; only image fields. + for (data, declared) in [(pdfcraft_xfa::fixtures::tiny_bmp(false), "image/bmp"), (pdfcraft_xfa::fixtures::tiny_tiff(false), "image/tiff")] { + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(data) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains(&format!("xfa:contentType=\"{declared}\""))); + } assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err()); assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err()); // One datasets stream per edit, however many steps it took. diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml index 29032d8930..dcec1e75bc 100644 --- a/crates/xfa/Cargo.toml +++ b/crates/xfa/Cargo.toml @@ -17,5 +17,5 @@ roxmltree = "0.20" base64 = "0.22" png = "0.18" sha2 = "0.11" -image = { version = "0.25", default-features = false, features = ["gif"] } +image = { version = "0.25", default-features = false, features = ["bmp", "gif", "tiff"] } thiserror = { workspace = true } diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 2eb6d3550f..a0307a6944 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -403,6 +403,62 @@ const TINY_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x /// 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 2 × 1 BMP, red then blue: 24-bit, or 8-bit with a two-colour palette. +pub fn tiny_bmp(palette: bool) -> Vec { + let (bpp, colours, pixels): (u16, &[u8], &[u8]) = + if palette { (8, &[0, 0, 255, 0, 255, 0, 0, 0], &[0, 1, 0, 0]) } else { (24, &[], &[0, 0, 255, 255, 0, 0, 0, 0]) }; + let offset = 54 + colours.len() as u32; + let mut b = b"BM".to_vec(); + b.extend_from_slice(&(offset + pixels.len() as u32).to_le_bytes()); + b.extend_from_slice(&[0; 4]); + b.extend_from_slice(&offset.to_le_bytes()); + for v in [40u32, 2, 1] { + b.extend_from_slice(&v.to_le_bytes()); + } + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&bpp.to_le_bytes()); + for v in [0u32, pixels.len() as u32, 0, 0, (colours.len() / 4) as u32, 0] { + b.extend_from_slice(&v.to_le_bytes()); + } + b.extend_from_slice(colours); + b.extend_from_slice(pixels); + b +} + +/// A 1 × 1 little-endian RGB TIFF of colour `#123456`, uncompressed or LZW. +pub fn tiny_tiff(lzw: bool) -> Vec { + // Clear, 0x12, 0x34, 0x56, end of information, as 9-bit codes, most significant bit first. + let strip: &[u8] = if lzw { &[0x80, 0x04, 0x86, 0x85, 0x68, 0x08] } else { &[0x12, 0x34, 0x56] }; + // Header (8), the IFD (2 + 9 entries of 12 + 4), then BitsPerSample's three values (6). + let (bps_at, strip_at) = (122u32, 128u32); + let (short, long) = (3u16, 4u16); + let mut b = b"II*\0".to_vec(); + b.extend_from_slice(&8u32.to_le_bytes()); + b.extend_from_slice(&9u16.to_le_bytes()); + for (tag, ty, count, value) in [ + (256u16, short, 1u32, 1u32), + (257, short, 1, 1), + (258, short, 3, bps_at), + (259, short, 1, if lzw { 5 } else { 1 }), + (262, short, 1, 2), + (273, long, 1, strip_at), + (277, short, 1, 3), + (278, short, 1, 1), + (279, long, 1, strip.len() as u32), + ] { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&ty.to_le_bytes()); + b.extend_from_slice(&count.to_le_bytes()); + b.extend_from_slice(&value.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); + for _ in 0..3 { + b.extend_from_slice(&8u16.to_le_bytes()); + } + b.extend_from_slice(strip); + b +} + /// 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 diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs index 24ae77f559..5ec42873ed 100644 --- a/crates/xfa/src/image.rs +++ b/crates/xfa/src/image.rs @@ -1,5 +1,5 @@ -//! 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 +//! Pictures in templates and data: JPEG (embedded as is), PNG, GIF, BMP and TIFF (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 @@ -13,6 +13,8 @@ pub enum Kind { Jpeg, Png, Gif, + Bmp, + Tiff, } /// What the bytes are, by their signature (the declared content type is often wrong). @@ -23,11 +25,22 @@ pub fn kind(data: &[u8]) -> Option { Some(Kind::Png) } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { Some(Kind::Gif) + } else if is_bmp(data) { + Some(Kind::Bmp) + } else if data.starts_with(b"II*\0") || data.starts_with(b"MM\0*") { + Some(Kind::Tiff) } else { None } } +/// "BM" alone is two letters of text: the info header that follows must have one of the sizes +/// the BMP versions define. +fn is_bmp(data: &[u8]) -> bool { + data.starts_with(b"BM") + && data.get(14..18).is_some_and(|b| matches!(u32::from_le_bytes([b[0], b[1], b[2], b[3]]), 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124)) +} + /// 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)> { @@ -56,9 +69,46 @@ fn header_size(data: &[u8]) -> Option<(u32, u32)> { let (w, h) = (le(6)?, le(8)?); (w > 0 && h > 0).then_some((w, h)) } + // The decoders read only the headers (TIFF: the first IFD) until asked for pixels. + Kind::Bmp => reader(data, image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0), + Kind::Tiff => { + use image::{ImageDecoder, metadata::Orientation}; + let mut decoder = reader(data, image::ImageFormat::Tiff).into_decoder().ok()?; + let (w, h) = decoder.dimensions(); + // Shown turned a quarter: the size viewers show is the stored one swapped. + let turned = matches!( + decoder.orientation().ok()?, + Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH + ); + let (w, h) = if turned { (h, w) } else { (w, h) }; + (w > 0 && h > 0).then_some((w, h)) + } } } +/// A TIFF's first page as viewers show it: turned and flipped as its Orientation tag says. +fn decode_tiff(data: &[u8]) -> image::ImageResult { + use image::ImageDecoder; + let mut decoder = reader(data, image::ImageFormat::Tiff).into_decoder()?; + let orientation = decoder.orientation()?; + let mut img = image::DynamicImage::from_decoder(decoder)?; + img.apply_orientation(orientation); + Ok(img) +} + +/// A decoder told the limits, so a frame larger than its header claims is refused before it is +/// allocated. +fn reader(data: &[u8], format: image::ImageFormat) -> image::ImageReader> { + let mut reader = image::ImageReader::new(std::io::Cursor::new(data)); + reader.set_format(format); + 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); + reader +} + /// Samples ready for an image XObject. #[derive(Debug)] pub struct Decoded { @@ -76,8 +126,8 @@ 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. +/// Decode a PNG, GIF (its first frame), BMP or TIFF (its first page). 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()))?; @@ -117,17 +167,15 @@ pub fn decode(data: &[u8]) -> Result { 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()) })?; + Some(k @ (Kind::Gif | Kind::Bmp | Kind::Tiff)) => { + // A GIF's frame may be larger than the logical screen the header gives. + let format = match k { + Kind::Gif => image::ImageFormat::Gif, + Kind::Bmp => image::ImageFormat::Bmp, + _ => image::ImageFormat::Tiff, + }; + let img = if k == Kind::Tiff { decode_tiff(data) } else { reader(data, format).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) { @@ -139,6 +187,6 @@ pub fn decode(data: &[u8]) -> Result { 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())), + Some(Kind::Jpeg) | None => Err(bad("not a PNG, GIF, BMP or TIFF".into())), } } diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 38c4d72705..b2c17a0597 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1613,7 +1613,7 @@ impl Layouter<'_> { 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")); + self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG, GIF, BMP or TIFF and is left out")); None } Err(_) => { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index bbde05ab0d..8e0c47d888 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -491,8 +491,8 @@ 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, + /// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs + /// 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); @@ -542,7 +542,7 @@ impl Emitter<'_> { 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) => { + Some(crate::image::Kind::Png | crate::image::Kind::Gif | crate::image::Kind::Bmp | crate::image::Kind::Tiff) => { let img = match crate::image::decode(data) { Ok(img) => img, Err(why) => { @@ -567,7 +567,7 @@ impl Emitter<'_> { 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")); + self.warnings.push(format!("a {what} image is not a JPEG, PNG, GIF, BMP or TIFF and is left out")); None } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 1d6efc152f..b3f79accbd 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -8,8 +8,8 @@ use crate::data::{ }; use crate::data::{add_data_instances, remove_data_instance, write_data_value}; use crate::fixtures::{ - TINY_GIF, bound_date_template, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png, - unnamed_hidden_template, + TINY_GIF, bound_date_template, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_bmp, tiny_png, + tiny_tiff, unnamed_hidden_template, }; use crate::layout::Item; use crate::layout::WidgetKind; @@ -1424,7 +1424,7 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte 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")] { + for (data, what) in [("Qk0AAAAA", "not a JPEG, PNG, GIF, BMP or TIFF"), ("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); @@ -1471,6 +1471,54 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000)); } +/// Designer forms often carry BMP and TIFF pictures, declared as `image/bmp` and `image/tif` +/// (the Government of Canada's IMM 5257 and IMM 5710 have both). They were left out. +#[test] +fn bmp_and_tiff_pictures_are_decoded_and_drawn() { + use crate::image::{Kind, decode, kind, size}; + for palette in [false, true] { + let bmp = tiny_bmp(palette); + assert_eq!(kind(&bmp), Some(Kind::Bmp)); + assert_eq!(size(&bmp), Some((2, 1))); + let d = decode(&bmp).unwrap(); + assert_eq!((d.width, d.height, d.color_space), (2, 1, "DeviceRGB"), "palette: {palette}"); + assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "red then blue, palette: {palette}"); + assert_eq!(d.alpha, None); + } + for lzw in [false, true] { + let tiff = tiny_tiff(lzw); + assert_eq!(kind(&tiff), Some(Kind::Tiff)); + assert_eq!(size(&tiff), Some((1, 1))); + let d = decode(&tiff).unwrap(); + assert_eq!((d.width, d.height, d.color_space), (1, 1, "DeviceRGB"), "lzw: {lzw}"); + assert_eq!(d.samples, vec![0x12, 0x34, 0x56], "lzw: {lzw}"); + } + assert_eq!(kind(b"MM\0*\0\0\0\x08"), Some(Kind::Tiff), "big-endian"); + let mut cut = tiny_tiff(true); + cut.truncate(60); + assert!(decode(&cut).is_err()); + // In a form: drawn, with no warning, whatever the declared type says. + use base64::Engine; + for (data, declared) in + [(tiny_bmp(false), "image/bmp"), (tiny_bmp(true), "image/x-ms-bmp"), (tiny_tiff(false), "image/tif"), (tiny_tiff(true), "image/tiff")] + { + let b64 = base64::engine::general_purpose::STANDARD.encode(&data); + let gif = base64::engine::general_purpose::STANDARD.encode(TINY_GIF); + let xml = + fields_template("").replace(&format!(r#"{gif}"#), &format!(r#"{b64}"#)); + assert!(xml.contains(declared)); + let mut doc = Document::open(Arc::new(shell(&xml))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("left out")), "{declared}: {:?}", report.warnings); + let samples: Vec = if declared.contains("bmp") { vec![255, 0, 0, 0, 0, 255] } else { vec![0x12, 0x34, 0x56] }; + let drawn = doc.object_numbers().into_iter().any(|n| { + matches!(&*doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }), Object::Stream(st) + if st.dict.name(b"Subtype") == Some(b"Image") && st.decoded().ok().as_ref() == Some(&samples)) + }); + assert!(drawn, "{declared}: its pixels are an image XObject"); + } +} + /// A repeating subform drawn inside positioned content (a top-to-bottom table in an area of a /// positioned subform) gets its `occur` instances, as in Adobe's viewers: real Designer forms /// have tables like this whose calculations read every row. Before, only one row was laid @@ -2503,3 +2551,40 @@ fn som_indices_count_earlier_same_named_siblings() { let sibs = SomIndices::of(&children); assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); } + +/// A TIFF is drawn as viewers show it, turned by its Orientation tag: a 2 × 1 picture (red, +/// blue) tagged 6 (turn 90° clockwise) is 1 × 2, red above blue, in layout and in the PDF. +#[test] +fn a_tiff_picture_is_turned_by_its_orientation_tag() { + let strip: &[u8] = &[255, 0, 0, 0, 0, 255]; + let (bps_at, strip_at) = (8 + 2 + 10 * 12 + 4, 8 + 2 + 10 * 12 + 4 + 6); + let mut b = b"II*\0".to_vec(); + b.extend_from_slice(&8u32.to_le_bytes()); + b.extend_from_slice(&10u16.to_le_bytes()); + for (tag, ty, count, value) in [ + (256u16, 3u16, 1u32, 2u32), + (257, 3, 1, 1), + (258, 3, 3, bps_at as u32), + (259, 3, 1, 1), + (262, 3, 1, 2), + (273, 4, 1, strip_at as u32), + (274, 3, 1, 6), + (277, 3, 1, 3), + (278, 3, 1, 1), + (279, 4, 1, strip.len() as u32), + ] { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&ty.to_le_bytes()); + b.extend_from_slice(&count.to_le_bytes()); + b.extend_from_slice(&value.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); + for _ in 0..3 { + b.extend_from_slice(&8u16.to_le_bytes()); + } + b.extend_from_slice(strip); + let d = crate::image::decode(&b).unwrap(); + assert_eq!(crate::image::size(&b), Some((1, 2)), "layout size"); + assert_eq!((d.width, d.height), (1, 2), "decoded size"); + assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f65e24c0da..d1c5a0c48e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,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/ui_group/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/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/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools"] -notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), 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. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); 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; barcodes are drawn as their text, not bars; 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/ui_group/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/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/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/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "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", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] +notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF 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. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), 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. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. 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, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); 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; 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" @@ -5245,7 +5245,7 @@ status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten"