From d8407c11f9589f5bcfbe4b24de9ad1a2fd58bd58 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:03:19 -0400 Subject: [PATCH] Tables: styles the file defines are read, drawn and kept (#292) * Tables: styles the file defines are read, drawn and kept PowerPoint writes the definition of every table style a deck uses into ppt/tableStyles.xml. DeckCraft mapped a handful of GUIDs to its own looks and drew any other style as Medium Style 2, so themed and light styles came out in the wrong colours, and dropped the definitions on save. The reader now keeps each definition (whole table, bands, first/last rows and columns, corner cells, table background; fills, theme fill and line references, borders, text colour and bold) on the presentation, and tables using one keep its GUID. The renderer applies the parts that cover each cell in the spec's order, a part's edge lines on its region's edges and its inside lines within it, and paints fills as real fills (gradients and see-through colours). The writer puts the definitions back as read. Co-Authored-By: Claude Opus 5.5 (1M context) * Tables: DeckCraft's own table styles still map back to their ids Co-Authored-By: Claude Opus 5.5 (1M context) * Fix a brace lost in the rebase --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/defaults.rs | 1 + crates/model/src/lib.rs | 3 + crates/model/src/table.rs | 106 ++++++++++++++++++++- crates/pptx/src/read/mod.rs | 73 ++++++++++++++ crates/pptx/src/read/shapes.rs | 6 +- crates/pptx/src/tables.rs | 19 +++- crates/pptx/src/write/mod.rs | 2 +- crates/pptx/tests/table_styles.rs | 84 ++++++++++++++++ crates/render/src/table.rs | 153 +++++++++++++++++++++++++++--- crates/render/src/tests.rs | 40 ++++++++ 10 files changed, 470 insertions(+), 17 deletions(-) create mode 100644 crates/pptx/tests/table_styles.rs diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 9b50ae27..8432ce4a 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -512,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen first_slide_number: 1, next_id: next.max(256), embedded_fonts: vec![], + table_styles: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index ef65a797..75d47161 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -798,6 +798,9 @@ pub struct Presentation { pub next_id: u32, /// Embedded fonts (family names; bytes in `media`). pub embedded_fonts: Vec<(String, MediaId)>, + /// Table styles defined in the file (`ppt/tableStyles.xml`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub table_styles: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 45de90b4..00c9cb5e 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::style::{Fill, Line}; +use crate::style::{ColorRef, Fill, Line}; use crate::text::{Anchor, TextBody}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -199,6 +199,110 @@ impl Table { } } +/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in +/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and +/// last rows and columns, corner cells) is filled, bordered and lettered. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TableStyleDef { + /// The style's GUID (`a:tableStyleId` on tables). + pub id: String, + pub name: String, + pub parts: Vec<(TablePart, TablePartStyle)>, + /// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used). + pub background: Option, + /// The definition as read, written back unchanged. + #[serde(skip_serializing_if = "String::is_empty")] + pub raw: String, +} + +impl TableStyleDef { + pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> { + self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s) + } +} + +/// The parts of a table style, in the order they apply (later ones win). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TablePart { + WholeTbl, + Band1H, + Band2H, + Band1V, + Band2V, + LastCol, + FirstCol, + LastRow, + FirstRow, + SeCell, + SwCell, + NeCell, + NwCell, +} + +impl TablePart { + pub const ALL: [TablePart; 13] = [ + TablePart::WholeTbl, + TablePart::Band1H, + TablePart::Band2H, + TablePart::Band1V, + TablePart::Band2V, + TablePart::LastCol, + TablePart::FirstCol, + TablePart::LastRow, + TablePart::FirstRow, + TablePart::SeCell, + TablePart::SwCell, + TablePart::NeCell, + TablePart::NwCell, + ]; + + pub fn xml(self) -> &'static str { + match self { + TablePart::WholeTbl => "wholeTbl", + TablePart::Band1H => "band1H", + TablePart::Band2H => "band2H", + TablePart::Band1V => "band1V", + TablePart::Band2V => "band2V", + TablePart::LastCol => "lastCol", + TablePart::FirstCol => "firstCol", + TablePart::LastRow => "lastRow", + TablePart::FirstRow => "firstRow", + TablePart::SeCell => "seCell", + TablePart::SwCell => "swCell", + TablePart::NeCell => "neCell", + TablePart::NwCell => "nwCell", + } + } + + pub fn from_xml(s: &str) -> Option { + TablePart::ALL.into_iter().find(|p| p.xml() == s) + } +} + +/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's +/// style index with its colour. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TablePartStyle { + pub fill: Option, + pub fill_ref: Option<(u32, ColorRef)>, + /// Left, right, top, bottom, inside horizontal, inside vertical. + pub borders: [Option; 6], + pub text_color: Option, + pub bold: Option, + pub italic: Option, +} + +/// A border of a table style part: a line, or a theme line style reference. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TableBorder { + Line { line: Line }, + Ref { idx: u32, color: ColorRef }, +} + /// Built-in table styles: (id, label). Styles are generated from the theme accents. pub fn table_styles() -> Vec<(String, String)> { let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())]; diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 03d162f0..692cfe56 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -30,6 +30,8 @@ pub struct Imp<'a> { pub next: u32, pub slide_by_part: HashMap, pub custom_show_names: HashMap, + /// GUIDs (upper case) of the table styles the file defines. + pub table_style_ids: Vec, } impl Imp<'_> { @@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result { next: max_id.max(1), slide_by_part: HashMap::new(), custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), }; // Slide ids first so hyperlinks anywhere can point at slides. let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; @@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result { if let Some(dts) = pres_el.child("defaultTextStyle") { p.default_text_style = dml::list_style(&mut imp, &pres_part, dts); } + if let Some(r) = pres_part.rels.first_of("tableStyles") + && let Some(d) = load(&pkg, &r.target) + { + let part = Part::new(&pkg, &r.target, &d); + p.table_styles = table_styles(&mut imp, &part, &d.root); + imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect(); + } // Masters and layouts. let mut layout_by_part: HashMap = HashMap::new(); @@ -505,6 +515,69 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut } } +/// `a:tblStyleLst`: the table styles the file defines. +fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec { + use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef}; + root.children_named("tblStyle") + .take(256) + .filter_map(|st| { + let id = st.attr("styleId")?.to_ascii_uppercase(); + let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() }; + if let Some(bg) = st.child("tblBg") { + let mut ps = TablePartStyle::default(); + if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) { + ps.fill = dml::fill_el(imp, part, f); + } + if let Some(fr) = bg.child("fillRef") { + ps.fill_ref = Some(( + fr.u32("idx").unwrap_or(0), + dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + )); + } + def.background = Some(ps); + } + for el in st.elements() { + let Some(kind) = TablePart::from_xml(el.local()) else { continue }; + let mut ps = TablePartStyle::default(); + if let Some(tx) = el.child("tcTxStyle") { + ps.text_color = dml::color(tx); + ps.bold = tx.attr("b").map(|v| v == "on"); + ps.italic = tx.attr("i").map(|v| v == "on"); + } + if let Some(tc) = el.child("tcStyle") { + if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) { + ps.fill = dml::fill_el(imp, part, f); + } + if let Some(fr) = tc.child("fillRef") { + ps.fill_ref = Some(( + fr.u32("idx").unwrap_or(0), + dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + )); + } + if let Some(bd) = tc.child("tcBdr") { + for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() { + let Some(b) = bd.child(side) else { continue }; + let border = if let Some(ln) = b.child("ln") { + Some(TableBorder::Line { line: dml::line(imp, part, ln) }) + } else { + b.child("lnRef").map(|r| TableBorder::Ref { + idx: r.u32("idx").unwrap_or(0), + color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + }) + }; + if let Some(slot) = ps.borders.get_mut(i) { + *slot = border; + } + } + } + } + def.parts.push((kind, ps)); + } + Some(def) + }) + .collect() +} + fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option { let bg = csld.child("bg")?; if let Some(pr) = bg.child("bgPr") { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c35d266c..ed6fd8e4 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -401,7 +401,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { t.band_row = pr.bool("bandRow").unwrap_or(false); t.band_col = pr.bool("bandCol").unwrap_or(false); if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) { - t.style = crate::tables::style_from_guid(id.trim()); + // A style the file defines is drawn from its definition (by its GUID). + // DeckCraft's own styles (written with GUIDs derived from their ids) map back to them. + let up = id.trim().to_ascii_uppercase(); + t.style = + if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) }; } } t.cols = tbl diff --git a/crates/pptx/src/tables.rs b/crates/pptx/src/tables.rs index 0025bd61..24b011c3 100644 --- a/crates/pptx/src/tables.rs +++ b/crates/pptx/src/tables.rs @@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String { guid(&format!("deckcraft-table-style:{style}")) } +/// Is `g` the GUID DeckCraft writes for one of its own styles? +pub fn is_own_guid(g: &str) -> bool { + let up = g.to_ascii_uppercase(); + deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up) +} + /// Table style GUID → our style id (or the GUID itself when unknown). pub fn style_from_guid(g: &str) -> String { let up = g.to_ascii_uppercase(); @@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) { w.close("a:tblStyle"); } -/// `ppt/tableStyles.xml` with definitions for the styles in `used`. -pub fn table_styles_xml(used: &[String]) -> Vec { +/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the +/// opened file defined (`defs`), written back as they were read. +pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec { let mut w = W::new(); w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}")); let names = deckcraft_model::table::table_styles(); @@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec { continue; } done.push(s); + if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s)) + && crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) + { + w.raw(&d.raw); + continue; + } let (_, define) = guid_for_style(s); if !define { continue; @@ -231,7 +244,7 @@ mod tests { "nwCell", ]; let used: Vec = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect(); - let d = crate::xml::parse(&table_styles_xml(&used)).unwrap(); + let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap(); let mut n = 0; for st in d.root.children_named("tblStyle") { n += 1; diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 0329e2ef..7fb6a713 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); - let ts = crate::tables::table_styles_xml(&x.table_styles); + let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml); diff --git a/crates/pptx/tests/table_styles.rs b/crates/pptx/tests/table_styles.rs new file mode 100644 index 00000000..015543f2 --- /dev/null +++ b/crates/pptx/tests/table_styles.rs @@ -0,0 +1,84 @@ +//! Fills and styles read from decks written by other tools. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::Fill; +use deckcraft_model::{Shape, ShapeId, defaults}; + +const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}"; + +/// A table style definition as PowerPoint writes one: red table background, see-through white +/// bands, 2 pt blue outer borders and no inside lines. +fn style_xml() -> String { + format!( + r#" +"# + ) +} + +/// An exported deck with a 3x2 table using the probe style. +fn table_deck() -> Vec { + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0); + t.style = "medium2-accent1".into(); + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Styled".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)), + kind: deckcraft_model::ShapeKind::Table(t), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + // Point the table at the probe style and replace the style part. + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name == "ppt/tableStyles.xml" { + d = style_xml().into_bytes(); + } else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") { + let s = String::from_utf8(d).expect("utf8"); + let start = s.find("").map(|i| i + "".len()); + let end = s.find(""); + d = match (start, end) { + (Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(), + _ => s.into_bytes(), + }; + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn table_styles_the_file_defines_are_read_used_and_kept() { + use deckcraft_model::table::{TableBorder, TablePart}; + let p = deckcraft_pptx::import(&table_deck()).expect("import"); + let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read"); + assert_eq!(def.name, "Probe Style"); + let bg = def.background.as_ref().expect("table background"); + assert!(matches!(bg.fill_ref, Some((1, _)))); + let whole = def.part(TablePart::WholeTbl).expect("whole table"); + assert_eq!(whole.bold, Some(true)); + assert!(whole.text_color.is_some()); + assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0))); + assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None)))); + assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. }))); + // The table keeps the style's GUID, so it's drawn from the definition. + let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table"); + let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") }; + assert_eq!(t.style, STYLE_GUID); + // Saving writes the definition back, unchanged. + let again = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&again).expect("reimport"); + let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save"); + assert_eq!(def2.parts, def.parts); +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index fa531a2c..30770da4 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -3,7 +3,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; -use deckcraft_model::table::{Cell, Table}; +use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef}; use deckcraft_model::text::{Anchor, TextBody, TextDir}; use deckcraft_model::{Presentation, Shape}; use deckcraft_text::Fields; @@ -18,6 +18,12 @@ pub struct CellLook { pub text: Option, pub bold: bool, pub border: Option<(Rgba, f64)>, + /// Left, right, top and bottom borders from a file-defined style (a fully transparent colour + /// means no border); `None` falls back to `border`. + pub sides: [Option<(Rgba, f64)>; 4], + /// A file-defined style's fill as a paint (gradients, see-through colours), with its + /// placeholder colour; drawn instead of `fill`. + pub paint: Option<(Fill, Option)>, } fn accent(ctx: &Ctx, style: &str) -> Rgba { @@ -33,7 +39,109 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba { ctx.color(&ColorRef::scheme(slot), None) } +/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to +/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the +/// fill, text and the borders on its region's edges (its inside lines within it). +fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook { + let (rows, cols) = (t.rows.len(), t.cols.len()); + let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c)); + let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1))); + let last_r = rows.saturating_sub(1); + let last_c = cols.saturating_sub(1); + let header = t.first_row && r == 0; + let total = t.last_row && r1 == last_r && rows > 1; + let first_col = t.first_col && c == 0; + let last_col = t.last_col && c1 == last_c && cols > 1; + let body_r = r.saturating_sub(usize::from(t.first_row)); + let body_c = c.saturating_sub(usize::from(t.first_col)); + let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None }; + for part in TablePart::ALL { + // (applies, region rows, region cols) + let (on, region) = match part { + TablePart::WholeTbl => (true, (0, last_r, 0, last_c)), + TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)), + TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)), + TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)), + TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)), + TablePart::LastCol => (last_col, (0, last_r, c, last_c)), + TablePart::FirstCol => (first_col, (0, last_r, 0, c1)), + TablePart::LastRow => (total, (r, last_r, 0, last_c)), + TablePart::FirstRow => (header, (0, r1, 0, last_c)), + TablePart::SeCell => (total && last_col, (r, r1, c, c1)), + TablePart::SwCell => (total && first_col, (r, r1, c, c1)), + TablePart::NeCell => (header && last_col, (r, r1, c, c1)), + TablePart::NwCell => (header && first_col, (r, r1, c, c1)), + }; + if !on { + continue; + } + let Some(ps) = def.part(part) else { continue }; + if let Some(p) = style_fill(ctx, ps) { + look.fill = fill_color(ctx, &p.0, p.1); + look.paint = Some(p); + } + if let Some(tc) = &ps.text_color { + look.text = Some(ctx.color(tc, None)); + } + if let Some(b) = ps.bold { + look.bold = b; + } + let (r0, rr1, c0, cc1) = region; + // Left, right, top, bottom of the cell: the part's edge or its inside line. + let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }]; + for (side, i) in pick.iter().enumerate() { + if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) { + *slot = Some(border_look(ctx, b)); + } + } + } + look +} + +/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as +/// the placeholder colour. +fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option)> { + if let Some((idx, col)) = &ps.fill_ref { + let ph = ctx.color(col, None); + return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) { + Some(f) if *idx > 0 => (f.clone(), Some(ph)), + _ if *idx == 0 => (Fill::None, None), + _ => (Fill::solid(ColorRef::rgb(ph)), None), + }); + } + ps.fill.clone().map(|f| (f, None)) +} + +/// A fill's colour for a table cell (gradients and pictures by their first colour). +fn fill_color(ctx: &Ctx, f: &Fill, ph: Option) -> Option { + match f { + Fill::None => Some(Rgba::TRANSPARENT), + Fill::Solid { color: c } => Some(ctx.color(c, ph)), + Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)), + Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)), + _ => None, + } +} + +/// A style border's colour and width (a transparent colour for no line). +fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) { + match b { + TableBorder::Line { line } => match &line.fill { + Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)), + Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)), + _ => (Rgba::TRANSPARENT, 0.0), + }, + TableBorder::Ref { idx, color } => { + let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75); + if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) } + } + } +} + pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { + if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) { + return look_def(ctx, def, t, r, c); + } let a = accent(ctx, &t.style); let kind = t.style.split('-').next().unwrap_or("medium2"); let header = t.first_row && r == 0; @@ -47,19 +155,23 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { let white = Rgba::WHITE; let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None); match kind { - "none" => CellLook { fill: None, text: None, bold: false, border: None }, - "grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) }, + "none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None }, + "grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None }, "light1" => CellLook { fill: if band || band_c { Some(tint(20000)) } else { None }, text: None, bold: header || total || first_col || last_col, border: if header { Some((a, 1.0)) } else { None }, + sides: [None; 4], + paint: None, }, "light2" => CellLook { fill: if header { Some(a) } else { None }, text: if header { Some(white) } else { None }, bold: header || total || first_col, border: Some((a, 1.0)), + sides: [None; 4], + paint: None, }, "dark1" => CellLook { fill: Some(if header { @@ -72,12 +184,16 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { text: Some(white), bold: header || total || first_col, border: Some((white, 1.0)), + sides: [None; 4], + paint: None, }, "medium4" => CellLook { fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }), text: None, bold: header || total || first_col || last_col, border: Some((a, 0.75)), + sides: [None; 4], + paint: None, }, _ => CellLook { // medium2: strong header, banded light tints, white grid. @@ -91,6 +207,8 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { text: if header || total || first_col || last_col { Some(white) } else { None }, bold: header || total || first_col || last_col, border: Some((white, 1.0)), + sides: [None; 4], + paint: None, }, } } @@ -182,6 +300,14 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> { pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) { let _ = pres; let rects = cell_rects(t, &row_heights(rctx, t, fields)); + // A file-defined style's table background, behind every cell. + if let Some(def) = rctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) + && let Some((bg, ph)) = def.background.as_ref().and_then(|b| style_fill(rctx, b)) + && let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) + { + ctx.set_transform(m); + crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); + } for (r, c, rect) in &rects { let Some(cell) = t.cell(*r, *c) else { continue }; let lk = look(rctx, t, *r, *c); @@ -190,12 +316,17 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, Some(f) => { crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres) } - None => { - if let Some(fc) = lk.fill { - ctx.set_paint(color(fc, 1.0)); - ctx.fill_rect(rect); + None => match &lk.paint { + Some((f, ph)) => { + crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres) } - } + None => { + if let Some(fc) = lk.fill.filter(|f| f.a > 0) { + ctx.set_paint(color(fc, 1.0)); + ctx.fill_rect(rect); + } + } + }, } } // Borders: explicit cell borders, else the style's grid. @@ -216,9 +347,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, Some(Fill::None) => continue, _ => continue, }, - None => match lk.border { - Some(b) => b, - None => continue, + None => match lk.sides.get(i).copied().flatten().or(lk.border) { + Some(b) if b.0.a > 0 && b.1 > 0.0 => b, + _ => continue, }, }; let mut p = BezPath::new(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index cfe4dc9a..16b3ccd0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -872,3 +872,43 @@ fn soft_edges_fade_to_the_outline() { // Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn. assert!(alpha(110, 105) < 0.01); } + +#[test] +fn a_file_defined_table_style_draws_its_parts() { + // Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and + // no inside lines: drawn from the definition, not DeckCraft's own look for the style. + use deckcraft_model::style::{Fill, Line}; + use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef}; + let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)); + let mut t = Table::new(4, 2, 400.0, 50.0); + t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into(); + t.first_row = false; + t.band_row = true; + let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } }; + let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() }; + whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0)); + whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } }); + let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 }); + let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() }; + let def = TableStyleDef { + id: t.style.clone(), + name: "Probe".into(), + parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)], + background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }), + raw: String::new(), + }; + let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() }; + let mut p = deck_with(vec![tbl]); + p.table_styles.push(def); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let px = |x, y| img.pixel(x, y); + // Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red. + let banded = px(300, 125); + assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}"); + assert_eq!(px(300, 175)[..3], [255, 0, 0]); + // No line between the rows; a blue left border. + let edge = px(300, 150); + assert!(edge[0] > 240, "no inside line: {edge:?}"); + let left = px(100, 175); + assert!(left[2] > 200 && left[0] < 60, "{left:?}"); +}