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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 14:00:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent c010b73428
commit 09921dfae8
29 changed files with 327 additions and 32 deletions
+5
View File
@@ -812,6 +812,11 @@ pub struct TableProps {
/// Tracked formatting change (`w:tblPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<TableProps>>>,
/// 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.
+25 -9
View File
@@ -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)]);
}
}
+1
View File
@@ -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)),
+3
View File
@@ -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 {
+36
View File
@@ -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#"
<w:tbl>
<w:tblPr><w:bidiVisual/><w:tblW w:w="0" w:type="auto"/><w:tblInd w:w="360" w:type="dxa"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:tc><w:p><w:r><w:t>اول</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>دوم</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:tbl>
<w:tblPr><w:bidiVisual w:val="0"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:tc><w:p/></w:tc></w:tr>
</w:tbl>
<w:p/>
<w:sectPr><w:cols w:num="2" w:space="720"/><w:bidi/></w:sectPr>"#;
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("<w:bidiVisual/>").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("<w:bidiVisual/>") < pos("<w:tblW"));
let r = wordcraft_docx::read(&bytes).unwrap();
let again: Vec<bool> = 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);
}
+2
View File
@@ -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
+5
View File
@@ -62,6 +62,11 @@ pub fn specs() -> Vec<CommandSpec> {
};
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)
+28 -3
View File
@@ -163,8 +163,17 @@ pub fn specs() -> Vec<CommandSpec> {
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);
}
+37
View File
@@ -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
+4 -2
View File
@@ -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) });
}
}
+28 -10
View File
@@ -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<Border>| b.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0));
+70
View File
@@ -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<f32> = 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<f32> = 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<f32> =
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");
}
+24 -1
View File
@@ -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| {
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 421 and column 13.
+4
View File
@@ -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 サイズに合わせる
Can't render this file because it contains an unexpected character in line 504 and column 13.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+4
View File
@@ -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 По размеру
Can't render this file because it contains an unexpected character in line 7 and column 25.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+4
View File
@@ -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 Прилагоди
Can't render this file because it contains an unexpected character in line 11 and column 25.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 430 and column 13.
+4
View File
@@ -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 Підігнати
Can't render this file because it contains an unexpected character in line 6 and column 31.
+4
View File
@@ -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 适应
Can't render this file because it contains an unexpected character in line 7 and column 25.
+4
View File
@@ -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 調整大小
Can't render this file because it contains an unexpected character in line 505 and column 13.
+5
View File
@@ -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
+3 -2
View File
@@ -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` |
+2 -1
View File
@@ -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 |
+2 -1
View File
@@ -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) |
+3 -3
View File
@@ -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 |