diff --git a/ROADMAP.md b/ROADMAP.md index f8afeca851..b602d1dbfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -522,6 +522,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| +| 2026-10-12 | RTF reads and writes footnotes, endnotes and comments (author, initials, date, commented range), so they survive a round trip through RTF (#1053) | | 2026-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) | diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index bc36c10331..8b93370bf7 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -4,11 +4,15 @@ //! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for //! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page //! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`), -//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables -//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading). +//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables +//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and +//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` … +//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}` +//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`). //! -//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers, -//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN` +//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects +//! are skipped; headers, footers, notes and comments are read into stories of their own), +//! `\par \line \tab \page`, `\uN` with `\ucN` //! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or //! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the //! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte @@ -16,13 +20,16 @@ //! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words //! above, stylesheet-based //! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and -//! tables. Group nesting and picture sizes are capped. +//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date, +//! text, the commented range). Group nesting, picture sizes and the number of notes and comments +//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside +//! headers, footers and other notes, tables inside note and comment texts (flattened). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use wordcraft_doc::props::VAlign; -use wordcraft_doc::{Align, Document, Rgb}; +use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img}; diff --git a/crates/formats/src/rtf/date.rs b/crates/formats/src/rtf/date.rs index 86b8fe8a43..763dc39bd5 100644 --- a/crates/formats/src/rtf/date.rs +++ b/crates/formats/src/rtf/date.rs @@ -1,3 +1,8 @@ +/// An ISO 8601 date-time from year, month, day, hour and minute. +pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String { + format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59)) +} + pub(super) fn rtf_date(s: &str) -> Option { let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::().ok()); let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 31cb940365..52e7e8d6e3 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -1,6 +1,9 @@ use super::date::{iso_to_dttm, rtf_date}; +use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS}; use super::styles::{style_fmt, styled}; -use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model}; +use super::{ + Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model, +}; // --------------------------------------------------------------------------------------------- // Export @@ -66,7 +69,30 @@ impl Tables { } } +/// What a place marker in the exported text stands for (see [`MARK_OPEN`]). +pub(super) enum Mark { + Note { + endnote: bool, + blocks: Vec, + }, + RangeStart(u32), + /// The end of a comment's range; the first end of each comment also carries its annotation. + RangeEnd { + id: u32, + ann: Option, + }, + Done, +} + +pub(super) struct Annotation { + author: String, + initials: String, + date: String, + blocks: Vec, +} + pub(super) struct Writer { + pub(super) marks: Vec, pub(super) t: Tables, /// List definitions: per list, the ordered flag per level. pub(super) lists: Vec<[bool; 9]>, @@ -81,6 +107,121 @@ pub(super) struct Writer { pub(super) sect_i: usize, } +pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool { + bl.iter().any(|b| match &**b { + Block::Para(p) => { + p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. })) + } + Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))), + }) +} + +/// A copy of `doc` whose note references and comment range ends are place markers in the text +/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither. +fn mark_doc(doc: &Document) -> Option<(Document, Vec)> { + if !has_marks(&doc.body, 0) { + return None; + } + let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() }; + let body = m.blocks(&doc.body, None, 0); + let mut d = doc.clone(); + d.body = body; + Some((d, m.marks)) +} + +/// The story id standing for "inside a note or comment": no nested notes, no comment ranges. +const IN_STORY: u32 = u32::MAX; + +struct Marker<'a> { + doc: &'a Document, + marks: Vec, + annotated: HashSet, +} + +impl Marker<'_> { + fn token(&mut self, m: Mark) -> String { + self.marks.push(m); + self.marks.len().saturating_sub(1).to_string() + } + + /// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs. + fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec) { + for b in bl.iter().take(MAX_NOTE_BLOCKS) { + match &**b { + Block::Para(p) => { + let q = self.para(p, Some(note)); + out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para)); + } + Block::Table(t) if depth < 4 => { + for c in t.rows.iter().flat_map(|r| r.cells.iter()) { + self.story(&c.blocks, note, depth + 1, out); + } + } + Block::Table(_) => {} + } + } + } + + fn blocks(&mut self, bl: &Blocks, note: Option, depth: usize) -> Blocks { + bl.iter() + .map(|b| match &**b { + Block::Para(p) if p.objects.is_empty() => b.clone(), + Block::Para(p) => Arc::new(Block::Para(self.para(p, note))), + Block::Table(t) if depth < 8 => { + let mut t = t.clone(); + for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + c.blocks = self.blocks(&c.blocks, note, depth + 1); + } + Arc::new(Block::Table(t)) + } + Block::Table(_) => b.clone(), + }) + .collect() + } + + /// `p` with its note references and comment range ends replaced by place markers. `note` is + /// the note being written, when `p` is part of one. + fn para(&mut self, p: &Paragraph, note: Option) -> Paragraph { + let mut q = p.clone(); + for off in p.object_offsets().into_iter().rev() { + let Some(obj) = q.object_at(off).cloned() else { continue }; + let tok = match obj { + InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")), + InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()), + InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| { + let mut blocks = Vec::new(); + self.story(&part.blocks, id, 0, &mut blocks); + self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks }) + }), + InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))), + InlineObject::CommentEnd { id } if note.is_none() => { + let ann = if self.annotated.insert(id) { self.annotation(id) } else { None }; + Some(self.token(Mark::RangeEnd { id, ann })) + } + _ => None, + }; + let Some(tok) = tok else { continue }; + let end = off + wordcraft_doc::para::OBJ.len_utf8(); + let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") }; + if q.delete(off, end).is_err() { + continue; + } + if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() { + continue; + } + } + q + } + + fn annotation(&mut self, id: u32) -> Option { + let c = self.doc.comments.get(&id)?; + let part = self.doc.parts.get(&c.part)?; + let mut blocks = Vec::new(); + self.story(&part.blocks, IN_STORY, 0, &mut blocks); + Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks }) + } +} + /// `\sectd` and a section's formatting, in the words the reader reads back. fn sect_words(s: &wordcraft_doc::SectionProps) -> String { use wordcraft_doc::section::{NumFormat, SectionStart}; @@ -232,7 +373,68 @@ impl Writer { s } + /// A run of text; place markers in it become notes and comment ranges. fn run(&mut self, t: &str, f: &Fmt, out: &mut String) { + if !t.contains(MARK_OPEN) { + return self.run_plain(t, f, out); + } + let mut rest = t; + while !rest.is_empty() { + let Some((pre, after)) = rest.split_once(MARK_OPEN) else { + self.run_plain(rest, f, out); + break; + }; + if !pre.is_empty() { + self.run_plain(pre, f, out); + } + let Some((tok, tail)) = after.split_once(MARK_CLOSE) else { + self.run_plain(after, f, out); + break; + }; + self.mark(tok, out); + rest = tail; + } + } + + /// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`. + fn mark(&mut self, tok: &str, out: &mut String) { + if tok == "S" { + out.push_str("{\\super\\chftn}"); + return; + } + let Some(m) = tok.parse::().ok().and_then(|i| self.marks.get_mut(i)) else { return }; + match std::mem::replace(m, Mark::Done) { + Mark::Note { endnote, blocks } => { + out.push_str("{\\super\\chftn}{\\footnote"); + if endnote { + out.push_str("\\ftnalt"); + } + out.push(' '); + self.blocks(&blocks, false, "\\par", out, 0); + out.push('}'); + } + Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")), + Mark::RangeEnd { id, ann } => { + out.push_str(&format!("{{\\*\\atrfend {id}}}")); + if let Some(a) = ann { + out.push_str(&format!( + "{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}", + esc(&a.initials), + esc(&a.author) + )); + if let Some(d) = rtf_date(&a.date) { + out.push_str(&format!("{{\\*\\atndate {d}}}")); + } + out.push('\n'); + self.blocks(&a.blocks, false, "\\par", out, 0); + out.push('}'); + } + } + Mark::Done => {} + } + } + + fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) { let mut ctl = String::new(); if let Some(c) = &f.fmt_change { // RTF 1.9.1, as Word writes it: the properties the change altered, as they were, @@ -685,9 +887,15 @@ fn level_def(ordered: bool, l: usize) -> String { /// RTF text of a document. pub fn export(doc: &Document) -> String { + let (marked, marks) = match mark_doc(doc) { + Some((d, m)) => (Some(d), m), + None => (None, Vec::new()), + }; + let doc = marked.as_ref().unwrap_or(doc); let flow = model::from_doc_tracked(doc); let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); let mut w = Writer { + marks, t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new(), authors: None, diff --git a/crates/formats/src/rtf/parse.rs b/crates/formats/src/rtf/parse.rs index c8249c75ec..3ebe60c0ab 100644 --- a/crates/formats/src/rtf/parse.rs +++ b/crates/formats/src/rtf/parse.rs @@ -1,7 +1,6 @@ use super::reader::Reader; -use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom}; -use super::{Cell, Document, FBlock, Flow, Para, model}; -use std::sync::Arc; +use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom}; +use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model}; /// Parse RTF bytes into the flow model. pub fn parse(bytes: &[u8]) -> Result { @@ -11,7 +10,7 @@ pub fn parse(bytes: &[u8]) -> Result { /// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which /// is the font of all text that doesn't select one. fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option), String> { - parse_full(bytes).map(|r| (r.flow, r.page, r.default_font)) + parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font)) } /// Everything an import takes from an RTF file. @@ -23,14 +22,19 @@ struct Parsed { default_font: Option, /// Each section's headers and footers, in order, the last section's last. headers_footers: Vec)>>, + notes: Vec, + comments: Vec, } -fn parse_full(bytes: &[u8]) -> Result { +/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for +/// [`import`] to turn into objects); without it they are skipped. +fn parse_full(bytes: &[u8], marks: bool) -> Result { let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0); if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) { return Err("not an RTF document (it doesn't start with {\\rtf)".into()); } let mut r = Reader::default(); + r.marks_on = marks; let mut i = start; let mut first_in_group = false; let mut starred = false; @@ -106,7 +110,10 @@ fn parse_full(bytes: &[u8]) -> Result { continue; } if r.skip_chars > 0 - && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl) + && matches!( + r.st.dest, + Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_) + ) { r.skip_chars -= 1; first_in_group = false; @@ -206,8 +213,10 @@ fn parse_full(bytes: &[u8]) -> Result { } fn finalize(mut r: Reader) -> Parsed { - // A header or footer left open by a truncated file ends here, and so does a multibyte run. + // A multibyte run ends here, and so does a note, comment, header or footer left open by a + // truncated file. r.finish_multibyte(); + r.end_sub(); r.end_header_footer(); if r.para_has || !r.para.inlines.is_empty() { r.end_para(false); @@ -234,7 +243,7 @@ fn finalize(mut r: Reader) -> Parsed { blocks.push(FBlock::Para(Para::default())); } let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string()); - Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers } + Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments } } /// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid @@ -277,7 +286,7 @@ pub(super) fn column_grid(rows: &mut [Vec], rights: &[Vec]) -> Vec Result { model::begin_import(); - let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?; + let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?; let mut doc = model::to_doc_in(&flow, last_section); // Each section's own headers and footers; those it leaves out stay linked to the previous. let mut sets = Vec::with_capacity(headers_footers.len()); @@ -309,6 +318,7 @@ pub fn import(bytes: &[u8]) -> Result { if let Some((h, f)) = sets.next() { (doc.last_section.headers, doc.last_section.footers) = (h, f); } + place_notes_and_comments(&mut doc, notes, comments); // RTF's paragraph defaults are single spacing and no space around paragraphs; what a file // wants beyond that it states on each paragraph. let dp = &mut Arc::make_mut(&mut doc.styles).default_para; @@ -320,3 +330,127 @@ pub fn import(bytes: &[u8]) -> Result { } Ok(doc) } + +/// Turn the place markers the reader left in the text into note references and comment ranges, +/// and add the notes' and comments' stories to `doc`. +fn place_notes_and_comments(doc: &mut Document, notes: Vec, comments: Vec) { + use wordcraft_doc::para::NoteKind; + use wordcraft_doc::{Comment, PartKind}; + if notes.is_empty() && comments.is_empty() { + return; + } + // (kind, part id) per note, by index. + let mut note_parts: Vec<(NoteKind, u32)> = Vec::new(); + for n in notes { + let (pk, nk, style) = + if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") }; + let mut blocks = model::part_blocks(doc, &n.blocks); + for b in &mut blocks { + if let Block::Para(p) = Arc::make_mut(b) + && p.props.style.is_none() + { + p.props.style = Some(style.to_string()); + } + } + note_parts.push((nk, doc.add_part(pk, blocks))); + } + // RTF range id to the comment's id in the document. + let mut by_rtf: HashMap = HashMap::new(); + for (k, c) in comments.into_iter().enumerate() { + let id = k as u32; + let blocks = model::part_blocks(doc, &c.blocks); + let part = doc.add_part(PartKind::Comment, blocks); + doc.comments + .insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() }); + by_rtf.insert(i64::from(c.rtf_id), id); + } + let ctx = Placer { note_parts, by_rtf }; + let mut body = std::mem::take(&mut doc.body); + ctx.blocks(&mut body, None, 0); + doc.body = body; + let ids: Vec = doc.parts.keys().copied().collect(); + for id in ids { + let Some(part) = doc.parts.get_mut(&id) else { continue }; + let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied(); + ctx.blocks(&mut part.blocks, own, 0); + // A note starts with its own number, as Word's do. + if let Some((kind, pid)) = own + && let Some(first) = part.blocks.first_mut() + && let Block::Para(p) = Arc::make_mut(first) + && !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid)) + { + let props = Placer::ref_props(kind); + if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() { + // The paragraph can't take it; the note simply has no number mark. + } + } + } +} + +struct Placer { + note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>, + by_rtf: HashMap, +} + +impl Placer { + fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps { + let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" }; + wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() } + } + + fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) { + for b in bl.iter_mut() { + let needs = match &**b { + Block::Para(p) => p.text.contains(MARK_OPEN), + Block::Table(_) => depth < 8, + }; + if !needs { + continue; + } + match Arc::make_mut(b) { + Block::Para(p) => self.para(p, own), + Block::Table(t) => { + for r in &mut t.rows { + for c in &mut r.cells { + self.blocks(&mut c.blocks, own, depth + 1); + } + } + } + } + } + } + + fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) { + // Every pass removes one marker, so this ends; the bound is only a seat belt. + for _ in 0..100_000 { + let Some(a) = p.text.find(MARK_OPEN) else { return }; + let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return }; + let end = a + len + MARK_CLOSE.len_utf8(); + let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string(); + let mut chars = inner.chars(); + let tag = chars.next().unwrap_or(' '); + let n = chars.as_str().parse::().unwrap_or(i64::MIN); + let (obj, props) = match tag { + 'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) { + Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)), + None => (None, Default::default()), + }, + 'S' => match own { + Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)), + None => (None, Default::default()), + }, + 'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()), + 'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()), + _ => (None, Default::default()), + }; + if p.delete(a, end).is_err() { + return; + } + if let Some(o) = obj + && p.insert_object(a, o, &props).is_err() + { + return; + } + } + } +} diff --git a/crates/formats/src/rtf/reader.rs b/crates/formats/src/rtf/reader.rs index 8eb0da342b..2d0a85ba92 100644 --- a/crates/formats/src/rtf/reader.rs +++ b/crates/formats/src/rtf/reader.rs @@ -1,8 +1,11 @@ -use super::date::dttm_to_iso; +use super::date::{dttm_to_iso, iso_date}; use super::parse::column_grid; -use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St}; +use super::state::{ + CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData, + Output, PageGeom, SecFmt, St, Sub, +}; use super::styles::{StyleDef, drop_style_formatting, style_fmt}; -use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model}; +use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model}; #[derive(Default)] pub(super) struct Reader { @@ -91,6 +94,22 @@ pub(super) struct Reader { pub(super) in_creatim: bool, pub(super) rule_pending: bool, pub(super) info_depth: Option, + // Notes and comments (only when the caller wants them). + pub(super) marks_on: bool, + pub(super) sub: Option, + pub(super) notes: Vec, + pub(super) comments: Vec, + pub(super) atn_text: String, + pub(super) atn_initials: String, + pub(super) atn_author: String, + pub(super) atn_date: String, + pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>, + pub(super) atn_ref: Option, + pub(super) last_end: Option, + pub(super) range_starts: HashSet, + pub(super) range_ends: HashSet, + pub(super) claimed: HashSet, + pub(super) synth: i32, } impl Reader { @@ -152,12 +171,103 @@ impl Reader { } } + /// A footnote, endnote or comment group starts: read it into an output of its own. + pub(super) fn start_sub(&mut self, comment: bool) { + if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES { + self.st.dest = Dest::Skip; + return; + } + let saved = self.swap_output(Output::default()); + self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) }); + self.st.intbl = false; + } + + /// A place marker as text in the current paragraph. + pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) { + self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt); + self.para_has = true; + } + + /// The footnote or comment group closed: keep its text and leave a marker in the body. + pub(super) fn end_sub(&mut self) { + let Some(sub) = self.sub.take() else { return }; + if self.para_has || !self.para.inlines.is_empty() { + self.end_para(false); + } + self.close_table(); + let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default(); + let mut inner = self.swap_output(*sub.saved); + inner.body.truncate(MAX_NOTE_BLOCKS); + if !sub.comment { + self.push_mark('N', self.notes.len() as i64, fmt); + self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body }); + return; + } + let want = self.atn_ref.take().or(self.last_end.take()); + let id = match want { + Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i, + _ => { + // No range of its own: the comment sits at this point. + self.synth = self.synth.saturating_sub(1); + let id = self.synth; + self.push_mark('B', i64::from(id), fmt.clone()); + self.push_mark('E', i64::from(id), fmt); + id + } + }; + let date = + if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() }; + self.atn_parts = None; + self.comments.push(CommentData { + rtf_id: id, + author: std::mem::take(&mut self.atn_author), + initials: std::mem::take(&mut self.atn_initials), + date, + blocks: inner.body, + }); + } + + /// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…). + pub(super) fn commit_atn(&mut self, kind: u8) { + let text = std::mem::take(&mut self.atn_text); + let text = text.trim(); + let id = text.parse::().ok(); + let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default(); + match kind { + 1 => { + if let Some(id) = id { + self.range_starts.insert(id); + self.push_mark('B', i64::from(id), fmt); + } + } + 2 => { + if let Some(id) = id { + self.range_ends.insert(id); + self.last_end = Some(id); + self.push_mark('E', i64::from(id), fmt); + } + } + 3 => self.atn_initials = text.to_string(), + 4 => self.atn_author = text.to_string(), + 5 => { + // Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number. + if let Some(parts) = self.atn_parts.take() { + self.atn_date = iso_date(parts); + } else if let Some(n) = text.parse::().ok().filter(|n| *n != 0) { + self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F)); + } + } + 6 => self.atn_ref = id, + _ => {} + } + } + /// `\sect`: end the paragraph, and with it the section, which takes its formatting and the /// headers and footers read since the last break. Inside a header, footer or table, and past /// [`MAX_SECTIONS`], it only ends the paragraph. pub(super) fn end_section(&mut self) { self.end_para_mark(); - if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS { + if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS { return; } let props = self.sec.props(&self.page); @@ -175,7 +285,7 @@ impl Reader { let pv = p.unwrap_or(0); // Inside a header or footer the word is read into a scratch copy and dropped. let mut scratch; - let sec = if self.hf.is_some() { + let sec = if self.hf.is_some() || self.sub.is_some() { scratch = self.sec.clone(); &mut scratch } else { @@ -231,6 +341,9 @@ impl Reader { self.overflow -= 1; return; } + if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) { + self.end_sub(); + } let closing = self.st.dest; // Destination-level commits. match closing { @@ -249,6 +362,7 @@ impl Reader { } self.in_creatim = false; } + Dest::Atn(k) => self.commit_atn(k), _ => {} } let Some(mut prev) = self.stack.pop() else { return }; diff --git a/crates/formats/src/rtf/state.rs b/crates/formats/src/rtf/state.rs index f01c7f5470..f91d63554e 100644 --- a/crates/formats/src/rtf/state.rs +++ b/crates/formats/src/rtf/state.rs @@ -5,6 +5,15 @@ pub(super) const MAX_GROUPS: usize = 512; /// Largest picture we decode (bytes). pub(super) const MAX_PICT: usize = 64 << 20; +/// Most notes, and most comments, one file may bring in. +pub(super) const MAX_NOTES: usize = 5_000; +/// Most blocks kept of one note's or comment's text. +pub(super) const MAX_NOTE_BLOCKS: usize = 500; +/// Brackets of a place marker in text: a note reference or a comment range end travels through the +/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the +/// writer keep out of real text) and becomes an inline object once the document exists. +pub(super) const MARK_OPEN: char = '\u{F8F0}'; +pub(super) const MARK_CLOSE: char = '\u{F8F1}'; /// Most sections we read (`\sect`); later breaks end paragraphs only. pub(super) const MAX_SECTIONS: usize = 10_000; @@ -25,6 +34,9 @@ pub(super) enum Dest { FldInst, Pict, Bookmark, + /// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`, + /// 5 `\atndate`/`\atntime`, 6 `\atnref`. + Atn(u8), /// The author table of tracked changes (`\revtbl`). RevTbl, /// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read @@ -32,6 +44,29 @@ pub(super) enum Dest { OldProps(bool), } +/// A footnote, endnote or comment group being read into an output of its own. +pub(super) struct Sub { + pub(super) depth: usize, + pub(super) comment: bool, + pub(super) endnote: bool, + pub(super) saved: Box, +} + +/// A note read: its kind and text. +pub(super) struct NoteData { + pub(super) endnote: bool, + pub(super) blocks: Vec, +} + +/// A comment read: the id its range markers carry, who wrote it and when, and its text. +pub(super) struct CommentData { + pub(super) rtf_id: i32, + pub(super) author: String, + pub(super) initials: String, + pub(super) date: String, + pub(super) blocks: Vec, +} + /// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`, /// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old /// formatting, once its group has been read. diff --git a/crates/formats/src/rtf/tests.rs b/crates/formats/src/rtf/tests.rs index d55ba62252..87e7b62910 100644 --- a/crates/formats/src/rtf/tests.rs +++ b/crates/formats/src/rtf/tests.rs @@ -1097,6 +1097,7 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() { let mut p = Para { kind: Kind::Code, ..Default::default() }; p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() }); let mut w = Writer { + marks: Vec::new(), t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() }, lists: Vec::new(), authors: None, diff --git a/crates/formats/src/rtf/text.rs b/crates/formats/src/rtf/text.rs index 49a3d7238c..5a6c5cfbf7 100644 --- a/crates/formats/src/rtf/text.rs +++ b/crates/formats/src/rtf/text.rs @@ -1,4 +1,4 @@ -use super::state::{Dest, MAX_PICT, St}; +use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St}; use super::{Fmt, Inline, Reader, Rgb, make_img, model}; use std::sync::Arc; @@ -196,6 +196,11 @@ impl Reader { return; } let mut f = self.st.fmt.clone(); + if s.contains([MARK_OPEN, MARK_CLOSE]) { + self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f); + self.para_has = true; + return; + } let r = self.st.rev; if r.ins { f.ins = Some(self.rev_mark(r.auth, r.dttm)); @@ -245,6 +250,11 @@ impl Reader { } } Dest::Info(_) => self.info_text.push_str(s), + Dest::Atn(_) => { + if self.atn_text.len() < 256 { + self.atn_text.push_str(s); + } + } Dest::FldInst => { if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i)) && f.len() < 4096 diff --git a/crates/formats/src/rtf/word.rs b/crates/formats/src/rtf/word.rs index 87a033caab..b71e473905 100644 --- a/crates/formats/src/rtf/word.rs +++ b/crates/formats/src/rtf/word.rs @@ -15,6 +15,9 @@ impl Reader { if w == "shppict" || self.start_header_footer_word(w) { return; } + if self.start_note_word(w) { + return; + } let dest = self.destination_for_word(w); if let Some(d) = dest { match d { @@ -295,6 +298,22 @@ impl Reader { // Transparent wrapper: read the nested pict without changing the destination. "pict" => Some(Dest::Pict), "bkmkstart" => Some(Dest::Bookmark), + "atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref" + if self.marks_on + && self.hf.is_none() + && self.st.dest == Dest::Body + && self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) => + { + self.atn_text.clear(); + Some(Dest::Atn(match w { + "atrfstart" => 1, + "atrfend" => 2, + "atnid" => 3, + "atnauthor" => 4, + "atndate" | "atntime" => 5, + _ => 6, + })) + } "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext" | "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst" | "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages" @@ -309,6 +328,32 @@ impl Reader { } } + /// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and + /// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number). + fn start_note_word(&mut self, word: &str) -> bool { + if self.st.dest == Dest::Body { + match word { + "ftnalt" => { + if let Some(sub) = self.sub.as_mut() { + sub.endnote = true; + } + return true; + } + "chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => { + let f = self.st.fmt.clone(); + self.push_mark('S', 0, f); + return true; + } + _ => {} + } + } + if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body { + return false; + } + self.start_sub(word == "annotation"); + true + } + fn start_header_footer_word(&mut self, word: &str) -> bool { if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf") || self.hf.is_some() @@ -398,6 +443,12 @@ impl Reader { self.picture_word(word, value); true } + Dest::Atn(kind) => { + if kind == 5 { + self.record_atn_part(word, value); + } + !matches!(word, "u" | "uc") + } Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true, Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false, Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => { @@ -421,6 +472,19 @@ impl Reader { } } + fn record_atn_part(&mut self, word: &str, value: i32) { + let v = value.clamp(0, 9999) as u32; + let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0)); + match word { + "yr" => c.0 = v, + "mo" => c.1 = v, + "dy" => c.2 = v, + "hr" => c.3 = v, + "min" => c.4 = v, + _ => {} + } + } + fn font_table_word(&mut self, word: &str, value: i32) { match word { "f" => { diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index fcecb910b5..9f12c9199b 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -879,6 +879,142 @@ fn exported_links_keep_only_safe_targets() { } } +// --------------------------------------------------------------------------------------------- +// RTF footnotes, endnotes and comments (#1053) + +/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body. +fn rtf_notes(d: &Document) -> (Vec, Vec) { + use wordcraft_doc::PartKind; + let story = |bl: &wordcraft_doc::Blocks| { + bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::>().join("|") + }; + let notes = d + .parts + .values() + .filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) + .map(|p| format!("{:?}:{}", p.kind, story(&p.blocks))) + .collect(); + let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect(); + (notes, comments) +} + +#[test] +fn rtf_footnote_and_endnote_round_trip() { + use wordcraft_doc::para::NoteKind; + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}} +\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta +gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}"; + let d = import("rtf", rtf).unwrap().unwrap(); + let body = d.body.iter().find_map(|b| b.as_para()).unwrap(); + let refs: Vec = + body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect(); + assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]); + assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta."); + let (notes, _) = rtf_notes(&d); + assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]); + // The note's own number sits at the start of its text, and its first words are bold. + let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap(); + let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap(); + assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. }))); + // Out and in again keeps both. + let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap(); + assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}"); + let d2 = import("rtf", out.as_bytes()).unwrap().unwrap(); + assert_eq!(rtf_notes(&d2).0, notes); + let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(body2.objects.len(), 2); + assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta."); + // The flow reader (no document) still just skips them. + let flow = crate::rtf::parse(rtf).unwrap(); + assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta.")); +} + +#[test] +fn rtf_comment_round_trip_with_author_and_date() { + let rtf = br"{\rtf1\ansi +\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par +\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}"; + let d = import("rtf", rtf).unwrap().unwrap(); + assert_eq!(d.comments.len(), 2); + let c = d.comments.values().next().unwrap(); + assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z")); + assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]); + let p = d.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.plain_text().trim(), "Before commented words after."); + // The range wraps exactly the commented words. + let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]); + assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. }))); + assert_eq!(p.text.get(s + 3..e), Some("commented words")); + let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap(); + assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}"); + let d2 = import("rtf", out.as_bytes()).unwrap().unwrap(); + assert_eq!(d2.comments.len(), 2); + let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap()); + assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date)); + assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]); + let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p2.objects.len(), 2); +} + +#[test] +fn rtf_hostile_notes_and_comments_do_not_panic() { + // Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting. + let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}"; + for n in 0..good.len() { + let _ = import("rtf", &good[..n]); + } + let junk: [&[u8]; 6] = [ + br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}", + br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}", + br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}", + br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}", + b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}", + b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}", + ]; + for j in junk { + let d = import("rtf", j).unwrap().unwrap(); + // Whatever came in, a second trip through RTF is fine too. + let out = export("rtf", &d).unwrap().unwrap(); + assert!(import("rtf", &out).unwrap().is_ok()); + } + // Many notes: bounded. + let mut many = String::from("{\\rtf1 "); + for _ in 0..6000 { + many.push_str("{\\footnote x}"); + } + many.push('}'); + let d = import("rtf", many.as_bytes()).unwrap().unwrap(); + assert!(d.parts.len() <= 5000); + // Private-use marker characters in ordinary text stay text. + let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap(); + assert!(d.parts.is_empty()); +} + +#[test] +fn rtf_notes_comments_tracked_changes_and_sections_work_together() { + // Two sections; the second holds a footnote, a comment and a tracked insertion, and has its + // own footer. A `\sect` inside the footnote's text must not end a section. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}} +\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect +\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}"; + let d = import("rtf", rtf).unwrap().unwrap(); + assert_eq!(d.sections().len(), 2); + let check = |d: &Document| { + let (notes, comments) = rtf_notes(d); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert!(notes[0].contains("Note text"), "{notes:?}"); + assert_eq!(comments.len(), 1, "{comments:?}"); + assert!(comments[0].contains("Remark"), "{comments:?}"); + let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added")); + assert!(ins); + }; + check(&d); + let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap(); + assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}"); + let d2 = import("rtf", out.as_bytes()).unwrap().unwrap(); + check(&d2); +} + /// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`, /// so the reader, which ends a bare destination at the first whitespace, reads it back whole. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f1b6f49283..612ed0538a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -4,7 +4,7 @@ > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously 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 (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); 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-08 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously 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 (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); 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 > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. > **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once. @@ -77,8 +77,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]); brackets and braces are drawn as their box, unfilled unless the file fills them (#5943). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Thin: comments, notes, RTL | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Footnotes, endnotes (`\footnote`, `\ftnalt`) and comments (`\atrfstart`/`\atrfend` ranges, `\annotation` with author, initials and date) are read and written (#1053; synthetic strings only, not compared with Word's RTF). Not kept: custom note marks, comment replies, notes in headers and footers. Thin: RTL | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Footnotes, endnotes (`\footnote`, `\ftnalt`) and comments (`\atrfstart`/`\atrfend` ranges, `\annotation` with author, initials and date) are read and written (#1053; synthetic strings only, not compared with Word's RTF). Not kept: custom note marks, comment replies, notes in headers and footers. Thin: RTL | | `.odt` OpenDocument Text (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema; what the standard leaves open is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)) | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. Placeholders (`text:placeholder`, §7.7.2, as LibreOffice's templates use: "") open as plain text content controls showing them, so a click selects one and typing replaces it, and are written back as placeholders of their type (their description as the control's title) while they show it. A master page followed by another (`style:next-style-name`: LibreOffice's "First Page" before "Default") is a section with a different first page, its own headers and footers on the first page and the next one's after it. The default paragraph style's font size (`fo:font-size` in `style:default-style`, or the Standard style's: the document's defaults) is the document's default size (it stayed 12 pt), a style with no parent keeping the default style's, and a date style ordered by the locale (`number:automatic-order`) shows in US English order ("November 2, 2026", as LibreOffice shows it here). A page background picture (`style:background-image` in the page layout, §17.3) is WordCraft's page picture (Design › Page Color › Fill Effects): repeated as tiled, stretched as stretched, not repeated as centred at its own size (a fill WordCraft draws so; DOCX writes it stretched), and is written back with its picture. Tables are fixed layout (Word's `w:tblLayout`), as LibreOffice's columns never fit their content, and a table starting a page (`fo:break-before` on the table) starts it: its first paragraph has a page break before it, which moves the table, and is written back on the table. The columns of a table filling the margins are written with their share of the table (`style:rel-column-width`) too, so on a narrower page they keep their proportions in LibreOffice (an aligned table's aren't: LibreOffice lays one out oddly with shares). A fixed date or time with neither a value nor text (LibreOffice's templates) is a date field showing the day; `text:author-name` is `USERNAME`, which WordCraft now fills with the user's name. A page layout that states no size is the default page's (the user's region's, as LibreOffice uses its default paper), and a margin it leaves unsaid is 0, XSL's initial value (a layout with no properties at all included); Emphasis marks (`style:text-emphasize`, §20.370) open as Word's (dot, accent as comma, circle, disc as dot; below as dot below) and are written back. Variables (`text:variable-set`, `text:variable-get`, §7.4) open as Word's SET and REF fields (a displayed set as SET then REF), which Update Field works out as LibreOffice shows them, and are written back declared (`text:variable-decls`), sets hidden with a get after them. What a named paragraph or character style turns off that the style it is based on turns on (`fo:font-weight="normal"` under a bold parent; italic, underline and strike-through too) is kept and written back, so it doesn't show through. Windows metafile pictures (WMF, EMF: LibreOffice's clip art and logos) open as pictures, at their own size when the frame gives none, instead of being dropped; picture frames are written derived from the Graphics style, without which LibreOffice doesn't show a floating picture inside a text box; drawings anchored outside any paragraph (LibreOffice's page-anchored frames before the first one) join the next paragraph instead of making one of their own, which started the document on the default page style, and a template of styles only (no `content.xml`) opens empty with its styles. OpenDocument text templates (`.ott`) open as documents and save as templates (the template's media type in `mimetype` and the manifest), with a password too. Turned text (`style:writing-mode`, §20.404; LibreOffice's own `loext:writing-mode="bt-lr"` for text running upwards) in table cells and text boxes opens as WordCraft's cell and text box text direction, down or up, and is written back the same way; turned characters (`style:text-rotation-angle`) open upright, as WordCraft has no turned characters. Package parts WordCraft doesn't read — LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`), `manifest.rdf`, other applications' files — are kept as they are and written back with their manifest entries, so LibreOffice still finds a document's macros (WordCraft never runs them); the old thumbnail and digital signatures (`META-INF/*signatures*.xml`, which sign the package as it was) are dropped on save, and paths that climb out of the package aren't written. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks are Word's `XE` fields — a mark at a point (`text:alphabetical-index-mark`, its `text:key1`/`text:key2` the levels above its entry) and one over text (`-mark-start`…`-mark-end` in one paragraph, its entry the marked text) — and `XE` fields are written as point marks, so References › Update Index rebuilds an ODT index from its entries (an index or other generated list held as a field around its entries, as ODT and Word files hold it, is first made the field's own paragraph, as WordCraft inserts one). Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), picture crops (`fo:clip`: top, right, bottom, left in lengths of the picture's own size, its pixels at 96 per inch ↔ WordCraft's crop fractions), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text, along its outline (`style:wrap-contour`: `outside` Word's Tight, `full` Through, its `draw:contour-polygon` the wrap polygon; a polygon in another coordinate system than the frame's, as LibreOffice writes for some pictures, is worked out from the picture instead) —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without; cell formulas — LibreOffice's `table:formula` in its own syntax, `ooow:sum ` — open as Word formula fields, `=SUM(A1:A3)`, showing the cell's value, so Update Field recalculates them, and formula fields in cells, `ABOVE`/`LEFT`/`BELOW`/`RIGHT` as the cells they name, are written back as cell formulas with their value; cells holding a plain number are written as numbers (`office:value-type="float"`) so formulas calculate with them; a formula one side can't say keeps its value as text), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it, and as that much more space after the paragraph, where LibreOffice leaves it after the last line) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`, to bookmarks kept over the text they mark — `text:bookmark-start`…`-end`, in a paragraph or across several — so a reference updates to that text) read as fields and written back as ODF fields; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering figures and tables (`text:sequence` ↔ `SEQ` with its number format, declared on save; a bookmark over the caption's label and number, which references to it show), chapter fields (`text:chapter` ↔ `STYLEREF "Heading n"`, by name or number), reference marks (as bookmarks) and references to them and to sequences (`text:reference-ref`, `text:sequence-ref` ↔ `REF`/`PAGEREF`); citations (`text:bibliography-mark`) are `CITATION` fields of the source each carries, which becomes one of the document's bibliography sources (its identifier the tag; its type, author, title, year, publisher, address, journal, volume, pages, URL, editor, edition, number and month), so a bibliography updates from them, and are written back as marks carrying their source; custom document properties (`meta:user-defined`: string, number, true/false and date) are WordCraft's custom document properties, read and written, and the fields showing one (`text:user-defined`) or a user field (`text:user-field-get`, its declaration a custom property too, so saved back as one) are `DOCPROPERTY` fields, which update from them and are written back as `text:user-defined`; fixed dates and fields without a Word equivalent (variables — `text:variable-set`/`-get`, whose value changes through the document —, references to a caption's text) read as their text, and Word fields ODF lacks written as their result, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); charts (a `draw:object` whose sub-document is an `office:chart`: column, bar, stacked, line, area, pie, doughnut and scatter charts; categories, series names and values — gaps included — from the chart's own table through their cell range addresses, title, legend position, data labels) open as WordCraft charts (in a drawing group too), drawn in the theme's colours and edited with Chart Design's commands, and every WordCraft chart is written back as an ODF chart object shaped like LibreOffice's (its data table, each series in its accent colour, slices clockwise from the top), in line or floating, inline in a flat document; a chart WordCraft can't edit as it is (3D, percent-stacked, stacked lines or areas, radar, stock or bubble charts, mixed plot types, a second value axis, one with no series) keeps its replacement picture, and fonts and axis scaling aren't kept; other embedded objects (`draw:object` sub-documents such as spreadsheets, drawings and presentations, and `draw:object-ole` OLE files) are kept whole — their files, media type and replacement picture — and saved back as the same objects, which LibreOffice opens as before; WordCraft shows one by its replacement picture, or, when that is in LibreOffice's own metafile format, by a placeholder of its size it draws itself (ruled for a spreadsheet), and doesn't edit or draw its contents; DOCX and other formats get the picture alone; in a flat document each object goes inline with its replacement picture — a sub-document as an `office:document` of its media type, its styles and settings with it, an OLE file as base64 — and is taken apart into its files again on opening; export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), page number format (the page style's, and a page number field's own as `PAGE \* Arabic`/`roman`/…), page colour, page borders (ODF's at the margin with the text inside their padding ↔ WordCraft's measured from the page edge), footnote and endnote numbering (`text:notes-configuration`: format, first number, restart, page foot or document end) and line numbering (`text:linenumbering-configuration`, on unless it says otherwise: every how many lines, distance, restart on each page), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes (`text:format-change`) as formatting revisions with their author and date — ODF records no old formatting, so the old is the text's own — and WordCraft's formatting revisions written as format changes; regions named by `xml:id` as ODF 1.3 requires; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out; comments (`office:annotation` with `office:annotation-end` ranges, author, date, initials, LibreOffice's resolved state and reply threads, `loext:resolved`/`loext:parent-name`) read and written, a comment whose end never comes covering nothing; comments in notes and in deleted text left out; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back; linked text boxes (`draw:chain-next-name` naming the frame the text continues in) stay linked both ways (a link to a missing box, to itself or round in a loop is dropped; a linked box keeps its own height where LibreOffice grows it to the rest of the text), keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drop caps (`style:drop-cap`: lines and distance; WordCraft drops one letter, so a longer drop cap opens as its first letter) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; where the styles say nothing, WordCraft's default fill `#156082` and a 0.25 pt line in `#0E405A`), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for (hexagon, octagon, smiley, can, moon, lightning, block arc, cross, pentagon, flowchart shapes and the rest) is drawn from its own path (`draw:enhanced-path`, its values worked out from the shape's `draw:equation` formulas and `draw:modifiers` adjustment values; arcs, ellipses and quarter ellipses followed in short straight steps) as a freeform, and only one with no path opens as a rectangle; a freeform with both closed and open paths is written as a custom shape whose path leaves the open ones unfilled (LibreOffice fills no part of a `draw:path` with an open part); shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors (`draw:connector`: straight, curved, or with bends) open as WordCraft connectors glued to the shapes their ends are glued to (a shape's four default glue points as its connection sites; a glue point of its own or a shape not read leaves that end unglued), placed between the shapes' sites when both are anchored in the connector's paragraph (a connector's own coordinates are LibreOffice's drawing layer's), and are written back glued, the shapes they glue to given ids, which LibreOffice re-routes; captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line frame or shape stands on the baseline, is centred on it or hangs from it as its `style:vertical-pos` relative to the baseline says (`top`, `middle`, `bottom`, or its top `svg:y` from it with `from-top`), read as its run's raise or lowering (Word's `w:position`, which WordCraft now applies to in-line pictures and shapes too) and written back as `from-top`; placement relative to the character or line stays on the baseline; a text box's frame derives from LibreOffice's Frame style, so LibreOffice draws its border | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) or a Single File Web Page (MHT) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`, `tests::a_single_file_web_page_saved_as_doc_opens_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | @@ -523,6 +523,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-10-16 | trivial | A `HYPERLINK` field reads as a link with its result as ordinary text, so a Word for the web table of contents keeps its tabs, dot leaders and page fields (#2174) | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | +| 2026-10-12 | trivial | RTF reads and writes footnotes, endnotes and comments with author, initials, date and commented range (#1053) | | 2026-10-18 | trivial | A Word 2003 XML or Flat OPC document, or a web page, saved under a `.doc`, `.docx` or `.rtf` name opens by what it holds, as in Word, instead of going to the Word 97-2003 reader and showing a blank page (#2853) | | 2026-10-18 | trivial | `w:sym`: the symbol's `w:font` is kept apart from the run font (ECMA-376 §17.3.3.30), so changing the text's font (or Clear Formatting) keeps Wingdings/Symbol characters showing, and they are written back as ``; Insert › Symbol in a symbol font does the same (#2036). A private-use character with no symbol font of its own in a non-symbol run is not guessed | | 2026-10-18 | trivial | Manage Sources imports Word's `Sources.xml` (`b:Sources`) into the document's bibliography sources, sharing the reader of a document's own bibliography part (#2640) | diff --git a/docs/gaps.md b/docs/gaps.md index 62b067883c..297d2f79e2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -7,7 +7,7 @@ > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (RTL paragraph direction in HTML/ODT/RTF implemented in #2695, pending merge); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (RTL paragraph direction in HTML/ODT/RTF implemented in #2695, pending merge); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -105,7 +105,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 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** (letters join across a change of formatting since #4093): kashida; RTL in HTML/ODT/RTF implemented in #2695 (pending merge; the estimate drops from 5–9 to 4–8 once it merges); 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); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [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 is WordCraft's one choice for each case ([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: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~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 and comments (done: RTF #1053), 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 is WordCraft's one choice for each case ([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: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~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; RTF notes and comments landed (#1053) | 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); repeated table headers are capped by item counts, the wrap cache by decoded pixels and linked text box layout by its lines, items and story text, and PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866; not measured on real documents) | Thesis and book authors | 10–20 | [layout-parity.md](layout-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); built-in citation refresh and first-cited bibliography ordering use temporary source and number indexes (#4888), retaining existing ordering and source metadata | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |