diff --git a/README.md b/README.md index 27d718c0cd..bc290a56b3 100644 --- a/README.md +++ b/README.md @@ -174,16 +174,21 @@ output type, checked before the input is read) and 3 (strict refusal). On exit 3 ```sh wordcraft-cli convert in.docx out.pdf --strict # wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3) +# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format # {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false} ``` The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict` (`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point), `missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the -story part it sits in such as `word/header2.xml`, and a reason code), `truncated`, the two totals +story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also +`background` (the page picture), `body`, `header`, `footer` or `text box` where the part name is not +known (a document not opened from a DOCX), or a chunk path such as +`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals `total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and `pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`, -`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. +`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total +(`300 characters cannot be drawn (first 256 listed)`). Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text without `--markup`), and a character a fallback font draws is fine. A Private Use Area character diff --git a/crates/docx/src/read/chunk.rs b/crates/docx/src/read/chunk.rs index 47b354569d..5ba1a738c6 100644 --- a/crates/docx/src/read/chunk.rs +++ b/crates/docx/src/read/chunk.rs @@ -84,11 +84,16 @@ impl Reader<'_> { // each named by the chunk and its part in it (`word/afchunk1.docx > word/header1.xml`). // A chunk in another format brings none: those importers record nothing yet (#6007). let nested = std::mem::take(&mut doc.missing_pictures); + let list_len = nested.list.len() as u64; for mut item in nested.list { let inner = if item.story.is_empty() { nested.main.as_str() } else { item.story.as_str() }; item.story = format!("{part} > {inner}"); self.doc.missing_pictures.push(item); } + // The nested records past its list are the file's too; their stories carry the chunk's + // part, so none is one the outer list already counted. + let unlisted = nested.total.saturating_sub(list_len); + self.doc.missing_pictures.total = self.doc.missing_pictures.total.saturating_add(unlisted); self.doc.missing_pictures.truncated |= nested.truncated; let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm); // Bookmarks and comment marks read before the chunk stay before it. diff --git a/crates/docx/tests/it/alt_chunks.rs b/crates/docx/tests/it/alt_chunks.rs index 5160d2570e..c13bfddc3e 100644 --- a/crates/docx/tests/it/alt_chunks.rs +++ b/crates/docx/tests/it/alt_chunks.rs @@ -375,11 +375,24 @@ fn hostile_chunks_never_panic() { /// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it. fn inner_with_dropped_picture(external: bool) -> Vec { - let drawing = r#""#; - let doc = format!(r#"{drawing}"#); - let (target, mode) = if external { ("http://127.0.0.1:9/never-fetched.png", r#" TargetMode="External""#) } else { ("media/gone.png", "") }; + inner_with_dropped_pictures(1, external) +} + +/// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part. +fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec { + let drawing = r#""#; + let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect(); + let doc = format!(r#"{body}"#); + let mode = if external { r#" TargetMode="External""# } else { "" }; + let image = |i: usize| { + let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") }; + format!( + r#""# + ) + }; let rels = format!( - r#""# + r#"{}"#, + (0..n).map(image).collect::() ); let types = r#""#; let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); @@ -409,3 +422,14 @@ fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() { assert_eq!(got[0].2, reason); } } + +/// A chunk with more dropped pictures than the list holds still counts them all (#6005). +#[test] +fn a_chunks_total_of_dropped_pictures_counts_past_the_list() { + let n = wordcraft_doc::MAX_MISSING_PICTURES + 44; + let inner = inner_with_dropped_pictures(n, false); + let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap(); + assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES); + assert!(d.missing_pictures.truncated); + assert_eq!(d.missing_pictures.total, n as u64); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 548b1a057d..c676e2c802 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -120,7 +120,7 @@ pub fn specs() -> Vec { .params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character on the page can't be drawn or a picture is missing; see file.strictReport)}"#) .pure(), CommandSpec::new("file.strictReport", "Strict Export Report", "File › Export", strict_report) - .params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "in", "reason": "missing part" | "external target" | "link only" | "undecodable" | "unsupported format"}], "truncated": bool}"#) + .params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "source_part": the story part it is in (word/header2.xml; background, body, header, footer, text box or "" where unknown), "reason": "missing_part" | "external_target" | "link_only" | "undecodable" | "unsupported_format"}], "truncated": bool (either list was cut at 256 entries), "total_missing_glyphs": n (distinct, past the cap too), "total_missing_pictures": n (past the cap too), "glyphs_truncated": bool, "pictures_truncated": bool}"#) .pure(), // A print setting, like Word's: not part of the document, so no undo step. CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| { diff --git a/crates/engine/src/strict.rs b/crates/engine/src/strict.rs index e6e8acec14..223366b81e 100644 --- a/crates/engine/src/strict.rs +++ b/crates/engine/src/strict.rs @@ -64,7 +64,7 @@ impl StrictReport { let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect(); let pictures: Vec<(String, String, String)> = self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect(); - human_parts(&glyphs, &pictures, self.glyphs_truncated, self.pictures_truncated) + human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures)) } /// The human lines, each `strict: …`. @@ -79,13 +79,16 @@ impl StrictReport { } /// The human text of a report: (code point text, count) and (part, story label, reason words). -fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], glyphs_cut: bool, pictures_cut: bool) -> Vec { +fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec { + let (glyphs_cut, pictures_cut) = cut; let mut out = Vec::new(); if !glyphs.is_empty() { let list: Vec = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect(); let more = if glyphs_cut { ", …" } else { "" }; - let n = glyphs.len(); - out.push(format!("{n} character{} cannot be drawn: {}{more}", if n == 1 { "" } else { "s" }, list.join(", "))); + let listed = glyphs.len() as u64; + let n = if glyphs_cut { totals.0.max(listed) } else { listed }; + let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; + out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", "))); } if !pictures.is_empty() { let list: Vec = pictures @@ -93,7 +96,10 @@ fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], .map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") }) .collect(); let more = if pictures_cut { "; …" } else { "" }; - out.push(format!("{} picture(s) left out: {}{more}", pictures.len(), list.join("; "))); + let listed = pictures.len() as u64; + let n = if pictures_cut { totals.1.max(listed) } else { listed }; + let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; + out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; "))); } out } @@ -118,7 +124,9 @@ pub fn lines_from_json(report: &Value) -> Vec { (None, None) => (flag("truncated"), flag("truncated")), _ => (flag("glyphs_truncated"), flag("pictures_truncated")), }; - human_parts(&glyphs, &pictures, glyphs_cut, pictures_cut).into_iter().map(|p| format!("strict: {p}")).collect() + let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64); + let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len())); + human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect() } /// The words for a JSON reason code; a code this version does not know is shown as it is. diff --git a/crates/engine/tests/strict_export.rs b/crates/engine/tests/strict_export.rs index 995cc0c9ee..819085b1ec 100644 --- a/crates/engine/tests/strict_export.rs +++ b/crates/engine/tests/strict_export.rs @@ -269,6 +269,7 @@ fn each_list_says_for_itself_that_it_was_cut() { assert_eq!((r["truncated"].clone(), r["glyphs_truncated"].clone(), r["pictures_truncated"].clone()), (json!(true), json!(true), json!(false))); let lines = wordcraft_engine::strict::lines_from_json(&r); assert!(lines[0].ends_with(", …"), "{}", lines[0]); + assert!(lines[0].starts_with("strict: 300 characters cannot be drawn (first 256 listed): "), "{}", lines[0]); assert!(!lines[1].contains('…'), "{}", lines[1]); assert_eq!((r["total_missing_glyphs"].clone(), r["total_missing_pictures"].clone()), (json!(300), json!(1))); }