diff --git a/crates/drawingml/src/chart.rs b/crates/drawingml/src/chart.rs index e0338542..e575d10f 100644 --- a/crates/drawingml/src/chart.rs +++ b/crates/drawingml/src/chart.rs @@ -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, + /// 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, + /// `c:cat`, or a scatter or bubble series' `c:xVal`. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories: Option, + /// `c:val` or `c:yVal`. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, + /// `c:bubbleSize`. + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option, +} + +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. diff --git a/crates/drawingml/src/chart/read.rs b/crates/drawingml/src/chart/read.rs index 610b0bbe..9919433e 100644 --- a/crates/drawingml/src/chart/read.rs +++ b/crates/drawingml/src/chart/read.rs @@ -387,9 +387,25 @@ fn read_series(s: &El, g: &El, group: (ChartType, bool, Option), 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 { + 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) -> (Option<&'a El>, Option<&'a El>) { diff --git a/crates/drawingml/src/chart/write.rs b/crates/drawingml/src/chart/write.rs index 559b2761..e1b5bd91 100644 --- a/crates/drawingml/src/chart/write.rs +++ b/crates/drawingml/src/chart/write.rs @@ -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, 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> = 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> = 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("Sheet1!$B$2:$B$3"), "{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("Data!$C$1") && xml.contains("Data!$A$2:$A$3") && xml.contains("'My sheet'!$C$2:$C$3")); + assert_eq!(crate::chart::read::read_chart_xml(xml.as_bytes()).unwrap().series[0].refs, c.series[0].refs); + } } diff --git a/crates/drawingml/src/color.rs b/crates/drawingml/src/color.rs index 2095ea0f..ce90db16 100644 --- a/crates/drawingml/src/color.rs +++ b/crates/drawingml/src/color.rs @@ -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 { - 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 { + 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); + } } diff --git a/crates/drawingml/src/read.rs b/crates/drawingml/src/read.rs index 1151c3b4..e74b65d0 100644 --- a/crates/drawingml/src/read.rs +++ b/crates/drawingml/src/read.rs @@ -19,9 +19,13 @@ pub fn pt(e: &El, name: &str) -> Option { 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 { - 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 { @@ -51,11 +55,11 @@ pub fn color_el(c: &El) -> Option { "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#""#).unwrap(); assert_eq!(body_pr(&d).vert.map(|v| v.xml()), Some("wordArtVertRtl")); } + + #[test] + fn percentages_read_in_either_spelling() { + let d = parse(br#""#).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)); + } }