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].
This commit is contained in:
aiongg
2026-10-13 14:23:08 +00:00
parent e862e0a613
commit debec75166
3 changed files with 167 additions and 24 deletions
+1
View File
@@ -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.
+52
View File
@@ -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#"<w:styles {W}><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Georgia"/><w:sz w:val="26"/></w:rPr></w:style></w:styles>"#
);
let doc = format!(r#"<w:document {W}><w:body><w:p><w:r><w:t>Plain</w:t></w:r></w:p></w:body></w:document>"#);
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(&para_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"));
}
}
+114 -24
View File
@@ -216,11 +216,13 @@ struct Ctx {
footnotes: HashMap<String, El>,
warnings: Vec<String>,
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<Vec<String>> = 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::<Vec<_>>().join("\n"))
.collect()
})
.collect();
let mut rows: Vec<Vec<String>> = 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<String>) {
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<Imported, ImportError> {
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<String, String> =
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<Imported, ImportError> {
// 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<String> = st.paras.iter().map(|p| p.style.clone()).collect();
let used_c: std::collections::HashSet<String> = 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<String> = stories().flat_map(|s| s.paras.iter().map(|p| p.style.clone())).collect();
let used_c: std::collections::HashSet<String> = stories().flat_map(|s| s.runs().map(|(_, f)| f.style.clone())).collect();
let keep = |list: Vec<ImportedStyle>, used: &std::collections::HashSet<String>| {
let mut want: std::collections::HashSet<String> = 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#"<w:p><w:r><w:t xml:space="preserve">kept </w:t></w:r>
<w:del w:id="1" w:author="A"><w:r><w:delText>GONE</w:delText></w:r></w:del>
<w:moveFrom w:id="2" w:author="A"><w:r><w:t>MOVED AWAY</w:t></w:r></w:moveFrom>
<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#;
let document = format!(r#"<w:document {W}><w:body>{para}<w:tbl><w:tr><w:tc>{para}<w:p/></w:tc></w:tr></w:tbl></w:body></w:document>"#);
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#"<w:styles {W}><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault>
<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Georgia"/><w:sz w:val="26"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:rPr><w:i/></w:rPr></w:style>
</w:styles>"#
);
let document = format!(
r#"<w:document {W}><w:body><w:p><w:r><w:t>Plain</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Quote"/></w:pPr><w:r><w:t>Quoted</w:t></w:r></w:p></w:body></w:document>"#
);
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");
}
}