diff --git a/Cargo.lock b/Cargo.lock index acfce94b..d03ab3a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1047,6 +1047,7 @@ version = "0.1.0" dependencies = [ "craft-xml", "kurbo 0.13.1", + "log", "serde", "serde_json", ] diff --git a/crates/drawingml/Cargo.toml b/crates/drawingml/Cargo.toml index 6873a91e..dc2a0b5a 100644 --- a/crates/drawingml/Cargo.toml +++ b/crates/drawingml/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] craft-xml = { workspace = true } kurbo = { workspace = true } +log = "0.4" serde = { workspace = true } [dev-dependencies] diff --git a/crates/drawingml/src/chart.rs b/crates/drawingml/src/chart.rs index c93d12f2..e0338542 100644 --- a/crates/drawingml/src/chart.rs +++ b/crates/drawingml/src/chart.rs @@ -1676,3 +1676,7 @@ mod chartex_tests { assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); } } + +pub mod chartex; +pub mod read; +pub mod write; diff --git a/crates/drawingml/src/chart/chartex.rs b/crates/drawingml/src/chart/chartex.rs new file mode 100644 index 00000000..c6c36d6f --- /dev/null +++ b/crates/drawingml/src/chart/chartex.rs @@ -0,0 +1,184 @@ +//! Office 2016 charts (`cx:chartSpace`, the `chartEx` part): waterfall, funnel, treemap, +//! sunburst, histogram, Pareto and box & whisker. Their data sits in `cx:chartData` as levels of +//! points, and each series names its layout (`layoutId`). + +use craft_xml::{El, Local, value}; + +use super::read::rich_text; +use crate::chart::{Chart, ChartType, Series}; +use std::collections::HashMap; + +/// Points per level and levels per dimension we keep (guards against hostile parts). +const MAX_POINTS: usize = 100_000; +const MAX_CHARTEX_POINTS: usize = 1_000_000; + +/// One `cx:data`: its categories (the innermost level) and named numeric dimensions. +#[derive(Default)] +struct Data { + categories: Vec, + /// Outer category levels, innermost first. + levels: Vec>, + numbers: HashMap>, Option)>, +} + +/// A level's points, by `idx`. +fn points(lvl: &El, budget: &mut usize) -> Vec { + if *budget == 0 { + return Vec::new(); + } + let cap = MAX_POINTS.min(*budget); + let mut pts = Vec::new(); + let mut max_idx = 0; + for pt in lvl.children(Local("pt")).take(cap) { + let i = pt.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0) as usize; + if i >= cap { + continue; + } + max_idx = max_idx.max(i + 1); + pts.push((i, pt.deep_text())); + } + if pts.is_empty() { + return Vec::new(); + } + let n = max_idx.min(cap); + let mut out = vec![String::new(); n]; + for (i, s) in pts { + if let Some(target) = out.get_mut(i) { + *target = s; + } + } + *budget = budget.saturating_sub(out.len()); + out +} + +fn data(d: &El, budget: &mut usize) -> Data { + let mut out = Data::default(); + for dim in d.elements().take(64) { + if *budget == 0 { + break; + } + // The first level is the innermost: a treemap's leaves, a waterfall's categories. + let Some(lvl) = dim.child(Local("lvl")) else { continue }; + let ty = dim.attr_local("type").unwrap_or(""); + if dim.is(Local("strDim")) || (dim.is(Local("numDim")) && ty == "cat") { + if ty == "cat" || out.categories.is_empty() { + out.categories = points(lvl, budget); + // Further levels are the hierarchy's parents, innermost first. + out.levels = dim.children(Local("lvl")).skip(1).take(crate::chart::MAX_LEVELS).map(|l| points(l, budget)).collect(); + } + } else if dim.is(Local("numDim")) { + let vals = points(lvl, budget).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect(); + out.numbers.insert(ty.to_string(), (vals, lvl.attr_local("formatCode").map(str::to_string))); + } + } + out +} + +fn kind_of(layout: &str) -> Option { + Some(match layout { + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "treemap" => ChartType::Treemap, + "sunburst" => ChartType::Sunburst, + "clusteredColumn" => ChartType::Histogram, + "boxWhisker" => ChartType::BoxWhisker, + "regionMap" => ChartType::RegionMap, + _ => return None, + }) +} + +/// Parse a `chartEx` part. `None` for anything else, and for layouts with no [`crate::chart::ChartType`]. +pub fn read_chartex_xml(bytes: &[u8]) -> Option { + let doc = crate::xmlx::parse(bytes).ok()?; + let mut root = doc; + crate::xmlx::resolve_mc(&mut root); + let mut c = chartex_from(&root)?; + c.raw = Some(String::from_utf8_lossy(bytes).into_owned()); + Some(c) +} + +pub fn chartex_from(root: &El) -> Option { + if !root.is(Local("chartSpace")) { + return None; + } + let mut decode_budget = MAX_CHARTEX_POINTS; + let datas: HashMap = root + .child(Local("chartData"))? + .children(Local("data")) + .take(64) + .map(|d| (d.attr_local("id").unwrap_or("0").to_string(), data(d, &mut decode_budget))) + .collect(); + let ch = root.child(Local("chart"))?; + let region = ch.child(Local("plotArea"))?.child(Local("plotAreaRegion"))?; + let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() }; + c.title = ch.child(Local("title")).map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into())); + c.legend = ch.child(Local("legend")).map(|l| l.attr_local("pos").unwrap_or("t").to_string()); + let mut kind = None; + let mut output_budget = MAX_CHARTEX_POINTS; + let mut categories_set = false; + for s in region.children(Local("series")).take(1000) { + let layout = s.attr_local("layoutId").unwrap_or(""); + if layout == "paretoLine" { + // The cumulative line PowerPoint adds to a Pareto histogram. + kind = Some(ChartType::Pareto); + continue; + } + let k = kind_of(layout)?; + if s.attr_local("hidden").and_then(value::to_bool) == Some(true) { + continue; + } + kind = Some(match kind { + Some(ChartType::Pareto) => ChartType::Pareto, + _ => k, + }); + let d = datas.get(s.child(Local("dataId")).and_then(|i| i.attr_local("val")).unwrap_or("0"))?; + let (values_src, format) = ["val", "size", "colorVal"] + .iter() + .find_map(|t| d.numbers.get(*t)) + .or_else(|| d.numbers.values().next()) + .map(|(v, f)| (v.as_slice(), f.clone())) + .unwrap_or((&[], None)); + let take_len = values_src.len().min(output_budget); + let values = values_src[..take_len].to_vec(); + output_budget = output_budget.saturating_sub(values.len()); + if !categories_set && (!d.categories.is_empty() || !d.levels.is_empty()) { + categories_set = true; + c.categories = d.categories.clone(); + c.category_levels = d.levels.clone(); + output_budget = output_budget.saturating_sub(c.categories.len()); + for lvl in &c.category_levels { + output_budget = output_budget.saturating_sub(lvl.len()); + } + } + if let Some(st) = s.child(Local("layoutPr")).and_then(|l| l.child(Local("statistics"))) { + c.inclusive_quartiles = st.attr_local("quartileMethod") == Some("inclusive"); + } + if let Some(b) = s.child(Local("layoutPr")).and_then(|l| l.child(Local("binning"))) { + c.bin_count = b.child(Local("binCount")).and_then(|n| n.attr_local("val").and_then(value::to_u32_lossy)).filter(|n| *n > 0); + c.bin_size = + b.child(Local("binSize")).and_then(|n| n.attr_local("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); + } + if let Some(st) = s.child(Local("layoutPr")).and_then(|l| l.child(Local("subtotals"))) { + c.subtotals = st + .children(Local("idx")) + .filter_map(|i| i.attr_local("val").and_then(value::to_u32_lossy)) + .map(|i| i as usize) + .take(MAX_POINTS) + .collect(); + } + let name = s.child(Local("tx")).and_then(|t| t.find(Local("v"))).map(|v| v.deep_text()).unwrap_or_default(); + c.series.push(Series { + name, + values, + data_labels: s.child(Local("dataLabels")).map(|l| l.attr_local("hidden").and_then(value::to_bool) != Some(true)), + number_format: format, + ..Default::default() + }); + } + c.kind = kind?; + c.data_labels = c.series.iter().any(|s| s.data_labels == Some(true)); + c.vary_colors = matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall); + // Waterfall, histogram and box charts have a value axis with gridlines by default. + c.gridlines = matches!(c.kind, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker); + Some(c) +} diff --git a/crates/drawingml/src/chart/read.rs b/crates/drawingml/src/chart/read.rs new file mode 100644 index 00000000..610b0bbe --- /dev/null +++ b/crates/drawingml/src/chart/read.rs @@ -0,0 +1,842 @@ +//! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`. + +use crate::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; +use crate::style::Fill; + +use craft_xml::{El, Local, value}; + +use crate::read as dml; + +/// Fills of chart parts never reference images we keep; read colour and pattern fills only. +fn simple_fill(sp: Option<&El>) -> Option { + let sp = sp?; + for e in sp.elements() { + match e.local() { + "noFill" => return Some(Fill::None), + "solidFill" => return dml::color(e).map(|color| Fill::Solid { color }), + "gradFill" => return Some(Fill::Gradient(dml::gradient(e))), + "pattFill" => return Some(Fill::Pattern(dml::pattern(e))), + _ => {} + } + } + None +} + +fn simple_line(sp: Option<&El>) -> Option { + let ln = sp?.child(Local("ln"))?; + let mut l = crate::style::Line { width: dml::pt(ln, "w"), ..Default::default() }; + l.fill = simple_fill(Some(ln)); + Some(l) +} + +pub fn rich_text(t: &El) -> Option { + let tx = t.child(Local("tx"))?; + if let Some(r) = tx.child(Local("rich")) { + let s = r.children(Local("p")).map(|p| p.deep_text()).collect::>().join("\n"); + return Some(s); + } + tx.find(Local("v")).map(|v| v.deep_text()) +} + +/// Most category/value/size slots one chart may decode across all its series and caches, so +/// sparse `ptCount`/`idx` declarations can't expand far beyond the XML they came from. +pub const MAX_CHART_POINTS: usize = 1_000_000; +/// Data slots (values and categories) the charts of one presentation may hold in all; a chart past +/// it is read as a placeholder frame. Each frame that references a chart decodes its own copy. +pub const MAX_PRESENTATION_CHART_POINTS: usize = 4_000_000; + +/// Chart part bytes all chart frames of a presentation may decode, counting every attempt: a +/// chart that is rejected, or has no data slots, would otherwise be decoded again for each frame +/// that shows it. +pub const MAX_CHART_DECODE_BYTES: usize = 256 * 1024 * 1024; + +/// Slots a decoded chart holds, counting the chart itself as at least one. +pub fn point_count(c: &Chart) -> usize { + let series = |s: &crate::chart::Series| { + let bars = s.error_bars.as_ref().map_or(0, |e| e.plus.len().saturating_add(e.minus.len())); + s.values.len().saturating_add(s.x_values.len()).saturating_add(s.sizes.len()).saturating_add(bars) + }; + let levels: usize = c.category_levels.iter().map(Vec::len).sum(); + 1usize.saturating_add(c.categories.len()).saturating_add(levels).saturating_add(c.series.iter().map(series).fold(0, usize::saturating_add)) +} + +/// Most slots one cache may hold. +const MAX_CACHE_POINTS: usize = 100_000; + +/// String values from `c:cat`/`c:tx` (str or num refs, caches or literals). Every slot returned +/// is taken from `budget`; once it runs out the values are cut short. +fn strings(e: &El, budget: &mut usize) -> Vec { + let cap = MAX_CACHE_POINTS.min(*budget); + let out = strings_capped(e, cap); + *budget = budget.saturating_sub(out.len()); + out +} + +fn strings_capped(e: &El, cap: usize) -> Vec { + let cache = + e.find(Local("strCache")).or_else(|| e.find(Local("numCache"))).or_else(|| e.find(Local("strLit"))).or_else(|| e.find(Local("numLit"))); + if let Some(c) = cache { + let n = (c.child(Local("ptCount")).and_then(|p| p.attr_local("val").and_then(value::to_u32_lossy)).unwrap_or(0) as usize).min(cap); + let mut out = vec![String::new(); n]; + for pt in c.children(Local("pt")).take(MAX_CACHE_POINTS) { + let i = pt.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0) as usize; + if i >= cap { + continue; + } + let v = pt.child(Local("v")).map(|v| v.deep_text()).unwrap_or_default(); + if i >= out.len() { + out.resize(i + 1, String::new()); + } + if let Some(s) = out.get_mut(i) { + *s = v; + } + } + return out; + } + if let Some(ml) = e.find(Local("multiLvlStrCache")) { + // Use the innermost level. + if let Some(l) = ml.child(Local("lvl")) { + return l.children(Local("pt")).take(cap).map(|p| p.child(Local("v")).map(|v| v.deep_text()).unwrap_or_default()).collect(); + } + } + if cap > 0 + && let Some(v) = e.child(Local("v")) + { + return vec![v.deep_text()]; + } + vec![] +} + +fn numbers(e: &El, budget: &mut usize) -> Vec> { + strings(e, budget).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect() +} + +/// Parse a chart part. Returns `None` for something that isn't a chart we can read. +pub fn read_chart_xml(bytes: &[u8]) -> Option { + let doc = crate::xmlx::parse(bytes).ok()?; + let mut root = doc; + crate::xmlx::resolve_mc(&mut root); + if !root.is(Local("chartSpace")) { + return None; + } + let mut c = chart_from(&root)?; + c.raw = Some(String::from_utf8_lossy(bytes).into_owned()); + Some(c) +} + +pub fn chart_from(root: &El) -> Option { + chart_within(root, MAX_CHART_POINTS) +} + +/// [`chart_from`] decoding at most `budget` data slots in all. +fn chart_within(root: &El, mut budget: usize) -> Option { + let ch = root.child(Local("chart"))?; + let pa = ch.child(Local("plotArea"))?; + let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() }; + c.style = root.child(Local("style")).and_then(|c| c.attr_local("val")).and_then(|v| v.parse().ok()).unwrap_or(2); + if let Some(t) = ch.child(Local("title")) { + c.title = Some(rich_text(t).unwrap_or_else(|| "Chart Title".into())); + } + if ch.child(Local("autoTitleDeleted")).and_then(|a| a.attr_local("val").and_then(value::to_bool)) == Some(true) { + c.title = None; + } + c.legend = ch.child(Local("legend")).map(|l| l.child(Local("legendPos")).and_then(|c| c.attr_local("val")).unwrap_or("r").to_string()); + c.legend_overlay = + ch.child(Local("legend")).and_then(|l| l.child(Local("overlay"))).and_then(|o| o.attr_local("val").and_then(value::to_bool)).unwrap_or(false); + c.chart_fill = simple_fill(root.child(Local("spPr"))); + c.plot_fill = simple_fill(pa.child(Local("spPr"))); + + let mut kinds: Vec = vec![]; + // The value axis the first plot group uses is the primary one; a group on another value axis + // (a combo chart's line on the right) is on the secondary axis. + let val_map: std::collections::HashMap<&str, bool> = pa + .children(Local("valAx")) + .filter_map(|a| { + Some(( + a.child(Local("axId")).and_then(|c| c.attr_local("val"))?, + matches!(a.child(Local("axPos")).and_then(|c| c.attr_local("val")), Some("l" | "r")), + )) + }) + .collect(); + let primary_val = primary_value_axis(pa, &val_map); + for g in pa.elements() { + let name = g.local(); + if !name.ends_with("Chart") { + continue; + } + let Some(kind) = group_chart_type(name, g) else { continue }; + if name == "stockChart" { + c.up_down_bars = g.child(Local("upDownBars")).is_some(); + } + let group_labels = read_group_settings(g, &mut c); + kinds.push(kind); + let val_axis = group_value_axis(&val_map, g); + let secondary = val_axis.is_some() && val_axis != primary_val; + for s in g.children(Local("ser")).take(1000) { + let ser = read_series(s, g, (kind, secondary, group_labels), &mut c.categories, &mut budget); + c.series.push(ser); + } + } + // `autoTitleDeleted val="0"` without `c:title`: PowerPoint and LibreOffice draw the series name as the title of a single-series chart. + if ch.child(Local("title")).is_none() + && ch.child(Local("autoTitleDeleted")).and_then(|a| a.attr_local("val").and_then(value::to_bool)) == Some(false) + && c.series.len() == 1 + { + c.title = c.series.first().map(|s| s.name.clone()).filter(|n| !n.is_empty()); + } + let first = *kinds.first()?; + c.kind = if kinds.iter().any(|k| *k != first) { ChartType::Combo } else { first }; + if c.kind != ChartType::Combo { + for s in &mut c.series { + s.kind = None; + s.secondary_axis = false; + } + } + c.lang = root.child(Local("lang")).and_then(|c| c.attr_local("val")).map(str::to_string); + let (val_ax, cat_ax) = read_axes(&mut c, pa, &primary_val); + read_text_styles(&mut c, root, ch, val_ax, cat_ax); + Some(c) +} + +/// A group's value axis: its vertical one when it has two (scatter), else its one. +fn group_value_axis(val_map: &std::collections::HashMap<&str, bool>, g: &El) -> Option { + let ids: Vec<(&str, bool)> = + g.children(Local("axId")).filter_map(|a| a.attr_local("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); + ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) +} + +/// The primary value axis is the one on the left (bottom for bar charts); the secondary one +/// sits on the right or top, or crosses the category axis at its maximum. +fn primary_value_axis(pa: &El, val_map: &std::collections::HashMap<&str, bool>) -> Option { + let secondary_placed = |a: &El| { + a.child(Local("crosses")).and_then(|c| c.attr_local("val")) == Some("max") + || matches!(a.child(Local("axPos")).and_then(|c| c.attr_local("val")), Some("r" | "t")) + }; + let val_axes_by_id: std::collections::HashMap<&str, &El> = + pa.children(Local("valAx")).filter_map(|a| Some((a.child(Local("axId")).and_then(|c| c.attr_local("val"))?, a))).collect(); + let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(|g| group_value_axis(val_map, g)).collect(); + used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned() +} + +/// The chart type of a plot group (`name` is its element name), or `None` for an unknown group. +fn group_chart_type(name: &str, g: &El) -> Option { + let grouping = g.child(Local("grouping")).and_then(|c| c.attr_local("val")).unwrap_or("clustered"); + Some(match name { + "barChart" | "bar3DChart" => { + let bar = g.child(Local("barDir")).and_then(|c| c.attr_local("val")) == Some("bar"); + match (bar, grouping) { + (false, "stacked") => ChartType::StackedColumn, + (false, "percentStacked") => ChartType::PercentColumn, + (false, _) => ChartType::Column, + (true, "stacked") => ChartType::StackedBar, + (true, "percentStacked") => ChartType::PercentBar, + (true, _) => ChartType::Bar, + } + } + "lineChart" | "line3DChart" => { + let markers = g.child(Local("marker")).and_then(|c| c.attr_local("val")).is_some_and(|v| v == "1" || v == "true") + && g.children(Local("ser")).all(|s| s.path(&[Local("marker"), Local("symbol")]).and_then(|m| m.attr_local("val")) != Some("none")); + match grouping { + "stacked" | "percentStacked" => ChartType::StackedLine, + _ if markers => ChartType::LineMarkers, + _ => ChartType::Line, + } + } + "areaChart" | "area3DChart" => { + if matches!(grouping, "stacked" | "percentStacked") { + ChartType::StackedArea + } else { + ChartType::Area + } + } + "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, + "doughnutChart" => ChartType::Doughnut, + "scatterChart" => ChartType::Scatter, + "bubbleChart" => ChartType::Bubble, + "radarChart" => { + if g.child(Local("radarStyle")).and_then(|c| c.attr_local("val")) == Some("filled") { + ChartType::FilledRadar + } else { + ChartType::Radar + } + } + "stockChart" => ChartType::Stock, + "surfaceChart" | "surface3DChart" => ChartType::Surface, + _ => return None, + }) +} + +/// Chart-wide settings a plot group carries (vary colours, gap, overlap, hole, data labels). +/// Returns the group's own data-label setting. +fn read_group_settings(g: &El, c: &mut Chart) -> Option { + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child(Local("varyColors")).and_then(|c| c.attr_local("val")).is_none_or(|v| v == "1" || v == "true"); + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is(Local("barChart")) || g.is(Local("bar3DChart")); + if let Some(v) = g.child(Local("gapWidth")).and_then(|c| c.attr_local("val")).and_then(|v| v.parse::().ok()) { + c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; + } + if let Some(v) = g.child(Local("overlap")).and_then(|c| c.attr_local("val")).and_then(|v| v.parse::().ok()) { + c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; + } + if let Some(v) = g.child(Local("holeSize")).and_then(|c| c.attr_local("val")).and_then(|v| v.parse::().ok()) { + c.hole_size = (v / 100.0).clamp(0.0, 0.9); + } + // `` without a val attribute means true, as in the schema's default. + // The setting belongs to this plot group: a group that asks for no labels keeps them + // off, even when another group of a combo chart asks for them (#868). + let group_labels = g.path(&[Local("dLbls"), Local("showVal")]).map(|v| v.attr_local("val").and_then(value::to_bool).unwrap_or(true)); + if group_labels == Some(true) { + c.data_labels = true; + } + group_labels +} + +/// Data labels' number format: the series' labels', the group's, else the source data's. +fn label_number_format(s: &El, g: &El) -> Option { + let label_fmt = |d: Option<&El>| { + d.and_then(|d| d.child(Local("numFmt"))) + .filter(|n| n.attr_local("sourceLinked") != Some("1")) + .and_then(|n| n.attr_local("formatCode")) + .map(str::to_string) + }; + let source = s + .child(Local("val")) + .or_else(|| s.child(Local("yVal"))) + .and_then(|v| v.find(Local("numCache"))) + .and_then(|c| c.child(Local("formatCode"))) + .map(|f| f.deep_text()); + label_fmt(s.child(Local("dLbls"))).or_else(|| label_fmt(g.child(Local("dLbls")))).or(source).filter(|f| !f.is_empty()) +} + +/// Error bars on the values: a scatter chart's y bars, any other chart's only ones. +fn read_error_bars(s: &El, kind: ChartType, budget: &mut usize) -> Option { + let e = s.children(Local("errBars")).find(|e| { + e.child(Local("errDir")).and_then(|c| c.attr_local("val")).is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble)) + })?; + let d = ErrorBars::default(); + Some(ErrorBars { + bar_type: e.child(Local("errBarType")).and_then(|c| c.attr_local("val")).unwrap_or("both").to_string(), + val_type: e.child(Local("errValType")).and_then(|c| c.attr_local("val")).unwrap_or("fixedVal").to_string(), + value: e + .child(Local("val")) + .and_then(|c| c.attr_local("val")) + .and_then(|v| v.parse().ok()) + .filter(|v: &f64| v.is_finite()) + .unwrap_or(d.value), + plus: e.child(Local("plus")).map(|p| numbers(p, budget)).unwrap_or_default(), + minus: e.child(Local("minus")).map(|p| numbers(p, budget)).unwrap_or_default(), + end_cap: e.child(Local("noEndCap")).and_then(|v| v.attr_local("val").and_then(value::to_bool)) != Some(true), + }) +} + +/// One `c:ser` of group `g`. `group` is (chart type, on the secondary axis, the group's data-label +/// setting); the first series with categories sets `categories`. +fn read_series(s: &El, g: &El, group: (ChartType, bool, Option), categories: &mut Vec, budget: &mut usize) -> Series { + let (kind, secondary, group_labels) = group; + let mut ser = Series { + name: s.child(Local("tx")).map(|t| strings(t, budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), + fill: simple_fill(s.child(Local("spPr"))), + line: simple_line(s.child(Local("spPr"))), + smooth: s.child(Local("smooth")).and_then(|v| v.attr_local("val").and_then(value::to_bool)).unwrap_or(false), + marker: s.path(&[Local("marker"), Local("symbol")]).and_then(|m| m.attr_local("val")).filter(|m| *m != "none").map(String::from), + kind: Some(kind), + secondary_axis: secondary, + ..Default::default() + }; + // `` without a val attribute means true, as in the schema's default. + if let Some(d) = + s.child(Local("dLbls")).and_then(|d| d.child(Local("showVal"))).map(|v| v.attr_local("val").and_then(value::to_bool).unwrap_or(true)) + { + ser.data_labels = Some(d); + } else if let Some(false) = group_labels { + // The group asks for no labels and the series does not say otherwise. + ser.data_labels = Some(false); + } + for t in s.children(Local("trendline")).take(16) { + if let Some(t) = trendline(t) { + ser.trendlines.push(t); + } + } + ser.number_format = label_number_format(s, g); + for dp in s.children(Local("dPt")) { + if let (Some(i), Some(f)) = + (dp.child(Local("idx")).and_then(|i| i.attr_local("val").and_then(value::to_u32_lossy)), simple_fill(dp.child(Local("spPr")))) + { + ser.point_fills.push((i as usize, f)); + } + } + if let Some(cat) = s.child(Local("cat")).or_else(|| s.child(Local("xVal"))) { + let cats = strings(cat, budget); + if matches!(kind, ChartType::Scatter | ChartType::Bubble) { + ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); + } + if categories.is_empty() { + *categories = cats; + } + } + if let Some(v) = s.child(Local("val")).or_else(|| s.child(Local("yVal"))) { + ser.values = numbers(v, budget); + } + ser.error_bars = read_error_bars(s, kind, budget); + if let Some(b) = s.child(Local("bubbleSize")) { + ser.sizes = numbers(b, budget); + } + ser +} + +/// 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>) { + let vertical = |a: &&El| matches!(a.child(Local("axPos")).and_then(|c| c.attr_local("val")), Some("l" | "r")); + let by_id = |id: &Option| { + pa.children(Local("valAx")).find(|a| id.is_some() && a.child(Local("axId")).and_then(|c| c.attr_local("val")) == id.as_deref()) + }; + let val_ax = by_id(primary_val).or_else(|| pa.children(Local("valAx")).find(vertical)).or_else(|| pa.children(Local("valAx")).last()); + let num = |a: &El, k: &str| a.child(Local(k)).and_then(|c| c.attr_local("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); + // An axis' number format: its own unless linked to the source (`sourceLinked="1"`, or no + // c:numFmt at all), in which case the data's format of the first series plotted on it. + let source_format = |ax: &El| { + let id = ax.child(Local("axId")).and_then(|c| c.attr_local("val")); + pa.elements() + .filter(|g| g.children(Local("axId")).any(|a| a.attr_local("val") == id)) + .flat_map(|g| g.children(Local("ser"))) + .find_map(|s| { + s.child(Local("val")) + .or_else(|| s.child(Local("yVal"))) + .and_then(|v| v.find(Local("numCache"))) + .and_then(|c| c.child(Local("formatCode"))) + .map(|f| f.deep_text()) + }) + .filter(|f| !f.is_empty() && f != "General") + }; + let format_code = |a: &El| match a.child(Local("numFmt")) { + Some(n) if n.attr_local("sourceLinked") != Some("1") => n.attr_local("formatCode").map(str::to_string), + n => source_format(a).or_else(|| n.and_then(|n| n.attr_local("formatCode")).map(str::to_string)), + }; + // `c:tickLblPos val="none"` hides the labels just as `c:delete` does. + let deleted = |a: &El| { + a.child(Local("delete")).and_then(|d| d.attr_local("val").and_then(value::to_bool)) == Some(true) + || a.child(Local("tickLblPos")).and_then(|c| c.attr_local("val")) == Some("none") + }; + // A gridline whose `c:spPr/a:ln` has `a:noFill` is not drawn, though the element is there. + let grid = |a: &El, k: &str| { + a.child(Local(k)) + .is_some_and(|g| !g.child(Local("spPr")).and_then(|p| p.child(Local("ln"))).is_some_and(|l| l.child(Local("noFill")).is_some())) + }; + if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) { + let primary_id = val_ax.and_then(|a| a.child(Local("axId")).and_then(|c| c.attr_local("val"))); + // The other vertical value axis is the secondary one. + if let Some(a) = pa.children(Local("valAx")).find(|a| a.child(Local("axId")).and_then(|c| c.attr_local("val")) != primary_id && vertical(a)) { + let sc = a.child(Local("scaling")); + c.y2_min = sc.and_then(|s| num(s, "min")); + c.y2_max = sc.and_then(|s| num(s, "max")); + c.y2_major = num(a, "majorUnit"); + c.y2_format = format_code(a); + c.y2_hidden = deleted(a); + } + } + let cat_ax = pa.child(Local("catAx")).or_else(|| pa.child(Local("dateAx"))).or_else(|| pa.children(Local("valAx")).find(|a| !vertical(a))); + if let Some(v) = val_ax { + c.gridlines = grid(v, "majorGridlines"); + c.minor_gridlines = grid(v, "minorGridlines"); + let sc = v.child(Local("scaling")); + c.y_min = + sc.and_then(|s| s.child(Local("min")).and_then(|c| c.attr_local("val"))).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); + c.y_max = + sc.and_then(|s| s.child(Local("max")).and_then(|c| c.attr_local("val"))).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); + c.axis_titles.1 = v.child(Local("title")).map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); + c.y_major = num(v, "majorUnit"); + c.y_log_base = sc + .and_then(|s| s.child(Local("logBase")).and_then(|c| c.attr_local("val"))) + .and_then(|v| v.parse().ok()) + .filter(|b: &f64| b.is_finite() && *b >= 2.0); + c.y_format = format_code(v); + c.y_hidden = deleted(v); + } + if let Some(a) = cat_ax { + c.cat_reversed = + a.child(Local("scaling")).and_then(|s| s.child(Local("orientation")).and_then(|c| c.attr_local("val"))).is_some_and(|o| o == "maxMin"); + c.axis_titles.0 = a.child(Local("title")).map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); + } + (val_ax, cat_ax) +} + +/// Text sizes and colours as the file sets them. +fn read_text_styles(c: &mut Chart, root: &El, ch: &El, val_ax: Option<&El>, cat_ax: Option<&El>) { + // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to + // the chart): the axes' and legend's, else the whole chart's; the title's own. + let chart_size = text_size(root.child(Local("txPr"))); + c.text_size = [val_ax, cat_ax, ch.child(Local("legend"))].into_iter().flatten().find_map(|e| text_size(e.child(Local("txPr")))).or(chart_size); + // Text colour likewise: the axes' and legend's, else the whole chart's. + c.text_color = [val_ax, cat_ax, ch.child(Local("legend")), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child(Local("txPr")))); + c.title_size = ch + .child(Local("title")) + .and_then(|t| text_size(t.child(Local("tx")).and_then(|x| x.child(Local("rich")))).or_else(|| text_size(t.child(Local("txPr"))))); +} + +fn trend_num(e: &El, k: &str) -> Option { + e.child(Local(k)).and_then(|c| c.attr_local("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && v.abs() <= 1.0e12) +} + +fn trend_name(s: &str) -> Option { + let cleaned: String = + s.chars().filter(|c| matches!(c, '\t' | '\n' | '\r') || (*c >= ' ' && *c != '\u{FFFE}' && *c != '\u{FFFF}')).take(255).collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { None } else { Some(cleaned.to_string()) } +} + +/// One `c:trendline`. Unknown types are skipped. +fn trendline(e: &El) -> Option { + let kind = TrendlineType::from_xml(e.child(Local("trendlineType")).and_then(|c| c.attr_local("val"))?)?; + Some(Trendline { + kind, + name: e.child(Local("name")).map(|n| n.deep_text()).as_deref().and_then(trend_name), + order: if kind == TrendlineType::Poly { + e.child(Local("order")).and_then(|o| o.attr_local("val").and_then(value::to_u32_lossy)).map(|v| v.clamp(2, 6)) + } else { + None + }, + period: if kind == TrendlineType::MovingAvg { + e.child(Local("period")).and_then(|o| o.attr_local("val").and_then(value::to_u32_lossy)).map(|v| v.clamp(2, 255)) + } else { + None + }, + forward: trend_num(e, "forward"), + backward: trend_num(e, "backward"), + intercept: trend_num(e, "intercept"), + disp_eq: e.child(Local("dispEq")).and_then(|d| d.attr_local("val").and_then(value::to_bool)).unwrap_or(false), + disp_r_sqr: e.child(Local("dispRSqr")).and_then(|d| d.attr_local("val").and_then(value::to_bool)).unwrap_or(false), + }) +} + +/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`. +fn text_color(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is(Local("defRPr")) || c.is(Local("rPr"))) + && let Some(Fill::Solid { color }) = simple_fill(Some(c)) + { + return Some(color); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + +/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths). +fn text_size(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is(Local("defRPr")) || c.is(Local("rPr"))) + && let Some(v) = c.attr_local("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0) + { + return Some((v / 100.0).clamp(1.0, 400.0)); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_count_charges_every_retained_vector() { + let mut c = Chart { categories: vec!["a".into()], category_levels: vec![vec!["x".into(); 2], vec!["y".into(); 3]], ..Default::default() }; + let mut s = crate::chart::Series { values: vec![Some(1.0); 4], x_values: vec![None; 5], sizes: vec![None; 6], ..Default::default() }; + s.error_bars = Some(crate::chart::ErrorBars { plus: vec![None; 7], minus: vec![None; 8], ..Default::default() }); + c.series = vec![s]; + assert_eq!(point_count(&c), 1 + 1 + 5 + 4 + 5 + 6 + 15); + } + + #[test] + fn sparse_caches_share_one_chart_wide_budget() { + // Every series declares 100000 points but writes one: without a chart-wide budget each + // would expand to the full count. + let ser = |n: usize| { + format!( + r#"{n}"# + ) + }; + let sers: String = (0..5).map(ser).collect(); + let xml = format!( + r#"{sers}"# + ); + let doc = crate::xmlx::parse(xml.as_bytes()).unwrap(); + let c = chart_within(&doc, 250_000).unwrap(); + let total: usize = c.series.iter().map(|s| s.values.len()).sum(); + assert!(total <= 250_000, "{total}"); + assert_eq!(c.series.len(), 5); + assert_eq!(c.series[0].values.len(), 100_000); + assert_eq!(c.series[0].values[0], Some(0.0)); + assert!(c.series[4].values.is_empty(), "the budget ran out before the last series"); + // A small chart is untouched by the default budget. + let c = chart_from(&doc).unwrap(); + assert!(c.series.iter().all(|s| s.values.len() == 100_000)); + } + + #[test] + fn minor_gridlines_are_read_and_written_back() { + let mut c = Chart::sample(ChartType::Line); + c.minor_gridlines = true; + let back = read_chart_xml(&crate::chart::write::chart_xml(&c).0).unwrap(); + assert!(back.gridlines && back.minor_gridlines); + c.minor_gridlines = false; + assert!(!read_chart_xml(&crate::chart::write::chart_xml(&c).0).unwrap().minor_gridlines); + } + + #[test] + fn series_pattern_fills_are_read_and_written_back() { + // A pattern-filled series and an empty one with an outline, as PowerPoint writes them. + let xml = br#"53"#; + let c = read_chart_xml(xml).unwrap(); + let accent4 = crate::style::ColorRef::scheme(crate::color::SchemeSlot::Accent4); + let pattern = crate::style::PatternFill { + preset: "openDmnd".into(), + fg: accent4.clone(), + bg: crate::style::ColorRef::scheme(crate::color::SchemeSlot::Bg1), + }; + assert_eq!(c.series[0].fill, Some(Fill::Pattern(pattern.clone()))); + assert_eq!(c.series[1].fill, Some(Fill::None)); + assert_eq!(c.series[1].line.as_ref().and_then(|l| l.fill.clone()), Some(Fill::solid(accent4))); + // Written out from the model (an edited chart), the pattern survives. + let mut edited = c.clone(); + edited.raw = None; + edited.title = Some("Edited".into()); + let back = read_chart_xml(&crate::chart::write::chart_xml(&edited).0).unwrap(); + assert_eq!(back.series[0].fill, Some(Fill::Pattern(pattern))); + assert_eq!(back.series[1].fill, Some(Fill::None)); + } + + #[test] + fn log_base_is_read() { + let xml = br#""#; + assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0)); + } + + #[test] + fn error_bars_are_read() { + let xml = br#"5"#; + let c = read_chart_xml(xml).unwrap(); + let e = c.series[0].error_bars.clone().unwrap(); + assert_eq!((e.bar_type.as_str(), e.val_type.as_str(), e.value, e.end_cap), ("plus", "percentage", 20.0, false)); + } + + #[test] + fn legend_position_and_overlay_are_read() { + let legend = r#""#; + let xml = format!( + r#"{legend}"# + ); + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.legend.as_deref(), Some("r")); + assert!(c.legend_overlay); + let xml = br#""#; + let c = read_chart_xml(xml).unwrap(); + assert_eq!(c.legend.as_deref(), Some("r")); + assert!(!c.legend_overlay); + } + + #[test] + fn combo_chart_with_a_secondary_axis() { + // Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right. + let xml = br##" +SpendW1W2200000310000 +Clicks60007400 + + +"##; + let c = read_chart_xml(xml).unwrap(); + assert_eq!(c.kind, ChartType::Combo); + assert_eq!(c.series.len(), 2); + assert!(!c.series[0].secondary_axis); + assert!(c.series[1].secondary_axis); + assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers)); + assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01))); + // Each value axis' number format, bounds and unit; the chart's language. + assert_eq!(c.y_format.as_deref(), Some("#,##0")); + assert_eq!((c.y_major, c.y2_max, c.y2_format.as_deref()), (Some(100000.0), Some(8000.0), Some("0.0"))); + assert_eq!(c.lang.as_deref(), Some("ru-RU")); + // Group order doesn't decide: columns on the right-hand axis are the secondary series even + // when their group comes first, and the line on the left is primary. + let swapped = String::from_utf8_lossy(xml) + .replace( + "", ""); + let c = read_chart_xml(single.as_bytes()).unwrap(); + assert!(c.series.iter().all(|s| !s.secondary_axis)); + } + + #[test] + fn linked_axis_formats_come_from_the_data() { + // No c:numFmt on the value axis, or a source-linked one: the series' own format. + let xml = |ax_fmt: &str| { + format!( + r#"#,##01200{ax_fmt}"# + ) + }; + let fmt = |x: &str| read_chart_xml(xml(x).as_bytes()).unwrap().y_format; + assert_eq!(fmt("").as_deref(), Some("#,##0")); + assert_eq!(fmt(r#""#).as_deref(), Some("#,##0")); + assert_eq!(fmt(r#""#).as_deref(), Some("0.0")); + } + + #[test] + fn text_colour_is_read_from_the_axes() { + // LibreOffice's templates set light axis text for dark slides on the axes' c:txPr. + let xml = r#"1"#; + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.text_color, Some(crate::style::ColorRef::rgb(crate::color::Rgba::rgb(255, 255, 255)))); + assert!(!c.cat_reversed); + let rev = read_chart_xml( + xml.replace("", "") + .as_bytes(), + ) + .unwrap(); + assert!(rev.cat_reversed, "maxMin reverses the category axis"); + // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. + assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); + } + + #[test] + fn text_sizes_are_read_from_the_file() { + // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to + // 18.62 pt; a chart that only sets its overall text size uses it everywhere. + let ns = + r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + let tx = |sz: u32| format!(r#""#); + let xml = format!( + r#"T{}{}"#, + tx(1197), + tx(1197) + ); + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!((c.text_size, c.title_size), (Some(11.97), Some(18.62))); + let xml = format!( + r#"{}"#, + tx(1400) + ); + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!((c.text_size, c.title_size), (Some(14.0), None)); + let xml = format!( + r#""# + ); + assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one"); + } + + #[test] + fn hidden_labels_and_unfilled_gridlines_are_read() { + let xml = |ax: &str| { + format!( + r#"{ax}"# + ) + }; + let no_fill = r#""#; + let c = read_chart_xml(xml(r#""#).as_bytes()).unwrap(); + assert!(c.gridlines && c.minor_gridlines && !c.y_hidden); + let c = read_chart_xml( + xml(&format!( + r#"{no_fill}{no_fill}"# + )) + .as_bytes(), + ) + .unwrap(); + assert!(!c.gridlines && !c.minor_gridlines && c.y_hidden); + } + + #[test] + fn trendlines_are_read() { + let xml = br#"Spend +Fit & forecast + + + +1"#; + let c = read_chart_xml(xml).unwrap(); + let t = &c.series[0].trendlines; + assert_eq!(t.len(), 3); + assert_eq!(t[0].name.as_deref(), Some("Fit & forecast")); + assert_eq!(t[0].kind, TrendlineType::Linear); + assert_eq!((t[0].forward, t[0].backward, t[0].intercept), (Some(1.0), Some(0.5), Some(0.0))); + assert!(t[0].disp_eq && t[0].disp_r_sqr); + assert_eq!((t[1].kind, t[1].order), (TrendlineType::Poly, Some(6))); + assert_eq!((t[2].kind, t[2].period), (TrendlineType::MovingAvg, Some(3))); + } + + const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + fn chart(auto_deleted: &str, title: &str, series: usize) -> Chart { + let val = r#"1"#; + let ser: String = (0..series).map(|i| format!(r#"S{i}{val}"#)).collect(); + let plot = format!(r#"{ser}"#); + let xml = format!(r#"{title}{auto_deleted}{plot}"#); + read_chart_xml(xml.as_bytes()).unwrap() + } + + #[test] + fn single_series_gets_automatic_title() { + assert_eq!(chart(r#""#, "", 1).title.as_deref(), Some("S0")); + } + + #[test] + fn no_automatic_title_when_deleted_or_multi_series() { + assert_eq!(chart(r#""#, "", 1).title, None); + assert_eq!(chart("", "", 1).title, None); + assert_eq!(chart(r#""#, "", 2).title, None); + } + + #[test] + fn explicit_title_wins() { + let t = "Mine"; + assert_eq!(chart(r#""#, t, 1).title.as_deref(), Some("Mine")); + } + + const NS_CHART: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + fn vary_xml(vary: &str) -> String { + let ser = r#""#; + let bar = format!(r#"{vary}{ser}"#); + format!(r#"{bar}"#) + } + + #[test] + fn missing_vary_colors_means_vary_by_point() { + assert!(read_chart_xml(vary_xml("").as_bytes()).is_some_and(|c| c.vary_colors)); + } + + #[test] + fn explicit_vary_colors_is_respected() { + assert!(read_chart_xml(vary_xml(r#""#).as_bytes()).is_some_and(|c| !c.vary_colors)); + assert!(read_chart_xml(vary_xml(r#""#).as_bytes()).is_some_and(|c| c.vary_colors)); + } +} diff --git a/crates/drawingml/src/chart/write.rs b/crates/drawingml/src/chart/write.rs new file mode 100644 index 00000000..559b2761 --- /dev/null +++ b/crates/drawingml/src/chart/write.rs @@ -0,0 +1,1147 @@ +//! Chart parts: a `c:chartSpace` with cached categories and values. +//! An unchanged chart also keeps the embedded workbook and the other parts it points at. + +use crate::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; +use crate::style::Fill; + +use crate::ns::{NS_A, NS_C, NS_R}; + +/// The Office 2016 chart namespace. +pub const NS_CX: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; +use craft_xml::{El, Local, Node, Writer}; + +use crate::write as dml; +use crate::xmlx::{A, TagExt}; + +/// Column letter for a 0-based index (A, B, … Z, AA…). +fn col(i: usize) -> String { + let mut n = i + 1; + let mut s = String::new(); + while n > 0 && s.len() < 4 { + let r = (n - 1) % 26; + s.insert(0, (b'A' + r as u8) as char); + n = (n - 1) / 26; + } + s +} + +fn fmt_num(v: f64) -> String { + if v == v.trunc() && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +fn simple_fill(w: &mut Writer, f: &Fill) { + match f { + Fill::None => w.empty("a:noFill", &[]), + Fill::Solid { color } => dml::solid(w, color), + Fill::Pattern(p) => { + w.tag("a:pattFill").attr("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }).open(); + w.open("a:fgClr", &[]); + dml::color(w, &p.fg); + w.close(); + w.open("a:bgClr", &[]); + dml::color(w, &p.bg); + w.close(); + w.close(); + } + Fill::Gradient(_) | Fill::Picture(_) | Fill::Group | Fill::Background => { + if let Fill::Gradient(g) = f + && let Some(s) = g.stops.first() + { + dml::solid(w, &s.color); + } + } + } +} + +fn sp_pr(w: &mut Writer, fill: Option<&Fill>, line: Option<&crate::style::Line>) { + if fill.is_none() && line.is_none() { + return; + } + w.open("c:spPr", &[]); + if let Some(f) = fill { + simple_fill(w, f); + } + if let Some(l) = line { + w.tag("a:ln").attr_opt("w", l.width.map(dml::emu_pos)).open(); + if let Some(f) = &l.fill { + simple_fill(w, f); + } + w.close(); + } + w.close(); +} + +/// `a:defRPr` carrying the chart's text colour, where PowerPoint keeps it: the paragraph +/// defaults of a title's rich text and of every `c:txPr`. +fn def_rpr(w: &mut Writer, color: Option<&crate::style::ColorRef>) { + match color { + Some(c) => { + w.open("a:defRPr", &[]); + dml::solid(w, c); + w.close(); + } + None => w.empty("a:defRPr", &[]), + } +} + +/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on +/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing +/// without a colour: the theme's default then applies on reading. +fn tx_pr(w: &mut Writer, c: &Chart) { + let Some(color) = &c.text_color else { return }; + w.open("c:txPr", &[]); + w.empty("a:bodyPr", &[]); + w.empty("a:lstStyle", &[]); + w.open("a:p", &[]); + w.open("a:pPr", &[]); + def_rpr(w, Some(color)); + w.close(); + w.tag("a:endParaRPr").attr("lang", "en-US").empty(); + w.close(); + w.close(); +} + +fn rich(w: &mut Writer, text: &str, color: Option<&crate::style::ColorRef>) { + w.open("c:tx", &[]); + w.open("c:rich", &[]); + w.empty("a:bodyPr", &[]); + w.empty("a:lstStyle", &[]); + for line in text.split('\n') { + w.open("a:p", &[]); + if color.is_some() { + w.open("a:pPr", &[]); + def_rpr(w, color); + w.close(); + } + w.open("a:r", &[]); + if let Some(c) = color { + w.tag("a:rPr").attr("lang", "en-US").open(); + dml::solid(w, c); + w.close(); + } + w.leaf("a:t", &[], line); + w.close(); + w.close(); + } + w.close(); + w.close(); +} + +/// A title (the chart's or an axis's) in the chart's text colour. +fn title(w: &mut Writer, text: &str, c: &Chart) { + w.open("c:title", &[]); + rich(w, text, c.text_color.as_ref()); + w.tag("c:overlay").attr("val", 0).empty(); + w.close(); +} + +/// Points one cache writes. Excel and PowerPoint hold 32,000 points per series, so this is far +/// above any chart they make; a longer vector is cut here with a warning, not refused. +const MAX_CACHE_POINTS: usize = 1_000_000; + +/// `vals` cut to [`MAX_CACHE_POINTS`]. +fn capped(vals: &[T]) -> &[T] { + if vals.len() > MAX_CACHE_POINTS { + log::warn!("pptx: chart data cache cut from {} to {MAX_CACHE_POINTS} points", vals.len()); + } + &vals[..vals.len().min(MAX_CACHE_POINTS)] +} + +fn str_cache(w: &mut Writer, f: &str, vals: &[String]) { + let vals = capped(vals); + w.open("c:strRef", &[]); + w.leaf("c:f", &[], f); + w.open("c:strCache", &[]); + w.tag("c:ptCount").attr("val", vals.len()).empty(); + for (i, v) in vals.iter().enumerate() { + w.tag("c:pt").attr("idx", i).open(); + w.leaf("c:v", &[], v); + w.close(); + } + w.close(); + w.close(); +} + +fn num_cache(w: &mut Writer, f: &str, vals: &[Option]) { + let vals = capped(vals); + w.open("c:numRef", &[]); + w.leaf("c:f", &[], f); + w.open("c:numCache", &[]); + w.leaf("c:formatCode", &[], "General"); + w.tag("c:ptCount").attr("val", vals.len()).empty(); + for (i, v) in vals.iter().enumerate() { + if let Some(v) = v.filter(|v| v.is_finite()) { + w.tag("c:pt").attr("idx", i).open(); + w.leaf("c:v", &[], &fmt_num(v)); + w.close(); + } + } + w.close(); + w.close(); +} + +/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them. +fn num_lit(w: &mut Writer, vals: &[Option]) { + let vals = capped(vals); + w.open("c:numLit", &[]); + w.tag("c:ptCount").attr("val", vals.len()).empty(); + for (i, v) in vals.iter().enumerate() { + if let Some(v) = v.filter(|v| v.is_finite()) { + w.tag("c:pt").attr("idx", i).open(); + w.leaf("c:v", &[], &fmt_num(v)); + w.close(); + } + } + w.close(); +} + +#[derive(Clone, Copy, PartialEq)] +enum Group { + Bar, + Line, + Area, + Pie, + Doughnut, + Scatter, + Radar, + Bubble, + Stock, +} + +fn group_of(k: ChartType) -> Group { + match k { + ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine => Group::Line, + ChartType::Stock => Group::Stock, + ChartType::Area | ChartType::StackedArea | ChartType::Surface => Group::Area, + ChartType::Pie => Group::Pie, + ChartType::Doughnut | ChartType::Sunburst => Group::Doughnut, + ChartType::Scatter => Group::Scatter, + ChartType::Bubble => Group::Bubble, + ChartType::Radar | ChartType::FilledRadar => Group::Radar, + _ => Group::Bar, + } +} + +fn error_bars(w: &mut Writer, e: &ErrorBars, g: Group) { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open("c:errBars", &[]); + if matches!(g, Group::Scatter | Group::Bubble) { + w.tag("c:errDir").attr("val", "y").empty(); + } + w.tag("c:errBarType").attr("val", word(&e.bar_type, &["both", "plus", "minus"], "both")).empty(); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.tag("c:errValType").attr("val", vt).empty(); + w.tag("c:noEndCap").attr("val", if e.end_cap { 0 } else { 1 }).empty(); + if vt == "cust" { + w.open("c:plus", &[]); + num_lit(w, &e.plus); + w.close(); + w.open("c:minus", &[]); + num_lit(w, &e.minus); + w.close(); + } else if e.value.is_finite() { + w.tag("c:val").attr("val", fmt_num(e.value)).empty(); + } + w.close(); +} + +/// Per-point fills of pie, doughnut and bar series. +fn point_fills(w: &mut Writer, s: &Series, g: Group) { + if !matches!(g, Group::Pie | Group::Doughnut | Group::Bar) { + return; + } + for (idx, f) in s.point_fills.iter().take(1000) { + w.open("c:dPt", &[]); + w.tag("c:idx").attr("val", idx).empty(); + if g == Group::Bar { + w.tag("c:invertIfNegative").attr("val", 0).empty(); + } + if g != Group::Bar { + w.tag("c:bubble3D").attr("val", 0).empty(); + } + sp_pr(w, Some(f), None); + w.close(); + } +} + +/// Data points (categories and values counted per series) one chart group writes to its caches. +const MAX_EXPORT_POINTS: usize = 4_000_000; + +fn series(w: &mut Writer, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) { + w.open("c:ser", &[]); + 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)); + 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. + w.open("c:spPr", &[]); + w.tag("a:ln").attr("w", 19050).open(); + w.empty("a:noFill", &[]); + w.close(); + w.close(); + } else { + sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + } + if g == Group::Stock { + // High-low-close: the close is a dash on the high-low line; with up/down bars (or a set + // marker) the series keep their own. + let close = !c.up_down_bars && i + 1 == c.series.len() && c.series.len() == 3; + let sym = s.marker.as_deref().filter(|m| !m.is_empty() && m.chars().all(|ch| ch.is_ascii_alphanumeric())).unwrap_or(if close { + "dash" + } else { + "none" + }); + w.open("c:marker", &[]); + w.tag("c:symbol").attr("val", sym).empty(); + if sym != "none" { + w.tag("c:size").attr("val", 7).empty(); + } + w.close(); + } + if g == Group::Bar { + w.tag("c:invertIfNegative").attr("val", 0).empty(); + } + if matches!(g, Group::Line | Group::Scatter | Group::Radar) { + let sym = match (&s.marker, kind) { + (Some(m), _) if !m.is_empty() => Some(m.as_str()), + (_, ChartType::LineMarkers) => None, + (_, ChartType::Scatter) => None, + _ => Some("none"), + }; + if let Some(sym) = sym.filter(|s| s.chars().all(|c| c.is_ascii_alphanumeric())) { + w.open("c:marker", &[]); + w.tag("c:symbol").attr("val", sym).empty(); + w.close(); + } + } + point_fills(w, s, g); + if let Some(d) = s.data_labels { + data_labels(w, c, d); + } + // After labels, before the category and value caches (ECMA-376 series content order). + if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) { + for t in s.trendlines.iter().take(16) { + trendline(w, t); + } + } + let n = c.categories.len().max(s.values.len()).min(MAX_CACHE_POINTS); + let last = n + 1; + if let Some(e) = &s.error_bars { + error_bars(w, e, 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); + w.close(); + w.open("c:yVal", &[]); + num_cache(w, &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); + w.close(); + w.tag("c:bubble3D").attr("val", 0).empty(); + } else { + w.tag("c:smooth").attr("val", if s.smooth { 1 } else { 0 }).empty(); + } + } else { + w.open("c:cat", &[]); + str_cache(w, &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); + w.close(); + if g == Group::Line { + w.tag("c:smooth").attr("val", if s.smooth { 1 } else { 0 }).empty(); + } + } + w.close(); +} + +fn trend_num(v: Option) -> Option { + v.filter(|v| v.is_finite() && v.abs() <= 1.0e12) +} + +fn trend_name(s: &str) -> Option { + let cleaned: String = + s.chars().filter(|c| matches!(c, '\t' | '\n' | '\r') || (*c >= ' ' && *c != '\u{FFFE}' && *c != '\u{FFFF}')).take(255).collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { None } else { Some(cleaned.to_string()) } +} + +fn trendline(w: &mut Writer, t: &Trendline) { + w.open("c:trendline", &[]); + if let Some(name) = t.name.as_deref().and_then(trend_name) { + w.leaf("c:name", &[], &name); + } + w.tag("c:trendlineType").attr("val", t.kind.xml()).empty(); + if t.kind == TrendlineType::Poly + && let Some(o) = t.order + { + w.tag("c:order").attr("val", o.clamp(2, 6)).empty(); + } + if t.kind == TrendlineType::MovingAvg + && let Some(p) = t.period + { + w.tag("c:period").attr("val", p.clamp(2, 255)).empty(); + } + if let Some(v) = trend_num(t.forward) { + w.tag("c:forward").attr("val", fmt_num(v)).empty(); + } + if let Some(v) = trend_num(t.backward) { + w.tag("c:backward").attr("val", fmt_num(v)).empty(); + } + if let Some(v) = trend_num(t.intercept) { + w.tag("c:intercept").attr("val", fmt_num(v)).empty(); + } + if t.disp_r_sqr { + w.tag("c:dispRSqr").attr("val", 1).empty(); + } + if t.disp_eq { + w.tag("c:dispEq").attr("val", 1).empty(); + } + w.close(); +} + +fn data_labels(w: &mut Writer, c: &Chart, show: bool) { + w.open("c:dLbls", &[]); + tx_pr(w, c); + for (n, v) in [ + ("c:showLegendKey", false), + ("c:showVal", show), + ("c:showCatName", false), + ("c:showSerName", false), + ("c:showPercent", false), + ("c:showBubbleSize", false), + ] { + w.tag(n).attr("val", if v { 1 } else { 0 }).empty(); + } + w.close(); +} + +const AX_CAT: u32 = 50_001; +const AX_VAL: u32 = 50_002; + +fn axes(w: &mut Writer, c: &Chart, g: Group) { + let horizontal = c.kind.horizontal() && g == Group::Bar; + let (cat_pos, val_pos) = if horizontal { ("l", "b") } else { ("b", "l") }; + if g == Group::Scatter || g == Group::Bubble { + w.open("c:valAx", &[]); + w.tag("c:axId").attr("val", AX_CAT).empty(); + w.open("c:scaling", &[]); + w.tag("c:orientation").attr("val", "minMax").empty(); + w.close(); + w.tag("c:delete").attr("val", 0).empty(); + w.tag("c:axPos").attr("val", "b").empty(); + if let Some(t) = &c.axis_titles.0 { + title(w, t, c); + } + w.tag("c:numFmt").attr("formatCode", "General").attr("sourceLinked", 1).empty(); + w.tag("c:majorTickMark").attr("val", "out").empty(); + w.tag("c:minorTickMark").attr("val", "none").empty(); + w.tag("c:tickLblPos").attr("val", "nextTo").empty(); + tx_pr(w, c); + w.tag("c:crossAx").attr("val", AX_VAL).empty(); + w.tag("c:crosses").attr("val", "autoZero").empty(); + w.tag("c:crossBetween").attr("val", "midCat").empty(); + w.close(); + } else { + w.open("c:catAx", &[]); + w.tag("c:axId").attr("val", AX_CAT).empty(); + w.open("c:scaling", &[]); + w.tag("c:orientation").attr("val", if c.cat_reversed { "maxMin" } else { "minMax" }).empty(); + w.close(); + w.tag("c:delete").attr("val", 0).empty(); + w.tag("c:axPos").attr("val", cat_pos).empty(); + if let Some(t) = &c.axis_titles.0 { + title(w, t, c); + } + w.tag("c:numFmt").attr("formatCode", "General").attr("sourceLinked", 1).empty(); + w.tag("c:majorTickMark").attr("val", "none").empty(); + w.tag("c:minorTickMark").attr("val", "none").empty(); + w.tag("c:tickLblPos").attr("val", "nextTo").empty(); + tx_pr(w, c); + w.tag("c:crossAx").attr("val", AX_VAL).empty(); + w.tag("c:crosses").attr("val", "autoZero").empty(); + w.tag("c:auto").attr("val", 1).empty(); + w.tag("c:lblAlgn").attr("val", "ctr").empty(); + w.tag("c:lblOffset").attr("val", 100).empty(); + w.tag("c:noMultiLvlLbl").attr("val", 0).empty(); + w.close(); + } + w.open("c:valAx", &[]); + w.tag("c:axId").attr("val", AX_VAL).empty(); + w.open("c:scaling", &[]); + if let Some(b) = c.y_log_base { + w.tag("c:logBase").attr("val", fmt_num(b)).empty(); + } + w.tag("c:orientation").attr("val", "minMax").empty(); + if let Some(m) = c.y_max.filter(|v| v.is_finite()) { + w.tag("c:max").attr("val", fmt_num(m)).empty(); + } + if let Some(m) = c.y_min.filter(|v| v.is_finite()) { + w.tag("c:min").attr("val", fmt_num(m)).empty(); + } + w.close(); + w.tag("c:delete").attr("val", 0).empty(); + w.tag("c:axPos").attr("val", val_pos).empty(); + if c.gridlines { + w.empty("c:majorGridlines", &[]); + } + if c.minor_gridlines { + w.empty("c:minorGridlines", &[]); + } + if let Some(t) = &c.axis_titles.1 { + title(w, t, c); + } + match c.y_format.as_deref().filter(|f| !f.is_empty()) { + Some(f) => w.tag("c:numFmt").attr("formatCode", f).attr("sourceLinked", 0).empty(), + None => w.tag("c:numFmt").attr("formatCode", if c.kind.percent() { "0%" } else { "General" }).attr("sourceLinked", 1).empty(), + } + w.tag("c:majorTickMark").attr("val", "none").empty(); + w.tag("c:minorTickMark").attr("val", "none").empty(); + w.tag("c:tickLblPos").attr("val", "nextTo").empty(); + tx_pr(w, c); + w.tag("c:crossAx").attr("val", AX_CAT).empty(); + w.tag("c:crosses").attr("val", "autoZero").empty(); + w.tag("c:crossBetween").attr("val", if g == Group::Area || g == Group::Scatter { "midCat" } else { "between" }).empty(); + w.close(); +} + +/// Points `series` writes into its caches for group `g`, synthesized vectors included. +fn series_points(c: &Chart, s: &Series, g: Group) -> usize { + let v = s.values.len(); + if matches!(g, Group::Scatter | Group::Bubble) { + let xs = if s.x_values.is_empty() { v } else { s.x_values.len() }; + let sizes = if g != Group::Bubble { + 0 + } else if s.sizes.is_empty() { + v + } else { + s.sizes.len() + }; + xs.saturating_add(v).saturating_add(sizes) + } else { + c.categories.len().saturating_add(v) + } +} + +fn plot_group(w: &mut Writer, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) { + let g = group_of(kind); + let grouping = if kind.percent() { + "percentStacked" + } else if kind.stacked() { + "stacked" + } else if matches!(g, Group::Bar) { + "clustered" + } else { + "standard" + }; + let vary = if c.vary_colors { 1 } else { 0 }; + let tag = match g { + Group::Bar => "c:barChart", + Group::Line => "c:lineChart", + Group::Area => "c:areaChart", + Group::Pie => "c:pieChart", + Group::Doughnut => "c:doughnutChart", + Group::Scatter => "c:scatterChart", + Group::Radar => "c:radarChart", + Group::Bubble => "c:bubbleChart", + Group::Stock => "c:stockChart", + }; + w.open(tag, &[]); + match g { + Group::Bar => { + w.tag("c:barDir").attr("val", if kind.horizontal() { "bar" } else { "col" }).empty(); + w.tag("c:grouping").attr("val", grouping).empty(); + } + Group::Line | Group::Area => w.tag("c:grouping").attr("val", grouping).empty(), + Group::Scatter => w.tag("c:scatterStyle").attr("val", "lineMarker").empty(), + Group::Radar => w.tag("c:radarStyle").attr("val", if kind == ChartType::FilledRadar { "filled" } else { "marker" }).empty(), + _ => {} + } + // `c:stockChart` has no `c:varyColors`. + if g != Group::Stock { + w.tag("c:varyColors").attr("val", vary).empty(); + } + // Each series repeats the categories in its own cache, so the output grows with series times + // categories: stop adding series once a group's caches pass the budget (the first is always written). + // The first series is always written; each of its caches is cut at MAX_CACHE_POINTS. + let mut left = MAX_EXPORT_POINTS; + for (n, (i, s)) in list.iter().enumerate() { + let cost = series_points(c, s, g); + if n > 0 && cost > left { + log::warn!("pptx: chart export stopped after {n} series; the data caches passed {MAX_EXPORT_POINTS} points"); + break; + } + left = left.saturating_sub(cost); + series(w, c, s, *i, kind, g); + } + if c.data_labels { + data_labels(w, c, true); + } + match g { + Group::Bar => { + w.tag("c:gapWidth").attr("val", (c.gap_width.clamp(0.0, 5.0) * 100.0).round() as i64).empty(); + let ov = if kind.stacked() { 100 } else { (c.overlap.clamp(-1.0, 1.0) * 100.0).round() as i64 }; + if ov != 0 { + w.tag("c:overlap").attr("val", ov).empty(); + } + } + Group::Line => w.tag("c:marker").attr("val", 1).empty(), + Group::Stock => { + w.open("c:hiLowLines", &[]); + w.open("c:spPr", &[]); + w.tag("a:ln").attr("w", 9525).open(); + w.open("a:solidFill", &[]); + w.tag("a:schemeClr").attr("val", "tx1").empty(); + w.close(); + w.close(); + w.close(); + w.close(); + if c.up_down_bars { + w.open("c:upDownBars", &[]); + w.tag("c:gapWidth").attr("val", 150).empty(); + w.empty("c:upBars", &[]); + w.empty("c:downBars", &[]); + w.close(); + } + } + Group::Pie => w.tag("c:firstSliceAng").attr("val", 0).empty(), + Group::Doughnut => { + w.tag("c:firstSliceAng").attr("val", 0).empty(); + w.tag("c:holeSize").attr("val", (c.hole_size.clamp(0.1, 0.9) * 100.0).round() as i64).empty(); + } + _ => {} + } + if !matches!(g, Group::Pie | Group::Doughnut) { + w.tag("c:axId").attr("val", AX_CAT).empty(); + w.tag("c:axId").attr("val", AX_VAL).empty(); + } + w.close(); +} + +/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart +/// is unchanged since it was read, else one written from the model. `None` for other chart types. +#[cfg(test)] +pub fn chartex_xml(c: &Chart) -> Option> { + chartex_part(c).map(|(x, _)| x) +} + +/// [`chartex_xml`], and whether it is the original part (so its kept relationships still apply). +pub fn chartex_part(c: &Chart) -> Option<(Vec, bool)> { + kept_chartex(c).map(|x| (x, true)).or_else(|| c.kind.is_chartex().then(|| (build_chartex(c), false))) +} + +fn kept_chartex(c: &Chart) -> Option> { + let raw = c.raw.as_ref()?; + let doc = crate::xmlx::parse(raw.as_bytes()).ok()?; + let mut root = doc.clone(); + crate::xmlx::resolve_mc(&mut root); + let mut back = super::chartex::chartex_from(&root)?; + back.raw = c.raw.clone(); + // The sidecar is not modelled chart state. + back.kept = c.kept.clone(); + if &back != c { + return None; + } + let mut keep = doc; + // The workbook link stays only when its part is written back. + drop_unkept_refs(&mut keep, c); + let mut w = Writer::document(); + w.raw(&keep.to_xml()); + Some(w.into_bytes()) +} + +/// The chart part XML, and whether it is the original part (so its relationships still apply). +pub fn chart_xml(c: &Chart) -> (Vec, bool) { + // A chart too large to model is written back as it was read. + if c.verbatim + && let Some(raw) = &c.raw + && let Ok(doc) = crate::xmlx::parse(raw.as_bytes()) + && doc.is(Local("chartSpace")) + { + let mut keep = doc; + drop_unkept_refs(&mut keep, c); + let mut w = Writer::document(); + w.raw(&keep.to_xml()); + return (w.into_bytes(), true); + } + // Reuse the kept part when the chart is unchanged since it was read. + if let Some(raw) = &c.raw + && !raw.contains(NS_CX) + && let Ok(doc) = crate::xmlx::parse(raw.as_bytes()) + && doc.is(Local("chartSpace")) + { + let mut root = doc.clone(); + crate::xmlx::resolve_mc(&mut root); + if let Some(back) = super::read::chart_from(&root) { + let mut me = c.clone(); + me.raw = None; + // The sidecar is not modelled chart state. Comparing it would throw away an unchanged chart. + me.kept = Default::default(); + if back == me { + let mut keep = doc; + // Without the parts those elements point at, PowerPoint repairs the file. + drop_unkept_refs(&mut keep, c); + let mut w = Writer::document(); + w.raw(&keep.to_xml()); + return (w.into_bytes(), true); + } + } + } + let mut w = Writer::document(); + w.tag("c:chartSpace").attr("xmlns:c", NS_C).attr("xmlns:a", NS_A).attr("xmlns:r", NS_R).open(); + w.tag("c:date1904").attr("val", 0).empty(); + w.tag("c:roundedCorners").attr("val", 0).empty(); + w.tag("c:style").attr("val", c.style.clamp(1, 48)).empty(); + w.open("c:chart", &[]); + match &c.title { + Some(t) => { + title(&mut w, t, c); + w.tag("c:autoTitleDeleted").attr("val", 0).empty(); + } + None => w.tag("c:autoTitleDeleted").attr("val", 1).empty(), + } + w.open("c:plotArea", &[]); + w.empty("c:layout", &[]); + let indexed: Vec<(usize, &Series)> = c.series.iter().enumerate().collect(); + let main = match c.kind { + ChartType::Combo => c.series.first().and_then(|s| s.kind).unwrap_or(ChartType::Column), + k => k, + }; + if c.kind == ChartType::Combo { + // One plot group per series type, sharing the axes. + let mut kinds: Vec = vec![]; + for s in &c.series { + let k = s.kind.unwrap_or(main); + let k = if matches!(group_of(k), Group::Pie | Group::Doughnut | Group::Scatter | Group::Bubble) { ChartType::Column } else { k }; + if !kinds.contains(&k) { + kinds.push(k); + } + } + for k in kinds { + let list: Vec<(usize, &Series)> = indexed + .iter() + .filter(|(_, s)| { + let sk = s.kind.unwrap_or(main); + let sk = + if matches!(group_of(sk), Group::Pie | Group::Doughnut | Group::Scatter | Group::Bubble) { ChartType::Column } else { sk }; + sk == k + }) + .copied() + .collect(); + plot_group(&mut w, c, k, &list); + } + axes(&mut w, c, Group::Bar); + } else { + if !matches!( + c.kind, + ChartType::Column + | ChartType::StackedColumn + | ChartType::PercentColumn + | ChartType::Bar + | ChartType::StackedBar + | ChartType::PercentBar + | ChartType::Line + | ChartType::LineMarkers + | ChartType::StackedLine + | ChartType::Area + | ChartType::StackedArea + | ChartType::Pie + | ChartType::Doughnut + | ChartType::Scatter + | ChartType::Bubble + | ChartType::Radar + | ChartType::FilledRadar + | ChartType::Stock + ) { + log::warn!("pptx: {} chart written as a column chart", c.kind.label()); + } + plot_group(&mut w, c, main, &indexed); + let g = group_of(main); + if !matches!(g, Group::Pie | Group::Doughnut) { + axes(&mut w, c, g); + } + } + if let Some(f) = &c.plot_fill { + sp_pr(&mut w, Some(f), None); + } + w.close(); + if let Some(pos) = &c.legend { + let p = match pos.as_str() { + "b" | "t" | "l" | "r" | "tr" => pos.as_str(), + _ => "r", + }; + w.open("c:legend", &[]); + w.tag("c:legendPos").attr("val", p).empty(); + w.tag("c:overlay").attr("val", u8::from(c.legend_overlay)).empty(); + tx_pr(&mut w, c); + w.close(); + } + w.tag("c:plotVisOnly").attr("val", 1).empty(); + w.tag("c:dispBlanksAs").attr("val", "gap").empty(); + w.close(); + if let Some(f) = &c.chart_fill { + sp_pr(&mut w, Some(f), None); + } + tx_pr(&mut w, c); + w.close(); + (w.into_bytes(), false) +} + +/// The relationship ids of an unchanged chart that [`write_kept`] writes back: external ones, and +/// internal ones whose part was kept. +fn kept_rel_ids(c: &Chart) -> std::collections::HashSet<&str> { + let parts: std::collections::HashSet = c.kept.parts.iter().filter(|p| kept_payload(&p.name)).map(|p| norm(&p.name)).collect(); + c.kept.rels.iter().filter(|r| r.external || parts.contains(&norm(&r.part))).map(|r| r.id.as_str()).collect() +} + +/// A part name in the form names are compared in: no leading `/`, `/` for `\`, lower case. +pub fn norm(name: &str) -> String { + name.trim_start_matches('/').replace('\\', "/").to_ascii_lowercase() +} + +/// A part a chart may carry along beside its own `chartN.xml` (workbook, style, drawing, picture): +/// one in the `embeddings`, `charts`, `drawings` or `media` folder of a presentation, workbook or +/// document. Chart documents themselves are rewritten, and nothing outside these folders is +/// copied: a relationship must not be able to replace another part of the package on save. +pub fn sidecar_part(name: &str) -> bool { + let n = norm(name); + let segs: Vec<&str> = n.split('/').filter(|s| !s.is_empty()).collect(); + if segs.iter().any(|s| *s == "." || *s == "..") { + return false; + } + let (Some(root), Some(area)) = (segs.first().copied(), segs.get(1).copied()) else { return false }; + if !matches!(root, "ppt" | "xl" | "word") || !matches!(area, "embeddings" | "charts" | "drawings" | "media") { + return false; + } + let file = segs.last().copied().unwrap_or(""); + let stem = file.strip_prefix("chart").unwrap_or(""); + let num = stem.strip_suffix(".xml.rels").or_else(|| stem.strip_suffix(".xml")).unwrap_or(""); + !(stem != file && !num.is_empty() && num.chars().all(|c| c.is_ascii_digit())) +} + +/// A kept part that is written as a part of its own (rels parts are rewritten instead). +fn kept_payload(name: &str) -> bool { + !name.to_ascii_lowercase().ends_with(".rels") && sidecar_part(name) +} + +/// Drop `c:externalData` / `c:userShapes` (and the `cx:` ones) that point at a relationship that +/// is not written back: without the part PowerPoint repairs the file. +fn drop_unkept_refs(root: &mut El, c: &Chart) { + let ids = kept_rel_ids(c); + fn rec(e: &mut El, ids: &std::collections::HashSet<&str>, depth: usize) { + if depth > crate::xmlx::MAX_DEPTH { + return; + } + e.children.retain(|n| match n { + Node::El(x) if x.is(Local("externalData")) || x.is(Local("userShapes")) => x.attr_local("r:id").is_some_and(|id| ids.contains(id)), + _ => true, + }); + for ch in e.elements_mut() { + rec(ch, ids, depth + 1); + } + } + rec(root, &ids, 0); +} + +/// A `cx:tx` holding plain text. +fn cx_text(w: &mut Writer, t: &str) { + w.open("cx:tx", &[]); + w.open("cx:txData", &[]); + w.open("cx:v", &[]); + w.text(t); + w.close(); + w.close(); + w.close(); +} + +/// A `cx:lvl` of points. +fn cx_level(w: &mut Writer, pts: &[String], format: Option<&str>) { + let mut a = A::new().a("ptCount", pts.len()); + if let Some(f) = format { + a = a.a("formatCode", f); + } + w.tag("cx:lvl").list(a).open(); + for (i, p) in pts.iter().enumerate() { + w.tag("cx:pt").attr("idx", i).open(); + w.text(p); + w.close(); + } + w.close(); +} + +/// The `cx:layoutPr` of the region map series in a kept `chartEx` part. +fn kept_region_layout(raw: &str) -> Option { + let doc = crate::xmlx::parse(raw.as_bytes()).ok()?; + let mut root = doc; + crate::xmlx::resolve_mc(&mut root); + let region = root.child(Local("chart"))?.child(Local("plotArea"))?.child(Local("plotAreaRegion"))?; + let s = region.children(Local("series")).find(|s| s.attr_local("layoutId") == Some("regionMap"))?; + let lp = s.child(Local("layoutPr"))?; + // Written under our `cx` prefix; a part that bound chartex to another prefix is not copied. + lp.name.starts_with("cx:").then(|| lp.to_xml()) +} + +/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data` +/// per series (categories and values), then the plot area with each series' layout, the axes +/// the layout uses, the title and the legend. +pub fn build_chartex(c: &Chart) -> Vec { + let mut w = Writer::document(); + w.tag("cx:chartSpace").attr("xmlns:a", NS_A).attr("xmlns:r", NS_R).attr("xmlns:cx", NS_CX).open(); + w.open("cx:chartData", &[]); + for (i, s) in c.series.iter().enumerate() { + w.tag("cx:data").attr("id", i).open(); + if !c.categories.is_empty() { + w.tag("cx:strDim").attr("type", "cat").open(); + cx_level(&mut w, &c.categories, None); + for lv in &c.category_levels { + cx_level(&mut w, lv, None); + } + w.close(); + } + let ty = match c.kind { + ChartType::Treemap | ChartType::Sunburst => "size", + ChartType::RegionMap => "colorVal", + _ => "val", + }; + w.tag("cx:numDim").attr("type", ty).open(); + let vals: Vec = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect(); + cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General"))); + w.close(); + w.close(); + } + w.close(); + w.open("cx:chart", &[]); + if let Some(t) = &c.title { + w.tag("cx:title").attr("pos", "t").attr("align", "ctr").attr("overlay", "0").open(); + cx_text(&mut w, t); + w.close(); + } + w.open("cx:plotArea", &[]); + w.open("cx:plotAreaRegion", &[]); + let layout = match c.kind { + ChartType::Waterfall => "waterfall", + ChartType::Funnel => "funnel", + ChartType::Treemap => "treemap", + ChartType::Sunburst => "sunburst", + ChartType::BoxWhisker => "boxWhisker", + ChartType::RegionMap => "regionMap", + _ => "clusteredColumn", + }; + let one = !matches!(c.kind, ChartType::BoxWhisker); + for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) { + w.tag("cx:series").attr("layoutId", layout).attr("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")).open(); + cx_text(&mut w, &s.name); + if c.series_data_labels(s) { + w.tag("cx:dataLabels").attr("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" }).open(); + w.tag("cx:visibility").attr("seriesName", "0").attr("categoryName", "0").attr("value", "1").empty(); + w.close(); + } + w.tag("cx:dataId").attr("val", i).empty(); + match c.kind { + ChartType::Waterfall if !c.subtotals.is_empty() => { + w.open("cx:layoutPr", &[]); + w.open("cx:subtotals", &[]); + for t in &c.subtotals { + w.tag("cx:idx").attr("val", t).empty(); + } + w.close(); + w.close(); + } + ChartType::Histogram | ChartType::Pareto => { + w.open("cx:layoutPr", &[]); + w.tag("cx:binning").attr("intervalClosed", "r").open(); + if let Some(size) = c.bin_size { + w.tag("cx:binSize").attr("val", fmt_num(size)).empty(); + } else if let Some(n) = c.bin_count { + w.tag("cx:binCount").attr("val", n).empty(); + } + w.close(); + w.close(); + } + ChartType::BoxWhisker => { + w.open("cx:layoutPr", &[]); + w.tag("cx:visibility").attr("meanLine", "0").attr("meanMarker", "1").attr("nonoutliers", "0").attr("outliers", "1").empty(); + w.tag("cx:statistics").attr("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" }).empty(); + w.close(); + } + ChartType::RegionMap => { + // The map's geography (projection, region level and the cached boundaries + // PowerPoint downloaded) is not modelled: carry it over from the part we read. + if let Some(lp) = c.raw.as_deref().and_then(kept_region_layout) { + w.raw(&lp); + } + } + _ => {} + } + w.close(); + } + if c.kind == ChartType::Pareto { + w.tag("cx:series").attr("layoutId", "paretoLine").attr("ownerIdx", "0").attr("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}").open(); + w.tag("cx:axisId").attr("val", "2").empty(); + w.close(); + } + w.close(); + if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { + w.tag("cx:axis").attr("id", "0").open(); + w.tag("cx:catScaling").attr("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" }).empty(); + w.empty("cx:tickLabels", &[]); + w.close(); + if c.kind != ChartType::Funnel { + w.tag("cx:axis").attr("id", "1").open(); + w.empty("cx:valScaling", &[]); + if c.gridlines { + w.empty("cx:majorGridlines", &[]); + } + w.empty("cx:tickLabels", &[]); + w.close(); + } + if c.kind == ChartType::Pareto { + w.tag("cx:axis").attr("id", "2").open(); + w.tag("cx:valScaling").attr("max", "1").attr("min", "0").empty(); + w.empty("cx:units", &[]); + w.empty("cx:tickLabels", &[]); + w.close(); + } + } + w.close(); + if let Some(pos) = &c.legend { + w.tag("cx:legend").attr("pos", pos.as_str()).attr("align", "ctr").attr("overlay", "0").empty(); + } + w.close(); + w.close(); + w.into_bytes() +} + +#[cfg(test)] +mod region_map_tests { + use super::*; + + /// A region map part as PowerPoint writes it (our own numbers; the cached geography is a + /// stand-in blob). + const MAP: &str = r#"Sheet1!$A$2:$A$4MinskBrestGomelSheet1!$B$2:$B$41258Sheet1!$B$1SalesZGVja2NyYWZ0LXRlc3Q="#; + + #[test] + fn region_maps_keep_their_geography_through_save_and_edit() { + let c = crate::chart::chartex::read_chartex_xml(MAP.as_bytes()).expect("a region map is read"); + assert_eq!(c.kind, ChartType::RegionMap); + assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]); + assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)])); + // Unchanged: written back as read. + let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default(); + assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}"); + // Edited: rebuilt from the model, with the geography carried over. + let mut e = c.clone(); + if let Some(s) = e.series.first_mut() { + s.values[1] = Some(20.0); + } + let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default(); + assert!(built.contains("").count() >= 5, "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(""), "{xml}"); + assert_eq!(crate::chart::read::read_chart_xml(xml.as_bytes()).unwrap().text_color, Some(tinted)); + // An RGB colour and no colour at all round-trip too. + c.text_color = Some(ColorRef::rgb(Rgba::rgb(255, 255, 255))); + let xml = super::chart_xml(&c).0; + assert_eq!(crate::chart::read::read_chart_xml(&xml).unwrap().text_color, c.text_color); + c.text_color = None; + let xml = String::from_utf8(super::chart_xml(&c).0).unwrap(); + assert!(!xml.contains(""), "{xml}"); + assert_eq!(crate::chart::read::read_chart_xml(xml.as_bytes()).unwrap().text_color, None); + } + + /// An imported chart's `c:txPr` colour survives an edit that rebuilds the part from the model + /// (the colour used to be read but never written, so a save after any edit lost it). + #[test] + fn imported_text_colour_survives_an_edit_and_a_save() { + let xml = r#"1"#; + let c = crate::chart::read::read_chart_xml(xml.as_bytes()).unwrap(); + let grey = ColorRef::scheme(crate::color::SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)); + assert_eq!(c.text_color, Some(grey.clone())); + // Unchanged: the part is written back as read. + assert!(super::chart_xml(&c).1); + // Edited (a title; the editor drops `raw` on any edit): rebuilt with the colour. + let mut e = c.clone(); + e.title = Some("Units".into()); + e.raw = None; + let (out, kept) = super::chart_xml(&e); + assert!(!kept); + let back = crate::chart::read::read_chart_xml(&out).unwrap(); + assert_eq!(back.text_color, Some(grey)); + assert_eq!(back.title.as_deref(), Some("Units")); + } + + #[test] + fn reversed_category_axis_round_trips() { + let mut c = Chart::sample(ChartType::Bar); + c.cat_reversed = true; + let xml = super::chart_xml(&c).0; + assert!(String::from_utf8_lossy(&xml).contains(r#""#)); + assert!(crate::chart::read::read_chart_xml(&xml).unwrap().cat_reversed); + c.cat_reversed = false; + assert!(!crate::chart::read::read_chart_xml(&super::chart_xml(&c).0).unwrap().cat_reversed); + } +} + +#[cfg(test)] +mod cache_cap_tests { + use super::*; + + /// A cache longer than the cap is cut, never refused; anything a real chart holds (32,000 + /// points per series) is far under it. Zero-sized elements, so the test allocates nothing. + #[test] + fn caches_are_cut_at_the_cap_not_refused() { + const { assert!(MAX_CACHE_POINTS >= 32_000) }; + let over = vec![(); MAX_CACHE_POINTS + 1]; + assert_eq!(capped(&over).len(), MAX_CACHE_POINTS); + let real = vec![(); 32_000]; + assert_eq!(capped(&real).len(), 32_000); + } +} diff --git a/crates/drawingml/src/diagram.rs b/crates/drawingml/src/diagram.rs index 30db71e8..ca6e3ef6 100644 --- a/crates/drawingml/src/diagram.rs +++ b/crates/drawingml/src/diagram.rs @@ -51,3 +51,5 @@ pub struct DiagramNode { #[serde(skip_serializing_if = "String::is_empty")] pub title: String, } + +pub mod parts; diff --git a/crates/drawingml/src/diagram/parts.rs b/crates/drawingml/src/diagram/parts.rs new file mode 100644 index 00000000..93d467ee --- /dev/null +++ b/crates/drawingml/src/diagram/parts.rs @@ -0,0 +1,922 @@ +//! SmartArt (DrawingML diagrams): the outline ⇄ the data model, and the layout, quick style and +//! colour definitions written for the layout kinds of [`crate::diagram::Diagram`]. +//! +//! A diagram's data part holds its points (`dgm:pt`: the document, one per node, and the +//! transitions PowerPoint pairs with each link) and its links (`dgm:cxn` of type `parOf`, parent → +//! child, ordered by `srcOrd`). The outline is that tree flattened depth first. + +use std::collections::HashMap; + +use crate::diagram::{Diagram, DiagramNode}; + +use craft_xml::{El, Local, Writer, value}; + +use crate::xmlx::{A, TagExt}; + +pub const NS_DGM: &str = "http://schemas.openxmlformats.org/drawingml/2006/diagram"; +pub const NS_DSP: &str = "http://schemas.microsoft.com/office/drawing/2008/diagram"; +pub const CT_DATA: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml"; +pub const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml"; +pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml"; +pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml"; +pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml"; +pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; +const QS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/"; +const CS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/colors/"; + +/// PowerPoint's quick style for a SmartArt Style. +pub fn qs_id(style: &str) -> String { + let s = match style { + "subtle" => "simple3", + "intense" => "simple5", + "outline" => "simple4", + "polished" => "3d1", + "inset" => "3d2", + "cartoon" => "3d3", + "powder" => "3d4", + "brick" => "3d5", + _ => "simple1", + }; + format!("{QS_BASE}{s}") +} + +/// The SmartArt Style closest to a quick style id. +pub fn style_for(qs: &str) -> &'static str { + match qs.rsplit('/').next().unwrap_or("") { + "simple1" | "" => "", + "simple4" => "outline", + "3d1" => "polished", + "3d2" => "inset", + "3d3" => "cartoon", + "3d4" => "powder", + "3d5" => "brick", + "simple2" | "simple3" => "subtle", + _ => "intense", + } +} + +/// PowerPoint's colour definition for Change Colors. +pub fn cs_id(colors: &str) -> String { + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return format!("{CS_BASE}colorful{a}"); + } + let grad = colors.ends_with("Grad"); + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_{}", if grad { 3 } else { 2 }), + _ => format!("{CS_BASE}colorful1"), + } +} + +/// The accents a Change Colors choice fills shapes with, in turn. +fn fill_accents(colors: &str) -> Vec { + if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + return vec![format!("accent{a}"), format!("accent{}", a + 1)]; + } + match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::().ok()) { + Some(n @ 1..=6) => vec![format!("accent{n}")], + _ => (1..=6).map(|k| format!("accent{k}")).collect(), + } +} + +/// Change Colors from a colour definition id: `accentN_…` keeps accent N, anything else is colourful. +pub fn colors_for(cs: &str) -> String { + let last = cs.rsplit('/').next().unwrap_or(""); + if let Some(a @ 2..=5) = last.strip_prefix("colorful").and_then(|n| n.parse::().ok()) { + return format!("range{a}{}", a + 1); + } + let mut parts = last.strip_prefix("accent").unwrap_or("").split('_'); + match (parts.next().and_then(|n| n.parse::().ok()), parts.next()) { + (Some(n @ 1..=6), Some("3" | "4" | "5")) => format!("accent{n}Grad"), + (Some(n @ 1..=6), _) => format!("accent{n}"), + _ => String::new(), + } +} +/// Most nodes read from one diagram. +const MAX_NODES: usize = 2000; + +/// PowerPoint's built-in layout a layout `kind` is written as. +pub fn layout_id(kind: &str) -> &'static str { + match kind { + "blockList" => "urn:microsoft.com/office/officeart/2005/8/layout/default", + "hBulletList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList1", + "process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1", + "chevron" => "urn:microsoft.com/office/officeart/2005/8/layout/chevron1", + "verticalProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process2", + "stepUp" => "urn:microsoft.com/office/officeart/2009/3/layout/StepUpProcess", + "cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2", + "blockCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle5", + "radial" => "urn:microsoft.com/office/officeart/2005/8/layout/radial1", + "hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1", + "pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1", + "invertedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid3", + "matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3", + "titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1", + "pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4", + "pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1", + "pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess10", + "pictureHList" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2", + "timeline" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess11", + "venn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn1", + "target" => "urn:microsoft.com/office/officeart/2005/8/layout/target1", + "textCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle1", + "continuousCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle3", + "horizontalHierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy2", + "gridMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix2", + "gear" => "urn:microsoft.com/office/officeart/2005/8/layout/gear1", + "funnel" => "urn:microsoft.com/office/officeart/2005/8/layout/funnel1", + "segmentedCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle8", + "multidirectionalCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle7", + "orgChart" => "urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", + "halfCircleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/HalfCircleOrganizationChart", + "nameTitleOrgChart" => "urn:microsoft.com/office/officeart/2008/layout/NameandTitleOrganizationalChart", + "verticalBoxList" => "urn:microsoft.com/office/officeart/2005/8/layout/list1", + "trapezoidList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList6", + "tableList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList3", + "accentProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process3", + "continuousBlockProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/hProcess9", + "bendingProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process5", + "linearVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn3", + "stackedVenn" => "urn:microsoft.com/office/officeart/2005/8/layout/venn2", + "nestedTarget" => "urn:microsoft.com/office/officeart/2005/8/layout/target2", + "cycleMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle4", + "segmentedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid4", + "pie" => "urn:microsoft.com/office/officeart/2005/8/layout/chart3", + _ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2", + } +} + +/// The layout `kind` closest to a PowerPoint layout (by its id and category). +pub fn kind_for(layout_id: &str, category: &str) -> &'static str { + let last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase(); + // Writers before Picture Accent Process got its own id wrote it as pList2 (Horizontal + // Picture List) with its own layout part in the process category; PowerPoint's pList2 is a list. + if last == "plist2" && category.eq_ignore_ascii_case("process") { + return "pictureProcess"; + } + // Exact ids first: the layouts written here, and their closest siblings. + for k in [ + "blockList", + "hBulletList", + "process", + "chevron", + "verticalProcess", + "stepUp", + "cycle", + "blockCycle", + "radial", + "hierarchy", + "pyramid", + "invertedPyramid", + "matrix", + "titledMatrix", + "pictureList", + "pictureCaption", + "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", + "cycleMatrix", + "segmentedPyramid", + "pie", + "list", + ] { + if layout_id == layout_id_of(k) { + return k; + } + } + let s = format!("{last} {}", category.to_ascii_lowercase()); + if s.contains("gear") { + "gear" + } else if s.contains("funnel") { + "funnel" + } else if s.contains("venn") { + "venn" + } else if s.contains("target") { + "target" + } else if s.contains("timeline") { + "timeline" + } else if s.contains("nameandtitle") { + "nameTitleOrgChart" + } else if s.contains("orgchart") || s.contains("organizationchart") || s.contains("organizationalchart") { + "orgChart" + } else if s.contains("hierarchy") { + "hierarchy" + } else if s.contains("radial") { + "radial" + } else if s.contains("cycle") { + "cycle" + } else if s.contains("pyramid") { + "pyramid" + } else if s.contains("matrix") { + "matrix" + } else if s.contains("chevron") { + "chevron" + } else if s.contains("process") || s.contains("arrow") { + "process" + } else if last == "default" { + "blockList" + } else if last.starts_with("hlist") { + "hBulletList" + } else { + "list" + } +} + +fn layout_id_of(kind: &str) -> &'static str { + layout_id(kind) +} + +fn category(kind: &str) -> &'static str { + match kind { + "process" + | "chevron" + | "verticalProcess" + | "stepUp" + | "pictureProcess" + | "timeline" + | "gear" + | "funnel" + | "accentProcess" + | "continuousBlockProcess" + | "bendingProcess" => "process", + "venn" | "target" | "linearVenn" | "stackedVenn" | "nestedTarget" => "relationship", + "cycle" | "blockCycle" | "radial" | "textCycle" | "continuousCycle" | "segmentedCycle" | "multidirectionalCycle" | "cycleMatrix" | "pie" => { + "cycle" + } + "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => "hierarchy", + "pyramid" | "invertedPyramid" | "segmentedPyramid" => "pyramid", + "matrix" | "titledMatrix" | "gridMatrix" => "matrix", + _ => "list", + } +} + +/// A model id, stable for a given diagram (`seed`) and point number. +fn guid(seed: u32, n: usize) -> String { + format!("{{{seed:08X}-D1A6-4C0A-9E5B-{n:012X}}}") +} + +fn text(w: &mut Writer, t: &str) { + w.open("dgm:t", &[]); + w.empty("a:bodyPr", &[]); + w.empty("a:lstStyle", &[]); + w.open("a:p", &[]); + if t.is_empty() { + w.tag("a:endParaRPr").attr("lang", "en-US").empty(); + } else { + w.open("a:r", &[]); + w.tag("a:rPr").attr("lang", "en-US").empty(); + w.leaf("a:t", &[], t); + w.close(); + } + w.close(); + w.close(); +} + +/// `ppt/diagrams/dataN.xml` for the outline. `drawing_rel` is the slide's relationship id of the +/// cached drawing; `pics` the data part's relationship ids of node pictures, by node index. +pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, String)]) -> Vec { + let lo = if d.layout_id.is_empty() { layout_id(&d.kind).to_string() } else { d.layout_id.clone() }; + let mut w = Writer::document(); + w.tag("dgm:dataModel").attr("xmlns:dgm", NS_DGM).attr("xmlns:a", crate::ns::NS_A).attr("xmlns:r", crate::ns::NS_R).open(); + w.open("dgm:ptLst", &[]); + let doc = guid(seed, 0); + w.tag("dgm:pt").attr("modelId", &doc).attr("type", "doc").open(); + w.tag("dgm:prSet") + .attr("loTypeId", &lo) + .attr("loCatId", category(&d.kind)) + .attr("qsTypeId", qs_id(&d.style)) + .attr("qsCatId", "simple") + .attr("csTypeId", cs_id(&d.colors)) + .attr("csCatId", if d.colors.starts_with("accent") { d.colors.as_str() } else { "colorful" }) + .attr("phldr", "1") + .empty(); + w.empty("dgm:spPr", &[]); + text(&mut w, ""); + w.close(); + // Node k: model id 1 + 3k; its link's parent and sibling transitions 2 + 3k and 3 + 3k. + let node = |k: usize| guid(seed, 1 + 3 * k); + for (k, n) in d.nodes.iter().enumerate() { + let mut a = A::new().a("modelId", node(k)); + if n.assistant { + a = a.a("type", "asst"); + } + w.tag("dgm:pt").list(a).open(); + w.tag("dgm:prSet").attr("phldrT", "[Text]").empty(); + w.empty("dgm:spPr", &[]); + text(&mut w, &n.text); + w.close(); + let cxn = guid(seed, 0x10_0000 + k); + for (i, ty) in [(2, "parTrans"), (3, "sibTrans")] { + w.tag("dgm:pt").attr("modelId", guid(seed, i + 3 * k)).attr("type", ty).attr("cxnId", &cxn).open(); + w.empty("dgm:prSet", &[]); + w.empty("dgm:spPr", &[]); + text(&mut w, ""); + w.close(); + } + } + // A node's picture sits on the presentation point of its picture placeholder, as PowerPoint + // stores it (`presAssocID` names the node, the blip is a relationship of the data part). + // Name and Title: a node's title is the text of its title box's presentation point. + for (k, n) in d.nodes.iter().enumerate() { + if n.title.is_empty() { + continue; + } + w.tag("dgm:pt").attr("modelId", guid(seed, 0x30_0000 + k)).attr("type", "pres").open(); + w.tag("dgm:prSet").attr("presAssocID", node(k)).attr("presName", "titleText1").attr("presStyleLbl", "fgAcc1").attr("custT", "1").empty(); + w.empty("dgm:spPr", &[]); + text(&mut w, &n.title); + w.close(); + } + for (k, rid) in pics { + if *k >= d.nodes.len() { + continue; + } + w.tag("dgm:pt").attr("modelId", guid(seed, 0x20_0000 + k)).attr("type", "pres").open(); + w.tag("dgm:prSet").attr("presAssocID", node(*k)).attr("presName", "Image").attr("presStyleLbl", "fgImgPlace1").empty(); + w.open("dgm:spPr", &[]); + w.open("a:blipFill", &[]); + w.tag("a:blip").attr("r:embed", rid.as_str()).empty(); + w.open("a:stretch", &[]); + w.empty("a:fillRect", &[]); + w.close(); + w.close(); + w.close(); + w.close(); + } + w.close(); + w.open("dgm:cxnLst", &[]); + let mut stack: Vec<(u8, String)> = vec![]; + let mut ords: HashMap = HashMap::new(); + for (k, n) in d.nodes.iter().enumerate() { + while stack.last().is_some_and(|(l, _)| *l >= n.level) { + stack.pop(); + } + let parent = stack.last().map(|(_, id)| id.clone()).unwrap_or_else(|| doc.clone()); + let ord = ords.entry(parent.clone()).or_insert(0); + w.tag("dgm:cxn") + .attr("modelId", guid(seed, 0x10_0000 + k)) + .attr("srcId", &parent) + .attr("destId", node(k)) + .attr("srcOrd", *ord) + .attr("destOrd", 0) + .attr("parTransId", guid(seed, 2 + 3 * k)) + .attr("sibTransId", guid(seed, 3 + 3 * k)) + .empty(); + *ord += 1; + stack.push((n.level, node(k))); + } + w.close(); + w.empty("dgm:bg", &[]); + w.empty("dgm:whole", &[]); + w.open("dgm:extLst", &[]); + w.tag("a:ext").attr("uri", "http://schemas.microsoft.com/office/drawing/2008/diagram").open(); + w.tag("dsp:dataModelExt") + .attr("xmlns:dsp", NS_DSP) + .attr("relId", drawing_rel) + .attr("minVer", "http://schemas.openxmlformats.org/drawingml/2006/diagram") + .empty(); + w.close(); + w.close(); + w.close(); + w.into_bytes() +} + +/// The outline of a data part: the `parOf` tree under the document point, depth first, or the data points in `ptLst` order when no `parOf` connections exist. +pub fn outline(data: &El) -> Vec { + outline_ids(data).into_iter().map(|(_, n)| n).collect() +} + +/// The relationship ids (of the data part) of node pictures, by node index in [`outline`]: the +/// blip fill of a presentation point associated with the node. +pub fn node_pictures(data: &El) -> Vec<(usize, String)> { + let ids: HashMap = outline_ids(data).into_iter().enumerate().map(|(k, (id, _))| (id, k)).collect(); + let Some(pts) = data.child(Local("ptLst")) else { return vec![] }; + let mut out: Vec<(usize, String)> = vec![]; + for pt in pts.children(Local("pt")).take(MAX_NODES * 8) { + if pt.attr_local("type") != Some("pres") { + continue; + } + let Some(k) = pt.child(Local("prSet")).and_then(|p| p.attr_local("presAssocID")).and_then(|a| ids.get(a)) else { continue }; + let Some(rid) = pt + .child(Local("spPr")) + .and_then(|s| s.child(Local("blipFill"))) + .and_then(|b| b.child(Local("blip"))) + .and_then(|b| b.attr_local("r:embed")) + else { + continue; + }; + if !out.iter().any(|(j, _)| j == k) { + out.push((*k, rid.to_string())); + } + } + out +} + +/// Extracts diagram outline `(modelId, DiagramNode)` pairs in tree order from a `dgm:dataModel` element. +fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> { + let Some(pts) = data.child(Local("ptLst")) else { return vec![] }; + let mut texts: HashMap<&str, String> = HashMap::new(); + let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut titles: HashMap<&str, String> = HashMap::new(); + let mut pt_order: Vec<&str> = Vec::new(); + let mut doc: Option<&str> = None; + for pt in pts.children(Local("pt")).take(MAX_NODES * 4) { + let Some(id) = pt.attr_local("modelId") else { continue }; + match pt.attr_local("type").unwrap_or("node") { + "doc" => doc = doc.or(Some(id)), + ty @ ("node" | "asst") => { + let t = pt.child(Local("t")).map(paragraphs).unwrap_or_default(); + texts.insert(id, t); + pt_order.push(id); + if ty == "asst" { + assistants.insert(id); + } + } + "pres" => { + // Name and Title: the title box's own text, on its presentation point. + let pr = pt.child(Local("prSet")); + if pr.and_then(|p| p.attr_local("presName")).is_some_and(|n| n.starts_with("titleText")) + && let Some(node) = pr.and_then(|p| p.attr_local("presAssocID")) + && let Some(t) = pt.child(Local("t")).map(paragraphs).filter(|t| !t.is_empty()) + { + titles.insert(node, t); + } + } + _ => {} + } + } + let mut kids: HashMap<&str, Vec<(i64, &str)>> = HashMap::new(); + if let Some(cx) = data.child(Local("cxnLst")) { + for c in cx.children(Local("cxn")).take(MAX_NODES * 4) { + if c.attr_local("type").unwrap_or("parOf") != "parOf" { + continue; + } + let (Some(src), Some(dst)) = (c.attr_local("srcId"), c.attr_local("destId")) else { continue }; + if texts.contains_key(dst) { + kids.entry(src).or_default().push((c.attr_local("srcOrd").and_then(value::to_i64_lossy).unwrap_or(0), dst)); + } + } + } + for v in kids.values_mut() { + v.sort_by_key(|(o, _)| *o); + } + let mut out = vec![]; + let mut seen = std::collections::HashSet::new(); + // Depth first, iteratively; a node reached twice (a cycle in a hostile file) is skipped. + let mut stack: Vec<(&str, u8)> = doc.and_then(|d| kids.get(d)).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default(); + while let Some((id, level)) = stack.pop() { + if out.len() >= MAX_NODES || !seen.insert(id) { + continue; + } + out.push(( + id.to_string(), + DiagramNode { + text: texts.get(id).cloned().unwrap_or_default(), + level: level.min(8), + picture: None, + assistant: assistants.contains(id), + title: titles.get(id).cloned().unwrap_or_default(), + }, + )); + if let Some(v) = kids.get(id) { + stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1)))); + } + } + if out.is_empty() && kids.is_empty() { + for id in pt_order { + if out.len() >= MAX_NODES || !seen.insert(id) { + continue; + } + out.push(( + id.to_string(), + DiagramNode { + text: texts.get(id).cloned().unwrap_or_default(), + level: 0, + picture: None, + assistant: assistants.contains(id), + title: titles.get(id).cloned().unwrap_or_default(), + }, + )); + } + } + out +} + +/// A text body's paragraphs joined by spaces. +fn paragraphs(t: &El) -> String { + t.children(Local("p")) + .map(|p| p.find_all(Local("t")).map(|e| e.deep_text()).collect::()) + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(" ") +} + +/// The layout algorithm and node shape of a layout `kind`. +fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) { + match kind { + "process" | "stepUp" | "pictureProcess" | "pictureCaption" | "pictureHList" | "timeline" => { + ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect") + } + "chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"), + "verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"), + "cycle" + | "radial" + | "venn" + | "target" + | "textCycle" + | "continuousCycle" + | "gear" + | "segmentedCycle" + | "multidirectionalCycle" + | "cycleMatrix" + | "pie" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"), + "blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"), + "pyramid" | "invertedPyramid" | "segmentedPyramid" => { + ("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid") + } + "matrix" | "titledMatrix" | "gridMatrix" | "blockList" => ( + "snake", + vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], + if kind == "blockList" { "rect" } else { "roundRect" }, + ), + "hBulletList" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "t")], "roundRect"), + _ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"), + } +} + +fn node_layout(w: &mut Writer, shape: &str, bullets: bool) { + w.tag("dgm:layoutNode").attr("name", "node").open(); + w.open("dgm:varLst", &[]); + w.tag("dgm:bulletEnabled").attr("val", if bullets { "1" } else { "0" }).empty(); + w.close(); + w.tag("dgm:alg").attr("type", "tx").empty(); + w.tag("dgm:shape").attr("type", shape).attr("r:blip", "").open(); + w.empty("dgm:adjLst", &[]); + w.close(); + w.tag("dgm:presOf").attr("axis", if bullets { "desOrSelf" } else { "self" }).attr("ptType", "node").empty(); + w.open("dgm:constrLst", &[]); + for side in ["tMarg", "bMarg", "lMarg", "rMarg"] { + w.tag("dgm:constr").attr("type", side).attr("refType", "primFontSz").attr("fact", "0.3").empty(); + } + w.close(); + w.open("dgm:ruleLst", &[]); + w.tag("dgm:rule").attr("type", "primFontSz").attr("val", "5").attr("fact", "NaN").attr("max", "NaN").empty(); + w.close(); + w.close(); +} + +fn space(w: &mut Writer) { + w.tag("dgm:forEach").attr("name", "sibling").attr("axis", "followSib").attr("ptType", "sibTrans").attr("cnt", "1").open(); + w.tag("dgm:layoutNode").attr("name", "space").open(); + w.tag("dgm:alg").attr("type", "sp").empty(); + w.tag("dgm:shape").attr("r:blip", "").open(); + w.empty("dgm:adjLst", &[]); + w.close(); + w.empty("dgm:presOf", &[]); + w.empty("dgm:constrLst", &[]); + w.empty("dgm:ruleLst", &[]); + w.close(); + w.close(); +} + +/// `ppt/diagrams/layoutN.xml`: a layout definition for the layout `kind`, under the id of +/// the closest built-in PowerPoint layout. +pub fn layout_xml(kind: &str) -> Vec { + let mut w = Writer::document(); + w.tag("dgm:layoutDef") + .attr("xmlns:dgm", NS_DGM) + .attr("xmlns:a", crate::ns::NS_A) + .attr("xmlns:r", crate::ns::NS_R) + .attr("uniqueId", layout_id(kind)) + .open(); + w.tag("dgm:title").attr("val", "").empty(); + w.tag("dgm:desc").attr("val", "").empty(); + w.open("dgm:catLst", &[]); + w.tag("dgm:cat").attr("type", category(kind)).attr("pri", "1000").empty(); + w.close(); + w.tag("dgm:layoutNode").attr("name", "diagram").open(); + w.open("dgm:varLst", &[]); + w.empty("dgm:dir", &[]); + w.tag("dgm:resizeHandles").attr("val", "exact").empty(); + w.close(); + if matches!(kind, "hierarchy" | "horizontalHierarchy" | "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart") { + // Each node over the row of its children, recursively. + w.tag("dgm:alg").attr("type", "hierChild").empty(); + w.tag("dgm:shape").attr("r:blip", "").open(); + w.empty("dgm:adjLst", &[]); + w.close(); + w.empty("dgm:presOf", &[]); + w.empty("dgm:constrLst", &[]); + w.empty("dgm:ruleLst", &[]); + w.tag("dgm:forEach").attr("name", "level").attr("axis", "ch").attr("ptType", "node").open(); + w.tag("dgm:layoutNode").attr("name", "hierRoot").open(); + w.tag("dgm:alg").attr("type", "hierRoot").empty(); + w.tag("dgm:shape").attr("r:blip", "").open(); + w.empty("dgm:adjLst", &[]); + w.close(); + w.empty("dgm:presOf", &[]); + w.empty("dgm:constrLst", &[]); + w.empty("dgm:ruleLst", &[]); + node_layout(&mut w, "roundRect", false); + w.tag("dgm:layoutNode").attr("name", "hierChild").open(); + w.tag("dgm:alg").attr("type", "hierChild").empty(); + w.tag("dgm:shape").attr("r:blip", "").open(); + w.empty("dgm:adjLst", &[]); + w.close(); + w.empty("dgm:presOf", &[]); + w.empty("dgm:constrLst", &[]); + w.empty("dgm:ruleLst", &[]); + w.tag("dgm:forEach").attr("name", "repeat").attr("ref", "level").empty(); + w.close(); + w.close(); + w.close(); + } else { + let (ty, params, shape) = alg(kind); + w.tag("dgm:alg").attr("type", ty).open(); + for (k, v) in params { + w.tag("dgm:param").attr("type", k).attr("val", v).empty(); + } + w.close(); + w.tag("dgm:shape").attr("r:blip", "").open(); + w.empty("dgm:adjLst", &[]); + w.close(); + w.empty("dgm:presOf", &[]); + w.open("dgm:constrLst", &[]); + w.tag("dgm:constr").attr("type", "w").attr("for", "ch").attr("forName", "node").attr("refType", "w").empty(); + w.tag("dgm:constr") + .attr("type", "h") + .attr("for", "ch") + .attr("forName", "node") + .attr("refType", "w") + .attr("refFor", "ch") + .attr("refForName", "node") + .attr("fact", "0.6") + .empty(); + w.tag("dgm:constr") + .attr("type", "w") + .attr("for", "ch") + .attr("forName", "space") + .attr("refType", "w") + .attr("refFor", "ch") + .attr("refForName", "node") + .attr("fact", "0.1") + .empty(); + w.tag("dgm:constr").attr("type", "primFontSz").attr("for", "ch").attr("forName", "node").attr("op", "equ").attr("val", "65").empty(); + w.close(); + w.empty("dgm:ruleLst", &[]); + w.tag("dgm:forEach").attr("name", "nodes").attr("axis", "ch").attr("ptType", "node").open(); + node_layout(&mut w, shape, true); + if !matches!(kind, "cycle" | "blockCycle" | "radial") { + space(&mut w); + } + w.close(); + } + w.close(); + w.close(); + w.into_bytes() +} + +/// The top bevel (preset, width in EMU) of a 3-D SmartArt Style. +fn bevel(style: &str) -> Option<(&'static str, i64)> { + Some(match style { + "polished" => ("circle", 76200), + "inset" => ("relaxedInset", 63500), + "cartoon" => ("softRound", 50800), + "powder" => ("convex", 101600), + "brick" => ("slope", 50800), + _ => return None, + }) +} + +/// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles. +pub fn style_xml(style: &str) -> Vec { + // Brick Scene looks at the diagram from above and to the side. + let camera = if style == "brick" { "isometricOffAxis2Top" } else { "orthographicFront" }; + let mut w = Writer::document(); + w.tag("dgm:styleDef").attr("xmlns:dgm", NS_DGM).attr("xmlns:a", crate::ns::NS_A).attr("uniqueId", qs_id(style)).open(); + w.tag("dgm:title").attr("val", "").empty(); + w.tag("dgm:desc").attr("val", "").empty(); + w.open("dgm:catLst", &[]); + w.tag("dgm:cat").attr("type", if bevel(style).is_some() { "3D" } else { "simple" }).attr("pri", "10100").empty(); + w.close(); + w.open("dgm:scene3d", &[]); + w.tag("a:camera").attr("prst", camera).empty(); + w.tag("a:lightRig").attr("rig", "threePt").attr("dir", "t").empty(); + w.close(); + w.tag("dgm:styleLbl").attr("name", "node1").open(); + w.open("dgm:scene3d", &[]); + w.tag("a:camera").attr("prst", camera).empty(); + w.tag("a:lightRig").attr("rig", "threePt").attr("dir", "t").empty(); + w.close(); + match bevel(style) { + Some((prst, w_emu)) => { + w.open("dgm:sp3d", &[]); + w.tag("a:bevelT").attr("w", w_emu.to_string()).attr("h", (w_emu * 3 / 5).to_string()).attr("prst", prst).empty(); + w.close(); + } + None => w.empty("dgm:sp3d", &[]), + } + w.tag("dgm:txPr").empty(); + w.open("dgm:style", &[]); + for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] { + w.tag(tag).attr("idx", idx).open(); + w.tag("a:scrgbClr").attr("r", "0").attr("g", "0").attr("b", "0").empty(); + w.close(); + } + w.tag("a:fontRef").attr("idx", "minor").open(); + w.tag("a:schemeClr").attr("val", "lt1").empty(); + w.close(); + w.close(); + w.close(); + w.close(); + w.into_bytes() +} + +/// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text. +pub fn colors_xml(colors: &str) -> Vec { + let mut w = Writer::document(); + w.tag("dgm:colorsDef").attr("xmlns:dgm", NS_DGM).attr("xmlns:a", crate::ns::NS_A).attr("uniqueId", cs_id(colors)).open(); + w.tag("dgm:title").attr("val", "").empty(); + w.tag("dgm:desc").attr("val", "").empty(); + let accents = fill_accents(colors); + let single = accents.len() == 1; + let cat = if single { accents.first().map(String::as_str).unwrap_or("accent1") } else { "colorful" }; + w.open("dgm:catLst", &[]); + w.tag("dgm:cat").attr("type", cat).attr("pri", "11200").empty(); + w.close(); + w.tag("dgm:styleLbl").attr("name", "node1").open(); + if colors.ends_with("Grad") { + // Gradient Range: the accent shaded darker across the shapes. + w.tag("dgm:fillClrLst").attr("meth", "repeat").open(); + for k in 0..4 { + w.tag("a:schemeClr").attr("val", cat).open(); + w.tag("a:lumMod").attr("val", (100_000 - k * 15_000).to_string()).empty(); + w.close(); + } + w.close(); + } else { + w.tag("dgm:fillClrLst").attr("meth", "repeat").open(); + for a in &accents { + w.tag("a:schemeClr").attr("val", a.as_str()).empty(); + } + w.close(); + } + w.tag("dgm:linClrLst").attr("meth", "repeat").open(); + w.tag("a:schemeClr").attr("val", "lt1").empty(); + w.close(); + w.empty("dgm:effectClrLst", &[]); + w.empty("dgm:txLinClrLst", &[]); + w.empty("dgm:txFillClrLst", &[]); + w.empty("dgm:txEffectClrLst", &[]); + w.close(); + w.close(); + w.into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nodes(v: &[(&str, u8)]) -> Vec { + v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None, assistant: false, title: String::new() }).collect() + } + + #[test] + fn outline_survives_the_data_model() { + let d = + Diagram { kind: "hierarchy".into(), nodes: nodes(&[("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1), ("Board", 0)]), ..Default::default() }; + let xml = data_xml(&d, 7, "rId9", &[]); + let doc = crate::xmlx::parse(&xml).unwrap(); + assert_eq!(outline(&doc), d.nodes); + let s = String::from_utf8(xml).unwrap(); + assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\"")); + for x in [ + layout_xml("hierarchy"), + layout_xml("cycle"), + style_xml("subtle"), + colors_xml(""), + colors_xml("accent3"), + style_xml("polished"), + colors_xml("range23"), + colors_xml("accent6Grad"), + layout_xml("pictureList"), + ] { + assert!(crate::xmlx::parse(&x).is_ok()); + } + } + + #[test] + fn assistants_survive_the_data_model() { + let mut v = nodes(&[("CEO", 0), ("Aide", 1), ("CTO", 1)]); + v[1].assistant = true; + v[0].title = "Chief Executive".into(); + let d = Diagram { kind: "orgChart".into(), nodes: v, ..Default::default() }; + let xml = data_xml(&d, 5, "rId1", &[]); + assert!(String::from_utf8(xml.clone()).unwrap().contains("type=\"asst\"")); + let doc = crate::xmlx::parse(&xml).unwrap(); + assert_eq!(outline(&doc), d.nodes); + } + + #[test] + fn node_pictures_come_from_presentation_points() { + let d = Diagram { kind: "pictureList".into(), nodes: nodes(&[("A", 0), ("a", 1), ("B", 0)]), ..Default::default() }; + let xml = data_xml(&d, 3, "rId1", &[(2, "rId7".into()), (9, "rId8".into())]); + let doc = crate::xmlx::parse(&xml).unwrap(); + assert_eq!(node_pictures(&doc), vec![(2, "rId7".to_string())]); + } + + #[test] + fn style_and_colour_ids_round_trip() { + for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] { + assert_eq!(style_for(&qs_id(st)), st); + } + for c in ["", "range23", "range56", "accent1", "accent4Grad"] { + assert_eq!(colors_for(&cs_id(c)), c); + } + assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT")); + assert!(layout_id("pictureProcess").ends_with("/hProcess10"), "Picture Accent Process"); + assert!(layout_id("pictureHList").ends_with("/pList2"), "Horizontal Picture List"); + // Picture Accent Process as an earlier writer wrote it (pList2, process category). + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "process"), "pictureProcess"); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/pList2", "list"), "pictureHList"); + for k in [ + "pictureList", + "pictureCaption", + "pictureProcess", + "pictureHList", + "timeline", + "venn", + "target", + "textCycle", + "continuousCycle", + "horizontalHierarchy", + "gridMatrix", + "gear", + "funnel", + "segmentedCycle", + "multidirectionalCycle", + "orgChart", + "halfCircleOrgChart", + "cycleMatrix", + "segmentedPyramid", + "pie", + "nameTitleOrgChart", + "verticalBoxList", + "trapezoidList", + "tableList", + "accentProcess", + "continuousBlockProcess", + "bendingProcess", + "linearVenn", + "stackedVenn", + "nestedTarget", + ] { + assert_eq!(kind_for(layout_id(k), ""), k); + } + } + + #[test] + fn hostile_links_are_bounded() { + let xml = br#"A"#; + let doc = crate::xmlx::parse(xml).unwrap(); + assert_eq!(outline(&doc), nodes(&[("A", 0), ("", 1)])); + assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "orgChart"); + assert_eq!(kind_for("urn:x/layout/NameandTitleOrganizationalChart", ""), "nameTitleOrgChart"); + assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron"); + assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process"); + assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList"); + assert_eq!(colors_for(&cs_id("accent4")), "accent4"); + assert_eq!(colors_for(&cs_id("")), ""); + assert_eq!(style_for(&qs_id("outline")), "outline"); + + let unconnected = br#"AlphaBeta"#; + let unconnected_doc = crate::xmlx::parse(unconnected).unwrap(); + assert_eq!(outline(&unconnected_doc), nodes(&[("Alpha", 0), ("Beta", 0)])); + + // When parOf connections exist but no root (type="doc") reaches them, do not flatten the hierarchy to level 0. + let unrooted_hierarchy = br#"ParentChild"#; + let unrooted_doc = crate::xmlx::parse(unrooted_hierarchy).unwrap(); + assert!(outline(&unrooted_doc).is_empty()); + } +} diff --git a/crates/drawingml/src/lib.rs b/crates/drawingml/src/lib.rs index 1e47301d..70e57b2c 100644 --- a/crates/drawingml/src/lib.rs +++ b/crates/drawingml/src/lib.rs @@ -9,10 +9,16 @@ //! - [`preset`]: outlines of the preset shapes (`a:prstGeom`), by their file names. //! - [`custgeom`]: custom geometry (`a:custGeom`), with the shape guide formulas. //! - [`warp`]: preset text warps (`a:prstTxWarp`). -//! - The model, without readers and writers yet: [`style`] (colour references, fills, lines, -//! effects), [`text`] (text bodies, paragraphs, runs), [`table`], [`chart`] with [`numfmt`] -//! (number format codes), [`theme`] (colour, font and format schemes) and [`diagram`] (SmartArt outlines). Media are named by [`MediaId`]; -//! the bytes stay with the caller. +//! - The model: [`style`] (colour references, fills, lines, effects), [`text`] (text bodies, +//! paragraphs, runs), [`table`], [`chart`] with [`numfmt`] (number format codes), [`theme`] +//! (colour, font and format schemes) and [`diagram`] (SmartArt outlines). Media are named by +//! [`MediaId`]; the bytes stay with the caller. +//! - Readers and writers: [`read`] and [`write`] (`a:` fills, lines, effects, `a:xfrm`, text +//! bodies), [`chart::read`], [`chart::chartex`] and [`chart::write`] (`c:chartSpace`, +//! `cx:chartSpace`), [`theme::read_theme`] and [`theme::theme_xml`], [`table::styles`] +//! (`a:tblStyleLst`) and [`diagram::parts`] (`dgm:` data, layout, style and colour parts). +//! What they need from the package is the caller's [`part::Source`] or [`part::Sink`]; +//! [`xmlx`] holds the parser settings and markup-compatibility handling they share. //! //! Nothing here opens a package: readers take a [`craft_xml::El`] and writers a //! [`craft_xml::Writer`], so the same code serves a slide, a document or a worksheet drawing. @@ -23,11 +29,23 @@ pub mod color; pub mod custgeom; pub mod diagram; pub mod numfmt; +pub mod part; pub mod preset; +pub mod read; pub mod style; pub mod table; pub mod text; pub mod theme; +pub mod write; +pub mod xmlx; + +/// Namespace URIs. +pub mod ns { + pub const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; + pub const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + pub const NS_A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main"; + pub const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; +} mod trendline; pub mod warp; diff --git a/crates/drawingml/src/part.rs b/crates/drawingml/src/part.rs new file mode 100644 index 00000000..9797ac36 --- /dev/null +++ b/crates/drawingml/src/part.rs @@ -0,0 +1,195 @@ +//! What a reader or writer needs from the package around the part it works on. The caller +//! implements these over its package layer, so nothing here opens or writes a package. + +use craft_xml::El; + +use crate::{MediaId, SlideId}; + +/// A relationship of the part being read. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rel { + /// The part name for an internal relationship, the address for an external one. + pub target: String, + pub external: bool, +} + +/// The part being read. +pub trait Source { + /// The relationship with this id. + fn rel(&self, id: &str) -> Option; + /// The media item for a relationship's target: the part named `target`, or a link to the + /// address `target` when `external`. `None` when the package has no such part. + fn media(&mut self, target: &str, external: bool) -> Option; + /// The slide stored in the part named `target`, in a presentation. + fn slide(&self, _target: &str) -> Option { + None + } + /// The name of the custom show with this id, in a presentation. + fn custom_show(&self, _id: &str) -> Option { + None + } + /// `el` as text that can be written back into another part: with the namespace + /// declarations it needs from the part's root (see [`crate::xmlx::keep_xml`]). + fn keep(&self, el: &El) -> String { + el.to_xml() + } + /// [`Source::keep`], also declaring prefixes that only an enclosing element binds. + fn keep_inner(&self, el: &El) -> String { + self.keep(el) + } +} + +/// A [`Source`] for a fragment with no package around it: no relationships, no media. +pub struct NoSource; + +impl Source for NoSource { + fn rel(&self, _id: &str) -> Option { + None + } + fn media(&mut self, _target: &str, _external: bool) -> Option { + None + } +} + +/// The part being written. +pub trait Sink { + /// The name of the part (`ppt/slides/slide1.xml`); ids derived from content are seeded with it. + fn part_name(&self) -> &str; + /// Adds a relationship from this part and returns its id. `kind` is the last segment of the + /// relationship type (`hyperlink`, `image`). + fn rel(&mut self, kind: &str, target: &str, external: bool) -> String; + /// A relationship to the media item, written into the package if it is not there yet. + /// `kind` is `image`, `audio` or `video`. `None` when there is no such item. + fn media_rel(&mut self, id: MediaId, kind: &str) -> Option; + /// A relationship to a stand-in picture, for a picture fill whose media is missing. + fn placeholder_rel(&mut self) -> String; + /// Is the item a link to a file outside the package (no bytes)? + fn media_linked(&self, id: MediaId) -> bool; + /// The item's file name. + fn media_name(&self, id: MediaId) -> Option; + /// A relationship to the part of that slide, in a presentation. + fn slide_rel(&mut self, _slide: SlideId) -> Option { + None + } + /// The position of the custom show with this name, in a presentation. + fn custom_show_index(&self, _name: &str) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::color::SchemeSlot; + use crate::style::{ColorRef, Fill, PictureFill}; + use crate::text::{Action, Hyperlink}; + use crate::theme::Theme; + use crate::xmlx::parse; + + /// A part with `rId1` → an internal picture, `rId2` → a web page, `rId3` → slide 2. + #[derive(Default)] + struct Part { + asked: Vec<(String, bool)>, + rels: HashMap, + written: Vec<(String, String, bool)>, + } + + impl Part { + fn new() -> Part { + let mut p = Part::default(); + p.rels.insert("rId1".into(), ("ppt/media/image1.png".into(), false)); + p.rels.insert("rId2".into(), ("https://example.com/".into(), true)); + p.rels.insert("rId3".into(), ("ppt/slides/slide2.xml".into(), false)); + p + } + } + + impl Source for Part { + fn rel(&self, id: &str) -> Option { + self.rels.get(id).map(|(target, external)| Rel { target: target.clone(), external: *external }) + } + fn media(&mut self, target: &str, external: bool) -> Option { + self.asked.push((target.into(), external)); + (target == "ppt/media/image1.png").then_some(MediaId(7)) + } + fn slide(&self, target: &str) -> Option { + (target == "ppt/slides/slide2.xml").then_some(SlideId(2)) + } + } + + impl Sink for Part { + fn part_name(&self) -> &str { + "ppt/slides/slide1.xml" + } + fn rel(&mut self, kind: &str, target: &str, external: bool) -> String { + self.written.push((kind.into(), target.into(), external)); + format!("rId{}", self.written.len()) + } + fn media_rel(&mut self, id: MediaId, kind: &str) -> Option { + (id == MediaId(7)).then(|| Sink::rel(self, kind, "../media/image1.png", false)) + } + fn placeholder_rel(&mut self) -> String { + Sink::rel(self, "image", "../media/missing.png", false) + } + fn media_linked(&self, _id: MediaId) -> bool { + false + } + fn media_name(&self, _id: MediaId) -> Option { + None + } + fn slide_rel(&mut self, slide: SlideId) -> Option { + (slide == SlideId(2)).then(|| Sink::rel(self, "slide", "slide2.xml", false)) + } + } + + #[test] + fn a_picture_fill_finds_its_media_through_the_source_and_writes_through_the_sink() { + let el = parse(br#""#).unwrap(); + let mut part = Part::new(); + let Some(Fill::Picture(pf)) = crate::read::fill_el(&mut part, &el) else { panic!("no picture fill") }; + assert_eq!(pf.media, MediaId(7)); + assert_eq!(part.asked, [("ppt/media/image1.png".to_string(), false)]); + let mut w = craft_xml::Writer::new(); + crate::write::blip_fill(&mut w, &mut part, "a:blipFill", &pf); + assert!(w.finish().contains(r#""#).unwrap(); + let slide = parse(br#""#).unwrap(); + let none = parse(br#""#).unwrap(); + let h = crate::read::hyperlink(&mut part, &url).unwrap(); + assert_eq!(h.action, Action::Url { url: "https://example.com/".into() }); + let s = crate::read::hyperlink(&mut part, &slide).unwrap(); + assert_eq!(s.action, Action::Slide { slide: SlideId(2) }); + assert!(crate::read::hyperlink(&mut part, &none).is_none()); + assert!(crate::read::hyperlink(&mut NoSource, &slide).is_none()); + let mut w = craft_xml::Writer::new(); + crate::write::hyperlink(&mut w, &mut part, "a:hlinkClick", &Hyperlink { action: Action::Slide { slide: SlideId(2) }, ..h }); + assert_eq!(w.finish(), r#""#); + } + + #[test] + fn a_theme_survives_writing_and_reading() { + let mut t = Theme { name: "Round trip".into(), ..Theme::default() }; + t.fonts.major.latin = "Head".into(); + t.format.fills[0] = Fill::solid(ColorRef::scheme(SchemeSlot::PhClr)); + let xml = crate::theme::theme_xml(&mut Part::new(), &t); + let back = crate::theme::read_theme(&mut Part::new(), &parse(&xml).unwrap()); + assert_eq!(back.name, t.name); + assert_eq!(back.colors.colors, t.colors.colors); + assert_eq!(back.fonts.major.latin, "Head"); + assert_eq!(back.format.fills, t.format.fills); + assert_eq!(back.format.lines.len(), 3); + } +} diff --git a/crates/drawingml/src/read.rs b/crates/drawingml/src/read.rs new file mode 100644 index 00000000..1151c3b4 --- /dev/null +++ b/crates/drawingml/src/read.rs @@ -0,0 +1,813 @@ +//! DrawingML reading: colours, fills, lines, effects, transforms and text. + +use crate::color::{ColorTransform, Rgba, SchemeSlot}; +use crate::style::{ + ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineAlign, LineCap, LineEnd, + LineJoin, PatternFill, PictureFill, PictureMode, Reflection, Shadow, +}; +use crate::text::{ + Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, LevelStyle, ListStyle, ParaProps, Paragraph, Run, RunKind, RunProps, Spacing, + Strike, TabStop, TextBody, TextDir, +}; +use crate::{MediaId, Xfrm, emu_to_pt}; + +use craft_xml::{El, Local, value}; + +use crate::part::Source; + +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. +pub fn pct(e: &El, name: &str) -> Option { + e.attr_local(name).and_then(value::to_f64).map(|v| v / 100_000.0) +} + +fn bullet_size_points(value: Option<&str>) -> Option { + value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) +} + +fn bullet_size_percent(value: Option<&str>) -> Option { + let value = value?.trim(); + let (value, scale) = if let Some(value) = value.strip_suffix('%') { (value.trim(), 100.0) } else { (value, 100_000.0) }; + value.parse::().ok().filter(|v| v.is_finite()).map(|v| v / scale) +} + +/// 1/60 000 degree → degrees. +pub fn ang(e: &El, name: &str) -> Option { + e.attr_local(name).and_then(value::to_f64).map(|v| v / 60_000.0) +} + +fn lin_to_srgb(v: f64) -> f64 { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// A colour element (`srgbClr`, `schemeClr`, …) with its transforms. +pub fn color_el(c: &El) -> Option { + let base = match c.local() { + "srgbClr" => ColorBase::Rgb { rgb: Rgba::from_hex(c.attr_local("val").unwrap_or("000000")).unwrap_or(Rgba::BLACK) }, + "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 + }), + }, + "prstClr" => ColorBase::Preset { name: c.attr_local("val").unwrap_or("black").to_string() }, + "scrgbClr" => { + let f = |n: &str| lin_to_srgb(c.attr_local(n).and_then(value::to_f64).unwrap_or(0.0) / 100_000.0); + ColorBase::Rgb { rgb: Rgba::from_f64(f("r"), f("g"), f("b"), 1.0) } + } + "hslClr" => { + let h = c.attr_local("hue").and_then(value::to_f64).unwrap_or(0.0) / 60_000.0; + let s = c.attr_local("sat").and_then(value::to_f64).unwrap_or(0.0) / 100_000.0; + let l = c.attr_local("lum").and_then(value::to_f64).unwrap_or(0.0) / 100_000.0; + ColorBase::Rgb { rgb: Rgba::from_hsl(h, s, l, 255) } + } + _ => return None, + }; + let mods = c.elements().take(32).filter_map(|m| ColorTransform::from_xml(m.local(), m.attr_local("val").and_then(value::to_i32_lossy))).collect(); + Some(ColorRef { base, mods }) +} + +/// The first colour child of a container (`solidFill`, `fgClr`, `lnRef`…). +pub fn color(container: &El) -> Option { + container.elements().find_map(color_el) +} + +const FILL_NAMES: [&str; 6] = ["noFill", "solidFill", "gradFill", "blipFill", "pattFill", "grpFill"]; + +/// The fill child of `parent`, if any. +pub fn fill_of(src: &mut dyn Source, parent: &El) -> Option { + parent.elements().find(|e| FILL_NAMES.contains(&e.local())).and_then(|f| fill_el(src, f)) +} + +pub fn fill_el(src: &mut dyn Source, f: &El) -> Option { + Some(match f.local() { + "noFill" => Fill::None, + "solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) }, + "gradFill" => Fill::Gradient(gradient(f)), + "blipFill" => match blip_fill(src, f) { + // A picture filling a shape or background with neither `a:stretch` nor `a:tile` is + // tiled at its own size, as PowerPoint draws it. + Some(mut p) if f.child(Local("stretch")).is_none() && f.child(Local("tile")).is_none() => { + p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + Fill::Picture(p) + } + Some(p) => Fill::Picture(p), + None => Fill::None, + }, + "pattFill" => Fill::Pattern(pattern(f)), + "grpFill" => Fill::Group, + _ => return None, + }) +} + +/// An `a:pattFill`: its preset (5 % dots when unset), foreground (black) and background (white). +pub fn pattern(f: &El) -> PatternFill { + PatternFill { + preset: f.attr_local("prst").unwrap_or("pct5").to_string(), + fg: f.child(Local("fgClr")).and_then(color).unwrap_or(ColorRef::rgb(Rgba::BLACK)), + bg: f.child(Local("bgClr")).and_then(color).unwrap_or(ColorRef::rgb(Rgba::WHITE)), + } +} + +pub fn gradient(f: &El) -> Gradient { + let mut stops: Vec = f + .child(Local("gsLst")) + .map(|l| { + l.children(Local("gs")) + .take(256) + .map(|g| GradientStop { pos: pct(g, "pos").unwrap_or(0.0).clamp(0.0, 1.0), color: color(g).unwrap_or(ColorRef::rgb(Rgba::BLACK)) }) + .collect() + }) + .unwrap_or_default(); + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + let shape = if let Some(l) = f.child(Local("lin")) { + GradientShape::Linear { angle: ang(l, "ang").unwrap_or(0.0), scaled: l.attr_local("scaled").and_then(value::to_bool).unwrap_or(false) } + } else if let Some(p) = f.child(Local("path")) { + let r = p.child(Local("fillToRect")); + let g = |n: &str| r.and_then(|r| pct(r, n)).unwrap_or(0.0); + GradientShape::Path { path: p.attr_local("path").unwrap_or("circle").to_string(), focus: [g("l"), g("t"), g("r"), g("b")] } + } else { + GradientShape::Linear { angle: 90.0, scaled: false } + }; + let tile = f.child(Local("tileRect")).map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0))); + Gradient { + tile: tile.unwrap_or_default(), + stops, + shape, + flip: f.attr_local("flip").and_then(GradientFlip::from_xml), + rotate_with_shape: f.attr_local("rotWithShape").and_then(value::to_bool).unwrap_or(true), + } +} + +/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media. +pub fn blip_fill(src: &mut dyn Source, bf: &El) -> Option { + let blip = bf.child(Local("blip")); + let media = blip.and_then(|b| media_ref(src, b)); + let media = media?; + let mut pf = PictureFill { media, upright: !bf.attr_local("rotWithShape").and_then(value::to_bool).unwrap_or(true), ..Default::default() }; + if let Some(layer) = blip.and_then(|b| b.child(Local("extLst"))).and_then(|x| x.find(Local("imgLayer"))) + && let Some(effect) = layer + .elements() + .filter(|e| e.local() == "imgEffect") + .flat_map(|e| e.elements()) + .map(|e| e.local().to_string()) + .find(|n| n.starts_with("artistic")) + && let Some(original) = media_ref(src, layer) + { + pf.artistic = Some(crate::style::Artistic { effect, original }); + } + if let Some(b) = blip { + for e in b.elements() { + match e.local() { + "alphaModFix" => pf.alpha = Some(pct(e, "amt").unwrap_or(1.0).clamp(0.0, 1.0)), + "lum" => { + pf.adjust.brightness = pct(e, "bright").unwrap_or(0.0).clamp(-1.0, 1.0); + pf.adjust.contrast = pct(e, "contrast").unwrap_or(0.0).clamp(-1.0, 1.0); + } + "grayscl" => pf.adjust.grayscale = true, + "biLevel" => pf.adjust.bilevel = Some(pct(e, "thresh").unwrap_or(0.5).clamp(0.0, 1.0)), + "alphaInv" => pf.adjust.alpha_inv = true, + "duotone" => { + let cs: Vec = e.elements().filter_map(color_el).collect(); + if let [a, b] = cs.as_slice() { + pf.adjust.duotone = Some((a.clone(), b.clone())); + } + } + "clrChange" => { + if let Some(ColorRef { base: ColorBase::Rgb { rgb }, .. }) = e.child(Local("clrFrom")).and_then(color) { + pf.adjust.clear_color = Some(rgb); + } + } + _ => {} + } + } + } + if let Some(r) = bf.child(Local("srcRect")) { + pf.crop = [pct(r, "l").unwrap_or(0.0), pct(r, "t").unwrap_or(0.0), pct(r, "r").unwrap_or(0.0), pct(r, "b").unwrap_or(0.0)] + .map(|v| v.clamp(-10.0, 1.0)); + } + if let Some(t) = bf.child(Local("tile")) { + pf.mode = PictureMode::Tile { + tx: pt(t, "tx").unwrap_or(0.0), + ty: pt(t, "ty").unwrap_or(0.0), + sx: pct(t, "sx").unwrap_or(1.0), + sy: pct(t, "sy").unwrap_or(1.0), + flip: t.attr_local("flip").unwrap_or("none").to_string(), + align: t.attr_local("algn").unwrap_or("tl").to_string(), + }; + } else { + let r = bf.path(&[Local("stretch"), Local("fillRect")]); + let g = |n: &str| r.and_then(|r| pct(r, n)).unwrap_or(0.0).clamp(-10.0, 1.0); + pf.mode = PictureMode::Stretch { fill_rect: [g("l"), g("t"), g("r"), g("b")] }; + } + Some(pf) +} + +/// Media for an element with `r:embed` (or `r:link`). +pub fn media_ref(src: &mut dyn Source, b: &El) -> Option { + if let Some(id) = b.attr_local("r:embed").filter(|s| !s.is_empty()) + && let Some(r) = src.rel(id) + && let Some(m) = src.media(&r.target, r.external) + { + return Some(m); + } + let r = src.rel(b.attr_local("r:link").filter(|s| !s.is_empty())?)?; + src.media(&r.target, r.external) +} + +pub fn line(src: &mut dyn Source, ln: &El) -> Line { + let mut l = Line { width: pt(ln, "w").map(|w| w.clamp(0.0, 2000.0)), ..Default::default() }; + l.cap = ln.attr_local("cap").map(|c| match c { + "rnd" => LineCap::Round, + "sq" => LineCap::Square, + _ => LineCap::Flat, + }); + l.compound = ln.attr_local("cmpd").map(Compound::from_xml); + l.align = ln.attr_local("algn").and_then(LineAlign::from_xml); + l.fill = fill_of(src, ln); + for e in ln.elements() { + match e.local() { + "prstDash" => l.dash = Some(Dash::Preset { name: e.attr_local("val").unwrap_or("solid").to_string() }), + "custDash" => { + let pattern = e.children(Local("ds")).take(64).map(|d| (pct(d, "d").unwrap_or(1.0), pct(d, "sp").unwrap_or(1.0))).collect(); + l.dash = Some(Dash::Custom { pattern }); + } + "round" => l.join = Some(LineJoin::Round), + "bevel" => l.join = Some(LineJoin::Bevel), + "miter" => { + l.join = Some(LineJoin::Miter); + l.miter_limit = e.attr_local("lim").and_then(parse_miter_limit); + } + "headEnd" => l.head = line_end(e), + "tailEnd" => l.tail = line_end(e), + _ => {} + } + } + l +} + +fn parse_miter_limit(value: &str) -> Option { + const MAX: u32 = i32::MAX as u32; + let value = value.trim(); + if let Some(percent) = value.strip_suffix('%') { + let units = percent.trim().parse::().ok()? * 1000.0; + (units.is_finite() && (0.0..=MAX as f64).contains(&units)).then(|| units.round() as u32) + } else { + value.parse::().ok().filter(|limit| *limit <= MAX) + } +} + +fn line_end(e: &El) -> Option { + let kind = e.attr_local("type").unwrap_or("none"); + Some(LineEnd { kind: kind.to_string(), w: e.attr_local("w").unwrap_or("med").to_string(), len: e.attr_local("len").unwrap_or("med").to_string() }) +} + +fn shadow(e: &El, inner: bool) -> Shadow { + Shadow { + color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), + blur: pt(e, "blurRad").unwrap_or(0.0).max(0.0), + dist: pt(e, "dist").unwrap_or(0.0), + dir: ang(e, "dir").unwrap_or(0.0), + inner, + sx: pct(e, "sx").unwrap_or(1.0), + sy: pct(e, "sy").unwrap_or(1.0), + kx: ang(e, "kx").unwrap_or(0.0), + ky: ang(e, "ky").unwrap_or(0.0), + align: e.attr_local("algn").unwrap_or(if inner { "" } else { "b" }).to_string(), + rotate_with_shape: e.attr_local("rotWithShape").and_then(value::to_bool).unwrap_or(true), + } +} + +/// `effectLst` → effects. +pub fn effects(el: &El) -> Effects { + let mut fx = Effects::default(); + for e in el.elements() { + match e.local() { + "outerShdw" => fx.outer_shadow = Some(shadow(e, false)), + "prstShdw" => { + let mut s = shadow(e, false); + s.align = "b".into(); + fx.outer_shadow = Some(s); + } + "innerShdw" => fx.inner_shadow = Some(shadow(e, true)), + "glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }), + "softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)), + "fillOverlay" => { + let fill = e.elements().find_map(|f| match f.local() { + "solidFill" => color(f).map(|c| Fill::Solid { color: c }), + "gradFill" => Some(Fill::Gradient(gradient(f))), + _ => None, + }); + fx.fill_overlay = fill.map(|fill| crate::style::FillOverlay { blend: e.attr_local("blend").unwrap_or("over").to_string(), fill }); + } + "reflection" => { + fx.reflection = Some(Reflection { + blur: pt(e, "blurRad").unwrap_or(0.0), + start_alpha: pct(e, "stA").unwrap_or(1.0), + end_alpha: pct(e, "endA").unwrap_or(0.0), + end_pos: pct(e, "endPos").unwrap_or(1.0), + dist: pt(e, "dist").unwrap_or(0.0), + st_pos: e.attr_local("stPos").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + dir: e.attr_local("dir").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + fade_dir: e.attr_local("fadeDir").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + sx: e.attr_local("sx").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + sy: e.attr_local("sy").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + kx: e.attr_local("kx").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + ky: e.attr_local("ky").and_then(value::to_i64_lossy).and_then(|v| i32::try_from(v).ok()), + align: e + .attr_local("algn") + .filter(|v| matches!(*v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + .map(str::to_owned), + rotate_with_shape: e.attr_local("rotWithShape").and_then(value::to_bool), + }) + } + _ => {} + } + } + fx +} + +/// `a:xfrm` → (box, child box for groups). +pub fn xfrm(x: &El) -> (Xfrm, Option) { + let off = x.child(Local("off")); + let ext = x.child(Local("ext")); + let g = |e: Option<&El>, n: &str| e.and_then(|e| pt(e, n)).unwrap_or(0.0); + let mut xf = Xfrm::new(g(off, "x"), g(off, "y"), g(ext, "cx").max(0.0), g(ext, "cy").max(0.0)); + xf.rot = ang(x, "rot").unwrap_or(0.0); + if !xf.rot.is_finite() { + xf.rot = 0.0; + } + xf.flip_h = x.attr_local("flipH").and_then(value::to_bool).unwrap_or(false); + xf.flip_v = x.attr_local("flipV").and_then(value::to_bool).unwrap_or(false); + let child = match (x.child(Local("chOff")), x.child(Local("chExt"))) { + (Some(o), Some(e)) => Some(Xfrm::new(g(Some(o), "x"), g(Some(o), "y"), g(Some(e), "cx").max(0.0), g(Some(e), "cy").max(0.0))), + _ => None, + }; + (xf, child) +} + +// --------------------------------------------------------------------------------------------- +// Text + +pub fn body_pr(b: &El) -> BodyProps { + let mut p = BodyProps { + inset_l: pt(b, "lIns"), + inset_t: pt(b, "tIns"), + inset_r: pt(b, "rIns"), + inset_b: pt(b, "bIns"), + anchor: b.attr_local("anchor").and_then(Anchor::from_xml), + anchor_ctr: b.attr_local("anchorCtr").and_then(value::to_bool), + wrap: b.attr_local("wrap").map(|w| w != "none"), + vert: b.attr_local("vert").and_then(TextDir::from_xml), + rot: ang(b, "rot"), + columns: b.attr_local("numCol").and_then(value::to_u32_lossy).map(|n| n.clamp(1, 16)), + col_spacing: pt(b, "spcCol"), + rtl_col: b.attr_local("rtlCol").and_then(value::to_bool), + upright: b.attr_local("upright").and_then(value::to_bool), + space_first_last: b.attr_local("spcFirstLastPara").and_then(value::to_bool), + compat_ln_spc: b.attr_local("compatLnSpc").and_then(value::to_bool), + vert_overflow: b.attr_local("vertOverflow").and_then(crate::text::TextOverflow::from_xml), + horz_overflow: b.attr_local("horzOverflow").and_then(crate::text::TextOverflow::from_horz_xml), + ..Default::default() + }; + for e in b.elements() { + match e.local() { + "normAutofit" => { + p.autofit = Some(AutoFit::Shrink { + font_scale: pct(e, "fontScale").map(|v| v.clamp(0.01, 1.0)), + line_reduction: pct(e, "lnSpcReduction").unwrap_or(0.0).clamp(0.0, 1.0), + }) + } + "spAutoFit" => p.autofit = Some(AutoFit::Shape), + "noAutofit" => p.autofit = Some(AutoFit::None), + "prstTxWarp" => { + p.warp = e.attr_local("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from); + p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default(); + } + _ => {} + } + } + p +} + +/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given); +/// slots the file leaves out keep the preset's default. +fn warp_adjusts(preset: &str, e: &El) -> Vec { + let mut adj: Vec = Vec::new(); + let Some(av) = e.child(Local("avLst")) else { return adj }; + let defaults = crate::warp::adjust_defaults(preset); + for gd in av.children(Local("gd")).take(16) { + let n = gd.attr_local("name").unwrap_or(""); + let idx = if n == "adj" { + 0 + } else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::().ok()).filter(|k| (1..=8).contains(k)) { + k - 1 + } else { + continue; + }; + let Some(v) = + gd.attr_local("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + else { + continue; + }; + while adj.len() <= idx { + adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(idx) { + *slot = v.clamp(-1.0e9, 1.0e9); + } + } + adj +} + +fn spacing(e: &El) -> Option { + if let Some(p) = e.child(Local("spcPct")) { + return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0))); + } + e.child(Local("spcPts")).map(|p| Spacing::Pts(p.attr_local("val").and_then(value::to_f64).unwrap_or(0.0).clamp(0.0, 1_000_000.0) / 100.0)) +} + +/// `a:pPr` / `a:lvlNpPr` → (level, paragraph props, default run props). +pub fn ppr(src: &mut dyn Source, p: &El) -> (Option, ParaProps, Option) { + let mut pp = ParaProps { + align: p.attr_local("algn").and_then(Align::from_xml), + margin_left: pt(p, "marL"), + margin_right: pt(p, "marR"), + indent: pt(p, "indent"), + rtl: p.attr_local("rtl").and_then(value::to_bool), + font_align: p.attr_local("fontAlgn").map(String::from), + east_asian_line_break: p.attr_local("eaLnBrk").and_then(value::to_bool), + latin_line_break: p.attr_local("latinLnBrk").and_then(value::to_bool), + hanging_punct: p.attr_local("hangingPunct").and_then(value::to_bool), + default_tab: pt(p, "defTabSz"), + ..Default::default() + }; + let lvl = p.attr_local("lvl").and_then(value::to_u32_lossy).map(|l| l.min(8) as u8); + let mut def = None; + for e in p.elements() { + match e.local() { + "lnSpc" => pp.line_spacing = spacing(e), + "spcBef" => pp.space_before = spacing(e), + "spcAft" => pp.space_after = spacing(e), + "buNone" => pp.bullet = Some(Bullet::None), + "buChar" => pp.bullet = Some(Bullet::Char { char: e.attr_local("char").unwrap_or("•").chars().take(4).collect() }), + "buAutoNum" => { + pp.bullet = Some(Bullet::AutoNum { + scheme: e.attr_local("type").unwrap_or("arabicPeriod").to_string(), + start_at: e.attr_local("startAt").and_then(value::to_u32_lossy).unwrap_or(1).clamp(1, 32767), + }) + } + "buBlip" => { + if let Some(m) = e.child(Local("blip")).and_then(|b| media_ref(src, b)) { + pp.bullet = Some(Bullet::Picture { media: m }); + } + } + "buFont" => pp.bullet_font = e.attr_local("typeface").map(String::from), + "buFontTx" => pp.bullet_font_follow_text = Some(true), + "buClr" => pp.bullet_color = color(e), + "buSzPct" => { + pp.bullet_size_points = None; + pp.bullet_size_follow_text = None; + pp.bullet_size = bullet_size_percent(e.attr_local("val")).map(|v| v.clamp(0.25, 4.0)); + } + "buSzPts" => { + pp.bullet_size = None; + pp.bullet_size_follow_text = None; + pp.bullet_size_points = bullet_size_points(e.attr_local("val")); + } + "buClrTx" => pp.bullet_color_follow_text = Some(true), + "buSzTx" => { + pp.bullet_size = None; + pp.bullet_size_points = None; + pp.bullet_size_follow_text = Some(true); + } + "tabLst" => { + pp.tabs = Some( + e.children(Local("tab")) + .take(64) + .map(|t| TabStop { pos: pt(t, "pos").unwrap_or(0.0), align: t.attr_local("algn").unwrap_or("l").to_string() }) + .collect(), + ) + } + "defRPr" => def = Some(rpr(src, e)), + _ => {} + } + } + (lvl, pp, def) +} + +pub fn hyperlink(src: &mut dyn Source, h: &El) -> Option { + let rid = h.attr_local("r:id").unwrap_or(""); + let rel = src.rel(rid); + let action = h.attr_local("action").unwrap_or(""); + let a = if let Some(rest) = action.strip_prefix("ppaction://hlinkshowjump?jump=") { + match rest { + "nextslide" => Action::NextSlide, + "previousslide" => Action::PreviousSlide, + "firstslide" => Action::FirstSlide, + "lastslide" => Action::LastSlide, + "endshow" => Action::EndShow, + "lastslideviewed" => Action::LastViewed, + _ => return None, + } + } else if action.starts_with("ppaction://hlinksldjump") { + let slide = rel.as_ref().and_then(|r| src.slide(&r.target))?; + Action::Slide { slide } + } else if let Some(q) = action.strip_prefix("ppaction://customshow?") { + let id = q.split('&').find_map(|kv| kv.strip_prefix("id=")).unwrap_or("0"); + let returns = !matches!(q.split('&').find_map(|kv| kv.strip_prefix("return=")), Some("false" | "0")); + Action::CustomShow { name: src.custom_show(id).unwrap_or_else(|| id.to_string()), returns } + } else if action.starts_with("ppaction://program") { + Action::Program { path: rel.map(|r| r.target).unwrap_or_default() } + } else if action.starts_with("ppaction://media") { + Action::PlayMedia + } else if action.is_empty() || action.starts_with("ppaction://hlinkfile") { + let r = rel?; + if r.target.is_empty() { + return None; + } + if !r.external + && let Some(slide) = src.slide(&r.target) + { + Action::Slide { slide } + } else { + Action::Url { url: r.target } + } + } else { + return None; + }; + Some(Hyperlink { + action: a, + tooltip: h.attr_local("tooltip").unwrap_or("").to_string(), + highlight_click: h.attr_local("highlightClick").and_then(value::to_bool).unwrap_or(false), + stop_sound: h.attr_local("endSnd").and_then(value::to_bool).unwrap_or(false), + sound: h.child(Local("snd")).and_then(|s| media_ref(src, s)), + zoom_return: None, + }) +} + +fn typeface(e: &El) -> Option { + let t = e.attr_local("typeface").filter(|t| !t.is_empty())?; + Some(t.chars().take(256).collect()) +} + +pub fn rpr(src: &mut dyn Source, r: &El) -> RunProps { + let mut p = RunProps { + lang: r.attr_local("lang").map(String::from), + alt_lang: r.attr_local("altLang").map(|lang| lang.chars().take(256).collect()), + size: r.attr_local("sz").and_then(value::to_f64).map(|s| (s / 100.0).clamp(1.0, 4000.0)), + bold: r.attr_local("b").and_then(value::to_bool), + italic: r.attr_local("i").and_then(value::to_bool), + underline: r.attr_local("u").map(String::from), + strike: r.attr_local("strike").map(|s| match s { + "sngStrike" => Strike::Single, + "dblStrike" => Strike::Double, + _ => Strike::None, + }), + baseline: pct(r, "baseline").map(|b| b.clamp(-10.0, 10.0)), + caps: r.attr_local("cap").map(|c| match c { + "small" => Caps::Small, + "all" => Caps::All, + _ => Caps::None, + }), + spacing: r.attr_local("spc").and_then(value::to_f64).map(|s| (s / 100.0).clamp(-4000.0, 4000.0)), + kern: r.attr_local("kern").and_then(value::to_f64).map(|k| (k / 100.0).clamp(0.0, 4000.0)), + no_proof: r.attr_local("noProof").and_then(value::to_bool), + normalize_height: r.attr_local("normalizeH").and_then(value::to_bool), + ..Default::default() + }; + for e in r.elements() { + match e.local() { + "ln" => p.outline = Some(line(src, e)), + n if FILL_NAMES.contains(&n) => p.fill = fill_el(src, e), + "effectLst" => { + let fx = effects(e); + p.shadow = fx.outer_shadow; + p.inner_shadow = fx.inner_shadow; + p.glow = fx.glow; + p.reflection = fx.reflection; + p.soft_edge = fx.soft_edge; + } + "highlight" => p.highlight = color(e), + "uFill" => p.underline_color = e.child(Local("solidFill")).and_then(color), + "latin" => p.font = typeface(e), + "ea" => p.font_ea = typeface(e), + "cs" => p.font_cs = typeface(e), + "sym" => p.font_sym = typeface(e), + "hlinkClick" => p.link = hyperlink(src, e), + "hlinkMouseOver" => p.hover = hyperlink(src, e), + _ => {} + } + } + p +} + +/// `a:lstStyle`, `p:titleStyle`, `a:defaultTextStyle`… → list style. +pub fn list_style(src: &mut dyn Source, ls: &El) -> ListStyle { + let mut out = ListStyle::default(); + for e in ls.elements() { + let n = e.local(); + let Some(l) = n.strip_prefix("lvl").and_then(|r| r.strip_suffix("pPr")).and_then(|d| d.parse::().ok()) else { continue }; + if !(1..=9).contains(&l) { + continue; + } + let (_, para, run) = ppr(src, e); + out.set(l - 1, LevelStyle { para, run: run.unwrap_or_default() }); + } + out +} + +pub fn text_body(src: &mut dyn Source, tx: &El) -> TextBody { + let mut body = tx.child(Local("bodyPr")).map(body_pr).unwrap_or_default(); + if let Some(b) = tx.child(Local("bodyPr")) { + let mut raw3d = String::new(); + if let Some(sc) = b.child(Local("scene3d")) { + raw3d.push_str(&src.keep_inner(sc)); + } + if let Some(sp) = b.child(Local("sp3d")).or_else(|| b.child(Local("flatTx"))) { + raw3d.push_str(&src.keep_inner(sp)); + } + if !raw3d.is_empty() && raw3d.len() <= 64 * 1024 { + body.raw3d = Some(raw3d); + } + } + let list_style = tx.child(Local("lstStyle")).map(|l| list_style(src, l)).unwrap_or_default(); + let paragraphs = tx.children(Local("p")).take(100_000).map(|p| paragraph(src, p)).collect(); + TextBody { body, list_style, paragraphs } +} + +const MAX_PARAGRAPH_RUNS: usize = 10_000; + +/// The run properties of `e`'s `rPr` child, or the defaults. +fn run_props(src: &mut dyn Source, e: &El) -> RunProps { + e.child(Local("rPr")).map(|r| rpr(src, r)).unwrap_or_default() +} + +/// Appends the runs of an `a:r` text, where a vertical tab or line feed is a line break, as it +/// lays out and as export writes it. +fn push_text_runs(para: &mut Paragraph, props: RunProps, text: &str) { + for (i, piece) in text.split(['\u{b}', '\n']).enumerate() { + if para.runs.len() >= MAX_PARAGRAPH_RUNS { + break; + } + if i > 0 { + para.runs.push(Run { text: String::new(), props: props.clone(), kind: RunKind::Break }); + } + if !piece.is_empty() { + if para.runs.len() >= MAX_PARAGRAPH_RUNS { + break; + } + para.runs.push(Run { text: piece.to_string(), props: props.clone(), kind: RunKind::Text }); + } + } +} + +/// Appends an Office Math (a14:m) run: its plain text and its OMML. +fn push_math_run(para: &mut Paragraph, src: &dyn Source, e: &El) { + if para.runs.len() < MAX_PARAGRAPH_RUNS { + // The text of the outermost `t` elements: a `t` nested in another is already part of + // the outer one's text, and taking it again would copy it once per nesting level. + fn outer_t(e: &El, depth: usize, text: &mut String) { + if depth > crate::xmlx::MAX_DEPTH { + return; + } + for c in e.elements() { + if c.is(Local("t")) { + text.push_str(&c.deep_text()); + } else { + outer_t(c, depth + 1, text); + } + } + } + let mut text = String::new(); + outer_t(e, 0, &mut text); + let omml = e.elements().map(|c| src.keep_inner(c)).collect::(); + para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } }); + } +} + +pub fn paragraph(src: &mut dyn Source, p: &El) -> Paragraph { + let mut para = Paragraph::default(); + for e in p.elements() { + match e.local() { + "pPr" => { + let (lvl, pp, def) = ppr(src, e); + para.level = lvl.unwrap_or(0); + para.props = pp; + if let Some(d) = def { + para.def_props = d; + } + } + "r" => { + let props = run_props(src, e); + let text: String = e.child(Local("t")).map(|t| t.deep_text()).unwrap_or_default(); + push_text_runs(&mut para, props, &text); + } + "br" if para.runs.len() < MAX_PARAGRAPH_RUNS => { + let props = run_props(src, e); + para.runs.push(Run { text: String::new(), props, kind: RunKind::Break }); + } + "fld" if para.runs.len() < MAX_PARAGRAPH_RUNS => { + let props = run_props(src, e); + let text = e.child(Local("t")).map(|t| t.deep_text()).unwrap_or_default(); + para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr_local("type").unwrap_or("").to_string() } }); + } + "m" => push_math_run(&mut para, &*src, e), + "endParaRPr" => para.end_props = rpr(src, e), + _ => {} + } + } + para +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::xmlx::parse; + + #[test] + fn nested_math_text_is_taken_once() { + // 200 `m:t` nested in each other around one word: the word, not 200 copies of it. + let x = format!(r#"{}word{}"#, "".repeat(200), "".repeat(200)); + let d = parse(x.as_bytes()).unwrap(); + let mut para = Paragraph::default(); + push_math_run(&mut para, &crate::part::NoSource, &d); + assert_eq!(para.runs[0].text, "word"); + } + + #[test] + fn colors_and_transforms() { + let d = parse( + br#""#, + ) + .unwrap(); + let c = color(&d).unwrap(); + assert_eq!(c.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 }); + assert_eq!(c.mods, vec![ColorTransform::LumMod(75000), ColorTransform::Alpha(50000)]); + let d = parse(br#""#).unwrap(); + assert!(matches!(color(&d).unwrap().base, ColorBase::System { last: Rgba::WHITE, .. })); + let d = parse(br#""#).unwrap(); + assert_eq!(color(&d).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) }); + } + + #[test] + fn bullet_point_size_trims_schema_whitespace() { + let d = parse(br#""#).unwrap(); + assert_eq!(bullet_size_points(d.attr_local("val")), Some(14.0)); + assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); + } + + #[test] + fn bullet_percentage_size_accepts_numeric_and_percent_forms() { + assert_eq!(bullet_size_percent(Some("100000")), Some(1.0)); + assert_eq!(bullet_size_percent(Some("100%")), Some(1.0)); + assert_eq!(bullet_size_percent(Some(" 125% ")), Some(1.25)); + assert_eq!(bullet_size_percent(Some("invalid")), None); + } + + #[test] + fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() { + assert_eq!(parse_miter_limit("200000"), Some(200_000)); + assert_eq!(parse_miter_limit("200%"), Some(200_000)); + assert_eq!(parse_miter_limit("1000001"), Some(1_000_001)); + assert_eq!(parse_miter_limit("malformed"), None); + assert_eq!(parse_miter_limit("2147483648"), None); + assert_eq!(parse_miter_limit("2147483.648%"), None); + } + + #[test] + fn body_props() { + let d = parse(br#""#).unwrap(); + let b = body_pr(&d); + assert_eq!(b.inset_l, Some(7.2)); + assert_eq!(b.anchor, Some(Anchor::Middle)); + assert_eq!(b.wrap, Some(false)); + assert_eq!(b.vert, Some(TextDir::Vertical270)); + assert_eq!(b.columns, Some(2)); + assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 })); + assert_eq!(b.space_first_last, None); + // A bare `` keeps no scale: the schema default 100 % is drawn as it is, + // not the same as a saved 100 % (`fontScale="100000"`) (#724). + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d).autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d).autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })); + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d).space_first_last, Some(true)); + // mongolianVert is its own direction (PowerPoint runs its lines left to right) and is + // written back as it was read. + let d = parse(br#""#).unwrap(); + let b = body_pr(&d); + assert_eq!(b.vert, Some(TextDir::MongolianVertical)); + assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert")); + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d).vert.map(|v| v.xml()), Some("wordArtVertRtl")); + } +} diff --git a/crates/drawingml/src/table.rs b/crates/drawingml/src/table.rs index 7772565e..e5fbe1f9 100644 --- a/crates/drawingml/src/table.rs +++ b/crates/drawingml/src/table.rs @@ -647,3 +647,5 @@ mod tests { assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((1, 1))); } } + +pub mod styles; diff --git a/crates/drawingml/src/table/styles.rs b/crates/drawingml/src/table/styles.rs new file mode 100644 index 00000000..cae3e0dd --- /dev/null +++ b/crates/drawingml/src/table/styles.rs @@ -0,0 +1,288 @@ +//! Table styles: our style ids ↔ table style GUIDs, and `tableStyles.xml` definitions for the +//! styles we write (generated from theme accents, matching how they are drawn). + +use crate::ns::NS_A; +use craft_xml::{Local, Writer}; + +/// 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 { + // The seed is part of the format: files written earlier carry GUIDs made from it. + guid(&format!("deckcraft-table-style:{style}")) +} + +/// Is `g` the GUID [`guid_for_style`] makes for one of the styles in [`crate::table::table_styles`]? +pub fn is_own_guid(g: &str) -> bool { + let up = g.to_ascii_uppercase(); + crate::table::table_styles().iter().any(|(id, _)| own_guid(id) == up) +} + +/// 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 crate::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 = crate::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 Writer, slot: &str, mods: &[(&str, i32)]) { + if mods.is_empty() { + w.tag("a:schemeClr").attr("val", slot).empty(); + return; + } + w.tag("a:schemeClr").attr("val", slot).open(); + for (n, v) in mods { + w.tag(n).attr("val", v).empty(); + } + w.close(); +} + +fn solid(w: &mut Writer, slot: &str, mods: &[(&str, i32)]) { + w.open("a:fill", &[]); + w.open("a:solidFill", &[]); + clr(w, slot, mods); + w.close(); + w.close(); +} + +/// A border: scheme colour, its transforms, width in EMU. +type Border<'a> = (&'a str, &'a [(&'a str, i32)], i32); + +fn borders(w: &mut Writer, b: Option>) { + w.open("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.open(side, &[]); + w.tag("a:ln").attr("w", width).attr("cmpd", "sng").open(); + w.open("a:solidFill", &[]); + clr(w, slot, mods); + w.close(); + w.close(); + w.close(); + } + } + w.close(); +} + +fn txt(w: &mut Writer, bold: bool, color: Option<&str>) { + w.tag("a:tcTxStyle").attr_opt("b", bold.then_some("on")).open(); + w.tag("a:fontRef").attr("idx", "minor").open(); + w.tag("a:prstClr").attr("val", "black").empty(); + w.close(); + match color { + Some(c) => w.tag("a:schemeClr").attr("val", c).empty(), + None => w.tag("a:schemeClr").attr("val", "dk1").empty(), + } + w.close(); +} + +/// Write one `a:tblStyle` for our style id. +fn style_def(w: &mut Writer, 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.tag("a:tblStyle").attr("styleId", own_guid(id)).attr("styleName", name).open(); + let part = |w: &mut Writer, tag: &str, bold: bool, text: Option<&str>, fill: Option<(&str, &[(&str, i32)])>, bdr: Option>| { + w.open(tag, &[]); + if bold || text.is_some() { + txt(w, bold, text); + } + w.open("a:tcStyle", &[]); + borders(w, bdr); + if let Some((s, m)) = fill { + solid(w, s, m); + } + w.close(); + w.close(); + }; + 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(); +} + +/// `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. A generated style is named +/// `"{name_prefix} {label}"` (`"Acme Medium 2 Accent 1"`). +pub fn table_styles_xml(used: &[String], defs: &[crate::table::TableStyleDef], name_prefix: &str) -> Vec { + let mut w = Writer::document(); + w.tag("a:tblStyleLst").attr("xmlns:a", NS_A).attr("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}").open(); + let names = crate::table::table_styles(); + // Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's + // definitions by lower-case id (the first wins). + let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut done_lower: std::collections::HashSet = std::collections::HashSet::new(); + let mut def_of: std::collections::HashMap = std::collections::HashMap::new(); + for d in defs { + def_of.entry(d.id.to_ascii_lowercase()).or_insert(d); + } + for s in used { + if !done.insert(s.as_str()) { + continue; + } + done_lower.insert(s.to_ascii_lowercase()); + if let Some(d) = def_of.get(&s.to_ascii_lowercase()) + && crate::xmlx::parse(d.raw.as_bytes()).is_ok_and(|x| x.is(Local("tblStyle"))) + { + w.raw(&d.raw); + continue; + } + let (_, define) = guid_for_style(s); + if !define { + continue; + } + let name = names.iter().find(|(id, _)| id == s).map(|(_, n)| format!("{name_prefix} {n}")).unwrap_or_else(|| s.clone()); + style_def(&mut w, s, &name); + } + // Styles the file defined but no table uses still belong to the deck. + for d in defs { + if done_lower.contains(&d.id.to_ascii_lowercase()) { + continue; + } + if crate::xmlx::parse(d.raw.as_bytes()).is_ok_and(|x| x.is(Local("tblStyle"))) { + done_lower.insert(d.id.to_ascii_lowercase()); + w.raw(&d.raw); + } + } + w.close(); + w.into_bytes() +} + +#[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 = crate::table::table_styles().into_iter().map(|(id, _)| id).collect(); + let d = crate::xmlx::parse(&table_styles_xml(&used, &[], "Test")).unwrap(); + let mut n = 0; + for st in d.children(Local("tblStyle")) { + n += 1; + let pos: Vec = 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_local("styleName")); + } + assert!(n > 30); + } + + #[test] + fn guids_round_trip() { + for (id, _) in crate::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}"); + } +} diff --git a/crates/drawingml/src/theme.rs b/crates/drawingml/src/theme.rs index d4416e2f..79c99277 100644 --- a/crates/drawingml/src/theme.rs +++ b/crates/drawingml/src/theme.rs @@ -181,3 +181,8 @@ mod tests { assert_eq!(partial.name, "X"); } } + +mod read; +mod write; +pub use read::read_theme; +pub use write::theme_xml; diff --git a/crates/drawingml/src/theme/read.rs b/crates/drawingml/src/theme/read.rs new file mode 100644 index 00000000..74e34f22 --- /dev/null +++ b/crates/drawingml/src/theme/read.rs @@ -0,0 +1,109 @@ +//! Theme parts (`a:theme`). + +use crate::color::{ColorScheme, Rgba, SchemeSlot}; +use crate::style::{ColorBase, Effects}; +use crate::theme::{FontScheme, FontSet, FormatScheme, ScriptFont, Theme, default_format}; + +use craft_xml::{El, Local}; + +use crate::part::Source; +use crate::read as dml; + +fn font_set(e: Option<&El>) -> FontSet { + let tf = |n: &str| e.and_then(|e| e.child(Local(n))).and_then(|c| c.attr_local("typeface")).unwrap_or("").to_string(); + let scripts = e + .map(|e| { + e.children(Local("font")) + .filter_map(|f| { + Some(ScriptFont { script: f.attr_local("script")?.to_string(), typeface: f.attr_local("typeface").unwrap_or("").to_string() }) + }) + .take(256) + .collect() + }) + .unwrap_or_default(); + FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts } +} + +pub fn read_theme(src: &mut dyn Source, root: &El) -> Theme { + let mut t = Theme { name: root.attr_local("name").unwrap_or("Theme").to_string(), ..Default::default() }; + let Some(te) = root.child(Local("themeElements")) else { return t }; + if let Some(cs) = te.child(Local("clrScheme")) { + let mut scheme = ColorScheme { name: cs.attr_local("name").unwrap_or("").to_string(), colors: t.colors.colors }; + for slot in SchemeSlot::THEME { + if let Some(c) = cs.child(Local(slot.xml_name())).and_then(dml::color) { + let rgb = match c.base { + ColorBase::Rgb { rgb } => rgb, + ColorBase::System { last, .. } => last, + ColorBase::Preset { name } => crate::color::preset(&name).unwrap_or(Rgba::BLACK), + ColorBase::Scheme { .. } => continue, + }; + scheme.set(slot, rgb); + } + } + t.colors = scheme; + } + if let Some(fs) = te.child(Local("fontScheme")) { + t.fonts = FontScheme { + name: fs.attr_local("name").unwrap_or("").to_string(), + major: font_set(fs.child(Local("majorFont"))), + minor: font_set(fs.child(Local("minorFont"))), + }; + } + if let Some(fm) = te.child(Local("fmtScheme")) { + let dflt = default_format(); + let mut f = FormatScheme { name: fm.attr_local("name").unwrap_or("").to_string(), ..Default::default() }; + if let Some(l) = fm.child(Local("fillStyleLst")) { + f.fills = l.elements().take(16).filter_map(|e| dml::fill_el(src, e)).collect(); + } + if let Some(l) = fm.child(Local("lnStyleLst")) { + f.lines = l.children(Local("ln")).take(16).map(|e| dml::line(src, e)).collect(); + } + if let Some(l) = fm.child(Local("effectStyleLst")) { + f.effects = l + .children(Local("effectStyle")) + .take(16) + .map(|e| { + let mut fx = e.child(Local("effectLst")).map(dml::effects).unwrap_or_default(); + if let Some(b) = e.path(&[Local("sp3d"), Local("bevelT")]) { + fx.bevel = Some(b.attr_local("prst").unwrap_or("circle").to_string()); + } + let raw: String = e.elements().filter(|c| c.is(Local("scene3d")) || c.is(Local("sp3d"))).map(|c| src.keep(c)).collect(); + if !raw.is_empty() { + fx.raw3d = Some(raw); + } + fx + }) + .collect(); + } + if let Some(l) = fm.child(Local("bgFillStyleLst")) { + f.bg_fills = l.elements().take(16).filter_map(|e| dml::fill_el(src, e)).collect(); + } + // The style matrix needs three entries of each kind. + pad(&mut f.fills, &dflt.fills); + pad(&mut f.lines, &dflt.lines); + pad(&mut f.bg_fills, &dflt.bg_fills); + while f.effects.len() < 3 { + f.effects.push(Effects::default()); + } + t.format = f; + } + let extra: String = root + .elements() + .filter(|e| e.is(Local("objectDefaults")) || e.is(Local("extraClrSchemeLst"))) + .filter(|e| !crate::xmlx::has_rel_refs(e)) + .map(|e| src.keep(e)) + .collect(); + if !extra.is_empty() { + t.raw_extra = Some(extra); + } + t +} + +fn pad(v: &mut Vec, d: &[T]) { + while v.len() < 3 { + match d.get(v.len()) { + Some(x) => v.push(x.clone()), + None => break, + } + } +} diff --git a/crates/drawingml/src/theme/write.rs b/crates/drawingml/src/theme/write.rs new file mode 100644 index 00000000..f6bb33b5 --- /dev/null +++ b/crates/drawingml/src/theme/write.rs @@ -0,0 +1,114 @@ +//! Theme parts. + +use crate::color::SchemeSlot; +use crate::style::{ColorRef, Effects, Fill}; +use crate::theme::{FontSet, Theme, default_format}; + +use craft_xml::Writer; + +use crate::ns::{NS_A, NS_R}; +use crate::part::Sink; +use crate::write as dml; + +fn font_set(w: &mut Writer, tag: &str, f: &FontSet) { + w.open(tag, &[]); + w.tag("a:latin").attr("typeface", if f.latin.is_empty() { "Arial" } else { &f.latin }).empty(); + w.tag("a:ea").attr("typeface", &f.ea).empty(); + w.tag("a:cs").attr("typeface", &f.cs).empty(); + for sf in &f.scripts { + w.tag("a:font").attr("script", &sf.script).attr("typeface", &sf.typeface).empty(); + } + w.close(); +} + +fn style_fill(f: &Fill) -> Fill { + match f { + Fill::Background | Fill::Group => Fill::solid(ColorRef::scheme(SchemeSlot::PhClr)), + f => f.clone(), + } +} + +pub fn theme_xml(out: &mut dyn Sink, t: &Theme) -> Vec { + let mut w = Writer::document(); + w.tag("a:theme").attr("xmlns:a", NS_A).attr("xmlns:r", NS_R).attr("name", if t.name.is_empty() { "Theme" } else { &t.name }).open(); + w.open("a:themeElements", &[]); + w.tag("a:clrScheme").attr("name", if t.colors.name.is_empty() { &t.name } else { &t.colors.name }).open(); + for slot in SchemeSlot::THEME { + let tag = format!("a:{}", slot.xml_name()); + w.open(&tag, &[]); + w.tag("a:srgbClr").attr("val", t.colors.get(slot).hex()).empty(); + w.close(); + } + w.close(); + w.tag("a:fontScheme").attr("name", if t.fonts.name.is_empty() { &t.name } else { &t.fonts.name }).open(); + font_set(&mut w, "a:majorFont", &t.fonts.major); + font_set(&mut w, "a:minorFont", &t.fonts.minor); + w.close(); + let d = default_format(); + let f = &t.format; + w.tag("a:fmtScheme").attr("name", if f.name.is_empty() { &t.name } else { &f.name }).open(); + let three = |v: &[Fill], d: &[Fill]| -> Vec { + let mut out: Vec = v.iter().map(style_fill).collect(); + while out.len() < 3 { + out.push(d.get(out.len()).cloned().unwrap_or(Fill::solid(ColorRef::scheme(SchemeSlot::PhClr)))); + } + out + }; + w.open("a:fillStyleLst", &[]); + for fl in three(&f.fills, &d.fills) { + dml::fill(&mut w, out, &fl); + } + w.close(); + w.open("a:lnStyleLst", &[]); + let mut lines = f.lines.clone(); + while lines.len() < 3 { + lines.push(d.lines.get(lines.len()).cloned().unwrap_or_default()); + } + for l in &lines { + let mut l = l.clone(); + if l.fill.is_none() { + l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::PhClr))); + } + if let Some(fl) = &l.fill { + l.fill = Some(style_fill(fl)); + } + dml::line(&mut w, out, "a:ln", &l); + } + w.close(); + w.open("a:effectStyleLst", &[]); + let mut fx = f.effects.clone(); + while fx.len() < 3 { + fx.push(Effects::default()); + } + for e in &fx { + w.open("a:effectStyle", &[]); + dml::effects(&mut w, e); + if let Some(raw) = &e.raw3d { + w.raw(raw); + } else if let Some(xml) = e.bevel.as_deref().and_then(dml::bevel_sp3d) { + w.raw(&xml); + } + w.close(); + } + w.close(); + w.open("a:bgFillStyleLst", &[]); + for fl in three(&f.bg_fills, &d.bg_fills) { + dml::fill(&mut w, out, &fl); + } + w.close(); + w.close(); + w.close(); + match &t.raw_extra { + Some(raw) if raw.contains("objectDefaults") => w.raw(raw), + Some(raw) => { + w.empty("a:objectDefaults", &[]); + w.raw(raw); + } + None => { + w.empty("a:objectDefaults", &[]); + w.empty("a:extraClrSchemeLst", &[]); + } + } + w.close(); + w.into_bytes() +} diff --git a/crates/drawingml/src/write.rs b/crates/drawingml/src/write.rs new file mode 100644 index 00000000..8acd5d22 --- /dev/null +++ b/crates/drawingml/src/write.rs @@ -0,0 +1,878 @@ +//! DrawingML writing: colours, fills, lines, effects, transforms and text. + +use crate::style::{ + ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientFlip, GradientShape, Line, LineAlign, LineCap, LineJoin, PictureFill, PictureMode, +}; +use crate::text::{ + Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody, +}; +use crate::{Xfrm, pt_to_emu}; + +use craft_xml::Writer; + +use crate::part::Sink; +use crate::xmlx::{A, TagExt}; + +pub fn emu(pt: f64) -> i64 { + pt_to_emu(pt).clamp(-27_273_042_316_900, 27_273_042_316_900) +} + +/// Positive coordinate extent (ST_PositiveCoordinate). +pub fn emu_pos(pt: f64) -> i64 { + emu(pt).max(0) +} + +/// Fraction → 1/100 000. +pub fn pct(v: f64) -> i64 { + if v.is_finite() { (v * 100_000.0).round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 } +} + +/// Degrees → 1/60 000 degree, normalised to 0..360. +pub fn ang(deg: f64) -> i64 { + if !deg.is_finite() { + return 0; + } + let d = deg.rem_euclid(360.0); + ((d * 60_000.0).round() as i64).clamp(0, 21_599_999) +} + +pub fn color(w: &mut Writer, c: &ColorRef) { + let (tag, a) = match &c.base { + ColorBase::Rgb { rgb } => ("a:srgbClr", A::new().a("val", rgb.hex())), + ColorBase::Scheme { slot } => ("a:schemeClr", A::new().a("val", slot.xml_name())), + // The reader keeps any name a file has; only names the schema allows are written. + ColorBase::Preset { name } => ("a:prstClr", A::new().a("val", if crate::color::is_preset_name(name) { name } else { "black" })), + ColorBase::System { name, last } => { + ("a:sysClr", A::new().a("val", if crate::color::is_system_name(name) { name } else { "windowText" }).a("lastClr", last.hex())) + } + }; + let rgb_alpha = match &c.base { + ColorBase::Rgb { rgb } if rgb.a < 255 && !c.mods.iter().any(|m| matches!(m, crate::color::ColorTransform::Alpha(_))) => { + Some((rgb.a as i64 * 100_000 / 255) as i32) + } + _ => None, + }; + if c.mods.is_empty() && rgb_alpha.is_none() { + w.tag(tag).list(a).empty(); + return; + } + w.tag(tag).list(a).open(); + for m in &c.mods { + let name = format!("a:{}", m.xml_name()); + match m.value() { + Some(v) => w.tag(&name).attr("val", v).empty(), + None => w.empty(&name, &[]), + } + } + if let Some(al) = rgb_alpha { + w.tag("a:alpha").attr("val", al).empty(); + } + w.close(); +} + +pub fn solid(w: &mut Writer, c: &ColorRef) { + w.open("a:solidFill", &[]); + color(w, c); + w.close(); +} + +/// Write a fill. `Fill::Background` (only meaningful on `p:sp`) writes nothing. +pub fn fill(w: &mut Writer, out: &mut dyn Sink, f: &Fill) { + match f { + Fill::None => w.empty("a:noFill", &[]), + Fill::Solid { color: c } => solid(w, c), + Fill::Gradient(g) => gradient(w, g), + Fill::Picture(pf) => blip_fill(w, out, "a:blipFill", pf), + Fill::Pattern(p) => { + w.tag("a:pattFill").attr("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }).open(); + w.open("a:fgClr", &[]); + color(w, &p.fg); + w.close(); + w.open("a:bgClr", &[]); + color(w, &p.bg); + w.close(); + w.close(); + } + Fill::Group => w.empty("a:grpFill", &[]), + Fill::Background => {} + } +} + +/// `a:blipFill` / `p:blipFill`. +pub fn blip_fill(w: &mut Writer, out: &mut dyn Sink, tag: &str, pf: &PictureFill) { + // An image that lives outside the package is a linked picture: `a:blip` names it through + // `r:link`; `r:embed` may only reference a part inside the package. + let linked = out.media_linked(pf.media); + let rid = out.media_rel(pf.media, "image").unwrap_or_else(|| out.placeholder_rel()); + w.tag(tag).attr("rotWithShape", if pf.upright { "0" } else { "1" }).open(); + let adj = &pf.adjust; + let has_kids = pf.alpha.is_some() + || adj.brightness != 0.0 + || adj.contrast != 0.0 + || adj.grayscale + || adj.duotone.is_some() + || adj.clear_color.is_some() + || adj.bilevel.is_some() + || adj.alpha_inv + || pf.artistic.is_some(); + let a = if linked { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) }; + if has_kids { + w.tag("a:blip").list(a).open(); + if let Some(c) = adj.clear_color { + w.open("a:clrChange", &[]); + w.open("a:clrFrom", &[]); + color(w, &ColorRef::rgb(c)); + w.close(); + w.open("a:clrTo", &[]); + color(w, &ColorRef::rgb(c).with(crate::color::ColorTransform::Alpha(0))); + w.close(); + w.close(); + } + if let Some(al) = pf.alpha { + w.tag("a:alphaModFix").attr("amt", pct(al.clamp(0.0, 1.0))).empty(); + } + if adj.alpha_inv { + w.empty("a:alphaInv", &[]); + } + if let Some(t) = adj.bilevel { + w.tag("a:biLevel").attr("thresh", pct(t.clamp(0.0, 1.0))).empty(); + } + if let Some((d, l)) = &adj.duotone { + w.open("a:duotone", &[]); + color(w, d); + color(w, l); + w.close(); + } + if adj.grayscale { + w.empty("a:grayscl", &[]); + } + if adj.brightness != 0.0 || adj.contrast != 0.0 { + w.tag("a:lum").attr("bright", pct(adj.brightness.clamp(-1.0, 1.0))).attr("contrast", pct(adj.contrast.clamp(-1.0, 1.0))).empty(); + } + // Artistic effect: the blip is the processed picture; the original rides in an + // `a14:imgLayer` with the effect's name, as PowerPoint writes it. + if let Some(art) = &pf.artistic + && art.effect.starts_with("artistic") + && art.effect.chars().all(|c| c.is_ascii_alphanumeric()) + && let Some(orig) = out.media_rel(art.original, "image") + { + w.open("a:extLst", &[]); + w.tag("a:ext").attr("uri", "{BEBA8EAE-BF5A-486C-A8C5-ECC9F3942E4B}").open(); + w.tag("a14:imgProps").attr("xmlns:a14", crate::ns::NS_A14).open(); + w.tag("a14:imgLayer").attr("r:embed", &orig).open(); + w.open("a14:imgEffect", &[]); + w.empty(&format!("a14:{}", art.effect), &[]); + w.close(); + w.close(); + w.close(); + w.close(); + w.close(); + } + w.close(); + } else { + w.tag("a:blip").list(a).empty(); + } + let c = pf.crop; + if c.iter().any(|v| *v != 0.0) { + w.tag("a:srcRect").attr("l", pct(c[0])).attr("t", pct(c[1])).attr("r", pct(c[2])).attr("b", pct(c[3])).empty(); + } + match &pf.mode { + PictureMode::Stretch { fill_rect: r } => { + w.open("a:stretch", &[]); + if r.iter().any(|v| *v != 0.0) { + w.tag("a:fillRect").attr("l", pct(r[0])).attr("t", pct(r[1])).attr("r", pct(r[2])).attr("b", pct(r[3])).empty(); + } else { + w.empty("a:fillRect", &[]); + } + w.close(); + } + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + let flip = match flip.as_str() { + "x" | "y" | "xy" => flip.as_str(), + _ => "none", + }; + let algn = match align.as_str() { + "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br" => align.as_str(), + _ => "tl", + }; + w.tag("a:tile") + .attr("tx", emu(*tx)) + .attr("ty", emu(*ty)) + .attr("sx", pct(*sx)) + .attr("sy", pct(*sy)) + .attr("flip", flip) + .attr("algn", algn) + .empty(); + } + } + w.close(); +} + +pub fn line(w: &mut Writer, out: &mut dyn Sink, tag: &str, l: &Line) { + let a = A::new() + .o("w", l.width.map(|v| emu(v.clamp(0.0, 1584.0)))) + .o( + "cap", + l.cap.map(|c| match c { + LineCap::Round => "rnd", + LineCap::Square => "sq", + LineCap::Flat => "flat", + }), + ) + .o("algn", l.align.map(LineAlign::xml)) + .o("cmpd", l.compound.map(Compound::xml)); + w.tag(tag).list(a).open(); + if let Some(f) = &l.fill { + match f { + Fill::None | Fill::Solid { .. } | Fill::Gradient(_) | Fill::Pattern(_) => fill(w, out, f), + _ => {} + } + } + match &l.dash { + Some(Dash::Preset { name }) => { + let n = if Dash::PRESETS.contains(&name.as_str()) { name.as_str() } else { "solid" }; + w.tag("a:prstDash").attr("val", n).empty(); + } + Some(Dash::Custom { pattern }) if !pattern.is_empty() => { + w.open("a:custDash", &[]); + for (d, s) in pattern.iter().take(64) { + w.tag("a:ds").attr("d", pct(*d).max(0)).attr("sp", pct(*s).max(0)).empty(); + } + w.close(); + } + _ => {} + } + match l.join { + Some(LineJoin::Round) => w.empty("a:round", &[]), + Some(LineJoin::Bevel) => w.empty("a:bevel", &[]), + Some(LineJoin::Miter) => { + let limit = l.miter_limit.filter(|limit| *limit <= i32::MAX as u32); + w.tag("a:miter").attr_opt("lim", limit).empty(); + } + None => {} + } + for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] { + if let Some(e) = e { + let kind = match e.kind.as_str() { + "triangle" | "stealth" | "diamond" | "oval" | "arrow" => e.kind.as_str(), + _ => "none", + }; + let sz = |s: &str| match s { + "sm" | "lg" => s.to_string(), + _ => "med".to_string(), + }; + w.tag(t).attr("type", kind).attr("w", sz(&e.w)).attr("len", sz(&e.len)).empty(); + } + } + w.close(); +} + +fn shadow(w: &mut Writer, tag: &str, s: &crate::style::Shadow) { + let mut a = A::new().a("blurRad", emu(s.blur).max(0)).a("dist", emu(s.dist).max(0)).a("dir", ang(s.dir)); + if tag == "a:outerShdw" { + if s.sx != 1.0 { + a = a.a("sx", pct(s.sx)); + } + if s.sy != 1.0 { + a = a.a("sy", pct(s.sy)); + } + if s.kx != 0.0 { + a = a.a("kx", (s.kx.clamp(-89.0, 89.0) * 60_000.0).round() as i64); + } + if s.ky != 0.0 { + a = a.a("ky", (s.ky.clamp(-89.0, 89.0) * 60_000.0).round() as i64); + } + let algn = match s.align.as_str() { + "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br" => s.align.as_str(), + _ => "b", + }; + a = a.a("algn", algn).a("rotWithShape", if s.rotate_with_shape { "1" } else { "0" }); + } + w.tag(tag).list(a).open(); + color(w, &s.color); + w.close(); +} + +fn gradient(w: &mut Writer, g: &crate::style::Gradient) { + w.tag("a:gradFill").attr_opt("flip", g.flip.map(GradientFlip::xml)).attr("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }).open(); + // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. + let mut stops = g.stops.clone(); + if stops.len() == 1 { + let mut s = stops[0].clone(); + s.pos = 1.0; + stops.push(s); + } + if !stops.is_empty() { + w.open("a:gsLst", &[]); + for s in &stops { + w.tag("a:gs").attr("pos", pct(s.pos.clamp(0.0, 1.0))).open(); + color(w, &s.color); + w.close(); + } + w.close(); + } + match &g.shape { + GradientShape::Linear { angle, scaled } => w.tag("a:lin").attr("ang", ang(*angle)).attr("scaled", if *scaled { "1" } else { "0" }).empty(), + GradientShape::Path { path, focus } => { + let p = match path.as_str() { + "rect" | "shape" | "circle" => path.as_str(), + _ => "circle", + }; + w.tag("a:path").attr("path", p).open(); + w.tag("a:fillToRect").attr("l", pct(focus[0])).attr("t", pct(focus[1])).attr("r", pct(focus[2])).attr("b", pct(focus[3])).empty(); + w.close(); + } + } + if g.tile.iter().any(|v| *v != 0.0) { + let [l, t, r, b] = g.tile; + w.tag("a:tileRect").attr("l", pct(l)).attr("t", pct(t)).attr("r", pct(r)).attr("b", pct(b)).empty(); + } + w.close(); +} + +pub fn effects(w: &mut Writer, fx: &Effects) { + w.open("a:effectLst", &[]); + // Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge. + if let Some(ov) = &fx.fill_overlay { + let blend = match ov.blend.as_str() { + b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b, + _ => "over", + }; + w.tag("a:fillOverlay").attr("blend", blend).open(); + match &ov.fill { + Fill::Solid { color: c } => solid(w, c), + Fill::Gradient(g) => gradient(w, g), + _ => w.empty("a:noFill", &[]), + } + w.close(); + } + if let Some(g) = &fx.glow { + w.tag("a:glow").attr("rad", emu(g.radius).max(0)).open(); + color(w, &g.color); + w.close(); + } + if let Some(s) = &fx.inner_shadow { + shadow(w, "a:innerShdw", s); + } + if let Some(s) = &fx.outer_shadow { + shadow(w, "a:outerShdw", s); + } + if let Some(r) = &fx.reflection { + w.tag("a:reflection") + .list( + A::new() + .a("blurRad", emu(r.blur).max(0)) + .a("stA", pct(r.start_alpha.clamp(0.0, 1.0))) + .a("endA", pct(r.end_alpha.clamp(0.0, 1.0))) + .a("endPos", pct(r.end_pos.clamp(0.0, 1.0))) + .a("dist", emu(r.dist).max(0)) + // Kept from a file as read: held to the schema's ranges so a stray value can't + // make PowerPoint repair the file (ST_PositiveFixedAngle, ST_FixedAngle, + // ST_PositiveFixedPercentage). + .o("stPos", r.st_pos.map(|v| v.clamp(0, 100_000))) + .o("dir", r.dir.map(|v| v.rem_euclid(21_600_000))) + .o("fadeDir", r.fade_dir.map(|v| v.rem_euclid(21_600_000))) + .o("sx", r.sx) + .o("sy", r.sy) + .o("kx", r.kx.map(|v| v.clamp(-5_399_999, 5_399_999))) + .o("ky", r.ky.map(|v| v.clamp(-5_399_999, 5_399_999))) + .o("algn", r.align.as_deref()) + .b("rotWithShape", r.rotate_with_shape), + ) + .empty(); + } + if let Some(r) = fx.soft_edge { + w.tag("a:softEdge").attr("rad", emu(r).max(0)).empty(); + } + w.close(); +} + +/// `a:sp3d` for a bevel preset when the shape or theme style has no kept 3-D XML. +/// The preset name is limited to ASCII letters and digits so it cannot break the markup. +pub fn bevel_sp3d(preset: &str) -> Option { + if preset.is_empty() || preset.len() > 32 || !preset.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + Some(format!("")) +} + +pub fn xfrm(w: &mut Writer, tag: &str, x: &Xfrm, child: Option<&Xfrm>) { + let x = if x.is_finite() { *x } else { Xfrm::default() }; + let rot = ang(x.rot); + w.tag(tag) + .attr_opt("rot", (rot != 0).then_some(rot)) + .attr_opt("flipH", (x.flip_h).then_some(1)) + .attr_opt("flipV", (x.flip_v).then_some(1)) + .open(); + w.tag("a:off").attr("x", emu(x.x)).attr("y", emu(x.y)).empty(); + w.tag("a:ext").attr("cx", emu_pos(x.w)).attr("cy", emu_pos(x.h)).empty(); + if let Some(c) = child { + let c = if c.is_finite() { *c } else { Xfrm::default() }; + w.tag("a:chOff").attr("x", emu(c.x)).attr("y", emu(c.y)).empty(); + w.tag("a:chExt").attr("cx", emu_pos(c.w)).attr("cy", emu_pos(c.h)).empty(); + } + w.close(); +} + +// --------------------------------------------------------------------------------------------- +// Text + +pub fn hyperlink(w: &mut Writer, out: &mut dyn Sink, tag: &str, h: &Hyperlink) { + let (rid, action) = match &h.action { + Action::Url { url } => { + if url.is_empty() { + return; + } + (out.rel("hyperlink", url, true), None) + } + Action::Slide { slide } => match out.slide_rel(*slide) { + Some(rid) => (rid, Some("ppaction://hlinksldjump".to_string())), + None => return, + }, + Action::NextSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=nextslide".into())), + Action::PreviousSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=previousslide".into())), + Action::FirstSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=firstslide".into())), + Action::LastSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslide".into())), + Action::EndShow => (String::new(), Some("ppaction://hlinkshowjump?jump=endshow".into())), + Action::LastViewed => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslideviewed".into())), + Action::CustomShow { name, returns } => { + let id = out.custom_show_index(name).unwrap_or(0); + let ret = if *returns { "true" } else { "false" }; + (String::new(), Some(format!("ppaction://customshow?id={id}&return={ret}"))) + } + Action::Program { path } => { + if path.is_empty() { + return; + } + (out.rel("hyperlink", path, true), Some("ppaction://program".into())) + } + Action::PlayMedia => (String::new(), Some("ppaction://media".into())), + }; + let attrs = A::new() + .a("r:id", rid) + .o("action", action) + .o("tooltip", (!h.tooltip.is_empty()).then_some(h.tooltip.as_str())) + .t("highlightClick", h.highlight_click) + .t("endSnd", h.stop_sound); + let sound = h.sound.and_then(|id| out.media_rel(id, "audio").map(|rid| (rid, id))); + let Some((sound_rid, sound_id)) = sound else { + w.tag(tag).list(attrs).empty(); + return; + }; + w.tag(tag).list(attrs).open(); + let name = out.media_name(sound_id).map(|n| sound_label(&n)).unwrap_or_else(|| "sound.wav".into()); + w.tag("a:snd").attr("r:embed", sound_rid).attr("name", name).empty(); + w.close(); +} + +/// Display name PowerPoint stores on `a:snd`. +fn sound_label(name: &str) -> String { + let file = name.rsplit(['/', '\\']).next().unwrap_or(name); + let label: String = file.chars().filter(|c| c.is_ascii() && !c.is_control() && *c != '"' && *c != '<' && *c != '>').take(80).collect(); + if label.is_empty() { "sound.wav".into() } else { label } +} + +/// Validate an unmodelled 3-D fragment (`a:scene3d`, `a:sp3d`, `a:flatTx`) before writing raw XML. +pub fn valid_raw3d<'a>(raw: &'a str, allowed: &[&str]) -> Option<&'a str> { + if raw.is_empty() || raw.len() > 64 * 1024 { + return None; + } + let wrap = format!("{raw}", crate::ns::NS_A); + let doc = crate::xmlx::parse(wrap.as_bytes()).ok()?; + let els: Vec<_> = doc.elements().collect(); + (!els.is_empty() + && els.len() <= allowed.len() + && doc.children.len() == els.len() + && els.iter().all(|e| e.prefix() == "a" && allowed.contains(&e.local()) && !crate::xmlx::has_rel_refs(e))) + .then_some(raw) +} + +pub fn body_pr(w: &mut Writer, b: &BodyProps) { + let a = A::new() + .o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64)) + .o("vert", b.vert.map(|v| v.xml())) + .o("wrap", b.wrap.map(|v| if v { "square" } else { "none" })) + .o("lIns", b.inset_l.map(emu)) + .o("tIns", b.inset_t.map(emu)) + .o("rIns", b.inset_r.map(emu)) + .o("bIns", b.inset_b.map(emu)) + .o("numCol", b.columns.map(|c| c.clamp(1, 16))) + .o("spcCol", b.col_spacing.map(|v| emu(v).max(0))) + .b("rtlCol", b.rtl_col) + .o("anchor", b.anchor.map(|a| a.xml())) + .b("anchorCtr", b.anchor_ctr) + .b("upright", b.upright) + .b("spcFirstLastPara", b.space_first_last) + .b("compatLnSpc", b.compat_ln_spc) + .o("vertOverflow", b.vert_overflow.map(|v| v.xml())) + .o("horzOverflow", crate::text::TextOverflow::horz_xml(b.horz_overflow)); + let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); + let raw3d = b.raw3d.as_deref().and_then(|s| valid_raw3d(s, &["scene3d", "sp3d", "flatTx"])); + if warp.is_none() && b.autofit.is_none() && raw3d.is_none() { + w.tag("a:bodyPr").list(a).empty(); + return; + } + w.tag("a:bodyPr").list(a).open(); + if let Some(p) = warp { + w.tag("a:prstTxWarp").attr("prst", p).open(); + let adj: Vec<(usize, i64)> = + b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect(); + if adj.is_empty() { + w.empty("a:avLst", &[]); + } else { + let single = crate::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1; + w.open("a:avLst", &[]); + for (i, v) in adj { + let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) }; + w.tag("a:gd").attr("name", n).attr("fmla", format!("val {v}")).empty(); + } + w.close(); + } + w.close(); + } + match b.autofit { + Some(AutoFit::None) => w.empty("a:noAutofit", &[]), + Some(AutoFit::Shape) => w.empty("a:spAutoFit", &[]), + Some(AutoFit::Shrink { font_scale, line_reduction }) => { + // A scale the model holds is written, 100 % included: reopening then keeps the same + // "draw the saved scale" state instead of becoming a bare `` (#724). + let fs = font_scale.map(|s| pct(s.clamp(0.01, 1.0))); + let lr = pct(line_reduction.clamp(0.0, 1.0)); + w.tag("a:normAutofit").attr_opt("fontScale", fs).attr_opt("lnSpcReduction", (lr != 0).then_some(lr)).empty(); + } + None => {} + } + if let Some(raw) = raw3d { + w.raw(raw); + } + w.close(); +} + +fn spacing(w: &mut Writer, tag: &str, s: &Spacing) { + w.open(tag, &[]); + match s { + Spacing::Pct(v) => w.tag("a:spcPct").attr("val", pct(v.clamp(0.0, 132.0))).empty(), + Spacing::Pts(v) => w.tag("a:spcPts").attr("val", (v.clamp(0.0, 1584.0) * 100.0).round() as i64).empty(), + } + w.close(); +} + +pub fn is_default_ppr(p: &ParaProps) -> bool { + *p == ParaProps::default() +} + +/// `a:pPr` / `a:lvlNpPr`. +pub fn ppr(w: &mut Writer, out: &mut dyn Sink, tag: &str, level: Option, p: &ParaProps, def: Option<&RunProps>) { + let a = A::new() + .o("marL", p.margin_left.map(|v| emu(v.clamp(0.0, 4032.0)))) + .o("marR", p.margin_right.map(|v| emu(v.clamp(0.0, 4032.0)))) + .o("lvl", level.filter(|l| *l > 0).map(|l| l.min(8))) + .o("indent", p.indent.map(|v| emu(v.clamp(-4032.0, 4032.0)))) + .o("algn", p.align.map(|a| a.xml())) + .o("defTabSz", p.default_tab.map(|v| emu(v).max(0))) + .b("rtl", p.rtl) + .b("eaLnBrk", p.east_asian_line_break) + .b("latinLnBrk", p.latin_line_break) + .b("hangingPunct", p.hanging_punct) + .o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b"))); + let has_kids = p.line_spacing.is_some() + || p.space_before.is_some() + || p.space_after.is_some() + || p.bullet.is_some() + || p.bullet_font.is_some() + || p.bullet_font_follow_text.is_some() + || p.bullet_color.is_some() + || p.bullet_color_follow_text.is_some() + || p.bullet_size.is_some() + || p.bullet_size_points.is_some() + || p.bullet_size_follow_text.is_some() + || p.tabs.is_some() + || def.is_some(); + if !has_kids { + w.tag(tag).list(a).empty(); + return; + } + w.tag(tag).list(a).open(); + if let Some(s) = &p.line_spacing { + spacing(w, "a:lnSpc", s); + } + if let Some(s) = &p.space_before { + spacing(w, "a:spcBef", s); + } + if let Some(s) = &p.space_after { + spacing(w, "a:spcAft", s); + } + if p.bullet_color.is_none() && p.bullet_color_follow_text == Some(true) { + w.empty("a:buClrTx", &[]); + } else if let Some(c) = &p.bullet_color { + w.open("a:buClr", &[]); + color(w, c); + w.close(); + } + if p.bullet_size.is_none() && p.bullet_size_points.is_none() && p.bullet_size_follow_text == Some(true) { + w.empty("a:buSzTx", &[]); + } else if let Some(s) = p.bullet_size_points.filter(|s| s.is_finite() && (1.0..=4000.0).contains(s)) { + w.tag("a:buSzPts").attr("val", ((s * 100.0).round() as u32).to_string()).empty(); + } else if let Some(s) = p.bullet_size { + w.tag("a:buSzPct").attr("val", pct(s.clamp(0.25, 4.0))).empty(); + } + if p.bullet_font.is_none() && p.bullet_font_follow_text == Some(true) { + w.empty("a:buFontTx", &[]); + } else if let Some(f) = &p.bullet_font { + w.tag("a:buFont").attr("typeface", f).empty(); + } + match &p.bullet { + Some(Bullet::None) => w.empty("a:buNone", &[]), + Some(Bullet::Char { char }) => w.tag("a:buChar").attr("char", if char.is_empty() { "•" } else { char }).empty(), + Some(Bullet::AutoNum { scheme, start_at }) => { + let s = if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric()) { "arabicPeriod" } else { scheme }; + w.tag("a:buAutoNum").attr("type", s).attr_opt("startAt", (*start_at != 1).then_some((*start_at).clamp(1, 32767))).empty(); + } + Some(Bullet::Picture { media }) => match out.media_rel(*media, "image") { + Some(rid) => { + w.open("a:buBlip", &[]); + let a = if out.media_linked(*media) { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) }; + w.tag("a:blip").list(a).empty(); + w.close(); + } + None => w.tag("a:buChar").attr("char", "•").empty(), + }, + None => {} + } + if let Some(t) = &p.tabs { + w.open("a:tabLst", &[]); + for tab in t.iter().take(32) { + let al = match tab.align.as_str() { + "ctr" | "r" | "dec" => tab.align.as_str(), + _ => "l", + }; + w.tag("a:tab").attr("pos", emu(tab.pos)).attr("algn", al).empty(); + } + w.close(); + } + if let Some(d) = def { + rpr(w, out, "a:defRPr", d); + } + w.close(); +} + +fn typeface(w: &mut Writer, tag: &str, f: &Option) { + if let Some(f) = f { + w.tag(tag).attr("typeface", f).empty(); + } +} + +/// `a:rPr`, `a:defRPr`, `a:endParaRPr`. +pub fn rpr(w: &mut Writer, out: &mut dyn Sink, tag: &str, r: &RunProps) { + let a = A::new() + .o("lang", r.lang.as_deref().filter(|l| !l.is_empty())) + .o("altLang", r.alt_lang.as_deref().filter(|l| !l.is_empty())) + .o("sz", r.size.map(|s| (s.clamp(1.0, 4000.0) * 100.0).round() as i64)) + .b("b", r.bold) + .b("i", r.italic) + .o("u", r.underline.as_deref().filter(|u| !u.is_empty() && u.chars().all(|c| c.is_ascii_alphanumeric()))) + .o( + "strike", + r.strike.map(|s| match s { + Strike::None => "noStrike", + Strike::Single => "sngStrike", + Strike::Double => "dblStrike", + }), + ) + .o("kern", r.kern.map(|k| (k.clamp(0.0, 4000.0) * 100.0).round() as i64)) + .o( + "cap", + r.caps.map(|c| match c { + Caps::None => "none", + Caps::Small => "small", + Caps::All => "all", + }), + ) + .o("spc", r.spacing.map(|s| (s.clamp(-4000.0, 4000.0) * 100.0).round() as i64)) + .o("baseline", r.baseline.map(|b| pct(b.clamp(-10.0, 10.0)))) + .b("noProof", r.no_proof) + .b("normalizeH", r.normalize_height); + let has_fx = r.shadow.is_some() || r.inner_shadow.is_some() || r.glow.is_some() || r.reflection.is_some() || r.soft_edge.is_some(); + let has_kids = r.outline.is_some() + || r.fill.is_some() + || has_fx + || r.highlight.is_some() + || r.underline_color.is_some() + || r.font.is_some() + || r.font_ea.is_some() + || r.font_cs.is_some() + || r.font_sym.is_some() + || r.link.is_some() + || r.hover.is_some(); + if !has_kids { + w.tag(tag).list(a).empty(); + return; + } + w.tag(tag).list(a).open(); + if let Some(l) = &r.outline { + line(w, out, "a:ln", l); + } + if let Some(f) = &r.fill { + match f { + Fill::Background => {} + f => fill(w, out, f), + } + } + if has_fx { + // Same child order as a shape's effect list: glow, inner shadow, outer shadow, reflection, soft edge. + effects( + w, + &Effects { + outer_shadow: r.shadow.clone(), + inner_shadow: r.inner_shadow.clone(), + glow: r.glow.clone(), + reflection: r.reflection.clone(), + soft_edge: r.soft_edge, + ..Default::default() + }, + ); + } + if let Some(h) = &r.highlight { + w.open("a:highlight", &[]); + color(w, h); + w.close(); + } + if let Some(c) = &r.underline_color { + w.open("a:uFill", &[]); + solid(w, c); + w.close(); + } + typeface(w, "a:latin", &r.font); + typeface(w, "a:ea", &r.font_ea); + typeface(w, "a:cs", &r.font_cs); + typeface(w, "a:sym", &r.font_sym); + if let Some(h) = &r.link { + hyperlink(w, out, "a:hlinkClick", h); + } + // Schema order: hlinkClick, hlinkMouseOver. + if let Some(h) = &r.hover { + hyperlink(w, out, "a:hlinkMouseOver", h); + } + w.close(); +} + +/// `a:lstStyle`-like element (`a:lstStyle`, `p:titleStyle`, `p:defaultTextStyle`…). +pub fn list_style(w: &mut Writer, out: &mut dyn Sink, tag: &str, ls: &ListStyle) { + if ls.is_empty() { + w.empty(tag, &[]); + return; + } + w.open(tag, &[]); + for (i, l) in ls.levels.iter().enumerate().take(9) { + if let Some(l) = l { + let t = format!("a:lvl{}pPr", i + 1); + let def = (l.run != RunProps::default()).then_some(&l.run); + ppr(w, out, &t, None, &l.para, def); + } + } + w.close(); +} + +/// Write `` text body (`p:txBody`, `a:txBody`). `math` selects the OMML form of equations. +pub fn text_body(w: &mut Writer, out: &mut dyn Sink, tag: &str, tb: &TextBody, math: bool) { + w.open(tag, &[]); + body_pr(w, &tb.body); + list_style(w, out, "a:lstStyle", &tb.list_style); + if tb.paragraphs.is_empty() { + w.empty("a:p", &[]); + } + for (pi, p) in tb.paragraphs.iter().enumerate() { + paragraph(w, out, p, math, pi); + } + w.close(); +} + +fn run_text(w: &mut Writer, out: &mut dyn Sink, text: &str, props: &RunProps) { + // The run's properties are the same for every piece and line break: serialize them once. + let mut rpr_xml: Option = None; + let mut put_rpr = |w: &mut Writer| match &rpr_xml { + Some(xml) => w.raw(xml), + None => { + let start = w.as_str().len(); + rpr(w, out, "a:rPr", props); + rpr_xml = Some(w.as_str().get(start..).unwrap_or_default().to_string()); + } + }; + for (i, piece) in text.split(['\n', '\u{b}']).enumerate() { + if i > 0 { + w.open("a:br", &[]); + put_rpr(w); + w.close(); + } + if piece.is_empty() && i > 0 { + continue; + } + w.open("a:r", &[]); + put_rpr(w); + w.leaf("a:t", &[], piece); + w.close(); + } +} + +pub fn paragraph(w: &mut Writer, out: &mut dyn Sink, p: &Paragraph, math: bool, index: usize) { + w.open("a:p", &[]); + let def = (p.def_props != RunProps::default()).then_some(&p.def_props); + if p.level > 0 || !is_default_ppr(&p.props) || def.is_some() { + ppr(w, out, "a:pPr", Some(p.level), &p.props, def); + } + for (ri, r) in p.runs.iter().enumerate() { + match &r.kind { + RunKind::Text => run_text(w, out, &r.text, &r.props), + RunKind::Break => { + w.open("a:br", &[]); + rpr(w, out, "a:rPr", &r.props); + w.close(); + } + RunKind::Field { field } => { + let id = crate::table::styles::guid(&format!("{}:{index}:{ri}:{field}", out.part_name())); + let ty = (!field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')).then_some(field.as_str()); + w.tag("a:fld").attr("id", id).attr_opt("type", ty).open(); + rpr(w, out, "a:rPr", &r.props); + w.leaf("a:t", &[], &r.text); + w.close(); + } + RunKind::Math { omml } => { + if math && !omml.is_empty() { + w.tag("a14:m").attr("xmlns:a14", crate::ns::NS_A14).open(); + w.raw(omml); + w.close(); + } else { + run_text(w, out, &r.text, &r.props); + } + } + } + } + if p.end_props != RunProps::default() { + rpr(w, out, "a:endParaRPr", &p.end_props); + } + w.close(); +} + +pub fn has_math(tb: &TextBody) -> bool { + tb.paragraphs.iter().flat_map(|p| &p.runs).any(|r| matches!(&r.kind, RunKind::Math { omml } if !omml.is_empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::Rgba; + + #[test] + fn unknown_preset_and_system_names_save_as_black_and_window_text() { + let write = |base: ColorBase| { + let mut w = Writer::new(); + color(&mut w, &ColorRef { base, mods: vec![] }); + w.finish() + }; + assert_eq!(write(ColorBase::Preset { name: "notAColor".into() }), r#""#); + assert_eq!(write(ColorBase::Preset { name: "darkBlue".into() }), r#""#); + let last = Rgba::rgb(0x12, 0x34, 0x56); + assert_eq!(write(ColorBase::System { name: "notAColor".into(), last }), r#""#); + assert_eq!(write(ColorBase::System { name: "3dLight".into(), last }), r#""#); + } +} diff --git a/crates/drawingml/src/xmlx.rs b/crates/drawingml/src/xmlx.rs new file mode 100644 index 00000000..d31616b3 --- /dev/null +++ b/crates/drawingml/src/xmlx.rs @@ -0,0 +1,421 @@ +//! What the readers and writers of the office XML formats need on top of `craft-xml`: the part +//! parser with their limits, markup-compatibility branches, fragments kept as text with their +//! namespace declarations, and relationship ids inside a fragment. +//! +//! Readers here match elements with [`craft_xml::Local`] and attributes with +//! [`El::attr_local`], so a document may use any prefixes. Integers are read with +//! [`craft_xml::value::to_i64_lossy`] and its siblings: producers write `12700.0` and values +//! far out of range, and neither should lose the element. + +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; + +use craft_xml::{El, Limits, Local, Node, Options, Tag}; + +/// Deepest element nesting any walk in this crate follows; also the parser's limit. +pub const MAX_DEPTH: usize = 256; +/// Most nodes in one part. +pub const MAX_NODES: usize = 4_000_000; + +/// Parses a part: names as written, namespace declarations kept, [`MAX_DEPTH`] and +/// [`MAX_NODES`]. +pub fn parse(bytes: &[u8]) -> Result { + Options { limits: Limits { max_depth: MAX_DEPTH, max_nodes: MAX_NODES, ..Limits::DEFAULT }, ..Options::default() }.parse(bytes) +} + +/// The namespace declarations on `root` as (`xmlns:p`, URI) pairs. +pub fn ns_decls(root: &El) -> Vec<(String, String)> { + root.attrs.iter().filter(|(k, _)| k.starts_with("xmlns")).cloned().collect() +} + +/// An attribute list built before its tag is started. +#[derive(Default)] +pub struct A { + v: Vec<(&'static str, String)>, +} + +impl A { + pub fn new() -> Self { + A { v: vec![] } + } + #[must_use] + pub fn a(mut self, k: &'static str, v: impl ToString) -> Self { + self.v.push((k, v.to_string())); + self + } + #[must_use] + pub fn o(mut self, k: &'static str, v: Option) -> Self { + if let Some(v) = v { + self.v.push((k, v.to_string())); + } + self + } + /// A boolean written as `1` or `0` when there is one. + #[must_use] + pub fn b(self, k: &'static str, v: Option) -> Self { + self.o(k, v.map(u8::from)) + } + /// A boolean written only when true. + #[must_use] + pub fn t(self, k: &'static str, v: bool) -> Self { + self.o(k, v.then_some(1)) + } +} + +/// [`A`] on a [`Tag`]. +pub trait TagExt { + #[must_use] + fn list(self, a: A) -> Self; +} + +impl TagExt for Tag<'_> { + fn list(self, a: A) -> Self { + a.v.iter().fold(self, |t, (k, v)| t.attr(k, v)) + } +} + +/// Prefixes declared below the root but not on it, skipping any bound to different namespaces. +pub fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> { + fn rec(e: &El, out: &mut Vec<(String, String)>, out_map: &mut HashMap, clash: &mut HashSet, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for (k, v) in e.attrs.iter().filter(|(k, _)| k.starts_with("xmlns:")) { + if let Some(existing) = out_map.get(k) { + if existing != v { + clash.insert(k.clone()); + } + } else if out.len() < 1000 { + out_map.insert(k.clone(), v.clone()); + out.push((k.clone(), v.clone())); + } + } + for c in e.elements() { + rec(c, out, out_map, clash, depth + 1); + } + } + let mut out = vec![]; + let mut out_map = HashMap::new(); + let mut clash = HashSet::new(); + let root_ns: HashSet<&str> = ns.iter().map(|(r, _)| r.as_str()).collect(); + for c in root.elements() { + rec(c, &mut out, &mut out_map, &mut clash, 1); + } + out.retain(|(k, _)| !clash.contains(k) && !root_ns.contains(k.as_str())); + out +} + +/// Longest namespace name copied onto a kept fragment or a resolved `mc` branch. Real ones are +/// URIs of well under 100 bytes; a longer one is not copied, so one declaration cannot be +/// multiplied by every fragment that uses its prefix. +pub const MAX_NS_VALUE: usize = 2048; + +/// Namespace declaration bytes one part may copy onto all its kept fragments together (the +/// budget a caller gives [`keep_with`]); past it a fragment keeps its markup but is not given +/// further declarations. +pub const MAX_COPIED_NS_BYTES: usize = 8 << 20; + +/// Prefixes `el` and its descendants use in element and attribute names, those listed in +/// `mc:Ignorable` or `mc:MustUnderstand` (which must stay declared for the markup-compatibility +/// rules to hold), and those starting a QName-like attribute value. +fn used_prefixes(el: &El) -> HashSet<&str> { + fn rec<'a>(e: &'a El, used: &mut HashSet<&'a str>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if let Some((p, _)) = e.name.split_once(':') { + used.insert(p); + } + for (k, v) in &e.attrs { + if let Some((p, local)) = k.split_once(':') + && p != "xmlns" + && p != "xml" + { + used.insert(p); + if local == "Ignorable" || local == "MustUnderstand" { + used.extend(v.split_whitespace()); + } + } + // QName-valued attributes (`xsi:type="q:Item"`) need their prefix declared too. + if k != "xmlns" && !k.starts_with("xmlns:") { + for t in v.split_whitespace() { + if let Some((p, _)) = t.split_once(':') + && !p.is_empty() + { + used.insert(p); + } + } + } + } + for c in e.elements() { + rec(c, used, depth + 1); + } + } + let mut used = HashSet::new(); + rec(el, &mut used, 0); + used +} + +/// Serialize `el`, declaring every prefix it uses that the source root declared. +pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String { + keep_with(el, &[ns], None) +} + +/// [`keep_xml`] over several declaration lists (the root's, then an enclosing wrapper's), taking +/// the bytes of the declarations it copies from `budget` when there is one (see +/// [`MAX_COPIED_NS_BYTES`]). +pub fn keep_with(el: &El, decls: &[&[(String, String)]], budget: Option<&Cell>) -> String { + let used = used_prefixes(el); + let mut have: HashSet<&str> = el.attrs.iter().map(|(a, _)| a.as_str()).collect(); + let mut extra = Vec::new(); + for (k, v) in decls.iter().flat_map(|d| d.iter()) { + let p = k.strip_prefix("xmlns:").unwrap_or(""); + if !p.is_empty() && used.contains(p) && v.len() <= MAX_NS_VALUE && !have.contains(k.as_str()) { + let cost = k.len() + v.len(); + if let Some(b) = budget { + if b.get() < cost { + continue; + } + b.set(b.get() - cost); + } + have.insert(k); + extra.push((k.clone(), v.clone())); + } + } + let mut el = el.clone(); + el.attrs.extend(extra); + el.to_xml() +} + +/// Is this attribute a relationship id (`r:id`, `r:embed`, `r:dm`…)? +pub fn is_rel_attr(k: &str, v: &str) -> bool { + !k.starts_with("xmlns") + && k.contains(':') + && matches!(k.rsplit(':').next(), Some("id" | "embed" | "link" | "dm" | "lo" | "qs" | "cs" | "pict" | "href")) + && !v.is_empty() +} + +/// Every relationship id `el` and its descendants use, sorted and without repeats. +pub fn rel_refs(el: &El) -> Vec { + fn rec(e: &El, out: &mut Vec, depth: usize) { + if depth > MAX_DEPTH { + return; + } + out.extend(e.attrs.iter().filter(|(k, v)| is_rel_attr(k, v)).map(|(_, v)| v.clone())); + for c in e.elements() { + rec(c, out, depth + 1); + } + } + let mut out = vec![]; + rec(el, &mut out, 0); + out.sort(); + out.dedup(); + out +} + +/// Does `el` or anything under it use a relationship id? Such a fragment cannot be kept as +/// text: the id means nothing in the part it is written back to. +pub fn has_rel_refs(el: &El) -> bool { + !rel_refs(el).is_empty() +} + +/// Namespace prefixes whose `mc:Choice` content the readers understand. +pub const SUPPORTED_MC: [&str; 10] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188", "cx1"]; + +/// An Office 2016 chart sits in a `cx1` choice; it is taken only when the choice holds a +/// `chartex` graphic (the fallback is a picture or "this chart isn't available" text). +fn chartex_readable(ch: &El) -> bool { + ch.find_all(Local("graphicData")).any(|g| g.attr_local("uri").is_some_and(|u| u.ends_with("/chartex"))) +} + +/// Replaces every `mc:AlternateContent` by the children of the branch the readers handle best: +/// the first `mc:Choice` whose `Requires` prefixes are all in [`SUPPORTED_MC`], else +/// `mc:Fallback`. Ink content parts take the fallback picture. +pub fn resolve_mc(el: &mut El) { + resolve_mc_keeping(el, &|_| false); +} + +/// [`resolve_mc`], leaving in place any `mc:AlternateContent` for which `keep` is true. +pub fn resolve_mc_keeping(el: &mut El, keep: &dyn Fn(&El) -> bool) { + rec_mc(el, 0, keep); +} + +fn rec_mc(el: &mut El, depth: usize, keep: &dyn Fn(&El) -> bool) { + if depth > MAX_DEPTH { + return; + } + let ac = Local("AlternateContent"); + if el.children.iter().any(|c| matches!(c, Node::El(e) if e.is(ac))) { + let mut out = Vec::with_capacity(el.children.len()); + for c in std::mem::take(&mut el.children) { + match c { + Node::El(ac_el) if ac_el.is(ac) => { + if keep(&ac_el) { + out.push(Node::El(ac_el)); + continue; + } + let choice = ac_el.children(Local("Choice")).find(|ch| { + let requires = ch.attr_local("Requires").unwrap_or(""); + let req_ok = requires.split_whitespace().all(|r| SUPPORTED_MC.contains(&r)); + let modelled = !ch.elements().any(|e| e.is(Local("contentPart"))) && (!requires.contains("cx1") || chartex_readable(ch)); + req_ok && modelled + }); + if let Some(b) = choice.or_else(|| ac_el.child(Local("Fallback"))) { + // The branch's namespace declarations (the first usable one of each name, + // in order), indexed by prefix once: each child then looks up the prefixes + // it uses instead of scanning every declaration. + let mut decls: Vec<(&String, &String)> = Vec::new(); + // An oversized declaration still shadows an outer one of its name: its + // content is left unbound rather than bound to the outer namespace. + let mut by_prefix: HashMap<&str, Option> = HashMap::new(); + for (k, v) in b.attrs.iter().chain(ac_el.attrs.iter()) { + let prefix = if k == "xmlns" { Some("") } else { k.strip_prefix("xmlns:") }; + if let Some(p) = prefix + && !by_prefix.contains_key(p) + { + by_prefix.insert(p, (v.len() <= MAX_NS_VALUE).then_some(decls.len())); + if v.len() <= MAX_NS_VALUE { + decls.push((k, v)); + } + } + } + for n in &b.children { + if let Node::El(e) = n { + // Carry the declarations from the branch that its content uses + // (all of them per child made children x declarations copies). + let used = used_prefixes(e); + let have: HashSet<&str> = e.attrs.iter().map(|(a, _)| a.as_str()).collect(); + let mut wanted: Vec = std::iter::once("") + .chain(used.iter().copied()) + .filter_map(|p| by_prefix.get(p).copied().flatten()) + .filter(|&i| decls.get(i).is_some_and(|(k, _)| !have.contains(k.as_str()))) + .collect(); + wanted.sort_unstable(); + wanted.dedup(); + let mut e = e.clone(); + e.attrs.extend(wanted.into_iter().filter_map(|i| decls.get(i)).map(|(k, v)| ((*k).clone(), (*v).clone()))); + out.push(Node::El(e)); + } + } + } + } + other => out.push(other), + } + } + el.children = out; + } + for c in el.elements_mut() { + rec_mc(c, depth + 1, keep); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_kept_fragment_declares_the_prefixes_it_uses_and_no_others() { + let root = parse(br#""#).unwrap(); + let ns = ns_decls(&root); + let x = root.child(Local("x")).unwrap(); + assert_eq!(keep_xml(x, &ns), r#""#); + assert_eq!(keep_xml(root.child(Local("z")).unwrap(), &ns), r#""#); + assert_eq!(rel_refs(x), ["rId1"]); + assert!(!has_rel_refs(root.child(Local("z")).unwrap())); + } + + #[test] + fn inner_declarations_are_found_and_clashing_ones_dropped() { + let root = parse(br#""#).unwrap(); + assert_eq!(inner_decls(&root, &ns_decls(&root)), [("xmlns:b".to_string(), "B".to_string())]); + } + + #[test] + fn alternate_content_takes_a_supported_choice_else_the_fallback() { + let mut root = parse( + br#""#, + ) + .unwrap(); + let mut kept = root.clone(); + resolve_mc(&mut root); + assert_eq!(root.elements().map(|e| e.name.as_str()).collect::>(), ["a", "d"]); + resolve_mc_keeping(&mut kept, &|ac| ac.find(Local("c")).is_some()); + assert_eq!(kept.elements().map(|e| e.local()).collect::>(), ["a", "AlternateContent"]); + } + + #[test] + fn the_parser_refuses_nesting_past_the_depth_limit() { + let deep = "".repeat(MAX_DEPTH + 2); + assert!(parse(deep.as_bytes()).is_err()); + assert!(parse(b"12").is_ok()); + } + + #[test] + fn an_attribute_list_writes_booleans_as_digits_and_skips_absent_values() { + let mut w = craft_xml::Writer::new(); + w.tag("x").list(A::new().a("k", 1).o("n", None::).b("b", Some(false)).t("t", true).t("f", false)).empty(); + assert_eq!(w.finish(), r#""#); + } + + #[test] + fn mc_branches_carry_only_the_declarations_their_content_uses() { + // 1,000 declarations on the branch (the parser takes at most 1,024 attributes on a tag) + // and 2,000 children: each child gets the one it uses (and the default), not all 1,000. + let decls: String = (0..1000).map(|i| format!(r#" xmlns:n{i}="urn:n{i}""#)).collect(); + let kids: String = (0..2000).map(|i| format!("", i % 1000)).collect(); + let x = format!(r#"{kids}"#); + let mut d = parse(x.as_bytes()).unwrap(); + resolve_mc(&mut d); + let kids: Vec<&El> = d.elements().collect(); + assert_eq!(kids.len(), 2000); + assert_eq!(kids[7].attrs, vec![("xmlns".to_string(), "urn:d".to_string()), ("xmlns:n7".to_string(), "urn:n7".to_string())]); + } + + /// The branch's own declaration of a prefix wins over the `AlternateContent`'s; an oversized + /// one still shadows it (its content stays unbound); a child's own declaration is left alone. + #[test] + fn mc_branch_declarations_keep_their_precedence() { + let long = "u".repeat(MAX_NS_VALUE + 1); + let x = format!( + r#""# + ); + let mut d = parse(x.as_bytes()).unwrap(); + resolve_mc(&mut d); + let kids: Vec<&El> = d.elements().collect(); + let attrs = |i: usize| kids[i].attrs.iter().map(|(k, v)| format!("{k}={v}")).collect::>(); + assert_eq!(attrs(0), ["xmlns:p=urn:b"]); + assert!(attrs(1).is_empty(), "{:?}", attrs(1)); + assert_eq!(attrs(2), ["xmlns:p=urn:own"]); + } + + #[test] + fn mc_branches_keep_prefixes_named_by_must_understand_and_in_qname_values() { + let x = br#""#; + let mut d = parse(x).unwrap(); + resolve_mc(&mut d); + let kid = d.elements().next().unwrap(); + assert!(kid.attrs.iter().any(|(k, v)| k == "xmlns:q" && v == "urn:q"), "{:?}", kid.attrs); + let x = br#""#; + let mut d = parse(x).unwrap(); + resolve_mc(&mut d); + let kid = d.elements().next().unwrap(); + assert!(kid.attrs.iter().any(|(k, v)| k == "xmlns:q" && v == "urn:q"), "{:?}", kid.attrs); + assert!(!kid.attrs.iter().any(|(k, _)| k == "xmlns:z"), "unused declarations stay out: {:?}", kid.attrs); + } + + #[test] + fn kept_fragments_skip_oversized_namespace_names_and_spend_a_budget() { + let big = "u".repeat(MAX_NS_VALUE + 1); + let ns = vec![("xmlns:a".to_string(), "urn:a".to_string()), ("xmlns:b".to_string(), big)]; + let mut el = parse(br#""#).unwrap(); + el.attrs.clear(); + let out = keep_xml(&el, &ns); + assert!(out.contains(r#"xmlns:a="urn:a""#), "{out}"); + assert!(!out.contains("uuuu"), "the oversized declaration is not copied"); + let budget = Cell::new(15); + assert!(keep_with(&el, &[&ns], Some(&budget)).contains(r#"xmlns:a="urn:a""#), "within budget"); + assert!(budget.get() < 15); + let again = keep_with(&el, &[&ns], Some(&budget)); + assert!(!again.contains("xmlns:a"), "budget spent, no further declarations: {again}"); + } +}