From 09921dfae8555ee396c4f66bb0ec23953f22b3ac Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 20:00:53 +0200 Subject: [PATCH] Right-to-left tables and sections (#362) (#365) Tables marked right to left (w:bidiVisual) now lay out mirrored: the first column stands at the right edge, start alignment and indent are measured from the right margin, and each cell's start margin and border sit on its right. Merged cells, turned text and nested tables follow; clicks and the caret follow the drawn geometry while Tab keeps the logical cell order. Right-to-left sections (w:bidi) run their newspaper columns from the right, with the line between columns mirrored. The table direction is read and written in .docx (section direction already round-tripped). New commands table.rtl and layout.sectionRtl, a "Table direction" choice in Table Properties ("rtl" in table.properties) and a "Section direction" choice in Page Setup, translated in every catalog. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/props.rs | 5 +++ crates/doc/src/section.rs | 34 ++++++++++---- crates/docx/src/read/props.rs | 1 + crates/docx/src/write/props.rs | 3 ++ crates/docx/tests/bidi.rs | 36 +++++++++++++++ crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/page.rs | 5 +++ crates/engine/src/cmd/table.rs | 31 +++++++++++-- crates/engine/src/tests.rs | 37 +++++++++++++++ crates/layout/src/lib.rs | 6 ++- crates/layout/src/table.rs | 38 +++++++++++----- crates/layout/src/tests_bidi.rs | 70 +++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 25 ++++++++++- crates/ui-egui/src/i18n/es.tsv | 4 ++ crates/ui-egui/src/i18n/et.tsv | 4 ++ crates/ui-egui/src/i18n/ja.tsv | 4 ++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++ crates/ui-egui/src/i18n/ru.tsv | 4 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 4 ++ crates/ui-egui/src/i18n/sr.tsv | 4 ++ crates/ui-egui/src/i18n/tr.tsv | 4 ++ crates/ui-egui/src/i18n/uk.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++ crates/ui-egui/src/lib.rs | 5 +++ docs/file-format-parity.md | 5 ++- docs/gaps.md | 3 +- docs/layout-parity.md | 3 +- docs/parity-checklist.md | 6 +-- 29 files changed, 327 insertions(+), 32 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 60e10dcfc..780b7c239 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -812,6 +812,11 @@ pub struct TableProps { /// Tracked formatting change (`w:tblPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, + /// Right-to-left table (`w:bidiVisual`): the first logical column stands at the right edge, + /// cell margins and left/right borders mirror, and the alignment and indent are measured from + /// the right margin. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub bidi_visual: bool, } /// Where a floating table sits; the text after it wraps around it. diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 9fc1dedca..ad59fdee3 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -359,14 +359,15 @@ impl SectionProps { self.margin_top = r; } } - /// Column (x offset from the text area's left, width) for each column. + /// Column (x offset from the text area's left, width) for each column, in reading order: + /// in a right-to-left section (`rtl`, `w:bidi`) the first column stands at the right edge + /// and the columns (with the space after each) run leftwards. pub fn column_boxes(&self) -> Vec<(f32, f32)> { let tw = self.text_width(); let n = self.columns.count.clamp(1, 45) as usize; - if !self.columns.widths.is_empty() && self.columns.widths.len() == n { + let boxes: Vec<(f32, f32)> = if !self.columns.widths.is_empty() && self.columns.widths.len() == n { let mut x = 0.0; - return self - .columns + self.columns .widths .iter() .map(|(w, sp)| { @@ -374,11 +375,13 @@ impl SectionProps { x += w + sp; b }) - .collect(); - } - let space = self.columns.space.max(0.0); - let w = ((tw - space * (n as f32 - 1.0)) / n as f32).max(18.0); - (0..n).map(|i| (i as f32 * (w + space), w)).collect() + .collect() + } else { + let space = self.columns.space.max(0.0); + let w = ((tw - space * (n as f32 - 1.0)) / n as f32).max(18.0); + (0..n).map(|i| (i as f32 * (w + space), w)).collect() + }; + if self.rtl { boxes.into_iter().map(|(x, w)| (tw - x - w, w)).collect() } else { boxes } } } @@ -421,4 +424,17 @@ mod tests { assert_eq!(b[0], (0.0, 216.0)); assert_eq!(b[1], (252.0, 216.0)); } + + #[test] + fn rtl_columns_run_right_to_left() { + let s = SectionProps { rtl: true, columns: Columns { count: 2, space: 36.0, ..Default::default() }, ..Default::default() }; + assert_eq!(s.column_boxes(), vec![(252.0, 216.0), (0.0, 216.0)]); + let s = SectionProps { + rtl: true, + columns: Columns { count: 2, widths: vec![(100.0, 20.0), (200.0, 0.0)], ..Default::default() }, + ..Default::default() + }; + // Text width 468: the first column hugs the right edge, the second sits 20pt to its left. + assert_eq!(s.column_boxes(), vec![(368.0, 100.0), (148.0, 200.0)]); + } } diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 4f3c9678f..0fba7bb96 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -304,6 +304,7 @@ impl PropCtx { "w:tblBorders" => t.borders = Some(borders(k)), "w:shd" => t.shading = shd_fill(k), "w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"), + "w:bidiVisual" => t.bidi_visual = on_off(k), "w:tblCellMar" => t.cell_margins = Some(margins(k)), "w:tblLook" => t.look = look(k), "w:tblpPr" => t.float = Some(table_float(k)), diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 18ef59cf1..cea14127f 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -360,6 +360,9 @@ fn tblpr_inner(w: &mut W, t: &TableProps) { if let Some(f) = &t.float { table_float(w, f); } + if t.bidi_visual { + w.empty("w:bidiVisual", &[]); + } if let Some(p) = t.width_pct { w.empty("w:tblW", &[("w:w", &n(round(p.clamp(0.0, 1000.0) * 50.0))), ("w:type", "pct")]); } else if let Some(v) = t.width { diff --git a/crates/docx/tests/bidi.rs b/crates/docx/tests/bidi.rs index d0719b5da..ede391b8d 100644 --- a/crates/docx/tests/bidi.rs +++ b/crates/docx/tests/bidi.rs @@ -139,3 +139,39 @@ fn hostile_bidi_markup_is_bounded() { assert_eq!(c.lang_bidi, None); assert!(c.size_cs.is_none_or(|s| (1.0..=1638.0).contains(&s))); } + +/// Right-to-left tables (`w:tblPr/w:bidiVisual`, ECMA-376 Part 1 §17.4.1) and sections +/// (`w:sectPr/w:bidi`, §17.6.1) read, write in schema order and round-trip (#362). +#[test] +fn rtl_tables_and_sections_round_trip() { + let body = r#" + + + + اولدوم + + + + + + + +"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let tables: Vec<&wordcraft_doc::Table> = d.body.iter().filter_map(|b| b.as_table()).collect(); + assert_eq!(tables.len(), 2); + assert!(tables[0].props.bidi_visual, "w:bidiVisual reads as a right-to-left table"); + assert_eq!(tables[0].props.indent, Some(18.0), "the indent is kept (measured from the right)"); + assert!(!tables[1].props.bidi_visual, "w:val=0 turns it off"); + assert!(d.last_section.rtl, "w:bidi in sectPr is a right-to-left section"); + let bytes = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&bytes); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + // CT_TblPr is a sequence: tblStyle, tblpPr, tblOverlap, bidiVisual, … tblW, jc, … tblInd. + let pos = |t: &str| xml.find(t).unwrap_or_else(|| panic!("{t} missing: {xml}")); + assert!(pos("") < pos(" = r.body.iter().filter_map(|b| b.as_table()).map(|t| t.props.bidi_visual).collect(); + assert_eq!(again, vec![true, false]); + assert!(r.last_section.rtl && r.last_section.columns.count == 2); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3b254cc62..5fb2ad70a 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -217,6 +217,7 @@ Layout|Page Setup|Line Numbers|layout.lineNumbers Layout|Page Setup|Hyphenation|layout.hyphenation Layout|Page Setup|Page Setup Dialog|layout.pageSetup Layout|Page Setup|Vertical Alignment|layout.verticalAlign +Layout|Page Setup|Section Direction|layout.sectionRtl Layout|Paragraph|Indent|para.indents Layout|Paragraph|Spacing|para.spacing Layout|Arrange|Position|arrange.position @@ -340,6 +341,7 @@ Table Layout|Table|Select Row|table.selectRow Table Layout|Table|Select Cell|table.selectCell Table Layout|Table|View Gridlines|table.viewGridlines Table Layout|Table|Properties|table.properties +Table Layout|Table|Table Direction|table.rtl Table Layout|Draw|Draw Table|table.draw Table Layout|Draw|Eraser|table.eraser Table Layout|Rows & Columns|Delete Cells|table.deleteCells diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 2f032ad23..6b67939cf 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -62,6 +62,11 @@ pub fn specs() -> Vec { }; with_sect(s, |x| x.valign = va) }), + CommandSpec::new("layout.sectionRtl", "Right-to-Left Section", "Layout › Page Setup", |s, v| { + let on = p::bool(v, "value").unwrap_or(!sect(s).rtl); + with_sect(s, |x| x.rtl = on) + }) + .params(r#"{"value"?: bool} (true: the section's columns run right to left; without a value it toggles)"#), CommandSpec::new("layout.differentFirstPage", "Different First Page", "Header & Footer › Options", |s, v| { let on = p::bool(v, "value").unwrap_or(!sect(s).title_page); with_sect(s, |x| x.title_page = on) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index d3dc048ac..8eace0a4a 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -163,8 +163,17 @@ pub fn specs() -> Vec { select_cells(s, &tp, Some((r, Some(c)))) }) .pure()), + t(CommandSpec::new("table.rtl", "Right-to-Left Table", "Table Layout › Table", |s, v| { + let (tp, _, _) = cell(s)?; + let on = match p::bool(v, "value") { + Some(b) => b, + None => !s.doc.table(s.sel.focus.story, &tp).is_some_and(|t| t.props.bidi_visual), + }; + with_table(s, |t| t.props.bidi_visual = on) + }) + .params(r#"{"value"?: bool} (true: the first column stands at the right; without a value it toggles)"#)), t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params( - r#"{"align"?: "left|center|right", "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#, + r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#, )), CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text).params(r#"{"separator"?: "tab|comma"}"#), CommandSpec::new("table.quick", "Quick Tables", "Insert › Tables", quick_table).params(r#"{"kind"?: "calendar|tabular|matrix"}"#), @@ -235,8 +244,20 @@ fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) { /// cell settings apply to the caret's. Without any setting it returns the current values. fn properties(s: &mut Session, v: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; - let keys = - ["align", "width", "widthPct", "indent", "rowHeight", "rowHeightRule", "allowBreak", "headerRow", "columnWidth", "cellWidth", "valign"]; + let keys = [ + "align", + "rtl", + "width", + "widthPct", + "indent", + "rowHeight", + "rowHeightRule", + "allowBreak", + "headerRow", + "columnWidth", + "cellWidth", + "valign", + ]; if !keys.iter().any(|k| v.get(*k).is_some()) { let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default(); let mut out = serde_json::to_value(&t.props).unwrap_or(Value::Null); @@ -284,10 +305,14 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { let header = p::bool(v, "headerRow"); let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0)); let cell_width = size("cellWidth", 6.0, 1584.0); + let rtl = p::bool(v, "rtl"); with_table(s, |t| { if let Some(a) = align { t.props.align = Some(a); } + if let Some(on) = rtl { + t.props.bidi_visual = on; + } if let Some(i) = indent { t.props.indent = Some(i); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dae0bc3dd..a6483507d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -917,6 +917,43 @@ fn table_text_direction_cycles_and_turns_the_text() { } } +/// Right-to-left tables and sections (#362): `table.rtl` and `layout.sectionRtl` set or toggle +/// the direction, one Undo step each, and the layout mirrors the table. +#[test] +fn table_and_section_direction_commands() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 3})); + let rtl = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().props.bidi_visual; + let first_cell_x = |s: &mut Session| { + s.layout().pages[0].items.iter().find_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, cell: 0, .. } => Some(rect.x), + _ => None, + }) + }; + let ltr_x = first_cell_x(&mut s).unwrap(); + run(&mut s, "table.rtl", json!({})); + assert!(rtl(&s)); + assert!(first_cell_x(&mut s).unwrap() > ltr_x + 100.0, "the first column moves to the right"); + run(&mut s, "table.rtl", json!({"value": true})); + assert!(rtl(&s), "an explicit value sets rather than toggles"); + run(&mut s, "table.properties", json!({"rtl": false})); + assert!(!rtl(&s)); + run(&mut s, "edit.undo", json!({})); + assert!(rtl(&s)); + assert_eq!(s.run("table.properties", &json!({})).unwrap()["bidiVisual"], json!(true)); + run(&mut s, "layout.sectionRtl", json!({})); + assert!(crate::cmd::page::sect(&s).rtl); + run(&mut s, "layout.sectionRtl", json!({"value": false})); + assert!(!crate::cmd::page::sect(&s).rtl); + run(&mut s, "edit.undo", json!({})); + assert!(crate::cmd::page::sect(&s).rtl); + // Outside a table `table.rtl` is disabled. + s.sel = crate::Selection::caret(Pos::body(0, 0)); + if s.sel.focus.path.cell().is_none() { + assert!(s.run("table.rtl", &json!({})).is_err()); + } +} + #[test] fn table_border_presets_mask_the_sides_they_clear() { // On a default (TableGrid) table the "outside"/"inside" presets must write nil over the diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index d7dbe47cc..dc56cb453 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1227,8 +1227,10 @@ impl PageBuilder<'_> { if s.columns.separator && s.columns.count > 1 { let cols = s.column_boxes(); for w in cols.windows(2) { - if let [(ax, aw), (bx, _)] = w { - let x = s.margin_left + s.gutter + (ax + aw + bx) / 2.0; + if let [(ax, aw), (bx, bw)] = w { + // Midway across the space between neighbours (leftwards in an RTL section). + let mid = if bx >= ax { (ax + aw + bx) / 2.0 } else { (bx + bw + ax) / 2.0 }; + let x = s.margin_left + s.gutter + mid; decor.push(Placed::Rule { x0: x, y0: body_top, x1: x, y1: s.page_h - s.margin_bottom, border: Border::single(0.5) }); } } diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index dfe33ee72..3d3805b8b 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -359,16 +359,26 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava tborders.overlay(&own); } let first_cell = t.rows.first().and_then(|r| r.cells.first()); - let x = match t.props.align { - Some(Align::Center) => (avail - total) / 2.0, - Some(Align::Right) => avail - total, - _ if ctx.doc.settings.compat_mode >= 15 => { - // The border is centred on the edge, so half of it sits outside: Word moves the - // table in by that half. - let border = first_cell.and_then(|c| c.props.borders.and_then(|b| b.left)).or(tborders.left); - indent + border.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0) / 2.0) - } - _ => indent - first_cell_left_margin_in(t, margins_def), + // A right-to-left table (`w:bidiVisual`) mirrors: its first column stands at the right, its + // start alignment and indent are measured from the right margin, and each cell's start + // (left) margin and border are on its right. + let rtl = t.props.bidi_visual; + // How far in from the start margin the table's edge sits when it is start-aligned. + let start_offset = if ctx.doc.settings.compat_mode >= 15 { + // The border is centred on the edge, so half of it sits outside: Word moves the table in + // by that half. + let border = first_cell.and_then(|c| c.props.borders.and_then(|b| b.left)).or(tborders.left); + indent + border.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0) / 2.0) + } else { + indent - first_cell_left_margin_in(t, margins_def) + }; + let x = match (t.props.align, rtl) { + (Some(Align::Center), _) => (avail - total) / 2.0, + // End alignment: the right margin, or the left one in a right-to-left table. + (Some(Align::Right), false) => avail - total, + (Some(Align::Right), true) => 0.0, + (_, true) => avail - total - start_offset, + (_, false) => start_offset, }; let nrows = t.rows.len(); let header_rows = t.props.look.header_row; @@ -403,7 +413,11 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let span = cell.span(); let x0 = colx.get(g).copied().unwrap_or(acc); let x1 = colx.get((g + span).min(ncols)).copied().unwrap_or(acc); + // Right to left: grid columns run from the right edge, and the start (left) margin + // is on the cell's right. + let (x0, x1) = if rtl { ((acc - x1).max(0.0), (acc - x0).max(0.0)) } else { (x0, x1) }; let margins = cell.props.margins.unwrap_or(margins_def); + let margins = if rtl { [margins[0], margins[3], margins[2], margins[1]] } else { margins }; let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; let region = region_of(t, style.as_ref(), ri, g, span, ncols); @@ -464,6 +478,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava { borders.top = Some(b); } + // The logical start (left) and end (right) borders, drawn mirrored in an RTL table. + if rtl { + std::mem::swap(&mut borders.left, &mut borders.right); + } // Word keeps the cell's text clear of its top border (and the last row's of its bottom // border): the border's width sits on top of the cell margin. let band = |b: Option| b.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0)); diff --git a/crates/layout/src/tests_bidi.rs b/crates/layout/src/tests_bidi.rs index 354061837..9fa922974 100644 --- a/crates/layout/src/tests_bidi.rs +++ b/crates/layout/src/tests_bidi.rs @@ -449,3 +449,73 @@ fn inline_picture_in_rtl_text_sits_in_its_place() { assert!(placed.iter().any(|r| (r.x - a).abs() < 0.5), "placed {placed:?} vs {a}"); assert!(a < 540.0 - 20.0, "after the first word, so left of the right margin"); } + +/// A right-to-left table (`w:bidiVisual`, #362): the first logical column stands at the right +/// edge, the table is start-aligned against the right margin, and each cell's start (left) +/// margin and border are drawn on its right. Clicks find cells by where they are drawn. +#[test] +fn rtl_table_lays_its_columns_out_right_to_left() { + use wordcraft_doc::props::{Border, Borders}; + use wordcraft_doc::{Path, Table}; + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(1, 3, 300.0); + t.props.bidi_visual = true; + // Start margin 10pt, end margin 2pt. + t.props.cell_margins = Some([0.0, 10.0, 0.0, 2.0]); + for (i, c) in t.rows[0].cells.iter_mut().enumerate() { + c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("c{i}"), Default::default()))]; + } + // A thick start border on the first cell. + t.rows[0].cells[0].props.borders = Some(Borders { left: Some(Border::single(3.0)), ..Default::default() }); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + let items = &l.pages[0].items; + let cell = + |k: usize| items.iter().find_map(|i| if let Placed::Cell { rect, cell, .. } = i { (*cell == k).then_some(*rect) } else { None }).unwrap(); + let (c0, c1, c2) = (cell(0), cell(1), cell(2)); + // Start-aligned against the right margin (72 + 468), first column rightmost. + let right = 72.0 + 468.0; + assert!((c0.right() - right).abs() < 2.0, "{c0:?}"); + assert!(c0.x > c1.x && c1.x > c2.x, "{c0:?} {c1:?} {c2:?}"); + assert!((c1.right() - c0.x).abs() < 0.01 && (c2.right() - c1.x).abs() < 0.01); + assert!((c2.x - (right - 300.0)).abs() < 2.0, "{c2:?}"); + // The first cell's text sits its end margin (2pt) in from the cell's left edge. + let text_x = + items.iter().find_map(|i| if let Placed::Lines { path, x, .. } = i { (path.0 == [1, 0, 0, 0]).then_some(*x) } else { None }).unwrap(); + assert!((text_x - (c0.x + 2.0)).abs() < 0.5, "text at {text_x}, cell {c0:?}"); + // The thick start border is drawn on the cell's right edge. + let thick: Vec = items + .iter() + .filter_map(|i| match i { + Placed::Rule { x0, x1, border, .. } if (x0 - x1).abs() < 0.01 && (border.width - 3.0).abs() < 0.01 => Some(*x0), + _ => None, + }) + .collect(); + assert_eq!(thick.len(), 1, "{thick:?}"); + assert!((thick[0] - c0.right()).abs() < 0.01, "{thick:?} vs {c0:?}"); + // A click in the rightmost cell lands in the first logical cell. + let hit = l.cell_at(0, c0.x + c0.w / 2.0, c0.y + 2.0).unwrap(); + assert_eq!((hit.2, hit.3), (0, 0), "{hit:?}"); +} + +/// A right-to-left section (`w:sectPr/w:bidi`, #362): newspaper columns run right to left, the +/// first column at the right, and the line between them stays midway. +#[test] +fn rtl_section_puts_its_first_column_on_the_right() { + use wordcraft_doc::section::Columns; + let text = "Columns flow from the right edge in a right-to-left section. ".repeat(150); + let mut d = Document::from_text(&text); + d.last_section.rtl = true; + d.last_section.columns = Columns { count: 2, space: 36.0, separator: true, widths: Vec::new() }; + let l = lay(&d); + let page = &l.pages[0]; + let xs: Vec = page.items.iter().filter_map(|i| if let Placed::Lines { x, .. } = i { Some(*x) } else { None }).collect(); + // Columns are 216pt wide: the first at 72 + 252, the second at the left margin. + assert!((xs[0] - (72.0 + 252.0)).abs() < 1.0, "first column on the right: {xs:?}"); + assert!(xs.iter().any(|x| (x - 72.0).abs() < 1.0), "second column on the left: {xs:?}"); + let caret = l.caret(&Pos::body(0, 0)).unwrap(); + assert!(caret.x > 72.0 + 252.0 - 1.0, "{caret:?}"); + let sep: Vec = + page.items.iter().filter_map(|i| if let Placed::Rule { x0, x1, .. } = i { ((x0 - x1).abs() < 0.01).then_some(*x0) } else { None }).collect(); + assert_eq!(sep, vec![72.0 + 234.0], "line between the columns"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 20062f7c6..4b0544042 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -89,6 +89,8 @@ pub enum Dialog { left: f32, right: f32, landscape: bool, + /// Section direction: right to left (`layout.sectionRtl`). + rtl: bool, }, Link { url: String, @@ -310,6 +312,8 @@ pub struct TableForm { /// `left`, `center` or `right`. pub align: String, pub indent: f32, + /// Table direction: right to left (`w:bidiVisual`). + pub rtl: bool, pub row_height_on: bool, pub row_height: f32, pub row_exact: bool, @@ -345,6 +349,7 @@ impl TableForm { } .into(), indent: fin(t.props.indent.unwrap_or(0.0)) / k, + rtl: t.props.bidi_visual, row_height_on: row.height.is_some() && row.height_rule != HeightRule::Auto, row_height: fin(row.height.unwrap_or(18.0)) / k, row_exact: row.height_rule == HeightRule::Exact, @@ -376,6 +381,9 @@ impl TableForm { if moved(self.indent, basis.indent) { v.insert("indent".into(), json!(self.indent * k)); } + if self.rtl != basis.rtl { + v.insert("rtl".into(), json!(self.rtl)); + } if self.row_height_on != basis.row_height_on || (self.row_height_on && (moved(self.row_height, basis.row_height) || self.row_exact != basis.row_exact)) { @@ -807,6 +815,7 @@ impl Dialog { left: sp.margin_left / 72.0, right: sp.margin_right / 72.0, landscape: sp.landscape, + rtl: sp.rtl, } } "link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text() }, @@ -1428,7 +1437,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::PageSetup { top, bottom, left, right, landscape } => { + Dialog::PageSetup { top, bottom, left, right, landscape, rtl } => { ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5))); egui::Grid::new("ps").num_columns(4).show(ui, |ui| { ui.label(tl!("Top:")); @@ -1447,11 +1456,19 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(landscape, false, tl!("Portrait")); ui.radio_value(landscape, true, tl!("Landscape")); }); + ui.horizontal(|ui| { + ui.label(tl!("Section direction:")); + ui.radio_value(rtl, false, tl!("Left-to-right")); + ui.radio_value(rtl, true, tl!("Right-to-left")); + }); let (ok, cancel) = buttons(ui, tl!("OK")); if ok { let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }})); let _ = app.run("layout.margins", json!({"top": *top * 72.0, "bottom": *bottom * 72.0, "left": *left * 72.0, "right": *right * 72.0})); + if *rtl != wordcraft_engine::cmd::page::sect(&app.session).rtl { + let _ = app.run("layout.sectionRtl", json!({"value": *rtl})); + } } ok || cancel } @@ -2262,6 +2279,12 @@ fn table_properties(ui: &mut Ui, f: &mut TableForm) { ui.label(tl!("Indent from left:")); ui.add_enabled(f.align == "left", egui::DragValue::new(&mut f.indent).speed(0.01).range(-11.0..=22.0).suffix(unit.suffix()).max_decimals(2)); ui.end_row(); + ui.label(tl!("Table direction:")); + ui.horizontal(|ui| { + ui.radio_value(&mut f.rtl, false, tl!("Left-to-right")); + ui.radio_value(&mut f.rtl, true, tl!("Right-to-left")); + }); + ui.end_row(); }); heading(ui, "Row"); egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 56a4e8ead..889216b50 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1412,6 +1412,10 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Right-to-Left Table Tabla de derecha a izquierda + Right-to-Left Section Sección de derecha a izquierda + Table direction: Dirección de la tabla: + Section direction: Dirección de la sección: Drawing Canvas Lienzo de dibujo New Drawing Canvas Nuevo lienzo de dibujo Fit Ajustar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index a29ae2418..a9355a7c7 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1398,6 +1398,10 @@ Ribbon Lint Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon tabs Lindi vahekaardid + Right-to-Left Table Paremalt vasakule tabel + Right-to-Left Section Paremalt vasakule jaotis + Table direction: Tabeli suund: + Section direction: Jaotise suund: Drawing Canvas Joonistuslõuend New Drawing Canvas Uus joonistuslõuend Fit Sobita diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dc9d5184c..12f37e168 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1396,6 +1396,10 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Right-to-Left Table 右から左の表 + Right-to-Left Section 右から左のセクション + Table direction: 表の方向: + Section direction: セクションの方向: Drawing Canvas 描画キャンバス New Drawing Canvas 新しい描画キャンバス Fit サイズに合わせる diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6d508265a..85ef8b225 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1408,6 +1408,10 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Right-to-Left Table Tabela da direita para a esquerda + Right-to-Left Section Seção da direita para a esquerda + Table direction: Direção da tabela: + Section direction: Direção da seção: Drawing Canvas Tela de desenho New Drawing Canvas Nova tela de desenho Fit Ajustar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ffb8092db..40e4e4af1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,10 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Right-to-Left Table Таблица справа налево + Right-to-Left Section Раздел справа налево + Table direction: Направление таблицы: + Section direction: Направление раздела: Drawing Canvas Полотно для рисования New Drawing Canvas Новое полотно для рисования Fit По размеру diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 55036c74f..8ea9c10df 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1406,6 +1406,10 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Right-to-Left Table Tabela sa desna na levo + Right-to-Left Section Odeljak sa desna na levo + Table direction: Smer tabele: + Section direction: Smer odeljka: Drawing Canvas Platno za crtanje New Drawing Canvas Novo platno za crtanje Fit Prilagodi diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 0c7db5151..147433e71 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1410,6 +1410,10 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Right-to-Left Table Табела са десна на лево + Right-to-Left Section Одељак са десна на лево + Table direction: Смер табеле: + Section direction: Смер одељка: Drawing Canvas Платно за цртање New Drawing Canvas Ново платно за цртање Fit Прилагоди diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 257e73d84..87c6847fd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,10 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Right-to-Left Table Sağdan Sola Tablo + Right-to-Left Section Sağdan Sola Bölüm + Table direction: Tablo yönü: + Section direction: Bölüm yönü: Drawing Canvas Çizim Tuvali New Drawing Canvas Yeni Çizim Tuvali Fit Sığdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5c078892c..7a9b273e7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1408,6 +1408,10 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Right-to-Left Table Таблиця справа наліво + Right-to-Left Section Розділ справа наліво + Table direction: Напрямок таблиці: + Section direction: Напрямок розділу: Drawing Canvas Полотно малювання New Drawing Canvas Нове полотно малювання Fit Підігнати diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 792fff069..f0416a137 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1408,6 +1408,10 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Right-to-Left Table 从右向左表格 + Right-to-Left Section 从右向左节 + Table direction: 表格方向: + Section direction: 节方向: Drawing Canvas 绘图画布 New Drawing Canvas 新建绘图画布 Fit 适应 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5ea284cf6..64f785c14 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1397,6 +1397,10 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Right-to-Left Table 從右至左表格 + Right-to-Left Section 從右至左節 + Table direction: 表格方向: + Section direction: 節方向: Drawing Canvas 繪圖畫布 New Drawing Canvas 新增繪圖畫布 Fit 調整大小 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cceda7159..9cd41a15c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1303,6 +1303,11 @@ mod tests { assert_eq!(t.props.align, Some(wordcraft_doc::props::Align::Center)); assert_eq!((t.rows[0].props.height, t.rows[0].props.header), (Some(36.0), true)); assert_eq!(dialogs::TableForm::read(&a).unwrap().changes(&f), json!({}), "the dialog reopens with the new values"); + // Table direction (#362) is one more setting of the same step. + let mut g = dialogs::TableForm::read(&a).unwrap(); + let basis = g.clone(); + g.rtl = true; + assert_eq!(g.changes(&basis), json!({"rtl": true})); } /// Home › Font › Enclose Characters (#297): the button opens the dialog on the selected diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 76cf07a3f..f99f9dda5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -19,7 +19,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | Format | Weight | Word | WordCraft read | WordCraft write | Fidelity | Hours | How it's tested | Notes | |---|---|---|---|---|---|---|---|---| -| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 4, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below | +| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~55% | 25–40 | `crates/docbin/src/tests.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures. Metafile (WMF/EMF) pictures, Word 6/95 files and encrypted files are refused or dropped. No writer | @@ -47,7 +47,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111) | -| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256) | +| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | @@ -85,6 +85,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) | | 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) | | 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) | | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | diff --git a/docs/gaps.md b/docs/gaps.md index 179a78ac4..aa00a86d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -26,7 +26,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | -| 16 | **Right-to-left completeness**: RTL sections and tables, kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 10–15 | [layout-parity.md](layout-parity.md) | +| 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**: vertical text, document grid, Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 16–24 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas untested | | AccessKit enabled; no tests | Blind and low-vision users; public-sector procurement | 10–15 | [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) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #16: right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) laid out mirrored, with `table.rtl` and `layout.sectionRtl` (#362) | | 2026-10-11 | trivial | #4: columns balance before a continuous section break (#350) | | 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 | | 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index af1bca3f0..76918e831 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -48,7 +48,7 @@ font can't match Word's metrics. | Document grid (`w:docGrid`, lines per page, characters per line) | ❌ | East Asian documents paginate differently without it | 4–6 | | Vertical text (`tbRl`) in sections and text boxes; cell text direction | 🟡 | cell text direction landed (#245); page-level and text-box vertical text missing | 8–12 | | Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 | -| Bidirectional paragraphs, mixed-direction lines | 🟡 | #207; RTL sections and tables (`w:bidiVisual`) missing | 4–6 | +| Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — | | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52 | — | @@ -68,6 +68,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) | | 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) | | 2026-10-11 | trivial | Newspaper columns balance before a continuous section break (#350) | | 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 382fed2ea..95632acc8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**423 of 448 catalog features have commands (94.4%).** +**425 of 450 catalog features have commands (94.4%).** | Tab | Live | Total | |---|---|---| @@ -12,13 +12,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Insert | 45 | 53 | | Draw | 6 | 11 | | Design | 10 | 10 | -| Layout | 19 | 19 | +| Layout | 20 | 20 | | References | 24 | 24 | | Mailings | 17 | 17 | | Review | 28 | 30 | | View | 25 | 29 | | Table Design | 8 | 8 | -| Table Layout | 30 | 30 | +| Table Layout | 31 | 31 | | Equation | 21 | 22 | | Picture Format | 14 | 14 | | Shape Format | 3 | 6 |