mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
craft-drawingml: readers and writers on craft-xml (#979)
* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps The parts of DrawingML that need no document model, moved from DeckCraft (60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU conversions) and crates/pptx/src/custgeom.rs with CustomPath from crates/model. The code is unchanged except that custgeom reads craft-xml trees and writes to a craft-xml Writer instead of DeckCraft's own XML helper, and matches elements by local name. The 59 tests of the moved code come with it; six more cover the custom geometry limits. * drawingml: evaluate a custom geometry's guide lists once per coordinate space read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every path: up to 1,000 paths times up to 10,000 guides, each name copied and hashed. Guides are now cached per path coordinate space (w, h). At most 16 spaces and 4 MiB of guide names are kept per geometry; paths in further spaces are left out. The first space is always kept, and a geometry without guides is not capped. Ported from storytold/deckcraft#1789. A list counts as having guides only when it has gd children, as that PR's review asked. * drawingml: the model types for fills, lines, text, tables, charts and diagrams Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs, table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode and the MediaId and SlideId newtypes from lib.rs. The files are unchanged except that deckcraft_color:: is crate::color:: and Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come with them. Readers and writers follow. * drawingml: the theme model (colour, font and format schemes) Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme, FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends on a gallery. * drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs, read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink. The chart frame budget and kept-part capture stay with the presentation reader. * drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78.
This commit is contained in:
Generated
+1
@@ -1047,6 +1047,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@ workspace = true
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
log = "0.4"
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Outer category levels, innermost first.
|
||||
levels: Vec<Vec<String>>,
|
||||
numbers: HashMap<String, (Vec<Option<f64>>, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A level's points, by `idx`.
|
||||
fn points(lvl: &El, budget: &mut usize) -> Vec<String> {
|
||||
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::<f64>().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<ChartType> {
|
||||
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<Chart> {
|
||||
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<Chart> {
|
||||
if !root.is(Local("chartSpace")) {
|
||||
return None;
|
||||
}
|
||||
let mut decode_budget = MAX_CHARTEX_POINTS;
|
||||
let datas: HashMap<String, Data> = 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::<f64>().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)
|
||||
}
|
||||
@@ -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<Fill> {
|
||||
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<crate::style::Line> {
|
||||
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<String> {
|
||||
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::<Vec<_>>().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<String> {
|
||||
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<String> {
|
||||
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<Option<f64>> {
|
||||
strings(e, budget).into_iter().map(|s| s.trim().parse::<f64>().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<Chart> {
|
||||
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> {
|
||||
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<Chart> {
|
||||
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<ChartType> = 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<String> {
|
||||
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<String> {
|
||||
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<String> = 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<ChartType> {
|
||||
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<bool> {
|
||||
// A missing <c:varyColors> 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::<f64>().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::<f64>().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::<f64>().ok()) {
|
||||
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
|
||||
}
|
||||
// `<c:showVal/>` 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<String> {
|
||||
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<ErrorBars> {
|
||||
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<bool>), categories: &mut Vec<String>, 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()
|
||||
};
|
||||
// `<c:showVal/>` 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::<f64>().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<String>) -> (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<String>| {
|
||||
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::<f64>().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<f64> {
|
||||
e.child(Local(k)).and_then(|c| c.attr_local("val")).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && v.abs() <= 1.0e12)
|
||||
}
|
||||
|
||||
fn trend_name(s: &str) -> Option<String> {
|
||||
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<Trendline> {
|
||||
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<crate::style::ColorRef> {
|
||||
fn rec(e: &El, depth: usize) -> Option<crate::style::ColorRef> {
|
||||
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<f64> {
|
||||
fn rec(e: &El, depth: usize) -> Option<f64> {
|
||||
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::<f64>().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#"<c:ser><c:idx val="{n}"/><c:val><c:numRef><c:numCache><c:ptCount val="100000"/><c:pt idx="0"><c:v>{n}</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#
|
||||
)
|
||||
};
|
||||
let sers: String = (0..5).map(ser).collect();
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/>{sers}</c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
||||
);
|
||||
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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:spPr><a:pattFill prst="openDmnd"><a:fgClr><a:schemeClr val="accent4"/></a:fgClr><a:bgClr><a:schemeClr val="bg1"/></a:bgClr></a:pattFill></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numLit></c:val></c:ser><c:ser><c:idx val="1"/><c:spPr><a:noFill/><a:ln w="19050"><a:solidFill><a:schemeClr val="accent4"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>3</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_bars_are_read() {
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:errBars><c:errBarType val="plus"/><c:errValType val="percentage"/><c:noEndCap val="1"/><c:val val="20"/></c:errBars><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
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#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea>{legend}</c:chart></c:chartSpace>"#
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(c.legend_overlay);
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea><c:legend/></c:chart></c:chartSpace>"#;
|
||||
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##"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:lang val="ru-RU"/><c:chart><c:plotArea>
|
||||
<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/><c:pt idx="0"><c:v>W1</c:v></c:pt><c:pt idx="1"><c:v>W2</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>200000</c:v></c:pt><c:pt idx="1"><c:v>310000</c:v></c:pt></c:numLit></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart>
|
||||
<c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="1"/><c:tx><c:v>Clicks</c:v></c:tx><c:spPr><a:ln w="28575"><a:solidFill><a:srgbClr val="00B050"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>6000</c:v></c:pt><c:pt idx="1"><c:v>7400</c:v></c:pt></c:numLit></c:val></c:ser><c:marker val="1"/><c:axId val="3"/><c:axId val="4"/></c:lineChart>
|
||||
<c:catAx><c:axId val="1"/><c:axPos val="b"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:axPos val="l"/><c:numFmt formatCode="#,##0" sourceLinked="1"/><c:crossAx val="1"/><c:majorUnit val="100000"/></c:valAx>
|
||||
<c:valAx><c:axId val="4"/><c:scaling><c:max val="8000"/></c:scaling><c:axPos val="r"/><c:numFmt formatCode="0.0" sourceLinked="0"/><c:crossAx val="3"/></c:valAx><c:catAx><c:axId val="3"/><c:delete val="1"/><c:axPos val="b"/><c:crossAx val="4"/></c:catAx>
|
||||
</c:plotArea></c:chart></c:chartSpace>"##;
|
||||
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(
|
||||
"<c:axPos val=\"l\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
"<c:axPos val=\"r\"/><c:crosses val=\"max\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
)
|
||||
.replace("<c:axPos val=\"r\"/><c:numFmt formatCode=\"0.0\"", "<c:axPos val=\"l\"/><c:numFmt formatCode=\"0.0\"");
|
||||
let c2 = read_chart_xml(swapped.as_bytes()).unwrap();
|
||||
assert!(c2.series[0].secondary_axis && !c2.series[1].secondary_axis);
|
||||
assert_eq!((c2.y_format.as_deref(), c2.y2_format.as_deref()), (Some("0.0"), Some("#,##0")));
|
||||
// A chart of one type on one axis has no secondary series.
|
||||
let single = String::from_utf8_lossy(xml)
|
||||
.replace("<c:axId val=\"3\"/><c:axId val=\"4\"/></c:lineChart>", "<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>");
|
||||
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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:formatCode>#,##0</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>1200</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/>{ax_fmt}<c:axPos val="l"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#
|
||||
)
|
||||
};
|
||||
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#"<c:numFmt formatCode="General" sourceLinked="1"/>"#).as_deref(), Some("#,##0"));
|
||||
assert_eq!(fmt(r#"<c:numFmt formatCode="0.0" sourceLinked="0"/>"#).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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1000"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr></c:catAx><c:valAx><c:axId val="2"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
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("<c:catAx><c:axId val=\"1\"/>", "<c:catAx><c:axId val=\"1\"/><c:scaling><c:orientation val=\"maxMin\"/></c:scaling>")
|
||||
.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#"<c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="{sz}"/></a:pPr></a:p></c:txPr>"#);
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1862"/></a:pPr><a:r><a:t>T</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/>{}</c:catAx><c:valAx><c:axId val="2"/>{}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#,
|
||||
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#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart>{}</c:chartSpace>"#,
|
||||
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#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
||||
);
|
||||
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#"<c:chartSpace xmlns:c="{NS_C}" xmlns:a="{NS_A}"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/>{ax}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#
|
||||
)
|
||||
};
|
||||
let no_fill = r#"<c:spPr><a:ln w="9525"><a:noFill/></a:ln></c:spPr>"#;
|
||||
let c = read_chart_xml(xml(r#"<c:majorGridlines/><c:minorGridlines/><c:tickLblPos val="nextTo"/>"#).as_bytes()).unwrap();
|
||||
assert!(c.gridlines && c.minor_gridlines && !c.y_hidden);
|
||||
let c = read_chart_xml(
|
||||
xml(&format!(
|
||||
r#"<c:majorGridlines>{no_fill}</c:majorGridlines><c:minorGridlines>{no_fill}</c:minorGridlines><c:tickLblPos val="none"/>"#
|
||||
))
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!c.gridlines && !c.minor_gridlines && c.y_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trendlines_are_read() {
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx>
|
||||
<c:trendline><c:name>Fit & forecast</c:name><c:trendlineType val="linear"/><c:forward val="1"/><c:backward val="0.5"/><c:intercept val="0"/><c:dispRSqr val="1"/><c:dispEq val="1"/></c:trendline>
|
||||
<c:trendline><c:trendlineType val="poly"/><c:order val="9"/></c:trendline>
|
||||
<c:trendline><c:trendlineType val="movingAvg"/><c:period val="3"/></c:trendline>
|
||||
<c:trendline><c:trendlineType val="notAType"/></c:trendline>
|
||||
<c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:lineChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
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#"<c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val>"#;
|
||||
let ser: String = (0..series).map(|i| format!(r#"<c:ser><c:idx val="{i}"/><c:tx><c:v>S{i}</c:v></c:tx>{val}</c:ser>"#)).collect();
|
||||
let plot = format!(r#"<c:plotArea><c:barChart><c:barDir val="col"/>{ser}</c:barChart></c:plotArea>"#);
|
||||
let xml = format!(r#"<c:chartSpace xmlns:c="{NS_C}" xmlns:a="{NS_A}"><c:chart>{title}{auto_deleted}{plot}</c:chart></c:chartSpace>"#);
|
||||
read_chart_xml(xml.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_series_gets_automatic_title() {
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="0"/>"#, "", 1).title.as_deref(), Some("S0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_automatic_title_when_deleted_or_multi_series() {
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="1"/>"#, "", 1).title, None);
|
||||
assert_eq!(chart("", "", 1).title, None);
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="0"/>"#, "", 2).title, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_title_wins() {
|
||||
let t = "<c:title><c:tx><c:rich><a:p><a:r><a:t>Mine</a:t></a:r></a:p></c:rich></c:tx></c:title>";
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="0"/>"#, 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#"<c:ser><c:idx val="0"/><c:order val="0"/></c:ser>"#;
|
||||
let bar = format!(r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{vary}{ser}</c:barChart>"#);
|
||||
format!(r#"<c:chartSpace xmlns:c="{NS_CHART}"><c:chart><c:plotArea>{bar}</c:plotArea></c:chart></c:chartSpace>"#)
|
||||
}
|
||||
|
||||
#[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#"<c:varyColors val="0"/>"#).as_bytes()).is_some_and(|c| !c.vary_colors));
|
||||
assert!(read_chart_xml(vary_xml(r#"<c:varyColors val="1"/>"#).as_bytes()).is_some_and(|c| c.vary_colors));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,3 +51,5 @@ pub struct DiagramNode {
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
pub mod parts;
|
||||
|
||||
@@ -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::<u8>().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::<u8>().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<String> {
|
||||
if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::<u8>().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::<u8>().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::<u8>().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::<u8>().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<u8> {
|
||||
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<String, usize> = 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<DiagramNode> {
|
||||
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<String, usize> = 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::<String>())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.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<u8> {
|
||||
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<u8> {
|
||||
// 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<u8> {
|
||||
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<DiagramNode> {
|
||||
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#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="0" type="doc"/><dgm:pt modelId="1"><dgm:t><a:p xmlns:a="a"><a:r><a:t>A</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2"/></dgm:ptLst><dgm:cxnLst><dgm:cxn srcId="0" destId="1"/><dgm:cxn srcId="1" destId="2"/><dgm:cxn srcId="2" destId="1"/><dgm:cxn type="presOf" srcId="0" destId="2"/></dgm:cxnLst></dgm:dataModel>"#;
|
||||
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#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="0" type="doc"/><dgm:pt modelId="1" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Alpha</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Beta</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:cxnLst/></dgm:dataModel>"#;
|
||||
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#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="1" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Parent</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Child</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:cxnLst><dgm:cxn srcId="1" destId="2"/></dgm:cxnLst></dgm:dataModel>"#;
|
||||
let unrooted_doc = crate::xmlx::parse(unrooted_hierarchy).unwrap();
|
||||
assert!(outline(&unrooted_doc).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Rel>;
|
||||
/// 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<MediaId>;
|
||||
/// The slide stored in the part named `target`, in a presentation.
|
||||
fn slide(&self, _target: &str) -> Option<SlideId> {
|
||||
None
|
||||
}
|
||||
/// The name of the custom show with this id, in a presentation.
|
||||
fn custom_show(&self, _id: &str) -> Option<String> {
|
||||
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<Rel> {
|
||||
None
|
||||
}
|
||||
fn media(&mut self, _target: &str, _external: bool) -> Option<MediaId> {
|
||||
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<String>;
|
||||
/// 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<String>;
|
||||
/// A relationship to the part of that slide, in a presentation.
|
||||
fn slide_rel(&mut self, _slide: SlideId) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// The position of the custom show with this name, in a presentation.
|
||||
fn custom_show_index(&self, _name: &str) -> Option<usize> {
|
||||
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<String, (String, bool)>,
|
||||
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<Rel> {
|
||||
self.rels.get(id).map(|(target, external)| Rel { target: target.clone(), external: *external })
|
||||
}
|
||||
fn media(&mut self, target: &str, external: bool) -> Option<MediaId> {
|
||||
self.asked.push((target.into(), external));
|
||||
(target == "ppt/media/image1.png").then_some(MediaId(7))
|
||||
}
|
||||
fn slide(&self, target: &str) -> Option<SlideId> {
|
||||
(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<String> {
|
||||
(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<String> {
|
||||
None
|
||||
}
|
||||
fn slide_rel(&mut self, slide: SlideId) -> Option<String> {
|
||||
(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#"<a:blipFill xmlns:a="A" xmlns:r="R"><a:blip r:embed="rId1"/><a:stretch><a:fillRect/></a:stretch></a:blipFill>"#).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#"<a:blip r:embed="rId1""#));
|
||||
assert_eq!(part.written, [("image".to_string(), "../media/image1.png".to_string(), false)]);
|
||||
// A missing picture gets the stand-in.
|
||||
let mut part = Part::new();
|
||||
let mut w = craft_xml::Writer::new();
|
||||
crate::write::blip_fill(&mut w, &mut part, "a:blipFill", &PictureFill { media: MediaId(99), ..pf });
|
||||
assert_eq!(part.written[0].1, "../media/missing.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlinks_resolve_to_urls_and_slides() {
|
||||
let mut part = Part::new();
|
||||
let url = parse(br#"<a:hlinkClick xmlns:a="A" xmlns:r="R" r:id="rId2"/>"#).unwrap();
|
||||
let slide = parse(br#"<a:hlinkClick xmlns:a="A" xmlns:r="R" r:id="rId3" action="ppaction://hlinksldjump"/>"#).unwrap();
|
||||
let none = parse(br#"<a:hlinkClick xmlns:a="A" xmlns:r="R" r:id="rId9"/>"#).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#"<a:hlinkClick r:id="rId1" action="ppaction://hlinksldjump"/>"#);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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<f64> {
|
||||
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<f64> {
|
||||
e.attr_local(name).and_then(value::to_f64).map(|v| v / 100_000.0)
|
||||
}
|
||||
|
||||
fn bullet_size_points(value: Option<&str>) -> Option<f64> {
|
||||
value.and_then(|v| v.trim().parse::<u32>().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0)
|
||||
}
|
||||
|
||||
fn bullet_size_percent(value: Option<&str>) -> Option<f64> {
|
||||
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::<f64>().ok().filter(|v| v.is_finite()).map(|v| v / scale)
|
||||
}
|
||||
|
||||
/// 1/60 000 degree → degrees.
|
||||
pub fn ang(e: &El, name: &str) -> Option<f64> {
|
||||
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<ColorRef> {
|
||||
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<ColorRef> {
|
||||
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<Fill> {
|
||||
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<Fill> {
|
||||
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<GradientStop> = 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<PictureFill> {
|
||||
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<ColorRef> = 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<MediaId> {
|
||||
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<u32> {
|
||||
const MAX: u32 = i32::MAX as u32;
|
||||
let value = value.trim();
|
||||
if let Some(percent) = value.strip_suffix('%') {
|
||||
let units = percent.trim().parse::<f64>().ok()? * 1000.0;
|
||||
(units.is_finite() && (0.0..=MAX as f64).contains(&units)).then(|| units.round() as u32)
|
||||
} else {
|
||||
value.parse::<u32>().ok().filter(|limit| *limit <= MAX)
|
||||
}
|
||||
}
|
||||
|
||||
fn line_end(e: &El) -> Option<LineEnd> {
|
||||
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<Xfrm>) {
|
||||
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<f64> {
|
||||
let mut adj: Vec<f64> = 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::<usize>().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::<f64>().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<Spacing> {
|
||||
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<u8>, ParaProps, Option<RunProps>) {
|
||||
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<Hyperlink> {
|
||||
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<String> {
|
||||
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::<u8>().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::<String>();
|
||||
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#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".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#"<a:solidFill xmlns:a="x"><a:schemeClr val="accent2"><a:lumMod val="75000"/><a:alpha val="50000"/></a:schemeClr></a:solidFill>"#,
|
||||
)
|
||||
.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#"<x><a:sysClr val="window" lastClr="FFFFFF"/></x>"#).unwrap();
|
||||
assert!(matches!(color(&d).unwrap().base, ColorBase::System { last: Rgba::WHITE, .. }));
|
||||
let d = parse(br#"<x><a:scrgbClr r="100000" g="0" b="0"/></x>"#).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#"<a:buSzPts xmlns:a="x" val=" 1400 "/>"#).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#"<a:bodyPr lIns="91440" anchor="ctr" wrap="none" vert="vert270" numCol="2"><a:normAutofit fontScale="62500" lnSpcReduction="20000"/></a:bodyPr>"#).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 `<a:normAutofit/>` 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#"<a:bodyPr><a:normAutofit/></a:bodyPr>"#).unwrap();
|
||||
assert_eq!(body_pr(&d).autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
|
||||
let d = parse(br#"<a:bodyPr><a:normAutofit fontScale="100000"/></a:bodyPr>"#).unwrap();
|
||||
assert_eq!(body_pr(&d).autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
|
||||
let d = parse(br#"<a:bodyPr spcFirstLastPara="1"/>"#).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#"<a:bodyPr vert="mongolianVert"/>"#).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#"<a:bodyPr vert="wordArtVertRtl"/>"#).unwrap();
|
||||
assert_eq!(body_pr(&d).vert.map(|v| v.xml()), Some("wordArtVertRtl"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Border<'_>>) {
|
||||
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<Border<'_>>| {
|
||||
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<u8> {
|
||||
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<String> = std::collections::HashSet::new();
|
||||
let mut def_of: std::collections::HashMap<String, &crate::table::TableStyleDef> = 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<String> = 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<usize> = st.elements().map(|e| ORDER.iter().position(|o| *o == e.local()).unwrap()).collect();
|
||||
assert!(pos.windows(2).all(|w| w[0] < w[1]), "{:?}: {pos:?}", st.attr_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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T: Clone>(v: &mut Vec<T>, d: &[T]) {
|
||||
while v.len() < 3 {
|
||||
match d.get(v.len()) {
|
||||
Some(x) => v.push(x.clone()),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<Fill> {
|
||||
let mut out: Vec<Fill> = 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()
|
||||
}
|
||||
@@ -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<String> {
|
||||
if preset.is_empty() || preset.len() > 32 || !preset.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("<a:sp3d><a:bevelT prst=\"{preset}\"/></a:sp3d>"))
|
||||
}
|
||||
|
||||
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!("<r xmlns:a=\"{}\">{raw}</r>", 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 `<a:normAutofit/>` (#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<u8>, 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<String>) {
|
||||
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 `<tag>` 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<String> = 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#"<a:prstClr val="black"/>"#);
|
||||
assert_eq!(write(ColorBase::Preset { name: "darkBlue".into() }), r#"<a:prstClr val="darkBlue"/>"#);
|
||||
let last = Rgba::rgb(0x12, 0x34, 0x56);
|
||||
assert_eq!(write(ColorBase::System { name: "notAColor".into(), last }), r#"<a:sysClr val="windowText" lastClr="123456"/>"#);
|
||||
assert_eq!(write(ColorBase::System { name: "3dLight".into(), last }), r#"<a:sysClr val="3dLight" lastClr="123456"/>"#);
|
||||
}
|
||||
}
|
||||
@@ -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<El, craft_xml::Error> {
|
||||
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<T: ToString>(mut self, k: &'static str, v: Option<T>) -> 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<bool>) -> 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<String, String>, clash: &mut HashSet<String>, 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<usize>>) -> 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<String> {
|
||||
fn rec(e: &El, out: &mut Vec<String>, 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<usize>> = 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<usize> = 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#"<p:sld xmlns:p="P" xmlns:a="A" xmlns:r="R"><p:x><a:y r:id="rId1"/></p:x><p:z/></p:sld>"#).unwrap();
|
||||
let ns = ns_decls(&root);
|
||||
let x = root.child(Local("x")).unwrap();
|
||||
assert_eq!(keep_xml(x, &ns), r#"<p:x xmlns:p="P" xmlns:a="A" xmlns:r="R"><a:y r:id="rId1"/></p:x>"#);
|
||||
assert_eq!(keep_xml(root.child(Local("z")).unwrap(), &ns), r#"<p:z xmlns:p="P"/>"#);
|
||||
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#"<r xmlns:a="A"><x xmlns:b="B"><y xmlns:c="C1"/></x><z xmlns:c="C2" xmlns:a="A"/></r>"#).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#"<r xmlns:mc="M"><mc:AlternateContent><mc:Choice Requires="p14"><a/></mc:Choice><mc:Fallback><b/></mc:Fallback></mc:AlternateContent><mc:AlternateContent><mc:Choice Requires="zz9"><c/></mc:Choice><mc:Fallback><d/></mc:Fallback></mc:AlternateContent></r>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut kept = root.clone();
|
||||
resolve_mc(&mut root);
|
||||
assert_eq!(root.elements().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["a", "d"]);
|
||||
resolve_mc_keeping(&mut kept, &|ac| ac.find(Local("c")).is_some());
|
||||
assert_eq!(kept.elements().map(|e| e.local()).collect::<Vec<_>>(), ["a", "AlternateContent"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parser_refuses_nesting_past_the_depth_limit() {
|
||||
let deep = "<a>".repeat(MAX_DEPTH + 2);
|
||||
assert!(parse(deep.as_bytes()).is_err());
|
||||
assert!(parse(b"<a><b>12</b></a>").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::<i32>).b("b", Some(false)).t("t", true).t("f", false)).empty();
|
||||
assert_eq!(w.finish(), r#"<x k="1" b="0" t="1"/>"#);
|
||||
}
|
||||
|
||||
#[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!("<n{}:x/>", i % 1000)).collect();
|
||||
let x = format!(r#"<r xmlns:mc="M"><mc:AlternateContent><mc:Fallback xmlns="urn:d"{decls}>{kids}</mc:Fallback></mc:AlternateContent></r>"#);
|
||||
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#"<r xmlns:mc="M"><mc:AlternateContent xmlns:p="urn:ac" xmlns:q="urn:q-short"><mc:Fallback xmlns:p="urn:b" xmlns:q="{long}"><p:x/><q:y/><p:z xmlns:p="urn:own"/></mc:Fallback></mc:AlternateContent></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::<Vec<_>>();
|
||||
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#"<r xmlns:mc="M"><mc:AlternateContent><mc:Fallback xmlns:q="urn:q"><a mc:MustUnderstand="q"/></mc:Fallback></mc:AlternateContent></r>"#;
|
||||
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#"<r xmlns:mc="M"><mc:AlternateContent><mc:Fallback xmlns:q="urn:q" xmlns:z="urn:z"><n xmlns:xsi="urn:xsi" xsi:type="q:Item"/></mc:Fallback></mc:AlternateContent></r>"#;
|
||||
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#"<a:x xmlns:a="urn:a" xmlns:b="urn:b"><b:y/></a:x>"#).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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user