mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 11:50:20 -06:00
#6005: count a chunk's unlisted pictures, document the real schema, show totals in human lines
A nested altChunk DOCX adds its records past its 256-entry list to the outer total. file.strictReport's params doc lists source_part, the snake_case codes and the totals. The human line says "300 characters cannot be drawn (first 256 listed)" when a list is cut. README gains the picture stderr line and the possible source_part values. 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
530e7b63bb
commit
7be581d90b
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u8> {
|
||||
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
|
||||
let doc = format!(r#"<w:document {NS}><w:body><w:p>{drawing}</w:p><w:sectPr/></w:body></w:document>"#);
|
||||
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<u8> {
|
||||
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
|
||||
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
|
||||
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
|
||||
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#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
|
||||
)
|
||||
};
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdP" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/></Relationships>"#
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
|
||||
(0..n).map(image).collect::<String>()
|
||||
);
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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| {
|
||||
|
||||
@@ -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<String> {
|
||||
fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec<String> {
|
||||
let (glyphs_cut, pictures_cut) = cut;
|
||||
let mut out = Vec::new();
|
||||
if !glyphs.is_empty() {
|
||||
let list: Vec<String> = 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<String> = 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<String> {
|
||||
(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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user