Rebased onto main as one commit (the branch had merge commits from main). Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
14080c614c
commit
ab49e4f5fa
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drag-select by whole units, #549; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (any text file opens, #569; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
|
|
@ -144,6 +144,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
|
||||
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
|
||||
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
|
||||
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
|
||||
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
|
||||
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
|
||||
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
|
||||
|
|
@ -192,6 +193,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
|
||||
| 2026-10-11 | trivial | Progress log: vertical sections (#491) |
|
||||
| 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) |
|
||||
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
|
||||
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
|
||||
| 2026-10-11 | trivial | Progress log: content controls (#381) |
|
||||
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ pub const OPEN_EXTS: &[&str] = &[
|
|||
/// Formats WordCraft saves (Save As).
|
||||
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"];
|
||||
|
||||
/// The extension of a file name or path, lower-case; empty when the file name has none (a dot
|
||||
/// in a folder name doesn't count).
|
||||
fn ext_of(name: &str) -> String {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.rsplit('.').next().unwrap_or("").to_string()
|
||||
let file = name.rsplit(['/', '\\']).next().unwrap_or(name);
|
||||
file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss?
|
||||
|
|
@ -94,19 +96,48 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
|||
doc.ensure_nonempty();
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if ext.is_empty() {
|
||||
return open_by_content(name, &ext, bytes, password);
|
||||
}
|
||||
let mut doc = match ext.as_str() {
|
||||
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
|
||||
"txt" | "text" | "" => wordcraft_formats::txt::import(bytes),
|
||||
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
|
||||
other => match crate::io_ext::open(other, bytes) {
|
||||
Some(r) => r?,
|
||||
None => return Err(format!("{name}: unsupported format `.{other}`")),
|
||||
None => return open_by_content(name, other, bytes, password),
|
||||
},
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
Ok((doc, false))
|
||||
}
|
||||
|
||||
/// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…),
|
||||
/// opened by what it holds, as Word does: a Word document or RTF file by its signature, any
|
||||
/// other text through the plain-text import with encoding detection (#569). Anything else is
|
||||
/// refused with a clear error rather than shown as garbage.
|
||||
fn open_by_content(name: &str, ext: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
const ZIP: &[u8] = b"PK\x03\x04";
|
||||
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
let (mut doc, encrypted) = if bytes.starts_with(OLE) && wordcraft_docx::is_encrypted(bytes) {
|
||||
(wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?, true)
|
||||
} else if bytes.starts_with(ZIP) {
|
||||
(wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
|
||||
} else if bytes.starts_with(OLE) {
|
||||
(wordcraft_docbin::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
|
||||
} else if bytes.starts_with(b"{\\rtf") {
|
||||
(wordcraft_formats::rtf::import(bytes)?, false)
|
||||
} else if wordcraft_formats::txt::is_text(bytes) {
|
||||
(wordcraft_formats::txt::import(bytes), false)
|
||||
} else if ext.is_empty() {
|
||||
return Err(format!("{name}: not a document or text file WordCraft can open"));
|
||||
} else {
|
||||
return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)"));
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
|
||||
/// Serialise a document; `name` gives the format by extension.
|
||||
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let ext = ext_of(name);
|
||||
|
|
|
|||
|
|
@ -3218,6 +3218,61 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
|
|||
assert!(s.run("picture.iconColor", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
|
||||
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
|
||||
/// clean error, never garbage or a panic.
|
||||
#[test]
|
||||
fn unknown_text_extensions_open_as_text() {
|
||||
let lines = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
|
||||
let m3u8 = "#EXTM3U\r\n#EXTINF:215,Björk — Jóga\r\nmusic/Jóga.mp3\r\n#EXTINF:-1,Радио\r\nhttp://example.org/live\r\n";
|
||||
let doc = crate::io::open_bytes("Mix.M3U8", m3u8.as_bytes()).unwrap();
|
||||
assert!(
|
||||
lines(&doc).starts_with("#EXTM3U\n#EXTINF:215,Björk — Jóga\nmusic/Jóga.mp3\n#EXTINF:-1,Радио\nhttp://example.org/live"),
|
||||
"{:?}",
|
||||
lines(&doc)
|
||||
);
|
||||
assert!(!lines(&doc).contains('\r'));
|
||||
// With a BOM, which must not show up as a character.
|
||||
let bom = [&[0xEF, 0xBB, 0xBF][..], m3u8.as_bytes()].concat();
|
||||
assert!(lines(&crate::io::open_bytes("a.m3u8", &bom).unwrap()).starts_with("#EXTM3U\n"));
|
||||
// A plain .m3u in Windows-1252 (é = E9, — = 97).
|
||||
let cp1252 = b"#EXTM3U\r\n#EXTINF:100,Caf\xE9 \x97 Live\r\nC:\\Music\\caf\xE9.mp3\r\n";
|
||||
assert_eq!(lines(&crate::io::open_bytes("old.m3u", cp1252).unwrap()), "#EXTM3U\n#EXTINF:100,Café — Live\nC:\\Music\\café.mp3\n");
|
||||
// Other text files, a file without an extension, a dot in a folder name only.
|
||||
for name in ["server.log", "settings.ini", "subs.srt", "README", "/home/a.b/README", "C:\\v1.2\\notes"] {
|
||||
assert_eq!(lines(&crate::io::open_bytes(name, b"one\ntwo").unwrap()), "one\ntwo", "{name}");
|
||||
}
|
||||
// Binary files: an error naming the file, no panic.
|
||||
for (name, bytes) in
|
||||
[("song.mp3", &b"ID3\x04\0\0\0\0\x01\x02\x03"[..]), ("pic.dat", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), ("blob", &[0u8, 1, 2, 3, 0, 255])]
|
||||
{
|
||||
let err = crate::io::open_bytes(name, bytes).unwrap_err();
|
||||
assert!(err.contains(name) && err.contains("not a document"), "{name}: {err}");
|
||||
}
|
||||
assert!(crate::io::open_bytes("x.pdf", b"%PDF-1.7\n1 0 obj\n").is_err());
|
||||
// A Word document or RTF file with the wrong extension opens by what it holds.
|
||||
let docx = crate::io::save_bytes("x.docx", &crate::sample::letter()).unwrap();
|
||||
assert_eq!(lines(&crate::io::open_bytes("letter.bak", &docx).unwrap()), lines(&crate::io::open_bytes("x.docx", &docx).unwrap()));
|
||||
let rtf = crate::io::open_bytes("note.tmp", br"{\rtf1\ansi Hello {\b bold}\par}").unwrap();
|
||||
assert!(lines(&rtf).starts_with("Hello bold"), "{:?}", lines(&rtf));
|
||||
// Through file.open, both from disk and with the bytes (the web's picker and drops).
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-569-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("road trip.m3u");
|
||||
std::fs::write(&path, cp1252).unwrap();
|
||||
let mut s = s();
|
||||
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(r["paragraphs"], 4);
|
||||
assert!(text(&s).contains("Café — Live"));
|
||||
let data = crate::cmd::insert::base64_encode(m3u8.as_bytes());
|
||||
run(&mut s, "file.open", json!({"path": "Mix.m3u8", "data": data}));
|
||||
assert!(text(&s).contains("Радио"));
|
||||
let bad = crate::cmd::insert::base64_encode(b"\0\x01\x02binary\0");
|
||||
assert!(s.run("file.open", &json!({"path": "x.bin", "data": bad})).is_err());
|
||||
assert!(text(&s).contains("Радио"), "a failed open leaves the document alone");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #549: after a double-click, dragging extends the selection by whole words; after a
|
||||
/// triple-click by whole paragraphs; after Ctrl/⌘+click by whole sentences. Forward drags keep
|
||||
/// the first unit's start as the anchor, backward drags its end.
|
||||
|
|
|
|||
|
|
@ -280,6 +280,26 @@ fn txt_round_trip() {
|
|||
assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€");
|
||||
}
|
||||
|
||||
/// #569: a file with an extension we don't know opens as text when it is text (a playlist), and
|
||||
/// a binary file is told apart.
|
||||
#[test]
|
||||
fn text_sniffing() {
|
||||
use crate::txt::is_text;
|
||||
assert!(is_text(b""));
|
||||
assert!(is_text(b"#EXTM3U\r\n#EXTINF:123,Artist - Title\r\nC:\\Music\\song.mp3\r\n"));
|
||||
assert!(is_text("#EXTM3U\n#EXTINF:-1,Мелодия — ünïcødé\nhttp://x/y.mp3\n".as_bytes()));
|
||||
assert!(is_text(&[0x23, 0x45, 0x58, 0x54, 0xE9, 0x0D, 0x0A, 0x1A])); // Windows-1252, DOS ^Z
|
||||
assert!(is_text(&[0xEF, 0xBB, 0xBF, b'a']));
|
||||
let u16le: Vec<u8> = "#EXTM3U\r\n".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
assert!(is_text(&u16le));
|
||||
assert!(!is_text(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"));
|
||||
assert!(!is_text(b"PK\x03\x04\x14\0\x06\0"));
|
||||
assert!(!is_text(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"));
|
||||
assert!(!is_text(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 3, 4, 5, 6, 7, 8]));
|
||||
// Odd-length bytes with zeros mixed in: binary, not UTF-16.
|
||||
assert!(!is_text(&[1, 0, 2, 0, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_unknown() {
|
||||
assert!(import("xyz", b"").is_none());
|
||||
|
|
|
|||
|
|
@ -13,6 +13,53 @@ fn utf16(b: &[u8], le: bool) -> String {
|
|||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
|
||||
/// UTF-16 without a BOM, told by its zero bytes: `Some(true)` little-endian, `Some(false)`
|
||||
/// big-endian.
|
||||
fn unmarked_utf16(b: &[u8]) -> Option<bool> {
|
||||
if b.len() < 4 || !b.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let sample = b.get(..b.len().min(512))?;
|
||||
let even_zero = sample.iter().step_by(2).filter(|x| **x == 0).count();
|
||||
let odd_zero = sample.iter().skip(1).step_by(2).filter(|x| **x == 0).count();
|
||||
let half = sample.len() / 2;
|
||||
if odd_zero * 10 >= half * 4 && even_zero * 10 < half {
|
||||
return Some(true);
|
||||
}
|
||||
if even_zero * 10 >= half * 4 && odd_zero * 10 < half {
|
||||
return Some(false);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Does `b` look like plain text, so a file with an extension WordCraft doesn't know (a
|
||||
/// playlist, a log, a config file…) can open as text the way Word's text import opens it?
|
||||
///
|
||||
/// Text has a BOM, or reads as UTF-16 by its zero bytes, or has no NUL byte and next to no
|
||||
/// other control characters in its first 64 KB. PDFs are refused by their signature (they
|
||||
/// start as text). An empty file is text.
|
||||
pub fn is_text(b: &[u8]) -> bool {
|
||||
if b.starts_with(&[0xEF, 0xBB, 0xBF]) || b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) {
|
||||
return true;
|
||||
}
|
||||
if b.starts_with(b"%PDF-") {
|
||||
return false;
|
||||
}
|
||||
let sample = b.get(..b.len().min(64 << 10)).unwrap_or(b);
|
||||
// Tab, line feed, form feed, carriage return, DOS end-of-file (^Z) and escape appear in text;
|
||||
// other controls in more than 1 of 100 characters mean binary.
|
||||
let odd = |c: u32| c == 0 || (c < 0x20 && !matches!(c, 0x09 | 0x0A | 0x0C | 0x0D | 0x1A | 0x1B)) || c == 0x7F;
|
||||
if let Some(le) = unmarked_utf16(sample) {
|
||||
let units = sample.as_chunks::<2>().0;
|
||||
let n = units.iter().filter(|c| odd(u32::from(if le { u16::from_le_bytes(**c) } else { u16::from_be_bytes(**c) }))).count();
|
||||
return n.saturating_mul(100) <= units.len();
|
||||
}
|
||||
if sample.contains(&0) {
|
||||
return false;
|
||||
}
|
||||
sample.iter().filter(|c| odd(u32::from(**c))).count().saturating_mul(100) <= sample.len()
|
||||
}
|
||||
|
||||
/// Decode text bytes: BOMs first, then UTF-16 without BOM (by its zero bytes), UTF-8, and
|
||||
/// Windows-1252 as the last resort.
|
||||
pub fn decode(b: &[u8]) -> String {
|
||||
|
|
@ -25,17 +72,8 @@ pub fn decode(b: &[u8]) -> String {
|
|||
if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return utf16(rest, false);
|
||||
}
|
||||
if b.len() >= 4 && b.len().is_multiple_of(2) {
|
||||
let sample = &b[..b.len().min(512)];
|
||||
let even_zero = sample.iter().step_by(2).filter(|x| **x == 0).count();
|
||||
let odd_zero = sample.iter().skip(1).step_by(2).filter(|x| **x == 0).count();
|
||||
let half = sample.len() / 2;
|
||||
if odd_zero * 10 >= half * 4 && even_zero * 10 < half {
|
||||
return utf16(b, true);
|
||||
}
|
||||
if even_zero * 10 >= half * 4 && odd_zero * 10 < half {
|
||||
return utf16(b, false);
|
||||
}
|
||||
if let Some(le) = unmarked_utf16(b) {
|
||||
return utf16(b, le);
|
||||
}
|
||||
match std::str::from_utf8(b) {
|
||||
Ok(s) => s.to_string(),
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ pub fn tool_definitions() -> Value {
|
|||
tool(
|
||||
"open_document",
|
||||
"Open",
|
||||
"Open a .docx, .odt, .rtf, .md, .tex, .html, .txt or .json file.",
|
||||
"Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text.",
|
||||
obj(json!({"path": s("File path")}), &["path"]),
|
||||
false
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; 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-11 · **Change:** trivial (unknown text extensions such as `.m3u` open as plain text, #569; previously trivial: Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; 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;
|
||||
|
|
@ -27,7 +27,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
|||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86). No footnotes, comments, tracked changes, fields or RTL |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection; Word's encoding choice dialog and line-ending options missing |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
|
||||
|
|
@ -118,6 +118,7 @@ coverage and WordCraft layout matching remain separate gaps.
|
|||
| 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) |
|
||||
| 2026-10-11 | trivial | Attached template (`w:attachedTemplate`, external relationship of the settings part) and `w:linkStyles` read and written in schema order; opening never reads the template (#377) |
|
||||
| 2026-10-11 | trivial | A bookmark (or comment range) ending after the document's last table keeps its end, in the table's last cell (#464); a field whose cached result is a picture (`INCLUDEPICTURE`) is kept as a range field around the picture, so the picture survives save; the linked file is never read (#465) |
|
||||
| 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
|
||||
| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue