refactor(render): split lib.rs into {raster,opts,slide,renderer,geometry,stroke,text_draw}.rs

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 8b456dce41
commit 5b02b1744c
8 changed files with 2486 additions and 2439 deletions
+201
View File
@@ -0,0 +1,201 @@
use super::*;
pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color {
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
}
pub(crate) fn shade(c: Rgba, m: FillMode) -> Rgba {
match m {
FillMode::Lighten => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.4),
FillMode::LightenLess => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.2),
FillMode::Darken => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.4),
FillMode::DarkenLess => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.2),
_ => c,
}
}
/// Geometry of a shape at its size.
pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry {
match &shape.geom {
Geom::Preset { name, adj } => preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).unwrap_or(Geometry {
paths: vec![],
text_rect: Rect::new(0.0, 0.0, w, h),
handles: vec![],
sites: vec![],
}),
Geom::Custom { paths } => {
let mut out = vec![];
for p in paths {
let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) });
let mut bp = parse_path(&p.d);
bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph));
// PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path
// inside another cuts a hole whichever way it winds, as in icons and outlined text.
out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true });
}
Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] }
}
}
}
/// Parse our custom path syntax (`M x y L x y C … Q … Z`).
pub fn parse_path(d: &str) -> BezPath {
let mut bp = BezPath::new();
let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect();
let mut i = 0;
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
let mut started = false;
while i < toks.len() {
let t = toks.get(i).copied().unwrap_or("");
match t {
"M" => {
if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) {
bp.move_to((x, y));
started = true;
}
i += 3;
}
"L" => {
if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) {
if !started {
bp.move_to((x, y));
started = true;
} else {
bp.line_to((x, y));
}
}
i += 3;
}
"C" => {
if let (Some(a), Some(b), Some(c), Some(d2), Some(e), Some(f)) =
(num(i + 1), num(i + 2), num(i + 3), num(i + 4), num(i + 5), num(i + 6))
&& started
{
bp.curve_to((a, b), (c, d2), (e, f));
}
i += 7;
}
"Q" => {
if let (Some(a), Some(b), Some(c), Some(d2)) = (num(i + 1), num(i + 2), num(i + 3), num(i + 4))
&& started
{
bp.quad_to((a, b), (c, d2));
}
i += 5;
}
"Z" | "z" => {
if started {
bp.close_path();
}
i += 1;
}
_ => i += 1,
}
}
bp
}
/// The transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine
/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts.
pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option<Affine> {
if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) {
return None;
}
let (e, _) = resolve::effects(rctx, s);
e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h))
}
/// The rectangle a picture's image occupies given its crop (fractions; negative = padding).
pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect {
let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(-10.0, 0.99) } else { 0.0 });
let vis_w = (1.0 - l - r).max(1e-6);
let vis_h = (1.0 - t - b).max(1e-6);
let fw = bounds.width() / vis_w;
let fh = bounds.height() / vis_h;
Rect::new(bounds.x0 - l * fw, bounds.y0 - t * fh, bounds.x0 - l * fw + fw, bounds.y0 - t * fh + fh)
}
/// Whether a shape's border is drawn outside its outline: a picture's is, unless the picture is
/// drawn in 3-D (a bevel seen through a 3-D scene), where PowerPoint centres it on the edge as on
/// any shape (observed: an 18 pt border covers 9 pt of the picture then).
pub(crate) fn border_outside(is_pic: bool, effects: Option<&Effects>) -> bool {
is_pic && !effects.is_some_and(|e| e.bevel.is_some() && e.raw3d.as_deref().is_some_and(|r| r.contains("scene3d")))
}
/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside
/// the outline is the border, with dashes kept their length. `None` for compound lines, whose
/// bands would not survive the doubling (they stay centred on the outline).
pub(crate) fn outside_line(line: &Line, lw: f64) -> Option<Line> {
if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) {
return None;
}
let dash = line.dash.as_ref().map(|d| {
let p = d.pattern();
deckcraft_model::style::Dash::Custom {
pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(),
}
});
Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() })
}
/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity,
/// and the space it is stroked in (see `Renderer::line_space`).
pub(crate) struct Caster<'a> {
pub(crate) line: std::borrow::Cow<'a, Line>,
pub(crate) ph: Option<Rgba>,
pub(crate) alpha: f64,
pub(crate) base: Affine,
pub(crate) rel: Affine,
}
/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a
/// gradient's mean stop alpha, otherwise opaque.
pub(crate) fn fill_opacity(rctx: &Ctx, fill: &Option<Fill>, ph: Option<Rgba>) -> Option<f64> {
match fill {
None | Some(Fill::None) => None,
Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0),
Some(Fill::Gradient(g)) if !g.stops.is_empty() => {
Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::<f64>() / g.stops.len() as f64)
}
Some(_) => Some(1.0),
}
}
/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point
/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the
/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow
/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its
/// middle.
pub(crate) fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine {
let (ax, ay) = match sh.align.as_str() {
"tl" => (0.0, 0.0),
"t" => (0.5, 0.0),
"tr" => (1.0, 0.0),
"l" => (0.0, 0.5),
"r" => (1.0, 0.5),
"bl" => (0.0, 1.0),
"b" => (0.5, 1.0),
"br" => (1.0, 1.0),
_ => (0.5, 0.5),
};
let a = Vec2::new(w * ax, h * ay);
let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan());
let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]);
if !k.is_finite() {
return Affine::IDENTITY;
}
Affine::translate(a) * k * Affine::translate(-a)
}
pub(crate) fn pre_translate(m: Affine, d: Vec2) -> Affine {
Affine::translate(d) * m
}
pub(crate) fn scale_of(m: Affine) -> f64 {
m.determinant().abs().sqrt()
}
pub(crate) fn blur(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
}
+27 -2439
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
use super::*;
/// Animation state of one shape during a slide show or preview.
#[derive(Clone, Debug)]
pub struct ShapeState {
pub visible: bool,
pub opacity: f64,
/// Applied in slide space around the shape's centre: translation (pt), scale, rotation (deg).
pub offset: Vec2,
pub scale: (f64, f64),
pub rotate: f64,
/// Reveal clip in shape-local unit space (0..1): l, t, r, b.
pub clip: Option<[f64; 4]>,
/// Colour override for emphasis effects (fill).
pub tint: Option<Rgba>,
/// Per-paragraph states of by-paragraph text builds (paragraph index, state).
pub paras: Vec<(usize, ParaState)>,
}
impl Default for ShapeState {
fn default() -> Self {
ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] }
}
}
/// Animation state of one paragraph, relative to its shape.
#[derive(Clone, Copy, Debug)]
pub struct ParaState {
pub visible: bool,
pub opacity: f64,
/// Translation in shape-local points.
pub offset: Vec2,
}
impl Default for ParaState {
fn default() -> Self {
ParaState { visible: true, opacity: 1.0, offset: Vec2::ZERO }
}
}
pub struct RenderOpts<'a> {
/// Pixels per point.
pub scale: f64,
/// Editor view: placeholder prompts and dashed placeholder outlines.
pub edit: bool,
/// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does.
pub show_hidden: bool,
/// Per-shape animation state.
pub state: Option<&'a dyn Fn(ShapeId) -> ShapeState>,
/// Don't draw these shapes (being edited in place, drawn by the UI).
pub skip: &'a [ShapeId],
/// Hide the text of this shape (the UI draws it while editing).
pub skip_text: Option<ShapeId>,
/// Text being edited: replace its prompt with gray markers on empty list paragraphs.
/// Ignored outside editor rendering.
pub editing_text: Option<ShapeId>,
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
pub gif_times: &'a [(ShapeId, f64)],
/// Worker threads (0 = this thread; required when effects use filters).
pub threads: u16,
/// Translate the slide within the output (pixels) and output size override.
pub offset: (f64, f64),
pub size: Option<(u32, u32)>,
/// Draw a background colour outside the slide (output larger than slide).
pub clear: Option<Rgba>,
/// Show this text in every date field instead of the date.
pub date_text: Option<String>,
/// The local date and time date fields show (`None`: the system clock).
pub now: Option<DateTime>,
/// Leave the slide background out (neither the white underlay nor the background fill), so
/// only what is on the slide is drawn over transparency: PNG export with a transparent
/// background. Shapes filled with the background (`useBgFill`) still show it.
pub transparent_background: bool,
}
impl Default for RenderOpts<'_> {
fn default() -> Self {
RenderOpts {
scale: 1.0,
edit: false,
show_hidden: false,
state: None,
skip: &[],
skip_text: None,
editing_text: None,
gif_times: &[],
threads: 0,
offset: (0.0, 0.0),
size: None,
clear: None,
date_text: None,
now: None,
transparent_background: false,
}
}
}
pub(crate) struct SlideFields {
pub(crate) num: u32,
/// Text for every date field instead of the date ([`RenderOpts::date_text`]).
pub(crate) date: Option<String>,
pub(crate) footer: String,
/// The local date and time date fields show; `None`: they keep their saved text.
pub(crate) now: Option<DateTime>,
}
impl SlideFields {
pub(crate) fn new(num: u32, footer: String, opts: &RenderOpts) -> Self {
SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) }
}
}
impl Fields for SlideFields {
fn field(&self, kind: &str) -> Option<String> {
self.field_in(kind, None)
}
fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
if kind == "slidenum" {
return Some(self.num.to_string());
}
if datetime::is_date_field(kind) {
// An automatic date: today's, in the format of its type. (A fixed date is plain text
// in the slide's placeholder, not a field.)
return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?));
}
if kind == "footer" {
return Some(self.footer.clone());
}
None
}
}
+113
View File
@@ -0,0 +1,113 @@
use super::*;
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
pub const MAX_SIDE: u32 = 16_000;
/// A rendered image: premultiplied RGBA8.
#[derive(Clone, Debug, Default)]
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Image {
/// Straight (unpremultiplied) RGBA.
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.as_chunks_mut::<4>().0 {
let a = px.get(3).copied().unwrap_or(255) as u32;
if a != 0 && a != 255 {
for c in px.iter_mut().take(3) {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
/// One pixel as straight (unpremultiplied) RGBA; `None` outside the image.
pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> {
if x >= self.width || y >= self.height {
return None;
}
let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?;
let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?;
if a == 0 || a == 255 {
return Some([r, g, b, a]);
}
let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8;
Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a])
}
pub fn to_png(&self) -> Vec<u8> {
let mut px = self.to_straight();
px.resize(self.width as usize * self.height as usize * 4, 0);
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { return Vec::new() };
let mut buf = Vec::new();
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) {
log::error!("PNG encode: {e}");
return Vec::new();
}
buf
}
/// PNG of this image whose `pHYs` chunk says `dpi` dots per inch.
///
/// Slide space is points, 72 per inch, so a render at `scale` pixels per point is
/// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a
/// Retina (2×) export opens at twice the slide's size.
pub fn to_png_at(&self, dpi: f64) -> Vec<u8> {
images::stamp_png_dpi(&self.to_png(), dpi)
}
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
self.to_jpeg_at(quality, None)
}
/// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range.
pub fn to_jpeg_at(&self, quality: u8, dpi: Option<f64>) -> Vec<u8> {
let straight = self.to_straight();
let rgb: Vec<u8> = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() };
let mut buf = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
if let Some(d) = dpi.and_then(dpi_u16) {
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d));
}
if let Err(e) = img.write_with_encoder(enc) {
log::error!("JPEG encode: {e}");
return Vec::new();
}
buf
}
/// Pixel at (x, y) as straight RGBA.
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = (y as usize * self.width as usize + x as usize) * 4;
let p = self.pixels.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]);
let a = p.get(3).copied().unwrap_or(0) as u32;
let un = |c: u8| (c as u32 * 255 + a / 2).checked_div(a).map(|v| v.min(255) as u8).unwrap_or(0);
[un(p[0]), un(p[1]), un(p[2]), a as u8]
}
}
/// Dots per inch for a slide drawn at `pixels_per_point`.
///
/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi:
/// the same pixels, at the slide's real size, on a Retina display.
pub fn dpi_for_scale(pixels_per_point: f64) -> f64 {
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return 72.0;
}
(pixels_per_point * 72.0).clamp(1.0, 10_000.0)
}
fn dpi_u16(dpi: f64) -> Option<u16> {
if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) {
return None;
}
Some(dpi.round() as u16)
}
/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch.
pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> {
images::dpi(bytes)
}
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
use super::opts::{RenderOpts, SlideFields};
use super::raster::{Image, MAX_SIDE};
use super::renderer::{Frame, RENDERER, Renderer};
use super::*;
static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn note_panic() {
PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0.
pub fn panics_caught() -> u64 {
PANICS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Does anything on this slide (or its layout/master graphics) use blur-based effects?
pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
let Some(rctx) = Ctx::for_slide(pres, slide) else { return false };
let mut any = false;
let mut check = |shapes: &[Shape]| {
deckcraft_model::walk(shapes, &mut |s, _| {
if any {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
any = true;
}
});
};
check(&slide.shapes);
if let Some(l) = rctx.layout {
check(&l.shapes);
}
check(&rctx.master.shapes);
any
}
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
/// text styles it inherits)? Such text is blurred, which needs filters.
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
use deckcraft_model::text::{ListStyle, RunProps};
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
let Some(t) = &s.text else { return false };
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|| styled(&t.list_style)
|| styled(&rctx.master.title_style)
|| styled(&rctx.master.body_style)
|| styled(&rctx.master.other_style)
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
render_slide_drawn(pres, index, opts).0
}
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
RENDERER.with(|r| {
let mut r = r.borrow_mut();
r.drawn = None;
let img = r.slide(pres, slide, index, opts);
(img, r.drawn.take())
})
})) {
Ok((img, drawn)) => {
let k = opts.scale;
let to_slide =
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
let extent = match drawn {
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
_ => page,
};
(img, extent)
}
Err(_) => {
log::error!("rendering slide {index} panicked; showing a blank slide");
note_panic();
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
let (w, h) = output_size(pres, opts);
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
}
}
}
/// Which part of a slide [`render_slide_part`] draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SlidePart {
/// The background fill with the master and layout graphics.
Backdrop,
/// The slide's own shapes over transparency.
Content,
}
/// Render one part of slide `index` (transitions that move the contents over the backgrounds).
/// A panic inside the renderer becomes a transparent image (logged), never a crash.
pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
let Some(slide) = pres.slides.get(index) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide_part(pres, slide, index, part, opts)))) {
Ok(img) => img,
Err(_) => {
log::error!("rendering part of slide {index} panicked; showing nothing");
note_panic();
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
let (w, h) = output_size(pres, opts);
Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] }
}
}
}
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw
/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies
/// to `shapes` only.
pub fn render_blend(
pres: &Presentation,
old: usize,
new: usize,
mix: f64,
shapes: &[(Shape, bool)],
text: &[TextMorph],
paths: &[PathMorph],
opts: &RenderOpts,
) -> Image {
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts))
})) {
Ok(img) => img,
Err(_) => {
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
note_panic();
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
render_slide(pres, new, opts)
}
}
}
/// Render a layout (Slide Master view thumbnail and canvas).
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
Ok(img) => img,
Err(_) => {
log::error!("rendering a layout panicked; showing a blank image");
note_panic();
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
Image::default()
}
}
}
pub(crate) fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) {
let (w, h) = opts
.size
.unwrap_or(((pres.slide_size.width * opts.scale).round().max(1.0) as u32, (pres.slide_size.height * opts.scale).round().max(1.0) as u32));
(w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16)
}
/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture).
pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option<Image> {
if ids.is_empty() || !scale.is_finite() || scale <= 0.0 {
return None;
}
let rctx = Ctx::for_slide(pres, slide)?;
let mut items = Vec::new();
let mut bounds: Option<Rect> = None;
for id in ids.iter().take(256) {
let Some(s) = slide.shape(*id) else { continue };
let b = resolve::xfrm(&rctx, s).bounds();
if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 {
continue;
}
bounds = Some(match bounds {
Some(u) => u.union(b),
None => b,
});
items.push(s);
}
let b = bounds?.inflate(8.0, 8.0);
if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) {
return None;
}
let fit = (2048.0 / b.width().max(b.height())).min(scale);
if !(fit.is_finite() && fit > 0.0) {
return None;
}
let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16;
let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16;
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let opts = RenderOpts { scale: fit, ..Default::default() };
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2());
RENDERER.with(|r| {
let mut r = r.borrow_mut();
for s in items {
r.shape(&mut ctx, &f, &rctx, s, view, 0);
}
Some(r.finish(ctx, w, h))
})
}
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
let rctx = Ctx::for_slide(pres, slide)?;
let s = slide.shape(id)?;
let x = resolve::xfrm(&rctx, s);
let b = x.bounds().inflate(8.0, 8.0);
let w = (b.width() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let opts = RenderOpts { scale, ..Default::default() };
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2());
RENDERER.with(|r| {
let mut r = r.borrow_mut();
r.shape(&mut ctx, &f, &rctx, s, view, 0);
Some(r.finish(ctx, w, h))
})
}
+441
View File
@@ -0,0 +1,441 @@
use super::geometry::{color, scale_of};
use super::*;
/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the
/// line belongs to; `path` itself when `None`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn stroke(
ctx: &mut RenderContext,
rctx: &Ctx,
line: &Line,
ph: Option<Rgba>,
path: &BezPath,
extent: Option<&BezPath>,
m: Affine,
lw: f64,
alpha: f64,
) {
use deckcraft_model::style::{LineCap, LineJoin};
let cap = match line.cap.unwrap_or_default() {
LineCap::Flat => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
};
let join = match line.join.unwrap_or_default() {
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
LineJoin::Miter => kurbo::Join::Miter,
};
// Hairlines stay visible at any zoom.
let min_w = 0.5 / scale_of(m).max(1e-6);
let sw = lw.max(min_w);
// PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`).
let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0);
// Square caps on a dashed line square off only the line's own two ends (where a dash is
// drawn): each dash itself ends flat, as PowerPoint draws them.
let mut square_ends = None;
if let Some(d) = &line.dash {
let pat: Vec<f64> = d.pattern().iter().map(|v| v * lw.max(0.75)).collect();
let pat = dash_with_caps(pat, cap, sw);
let pat = limit_dash_count(path, pat);
if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) {
if cap == kurbo::Cap::Square {
sk = sk.with_caps(kurbo::Cap::Butt);
square_ends = Some(square_dash_ends(path, &pat, sw));
}
sk = sk.with_dashes(0.0, pat);
}
}
ctx.set_transform(m);
match &line.fill {
Some(Fill::Solid { color: c }) => {
ctx.set_paint(color(rctx.color(c, ph), alpha));
}
Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)),
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
}
// Compound lines: each band stroked along the path moved to its place across the width.
let compound = line.compound.unwrap_or_default();
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
let tolerance = 0.1 / scale_of(m).max(1e-6);
for &(a, b) in compound::bands(compound) {
let d = (-0.5 + (a + b) / 2.0) * lw;
let mut band = sk.clone();
band.width = (b - a) * lw;
ctx.set_stroke(band);
ctx.stroke_path(&compound::offset(path, d, tolerance));
}
return;
}
ctx.set_stroke(sk);
ctx.stroke_path(path);
if let Some(ends) = square_ends {
ctx.fill_path(&ends);
}
}
/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square
/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end
/// when the pattern is in a dash there.
fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath {
use kurbo::{ParamCurve, ParamCurveArclen};
let mut out = BezPath::new();
let period: f64 = pattern.iter().sum();
let mut cap = |at: Point, dir: Vec2| {
let n = Vec2::new(-dir.y, dir.x) * (width / 2.0);
let back = dir * (width / 2.0);
out.move_to(at + n);
out.line_to(at + n + back);
out.line_to(at - n + back);
out.line_to(at - n);
out.close_path();
};
let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| {
let d = seg.eval(t1) - seg.eval(t0);
let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() };
if d.hypot() > 1e-9 { Some(d.normalize()) } else { None }
};
let mut subs: Vec<(Vec<kurbo::PathSeg>, bool)> = vec![];
let mut cur: Vec<kurbo::PathSeg> = vec![];
let mut closed = false;
let mut last = Point::ZERO;
let mut start = Point::ZERO;
for el in path.elements() {
let seg = match *el {
PathEl::MoveTo(p) => {
if !cur.is_empty() {
subs.push((std::mem::take(&mut cur), closed));
}
closed = false;
last = p;
start = p;
continue;
}
PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)),
PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)),
PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)),
PathEl::ClosePath => {
closed = true;
last = start;
continue;
}
};
last = seg.end();
cur.push(seg);
}
if !cur.is_empty() {
subs.push((cur, closed));
}
for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) {
let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue };
if let Some(d) = dir(first, 1e-3, 0.0) {
cap(first.start(), d);
}
let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum();
if !(period > 0.0 && len.is_finite()) {
continue;
}
let mut pos = len % period;
// Ending exactly on a period boundary is the end of a gap.
let mut on = pos > period * 1e-6;
for v in pattern.iter().cycle().take(pattern.len()) {
if pos <= *v {
break;
}
pos -= v;
on = !on;
}
if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) {
cap(end.end(), d);
}
}
out
}
/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the
/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs
/// across its width.
fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option<Rgba>, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient {
let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0);
let bounds = paint::tile_box(bounds, g.tile);
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect();
paint::gradient(&g.shape, stops, bounds, alpha)
}
/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As
/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a
/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid
/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a
/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see
/// `square_dash_ends`.)
pub(crate) fn dash_with_caps(mut pat: Vec<f64>, cap: kurbo::Cap, width: f64) -> Vec<f64> {
if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) {
return pat;
}
for (i, v) in pat.iter_mut().enumerate() {
*v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width };
}
pat
}
/// The most dashes one stroke is cut into. Stroking expands every dash into its own outline, so a
/// tiny dash on a huge shape (a 1 EMU-wide `custDash` on a 2147483647 EMU rectangle) would
/// otherwise need gigabytes and abort the app when the slide is drawn. Past this count the
/// pattern is scaled up evenly, which looks the same: the dashes are far below a pixel by then.
pub(crate) const MAX_DASHES: f64 = 20_000.0;
pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec<f64>) -> Vec<f64> {
use kurbo::ParamCurveArclen;
let period: f64 = pat.iter().sum();
if !(period.is_finite() && period > 0.0) {
return pat;
}
let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum();
let dashes = len / period;
if !len.is_finite() {
return vec![];
}
if dashes <= MAX_DASHES {
return pat;
}
let k = dashes / MAX_DASHES;
pat.into_iter().map(|v| v * k).collect()
}
fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
let els: Vec<PathEl> = path.elements().to_vec();
let mut pts: Vec<Point> = vec![];
for e in &els {
match e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(*p),
PathEl::QuadTo(a, b) => {
pts.push(*a);
pts.push(*b);
}
PathEl::CurveTo(a, b, c) => {
pts.push(*a);
pts.push(*b);
pts.push(*c);
}
PathEl::ClosePath => {}
}
}
if pts.len() < 2 {
return None;
}
let first = *pts.first()?;
let second = pts.iter().skip(1).find(|p| (**p - first).hypot() > 1e-6).copied().unwrap_or(*pts.get(1)?);
let last = *pts.last()?;
let prev = pts.iter().rev().skip(1).find(|p| (**p - last).hypot() > 1e-6).copied().unwrap_or(*pts.get(pts.len() - 2)?);
Some(((first, second), (last, prev)))
}
/// An arrowhead's width and length for a line `lw` wide. PowerPoint sizes them in multiples of
/// the line width (small 2, medium 3, large 5), but never smaller than for a 2 pt line.
pub(crate) fn head_size(e: &deckcraft_model::style::LineEnd, lw: f64) -> (f64, f64) {
let sz = |s: &str| match s {
"sm" => 2.0,
"lg" => 5.0,
_ => 3.0,
};
let k = lw.max(2.0);
(sz(&e.w) * k, sz(&e.len) * k)
}
/// How far back from the tip an open arrowhead's legs (`lw` wide) meet along their centre lines:
/// PowerPoint puts the outside of the point, not the legs' centre lines, on the line's end.
fn open_arrow_setback(wl: f64, ll: f64, lw: f64) -> f64 {
let half = (wl / 2.0).atan2(ll);
if half.sin() > 1e-6 { (lw / 2.0 / half.sin()).min(ll) } else { 0.0 }
}
/// How much of a `lw`-wide line to leave off at its start and end, under its arrowheads: up to
/// a triangle's base, into a stealth arrow's notch far enough for its barbs to cover the line's
/// corners, and to where an open arrow's legs meet. Ovals and diamonds sit on the end itself.
pub(crate) fn head_trims(line: &Line, lw: f64) -> (f64, f64) {
// A square cap reaches half a width past the line's end: leave that off too.
let cap = if line.cap == Some(deckcraft_model::style::LineCap::Square) { lw / 2.0 } else { 0.0 };
let trim = |e: &Option<deckcraft_model::style::LineEnd>| {
let Some(e) = e else { return 0.0 };
let (wl, ll) = head_size(e, lw);
match e.kind.as_str() {
"triangle" => ll + cap,
"stealth" => (ll * (0.6 + 0.4 * lw / wl.max(1e-9))).min(ll) + cap,
"arrow" => open_arrow_setback(wl, ll, lw.max(0.75)) + cap,
_ => 0.0,
}
};
(trim(&line.head), trim(&line.tail))
}
/// `path` with `start` left off the beginning of its first sub-path and `end` off the end of its
/// last (by arc length), where those are open. A sub-path shorter than its trims disappears.
pub(crate) fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath {
use kurbo::{ParamCurve, ParamCurveArclen};
const ACC: f64 = 1e-3;
let mut subs: Vec<(Vec<kurbo::PathSeg>, bool)> = vec![];
let (mut last, mut first) = (Point::ZERO, Point::ZERO);
for el in path.elements() {
let seg = match *el {
PathEl::MoveTo(p) => {
subs.push((vec![], false));
last = p;
first = p;
continue;
}
PathEl::ClosePath => {
if let Some(s) = subs.last_mut() {
s.1 = true;
}
last = first;
continue;
}
PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)),
PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)),
PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)),
};
last = seg.end();
if subs.is_empty() {
subs.push((vec![], false));
}
if let Some(s) = subs.last_mut() {
s.0.push(seg);
}
}
if let Some((segs, false)) = subs.first_mut()
&& start > 0.0
{
let mut left = start;
let mut drop_count = 0;
for (i, seg) in segs.iter().enumerate() {
let l = seg.arclen(ACC);
if l > left {
let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
segs.drain(..i);
segs[0] = partial;
drop_count = 0;
break;
}
left -= l;
drop_count = i + 1;
}
if drop_count > 0 {
segs.drain(..drop_count);
}
}
if let Some((segs, false)) = subs.last_mut()
&& end > 0.0
{
let mut left = end;
while let Some(seg) = segs.last().copied() {
let l = seg.arclen(ACC);
if l > left {
if let Some(s1) = segs.last_mut() {
*s1 = seg.subsegment(0.0..seg.inv_arclen(l - left, ACC));
}
break;
}
left -= l;
segs.pop();
}
}
let mut out = BezPath::new();
for (segs, closed) in subs {
let Some(s0) = segs.first() else { continue };
out.move_to(s0.start());
for seg in &segs {
match seg {
kurbo::PathSeg::Line(l) => out.line_to(l.p1),
kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
if closed {
out.close_path();
}
}
out
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return };
// The outline's ends in the space the arrowheads are sized and drawn in.
let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2);
let col = match &line.fill {
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
_ => Rgba::BLACK,
};
// A gradient line's heads take the gradient where they are.
let grad = match &line.fill {
Some(Fill::Gradient(g)) if !g.stops.is_empty() => {
let mut moved = sp.path.clone();
moved.apply_affine(rel);
Some(line_gradient(rctx, g, ph, &moved, lw, 1.0))
}
_ => None,
};
let set_paint = |ctx: &mut RenderContext| match &grad {
Some(g) => ctx.set_paint(g.clone()),
None => ctx.set_paint(color(col, 1.0)),
};
for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] {
let Some(e) = end else { continue };
if e.kind == "none" {
continue;
}
let (wl, ll) = head_size(e, lw);
let dir = (tip - from).normalize();
if !dir.x.is_finite() {
continue;
}
let n = Vec2::new(-dir.y, dir.x);
let base = tip - dir * ll;
let mut p = BezPath::new();
match e.kind.as_str() {
"oval" => {
let c = kurbo::Ellipse::new(tip, (ll / 2.0, wl / 2.0), dir.y.atan2(dir.x));
p = c.to_path(0.05);
}
"diamond" => {
p.move_to(tip + dir * ll / 2.0);
p.line_to(tip + n * wl / 2.0);
p.line_to(tip - dir * ll / 2.0);
p.line_to(tip - n * wl / 2.0);
p.close_path();
}
"stealth" => {
p.move_to(tip);
p.line_to(base + n * wl / 2.0);
p.line_to(tip - dir * ll * 0.6);
p.line_to(base - n * wl / 2.0);
p.close_path();
}
"arrow" => {
// Legs the line's width with round ends, meeting in a sharp point whose outside is
// on the line's end.
let leg = lw.max(0.75);
let back = dir * open_arrow_setback(wl, ll, leg);
let mut o = BezPath::new();
o.move_to(base + n * wl / 2.0 - back);
o.line_to(tip - back);
o.line_to(base - n * wl / 2.0 - back);
ctx.set_transform(m);
set_paint(ctx);
ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round));
ctx.stroke_path(&o);
continue;
}
_ => {
p.move_to(tip);
p.line_to(base + n * wl / 2.0);
p.line_to(base - n * wl / 2.0);
p.close_path();
}
}
ctx.set_transform(m);
set_paint(ctx);
ctx.fill_path(&p);
}
}
+318
View File
@@ -0,0 +1,318 @@
use super::geometry::{blur, color, pre_translate, scale_of};
use super::opts::ParaState;
use super::*;
/// The prompt shown in an empty placeholder, formatted like its content would be.
pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
// Layout/master prompt text when the layout supplies a custom one.
let (lp, _) = resolve::parents(rctx, s);
let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty());
if let Some(t) = custom {
return t.clone();
}
if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) {
return TextBody::default();
}
let txt = kind.prompt();
if txt.is_empty() || kind.is_footer_kind() {
return TextBody::default();
}
let mut t = TextBody::from_text(txt);
if matches!(kind, PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram | PhType::ClipArt) {
t.body.anchor = Some(deckcraft_model::text::Anchor::Bottom);
if let Some(p) = t.paragraphs.first_mut() {
p.props.align = Some(deckcraft_model::text::Align::Center);
p.props.bullet = Some(deckcraft_model::text::Bullet::None);
p.props.margin_left = Some(0.0);
p.props.indent = Some(0.0);
for r in &mut p.runs {
r.props.size = Some(14.0);
}
}
}
t
}
thread_local! {
static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new());
}
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()) {
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();
}
c.insert(key, p.clone());
});
p
}
/// Draw a text body into shape space `m` within the text rect.
pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option<Rgba>) {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false });
draw_layout(ctx, &l, tr, m);
}
/// Add transient list markers to an editor layout, using the document's own bullet glyphs.
pub(crate) fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) {
for mut run in l.empty_bullets.drain(..) {
run.color = Rgba::rgb(0xB3, 0xB3, 0xB3);
run.alpha = 1.0;
l.runs.push(run);
}
}
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
///
/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's
/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about
/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180°
/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this.
pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
let c = tr.center().to_vec2();
let mut t = m;
if m.determinant() < 0.0 && c.is_finite() {
t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c);
}
if rotation != 0.0 && rotation.is_finite() && c.is_finite() {
t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c);
}
t
}
/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<deckcraft_geom::warp::Warp> {
let bp = resolve::body(rctx, s);
let name = bp.warp.as_deref()?;
deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
}
/// Draw an already laid-out text block.
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
draw_layout_with(ctx, l, tr, m, &[]);
}
/// Draw a laid-out text block with per-paragraph animation states.
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 deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
return;
}
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
fill_deco(ctx, d);
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
// Text shadows, then glows, under all the text.
for pass in [TextEffect::Shadow, TextEffect::Glow] {
for run in &l.runs {
let ps = para(run.para);
if ps.visible && ps.opacity > 0.001 {
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
}
}
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
}
let ps = para(run.para);
if !ps.visible || ps.opacity <= 0.001 {
continue;
}
let m = m * Affine::translate(ps.offset);
// A gradient fill starts over on every line and spans that line's text (from its top to
// its bottom, from its first to its last character), as in PowerPoint.
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
for (gid, x, y) in &run.glyphs {
let at = m * glyph_place(run, *gid, *x, *y);
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, 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();
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
}
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
deco(ctx, d);
}
}
/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a
/// quarter turn back about its em box's centre when it stands upright in vertical text.
pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine {
let at = Affine::translate((x, y));
match run.upright_centre(gid, x, y) {
Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at,
None => at,
}
}
/// Fill an underline, strikethrough or highlight with the current paint and transform: its box,
/// its dashes, or its zigzag stroked.
pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
if let Some((pts, w)) = d.wave() {
let mut path = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 {
path.move_to(*p);
} else {
path.line_to(*p);
}
}
ctx.set_stroke(kurbo::Stroke::new(w.max(0.01)));
ctx.stroke_path(&path);
return;
}
for r in d.pieces() {
ctx.fill_rect(&r);
}
}
/// 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 {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return 0;
}
let end = (first + count).min(l.lines.len());
if first < end {
return l.lines[first..end]
.iter()
.enumerate()
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| first + i)
.unwrap_or(0);
}
}
l.lines
.iter()
.enumerate()
.filter(|(_, li)| li.para == para)
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
/// The box a line's text covers: its top and bottom, its first and last caret.
pub(crate) fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
let r = Rect::new(x0, li.top, x1, li.bottom);
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
#[derive(Clone, Copy, PartialEq)]
enum TextEffect {
Shadow,
Glow,
}
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
let Some(fx) = run.effects else { return };
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 };
let px = scale_of(m);
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
// past the glyph's edge and fades out by about two thirds of r.
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
_ => return,
};
let alpha = (c.a as f64 / 255.0) * opacity;
if alpha <= 0.0 {
return;
}
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
ctx.set_paint(color(c.with_alpha(255), 1.0));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
ctx.pop_layer();
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
}
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
/// the glyph's origin to that space) instead of the run's colour.
fn draw_glyph_with(
ctx: &mut RenderContext,
run: &deckcraft_text::GlyphRun,
gid: u32,
at: Affine,
opacity: f64,
fill: Option<(&peniko::Gradient, Affine)>,
) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let k = run.size / run.face.upem.max(1.0);
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
// strokes the plain glyph below.
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
return;
}
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if let Some((g, to_text)) = fill
&& alpha > 0.0
{
ctx.set_paint(g.clone());
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
ctx.fill_path(&path);
ctx.reset_paint_transform();
} else if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}