Create and modify your own table styles, saved in the .docx (#146) (#256)

New Table Style and Modify Table Style (Table Design > Table Styles) make
real table styles in the document's style sheet: name, based on, and
formatting for the whole table, the header row and banded rows (borders,
fill, bold, font colour). The table at the caret takes the new style, it
shows in the gallery, and other tools that read table styles by name
(pandoc's custom-style, for one) see it.

- engine: table.newStyle / table.modifyStyle with documented params
  (regions take borders, fill, bold, italic, colour, size, font; bandSize;
  para); table.style only accepts table styles now.
- doc/layout: table styles gain header-row and row-band cell borders,
  row-band character formatting and rows per band, applied by layout.
- docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are
  read and written, so custom styles round-trip.
- ui: a compact dialog from the Styles menu next to the gallery; table
  style tiles redraw when a style changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-10 19:45:11 +02:00 committed by GitHub
parent 19e2e37ca6
commit d8f71959d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 620 additions and 59 deletions

View File

@ -9,7 +9,7 @@ and agent control. This file tracks where we are honestly. Generated numbers com
| Measure | Value |
|---|---|
| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
| Feature catalog coverage (Word ribbon/menu features with a command) | **378 / 428 (88%)** |
| Feature catalog coverage (Word ribbon/menu features with a command) | **380 / 430 (88%)** |
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |

View File

@ -52,7 +52,16 @@ pub struct TableStyleParts {
pub cell_margins: Option<[f32; 4]>,
pub header_fill: Option<Rgb>,
pub header_chr: CharProps,
/// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the
/// border between header rows, `inside_v` between its cells.
pub header_borders: Option<Borders>,
pub band_fill: Option<Rgb>,
/// Character formatting of the odd row bands (`band1Horz` region).
pub band_chr: CharProps,
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
pub band_borders: Option<Borders>,
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
pub band_size: Option<u32>,
pub first_col_chr: CharProps,
pub total_chr: CharProps,
pub total_border_top: Option<Border>,
@ -62,17 +71,18 @@ impl TableStyleParts {
/// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins,
/// the rest is inherited; borders merge edge by edge.
pub fn overlay(&mut self, patch: &TableStyleParts) {
self.borders = match (self.borders, patch.borders) {
(Some(b), Some(p)) => Some(Borders {
top: p.top.or(b.top),
left: p.left.or(b.left),
bottom: p.bottom.or(b.bottom),
right: p.right.or(b.right),
between: p.between.or(b.between),
inside_v: p.inside_v.or(b.inside_v),
}),
let merge = |b: Option<Borders>, p: Option<Borders>| match (b, p) {
(Some(mut b), Some(p)) => {
b.overlay(&p);
Some(b)
}
(b, p) => p.or(b),
};
self.borders = merge(self.borders, patch.borders);
self.header_borders = merge(self.header_borders, patch.header_borders);
self.band_borders = merge(self.band_borders, patch.band_borders);
self.band_size = patch.band_size.or(self.band_size);
self.band_chr.overlay(&patch.band_chr);
self.fill = patch.fill.or(self.fill);
self.cell_margins = patch.cell_margins.or(self.cell_margins);
self.header_fill = patch.header_fill.or(self.header_fill);

View File

@ -444,9 +444,11 @@ impl Reader<'_> {
}
t.fill = tp.child("w:shd").and_then(props::shd_fill);
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
}
for c in s.children("w:tblStylePr") {
for c in s.children("w:tblStylePr").take(64) {
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill);
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
match c.attr("w:type") {
Some("wholeTable") => {
@ -464,8 +466,13 @@ impl Reader<'_> {
Some("firstRow") => {
t.header_fill = fill;
t.header_chr = chr;
t.header_borders = cell_borders;
}
Some("band1Horz") => {
t.band_fill = fill;
t.band_chr = chr;
t.band_borders = cell_borders;
}
Some("band1Horz") => t.band_fill = fill,
Some("firstCol") => t.first_col_chr = chr,
Some("lastRow") => {
t.total_chr = chr;

View File

@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) {
w.close("w:rPr");
}
if let Some(t) = &st.table {
if t.borders.is_some() || t.cell_margins.is_some() {
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
w.open("w:tblPr", &[]);
if let Some(n) = t.band_size {
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
}
if let Some(b) = &t.borders {
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
}
@ -632,35 +635,40 @@ fn style_xml(w: &mut W, st: &Style) {
}
w.close("w:tblPr");
}
let cond =
|w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option<wordcraft_doc::Rgb>, top: Option<&wordcraft_doc::props::Border>| {
if !props::has_rpr(chr) && fill.is_none() && top.is_none() {
return;
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
// and shading.
let cond = |w: &mut W,
ty: &str,
chr: &wordcraft_doc::CharProps,
fill: Option<wordcraft_doc::Rgb>,
borders: Option<wordcraft_doc::props::Borders>| {
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
return;
}
w.open("w:tblStylePr", &[("w:type", ty)]);
if props::has_rpr(chr) {
w.open("w:rPr", &[]);
props::rpr_inner(w, chr);
w.close("w:rPr");
}
if fill.is_some() || borders.is_some() {
w.open("w:tcPr", &[]);
if let Some(bs) = &borders {
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
}
w.open("w:tblStylePr", &[("w:type", ty)]);
if props::has_rpr(chr) {
w.open("w:rPr", &[]);
props::rpr_inner(w, chr);
w.close("w:rPr");
if let Some(f) = fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
}
if fill.is_some() || top.is_some() {
w.open("w:tcPr", &[]);
if let Some(b) = top {
let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() };
props::borders(w, "w:tcBorders", &bs, None, &[]);
}
if let Some(f) = fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
}
w.close("w:tcPr");
}
w.close("w:tblStylePr");
};
w.close("w:tcPr");
}
w.close("w:tblStylePr");
};
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, None);
cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref());
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);
cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None);
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
}
w.close("w:style");
}

