From 7f1ebc2a5c251c4d4899e44a1c13eaed59ab4624 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:02:27 +0000 Subject: [PATCH] Refactor render for strict Clippy checks --- crates/render/src/chart.rs | 936 ++++++++++++++++++++------------ crates/render/src/chartex.rs | 102 +++- crates/render/src/opts.rs | 2 +- crates/render/src/radar.rs | 21 +- crates/render/src/renderer.rs | 318 ++++++++--- crates/render/src/slide.rs | 5 +- crates/render/src/tests.rs | 16 +- crates/render/src/text_draw.rs | 12 +- crates/render/src/transition.rs | 2 +- 9 files changed, 943 insertions(+), 471 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 626c2d91..cb96dba9 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -147,18 +147,16 @@ fn split_label(name: &str) -> String { } } -pub(crate) fn label( - ctx: &mut RenderContext, - rctx: &Ctx, - m: Affine, - text: &str, - r: Rect, - size: f64, - align: Align, - anchor: Anchor, - col: Rgba, - bold: bool, -) { +pub(crate) struct LabelStyle { + pub(crate) size: f64, + pub(crate) align: Align, + pub(crate) anchor: Anchor, + pub(crate) col: Rgba, + pub(crate) bold: bool, +} + +pub(crate) fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, style: LabelStyle) { + let LabelStyle { size, align, anchor, col, bold } = style; let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -178,7 +176,14 @@ pub(crate) fn label( } } let tmp = Shape { text: Some(body.clone()), ..Default::default() }; - crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None); + crate::draw_text( + ctx, + rctx, + &tmp, + &body, + m, + &deckcraft_text::Opts { rect: r, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); } fn fmt_num(v: f64) -> String { @@ -246,15 +251,24 @@ fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator { pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { match c.kind { // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. - ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::Histogram | ChartType::Pareto => { + draw_inner(ctx, rctx, &c.histogram_view(), ChartFrame { transform: m, width: w, height: h, boxes: None }) + } ChartType::BoxWhisker => { let (view, boxes) = crate::chartex::box_view(c); - draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + draw_inner(ctx, rctx, &view, ChartFrame { transform: m, width: w, height: h, boxes: Some(&boxes) }); } - _ => draw_inner(ctx, rctx, c, m, w, h, None), + _ => draw_inner(ctx, rctx, c, ChartFrame { transform: m, width: w, height: h, boxes: None }), } } +struct ChartFrame<'a> { + transform: Affine, + width: f64, + height: f64, + boxes: Option<&'a crate::chartex::Boxes>, +} + /// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which /// is 595959 on a light theme and a light grey on a dark one. pub fn default_text_color() -> ColorRef { @@ -262,8 +276,8 @@ pub fn default_text_color() -> ColorRef { ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)) } -#[allow(clippy::too_many_arguments)] -fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame<'_>) { + let ChartFrame { transform: m, width: w, height: h, boxes } = frame; // Text without a colour of its own takes PowerPoint's default, the theme's text colour at // lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart // on a dark theme is readable (a fixed grey used to vanish there). @@ -289,128 +303,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let title_size = c.title_size.unwrap_or(base * 1.4); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); if let Some(t) = &c.title { - label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false); + label( + ctx, + rctx, + m, + t, + Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), + LabelStyle { size: title_size, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + ); area.y0 += title_size * 1.7; } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. let waterfall = c.kind == ChartType::Waterfall; // Each legend entry: its name, its key's paint and the key's outline. - let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall { - ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { - // A hierarchy's legend names its top-level groups. - c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { - // A map draws its own colour scale. - vec![] - } else if pie { - c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else { - c.series - .iter() - .enumerate() - .map(|(i, s)| { - let k = c.series_kind(s); - let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - if line { - (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) - } else if k.is_bar_like() { - let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { - Paint::Color(series_color(rctx, c, i)) - } else { - paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) - }; - (s.name.clone(), paint, bar_outline(rctx, s)) - } else { - (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) - } - }) - .collect() - }; - if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { - let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); - let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); - let size = if vertical { - (item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5) - } else { - (item_ws.iter().sum::().min(w * 0.9), base * 1.4) - }; - let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); - area = plot_area; - let (mut x, mut y) = (origin.x, origin.y); - for (name, paint, outline) in &legend_items { - let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); - fill_with(ctx, paint, key, m); - if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { - ctx.set_transform(m); - ctx.set_paint(color(oc, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); - ctx.stroke_path(&key.to_path(0.1)); - } - let tw = name.chars().count() as f64 * base * 0.55 + base; - label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); - if vertical { - y += base * 1.5; - } else { - x += tw + base * 0.6; - } - } - } + let legend_items = build_legend_items(rctx, c, pie, waterfall); + area = draw_legend(ctx, &LegendDraw { rctx, chart: c, transform: m, items: &legend_items, base, text_color: text_col, width: w }, area); if pie { - let Some(s) = c.series.first() else { return }; - let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); - let total: f64 = vals.iter().sum(); - if total <= 0.0 { - return; - } - let r = (area.width().min(area.height()) / 2.0) * 0.95; - let cen = area.center(); - let mut a0 = -std::f64::consts::FRAC_PI_2; - for (i, v) in vals.iter().enumerate() { - let sweep = v / total * std::f64::consts::TAU; - let mut p = BezPath::new(); - p.move_to(cen); - let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); - p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); - p.extend(arc.append_iter(0.1)); - p.close_path(); - ctx.set_transform(m); - let col = s - .point_fills - .iter() - .find(|(k, _)| *k == i) - .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) - .unwrap_or_else(|| series_color(rctx, c, i)); - ctx.set_paint(color(col, 1.0)); - ctx.fill_path(&p); - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(1.0)); - ctx.stroke_path(&p); - if c.series_data_labels(s) { - let mid = a0 + sweep / 2.0; - let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); - label( - ctx, - rctx, - m, - &c.data_label(s, *v), - Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), - base, - Align::Center, - Anchor::Middle, - // On the slices themselves: white, unless the chart's text was given a colour. - if c.text_color.is_some() { text_col } else { Rgba::WHITE }, - false, - ); - } - a0 += sweep; - } - if c.kind == ChartType::Doughnut { - ctx.set_transform(m); - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); - } + draw_pie(ctx, rctx, c, m, area, base, text_col); return; } if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { @@ -425,23 +335,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; - // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. - // On a horizontal bar chart the category axis is the left one. - let (left_title, bottom_title) = - if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; - let band_h = base * 1.6; - if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { - let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); - label(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true); - area.y1 -= band_h + base * 0.3; - } - if let Some(t) = left_title.filter(|t| !t.is_empty()) { - let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); - let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); - let half = area.height() / 2.0; - label(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true); - area.x0 += band_h + base * 0.3; - } + area = draw_axis_titles(ctx, rctx, &AxisTitleDrawing { chart: c, transform: m, base, text_color: text_col, horiz }, area); // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; @@ -492,6 +386,265 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(), _ => to_v(v), }; + draw_gridlines( + ctx, + &GridlineDrawing { + rctx, + chart: c, + transform: m, + area, + plot, + horiz, + base, + text_color: text_col, + grid_color: grid_col, + min, + max, + step, + log, + primary_ticks: &primary_ticks, + second, + to_v: &to_v, + to_axis: &to_axis, + }, + ); + let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; + // 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. The forecast's empty slots stay on + // their own side of the data. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); + draw_category_labels( + ctx, + &CategoryLabelDrawing { rctx, chart: c, transform: m, area, plot, horiz, band, slot: &slot, xscale, base, text_color: text_col }, + ); + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(clamp_soft(0.0, min, max)), + }; + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks = draw_bars( + ctx, + &BarDrawing { + rctx, + chart: c, + transform: m, + plot, + band, + horiz, + ncat, + bar_kind, + waterfall, + slot: &slot, + to_v: &to_v, + to_axis: &to_axis, + base, + text_color: text_col, + }, + ); + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } + } + draw_lines( + ctx, + &mut LineDrawing { + chart: c, + rctx, + transform: m, + plot, + category_count: ncat, + band, + slot: &slot, + to_x: &to_x, + to_axis: &to_axis, + zero, + base, + error_marks: &mut err_marks, + }, + ); + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, TrendlineLayout { plot, band, slot: &slot_at, xscale, ncat, horiz, base, text_col, to_axis: &to_axis }); + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } + // Axis line. + let mut ax = BezPath::new(); + if horiz { + ax.move_to((zero, plot.y0)); + ax.line_to((zero, plot.y1)); + } else { + ax.move_to((plot.x0, zero)); + ax.line_to((plot.x1, zero)); + } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&ax); +} + +struct LegendDraw<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + items: &'a [(String, Paint, Option<(Rgba, f64)>)], + base: f64, + text_color: Rgba, + width: f64, +} + +fn draw_legend(ctx: &mut RenderContext, draw: &LegendDraw<'_, '_>, mut area: Rect) -> Rect { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let legend_items = draw.items; + let base = draw.base; + let text_col = draw.text_color; + let width = draw.width; + let Some(pos) = c.legend.as_ref().filter(|_| !legend_items.is_empty()) else { return area }; + let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); + let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let size = if vertical { + (item_ws.iter().copied().fold(0.0, f64::max).min(width * 0.4), legend_items.len() as f64 * base * 1.5) + } else { + (item_ws.iter().sum::().min(width * 0.9), base * 1.4) + }; + let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); + area = plot_area; + let (mut x, mut y) = (origin.x, origin.y); + for (name, paint, outline) in legend_items { + let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); + fill_with(ctx, paint, key, m); + if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { + ctx.set_transform(m); + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); + ctx.stroke_path(&key.to_path(0.1)); + } + let tw = name.chars().count() as f64 * base * 0.55 + base; + label( + ctx, + rctx, + m, + name, + Rect::new(x + base, y, x + base + tw, y + base * 1.3), + LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + ); + if vertical { + y += base * 1.5; + } else { + x += tw + base * 0.6; + } + } + area +} + +fn build_legend_items(rctx: &Ctx, c: &Chart, pie: bool, waterfall: bool) -> Vec<(String, Paint, Option<(Rgba, f64)>)> { + if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + // A hierarchy's legend names its top-level groups. + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { + // A map draws its own colour scale. + vec![] + } else if pie { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else { + c.series + .iter() + .enumerate() + .map(|(i, s)| { + let k = c.series_kind(s); + let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + if line { + (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) + } else if k.is_bar_like() { + let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { + Paint::Color(series_color(rctx, c, i)) + } else { + paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) + }; + (s.name.clone(), paint, bar_outline(rctx, s)) + } else { + (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) + } + }) + .collect() + } +} + +struct GridlineDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + area: Rect, + plot: Rect, + horiz: bool, + base: f64, + text_color: Rgba, + grid_color: Rgba, + min: f64, + max: f64, + step: f64, + log: Option<(f64, (f64, f64, f64))>, + primary_ticks: &'a [f64], + second: Option<(f64, f64, f64)>, + to_v: &'a dyn Fn(f64) -> f64, + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn draw_gridlines(ctx: &mut RenderContext, draw: &GridlineDrawing<'_, '_>) { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let area = draw.area; + let plot = draw.plot; + let horiz = draw.horiz; + let base = draw.base; + let text_col = draw.text_color; + let grid_col = draw.grid_color; + let min = draw.min; + let max = draw.max; + let step = draw.step; + let log = draw.log; + let primary_ticks = draw.primary_ticks; + let second = draw.second; + let to_v = draw.to_v; + let to_axis = draw.to_axis; + let ticks = axis_ticks; // Minor gridlines, under the major ones: five to a major unit. if c.minor_gridlines { let minor = step / 5.0; @@ -515,7 +668,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, ctx.stroke_path(&g); } // Gridlines and value labels. - for &v in &primary_ticks { + for &v in primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -528,11 +681,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } else { g.move_to((plot.x0, pos)); @@ -543,11 +692,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), - base, - Align::Right, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } if c.gridlines { @@ -567,29 +712,39 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &c.axis_label(true, v), Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), - base, - Align::Left, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, ); } } - let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - // Forecast reserves empty category slots so the line can run past the data. Charts with no - // forecast keep the old band. - let (back, fwd) = forecast_pad(c); - let nspan = ncat as f64 + back + fwd; - let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; - // 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. The forecast's empty slots stay on - // their own side of the data. - let backwards = horiz != c.cat_reversed; - let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; - // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its - // own tick labels in place of category names. - let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; - let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); +} + +struct CategoryLabelDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + area: Rect, + plot: Rect, + horiz: bool, + band: f64, + slot: &'a dyn Fn(usize) -> f64, + xscale: Option<(f64, f64, f64)>, + base: f64, + text_color: Rgba, +} + +fn draw_category_labels(ctx: &mut RenderContext, draw: &CategoryLabelDrawing<'_, '_>) { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let area = draw.area; + let plot = draw.plot; + let horiz = draw.horiz; + let band = draw.band; + let slot = draw.slot; + let xscale = draw.xscale; + let base = draw.base; + let text_col = draw.text_color; + let ticks = axis_ticks; if let Some(sc) = xscale { for v in ticks(sc) { let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width(); @@ -599,11 +754,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &fmt_num(v), Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } } @@ -618,11 +769,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, name, Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base), - base, - Align::Right, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } else { let text = if wraps { split_label(name) } else { name.clone() }; @@ -632,23 +779,181 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } } - if let Some(b) = boxes { - crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); - return; +} + +struct AxisTitleDrawing<'a> { + chart: &'a Chart, + transform: Affine, + base: f64, + text_color: Rgba, + horiz: bool, +} + +fn draw_axis_titles(ctx: &mut RenderContext, rctx: &Ctx, draw: &AxisTitleDrawing<'_>, mut area: Rect) -> Rect { + let c = draw.chart; + let m = draw.transform; + let base = draw.base; + let text_col = draw.text_color; + let horiz = draw.horiz; + // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. + // On a horizontal bar chart the category axis is the left one. + let (left_title, bottom_title) = + if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; + let band_h = base * 1.6; + if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { + let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); + label(ctx, rctx, m, t, r, LabelStyle { size: base, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: true }); + area.y1 -= band_h + base * 0.3; } - let zero = match log { - Some((b, _)) => to_v(b.powf(min)), - None => to_v(clamp_soft(0.0, min, max)), - }; - // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + if let Some(t) = left_title.filter(|t| !t.is_empty()) { + let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); + let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); + let half = area.height() / 2.0; + label( + ctx, + rctx, + turned, + t, + Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: text_col, bold: true }, + ); + area.x0 += band_h + base * 0.3; + } + area +} + +struct BarDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + plot: Rect, + band: f64, + horiz: bool, + ncat: usize, + bar_kind: Option, + waterfall: bool, + slot: &'a dyn Fn(usize) -> f64, + to_v: &'a dyn Fn(f64) -> f64, + to_axis: &'a dyn Fn(bool, f64) -> f64, + base: f64, + text_color: Rgba, +} + +struct LineDrawing<'a, 'ctx> { + chart: &'a Chart, + rctx: &'a Ctx<'ctx>, + transform: Affine, + plot: Rect, + category_count: usize, + band: f64, + slot: &'a dyn Fn(usize) -> f64, + to_x: &'a dyn Fn(f64) -> Option, + to_axis: &'a dyn Fn(bool, f64) -> f64, + zero: f64, + base: f64, + error_marks: &'a mut Vec<(f64, f64, f64, f64, bool)>, +} + +fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) { + let c = draw.chart; + let rctx = draw.rctx; + let m = draw.transform; + let plot = draw.plot; + let ncat = draw.category_count; + let band = draw.band; + let slot = draw.slot; + let to_x = draw.to_x; + let to_axis = draw.to_axis; + let zero = draw.zero; + let base = draw.base; + let err_marks = &mut draw.error_marks; + let mut acc = vec![0.0; ncat]; + for (si, s) in c.series.iter().enumerate() { + let kind = c.series_kind(s); + if kind.is_bar_like() || kind == ChartType::Stock { + continue; + } + let sec = c.on_secondary(s); + let stacked = kind.stacked(); + let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; + let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); + let mut pts = vec![]; + for i in 0..ncat { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let v = if stacked { + let base_v = acc.get(i).copied().unwrap_or(0.0); + if let Some(a) = acc.get_mut(i) { + *a += v; + } + base_v + v + } else { + v + }; + let x = match kind { + ChartType::Scatter | ChartType::Bubble => { + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, Some(_)) => continue, + } + } + _ => plot.x0 + band * (slot(i) + 0.5), + }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } + pts.push(Point::new(x, to_axis(sec, v))); + } + if pts.is_empty() { + continue; + } + 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(); + if let (Some(l), Some(f)) = (pts.last(), pts.first()) { + a.line_to((l.x, zero)); + a.line_to((f.x, zero)); + a.close_path(); + } + ctx.set_paint(color(col, 0.85)); + ctx.fill_path(&a); + } else if kind != ChartType::Scatter { + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&p); + } + if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { + ctx.set_paint(color(col, 1.0)); + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } + } + } +} + +fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f64, f64, f64, bool)> { + let c = draw.chart; + let rctx = draw.rctx; + let m = draw.transform; + let plot = draw.plot; + let band = draw.band; + let horiz = draw.horiz; + let ncat = draw.ncat; + let bar_kind = draw.bar_kind; + let waterfall = draw.waterfall; + let slot = draw.slot; + let to_v = draw.to_v; + let to_axis = draw.to_axis; + let base = draw.base; + let text_col = draw.text_color; let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = @@ -748,124 +1053,77 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &t, lr, - base * 0.9, - if horiz { Align::Left } else { Align::Center }, - if horiz { Anchor::Middle } else { Anchor::Bottom }, - text_col, - false, + LabelStyle { + size: base * 0.9, + align: if horiz { Align::Left } else { Align::Center }, + anchor: if horiz { Anchor::Middle } else { Anchor::Bottom }, + col: text_col, + bold: false, + }, ); } } } } - // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. - if c.kind == ChartType::Stock { - for i in 0..ncat { - crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); - } + err_marks +} + +fn draw_pie(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if total <= 0.0 { + return; } - // Lines, areas, scatter: the series that aren't bars. - let mut acc = vec![0.0; ncat]; - for (si, s) in c.series.iter().enumerate() { - let kind = c.series_kind(s); - if kind.is_bar_like() || kind == ChartType::Stock { - continue; - } - let sec = c.on_secondary(s); - let stacked = kind.stacked(); - let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; - let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); - let mut pts = vec![]; - for i in 0..ncat { - let Some(v) = s.values.get(i).copied().flatten() else { continue }; - let v = if stacked { - let base_v = acc.get(i).copied().unwrap_or(0.0); - if let Some(a) = acc.get_mut(i) { - *a += v; - } - base_v + v - } else { - v - }; - let x = match kind { - ChartType::Scatter | ChartType::Bubble => { - match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { - (Some(x), _) => x, - // No x values at all: the points are numbered 1, 2, 3... - (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), - (None, Some(_)) => continue, - } - } - _ => plot.x0 + band * (slot(i) + 0.5), - }; - if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { - err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); - } - pts.push(Point::new(x, to_axis(sec, v))); - } - if pts.is_empty() { - continue; - } - 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(); - if let (Some(l), Some(f)) = (pts.last(), pts.first()) { - a.line_to((l.x, zero)); - a.line_to((f.x, zero)); - a.close_path(); - } - ctx.set_paint(color(col, 0.85)); - ctx.fill_path(&a); - } else if kind != ChartType::Scatter { - let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); - ctx.set_paint(color(col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); - ctx.stroke_path(&p); - } - if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { - ctx.set_paint(color(col, 1.0)); - for q in &pts { - ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); - } - } - } - // A category position (0-based, fractional) as a slot, the way `slot` places the bars. - let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; - draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); - if !err_marks.is_empty() { + let r = (area.width().min(area.height()) / 2.0) * 0.95; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; let mut p = BezPath::new(); - for &(mid, a, b, cap, end_cap) in &err_marks { - // A horizontal bar chart's error bars run across, its value axis being horizontal. - let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; - p.move_to(pt(a, mid)); - p.line_to(pt(b, mid)); - if end_cap { - for e in [a, b] { - p.move_to(pt(e, mid - cap)); - p.line_to(pt(e, mid + cap)); - } - } - } + p.move_to(cen); + let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(arc.append_iter(0.1)); + p.close_path(); ctx.set_transform(m); - ctx.set_paint(color(text_col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); + let col = s + .point_fills + .iter() + .find(|(k, _)| *k == i) + .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) + .unwrap_or_else(|| series_color(rctx, c, i)); + ctx.set_paint(color(col, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.stroke_path(&p); + if c.series_data_labels(s) { + let mid = a0 + sweep / 2.0; + let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); + label( + ctx, + rctx, + m, + &c.data_label(s, *v), + Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), + LabelStyle { + size: base, + align: Align::Center, + anchor: Anchor::Middle, + // On the slices themselves: white, unless the chart's text was given a colour. + col: if c.text_color.is_some() { text_col } else { Rgba::WHITE }, + bold: false, + }, + ); + } + a0 += sweep; } - // Axis line. - let mut ax = BezPath::new(); - if horiz { - ax.move_to((zero, plot.y0)); - ax.line_to((zero, plot.y1)); - } else { - ax.move_to((plot.x0, zero)); - ax.line_to((plot.x1, zero)); + if c.kind == ChartType::Doughnut { + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); } - ctx.set_transform(m); - ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); - ctx.stroke_path(&ax); } /// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than @@ -943,36 +1201,35 @@ fn forecast_pad(c: &Chart) -> (f64, f64) { const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; -fn map_xy( +struct XYMapper<'a> { plot: Rect, band: f64, - slot: &impl Fn(f64) -> f64, + slot: &'a dyn Fn(f64) -> f64, horiz: bool, scatter: bool, - sx0: f64, - sx1: f64, - sec: bool, - x: f64, - y: f64, - to_axis: &impl Fn(bool, f64) -> f64, -) -> Option { + scatter_x: (f64, f64), + secondary: bool, + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn map_xy(mapper: &XYMapper<'_>, x: f64, y: f64) -> Option { if !x.is_finite() || !y.is_finite() { return None; } - let value = to_axis(sec, y); + let value = (mapper.to_axis)(mapper.secondary, y); if !value.is_finite() { return None; } - let along = if scatter && sx1 > sx0 { - plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + let along = if mapper.scatter && mapper.scatter_x.1 > mapper.scatter_x.0 { + mapper.plot.x0 + (x - mapper.scatter_x.0) / (mapper.scatter_x.1 - mapper.scatter_x.0) * mapper.plot.width() } else { - let origin = if horiz { plot.y0 } else { plot.x0 }; - origin + band * (slot(x - 1.0) + 0.5) + let origin = if mapper.horiz { mapper.plot.y0 } else { mapper.plot.x0 }; + origin + mapper.band * ((mapper.slot)(x - 1.0) + 0.5) }; if !along.is_finite() { return None; } - Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) + Some(if mapper.horiz && !mapper.scatter { Point::new(value, along) } else { Point::new(along, value) }) } /// The part of segment `a`–`b` that lies inside `r`. @@ -1006,21 +1263,20 @@ fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) } -fn draw_trendlines( - ctx: &mut RenderContext, - rctx: &Ctx, - c: &Chart, - m: Affine, +struct TrendlineLayout<'a> { plot: Rect, band: f64, - slot: &impl Fn(f64) -> f64, + slot: &'a dyn Fn(f64) -> f64, xscale: Option<(f64, f64, f64)>, ncat: usize, horiz: bool, base: f64, text_col: Rgba, - to_axis: &impl Fn(bool, f64) -> f64, -) { + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, layout: TrendlineLayout<'_>) { + let TrendlineLayout { plot, band, slot, xscale, ncat, horiz, base, text_col, to_axis } = layout; let mut label_n = 0usize; for s in &c.series { if s.trendlines.is_empty() { @@ -1057,6 +1313,7 @@ fn draw_trendlines( } else { (0.0, 0.0) }; + let mapper = XYMapper { plot, band, slot, horiz, scatter, scatter_x: (sx0, sx1), secondary: sec, to_axis }; for (ti, t) in s.trendlines.iter().take(8).enumerate() { let Some(fit) = t.fit(&xs, &ys) else { continue }; let (x0, x1) = fit.span(t.forward, t.backward); @@ -1066,10 +1323,7 @@ fn draw_trendlines( let mut ink_pts = Vec::new(); let mut any = false; for w in samples.windows(2) { - let (Some(a), Some(b)) = ( - map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), - map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), - ) else { + let (Some(a), Some(b)) = (map_xy(&mapper, w[0].0, w[0].1), map_xy(&mapper, w[1].0, w[1].1)) else { prev = None; continue; }; @@ -1117,11 +1371,7 @@ fn draw_trendlines( m, &caption, Rect::new(x, y, x + box_w, y + box_h), - (base * 0.9).clamp(8.0, 13.0), - Align::Left, - Anchor::Top, - text_col, - false, + LabelStyle { size: (base * 0.9).clamp(8.0, 13.0), align: Align::Left, anchor: Anchor::Top, col: text_col, bold: false }, ); label_n += 1; } diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index 55350d5f..9a9a66b8 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -62,9 +62,23 @@ fn funnel( ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); ctx.fill_rect(&r); if let Some(name) = c.categories.get(i) { - label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + name, + Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), + crate::chart::LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } - label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &value_label(*v), + Rect::new(cx - 60.0, y, cx + 60.0, y + band), + crate::chart::LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, + ); } } @@ -118,7 +132,20 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: } let inner = t.inset(-base * 0.25); if inner.width() > base && inner.height() > base * 0.8 { - label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { + size: base * 0.9, + align: Align::Center, + anchor: Anchor::Middle, + col: if dark { Rgba::WHITE } else { text_col }, + bold: false, + }, + ); } } } @@ -136,9 +163,23 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1)); } let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2); - label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + &c.data_label(s, min), + ly, + crate::chart::LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + ); let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2); - label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + &c.data_label(s, max), + ry, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1); label( @@ -147,11 +188,13 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: m, "Map preview unavailable: regions shown as tiles", note, - base * 0.85, - Align::Center, - Anchor::Middle, - mix(text_col, Rgba::WHITE, 0.4), - false, + crate::chart::LabelStyle { + size: base * 0.85, + align: Align::Center, + anchor: Anchor::Middle, + col: mix(text_col, Rgba::WHITE, 0.4), + bold: false, + }, ); } @@ -248,7 +291,14 @@ fn treemap( } let inner = t.inset(-base * 0.3); if inner.width() > base && inner.height() > base { - label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Top, col: Rgba::WHITE, bold: false }, + ); } } } @@ -289,11 +339,7 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, name, Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), - base, - Align::Center, - Anchor::Middle, - Rgba::WHITE, - false, + crate::chart::LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, ); } a0 = a1; @@ -344,7 +390,14 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: } let inner = t.inset(-base * 0.3); if inner.width() > base && inner.height() > base { - label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Left, anchor: Anchor::Top, col: Rgba::WHITE, bold: false }, + ); } continue; } @@ -355,7 +408,14 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&t); if t.width() > base * 2.0 && banner > base * 0.8 { let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner); - label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true); + label( + ctx, + rctx, + m, + &n.name, + head, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: Rgba::WHITE, bold: true }, + ); } let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0); let k = lighten(fill, 0.12 * (depth + 1) as f64); @@ -437,11 +497,7 @@ fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area m, &n.name, Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base), - base * 0.9, - Align::Center, - Anchor::Middle, - Rgba::WHITE, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, ); } if !n.children.is_empty() { diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs index c6579b9b..898d84cc 100644 --- a/crates/render/src/opts.rs +++ b/crates/render/src/opts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DateTime, Fields, Rgba, ShapeId, Vec2, datetime}; /// Animation state of one shape during a slide show or preview. #[derive(Clone, Debug)] diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index e9c9255c..9e8df451 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -67,7 +67,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec Align::Right => q.x - w + base * 0.3, _ => q.x - w / 2.0, }; - label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + name, + Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), + crate::chart::LabelStyle { size: base, align, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } } // Value labels up the first spoke. @@ -83,11 +90,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, &t, Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6), - base * 0.9, - Align::Right, - Anchor::Middle, - text_col, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } } @@ -130,11 +133,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, &t, Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2), - base * 0.9, - Align::Center, - Anchor::Bottom, - text_col, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: false }, ); } } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 510cb6c5..fdabb213 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -60,6 +60,50 @@ pub(crate) struct Frame<'a> { pub(crate) fields: SlideFields, } +/// A shape being drawn: its resolved geometry, fill, line and effects, in shape space `m` at size +/// `w` x `h`, shared by the stages of `Renderer::geometry_shape`. +struct ShapeDraw<'a, 'ctx> { + f: &'a Frame<'a>, + rctx: &'a Ctx<'ctx>, + s: &'a Shape, + st: &'a ShapeState, + m: Affine, + w: f64, + h: f64, + depth: usize, + geo: Geometry, + fades: Vec, + fill: Option, + fill_ph: Option, + line: Line, + line_ph: Option, + effects: Option, + fx_ph: Option, + outline: BezPath, + is_pic: bool, + empty_ph: bool, +} + +impl ShapeDraw<'_, '_> { + /// How much of the fill and stroke of path `i` shows (morphing outlines fade). + fn fade(&self, i: usize) -> deckcraft_geom::morph::Fade { + self.fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }) + } +} + +/// What casts a shape's shadow, glow and reflection: its geometry, fill and outline, in shape +/// space `m` at size `w` x `h`. +struct FxSource<'a> { + geo: &'a Geometry, + fill: &'a Option, + fill_alpha: Option, + outline: Option>, + content: Option<&'a deckcraft_model::style::PictureFill>, + m: Affine, + w: f64, + h: f64, +} + impl Renderer { fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { let (w, h) = output_size(pres, opts); @@ -541,7 +585,6 @@ impl Renderer { Some(pm) => self.morphs.geometry(pm, s, w, h), None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), }; - let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); let (fill, fill_ph) = resolve::fill(rctx, s); let (line, line_ph) = resolve::line(rctx, s); let (effects, fx_ph) = resolve::effects(rctx, s); @@ -552,6 +595,56 @@ impl Renderer { // Placeholders in the editor: dashed outline and prompt, nothing else when empty. let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; + let d = ShapeDraw { f, rctx, s, st, m, w, h, depth, geo, fades, fill, fill_ph, line, line_ph, effects, fx_ph, outline, is_pic, empty_ph }; + self.cast_effects(ctx, &d); + let soft = d.effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); + if let Some(r) = soft { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + let _ = r; + } + let pt = self.point_scale(&d); + self.draw_extrusion(ctx, &d, pt); + self.draw_contour(ctx, &d, pt); + self.draw_fill(ctx, &d); + self.draw_fill_overlay(ctx, &d); + self.draw_bevel(ctx, &d, pt); + self.draw_outline(ctx, &d); + if let Some(r) = soft { + self.draw_feather(ctx, &d, r); + } + self.draw_inner_shadow(ctx, &d); + self.draw_text_body(ctx, &d); + } + + /// Shape-space units per point for the 3-D sizes, measured without the camera's turn (which + /// `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. + fn point_scale(&self, d: &ShapeDraw) -> f64 { + let turn = d + .effects + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, d.w, d.h)) + .filter(|t| t.determinant().abs() > 1e-9); + self.per_point(turn.map_or(d.m, |t| d.m * t.inverse()), d.depth) + } + + fn cast_effects(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let fill_ph = d.fill_ph; + let line_ph = d.line_ph; + let fx_ph = d.fx_ph; + let is_pic = d.is_pic; + let s = d.s; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let line = &d.line; // Effects below the shape. if let Some(e) = &effects { // A picture (or picture fill) casts its shadow from its pixels, not its frame. @@ -560,7 +653,7 @@ impl Renderer { _ => None, }; // What casts the shadow: the fill as see-through as it is, and the outline. - let fill_alpha = fill_opacity(rctx, &fill, fill_ph); + let fill_alpha = fill_opacity(rctx, fill, fill_ph); let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { let a = match lf { Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, @@ -568,28 +661,22 @@ impl Renderer { }; let (base, rel) = self.line_space(m, depth); let lw = line.width.unwrap_or(0.75).max(0.0); - let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; - Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } + let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } }); - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); + self.effects_below(ctx, rctx, e, fx_ph, FxSource { geo, fill, fill_alpha, outline, content: content.as_ref(), m, w, h }); } - let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); - if let Some(r) = soft { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, None, None, None); - let _ = r; - } - // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which - // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. - let pt = { - let turn = effects - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, w, h)) - .filter(|t| t.determinant().abs() > 1e-9); - self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) - }; + } + + fn draw_extrusion(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let rctx = d.rctx; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; // A 3-D extrusion under a turned camera: the sides, swept behind the face. if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(ex) = extrude::from_raw(raw) @@ -604,8 +691,17 @@ impl Renderer { let pre = m * turn.inverse(); let lit = light::Lighting::from_raw(raw); let d = ex.depth * pt; - extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); + extrude::draw(ctx, outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); } + } + + fn draw_contour(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let fill_ph = d.fill_ph; + let rctx = d.rctx; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; // A 3-D contour: a band around the outline, behind the face (and in front of any sides). if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(c) = extrude::contour_from_raw(raw) @@ -615,8 +711,23 @@ impl Renderer { _ => None, }; let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); - extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); + extrude::draw_contour(ctx, outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); } + } + + fn draw_fill(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let empty_ph = d.empty_ph; + let s = d.s; + let f = d.f; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let outline = &d.outline; + let fade = |i: usize| d.fade(i); // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -625,11 +736,11 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); } ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); - ctx.push_clip_layer(&outline); + ctx.push_clip_layer(outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); ctx.pop_layer(); @@ -642,7 +753,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); } ctx.set_transform(m); let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; @@ -653,7 +764,7 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); if let Some(p) = mc.poster { let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; - ctx.push_clip_layer(&outline); + ctx.push_clip_layer(outline); paint::picture(ctx, f.pres, &pf, bounds, m, None); ctx.pop_layer(); } else if mc.video { @@ -668,10 +779,23 @@ impl Renderer { if let Some(fl) = &fill && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); } } } + } + + fn draw_fill_overlay(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fx_ph = d.fx_ph; + let f = d.f; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let fade = |i: usize| d.fade(i); // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply // a gradient over a tiled picture), inside the filled outline, under the line. if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) @@ -691,13 +815,19 @@ impl Renderer { ctx.pop_layer(); } } - // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). - let line = match (&s.geom, line.join) { - (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { - Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } - } - _ => line, - }; + } + + fn draw_bevel(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let is_pic = d.is_pic; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; // A 3-D bevel: the face and a band inside the outline lit as surfaces. if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; @@ -717,6 +847,29 @@ impl Renderer { bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); } } + } + + fn draw_outline(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let line_ph = d.line_ph; + let is_pic = d.is_pic; + let s = d.s; + let rctx = d.rctx; + let geo = &d.geo; + let effects = &d.effects; + let outline = &d.outline; + let line = d.line.clone(); + let fade = |i: usize| d.fade(i); + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); @@ -779,7 +932,7 @@ impl Renderer { stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); } if ends { - arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); + arrowheads(ctx, rctx, &line, line_ph, geo, rel, base, lw); } if clipped { ctx.pop_layer(); @@ -788,33 +941,46 @@ impl Renderer { ctx.pop_layer(); } } - if let Some(r) = soft { - // Feather: the shape (fill and line) fades to nothing at its outline. Measured on - // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of - // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. - ctx.set_transform(m); - ctx.push_layer( - None, - Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), - None, - None, - Some(blur(r / 3.0 * scale_of(m))), - ); - ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - ctx.fill_path(&outline); - ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); - ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); - ctx.stroke_path(&outline); - ctx.pop_layer(); - ctx.pop_layer(); - ctx.pop_layer(); - } + } + + fn draw_feather(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, r: f64) { + let m = d.m; + let outline = &d.outline; + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. + ctx.set_transform(m); + ctx.push_layer( + None, + Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), + None, + None, + Some(blur(r / 3.0 * scale_of(m))), + ); + ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + ctx.fill_path(outline); + ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + ctx.stroke_path(outline); + ctx.pop_layer(); + ctx.pop_layer(); + ctx.pop_layer(); + } + + fn draw_inner_shadow(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fx_ph = d.fx_ph; + let rctx = d.rctx; + let effects = &d.effects; + let outline = &d.outline; // Inner shadow. if let Some(e) = &effects && let Some(sh) = &e.inner_shadow { ctx.set_transform(m); - ctx.push_clip_layer(&outline); + ctx.push_clip_layer(outline); let c = rctx.color(&sh.color, fx_ph); let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; // As PowerPoint draws it (measured on its export): the shadow falls inside along the @@ -832,6 +998,19 @@ impl Renderer { ctx.pop_layer(); ctx.pop_layer(); } + } + + fn draw_text_body(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let empty_ph = d.empty_ph; + let s = d.s; + let f = d.f; + let rctx = d.rctx; + let st = d.st; + let geo = &d.geo; // Text (or the placeholder prompt in the editor). let skip_text = f.opts.skip_text == Some(s.id); if let Some(body) = &s.text @@ -842,7 +1021,7 @@ impl Renderer { let (tw, th, m) = self.text_space(m, depth, w, h); let text_geo; let geo = if (tw, th) == (w, h) { - &geo + geo } else { text_geo = shape_geometry(s, tw, th); &text_geo @@ -878,7 +1057,7 @@ impl Renderer { let prompt_body = prompt_body(rctx, s, kind); if !prompt_body.is_empty() { let grey = Rgba::rgb(0x59, 0x59, 0x59); - draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); + draw_text(ctx, rctx, s, &prompt_body, m, &Opts { rect: tr, fields: &f.fields, prompt_color: Some(grey), no_shrink: false }); } } } @@ -895,21 +1074,8 @@ impl Renderer { } } - fn effects_below( - &mut self, - ctx: &mut RenderContext, - rctx: &Ctx, - e: &Effects, - ph: Option, - geo: &Geometry, - fill: &Option, - fill_alpha: Option, - outline: Option, - content: Option<&deckcraft_model::style::PictureFill>, - m: Affine, - w: f64, - h: f64, - ) { + fn effects_below(&mut self, ctx: &mut RenderContext, rctx: &Ctx, e: &Effects, ph: Option, src: FxSource) { + let FxSource { geo, fill, fill_alpha, outline, content, m, w, h } = src; let silhouette = { let mut p = BezPath::new(); for sp in &geo.paths { diff --git a/crates/render/src/slide.rs b/crates/render/src/slide.rs index 77040d97..b3e6e5de 100644 --- a/crates/render/src/slide.rs +++ b/crates/render/src/slide.rs @@ -1,7 +1,7 @@ use super::opts::{RenderOpts, SlideFields}; use super::raster::{Image, MAX_SIDE}; use super::renderer::{Frame, RENDERER, Renderer}; -use super::*; +use super::{Affine, Ctx, PathMorph, Presentation, Rect, RenderContext, Shape, ShapeId, Slide, TextMorph, resolve}; static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -125,8 +125,7 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt /// to `shapes` only. pub fn render_blend( pres: &Presentation, - old: usize, - new: usize, + (old, new): (usize, usize), mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index bcddf4c9..cddf1e37 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -616,14 +616,14 @@ fn blend_ends_match_the_slides_and_mixes_backdrops() { let o = RenderOpts { scale: 0.5, ..Default::default() }; let shapes = vec![(sh, false)]; // At the ends the frame is the slide itself; the shape stays opaque throughout. - assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); - let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o); + assert_eq!(render_blend(&p, (0, 1), 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); + let mid = render_blend(&p, (0, 1), 0.5, &shapes, &[], &[], &o); let px = mid.pixel(5, 5); assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}"); assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75)); // Hostile input: bad indices and NaN never panic. - assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0); - let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o); + assert_eq!(render_blend(&p, (0, 9), 0.5, &shapes, &[], &[], &o).width, 0); + let _ = render_blend(&p, (0, 1), f64::NAN, &shapes, &[], &[], &o); } fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape { @@ -668,7 +668,7 @@ fn morph_text_ends_match_the_slides() { sb.xfrm = Some(x); sa.xfrm = Some(x); let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t }; - render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o) + render_blend(&p, (0, 1), t, &[(sb, false), (sa, true)], &[tm], &[], &o) }; // Warm up: faces load lazily (a background scan runs), so the first render may still fall back. let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5)); @@ -781,7 +781,7 @@ fn morph_path_ends_match_the_slides() { let lerp = |a: f64, b: f64| a + (b - a) * t; let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h)); let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; - render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o) + render_blend(&p, (0, 1), t, &[(form(10, "star5", x), false)], &[], &[pm], &o) }; // The morph splits the outlines into more curves, which flatten slightly differently: edge pixels // may differ a little, a different shape differs a lot. @@ -792,7 +792,7 @@ fn morph_path_ends_match_the_slides() { let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o)); assert!(d1 < 40, "end differs {d1}"); // Without the morph the start would show the star. - let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); + let star = render_blend(&p, (0, 1), 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); assert!(differ(&star, &render_slide(&p, 0, &o)) > 400); // Halfway the shape is filled around the middle of its box. let mid = frame(0.5); @@ -818,7 +818,7 @@ fn morph_path_fades_a_fill_only_one_side_has() { let at = |t: f64| { let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t }; // A point near the line but not on it: inside the shape all the way. - render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) + render_blend(&p, (0, 1), t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) }; let (full, half, none) = (at(0.0), at(0.5), at(1.0)); assert_eq!(none, [255, 255, 255, 255]); diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs index 095eedab..3fe96d69 100644 --- a/crates/render/src/text_draw.rs +++ b/crates/render/src/text_draw.rs @@ -1,6 +1,8 @@ use super::geometry::{blur, color, pre_translate, scale_of}; use super::opts::ParaState; -use super::*; +use super::{ + Affine, Arc, BezPath, Ctx, FontDb, HashMap, Opts, PhType, Rect, RenderContext, Rgba, Shape, TextBody, Vec2, colr, paint, peniko, resolve, +}; /// The prompt shown in an empty placeholder, formatted like its content would be. pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { @@ -53,10 +55,10 @@ pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc p } -/// Draw a text body into shape space `m` within the text rect. -pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option) { - let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); - draw_layout(ctx, &l, tr, m); +/// Draw a text body into shape space `m` within the text rect `opts.rect`. +pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, m: Affine, opts: &Opts) { + let l = deckcraft_text::layout(rctx, s, body, opts); + draw_layout(ctx, &l, opts.rect, m); } /// Add transient list markers to an editor layout, using the document's own bullet glyphs. diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs index ee760767..ca2a37e1 100644 --- a/crates/render/src/transition.rs +++ b/crates/render/src/transition.rs @@ -87,7 +87,7 @@ pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &Rende .iter() .map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) .collect(); - return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o); + return render_blend(pres, (from, index), m.mix, &m.shapes, &text, &paths, &o); } } let new = render_slide(pres, index, opts);