#6005: strict PNG refuses a metafile page background the raster leaves out; O(1) listed-picture dedupe

- wordcraft_geom::picture::background_size is the one predicate the page raster and the strict check use for a page background; a metafile page picture fails strict for PNG (unsupported format, source background) and still passes for PDF.
- undecodable_pictures dedupes listed pictures with a HashSet alongside the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
This commit is contained in:
Martin Ball
2026-11-09 11:51:00 +00:00
co-authored by Claude Opus 5.5
parent 40becaa3c2
commit fe904e4a8a
4 changed files with 52 additions and 10 deletions
+28
View File
@@ -465,3 +465,31 @@ fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() {
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_dir_all(d).unwrap();
}
/// The page raster leaves out a metafile page background (it only draws raster ones), so a PNG
/// would silently lack it: strict refuses that output, and a PDF, which draws it as vector, passes.
#[test]
fn a_metafile_page_background_fails_strict_for_png_but_not_for_pdf() {
if without_fonts() {
return;
}
let d = dir("emf-background");
let mut doc = Document::from_text("text");
doc.media.insert("bg.emf".into(), std::sync::Arc::new(emf_with_text("abc")));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.emf".into(), fill_type: "frame".into(), title: String::new() });
let input = d.join("in.json");
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let png = d.join("out.png");
let r = convert(&input, &png, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let json = report_json(&r);
assert_eq!(json["missing_pictures"][0]["part"], "bg.emf", "{}", stderr(&r));
assert_eq!(json["missing_pictures"][0]["source_part"], "background");
assert_eq!(json["missing_pictures"][0]["reason"], "unsupported_format");
assert!(!png.exists());
let pdf = d.join("out.pdf");
let r = convert(&input, &pdf, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(pdf.exists());
std::fs::remove_dir_all(d).unwrap();
}
+12
View File
@@ -253,6 +253,18 @@ pub fn decode_for_drawing(bytes: &[u8], budget: u64, max_side: u32) -> Result<Dy
Ok(img)
}
/// Largest page-background picture the page raster draws, in pixels (hostile files).
pub const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
/// The pixel size of a page-background picture the page raster draws (Design > Page Color > Fill
/// Effects), or None when it leaves the background out: not a raster picture it can read (a
/// metafile is not) or one with no pixels or more than [`MAX_BACKGROUND_PIXELS`]. The raster and
/// the strict check both ask this, so they can't drift.
pub fn background_size(bytes: &[u8]) -> Option<(u32, u32)> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
(w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_BACKGROUND_PIXELS).then_some((w, h))
}
/// Whether a raster picture decodes for drawing, by the rule the exporters use ([`probe`] within
/// [`crate::MAX_DECODED_BYTES`], then [`decode_for_drawing`] with `max_side`).
pub fn check_for_drawing(bytes: &[u8], max_side: u32) -> Result<(), DrawFailure> {
+11 -3
View File
@@ -69,6 +69,8 @@ pub fn undecodable_pictures(
let mut total: u64 = 0;
// Hashes of the distinct ones past the cap, so the total counts each once.
let mut unlisted: HashSet<u64> = HashSet::new();
// What `out` holds, for an O(1) test of "already listed".
let mut listed: HashSet<MissingPicture> = HashSet::new();
let mut verdicts: HashMap<String, Option<DrawFailure>> = HashMap::new();
let range = pages.unwrap_or(0..=usize::MAX);
for (n, page) in layout.pages.iter().enumerate() {
@@ -95,7 +97,7 @@ pub fn undecodable_pictures(
};
let part = doc.missing_pictures.media_parts.get(&key).cloned().unwrap_or(key);
let item = MissingPicture { part, story: story_name(doc, region, it), reason };
if out.contains(&item) {
if listed.contains(&item) {
continue;
}
if out.len() >= MAX_MISSING_PICTURES {
@@ -106,6 +108,7 @@ pub fn undecodable_pictures(
continue;
}
total = total.saturating_add(1);
listed.insert(item.clone());
out.push(item);
}
}
@@ -115,8 +118,12 @@ pub fn undecodable_pictures(
if let Some(pic) = &doc.settings.page_picture
&& layout.pages.iter().enumerate().any(|(n, _)| range.contains(&n))
{
// The page raster leaves out a background it can't read as a raster picture (a metafile),
// which the PDF draws as vector.
let failure = match doc.media.get(&pic.media) {
Some(bytes) => media_check(bytes, output).err(),
Some(bytes) => media_check(bytes, output).err().or_else(|| {
(output == StrictOutput::Png && wordcraft_geom::picture::background_size(bytes).is_none()).then_some(DrawFailure::UnsupportedFormat)
}),
None => Some(DrawFailure::Undecodable),
};
if let Some(failure) = failure {
@@ -126,7 +133,7 @@ pub fn undecodable_pictures(
};
let part = doc.missing_pictures.media_parts.get(&pic.media).cloned().unwrap_or_else(|| pic.media.clone());
let item = MissingPicture { part, story: "background".into(), reason };
if !out.contains(&item) {
if !listed.contains(&item) {
if out.len() >= MAX_MISSING_PICTURES {
truncated = true;
if unlisted.insert(picture_hash(&item)) {
@@ -134,6 +141,7 @@ pub fn undecodable_pictures(
}
} else {
total = total.saturating_add(1);
listed.insert(item.clone());
out.push(item);
}
}
+1 -7
View File
@@ -309,18 +309,12 @@ fn device_pixel(view: Affine) -> f64 {
if scale.is_finite() && scale > 1e-9 { 1.0 / scale } else { 1.0 }
}
/// Largest page-background picture decoded, in pixels (hostile files).
const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
/// Draw the page's picture or texture fill (Design › Page Color › Fill Effects) behind the content:
/// tiled at its natural size (96 dpi) for a `tile` fill, stretched over the page otherwise.
pub(crate) fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) {
let Some(pic) = &doc.settings.page_picture else { return };
let Some(bytes) = doc.media.get(&pic.media) else { return };
let (nw, nh) = match image_size(bytes) {
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => (w as f64, h as f64),
_ => return,
};
let Some((nw, nh)) = wordcraft_geom::picture::background_size(bytes).map(|(w, h)| (f64::from(w), f64::from(h))) else { return };
let Some(pm) = pixmap_for(doc, &pic.media, wordcraft_doc::picfx::PicFx::default()) else { return };
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
let (w, h) = (page.w as f64, (page.h as f64).min(1e6));