Table styles: every region, a live preview, delete, custom styles first (#146)

Follow-up to the custom table styles work: the New/Modify Table Style
dialog now covers every region a table style formats, shows what the
style will look like, and table styles can be deleted.

- engine: table.newStyle / table.modifyStyle take lastRow (total row),
  firstColumn, lastColumn and bandedColumns regions with the same props
  as the others. New table.deleteStyle removes a custom table style:
  built-in ones are refused, styles based on it are re-based on its own
  base with its formatting folded in (so they look the same), and tables
  using it fall back to Table Grid. Undoable like any command.
- doc/layout: table styles gain fill and cell borders for the first and
  last column and total row, and fill, text and borders for column
  bands; layout applies them in the standard order (column bands, row
  bands, first/last column, header/total row).
- docx: firstCol/lastCol/lastRow/band1Vert shading and cell borders are
  read and written.
- ui: the dialog's "Apply formatting to" lists all seven regions and a
  small live preview table shows the pending changes; Styles menu gets
  Delete Table Style (disabled for built-ins); the gallery shows the
  document's own table styles first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-10 19:26:13 +02:00
co-authored by Claude Opus 5.5
parent 8aa4a25245
commit 2fefdebcb1
16 changed files with 561 additions and 93 deletions
+25
View File
@@ -65,6 +65,21 @@ pub struct TableStyleParts {
pub first_col_chr: CharProps,
pub total_chr: CharProps,
pub total_border_top: Option<Border>,
/// Shading and cell borders of the first column (`firstCol` region).
pub first_col_fill: Option<Rgb>,
pub first_col_borders: Option<Borders>,
/// The last column (`lastCol` region).
pub last_col_fill: Option<Rgb>,
pub last_col_chr: CharProps,
pub last_col_borders: Option<Borders>,
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
/// unset falls back to `total_border_top`.
pub total_fill: Option<Rgb>,
pub total_borders: Option<Borders>,
/// The odd column bands (`band1Vert` region).
pub col_band_fill: Option<Rgb>,
pub col_band_chr: CharProps,
pub col_band_borders: Option<Borders>,
}
impl TableStyleParts {
@@ -91,6 +106,16 @@ impl TableStyleParts {
self.first_col_chr.overlay(&patch.first_col_chr);
self.total_chr.overlay(&patch.total_chr);
self.total_border_top = patch.total_border_top.or(self.total_border_top);
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
self.last_col_chr.overlay(&patch.last_col_chr);
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
self.total_fill = patch.total_fill.or(self.total_fill);
self.total_borders = merge(self.total_borders, patch.total_borders);
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
self.col_band_chr.overlay(&patch.col_band_chr);
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
}
}
+20 -2
View File
@@ -473,10 +473,28 @@ impl Reader<'_> {
t.band_chr = chr;
t.band_borders = cell_borders;
}
Some("firstCol") => t.first_col_chr = chr,
Some("firstCol") => {
t.first_col_chr = chr;
t.first_col_fill = fill;
t.first_col_borders = cell_borders;
}
Some("lastCol") => {
t.last_col_chr = chr;
t.last_col_fill = fill;
t.last_col_borders = cell_borders;
}
Some("lastRow") => {
t.total_chr = chr;
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
t.total_fill = fill;
// The top rule is kept on its own (as built-in styles have it); the other edges
// are the region's borders.
t.total_border_top = cell_borders.and_then(|b| b.top);
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
}
Some("band1Vert") => {
t.col_band_fill = fill;
t.col_band_chr = chr;
t.col_band_borders = cell_borders;
}
_ => {}
}
+12 -3
View File
@@ -665,9 +665,18 @@ fn style_xml(w: &mut W, st: &Style) {
};
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() });
cond(w, "lastRow", &t.total_chr, None, total_top);
cond(w, "firstCol", &t.first_col_chr, None, None);
// The total row's borders, its top edge falling back to the built-in top rule.
let total_borders = match (t.total_borders, t.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
};
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
}
w.close("w:style");
+1
View File
@@ -329,6 +329,7 @@ Table Design|Table Style Options|Table Style Options|table.look
Table Design|Table Styles|Table Styles|table.style
Table Design|Table Styles|New Table Style|table.newStyle
Table Design|Table Styles|Modify Table Style|table.modifyStyle
Table Design|Table Styles|Delete Table Style|table.deleteStyle
Table Design|Table Styles|Shading|table.shading
Table Design|Borders|Borders|table.borders
Table Design|Borders|Border Painter|table.borderPainter
+151 -17
View File
@@ -1,20 +1,28 @@
//! Table Design › Table Styles: create and modify table styles (whole table, header row and
//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6).
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
//! document's style sheet (ECMA-376 §17.7.6).
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
use wordcraft_doc::{Block, Table};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`,
/// `bandedRows`) takes the same shortcuts.
/// The regions a table style formats, by param name: the table style options they follow.
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
/// shortcuts.
macro_rules! style_params {
($head:literal) => {
concat!(
"{",
$head,
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#,
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
)
};
@@ -25,6 +33,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)),
CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style)
.params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)),
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
.params(r#"{"style"?: string (default: the current table's)}"#),
]
}
@@ -43,7 +53,8 @@ fn current_table_style(s: &Session) -> Option<String> {
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
}
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
/// The style `table.newStyle` would add for `v`, validated.
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() {
return Err(CmdError::Params("name is empty".into()));
@@ -56,8 +67,14 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")),
};
let id = s.doc.styles.new_id(&name);
let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
edit(&mut st, v)?;
Ok(st)
}
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_new(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(true) {
@@ -66,11 +83,28 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"id": id}))
}
/// The table style `v` names (`style`), else the current table's.
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
match p::str(v, "style") {
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))),
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())),
}
}
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
let id = match p::str(v, "style") {
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?,
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?,
};
let st = build_modified(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(false) {
apply(s, &id)?;
}
Ok(json!({"id": id}))
}
/// The style `table.modifyStyle` would store for `v`, validated.
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
let id = target(s, v)?;
let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &rename
&& s.doc.styles.find(n).is_some_and(|x| x.id != id)
@@ -96,12 +130,100 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
st.based_on = Some(b);
}
edit(&mut st, v)?;
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(false) {
apply(s, &id)?;
Ok(st)
}
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
/// aren't checked; `None` when the params don't validate.
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
if !v.is_object() {
return None;
}
Ok(json!({"id": id}))
let mut v = v.clone();
let st = if p::str(&v, "style").is_some() {
if let Some(o) = v.as_object_mut() {
o.remove("name");
}
build_modified(s, &v).ok()?
} else {
// A name no style has: the look doesn't depend on it.
v["name"] = json!("\u{1}preview");
build_new(s, &v).ok()?
};
let id = st.id.clone();
let mut sheet = s.doc.styles.clone();
sheet.upsert(st);
sheet.table_style(&id)
}
/// Delete a custom table style. Styles based on it are re-based on its own base with its
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
/// tables get), or no style when the document has no Table Grid.
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
let id = target(s, v)?;
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
if st.builtin {
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
}
let mut rebased = Vec::new();
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
let mut para = st.para.clone();
para.overlay(&child.para);
child.para = para;
let mut chr = st.chr.clone();
chr.overlay(&child.chr);
child.chr = chr;
if child.kind == StyleKind::Table {
let mut parts = st.table.clone().unwrap_or_default();
if let Some(t) = &child.table {
parts.overlay(t);
}
child.table = (parts != TableStyleParts::default()).then_some(parts);
}
child.based_on = st.based_on.clone();
rebased.push(child.id.clone());
}
s.doc.styles.styles.retain(|x| x.id != id);
let fallback = table_style_id(s, "TableGrid");
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
for part in s.doc.parts.values_mut() {
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
}
s.dirty = true;
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
}
/// Tables nest at most this deep for restyling.
const MAX_DEPTH: usize = 32;
/// Whether table `t`, or one nested in it, uses style `id`.
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
t.props.style.as_deref() == Some(id)
|| (depth < MAX_DEPTH
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
}
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
let mut n = 0;
for b in blocks {
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
continue;
}
if let Block::Table(t) = Arc::make_mut(b) {
if t.props.style.as_deref() == Some(from) {
t.props.style = to.map(str::to_string);
n += 1;
}
if depth < MAX_DEPTH {
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
n += restyle(&mut c.blocks, from, to, depth + 1);
}
}
}
}
n
}
/// Apply style `id` to the table at the caret, if any.
@@ -123,6 +245,18 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
if let Some(r) = v.get("bandedRows") {
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
}
if let Some(r) = v.get("lastRow") {
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
}
if let Some(r) = v.get("firstColumn") {
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
}
if let Some(r) = v.get("lastColumn") {
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
}
if let Some(r) = v.get("bandedColumns") {
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
}
if let Some(n) = v.get("bandSize") {
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
}
+70
View File
@@ -1763,3 +1763,73 @@ fn custom_table_style_applies_modifies_and_saves() {
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
}
/// #146: the first/last column, total row and banded column regions apply in layout (later
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
#[test]
fn custom_table_style_column_and_total_regions() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
run(
&mut s,
"table.look",
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
);
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Ledger",
"firstColumn": {"fill": "112233", "bold": true},
"lastColumn": {"fill": "445566"},
"lastRow": {"fill": "778899", "borders": true},
"bandedColumns": {"fill": "ABCDEF"}}),
);
let id = r["id"].as_str().unwrap().to_string();
let fills = |s: &mut Session, c: Rgb| {
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
};
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
assert_eq!(
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
);
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
}
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
#[test]
fn delete_table_style_falls_back_and_undoes() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(style(&s).as_deref(), Some("Base"));
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
let r = run(&mut s, "table.deleteStyle", json!({}));
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
assert!(s.doc.styles.get("Base").is_none());
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
let child = s.doc.styles.table_style("Child").unwrap();
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Base").is_some());
assert_eq!(style(&s).as_deref(), Some("Base"));
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
}
+90 -27
View File
@@ -4,7 +4,7 @@
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
use wordcraft_doc::styles::TableStyleProps;
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
use wordcraft_doc::{StoryRef, Table};
use wordcraft_geom::Rect;
@@ -40,9 +40,9 @@ struct Turned {
across: f32,
/// List counters and equation count before the cell, to lay it out again the same way.
snap: (Counters, u32),
/// Its formatting region (header row, total row, first column, row band) and cell index in
/// the row.
region: (bool, bool, bool, bool),
/// Its formatting region (header row, total row, first column, last column, row band, column
/// band) and cell index in the row.
region: (bool, bool, bool, bool, bool, bool),
ci: usize,
/// Top margin plus top border band.
top: f32,
@@ -95,12 +95,54 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
}
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
/// total row, first column, last column, row band, column band), later regions winning. Row
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
let [header, total, first_col, last_col, band, col_band] = regions;
let row = |b: Option<Borders>| {
b.map(|b| Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
})
};
let col = |b: Option<Borders>| {
b.map(|b| Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
})
};
let mut out = Borders::default();
for (on, b) in [
(col_band, col(p.col_band_borders)),
(band, row(p.band_borders)),
(first_col, col(p.first_col_borders)),
(last_col, col(p.last_col_borders)),
(header, row(p.header_borders)),
(total, row(p.total_borders)),
] {
if on && let Some(b) = b {
out.overlay(&b);
}
}
out
}
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
let style = table_style(ctx, t);
let parts = style.as_ref().map(|s| &s.parts);
// Cell text formatting per (header row, total row, first column, row band) region, built once
// per table.
let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new();
// Cell text formatting per region (header row, total row, first column, last column, row
// band, column band), built once per table.
let mut cell_text: Vec<((bool, bool, bool, bool, bool, bool), CellText)> = Vec::new();
let ncols = t.cols().max(1);
// Column widths.
let mut grid: Vec<f32> = if t.grid.len() == ncols {
@@ -182,15 +224,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
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);
// The header row's or the odd band's own cell borders (conditional formatting).
let region_borders = if is_header {
parts.and_then(|p| p.header_borders)
} else if band {
parts.and_then(|p| p.band_borders)
} else {
None
}
.unwrap_or_default();
for (ci, cell) in row.cells.iter().enumerate() {
let span = cell.span();
let x0 = colx.get(g).copied().unwrap_or(acc);
@@ -198,15 +231,34 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let margins = cell.props.margins.unwrap_or(margins_def);
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
let mut fill = cell.props.shading;
let region = (is_header, is_total, t.props.look.first_column && g == 0, band);
let first_col = t.props.look.first_column && g == 0;
let last_col = t.props.look.last_column && g + span >= ncols && ncols > 1;
let first_cols = usize::from(t.props.look.first_column);
let col_band = t.props.look.banded_columns && !first_col && g.saturating_sub(first_cols).is_multiple_of(2);
let region = (is_header, is_total, first_col, last_col, band, col_band);
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
// column bands, row bands, first/last column, header/total row.
if let Some(p) = parts {
if is_header {
fill = fill.or(p.header_fill);
}
if band && fill.is_none() {
fill = p.band_fill;
}
fill = fill.or(p.fill);
let row_fill = if is_header {
p.header_fill
} else if is_total {
p.total_fill
} else {
None
};
let col_fill = if first_col {
p.first_col_fill
} else if last_col {
p.last_col_fill
} else {
None
};
fill = fill
.or(row_fill)
.or(col_fill)
.or(if band { p.band_fill } else { None })
.or(if col_band { p.col_band_fill } else { None })
.or(p.fill);
}
if let Some(st) = &style
&& !cell_text.iter().any(|(r, _)| *r == region)
@@ -214,12 +266,18 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
// The whole table's formatting, then column, then row conditional formatting
// (later regions win, ECMA-376 §17.7.6).
let mut chr = st.chr.clone();
if col_band {
chr.overlay(&st.parts.col_band_chr);
}
if band {
chr.overlay(&st.parts.band_chr);
}
if region.2 {
if first_col {
chr.overlay(&st.parts.first_col_chr);
}
if last_col {
chr.overlay(&st.parts.last_col_chr);
}
if is_header {
chr.overlay(&st.parts.header_chr);
} else if is_total {
@@ -236,8 +294,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let edge =
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
let cb = cell.props.borders.unwrap_or_default();
let rb = region_borders;
let (first_g, last_g) = (g == 0, g + span >= ncols);
let rb = parts
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
.unwrap_or_default();
let mut borders = Borders {
top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between),
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
@@ -246,7 +306,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
between: None,
inside_v: None,
};
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
if is_total
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
&& let Some(b) = parts.and_then(|p| p.total_border_top)
{
borders.top = Some(b);
}
// Word keeps the cell's text clear of its top border (and the last row's of its bottom
+159 -30
View File
@@ -108,13 +108,13 @@ pub enum Dialog {
name: String,
/// Style id.
based_on: String,
/// Index into `regions`: whole table, header row, banded rows.
/// Index into `regions` (see [`REGION_LABELS`]).
region: usize,
regions: Box<[TableRegion; 3]>,
regions: Box<[TableRegion; 7]>,
/// What the regions showed when the dialog opened (or the base style changed): only
/// changes are sent, so everything else stays inherited.
#[serde(skip)]
basis: Box<[TableRegion; 3]>,
basis: Box<[TableRegion; 7]>,
},
Commands {
query: String,
@@ -236,8 +236,11 @@ pub struct TableRegion {
pub color: String,
}
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
/// The regions of table style `id` as resolved through its based-on chain.
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
let p = &st.parts;
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
@@ -246,17 +249,136 @@ fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
_ => String::new(),
};
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
let mut header_chr = st.chr.clone();
header_chr.overlay(&p.header_chr);
let mut band_chr = st.chr.clone();
band_chr.overlay(&p.band_chr);
// A region's text: the whole table's formatting with the region's over it.
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
let mut c = st.chr.clone();
c.overlay(chr);
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
};
let none = wordcraft_doc::CharProps::default();
Box::new([
TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) },
TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) },
TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) },
region(p.borders, p.fill, &none),
region(p.header_borders, p.header_fill, &p.header_chr),
region(p.total_borders, p.total_fill, &p.total_chr),
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
region(p.band_borders, p.band_fill, &p.band_chr),
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
])
}
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
/// only what changed from `basis`, so the rest stays inherited.
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
let mut v = json!({"name": name.trim(), "basedOn": based_on});
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
let ch = region_changes(r, b);
if ch.as_object().is_some_and(|o| !o.is_empty()) {
v[key] = ch;
}
}
if let Some(id) = id {
v["style"] = json!(id);
}
v
}
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
use wordcraft_doc::props::{Border, Borders};
const COLS: usize = 5;
const ROWS: usize = 5;
const TEXT: [[&str; COLS]; ROWS] = [
["", "Mon", "Tue", "Wed", "Sum"],
["North", "4", "7", "2", "13"],
["South", "6", "1", "5", "12"],
["West", "3", "8", "4", "15"],
["Total", "13", "16", "11", "40"],
];
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
let painter = ui.painter_at(rect);
// The page is white whatever the interface theme.
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
let t = Tokens::get(ui.ctx());
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let Some(st) = style else { return };
let p = &st.parts;
let table = rect.shrink2(vec2(14.0, 10.0));
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
let tb = p.borders.unwrap_or_default();
for (ri, row) in TEXT.iter().enumerate() {
let header = look.header_row && ri == 0;
let total = look.total_row && ri + 1 == ROWS;
let band = look.banded_rows
&& !header
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
for (ci, txt) in row.iter().enumerate() {
let first = look.first_column && ci == 0;
let last = look.last_column && ci + 1 == COLS;
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
// Regions from lowest to highest priority, as layout applies them.
let regions = [
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
(band, p.band_fill, &p.band_chr, p.band_borders),
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
(header, p.header_fill, &p.header_chr, p.header_borders),
(total, p.total_fill, &p.total_chr, p.total_borders),
];
let mut fill = p.fill;
let mut chr = st.chr.clone();
let mut b = Borders {
top: if ri == 0 { tb.top } else { tb.between },
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
left: if ci == 0 { tb.left } else { tb.inside_v },
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
between: None,
inside_v: None,
};
for (on, f, c, rb) in regions {
if !on {
continue;
}
fill = f.or(fill);
chr.overlay(c);
if let Some(rb) = rb {
b.overlay(&Borders { between: None, inside_v: None, ..rb });
}
}
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
b.top = p.total_border_top.or(b.top);
}
if let Some(f) = fill {
painter.rect_filled(cell, 0.0, rgb(f));
}
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
if let Some(e) = e.filter(Border::is_visible) {
let color = e.color.map_or(egui::Color32::BLACK, rgb);
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
}
};
edge(cell.left_top(), cell.right_top(), b.top);
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
edge(cell.left_top(), cell.left_bottom(), b.left);
edge(cell.right_top(), cell.right_bottom(), b.right);
let color = match chr.color {
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
_ => egui::Color32::BLACK,
};
let font = egui::FontId::proportional(11.0);
let pos = cell.left_center() + vec2(4.0, 0.0);
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
if chr.bold.unwrap_or(false) {
// A second pass a hair to the right reads as bold at this size.
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
}
}
}
}
/// The style of the table at the caret, when it is a table style.
fn current_table_style(app: &WordApp) -> Option<String> {
let s = &app.session;
@@ -1027,12 +1149,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.end_row();
ui.label(tl!("Apply formatting to:"));
let names = ["Whole Table", "Header Row", "Banded Rows"];
egui::ComboBox::from_id_salt("tstyle_region")
.width(200.0)
.selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table")))
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
.show_ui(ui, |ui| {
for (i, n) in names.iter().enumerate() {
for (i, n) in REGION_LABELS.iter().enumerate() {
ui.selectable_value(region, i, tl!(n));
}
});
@@ -1059,24 +1180,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.end_row();
});
}
// Live preview: the style as OK would leave it, on the current table's options with
// the region being edited turned on.
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
let mut look = app
.session
.sel
.focus
.path
.cell()
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
.unwrap_or_default();
match *region {
1 => look.header_row = true,
2 => look.total_row = true,
3 => look.first_column = true,
4 => look.last_column = true,
5 => look.banded_rows = true,
6 => look.banded_columns = true,
_ => {}
}
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let mut v = json!({"name": name.trim(), "basedOn": based_on});
for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() {
if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) {
let ch = region_changes(r, b);
if ch.as_object().is_some_and(|o| !o.is_empty()) {
v[key] = ch;
}
}
}
let cmd = match id {
Some(sid) => {
v["style"] = json!(sid);
"table.modifyStyle"
}
None => "table.newStyle",
};
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
if let Err(e) = app.run(cmd, v) {
app.status(e);
return false;
+2
View File
@@ -955,3 +955,5 @@
Apply formatting to: Aplicar formato a:
Whole Table Toda la tabla
Fill color: Color de relleno:
Delete Table Style Eliminar estilo de tabla
Preview Vista previa
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -938,3 +938,5 @@
Apply formatting to: 書式の適用先:
Whole Table 表全体
Fill color: 塗りつぶしの色:
Delete Table Style 表スタイルの削除
Preview プレビュー
Can't render this file because it contains an unexpected character in line 411 and column 13.
+2
View File
@@ -950,3 +950,5 @@
Apply formatting to: Aplicar formatação a:
Whole Table Tabela inteira
Fill color: Cor de preenchimento:
Delete Table Style Excluir estilo de tabela
Preview Visualização
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -950,3 +950,5 @@
Apply formatting to: Застосувати форматування до:
Whole Table Уся таблиця
Fill color: Колір заливки:
Delete Table Style Видалити стиль таблиці
Preview Попередній перегляд
Can't render this file because it contains an unexpected character in line 6 and column 31.
+2
View File
@@ -949,3 +949,5 @@
Apply formatting to: 将格式应用于:
Whole Table 整个表格
Fill color: 填充颜色:
Delete Table Style 删除表格样式
Preview 预览
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -939,3 +939,5 @@
Apply formatting to: 將格式套用至:
Whole Table 整個表格
Fill color: 填滿色彩:
Delete Table Style 刪除表格樣式
Preview 預覽
Can't render this file because it contains an unexpected character in line 412 and column 13.
+19 -12
View File
@@ -1233,18 +1233,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Table Styles", None, app, |ui, app| {
let styles: Vec<(String, String)> = app
// The document's own styles first, so a new one shows without scrolling.
let mut styles: Vec<(bool, String, String)> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
.map(|s| (s.id.clone(), s.name.clone()))
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
.collect();
styles.sort_by_key(|(builtin, _, _)| *builtin);
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
ui.horizontal(|ui| {
for (id, name) in styles {
for (_, id, name) in styles {
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
let _ = app.run("table.style", json!({"style": id}));
}
@@ -1252,21 +1254,26 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
});
stack(ui, |ui| {
let can_modify = look.is_some()
&& app
.session
.sel
.focus
.path
.cell()
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone())
.is_some();
let current = look.and_then(|_| {
let (tp, _, _) = app.session.sel.focus.path.cell()?;
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
app.session.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.builtin)
});
let can_modify = current.is_some();
// Built-in table styles can't be deleted.
let can_delete = current == Some(false);
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| {
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
ui.close();
}
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
if let Err(e) = app.run("table.deleteStyle", json!({})) {
app.status(e);
}
ui.close();
}
});
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
+2 -2
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**380 of 430 catalog features have commands (88.4%).**
**381 of 431 catalog features have commands (88.4%).**
| Tab | Live | Total |
|---|---|---|
@@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Mailings | 17 | 17 |
| Review | 26 | 29 |
| View | 25 | 29 |
| Table Design | 7 | 7 |
| Table Design | 8 | 8 |
| Table Layout | 28 | 30 |
| Equation | 21 | 22 |
| Picture Format | 13 | 13 |