From ab49e4f5fa4815e5c53f2f4a0ba56006bcee3509 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 05:07:41 +0200 Subject: [PATCH] =?UTF-8?q?Open=20playlists=20and=20other=20text=20files?= =?UTF-8?q?=20(.m3u,=20.m3u8,=20.log,=20.ini,=20=E2=80=A6)=20(#569)=20(#64?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/engine/src/io.rs | 37 +++++++++++++++++++++-- crates/engine/src/tests.rs | 55 ++++++++++++++++++++++++++++++++++ crates/formats/src/tests.rs | 20 +++++++++++++ crates/formats/src/txt.rs | 60 ++++++++++++++++++++++++++++++------- crates/mcp/src/tools.rs | 2 +- docs/file-format-parity.md | 5 ++-- 7 files changed, 165 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ee3cb24..8d91569 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%) | diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 59a43fa..204c115 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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::(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, String> { let ext = ext_of(name); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 875b3d1..0f0bf6b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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. diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 889d8f2..b939e7a 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -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 = "#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()); diff --git a/crates/formats/src/txt.rs b/crates/formats/src/txt.rs index c7dd7dd..0f85b3a 100644 --- a/crates/formats/src/txt.rs +++ b/crates/formats/src/txt.rs @@ -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 { + 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(), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index ca1961e..8ec4dfd 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -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 ), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index aed3e26..d5cdcb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 `` 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) |