From b73c21db37acf667461d99007be84ee7c2a8099b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:36:39 -0800 Subject: [PATCH] Data merge: a file that is not an image does not hide a later image candidate The image search stopped at the first file that could be read and checked whether it decodes afterwards. A non-image file at an earlier candidate (the drive root, or beside the data file) then hid a valid image beside the data file or the document, and the field was reported missing. The decode check now runs inside the search, so only an image ends it. --- crates/engine/src/cmd/datamerge/fill.rs | 11 ++++++----- crates/engine/src/cmd/datamerge/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index caea4d67e..ebe950fb6 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -464,7 +464,7 @@ struct LoadedImage { fn load_image(cell: &str, sources: &Sources) -> Option { let Sources { data_dir, doc_dir, images, .. } = *sources; let cands = image_candidates(cell, data_dir, doc_dir); - let (path, bytes) = cands.iter().find_map(|p| { + cands.iter().find_map(|p| { // Read once per merge: many records naming one file is the normal case, and reading it // per record is the amplification (#1595). let bytes = images.get_or_read(p, || { @@ -475,10 +475,11 @@ fn load_image(cell: &str, sources: &Sources) -> Option { } crate::io::read_file(p).ok().filter(|b| !b.is_empty()) })?; - Some((p.clone(), bytes)) - })?; - let (pixels, natural) = designcraft_render::image_size(&bytes).zip(designcraft_render::natural_size(&bytes, 0))?; - Some(LoadedImage { path, bytes, pixels, natural }) + // A file that does not decode leaves the search going, so a valid image at a later + // candidate is still found. + let (pixels, natural) = designcraft_render::image_size(&bytes).zip(designcraft_render::natural_size(&bytes, 0))?; + Some(LoadedImage { path: p.clone(), bytes, pixels, natural }) + }) } fn report_missing(report: &mut FillReport, record: u32, field: &str, cell: &str) { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 39b1988e2..5480a5686 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -914,6 +914,28 @@ fn an_image_path_with_a_leading_separator_is_found_beside_the_data_file() { assert_eq!(r["missingImages"].as_array().map(Vec::len), Some(0), "{r}"); } +/// A file at an earlier candidate that is not an image does not hide a valid image at a later +/// one: the search goes on past it. +#[test] +fn a_non_image_file_beside_the_data_does_not_hide_the_image_beside_the_document() { + let dir = scratch("undecodable-image"); + let (data, docs) = (dir.join("data"), dir.join("docs")); + write(&data.join("pics").join("a.png"), "not an image"); + write_png_sized(&docs.join("pics").join("a.png"), 12, 12); + write(&data.join("cards.csv"), "@Photo\npics/a.png\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("cards.csv")})); + exec(&mut s, "file.save", json!({"path": docs.join("layout.designcraft")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + exec(&mut s, "data.options", json!({"linkImages": true})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["missingImages"].as_array().map(Vec::len), Some(0), "{r}"); + let link = graphic_link(&s).unwrap_or_default(); + assert!(link.contains("docs"), "{link}"); +} + #[test] fn single_record_merge_copies_pages_and_leaves_the_template() { let mut s = Session::new();