diff --git a/apps/wordcraft-cli/tests/it/strict.rs b/apps/wordcraft-cli/tests/it/strict.rs index 2ea53c5e91..399fc09927 100644 --- a/apps/wordcraft-cli/tests/it/strict.rs +++ b/apps/wordcraft-cli/tests/it/strict.rs @@ -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(); +} diff --git a/crates/geom/src/picture.rs b/crates/geom/src/picture.rs index 6c63ac1c8d..1d05eaa868 100644 --- a/crates/geom/src/picture.rs +++ b/crates/geom/src/picture.rs @@ -253,6 +253,18 @@ pub fn decode_for_drawing(bytes: &[u8], budget: u64, max_side: u32) -> Result 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> { diff --git a/crates/layout/src/strict.rs b/crates/layout/src/strict.rs index 5c9608a9ec..c9dcfec389 100644 --- a/crates/layout/src/strict.rs +++ b/crates/layout/src/strict.rs @@ -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 = HashSet::new(); + // What `out` holds, for an O(1) test of "already listed". + let mut listed: HashSet = HashSet::new(); let mut verdicts: HashMap> = 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); } } diff --git a/crates/render/src/media.rs b/crates/render/src/media.rs index bb77e71f27..2dbf1faf88 100644 --- a/crates/render/src/media.rs +++ b/crates/render/src/media.rs @@ -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));