From f3ca5a63b9ab5e47875651a1f6da224b68630af8 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:17:51 +0300 Subject: [PATCH] Table: borders, cell margins and cell text alignment The model, renderer and PPTX already carried cell borders, diagonals, margins and vertical alignment, but nothing set them. New commands: table.borders (all, outside, inside, inside horizontal/vertical, each side, diagonals or none, with pen colour, width and dash; the facing side of a neighbouring cell follows, as in PowerPoint), table.cellMargins (Normal, None, Narrow, Wide or per side) and table.cellAlign. They act on the given cells, the selected range, the cell with the caret, or the whole table. Table Design gains a Borders menu with pen weight, style and colour; Table Layout's Top/Center/Bottom now align cell text (they set the text body's anchor, which cells override) and gain Cell Margins. Co-authored-by: Cursor --- crates/engine/src/cmd/table.rs | 183 +++++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 63 ++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/ribbon.rs | 59 ++++++++++- docs/parity-checklist.md | 8 +- 5 files changed, 300 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..750e389b 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -55,6 +55,33 @@ pub fn specs() -> Vec { has_selection, cell_fill ), + cmd!( + "table.borders", + "Borders", + ["Table Design", "Draw Borders"], + None, + "{which: all|outside|inside|insideH|insideV|top|bottom|left|right|diagDown|diagUp|none, color?, width?: pt, dash?: solid|dash|dot…, cells?: [[r,c],…] (default: selected cells or all), id?}", + has_selection, + borders + ), + cmd!( + "table.cellMargins", + "Cell Margins", + ["Table Layout", "Alignment"], + None, + "{preset?: normal|none|narrow|wide, left?, right?, top?, bottom?: pt, cells?, id?}", + has_selection, + cell_margins + ), + cmd!( + "table.cellAlign", + "Align Cell Text", + ["Table Layout", "Alignment"], + None, + "{anchor: top|middle|bottom, cells?, id?}", + has_selection, + cell_align + ), cmd!("table.distributeRows", "Distribute Rows", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(s, p, true)), cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute( s, p, false @@ -195,20 +222,27 @@ fn options(s: &mut Session, p: &Value) -> Result { }) } -fn cell_fill(s: &mut Session, p: &Value) -> Result { - let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) }; +/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the +/// caret, else every cell. +fn target_cells(s: &Session, p: &Value) -> Result Vec<(usize, usize)> + use<>> { let cells: Option> = p .get("cells") .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect()); - let sel = s.doc()?.selection.cells; + .map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect()); + let st = s.doc()?; + let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c))); + Ok(move |t: &Table| match (&cells, sel) { + (Some(c), _) => c.clone(), + (None, Some((a, b))) => (a.0.min(b.0)..=a.0.max(b.0)).flat_map(|r| (a.1.min(b.1)..=a.1.max(b.1)).map(move |c| (r, c))).collect(), + _ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(), + }) +} + +fn cell_fill(s: &mut Session, p: &Value) -> Result { + let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) }; + let targets = target_cells(s, p)?; with_table(s, p, "table.cellFill", move |t| { - let list: Vec<(usize, usize)> = match (&cells, sel) { - (Some(c), _) => c.clone(), - (None, Some((_, a, b))) => (a.0.min(b.0)..=a.0.max(b.0)).flat_map(|r| (a.1.min(b.1)..=a.1.max(b.1)).map(move |c| (r, c))).collect(), - _ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(), - }; - for (r, c) in list { + for (r, c) in targets(t) { if let Some(cell) = t.cell_mut(r, c) { cell.fill.clone_from(&fill); } @@ -217,6 +251,135 @@ fn cell_fill(s: &mut Session, p: &Value) -> Result { }) } +const LEFT: usize = 0; +const RIGHT: usize = 1; +const TOP: usize = 2; +const BOTTOM: usize = 3; + +fn borders(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::Line; + let which = str_param(p, "which").unwrap_or("all").to_string(); + const WHICH: [&str; 12] = ["all", "outside", "inside", "insideH", "insideV", "top", "bottom", "left", "right", "diagDown", "diagUp", "none"]; + if !WHICH.contains(&which.as_str()) { + return Err(bad("table.borders", format!("unknown `which` {which:?}; one of {}", WHICH.join(", ")))); + } + let color = color_param(p, "color").unwrap_or_else(|| deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))); + let width = f64_param(p, "width").filter(|w| w.is_finite()).unwrap_or(1.0).clamp(0.0, 100.0); + let mut line = Line::solid(color, width); + if let Some(d) = str_param(p, "dash").filter(|d| *d != "solid") { + if !deckcraft_model::style::Dash::PRESETS.contains(&d) { + return Err(bad("table.borders", format!("unknown dash {d:?}; one of {}", deckcraft_model::style::Dash::PRESETS.join(", ")))); + } + line.dash = Some(deckcraft_model::style::Dash::Preset { name: d.to_string() }); + } + // "none" clears to no line, so the table style's own lines don't show through either. + let line = if which == "none" { Line::none() } else { line }; + let targets = target_cells(s, p)?; + with_table(s, p, "table.borders", move |t| { + let list = targets(t); + let (Some(r0), Some(r1), Some(c0), Some(c1)) = + (list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max()) + else { + return Ok(()); + }; + let (rows, cols) = (t.n_rows(), t.n_cols()); + // Set one side of a cell and the facing side of its neighbour, as PowerPoint does. + let set = |t: &mut Table, r: usize, c: usize, side: usize| { + if let Some(cell) = t.cell_mut(r, c) { + cell.borders[side] = Some(line.clone()); + } + let n = match side { + LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)), + RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)), + TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)), + _ => (r + 1 < rows).then_some((r + 1, c, TOP)), + }; + if let Some((r, c, side)) = n + && let Some(cell) = t.cell_mut(r, c) + { + cell.borders[side] = Some(line.clone()); + } + }; + for (r, c) in list { + if r < r0 || r > r1 || c < c0 || c > c1 { + continue; + } + let outer = [c == c0, c == c1, r == r0, r == r1]; + for side in [LEFT, RIGHT, TOP, BOTTOM] { + let horizontal = side == TOP || side == BOTTOM; + let on = match which.as_str() { + "all" | "none" => true, + "outside" => outer[side], + "inside" => !outer[side], + "insideH" => horizontal && !outer[side], + "insideV" => !horizontal && !outer[side], + "top" => side == TOP && outer[side], + "bottom" => side == BOTTOM && outer[side], + "left" => side == LEFT && outer[side], + "right" => side == RIGHT && outer[side], + _ => false, + }; + if on { + set(t, r, c, side); + } + } + if let Some(cell) = t.cell_mut(r, c) { + match which.as_str() { + "diagDown" => cell.diag_down = Some(line.clone()), + "diagUp" => cell.diag_up = Some(line.clone()), + "none" => { + cell.diag_down = None; + cell.diag_up = None; + } + _ => {} + } + } + } + Ok(()) + }) +} + +fn cell_margins(s: &mut Session, p: &Value) -> Result { + // PowerPoint's presets (left/right, top/bottom). + let base = match str_param(p, "preset") { + Some("none") => [0.0; 4], + Some("narrow") => [3.6; 4], + Some("wide") => [14.4, 14.4, 14.4, 14.4], + Some("normal") | None => [7.2, 7.2, 3.6, 3.6], + Some(o) => return Err(bad("table.cellMargins", format!("unknown preset {o:?}; one of normal, none, narrow, wide"))), + }; + let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0)); + let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)]; + let targets = target_cells(s, p)?; + with_table(s, p, "table.cellMargins", move |t| { + for (r, c) in targets(t) { + if let Some(cell) = t.cell_mut(r, c) { + cell.margins = Some(m); + } + } + Ok(()) + }) +} + +fn cell_align(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::Anchor; + let a = match str_param(p, "anchor").unwrap_or("top") { + "middle" | "center" | "ctr" => Anchor::Middle, + "bottom" | "b" => Anchor::Bottom, + "top" | "t" => Anchor::Top, + o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))), + }; + let targets = target_cells(s, p)?; + with_table(s, p, "table.cellAlign", move |t| { + for (r, c) in targets(t) { + if let Some(cell) = t.cell_mut(r, c) { + cell.anchor = Some(a); + } + } + Ok(()) + }) +} + fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result { with_table(s, p, "table.distribute", move |t| { if rows { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..02b3ffb4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,66 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn table_borders_margins_and_cell_alignment() { + let mut s = session(); + let id = s.execute("insert.table", &json!({"rows": 3, "cols": 3, "rect": [100.0, 100.0, 300.0, 120.0]})).unwrap()["id"].clone(); + let table = |s: &Session| { + let st = s.doc().unwrap(); + let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone(); + let deckcraft_model::ShapeKind::Table(t) = sh.kind else { panic!("not a table") }; + t + }; + // Outside borders round the top-left 2×2 block; neighbours get the facing side too. + let block = json!([[0, 0], [0, 1], [1, 0], [1, 1]]); + s.execute("table.borders", &json!({"id": id, "which": "outside", "cells": block, "color": "#FF0000", "width": 2.0, "dash": "dash"})).unwrap(); + let t = table(&s); + let w = |r: usize, c: usize, side: usize| t.rows[r].cells[c].borders[side].as_ref().and_then(|l| l.width); + assert_eq!(w(0, 0, 0), Some(2.0)); + assert_eq!(w(0, 0, 2), Some(2.0)); + assert_eq!(w(0, 0, 1), None, "inside edge untouched"); + assert_eq!(w(1, 1, 1), Some(2.0)); + assert_eq!(w(1, 2, 0), Some(2.0), "neighbour's facing side"); + assert_eq!(w(2, 1, 2), Some(2.0), "neighbour below"); + assert!(matches!(&t.rows[0].cells[0].borders[0].as_ref().unwrap().dash, Some(deckcraft_model::style::Dash::Preset { name }) if name == "dash")); + // Inside horizontal only for the block. + s.execute("table.borders", &json!({"id": id, "which": "insideH", "cells": block})).unwrap(); + let t = table(&s); + assert!(t.rows[0].cells[0].borders[3].is_some() && t.rows[1].cells[0].borders[2].is_some()); + assert!(t.rows[0].cells[0].borders[1].is_none()); + // Diagonal and none. + s.execute("table.borders", &json!({"id": id, "which": "diagDown", "cells": [[2, 2]]})).unwrap(); + assert!(table(&s).rows[2].cells[2].diag_down.is_some()); + s.execute("table.borders", &json!({"id": id, "which": "none"})).unwrap(); + let t = table(&s); + assert!( + t.rows + .iter() + .flat_map(|r| &r.cells) + .all(|c| c.diag_down.is_none() && c.borders.iter().all(|b| b.as_ref().is_some_and(|l| l.fill == Some(deckcraft_model::Fill::None)))) + ); + assert!(s.execute("table.borders", &json!({"id": id, "which": "sideways"})).is_err()); + // Margins and vertical alignment. + s.execute("table.cellMargins", &json!({"id": id, "preset": "wide", "cells": [[0, 0]]})).unwrap(); + s.execute("table.cellMargins", &json!({"id": id, "preset": "none", "left": 5.0, "cells": [[0, 1]]})).unwrap(); + s.execute("table.cellAlign", &json!({"id": id, "anchor": "middle"})).unwrap(); + let t = table(&s); + assert_eq!(t.rows[0].cells[0].margins, Some([14.4; 4])); + assert_eq!(t.rows[0].cells[1].margins, Some([5.0, 0.0, 0.0, 0.0])); + assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.anchor == Some(deckcraft_model::text::Anchor::Middle))); + // Survives PPTX. + let p = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap(); + let bt = back + .slides + .iter() + .flat_map(|sl| &sl.shapes) + .find_map(|sh| if let deckcraft_model::ShapeKind::Table(t) = &sh.kind { Some(t.clone()) } else { None }) + .unwrap(); + assert_eq!(bt.rows[0].cells[0].margins, Some([14.4; 4])); + assert_eq!(bt.rows[1].cells[1].anchor, Some(deckcraft_model::text::Anchor::Middle)); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(table(&s).rows[0].cells[0].anchor.is_none()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..357855f4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -178,6 +178,8 @@ pub struct Perf { } pub struct SlideApp { + /// Table Design's border pen (`color`, `width`, `dash` for `table.borders`). + pub table_pen: serde_json::Value, pub session: Session, pub services: Services, pub ui: UiState, @@ -233,6 +235,7 @@ impl SlideApp { pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); SlideApp { + table_pen: serde_json::json!({"width": 1.0}), session, services, ui: UiState::default(), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..2982a6f0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2224,6 +2224,53 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { } }); }); + group(ui, |ui| { + let r = big_button(ui, Icon::Borders, "Borders", true); + egui::Popup::menu(&r).show(|ui| { + let pen = app.table_pen.clone(); + for (label, which) in [ + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ("Top Border", "top"), + ("Bottom Border", "bottom"), + ("Left Border", "left"), + ("Right Border", "right"), + ("Inside Horizontal Border", "insideH"), + ("Inside Vertical Border", "insideV"), + ("Diagonal Down Border", "diagDown"), + ("Diagonal Up Border", "diagUp"), + ] { + let mut p = pen.clone(); + p["which"] = json!(which); + menu_item(app, ui, Icon::Borders, label, "table.borders", p); + } + ui.separator(); + ui.menu_button("Pen Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); + if ui.radio(cur, format!("{w} pt")).clicked() { + app.table_pen["width"] = json!(w); + } + } + }); + ui.menu_button("Pen Style", |ui| { + for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { + let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; + if ui.radio(cur, l).clicked() { + app.table_pen["dash"] = json!(d); + } + } + }); + ui.menu_button("Pen Color", |ui| { + let sc = scheme(app); + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + app.table_pen["color"] = cref_param(&c); + } + }); + }); + }); } pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { @@ -2290,10 +2337,18 @@ fn table_layout(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).clicked() { - run(app, "format.anchor", json!({"anchor": a})); + if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { + run(app, "table.cellAlign", json!({"anchor": a})); } } + ui.menu_button("Cell Margins", |ui| { + for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { + if ui.button(l).clicked() { + run(app, "table.cellMargins", json!({"preset": preset})); + ui.close(); + } + } + }); }); }); }); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..043d2b7b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,12 +3,12 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | +| P1 | 65 | 39 | 20 | 6 | 75% | | P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | @@ -22,7 +22,7 @@ The summary between ` | Format | 83% | | Arrange | 100% | | Text | 80% | -| Tables | 89% | +| Tables | 95% | | Charts | 65% | | SmartArt | 33% | | Pictures | 48% | @@ -169,7 +169,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Tables | Insert/delete rows and columns | P0 | D | | Tables | Merge and split cells | P1 | D | | Tables | Distribute rows/columns, sizes | P1 | D | -| Tables | Cell fill, borders, margins, alignment | P1 | P | +| Tables | Cell fill, borders, margins, alignment | P1 | D | | Tables | Keyboard navigation between cells | P0 | D | | Charts | Insert chart (column, bar, line, area, pie, doughnut, scatter) | P0 | D | | Charts | Other chart types (radar, stock, combo, waterfall, funnel, treemap, sunburst, histogram, box) | P2 | P |