mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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(¶_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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user