diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 9b50ae2..8432ce4 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 ef65a79..75d4716 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 45de90b..00c9cb5 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 03d162f..2bfe02b 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(); @@ -503,6 +513,68 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut walk_shapes_mut(children, f); } } + +/// `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 { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c35d266..a7769a5 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -401,7 +401,9 @@ 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). + let up = id.trim().to_ascii_uppercase(); + t.style = if imp.table_style_ids.contains(&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 0025bd6..0640125 100644 --- a/crates/pptx/src/tables.rs +++ b/crates/pptx/src/tables.rs @@ -198,8 +198,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 +210,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 +238,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 0329e2e..7fb6a71 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 0000000..015543f --- /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 fa531a2..30770da 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 cfe4dc9..16b3ccd 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:?}"); +}