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");
+ }
}