From a4cdbb6235cc3128b4228e23fe66bf0d1e299c50 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 03:39:10 +0200 Subject: [PATCH 1/2] XFA: decode BMP and TIFF pictures (fixes #1644) Pictures in templates and data that are BMP or TIFF were left out with a warning. They are now recognised by their signature, sized from their headers, and decoded through the same bounded reader as GIF (first page of a TIFF). A BMP needs a valid info-header size after "BM", so text starting with those two letters is still not taken for one. A BMP or TIFF chosen for an XFA image field is kept in the data as image/bmp or image/tiff, since the layout can now show it. --- crates/engine/src/lib.rs | 4 ++- crates/engine/src/tests.rs | 5 +++ crates/xfa/Cargo.toml | 2 +- crates/xfa/src/fixtures.rs | 56 ++++++++++++++++++++++++++++++++++ crates/xfa/src/image.rs | 59 ++++++++++++++++++++++++++---------- crates/xfa/src/layout.rs | 2 +- crates/xfa/src/pdf.rs | 8 ++--- crates/xfa/src/tests.rs | 54 +++++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 6 ++-- 9 files changed, 168 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf689af541..03e94bd0a3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2457,7 +2457,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()); @@ -3167,6 +3167,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 d5ffac5001..d53803560e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4295,6 +4295,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 54bb9bc216..4343486653 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -267,6 +267,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..7af9c0cb3a 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,25 @@ 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 => reader(data, image::ImageFormat::Tiff).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0), } } +/// 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 +105,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 +146,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 = + 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 +166,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 62d7beedf1..e6e5f58020 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -1529,7 +1529,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 cca62e64e7..9069e5965e 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); @@ -539,7 +539,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) => { @@ -564,7 +564,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 03eb2c67ad..cfae8f42fe 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -7,7 +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::{TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png}; +use crate::fixtures::{ + TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_bmp, tiny_png, tiny_tiff, +}; use crate::layout::Item; use crate::layout::WidgetKind; use crate::model::*; @@ -1094,7 +1096,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); @@ -1141,6 +1143,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 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d8fbe77ddf..348274ea9d 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/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"] -notes = "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 shows the public ISCIII 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, 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. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #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/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/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "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"] +notes = "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 shows the public ISCIII 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, 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. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #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::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, 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, 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" From 947f0f2994b08c3d62797ce9ee5077eac0d2956a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 20:37:33 +0200 Subject: [PATCH 2/2] XFA: turn TIFF pictures by their Orientation tag --- crates/xfa/src/image.rs | 27 ++++++++++++++++++++++++--- crates/xfa/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs index 7af9c0cb3a..5ec42873ed 100644 --- a/crates/xfa/src/image.rs +++ b/crates/xfa/src/image.rs @@ -71,10 +71,31 @@ fn header_size(data: &[u8]) -> Option<(u32, u32)> { } // 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 => reader(data, image::ImageFormat::Tiff).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> { @@ -153,8 +174,8 @@ pub fn decode(data: &[u8]) -> Result { Kind::Bmp => image::ImageFormat::Bmp, _ => image::ImageFormat::Tiff, }; - let img = - reader(data, format).decode().map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?; + 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) { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index cfae8f42fe..3e4a16e9bf 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2117,3 +2117,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]); +}