From 69b457f1558eaa87aa2eb63be3f451071fc74efe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:09:10 +0000 Subject: [PATCH] M-ODT gap 19: bookmarks, notes, objects and page-anchored frames written as read - Paragraphs and tables holding bookmarks are kept; the writer takes the names kept markup begins and ends, so either end may be as read. - Footnotes and endnotes: each note's text fingerprinted when read; kept while it and the note numbering are as read. - Formulas, charts and other objects in kept markup, with their files and preview pictures (only while that markup is written). - Page-anchored frames LibreOffice puts in the text before a paragraph are kept before its markup. - Styles of different families sharing a name are kept apart. - The text keeps text:use-soft-page-breaks while kept markup holds them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 1 + crates/doc/src/kept.rs | 28 ++++- crates/formats/src/odt/body.rs | 80 ++++++++++--- crates/formats/src/odt/kept.rs | 56 +++++++-- crates/formats/src/odt/package.rs | 17 ++- crates/formats/src/odt/tests_more/lossless.rs | 106 ++++++++++++++++-- crates/formats/src/odt/tests_more/notes.rs | 12 +- crates/formats/src/odt/tests_more/objects.rs | 7 +- .../formats/src/odt/tests_more/text_boxes.rs | 4 +- crates/formats/src/odt/writer.rs | 5 +- docs/file-format-parity.md | 3 +- docs/gaps.md | 2 +- 12 files changed, 277 insertions(+), 44 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 21ef328dee..95530a5386 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-03: ODT paragraphs holding bookmarks, notes, formulas and objects, and the page-anchored frames before a paragraph, are written as read while unchanged (gap 19); styles of different families sharing a name are kept apart. Over the corpus 123 of 133 files round-trip pixel-identical (118), 132 with the same text, and the attributes and values a save loses fall from 539 to under 315. - 2026-11-03: ODT paragraphs showing text boxes and shapes holding text are written as read while that text is unchanged (gap 19). Over the corpus 111 of 133 files now round-trip pixel-identical (103), the round-trip difference falls from 44k to 39k pixels, and the element kinds a save loses fall from 16 to 8. - 2026-11-03: ODT paragraphs holding comments or tracked changes are written as read while those are unchanged (gap 19): a comment edited, resolved or answered, or a change accepted, sends its paragraph to the model; the file's change regions are kept beside WordCraft's own. Over the corpus the words a save loses fall from 29 to 4. - 2026-11-03: ODT paragraphs and tables holding pictures and shapes are written as read while unchanged (gap 19), with their pictures and frame styles; over the corpus the attributes and values a save loses halve (1,551 → 789), the pages unchanged. diff --git a/crates/doc/src/kept.rs b/crates/doc/src/kept.rs index e2374c2efe..0bc913299c 100644 --- a/crates/doc/src/kept.rs +++ b/crates/doc/src/kept.rs @@ -48,8 +48,8 @@ pub struct KeptXml { pub indexes: HashMap)>, /// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup. pub pending_indexes: HashMap)>, - /// Each text box's or shape's story's [`story_fingerprint`] when read: a paragraph showing - /// one is written as read only while it is as read. + /// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph + /// showing one is written as read only while it is as read. pub story_fps: HashMap, /// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a /// paragraph holding comments is written as read only while they are as read. @@ -149,9 +149,21 @@ impl KeptXml { } count == in_markup } - /// Whether the stories paragraph `p` shows (its text boxes' and shapes' text) are as read. + /// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are + /// as read. pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool { - p.objects.iter().flat_map(|o| o.text_boxes()).all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id))) + // A note's citation is its number in the document's numbering, which is the page setup's. + let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. })); + if notes && !self.pages_as_read(doc) { + return false; + } + p.objects + .iter() + .flat_map(|o| match o { + crate::para::InlineObject::NoteRef { id, .. } => vec![*id], + o => o.text_boxes(), + }) + .all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id))) } /// The wrappers kept around table `t`, while it still is what was read. pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec>, u32)> { @@ -442,8 +454,12 @@ impl crate::Document { } // The text boxes' and shapes' stories as read, for paragraphs showing them. if self.kept.get().is_some_and(|k| !k.pending.is_empty()) { - let fps: HashMap = - self.parts.iter().filter(|(_, p)| p.kind == crate::PartKind::TextBox).map(|(id, _)| (*id, story_fingerprint(self, *id))).collect(); + let fps: HashMap = self + .parts + .iter() + .filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote)) + .map(|(id, _)| (*id, story_fingerprint(self, *id))) + .collect(); if let Some(k) = self.kept.get_mut() { k.story_fps = fps; } diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index aab07569f2..ea5f59e5f5 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -72,6 +72,13 @@ pub(super) struct Body<'a> { /// frames before the first one): the next paragraph's, so they don't make a paragraph of /// their own (which would start the document on another page style). loose: Vec, + /// The markup of each drawing in `loose`, as read, while each one's is (`loose_whole`): the + /// paragraph they join keeps it before its own (see `feed_at`). + loose_raw: Vec>, + loose_whole: bool, + /// The drawing outside a paragraph being read: where it starts, its depth, and how many + /// drawings were waiting before it. + loose_open: Option<(usize, usize, usize)>, fmt: Vec, para: Option, /// The text read last ended in a space collapsed from XML whitespace (not a `text:s`). @@ -211,6 +218,9 @@ impl<'a> Body<'a> { manifest: None, containers: vec![Vec::new()], loose: Vec::new(), + loose_raw: Vec::new(), + loose_whole: true, + loose_open: None, fmt: Vec::new(), para: None, xml_space_end: false, @@ -304,6 +314,39 @@ impl<'a> Body<'a> { self.item_start = None; } + /// [`Body::feed_at`] for a drawing outside a paragraph (a page-anchored frame in the text): + /// its markup kept, for the paragraph it joins. `None` when it took the event. + fn loose_drawing(&mut self, ev: Ev, at: (usize, usize)) -> Option { + match &ev { + Ev::Start(q, _) + if self.loose_open.is_none() + && q.starts_with("draw:") + && is_drawing(q) + && self.para.is_none() + && self.kept_open.is_none() + && !self.raw.is_empty() + && self.in_text + && self.skip == 0 + && self.at_body_level() => + { + self.loose_open = Some((at.0, self.depth + 1, self.loose.len())); + } + Ev::End(_) if self.loose_open.is_some_and(|(_, d, _)| d == self.depth) => { + self.feed(ev); + if let Some((from, _, before)) = self.loose_open.take() { + match self.raw.get(from..at.1).and_then(|b| std::str::from_utf8(b).ok()) { + Some(xml) if self.loose.len() == before + 1 => self.loose_raw.push(std::sync::Arc::from(xml)), + _ if self.loose.len() == before => {} + _ => self.loose_whole = false, + } + } + return None; + } + _ => {} + } + Some(ev) + } + /// [`Body::feed`], `at` the event's markup in [`Body::raw`]: a body paragraph that comes out /// whole and refers to nothing outside itself keeps its markup (`Para::kept`), so a save /// that doesn't change it writes it as it was (see `wordcraft_doc::kept`). @@ -330,6 +373,7 @@ impl<'a> Body<'a> { clean: true, done: self.container().len(), comments: KeptComments::default(), + prefix: String::new(), }); } None => {} @@ -424,6 +468,7 @@ impl<'a> Body<'a> { clean: true, done: self.container().len(), comments: KeptComments::default(), + prefix: String::new(), }); } None => {} @@ -443,6 +488,7 @@ impl<'a> Body<'a> { } _ => {} } + let Some(ev) = self.loose_drawing(ev, at) else { return }; match &ev { Ev::Start(q, a) => match &mut self.kept_open { Some(k) => { @@ -461,10 +507,12 @@ impl<'a> Body<'a> { && self.field.is_none() && !self.in_desc => { - // Drawings read before it that join it aren't in its markup. - let clean = self.loose.is_empty(); + // Drawings read before it that join it: their markup before its own, when + // each one's was kept. + let clean = self.loose.is_empty() || (self.loose_whole && self.loose_raw.len() == self.loose.len()); + let prefix = if self.loose.is_empty() { String::new() } else { self.loose_raw.concat() }; self.kept_open = - Some(KeptOpen { from: at.0, depth: self.depth + 1, clean, done: self.paras_done, comments: KeptComments::default() }); + Some(KeptOpen { from: at.0, depth: self.depth + 1, clean, done: self.paras_done, comments: KeptComments::default(), prefix }); } None => {} }, @@ -485,7 +533,7 @@ impl<'a> Body<'a> { && let Some(p) = &mut self.para && let Some(xml) = self.raw.get(k.from..at.1).and_then(|b| std::str::from_utf8(b).ok()) { - p.kept = Some(std::sync::Arc::from(xml)); + p.kept = Some(std::sync::Arc::from(format!("{}{xml}", k.prefix))); } } _ => {} @@ -804,6 +852,8 @@ impl Body<'_> { let mut loose = std::mem::take(&mut self.loose); loose.append(&mut p.inlines); p.inlines = loose; + self.loose_raw.clear(); + self.loose_whole = true; } self.para = Some(p); } @@ -1701,6 +1751,8 @@ struct KeptOpen { done: usize, /// The comments in a paragraph being kept (see [`KeptComments`]). comments: KeptComments, + /// The markup of drawings read before it that join it, kept before its own. + prefix: String, } /// The comments (§14.1 `office:annotation`) and tracked changes (§5.5.7) in a paragraph being @@ -1756,8 +1808,9 @@ impl KeptComments { "text:change" => get(a, "text:change-id").is_some(), // A text box continuing in another box, or continued from one: their names tie them. "draw:text-box" if get(a, "draw:chain-next-name").is_some() => false, - // A drawing: the paragraphs of its text (a text box's, a shape's) are its story's. - q if self.open.is_none() && q.starts_with("draw:") && is_drawing(q) => { + // A drawing or a note: the paragraphs of its text (a text box's, a shape's, the + // note's) are its story's. + q if self.open.is_none() && (q == "text:note" || q.starts_with("draw:") && is_drawing(q)) => { self.open = Some((depth, done)); true } @@ -1785,17 +1838,18 @@ impl KeptComments { } /// Whether markup holding element `q` can be kept as read. Pictures, shapes and text boxes can -/// (their text is a story of its own, kept while it is as read: see `KeptComments`); embedded -/// objects (charts and formulas, written on their own), form controls and plugins can't, nor -/// marks that pair up with others elsewhere (comments and tracked changes are paired apart). +/// (their text is a story of its own, kept while it is as read: see `KeptComments`), and +/// embedded objects (a formula's text and a chart's data are the paragraph's, an object's files +/// are kept with it); form controls (named afresh in the forms) and plugins can't, nor +/// marks that pair up with others elsewhere by ids the writer gives (comments and tracked +/// changes are paired apart). Bookmarks can: their names are kept, so either end may be +/// written as read and the other from the model. fn keepable(q: &str) -> bool { - !(matches!(q, "draw:object" | "draw:object-ole" | "draw:control" | "draw:plugin" | "draw:applet" | "draw:floating-frame") + !(matches!(q, "draw:control" | "draw:plugin" | "draw:applet" | "draw:floating-frame") || q.starts_with("office:annotation") || q.starts_with("text:change") || q.starts_with("form:") - || q.ends_with("-start") - || q.ends_with("-end") - || q == "text:bookmark") + || (q.ends_with("-start") || q.ends_with("-end")) && !q.starts_with("text:bookmark-")) } /// Whether `q` is a drawing that can hold text: a frame, a group or a shape. diff --git a/crates/formats/src/odt/kept.rs b/crates/formats/src/odt/kept.rs index bcbd951078..9b1871f2b7 100644 --- a/crates/formats/src/odt/kept.rs +++ b/crates/formats/src/odt/kept.rs @@ -50,6 +50,16 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc)> { let parent = path.get(depth.wrapping_sub(2)).map(String::as_str).unwrap_or(""); let key = match (depth, parent) { (2, _) if content && q == "office:scripts" => Some("top:office:scripts".to_string()), + // Whether the text holds soft page breaks (§19.857), which markup kept may. + (3, "office:body") + if content + && q == "office:text" + && get(&a, "text:use-soft-page-breaks").is_some_and(|v| v.trim() == "true") + && out.len() < MAX_DEFS => + { + out.push(("soft-page-breaks".to_string(), Arc::from("true"))); + None + } (3, "office:automatic-styles") => child_key(if content { "auto" } else { "sauto" }, &q, &a), (3, "office:styles") => child_key("common", &q, &a), (3, "office:font-face-decls") => child_key("font", &q, &a), @@ -134,7 +144,7 @@ fn to_1_3(xml: &str) -> String { fn child_key(g: &str, q: &str, a: &super::xml::Attrs) -> Option { // Drawing definitions (dashes, gradients, arrowheads…) are named by `draw:name`. match get(a, "style:name").or_else(|| get(a, "draw:name")).filter(|n| !n.is_empty()) { - Some(name) => Some(format!("{g}:{name}{}", space(q))), + Some(name) => Some(format!("{g}:{name}{}", space(q, get(a, "style:family").unwrap_or("")))), None => { let which = get(a, "style:family").or_else(|| get(a, "text:note-class")).unwrap_or(""); Some(format!("{g}~{q}~{which}")) @@ -142,13 +152,17 @@ fn child_key(g: &str, q: &str, a: &super::xml::Attrs) -> Option { } } -/// The name space a definition (element `q`) belongs to, as a key's suffix: names are unique -/// only within one (§16.2: a style's within its family; a list style, a data style, a gradient -/// may share a paragraph style's name). Styles (`style:style`), font faces and master pages -/// have none; a list style is `|list`, a data style `|data`, another definition `|`. -fn space(q: &str) -> String { +/// The name space a definition (element `q`, of style family `family`) belongs to, as a key's +/// suffix: names are unique only within one (§16.2: a style's within its family; a list style, +/// a data style, a gradient may share a paragraph style's name, as a graphic style may: +/// LibreOffice's "Marginalia" is both). Paragraph styles, font faces and master pages have none; +/// another family's style is `|`, a list style `|list`, a data style `|data`, another +/// definition `|`. +fn space(q: &str, family: &str) -> String { match q { - "style:style" | "style:font-face" | "style:master-page" => String::new(), + "style:style" if family.trim() == "paragraph" || family.is_empty() => String::new(), + "style:style" => format!("|{}", family.trim()), + "style:font-face" | "style:master-page" => String::new(), "text:list-style" => "|list".to_string(), q if q.starts_with("number:") => "|data".to_string(), q => format!("|{}", q.split_once(':').map_or(q, |(_, l)| l)), @@ -610,6 +624,34 @@ pub(super) fn changes(xml: &str) -> BTreeSet { out } +/// The names of the bookmarks (§6.2.1) `xml` begins (at a point too) and ends. +pub(super) fn bookmarks(xml: &str) -> (BTreeSet, BTreeSet) { + let (mut starts, mut ends) = (BTreeSet::new(), BTreeSet::new()); + if xml.contains(" { + starts.insert(n.to_string()); + ends.insert(n.to_string()); + } + "text:bookmark-start" => { + starts.insert(n.to_string()); + } + "text:bookmark-end" => { + ends.insert(n.to_string()); + } + _ => {} + } + } + }); + } + (starts, ends) +} + /// The sequences (§7.4.13 `text:sequence`) `xml` counts, which the document declares. pub(super) fn sequences(xml: &str) -> BTreeSet { let mut out = BTreeSet::new(); diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index 5c56124815..66e0638588 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -809,6 +809,17 @@ fn with_kept_files( } } +/// `office:text`'s statement that the text holds the file's soft page breaks (§19.857 +/// `text:use-soft-page-breaks`), when the file said so and markup kept as read in `body` holds +/// them. +fn soft_page_breaks(kept: Option<&wordcraft_doc::kept::KeptXml>, body: &str) -> &'static str { + if kept.is_some_and(|k| k.defs.contains_key("soft-page-breaks")) && body.contains(" Result, String> { }) .unwrap_or_default(); let scripts = kept.and_then(|k| k.defs.get("top:office:scripts")).map(|d| d.to_string()).unwrap_or_default(); + let soft = soft_page_breaks(kept, &body); let content = format!( - "\n{scripts}{}\n{}{tracked}{var_decls}{user_decls}{decls}{other_decls}{body}\n", + "\n{scripts}{}\n{}{tracked}{var_decls}{user_decls}{decls}{other_decls}{body}\n", w.automatic_styles(), // The file's forms as read, with the document's controls. super::kept::merge_forms(kept.and_then(|k| k.defs.get("forms")).map(|d| &**d), super::forms::forms_xml(&w.forms, &hidden_controls(doc))) @@ -1018,7 +1030,8 @@ fn export_as(doc: &Document, mime: &str) -> Result, String> { let parts: Vec = pages.iter().map(|(_, sec)| page_parts(doc, sec, &mut hw)).collect(); // A custom style kept paragraphs name is written from the model (as edited), not as read. for st in &doc.styles.styles { - if w.kept_used.1.contains_key(&super::writer::odf_name(&st.id)) { + let name = super::writer::odf_name(&st.id); + if w.kept_used.1.contains_key(&name) || w.kept_used.1.contains_key(&format!("{name}|text")) { if model::is_custom_style(doc, &st.id) { w.named.insert(st.id.clone()); } else if model::is_custom_char_style(doc, &st.id) { diff --git a/crates/formats/src/odt/tests_more/lossless.rs b/crates/formats/src/odt/tests_more/lossless.rs index c90a94ae42..d514a7c5a3 100644 --- a/crates/formats/src/odt/tests_more/lossless.rs +++ b/crates/formats/src/odt/tests_more/lossless.rs @@ -37,12 +37,25 @@ fn package(s: &Sample) -> Vec { z.start_file(name, o).unwrap(); std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); } - // The picture a sample may show. - z.start_file("Pictures/dot.png", o).unwrap(); - std::io::Write::write_all(&mut z, DOT).unwrap(); + // The picture a sample may show, and a formula object with its preview. + for (name, data) in [ + ("META-INF/manifest.xml", MANIFEST.as_bytes()), + ("Pictures/dot.png", DOT), + ("Object 9/content.xml", FORMULA.as_bytes()), + ("ObjectReplacements/Object 9", DOT), + ] { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data).unwrap(); + } z.finish().unwrap().into_inner() } +/// The samples' manifest (ODF 1.3 Part 2 §4): the formula object is one. +const MANIFEST: &str = r#""#; + +/// A formula object's content (MathML, ODF 1.3 §10.4.3): x². +const FORMULA: &str = r#"x2x^2"#; + /// A one-pixel picture of our own. const DOT: &[u8] = &[ 0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4, @@ -137,7 +150,7 @@ fn check(s: &Sample) { /// Paragraphs and text WordCraft partly models: unknown elements, attributes and namespaces. const TEXT: Sample = Sample { name: "text", - content: r##"Orchard notesPears ripen late in the year, and apples early.Second paragraph with a 1Footnote words. note."##, + content: r##"Orchard notesPears ripen late in the year, and apples early.Second paragraph with a 1Footnote words. note."##, styles: r##""##, meta: "", }; @@ -147,7 +160,7 @@ const TEXT: Sample = Sample { const STYLES: Sample = Sample { name: "styles", content: r##"Only text."##, - styles: r##""##, + styles: r##""##, meta: "", }; @@ -261,13 +274,31 @@ const CHANGES: Sample = Sample { /// WordCraft doesn't model. const TEXT_BOXES: Sample = Sample { name: "text boxes", - content: r##"A box Inside the box.A second line.beside the text.A shape Stopwith words."##, + content: r##"Our addressA box Inside the box.A second line.beside the text.A shape Stopwith words."##, styles: r##""##, meta: "", }; +/// Bookmarks: at a point in a table's cell (with LibreOffice's relative column widths), and over +/// text from one paragraph into the next. +const BOOKMARKS: Sample = Sample { + name: "bookmarks", + content: r##"ItemWhoFrom hereto there."##, + styles: "", + meta: "", +}; + +/// A footnote with styled text, and a formula object with its preview picture. +const NOTES_AND_FORMULAS: Sample = Sample { + name: "notes and formulas", + content: r##"A claim1A source for it. and a formula in it."##, + styles: "", + meta: "", +}; + /// Every sample. -const SAMPLES: [Sample; 13] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES, DRAWINGS, COMMENTS, CHANGES, TEXT_BOXES]; +const SAMPLES: [Sample; 15] = + [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES, DRAWINGS, COMMENTS, CHANGES, TEXT_BOXES, BOOKMARKS, NOTES_AND_FORMULAS]; /// Whether `xml` is well-formed: every element closed, in order, once. fn well_formed(xml: &[u8]) -> bool { @@ -534,3 +565,64 @@ fn linked_text_boxes_keep_their_link() { let next = c.split("draw:chain-next-name=\"").nth(1).and_then(|r| r.split('"').next()).unwrap_or_default().to_string(); assert!(!next.is_empty() && c.contains(&format!("draw:name=\"{next}\"")), "{c}"); } + +#[test] +fn a_bookmark_kept_at_one_end_is_written_once() { + // Either paragraph a bookmark spans edited: one start and one end, each once. + let d = import(&package(&BOOKMARKS)).unwrap(); + for start in ["From", "to there"] { + let mut e = d.clone(); + let i = e.body.iter().position(|b| b.as_para().is_some_and(|p| p.text.starts_with(start))).unwrap(); + if let Some(wordcraft_doc::Block::Para(p)) = e.body.get_mut(i).map(std::sync::Arc::make_mut) { + p.props.style = Some("Edited".into()); + p.touch(); + } + let c = content_of(&export(&e).unwrap()); + assert_eq!(c.matches(r#""#).count(), 1, "{c}"); + assert_eq!(c.matches(r#""#).count(), 1, "{c}"); + assert!(c.find("bookmark-start").unwrap_or(0) < c.find("bookmark-end").unwrap_or(0), "{c}"); + } +} + +#[test] +fn a_note_or_formula_edited_is_written_from_the_model() { + let d = import(&package(&NOTES_AND_FORMULAS)).unwrap(); + let saved = export(&d).unwrap(); + assert!(content_of(&saved).contains(r#"wcx:note="kept""#)); + let names = |b: &[u8]| zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())).unwrap().file_names().map(String::from).collect::>(); + assert!(names(&saved).iter().any(|n| n == "Object 9/content.xml"), "{:?}", names(&saved)); + let manifest = crate::odt::tests::entry(&saved, "META-INF/manifest.xml"); + assert!( + manifest.contains(r#"manifest:full-path="Object 9/" manifest:media-type="application/vnd.oasis.opendocument.formula""#) + && manifest.contains(r#"manifest:full-path="ObjectReplacements/Object 9""#), + "{manifest}" + ); + // The note's text edited. + let mut e = d.clone(); + let id = e.parts.iter().find(|(_, p)| p.kind == wordcraft_doc::PartKind::Footnote).map(|(id, _)| *id).unwrap(); + let mut blocks = e.parts.get(&id).unwrap().blocks.clone(); + if let Some(wordcraft_doc::Block::Para(p)) = blocks.first_mut().map(std::sync::Arc::make_mut) { + p.props.style = Some("Edited".into()); + p.touch(); + } + if let Some(part) = e.parts.get_mut(&id) { + part.blocks = blocks; + } + let c = content_of(&export(&e).unwrap()); + assert!(!c.contains(r#"wcx:note="kept""#) && c.contains("for it."), "{c}"); + // The formula edited: written anew, and the old one's files aren't. + let mut e = d.clone(); + for b in e.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for o in &mut p.objects { + if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o { + *linear = "y^3".into(); + } + } + p.touch(); + } + } + let saved = export(&e).unwrap(); + assert!(!names(&saved).iter().any(|n| n.starts_with("Object 9")), "{:?}", names(&saved)); + assert!(parts_well_formed(&saved).is_ok()); +} diff --git a/crates/formats/src/odt/tests_more/notes.rs b/crates/formats/src/odt/tests_more/notes.rs index 8b5d2fdf19..acf54661e1 100644 --- a/crates/formats/src/odt/tests_more/notes.rs +++ b/crates/formats/src/odt/tests_more/notes.rs @@ -177,13 +177,17 @@ fn citations_are_numbered_as_the_document_numbers_its_notes() { // §6.3.3: the citation is the text shown. Footnotes in A, B, C from the third (ODF counts the // first number from 0), endnotes as configured; without a configuration 1, 2, 3 and i, ii. let mut d = import(&body(r#"Onexa twoxb threexc"#)).unwrap(); - let citations = |d: &Document| { - let c = crate::odt::tests::entry(&export(d).unwrap(), "content.xml"); + let citations = |d: &Document, model: bool| { + let saved = if model { crate::odt::tests::export_model(d) } else { export(d) }; + let c = crate::odt::tests::entry(&saved.unwrap(), "content.xml"); c.split("").skip(1).filter_map(|s| s.split('<').next()).map(str::to_string).collect::>() }; - assert_eq!(citations(&d), ["1", "2", "i"]); + assert_eq!(citations(&d, true), ["1", "2", "i"]); + // Unedited, the file's own (as read). + assert_eq!(citations(&d, false), ["x", "x", "x"]); d.settings.footnote_format = wordcraft_doc::section::NumFormat::UpperLetter; d.settings.footnote_pr.num_start = Some(3); d.settings.endnote_format = wordcraft_doc::section::NumFormat::Decimal; - assert_eq!(citations(&d), ["C", "D", "1"]); + // The numbering changed: its notes' paragraph written from the model, numbered anew. + assert_eq!(citations(&d, false), ["C", "D", "1"]); } diff --git a/crates/formats/src/odt/tests_more/objects.rs b/crates/formats/src/odt/tests_more/objects.rs index f57db5c093..f1c3e4d5b6 100644 --- a/crates/formats/src/odt/tests_more/objects.rs +++ b/crates/formats/src/odt/tests_more/objects.rs @@ -128,9 +128,14 @@ fn a_flat_document_holds_its_objects_and_their_pictures_inline() { let saved = export(&again).unwrap(); let z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap(); let names: Vec<&str> = z.file_names().collect(); - assert!(names.iter().filter(|n| n.starts_with("ObjectReplacements/")).count() == 2, "{names:?}"); let ole = crate::odt::tests::entry(&saved, "content.xml"); assert!(ole.contains(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); 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 `
` 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-11-03 · **Change:** trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); 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 `
` 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; @@ -285,6 +285,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) | | 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) | | 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) | +| 2026-11-03 | trivial | ODT: more paragraphs written back as read while unchanged: those holding bookmarks (their names kept, so a bookmark may start in markup as read and end in a paragraph written from the model; each name written once), footnotes and endnotes (each note's text fingerprinted when read; while the note numbering is as read, as the citation is its number), formulas, charts and other embedded objects (with their files and preview pictures, only while the paragraph showing them is written as read), and those page-anchored frames LibreOffice puts in the text before a paragraph join (their markup kept before the paragraph's). Styles of different families with one name (LibreOffice's paragraph and graphic "Marginalia") are kept apart, as list styles were. The text keeps `text:use-soft-page-breaks` while markup kept as read holds soft page breaks. Still written from the model: form controls (named afresh in the forms), and a chart's paragraph (WordCraft draws the chart after reading it). | | 2026-11-03 | trivial | ODT: a paragraph showing text boxes or shapes holding text is written back as read while their text is as read (each story fingerprinted when read, with the boxes and notes it holds; an edit to it sends the paragraph to the model). Linked boxes (`draw:chain-next-name`), a comment in a box's text, charts and formulas are still written from the model. | | 2026-11-03 | trivial | ODT: a paragraph holding comments or tracked changes is written back as read while they are as read: comments whose range, replies and end are in it (a comment's text, author, state and replies fingerprinted when read: one edited, resolved, answered or deleted sends the paragraph to the model), and changes whose start and end are in it, with their regions as read (a deletion's content, the changes inside it first, and the change's own remark); a region without the date ODF requires is written from the model, dated. An older file's region gets the `xml:id` ODF 1.3 names it by. WordCraft's own regions take the writer's prefix (`wcct1`) beside the file's. Over the corpus the words a save loses fall from 29 to 4. | | 2026-11-03 | trivial | ODT: a paragraph or table holding pictures and drawing shapes (no text in them) is written back as read while nothing in it changed, with the pictures it shows (only those: a picture an edited or deleted paragraph showed isn't written) and its frames' styles: wrapping, contour, picture adjustments and the rest of what WordCraft doesn't model are kept. Text boxes, shapes holding text, charts, formulas and form controls are still written from the model. The file's own `Graphics` and `Frame` styles are written as read. A flat document's pictures are put inline whatever form their element has (one holding a title lost what followed it). Over the corpus the attributes and values a save loses halve (1,551 → 789). | diff --git a/docs/gaps.md b/docs/gaps.md index 97ea02401b..1c724ee453 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: sections bounded by a paragraph with a twin, or in a table cell; linked text boxes, charts and formulas, comments and changes spanning paragraphs are written from the model) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: sections bounded by a paragraph with a twin, or in a table cell; linked text boxes, form controls, comments and changes spanning paragraphs are written from the model) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |