Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 9 additions & 4 deletions crates/rofd-core/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Clips/> 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(),
Expand Down
6 changes: 4 additions & 2 deletions crates/rofd-core/src/raw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fonts>,
#[serde(rename = "MultiMedias")]
pub(crate) multi_medias: Option<MultiMedias>,
/// 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<MultiMedias>,
#[serde(rename = "DrawParams")]
pub(crate) draw_params: Option<DrawParams>,
#[serde(rename = "ColorSpaces")]
Expand Down
13 changes: 12 additions & 1 deletion crates/rofd-core/src/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)?;
}
Expand Down
21 changes: 20 additions & 1 deletion crates/rofd-core/tests/clipping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Clips/> elements; lenient mode treats
// them as absent rather than rejecting the whole page.
let page = open_page(&path_with_clips("<ofd: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("<ofd: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 = [
("<ofd:Clips/>", "Clips must contain at least one Clip"),
(
"<ofd:Clips><ofd:Clip/></ofd:Clips>",
"Clip must contain at least one Area",
Expand Down
42 changes: 42 additions & 0 deletions crates/rofd-core/tests/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,45 @@ fn duplicate_fonts_blocks_are_rejected_in_strict_mode() {
Err(Error::InvalidStructure { .. })
));
}

const DUPLICATE_MULTI_MEDIAS_CATALOG: &[u8] = br#"<ofd:Res xmlns:ofd="http://www.ofdspec.org/2016">
<ofd:MultiMedias><ofd:MultiMedia ID="1" Type="Image" Format="PNG"><ofd:MediaFile>x.png</ofd:MediaFile></ofd:MultiMedia></ofd:MultiMedias>
<ofd:MultiMedias><ofd:MultiMedia ID="2" Type="Image" Format="PNG"><ofd:MediaFile>y.png</ofd:MediaFile></ofd:MultiMedia></ofd:MultiMedias>
</ofd:Res>"#;

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(
"<ofd:PublicRes>Res/DupMedia.xml</ofd:PublicRes>",
&[
("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(
"<ofd:PublicRes>Res/DupMedia.xml</ofd:PublicRes>",
&[("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 { .. })
));
}
1 change: 1 addition & 0 deletions crates/rofd-render/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ pkg-config.workspace = true

[dev-dependencies]
zip = { workspace = true, features = ["deflate"] }
tiff = { workspace = true }
19 changes: 11 additions & 8 deletions crates/rofd-render/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -522,14 +525,9 @@ fn preflight(resource: &ImageResource, limits: &ResourceLimits) -> Result<Source
limits.max_encoded_image_bytes,
)?;
let detected = detect_format(resource)?;
if detected != resource.format() {
return Err(Error::ImageFormatMismatch {
resource_id: resource.id(),
path: resource.asset_path().to_owned(),
declared: resource.format(),
detected,
});
}
// The catalog's declared format is unreliable metadata from real-world
// producers (invoice generators declaring PNG for JPEG data); the byte
// signature is authoritative, so decoding always follows `detected`.
if detected == ImageFormat::Jbig2 {
if cfg!(feature = "jbig2") {
// Page dimensions only exist after decoding, so validation
Expand Down Expand Up @@ -577,6 +575,11 @@ fn detect_format(resource: &ImageResource) -> Result<ImageFormat> {
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);
}
Expand Down
5 changes: 5 additions & 0 deletions crates/rofd-render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
)]
Expand Down
54 changes: 34 additions & 20 deletions crates/rofd-render/tests/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -74,6 +74,31 @@ fn decode(bytes: &[u8], format: &str) -> rofd_render::Result<rofd_render::Decode
)
}

#[test]
fn bigtiff_signature_decodes_through_the_tiff_reader() {
// BigTIFF (II+\0 / MM\0+) data decodes through the `image` crate's TIFF
// reader, whether the catalog declares classic TIFF or anything else.
let image_data = vec![64u8; 3 * 2 * 3];
let mut bytes = Cursor::new(Vec::new());
{
let mut encoder = tiff::encoder::TiffEncoder::new_big(&mut bytes).unwrap();
encoder
.write_image::<tiff::encoder::colortype::RGB8>(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<u8> {
let mut bytes = Vec::from(&b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR"[..]);
bytes.extend_from_slice(&width.to_be_bytes());
Expand Down Expand Up @@ -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 {
Expand Down
Loading