mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
SCRUM-28: preserve imported TOC result ranges (#617)
This commit is contained in:
12 files changed
+459
-51
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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."))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user