From debec751660391a0d53df6c8f28106669cfdd55c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:23:08 +0000 Subject: [PATCH] fix(textimport): Word table cells skip deleted text and field codes; Normal comes in as its own style (#721, #722) Table cell paragraphs go through the same run and inline path as body paragraphs, so deleted and moved-from text and field instructions stay out of cells, and field results, tabs and breaks come in. Word's Normal imports as a paragraph style named Normal whose definition is w:docDefaults with Normal's own properties on top; paragraphs without a style use Word's default paragraph style. A styleMap entry can still map Normal to [Basic Paragraph]. --- ROADMAP.md | 1 + crates/engine/src/cmd/place_text.rs | 52 +++++++++++ crates/textimport/src/docx.rs | 138 +++++++++++++++++++++++----- 3 files changed, 167 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..bb66401f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Word import: table cells read text by the same rules as body text, so deleted text, moved-away text and field codes stay out (#721). Word's Normal comes in as its own paragraph style "Normal" over the document defaults, so text formatted only by Normal keeps its look; it conflicts only with a document's own Normal, and the style map can send it to [Basic Paragraph] (#722). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 97917ee3b..aed921951 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -481,4 +481,56 @@ mod pdf_page_tests { s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleConflicts": "redefine", "rect": [72, 400, 300, 500]})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0)); } + + #[test] + fn word_normal_comes_in_as_its_own_style() { + // Regression (#722): Normal's formatting (over Word's document defaults) was dropped. + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let styles = format!( + r#" + "# + ); + let doc = format!(r#"Plain"#); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] { + w.start_file(n, zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + let b64 = crate::cmd::base64_encode(&w.finish().unwrap().into_inner()); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + let placed = |s: &Session, story: &serde_json::Value| { + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(story["story"].as_u64().unwrap())).unwrap(); + let (_, para_chars) = d.styles.resolve_para(&st.paras[0]); + let c = d.styles.resolve_char(¶_chars, st.format_after(0)); + (st.paras[0].style.clone(), c.size, c.font_family) + }; + let georgia = |style: &str| (style.to_string(), 13.0, "Georgia".to_string()); + // Default options: the document gains Normal and the text looks as it did in Word. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let info = s.execute("place.styles", &json!({"base64": b64, "name": "a.docx"})).unwrap(); + assert_eq!(info["paragraph"], json!(["Normal"])); + assert_eq!(info["conflicts"], json!([])); + let r = s.execute("file.place", &json!({"base64": b64, "name": "a.docx"})).unwrap(); + assert_eq!(placed(&s, &r), georgia("Normal")); + assert!(s.doc().unwrap().doc.styles.para(basic).unwrap().chars.is_empty(), "[Basic Paragraph] untouched"); + // Normal mapped to [Basic Paragraph]: no new style, and the document's definition is used + // (mapped styles aren't imported, whatever the conflict option). + s.execute("file.new", &json!({})).unwrap(); + let r = + s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleMap": {"Normal": basic}, "styleConflicts": "redefine"})).unwrap(); + assert!(s.doc().unwrap().doc.styles.para("Normal").is_none()); + assert!(s.doc().unwrap().doc.styles.para(basic).unwrap().chars.is_empty()); + let (style, size, _) = placed(&s, &r); + assert_eq!((style.as_str(), size), (basic, 12.0)); + // A document with its own Normal: the usual conflict rules. + s.execute("style.paragraph.create", &json!({"name": "Normal", "chars": {"size": 9}})).unwrap(); + let r = s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "rect": [72, 400, 300, 500]})).unwrap(); + assert_eq!(placed(&s, &r).1, 9.0, "use existing"); + let r = + s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleConflicts": "autoRename", "rect": [72, 520, 300, 600]})).unwrap(); + assert_eq!(placed(&s, &r), georgia("Normal_wrd_1")); + } } diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 67a2b71a1..e9ecf808c 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -216,11 +216,13 @@ struct Ctx { footnotes: HashMap, warnings: Vec, next_table: u64, + /// The style of paragraphs without `w:pStyle` (Word's default paragraph style, usually Normal). + default_para: String, } impl Ctx { fn para_style(&self, ppr: Option<&El>) -> String { - ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| BASIC_PARAGRAPH.into()) + ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone()) } /// Append the paragraphs and tables of `body` to `st`. @@ -242,16 +244,16 @@ impl Ctx { self.inline(e, st, allow_notes); } "tbl" => { - let rows: Vec> = e - .els() - .filter(|r| r.name == "tr") - .map(|r| { - r.els() - .filter(|c| c.name == "tc") - .map(|c| c.els().filter(|p| p.name == "p").map(|p| p.text()).collect::>().join("\n")) - .collect() - }) - .collect(); + let mut rows: Vec> = Vec::new(); + for r in e.els().filter(|r| r.name == "tr") { + let mut row = Vec::new(); + for c in r.els().filter(|c| c.name == "tc") { + let mut paras = Vec::new(); + self.cell_paras(c, &mut paras); + row.push(paras.join("\n")); + } + rows.push(row); + } if rows.is_empty() { continue; } @@ -271,6 +273,27 @@ impl Ctx { } } + /// The text of each paragraph in a table cell, read by the same run rules as body text. + /// Cells are plain text, so a page or column break in a cell ends its paragraph. + fn cell_paras(&mut self, el: &El, out: &mut Vec) { + for e in el.els() { + match e.name.as_str() { + "p" => { + let mut st = Story::new(StoryId(0)); + self.inline(e, &mut st, false); + let is_break = |c: char| matches!(c, designcraft_doc::PAGE_BREAK | designcraft_doc::COLUMN_BREAK); + out.push(st.text.chars().map(|c| if is_break(c) { '\n' } else { c }).collect()); + } + "sdt" => { + if let Some(c) = e.child("sdtContent") { + self.cell_paras(c, out); + } + } + _ => {} + } + } + } + /// Runs (and hyperlinks, fields' results) of a paragraph. fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) { for e in p.els() { @@ -333,29 +356,53 @@ pub fn import(bytes: &[u8]) -> Result { let doc = part(&mut zip, "word/document.xml")?.ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; let doc = parse(&doc)?; // Styles. - let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 }; + let mut ctx = Ctx { + para_names: HashMap::new(), + char_names: HashMap::new(), + footnotes: HashMap::new(), + warnings: vec![], + next_table: 0, + default_para: BASIC_PARAGRAPH.into(), + }; let mut para_styles = Vec::new(); let mut char_styles = Vec::new(); if let Some(styles) = part(&mut zip, "word/styles.xml")?.map(|s| parse(&s)).transpose()? { let ids: HashMap = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect(); + // Word's document defaults sit beneath every paragraph style that isn't based on another. + let defaults = styles.child("docDefaults"); + let default_para = para_attrs(defaults.and_then(|d| d.child("pPrDefault")).and_then(|d| d.child("pPr"))); + let default_chars = run_attrs(defaults.and_then(|d| d.child("rPrDefault")).and_then(|d| d.child("rPr"))); + let is_para = |s: &&El| s.name == "style" && s.attr("type").unwrap_or("paragraph") == "paragraph"; + let default_id = styles + .els() + .filter(is_para) + .find(|s| matches!(s.attr("default"), Some("1" | "true" | "on"))) + .or_else(|| styles.els().filter(is_para).find(|s| s.attr("styleId") == Some("Normal"))) + .and_then(|s| s.attr("styleId")); + if let Some(name) = default_id.and_then(|id| ids.get(id)) { + ctx.default_para = name.clone(); + } for s in styles.els().filter(|s| s.name == "style") { let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue }; let kind = s.attr("type").unwrap_or("paragraph"); - // Word's Normal / default paragraph font are our [Basic Paragraph] / [None]. + // Word's default paragraph font is "no character style": our [None]. let name = match (kind, name.as_str()) { - ("paragraph", "Normal") => BASIC_PARAGRAPH.to_string(), ("character", "Default Paragraph Font") => NO_CHAR_STYLE.to_string(), _ => name.clone(), }; - let based_on = s.val("basedOn").and_then(|b| ids.get(&b).cloned()).map(|b| if b == "Normal" { BASIC_PARAGRAPH.to_string() } else { b }); - let imp = ImportedStyle { name: name.clone(), based_on, para: para_attrs(s.child("pPr")), chars: run_attrs(s.child("rPr")) }; + let based_on = s.val("basedOn").and_then(|b| ids.get(&b).cloned()); + let mut imp = ImportedStyle { name: name.clone(), based_on, para: para_attrs(s.child("pPr")), chars: run_attrs(s.child("rPr")) }; match kind { "paragraph" => { ctx.para_names.insert(id.to_string(), name.clone()); - if name != BASIC_PARAGRAPH { - para_styles.push(imp); + if imp.based_on.is_none() { + let (mut para, mut chars) = (default_para.clone(), default_chars.clone()); + para.merge(&imp.para); + chars.merge(&imp.chars); + (imp.para, imp.chars) = (para, chars); } + para_styles.push(imp); } "character" => { ctx.char_names.insert(id.to_string(), name.clone()); @@ -381,8 +428,9 @@ pub fn import(bytes: &[u8]) -> Result { // A page or column break ends its paragraph. st.end_paragraphs_at_breaks(); // Only styles that are used, plus what they are based on. - let used_p: std::collections::HashSet = st.paras.iter().map(|p| p.style.clone()).collect(); - let used_c: std::collections::HashSet = st.runs().map(|(_, f)| f.style.clone()).collect(); + let stories = || std::iter::once(&st).chain(st.notes.iter().map(|n| &n.text)); + let used_p: std::collections::HashSet = stories().flat_map(|s| s.paras.iter().map(|p| p.style.clone())).collect(); + let used_c: std::collections::HashSet = stories().flat_map(|s| s.runs().map(|(_, f)| f.style.clone())).collect(); let keep = |list: Vec, used: &std::collections::HashSet| { let mut want: std::collections::HashSet = used.clone(); loop { @@ -460,7 +508,7 @@ mod tests { assert!(st.text.starts_with("Title & more\nPlain bold styled\tx"), "{:?}", st.text); assert!(st.text.ends_with("After")); assert_eq!(st.paras[0].style, "heading 1"); - assert_eq!(st.paras[1].style, BASIC_PARAGRAPH); + assert_eq!(st.paras[1].style, "Normal"); let at = st.text.find("bold").unwrap(); assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold")); assert_eq!(st.format_after(st.text.find("styled").unwrap()).style, "Emphasis"); @@ -468,9 +516,10 @@ mod tests { assert_eq!(st.notes[0].text.text, "The note."); assert_eq!(st.tables.len(), 1); let names: Vec<&str> = i.para_styles.iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, ["heading 1"], "unused styles are skipped"); - assert_eq!(i.para_styles[0].chars.size, Some(16.0)); - assert_eq!(i.para_styles[0].para.space_before, Some(12.0)); + assert_eq!(names, ["Normal", "heading 1"], "unused styles are skipped"); + assert_eq!(i.para_styles[1].based_on.as_deref(), Some("Normal")); + assert_eq!(i.para_styles[1].chars.size, Some(16.0)); + assert_eq!(i.para_styles[1].para.space_before, Some(12.0)); assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic")); } @@ -510,4 +559,45 @@ mod tests { i.story.check().unwrap(); assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); } + + #[test] + fn table_cells_read_text_like_body_paragraphs() { + // Regression (#721): cells took every text node, so deleted text, move sources and field + // instructions showed up in the table. + let para = r#"kept + GONE + MOVED AWAY + PAGE + 7"#; + let document = format!(r#"{para}{para}"#); + let i = import(&docx(&document, "", "")).unwrap(); + assert!(i.story.text.starts_with("kept 7"), "{:?}", i.story.text); + let cell = &i.story.tables.values().next().unwrap().cell(0, 0).unwrap().text; + assert_eq!(cell.text, "kept 7\n"); + } + + #[test] + fn normal_is_its_own_style_over_the_document_defaults() { + // Regression (#722): text formatted only by Normal and the document defaults lost it. + let styles = format!( + r#" + + + + "# + ); + let document = format!( + r#"Plain + Quoted"# + ); + let i = import(&docx(&document, &styles, "")).unwrap(); + assert_eq!(i.story.paras[0].style, "Normal"); + let normal = i.para_styles.iter().find(|s| s.name == "Normal").unwrap(); + assert_eq!(normal.chars.size, Some(13.0), "Normal's size wins over the default"); + assert_eq!(normal.chars.font_family.as_deref(), Some("Georgia")); + assert_eq!(normal.para.space_after, Some(8.0), "from the document defaults"); + let quote = i.para_styles.iter().find(|s| s.name == "Quote").unwrap(); + assert_eq!(quote.based_on.as_deref(), Some("Normal")); + assert_eq!(quote.chars.size, None, "inherited from Normal, not copied"); + } }