mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
#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:
co-authored by
Claude Opus 5.5
parent
40becaa3c2
commit
fe904e4a8a
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user