diff --git a/ROADMAP.md b/ROADMAP.md index cf45fca..e4303db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7a6a408..133ce52 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -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 { .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 { 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 { 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> { + 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())) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07d610b..66434a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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] diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index cea9db7..b96a8c6 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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)] diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index ae65570..01cf37e 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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 { + 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())) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7268b30..22fe157 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, 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, + #[serde(skip)] + basis: Box, + }, +} + +/// 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 { + 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, 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, diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 66f20a0..6aead4f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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.: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 66a05e7..a835285 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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: 幅: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 43d5f54..8aec197 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 85bb101..d0fb785 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -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: Š: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e36072c..925c6b3 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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: Ш: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8e80d1c..cdf03b3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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: Ш: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5af0775..2608695 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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: 宽: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f92a86a..baf569b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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: 寬: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 313cd83..49dbf43 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 73c4909..fb3dda4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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); diff --git a/docs/gaps.md b/docs/gaps.md index 48f3209..5f2a56c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 93b5d6d..1ad8d5b 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a86672f..a0a441f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |