mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
SCRUM-34: protect original files during native saves (#621)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
95abff4205
commit
1f09547f4e
6 files changed
+373
-6
No files matched your search
@@ -176,6 +176,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
|
||||
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
|
||||
| 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) |
|
||||
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
|
||||
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
|
||||
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
|
||||
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
|
||||
@@ -225,6 +226,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
|
||||
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
|
||||
/// Formats WordCraft opens.
|
||||
pub const OPEN_EXTS: &[&str] = &[
|
||||
"docx",
|
||||
@@ -320,10 +323,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> {
|
||||
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
|
||||
// Write atomically: temp file next to the target, then rename.
|
||||
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Sibling temporary files owned by one save. The destination changes only after preparation
|
||||
//! succeeds; concurrent saves have separate files and the last successful replacement wins.
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let permissions = match fs::metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
Ok(_) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// A new document gets the usual mode (0666 less the umask); a replaced one keeps its own,
|
||||
// and its temporary file stays private until that mode is applied.
|
||||
let private = permissions.is_some();
|
||||
replace_with(path, private, |file| {
|
||||
file.write_all(bytes)?;
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
// Synchronize the prepared file before replacing the destination. Directory durability
|
||||
// and network-filesystem/power-loss guarantees are outside this operation's contract.
|
||||
file.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let mut temp = Temporary::create(path, private)?;
|
||||
prepare(temp.file()?)?;
|
||||
temp.commit(path)
|
||||
}
|
||||
|
||||
struct Temporary {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl Temporary {
|
||||
/// `private`: create the file readable by its owner only (0600) rather than with the default
|
||||
/// mode (0666 less the umask).
|
||||
fn create(destination: &Path, private: bool) -> io::Result<Self> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
if destination.file_name().is_none() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name"));
|
||||
}
|
||||
// A short name also works beside a destination already at the filename-length limit.
|
||||
for _ in 0..128 {
|
||||
let id = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
match options.open(&path) {
|
||||
Ok(file) => return Ok(Self { path, file: Some(file), committed: false }),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file"))
|
||||
}
|
||||
|
||||
fn file(&mut self) -> io::Result<&mut File> {
|
||||
self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed"))
|
||||
}
|
||||
|
||||
fn commit(mut self, destination: &Path) -> io::Result<()> {
|
||||
// Close before replacement, including on Windows.
|
||||
drop(self.file.take());
|
||||
fs::rename(&self.path, destination)?;
|
||||
self.committed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
drop(self.file.take());
|
||||
if !self.committed {
|
||||
// If filesystem permissions change, cleanup can fail too. Never remove a neighbour
|
||||
// or the destination to compensate for a failed save.
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn target(&self) -> PathBuf {
|
||||
self.0.join("original.docx")
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() {
|
||||
for partial in [true, false] {
|
||||
let d = Scratch::new();
|
||||
let path = d.target();
|
||||
fs::write(&path, b"Original bytes.").unwrap();
|
||||
let err = replace_with(&path, true, |file| {
|
||||
file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?;
|
||||
Err(io::Error::other("injected preparation failure"))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("injected"));
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() {
|
||||
let d = Scratch::new();
|
||||
let path = d.target();
|
||||
fs::write(&path, b"Original bytes.").unwrap();
|
||||
let mut first = Temporary::create(&path, true).unwrap();
|
||||
let mut second = Temporary::create(&path, true).unwrap();
|
||||
assert_ne!(first.path, second.path);
|
||||
first.file().unwrap().write_all(b"First complete save.").unwrap();
|
||||
second.file().unwrap().write_all(b"Second complete save.").unwrap();
|
||||
first.file().unwrap().sync_all().unwrap();
|
||||
second.file().unwrap().sync_all().unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
|
||||
first.commit(&path).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"First complete save.");
|
||||
second.commit(&path).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Second complete save.");
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_temporary_files_are_private_until_prepared() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = Scratch::new();
|
||||
let temp = Temporary::create(&d.target(), true).unwrap();
|
||||
assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
/// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced
|
||||
/// document keeps its own mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = Scratch::new();
|
||||
let reference = d.0.join("reference.docx");
|
||||
fs::write(&reference, b"x").unwrap();
|
||||
let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777;
|
||||
let path = d.target();
|
||||
write(&path, b"New document.").unwrap();
|
||||
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode);
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
|
||||
write(&path, b"Saved again.").unwrap();
|
||||
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Original synthetic save cases. Every test owns its directory and document copies.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Document, StoryRef};
|
||||
use wordcraft_engine::{Session, io};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!("wordcraft-safe-save-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
fn names(&self) -> Vec<String> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(&self.0).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn original(path: &Path) -> Vec<u8> {
|
||||
let bytes = io::save_bytes("original.docx", &Document::from_text("Original synthetic contract.")).unwrap();
|
||||
std::fs::write(path, &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
fn edited() -> Document {
|
||||
Document::from_text("Original synthetic contract. Edited sentinel.")
|
||||
}
|
||||
fn saved(path: &Path) {
|
||||
assert_eq!(io::open_path(path).unwrap().plain_text(StoryRef::Body), edited().plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_temporary_file_is_not_overwritten_or_removed() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let neighbour = d.path("contract.docx.tmp");
|
||||
std::fs::write(&neighbour, b"Keep this existing sibling.").unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::read(&neighbour).unwrap(), b"Keep this existing sibling.");
|
||||
assert_eq!(d.names(), ["contract.docx", "contract.docx.tmp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_temporary_directory_does_not_block_a_save() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let neighbour = d.path("contract.docx.tmp");
|
||||
std::fs::create_dir(&neighbour).unwrap();
|
||||
std::fs::write(neighbour.join("keep"), b"Keep this directory.").unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::read(neighbour.join("keep")).unwrap(), b"Keep this directory.");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn temporary_links_cannot_redirect_a_save_into_the_original_or_a_neighbour() {
|
||||
for link_to_original in [false, true] {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let other = d.path("neighbour.txt");
|
||||
std::fs::write(&other, b"Keep this neighbour.").unwrap();
|
||||
let link = d.path("contract.docx.tmp");
|
||||
let target = if link_to_original { &path } else { &other };
|
||||
std::os::unix::fs::symlink(target, &link).unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
assert!(!path.is_symlink());
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), *target);
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"Keep this neighbour.");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_save_as_keeps_original_dirty_session_and_destination() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("original.docx");
|
||||
let bytes = original(&path);
|
||||
let blocked = d.path("blocked.docx");
|
||||
std::fs::create_dir(&blocked).unwrap();
|
||||
std::fs::write(blocked.join("keep"), b"Keep this destination.").unwrap();
|
||||
let mut s = Session::new(edited());
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = true;
|
||||
let before = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(s.run("file.saveAs", &json!({"path": blocked})).is_err());
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.path.as_ref(), Some(&path));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read(blocked.join("keep")).unwrap(), b"Keep this destination.");
|
||||
assert_eq!(d.names(), ["blocked.docx", "original.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_save_as_keeps_original_and_updates_session_only_after_saving() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("original.docx");
|
||||
let bytes = original(&path);
|
||||
let copy = d.path("copy.docx");
|
||||
let mut s = Session::new(edited());
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = true;
|
||||
s.run("file.saveAs", &json!({"path": copy})).unwrap();
|
||||
assert!(!s.dirty);
|
||||
assert_eq!(s.path.as_ref(), Some(©));
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
saved(©);
|
||||
assert_eq!(d.names(), ["copy.docx", "original.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_keeps_original_and_session_unchanged() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("original.docx");
|
||||
let bytes = original(&path);
|
||||
let mut s = Session::new(edited());
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = true;
|
||||
assert!(s.run("file.saveAs", &json!({"path": d.path("missing/copy.docx")})).is_err());
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.path, Some(path.clone()));
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
assert_eq!(d.names(), ["original.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_save_reopens_only_with_password_and_leaves_neighbours_alone() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let neighbour = d.path("contract.docx.tmp");
|
||||
std::fs::write(&neighbour, b"Keep this sibling.").unwrap();
|
||||
io::save_path_with(&path, &edited(), Some("synthetic-test-password"), &io::ExportOptions::default()).unwrap();
|
||||
assert!(io::needs_password(&io::open_path(&path).unwrap_err()));
|
||||
let (doc, encrypted) = io::open_path_with(&path, Some("synthetic-test-password")).unwrap();
|
||||
assert!(encrypted);
|
||||
assert_eq!(doc.plain_text(StoryRef::Body), edited().plain_text(StoryRef::Body));
|
||||
assert_eq!(std::fs::read(&neighbour).unwrap(), b"Keep this sibling.");
|
||||
assert_eq!(d.names(), ["contract.docx", "contract.docx.tmp"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn replacement_keeps_existing_unix_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -99,6 +99,14 @@ The [public OOXML reference](https://learn.microsoft.com/en-us/dotnet/api/docume
|
||||
describes omission as leaving paragraph defaults application-defined. Real-world corpus
|
||||
coverage and WordCraft layout matching remain separate gaps.
|
||||
|
||||
## Native save-path guarantees
|
||||
|
||||
Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `<name>.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged.
|
||||
|
||||
Synthetic CLI cases and `engine/tests/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection.
|
||||
|
||||
Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full conditions, power loss and network filesystems are not verified by these tests. The containing directory is not explicitly synchronized. Cleanup can fail when storage or permissions change, and process termination can leave a temporary file. No Word observation was needed for this filesystem change; it makes no new format-fidelity claim.
|
||||
|
||||
## What decides beta for formats
|
||||
|
||||
1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with
|
||||
@@ -134,6 +142,7 @@ coverage and WordCraft layout matching remain separate gaps.
|
||||
| 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) |
|
||||
| 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) |
|
||||
| 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) |
|
||||
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
|
||||
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
|
||||
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
|
||||
|
||||
+4
-1
@@ -1,12 +1,14 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (native save temporary-file ownership and failure protection; previously trivial: Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs
|
||||
first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
|
||||
Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees).
|
||||
|
||||
## Ranked
|
||||
|
||||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
@@ -84,6 +86,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain |
|
||||
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) |
|
||||
| 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
|
||||
Reference in new issue
Block a user