View File

@ -429,7 +429,8 @@ fn comments_round_trip() {
}
}
/// A table style's cell text formatting, whole-table shading and cell margins survive a save.
/// A table style's cell text formatting, whole-table shading, cell margins and its header row and
/// row band conditional formats (#146) survive a save.
#[test]
fn table_style_formatting_round_trips() {
use wordcraft_doc::styles::TableStyleParts;
@ -440,6 +441,11 @@ fn table_style_formatting_round_trips() {
cell_margins: Some([1.0, 14.4, 0.0, 14.4]),
header_chr: CharProps { italic: Some(true), ..Default::default() },
header_fill: Some(Rgb(0xFF, 0xFF, 0)),
header_borders: Some(Borders::all(Border::single(1.5))),
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() },
band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
band_size: Some(3),
..Default::default()
};
d.styles.upsert(Style {

View File

@ -327,6 +327,8 @@ View|Macros|Record Macro|tools.recordMacro
View|SharePoint|Properties|file.properties
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|Shading|table.shading
Table Design|Borders|Borders|table.borders
Table Design|Borders|Border Painter|table.borderPainter

View File

@ -16,6 +16,7 @@ pub mod references;
pub mod review;
pub mod speech;
pub mod table;
pub mod table_style;
pub mod text;
pub mod tools;
pub mod view;
@ -39,6 +40,7 @@ pub fn registry() -> Registry {
v.extend(equation::specs());
v.extend(page::specs());
v.extend(table::specs());
v.extend(table_style::specs());
v.extend(review::specs());
v.extend(file::specs());
v.extend(design::specs());

View File

@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)),
t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| {
let st = p::req_str(v, "style")?;
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
with_table(s, |t| t.props.style = Some(id.clone()))
})
.params(r#"{"style": string}"#)),
@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul
sel_result(s)
}
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
with_cells(s, |cl| cl.props.text_direction = dir)
}
fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
let story = s.sel.focus.story;
let paths: Vec<Path> = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
for p in paths {

View File

@ -0,0 +1,183 @@
//! 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).
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`,
/// `bandedRows`) 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#" 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}"#
)
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
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"#)),
]
}
/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count.
pub fn table_style_id(s: &Session, name: &str) -> Option<String> {
let st = &s.doc.styles.styles;
st.iter()
.find(|x| x.kind == StyleKind::Table && x.id == name)
.or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name)))
.map(|x| x.id.clone())
}
/// The style of the table at the caret.
fn current_table_style(s: &Session) -> Option<String> {
let (tp, _, _) = s.sel.focus.path.cell()?;
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
}
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() {
return Err(CmdError::Params("name is empty".into()));
}
if s.doc.styles.find(&name).is_some() {
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
}
let based_on = match p::str(v, "basedOn") {
Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?),
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() };
edit(&mut st, v)?;
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(true) {
apply(s, &id)?;
}
Ok(json!({"id": id}))
}
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 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)
{
return Err(CmdError::Failed(format!("a style named `{n}` already exists")));
}
let based_on = match p::str(v, "basedOn") {
Some(b) => {
let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?;
// A style can't be based on itself or on a style based on it.
if s.doc.styles.chain(&b).iter().any(|x| x.id == id) {
return Err(CmdError::Params("a style can't be based on itself".into()));
}
Some(b)
}
None => None,
};
let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
if let Some(n) = rename {
st.name = n;
}
if let Some(b) = based_on {
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(json!({"id": id}))
}
/// Apply style `id` to the table at the caret, if any.
fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> {
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) };
s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string());
super::table::touch_cells(s, &tp)
}
/// Apply the formatting params to `st`.
fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
let mut parts = st.table.take().unwrap_or_default();
if let Some(r) = v.get("wholeTable") {
region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?;
}
if let Some(r) = v.get("headerRow") {
region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?;
}
if let Some(r) = v.get("bandedRows") {
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
}
if let Some(n) = v.get("bandSize") {
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
}
if let Some(pp) = v.get("para") {
let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?;
st.para.overlay(&pp);
st.para.style = None;
st.para.numbering = None;
}
st.table = (parts != TableStyleParts::default()).then_some(parts);
Ok(())
}
/// One region's shortcuts: borders, fill and character formatting.
fn region(r: &Value, fill: &mut Option<Rgb>, chr: &mut CharProps, borders: &mut Option<Borders>) -> Result<(), CmdError> {
if !r.is_object() {
return Err(CmdError::Params("a region is an object".into()));
}
let color = |k: &str| -> Result<Option<Rgb>, CmdError> {
match r.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))),
Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))),
}
};
if r.get("fill").is_some() {
*fill = color("fill")?;
}
if r.get("color").is_some() {
chr.color = color("color")?.map(TextColor::Rgb);
}
if let Some(b) = p::bool(r, "bold") {
chr.bold = Some(b);
chr.bold_cs = Some(b);
}
if let Some(b) = p::bool(r, "italic") {
chr.italic = Some(b);
chr.italic_cs = Some(b);
}
if let Some(sz) = p::f32(r, "size") {
chr.size = Some(sz.clamp(1.0, 1638.0));
chr.size_cs = chr.size;
}
if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) {
chr.font = Some(f.to_string());
}
match r.get("borders") {
Some(Value::Bool(true)) => {
let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0);
*borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 }));
}
Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
Some(Value::Null) => *borders = None,
Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())),
None => {}
}
Ok(())
}

View File

@ -1701,6 +1701,69 @@ fn timestamps_come_from_the_clock() {
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
}
/// #146: a table style made by command styles the table at the caret (header row fill and bold
/// text, banded rows), can be modified, and saves as a table style the table refers to.
#[test]
fn custom_table_style_applies_modifies_and_saves() {
use wordcraft_doc::{Rgb, StyleKind, TextColor};
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "Head"}));
run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Thesis Table", "basedOn": "Table Grid",
"wholeTable": {"size": 10},
"headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true},
"bandedRows": {"fill": "EEEEEE", "italic": true}}),
);
let id = r["id"].as_str().unwrap().to_string();
let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table");
assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique");
assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles");
assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables");
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()
};
let head = |s: &mut Session| {
let mut p = tp.0.clone();
p.extend([0, 0, 0]);
s.layout().pages[0]
.items
.iter()
.find_map(
|i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None },
)
.unwrap()
};
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells");
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band");
let rc = head(&mut s);
assert!(rc.bold && rc.size == 10.0, "{rc:?}");
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
// Modify the current table's style: the table follows.
run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2}));
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0);
assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2);
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band");
assert!(!head(&mut s).bold);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let st = back.styles.get(&id).unwrap();
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
let parts = st.table.as_ref().unwrap();
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
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");
}
#[test]
fn page_and_table_gridlines_toggle_independently() {
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell

View File

@ -40,8 +40,8 @@ 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) and cell index in the row.
region: (bool, bool, bool),
/// Its formatting region (header row, total row, first column, row band) and cell index in the row.
region: (bool, bool, bool, bool),
ci: usize,
/// Top margin plus top border band.
top: f32,
@ -97,8 +97,9 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
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) region, built once per table.
let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new();
// 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();
let ncols = t.cols().max(1);
// Column widths.
let mut grid: Vec<f32> = if t.grid.len() == ncols {
@ -152,6 +153,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
};
let nrows = t.rows.len();
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;
// First pass: lay out every cell's content.
struct CellBox {
items: Vec<Placed>,
@ -178,7 +180,16 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let mut row_insets = 0.0f32;
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 - usize::from(header_rows)) % 2 == 0;
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);
@ -186,7 +197,7 @@ 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);
let region = (is_header, is_total, t.props.look.first_column && g == 0, band);
if let Some(p) = parts {
if is_header {
fill = fill.or(p.header_fill);
@ -202,6 +213,9 @@ 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 band {
chr.overlay(&st.parts.band_chr);
}
if region.2 {
chr.overlay(&st.parts.first_col_chr);
}
@ -221,11 +235,13 @@ 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 mut borders = Borders {
top: edge(cb.top, ri == 0, tb.top, tb.between),
bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between),
left: edge(cb.left, g == 0, tb.left, tb.inside_v),
right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v),
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),
left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v),
right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v),
between: None,
inside_v: None,
};

View File

@ -1,8 +1,8 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, Command search, 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, Command search, 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;
@ -101,6 +101,21 @@ pub enum Dialog {
before: f32,
after: f32,
},
/// Table Design › New / Modify Table Style: one formatting region at a time.
TableStyle {
/// The style being modified; `None` creates one.
id: Option<String>,
name: String,
/// Style id.
based_on: String,
/// Index into `regions`: whole table, header row, banded rows.
region: usize,
regions: Box<[TableRegion; 3]>,
/// 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]>,
},
Commands {
query: String,
},
@ -211,6 +226,93 @@ pub fn recipient_table(fields: &[String], rows: &[Vec<String>]) -> Result<Value,
Ok(json!({"fields": fields, "rows": rows}))
}
/// One table style region in the Table Style dialog: colours are hex, empty for none.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableRegion {
pub borders: bool,
pub fill: String,
pub bold: bool,
pub color: String,
}
/// The regions of table style `id` as resolved through its based-on chain.
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
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();
let text = |c: &wordcraft_doc::CharProps| match c.color {
Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(),
_ => 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);
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) },
])
}
/// 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;
let (tp, _, _) = s.sel.focus.path.cell()?;
let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?;
s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone())
}
/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region.
fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) };
let mut v = json!({});
if r.borders != basis.borders {
v["borders"] = json!(r.borders);
}
if r.fill != basis.fill {
v["fill"] = color(&r.fill);
}
if r.bold != basis.bold {
v["bold"] = json!(r.bold);
}
if r.color != basis.color {
v["color"] = color(&r.color);
}
v
}
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
let c = wordcraft_doc::Rgb::parse(value);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let t = Tokens::get(ui.ctx());
match c {
Some(c) => {
ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));
}
None => {
ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));
}
}
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() };
ui.menu_button(label, |ui| {
if ui.button(tl!("No Color")).clicked() {
value.clear();
ui.close();
}
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
*value = hex;
ui.close();
}
});
});
}
impl Dialog {
pub fn name(&self) -> &'static str {
match self {
@ -228,6 +330,8 @@ impl Dialog {
Dialog::Watermark { .. } => "watermark",
Dialog::NewStyle { .. } => "newStyle",
Dialog::ModifyStyle { .. } => "modifyStyle",
Dialog::TableStyle { id: None, .. } => "newTableStyle",
Dialog::TableStyle { .. } => "modifyTableStyle",
Dialog::Commands { .. } => "commands",
Dialog::About { .. } => "about",
Dialog::SaveChanges { .. } => "saveChanges",
@ -327,6 +431,20 @@ impl Dialog {
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() },
"newTableStyle" => {
let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into());
let styles = &app.session.doc.styles;
let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default();
let regions = table_regions(app, &based_on);
Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions }
}
"modifyTableStyle" => {
let id = current_table_style(app)?;
let st = app.session.doc.styles.get(&id)?;
let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default());
let regions = table_regions(app, &id);
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
}
"commands" => Dialog::Commands { query: String::new() },
"about" => Dialog::About { tab: 0 },
"contributors" => Dialog::About { tab: 1 },
@ -449,6 +567,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::Watermark { .. } => "Custom Watermark",
Dialog::NewStyle { .. } => "Create New Style",
Dialog::ModifyStyle { .. } => "Modify Style",
Dialog::TableStyle { id: None, .. } => "New Table Style",
Dialog::TableStyle { .. } => "Modify Table Style",
Dialog::Commands { .. } => "Search Commands",
Dialog::About { .. } => "About WordCraft",
Dialog::SaveChanges { .. } => "WordCraft",
@ -883,6 +1003,90 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::TableStyle { id, name, based_on, region, regions, basis } => {
let styles: Vec<(String, String)> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref())
.map(|s| (s.id.clone(), s.name.clone()))
.collect();
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
let mut rebased = false;
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
ui.end_row();
ui.label(tl!("Style based on:"));
egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
for (sid, sname) in &styles {
if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on {
*based_on = sid.clone();
rebased = true;
}
}
});
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")))
.show_ui(ui, |ui| {
for (i, n) in names.iter().enumerate() {
ui.selectable_value(region, i, tl!(n));
}
});
ui.end_row();
});
// A new style shows its new base's formatting; a modified one keeps its own.
if rebased && id.is_none() {
*regions = table_regions(app, based_on);
*basis = regions.clone();
}
ui.separator();
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(r) = regions.get_mut(*region) {
ui.horizontal(|ui| {
ui.checkbox(&mut r.borders, tl!("Borders"));
ui.checkbox(&mut r.bold, tl!("Bold"));
});
egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Fill color:"));
color_menu(ui, &theme, &mut r.fill);
ui.end_row();
ui.label(tl!("Font color:"));
color_menu(ui, &theme, &mut r.color);
ui.end_row();
});
}
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",
};
if let Err(e) = app.run(cmd, v) {
app.status(e);
return false;
}
}
ok || cancel
}
Dialog::Commands { query } => {
let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0));
r.request_focus();

View File

@ -948,3 +948,10 @@
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.
New Table Style Nuevo estilo de tabla
Modify Table Style Modificar estilo de tabla
New Table Style… Nuevo estilo de tabla…
Modify Table Style… Modificar estilo de tabla…
Apply formatting to: Aplicar formato a:
Whole Table Toda la tabla
Fill color: Color de relleno:

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -931,3 +931,10 @@
Dark ダーク
Use system setting システム設定を使用
A file dialog is already open. ファイル ダイアログが既に開いています。
New Table Style 新しい表スタイル
Modify Table Style 表スタイルの変更
New Table Style… 新しい表スタイル…
Modify Table Style… 表スタイルの変更…
Apply formatting to: 書式の適用先:
Whole Table 表全体
Fill color: 塗りつぶしの色:

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -943,3 +943,10 @@
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.
New Table Style Novo estilo de tabela
Modify Table Style Modificar estilo de tabela
New Table Style… Novo estilo de tabela…
Modify Table Style… Modificar estilo de tabela…
Apply formatting to: Aplicar formatação a:
Whole Table Tabela inteira
Fill color: Cor de preenchimento:

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -943,3 +943,10 @@
Dark Темна
Use system setting Як у системі
A file dialog is already open. Діалогове вікно файлу вже відкрито.
New Table Style Новий стиль таблиці
Modify Table Style Змінити стиль таблиці
New Table Style… Новий стиль таблиці…
Modify Table Style… Змінити стиль таблиці…
Apply formatting to: Застосувати форматування до:
Whole Table Уся таблиця
Fill color: Колір заливки:

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -942,3 +942,10 @@
Dark 深色
Use system setting 使用系统设置
A file dialog is already open. 已有一个文件对话框处于打开状态。
New Table Style 新建表格样式
Modify Table Style 修改表格样式
New Table Style… 新建表格样式…
Modify Table Style… 修改表格样式…
Apply formatting to: 将格式应用于:
Whole Table 整个表格
Fill color: 填充颜色:

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -932,3 +932,10 @@
Dark 深色
Use system setting 使用系統設定
A file dialog is already open. 已有一個檔案對話方塊開啟中。
New Table Style 新增表格樣式
Modify Table Style 修改表格樣式
New Table Style… 新增表格樣式…
Modify Table Style… 修改表格樣式…
Apply formatting to: 將格式套用至:
Whole Table 整個表格
Fill color: 填滿色彩:

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -329,7 +329,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let key = format!("tstyle:{style}:{ppp}");
// Keyed by the style sheet too, so a modified table style redraws.
let skey = app.previews.styles_key(&app.session.doc, app.session.rev());
let key = format!("tstyle:{style}:{skey}:{ppp}");
let doc = &app.session.doc;
let tex = app.previews.get_or(ui.ctx(), &key, || {
let mut tb = Table::new(5, 4, 100.0);

View File

@ -1257,6 +1257,22 @@ 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();
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();
}
});
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}));
let theme = app.session.doc.settings.theme_colors.clone();

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.
**378 of 428 catalog features have commands (88.3%).**
**380 of 430 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 | 5 | 5 |
| Table Design | 7 | 7 |
| Table Layout | 28 | 30 |
| Equation | 21 | 22 |
| Picture Format | 13 | 13 |