diff --git a/Cargo.lock b/Cargo.lock index 3869c84c..37804c41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,6 +904,7 @@ dependencies = [ "pkg-config", "rofd-core", "thiserror 2.0.20", + "tiff", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 5690f104..e9a7e2d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,4 @@ serde-xml-rs = "0.6.0" thiserror = "2.0.20" xml-rs = "0.8.27" zip = { version = "2.4.2", default-features = false, features = ["deflate"] } +tiff = "0.9" diff --git a/crates/rofd-core/src/content.rs b/crates/rofd-core/src/content.rs index d1e1fc9f..649d0f71 100644 --- a/crates/rofd-core/src/content.rs +++ b/crates/rofd-core/src/content.rs @@ -1517,10 +1517,15 @@ impl ConversionContext<'_> { return Ok(Vec::new()); }; if clips.clips.is_empty() { - return Err(Error::InvalidStructure { - path: self.path.to_owned(), - message: "Clips must contain at least one Clip".to_owned(), - }); + // Invoice generators emit empty elements; in lenient mode + // treat them as absent instead of rejecting the whole page. + if self.document.strictness() == crate::Strictness::Strict { + return Err(Error::InvalidStructure { + path: self.path.to_owned(), + message: "Clips must contain at least one Clip".to_owned(), + }); + } + return Ok(Vec::new()); } let affected_by_object_transform = parse_object_bool( clips.trans_flag.as_deref(), diff --git a/crates/rofd-core/src/raw.rs b/crates/rofd-core/src/raw.rs index ec743944..b47d3d60 100644 --- a/crates/rofd-core/src/raw.rs +++ b/crates/rofd-core/src/raw.rs @@ -109,8 +109,10 @@ pub(crate) struct ResourceRoot { /// ano.ofd); every block contributes entries to the catalog. #[serde(rename = "Fonts", default)] pub(crate) fonts: Vec, - #[serde(rename = "MultiMedias")] - pub(crate) multi_medias: Option, + /// Real-world producers occasionally emit several MultiMedias blocks + /// (invoice generators); every block contributes entries to the catalog. + #[serde(rename = "MultiMedias", default)] + pub(crate) multi_medias: Vec, #[serde(rename = "DrawParams")] pub(crate) draw_params: Option, #[serde(rename = "ColorSpaces")] diff --git a/crates/rofd-core/src/resources.rs b/crates/rofd-core/src/resources.rs index 0bcc6451..259bea0b 100644 --- a/crates/rofd-core/src/resources.rs +++ b/crates/rofd-core/src/resources.rs @@ -164,7 +164,12 @@ impl ImageResource { self.id } - /// Returns the declared and supported encoded image format. + /// Returns the declared encoded image format. + /// + /// This is catalog metadata only: real-world producers may misdeclare + /// formats, so decoding follows the signature-detected format (see + /// `rofd_render`'s `detect_format`), and `rofd-render` also consults the + /// declared value as a heuristic for JBIG2 diagnostic handling. pub fn format(&self) -> ImageFormat { self.format } @@ -345,6 +350,12 @@ impl ResourceCatalog { message: "resource catalog declares duplicate Fonts elements".to_owned(), }); } + if strictness == crate::Strictness::Strict && root.multi_medias.len() > 1 { + return Err(Error::InvalidStructure { + path: path.as_str().to_owned(), + message: "resource catalog declares duplicate MultiMedias elements".to_owned(), + }); + } for font in root.fonts.into_iter().flat_map(|fonts| fonts.entries) { catalog.insert_font(font, &root.base_loc, path)?; } diff --git a/crates/rofd-core/tests/clipping.rs b/crates/rofd-core/tests/clipping.rs index 2abda779..998b8bd5 100644 --- a/crates/rofd-core/tests/clipping.rs +++ b/crates/rofd-core/tests/clipping.rs @@ -121,10 +121,29 @@ fn trans_flag_defaults_false_and_accepts_xml_schema_boolean_spellings() { } } +#[test] +fn empty_clips_element_is_tolerated_in_lenient_mode() { + // Invoice generators emit empty elements; lenient mode treats + // them as absent rather than rejecting the whole page. + let page = open_page(&path_with_clips("", "M 0 0")).unwrap(); + assert_eq!(path_object(&page).clips().len(), 0); +} + +#[test] +fn empty_clips_element_is_rejected_in_strict_mode() { + let error = open_page_strict(&path_with_clips("", "M 0 0")).unwrap_err(); + assert!( + matches!( + error, + Error::InvalidStructure { ref message, .. } if message.contains("Clips must contain at least one Clip") + ), + "got {error:?}" + ); +} + #[test] fn rejects_missing_or_ambiguous_clip_structure() { let cases = [ - ("", "Clips must contain at least one Clip"), ( "", "Clip must contain at least one Area", diff --git a/crates/rofd-core/tests/resources.rs b/crates/rofd-core/tests/resources.rs index 5f29c7bf..c79b7cb9 100644 --- a/crates/rofd-core/tests/resources.rs +++ b/crates/rofd-core/tests/resources.rs @@ -548,3 +548,45 @@ fn duplicate_fonts_blocks_are_rejected_in_strict_mode() { Err(Error::InvalidStructure { .. }) )); } + +const DUPLICATE_MULTI_MEDIAS_CATALOG: &[u8] = br#" + x.png + y.png +"#; + +const FAKE_PNG: &[u8] = b"\x89PNG\r\n\x1a\nfake"; + +#[test] +fn duplicate_multi_medias_blocks_are_merged_in_lenient_mode() { + // Invoice generators emit several MultiMedias blocks in one catalog. + let document = open( + "Res/DupMedia.xml", + &[ + ("Doc_0/Res/DupMedia.xml", DUPLICATE_MULTI_MEDIAS_CATALOG), + ("Doc_0/Res/x.png", FAKE_PNG), + ("Doc_0/Res/y.png", FAKE_PNG), + ], + ); + assert!(document.image_resource(1).is_ok()); + assert!(document.image_resource(2).is_ok()); +} + +#[test] +fn duplicate_multi_medias_blocks_are_rejected_in_strict_mode() { + let bytes = package( + "Res/DupMedia.xml", + &[("Doc_0/Res/DupMedia.xml", DUPLICATE_MULTI_MEDIAS_CATALOG)], + ); + let document = Document::from_bytes( + bytes, + LoadOptions { + strictness: rofd_core::Strictness::Strict, + ..LoadOptions::default() + }, + ) + .unwrap(); + assert!(matches!( + document.image_resource(1), + Err(Error::InvalidStructure { .. }) + )); +} diff --git a/crates/rofd-render/Cargo.toml b/crates/rofd-render/Cargo.toml index a1a2f928..b235fca5 100644 --- a/crates/rofd-render/Cargo.toml +++ b/crates/rofd-render/Cargo.toml @@ -27,3 +27,4 @@ pkg-config.workspace = true [dev-dependencies] zip = { workspace = true, features = ["deflate"] } +tiff = { workspace = true } diff --git a/crates/rofd-render/src/images.rs b/crates/rofd-render/src/images.rs index fd0bf7d4..0d595354 100644 --- a/crates/rofd-render/src/images.rs +++ b/crates/rofd-render/src/images.rs @@ -124,6 +124,9 @@ enum DecodeFailure { resource_id: u64, path: String, }, + /// Dead in production since preflight stopped enforcing the declared + /// format; kept so `from_error`/`into_error` still cover the public + /// [`Error::ImageFormatMismatch`] variant losslessly. FormatMismatch { resource_id: u64, path: String, @@ -522,14 +525,9 @@ fn preflight(resource: &ImageResource, limits: &ResourceLimits) -> Result Result { if bytes.starts_with(b"II\x2a\x00") || bytes.starts_with(b"MM\x00\x2a") { return Ok(ImageFormat::Tiff); } + // Real-world producers occasionally declare classic TIFF for BigTIFF + // data; both signatures decode through the `image` crate's TIFF reader. + if bytes.starts_with(b"II\x2b\x00") || bytes.starts_with(b"MM\x00\x2b") { + return Ok(ImageFormat::Tiff); + } if bytes.starts_with(b"\x97JB2\r\n\x1a\n") { return Ok(ImageFormat::Jbig2); } diff --git a/crates/rofd-render/src/lib.rs b/crates/rofd-render/src/lib.rs index 53375744..02a25101 100644 --- a/crates/rofd-render/src/lib.rs +++ b/crates/rofd-render/src/lib.rs @@ -270,6 +270,11 @@ pub enum Error { path: String, }, /// The byte signature does not agree with the catalog's declared image format. + /// + /// Retained for public-API stability: the preflight stage no longer + /// constructs this variant (signature-detected formats are authoritative + /// for decoding), but the FFI error mapping and potential downstream + /// matches still reference it. #[error( "image resource {resource_id} at {path} declares {declared:?} but contains {detected:?}" )] diff --git a/crates/rofd-render/tests/images.rs b/crates/rofd-render/tests/images.rs index b6d4c19a..0fcc4736 100644 --- a/crates/rofd-render/tests/images.rs +++ b/crates/rofd-render/tests/images.rs @@ -2,7 +2,7 @@ use std::io::{Cursor, Write}; use std::sync::{Arc, Barrier}; use std::thread; -use rofd_core::{Document, ImageFormat, LoadOptions, ResourceLimits}; +use rofd_core::{Document, LoadOptions, ResourceLimits}; use rofd_render::{Error, ImageDecoder}; use zip::{write::SimpleFileOptions, ZipWriter}; @@ -74,6 +74,31 @@ fn decode(bytes: &[u8], format: &str) -> rofd_render::Result(3, 2, &image_data) + .unwrap(); + } + let encoded = bytes.into_inner(); + assert!( + encoded.starts_with(b"II\x2b\x00"), + "expected BigTIFF signature" + ); + + let decoded = decode(&encoded, "TIFF").unwrap(); + assert_eq!(decoded.dimensions(), (3, 2)); + // The signature also overrides a mismatching declaration. + let decoded = decode(&encoded, "PNG").unwrap(); + assert_eq!(decoded.dimensions(), (3, 2)); +} + fn png_header(width: u32, height: u32) -> Vec { let mut bytes = Vec::from(&b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR"[..]); bytes.extend_from_slice(&width.to_be_bytes()); @@ -152,25 +177,14 @@ fn lossless_formats_decode_in_source_orientation_with_opaque_rgba_channels() { } #[test] -fn magic_is_required_and_must_match_the_declared_format() { - assert!(matches!( - decode(PNG, "JPEG"), - Err(Error::ImageFormatMismatch { - resource_id: 10, - declared: ImageFormat::Jpeg, - detected: ImageFormat::Png, - .. - }) - )); - assert!(matches!( - decode(JPEG, "PNG"), - Err(Error::ImageFormatMismatch { - resource_id: 10, - declared: ImageFormat::Png, - detected: ImageFormat::Jpeg, - .. - }) - )); +fn magic_is_required_and_overrides_the_declared_format() { + // Real-world producers misdeclare formats (invoices declaring PNG for + // JPEG data); the byte signature is authoritative when the two disagree. + let png_declared_jpeg = decode(PNG, "JPEG").unwrap(); + assert_eq!(png_declared_jpeg.resource_id(), 10); + assert_eq!(png_declared_jpeg.dimensions(), (3, 2)); + let jpeg_declared_png = decode(JPEG, "PNG").unwrap(); + assert_eq!(jpeg_declared_png.dimensions(), (3, 2)); assert!(matches!( decode(b"WEBP-not-supported", "PNG"), Err(Error::UnsupportedImageFormat {