mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-01 06:26:59 -06:00
Animation state no longer moves layout and master shapes that share an id with a slide shape (fixes #1362)
1199 lines
56 KiB
Rust
1199 lines
56 KiB
Rust
use super::geometry::{
|
||
Caster, blur, border_outside, camera_turn, color, drawable, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry,
|
||
};
|
||
use super::opts::{RenderOpts, ShapeState, SlideFields};
|
||
use super::raster::Image;
|
||
use super::slide::{SlidePart, needs_filters, output_size};
|
||
use super::stroke::{arrowheads, head_trims, stroke, trim_ends};
|
||
use super::text_draw::{draw_layout_with, draw_text, line_bounds, preview_empty_bullets, prompt_body, text_transform, text_warp};
|
||
use super::*;
|
||
|
||
pub struct Renderer {
|
||
resources: Resources,
|
||
/// Outlines morphing in the frame being drawn by `blend`.
|
||
paths: Vec<PathMorph>,
|
||
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
|
||
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
|
||
bg: Option<(Fill, Option<Rgba>, Affine)>,
|
||
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
|
||
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
|
||
pub(crate) drawn: Option<Rect>,
|
||
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
||
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
||
view: Affine,
|
||
/// The fill of the group being drawn, with the transform and box it is laid out in: children
|
||
/// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group.
|
||
group_fill: Option<(Fill, Affine, Rect)>,
|
||
/// Their aligned outlines, kept from frame to frame.
|
||
morphs: morph_path::Cache,
|
||
/// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group.
|
||
in_shadow_pass: bool,
|
||
/// Cumulative shape traversal counter to prevent nested group amplification DoS.
|
||
render_work: usize,
|
||
/// 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,
|
||
}
|
||
|
||
impl Default for Renderer {
|
||
fn default() -> Self {
|
||
Renderer {
|
||
resources: Resources::new(),
|
||
paths: Vec::new(),
|
||
bg: None,
|
||
drawn: None,
|
||
view: Affine::IDENTITY,
|
||
group_fill: None,
|
||
morphs: morph_path::Cache::default(),
|
||
in_shadow_pass: false,
|
||
render_work: 0,
|
||
shadow_work: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
thread_local! {
|
||
pub(crate) static RENDERER: std::cell::RefCell<Renderer> = std::cell::RefCell::new(Renderer::default());
|
||
}
|
||
|
||
pub(crate) struct Frame<'a> {
|
||
pub(crate) pres: &'a Presentation,
|
||
pub(crate) opts: &'a RenderOpts<'a>,
|
||
pub(crate) fields: SlideFields,
|
||
}
|
||
|
||
/// A shape being drawn: its resolved geometry, fill, line and effects, in shape space `m` at size
|
||
/// `w` x `h`, shared by the stages of `Renderer::geometry_shape`.
|
||
struct ShapeDraw<'a, 'ctx> {
|
||
f: &'a Frame<'a>,
|
||
rctx: &'a Ctx<'ctx>,
|
||
s: &'a Shape,
|
||
st: &'a ShapeState,
|
||
m: Affine,
|
||
w: f64,
|
||
h: f64,
|
||
depth: usize,
|
||
geo: Geometry,
|
||
fades: Vec<deckcraft_geom::morph::Fade>,
|
||
fill: Option<Fill>,
|
||
fill_ph: Option<Rgba>,
|
||
line: Line,
|
||
line_ph: Option<Rgba>,
|
||
effects: Option<Effects>,
|
||
fx_ph: Option<Rgba>,
|
||
outline: BezPath,
|
||
is_pic: bool,
|
||
empty_ph: bool,
|
||
}
|
||
|
||
impl ShapeDraw<'_, '_> {
|
||
/// How much of the fill and stroke of path `i` shows (morphing outlines fade).
|
||
fn fade(&self, i: usize) -> deckcraft_geom::morph::Fade {
|
||
self.fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 })
|
||
}
|
||
}
|
||
|
||
/// What casts a shape's shadow, glow and reflection: its geometry, fill and outline, in shape
|
||
/// space `m` at size `w` x `h`.
|
||
struct FxSource<'a> {
|
||
geo: &'a Geometry,
|
||
fill: &'a Option<Fill>,
|
||
fill_alpha: Option<f64>,
|
||
outline: Option<Caster<'a>>,
|
||
content: Option<&'a deckcraft_model::style::PictureFill>,
|
||
m: Affine,
|
||
w: f64,
|
||
h: f64,
|
||
}
|
||
|
||
impl Renderer {
|
||
fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) {
|
||
let (w, h) = output_size(pres, opts);
|
||
let mut ctx = RenderContext::new_with(
|
||
w,
|
||
h,
|
||
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() },
|
||
);
|
||
if let Some(c) = opts.clear {
|
||
ctx.set_paint(color(c, 1.0));
|
||
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
|
||
}
|
||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||
(ctx, w, h, view)
|
||
}
|
||
|
||
/// Note that `r` (local space, placed by `m`) was drawn.
|
||
fn track(&mut self, m: Affine, r: Rect) {
|
||
let b = m.transform_rect_bbox(r);
|
||
if b.is_finite() {
|
||
self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
|
||
}
|
||
}
|
||
|
||
pub(crate) fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
|
||
SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts)
|
||
}
|
||
|
||
/// Background plus the master and layout graphics the slide shows.
|
||
fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) {
|
||
// Backdrop graphics never take shape states: their ids may clash with the slide's shapes.
|
||
let bopts = RenderOpts { state: None, date_text: f.opts.date_text.clone(), ..*f.opts };
|
||
let bf = Frame { pres: f.pres, opts: &bopts, fields: f.fields.clone() };
|
||
let f = &bf;
|
||
self.background(ctx, f, rctx, Some(slide), view);
|
||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||
if show_master {
|
||
let mctx = Ctx::for_master(f.pres, rctx.master);
|
||
for s in &rctx.master.shapes {
|
||
if s.ph.is_none() {
|
||
self.shape(ctx, f, &mctx, s, view, 0);
|
||
}
|
||
}
|
||
}
|
||
if show_layout && let Some(l) = rctx.layout {
|
||
let lctx = Ctx::for_layout(f.pres, rctx.master, l);
|
||
for s in &l.shapes {
|
||
if s.ph.is_none() {
|
||
self.shape(ctx, f, &lctx, s, view, 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||
self.render_work = 0;
|
||
self.shadow_work = 0;
|
||
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) };
|
||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||
self.backdrop(&mut ctx, &f, &rctx, slide, view);
|
||
for s in &slide.shapes {
|
||
self.shape(&mut ctx, &f, &rctx, s, view, 0);
|
||
}
|
||
}
|
||
self.finish(ctx, w, h)
|
||
}
|
||
|
||
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.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) };
|
||
if let Some(rctx) = Ctx::for_slide(pres, slide) {
|
||
match part {
|
||
SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view),
|
||
SlidePart::Content => {
|
||
// Shapes filled with the background (`useBgFill`) still show it.
|
||
let (fill, ph) = resolve::background(&rctx, Some(slide));
|
||
self.bg = Some((fill, ph, view));
|
||
for s in &slide.shapes {
|
||
self.shape(&mut ctx, &f, &rctx, s, view, 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.finish(ctx, w, h)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn blend(
|
||
&mut self,
|
||
pres: &Presentation,
|
||
old: (&Slide, usize),
|
||
new: (&Slide, usize),
|
||
mix: f64,
|
||
shapes: &[(Shape, bool)],
|
||
text: &[TextMorph],
|
||
paths: &[PathMorph],
|
||
opts: &RenderOpts,
|
||
) -> Image {
|
||
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
|
||
self.render_work = 0;
|
||
self.shadow_work = 0;
|
||
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) };
|
||
if mix < 1.0 {
|
||
let f = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
|
||
self.backdrop(&mut ctx, &f, &co, old.0, view);
|
||
}
|
||
if mix > 0.0 {
|
||
let f = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
|
||
let layered = mix < 1.0;
|
||
if layered {
|
||
ctx.push_layer(None, None, Some(mix as f32), None, None);
|
||
}
|
||
self.backdrop(&mut ctx, &f, &cn, new.0, view);
|
||
if layered {
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
|
||
let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
|
||
let mut drawn: Vec<ShapeId> = Vec::new();
|
||
// Set only now: the backdrop's ids may clash with the frame's.
|
||
self.paths = paths.to_vec();
|
||
for (s, from_old) in shapes {
|
||
let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
|
||
let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
|
||
self.shape(&mut ctx, f, c, s, view, 0);
|
||
continue;
|
||
};
|
||
self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0);
|
||
drawn.push(s.id);
|
||
if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
|
||
continue;
|
||
}
|
||
let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
|
||
if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
|
||
let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
|
||
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
|
||
}
|
||
}
|
||
self.paths.clear();
|
||
self.morphs.sweep();
|
||
self.finish(ctx, w, h)
|
||
}
|
||
|
||
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.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() });
|
||
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
|
||
let f = Frame { pres, opts, fields: SlideFields::new(1, String::new(), opts) };
|
||
if let Some(m) = pres.masters.get(mi) {
|
||
match li.and_then(|i| m.layouts.get(i)) {
|
||
Some(l) => {
|
||
let c = Ctx::for_layout(pres, m, l);
|
||
self.background(&mut ctx, &f, &c, None, view);
|
||
if l.show_master_shapes {
|
||
let mc = Ctx::for_master(pres, m);
|
||
for s in m.shapes.iter().filter(|s| s.ph.is_none()) {
|
||
self.shape(&mut ctx, &f, &mc, s, view, 0);
|
||
}
|
||
}
|
||
for s in &l.shapes {
|
||
self.shape(&mut ctx, &f, &c, s, view, 0);
|
||
}
|
||
}
|
||
None => {
|
||
let c = Ctx::for_master(pres, m);
|
||
self.background(&mut ctx, &f, &c, None, view);
|
||
for s in &m.shapes {
|
||
self.shape(&mut ctx, &f, &c, s, view, 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.finish(ctx, w, h)
|
||
}
|
||
|
||
pub(crate) fn finish(&mut self, mut ctx: RenderContext, w: u16, h: u16) -> Image {
|
||
ctx.flush();
|
||
let mut pixels = vec![0u8; w as usize * h as usize * 4];
|
||
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {
|
||
ctx.render(pm, &mut self.resources);
|
||
}
|
||
Image { width: w as u32, height: h as u32, pixels }
|
||
}
|
||
|
||
fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) {
|
||
let (fill, ph) = resolve::background(rctx, slide);
|
||
if f.opts.transparent_background {
|
||
self.bg = Some((fill, ph, view));
|
||
return;
|
||
}
|
||
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||
ctx.set_transform(view);
|
||
let path = r.to_path(0.1);
|
||
// PowerPoint lays every background over white: a picture stretched into part of the
|
||
// slide, or a see-through colour, leaves white around and under it.
|
||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||
ctx.fill_path(&path);
|
||
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
|
||
self.bg = Some((fill, ph, view));
|
||
}
|
||
|
||
pub(crate) fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||
if depth == 0 {
|
||
let outer = std::mem::replace(&mut self.view, parent);
|
||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||
self.view = outer;
|
||
} else {
|
||
self.shape_at(ctx, f, rctx, s, parent, depth);
|
||
}
|
||
}
|
||
|
||
/// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the
|
||
/// one that takes the shape's outline into it. Inside a group they are the slide-level view and
|
||
/// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but
|
||
/// never the line width; outside a group, `m` itself and no change to the outline.
|
||
fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) {
|
||
let view = self.view;
|
||
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
|
||
}
|
||
|
||
/// Shape-space units per point: a group's child coordinates can scale a shape's box (a legacy
|
||
/// 1360-unit child space makes one unit hundreds of EMUs), but 3-D sizes kept in points
|
||
/// (contour width, extrusion depth, bevel) stay points, as PowerPoint draws them. 1 outside a
|
||
/// group.
|
||
fn per_point(&self, m: Affine, depth: usize) -> f64 {
|
||
let k = scale_of(self.line_space(m, depth).1);
|
||
if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 }
|
||
}
|
||
|
||
/// 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.
|
||
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
|
||
let (_, rel) = self.line_space(m, depth);
|
||
let c = rel.as_coeffs();
|
||
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
|
||
let ok = |k: f64| k.is_finite() && k > 1e-9;
|
||
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
|
||
return (w, h, m);
|
||
}
|
||
let (tw, th) = (w * sx, h * sy);
|
||
if !tw.is_finite() || !th.is_finite() {
|
||
return (w, h, m);
|
||
}
|
||
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
|
||
}
|
||
|
||
/// A shape's geometry in its own `w` × `h` space, built at its size on the slide: inside a
|
||
/// group whose child coordinates scale the shape unevenly, the preset's rounded corners and
|
||
/// other size-derived features stay as they would be on a plain shape of that size (#1083).
|
||
fn scaled_geometry(&self, s: &Shape, m: Affine, depth: usize, w: f64, h: f64) -> Geometry {
|
||
let (tw, th, _) = self.text_space(m, depth, w, h);
|
||
if (tw, th) == (w, h) {
|
||
return shape_geometry(s, w, h);
|
||
}
|
||
let mut geo = shape_geometry(s, tw, th);
|
||
let k = |a: f64, b: f64| if b.abs() > 1e-12 { a / b } else { 1.0 };
|
||
let back = Affine::scale_non_uniform(k(w, tw), k(h, th));
|
||
for p in &mut geo.paths {
|
||
p.path.apply_affine(back);
|
||
}
|
||
geo.text_rect = back.transform_rect_bbox(geo.text_rect);
|
||
for p in &mut geo.sites {
|
||
*p = back * *p;
|
||
}
|
||
geo
|
||
}
|
||
|
||
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) {
|
||
return;
|
||
}
|
||
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
|
||
*work = work.saturating_add(1);
|
||
if *work > 100_000 {
|
||
return;
|
||
}
|
||
// Footer placeholders on layouts/masters show only on slides that include them.
|
||
let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default();
|
||
if !st.visible || st.opacity <= 0.001 {
|
||
return;
|
||
}
|
||
let x = resolve::xfrm(rctx, s);
|
||
if !x.is_finite() {
|
||
return;
|
||
}
|
||
let mut m = parent * x.affine();
|
||
if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 {
|
||
let c = Point::new(x.w / 2.0, x.h / 2.0);
|
||
let around = Affine::translate(c.to_vec2())
|
||
* Affine::rotate(st.rotate.to_radians())
|
||
* Affine::scale_non_uniform(st.scale.0, st.scale.1)
|
||
* Affine::translate(-c.to_vec2());
|
||
m = parent * Affine::translate(st.offset) * x.affine() * around;
|
||
}
|
||
if let Some(a) = camera_turn(rctx, s, x.w, x.h) {
|
||
m *= a;
|
||
}
|
||
let layered = st.opacity < 0.999 || st.clip.is_some();
|
||
if layered {
|
||
ctx.set_transform(m);
|
||
let clip = st.clip.map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1));
|
||
ctx.push_layer(clip.as_ref(), None, Some(st.opacity.clamp(0.0, 1.0) as f32), None, None);
|
||
}
|
||
match &s.kind {
|
||
ShapeKind::Group { children, child } => {
|
||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||
let gm = parent * deckcraft_geom::group_child_affine(&x, ch);
|
||
// 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))),
|
||
};
|
||
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
|
||
// children drawn once more under the shadow's offset, scale and skew, recoloured to
|
||
// the shadow colour and blurred, below the group.
|
||
if !self.in_shadow_pass
|
||
&& let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref())
|
||
{
|
||
let c = rctx.color(&sh.color, None);
|
||
let k = scale_of(m);
|
||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||
let local = shadow_local(sh, x.w, x.h);
|
||
let (own, pre) =
|
||
if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) };
|
||
let det = m.determinant();
|
||
if det.is_finite() && det.abs() > 1e-12 {
|
||
let shift = pre * m * own * m.inverse();
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||
self.in_shadow_pass = true;
|
||
for ch in children {
|
||
self.shape(ctx, f, rctx, ch, shift * gm, depth + 1);
|
||
}
|
||
self.in_shadow_pass = false;
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||
let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height);
|
||
ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4));
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
for c in children {
|
||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||
}
|
||
self.group_fill = outer;
|
||
}
|
||
ShapeKind::Table(t) => {
|
||
// Rows grow with their text: what the cells cover, borders included.
|
||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id));
|
||
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||
}
|
||
ShapeKind::Chart(c) => {
|
||
self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
|
||
chart::draw(ctx, rctx, c, m, x.w, x.h)
|
||
}
|
||
ShapeKind::Ink { strokes } => {
|
||
for sk in strokes {
|
||
let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points);
|
||
let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
|
||
for (px, py, pr) in &sk.points {
|
||
let w = deckcraft_model::ink_sample_width(pen, *pr, legacy);
|
||
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
|
||
}
|
||
let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect();
|
||
if pts.len() < 2 {
|
||
continue;
|
||
}
|
||
let (base, rel) = self.line_space(parent, depth);
|
||
ctx.set_transform(base);
|
||
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
|
||
// Uniform-width and highlighter strokes are one path: segments drawn one by one
|
||
// overlap at the joins, which darkens see-through highlighter ink in beads.
|
||
if legacy || sk.highlighter {
|
||
let mut bp = BezPath::new();
|
||
for (i, (px, py, _)) in pts.iter().enumerate() {
|
||
if i == 0 {
|
||
bp.move_to((*px, *py));
|
||
} else {
|
||
bp.line_to((*px, *py));
|
||
}
|
||
}
|
||
bp.apply_affine(rel);
|
||
ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||
ctx.stroke_path(&bp);
|
||
continue;
|
||
}
|
||
for pair in pts.windows(2) {
|
||
let [p0, p1] = pair else { continue };
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((p0.0, p0.1));
|
||
bp.line_to((p1.0, p1.1));
|
||
bp.apply_affine(rel);
|
||
let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy);
|
||
let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy);
|
||
ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||
ctx.stroke_path(&bp);
|
||
}
|
||
}
|
||
}
|
||
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
|
||
}
|
||
if layered {
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
|
||
/// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per
|
||
/// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill`
|
||
/// the group's fill.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn fill_geometry(
|
||
&self,
|
||
ctx: &mut RenderContext,
|
||
f: &Frame,
|
||
rctx: &Ctx,
|
||
fl: &Fill,
|
||
fill_ph: Option<Rgba>,
|
||
geo: &Geometry,
|
||
(w, h): (f64, f64),
|
||
m: Affine,
|
||
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
|
||
) {
|
||
for (i, sp) in geo.paths.iter().enumerate() {
|
||
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
|
||
continue;
|
||
}
|
||
ctx.set_transform(m);
|
||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||
match (fl, &self.bg) {
|
||
// `useBgFill`: the slide's background, laid out on the slide (not the
|
||
// shape), shows through the shape.
|
||
(Fill::Background, Some((bg, ph, view))) => {
|
||
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||
let path = (view.inverse() * m) * sp.path.clone();
|
||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||
}
|
||
// `grpFill`: the group's fill, laid out over the whole group.
|
||
(Fill::Group, _) => {
|
||
if let Some((gf, gm, gbox)) = &self.group_fill
|
||
&& gm.determinant().abs() > 1e-12
|
||
{
|
||
let path = (gm.inverse() * m) * sp.path.clone();
|
||
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),
|
||
}
|
||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn geometry_shape(
|
||
&mut self,
|
||
ctx: &mut RenderContext,
|
||
f: &Frame,
|
||
rctx: &Ctx,
|
||
s: &Shape,
|
||
m: Affine,
|
||
w: f64,
|
||
h: f64,
|
||
st: &ShapeState,
|
||
depth: usize,
|
||
) {
|
||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||
Some(pm) => self.morphs.geometry(pm, s, w, h),
|
||
None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()),
|
||
};
|
||
if !drawable(&geo, m, w, h) {
|
||
log::warn!("render: shape {} skipped: its outline is not finite or far larger than any screen", s.id.0);
|
||
return;
|
||
}
|
||
let (fill, fill_ph) = resolve::fill(rctx, s);
|
||
let (line, line_ph) = resolve::line(rctx, s);
|
||
let (effects, fx_ph) = resolve::effects(rctx, s);
|
||
let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
|
||
self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
|
||
let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
|
||
let outline = geo.outline();
|
||
// Placeholders in the editor: dashed outline and prompt, nothing else when empty.
|
||
let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_));
|
||
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
|
||
let d = ShapeDraw { f, rctx, s, st, m, w, h, depth, geo, fades, fill, fill_ph, line, line_ph, effects, fx_ph, outline, is_pic, empty_ph };
|
||
self.cast_effects(ctx, &d);
|
||
let soft = d.effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||
if let Some(r) = soft {
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, None, None, None, None);
|
||
let _ = r;
|
||
}
|
||
let pt = self.point_scale(&d);
|
||
self.draw_extrusion(ctx, &d, pt);
|
||
self.draw_contour(ctx, &d, pt);
|
||
self.draw_fill(ctx, &d);
|
||
self.draw_fill_overlay(ctx, &d);
|
||
self.draw_bevel(ctx, &d, pt);
|
||
self.draw_outline(ctx, &d);
|
||
if let Some(r) = soft {
|
||
self.draw_feather(ctx, &d, r);
|
||
}
|
||
self.draw_inner_shadow(ctx, &d);
|
||
self.draw_text_body(ctx, &d);
|
||
}
|
||
|
||
/// Shape-space units per point for the 3-D sizes, measured without the camera's turn (which
|
||
/// `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale.
|
||
fn point_scale(&self, d: &ShapeDraw) -> f64 {
|
||
let turn = d
|
||
.effects
|
||
.as_ref()
|
||
.and_then(|e| e.raw3d.as_deref())
|
||
.and_then(camera::from_raw)
|
||
.and_then(|c| camera::affine(&c, d.w, d.h))
|
||
.filter(|t| t.determinant().abs() > 1e-9);
|
||
self.per_point(turn.map_or(d.m, |t| d.m * t.inverse()), d.depth)
|
||
}
|
||
|
||
fn cast_effects(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let depth = d.depth;
|
||
let fill_ph = d.fill_ph;
|
||
let line_ph = d.line_ph;
|
||
let fx_ph = d.fx_ph;
|
||
let is_pic = d.is_pic;
|
||
let s = d.s;
|
||
let rctx = d.rctx;
|
||
let geo = &d.geo;
|
||
let fill = &d.fill;
|
||
let effects = &d.effects;
|
||
let line = &d.line;
|
||
// Effects below the shape.
|
||
if let Some(e) = &effects {
|
||
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
|
||
let content = match (&s.kind, &fill) {
|
||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||
_ => None,
|
||
};
|
||
// What casts the shadow: the fill as see-through as it is, and the outline.
|
||
let fill_alpha = fill_opacity(rctx, fill, fill_ph);
|
||
let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| {
|
||
let a = match lf {
|
||
Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0,
|
||
_ => 1.0,
|
||
};
|
||
let (base, rel) = self.line_space(m, depth);
|
||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||
let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(line, lw) } else { None };
|
||
Caster { line: outside.map_or(std::borrow::Cow::Borrowed(line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel }
|
||
});
|
||
self.effects_below(ctx, rctx, e, fx_ph, FxSource { geo, fill, fill_alpha, outline, content: content.as_ref(), m, w, h });
|
||
}
|
||
}
|
||
|
||
fn draw_extrusion(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let fill_ph = d.fill_ph;
|
||
let rctx = d.rctx;
|
||
let fill = &d.fill;
|
||
let effects = &d.effects;
|
||
let outline = &d.outline;
|
||
// A 3-D extrusion under a turned camera: the sides, swept behind the face.
|
||
if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref())
|
||
&& let Some(ex) = extrude::from_raw(raw)
|
||
&& let Some(cam) = camera::from_raw(raw)
|
||
&& let Some(turn) = camera::affine(&cam, w, h)
|
||
&& turn.determinant().abs() > 1e-9
|
||
{
|
||
let face = match &fill {
|
||
Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)),
|
||
_ => None,
|
||
};
|
||
let pre = m * turn.inverse();
|
||
let lit = light::Lighting::from_raw(raw);
|
||
let d = ex.depth * pt;
|
||
extrude::draw(ctx, outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit);
|
||
}
|
||
}
|
||
|
||
fn draw_contour(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) {
|
||
let m = d.m;
|
||
let fill_ph = d.fill_ph;
|
||
let rctx = d.rctx;
|
||
let fill = &d.fill;
|
||
let effects = &d.effects;
|
||
let outline = &d.outline;
|
||
// A 3-D contour: a band around the outline, behind the face (and in front of any sides).
|
||
if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref())
|
||
&& let Some(c) = extrude::contour_from_raw(raw)
|
||
{
|
||
let face = match &fill {
|
||
Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)),
|
||
_ => None,
|
||
};
|
||
let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff));
|
||
extrude::draw_contour(ctx, outline, m, c.width * pt, col, &light::Lighting::from_raw(raw));
|
||
}
|
||
}
|
||
|
||
fn draw_fill(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let fill_ph = d.fill_ph;
|
||
let empty_ph = d.empty_ph;
|
||
let s = d.s;
|
||
let f = d.f;
|
||
let rctx = d.rctx;
|
||
let geo = &d.geo;
|
||
let fill = &d.fill;
|
||
let outline = &d.outline;
|
||
let fade = |i: usize| d.fade(i);
|
||
// Fill.
|
||
match &s.kind {
|
||
ShapeKind::Picture { fill: pf } => {
|
||
// The picture's own fill shows behind its transparent pixels, as in PowerPoint (an
|
||
// emphasis tint recolours it, as it does any shape's fill).
|
||
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);
|
||
}
|
||
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);
|
||
ctx.pop_layer();
|
||
}
|
||
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||
// preview picture, stretched over the frame, as PowerPoint draws it.
|
||
ShapeKind::Opaque { preview: Some(media), .. } => {
|
||
// An embedded object's fallback picture can carry a fill (old equation objects
|
||
// drawn on coloured boxes): behind the preview, as for a picture.
|
||
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);
|
||
}
|
||
ctx.set_transform(m);
|
||
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
|
||
}
|
||
ShapeKind::Media(mc) => {
|
||
ctx.set_transform(m);
|
||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||
if let Some(p) = mc.poster {
|
||
let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() };
|
||
ctx.push_clip_layer(outline);
|
||
paint::picture(ctx, f.pres, &pf, bounds, m, None);
|
||
ctx.pop_layer();
|
||
} else if mc.video {
|
||
ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255));
|
||
ctx.fill_rect(&bounds);
|
||
}
|
||
if !mc.video || mc.poster.is_none() {
|
||
paint::media_glyph(ctx, mc.video, bounds);
|
||
}
|
||
}
|
||
_ => {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_fill_overlay(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let fx_ph = d.fx_ph;
|
||
let f = d.f;
|
||
let rctx = d.rctx;
|
||
let geo = &d.geo;
|
||
let fill = &d.fill;
|
||
let effects = &d.effects;
|
||
let fade = |i: usize| d.fade(i);
|
||
// `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply
|
||
// a gradient over a tiled picture), inside the filled outline, under the line.
|
||
if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref())
|
||
&& fill.as_ref().is_some_and(|f| !f.is_none())
|
||
{
|
||
let mix = match ov.blend.as_str() {
|
||
"mult" => peniko::Mix::Multiply,
|
||
"screen" => peniko::Mix::Screen,
|
||
"darken" => peniko::Mix::Darken,
|
||
"lighten" => peniko::Mix::Lighten,
|
||
_ => peniko::Mix::Normal,
|
||
};
|
||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) {
|
||
ctx.set_transform(m);
|
||
ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None);
|
||
paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres);
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_bevel(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let fill_ph = d.fill_ph;
|
||
let is_pic = d.is_pic;
|
||
let rctx = d.rctx;
|
||
let geo = &d.geo;
|
||
let fill = &d.fill;
|
||
let effects = &d.effects;
|
||
let outline = &d.outline;
|
||
// A 3-D bevel: the face and a band inside the outline lit as surfaces.
|
||
if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) {
|
||
let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv };
|
||
let solid = match &fill {
|
||
Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)),
|
||
_ => None,
|
||
};
|
||
let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none());
|
||
if filled {
|
||
let mut area = BezPath::new();
|
||
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
||
area.extend(sp.path.iter());
|
||
}
|
||
if is_pic || area.elements().is_empty() {
|
||
area = outline.clone();
|
||
}
|
||
bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_outline(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let depth = d.depth;
|
||
let line_ph = d.line_ph;
|
||
let is_pic = d.is_pic;
|
||
let s = d.s;
|
||
let rctx = d.rctx;
|
||
let geo = &d.geo;
|
||
let effects = &d.effects;
|
||
let outline = &d.outline;
|
||
let line = d.line.clone();
|
||
let fade = |i: usize| d.fade(i);
|
||
// Some presets' outlines default to sharp corners (PowerPoint's own per-shape default).
|
||
let line = match (&s.geom, line.join) {
|
||
(Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => {
|
||
Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line }
|
||
}
|
||
_ => line,
|
||
};
|
||
// Outline.
|
||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||
let (base, rel) = self.line_space(m, depth);
|
||
// Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed
|
||
// outlines or on presets like brackets.
|
||
let ends = s.is_line()
|
||
|| geo.is_open()
|
||
|| (matches!(s.geom, Geom::Custom { .. })
|
||
&& geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath))));
|
||
let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) };
|
||
let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none"));
|
||
// A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into
|
||
// a layer that is then faded, so they don't darken where they meet.
|
||
let see_through = match &line.fill {
|
||
Some(Fill::Solid { color: c }) if has_heads => Some(rctx.color(c, line_ph)).filter(|c| c.a < 255),
|
||
_ => None,
|
||
};
|
||
let line = match see_through {
|
||
Some(c) => {
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None);
|
||
std::borrow::Cow::Owned(Line {
|
||
fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))),
|
||
..line.clone()
|
||
})
|
||
}
|
||
None => std::borrow::Cow::Borrowed(&line),
|
||
};
|
||
// A picture's border lies outside the picture, as PowerPoint draws it: stroked twice
|
||
// as wide, outside the outline only (see `border_outside`).
|
||
let outside = border_outside(is_pic, effects.as_ref()).then(|| outside_line(&line, lw)).flatten();
|
||
let clipped = outside.is_some();
|
||
let (line, lw) = match outside {
|
||
Some(o) => {
|
||
let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1);
|
||
clip.extend(outline.iter());
|
||
ctx.set_transform(m);
|
||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||
ctx.push_clip_layer(&clip);
|
||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||
(std::borrow::Cow::Owned(o), lw * 2.0)
|
||
}
|
||
None => (line, lw),
|
||
};
|
||
let first_stroked = geo.paths.iter().position(|p| p.stroke);
|
||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
||
let mut path = if rel == Affine::IDENTITY {
|
||
std::borrow::Cow::Borrowed(&sp.path)
|
||
} else {
|
||
let mut moved = sp.path.clone();
|
||
moved.apply_affine(rel);
|
||
std::borrow::Cow::Owned(moved)
|
||
};
|
||
// The line stops inside its arrowheads, so a wide line doesn't poke out of them.
|
||
let whole = path.clone();
|
||
if Some(i) == first_stroked && trims != (0.0, 0.0) {
|
||
path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1));
|
||
}
|
||
stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke);
|
||
}
|
||
if ends {
|
||
arrowheads(ctx, rctx, &line, line_ph, geo, rel, base, lw);
|
||
}
|
||
if clipped {
|
||
ctx.pop_layer();
|
||
}
|
||
if see_through.is_some() {
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_feather(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, r: f64) {
|
||
let m = d.m;
|
||
let outline = &d.outline;
|
||
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
|
||
// PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
|
||
// r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
|
||
ctx.set_transform(m);
|
||
ctx.push_layer(
|
||
None,
|
||
Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)),
|
||
None,
|
||
None,
|
||
Some(blur(r / 3.0 * scale_of(m))),
|
||
);
|
||
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
|
||
ctx.fill_path(outline);
|
||
ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut));
|
||
ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
|
||
ctx.stroke_path(outline);
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
}
|
||
|
||
fn draw_inner_shadow(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let fx_ph = d.fx_ph;
|
||
let rctx = d.rctx;
|
||
let effects = &d.effects;
|
||
let outline = &d.outline;
|
||
// Inner shadow.
|
||
if let Some(e) = &effects
|
||
&& let Some(sh) = &e.inner_shadow
|
||
{
|
||
ctx.set_transform(m);
|
||
ctx.push_clip_layer(outline);
|
||
let c = rctx.color(&sh.color, fx_ph);
|
||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
|
||
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
|
||
// blur radius.
|
||
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
|
||
ctx.set_paint(color(c, 1.0));
|
||
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
|
||
let mut hole = outline.clone();
|
||
hole.apply_affine(Affine::translate(-d));
|
||
ring.extend(hole.iter());
|
||
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||
ctx.fill_path(&ring);
|
||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
|
||
fn draw_text_body(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) {
|
||
let m = d.m;
|
||
let w = d.w;
|
||
let h = d.h;
|
||
let depth = d.depth;
|
||
let empty_ph = d.empty_ph;
|
||
let s = d.s;
|
||
let f = d.f;
|
||
let rctx = d.rctx;
|
||
let st = d.st;
|
||
let geo = &d.geo;
|
||
// Text (or the placeholder prompt in the editor).
|
||
let skip_text = f.opts.skip_text == Some(s.id);
|
||
if let Some(body) = &s.text
|
||
&& !skip_text
|
||
{
|
||
// Text in a group is set at the shape's size on the slide, in points: a group's
|
||
// child-coordinate scale sizes and places the box, never the type (#233's twin).
|
||
let (tw, th, m) = self.text_space(m, depth, w, h);
|
||
let text_geo;
|
||
let geo = if (tw, th) == (w, h) {
|
||
geo
|
||
} else {
|
||
text_geo = shape_geometry(s, tw, th);
|
||
&text_geo
|
||
};
|
||
let tr = geo.text_rect;
|
||
let editing = f.opts.edit && f.opts.editing_text == Some(s.id);
|
||
if !body.is_empty() || editing {
|
||
// Imported placeholders can have no paragraph until the first keystroke.
|
||
let mut empty;
|
||
let body = if editing && body.paragraphs.is_empty() {
|
||
empty = body.clone();
|
||
empty.paragraphs.push(Default::default());
|
||
&empty
|
||
} else {
|
||
body
|
||
};
|
||
let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||
if editing {
|
||
preview_empty_bullets(&mut l);
|
||
}
|
||
let tm = text_transform(m, tr, l.rotation);
|
||
for b in l.lines.iter().filter_map(line_bounds) {
|
||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||
let pad = b.height() * 0.3;
|
||
self.track(tm, b.inflate(pad, pad));
|
||
}
|
||
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
|
||
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
|
||
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
|
||
}
|
||
} else if f.opts.edit && s.ph.is_some() {
|
||
let kind = s.ph_type().unwrap_or(PhType::Body);
|
||
let prompt_body = prompt_body(rctx, s, kind);
|
||
if !prompt_body.is_empty() {
|
||
let grey = Rgba::rgb(0x59, 0x59, 0x59);
|
||
draw_text(ctx, rctx, s, &prompt_body, m, &Opts { rect: tr, fields: &f.fields, prompt_color: Some(grey), no_shrink: false });
|
||
}
|
||
}
|
||
}
|
||
if empty_ph && f.opts.edit {
|
||
// Dashed placeholder outline.
|
||
ctx.set_transform(m);
|
||
let px = 1.0 / scale_of(m).max(1e-6);
|
||
ctx.set_paint(peniko::Color::from_rgba8(0xA6, 0xA6, 0xA6, 255));
|
||
ctx.set_stroke(kurbo::Stroke::new(px).with_dashes(0.0, [3.0 * px, 2.0 * px]));
|
||
ctx.stroke_path(&Rect::new(0.0, 0.0, w, h).to_path(0.1));
|
||
if matches!(s.ph_type(), Some(PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram)) {
|
||
paint::placeholder_icon(ctx, s.ph_type().unwrap_or(PhType::Picture), Rect::new(0.0, 0.0, w, h));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn effects_below(&mut self, ctx: &mut RenderContext, rctx: &Ctx, e: &Effects, ph: Option<Rgba>, src: FxSource) {
|
||
let FxSource { geo, fill, fill_alpha, outline, content, m, w, h } = src;
|
||
let silhouette = {
|
||
let mut p = BezPath::new();
|
||
for sp in &geo.paths {
|
||
if sp.fill != FillMode::None || fill.is_none() {
|
||
p.extend(sp.path.iter());
|
||
}
|
||
}
|
||
if p.elements().is_empty() { geo.outline() } else { p }
|
||
};
|
||
let k = scale_of(m);
|
||
if let Some(sh) = &e.outer_shadow {
|
||
let c = rctx.color(&sh.color, ph);
|
||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||
let local = shadow_local(sh, w, h);
|
||
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
|
||
let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) };
|
||
let sm = pre * m * own;
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||
ctx.set_transform(sm);
|
||
match content {
|
||
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
|
||
// transparent background casts the logo's shadow): the picture, clipped to the
|
||
// shape, recoloured to the shadow colour.
|
||
Some(pf) => {
|
||
ctx.push_clip_layer(&silhouette);
|
||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
||
// Drawing the picture leaves its own pixel transform set: back to the shape's,
|
||
// or the recolouring below covers only part of a large picture's shadow.
|
||
ctx.set_transform(sm);
|
||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
}
|
||
// The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
// … and the outline (a picture's border too).
|
||
if let Some(o) = &outline {
|
||
let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() };
|
||
let lw = o.line.width.unwrap_or(0.75).max(0.0);
|
||
for sp in geo.paths.iter().filter(|sp| sp.stroke) {
|
||
let mut path = sp.path.clone();
|
||
path.apply_affine(o.rel * own);
|
||
stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha);
|
||
}
|
||
}
|
||
ctx.pop_layer();
|
||
}
|
||
// A shape with no fill and no line (a text box) draws nothing to glow around: its text's
|
||
// own glow is all PowerPoint shows, not a halo round the box.
|
||
let glows = fill_alpha.is_some() || content.is_some() || outline.is_some();
|
||
if let Some(g) = e.glow.as_ref().filter(|_| glows) {
|
||
let c = rctx.color(&g.color, ph);
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
|
||
// out from the outline and gone by r.
|
||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
|
||
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);
|
||
}
|
||
ctx.pop_layer();
|
||
}
|
||
if let Some(rf) = &e.reflection {
|
||
// Mirror below the shape, fading out.
|
||
let mm = m * Affine::translate((0.0, 2.0 * h + rf.dist)) * Affine::scale_non_uniform(1.0, -1.0);
|
||
ctx.set_transform(Affine::IDENTITY);
|
||
ctx.push_layer(None, None, Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6), None, Some(blur(rf.blur * 0.5 * k)));
|
||
ctx.set_transform(mm);
|
||
match content {
|
||
// A picture reflects its pixels (it has no fill of its own).
|
||
Some(pf) => {
|
||
ctx.push_clip_layer(&silhouette);
|
||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), mm, None);
|
||
ctx.pop_layer();
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
ctx.set_transform(mm);
|
||
// Fade mask.
|
||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None);
|
||
let grad = peniko::Gradient::new_linear(Point::new(0.0, h), Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0))))
|
||
.with_stops([(0.0, peniko::Color::from_rgba8(0, 0, 0, 255)), (1.0, peniko::Color::from_rgba8(0, 0, 0, 0))].as_slice());
|
||
ctx.set_paint(grad);
|
||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(4.0, 4.0));
|
||
ctx.pop_layer();
|
||
ctx.pop_layer();
|
||
}
|
||
}
|
||
}
|