mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-10 12:29:34 -06:00
Merge remote-tracking branch 'origin/main' into review-1192
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -94,6 +94,9 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_wild;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_effect_holes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mt_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user