Files
deckcraft/crates/pptx/src/tables.rs
T

255 lines
9.8 KiB
Rust

//! Table styles: our style ids ↔ table style GUIDs, and `tableStyles.xml` definitions for the
//! styles we write (generated from theme accents, matching how DeckCraft draws them).
use crate::opc::NS_A;
use crate::xml::{A, W};
/// Well-known built-in table style GUIDs (format identifiers) and our equivalent ids.
const KNOWN: &[(&str, &str)] = &[
("{2D5ABB26-0587-4C30-8999-92F81FD0307C}", "none"),
("{5940675A-B579-460E-94D1-54222C63F5DA}", "grid"),
("{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}", "medium2-accent1"),
("{073A0DAA-6AF3-43AB-8588-CEC1D06C72B9}", "medium2-tx1"),
("{21E4AEA4-8DFA-4A89-87EB-49C32662AFE8}", "medium2-accent2"),
("{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}", "medium2-accent3"),
("{00A15C55-8517-42AA-B614-E9B94910E393}", "medium2-accent4"),
("{7DF18680-E054-41AD-8BC1-D1AEF772440D}", "medium2-accent5"),
("{93296810-A885-4BE3-A3E7-6D5BEEA58F35}", "medium2-accent6"),
];
/// GUIDs we write as built-in references (no definition needed).
const BUILTIN_WRITE: [&str; 3] = ["none", "grid", "medium2-accent1"];
fn fnv(s: &str, seed: u64) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64 ^ seed;
for b in s.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// A stable GUID derived from text (version-4 layout).
pub fn guid(text: &str) -> String {
let a = fnv(text, 0x5163_7261_6674);
let b = fnv(text, 0x7461_626c_6573);
format!(
"{{{:08X}-{:04X}-4{:03X}-8{:03X}-{:012X}}}",
(a >> 32) as u32,
(a >> 16) as u16,
(a & 0x0fff) as u16,
(b >> 48) as u16 & 0x0fff,
b & 0xffff_ffff_ffff
)
}
fn own_guid(style: &str) -> String {
guid(&format!("deckcraft-table-style:{style}"))
}
/// 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();
if let Some((_, s)) = KNOWN.iter().find(|(k, _)| *k == up) {
return s.to_string();
}
for (id, _) in deckcraft_model::table::table_styles() {
if own_guid(&id) == up {
return id;
}
}
if g.is_empty() { "none".into() } else { g.to_string() }
}
/// GUID to write for our style id, and whether a definition must be written.
pub fn guid_for_style(style: &str) -> (String, bool) {
if style.starts_with('{') {
return (style.to_string(), false);
}
if BUILTIN_WRITE.contains(&style)
&& let Some((g, _)) = KNOWN.iter().find(|(_, s)| *s == style)
{
return (g.to_string(), false);
}
let known = deckcraft_model::table::table_styles().iter().any(|(id, _)| id == style);
if known { (own_guid(style), true) } else { ("{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}".into(), false) }
}
fn clr(w: &mut W, slot: &str, mods: &[(&str, i32)]) {
if mods.is_empty() {
w.val("a:schemeClr", slot);
return;
}
w.open("a:schemeClr", A::new().a("val", slot));
for (n, v) in mods {
w.val(n, v);
}
w.close("a:schemeClr");
}
fn solid(w: &mut W, slot: &str, mods: &[(&str, i32)]) {
w.open0("a:fill");
w.open0("a:solidFill");
clr(w, slot, mods);
w.close("a:solidFill");
w.close("a:fill");
}
fn borders(w: &mut W, b: Option<(&str, &[(&str, i32)], i32)>) {
w.open0("a:tcBdr");
if let Some((slot, mods, width)) = b {
for side in ["a:left", "a:right", "a:top", "a:bottom", "a:insideH", "a:insideV"] {
w.open0(side);
w.open("a:ln", A::new().a("w", width).a("cmpd", "sng"));
w.open0("a:solidFill");
clr(w, slot, mods);
w.close("a:solidFill");
w.close("a:ln");
w.close(side);
}
}
w.close("a:tcBdr");
}
fn txt(w: &mut W, bold: bool, color: Option<&str>) {
w.open("a:tcTxStyle", A::new().o("b", bold.then_some("on")));
w.open("a:fontRef", A::new().a("idx", "minor"));
w.val("a:prstClr", "black");
w.close("a:fontRef");
match color {
Some(c) => w.val("a:schemeClr", c),
None => w.val("a:schemeClr", "dk1"),
}
w.close("a:tcTxStyle");
}
/// Write one `a:tblStyle` for our style id.
fn style_def(w: &mut W, id: &str, name: &str) {
let kind = id.split('-').next().unwrap_or("medium2");
let acc = match id.rsplit('-').next().unwrap_or("accent1") {
"tx1" => "dk1",
a if a.starts_with("accent") => a,
_ => "accent1",
};
let acc: &str = acc;
w.open("a:tblStyle", A::new().a("styleId", own_guid(id)).a("styleName", name));
let part =
|w: &mut W, tag: &str, bold: bool, text: Option<&str>, fill: Option<(&str, &[(&str, i32)])>, bdr: Option<(&str, &[(&str, i32)], i32)>| {
w.open0(tag);
if bold || text.is_some() {
txt(w, bold, text);
}
w.open0("a:tcStyle");
borders(w, bdr);
if let Some((s, m)) = fill {
solid(w, s, m);
}
w.close("a:tcStyle");
w.close(tag);
};
match kind {
"grid" => {
part(w, "a:wholeTbl", false, Some("dk1"), None, Some(("dk1", &[], 9525)));
}
"light1" => {
part(w, "a:wholeTbl", false, Some("dk1"), None, None);
part(w, "a:band1H", false, None, Some((acc, &[("a:tint", 20000)])), None);
part(w, "a:band1V", false, None, Some((acc, &[("a:tint", 20000)])), None);
part(w, "a:lastCol", true, None, None, None);
part(w, "a:firstCol", true, None, None, None);
part(w, "a:lastRow", true, None, None, None);
part(w, "a:firstRow", true, None, None, Some((acc, &[], 12700)));
}
"light2" => {
part(w, "a:wholeTbl", false, Some("dk1"), None, Some((acc, &[], 12700)));
// Schema order: … lastCol, firstCol, lastRow, …, firstRow.
part(w, "a:firstCol", true, None, None, None);
part(w, "a:lastRow", true, None, None, None);
part(w, "a:firstRow", true, Some("lt1"), Some((acc, &[])), Some((acc, &[], 12700)));
}
"dark1" => {
part(w, "a:wholeTbl", false, Some("lt1"), Some((acc, &[("a:shade", 75000)])), Some(("lt1", &[], 12700)));
part(w, "a:band1H", false, None, Some((acc, &[("a:shade", 60000)])), None);
part(w, "a:band1V", false, None, Some((acc, &[("a:shade", 60000)])), None);
part(w, "a:firstCol", true, None, None, None);
part(w, "a:lastRow", true, None, None, None);
part(w, "a:firstRow", true, Some("lt1"), Some(("dk1", &[])), None);
}
"medium4" => {
part(w, "a:wholeTbl", false, Some("dk1"), Some((acc, &[("a:tint", 20000)])), Some((acc, &[], 9525)));
part(w, "a:band1H", false, None, Some((acc, &[("a:tint", 40000)])), None);
part(w, "a:band1V", false, None, Some((acc, &[("a:tint", 40000)])), None);
part(w, "a:lastCol", true, None, None, None);
part(w, "a:firstCol", true, None, None, None);
part(w, "a:lastRow", true, None, None, None);
part(w, "a:firstRow", true, None, Some((acc, &[("a:tint", 40000)])), None);
}
_ => {
// medium2
part(w, "a:wholeTbl", false, Some("dk1"), Some((acc, &[("a:tint", 20000)])), Some(("lt1", &[], 12700)));
part(w, "a:band1H", false, None, Some((acc, &[("a:tint", 40000)])), None);
part(w, "a:band1V", false, None, Some((acc, &[("a:tint", 40000)])), None);
part(w, "a:lastCol", true, Some("lt1"), Some((acc, &[])), None);
part(w, "a:firstCol", true, Some("lt1"), Some((acc, &[])), None);
part(w, "a:lastRow", true, Some("lt1"), Some((acc, &[])), None);
part(w, "a:firstRow", true, Some("lt1"), Some((acc, &[])), None);
}
}
w.close("a:tblStyle");
}
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
pub fn table_styles_xml(used: &[String]) -> 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();
let mut done: Vec<&str> = vec![];
for s in used {
if done.contains(&s.as_str()) {
continue;
}
done.push(s);
let (_, define) = guid_for_style(s);
if !define {
continue;
}
let name = names.iter().find(|(id, _)| id == s).map(|(_, n)| format!("DeckCraft {n}")).unwrap_or_else(|| s.clone());
style_def(&mut w, s, &name);
}
w.close("a:tblStyleLst");
w.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_parts_follow_schema_order() {
const ORDER: [&str; 14] = [
"tblBg", "wholeTbl", "band1H", "band2H", "band1V", "band2V", "lastCol", "firstCol", "lastRow", "seCell", "swCell", "firstRow", "neCell",
"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 mut n = 0;
for st in d.root.children_named("tblStyle") {
n += 1;
let pos: Vec<usize> = st.elements().map(|e| ORDER.iter().position(|o| *o == e.local()).unwrap()).collect();
assert!(pos.windows(2).all(|w| w[0] < w[1]), "{:?}: {pos:?}", st.attr("styleName"));
}
assert!(n > 30);
}
#[test]
fn guids_round_trip() {
for (id, _) in deckcraft_model::table::table_styles() {
let (g, _) = guid_for_style(&id);
assert_eq!(style_from_guid(&g), id, "{id} via {g}");
assert_eq!(g.len(), 38);
}
assert_eq!(style_from_guid("{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"), "medium2-accent1");
assert_eq!(style_from_guid("{ABCDEF00-0000-0000-0000-000000000000}"), "{ABCDEF00-0000-0000-0000-000000000000}");
}
}