mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
c010b73428
commit
09921dfae8
@@ -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.
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user