SCRUM-28: preserve imported TOC result ranges (#617)

This commit is contained in:
Rees authored and GitHub committed 2026-10-12 03:29:32 -04:00
1 parent ad8c264420
commit 4611afba16
12 files changed
+459 -51

No files matched your search

+2
View File
@@ -179,6 +179,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
| 2026-10-12 | Custom XML backing stores preserved as opaque data and related properties through edited DOCX saves; content-control markup is handled by the existing reader/writer and #532 bibliography I/O remains separate; binding evaluation and readiness unchanged |
| 2026-10-12 | Imported TOCs keep actual result boundaries, nested page fields, formatting and locks through DOCX saves without relying on TOC styles; existing engine-managed styled TOCs remain refreshable |
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
| 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) |
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
@@ -232,6 +233,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
| 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged |
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
+2 -2
View File
@@ -447,8 +447,8 @@ pub enum InlineObject {
code: bool,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Citation managers' `ADDIN`
/// fields (Zotero, Mendeley, EndNote) are kept this way; `instr` is the field code. See
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
/// [`crate::fields`].
FieldStart {
instr: String,
+42 -6
View File
@@ -385,8 +385,8 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
});
}
/// Word starts a TOC field in the first entry; the engine keeps it at the end of the TOC heading
/// (the paragraph holding the field marks where the TOC is). Move it there when one precedes it.
/// Normalize only a complete TOC range that the engine's heading/entry representation can
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
@@ -394,12 +394,23 @@ fn toc_field_to_heading(body: &mut Blocks) {
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
continue;
}
let Some((field, props)) = body[i].as_para().and_then(|p| match p.object_at(0) {
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => Some((f.clone(), p.props_of_char(0).clone())),
_ => None,
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
let (field, end) = match p.object_at(0) {
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => {
(InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?))
}
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
_ => return None,
};
Some((field, p.props_of_char(0).clone(), end))
}) else {
continue;
};
// Delete the end first: the two markers may occupy the same paragraph.
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
let off = p.len().saturating_sub(OBJ.len_utf8());
let _ = p.delete(off, p.len());
}
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
let _ = p.delete(0, OBJ.len_utf8());
}
@@ -411,7 +422,32 @@ fn toc_field_to_heading(body: &mut Blocks) {
}
fn is_toc(instr: &str) -> bool {
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
/// The actual matching end must also be the end the styled writer would choose: at the end
/// of the last contiguous TOC entry, with no section break or trailing content outside it.
fn toc_range_end(body: &Blocks, start: usize) -> Option<usize> {
let entry =
|p: &wordcraft_doc::Paragraph| p.section.is_none() && p.props.style.as_deref().is_some_and(|s| s.starts_with("TOC") && s != "TOCHeading");
let mut depth = 0_usize;
for (i, b) in body.iter().enumerate().skip(start) {
let p = b.as_para().filter(|p| entry(p))?;
for (off, o) in p.object_offsets().into_iter().zip(&p.objects) {
match o {
InlineObject::FieldStart { .. } => depth = depth.checked_add(1)?,
InlineObject::FieldEnd => {
depth = depth.checked_sub(1)?;
if depth == 0 {
let following_entry = body.get(i + 1).and_then(|b| b.as_para()).is_some_and(entry);
return (off.checked_add(OBJ.len_utf8()) == Some(p.len()) && !following_entry).then_some(i);
}
}
_ => {}
}
}
}
None
}
/// Built-in style names Word stores in lower case, and how the UI shows them.
+2 -2
View File
@@ -83,9 +83,9 @@ fn enclosure(f: &FieldState) -> Option<wordcraft_doc::eq::Enclosure> {
}
/// Fields kept as ranges: those whose result another program writes, with formatting and often
/// several paragraphs (citation managers' `ADDIN` fields: Zotero, Mendeley, EndNote).
/// several paragraphs (TOCs and citation managers' `ADDIN` fields).
fn is_range_instr(instr: &str) -> bool {
instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
}
/// The last paragraph of a block list: its last block, or inside a final table the last
+1 -1
View File
@@ -1488,7 +1488,7 @@ const TOC_SDT_OPEN: &str =
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>"#;
fn is_toc(instr: &str) -> bool {
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
+25 -2
View File
@@ -97,6 +97,29 @@ fn paras(d: &Document) -> Vec<&Paragraph> {
d.body.iter().filter_map(|b| b.as_para()).collect()
}
#[test]
fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
let d = read_body(
r#"
<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r>
<w:r><w:instrText> TOC \o "1-1" \h </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:b/></w:rPr><w:t>Intro</w:t></w:r>
<w:r><w:tab/></w:r><w:fldSimple w:instr=" PAGEREF _Toc1 \h "><w:r><w:t>1</w:t></w:r></w:fldSimple></w:hyperlink>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p>"#,
);
let p = paras(&d);
assert_eq!(p[0].plain_text(), "Contents");
assert_eq!(p[0].objects, vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false }]);
assert_eq!(p[1].plain_text(), "Intro\t1");
assert_eq!(p[1].props_of_char(0).bold, Some(true));
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }]);
}
#[test]
fn toc_complex_field_spanning_paragraphs() {
let body = r#"
@@ -112,13 +135,13 @@ fn toc_complex_field_spanning_paragraphs() {
let p = paras(&d);
assert_eq!(p.len(), 5);
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
assert_eq!(p[0].objects[0], InlineObject::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false, code: false });
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false });
assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false });
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
assert_eq!(p[1].text, "Body\t2");
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc2"));
assert_eq!(p[2].text, "");
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
assert_eq!(p[4].plain_text(), "Page 7");
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false });
+38 -33
View File
@@ -685,41 +685,46 @@ fn word_note_mark_round_trips_once() {
/// or Word shows them as plain text with no Update Table.
#[test]
fn toc_field_wraps_its_entries() {
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
&CharProps::default(),
)
.unwrap();
let entries = [("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")];
let mut blocks = vec![head];
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
blocks.push(Paragraph::with_text("Body", CharProps::default()));
let d = doc_with(blocks);
for entries in [&[("Intro\t1", "TOC1")][..], &[("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")][..]] {
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
let end = head.len();
head.insert_object(
end,
InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false },
&CharProps::default(),
)
.unwrap();
let mut blocks = vec![head];
blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st)));
blocks.push(Paragraph::with_text("Body", CharProps::default()));
let d = doc_with(blocks);
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let sdt = xml.find("<w:sdt>").expect("TOC content control");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
let last_entry = xml.find("Detail").unwrap();
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
assert!(begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(), "field must span the entries: {xml}");
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let sdt = xml.find("<w:sdt>").expect("TOC content control");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
let last_entry = xml.find(entries.last().unwrap().0.split('\t').next().unwrap()).unwrap();
assert!(sdt < xml.find("Contents").unwrap() && xml.find("</w:sdt>").unwrap() < xml.find(">Body<").unwrap(), "{xml}");
assert!(
begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("</w:sdt>").unwrap(),
"field must span the entries: {xml}"
);
// Word starts the field in the first entry; started in the heading, LibreOffice splits the heading.
let heading_end = xml[..xml.find("Intro").unwrap()].rfind("</w:p>").unwrap();
assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}");
// WordCraft reads its own TOC back unchanged.
let r = wordcraft_docx::read(&bytes).expect("read");
let ps = paras(&r);
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), ["Contents", "Intro\t1", "Detail\t2", "Body"]);
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
// WordCraft reads its own TOC back unchanged.
let r = wordcraft_docx::read(&bytes).expect("read");
let ps = paras(&r);
let expected: Vec<_> = std::iter::once("Contents").chain(entries.iter().map(|(t, _)| *t)).chain(std::iter::once("Body")).collect();
assert_eq!(ps.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), expected);
assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects);
assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects);
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
}
}
/// Table Layout › Text Direction (#226): each direction is written as Word's `w:textDirection`
+177
View File
@@ -0,0 +1,177 @@
//! Original field-boundary fixtures from the public OOXML complex-field contract.
use std::io::Write;
use wordcraft_doc::{InlineObject, Path, StoryRef};
fn package(body: &str) -> Vec<u8> {
let xml = format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, value) in [
("word/document.xml", xml.as_str()),
(
"_rels/.rels",
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn start(code: &str) -> String {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r><w:r><w:instrText>{code}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#
)
}
const END: &str = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
const PAGE: &str = r#"<w:fldSimple w:instr="PAGEREF Original"><w:r><w:t>7</w:t></w:r></w:fldSimple>"#;
const ENTRY: &str = r#"<w:hyperlink w:anchor="Original"><w:r><w:rPr><w:b/><w:i/></w:rPr><w:t>Scope</w:t></w:r><w:r><w:tab/></w:r>"#;
#[test]
fn unstyled_toc_keeps_range_nested_field_and_formatting_through_two_edited_saves() {
for split in [false, true] {
let content = if split {
format!("{ENTRY}{PAGE}</w:hyperlink></w:p><w:p><w:r><w:t>Delivery</w:t></w:r>")
} else {
format!("{ENTRY}{PAGE}</w:hyperlink>")
};
let body = format!(
r#"<w:p><w:r><w:t>Before.</w:t></w:r></w:p><w:p>{}{content}{END}</w:p><w:p><w:r><w:t>After.</w:t></w:r></w:p>"#,
start(r#"TOC \o "1-2" \h"#)
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
for cycle in 0..=2 {
let ranges = doc.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].instr, r#"TOC \o "1-2" \h"#);
assert!(ranges[0].locked);
assert_eq!(ranges[0].start.path, Path::top(1));
assert_eq!(ranges[0].start.off, 0);
assert_eq!(ranges[0].end.path, Path::top(if split { 2 } else { 1 }));
let entry = doc.body[1].as_para().unwrap();
assert_eq!(entry.plain_text(), "Scope\t7");
let props = entry.props_of_char(wordcraft_doc::para::OBJ.len_utf8());
assert_eq!(props.bold, Some(true));
assert_eq!(props.italic, Some(true));
assert_eq!(props.link.as_deref(), Some("#Original"));
assert!(
entry.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "PAGEREF Original" && result == "7"))
);
assert_eq!(doc.body.last().unwrap().as_para().unwrap().plain_text(), "After.");
if cycle == 2 {
break;
}
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Independent edit. ", &Default::default()).unwrap();
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
}
#[test]
fn styled_heading_does_not_expand_partial_custom_or_short_ranges() {
let heading = r#"<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>"#;
let extra = r#"<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:t>Outside entry.</w:t></w:r></w:p>"#;
for (style, prefix, suffix, following) in
[("TOC1", "Prefix.", "", ""), ("TOC1", "", "Suffix.", ""), ("TOC1", "", "", extra), ("CompanyContents", "", "", "")]
{
let body = format!(
r#"{heading}<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{prefix}</w:t></w:r>{}<w:r><w:t>Scope</w:t></w:r>{END}<w:r><w:t>{suffix}</w:t></w:r></w:p>{following}<w:p><w:r><w:t>Body.</w:t></w:r></w:p>"#,
start("TOC")
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
let text = doc.plain_text(StoryRef::Body);
for cycle in 0..=2 {
let ranges = doc.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1, "{style} {prefix} {suffix} {following}");
assert_eq!(ranges[0].start.path, Path::top(1));
assert_eq!(ranges[0].end.path, Path::top(1));
assert_eq!(ranges[0].start.off, prefix.len());
assert_eq!(ranges[0].end.off, prefix.len() + wordcraft_doc::para::OBJ.len_utf8() + "Scope".len());
assert_eq!(doc.plain_text(StoryRef::Body), text);
if cycle == 2 {
break;
}
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
}
#[test]
fn simple_lowercase_toc_and_multiple_tocs_keep_their_own_ranges() {
let body = format!(
r#"<w:p><w:fldSimple w:instr="toc \h" w:fldLock="true">{ENTRY}{PAGE}</w:hyperlink></w:fldSimple><w:r><w:t>Between.</w:t></w:r>{}<w:r><w:t>Other result.</w:t></w:r>{END}<w:r><w:t>After.</w:t></w:r></w:p>"#,
start(r#"TOC \o "1-1""#)
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
for cycle in 0..=2 {
let r = doc.field_ranges(StoryRef::Body);
assert_eq!(r.len(), 2);
assert_eq!(r[0].instr, r#"toc \h"#);
assert_eq!(r[1].instr, r#"TOC \o "1-1""#);
assert!(r.iter().all(|r| r.locked));
assert!(r[0].end.off < r[1].start.off);
assert_eq!(doc.plain_text(StoryRef::Body), "Scope\t7Between.Other result.After.");
if cycle == 2 {
break;
}
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
#[test]
fn toc_in_table_cell_does_not_capture_the_next_cell() {
let body = format!(
r#"<w:tbl><w:tr><w:tc><w:p>{}{ENTRY}{PAGE}</w:hyperlink>{END}</w:p></w:tc><w:tc><w:p><w:r><w:t>Other cell.</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
start("TOC")
);
let mut doc = wordcraft_docx::read(&package(&body)).unwrap();
for cycle in 0..=2 {
let ranges = doc.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start.path, wordcraft_doc::Path(vec![0, 0, 0, 0]));
assert_eq!(ranges[0].end.path, ranges[0].start.path);
assert_eq!(doc.body[0].as_table().unwrap().rows[0].cells[1].blocks[0].as_para().unwrap().plain_text(), "Other cell.");
if cycle == 2 {
break;
}
doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
}
}
#[test]
fn missing_end_and_excessive_nesting_stay_bounded_and_save_balanced() {
for (starts, ends) in [(1, 0), (40, 40)] {
let body = format!("<w:p>{}<w:r><w:t>Visible result.</w:t></w:r>{}</w:p>", start("TOC").repeat(starts), END.repeat(ends));
let doc = wordcraft_docx::read(&package(&body)).unwrap();
let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(!saved.has_unbalanced_field_ranges());
assert_eq!(saved.plain_text(StoryRef::Body), "Visible result.");
assert!(saved.field_ranges(StoryRef::Body).len() <= 32);
}
}
#[test]
fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() {
let body = format!(
r#"<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="TOC1"/><w:sectPr/></w:pPr>{}<w:r><w:t>Scope</w:t></w:r>{END}</w:p><w:p>{}<w:r><w:t>Other.</w:t></w:r>{END}</w:p>"#,
start("TOC"),
start("TOC_OTHER")
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
for d in [&doc, &wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap()] {
let ranges = d.field_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start.path, Path::top(1));
assert!(d.body[1].as_para().unwrap().section.is_some());
assert!(
d.body[2]
.as_para()
.unwrap()
.objects
.iter()
.any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "TOC_OTHER" && result == "Other."))
);
}
}
+38 -1
View File
@@ -1,7 +1,7 @@
//! References tab: table of contents, footnotes/endnotes, captions; field updates.
use serde_json::{Value, json};
use wordcraft_doc::para::{InlineObject, NoteKind};
use wordcraft_doc::para::{InlineObject, NoteKind, OBJ};
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block};
@@ -309,6 +309,41 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
Some((start, end))
}
/// A TOC imported as a range field (`FieldStart`…`FieldEnd`: one without a TOC heading, or with
/// content around it the engine's TOC can't reproduce) becomes the engine's TOC before an update,
/// so Update Table refreshes it rather than adding a second one: an empty `TOC` field where the
/// range started, its old result gone, and the regenerated entries after that paragraph (text
/// that followed the range moves below them). Only an unlocked, top-level TOC that isn't inside
/// another range field, and only when the body has no engine TOC already.
fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
if toc_range(s).is_some() {
return Ok(());
}
let is_toc = |instr: &str| {
let field = FieldCode::parse(instr);
field.name == "TOC" && !field.has('c')
};
let Some(r) = s
.doc
.field_ranges(StoryRef::Body)
.into_iter()
.find(|r| r.depth == 0 && !r.locked && is_toc(&r.instr) && r.start.path.depth() == 0 && r.end.path.depth() == 0)
else {
return Ok(());
};
let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) };
let end = Pos { off: after_end, ..r.end.clone() };
let props = s.doc.para_at(&r.start).map(|p| p.props_of_char(r.start.off).clone()).unwrap_or_default();
let at = s.doc.delete_range(&r.start, &end)?;
let field = InlineObject::Field { instr: r.instr.trim().to_string(), result: String::new(), locked: false, code: false };
let after = s.doc.insert_object(&at, field, &props)?;
if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) {
s.doc.split_paragraph(&after)?;
}
s.clamp_selection();
Ok(())
}
/// The TOC field instruction in `p` (not a table of figures).
fn toc_field(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o {
@@ -321,6 +356,7 @@ fn toc_field(p: &Paragraph) -> Option<String> {
}
fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
range_toc_to_field(s)?;
let replace = v.as_object().is_some_and(|m| !m.is_empty());
// Replacing a table puts the new one where the old one was, in place of an empty paragraph.
let mut placeholder = false;
@@ -408,6 +444,7 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
/// Rebuild the entries the TOC field's switches ask for. The tab leader is `leader`, or the one
/// the entries had (Word keeps it in the entries' tab stops, not in the field).
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdError> {
range_toc_to_field(s)?;
let Some((start, end)) = toc_range(s) else { return Ok(()) };
let Some(instr) = s.doc.body.get(start).and_then(|b| b.as_para()).and_then(toc_field) else { return Ok(()) };
let f = FieldCode::parse(&instr);
+105
View File
@@ -1422,6 +1422,111 @@ fn toc_and_fields() {
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
}
#[test]
fn toc_refresh_after_save_and_reopen() {
for extension in ["docx", "json"] {
for (headings, levels) in [(&["Top"][..], 1), (&["Top", "Next"][..], 1), (&["Top"][..], 4)] {
let mut s = s();
run(&mut s, "document.setText", json!({"text": headings.join("\n")}));
for i in 0..headings.len() {
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
run(&mut s, "styles.apply", json!({"style": "Heading 1"}));
}
// A level-two heading must stay out of a depth-one TOC after reopening.
if levels == 1 {
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Detail"}));
run(&mut s, "styles.apply", json!({"style": "Heading 2"}));
}
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "references.toc", json!({"levels": levels}));
run(&mut s, "references.updateToc", json!({}));
let expected = text(&s);
let name = format!("toc.{extension}");
let bytes = crate::io::save_bytes(&name, &s.doc).unwrap();
s = Session::new(crate::io::open_bytes(&name, &bytes).unwrap());
for command in ["references.updateToc", "references.updateToc", "references.updateFields"] {
run(&mut s, command, json!({}));
assert_eq!(text(&s), expected, "{extension}, levels={levels}, {command}");
}
let heading = s
.doc
.body
.iter()
.position(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("Heading1") && p.plain_text() == "Top"))
.unwrap();
// Hyperlinked TOCs bookmark their headings; select the text after those markers.
let off = s.doc.body[heading].as_para().unwrap().text.find("Top").unwrap();
run(&mut s, "select.range", json!({"anchor": Pos::body(heading, off), "focus": Pos::body(heading, off + 3)}));
run(&mut s, "text.insert", json!({"text": "Revised"}));
let before_refresh = text(&s);
run(&mut s, "references.updateToc", json!({}));
let revised = text(&s);
assert_eq!(revised.matches("Revised\t").count(), 1, "{revised}");
assert!(!revised.contains("Top"), "{revised}");
assert_eq!(revised.matches("Next\t").count(), headings.len() - 1, "{revised}");
assert_eq!(revised.matches("Detail\t").count(), 0, "{revised}");
assert_eq!(revised.lines().filter(|line| *line == "Detail").count(), usize::from(levels == 1), "{revised}");
assert!(s.undo());
assert_eq!(text(&s), before_refresh);
run(&mut s, "references.updateToc", json!({}));
assert_eq!(text(&s), revised);
let bytes = crate::io::save_bytes(&name, &s.doc).unwrap();
s = Session::new(crate::io::open_bytes(&name, &bytes).unwrap());
run(&mut s, "references.updateFields", json!({}));
assert_eq!(text(&s), revised, "saved edited TOC, {extension}");
}
}
}
/// A TOC imported as a range field (no TOC heading, so it isn't turned back into the engine's
/// TOC on open) is still found by Update Table, Update Field and Table of Contents: it is
/// refreshed in place, not duplicated, and the refresh is one undo step.
#[test]
fn imported_range_toc_is_refreshed_in_place() {
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::CharProps;
for locked in [false, true] {
let mut d = wordcraft_doc::Document::from_text("Before.\nOld entry\t9\nAlpha\nBeta");
{
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
let n = p.len();
p.insert_object(n, InlineObject::FieldEnd, &CharProps::default()).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-2" \h"#.into(), locked }, &CharProps::default()).unwrap();
}
let mut s = Session::new(d);
for (i, style) in [(2, "Heading 1"), (3, "Heading 2")] {
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
run(&mut s, "styles.apply", json!({"style": style}));
}
// Through DOCX, as an imported file is.
let bytes = wordcraft_docx::write(&s.doc).unwrap();
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1, "read back as a range field");
let before = text(&s);
run(&mut s, "references.updateToc", json!({}));
let refreshed = text(&s);
if locked {
// Like Word, a locked field keeps its result.
assert_eq!(refreshed, before);
continue;
}
assert!(!refreshed.contains("Old entry"), "{refreshed}");
assert!(refreshed.starts_with("Before.\n"), "{refreshed}");
assert_eq!(refreshed.matches("Alpha\t").count(), 1, "{refreshed}");
assert_eq!(refreshed.matches("Beta\t").count(), 1, "{refreshed}");
assert!(s.doc.field_ranges(StoryRef::Body).is_empty(), "now the engine's TOC");
for command in ["references.updateToc", "references.updateFields", "references.toc"] {
run(&mut s, command, json!({}));
assert_eq!(text(&s), refreshed, "{command} refreshes the same TOC");
}
while s.undo() {}
assert_eq!(text(&s), before);
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1, "undo brings the imported range back");
}
}
#[test]
fn toc_page_numbers_follow_headings() {
let mut s = s();
+24 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (imported TOC result boundaries preserved; native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -54,7 +54,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 |
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them |
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) |
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) |
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) |
| Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC |
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
@@ -131,6 +131,27 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi
3. Move tracking (`w:moveFrom`/`w:moveTo`) read and written (formatting revisions: #41; content controls: #381).
4. Encrypted `.docx` opens with a password.
## Imported TOC result preservation
Original synthetic TOCs without TOC paragraph styles lose their outer result range on main
and #538. Main also flattens a nested page field in a single-paragraph result. Imported TOCs
now use the existing range-field markers to retain the actual result boundaries, nested
page-reference fields, formatting, links and locks through edited saves.
The public [complex-field contract](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.fieldchar?view=openxml-3.0.1)
defines begin/separate/end delimiters and permits nested fields. Paragraph styles do not define
those boundaries. A complete range is converted to the engine's styled TOC representation only
when that writer can reproduce its exact extent; partial or custom-styled ranges stay explicit.
Focused original regressions cover single/multiple paragraphs, partial ranges, adjacent styled
text, multiple/simple fields, table cells, section breaks, missing ends and bounded nesting.
The existing styled TOC write/read path remains supported. Update Table and Update Fields refresh
unlocked top-level body TOC ranges in place through the engine's generator; one undo restores
the imported range. Locked, nested and table-cell ranges remain preserved without regeneration.
Preservation is separate from field evaluation. Native Word reopening and
refresh, full schema validation and company documents remain untested. Synthetic checks do not
raise readiness estimates. No Word-derived files are fixtures.
## Revision history
| Date | Change | Summary |
@@ -159,6 +180,7 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi
| 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) |
| 2026-10-12 | minor | Preserve opaque custom XML stores and related properties; bindings and control support remain separate |
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
| 2026-10-11 | minor | Preserve imported TOC ranges independently of paragraph styles, with explicit evaluation limits |
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (imported TOC boundaries preserved with refresh coverage; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
@@ -13,7 +13,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| # | Gap | B | Evidence | Impact | Hours | Doc |
|---|---|---|---|---|---|---|
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph-default preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
@@ -91,6 +91,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 |
| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap |
| 2026-10-12 | minor | Preserve opaque custom XML data/properties through saves; gap #5 and readiness remain unchanged |
| 2026-10-11 | minor | Imported TOC boundaries preserved in synthetic checks; real-world corpus gap and readiness unchanged |
| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain |
| 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain |
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |