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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:17:51 +03:00
co-authored by Cursor
parent 457406a7a5
commit f3ca5a63b9
5 changed files with 300 additions and 16 deletions
+173 -10
View File
@@ -55,6 +55,33 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
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<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
let cells: Option<Vec<(usize, usize)>> = 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<Value> {
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<Value> {
})
}
const LEFT: usize = 0;
const RIGHT: usize = 1;
const TOP: usize = 2;
const BOTTOM: usize = 3;
fn borders(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
// 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<Value> {
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<Value> {
with_table(s, p, "table.distribute", move |t| {
if rows {
+63
View File
@@ -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());
}
+3
View File
@@ -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(),
+57 -2
View File
@@ -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();
}
}
});
});
});
});
+4 -4
View File
@@ -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 `<!-- SUMMARY -->
**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 `<!-- SUMMARY -->
| 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 |