diff --git a/crates/doc/src/kept.rs b/crates/doc/src/kept.rs index cae60f2c2d..07f9624c32 100644 --- a/crates/doc/src/kept.rs +++ b/crates/doc/src/kept.rs @@ -37,6 +37,12 @@ pub struct KeptXml { pub wraps: HashMap>, u32)>, /// While reading: the same by paragraph `rev`, until [`Kept::seal`]. pub pending_wraps: HashMap>, u32)>, + /// Indexes kept as read: by their first paragraph's fingerprint, how many top-level + /// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]), + /// and the index's markup. Written as read while those paragraphs are all as read. + pub indexes: HashMap)>, + /// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup. + pub pending_indexes: HashMap)>, /// Named definitions the kept markup refers to (styles, font faces…), by a key the format /// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read. pub defs: BTreeMap>, @@ -122,6 +128,21 @@ impl KeptXml { } self.wraps.get(&fingerprint(p)) } + /// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their + /// [`run_fingerprint`] when read, its markup. + pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc)> { + if self.indexes.is_empty() { + return None; + } + self.indexes.get(&fingerprint(p)) + } + /// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`. + pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc) { + if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES { + self.bytes += xml.len(); + self.pending_indexes.insert(rev, (n, xml)); + } + } /// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget. pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec>, u32)) { let n: usize = wrap.0.iter().map(|t| t.len()).sum(); @@ -203,6 +224,15 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 { h.finish() } +/// What a run of paragraphs is in the model, together. +pub fn run_fingerprint<'a>(paras: impl Iterator) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for p in paras { + fingerprint(p).hash(&mut h); + } + h.finish() +} + /// What table `t` is in the model. pub fn table_fingerprint(t: &crate::Table) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -248,8 +278,18 @@ impl crate::Document { /// After reading: the markup kept for paragraphs still as the reader made them is filed /// under their fingerprints (see [`Kept::seal`]); the rest is dropped. pub fn seal_kept(&mut self) { - // The body's tables kept, by their place among its tables. + // The body's tables kept, by their place among its tables; its indexes, by their run of + // top-level paragraphs. if let Some(k) = self.kept.get_mut() { + let pending = std::mem::take(&mut k.pending_indexes); + for (i, b) in self.body.iter().enumerate() { + let Some(p) = b.as_para() else { continue }; + let Some((n, xml)) = pending.get(&p.rev) else { continue }; + let run: Option> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect()); + if let Some(run) = run.filter(|r| r.len() == *n) { + k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone())); + } + } let pending = std::mem::take(&mut k.pending_tables); let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect(); for (n, xml) in pending { diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 4117983571..a4a0913aa4 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -463,6 +463,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { mark: None, kept: None, wrap: None, + index: None, }; if opts.paras { out.extra = para_extra(doc, &p.props); @@ -503,6 +504,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { mark: None, kept: None, wrap: None, + index: None, }; let prev = std::mem::replace(&mut out, next); if prev.inlines.is_empty() { @@ -839,9 +841,43 @@ fn plain_control(kind: &wordcraft_doc::control::ControlKind) -> bool { matches!(kind, K::Text { .. } | K::CheckBox { .. } | K::DropDown { .. } | K::ComboBox { .. } | K::Date { .. }) } +/// The index kept as read starting at block `i` of `bl`, while the paragraphs it takes are all +/// as read: how many blocks, and its markup. +fn kept_index(doc: &Document, bl: &Blocks, i: usize) -> Option<(usize, std::sync::Arc)> { + let k = doc.kept.of("odt")?; + let p = bl.get(i)?.as_para()?; + let (n, run, xml) = k.index(p)?; + let paras: Vec<&Paragraph> = bl.get(i..i + n)?.iter().filter_map(|b| b.as_para()).collect(); + (paras.len() == *n && wordcraft_doc::kept::run_fingerprint(paras.into_iter()) == *run).then(|| (*n, xml.clone())) +} + fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: FlowOpts) -> Vec { let mut out = Vec::new(); - for b in bl { + let mut skip = 0usize; + for (i, b) in bl.iter().enumerate() { + if skip > 0 { + skip -= 1; + continue; + } + // An index kept as read, while every paragraph it takes still is what was read: its + // flow paragraphs, the first carrying its markup and how many there are. + if tracked.paras + && depth == 0 + && let Some(ix) = kept_index(doc, bl, i) + { + let from = out.len(); + for b in bl.get(i..i + ix.0).unwrap_or(&[]) { + if let Block::Para(p) = &**b { + out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para)); + } + } + let n = out.len() - from; + if let Some(FBlock::Para(first)) = out.get_mut(from) { + first.index = Some(Box::new((ix.1, n))); + } + skip = ix.0.saturating_sub(1); + continue; + } match &**b { Block::Para(p) => out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para)), Block::Table(t) if depth < MAX_DEPTH => out.push(FBlock::Table(flow_table(doc, t, depth, tracked))), diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 84c5369bad..bb1f665fd4 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -470,6 +470,28 @@ impl<'a> Builder<'a> { } } + /// Keeps a paragraph's markup read from the file (its own, its frame wrap, the index it + /// starts) pending until the document is sealed. + fn keep_pending(&mut self, p: &Para, rev: u64) { + if p.kept.is_none() && p.wrap.is_none() && p.index.is_none() { + return; + } + if self.doc.kept.get().is_none() { + self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default()); + } + if let Some(k) = self.doc.kept.get_mut() { + if let Some(xml) = &p.kept { + k.keep_pending(rev, xml.clone()); + } + if let Some(w) = &p.wrap { + k.keep_pending_wrap(rev, (**w).clone()); + } + if let Some(ix) = &p.index { + k.keep_pending_index(rev, ix.1, ix.0.clone()); + } + } + } + fn para(&mut self, p: &Para, list_num: Option) -> Paragraph { let mut out = Paragraph::new(); out.props = para_props(p, list_num); @@ -625,19 +647,7 @@ impl<'a> Builder<'a> { out.touch(); // Its markup kept for the writer, as long as nothing changes the paragraph from here on // (see `wordcraft_doc::kept`: the reader seals it once the document is done). - if p.kept.is_some() || p.wrap.is_some() { - if self.doc.kept.get().is_none() { - self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default()); - } - if let Some(k) = self.doc.kept.get_mut() { - if let Some(xml) = &p.kept { - k.keep_pending(out.rev, xml.clone()); - } - if let Some(w) = &p.wrap { - k.keep_pending_wrap(out.rev, (**w).clone()); - } - } - } + self.keep_pending(p, out.rev); out } diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 0b25014f08..44661a67a0 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -673,6 +673,9 @@ pub struct Para { /// Sections kept as read around paragraphs ([`wordcraft_doc::kept`]): the start tags of /// those opening before this paragraph (outermost first), and how many close after it. pub wrap: Option>, u32)>>, + /// An index (a table of contents…) kept as read that starts with this paragraph + /// ([`wordcraft_doc::kept`]): its markup, and how many blocks of the flow it takes. + pub index: Option, usize)>>, } impl Para { diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 7c1da2a8f9..39fe9e2c31 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -92,6 +92,8 @@ pub(super) struct Body<'a> { kept_open: Option, /// The body table whose markup is being kept (its `done`: the container's length before it). kept_table: Option, + /// The body index (a table of contents…) whose markup is being kept, likewise. + kept_index: Option, /// Body sections whose wrapper is kept as read: start tag, depth, the body's length before /// it; `None` for one read as a columned section (kept as a section break). kept_sections: Vec, usize, usize)>>, @@ -220,6 +222,7 @@ impl<'a> Body<'a> { raw: &[], kept_open: None, kept_table: None, + kept_index: None, kept_sections: Vec::new(), section_masters: vec![None], pending_master: None, @@ -300,6 +303,47 @@ impl<'a> Body<'a> { /// 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`). pub(super) fn feed_at(&mut self, ev: Ev, at: (usize, usize)) { + // A body index, kept whole: its settings and templates, which WordCraft writes its own. + match &ev { + Ev::Start(q, _) => match &mut self.kept_index { + Some(k) => k.clean &= !(q.starts_with("draw:") || q.starts_with("office:annotation") || q.starts_with("text:change")), + None if q.starts_with("text:") + && is_index(local(q)) + && !self.raw.is_empty() + && self.in_text + && self.skip == 0 + && self.containers.len() == 1 + && self.tables.is_empty() + && self.frame.is_none() + && self.note.is_none() + && self.del.is_none() => + { + self.end_para(); + self.kept_index = Some(KeptOpen { from: at.0, depth: self.depth + 1, clean: true, done: self.container().len() }); + } + None => {} + }, + Ev::End(q) if q.starts_with("text:") && is_index(local(q)) && self.kept_index.as_ref().is_some_and(|k| k.depth == self.depth) => { + let k = self.kept_index.take(); + self.feed(ev); + if let Some(k) = k + && k.clean + && self.containers.len() == 1 + && let Some(xml) = self.raw.get(k.from..at.1).and_then(|b| std::str::from_utf8(b).ok()) + { + let blocks = self.container(); + let n = blocks.len().saturating_sub(k.done); + if n > 0 + && blocks.get(k.done..).is_some_and(|r| r.iter().all(|b| matches!(b, FBlock::Para(_)))) + && let Some(FBlock::Para(p)) = blocks.get_mut(k.done) + { + p.index = Some(Box::new((std::sync::Arc::from(xml), n))); + } + } + return; + } + _ => {} + } // A body section: its start tag kept, put around its paragraphs at its end. match &ev { Ev::Start(q, _) if q == "text:section" && self.kept_sections.len() < 64 => { diff --git a/crates/formats/src/odt/kept.rs b/crates/formats/src/odt/kept.rs index e18916f7e3..f054cd3713 100644 --- a/crates/formats/src/odt/kept.rs +++ b/crates/formats/src/odt/kept.rs @@ -55,6 +55,7 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc)> { (3, "office:font-face-decls") => child_key("font", &q, &a), (3, "office:master-styles") => child_key("master", &q, &a), (4, "office:text") if content && q.starts_with("text:") && q.ends_with("-decls") => Some(format!("decls:{q}")), + (4, "office:text") if content && q == "office:forms" => Some("forms".to_string()), _ => None, }; if let Some(key) = key { @@ -345,6 +346,159 @@ pub(super) fn with_kept_meta(meta: String, defs: &BTreeMap>, or } } +/// Whether a child of a `form:form` is a form control (else the form's own properties or +/// event listeners). +fn is_control(q: &str) -> bool { + q.starts_with("form:") && !matches!(q, "form:properties" | "form:form") +} + +/// The children `depth` deep in `xml` (the root is 1): each one's element, `form:name` and +/// markup. +fn children_at(xml: &str, at_depth: usize) -> Vec<(String, Option, &str)> { + let mut out = Vec::new(); + let mut depth = 0usize; + let mut open: Option<(String, Option, usize)> = None; + events_at(xml.as_bytes(), |ev, at| match ev { + Ev::Start(q, a) => { + depth += 1; + if depth == at_depth && open.is_none() { + open = Some((q, get(&a, "form:name").map(str::to_string), at.0)); + } + } + Ev::End(_) => { + if depth == at_depth + && let Some((q, name, from)) = open.take() + && let Some(x) = xml.get(from..at.1) + { + out.push((q, name, x)); + } + depth = depth.saturating_sub(1); + } + Ev::Text(_) => {} + }); + out +} + +/// Control `ours` (as WordCraft writes it) with what the file's control `theirs` had that +/// WordCraft doesn't model and so can't have changed: its other attributes (not its ids) and its +/// properties and event listeners. +fn with_unmodelled(ours: &str, theirs: &str) -> String { + let attrs_of = |xml: &str| { + let mut out: Vec<(String, String)> = Vec::new(); + let mut first = true; + super::xml::events(xml.as_bytes(), |ev| { + if let Ev::Start(_, a) = ev + && std::mem::take(&mut first) + { + out = a; + } + }); + out + }; + let have: BTreeSet = attrs_of(ours).into_iter().map(|(k, _)| k).collect(); + let extra: String = attrs_of(theirs) + .into_iter() + .filter(|(k, _)| !have.contains(k) && !matches!(k.as_str(), "form:id" | "xml:id" | "form:name") && !k.starts_with("xmlns")) + .map(|(k, v)| format!(" {k}=\"{}\"", super::writer::x(&v))) + .collect(); + let kids: String = children_at(theirs, 2) + .into_iter() + .filter(|(q, ..)| matches!(q.as_str(), "form:properties" | "office:event-listeners")) + .filter(|(q, ..)| !ours.contains(&format!("<{q}"))) + .map(|(.., x)| x) + .collect(); + // Into its start tag, and before its end (an empty one opened for them). + let Some(end) = ours.find('>') else { return ours.to_string() }; + let (head, rest) = ours.split_at(end); + let empty = head.ends_with('/'); + let head = head.trim_end_matches('/'); + let name = head.trim_start_matches('<').split(char::is_whitespace).next().unwrap_or(""); + if empty { + if kids.is_empty() { format!("{head}{extra}/>") } else { format!("{head}{extra}>{kids}") } + } else { + let close = format!(""); + let body = rest.get(1..).unwrap_or(""); + match body.rfind(&close) { + Some(at) => format!("{head}{extra}>{}{kids}{}", body.get(..at).unwrap_or(""), body.get(at..).unwrap_or("")), + None => format!("{head}{extra}>{body}"), + } + } +} + +/// The document's forms (`office:forms`, §13.2) as the file had them (`orig`), with the +/// controls WordCraft writes (`ours`, its own `office:forms`): the file's forms element and each +/// form's own start tag, properties and event listeners as read; the controls the document has +/// in the first form (the file's controls are the document's, read into it and written back); +/// `ours` alone when the file had none, its own form when the file had none. +pub(super) fn merge_forms(orig: Option<&str>, ours: String) -> String { + let Some(orig) = orig else { return ours }; + // `ours`: the controls (children of its one form). + let ours_controls = children_at(&ours, 3); + let orig_controls: Vec<(String, Option, &str)> = children_at(orig, 3).into_iter().filter(|(q, ..)| is_control(q)).collect(); + // Each of ours with what the file's control by that name (else in that place) had and + // WordCraft doesn't model. + let mut used = vec![false; orig_controls.len()]; + let mut controls = String::new(); + for (i, (q, name, xml)) in ours_controls.iter().enumerate() { + let same = |(k, (oq, oname, _)): &(usize, &(String, Option, &str))| !used[*k] && oq == q && oname == name; + let found = orig_controls.iter().enumerate().find(|c| same(c)).map(|(k, _)| k).or_else(|| { + let nth = ours_controls.iter().take(i).filter(|(oq, ..)| oq == q).count(); + orig_controls.iter().enumerate().filter(|(k, (oq, ..))| !used[*k] && oq == q).nth(nth).map(|(k, _)| k) + }); + match found.and_then(|k| orig_controls.get(k).map(|c| (k, c.2))) { + Some((k, theirs)) => { + used[k] = true; + controls.push_str(&with_unmodelled(xml, theirs)); + } + None => controls.push_str(xml), + } + } + // `orig`: its forms element, each form's start tag and non-control children. + let mut head: Option = None; + let mut forms: Vec<(String, String)> = Vec::new(); + let mut child: Option<(usize, bool)> = None; + let mut depth = 0usize; + events_at(orig.as_bytes(), |ev, at| match ev { + Ev::Start(q, _) => { + depth += 1; + let tag = orig.get(at.0..at.1).unwrap_or(""); + let opened = || tag.strip_suffix("/>").map_or_else(|| tag.to_string(), |t| format!("{}>", t.trim_end())); + match depth { + 1 => head = Some(opened()), + 2 if q == "form:form" => forms.push((opened(), String::new())), + 3 if child.is_none() => child = Some((at.0, is_control(&q))), + _ => {} + } + } + Ev::End(_) => { + if depth == 3 + && let Some((from, control)) = child.take() + && !control + && let (Some(f), Some(x)) = (forms.last_mut(), orig.get(from..at.1)) + { + f.1.push_str(x); + } + depth = depth.saturating_sub(1); + } + Ev::Text(_) => {} + }); + let Some(head) = head else { return ours }; + let mut out = head; + if forms.is_empty() && !controls.is_empty() { + out.push_str(&format!("{controls}")); + } + for (i, (start, kids)) in forms.iter().enumerate() { + out.push_str(start); + out.push_str(kids); + if i == 0 { + out.push_str(&controls); + } + out.push_str(""); + } + out.push_str(""); + out +} + /// A list of declarations as the file had it (`orig`, a `text:variable-decls` or the like), /// with those of WordCraft's (`ours`, the same list) it doesn't have by name; `ours` alone when /// the file had none. diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index f00bae7582..4f6dc84a9b 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -997,7 +997,8 @@ fn export_as(doc: &Document, mime: &str) -> Result, String> { let content = format!( "\n{scripts}{}\n{}{tracked}{var_decls}{user_decls}{decls}{other_decls}{body}\n", w.automatic_styles(), - super::forms::forms_xml(&w.forms, &hidden_controls(doc)) + // 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))) ); let mut hw = Writer::with_prefix(if kept.is_some() { "wcM" } else { "M" }); hw.accents = accents; diff --git a/crates/formats/src/odt/tests_more/forms.rs b/crates/formats/src/odt/tests_more/forms.rs index 26b8c0ea4d..39170f56d1 100644 --- a/crates/formats/src/odt/tests_more/forms.rs +++ b/crates/formats/src/odt/tests_more/forms.rs @@ -194,7 +194,7 @@ fn hidden_controls_are_kept() { r#"{hidden}x"# ); let c = entry(&export(&import(&package(&only, None)).unwrap()).unwrap(), "content.xml"); - assert!(c.contains("Name: "##, + styles: "", + meta: "", +}; + +/// A table of contents and an alphabetical index with their settings and templates. +const INDEXES: Sample = Sample { + name: "indexes", + content: r##"ContentsContentsOrchards1OrchardsPears and apples.WordsWordsapples1"##, + styles: "", + meta: "", +}; + /// Every sample. -const SAMPLES: [Sample; 7] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS]; +const SAMPLES: [Sample; 9] = [TEXT, STYLES, META, DECLS, TABLE, PAGES, SECTIONS, FORMS, INDEXES]; /// Whether `xml` is well-formed: every element closed, in order, once. fn well_formed(xml: &[u8]) -> bool { @@ -273,3 +289,26 @@ fn edits_leave_kept_markup_well_formed() { } } } + +#[test] +fn an_edited_index_is_written_from_the_model() { + // Unedited, the index keeps its templates; once an entry changes, the edit is what's saved. + use wordcraft_doc::Pos; + let d = import(&package(&INDEXES)).unwrap(); + let c = content_of(&export(&d).unwrap()); + assert!(c.contains("text:combine-entries-with-pp=\"false\""), "{c}"); + let i = d.body.iter().position(|b| b.as_para().is_some_and(|p| p.plain_text().starts_with("Orchards\t"))).unwrap(); + let mut e = d.clone(); + e.insert_text(&Pos::body(i, 0), "Edited ", &Default::default()).unwrap(); + let c = content_of(&export(&e).unwrap()); + assert!(c.contains("Edited Orchards"), "{c}"); + // The other index, unedited, is still as read. + assert!(c.contains("text:combine-entries-with-pp=\"false\""), "{c}"); +} + +fn content_of(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut s).unwrap(); + s +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index 5314a2e844..4fbd442648 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -681,6 +681,18 @@ impl Writer { // Only the first top-level block of a section starts its page. if depth == 0 { self.master = self.pending_start.take(); + // An index kept as read (its paragraphs all as read): as it was. + if let Some(FBlock::Para(p)) = blocks.get(i) + && let Some(ix) = p.index.as_deref() + && chains.is_none() + && (self.master.is_none() || self.kept_pages) + && self.kept_markup(&ix.0, out) + { + self.master = None; + out.push('\n'); + i += ix.1.max(1); + continue; + } // A table of contents or another index, with its entries. if let Some((n, kind, titled)) = index_at(blocks, i) { let joined = chains.is_some_and(|c| (i..i + n).any(|k| c.contains_key(&k))); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 220e07792b..bb5ca6f9f5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** 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 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: form definitions and indexes are written back as read. The file's `office:forms` keeps its own head and each `form:form` with its settings, properties, event listeners and anything WordCraft doesn't model; each control WordCraft keeps is merged with the file's own (its unmodelled attributes, `form:properties` and `office:event-listeners`). A table of contents, alphabetical index, bibliography or other index whose generated entries nobody edited is written as read, with its source settings and entry templates; once its entries change it is written from the model. The no-edit test now saves nine samples of our own. | | 2026-11-03 | trivial | ODT: a gradient's unitless angle in an ODF 1.3 file is read in degrees, as the standard says (§18.3.1; it was read in tenths of a degree in every file). In files older than ODF 1.3 it is still read in tenths, as LibreOffice and OpenOffice wrote and read them: a deviation from the standard, out of necessity, documented in `docs/odf-interpretation.md`. The ODF 1.2 → 1.3 changes were checked; none other changes what kept markup means. | | 2026-11-03 | minor | ODT: more is written back as read when unedited — tables, the document's properties WordCraft doesn't model (template, statistics, editing time, printing), the declarations opening the text and the document's scripts, page layouts and master pages while the page setup is unchanged (with the master page each section started with), and named sections around unedited paragraphs (start tag and section style as read). A file's package files and folders that kept markup names (a style's fill picture, a header's embedded object) are written with it, with their manifest entries. Files are saved as ODF 1.3; markup kept from an older file is brought to 1.3's meaning (a gradient's or hatch's unitless angle was tenths of a degree). WordCraft's own drawings state `style:flow-with-text`, which the standard leaves open. A test now saves seven samples of our own without edits and finds nothing lost (`odt::tests_more::lossless`). Over the 133-file corpus: round-trip difference 191k → 51k pixels, pixel-identical files 62 → 95, lost element kinds 65 → 38. | | 2026-11-03 | minor | ODT: what isn't edited is written back as it was read. A paragraph nothing changed (by a fingerprint of how it was modelled) is written with its own markup, unknown elements and attributes included, with the automatic styles, common styles, font faces, namespaces and package files it names; while no style or list definition changed, `styles.xml`'s styles are the file's own (unused list styles, note and line numbering configuration, attributes WordCraft doesn't read). Paragraphs with drawings, comments, tracked changes, ranged marks, or that start a page are written from the model; the default graphic style and drawing definitions are WordCraft's, as drawings are written from the model. Kept markup stays in memory only (`wordcraft_doc::kept`, 64 MB at most). Round-trip differences over the 133-file corpus 528k → 191k pixels, pixel-identical files 46 → 62. |