diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 0844c9315..544f2b181 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -65,6 +65,21 @@ pub struct TableStyleParts { pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, + /// Shading and cell borders of the first column (`firstCol` region). + pub first_col_fill: Option, + pub first_col_borders: Option, + /// The last column (`lastCol` region). + pub last_col_fill: Option, + pub last_col_chr: CharProps, + pub last_col_borders: Option, + /// 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, + pub total_borders: Option, + /// The odd column bands (`band1Vert` region). + pub col_band_fill: Option, + pub col_band_chr: CharProps, + pub col_band_borders: Option, } 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); } } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index bf8e29bab..6b0d570fc 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -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; } _ => {} } diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index e6a49c66e..f61fa4ad7 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -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"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index f5c881297..d08fa4359 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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 diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs index 79baae174..11beed7ca 100644 --- a/crates/engine/src/cmd/table_style.rs +++ b/crates/engine/src/cmd/table_style.rs @@ -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::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 { 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 { 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 { + 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 { + 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 { + 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], 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); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed1122940..daf1308f3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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")); +} diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 6b175796f..6a2136993 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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| { + 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| { + 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 = 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, outer: bool, outer_b: Option, inner_b: Option| 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 diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e60aa6fb..aa9e46afe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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| 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| 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, fill: Option, 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| { + 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 { 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; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19de3af08..6e5cab17d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4acca7de1..544e91151 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -938,3 +938,5 @@ Apply formatting to: 書式の適用先: Whole Table 表全体 Fill color: 塗りつぶしの色: + Delete Table Style 表スタイルの削除 + Preview プレビュー diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 888924580..17b112674 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17b84bd9e..524b70d75 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -950,3 +950,5 @@ Apply formatting to: Застосувати форматування до: Whole Table Уся таблиця Fill color: Колір заливки: + Delete Table Style Видалити стиль таблиці + Preview Попередній перегляд diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 35b7f9065..fe6106be4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -949,3 +949,5 @@ Apply formatting to: 将格式应用于: Whole Table 整个表格 Fill color: 填充颜色: + Delete Table Style 删除表格样式 + Preview 预览 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 58c2d70f2..1ad5fe2ed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -939,3 +939,5 @@ Apply formatting to: 將格式套用至: Whole Table 整個表格 Fill color: 填滿色彩: + Delete Table Style 刪除表格樣式 + Preview 預覽 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2d4e48b49..1ca6869bc 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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})); diff --git a/docs/parity.md b/docs/parity.md index 2de2ef497..ddefbc44d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |