mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Charts: category axis orientation (maxMin) read, written and drawn; horizontal bars run bottom-up
This commit is contained in:
@@ -178,6 +178,9 @@ pub struct Chart {
|
||||
pub y_format: Option<String>,
|
||||
/// The value axis is deleted (`c:delete`): no labels, though gridlines may stay.
|
||||
pub y_hidden: bool,
|
||||
/// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a
|
||||
/// horizontal bar chart top to bottom (the default puts the first category at the bottom).
|
||||
pub cat_reversed: bool,
|
||||
/// A combo chart's secondary value axis: bounds, major unit and number format.
|
||||
pub y2_min: Option<f64>,
|
||||
pub y2_max: Option<f64>,
|
||||
@@ -219,6 +222,7 @@ impl Default for Chart {
|
||||
y_major: None,
|
||||
y_format: None,
|
||||
y_hidden: false,
|
||||
cat_reversed: false,
|
||||
y2_min: None,
|
||||
y2_max: None,
|
||||
y2_major: None,
|
||||
|
||||
@@ -282,6 +282,7 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.y_hidden = deleted(v);
|
||||
}
|
||||
if let Some(a) = cat_ax {
|
||||
c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin");
|
||||
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
}
|
||||
// Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to
|
||||
@@ -399,6 +400,13 @@ mod tests {
|
||||
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))));
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! PPTX import: package → [`Presentation`].
|
||||
|
||||
mod chart;
|
||||
pub(crate) mod chart;
|
||||
pub(crate) mod dml;
|
||||
mod shapes;
|
||||
mod theme;
|
||||
|
||||
@@ -251,7 +251,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.open0("c:catAx");
|
||||
w.val("c:axId", AX_CAT);
|
||||
w.open0("c:scaling");
|
||||
w.val("c:orientation", "minMax");
|
||||
w.val("c:orientation", if c.cat_reversed { "maxMin" } else { "minMax" });
|
||||
w.close("c:scaling");
|
||||
w.val("c:delete", 0);
|
||||
w.val("c:axPos", cat_pos);
|
||||
@@ -479,3 +479,19 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
|
||||
w.close("c:chartSpace");
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
|
||||
#[test]
|
||||
fn reversed_category_axis_round_trips() {
|
||||
let mut c = Chart::sample(ChartType::Bar);
|
||||
c.cat_reversed = true;
|
||||
let xml = super::chart_xml(&c);
|
||||
assert!(String::from_utf8_lossy(&xml).contains(r#"<c:orientation val="maxMin"/>"#));
|
||||
assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed);
|
||||
c.cat_reversed = false;
|
||||
assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c)).unwrap().cat_reversed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,10 +382,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
|
||||
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
|
||||
// Where category `i`'s band starts along its axis: left to right, or bottom to top on a
|
||||
// horizontal bar chart, unless the axis is reversed.
|
||||
let backwards = horiz != c.cat_reversed;
|
||||
let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 } else { i as f64 };
|
||||
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
|
||||
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
|
||||
for (i, name) in c.categories.iter().enumerate() {
|
||||
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
|
||||
let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) };
|
||||
if horiz {
|
||||
label(
|
||||
ctx,
|
||||
@@ -440,7 +444,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
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 + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
|
||||
let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
|
||||
let (a, b) = if stacked {
|
||||
if v >= 0.0 {
|
||||
let r = (pos_acc, pos_acc + v);
|
||||
@@ -513,9 +517,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (slot(i) + 0.5) }
|
||||
}
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
_ => plot.x0 + band * (slot(i) + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_axis(sec, v)));
|
||||
}
|
||||
|
||||
@@ -198,6 +198,26 @@ fn column_points_take_their_own_fill_and_smooth_lines_curve() {
|
||||
assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() {
|
||||
let draw = |reversed: bool| {
|
||||
let mut c = Chart::sample(ChartType::Bar);
|
||||
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![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))];
|
||||
c.cat_reversed = reversed;
|
||||
let sh = Shape { id: ShapeId(623), 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 rows: Vec<u32> = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect();
|
||||
(rows[0] + rows[rows.len() - 1]) / 2
|
||||
};
|
||||
assert!(draw(false) > 360, "first category at the bottom: {}", draw(false));
|
||||
assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
|
||||
Reference in New Issue
Block a user