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) <noreply@anthropic.com>
This commit is contained in:
parent
880b974cff
commit
a38da4bff8
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<table::TableStyleDef>,
|
||||
/// Unmodelled presentation-level XML kept for round-trip.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_ext: Option<String>,
|
||||
|
|
|
|||
|
|
@ -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<TablePartStyle>,
|
||||
/// 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> {
|
||||
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<Fill>,
|
||||
pub fill_ref: Option<(u32, ColorRef)>,
|
||||
/// Left, right, top, bottom, inside horizontal, inside vertical.
|
||||
pub borders: [Option<TableBorder>; 6],
|
||||
pub text_color: Option<ColorRef>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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())];
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub struct Imp<'a> {
|
|||
pub next: u32,
|
||||
pub slide_by_part: HashMap<String, SlideId>,
|
||||
pub custom_show_names: HashMap<String, String>,
|
||||
/// GUIDs (upper case) of the table styles the file defines.
|
||||
pub table_style_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
|
|
@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
|||
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<Presentation, PptxError> {
|
|||
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<String, LayoutId> = 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<deckcraft_model::table::TableStyleDef> {
|
||||
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<Background> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
/// `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<u8> {
|
||||
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<u8> {
|
|||
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<String> = 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;
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, 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);
|
||||
|
|
|
|||
|
|
@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// An exported deck with a 3x2 table using the probe style.
|
||||
fn table_deck() -> Vec<u8> {
|
||||
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("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
|
||||
let end = s.find("</a:tableStyleId>");
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<Rgba>,
|
||||
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<Rgba>)>,
|
||||
}
|
||||
|
||||
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<Rgba>)> {
|
||||
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<Rgba>) -> Option<Rgba> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue