Merge remote-tracking branch 'origin/main' into review-1192

This commit is contained in:
MarekSpodBiedry
2026-11-04 07:51:04 +01:00
123 changed files with 6444 additions and 606 deletions
+25 -8
View File
@@ -1,5 +1,7 @@
//! Charts: title, plot area with axes and gridlines, series marks, legend.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::Shape;
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
@@ -85,9 +87,9 @@ fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
/// `a:noFill` leaves the bar empty, as in PowerPoint.
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series, fills: Option<&HashMap<usize, &Fill>>) -> Paint {
if let Some(f) = fills.and_then(|m| m.get(&pi)) {
return paint_of(ctx, Some(*f), series_fill(ctx, c, si, Some(s)));
}
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
return Paint::Color(series_color(ctx, c, pi));
@@ -883,6 +885,8 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
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![];
// Whether the series has any x value, found once rather than per point.
let any_x = s.x_values.iter().flatten().next().is_some();
for i in 0..ncat {
let Some(v) = s.values.get(i).copied().flatten() else { continue };
let v = if stacked {
@@ -896,11 +900,11 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
};
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()) {
match (s.x_values.get(i).copied().flatten().and_then(to_x), any_x) {
(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,
(None, false) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, true) => continue,
}
}
_ => plot.x0 + band * (slot(i) + 0.5),
@@ -970,6 +974,19 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let group = bar + step * (n - 1.0);
// Error bars need each series' mean and spread: worked out once, not per point.
let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect();
// Each series' per-point fills by point (the first for a point wins), not searched per bar.
let point_fills: Vec<HashMap<usize, &Fill>> = bars
.iter()
.map(|(_, s)| {
let mut m = HashMap::new();
for (k, f) in &s.point_fills {
m.entry(*k).or_insert(f);
}
m
})
.collect();
// A waterfall's running bars, worked out once for all its categories.
let waterfall_bars = if waterfall { c.waterfall_bars() } else { Vec::new() };
for i in 0..ncat {
let mut pos_acc = 0.0;
let mut neg_acc = 0.0;
@@ -983,7 +1000,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
let start = band * slot(i) + (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()
waterfall_bars.get(i).copied().unwrap_or_default()
} else if stacked {
if v >= 0.0 {
let r = (pos_acc, pos_acc + v);
@@ -1015,7 +1032,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
};
Paint::Color(series_color(rctx, c, slot))
} else {
bar_paint(rctx, c, *si, i, s)
bar_paint(rctx, c, *si, i, s, point_fills.get(bi))
};
fill_with(ctx, &paint, r.abs(), m);
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
+44 -6
View File
@@ -37,7 +37,8 @@ struct Cache {
bad_order: VecDeque<usize>,
bad_bytes: usize,
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
/// Decoded animations by (bytes, adjustments), each with the clock of its last use.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>, u64)>,
}
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
@@ -432,16 +433,33 @@ fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
return None;
}
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
let hit = with(|c| {
c.clock += 1;
let clock = c.clock;
c.anims.get_mut(&key).map(|e| {
e.2 = clock;
e.1.clone()
})
});
if let Some(a) = hit {
return a;
}
let a = decode_anim(bytes, adj).map(Arc::new);
with(|c| {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
c.anims.clear();
// Make room by dropping the least recently used animations, not every one of them, so a
// slide whose animations overflow the cache does not decode all of them again each frame.
let new = a.as_ref().map_or(0, |a| a.bytes());
loop {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + new <= GIF_CACHE_BUDGET && c.anims.len() < 64 {
break;
}
let Some(oldest) = c.anims.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break };
c.anims.remove(&oldest);
}
c.anims.insert(key, (bytes.clone(), a.clone()));
c.clock += 1;
let clock = c.clock;
c.anims.insert(key, (bytes.clone(), a.clone(), clock));
});
a
}
@@ -535,6 +553,26 @@ mod tests {
out.into_inner()
}
/// A two-frame GIF whose first pixel is `n`, so each one has its own bytes.
fn gif(n: u8) -> Arc<Vec<u8>> {
use image::codecs::gif::GifEncoder;
let frame = |c: u8| image::Frame::new(image::RgbaImage::from_pixel(2, 2, image::Rgba([n, c, 0, 255])));
let mut out = Vec::new();
GifEncoder::new(&mut out).encode_frames([frame(0), frame(255)]).expect("encode");
Arc::new(out)
}
#[test]
fn a_full_animation_cache_drops_the_oldest_entry_not_all_of_them() {
let adj = PictureAdjust::default();
let gifs: Vec<_> = (0..70).map(gif).collect();
for g in &gifs {
assert!(anim(g, &adj).is_some());
}
let (len, newest) = with(|c| (c.anims.len(), c.anims.contains_key(&(Arc::as_ptr(&gifs[69]) as usize, adjust_key(&adj)))));
assert!(newest && len >= 32, "{len} cached");
}
#[test]
fn rgba_conversion_is_refused_past_the_cap() {
let png = grey_png(100, 50);
+3
View File
@@ -94,6 +94,9 @@ mod tests;
#[cfg(test)]
mod tests_wild;
#[cfg(test)]
mod tests_effect_holes;
#[cfg(test)]
mod mt_tests {
use super::*;
+22 -10
View File
@@ -76,16 +76,12 @@ fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
let mut used = vec![false; a.len()];
b.iter()
.map(|tb| {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
if let Some(u) = used.get_mut(i) {
*u = true;
}
Some(i)
})
.collect()
// Unused tokens of `a` by text, in reading order, so each lookup is a pop rather than a scan.
let mut free: std::collections::HashMap<&str, std::collections::VecDeque<usize>> = std::collections::HashMap::new();
for (i, ta) in a.iter().enumerate() {
free.entry(ta.key.as_str()).or_default().push_back(i);
}
b.iter().map(|tb| free.get_mut(tb.key.as_str())?.pop_front()).collect()
}
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
@@ -228,3 +224,19 @@ pub(crate) fn draw(
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
}
#[cfg(test)]
mod match_tests {
use super::*;
fn tok(k: &str) -> Token {
Token { key: k.into(), glyphs: Vec::new(), origin: Point::ZERO, run: 0 }
}
#[test]
fn each_source_token_is_used_once_in_reading_order() {
let a: Vec<Token> = ["a", "b", "a"].map(tok).into();
let b: Vec<Token> = ["a", "a", "a", "b", "c"].map(tok).into();
assert_eq!(matches(&a, &b), vec![Some(0), Some(2), None, Some(1), None]);
}
}
+35 -4
View File
@@ -91,7 +91,10 @@ pub fn fill_path(
let on_path = m * path.clone();
ctx.set_transform(Affine::IDENTITY);
ctx.push_clip_layer(&on_path);
picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None);
// Drawn in device pixels, so a tile (given in shape units) is sized to pixels first.
let tiled = tiled_per_point(fill, (c[0].hypot(c[1]), c[2].hypot(c[3])));
let upright = if let Fill::Picture(p) = tiled.as_ref() { p } else { pf };
picture(ctx, pres, &resolved(rctx, upright, ph), on_slide, Affine::IDENTITY, None);
ctx.pop_layer();
return;
}
@@ -226,9 +229,12 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
}
let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih));
// PowerPoint draws the tile at a whole number of output pixels.
let k = px_scale.max(1e-9);
let snap = |v: f64| (v * k).round().max(1.0) / k;
let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy));
// Per axis: a group can stretch its child space unevenly, and a pixel is then a different
// number of shape units across and down.
let c = m.as_coeffs();
let (kx, ky) = (c[0].hypot(c[1]).max(1e-9), c[2].hypot(c[3]).max(1e-9));
let snap = |v: f64, k: f64| (v * k).round().max(1.0) / k;
let (tile_w, tile_h) = (snap(nw * 0.75 * sx, kx), snap(nh * 0.75 * sy, ky));
if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) {
return;
}
@@ -260,6 +266,31 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
draw_image(ctx, &image, iw, ih, dest, m, alpha);
}
/// `fill`, with a tiled picture's tile size and offset moved from points to shape units, given
/// the shape units to the point along x and y. A group's child coordinates scale its shapes'
/// boxes (a group can stretch them unevenly), but a tile keeps its size on the slide as PowerPoint
/// draws it, so in a group with a legacy child space the tile would otherwise cover hundreds of
/// boxes and the fill show one flat texel. Anything but a tiled picture, or a unit scale, is
/// returned as it is.
pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> {
let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) };
match tiled_picture_per_point(pf, per_point) {
std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(fill),
std::borrow::Cow::Owned(p) => std::borrow::Cow::Owned(Fill::Picture(p)),
}
}
/// [`tiled_per_point`] for a picture fill itself (a picture shape's own fill is not a [`Fill`]).
pub fn tiled_picture_per_point(pf: &PictureFill, per_point: (f64, f64)) -> std::borrow::Cow<'_, PictureFill> {
let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(pf) };
let (kx, ky) = per_point;
if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) {
return std::borrow::Cow::Borrowed(pf);
}
let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() };
std::borrow::Cow::Owned(PictureFill { mode, ..pf.clone() })
}
/// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or
/// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point
/// of the box, then moved by the offset (points).
+3 -1
View File
@@ -137,9 +137,11 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
}
ShapeKind::Table(t) => {
// Cell text, laid out exactly as the table draws it.
// The table's style is looked up once, not once per cell.
let def = table::resolve_style(rctx, t);
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body(rctx, t, r, c, cell);
let body = table::cell_body_with_def(rctx, def, t, r, c, cell);
let tmp = Shape::table_cell(body.clone());
place_text(out, rctx, fields, s.id, &tmp, &body, rect, m);
}
+89 -16
View File
@@ -33,6 +33,10 @@ pub struct Renderer {
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
/// of the normal drawing that follows.
shadow_work: usize,
/// `RenderOpts::skip` as a set, with the slice (address, length) it was built from: the
/// slice can be as long as the slide, and is tested for every shape drawn. Forgotten when a
/// frame starts, so another frame's slice at the same address is never mistaken for it.
skip: Option<(usize, usize, std::collections::HashSet<ShapeId>)>,
}
impl Default for Renderer {
@@ -48,6 +52,7 @@ impl Default for Renderer {
in_shadow_pass: false,
render_work: 0,
shadow_work: 0,
skip: None,
}
}
}
@@ -163,6 +168,7 @@ impl Renderer {
pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -178,6 +184,7 @@ impl Renderer {
pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -212,6 +219,7 @@ impl Renderer {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
@@ -260,6 +268,7 @@ impl Renderer {
pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
@@ -346,6 +355,14 @@ impl Renderer {
if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 }
}
/// [`Renderer::per_point`] along the shape's own x and y axes: a group can stretch its child
/// space unevenly, and a tiled fill keeps its size in points on the slide on both axes.
fn per_point_xy(&self, m: Affine, depth: usize) -> (f64, f64) {
let c = self.line_space(m, depth).1.as_coeffs();
let inv = |s: f64| if s.is_finite() && s > 1e-12 { 1.0 / s } else { 1.0 };
(inv(c[0].hypot(c[1])), inv(c[2].hypot(c[3])))
}
/// The size and transform a shape's text is set in: inside a group whose child coordinates
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
@@ -385,8 +402,25 @@ impl Renderer {
geo
}
/// Whether `id` is in `skip`, looked up in a set built once per frame and slice.
fn skipped(&mut self, skip: &[ShapeId], id: ShapeId) -> bool {
if skip.is_empty() {
return false;
}
let key = (skip.as_ptr() as usize, skip.len());
match &self.skip {
Some((p, n, set)) if (*p, *n) == key => set.contains(&id),
_ => {
let set: std::collections::HashSet<ShapeId> = skip.iter().copied().collect();
let hit = set.contains(&id);
self.skip = Some((key.0, key.1, set));
hit
}
}
}
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || self.skipped(f.opts.skip, s.id) {
return;
}
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
@@ -428,7 +462,8 @@ impl Renderer {
// A group's own fill (a group filled with its parent's passes that on).
let own = match &s.fill {
Some(Fill::Group) | None => self.group_fill.clone(),
Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))),
// A tile is given in points; this group's own space may be scaled by its parents.
Some(fl) => Some((paint::tiled_per_point(fl, self.per_point_xy(m, depth)).into_owned(), m, Rect::new(0.0, 0.0, x.w, x.h))),
};
let outer = std::mem::replace(&mut self.group_fill, own);
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
@@ -541,6 +576,7 @@ impl Renderer {
geo: &Geometry,
(w, h): (f64, f64),
m: Affine,
per_point: (f64, f64),
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
) {
for (i, sp) in geo.paths.iter().enumerate() {
@@ -566,7 +602,10 @@ impl Renderer {
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
}
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
_ => {
let fl = paint::tiled_per_point(fl, per_point);
paint::fill_path(ctx, rctx, &fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres)
}
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
@@ -655,9 +694,13 @@ impl Renderer {
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.
// A picture (or picture fill) casts its shadow from its pixels, not its frame. A tile
// is sized in points on the slide, as the face's is, and so is its shadow and reflection.
let content = match (&s.kind, &fill) {
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => {
let pf = paint::tiled_picture_per_point(pf, self.per_point_xy(m, depth));
Some(paint::resolved(rctx, &pf, None).into_owned())
}
_ => None,
};
// What casts the shadow: the fill as see-through as it is, and the outline.
@@ -736,6 +779,7 @@ impl Renderer {
let fill = &d.fill;
let outline = &d.outline;
let fade = |i: usize| d.fade(i);
let pt_xy = self.per_point_xy(m, d.depth);
// Fill.
match &s.kind {
ShapeKind::Picture { fill: pf } => {
@@ -744,13 +788,14 @@ 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, pt_xy, &fade);
}
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
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);
let pf = paint::tiled_picture_per_point(pf, pt_xy);
paint::picture(ctx, f.pres, &paint::resolved(rctx, &pf, None), bounds, m, t);
ctx.pop_layer();
}
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
@@ -761,7 +806,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, pt_xy, &fade);
}
ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
@@ -787,7 +832,7 @@ 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, pt_xy, &fade);
}
}
}
@@ -1125,12 +1170,15 @@ impl Renderer {
// see-through as its fill (none for an outline-only shape) …
None => {
if let Some(a) = fill_alpha {
let mut filled = BezPath::new();
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
filled.extend(sp.path.iter());
}
ctx.set_paint(color(c.with_alpha(255), a));
ctx.fill_path(&filled);
// Each subpath under its own fill rule, as the shape itself is
// drawn: one merged path filled NonZero fills the hole of an
// even-odd shape (a donut, a frame) (#1596).
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
ctx.fill_path(&sp.path);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
}
}
@@ -1161,7 +1209,19 @@ impl Renderer {
ctx.stroke_path(&silhouette);
// A shape without a fill (an outline, a frame) glows along its line only (#225).
if fill_alpha.is_some() || content.is_some() {
ctx.fill_path(&silhouette);
// Per subpath under its own fill rule, as in the shadow above: the
// merged silhouette filled NonZero fills an even-odd hole (#1596).
// When no subpath takes part the silhouette is the outline fallback.
let mut filled_any = false;
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
ctx.fill_path(&sp.path);
filled_any = true;
}
if !filled_any {
ctx.fill_path(&silhouette);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
ctx.pop_layer();
}
@@ -1180,7 +1240,20 @@ impl Renderer {
}
None => {
if let Some(fl) = fill {
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
// Per subpath under its own fill rule, as in the shadow above:
// the merged silhouette filled NonZero fills an even-odd hole
// (#1596). The outline fallback covers shapes with no
// participating subpath.
let mut filled_any = false;
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
paint::fill_path(ctx, rctx, fl, ph, &sp.path, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
filled_any = true;
}
if !filled_any {
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
}
}
+2 -1
View File
@@ -191,7 +191,8 @@ pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec<f64>) -> Vec<f64> {
return pat;
}
let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum();
let dashes = len / period;
// Every dash of the pattern counts, not each repeat of it: a custom pattern can hold many.
let dashes = len / period * pat.len().div_ceil(2) as f64;
if !len.is_finite() {
return vec![];
}
+50
View File
@@ -515,6 +515,56 @@ fn glow_of_a_shape_with_no_fill_and_no_line_is_not_drawn() {
}
}
#[test]
fn shadow_and_glow_keep_an_even_odd_shapes_hole() {
// #1596: a donut is one even-odd subpath — its hole exists only under the
// even-odd rule. The effects were cast from a merged silhouette filled
// NonZero, so the shadow and the glow filled the hole solid. The hole's
// centre must stay the slide's white in both, while the effects still draw.
let donut = |effects: deckcraft_model::Effects| {
let mut sh = Shape {
id: ShapeId(612),
xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 200.0)),
geom: Geom::preset("donut"),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 160, 0)))),
..Default::default()
};
sh.effects = Some(effects);
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let img = donut(deckcraft_model::Effects {
outer_shadow: Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
blur: 8.0,
dist: 0.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}),
..Default::default()
});
let hole = img.pixel(150, 150);
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "shadow in the hole: {hole:?}");
// The shadow still falls around the shape (just below its bottom edge).
let below = img.pixel(150, 251);
assert!(below[0] < 200 && below[0] == below[2], "no shadow below: {below:?}");
let img = donut(deckcraft_model::Effects {
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)), radius: 8.0 }),
..Default::default()
});
let hole = img.pixel(150, 150);
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "glow in the hole: {hole:?}");
// The ring itself is the shape's orange.
let ring = img.pixel(150, 75);
assert!(ring[0] > 200 && ring[1] > 100 && ring[2] < 100, "ring: {ring:?}");
}
#[test]
fn placeholder_prompts_only_in_edit_view() {
let p = Presentation::default();
+198
View File
@@ -0,0 +1,198 @@
//! Contributor-original pixel goldens for #1596 and the review of #1626.
//! The fixed samples cover background, hole/centre, ring, visible halos and reflected coverage.
use super::{Fill, Image, RenderOpts, Rgba, render_slide};
use deckcraft_model::style::{ColorRef, Effects, Glow, Line, Reflection, Shadow};
use deckcraft_model::{Geom, Presentation, Shape, ShapeId, Xfrm};
const PROBES: [(u32, u32); 9] = [
(20, 20), // Clear background.
(130, 130), // Clear donut hole; filled ellipse centre.
(130, 70), // Original ring/ellipse fill.
(130, 47), // Visible shadow/glow above the outer edge.
(130, 213), // Visible shadow/glow below the outer edge.
(130, 93), // Shadow/glow along the inner rim, inside the hole.
(130, 230), // Reflected upper ring.
(130, 300), // Reflected hole/ellipse centre.
(70, 300), // Reflected ring at the same fade as the hole.
];
#[derive(Clone, Copy, Debug)]
enum Effect {
Shadow,
Glow,
Reflection,
}
fn scene(shape: &str, effect: Effect) -> Presentation {
let mut p = Presentation::default();
let mut effects = Effects::default();
match effect {
Effect::Shadow => {
effects.outer_shadow = Some(Shadow {
color: ColorRef::rgb(Rgba::BLACK),
blur: 8.0,
dist: 0.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
}
Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }),
Effect::Reflection => {
effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0, ..Default::default() })
}
}
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) });
slide.shapes = vec![Shape {
id: ShapeId(1596),
xfrm: Some(Xfrm::new(50.0, 50.0, 160.0, 160.0)),
geom: Geom::preset(shape),
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(32, 96, 224)))),
line: Some(Line::none()),
effects: Some(effects),
..Default::default()
}];
p
}
fn draw(shape: &str, effect: Effect) -> Image {
let p = scene(shape, effect);
let image = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
if let Ok(dir) = std::env::var("DECKCRAFT_EFFECT_HOLES_QA") {
// Overwrite, so a QA directory can be reused across runs.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")), image.to_png()).unwrap();
}
image
}
// Fixed contributor-original RGBA goldens; tolerate one final quantization step.
// The nonwhite halo/reflection samples also fail if an effect is disabled outright.
fn assert_golden(shape: &str, effect: Effect, expected: [[u8; 4]; 9]) {
let image = draw(shape, effect);
for ((x, y), want) in PROBES.into_iter().zip(expected) {
let got = image.pixel(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 1), "{shape} {effect:?} at {x},{y}: {got:?} vs golden {want:?}");
}
}
#[test]
fn donut_shadow_matches_golden() {
assert_golden(
"donut",
Effect::Shadow,
[
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[214, 214, 214, 255],
[233, 233, 233, 255],
[227, 227, 227, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn donut_glow_matches_golden() {
assert_golden(
"donut",
Effect::Glow,
[
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[255, 49, 49, 255],
[255, 111, 111, 255],
[255, 96, 96, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn donut_reflection_matches_golden() {
assert_golden(
"donut",
Effect::Reflection,
[
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[130, 166, 238, 255],
[255, 255, 255, 255],
[189, 208, 246, 255],
],
);
}
#[test]
fn ellipse_shadow_matches_golden() {
assert_golden(
"ellipse",
Effect::Shadow,
[
[255, 255, 255, 255],
[32, 96, 224, 255],
[32, 96, 224, 255],
[214, 214, 214, 255],
[233, 233, 233, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn ellipse_glow_matches_golden() {
assert_golden(
"ellipse",
Effect::Glow,
[
[255, 255, 255, 255],
[32, 96, 224, 255],
[32, 96, 224, 255],
[255, 49, 49, 255],
[255, 111, 111, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn ellipse_reflection_matches_golden() {
assert_golden(
"ellipse",
Effect::Reflection,
[
[255, 255, 255, 255],
[32, 96, 224, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[130, 166, 238, 255],
[189, 208, 246, 255],
[189, 208, 246, 255],
],
);
}
+226 -1
View File
@@ -454,6 +454,198 @@ fn tiled_pictures_flip_align_and_skip_non_positive_scales() {
assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5));
}
#[allow(clippy::unwrap_used)]
fn red_over_blue() -> Vec<u8> {
let img = image::RgbImage::from_fn(4, 8, |_, y| if y < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) });
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() {
use deckcraft_model::style::{PictureFill, PictureMode};
// A group's child space scales its shapes' boxes, not the tile: the same tiles show whether
// the group's child space is 1600 times smaller (old decks write them so), 4 times larger,
// the slide's own or stretched unevenly (PowerPoint draws all of them alike, measured on its
// own export of a probe deck). Before, one tile covered hundreds of boxes and the fill was one
// flat texel, and an uneven group stretched the tile.
// Wide: 8x4 px = 6x3 pt, ten times = 60x30 pt tiles, red then blue across.
// Tall: 4x8 px = 3x6 pt, ten times = 30x60 pt tiles, red over blue down.
let blue = Rgba::rgb(0, 0, 255);
let scales = [(1.0, 1.0), (1600.0, 1600.0), (0.25, 0.25), (2.0, 0.5), (0.5, 2.0), (1800.0, 1.0), (1.0, 1800.0)];
for (kx, ky) in scales {
for tall in [false, true] {
let mut p = Presentation::default();
let png = if tall { red_over_blue() } else { half_red_half_blue() };
let media = p.add_media("t.png", "image/png", png).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let member = Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky)),
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
line: no_line(),
..Default::default()
};
let group = Shape {
id: ShapeId(21),
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky) },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Along the tile's long side: first half red, second blue, then red again.
let (a, b, c) = if tall { ((115, 115), (115, 145), (115, 175)) } else { ((115, 115), (145, 115), (175, 115)) };
let (pa, pb, pc) = (img.pixel(a.0, a.1), img.pixel(b.0, b.1), img.pixel(c.0, c.1));
assert!(
close(pa, RED, 30) && close(pb, blue, 30) && (tall || close(pc, RED, 30)) && (!tall || close(pc, WHITE, 5) || close(pc, RED, 30)),
"child space {kx} x {ky}, tall={tall}: {pa:?} {pb:?} {pc:?}"
);
}
}
}
/// A slide with `shape` (built for a box of `w` x `h` child units) in a group at (100, 100) that is
/// 240 x 60 pt on the slide and scaled `kx` x `ky` against its child space, drawn at 1 px per point.
fn in_a_scaled_group(kx: f64, ky: f64, p: Presentation, shape: impl FnOnce(f64, f64) -> Shape) -> Image {
let (w, h) = (240.0 / kx, 60.0 / ky);
let group = Shape {
id: ShapeId(21),
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
kind: ShapeKind::Group { children: vec![shape(w, h)], child: Xfrm::new(0.0, 0.0, w, h) },
..Default::default()
};
let mut p = p;
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
#[test]
fn a_tile_a_group_passes_to_its_members_keeps_its_size_inside_a_scaled_group() {
use deckcraft_model::style::{PictureFill, PictureMode};
// The group that owns the tiled fill sits inside another group whose child space is scaled: the
// members that take it (`grpFill`) get tiles of 60 x 30 pt, as with no outer group.
let blue = Rgba::rgb(0, 0, 255);
for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let img = in_a_scaled_group(kx, ky, p, |w, h| {
let member =
Shape { id: ShapeId(23), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), fill: Some(Fill::Group), line: no_line(), ..Default::default() };
Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, w, h) },
..Default::default()
}
});
let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115));
assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "outer group {kx} x {ky}: {a:?} {b:?} {c:?}");
}
}
#[test]
fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() {
use deckcraft_model::style::{PictureFill, PictureMode};
// A picture (`p:pic`) whose blip fill is a tile rather than a stretch.
let blue = Rgba::rgb(0, 0, 255);
for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let img = in_a_scaled_group(kx, ky, p, |w, h| Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } },
line: no_line(),
..Default::default()
});
let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115));
assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "group {kx} x {ky}: {a:?} {b:?} {c:?}");
}
}
#[test]
fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() {
use deckcraft_model::style::{PictureFill, PictureMode, Reflection};
// The reflection mirrors the picture's pixels, so its tiles must be the face's, 60 x 30 pt,
// whatever the group's child scale: just below the box it shows the same colours.
let reflected = |kx: f64, ky: f64| {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() };
let img = in_a_scaled_group(kx, ky, p, |w, h| Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } },
line: no_line(),
effects: Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }),
..Default::default()
});
[img.pixel(115, 163), img.pixel(145, 163), img.pixel(175, 163)]
};
let plain = reflected(1.0, 1.0);
assert!(plain[0][0] > plain[0][2] && plain[1][2] > plain[1][0] && plain[2][0] > plain[2][2], "red, blue, red in the reflection: {plain:?}");
for (kx, ky) in [(0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
let got = reflected(kx, ky);
for (g, e) in got.iter().zip(&plain) {
assert!(close(*g, Rgba::rgb(e[0], e[1], e[2]), 40), "child scale {kx} x {ky}: {got:?} against {plain:?}");
}
}
}
#[test]
fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() {
use deckcraft_model::style::{PictureFill, PictureMode};
// `rotWithShape="0"` draws the tiles on the slide's axes, in device pixels: they were sized in
// pixels, so zooming (or a group's child scale) changed their size on the slide. PowerPoint's
// export of the same shapes shows 60x30 pt tiles at every zoom.
let draw = |scale: f64, group: f64| {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let fill = Fill::Picture(PictureFill { media, mode, upright: true, ..Default::default() });
let shape = Shape {
id: ShapeId(22),
xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }),
fill: Some(fill),
line: no_line(),
..Default::default()
};
let shape = if group == 1.0 {
Shape { xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(100.0, 100.0, 240.0, 120.0) }), ..shape }
} else {
Shape {
id: ShapeId(21),
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 120.0)),
kind: ShapeKind::Group { children: vec![shape], child: Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) },
..Default::default()
}
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
render_slide(&p, 0, &RenderOpts { scale, ..Default::default() })
};
for group in [1.0, 0.25] {
let (one, two) = (draw(1.0, group), draw(2.0, group));
let (mut same, mut total) = (0, 0);
for y in (120..220).step_by(7) {
for x in (120..320).step_by(9) {
let (a, b) = (one.pixel(x, y), two.pixel(2 * x, 2 * y));
if close(a, WHITE, 5) && close(b, WHITE, 5) {
continue; // outside the turned rectangle
}
total += 1;
same += usize::from(close(a, Rgba::rgb(b[0], b[1], b[2]), 40));
}
}
assert!(total > 40 && same * 10 >= total * 9, "group scale {group}: {same} of {total} samples agree between zoom 1 and 2");
}
}
#[test]
fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() {
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
@@ -743,7 +935,7 @@ fn a_picture_reflects_its_pixels() {
let mut p = picture_deck(None);
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
if let Some(sh) = s.shapes.first_mut() {
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 };
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() };
sh.effects = Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() });
}
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
@@ -798,4 +990,37 @@ fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() {
p.line_to((4.0e6, 0.0));
let pat = limit_dash_count(&p, vec![1.0, 1.0]);
assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}");
// A custom pattern of 1,000 dashes counts each of them: the limit is on dashes drawn.
let pat = limit_dash_count(&p, vec![1.0; 2000]);
let period: f64 = pat.iter().sum();
assert!((4.0e6 / period * 1000.0 - MAX_DASHES).abs() < 1.0, "{}", period);
}
/// A bar chart with 20,000 points, each with its own fill, and a sparse scatter of the same size:
/// the per-point fills and the scatter's "has any x" are looked up, not searched per point.
#[test]
fn many_point_fills_and_sparse_scatter_x_render() {
use deckcraft_model::ShapeKind;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::Fill;
let n = 20_000;
let chart = |kind: ChartType| {
let mut c = Chart::sample(kind);
c.series.truncate(1);
c.categories = (0..n).map(|i| i.to_string()).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
c
};
let mut bars = chart(ChartType::Column);
bars.series[0].point_fills = (0..n).rev().map(|i| (i, Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))))).collect();
let mut scatter = chart(ChartType::Scatter);
scatter.series[0].x_values = (0..n).map(|i| (i + 1 == n).then_some(1.0)).collect();
let shape = |id: u32, c: Chart| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(10.0, 10.0, 400.0, 300.0)),
kind: ShapeKind::Chart(Box::new(c)),
..Default::default()
};
let img = render(vec![shape(2, bars), shape(3, scatter)]);
assert!(img.width > 0);
}
+122 -8
View File
@@ -35,22 +35,30 @@ pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
t
}
/// Most path elements this thread's glyph cache retains. Its entries are shared with the font
/// cache, but they stay alive here after that cache evicts them, so they are bounded by elements.
const GLYPH_CACHE_MAX_ELEMENTS: usize = 1 << 22;
thread_local! {
static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new());
/// The cached outlines, and the path elements they hold.
static GLYPH_CACHE: std::cell::RefCell<(HashMap<(u32, u32), Arc<BezPath>>, usize)> = std::cell::RefCell::new((HashMap::new(), 0));
}
pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id(), gid);
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) {
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().0.get(&key).cloned()) {
return p;
}
let p = FontDb::global().outline(face, gid);
GLYPH_CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 200_000 {
c.clear();
let n = p.elements().len();
if c.0.len() > 200_000 || c.1.saturating_add(n) > GLYPH_CACHE_MAX_ELEMENTS {
c.0.clear();
c.1 = 0;
}
c.insert(key, p.clone());
c.1 += n;
c.0.insert(key, p.clone());
});
p
}
@@ -105,6 +113,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let lines_of = LineIndex::new(l);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
@@ -144,7 +153,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
let li = lines_of.line_at(run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
@@ -191,8 +200,78 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
/// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's
/// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text
/// crate produces) is searched by bisection; any other falls back to a scan with the same answer.
pub(crate) struct LineIndex<'a> {
l: &'a deckcraft_text::TextLayout,
/// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index.
ordered: Vec<bool>,
}
impl<'a> LineIndex<'a> {
pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self {
let ordered = l
.para_lines
.iter()
.map(|&(first, count)| {
let end = first.saturating_add(count).min(l.lines.len());
l.lines.get(first..end).is_some_and(|ls| {
ls.iter().all(|a| a.top <= a.bottom)
&& ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline)
})
})
.collect();
LineIndex { l, ordered }
}
/// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`.
fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option<usize> {
let i = lines.partition_point(|li| li.baseline < y);
let best = match (i.checked_sub(1), lines.get(i)) {
(Some(p), Some(n)) => {
if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() {
p
} else {
i
}
}
(Some(p), None) => p,
(None, Some(_)) => i,
(None, None) => return None,
};
let b = lines[best].baseline;
Some(lines.partition_point(|li| li.baseline < b))
}
/// The paragraph's lines when they are ordered, with the index of the first.
fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> {
let &(first, count) = self.l.para_lines.get(para)?;
let end = first.saturating_add(count).min(self.l.lines.len());
(self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end]))
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
pub(crate) fn line_at(&self, para: usize, y: f64) -> usize {
if let Some((first, ls)) = self.ordered_lines(para) {
return Self::nearest(ls, y).map_or(0, |i| first + i);
}
line_at_scan(self.l, para, y)
}
/// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line.
pub(crate) fn line_of(&self, para: usize, y: f64) -> Option<usize> {
let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) };
let i = ls.partition_point(|li| li.bottom + 0.01 < y);
if ls.get(i).is_some_and(|li| y >= li.top - 0.01) {
return Some(first + i);
}
Self::nearest(ls, y).map(|i| first + i)
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning.
fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return 0;
@@ -318,3 +397,38 @@ fn draw_glyph_with(
ctx.stroke_path(&path);
}
}
#[cfg(test)]
mod line_index_tests {
use super::*;
use deckcraft_text::{LineInfo, TextLayout};
fn layout(ordered: bool) -> TextLayout {
let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() };
let mut lines: Vec<LineInfo> = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect();
if !ordered {
lines.swap(3, 7);
}
TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() }
}
#[test]
fn bisection_matches_the_scan() {
for ordered in [true, false] {
let l = layout(ordered);
let idx = LineIndex::new(l_ref(&l));
assert_eq!(idx.ordered, vec![ordered, true]);
for para in 0..2 {
for k in -20..450 {
let y = f64::from(k) * 0.9;
assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}");
assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}");
}
}
}
}
fn l_ref(l: &TextLayout) -> &TextLayout {
l
}
}
+6 -4
View File
@@ -9,13 +9,14 @@ use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
use crate::text_draw::LineIndex;
use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning.
pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return None;
@@ -110,6 +111,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let n = l.lines.len();
let lines_of = LineIndex::new(l);
let mut ink: Vec<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = Vec::new();
@@ -117,7 +119,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
for (gid, x, y) in &run.glyphs {
let Some(li) = line_of(l, run.para, *y) else { continue };
let Some(li) = lines_of.line_of(run.para, *y) else { continue };
if let Some(o) = off.get_mut(li) {
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
}
@@ -156,7 +158,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
return;
}
let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = line_of(l, d.para, y) else { return };
let Some(li) = lines_of.line_of(d.para, y) else { return };
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
for r in d.pieces() {