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 {