drawingml: what WordCraft and GridCraft read that DeckCraft's copy did not

Every ST_PresetColorVal name has its colour, in any case and with the dk/lt/med abbreviations;
a sysClr without lastClr takes the usual value of its name; a Strict percentage (50%) reads
anywhere a percentage does; a chart series keeps its c:f formulas and the writer uses them.
This commit is contained in:
Dylan M. Taylor
2026-11-12 00:28:36 +00:00
parent dce266b220
commit 1ed16df88b
5 changed files with 293 additions and 38 deletions
+30
View File
@@ -245,6 +245,36 @@ pub struct Series {
/// `c:errBars` on the values (y bars; a bar chart's run along its value axis).
#[serde(skip_serializing_if = "Option::is_none")]
pub error_bars: Option<ErrorBars>,
/// Where the series' data lives (`c:f` formulas), as the file gave them.
#[serde(skip_serializing_if = "SeriesRefs::is_empty")]
pub refs: SeriesRefs,
}
/// The formulas (`c:f`) a series' data comes from, such as `Sheet1!$B$2:$B$5`. A writer uses the
/// ones given and makes up the rest for an embedded workbook laid out one series per column
/// (names in row 1, categories in column A). A caller that changes the data so that a given
/// range no longer covers it clears that formula.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SeriesRefs {
/// `c:tx`.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// `c:cat`, or a scatter or bubble series' `c:xVal`.
#[serde(skip_serializing_if = "Option::is_none")]
pub categories: Option<String>,
/// `c:val` or `c:yVal`.
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<String>,
/// `c:bubbleSize`.
#[serde(skip_serializing_if = "Option::is_none")]
pub sizes: Option<String>,
}
impl SeriesRefs {
pub fn is_empty(&self) -> bool {
*self == SeriesRefs::default()
}
}
/// Error bars (`c:errBars`): how far each point's bar reaches below and above it.
+16
View File
@@ -387,9 +387,25 @@ fn read_series(s: &El, g: &El, group: (ChartType, bool, Option<bool>), categorie
if let Some(b) = s.child(Local("bubbleSize")) {
ser.sizes = numbers(b, budget);
}
let f = |e: Option<&El>| data_ref(e?);
ser.refs = crate::chart::SeriesRefs {
name: f(s.child(Local("tx"))),
categories: f(s.child(Local("cat")).or_else(|| s.child(Local("xVal")))),
values: f(s.child(Local("val")).or_else(|| s.child(Local("yVal")))),
sizes: f(s.child(Local("bubbleSize"))),
};
ser
}
/// The formula of a data source (`c:numRef`, `c:strRef` or `c:multiLvlStrRef`), if it has one.
fn data_ref(e: &El) -> Option<String> {
let r = e.elements().find(|r| matches!(r.local(), "numRef" | "strRef" | "multiLvlStrRef"))?;
let f = r.child(Local("f"))?.text();
let f = f.trim();
// Formulas are short; a huge one is not kept.
(!f.is_empty() && f.len() <= 8192).then(|| f.to_string())
}
/// Axis scaling, titles, number formats and visibility. Returns the primary value axis and the
/// category axis.
fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option<String>) -> (Option<&'a El>, Option<&'a El>) {
+27 -6
View File
@@ -272,7 +272,9 @@ fn series(w: &mut Writer, c: &Chart, s: &Series, i: usize, kind: ChartType, g: G
w.tag("c:idx").attr("val", i).empty();
w.tag("c:order").attr("val", i).empty();
w.open("c:tx", &[]);
str_cache(w, &format!("Sheet1!${}$1", col(i + 1)), std::slice::from_ref(&s.name));
let r = &s.refs;
let or = |given: &Option<String>, made: String| given.clone().unwrap_or(made);
str_cache(w, &or(&r.name, format!("Sheet1!${}$1", col(i + 1))), std::slice::from_ref(&s.name));
w.close();
if g == Group::Stock && s.line.is_none() {
// A stock chart's series are points joined by the high-low lines, not lines of their own.
@@ -334,15 +336,15 @@ fn series(w: &mut Writer, c: &Chart, s: &Series, i: usize, kind: ChartType, g: G
if matches!(g, Group::Scatter | Group::Bubble) {
let xs: Vec<Option<f64>> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() };
w.open("c:xVal", &[]);
num_cache(w, &format!("Sheet1!$A$2:$A${last}"), &xs);
num_cache(w, &or(&r.categories, format!("Sheet1!$A$2:$A${last}")), &xs);
w.close();
w.open("c:yVal", &[]);
num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 1)), &s.values);
num_cache(w, &or(&r.values, format!("Sheet1!${0}$2:${0}${last}", col(i + 1))), &s.values);
w.close();
if g == Group::Bubble {
let sizes: Vec<Option<f64>> = if s.sizes.is_empty() { s.values.iter().map(|_| Some(1.0)).collect() } else { s.sizes.clone() };
w.open("c:bubbleSize", &[]);
num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 2)), &sizes);
num_cache(w, &or(&r.sizes, format!("Sheet1!${0}$2:${0}${last}", col(i + 2))), &sizes);
w.close();
w.tag("c:bubble3D").attr("val", 0).empty();
} else {
@@ -350,10 +352,10 @@ fn series(w: &mut Writer, c: &Chart, s: &Series, i: usize, kind: ChartType, g: G
}
} else {
w.open("c:cat", &[]);
str_cache(w, &format!("Sheet1!$A$2:$A${last}"), &c.categories);
str_cache(w, &or(&r.categories, format!("Sheet1!$A$2:$A${last}")), &c.categories);
w.close();
w.open("c:val", &[]);
num_cache(w, &format!("Sheet1!${0}$2:${0}${last}", col(i + 1)), &s.values);
num_cache(w, &or(&r.values, format!("Sheet1!${0}$2:${0}${last}", col(i + 1))), &s.values);
w.close();
if g == Group::Line {
w.tag("c:smooth").attr("val", if s.smooth { 1 } else { 0 }).empty();
@@ -1144,4 +1146,23 @@ mod cache_cap_tests {
let real = vec![(); 32_000];
assert_eq!(capped(&real).len(), 32_000);
}
#[test]
fn series_formulas_are_read_and_written_back_and_made_up_when_absent() {
let mut c = Chart { kind: ChartType::Bar, categories: vec!["a".into(), "b".into()], ..Default::default() };
c.series.push(Series { name: "S".into(), values: vec![Some(1.0), Some(2.0)], ..Default::default() });
let made = String::from_utf8(super::chart_xml(&c).0).unwrap();
assert!(made.contains("<c:f>Sheet1!$B$2:$B$3</c:f>"), "{made}");
let back = crate::chart::read::read_chart_xml(made.as_bytes()).unwrap();
assert_eq!(back.series[0].refs.values.as_deref(), Some("Sheet1!$B$2:$B$3"));
c.series[0].refs = crate::chart::SeriesRefs {
name: Some("Data!$C$1".into()),
categories: Some("Data!$A$2:$A$3".into()),
values: Some("'My sheet'!$C$2:$C$3".into()),
sizes: None,
};
let xml = String::from_utf8(super::chart_xml(&c).0).unwrap();
assert!(xml.contains("<c:f>Data!$C$1</c:f>") && xml.contains("<c:f>Data!$A$2:$A$3</c:f>") && xml.contains("<c:f>'My sheet'!$C$2:$C$3</c:f>"));
assert_eq!(crate::chart::read::read_chart_xml(xml.as_bytes()).unwrap().series[0].refs, c.series[0].refs);
}
}
+201 -25
View File
@@ -485,34 +485,187 @@ pub const STANDARD_COLORS: [(Rgba, &str); 10] = [
(Rgba::rgb(0x70, 0x30, 0xA0), "Purple"),
];
/// A few CSS-like preset colour names (`prstClr`).
/// A preset colour (`a:prstClr/@val`, `ST_PresetColorVal`): every name in [`PRESET_NAMES`], with
/// the CSS colour of the same name. Case does not matter, and the `dk`, `lt` and `med`
/// abbreviations are the `dark`, `light` and `medium` names (writers vary both).
pub fn preset(name: &str) -> Option<Rgba> {
let c = match name {
"black" => (0, 0, 0),
"white" => (255, 255, 255),
"red" => (255, 0, 0),
"green" => (0, 128, 0),
"lime" => (0, 255, 0),
"blue" => (0, 0, 255),
"yellow" => (255, 255, 0),
"cyan" | "aqua" => (0, 255, 255),
"magenta" | "fuchsia" => (255, 0, 255),
"gray" | "grey" => (128, 128, 128),
"silver" => (192, 192, 192),
"maroon" => (128, 0, 0),
"navy" => (0, 0, 128),
"olive" => (128, 128, 0),
"purple" => (128, 0, 128),
"teal" => (0, 128, 128),
"orange" => (255, 165, 0),
"pink" => (255, 192, 203),
"brown" => (165, 42, 42),
"gold" => (255, 215, 0),
"ltGray" | "lightGray" => (211, 211, 211),
"dkGray" | "darkGray" => (169, 169, 169),
let mut lower = name.to_ascii_lowercase();
for (short, long) in [("dk", "dark"), ("lt", "light"), ("med", "medium")] {
if lower.starts_with(short) && !lower.starts_with(long) {
lower.replace_range(..short.len(), long);
}
}
let i = PRESET_RGB.binary_search_by(|(n, _)| (*n).cmp(lower.as_str())).ok()?;
PRESET_RGB.get(i).map(|(_, v)| rgb24(*v))
}
fn rgb24(v: u32) -> Rgba {
Rgba::rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)
}
/// The preset colours by lower-case long name, sorted.
const PRESET_RGB: [(&str, u32); 147] = [
("aliceblue", 0xF0F8FF),
("antiquewhite", 0xFAEBD7),
("aqua", 0x00FFFF),
("aquamarine", 0x7FFFD4),
("azure", 0xF0FFFF),
("beige", 0xF5F5DC),
("bisque", 0xFFE4C4),
("black", 0x000000),
("blanchedalmond", 0xFFEBCD),
("blue", 0x0000FF),
("blueviolet", 0x8A2BE2),
("brown", 0xA52A2A),
("burlywood", 0xDEB887),
("cadetblue", 0x5F9EA0),
("chartreuse", 0x7FFF00),
("chocolate", 0xD2691E),
("coral", 0xFF7F50),
("cornflowerblue", 0x6495ED),
("cornsilk", 0xFFF8DC),
("crimson", 0xDC143C),
("cyan", 0x00FFFF),
("darkblue", 0x00008B),
("darkcyan", 0x008B8B),
("darkgoldenrod", 0xB8860B),
("darkgray", 0xA9A9A9),
("darkgreen", 0x006400),
("darkgrey", 0xA9A9A9),
("darkkhaki", 0xBDB76B),
("darkmagenta", 0x8B008B),
("darkolivegreen", 0x556B2F),
("darkorange", 0xFF8C00),
("darkorchid", 0x9932CC),
("darkred", 0x8B0000),
("darksalmon", 0xE9967A),
("darkseagreen", 0x8FBC8F),
("darkslateblue", 0x483D8B),
("darkslategray", 0x2F4F4F),
("darkslategrey", 0x2F4F4F),
("darkturquoise", 0x00CED1),
("darkviolet", 0x9400D3),
("deeppink", 0xFF1493),
("deepskyblue", 0x00BFFF),
("dimgray", 0x696969),
("dimgrey", 0x696969),
("dodgerblue", 0x1E90FF),
("firebrick", 0xB22222),
("floralwhite", 0xFFFAF0),
("forestgreen", 0x228B22),
("fuchsia", 0xFF00FF),
("gainsboro", 0xDCDCDC),
("ghostwhite", 0xF8F8FF),
("gold", 0xFFD700),
("goldenrod", 0xDAA520),
("gray", 0x808080),
("green", 0x008000),
("greenyellow", 0xADFF2F),
("grey", 0x808080),
("honeydew", 0xF0FFF0),
("hotpink", 0xFF69B4),
("indianred", 0xCD5C5C),
("indigo", 0x4B0082),
("ivory", 0xFFFFF0),
("khaki", 0xF0E68C),
("lavender", 0xE6E6FA),
("lavenderblush", 0xFFF0F5),
("lawngreen", 0x7CFC00),
("lemonchiffon", 0xFFFACD),
("lightblue", 0xADD8E6),
("lightcoral", 0xF08080),
("lightcyan", 0xE0FFFF),
("lightgoldenrodyellow", 0xFAFAD2),
("lightgray", 0xD3D3D3),
("lightgreen", 0x90EE90),
("lightgrey", 0xD3D3D3),
("lightpink", 0xFFB6C1),
("lightsalmon", 0xFFA07A),
("lightseagreen", 0x20B2AA),
("lightskyblue", 0x87CEFA),
("lightslategray", 0x778899),
("lightslategrey", 0x778899),
("lightsteelblue", 0xB0C4DE),
("lightyellow", 0xFFFFE0),
("lime", 0x00FF00),
("limegreen", 0x32CD32),
("linen", 0xFAF0E6),
("magenta", 0xFF00FF),
("maroon", 0x800000),
("mediumaquamarine", 0x66CDAA),
("mediumblue", 0x0000CD),
("mediumorchid", 0xBA55D3),
("mediumpurple", 0x9370DB),
("mediumseagreen", 0x3CB371),
("mediumslateblue", 0x7B68EE),
("mediumspringgreen", 0x00FA9A),
("mediumturquoise", 0x48D1CC),
("mediumvioletred", 0xC71585),
("midnightblue", 0x191970),
("mintcream", 0xF5FFFA),
("mistyrose", 0xFFE4E1),
("moccasin", 0xFFE4B5),
("navajowhite", 0xFFDEAD),
("navy", 0x000080),
("oldlace", 0xFDF5E6),
("olive", 0x808000),
("olivedrab", 0x6B8E23),
("orange", 0xFFA500),
("orangered", 0xFF4500),
("orchid", 0xDA70D6),
("palegoldenrod", 0xEEE8AA),
("palegreen", 0x98FB98),
("paleturquoise", 0xAFEEEE),
("palevioletred", 0xDB7093),
("papayawhip", 0xFFEFD5),
("peachpuff", 0xFFDAB9),
("peru", 0xCD853F),
("pink", 0xFFC0CB),
("plum", 0xDDA0DD),
("powderblue", 0xB0E0E6),
("purple", 0x800080),
("red", 0xFF0000),
("rosybrown", 0xBC8F8F),
("royalblue", 0x4169E1),
("saddlebrown", 0x8B4513),
("salmon", 0xFA8072),
("sandybrown", 0xF4A460),
("seagreen", 0x2E8B57),
("seashell", 0xFFF5EE),
("sienna", 0xA0522D),
("silver", 0xC0C0C0),
("skyblue", 0x87CEEB),
("slateblue", 0x6A5ACD),
("slategray", 0x708090),
("slategrey", 0x708090),
("snow", 0xFFFAFA),
("springgreen", 0x00FF7F),
("steelblue", 0x4682B4),
("tan", 0xD2B48C),
("teal", 0x008080),
("thistle", 0xD8BFD8),
("tomato", 0xFF6347),
("turquoise", 0x40E0D0),
("violet", 0xEE82EE),
("wheat", 0xF5DEB3),
("white", 0xFFFFFF),
("whitesmoke", 0xF5F5F5),
("yellow", 0xFFFF00),
("yellowgreen", 0x9ACD32),
];
/// A system colour (`a:sysClr/@val`) for a file that gives no `lastClr`: the usual light-theme
/// value of the common names; `None` for the others.
pub fn system(name: &str) -> Option<Rgba> {
let v = match name {
"windowText" | "btnText" | "menuText" | "captionText" | "infoText" => 0x000000,
"window" | "menu" | "highlightText" => 0xFFFFFF,
"btnFace" | "3dLight" => 0xF0F0F0,
"grayText" | "btnShadow" => 0xA0A0A0,
"highlight" => 0x0078D7,
_ => return None,
};
Some(Rgba::rgb(c.0, c.1, c.2))
Some(rgb24(v))
}
/// Every preset colour name a file may write (`ST_PresetColorVal`, with the later editions' aliases).
@@ -827,4 +980,27 @@ mod tests {
assert_eq!(SchemeSlot::from_xml(slot.xml_name()), Some(slot));
}
}
#[test]
fn every_preset_name_has_a_colour_whatever_its_case_or_abbreviation() {
for name in PRESET_NAMES {
assert!(preset(name).is_some(), "{name}");
}
assert!(PRESET_RGB.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(preset("Red"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(preset("dkBlue"), preset("darkBlue"));
assert_eq!(preset("darkBlue"), Some(Rgba::rgb(0, 0, 0x8B)));
assert_eq!(preset("ltGray"), Some(Rgba::rgb(0xD3, 0xD3, 0xD3)));
assert_eq!(preset("medSeaGreen"), Some(Rgba::rgb(0x3C, 0xB3, 0x71)));
assert_eq!(preset("green"), Some(Rgba::rgb(0, 0x80, 0)));
assert_eq!(preset("notAColor"), None);
assert_eq!(preset("dk"), None);
}
#[test]
fn common_system_colours_have_a_value_without_last_clr() {
assert_eq!(system("window"), Some(Rgba::WHITE));
assert_eq!(system("btnFace"), Some(Rgba::rgb(0xF0, 0xF0, 0xF0)));
assert_eq!(system("scrollBar"), None);
}
}
+19 -7
View File
@@ -19,9 +19,13 @@ pub fn pt(e: &El, name: &str) -> Option<f64> {
e.attr_local(name).and_then(value::to_i64_lossy).map(emu_to_pt)
}
/// 1/100 000 → fraction.
/// 1/100 000 → fraction. Strict OOXML writes a percentage as `50%`.
pub fn pct(e: &El, name: &str) -> Option<f64> {
e.attr_local(name).and_then(value::to_f64).map(|v| v / 100_000.0)
let v = e.attr_local(name)?.trim();
match v.strip_suffix('%') {
Some(p) => value::to_f64(p).map(|v| v / 100.0),
None => value::to_f64(v).map(|v| v / 100_000.0),
}
}
fn bullet_size_points(value: Option<&str>) -> Option<f64> {
@@ -51,11 +55,11 @@ pub fn color_el(c: &El) -> Option<ColorRef> {
"schemeClr" => ColorBase::Scheme { slot: c.attr_local("val").and_then(SchemeSlot::from_xml).unwrap_or(SchemeSlot::Tx1) },
"sysClr" => ColorBase::System {
name: c.attr_local("val").unwrap_or("windowText").to_string(),
last: c.attr_local("lastClr").and_then(Rgba::from_hex).unwrap_or(if c.attr_local("val") == Some("window") {
Rgba::WHITE
} else {
Rgba::BLACK
}),
last: c
.attr_local("lastClr")
.and_then(Rgba::from_hex)
.or_else(|| c.attr_local("val").and_then(crate::color::system))
.unwrap_or(Rgba::BLACK),
},
"prstClr" => ColorBase::Preset { name: c.attr_local("val").unwrap_or("black").to_string() },
"scrgbClr" => {
@@ -810,4 +814,12 @@ mod tests {
let d = parse(br#"<a:bodyPr vert="wordArtVertRtl"/>"#).unwrap();
assert_eq!(body_pr(&d).vert.map(|v| v.xml()), Some("wordArtVertRtl"));
}
#[test]
fn percentages_read_in_either_spelling() {
let d = parse(br#"<a:custDash xmlns:a="x"><a:ds d="400000" sp="300%"/><a:ds d="12.5%" sp="x"/></a:custDash>"#).unwrap();
let ds: Vec<_> = d.children(Local("ds")).collect();
assert_eq!((pct(ds[0], "d"), pct(ds[0], "sp")), (Some(4.0), Some(3.0)));
assert_eq!((pct(ds[1], "d"), pct(ds[1], "sp")), (Some(0.125), None));
}
}