diff --git a/ROADMAP.md b/ROADMAP.md index 579117238..a44e19f04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: 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: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: 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: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt | | 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) | | 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. | | 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. | @@ -460,6 +461,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) | | 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | +| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | | 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. | diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index fc3642cd1..3dfb53ea9 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -930,6 +930,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { let (ins, del) = rev_marks(doc, &p.mark); (out.mark_ins, out.mark_del) = (ins.map(Box::new), del.map(Box::new)); } + // The paragraph that ends a section carries its properties (the last piece of it does). + out.section.clone_from(&p.section); done.push(out); done } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 7296d826d..ad77ce089 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -78,16 +78,31 @@ fn pt(v: f32) -> String { format!("{}pt", s.trim_end_matches('0').trim_end_matches('.')) } +/// A section that starts a page: the master page it uses and where page numbering restarts. +type SecStart = (String, Option); + #[derive(Default)] struct Writer { /// Automatic text styles: properties XML → name. text_styles: Vec<(String, String)>, - /// Automatic paragraph styles: (parent, properties XML) → name. - para_styles: Vec<((String, String), String)>, + /// Automatic paragraph styles: (parent, properties XML, master page) → name. + para_styles: Vec<((String, String, String), String)>, /// List styles: level kinds. list_styles: Vec<[bool; 9]>, /// Table styles: (name, column widths). tables: Vec>, + /// The master page (and page-number restart) each table in `tables` starts. + table_starts: Vec>, + /// Automatic styles of the columned `text:section`s written. + section_styles: Vec, + /// The page a section starts, for the next top-level block. + pending_start: Option, + /// The next top-level list continues the one that ended the previous section. + continue_list: bool, + /// `xml:id` of the last top-level list written, for `text:continue-list`. + last_list: Option, + /// The page the block being written starts. + master: Option, /// Cell styles by shading. cell_styles: Vec>, pictures: Vec<(String, std::sync::Arc>, String)>, @@ -169,6 +184,12 @@ impl Writer { } fn para_style(&mut self, p: &Para) -> String { + self.para_style_at(p, None) + } + + /// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3 + /// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322). + fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String { let parent = parent_style(p.kind).to_string(); let mut props = String::new(); match p.align { @@ -178,16 +199,21 @@ impl Writer { Some(Align::Left) => props.push_str(" fo:text-align=\"start\""), None => {} } - if p.page_break { + // The master page starts the page itself: no page break besides. + if p.page_break && start.is_none() { props.push_str(" fo:break-before=\"page\""); } + if let Some(n) = start.and_then(|(_, n)| *n) { + props.push_str(&format!(" style:page-number=\"{n}\"")); + } if p.kind == Kind::Rule { props.push_str(" fo:border-bottom=\"0.75pt solid #a0a0a0\" fo:padding-bottom=\"1pt\""); } - if props.is_empty() { + let master = start.map(|(m, _)| m.clone()).unwrap_or_default(); + if props.is_empty() && master.is_empty() { return parent; } - let key = (parent, props); + let key = (parent, props, master); if let Some((_, n)) = self.para_styles.iter().find(|(k, _)| *k == key) { return n.clone(); } @@ -401,9 +427,10 @@ impl Writer { /// is the first). fn para_in(&mut self, p: &Para, chain: Option<&Vec>, out: &mut String) { let paras: &[Para] = chain.map(|c| c.as_slice()).unwrap_or(std::slice::from_ref(p)); + let start = self.master.take(); self.chain = chain.map(|c| c.iter().map(|q| self.para_style(q)).collect()).unwrap_or_default(); self.part = 0; - let style = self.para_style(p); + let style = self.para_style_at(p, start.as_ref()); self.cur_style = style.clone(); let tag = match p.kind { Kind::Heading(n) => { @@ -447,6 +474,10 @@ impl Writer { let chain = |i: usize| chains.and_then(|c| c.get(&i)); let mut i = 0; while let Some(b) = blocks.get(i) { + // Only the first top-level block of a section starts its page. + if depth == 0 { + self.master = self.pending_start.take(); + } match b { FBlock::Table(t) => { if depth < model::MAX_DEPTH { @@ -494,7 +525,20 @@ impl Writer { *top = true; } if stack.is_empty() { - out.push_str(&format!("")); + let id = format!("lst{}", self.list_styles.len()); + // A list cut by a section boundary goes on numbering: continue-list names the + // preceding list by its xml:id, whatever its style name (ODF 1.3 §19.743). + let prev = self.last_list.clone(); + let cont = match prev { + Some(prev) if depth == 0 && start == 0 && std::mem::take(&mut self.continue_list) => { + format!(" text:continue-list=\"{prev}\"") + } + _ => String::new(), + }; + if depth == 0 { + self.last_list = Some(id.clone()); + } + out.push_str(&format!("")); } else { out.push_str(""); } @@ -546,6 +590,7 @@ impl Writer { vec![468.0 / cols as f32; cols] }; self.tables.push(widths); + self.table_starts.push(self.master.take()); let tn = self.tables.len(); out.push_str(&format!("")); for c in 0..cols { @@ -602,9 +647,10 @@ impl Writer { for (props, n) in &self.text_styles { s.push_str(&format!("")); } - for ((parent, props), n) in &self.para_styles { + for ((parent, props, master), n) in &self.para_styles { + let master = if master.is_empty() { String::new() } else { format!(" style:master-page-name=\"{}\"", x(master)) }; s.push_str(&format!( - "" + "" )); } for (i, kinds) in self.list_styles.iter().enumerate() { @@ -631,8 +677,14 @@ impl Writer { for (i, widths) in self.tables.iter().enumerate() { let tn = i + 1; let total: f32 = widths.iter().sum(); + let (master, number) = match self.table_starts.get(i).and_then(Option::as_ref) { + Some((m, n)) => { + (format!(" style:master-page-name=\"{}\"", x(m)), n.map(|n| format!(" style:page-number=\"{n}\"")).unwrap_or_default()) + } + None => (String::new(), String::new()), + }; s.push_str(&format!( - "", + "", pt(total) )); for (c, w) in widths.iter().enumerate() { @@ -650,6 +702,9 @@ impl Writer { i + 1 )); } + for st in &self.section_styles { + s.push_str(st); + } s.push_str(""); s } @@ -761,7 +816,15 @@ fn now_unix() -> i64 { } } -fn styles_xml(sec: &wordcraft_doc::SectionProps, body_font: &str) -> String { +/// `style:columns` (ODF 1.3 §17.12) of equal columns with their gap and, when on, the line between. +fn columns_xml(c: &Columns) -> String { + let sep = if c.separator { "" } else { "" }; + format!("{sep}", c.count.clamp(1, 45), pt(c.space)) +} + +/// One master page per entry of `pages` (name, setup), each with its own page layout `pm1`, `pm2`… +/// (§16.5, §16.9); the first is the default one, "Standard". +fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str) -> String { let mut s = format!("\n"); s.push_str(&format!( "", @@ -781,17 +844,26 @@ fn styles_xml(sec: &wordcraft_doc::SectionProps, body_font: &str) -> String { s.push_str(""); s.push_str(&format!("", model::MONO_FONT)); s.push_str(""); - s.push_str(&format!( - "", - pt(sec.page_w), - pt(sec.page_h), - pt(sec.margin_top), - pt(sec.margin_bottom), - pt(sec.margin_left), - pt(sec.margin_right), - if sec.page_w > sec.page_h { "landscape" } else { "portrait" } - )); - s.push_str("\n"); + for (i, (_, sec)) in pages.iter().enumerate() { + // A page of several columns carries them in its layout. + let cols = if sec.columns.count > 1 { columns_xml(&sec.columns) } else { String::new() }; + s.push_str(&format!( + "{cols}", + i + 1, + pt(sec.page_w), + pt(sec.page_h), + pt(sec.margin_top), + pt(sec.margin_bottom), + pt(sec.margin_left), + pt(sec.margin_right), + if sec.page_w > sec.page_h { "landscape" } else { "portrait" } + )); + } + s.push_str(""); + for (i, (name, _)) in pages.iter().enumerate() { + s.push_str(&format!("", x(name), i + 1)); + } + s.push_str("\n"); s } @@ -820,12 +892,73 @@ fn meta_xml(m: &Meta) -> String { s } +/// The sections of a flow: each one's setup and the top-level blocks it holds. A paragraph that +/// carries a section's properties ends it; the document's last section follows the last such +/// paragraph (and is left out when nothing follows it, unless it is the only one). +fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, std::ops::Range)> { + let mut out = Vec::new(); + let mut from = 0; + for (i, b) in blocks.iter().enumerate() { + if let FBlock::Para(p) = b + && let Some(s) = &p.section + { + out.push(((**s).clone(), from..i + 1)); + from = i + 1; + } + } + if from < blocks.len() || out.is_empty() { + out.push((last.clone(), from..blocks.len())); + } + out +} + /// Write an `.odt` package. pub fn export(doc: &Document) -> Result, String> { let flow = model::from_doc_tracked(doc); let mut w = Writer::default(); let mut body = String::new(); - w.blocks(&flow.blocks, &mut body, 0); + let sections = flow_sections(&flow.blocks, &doc.last_section); + // One master page per section that starts a page; the first one is "Standard". + let mut pages: Vec<(String, SectionProps)> = Vec::new(); + // The columns of the page in force: a continuous section with others is a `text:section`. + let mut page_cols = Columns::default(); + for (k, (sec, range)) in sections.iter().enumerate() { + let new_page = k == 0 || sec.start != SectionStart::Continuous; + if new_page { + let name = if k == 0 { "Standard".to_string() } else { format!("Page{}", pages.len() + 1) }; + pages.push((name.clone(), sec.clone())); + page_cols = sec.columns.clone(); + // The first section needs its paragraph to name the page only to restart numbering. + if k > 0 || sec.page_num_start.is_some() { + w.pending_start = Some((name, sec.page_num_start)); + } + } + // A continuous section whose columns differ from the page's is a `text:section` (§5.4) + // with them (one column included). A restart of page numbering isn't written for it: ODF + // applies `style:page-number` only together with a master page. + let wrapped = k > 0 + && sec.start == SectionStart::Continuous + && (sec.columns.count != page_cols.count || (sec.columns.count > 1 && sec.columns != page_cols)); + // A list cut at the boundary goes on numbering in the new section. + let list_at = |b: Option<&FBlock>| matches!(b, Some(FBlock::Para(p)) if p.list.is_some()); + w.continue_list = k > 0 && list_at(flow.blocks.get(range.start)) && list_at(range.start.checked_sub(1).and_then(|i| flow.blocks.get(i))); + if wrapped { + w.close_ins(&mut body); + let n = w.section_styles.len() + 1; + w.section_styles.push(format!( + "{}", + columns_xml(&sec.columns) + )); + body.push_str(&format!("")); + } + w.blocks(flow.blocks.get(range.clone()).unwrap_or(&[]), &mut body, 0); + w.pending_start = None; + w.continue_list = false; + if wrapped { + w.close_ins(&mut body); + body.push_str("\n"); + } + } w.close_ins(&mut body); let tracked = if w.regions.is_empty() && !doc.settings.track_changes { String::new() @@ -837,7 +970,7 @@ pub fn export(doc: &Document) -> Result, String> { w.automatic_styles() ); let body_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); - let styles = styles_xml(&doc.last_section, &body_font); + let styles = styles_xml(&pages, &body_font); let meta = meta_xml(&flow.meta); let mut manifest = String::from( "\n\n\n\n\n\n", @@ -1769,7 +1902,11 @@ impl Body<'_> { self.end_para(); if self.cols_at.is_none() && self.in_body() - && let Some(columns) = get(a, "text:style-name").and_then(|s| self.styles.columns(s)).filter(|c| c.count > 1) + && let Some(columns) = get(a, "text:style-name") + .and_then(|s| self.styles.columns(s)) + // A section whose columns match the open section's (a plain one-column + // section in a one-column page) is no new section. + .filter(|c| c.count != self.sec.columns.count || (c.count > 1 && *c != self.sec.columns)) { let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, columns, ..self.page.clone() }; self.break_section(next); @@ -2646,6 +2783,157 @@ mod tests { assert_eq!(text(secs[3].0), "Last line"); } + /// (start, landscape, width, height, columns, restart, margins) of each section, and the + /// text of the paragraph ending it. + fn section_summary(doc: &Document) -> Vec<(wordcraft_doc::section::SectionStart, bool, f32, f32, u32, bool, Option, [f32; 4], String)> { + doc.sections() + .iter() + .map(|(end, s)| { + let text = match doc.body.get(*end).map(|b| &**b) { + Some(wordcraft_doc::Block::Para(p)) => p.plain_text().to_string(), + _ => String::new(), + }; + ( + s.start, + s.landscape, + s.page_w.round(), + s.page_h.round(), + s.columns.count, + s.columns.separator, + s.page_num_start, + [s.margin_top, s.margin_bottom, s.margin_left, s.margin_right].map(f32::round), + text, + ) + }) + .collect() + } + + #[test] + fn export_writes_sections_that_import_back() { + // Portrait; landscape on a new page restarting at 1; continuous two columns with a + // separator; portrait again. Export, import: the same sections. + let doc = import(§ions_package()).unwrap(); + let before = section_summary(&doc); + assert_eq!(before.len(), 4, "{before:?}"); + let back = import(&export(&doc).unwrap()).unwrap(); + let after = section_summary(&back); + assert_eq!(after, before); + let (a, b) = (&doc.sections(), &back.sections()); + for (x, y) in a.iter().zip(b.iter()) { + assert!((x.1.columns.space - y.1.columns.space).abs() < 0.1, "{:?} vs {:?}", x.1.columns, y.1.columns); + assert!((x.1.margin_top - y.1.margin_top).abs() < 0.1 && (x.1.margin_right - y.1.margin_right).abs() < 0.1); + } + } + + #[test] + fn export_starts_a_section_at_a_table_and_keeps_its_restart() { + // A section that starts with a table names its master page on the table's style. + let styles = format!( + r#""# + ); + let content = format!( + r#"IntroCellAfter"# + ); + let doc = import(&package(&content, Some(&styles))).unwrap(); + let before = section_summary(&doc); + assert_eq!(before.len(), 2, "{before:?}"); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(section_summary(&back), before); + assert_eq!(back.sections()[1].1.page_num_start, Some(5)); + } + + /// Master pages `Standard` (portrait) and `Cols` (landscape, two columns with a line) for the + /// round-trip tests below. + fn cols_styles() -> String { + format!( + r#""# + ) + } + + /// Import `body` (with automatic `styles`) over [`cols_styles`], export, import: both summaries. + fn round_trip(styles: &str, body: &str) -> (Vec, Vec) { + let content = format!( + r#"{styles}{body}"# + ); + let doc = import(&package(&content, Some(&cols_styles()))).unwrap(); + let back = import(&export(&doc).unwrap()).unwrap(); + let show = |d: &Document| section_summary(d).iter().map(|s| format!("{s:?}")).collect::>(); + (show(&doc), show(&back)) + } + + #[test] + fn export_keeps_a_columned_page_and_the_one_column_part_after_it() { + let styles = r#""#; + let body = r#"IntroTwo columnsOne column"#; + let (before, after) = round_trip(styles, body); + assert_eq!(before.len(), 3, "{before:?}"); + assert!(before[1].contains(", 2, true,") && before[2].contains("Continuous") && before[2].contains(", 1, false,"), "{before:?}"); + assert_eq!(after, before); + } + + #[test] + fn plain_one_column_text_sections_do_not_make_sections() { + let styles = r#""#; + let body = r#"ABC"#; + let (before, after) = round_trip(styles, body); + assert_eq!(before.len(), 1, "{before:?}"); + assert_eq!(after, before); + } + + #[test] + fn export_keeps_a_section_that_starts_on_a_list_item() { + let styles = r#""#; + let body = r#"IntroFirstSecondAfter"#; + let (before, after) = round_trip(styles, body); + assert_eq!(before.len(), 2, "{before:?}"); + assert_eq!(after, before); + } + + #[test] + fn a_list_cut_at_a_section_boundary_goes_on_numbering() { + let styles = r#""#; + let body = r#"OneTwo"#; + let content = format!( + r#"{styles}{body}"# + ); + let doc = import(&package(&content, Some(&cols_styles()))).unwrap(); + let bytes = export(&doc).unwrap(); + let xml = String::from_utf8(read_entry(&mut archive(&bytes), "content.xml").unwrap()).unwrap(); + assert_eq!(xml.matches("text:continue-list=\"").count(), 1, "{xml}"); + // The continuation points at the first list's xml:id. + assert!(xml.contains("xml:id=\"lst1\"") && xml.contains("text:continue-list=\"lst1\""), "{xml}"); + assert_eq!(section_summary(&import(&bytes).unwrap()), section_summary(&doc)); + } + + #[test] + fn export_keeps_a_page_number_restart_on_the_first_section() { + let styles = r#""#; + let body = r#"FirstSecond"#; + let (before, after) = round_trip(styles, body); + assert_eq!(before.len(), 1, "{before:?}"); + assert!(before[0].contains("Some(7)"), "{before:?}"); + assert_eq!(after, before); + } + + #[test] + fn export_of_a_one_section_document_stays_simple() { + let doc = import(§ions_package()).unwrap(); + let one = import(&export(&doc).unwrap()).unwrap(); + let mut single = one.clone(); + for b in single.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.section = None; + } + } + let bytes = export(&single).unwrap(); + let mut z = archive(&bytes); + let styles = String::from_utf8(read_entry(&mut z, "styles.xml").unwrap()).unwrap(); + let content = String::from_utf8(read_entry(&mut z, "content.xml").unwrap()).unwrap(); + assert_eq!(styles.matches(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** 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-10-18 · **Change:** trivial (ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -41,8 +41,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]). 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). | | 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); 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 | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), 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 read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or 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). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. 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; paragraph-mark and formatting revisions 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 still writes one section. Thin: comments, notes, section export, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), 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); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, 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). 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). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). 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) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -223,6 +223,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | ODT export writes sections: each new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is a columned `text:section` (gap 19) | | 2026-10-17 | trivial | Paste: an HTML `
` (LibreOffice's both-sides indent) pastes as a Normal paragraph with its margins as indents, not the centred italic Quote style; opening .html unchanged (#1270) | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | | 2026-10-16 | trivial | Cached textual `REF` results with `\h` export links to their bookmarks. Code view and existing enclosing hyperlinks remain unchanged; unresolved bookmarks emit no PDF link. | diff --git a/docs/gaps.md b/docs/gaps.md index bc1697303..eaf11db00 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-18 · **Change:** 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-10-18 · **Change:** 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -41,7 +41,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export left; ODT export done), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [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; 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) | | 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, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | @@ -94,6 +94,7 @@ The Language dialog reads effective character formatting, including document def | 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain | | 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | +| 2026-10-14 | trivial | #19 narrowed: ODT export done (master page and page layout per section, columned `text:section`s) | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | | 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |