collective

Browse / github.com/storytold/wordcraft / patch

.doc reader: table of contents, note numbers, heading styles, list styles and the Styles gallery

.doc reader: table of contents, note numbers, heading styles, list styles and the Styles gallery Six fixes to the Word 97-2003 (`.doc`) reader, one commit each, found by comparing 23 real `.doc` files from GOV.UK with the PDFs Word exported of them (each PDF's Producer says Microsoft Word, and its text matches the file). An Ofsted report, "Going in the right direction?" (No. 130114), showed all of them: | | Before | Now | |---|---|---| | Table of contents | a blank page with one line of `HYPERLINK \l "_Toc…" Executive summary PAGEREF _Toc… \h 4 …` running off it | entries, levels and page numbers as in Word | | Update Table | rebuilt the entries in plain text | keeps the document's TOC s

Status
open
Score
0.00
Adopters
0 other operators
Author
VoiceLessQ (imported from pull request #4484, open)
Base
4b8b06fe03f93c9fc81783ee3e5b01977be89efc
Size
+491 −63 in 5 files
Review
pending
Shared
2026-10-28
Id
5998e5d3c5a2d60aaf8ed3e9

How it went

No other operator has reported using it yet.

Diff

raw · crates/docbin/src/fmt.rs crates/docbin/src/lib.rs crates/docbin/src/stsh.rs crates/docbin/src/tests.rs docs/file-format-parity.md

diff --git a/crates/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs--- a/crates/docbin/src/fmt.rs+++ b/crates/docbin/src/fmt.rs@@ -2,7 +2,8 @@  use wordcraft_doc::Rgb; use wordcraft_doc::props::{-    Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign,+    Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, NumRef, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline,+    VertAlign, }; use wordcraft_doc::styles::{Style, StyleKind, StyleSheet}; @@ -428,14 +429,15 @@ pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String]) -> Sty         if st.name.is_empty() {             continue;         }-        let id = style_id(&st.name);+        let builtin = builtin_by_sti(st.sti);+        let id = raw_style_id(st);         let kind = match st.stk {             2 => StyleKind::Character,             3 => StyleKind::Table,             4 => StyleKind::Numbering,             _ => StyleKind::Paragraph,         };-        let base = base_of(raw, istd).and_then(|i| raw.get(i)).map(|b| style_id(&b.name));+        let base = base_of(raw, istd).and_then(|i| raw.get(i)).map(raw_style_id);         // Inherit: properties come from the base chain, root first, then our own grpprls.         let mut chr = CharProps::default();         let mut para = ParaProps::default();@@ -444,12 +446,13 @@ pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String]) -> Sty             apply_chain_para(&mut para, &s.papx);         }         if para.outline_level.is_none() && kind == StyleKind::Paragraph {-            let h = heading_level(&st.name);+            let h = builtin.as_ref().map_or_else(|| heading_level(&st.name), |b| b.2);             if h > 0 {                 para.outline_level = Some(h - 1);             }         }-        sheet.styles.push(Style { id, name: display_name(&st.name), kind, based_on: base, chr, para, ..Default::default() });+        let name = builtin.map_or_else(|| display_name(&st.name), |b| b.1);+        sheet.styles.push(Style { id, name, kind, based_on: base, chr, para, ..Default::default() });     }     sheet }@@ -482,9 +485,34 @@ fn style_chain(raw: &[crate::stsh::RawStyle], istd: usize) -> Vec<usize> {     chain } +/// The built-in style an invariant `sti` names: (id, shown name, heading level). The `sti` is+/// the same in every language ([MS-DOC] StdfBase), so "Überschrift 1" is still heading 1.+fn builtin_by_sti(sti: u16) -> Option<(String, String, u8)> {+    match sti {+        0 => Some(("Normal".into(), "Normal".into(), 0)),+        1..=9 => Some((format!("Heading{sti}"), format!("Heading {sti}"), sti as u8)),+        19..=27 => Some((format!("TOC{}", sti - 18), format!("TOC {}", sti - 18), 0)),+        _ => None,+    }+}++/// A stored style's id: by its built-in index when it has one, else by its name.+pub(crate) fn raw_style_id(raw: &crate::stsh::RawStyle) -> String {+    builtin_by_sti(raw.sti).map_or_else(|| style_id(&raw.name), |b| b.0)+}+ /// The style-id form of a stored (lower-case) built-in name, matching the docx reader's ids. pub(crate) fn style_id(name: &str) -> String {     let lower = name.trim().to_ascii_lowercase();+    // Word's ids for the TOC levels and headings, which References › Update Table and the+    // Heading 1-3 commands (Ctrl+Alt+1-3) name.+    if let Some(n) = toc_level(&lower) {+        return format!("TOC{n}");+    }+    let h = heading_level(&lower);+    if h > 0 {+        return format!("Heading{h}");+    }     match lower.as_str() {         "normal" => "Normal".into(),         "title" => "Title".into(),@@ -522,12 +550,20 @@ fn heading_level(name: &str) -> u8 {     lower.strip_prefix("heading ").and_then(|d| d.parse().ok()).filter(|&l: &u8| (1..=9).contains(&l)).unwrap_or(0) } +/// "toc 2" → 2; `None` when not a table of contents level.+fn toc_level(lower: &str) -> Option<u8> {+    lower.strip_prefix("toc ").and_then(|d| d.parse().ok()).filter(|l: &u8| (1..=9).contains(l))+}+ fn display_name(name: &str) -> String {     let lower = name.trim().to_ascii_lowercase();     let h = heading_level(&lower);     if h > 0 {         return format!("Heading {h}");     }+    if let Some(n) = toc_level(&lower) {+        return format!("TOC {n}");+    }     match lower.as_str() {         "body text" => "Body Text".into(),         _ => style_id(name),@@ -546,9 +582,27 @@ fn apply_chain(chr: &mut CharProps, grpprl: &[u8], fonts: &[String]) {  /// Apply a grpprl of paragraph sprms. fn apply_chain_para(para: &mut ParaProps, grpprl: &[u8]) {+    let (mut ilfo, mut ilvl) = (None, None);     for prl in sprm::iter(grpprl) {-        if prl.sgc() == 1 {-            apply_para(para, &prl);+        match prl.op {+            crate::P_ILFO => ilfo = Some(crate::ilfo_operand(prl.operand)),+            crate::P_ILVL => ilvl = Some(crate::ilvl_operand(prl.operand)),+            _ if prl.sgc() == 1 => apply_para(para, &prl),+            _ => {}+        }+    }+    // A list style ("List Number", a numbered paragraph style): its paragraphs are numbered+    // without a list of their own. A style based on it may change the level or end the list.+    match (ilfo, &mut para.numbering) {+        (Some(ilfo), n) => {+            let level = ilvl.or(n.map(|n| n.level)).unwrap_or(0);+            *n = match crate::num_of(ilfo) {+                Some(num) => Some(NumRef { num, level }),+                // Ending the based-on style's list is `num: 0`: no value would inherit it.+                None => n.map(|_| NumRef { num: 0, level }),+            };         }+        (None, Some(n)) => n.level = ilvl.unwrap_or(n.level),+        (None, None) => {}     } }diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs--- a/crates/docbin/src/lib.rs+++ b/crates/docbin/src/lib.rs@@ -248,6 +248,17 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume     }     doc.body.clear();     doc.styles = fmt::stylesheet(&raw_styles, &fonts);+    // Word's built-in styles (`sti` below istiUser) are built in here too, and with no quick+    // flag read the Home › Styles gallery shows Word's usual set, as the DOCX reader does.+    for raw in raw_styles.iter().filter(|r| !r.name.is_empty() && r.sti < stsh::STI_USER) {+        let id = fmt::raw_style_id(raw);+        if let Some(st) = doc.styles.styles.iter_mut().find(|s| s.id == id) {+            st.builtin = true;+        }+    }+    if !doc.styles.styles.iter().any(|s| s.quick) {+        doc.styles.add_default_gallery();+    }     doc.numbering = list::parse(&table, &fib, &fonts);     let sheet: StyleSheet = doc.styles.clone();     let sects = sections::parse(&word, &table, &fib);@@ -278,6 +289,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume         raw_styles: &raw_styles,         sheet: &sheet,         notes: &[],+        own_note: None,         marks: &[],         drawn: &[],         sect_ends: &[],@@ -337,20 +349,27 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume     // Footnote and endnote stories: each text range becomes a part; the reference CPs then     // link to the part ids during the main walk.     let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();-    for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {-        let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };-        let blocks = table::assemble(walk(&ctx, a, b, &mut pics));-        if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {-            let id = doc.add_part(PartKind::Footnote, blocks);-            note_links.push((cp, NoteKind::Footnote, id));-        }-    }-    for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {-        let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };-        let blocks = table::assemble(walk(&ctx, a, b, &mut pics));-        if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {-            let id = doc.add_part(PartKind::Endnote, blocks);-            note_links.push((cp, NoteKind::Endnote, id));+    let note_kinds = [+        (fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT, ftn_base, fib.ccp.ftn, NoteKind::Footnote, PartKind::Footnote),+        (fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT, edn_base, fib.ccp.edn, NoteKind::Endnote, PartKind::Endnote),+    ];+    for (ref_pair, txt_pair, base, len, kind, part_kind) in note_kinds {+        for (cp, ab) in notes::parse_notes(&table, &fib, ref_pair, txt_pair) {+            let Some((a, b)) = story(base, len, ab) else { continue };+            if doc.parts.len() >= MAX_PARTS {+                continue;+            }+            // The part's id first, so the note's own number in its text can name it.+            let Ok(id) = doc.try_add_part(part_kind, Vec::new()) else { continue };+            let blocks = table::assemble(walk(&WalkCtx { own_note: Some((kind, id)), ..ctx }, a, b, &mut pics));+            if blocks.is_empty() {+                doc.parts.remove(&id);+                continue;+            }+            if let Some(part) = doc.parts.get_mut(&id) {+                part.blocks = blocks;+            }+            note_links.push((cp, kind, id));         }     }     // Sorted so the main walk finds each reference by binary search.@@ -417,6 +436,8 @@ struct WalkCtx<'a> {     sheet: &'a StyleSheet,     /// (reference CP in the main document, kind, part id) for notes.     notes: &'a [(u32, NoteKind, u32)],+    /// The note whose text is being walked: its reference character is the note's own number.+    own_note: Option<(NoteKind, u32)>,     /// Bookmark boundaries (main-document CPs), sorted for a sequential walk.     marks: &'a [(u32, notes::Mark)],     /// Floating shapes and pictures by anchor CP (absolute), sorted.@@ -486,9 +507,10 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic     let mut pb = ParaBuild::default();     let mut cp = cp_start;     let mut marks = ctx.marks.iter().peekable();-    // Field assembly state: instruction characters are swallowed, the result is kept until-    // the end character, then emitted as one object (hyperlinks keep their result text).-    let mut field: Option<FieldBuild> = None;+    // Open fields, innermost last: instruction characters are swallowed, the result is kept until+    // the end character, then emitted as one object (hyperlinks keep their result text). A field+    // in another's instruction is flattened into it; one in a result is read on its own.+    let mut fields: Vec<FieldBuild> = Vec::new();     let mut memo: Option<FmtMemo<'a>> = None;     while cp < cp_end {         // Bookmark boundaries at this CP (main-document walks only; subdocument walks get@@ -527,31 +549,35 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic                 let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);                 let (istd, papx) = ctx.papx_bins.papx(ctx.word, fc_mark);                 if let Some(st) = ctx.raw_styles.get(istd as usize).filter(|s| !s.name.is_empty()) {-                    pb.props.style = Some(fmt::style_id(&st.name));+                    pb.props.style = Some(fmt::raw_style_id(st));                 }                 let mut row = table::decode(papx);                 row.cell_mark = c == '\u{7}';-                let mut ilvl = 0u8;-                let mut ilfo = 0i32;+                let mut ilvl: Option<u8> = None;+                let mut ilfo: Option<i32> = None;                 for prl in sprm::iter(papx) {                     if prl.op == P_ILVL {-                        ilvl = prl.operand.first().copied().unwrap_or(0).min(8);+                        ilvl = Some(ilvl_operand(prl.operand));                         continue;                     }                     if prl.op == P_ILFO {-                        ilfo = match prl.operand {-                            [b0, b1, ..] => i16::from_le_bytes([*b0, *b1]) as i32,-                            _ => 0,-                        };+                        ilfo = Some(ilfo_operand(prl.operand));                         continue;                     }                     if prl.sgc() == 1 {                         fmt::apply_para(&mut pb.props, &prl);                     }                 }-                if let Some(num) = num_of(ilfo) {-                    pb.props.numbering = Some(NumRef { num, level: ilvl });-                }+                // The paragraph's own list, or its style's list at its own level. Switching the+                // style's list off is `num: 0`, which also drops the style's indents, so only a+                // style that has a list gets it.+                let style_list = pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)).and_then(|s| s.para.numbering).filter(|n| n.num != 0);+                pb.props.numbering = match (ilfo.map(num_of), style_list) {+                    (Some(Some(num)), _) => Some(NumRef { num, level: ilvl.or(style_list.map(|n| n.level)).unwrap_or(0) }),+                    (Some(None), Some(s)) => Some(NumRef { num: 0, level: s.level }),+                    (None, Some(s)) => ilvl.map(|level| NumRef { num: s.num, level }),+                    _ => None,+                };                 let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);                 pb.mark = char_props(ctx.chpx_bins.chpx(ctx.word, fc), ctx.fonts, &CharProps::default());                 let done = std::mem::take(&mut pb);@@ -570,32 +596,69 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic             }             // Field begin/separator/end ([MS-DOC] §2.9.84 Plcfld).             '\u{13}' => {-                match field.as_mut() {-                    Some(f) => {-                        f.depth = f.depth.saturating_add(1);-                    }-                    None => field = Some(FieldBuild { depth: 1, instr: String::new(), sep: None }),+                let full = fields.len() >= MAX_FIELD_NEST;+                match fields.last_mut() {+                    Some(f) if f.over_hidden > 0 => f.over_hidden = f.over_hidden.saturating_add(1),+                    Some(f) if f.sep.is_none() && !f.range => f.depth = f.depth.saturating_add(1),+                    // Past the cap, a field in a result is counted, not kept: its code is+                    // hidden and its result shows in the innermost kept field.+                    Some(f) if full => f.over_hidden = 1,+                    _ => fields.push(FieldBuild { depth: 1, instr: String::new(), sep: None, range: false, over_hidden: 0, over_shown: 0 }),                 }                 cp = cp.saturating_add(1);                 continue;             }             '\u{14}' => {-                if let Some(f) = field.as_mut()+                if let Some(f) = fields.last_mut()+                    && f.over_hidden > 0+                {+                    // The separator of the innermost counted field: its result follows.+                    if f.over_hidden == 1 {+                        f.over_hidden = 0;+                        f.over_shown = f.over_shown.saturating_add(1);+                    }+                } else if let Some(f) = fields.last_mut()                     && f.depth == 1                     && f.sep.is_none()+                    && !f.range+                    && f.over_shown == 0                 {-                    f.sep = Some(Vec::new());+                    if is_range_instr(&f.instr) {+                        // A table of contents keeps its entries as ordinary paragraphs between+                        // markers, as the DOCX reader does.+                        f.range = true;+                        push_field_start(&mut pb, &f.instr);+                    } else {+                        f.sep = Some(Vec::new());+                    }                 }                 cp = cp.saturating_add(1);                 continue;             }             '\u{15}' => {-                if let Some(mut f) = field.take() {+                if let Some(f) = fields.last_mut()+                    && (f.over_hidden > 0 || f.over_shown > 0)+                {+                    if f.over_hidden > 0 {+                        f.over_hidden -= 1;+                    } else {+                        f.over_shown -= 1;+                    }+                } else if let Some(mut f) = fields.pop() {                     if f.depth > 1 {                         f.depth -= 1;-                        field = Some(f);+                        fields.push(f);+                    } else if f.range {+                        push_field_end(&mut pb);+                    } else if f.sep.is_none() && is_range_instr(&f.instr) {+                        push_field_start(&mut pb, &f.instr);+                        push_field_end(&mut pb);                     } else {-                        finish_field(&mut pb, f, ctx.work);+                        match fields.last_mut().filter(|o| !o.range).and_then(|o| o.sep.as_mut()) {+                            // A field in another field's kept result shows its own result there.+                            Some(outer) => outer.extend(f.sep.unwrap_or_default()),+                            None => finish_field(&mut pb, f, ctx.work),+                        }                     }                 }                 cp = cp.saturating_add(1);@@ -638,23 +701,25 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic                 }             }             '\u{2}' => {-                // Footnote or endnote reference; custom symbols share the same character.+                // Footnote or endnote reference; custom symbols share the same character. In a+                // note's own text it is that note's number, as `w:footnoteRef` is in DOCX.                 let at = ctx.notes.partition_point(|(c, _, _)| *c < cp);-                if let Some((_, kind, id)) = ctx.notes.get(at).filter(|(c, _, _)| *c == cp) {+                let note = ctx.notes.get(at).filter(|(c, _, _)| *c == cp).map(|&(_, kind, id)| (kind, id)).or(ctx.own_note);+                if let Some((kind, id)) = note {                     pb.push('\u{FFFC}', CharProps::default());-                    pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });+                    pb.objects.push(InlineObject::NoteRef { kind, id, custom: String::new() });                 }             }-            '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),-            '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),-            '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),-            '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),-            '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),-            '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),+            '\t' => push_formatted(&mut pb, cp, '\t', ctx, fields.last_mut(), &mut memo),+            '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, fields.last_mut(), &mut memo),+            '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, fields.last_mut(), &mut memo),+            '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, fields.last_mut(), &mut memo),+            '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, fields.last_mut(), &mut memo),+            '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, fields.last_mut(), &mut memo),             // Object anchors, comment references and other special characters are dropped             // (or handled by later passes).             c if (c as u32) < 0x20 => {}-            c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),+            c => push_formatted(&mut pb, cp, c, ctx, fields.last_mut(), &mut memo),         }         cp = cp.saturating_add(units);     }@@ -675,6 +740,33 @@ struct FieldBuild {     depth: u32,     instr: String,     sep: Option<Vec<(char, CharProps)>>,+    /// A range field: its result is ordinary content after a `FieldStart` marker.+    range: bool,+    /// Fields past [`MAX_FIELD_NEST`] opened in this one's result, only counted: those still+    /// in their code (whose characters are dropped), and those showing their result here.+    over_hidden: u32,+    over_shown: u32,+}++/// Fields open inside one another's results at most; deeper ones are only counted by the+/// innermost (`over_hidden`, `over_shown`), so a hostile file can't grow the stack without bound.+const MAX_FIELD_NEST: usize = 32;++/// Fields whose result stays ordinary content between `FieldStart` and `FieldEnd` markers, as+/// the DOCX reader keeps them: a table of contents (its entries are paragraphs) and ADDIN fields.+fn is_range_instr(instr: &str) -> bool {+    let t = instr.trim_start();+    t.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC")) || t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))+}++fn push_field_start(pb: &mut ParaBuild, instr: &str) {+    pb.push('\u{FFFC}', CharProps::default());+    pb.objects.push(InlineObject::FieldStart { instr: instr.trim().to_string(), locked: false, dirty: false });+}++fn push_field_end(pb: &mut ParaBuild) {+    pb.push('\u{FFFC}', CharProps::default());+    pb.objects.push(InlineObject::FieldEnd); }  /// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all@@ -824,6 +916,19 @@ fn symbol_operand(chpx: &[u8]) -> Option<(u16, u16)> {     None } +/// `sprmPIlvl` operand: the list level, 0-8.+fn ilvl_operand(operand: &[u8]) -> u8 {+    operand.first().copied().unwrap_or(0).min(8)+}++/// `sprmPIlfo` operand: the signed 16-bit LFO reference that [`num_of`] reads.+fn ilfo_operand(operand: &[u8]) -> i32 {+    match operand {+        [b0, b1, ..] => i16::from_le_bytes([*b0, *b1]) as i32,+        _ => 0,+    }+}+ /// `sprmPIlfo` operand (a signed 16-bit value, [MS-DOC] §2.6.2) → the num id, if the /// paragraph is in a list. 0x0001-0x07FE is a 1-based LFO index; 0xF802-0xFFFF (-2046..=-1) /// is the negation of one and keeps the paragraph's own indents, which we do by leaving the@@ -841,6 +946,10 @@ fn num_of(ilfo: i32) -> Option<u32> { /// the character is recorded into the instruction or the cached result instead of (or as /// well as) the visible text. fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {+    // The code of a field counted past the nesting cap is never shown.+    if field.as_ref().is_some_and(|f| f.over_hidden > 0) {+        return;+    }     let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);     let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);     let prm = ctx.pieces.prm_of(cp);@@ -875,7 +984,8 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f             c = sym;         }     }-    match field {+    // A range field's result is ordinary content.+    match field.filter(|f| !f.range) {         Some(f) => match f.sep.as_mut() {             // Inside a field, characters are recorded for the field object instead of shown;             // `finish_field` emits the visible text (hyperlink runs or one object anchor).diff --git a/crates/docbin/src/stsh.rs b/crates/docbin/src/stsh.rs--- a/crates/docbin/src/stsh.rs+++ b/crates/docbin/src/stsh.rs@@ -6,9 +6,14 @@ use crate::DocbinError; /// Largest stylesheet we accept (styles are 0x0FFE max by the spec; stay stricter). const MAX_STYLES: usize = 4096; +/// `istiUser`: the `sti` of a style the user made; lower values are Word's built-in styles.+pub(crate) const STI_USER: u16 = 0x0FFE;+ /// One raw style record (`STD`). #[derive(Clone, Debug, Default)] pub(crate) struct RawStyle {+    /// Built-in style index ([MS-DOC] §2.9.260): Word's own styles are below [`STI_USER`].+    pub(crate) sti: u16,     /// 1 paragraph, 2 character, 3 table, 4 numbering.     pub(crate) stk: u8,     /// Zero-based index of the parent style; 0x0FFF = none.@@ -60,6 +65,7 @@ pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, Do         // StdfBase ([MS-DOC] §2.9.260, 10 bytes): sti(12)+flags(4); stk(4)+istdBase(12);         // cupx(4)+istdNext(12); bchUpe(16) (= cbStd); grfstd(16). istdBase is a zero-based         // index into the stylesheet, 0x0FFF meaning "no parent".+        let sti = stdf.get(0..2).map(|b| u16::from_le_bytes([b[0], b[1]]) & 0x0FFF).unwrap_or(STI_USER);         let stk = (stdf.get(2).copied().unwrap_or(1) & 0x0F).max(1);         let w1 = stdf.get(2..4).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0xFFF1);         let istd_base = w1 >> 4;@@ -100,7 +106,7 @@ pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, Do         {             chpx = u.clone();         }-        out.push(RawStyle { stk, istd_base, name, papx, chpx });+        out.push(RawStyle { sti, stk, istd_base, name, papx, chpx });     }     Ok(out) }diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs--- a/crates/docbin/src/tests.rs+++ b/crates/docbin/src/tests.rs@@ -532,6 +532,8 @@ struct RichExtra {     prm: u16,     alt_fonts: Vec<Option<&'static str>>,     style_next: Vec<u16>,+    /// Built-in style indexes (`sti`), parallel to `RichSpec::styles`; none means user-made.+    style_sti: Vec<u16>, }  fn rich_doc(spec: &RichSpec) -> Vec<u8> {@@ -569,7 +571,14 @@ fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec<u8> {         .styles         .iter()         .enumerate()-        .map(|(i, s)| std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4))+        .map(|(i, s)| {+            let mut std = std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4);+            if let Some(sti) = extra.style_sti.get(i) {+                // After the record's cbStd: sti(12) and flags.+                std[2..4].copy_from_slice(&sti.to_le_bytes());+            }+            std+        })         .collect();     let stsh = stsh_bytes(&styles);     let fonts: Vec<(&str, Option<&str>)> = spec.fonts.iter().enumerate().map(|(i, n)| (*n, extra.alt_fonts.get(i).copied().flatten())).collect();@@ -669,14 +678,137 @@ fn paragraph_props_and_heading_style() {     let doc = read(&rich_doc(&spec)).expect("opens");     let heading = doc.styles.styles.iter().find(|s| s.name == "Heading 1").expect("style");     assert_eq!(heading.para.align, Some(wordcraft_doc::props::Align::Center));+    // Word's id, which Home › Styles › Heading 1 (Ctrl+Alt+1) applies; with `Heading 1` the+    // command failed with "no style `Heading1`" in every .doc file.+    assert_eq!(heading.id, "Heading1");     let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");-    assert_eq!(p0.props.style.as_deref(), Some("Heading 1"));+    assert_eq!(p0.props.style.as_deref(), Some("Heading1"));     assert_eq!(heading.para.outline_level, Some(0));     let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("para");     assert_eq!(p1.props.indent_left, Some(36.0)); // 720 twips = 36pt     assert_eq!(p1.props.style.as_deref(), Some("Normal")); } +/// The TOC level styles get Word's ids (`TOC1`), the ones References › Update Table gives its+/// entries; with `TOC 1` the rebuilt entries lost the document's TOC formatting.+#[test]+fn toc_styles_take_words_ids() {+    let spec = RichSpec {+        text: "Intro\rPart\r",+        styles: vec![+            (1, 0x0FFF, "Normal", Vec::new(), Vec::new()),+            (1, 0, "toc 1", grpprl(&[(0x2461, &[0x01, 0x00])]), Vec::new()),+            (1, 1, "toc 2", Vec::new(), Vec::new()),+        ],+        fonts: vec!["Times New Roman"],+        chpx_runs: vec![(0, Vec::new())],+        papx_entries: vec![(0, 1, Vec::new()), (6, 2, Vec::new())],+    };+    let doc = read(&rich_doc(&spec)).expect("opens");+    let ids: Vec<(&str, &str, Option<&str>)> =+        doc.styles.styles.iter().filter(|s| s.name.starts_with("TOC")).map(|s| (s.id.as_str(), s.name.as_str(), s.based_on.as_deref())).collect();+    assert_eq!(ids, [("TOC1", "TOC 1", Some("Normal")), ("TOC2", "TOC 2", Some("TOC1"))]);+    let styles: Vec<Option<&str>> = doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.as_deref()).collect();+    assert_eq!(styles, [Some("TOC1"), Some("TOC2")]);+}++/// A list style numbers its paragraphs without a list of their own. Its `sprmPIlfo` was read+/// only from paragraphs, so the numbers and bullets of "Numbered Paragraph"-style documents were+/// lost. Ending the list (in a based-on style or a paragraph) is `num: 0`; `ilfo 0` on a+/// paragraph whose style has no list keeps nothing, so its indents stay.+#[test]+fn list_styles_number_their_paragraphs() {+    use wordcraft_doc::props::NumRef;+    let ilfo = |n: i16| n.to_le_bytes();+    let spec = RichSpec {+        text: "a\rb\rc\rd\re\r",+        styles: vec![+            (1, 0x0FFF, "Normal", Vec::new(), Vec::new()),+            (1, 0, "List Number", grpprl(&[(0x460B, &ilfo(1)), (0x260A, &[0])]), Vec::new()),+            (1, 1, "List Number Extra", grpprl(&[(0x460B, &ilfo(0))]), Vec::new()),+            (1, 0, "Body", Vec::new(), Vec::new()),+        ],+        fonts: vec!["Times New Roman"],+        chpx_runs: vec![(0, Vec::new())],+        papx_entries: vec![+            (0, 1, Vec::new()),+            (2, 2, Vec::new()),+            (4, 1, grpprl(&[(0x460B, &ilfo(0))])),+            (6, 3, grpprl(&[(0x460B, &ilfo(0))])),+            (8, 1, grpprl(&[(0x260A, &[2])])),+        ],+    };+    let doc = read(&rich_doc(&spec)).expect("opens");+    let style = |id: &str| doc.styles.get(id).and_then(|s| s.para.numbering);+    assert_eq!(style("List Number"), Some(NumRef { num: 1, level: 0 }));+    assert_eq!(style("List Number Extra"), Some(NumRef { num: 0, level: 0 }), "ends the based-on list");+    let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();+    let own: Vec<Option<NumRef>> = paras.iter().map(|p| p.props.numbering).collect();+    assert_eq!(own, [None, None, Some(NumRef { num: 0, level: 0 }), None, Some(NumRef { num: 1, level: 2 })]);+    let shown: Vec<Option<u32>> =+        paras.iter().map(|p| p.props.numbering.or(doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0).map(|n| n.num)).collect();+    assert_eq!(shown, [Some(1), None, None, None, Some(1)]);+}++/// Word's built-in styles (`sti` below istiUser) are built in, and with no quick-style flag+/// read the Home › Styles gallery shows Word's usual set, as for a DOCX file without+/// `w:qFormat`. The gallery was empty for every .doc file.+#[test]+fn built_in_styles_fill_the_styles_gallery() {+    let spec = RichSpec {+        text: "a\r",+        styles: vec![+            (1, 0x0FFF, "Normal", Vec::new(), Vec::new()),+            (1, 0, "heading 1", Vec::new(), Vec::new()),+            (1, 0, "Report Body", Vec::new(), Vec::new()),+        ],+        fonts: vec!["Times New Roman"],+        chpx_runs: vec![(0, Vec::new())],+        papx_entries: vec![(0, 0, Vec::new())],+    };+    let extra = RichExtra { style_sti: vec![0, 1, 0x0FFE], ..Default::default() };+    let doc = read(&rich_doc_with(&spec, &extra)).expect("opens");+    let st = |id: &str| doc.styles.get(id).cloned().expect(id);+    assert!(st("Normal").builtin && st("Heading1").builtin && !st("Report Body").builtin);+    assert!(st("Normal").quick && st("Heading1").quick, "Word's usual gallery");+    assert!(!st("Report Body").quick);+}++/// Built-in styles are known by their `sti`, which is the same in every language: German+/// Word's "Überschrift 1" and "Verzeichnis 1" are heading 1 and toc 1. By name alone they got+/// ids no command looks for.+#[test]+fn localized_built_in_styles_take_words_ids() {+    let spec = RichSpec {+        text: "a\rb\r",+        styles: vec![+            (1, 0x0FFF, "Standard", Vec::new(), Vec::new()),+            (1, 0, "\u{DC}berschrift 1", Vec::new(), Vec::new()),+            (1, 0, "Verzeichnis 1", Vec::new(), Vec::new()),+            (1, 0, "Eigene", Vec::new(), Vec::new()),+        ],+        fonts: vec!["Times New Roman"],+        chpx_runs: vec![(0, Vec::new())],+        papx_entries: vec![(0, 1, Vec::new()), (2, 2, Vec::new())],+    };+    let extra = RichExtra { style_sti: vec![0, 1, 19, 0x0FFE], ..Default::default() };+    let doc = read(&rich_doc_with(&spec, &extra)).expect("opens");+    let own: Vec<(&str, &str, Option<&str>)> =+        doc.styles.styles.iter().take(4).map(|s| (s.id.as_str(), s.name.as_str(), s.based_on.as_deref())).collect();+    assert_eq!(+        own,+        [+            ("Normal", "Normal", None),+            ("Heading1", "Heading 1", Some("Normal")),+            ("TOC1", "TOC 1", Some("Normal")),+            ("Eigene", "Eigene", Some("Normal"))+        ]+    );+    assert_eq!(doc.styles.get("Heading1").and_then(|s| s.para.outline_level), Some(0));+    let styles: Vec<Option<&str>> = doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.as_deref()).collect();+    assert_eq!(styles, [Some("Heading1"), Some("TOC1")]);+}+ /// #534: Chinese theses set a first-line indent of two characters (`sprmPDxaLeft1`, whose operand /// is two bytes, plus `sprmPDxcLeft1` in hundredths of a character), and exact line spacing as a /// negative LSPD `dyaLine`. Both were lost: the indent read as four bytes, and exact 20pt as@@ -1311,6 +1443,44 @@ fn footnote_reference_links_story_part() {     assert_eq!(part.kind, wordcraft_doc::PartKind::Footnote); } +/// A note's text starts with its own reference character, which Word draws as the note's+/// number. It was dropped (only body references were matched), so the note text at the foot of+/// the page had no number; now it is the note's own number, as `w:footnoteRef` is in DOCX.+#[test]+fn a_note_text_starts_with_its_own_number() {+    use wordcraft_doc::para::{InlineObject, NoteKind};+    let text = "A\u{2} b\u{2}\r";+    let ccp = text.chars().count() as u32;+    let spec = StructSpec {+        text,+        note_stories: vec!["\u{2} Note body\r"],+        endnote_stories: vec!["\u{2} End\r"],+        extra_pairs: vec![+            (2, plc(&[1, ccp], &1u16.to_le_bytes())),+            (3, plc(&[0, 12], &[])),+            (46, plc(&[4, ccp], &1u16.to_le_bytes())),+            (47, plc(&[0, 6], &[])),+        ],+        ..structured_spec(text, ccp)+    };+    let doc = read(&struct_doc(&spec)).expect("opens");+    let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");+    let refs: Vec<(NoteKind, u32)> = p0+        .objects+        .iter()+        .filter_map(|o| match o {+            InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)),+            _ => None,+        })+        .collect();+    assert_eq!(refs.iter().map(|r| r.0).collect::<Vec<_>>(), [NoteKind::Footnote, NoteKind::Endnote]);+    for (kind, id) in refs {+        let first = doc.parts.get(&id).and_then(|p| p.blocks.first()).and_then(|b| b.as_para()).expect("note text");+        assert!(first.text.starts_with('\u{FFFC}'), "{:?}", first.text);+        assert!(matches!(first.objects.first(), Some(InlineObject::NoteRef { kind: k, id: i, .. }) if *k == kind && *i == id), "{:?}", first.objects);+    }+}+ #[test] fn endnote_reference_links_story_part() {     let text = "Ref\u{2}\r";@@ -1354,6 +1524,79 @@ fn fields_and_hyperlinks() {     assert_eq!(result, "3"); } +/// A table of contents is a TOC field whose result holds one paragraph per entry, each a+/// HYPERLINK with a PAGEREF inside. The entries were swallowed into one field object, leaving+/// empty paragraphs and one line of `HYPERLINK \l … PAGEREF …` codes; now they stay paragraphs+/// between range-field markers, as the DOCX reader keeps them.+#[test]+fn toc_entries_stay_paragraphs_with_their_links_and_page_numbers() {+    use wordcraft_doc::para::InlineObject;+    let entry =+        |bm: &str, title: &str, page: &str| format!("\u{13}HYPERLINK \\l \"{bm}\"\u{14}{title}\t\u{13} PAGEREF {bm} \\h \u{14}{page}\u{15}\u{15}");+    let text = format!("\u{13} TOC \\o \"1-2\" \\h \\z \u{14}{}\r{}\u{15}\rBody\r", entry("_Toc1", "Intro", "4"), entry("_Toc2", "Method", "5"));+    let text: &'static str = Box::leak(text.into_boxed_str());+    let spec = structured_spec(text, text.chars().count() as u32);+    let doc = read(&struct_doc(&spec)).expect("opens");+    let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();+    let texts: Vec<&str> = paras.iter().map(|p| p.text.as_str()).collect();+    assert_eq!(texts, ["\u{FFFC}Intro\t4", "Method\t5\u{FFFC}", "Body"]);+    let first = paras.first().expect("first entry");+    assert!(matches!(first.objects.first(), Some(InlineObject::FieldStart { instr, .. }) if instr == r#"TOC \o "1-2" \h \z"#), "{:?}", first.objects);+    let second = paras.get(1).expect("second entry");+    assert!(matches!(second.objects.first(), Some(InlineObject::FieldEnd)), "{:?}", second.objects);+    // Each entry links to its heading's bookmark.+    for (p, target) in [(first, "#_Toc1"), (second, "#_Toc2")] {+        assert!(p.runs.iter().any(|r| r.props.link.as_deref() == Some(target)), "{target}: {:?}", p.runs);+    }+}++/// A TOC field with no result yet is still a whole range field.+#[test]+fn an_empty_toc_field_is_a_whole_range_field() {+    use wordcraft_doc::para::InlineObject;+    let text = "\u{13}TOC \\o\u{15}\r";+    let spec = structured_spec(text, text.chars().count() as u32);+    let doc = read(&struct_doc(&spec)).expect("opens");+    let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");+    assert!(matches!(p0.objects.as_slice(), [InlineObject::FieldStart { .. }, InlineObject::FieldEnd]), "{:?}", p0.objects);+}++/// A field inside another field's result shows its result there, not its code.+#[test]+fn a_field_in_a_result_adds_its_result_not_its_code() {+    let text = "\u{13}QUOTE x\u{14}a\u{13}PAGE\u{14}7\u{15}b\u{15}\r";+    let spec = structured_spec(text, text.chars().count() as u32);+    let doc = read(&struct_doc(&spec)).expect("opens");+    let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");+    let Some(wordcraft_doc::para::InlineObject::Field { instr, result, .. }) = p0.objects.first() else { panic!("no field: {:?}", p0.objects) };+    assert_eq!((instr.as_str(), result.as_str()), ("QUOTE x", "a7b"));+}++/// Hostile nesting: fields opened inside results thousands deep stay bounded and the TOC+/// markers still pair up.+#[test]+fn deeply_nested_fields_in_results_are_bounded() {+    use wordcraft_doc::para::InlineObject;+    let n = 5_000;+    let text = format!("\u{13}TOC\u{14}{}y{}\u{15}\r", "\u{13}X\u{14}".repeat(n), "\u{15}".repeat(n));+    let text: &'static str = Box::leak(text.into_boxed_str());+    let spec = structured_spec(text, text.chars().count() as u32);+    let doc = read(&struct_doc(&spec)).expect("opens");+    let objs: Vec<&InlineObject> = doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();+    let starts = objs.iter().filter(|o| matches!(o, InlineObject::FieldStart { .. })).count();+    let ends = objs.iter().filter(|o| matches!(o, InlineObject::FieldEnd)).count();+    assert_eq!((starts, ends), (1, 1));+    // Past the cap the fields are only counted: their codes ("X") never reach the result.+    let results: Vec<&str> = objs+        .iter()+        .filter_map(|o| match o {+            InlineObject::Field { result, .. } => Some(result.as_str()),+            _ => None,+        })+        .collect();+    assert_eq!(results, ["y"]);+}+ #[test] fn hyperlink_target_sublocation_and_quotes() {     assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));@@ -2481,7 +2724,14 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {     // One copy of the target for the one run, not one per character (~2.5 s here): counted by     // what the copies charge to the work budget, which a busy machine can't skew.     let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];-    let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };+    let f = super::FieldBuild {+        depth: 1,+        instr: format!("HYPERLINK \"http://{target}\""),+        sep: Some(result),+        range: false,+        over_hidden: 0,+        over_shown: 0,+    };     let work = super::Work::new(u64::MAX);     super::finish_field(&mut super::ParaBuild::default(), f, &work);     assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");@@ -2494,7 +2744,14 @@ fn hyperlink_target_copies_are_charged_to_the_work_budget() {     use wordcraft_doc::props::CharProps;     let bold = CharProps { bold: Some(true), ..Default::default() };     let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();-    let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };+    let f = super::FieldBuild {+        depth: 1,+        instr: format!("HYPERLINK \"{}\"", "u".repeat(100)),+        sep: Some(result),+        range: false,+        over_hidden: 0,+        over_shown: 0,+    };     let mut pb = super::ParaBuild::default();     let budget = super::Work::new(10_000);     super::finish_field(&mut pb, f, &budget);diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- 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-11 · **Last updated:** 2026-10-26 · **Change:** trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; 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-11 · **Last updated:** 2026-10-26 · **Change:** trivial (`.doc` table of contents: entries stay paragraphs instead of one line of field codes; note numbers, Word's heading ids, list styles and the Styles gallery; previously trivial: DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; 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;@@ -49,7 +49,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |-| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). |+| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists (also numbers and bullets that come from a list style, which a based-on style or a paragraph can end), notes (each note's text starts with its number), fields (a table of contents keeps its entries as paragraphs with their links and page numbers, and a field inside another field's result shows that result, not its code; Update Table keeps the document's TOC styles), headings (Word's `Heading1` ids, so the Heading commands find them), Word's built-in styles marked built in and Word's usual Home › Styles gallery, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | 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. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |@@ -264,6 +264,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | `.doc` table of contents: entries stay paragraphs between range-field markers instead of one line of `HYPERLINK`/`PAGEREF` codes; a field in another field's result adds its result. The TOC level and heading styles take Word's ids (`TOC1`, `Heading1`), so Update Table keeps the document's TOC formatting and Home › Styles › Heading 1 (Ctrl+Alt+1) works; a note's text starts with its number; list styles number their paragraphs; the Styles gallery shows Word's usual set. | | 2026-10-17 | trivial | Encryption rows: Standard (Word 2007, AES-ECB) encrypted packages open with a password, also when named `.doc`/`.dot`; AES headers in Word 97-2003 files are outside the public spec and refused (gap 9). The duplicate combined `.docx`/`.doc` row is removed; the `.doc` row reads 🟡 ~60% (it opens XOR/RC4/RC4 CryptoAPI); hours 2–4 → 1–2 and 4–6 → 1–2 (left: checks against Word-written files); total re-summed from the rows, 175–287 h | | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) |