From f0c1b64bc8569a3f6cc61035a5473b86b0c1ae87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:00:56 +0200 Subject: [PATCH 1/3] RTF: footnotes, endnotes and comments are read and written (#1053) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/formats/src/rtf.rs | 587 +++++++++++++++++++++++++++++++++++- crates/formats/src/tests.rs | 111 +++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 5 files changed, 689 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c331514cf3..42981f4ba3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 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-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 31320221fe..c38bdf9c34 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -4,20 +4,27 @@ //! 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`) or the //! current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek…; Windows-1252 when //! none is named), 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::{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, make_img}; @@ -25,6 +32,15 @@ use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo const MAX_GROUPS: usize = 512; /// Largest picture we decode (bytes). const MAX_PICT: usize = 64 << 20; +/// Most notes, and most comments, one file may bring in. +const MAX_NOTES: usize = 5_000; +/// Most blocks kept of one note's or comment's text. +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. +const MARK_OPEN: char = '\u{F8F0}'; +const MARK_CLOSE: char = '\u{F8F1}'; // --------------------------------------------------------------------------------------------- // Export @@ -83,7 +99,30 @@ impl Tables { } } +/// What a place marker in the exported text stands for (see [`MARK_OPEN`]). +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, +} + +struct Annotation { + author: String, + initials: String, + date: String, + blocks: Vec, +} + struct Writer { + marks: Vec, t: Tables, /// List definitions: per list, the ordered flag per level. lists: Vec<[bool; 9]>, @@ -102,7 +141,68 @@ fn style_num(k: Kind) -> Option { } impl Writer { + /// 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 f.bold { ctl.push_str("\\b"); @@ -388,6 +488,11 @@ fn level_def(ordered: bool, l: usize) -> String { } } +/// An ISO 8601 date-time from year, month, day, hour and minute. +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)) +} + 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)?); @@ -395,11 +500,131 @@ fn rtf_date(s: &str) -> Option { Some(format!("\\yr{y}\\mo{mo}\\dy{d}\\hr{h}\\min{mi}")) } +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 }) + } +} + /// 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(doc); let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); - let mut w = Writer { t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new() }; + let mut w = Writer { marks, t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new() }; let mut body = String::new(); w.blocks(&flow.blocks, false, "\\par", &mut body, 0); export_with(&flow.meta, &w, &body, &doc.last_section) @@ -480,6 +705,32 @@ 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), +} + +/// A footnote, endnote or comment group being read into an output of its own. +struct Sub { + depth: usize, + comment: bool, + endnote: bool, + saved: Box, +} + +/// A note read: its kind and text. +struct NoteData { + endnote: bool, + blocks: Vec, +} + +/// A comment read: the id its range markers carry, who wrote it and when, and its text. +struct CommentData { + rtf_id: i32, + author: String, + initials: String, + date: String, + blocks: Vec, } /// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages @@ -708,6 +959,22 @@ struct Reader { in_creatim: bool, rule_pending: bool, info_depth: Option, + // Notes and comments (only when the caller wants them). + marks_on: bool, + sub: Option, + notes: Vec, + comments: Vec, + atn_text: String, + atn_initials: String, + atn_author: String, + atn_date: String, + atn_parts: Option<(u32, u32, u32, u32, u32)>, + atn_ref: Option, + last_end: Option, + range_starts: HashSet, + range_ends: HashSet, + claimed: HashSet, + synth: i32, } impl Reader { @@ -769,6 +1036,97 @@ impl Reader { } } + /// A footnote, endnote or comment group starts: read it into an output of its own. + 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. + 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. + 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`…). + 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, + _ => {} + } + } + fn open(&mut self) { self.skip_chars = 0; if self.stack.len() >= MAX_GROUPS { @@ -784,6 +1142,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 { @@ -801,6 +1162,7 @@ impl Reader { } self.in_creatim = false; } + Dest::Atn(k) => self.commit_atn(k), _ => {} } let Some(prev) = self.stack.pop() else { return }; @@ -873,6 +1235,11 @@ impl Reader { return; } let 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; + } self.para.push_text(s, &f); self.para_has = true; } @@ -906,6 +1273,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 @@ -1147,6 +1519,26 @@ impl Reader { "shppict" => return, "pict" => Some(Dest::Pict), "bkmkstart" => Some(Dest::Bookmark), + "footnote" | "annotation" if self.marks_on && self.hf.is_none() && self.sub.is_none() && self.st.dest == Dest::Body => { + self.start_sub(w == "annotation"); + return; + } + "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" if self.hf.is_none() && self.st.dest == Dest::Body && !self.st.intbl => { @@ -1309,6 +1701,23 @@ impl Reader { } return; } + Dest::Atn(k) => { + if k == 5 { + let v = pv.clamp(0, 9999) as u32; + let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0)); + match w { + "yr" => c.0 = v, + "mo" => c.1 = v, + "dy" => c.2 = v, + "hr" => c.3 = v, + "min" => c.4 = v, + _ => {} + } + } + if !matches!(w, "u" | "uc") { + return; + } + } // Text destinations take Unicode escapes (with fallback skipping) like the body. Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(w, "u" | "uc") => return, Dest::Info(_) | Dest::FldInst | Dest::Bookmark => {} @@ -1492,6 +1901,17 @@ impl Reader { self.defs.push(d); } } + // A footnote group is an endnote when it says so. + "ftnalt" => { + if let Some(sub) = self.sub.as_mut() { + sub.endnote = true; + } + } + // The note's own number, at the start of its text. + "chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => { + let f = self.st.fmt.clone(); + self.push_mark('S', 0, f); + } "field" => self.start_field(), "fldrslt" => { let link = self.st.field.and_then(|i| self.fields.get(i)).and_then(|s| hyperlink(s)); @@ -1543,7 +1963,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. @@ -1553,14 +1973,18 @@ struct Parsed { default_font: Option, headers_footers: Vec<(HeaderFooter, Vec)>, title_page: bool, + 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(); + let mut r = Reader { marks_on: marks, ..Reader::default() }; let mut i = start; let mut first_in_group = false; let mut starred = false; @@ -1630,7 +2054,7 @@ fn parse_full(bytes: &[u8]) -> Result { r.info_depth = Some(r.depth()); } if r.skip_chars > 0 - && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark) + && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::Atn(_)) && !matches!(word, "par" | "pard" | "cell" | "row") { r.skip_chars -= 1; @@ -1710,7 +2134,8 @@ fn parse_full(bytes: &[u8]) -> Result { } } } - // A header or footer left open by a truncated file ends here. + // A note, comment, header or footer left open by a truncated file ends here. + r.end_sub(); r.end_header_footer(); if r.para_has || !r.para.inlines.is_empty() { r.end_para(false); @@ -1726,7 +2151,15 @@ fn parse_full(bytes: &[u8]) -> Result { blocks.push(FBlock::Para(Para::default())); } let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned(); - Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page }) + Ok(Parsed { + flow: Flow { blocks, meta: r.meta }, + page: r.page, + default_font, + headers_footers: r.hf_parts, + title_page: r.title_page, + notes: r.notes, + comments: r.comments, + }) } /// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid @@ -1768,7 +2201,7 @@ fn column_grid(rows: &mut [Vec], rights: &[Vec]) -> Vec { /// Parse RTF bytes into a document. pub fn import(bytes: &[u8]) -> Result { - let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?; + let Parsed { flow, page, default_font, headers_footers, title_page, notes, comments } = parse_full(bytes, true)?; let mut doc = model::to_doc_in(&flow, page.section()); for (which, blocks) in &headers_footers { let blocks = model::part_blocks(&mut doc, blocks); @@ -1781,6 +2214,7 @@ pub fn import(bytes: &[u8]) -> Result { } } doc.last_section.title_page = title_page; + 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 doc.styles.default_para; @@ -1793,6 +2227,129 @@ 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 }); + 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; + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index d4a8190025..99d1a53a5e 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -662,3 +662,114 @@ fn exported_links_keep_only_safe_targets() { assert!(!md.contains(&format!("[{text}](")), "md: {md}"); } } + +// --------------------------------------------------------------------------------------------- +// 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()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 200f259e22..2a73f58200 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** 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-10-12 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -25,7 +25,7 @@ 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` (56) | 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]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. 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. 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). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, 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). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). 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: revisions, sections, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.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`. 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 | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). 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) | @@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | RTF reads and writes footnotes, endnotes and comments with author, initials, date and commented range (#1053) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | diff --git a/docs/gaps.md b/docs/gaps.md index 04a985cc91..b9bceaa445 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** 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-10-12 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-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; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: revisions, sections, RTL (RTF footnotes, endnotes and comments landed, #1053) | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | 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 | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | From 2a82cc4a22376ada3f07bf9e2387f0b2a1d344b3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 02:20:51 +0100 Subject: [PATCH 2/3] Fix test layout after merging upstream/main Co-Authored-By: Claude Sonnet 5.5 --- crates/formats/src/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index a86002f414..870d0b0eee 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -1001,7 +1001,6 @@ fn rtf_notes_comments_tracked_changes_and_sections_work_together() { 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 `<…>`, From 05a37f6fe2924fa19e32382688da2c1aa74cacd1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 29 Oct 2026 22:24:10 +0100 Subject: [PATCH 3/3] Apply cargo fmt Co-Authored-By: Claude Sonnet 5.5 --- crates/formats/src/rtf/export.rs | 2 +- crates/formats/src/rtf/parse.rs | 10 +--------- crates/formats/src/rtf/reader.rs | 5 ++++- crates/formats/src/rtf/word.rs | 7 +------ 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 0a41037090..e86af5ac0a 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -1,6 +1,6 @@ use super::date::{iso_to_dttm, rtf_date}; -use super::styles::{style_fmt, styled}; use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS}; +use super::styles::{style_fmt, styled}; use super::{ Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model, }; diff --git a/crates/formats/src/rtf/parse.rs b/crates/formats/src/rtf/parse.rs index 60f9e1cd21..69ed99b4b3 100644 --- a/crates/formats/src/rtf/parse.rs +++ b/crates/formats/src/rtf/parse.rs @@ -235,15 +235,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, - notes: r.notes, - comments: r.comments, - } + 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 diff --git a/crates/formats/src/rtf/reader.rs b/crates/formats/src/rtf/reader.rs index 95c5e4f25e..61630b22b1 100644 --- a/crates/formats/src/rtf/reader.rs +++ b/crates/formats/src/rtf/reader.rs @@ -1,6 +1,9 @@ use super::date::{dttm_to_iso, iso_date}; use super::parse::column_grid; -use super::state::{CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, NoteData, Sub, 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, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model}; diff --git a/crates/formats/src/rtf/word.rs b/crates/formats/src/rtf/word.rs index f81da6a683..dbe91d0896 100644 --- a/crates/formats/src/rtf/word.rs +++ b/crates/formats/src/rtf/word.rs @@ -336,12 +336,7 @@ impl Reader { _ => {} } } - if !matches!(word, "footnote" | "annotation") - || !self.marks_on - || self.hf.is_some() - || self.sub.is_some() - || self.st.dest != Dest::Body - { + 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");