mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #638 from NikitkaRa: Charts: bar widths, column/bar point fills, smoothed lines and chart text colour as the file sets them
# Conflicts: # crates/pptx/src/read/chart.rs # crates/render/src/chart.rs
This commit is contained in:
@@ -198,11 +198,18 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
if let Some(v) = g.child_val("varyColors") {
|
||||
c.vary_colors = 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("barChart") || g.is("bar3DChart");
|
||||
if let Some(v) = g.child_val("gapWidth").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_val("overlap").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_val("holeSize").and_then(|v| v.parse::<f64>().ok()) {
|
||||
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
|
||||
@@ -326,6 +333,8 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
// the chart): the axes' and legend's, else the whole chart's; the title's own.
|
||||
let chart_size = text_size(root.child("txPr"));
|
||||
c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("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("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr")));
|
||||
c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr"))));
|
||||
Some(c)
|
||||
}
|
||||
@@ -357,6 +366,27 @@ fn trendline(e: &El) -> Option<Trendline> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`.
|
||||
fn text_color(e: Option<&El>) -> Option<deckcraft_model::ColorRef> {
|
||||
fn rec(e: &El, depth: usize) -> Option<deckcraft_model::ColorRef> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
for c in e.elements() {
|
||||
if (c.is("defRPr") || c.is("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> {
|
||||
@@ -475,6 +505,16 @@ mod tests {
|
||||
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(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255))));
|
||||
// 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
|
||||
|
||||
+47
-12
@@ -36,6 +36,43 @@ pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_m
|
||||
}
|
||||
}
|
||||
|
||||
/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'.
|
||||
fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba {
|
||||
match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_fill(ctx, c, i, Some(s)),
|
||||
}
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve
|
||||
/// PowerPoint draws, passing through every point.
|
||||
pub(crate) fn smooth_path(pts: &[Point]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
let Some(first) = pts.first() else { return p };
|
||||
p.move_to(*first);
|
||||
for k in 0..pts.len() - 1 {
|
||||
let p0 = pts[k.saturating_sub(1)];
|
||||
let (p1, p2) = (pts[k], pts[k + 1]);
|
||||
let p3 = pts[(k + 2).min(pts.len() - 1)];
|
||||
let c1 = p1 + (p2 - p0) / 6.0;
|
||||
let c2 = p2 - (p3 - p1) / 6.0;
|
||||
p.curve_to(c1, c2, p2);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
|
||||
/// palette's.
|
||||
pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
@@ -450,10 +487,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
|
||||
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
|
||||
let ns = bars.len().max(1);
|
||||
// Gap width and overlap are fractions of one bar's width: a category's band holds its bars
|
||||
// (overlapping by `overlap` of a bar between neighbours) plus one gap.
|
||||
let gap = c.gap_width.clamp(0.0, 5.0);
|
||||
let group = band / (1.0 + gap);
|
||||
let stacked = bk.stacked();
|
||||
let bar = if stacked { group } else { group / ns as f64 };
|
||||
let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) };
|
||||
let n = ns as f64;
|
||||
let bar = band / (n - (n - 1.0) * ov + gap);
|
||||
let step = bar * (1.0 - ov);
|
||||
let group = bar + step * (n - 1.0);
|
||||
for i in 0..ncat {
|
||||
let mut pos_acc = 0.0;
|
||||
let mut neg_acc = 0.0;
|
||||
@@ -465,7 +507,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
for (bi, (si, s)) in bars.iter().enumerate() {
|
||||
let sec = c.on_secondary(s);
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||||
let start = band * (i as f64 + back) + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let start = band * (i as f64 + back) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
|
||||
let (a, b) = if waterfall {
|
||||
c.waterfall_bars().get(i).copied().unwrap_or_default()
|
||||
} else if stacked {
|
||||
@@ -499,7 +541,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
};
|
||||
series_color(rctx, c, slot)
|
||||
} else {
|
||||
series_fill(rctx, c, *si, Some(s))
|
||||
point_fill(rctx, c, *si, s, i)
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
@@ -579,14 +621,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
}
|
||||
let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) };
|
||||
ctx.set_transform(m);
|
||||
if area_kind {
|
||||
let mut a = p.clone();
|
||||
|
||||
@@ -281,6 +281,49 @@ fn chart_axis_titles_are_drawn() {
|
||||
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_points_take_their_own_fill_and_smooth_lines_curve() {
|
||||
// One column series where only the middle point is given a red c:dPt fill.
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.series.truncate(1);
|
||||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||||
c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)];
|
||||
c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))];
|
||||
let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]);
|
||||
assert!(red(480), "the middle column takes its point's fill");
|
||||
assert!(!red(200) && !red(760), "the others keep the series colour");
|
||||
|
||||
// Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and
|
||||
// LibreOffice's geometry), not band / 2.5 split in two.
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.series.truncate(2);
|
||||
c.categories = vec!["A".into()];
|
||||
c.series[0].values = vec![Some(10.0)];
|
||||
c.series[1].values = vec![Some(10.0)];
|
||||
c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
(c.gap_width, c.overlap) = (1.5, 0.0);
|
||||
let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5;
|
||||
let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64;
|
||||
// The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band
|
||||
// was split as 1 + gap and then shared between the series).
|
||||
assert!((200.0..280.0).contains(&reds), "bar {reds} px");
|
||||
|
||||
// A smoothed line through a peak overshoots no point but leaves the straight chord.
|
||||
let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)];
|
||||
let p = crate::chart::smooth_path(&pts);
|
||||
assert_eq!(p.elements().len(), 3, "a cubic per segment");
|
||||
let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5);
|
||||
assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
|
||||
Reference in New Issue
Block a user