Table sizing: Height/Width boxes, AutoFit to contents and a Table Properties dialog (#287)

* Table sizing: Height/Width boxes, real AutoFit Contents, Table Properties dialog (#44)

- Table Layout > Cell Size gets Height and Width boxes (inches) for the caret cell's row
  and column; each edit runs table.rowHeight / table.columnWidth, a drag is one Undo.
- AutoFit Contents now measures every column's narrowest (longest word) and widest
  (unwrapped text) content with the layout's text shaping, shares the text width the way
  Word does, and writes the widths into the table grid and cell widths.
- Table Properties opens a dialog (table width, alignment, indent; row height and rule,
  break across pages, header row; column width; cell width and vertical alignment). OK
  applies only what changed as one table.properties step; with settings the command still
  works headlessly, and without them it reports the current values.
- table.columnWidth clears the table's preferred width so the column keeps the new size;
  table.rowHeight takes an optional exact rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Progress docs: Table Properties dialog and AutoFit Contents landed (#44)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Table sizing: saturating column-span sum in sync_cell_widths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge fix: one region_text (main's) after #287 met the landed table-style code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:52:08 -04:00
1 parent c0bbcbac24
commit 08e8d4ade0
19 files changed
+851 -55

No files matched your search

+1 -1
View File
@@ -130,7 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250); v0.4.0 released |
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44); v0.4.0 released |
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
+172 -43
View File
@@ -1,7 +1,7 @@
//! Table Design and Table Layout tabs.
use serde_json::{Value, json};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, Rgb, TextDirection, VAlign};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block};
use super::sel_result;
@@ -96,30 +96,13 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"value": "topLeft|topCenter|…|bottomRight"}"#)),
t(CommandSpec::new("table.textDirection", "Text Direction", "Table Layout › Alignment", text_direction)
.params(r#"{"value"?: "horizontal|down|up"} (default: the next direction after the caret cell's, like Word's button)"#)),
t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", |s, v| {
let mode = p::str(v, "mode").unwrap_or("window");
let tw = super::page::sect(s).text_width();
with_table(s, |t| match mode {
"fixed" => t.props.fixed = true,
"contents" => {
t.props.fixed = false;
t.props.width = None;
t.props.width_pct = None;
}
_ => {
t.props.fixed = false;
t.props.width_pct = Some(100.0);
let n = t.grid.len().max(1) as f32;
t.grid.iter_mut().for_each(|g| *g = tw / n);
}
})
})
.params(r#"{"mode": "contents|window|fixed"}"#)),
t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", autofit).params(r#"{"mode": "contents|window|fixed"}"#)),
t(CommandSpec::new("table.distributeColumns", "Distribute Columns", "Table Layout › Cell Size", |s, _| {
with_table(s, |t| {
let total: f32 = t.grid.iter().sum();
let n = t.grid.len().max(1) as f32;
t.grid.iter_mut().for_each(|g| *g = total / n);
sync_cell_widths(t);
})
})),
t(CommandSpec::new("table.distributeRows", "Distribute Rows", "Table Layout › Cell Size", |s, _| {
@@ -133,25 +116,25 @@ pub fn specs() -> Vec<CommandSpec> {
t(CommandSpec::new("table.columnWidth", "Table Column Width", "Table Layout › Cell Size", |s, v| {
let w = p::req_f32(v, "width")?.clamp(6.0, 1584.0);
let (_, r, c) = cell(s)?;
with_table(s, |t| {
let g = t.grid_col(r, c);
if let Some(x) = t.grid.get_mut(g) {
*x = w;
}
})
with_table(s, |t| set_column_width(t, r, c, w))
})
.params(r#"{"width": pt}"#)),
.params(r#"{"width": pt} (the caret cell's column; the table's preferred width is cleared so the column gets exactly this)"#)),
t(CommandSpec::new("table.rowHeight", "Table Row Height", "Table Layout › Cell Size", |s, v| {
let h = p::req_f32(v, "height")?.clamp(1.0, 1584.0);
let rule = match p::str(v, "rule") {
None | Some("atLeast") => HeightRule::AtLeast,
Some("exact") => HeightRule::Exact,
Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))),
};
let (_, r, _) = cell(s)?;
with_table(s, |t| {
if let Some(row) = t.rows.get_mut(r) {
row.props.height = Some(h);
row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
row.props.height_rule = rule;
}
})
})
.params(r#"{"height": pt}"#)),
.params(r#"{"height": pt, "rule"?: "atLeast|exact"}"#)),
t(CommandSpec::new("table.repeatHeader", "Repeat Header Rows", "Table Layout › Data", |s, v| {
let (_, r, _) = cell(s)?;
let on = p::bool(v, "value");
@@ -180,25 +163,171 @@ pub fn specs() -> Vec<CommandSpec> {
select_cells(s, &tp, Some((r, Some(c))))
})
.pure()),
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", |s, v| {
if let Some(a) = p::str(v, "align") {
let al = match a {
"center" => Align::Center,
"right" => Align::Right,
_ => Align::Left,
};
return with_table(s, |t| t.props.align = Some(al));
}
let (tp, _, _) = cell(s)?;
let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default();
Ok(serde_json::to_value(&t.props).unwrap_or(Value::Null))
})
.params(r#"{"align"?: "left|center|right"}"#)),
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)"#,
)),
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"}"#),
]
}
/// Table Layout › AutoFit: Contents sizes the columns to their text, Window spreads them across
/// the text column, Fixed keeps the widths as they are.
fn autofit(s: &mut Session, v: &Value) -> CmdResult {
let mode = p::str(v, "mode").unwrap_or("window");
let (tp, _, _) = cell(s)?;
let story = s.sel.focus.story;
let tw = super::page::sect(s).text_width();
match mode {
"fixed" => with_table(s, |t| t.props.fixed = true),
"contents" => {
let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
let avail = (tw - t.props.indent.filter(|i| i.is_finite()).unwrap_or(0.0).max(0.0)).max(72.0);
let widths = wordcraft_layout::autofit_widths(&wordcraft_layout::measure_table_columns(&s.doc, t), avail);
with_table(s, |t| {
t.props.fixed = false;
t.props.width = None;
t.props.width_pct = None;
t.grid = widths.iter().map(|w| w.max(6.0)).collect();
sync_cell_widths(t);
})
}
_ => with_table(s, |t| {
t.props.fixed = false;
t.props.width_pct = Some(100.0);
let n = t.grid.len().max(1) as f32;
t.grid.iter_mut().for_each(|g| *g = tw / n);
sync_cell_widths(t);
}),
}
}
/// Give every cell the width of the grid columns it spans, so the cells' preferred widths
/// (saved to .docx) agree with the grid.
fn sync_cell_widths(t: &mut Table) {
let grid = t.grid.clone();
for row in &mut t.rows {
let mut g = 0usize;
for c in &mut row.cells {
let span = c.span();
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
if w > 0.0 {
c.props.width = Some(w);
}
g += span;
}
}
}
/// Set the width of the grid column cell `c` of row `r` starts in. The table's preferred width
/// goes, so the layout doesn't scale the column back.
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
let g = t.grid_col(r, c);
if let Some(x) = t.grid.get_mut(g) {
*x = w;
t.props.width = None;
t.props.width_pct = None;
sync_cell_widths(t);
}
}
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
/// 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"];
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);
let row = t.rows.get(r);
let cl = row.and_then(|x| x.cells.get(c));
if let Some(o) = out.as_object_mut() {
o.insert("row".into(), row.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null));
o.insert("columnWidth".into(), t.grid.get(t.grid_col(r, c)).map_or(Value::Null, |w| json!(w)));
o.insert("cell".into(), cl.map(|x| serde_json::to_value(&x.props).unwrap_or(Value::Null)).unwrap_or(Value::Null));
}
return Ok(out);
}
let align = match p::str(v, "align") {
None => None,
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
Some(x) => return Err(CmdError::Params(format!("unknown table alignment `{x}`"))),
};
let rule = match p::str(v, "rowHeightRule") {
None => None,
Some("atLeast") => Some(HeightRule::AtLeast),
Some("exact") => Some(HeightRule::Exact),
Some(x) => return Err(CmdError::Params(format!("unknown height rule `{x}`"))),
};
let valign = match p::str(v, "valign") {
None => None,
Some("top") => Some(VAlign::Top),
Some("center") => Some(VAlign::Center),
Some("bottom") => Some(VAlign::Bottom),
Some(x) => return Err(CmdError::Params(format!("unknown vertical alignment `{x}`"))),
};
// `null` clears a preferred size (automatic); a number sets it.
let size = |k: &str, lo: f32, hi: f32| -> Option<Option<f32>> {
match v.get(k)? {
Value::Null => Some(None),
x => x.as_f64().map(|f| f as f32).filter(|f| f.is_finite()).map(|f| Some(f.clamp(lo, hi))),
}
};
let width = size("width", 6.0, 1584.0);
let width_pct = p::f32(v, "widthPct").map(|x| x.clamp(1.0, 100.0));
let indent = p::f32(v, "indent").map(|x| x.clamp(-1584.0, 1584.0));
let row_height = size("rowHeight", 1.0, 1584.0);
let allow_break = p::bool(v, "allowBreak");
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);
with_table(s, |t| {
if let Some(a) = align {
t.props.align = Some(a);
}
if let Some(i) = indent {
t.props.indent = Some(i);
}
if let Some(cw) = column_width {
set_column_width(t, r, c, cw);
}
if let Some(w) = width {
t.props.width = w;
t.props.width_pct = None;
}
if let Some(pct) = width_pct {
t.props.width_pct = Some(pct);
}
if let Some(row) = t.rows.get_mut(r) {
if let Some(h) = row_height {
row.props.height = h;
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
} else if let Some(rule) = rule
&& row.props.height.is_some()
{
row.props.height_rule = rule;
}
if let Some(b) = allow_break {
row.props.cant_split = !b;
}
if let Some(h) = header {
row.props.header = h;
}
if let Some(cl) = row.cells.get_mut(c) {
if let Some(w) = cell_width {
cl.props.width = w;
}
if let Some(va) = valign {
cl.props.valign = va;
}
}
}
})
}
fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> {
s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))
}
+109
View File
@@ -619,6 +619,115 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
#[test]
fn autofit_contents_sizes_columns_to_their_text() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
let grid = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().grid.clone();
let before = grid(&s);
run(&mut s, "text.insert", json!({"text": "A"}));
run(&mut s, "text.tab", json!({}));
run(
&mut s,
"text.insert",
json!({"text": "This cell holds a long sentence that would wrap many times in a third of the page width, so it should get most of the room."}),
);
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "B"}));
run(&mut s, "table.autofit", json!({"mode": "contents"}));
let after = grid(&s);
let tw = cmd::page::sect(&s).text_width();
assert!(after[0] < before[0] / 2.0, "short column narrows: {before:?} → {after:?}");
assert!(after[2] < before[2] / 2.0, "short column narrows: {before:?} → {after:?}");
assert!(after[1] > before[1] * 1.5, "long column widens: {before:?} → {after:?}");
let total: f32 = after.iter().sum();
assert!(total <= tw + 0.5 && total > tw - 1.0, "the long text fills the page width: {total} vs {tw}");
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert!(t.props.width_pct.is_none() && !t.props.fixed);
assert_eq!(t.rows[0].cells[1].props.width, Some(after[1]), "cell widths follow the grid");
// Short text only: every column gets just what its text needs.
s.doc = wordcraft_doc::Document::new();
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "Name"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "Quantity"}));
run(&mut s, "table.autofit", json!({"mode": "contents"}));
let g = grid(&s);
assert!(g[0] < g[1] && g[1] < 100.0, "{g:?}");
// The layout uses the new widths.
let w = s.layout().pages[0].items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, row: 0, cell: 0, .. } => Some(rect.w),
_ => None,
});
assert!(w.is_some_and(|w| (w - g[0]).abs() < 0.5), "{w:?} vs {g:?}");
}
/// The Height and Width boxes (Table Layout › Cell Size) run `table.rowHeight` and
/// `table.columnWidth`; each edit is one Undo.
#[test]
fn cell_size_boxes_set_row_height_and_column_width() {
use wordcraft_doc::props::HeightRule;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
run(&mut s, "table.autofit", json!({"mode": "window"}));
let t0 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
run(&mut s, "table.columnWidth", json!({"width": 100.0}));
run(&mut s, "table.rowHeight", json!({"height": 30.0, "rule": "exact"}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.grid[0], 100.0);
assert_eq!(t.rows[0].cells[0].props.width, Some(100.0));
assert!(t.props.width_pct.is_none(), "the column isn't scaled back to the window");
assert_eq!((t.rows[0].props.height, t.rows[0].props.height_rule), (Some(30.0), HeightRule::Exact));
let rect = s.layout().pages[0].items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, row: 0, cell: 0, .. } => Some(*rect),
_ => None,
});
assert!(rect.is_some_and(|r| (r.w - 100.0).abs() < 0.5 && (r.h - 30.0).abs() < 0.5), "{rect:?}");
assert!(s.run("table.rowHeight", &json!({"height": 30.0, "rule": "sideways"})).is_err());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0);
}
/// Table Properties (#44): table, row, column and cell settings land as one undo step, and
/// without settings the command reports the current ones (the dialog's source).
#[test]
fn table_properties_apply_in_one_step() {
use wordcraft_doc::props::{Align, HeightRule, VAlign};
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let t0 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
run(
&mut s,
"table.properties",
json!({"align": "center", "indent": 18.0, "width": 300.0, "rowHeight": 40.0, "rowHeightRule": "exact", "allowBreak": false,
"headerRow": true, "columnWidth": 120.0, "cellWidth": 110.0, "valign": "bottom"}),
);
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!((t.props.align, t.props.indent, t.props.width), (Some(Align::Center), Some(18.0), Some(300.0)));
let row = &t.rows[0];
assert_eq!((row.props.height, row.props.height_rule, row.props.cant_split, row.props.header), (Some(40.0), HeightRule::Exact, true, true));
assert_eq!(t.grid[0], 120.0);
assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), VAlign::Bottom));
let applied = t.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0, "one undo step per apply");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &applied);
let state = run(&mut s, "table.properties", json!({}));
assert_eq!(state["align"], "center");
assert_eq!(state["row"]["heightRule"], "exact");
assert_eq!(state["columnWidth"], 120.0);
assert_eq!(state["cell"]["valign"], "bottom");
// Clearing the row height makes it automatic again; bad values are refused.
run(&mut s, "table.properties", json!({"rowHeight": null}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows[0].props.height_rule, HeightRule::Auto);
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
}
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
/// top-to-bottom and bottom-to-top, and the layout turns the text.
#[test]
+1
View File
@@ -29,6 +29,7 @@ use wordcraft_geom::{Point, Rect};
pub use fields::FieldCtx;
pub use hit::VisualStep;
pub use para::{LineEnd, ParaLayout};
pub use table::{autofit_widths, measure_table_columns};
/// How the document is viewed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
+167
View File
@@ -85,6 +85,173 @@ fn default_margins(t: &Table, style: Option<&TableStyleProps>) -> [f32; 4] {
t.props.cell_margins.or(style.and_then(|s| s.parts.cell_margins)).unwrap_or(DEFAULT_MARGINS)
}
/// The most paragraphs [`measure_table_columns`] lays out: a huge table is measured by its first
/// rows only, so AutoFit stays quick.
const MEASURE_BUDGET: usize = 4000;
/// The line length paragraphs are measured at: wide enough that nothing real wraps.
const MEASURE_WIDE: f32 = 15_840.0;
/// What an empty cell's text needs (about a paragraph mark), points.
const EMPTY_TEXT: f32 = 12.0;
/// What one paragraph needs: the longest run of text without a break opportunity (`min`) and
/// its longest line when nothing wraps (`max`), indents included.
fn measure_para(p: &wordcraft_doc::Paragraph, env: &crate::para::ParaEnv) -> (f32, f32) {
use crate::para::ClKind;
let pl = crate::para::layout_para(p, env);
let rp = &pl.rp;
let indents = rp.indent_left.max(0.0) + rp.indent_right.max(0.0) + rp.indent_first.max(0.0);
let (mut word, mut min) = (0.0f32, 0.0f32);
for c in &pl.clusters {
match c.kind {
ClKind::Text | ClKind::Object(_) => word += c.adv.max(0.0),
ClKind::Marker => {}
_ => {
min = min.max(word);
word = 0.0;
}
}
if c.break_after {
min = min.max(word);
word = 0.0;
}
}
min = min.max(word);
let mut max = 0.0f32;
for l in &pl.lines {
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for k in l.c0..l.c1 {
let (Some(c), Some(a), Some(b)) = (pl.clusters.get(k), l.cl_left(k), l.cl_right(k)) else { continue };
lo = lo.min(a.min(b));
if !matches!(c.kind, ClKind::Space | ClKind::Marker | ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak) {
hi = hi.max(a.max(b));
}
}
if hi > lo {
max = max.max(hi - lo + l.hyphen.map_or(0.0, |h| h.2));
}
}
let min = if min.is_finite() { min } else { 0.0 };
let max = if max.is_finite() { max.max(min) } else { min };
(min + indents, max + indents)
}
/// What each grid column of `t` needs for AutoFit to Contents, as (narrowest, widest) in points
/// with cell margins included: the narrowest fits each cell's longest word (text that can't
/// wrap), the widest fits every paragraph on one line. A cell spanning several columns spreads
/// what its columns lack over them. Paragraphs are laid out with the table style's formatting;
/// after [`MEASURE_BUDGET`] paragraphs the remaining rows are skipped.
pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f32, f32)> {
use wordcraft_doc::Block;
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
let parts = style.as_ref().map(|s| &s.parts);
let margins_def = default_margins(t, style.as_ref());
let ncols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
let fields = crate::FieldCtx::default();
let header_rows = t.props.look.header_row;
let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize;
let nrows = t.rows.len();
let mut cols = vec![(0.0f32, 0.0f32); ncols];
// Cells spanning several columns: (first column, span, min, max), applied after the others.
let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new();
let mut budget = MEASURE_BUDGET;
'rows: for (ri, row) in t.rows.iter().enumerate() {
let is_header = header_rows && ri == 0;
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
let mut g = 0usize;
for cell in &row.cells {
let span = cell.span();
let margins = cell.props.margins.unwrap_or(margins_def);
let side = margins[1].max(0.0) + margins[3].max(0.0);
let text = style.as_ref().map(|st| region_text(st, (is_header, is_total, t.props.look.first_column && g == 0, band)));
let env = crate::para::ParaEnv {
doc,
width: MEASURE_WIDE,
label: None,
fields: &fields,
show_hidden: false,
hide_deleted: false,
table: text.as_ref(),
proofing: false,
exclusions: &[],
eq_number: 0,
};
let (mut min, mut max) = (EMPTY_TEXT, EMPTY_TEXT);
if cell.props.vmerge != VMerge::Continue && !cell.props.text_direction.is_turned() {
for b in &cell.blocks {
match &**b {
Block::Para(p) => {
if budget == 0 {
break 'rows;
}
budget -= 1;
let (a, b) = measure_para(p, &env);
min = min.max(a);
max = max.max(b);
}
// A nested table keeps its own column widths.
Block::Table(inner) => {
let w: f32 = inner.grid.iter().filter(|w| w.is_finite()).map(|w| w.clamp(0.0, 1584.0)).sum();
min = min.max(w);
max = max.max(w);
}
}
}
}
let (min, max) = ((min + side).min(1584.0), (max + side).min(1584.0));
if span == 1 {
if let Some(c) = cols.get_mut(g) {
c.0 = c.0.max(min);
c.1 = c.1.max(max);
}
} else {
spanned.push((g, span, min, max));
}
g += span;
}
}
for (g, span, min, max) in spanned {
let Some(cs) = cols.get_mut(g..(g + span).min(ncols)) else { continue };
if cs.is_empty() {
continue;
}
let n = cs.len() as f32;
let (have_min, have_max) = cs.iter().fold((0.0, 0.0), |(a, b), c| (a + c.0, b + c.1));
for c in cs.iter_mut() {
c.0 += ((min - have_min) / n).max(0.0);
c.1 += ((max - have_max) / n).max(0.0);
c.1 = c.1.max(c.0);
}
}
// A column no cell starts in still needs room for its margins.
let floor = margins_def[1].max(0.0) + margins_def[3].max(0.0) + EMPTY_TEXT;
cols.iter_mut().for_each(|c| {
c.0 = c.0.max(floor);
c.1 = c.1.max(c.0);
});
cols
}
/// Column widths for AutoFit to Contents, the way Word shares the room: every column gets its
/// widest content when that all fits in `avail`; otherwise each gets its narrowest plus a share
/// of the remaining room in proportion to how much wider its content would like to be. When
/// even the narrowest widths don't fit, the columns keep them (the table runs past the margin,
/// as in Word).
pub fn autofit_widths(cols: &[(f32, f32)], avail: f32) -> Vec<f32> {
let clean = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
let cols: Vec<(f32, f32)> = cols.iter().map(|&(a, b)| (clean(a), clean(b).max(clean(a)))).collect();
let avail = clean(avail);
let (sum_min, sum_max) = cols.iter().fold((0.0f32, 0.0f32), |(a, b), c| (a + c.0, b + c.1));
if sum_max <= avail {
return cols.iter().map(|c| c.1).collect();
}
if sum_min >= avail || sum_max <= sum_min {
return cols.iter().map(|c| c.0).collect();
}
let k = (avail - sum_min) / (sum_max - sum_min);
cols.iter().map(|c| c.0 + (c.1 - c.0) * k).collect()
}
/// The first cell's left margin: how far its text sits inside the table's edge.
pub(crate) fn first_cell_left_margin(ctx: &Ctx, t: &Table) -> f32 {
first_cell_left_margin_in(t, default_margins(t, table_style(ctx, t).as_ref()))
+192 -4
View File
@@ -1,8 +1,8 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, Paste
//! Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find
//! Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by running a command
//! (or shows one's result), so agents get the same result without the dialog.
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Table Properties,
//! Command search, Paste Special, About, Save Changes, and the mail-merge Recipient List, Insert
//! Merge Field, Find Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends
//! by running a command (or shows one's result), so agents get the same result without the dialog.
use egui::{Sense, Ui, vec2};
use serde::Serialize;
@@ -180,6 +180,114 @@ pub enum Dialog {
unknown: Vec<String>,
records: u64,
},
/// Table Layout › Properties: the table, the caret's row, column and cell. Only what changed
/// from `basis` (the values when it opened) is applied, as one `table.properties` step.
TableProperties {
form: Box<TableForm>,
#[serde(skip)]
basis: Box<TableForm>,
},
}
/// The Table Properties dialog's fields. Lengths are in the interface unit
/// ([`wordcraft_geom::Unit`], inches by default).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableForm {
pub width_on: bool,
pub width: f32,
/// `left`, `center` or `right`.
pub align: String,
pub indent: f32,
pub row_height_on: bool,
pub row_height: f32,
pub row_exact: bool,
pub allow_break: bool,
pub header_row: bool,
pub column_width: f32,
pub cell_width_on: bool,
pub cell_width: f32,
/// `top`, `center` or `bottom`.
pub valign: String,
}
impl TableForm {
/// The caret's table, row, column and cell as the dialog shows them.
pub fn read(app: &WordApp) -> Option<TableForm> {
use wordcraft_doc::props::{Align, HeightRule, VAlign};
let s = &app.session;
let (tp, r, c) = s.sel.focus.path.cell()?;
let t = s.doc.table(s.sel.focus.story, &tp)?;
let k = wordcraft_geom::Unit::default().pt_per_unit();
let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default();
let cell = t.rows.get(r).and_then(|x| x.cells.get(c)).map(|x| x.props.clone()).unwrap_or_default();
let tw = wordcraft_engine::cmd::page::sect(s).text_width();
let width = t.props.width_pct.filter(|p| *p > 0.0).map(|p| tw * p.min(100.0) / 100.0).or(t.props.width.filter(|w| *w > 0.0));
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
Some(TableForm {
width_on: width.is_some(),
width: fin(width.unwrap_or(tw)) / k,
align: match t.props.align {
Some(Align::Center) => "center",
Some(Align::Right) => "right",
_ => "left",
}
.into(),
indent: fin(t.props.indent.unwrap_or(0.0)) / k,
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,
allow_break: !row.cant_split,
header_row: row.header,
column_width: fin(t.grid.get(t.grid_col(r, c)).copied().unwrap_or(72.0)) / k,
cell_width_on: cell.width.is_some(),
cell_width: fin(cell.width.unwrap_or(72.0)) / k,
valign: match cell.valign {
VAlign::Center => "center",
VAlign::Bottom => "bottom",
_ => "top",
}
.into(),
})
}
/// `table.properties` parameters for what differs from `basis` (empty when nothing does).
pub fn changes(&self, basis: &TableForm) -> Value {
let k = wordcraft_geom::Unit::default().pt_per_unit();
let moved = |a: f32, b: f32| (a - b).abs() > 1e-4;
let mut v = serde_json::Map::new();
if self.width_on != basis.width_on || (self.width_on && moved(self.width, basis.width)) {
v.insert("width".into(), if self.width_on { json!(self.width * k) } else { Value::Null });
}
if self.align != basis.align {
v.insert("align".into(), json!(self.align));
}
if moved(self.indent, basis.indent) {
v.insert("indent".into(), json!(self.indent * k));
}
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))
{
v.insert("rowHeight".into(), if self.row_height_on { json!(self.row_height * k) } else { Value::Null });
v.insert("rowHeightRule".into(), json!(if self.row_exact { "exact" } else { "atLeast" }));
}
if self.allow_break != basis.allow_break {
v.insert("allowBreak".into(), json!(self.allow_break));
}
if self.header_row != basis.header_row {
v.insert("headerRow".into(), json!(self.header_row));
}
if moved(self.column_width, basis.column_width) {
v.insert("columnWidth".into(), json!(self.column_width * k));
}
if self.cell_width_on != basis.cell_width_on || (self.cell_width_on && moved(self.cell_width, basis.cell_width)) {
v.insert("cellWidth".into(), if self.cell_width_on { json!(self.cell_width * k) } else { Value::Null });
}
if self.valign != basis.valign {
v.insert("valign".into(), json!(self.valign));
}
Value::Object(v)
}
}
/// The address parts Match Fields lists, with their keys in `mailings.matchFields`' `address`.
@@ -351,6 +459,7 @@ impl Dialog {
Dialog::MergeRule { .. } => "ruleIf",
Dialog::MatchFields { .. } => "matchFields",
Dialog::CheckErrors { .. } => "checkErrors",
Dialog::TableProperties { .. } => "tableProperties",
}
}
@@ -455,6 +564,10 @@ impl Dialog {
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
}
"commands" => Dialog::Commands { query: String::new() },
"tableProperties" => {
let form = Box::new(TableForm::read(app)?);
Dialog::TableProperties { basis: form.clone(), form }
}
"pasteSpecial" => Dialog::paste_special(app, &json!({})),
"about" => Dialog::About { tab: 0 },
"contributors" => Dialog::About { tab: 1 },
@@ -602,6 +715,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::MergeRule { .. } => "If…Then…Else",
Dialog::MatchFields { .. } => "Match Fields",
Dialog::CheckErrors { .. } => "Check for Errors",
Dialog::TableProperties { .. } => "Table Properties",
};
egui::Window::new(tl!(title))
.id(egui::Id::new(("dialog", title)))
@@ -845,6 +959,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::TableProperties { form, basis } => {
table_properties(ui, form);
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let changes = form.changes(basis);
if changes.as_object().is_some_and(|m| !m.is_empty()) {
let _ = app.run("table.properties", changes);
}
}
ok || cancel
}
Dialog::InsertTable { rows, cols } => {
egui::Grid::new("it").num_columns(2).show(ui, |ui| {
ui.label(tl!("Number of columns:"));
@@ -1461,6 +1586,69 @@ fn recipient_list(app: &mut WordApp, ui: &mut Ui, fields: &mut Vec<String>, rows
/// The Paragraph dialog's OK. `align`, `left` and `right` are as seen on the page; `flags` are
/// keep with next, keep lines together, page break before, widow/orphan control.
#[allow(clippy::too_many_arguments)]
/// The Table Properties dialog's fields: Table, Row, Column and Cell sections.
fn table_properties(ui: &mut Ui, f: &mut TableForm) {
let unit = wordcraft_geom::Unit::default();
fn len(v: &mut f32, lo: f32, unit: wordcraft_geom::Unit) -> egui::DragValue<'_> {
egui::DragValue::new(v).speed(0.01).range(lo..=22.0).suffix(unit.suffix()).max_decimals(2)
}
let heading = |ui: &mut Ui, s: &str| {
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
};
heading(ui, "Table");
egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.checkbox(&mut f.width_on, tl!("Preferred width:"));
ui.add_enabled(f.width_on, len(&mut f.width, 0.1, unit));
ui.end_row();
ui.label(tl!("Alignment:"));
ui.horizontal(|ui| {
for (v, l) in [("left", "Left"), ("center", "Center"), ("right", "Right")] {
ui.radio_value(&mut f.align, v.to_string(), tl!(l));
}
});
ui.end_row();
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();
});
heading(ui, "Row");
egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.checkbox(&mut f.row_height_on, tl!("Specify height:"));
ui.horizontal(|ui| {
ui.add_enabled(f.row_height_on, len(&mut f.row_height, 0.02, unit));
ui.add_enabled_ui(f.row_height_on, |ui| {
egui::ComboBox::from_id_salt("tp_rule").selected_text(tl!(if f.row_exact { "Exactly" } else { "At least" })).show_ui(ui, |ui| {
ui.selectable_value(&mut f.row_exact, false, tl!("At least"));
ui.selectable_value(&mut f.row_exact, true, tl!("Exactly"));
});
});
});
ui.end_row();
});
ui.checkbox(&mut f.allow_break, tl!("Allow row to break across pages"));
ui.checkbox(&mut f.header_row, tl!("Repeat as header row on each page"));
heading(ui, "Column");
egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Preferred width:"));
ui.add(len(&mut f.column_width, 0.1, unit));
ui.end_row();
});
heading(ui, "Cell");
egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.checkbox(&mut f.cell_width_on, tl!("Preferred width:"));
ui.add_enabled(f.cell_width_on, len(&mut f.cell_width, 0.1, unit));
ui.end_row();
ui.label(tl!("Vertical alignment:"));
ui.horizontal(|ui| {
for (v, l) in [("top", "Top"), ("center", "Center"), ("bottom", "Bottom")] {
ui.radio_value(&mut f.valign, v.to_string(), tl!(l));
}
});
ui.end_row();
});
}
fn apply_paragraph(
app: &mut WordApp,
rtl: bool,
+15 -1
View File
@@ -9,7 +9,6 @@
# Neutral Spanish, understood across Spain and Latin America. Commands are infinitives (Guardar,
# Copiar); second-person text uses tú; labels use sentence case; product and format names stay as
# they are (WordCraft, PDF, Markdown, MCP …).
#
# Clean room: every entry was written from the plain meaning of the English label in ordinary
# computing vocabulary. No Microsoft Word (or other product) localisation, string table or
# translated UI was consulted or copied; add entries the same way.
@@ -1018,6 +1017,21 @@
Dark Oscuro
Use system setting Usar la configuración del sistema
A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto.
Height: Alto:
Width: Ancho:
Row Fila
Cell Celda
Preferred width: Ancho preferido:
Alignment: Alineación:
Indent from left: Sangría desde la izquierda:
Specify height: Especificar alto:
Exactly Exactamente
At least Al menos
Allow row to break across pages Permitir dividir la fila entre páginas
Repeat as header row on each page Repetir como fila de encabezado en cada página
Vertical alignment: Alineación vertical:
Top Arriba
Bottom Abajo
Alt text Texto alternativo
Alt: Alt.:
W: An.:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+15
View File
@@ -1001,6 +1001,21 @@
Dark ダーク
Use system setting システム設定を使用
A file dialog is already open. ファイル ダイアログが既に開いています。
Height: 高さ:
Width: 幅:
Row 行
Cell セル
Preferred width: 推奨幅:
Alignment: 配置:
Indent from left: 左インデント:
Specify height: 高さを指定:
Exactly 固定値
At least 最小値
Allow row to break across pages ページ間で行を分割する
Repeat as header row on each page 各ページに見出し行として繰り返す
Vertical alignment: 垂直方向の配置:
Top 上
Bottom 下
Alt text 代替テキスト
Alt: 代替テキスト:
W: 幅:
Can't render this file because it contains an unexpected character in line 416 and column 13.
+15
View File
@@ -1013,6 +1013,21 @@
Dark Escuro
Use system setting Usar a configuração do sistema
A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta.
Height: Altura:
Width: Largura:
Row Linha
Cell Célula
Preferred width: Largura preferencial:
Alignment: Alinhamento:
Indent from left: Recuo à esquerda:
Specify height: Especificar altura:
Exactly Exatamente
At least Pelo menos
Allow row to break across pages Permitir quebra de linha entre páginas
Repeat as header row on each page Repetir como linha de cabeçalho em cada página
Vertical alignment: Alinhamento vertical:
Top Superior
Bottom Inferior
Alt text Texto alternativo
Alt: Alt.:
W: L:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+15
View File
@@ -1012,6 +1012,21 @@
Type the recipients, or load them from a file. Unesite primaoce ili ih učitajte iz fajla.
Use an Existing List… Koristi postojeću listu…
A file dialog is already open. Dijalog za fajlove je već otvoren.
Height: Visina:
Width: Širina:
Row Red
Cell Ćelija
Preferred width: Željena širina:
Alignment: Poravnanje:
Indent from left: Uvlačenje sleva:
Specify height: Zadaj visinu:
Exactly Tačno
At least Najmanje
Allow row to break across pages Dozvoli prelom reda između stranica
Repeat as header row on each page Ponovi kao red zaglavlja na svakoj stranici
Vertical alignment: Vertikalno poravnanje:
Top Gore
Bottom Dole
Alt text Alternativni tekst
Alt: Alt. tekst:
W: Š:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+15 -1
View File
@@ -10,7 +10,6 @@
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a
# trailing "…" in one needs one in the other.
# Lines starting with # and blank lines are ignored. A missing entry shows in English.
#
# Clean room: every entry was written from the plain meaning of the English label in ordinary
# computing vocabulary. No Microsoft Word (or other product) localisation, string table or
# translated UI was consulted or copied; add entries the same way.
@@ -1017,6 +1016,21 @@
Type the recipients, or load them from a file. Унесите примаоце или их учитајте из фајла.
Use an Existing List… Користи постојећу листу…
A file dialog is already open. Дијалог за фајлове је већ отворен.
Height: Висина:
Width: Ширина:
Row Ред
Cell Ћелија
Preferred width: Жељена ширина:
Alignment: Поравнање:
Indent from left: Увлачење слева:
Specify height: Задај висину:
Exactly Тачно
At least Најмање
Allow row to break across pages Дозволи прелом реда између страница
Repeat as header row on each page Понови као ред заглавља на свакој страници
Vertical alignment: Вертикално поравнање:
Top Горе
Bottom Доле
Alt text Алтернативни текст
Alt: Алт. текст:
W: Ш:
Can't render this file because it contains an unexpected character in line 11 and column 25.
+15
View File
@@ -1013,6 +1013,21 @@
Dark Темна
Use system setting Як у системі
A file dialog is already open. Діалогове вікно файлу вже відкрито.
Height: Висота:
Width: Ширина:
Row Рядок
Cell Клітинка
Preferred width: Бажана ширина:
Alignment: Вирівнювання:
Indent from left: Відступ ліворуч:
Specify height: Указати висоту:
Exactly Точно
At least Принаймні
Allow row to break across pages Дозволити розрив рядка між сторінками
Repeat as header row on each page Повторювати як рядок заголовка на кожній сторінці
Vertical alignment: Вертикальне вирівнювання:
Top Угорі
Bottom Унизу
Alt text Альтернативний текст
Alt: Альт.:
W: Ш:
Can't render this file because it contains an unexpected character in line 6 and column 31.
+15
View File
@@ -1012,6 +1012,21 @@
Dark 深色
Use system setting 使用系统设置
A file dialog is already open. 已有一个文件对话框处于打开状态。
Height: 高度:
Width: 宽度:
Row 行
Cell 单元格
Preferred width: 首选宽度:
Alignment: 对齐方式:
Indent from left: 左缩进:
Specify height: 指定高度:
Exactly 固定值
At least 最小值
Allow row to break across pages 允许跨页断行
Repeat as header row on each page 在各页重复作为标题行
Vertical alignment: 垂直对齐方式:
Top 顶端
Bottom 底端
Alt text 替代文字
Alt: 替代文字:
W: 宽:
Can't render this file because it contains an unexpected character in line 6 and column 25.
+15
View File
@@ -1002,6 +1002,21 @@
Dark 深色
Use system setting 使用系統設定
A file dialog is already open. 已有一個檔案對話方塊開啟中。
Height: 高度:
Width: 寬度:
Row 列
Cell 儲存格
Preferred width: 慣用寬度:
Alignment: 對齊方式:
Indent from left: 左側縮排:
Specify height: 指定高度:
Exactly 固定
At least 最小
Allow row to break across pages 允許列跨頁中斷
Repeat as header row on each page 在每頁重複作為標題列
Vertical alignment: 垂直對齊方式:
Top 上
Bottom 下
Alt text 替代文字
Alt: 替代文字:
W: 寬:
Can't render this file because it contains an unexpected character in line 417 and column 13.
+32 -2
View File
@@ -952,8 +952,9 @@ fn keeps_everything(name: &str) -> bool {
/// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get
/// the command's own error or default instead): Select Recipients and Edit Recipient List without
/// data, Insert Merge Field without a field, Find Recipient without text, and the If and Skip
/// Record If rules (or Rules with no rule at all) without a field.
/// data, Insert Merge Field without a field, Find Recipient without text, the If and Skip
/// Record If rules (or Rules with no rule at all) without a field, and Table Properties without
/// settings.
fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
let has = |k: &str| params.get(k).is_some_and(|v| !v.is_null());
let rule = params.get("rule").and_then(Value::as_str).map(str::to_ascii_uppercase);
@@ -967,6 +968,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"mailings.editRecipients" if !has("rows") => Some("recipientList"),
"mailings.insertField" if !has("field") => Some("insertMergeField"),
"mailings.findRecipient" if !has("text") => Some("findRecipient"),
// Table Properties without settings shows the dialog (with settings it applies them).
"table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"),
_ => None,
}
}
@@ -1051,6 +1054,33 @@ mod tests {
WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default())
}
/// Table Layout › Properties (#44) opens the dialog from the ribbon; OK sends only what
/// changed, as one `table.properties` step. Scripts with settings never see the dialog.
#[test]
fn table_properties_button_opens_the_dialog() {
let mut a = app();
a.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap();
a.run("table.properties", json!({})).unwrap();
let Some(dialogs::Dialog::TableProperties { form, basis }) = a.dialog.clone() else { panic!("no dialog: {:?}", a.dialog) };
assert_eq!(form.changes(&basis), json!({}), "untouched, nothing changes");
let mut f = (*form).clone();
f.align = "center".into();
f.row_height_on = true;
f.row_height = 0.5;
f.row_exact = true;
f.header_row = true;
let changes = f.changes(&basis);
assert_eq!(changes, json!({"align": "center", "rowHeight": 36.0, "rowHeightRule": "exact", "headerRow": true}));
a.dialog = None;
a.run("table.properties", changes).unwrap();
assert!(a.dialog.is_none(), "settings apply without the dialog");
let (tp, _, _) = a.session.sel.focus.path.cell().unwrap();
let t = a.session.doc.table(a.session.sel.focus.story, &tp).unwrap();
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");
}
/// Issue #139: Ctrl+wheel over the page didn't zoom.
#[test]
fn ctrl_wheel_over_canvas_zooms() {
+51
View File
@@ -1306,6 +1306,56 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Table Layout › Cell Size: the caret cell's row height and column width, in inches. They show
/// the size on the page (a row without a set height shows the height its text gives it) and
/// editing runs `table.rowHeight` / `table.columnWidth`; dragging one is a single Undo.
fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) {
let story = app.session.sel.focus.story;
let Some((tp, r, c)) = app.session.sel.focus.path.cell() else { return };
let Some(t) = app.session.doc.table(story, &tp) else { return };
let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default();
let stored_w = t.grid.get(t.grid_col(r, c)).copied();
let laid = app.session.layout().pages.iter().flat_map(|pg| pg.items.iter()).find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st } if *table == tp && *row == r && *cell == c && *st == story => {
Some(*rect)
}
_ => None,
});
let unit = wordcraft_geom::Unit::default();
let k = unit.pt_per_unit();
let h0 = row.height.or(laid.map(|x| x.h)).unwrap_or(0.0);
let w0 = laid.map(|x| x.w).or(stored_w).unwrap_or(0.0);
let exact = row.height_rule == wordcraft_doc::props::HeightRule::Exact;
// The edited value in points, and whether it continues a drag (joins the previous Undo step).
let boxed = |ui: &mut Ui, label: &str, pt: f32| -> Option<(f32, bool)> {
let mut out = None;
crate::widgets::row(ui, |ui| {
ui.add_sized(vec2(48.0, 18.0), egui::Label::new(egui::RichText::new(tl!(label)).small()));
let mut v = pt / k;
let r = ui.add_sized(vec2(72.0, 18.0), egui::DragValue::new(&mut v).speed(0.01).range(0.02..=22.0).suffix(unit.suffix()).max_decimals(2));
if r.changed() {
out = Some((v * k, r.dragged() && !r.drag_started()));
}
});
ui.add_space(2.0);
out
};
stack(ui, |ui| {
if let Some((h, join)) = boxed(ui, "Height:", h0) {
if join {
app.session.join_next_undo();
}
let _ = app.run("table.rowHeight", json!({"height": h, "rule": if exact { "exact" } else { "atLeast" }}));
}
if let Some((w, join)) = boxed(ui, "Width:", w0) {
if join {
app.session.join_next_undo();
}
let _ = app.run("table.columnWidth", json!({"width": w}));
}
});
}
fn table_layout(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Table", None, app, |ui, app| {
stack(ui, |ui| {
@@ -1346,6 +1396,7 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) {
mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
});
cell_size_boxes(ui, app);
stack(ui, |ui| {
small(ui, app, "distributeRows", Some("Distribute Rows"), "Distribute Rows", "table.distributeRows", json!({}), false);
small(ui, app, "distributeCols", Some("Distribute Columns"), "Distribute Columns", "table.distributeColumns", json!({}), false);
+2 -1
View File
@@ -17,7 +17,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; columns don't balance; long footnotes don't continue; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) |
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 7 | **Dialog depth**: 16 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Define Multilevel List, Table Properties, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
| 7 | **Dialog depth**: 16 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
| 10 | **Objects**: no group (#267 open), rotation handle, contour (tight/through) wrap, shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
@@ -65,6 +65,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) |
| 2026-10-10 | trivial | Table Properties dialog landed (#44); removed from the dialog-depth list |
| 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list |
| 2026-10-10 | minor | Beta note restated: full number back to the additive ~60% (method aligned with the standard, no new evidence) |
| 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours |
+2 -1
View File
@@ -33,7 +33,7 @@ font can't match Word's metrics.
| Body top below a tall header, footer pushes body up | ✅ | #138 | — |
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | **A long note doesn't continue onto the next page**; no continuation separator/notice; per-section restart partial | 4–6 |
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — |
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; autofit algorithm differs from Word's on mixed widths | 4–6 |
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 |
| Floating tables (`w:tblpPr`) | 🟡 | #137; overlap rules untested | 2–3 |
| Nested tables | 🟡 | render; polish missing | 2–4 |
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — |
@@ -68,4 +68,5 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | AutoFit Contents measures the text (#44) |
| 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues |
+2 -1
View File
@@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Quick Access Toolbar | Undo, Redo, Repeat, AutoSave | customizable | 50% | 2–3 |
| Customize Ribbon / Customize Keyboard | ❌ | ✅ | 0% | 6–10 |
| Keyboard shortcuts | **101** bound in `CommandSpec::key` (measured) | ~250 default shortcuts | ~60% | 4–6 |
| Modal dialogs | **16** (`Dialog` enum: Font, Paragraph, Find, Go To, Insert Table, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New/Modify Style, Commands, About, Save Changes) plus panes and menus for the rest | ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Caption, Index, TOC options, Citation, Define Multilevel List, Table Properties, Track Changes Options, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, Mail Merge, AutoCorrect, Options panes…) | ~40% | 20–30 |
| Modal dialogs | **17** (`Dialog` enum: Font, Paragraph, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New/Modify Style, Commands, About, Save Changes) plus panes and menus for the rest | ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Caption, Index, TOC options, Citation, Define Multilevel List, Track Changes Options, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, Mail Merge, AutoCorrect, Options panes…) | ~40% | 20–30 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions; objects and tables partial | rich per context | 55% | 3–5 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 |
@@ -36,5 +36,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) |
| 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |
| 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory |