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 |