diff --git a/Cargo.lock b/Cargo.lock index ba8b47409..8154b2041 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5382,6 +5382,7 @@ dependencies = [ name = "wordcraft-ui-egui" version = "0.8.0" dependencies = [ + "cfb", "craft-ui", "egui", "egui_kittest", diff --git a/ROADMAP.md b/ROADMAP.md index 84a70a36c..ae56fd537 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. + - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | @@ -464,6 +466,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) | +| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified | +| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output | | 2026-10-16 | trivial | Progress log: the Spike | | 2026-10-16 | trivial | Progress log: `w:eastAsianLayout` kept on save | | 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) | diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 28a0c979f..48ed41219 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -39,6 +39,7 @@ zbus = "=5.19.0" js-sys = "0.3" [dev-dependencies] +cfb = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } zip = { workspace = true } diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 24f2f9e6f..f3319986a 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -280,7 +280,10 @@ impl WordApp { let (req, after) = match purpose { "picture" => (FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture), "recipients" => (FileDialogRequest::Open { purpose: "recipients".into() }, AfterPick::Recipients), - "save" => (FileDialogRequest::Save { name: self.default_save_name() }, AfterPick::SaveAs(crate::file_dialogs::AfterSave::Nothing)), + "save" => ( + FileDialogRequest::Save { name: self.default_save_name() }, + AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() }, + ), _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument), }; self.show_file_browser(req, after, false); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 63f144c73..1316879a5 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -59,7 +59,7 @@ pub(crate) enum AfterPick { /// Insert the picked picture, or replace the pending Change Picture target. Picture, /// Save the document there, then carry on. - SaveAs(AfterSave), + SaveAs { after: AfterSave, document: u64 }, /// Save a copy there in this format (`pdf`, `docx`, `png`…). Export { ext: String }, /// Write the PDF of these print settings there (File › Print › Save these pages as PDF). @@ -292,7 +292,10 @@ impl WordApp { } r } - AfterPick::SaveAs(after) => { + AfterPick::SaveAs { after, document } => { + if document != self.session.document_id() { + return Err("the document for this save has been replaced".into()); + } // The save's error, kept for the Save Changes prompt to explain (#1445). let result = picked.map(|path| self.run("file.save", json!({"path": path}))); let saved = matches!(&result, Some(Ok(v)) if v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()); @@ -330,7 +333,7 @@ impl WordApp { /// Ask where to save, save there, then do `after`. pub(crate) fn save_as(&mut self, after: AfterSave) -> Asked { let name = self.default_save_name(); - self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs(after)) + self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs { after, document: self.session.document_id() }) } /// File › Export: ask where to save a copy in this format, then write it. @@ -395,6 +398,219 @@ mod tests { const UNSAVED: &str = "My unsaved novel chapter"; + /// Original Word 97 compound file: one compressed text piece, built from MS-DOC's FIB/Clx. + fn legacy_word_bytes() -> Vec { + use std::io::{Cursor, Write}; + let text = b"Legacy contract\r"; + let mut word = vec![0u8; 0x600]; + for (at, value) in [(0, 0xA5ECu16), (2, 0x00C1), (0x0A, 0x1000), (0x0C, 0x00BF), (0x20, 14), (0x3E, 22), (0x98, 0x005D)] { + word[at..at + 2].copy_from_slice(&value.to_le_bytes()); + } + word[0x40..0x44].copy_from_slice(&0x1000u32.to_le_bytes()); + word[0x4C..0x50].copy_from_slice(&(text.len() as u32).to_le_bytes()); + let mut clx = vec![2]; + clx.extend_from_slice(&16u32.to_le_bytes()); + clx.extend_from_slice(&0u32.to_le_bytes()); + clx.extend_from_slice(&(text.len() as u32).to_le_bytes()); + clx.extend_from_slice(&[0, 0]); + clx.extend_from_slice(&(0x4000_0000u32 | (2 * 0x600)).to_le_bytes()); + clx.extend_from_slice(&[0, 0]); + let pair = 0x9A + 33 * 8; + word[pair..pair + 4].copy_from_slice(&0x40u32.to_le_bytes()); + word[pair + 4..pair + 8].copy_from_slice(&(clx.len() as u32).to_le_bytes()); + word.extend_from_slice(text); + let mut table = vec![0; 0x40]; + table.extend_from_slice(&clx); + let mut file = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); + for (name, data) in [("/WordDocument", word), ("/0Table", table)] { + file.create_stream(name).unwrap().write_all(&data).unwrap(); + } + file.into_inner().into_inner() + } + + /// #2445: Save on an imported binary Word file asks for DOCX, preserving the import on Cancel. + #[test] + fn imported_word_save_asks_for_docx_and_preserves_the_original() { + for ext in ["doc", "DOT", "DOC", "dot"] { + let dir = scratch(&format!("imported-{ext}")); + let original = dir.join(format!("contract.{ext}")); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); + assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Legacy contract")); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + let draft = a.session.doc.clone(); + let revision = a.session.rev(); + let result = a.run("file.save", json!({})).unwrap(); + assert_eq!(result["pending"], "saveAs"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() }); + answer(&shown, None); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false); + assert_eq!(a.session.doc, draft); + assert_eq!(a.session.rev(), revision); + assert!(a.session.dirty); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + + a.run("file.save", json!({})).unwrap(); + let saved = dir.join("revised.docx"); + answer(&shown, Some(&saved.to_string_lossy())); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], true); + assert!(!a.session.dirty); + assert_eq!(a.session.path.as_deref(), Some(saved.as_path())); + assert!(saved_text(&saved).contains("Revised Legacy contract")); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + // The new supported path saves directly, without another chooser. + a.run("text.insert", json!({"text": "Again "})).unwrap(); + assert_eq!(a.run("file.save", json!({})).unwrap()["saved"], true); + assert!(shown.borrow().is_empty()); + assert!(saved_text(&saved).contains("Again ")); + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn imported_word_close_waits_for_save_as_and_keeps_failed_or_cancelled_drafts() { + let dir = scratch("imported-close"); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + let draft = a.session.doc.clone(); + + a.run("file.close", json!({})).unwrap(); + assert_eq!(a.run("ui.saveChanges", json!({"answer": "save"})).unwrap()["pending"], "saveAs"); + assert!(a.has_document() && !a.quit_requested); + answer(&shown, Some(&dir.join("missing").join("contract.docx").to_string_lossy())); + let error = a.poll_file_dialog().unwrap().unwrap_err(); + assert!(error.contains("still open"), "{error}"); + assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); + assert!(a.has_document() && a.session.dirty && !a.quit_requested); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), draft.plain_text(wordcraft_doc::StoryRef::Body)); + + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + answer(&shown, None); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], false); + assert!(a.has_document() && a.session.dirty && !a.quit_requested); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + + a.run("file.close", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + let saved = dir.join("contract.docx"); + answer(&shown, Some(&saved.to_string_lossy())); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], true); + assert!(a.quit_requested, "Close runs only after the DOCX save succeeds"); + assert!(saved_text(&saved).contains("Revised Legacy contract")); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + let _ = std::fs::remove_dir_all(dir); + } + + /// A late Save As answer belongs to the imported document that opened the picker. + #[test] + fn imported_word_stale_save_as_never_writes_the_replacement_document() { + for close in [false, true] { + let dir = scratch(if close { "imported-stale-close" } else { "imported-stale-save" }); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let destination = dir.join("existing.docx"); + let sentinel = b"Existing destination must survive"; + std::fs::write(&destination, sentinel).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original})).unwrap(); + typed(&mut a); + if close { + a.run("file.close", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + } else { + a.run("file.save", json!({})).unwrap(); + } + assert!(a.file_dialog_open()); + let previous = a.session.document_id(); + // Automation bypasses the user prompt and can replace a document during a picker. + a.execute("file.new", json!({})).unwrap(); + typed(&mut a); + assert_ne!(previous, a.session.document_id()); + let draft = a.session.doc.clone(); + let revision = a.session.rev(); + answer(&shown, Some(&destination.to_string_lossy())); + let _ = a.poll_file_dialog().unwrap(); + assert!(std::fs::read(&destination).unwrap() == sentinel, "stale picker overwrote the destination"); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + assert_eq!(a.session.doc, draft); + assert_eq!(a.session.rev(), revision); + assert!(a.session.dirty && a.session.path.is_none() && !a.quit_requested); + } + } + + #[test] + fn imported_word_programmatic_and_explicit_saves_never_open_dialogs() { + let dir = scratch("imported-explicit"); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + let error = a.execute("file.save", json!({})).unwrap_err(); + assert!(error.contains("can't save as `.doc`"), "{error}"); + let error = a.run("file.save", json!({"path": original.to_string_lossy()})).unwrap_err(); + assert!(error.contains("can't save as `.doc`"), "{error}"); + assert!(shown.borrow().is_empty()); + assert!(a.session.dirty); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn imported_word_save_controls_ask_for_docx() { + use egui_kittest::kittest::Queryable; + let dir = scratch("imported-entry-points"); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("ui.language", json!({"value": "en"})).unwrap(); + a.run("file.open", json!({"path": original})).unwrap(); + typed(&mut a); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(3); + assert!(h.state().file_dialog_open(), "the Save button asks for a supported target"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() }); + answer(&shown, None); + h.run_steps(3); + + h.state_mut().run("ui.backstage", json!({"value": false})).unwrap(); + h.state_mut().canvas.want_focus = true; + h.run_steps(6); + assert!(h.state().canvas.focused, "Mod+S starts in the editor"); + h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.run_steps(3); + assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() }); + answer(&shown, None); + h.run_steps(3); + assert!(h.state().session.dirty); + assert_eq!(h.state().session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + } + /// Dialogs the fake host was asked to show, each with the channel its answer goes through. type Shown = Rc)>>>; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ef7cffd47..72574cac7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1091,9 +1091,9 @@ impl WordApp { let go = match choice { SaveChoice::Cancel => false, SaveChoice::DontSave => true, - // A new document: ask where through Save As; the command runs once that has saved, - // which with the desktop's non-blocking dialog is on a later frame (#94). - SaveChoice::Save if self.session.path.is_none() && self.services.download.is_none() => { + // A new document or an import-only Word file: ask where through Save As. The command + // runs once that has saved, on a later frame with the non-blocking dialog (#94). + SaveChoice::Save if (self.session.path.is_none() || self.legacy_word_path()) && self.services.download.is_none() => { return match self.save_as(file_dialogs::AfterSave::Continue { then, params, document }) { file_dialogs::Asked::Done(r) => r, file_dialogs::Asked::Pending => Ok(json!({"pending": "saveAs"})), @@ -1119,9 +1119,23 @@ impl WordApp { self.execute_user(&then, params).map(|r| json!({"done": true, "result": r})) } - /// [`WordApp::execute`] for something the user did: a document that turns out to be - /// password-protected asks for the password (and opens with it) instead of failing (#55). + /// User commands can ask for a supported Save As target or an opening password (#55). + /// Scripts and agents use [`WordApp::execute`], which never opens these dialogs. pub(crate) fn execute_user(&mut self, id: &str, params: Value) -> Result { + // Legacy Word files are import-only (#2445). Only a user's implicit Save asks for + // a supported target; scripts and explicitly supplied paths keep their normal errors. + if id == "file.save" + && params.get("path").and_then(Value::as_str).is_none() + && self.has_document() + && self.legacy_word_path() + && self.services.download.is_none() + { + return match self.save_as(file_dialogs::AfterSave::Nothing) { + file_dialogs::Asked::Done(r) => r, + file_dialogs::Asked::Pending => Ok(json!({"pending": "saveAs"})), + file_dialogs::Asked::Busy => Ok(json!({"saved": false})), + }; + } let retry = (id == "file.open").then(|| params.clone()); let r = self.execute(id, params); if let (Err(e), Some(params)) = (&r, retry) @@ -1790,6 +1804,16 @@ impl WordApp { r } + /// These paths have readers but no writer, so an interactive save needs another format. + fn legacy_word_path(&self) -> bool { + self.session + .path + .as_ref() + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("doc") || e.eq_ignore_ascii_case("dot")) + } + /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros), /// otherwise .docx. fn default_save_name(&self) -> String { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a28e009b3..5ecf5fe65 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -216,6 +216,12 @@ attributes inside stories and style or list definitions; a regenerated theme. Te +## Saving legacy Word imports + +Word 97–2003 `.doc` and `.dot` remain import-only. A user Save without an explicit destination opens the existing Save As flow suggesting DOCX (#2445), including before Close. The original stays unchanged. Cancelling retains the draft, revision and path; a failed write keeps the draft open, and successful DOCX save/reopen retains the edited text. Uppercase extensions follow the same route. Scripts and explicitly supplied legacy destinations still receive an unsupported-format error and never open a dialog. Late Save As answers are rejected before writing if automation has replaced the document. + +Original binary fixtures and actual WordApp/Session file I/O cover Save, toolbar dispatch, a Mod+S input event, cancel, failure, retry, close continuation and repeated DOCX saves. The picker service is controlled in these tests; a native Windows picker and broad legacy-document fidelity remain unqualified. + ## Cached field result formatting Imported text fields use the first nonempty cached result run's formatting on their paragraph span, including character styles. Empty results retain begin-marker formatting. Synthetic fixtures cover simple and complex fields, default inheritance and repeated saves. Mixed-format results remain represented by one span; field refresh policy is unchanged. @@ -228,6 +234,8 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-14 | trivial | `ASK` and `SET` fields keep their result but show nothing (#1765) | | 2026-10-16 | trivial | Strict OOXML line heights: `w:line="13.90pt"` under `lineRule="auto"` is read as Word renders it — through twips (13.90 × 20 = 278; the 1.1583 multiple), not as 240ths of a line — so a Word 2024 strict file opens at Word's line spacing and page count (#2329) | | 2026-10-14 | trivial | ODT export writes sections: each new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is a columned `text:section` (gap 19) | +| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified | +| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output | | 2026-10-17 | trivial | Paste: an HTML `
` (LibreOffice's both-sides indent) pastes as a Normal paragraph with its margins as indents, not the centred italic Quote style; opening .html unchanged (#1270) | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | | 2026-10-16 | trivial | Cached textual `REF` results with `\h` export links to their bookmarks. Code view and existing enclosing hyperlinks remain unchanged; unresolved bookmarks emit no PDF link. | diff --git a/docs/gaps.md b/docs/gaps.md index f6bf6822b..a5d9a3e1a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,6 +24,8 @@ Additive paragraph spacing retains the full before-space after a section page br Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. Other pagination gaps remain open. +Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existing DOCX Save As dialog. Cancel and write failures retain the draft and original path; Save before Close continues only after a successful supported-format save. This narrows save reliability under gap #3; binary `.doc` writing remains unsupported. Late Save As answers are rejected before writing if automation has replaced the document. + ## Ranked | # | Gap | B | Evidence | Impact | Hours | Doc | @@ -94,6 +96,8 @@ The Language dialog reads effective character formatting, including document def | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified | +| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output | | 2026-10-16 | trivial | #24: the Spike (Ctrl+F3, Ctrl+Shift+F3) | | 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain | | 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain |