From d5e30557eb9a846cd3eda43aa1552d88e25339b3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:31:13 -0400 Subject: [PATCH 01/59] Transitions like PowerPoint: measured frame by frame and retuned (#366) Every transition and Effect Option was built in PowerPoint (COM), exported as video and compared frame by frame with DeckCraft at the same progress (mean abs diff over 150 variants 35.7 -> 15.0, 0-255). - anim: transition_frame(kind, option, t, aspect) with measured curves (smoothstep fades, ease-in-out moves, Cover/Uncover/Pan curves), directions and option meanings, soft edges as per-corner opacity, 3-D effects as perspective-projected cards, Dynamic Content moving slide contents over cross-fading backgrounds (Source::OldBack/NewBack/OldContent/NewContent); layer_mesh subdivides perspective quads so textures stay straight. - New options: Page Curl Single, Glitter Hexagons, Shred In/Out, Zoom and Rotate; PowerPoint-style Effect Options labels. - render: composite_transition (CPU twin of the show), render_slide_part, render_transition; deckcraft-cli render-transition FILE --slide N --t T. - show: per-vertex opacity meshes, slide parts rendered once per transition. - pptx: PowerPoint's elements and defaults (p:strips diagonal wipes, p14:warp and p:newsflash for Zoom, p:zoom for Shape In/Out, glitter pattern, shred rectangle/dir, prstTrans invX per preset, pageCurlSingle, defaults for randomBar/doors/window/pan/zoom); PowerPoint reopens DeckCraft's files with the same effects. - Tests: measured curves, probes of which slide shows where at t, option coverage, per-frame triangle budget, PowerPoint XML and defaults round trip. Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/deckcraft-cli/src/main.rs | 77 ++ crates/anim/src/lib.rs | 8 +- crates/anim/src/mesh.rs | 139 ++++ crates/anim/src/transition.rs | 917 +++------------------ crates/anim/src/transition_fx.rs | 1169 +++++++++++++++++++++++++++ crates/anim/src/transition_geom.rs | 382 +++++++++ crates/anim/src/transition_tests.rs | 230 ++++++ crates/model/src/anim.rs | 129 ++- crates/pptx/src/maps.rs | 87 +- crates/pptx/tests/roundtrip.rs | 132 +++ crates/render/Cargo.toml | 1 + crates/render/src/lib.rs | 46 ++ crates/render/src/transition.rs | 517 ++++++++++++ crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/src/show.rs | 74 +- docs/animation-parity.md | 36 +- docs/architecture.md | 11 +- 19 files changed, 3080 insertions(+), 879 deletions(-) create mode 100644 crates/anim/src/mesh.rs create mode 100644 crates/anim/src/transition_fx.rs create mode 100644 crates/anim/src/transition_geom.rs create mode 100644 crates/anim/src/transition_tests.rs create mode 100644 crates/render/src/transition.rs diff --git a/Cargo.lock b/Cargo.lock index f4d53470..4044fce6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,6 +1109,7 @@ dependencies = [ name = "deckcraft-render" version = "0.6.1" dependencies = [ + "deckcraft-anim", "deckcraft-color", "deckcraft-fonts", "deckcraft-geom", diff --git a/ROADMAP.md b/ROADMAP.md index 2dba0e27..26b2ec62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index c450393f..fd1d96ea 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -3,6 +3,7 @@ //! ```text //! deckcraft-cli info FILE # slides, titles, layouts (JSON) //! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR +//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s) //! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide) //! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] //! deckcraft-cli commands [FILTER] # list commands (JSON) @@ -34,6 +35,7 @@ macro_rules! outln { const USAGE: &str = "usage: deckcraft-cli info FILE deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT + deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png deckcraft-cli convert IN OUT deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] deckcraft-cli commands [FILTER] @@ -52,6 +54,7 @@ fn main() -> ExitCode { } Some("info") => report(info(rest)), Some("render") => report(render(rest)), + Some("render-transition") => report(render_transition(rest)), Some("convert") => report(convert(rest)), Some("run") => report(run(rest)), Some("commands") => { @@ -161,6 +164,80 @@ fn render(args: &[String]) -> Result<(), String> { Ok(()) } +/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at +/// the given progress values. With several values, OUT gets `-t` before its extension. +fn render_transition(args: &[String]) -> Result<(), String> { + use deckcraft_engine::render; + let mut input = None; + let mut slide: Option = None; + let mut ts: Vec = Vec::new(); + let mut width: Option = None; + let mut out = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?), + "--t" => { + let v = it.next().ok_or("--t needs a value")?; + for x in v.split(',') { + let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?; + if !t.is_finite() { + return Err(format!("bad --t value `{x}`")); + } + ts.push(t); + } + } + "--width" => { + width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?) + } + other if input.is_none() => input = Some(other.to_string()), + other => out = Some(other.to_string()), + } + } + let input = input.ok_or("render-transition needs a FILE")?; + let out = out.ok_or("render-transition needs an output path")?; + let i = slide.ok_or("render-transition needs --slide N")?; + if ts.is_empty() { + return Err("render-transition needs --t".into()); + } + let mut s = Session::new(); + open(&mut s, Some(&input), false)?; + let st = s.doc().map_err(|e| e.to_string())?; + let doc = &st.doc; + let n = doc.slides.len(); + let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) }; + let sw = doc.slide_size.width.max(1.0); + let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64); + let opts = render::RenderOpts { scale: w / sw, ..Default::default() }; + let tr = sl.transition.clone().unwrap_or_default(); + // Render both slides once and composite every frame from them (morph re-renders per frame). + let prev = i.checked_sub(1); + let cached = (tr.kind != "morph").then(|| { + let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts)); + (prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts) + }); + let aspect = sw / doc.slide_size.height.max(1.0); + for t in &ts { + let img = match &cached { + Some((old, new, parts)) => { + render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect)) + } + None => render::render_transition(doc, i, *t, &opts), + }; + let path = if ts.len() == 1 { + out.clone() + } else { + let p = std::path::Path::new(&out); + let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame"); + let name = format!("{stem}-t{t:.3}.png"); + p.with_file_name(name).to_string_lossy().into_owned() + }; + std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?; + outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option); + } + Ok(()) +} + fn convert(args: &[String]) -> Result<(), String> { let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; let mut s = Session::new(); diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index fae7e4dd..d94167ca 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -14,24 +14,30 @@ mod easing; mod effects; +mod mesh; mod morph; mod path; mod show; mod timeline; mod transition; +mod transition_fx; +mod transition_geom; #[cfg(test)] mod tests; +#[cfg(test)] +mod transition_tests; pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; +pub use mesh::{MeshVertex, layer_mesh}; pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; -pub use transition::{Layer, Source, transition_layers}; +pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts}; /// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. /// diff --git a/crates/anim/src/mesh.rs b/crates/anim/src/mesh.rs new file mode 100644 index 00000000..fc68838a --- /dev/null +++ b/crates/anim/src/mesh.rs @@ -0,0 +1,139 @@ +//! Triangle meshes for transition layers. +//! +//! A [`Layer`] is a textured quad. Drawn as two triangles, a trapezoid (a slide seen in +//! perspective) bends its texture along the diagonal, because GPUs and simple rasterisers +//! interpolate texture coordinates linearly in screen space. [`layer_mesh`] splits such quads into a +//! grid whose vertices follow the projective map from the source rectangle to the quad, so the +//! picture looks like a flat card turned in 3-D. Rectangles and parallelograms stay two triangles, +//! wedges one: the per-frame cost stays a handful of vertices per layer. + +use crate::transition::Layer; + +/// One mesh vertex: position in slide-unit space (0..1, y down), texture coordinate (0..1) and +/// opacity (the layer's alpha times its corner fade, interpolated). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshVertex { + pub pos: [f64; 2], + pub uv: [f64; 2], + pub a: f64, +} + +/// Grid cells per side for quads that need a projective subdivision. +const GRID: usize = 8; + +/// The triangles of a layer: vertices and index triples into them. +pub fn layer_mesh(l: &Layer) -> (Vec, Vec<[u32; 3]>) { + let q = l.quad; + let uv = l.uv; + let fa = l.fade.map(|f| f * l.alpha); + // A wedge (triangle): the 4th corner repeats the 3rd. + if q[3] == q[2] && uv[3] == uv[2] { + let v = (0..3).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect(); + return (v, vec![[0, 1, 2]]); + } + let affine = { + // Parallelogram: opposite edges equal. + let e1 = [q[1][0] - q[0][0], q[1][1] - q[0][1]]; + let e2 = [q[2][0] - q[3][0], q[2][1] - q[3][1]]; + (e1[0] - e2[0]).abs() < 1e-9 && (e1[1] - e2[1]).abs() < 1e-9 + }; + let h = if affine { None } else { square_to_quad(q) }; + let Some(h) = h else { + let v = (0..4).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect(); + return (v, vec![[0, 1, 2], [0, 2, 3]]); + }; + // Small quads (tiles, shards) bend their texture too little to matter: fewer cells. + let span = q.iter().flat_map(|p| q.iter().map(move |r| (p[0] - r[0]).abs().max((p[1] - r[1]).abs()))).fold(0.0, f64::max); + let n = ((span * 2.0 * GRID as f64).ceil() as usize).clamp(1, GRID); + let mut v = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + let b = j as f64 / n as f64; + for i in 0..=n { + let a = i as f64 / n as f64; + // Texture: bilinear in (a, b) over the four uv corners (exact for rectangles). + let top = lerp2(uv[0], uv[1], a); + let bot = lerp2(uv[3], uv[2], a); + let t = lerp2(top, bot, b); + let at = fa[0] + (fa[1] - fa[0]) * a; + let ab = fa[3] + (fa[2] - fa[3]) * a; + v.push(MeshVertex { pos: apply(&h, a, b), uv: t, a: at + (ab - at) * b }); + } + } + let mut tris = Vec::with_capacity(n * n * 2); + let w = (n + 1) as u32; + for j in 0..n as u32 { + for i in 0..n as u32 { + let p = j * w + i; + tris.push([p, p + 1, p + w + 1]); + tris.push([p, p + w + 1, p + w]); + } + } + (v, tris) +} + +fn lerp2(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] +} + +/// Projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`'s tl, tr, br, +/// bl (Heckbert's square-to-quad). `None` when the quad is degenerate. +fn square_to_quad(q: [[f64; 2]; 4]) -> Option<[f64; 8]> { + let [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = q; + let sx = x0 - x1 + x2 - x3; + let sy = y0 - y1 + y2 - y3; + let (dx1, dx2, dy1, dy2) = (x1 - x2, x3 - x2, y1 - y2, y3 - y2); + let den = dx1 * dy2 - dx2 * dy1; + if !den.is_finite() || den.abs() < 1e-12 { + return None; + } + let g = (sx * dy2 - dx2 * sy) / den; + let h = (dx1 * sy - sx * dy1) / den; + let m = [x1 - x0 + g * x1, x3 - x0 + h * x3, x0, y1 - y0 + g * y1, y3 - y0 + h * y3, y0, g, h]; + m.iter().all(|v| v.is_finite()).then_some(m) +} + +fn apply(m: &[f64; 8], u: f64, v: f64) -> [f64; 2] { + let w = m[6] * u + m[7] * v + 1.0; + let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w }; + [(m[0] * u + m[1] * v + m[2]) / w, (m[3] * u + m[4] * v + m[5]) / w] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transition::{Layer, Source}; + + #[test] + fn rect_is_two_triangles() { + let (v, t) = layer_mesh(&Layer::full(Source::New, 1.0)); + assert_eq!((v.len(), t.len()), (4, 2)); + } + + #[test] + fn trapezoid_is_subdivided_projectively() { + // A card turned about its left edge: the right edge is shorter. + let q = [[0.0, 0.0], [0.6, 0.2], [0.6, 0.8], [0.0, 1.0]]; + let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0); + let (v, t) = layer_mesh(&l); + assert_eq!(v.len(), (GRID + 1) * (GRID + 1)); + assert_eq!(t.len(), GRID * GRID * 2); + // A small tile in perspective needs no more than a couple of triangles. + let small = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], [[0.5, 0.5], [0.52, 0.505], [0.52, 0.515], [0.5, 0.52]], 1.0); + assert!(layer_mesh(&small).1.len() <= 2); + // Corners land on the quad's corners. + assert_eq!(v[0].pos, q[0]); + let last = v[v.len() - 1].pos; + assert!((last[0] - 0.6).abs() < 1e-9 && (last[1] - 0.8).abs() < 1e-9); + // The texture's middle column sits nearer the far (short) edge than halfway: foreshortening. + let mid = v[GRID / 2].pos[0]; + assert!(mid > 0.3, "{mid}"); + } + + #[test] + fn degenerate_quads_do_not_blow_up() { + let q = [[0.5, 0.5]; 4]; + let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0); + let (v, _) = layer_mesh(&l); + assert!(v.iter().all(|p| p.pos[0].is_finite() && p.pos[1].is_finite())); + } +} diff --git a/crates/anim/src/transition.rs b/crates/anim/src/transition.rs index 02892462..7369ae9c 100644 --- a/crates/anim/src/transition.rs +++ b/crates/anim/src/transition.rs @@ -1,15 +1,18 @@ //! Slide transitions as layers of textured quads. //! //! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide, -//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from -//! trapezoids; wedges are triangles (4th corner repeats the 3rd). - -use std::f64::consts::PI; +//! their backgrounds or contents alone, or a solid colour) onto a quad in slide-unit space (0..1, y +//! down), with an opacity per corner. Perspective comes from projecting 3-D cards +//! ([`crate::layer_mesh`] keeps their textures straight); soft edges come from per-corner opacity +//! ramps; wedges are triangles (4th corner repeats the 3rd). +//! +//! The motions, curves, directions and option meanings were measured frame by frame on +//! PowerPoint's video export of synthetic decks (`plan/powerpoint/tools/synth_transitions.ps1`). +//! The effects themselves live in [`crate::transition_fx`]. use serde::{Deserialize, Serialize}; use crate::clampf; -use crate::easing::smooth; /// What a layer shows. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -20,6 +23,35 @@ pub enum Source { New, Black, White, + /// The outgoing slide's background (background fill plus master and layout graphics) alone. + OldBack, + /// The incoming slide's background alone. + NewBack, + /// The outgoing slide's own shapes alone, over transparency. + OldContent, + /// The incoming slide's own shapes alone, over transparency. + NewContent, +} + +impl Source { + /// The whole-slide source to draw instead when the parts aren't available: a background + /// becomes its slide, content becomes nothing. + pub fn whole(self) -> Option { + match self { + Source::OldBack => Some(Source::Old), + Source::NewBack => Some(Source::New), + Source::OldContent | Source::NewContent => None, + s => Some(s), + } + } + /// Does this source need the slide rendered in parts (background / content)? + pub fn is_part(self) -> bool { + matches!(self, Source::OldBack | Source::NewBack | Source::OldContent | Source::NewContent) + } +} + +fn ones() -> [f64; 4] { + [1.0; 4] } /// One textured quad of a transition frame. @@ -34,12 +66,18 @@ pub struct Layer { /// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these. pub uv: [[f64; 2]; 4], pub alpha: f64, + /// Opacity of each corner (tl, tr, br, bl), multiplied by `alpha` and interpolated across the + /// quad: soft edges and feathered masks. + #[serde(default = "ones")] + pub fade: [f64; 4], } -type R = [f64; 4]; -const FULL: R = [0.0, 0.0, 1.0, 1.0]; +pub(crate) type R = [f64; 4]; +pub(crate) type P = [f64; 2]; +pub(crate) type Q = [[f64; 2]; 4]; +pub(crate) const FULL: R = [0.0, 0.0, 1.0, 1.0]; -fn corners(r: R) -> [[f64; 2]; 4] { +pub(crate) fn corners(r: R) -> Q { [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] } @@ -50,23 +88,39 @@ impl Layer { } /// `src` rectangle of the source drawn into the `dst` rectangle. pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer { - Layer { source, src, quad: corners(dst), uv: corners(src), alpha } + Layer { source, src, quad: corners(dst), uv: corners(src), alpha, fade: ones() } } /// `src` rectangle of the source drawn onto a quad. - pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer { - Layer { source, src, quad, uv: corners(src), alpha } + pub fn quad(source: Source, src: R, quad: Q, alpha: f64) -> Layer { + Layer { source, src, quad, uv: corners(src), alpha, fade: ones() } } /// The part of the source under `dst` drawn in place. - fn inplace(source: Source, dst: R, alpha: f64) -> Layer { + pub(crate) fn inplace(source: Source, dst: R, alpha: f64) -> Layer { Layer::rect(source, dst, dst, alpha) } - /// A triangle of the source drawn in place. - fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer { - let xs = [a[0], b[0], c[0]]; - let ys = [a[1], b[1], c[1]]; + /// A triangle with its own texture coordinates and corner opacities. + pub(crate) fn tri_uv(source: Source, p: [P; 3], uv: [P; 3], fade: [f64; 3], alpha: f64) -> Layer { + let xs = [uv[0][0], uv[1][0], uv[2][0]]; + let ys = [uv[0][1], uv[1][1], uv[2][1]]; let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min); let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max); - Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha } + Layer { + source, + src: [mn(xs), mn(ys), mx(xs), mx(ys)], + quad: [p[0], p[1], p[2], p[2]], + uv: [uv[0], uv[1], uv[2], uv[2]], + alpha, + fade: [fade[0], fade[1], fade[2], fade[2]], + } + } + /// With corner opacities. + pub(crate) fn faded(mut self, fade: [f64; 4]) -> Layer { + self.fade = fade; + self + } + /// A black copy of this layer's shape at `k` of its opacity: shading a card. + pub(crate) fn shade(&self, k: f64) -> Layer { + Layer { source: Source::Black, alpha: self.alpha * k.clamp(0.0, 1.0), ..*self } } fn sanitized(mut self) -> Layer { let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 }; @@ -81,827 +135,48 @@ impl Layer { c[0] = c[0].clamp(0.0, 1.0); c[1] = c[1].clamp(0.0, 1.0); } + for a in self.fade.iter_mut() { + *a = clampf(*a, 0.0, 1.0, 0.0); + } self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0); self } } -// ---------- geometry helpers ---------- - -fn offset(r: R, dx: f64, dy: f64) -> R { - [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy] -} - -fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer { - Layer::rect(source, FULL, offset(FULL, dx, dy), alpha) -} - -/// The whole source scaled about (cx, cy) and moved. -fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer { - let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy]; - Layer::rect(source, FULL, r, alpha) -} - -/// Rotate a point about a pivot (degrees). -fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] { - let (s, co) = deg.to_radians().sin_cos(); - let (x, y) = (p[0] - c[0], p[1] - c[1]); - [c[0] + x * co - y * s, c[1] + x * s + y * co] -} - -fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] { - [rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)] -} - -/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end). -fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] { - let h = r[3] - r[1]; - [[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]] -} - -/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end). -fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] { - let w = r[2] - r[0]; - [[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]] -} - -/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction). -fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R { - let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r }; - if mx { - r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]]; - } - if my { - r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]]; - } - r -} - -/// Motion direction of an option: l, r, u, d and diagonals. -fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) { - let mut d = (0.0, 0.0); - if opt.contains('l') { - d.0 = -1.0; - } - if opt.contains('r') { - d.0 = 1.0; - } - if opt.contains('u') { - d.1 = -1.0; - } - if opt.contains('d') { - d.1 = 1.0; - } - if d == (0.0, 0.0) { default } else { d } -} - -fn hash64(mut x: u64) -> u64 { - x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); - x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - x ^ (x >> 31) -} - -/// Deterministic pseudo-random value in [0, 1). -fn rnd(i: u64) -> f64 { - (hash64(i) >> 11) as f64 / (1u64 << 53) as f64 -} - -fn ramp(t: f64, order: f64, window: f64) -> f64 { - let w = window.clamp(0.01, 1.0); - ((t - order * (1.0 - w)) / w).clamp(0.0, 1.0) -} - -fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> { - let mut v = Vec::with_capacity(cols * rows); - for r in 0..rows { - for c in 0..cols { - let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64]; - v.push((c, r, cell)); - } - } - v -} - -fn center(r: R) -> [f64; 2] { - [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] -} - -fn scale_rect(r: R, s: f64) -> R { - let c = center(r); - let hw = (r[2] - r[0]) / 2.0 * s; - let hh = (r[3] - r[1]) / 2.0 * s; - [c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh] -} - -// ---------- effects ---------- +/// Slide aspect assumed by [`transition_layers`] (16:9). +pub const DEFAULT_ASPECT: f64 = 16.0 / 9.0; /// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at -/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone; -/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph -/// frames with [`crate::morph_frame`]). +/// progress `t` (0..1) on a 16:9 slide. See [`transition_frame`]. pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec { + transition_frame(kind, option, t, DEFAULT_ASPECT) +} + +/// Layers of the transition `kind` with `option` at linear progress `t` (0..1; the effect applies +/// its own easing) on a slide `aspect` wide per unit of height. Draw in order over black. At +/// `t >= 1` the result is the new slide alone; at `t <= 0` (or NaN) the old slide alone. Morph +/// returns a plain crossfade (the UI renders morph frames with [`crate::morph_frame`]). Layers +/// with [`Source::is_part`] sources need the slides rendered as background and content +/// ([`uses_slide_parts`]). +pub fn transition_frame(kind: &str, option: &str, t: f64, aspect: f64) -> Vec { let t = clampf(t, 0.0, 1.0, 0.0); + let aspect = if aspect.is_finite() && aspect > 0.01 { aspect.min(100.0) } else { DEFAULT_ASPECT }; let v = if t >= 1.0 { vec![Layer::full(Source::New, 1.0)] } else if t <= 0.0 { vec![Layer::full(Source::Old, 1.0)] } else { - layers(kind, option, t, 0) + crate::transition_fx::layers(kind, option, t, aspect, 0) }; - v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect() + v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0 && l.fade.iter().any(|a| *a > 0.0)).collect() } -fn crossfade(t: f64) -> Vec { - vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)] -} - -const RANDOM_KINDS: &[&str] = &[ - "fade", - "push", - "wipe", - "split", - "reveal", - "randomBar", - "shape", - "uncover", - "cover", - "fallOver", - "drape", - "curtains", - "wind", - "prestige", - "fracture", - "crush", - "peelOff", - "pageCurlDouble", - "airplane", - "origami", - "dissolve", - "checker", - "blinds", - "clock", - "ripple", - "honeycomb", - "glitter", - "vortex", - "shred", - "switch", - "flip", - "gallery", - "cube", - "doors", - "box", - "comb", - "zoom", - "pan", - "ferris", - "conveyor", - "rotate", - "window", - "orbit", - "flythrough", -]; - -fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec { - use Source::{Black, New, Old, White}; - let o = option; - let s = smooth(t); +/// Does `kind` animate the slides' backgrounds and contents separately (PowerPoint's Dynamic +/// Content transitions and Reveal)? Then the frame uses [`Source::OldBack`] & co. +pub fn uses_slide_parts(kind: &str, option: &str) -> bool { match kind { - "none" => vec![Layer::full(New, 1.0)], - "cut" => { - if o == "throughBlack" && t < 0.5 { - vec![Layer::full(Black, 1.0)] - } else { - vec![Layer::full(New, 1.0)] - } - } - "fade" if o == "throughBlack" => { - if t < 0.5 { - vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)] - } else { - vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)] - } - } - "fade" | "morph" => crossfade(t), - "push" | "pan" => { - let (dx, dy) = dir(o, (0.0, -1.0)); - vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] - } - "uncover" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)] - } - "cover" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] - } - "wipe" => wipe(o, t), - "split" => { - let horz = o.starts_with("horz"); - let mut v = vec![Layer::full(Old, 1.0)]; - let rects: Vec = if o.ends_with("In") { - vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]] - } else { - vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]] - }; - for r in rects { - v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); - } - v - } - "reveal" => { - let dx = if o.ends_with("Right") { 1.0 } else { -1.0 }; - if o.starts_with("black") { - if t < 0.5 { - vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)] - } else { - vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)] - } - } else { - vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)] - } - } - "randomBar" => { - let horz = o == "horz"; - let n = 48; - let mut v = vec![Layer::full(Old, 1.0)]; - for i in 0..n { - if t > rnd(i as u64 + 17) * 0.98 { - let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0]; - v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); - } - } - v - } - "shape" => { - let form = match o { - "circle" | "diamond" | "plus" => o, - _ => "box", - }; - let inward = o == "in"; - if inward { - let mut v = vec![Layer::full(New, 1.0)]; - v.extend(shape_form(Old, form, 1.0 - s)); - v - } else { - let mut v = vec![Layer::full(Old, 1.0)]; - v.extend(shape_form(New, form, s)); - v - } - } - "flash" => { - if t < 0.5 { - vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))] - } else { - vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)] - } - } - "fallOver" => { - let sg = if o == "r" { 1.0 } else { -1.0 }; - let a = t * t * PI / 2.0; - let top = 1.0 - a.cos(); - let ins = 0.12 * a.sin(); - let sh = sg * 0.15 * a.sin(); - let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]]; - vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))] - } - "drape" => { - let k = t * t; - let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]]; - let q = if o == "r" { mirror_q(q) } else { q }; - vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)] - } - "curtains" => { - let w = 0.5 * (1.0 - s); - let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]]; - let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]]; - vec![ - scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0), - Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0), - Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0), - ] - } - "wind" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let r = offset(FULL, d * 1.2 * s, -0.1 * s); - let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]]; - vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)] - } - "prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)], - "fracture" => { - let mut v = vec![Layer::full(New, 1.0)]; - for (c, r, cell) in grid(8, 6) { - let i = (r * 8 + c) as u64; - let q = ramp(t, rnd(i + 101) * 0.5, 0.5); - let cc = center(cell); - let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q); - let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q); - v.push(Layer::quad(Old, cell, quad, 1.0 - q)); - } - v - } - "crush" => { - let mut v = vec![Layer::full(New, 1.0)]; - for (c, r, cell) in grid(4, 4) { - let i = (r * 4 + c) as u64; - let cc = center(cell); - let k = 1.0 - s; - let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k]; - let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3))); - let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]); - let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s); - v.push(Layer::quad(Old, cell, quad, 1.0 - t * t)); - } - v - } - "peelOff" => { - let mut v = vec![Layer::full(New, 1.0)]; - v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r")); - v - } - "pageCurlDouble" => { - let mut v = vec![Layer::full(New, 1.0)]; - let left_first = o != "r"; - v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first)); - v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first)); - v - } - "airplane" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let k = 1.0 - 0.8 * s; - let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s); - let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s }); - vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)] - } - "origami" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let mut v = vec![Layer::full(New, 1.0)]; - if t < 0.5 { - let f = t * 2.0; - let w = 0.5 * (1.0 - 0.8 * f); - v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0)); - v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0)); - } else { - let f = (t - 0.5) * 2.0; - let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f); - v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f)); - } - v - } - "dissolve" => { - let mut v = vec![Layer::full(Old, 1.0)]; - for (c, r, cell) in grid(32, 18) { - if t > rnd((r * 32 + c) as u64 + 991) * 0.98 { - v.push(Layer::inplace(New, cell, 1.0)); - } - } - v - } - "checker" => { - let vert = o == "vert"; - let (n, m) = (10usize, 8usize); - let mut v = vec![Layer::full(Old, 1.0)]; - for (c, r, cell) in grid(n, m) { - let across = if vert { c } else { r }; - let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0); - if f <= 0.0 { - continue; - } - let rc = if vert { - [cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f] - } else { - [cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]] - }; - v.push(Layer::inplace(New, rc, 1.0)); - } - v - } - "blinds" => { - let horz = o == "horz"; - let n = 8; - let mut v = vec![Layer::full(Old, 1.0)]; - for i in 0..n { - let x0 = i as f64 / n as f64; - let r = [x0, 0.0, x0 + s / n as f64, 1.0]; - v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); - } - v - } - "clock" => clock(o, s), - "ripple" => { - let origin = match o { - "lu" => [0.0, 0.0], - "ru" => [1.0, 0.0], - "ld" => [0.0, 1.0], - "rd" => [1.0, 1.0], - _ => [0.5, 0.5], - }; - let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 }; - let mut v = vec![Layer::full(Old, 1.0)]; - for (_, _, cell) in grid(24, 14) { - let cc = center(cell); - let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd; - let a = ramp(t, d.min(1.0), 0.3); - if a > 0.0 { - let sc = 1.0 + 0.15 * (a * PI).sin(); - v.push(Layer::rect(New, cell, scale_rect(cell, sc), a)); - } - } - v - } - "honeycomb" => { - let (cols, rows) = (12usize, 8usize); - let mut v = vec![Layer::full(Old, 1.0)]; - for c in 0..cols { - let odd = c % 2 == 1; - let extra = if odd { 1 } else { 0 }; - for r in 0..rows + extra { - let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64; - let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)]; - let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35); - if a > 0.0 { - v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a)); - } - } - } - v - } - "glitter" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let mut v = vec![Layer::full(Old, 1.0)]; - for (c, r, cell) in grid(20, 12) { - let cc = center(cell); - let grad = if dx < 0.0 { - 1.0 - cc[0] - } else if dx > 0.0 { - cc[0] - } else if dy < 0.0 { - 1.0 - cc[1] - } else { - cc[1] - }; - let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77); - let a = ramp(t, order, 0.25); - if a > 0.0 { - let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0); - v.push(Layer::quad(New, cell, quad, a)); - } - } - v - } - "vortex" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let mut v = vec![Layer::full(New, 1.0)]; - for (c, r, cell) in grid(16, 9) { - let i = (r * 16 + c) as u64; - let cc = center(cell); - let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy); - let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4); - let ang = q * 2.0 * PI + rnd(i) * PI; - let rad = q * 1.2; - let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3); - let pos = [cc[0] + ox, cc[1] + oy]; - let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0); - v.push(Layer::quad(Old, cell, quad, 1.0 - q)); - } - v - } - "shred" => { - let mut v = vec![Layer::full(New, 1.0)]; - if o == "particles" { - for (c, r, cell) in grid(24, 14) { - let i = (r * 24 + c) as u64; - let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4); - let dy = q * q * 1.2; - let dx = (rnd(i + 9) - 0.5) * 0.3 * q; - v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q)); - } - } else { - let n = 16; - for i in 0..n { - let x0 = i as f64 / n as f64; - let x1 = (i + 1) as f64 / n as f64; - let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7); - let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q }; - v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0)); - } - } - v - } - "switch" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let k = (t * PI).sin(); - let old = Layer::quad( - Old, - FULL, - persp_h( - offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0), - if d > 0.0 { 0.0 } else { 0.08 * k }, - if d > 0.0 { 0.08 * k } else { 0.0 }, - ), - 1.0, - ); - let new = Layer::quad( - New, - FULL, - persp_h( - offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0), - if d > 0.0 { 0.08 * k } else { 0.0 }, - if d > 0.0 { 0.0 } else { 0.08 * k }, - ), - 1.0, - ); - if t < 0.5 { vec![new, old] } else { vec![old, new] } - } - "flip" => { - let a = PI * s; - let w = a.cos().abs(); - let k = 0.1 * a.sin(); - let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0]; - let right = o != "l"; - if t < 0.5 { - let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) }; - vec![Layer::quad(Old, FULL, q, 1.0)] - } else { - let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) }; - vec![Layer::quad(New, FULL, q, 1.0)] - } - } - "gallery" | "conveyor" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let k = (s * PI).sin(); - let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k; - let gap = if kind == "gallery" { 1.15 } else { 1.05 }; - let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0); - let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0); - let p = 0.08 * k; - vec![ - Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0), - Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0), - ] - } - "cube" | "box" | "rotate" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let vertical = dy != 0.0 && dx == 0.0; - let neg = if vertical { dy < 0.0 } else { dx < 0.0 }; - let k = (s * PI).sin(); - let (far_old, far_new, shared) = match kind { - "cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k), - "box" => (0.0, 0.0, 0.18 * k), - _ => (0.25 * s, 0.25 * (1.0 - s), 0.0), - }; - // Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1]. - let split = 1.0 - s; - let (old_q, new_q) = if kind == "rotate" { - if t < 0.5 { - (Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None) - } else { - (None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new))) - } - } else { - (Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new))) - }; - let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] { - let q = if neg { q } else { mirror_q(q) }; - if vertical { swap_q(q) } else { q } - }; - let mut v = vec![Layer::full(Black, 1.0)]; - if let Some(q) = old_q { - v.push(Layer::quad(Old, FULL, fix(q), 1.0)); - } - if let Some(q) = new_q { - v.push(Layer::quad(New, FULL, fix(q), 1.0)); - } - v - } - "doors" | "window" => { - let horz = o == "horz"; - let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) }; - let w = 0.5 * (1.0 - s); - let k = persp * s; - let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]]; - let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]]; - let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) }; - let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) }; - vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)] - } - "comb" => { - let vert = o == "vert"; - let n = 8; - let mut v = Vec::new(); - for i in 0..n { - let a = i as f64 / n as f64; - let b = (i + 1) as f64 / n as f64; - let d = if i % 2 == 0 { 1.0 } else { -1.0 }; - let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; - let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) }; - let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) }; - v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0)); - v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0)); - } - v - } - "zoom" => { - if o == "out" { - vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)] - } else { - vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)] - } - } - "orbit" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let k = (s * PI).sin(); - let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s); - let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s); - if t < 0.5 { vec![new, old] } else { vec![old, new] } - } - "flythrough" => { - let bounce = o.ends_with("Bounce"); - let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base }; - if o.starts_with("out") { - vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)] - } else { - vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)] - } - } - "ferris" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let pivot = [0.5, 2.5]; - let a = 50.0 * s * d; - let oq = rot_quad(corners(FULL), pivot, a); - let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d); - vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)] - } - "random" if depth == 0 => { - let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64)); - let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade"); - let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or(""); - layers(k, opt, t, depth + 1) - } - _ => crossfade(t), + "pan" | "ferris" | "conveyor" | "rotate" | "window" | "orbit" | "flythrough" => true, + "reveal" => !option.starts_with("black"), + _ => false, } } - -fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] { - // Mirror x and keep the tl, tr, br, bl order (swap left/right corners). - let m = |p: [f64; 2]| [1.0 - p[0], p[1]]; - [m(q[1]), m(q[0]), m(q[3]), m(q[2])] -} - -fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] { - // Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners. - let s = |p: [f64; 2]| [p[1], p[0]]; - [s(q[0]), s(q[3]), s(q[2]), s(q[1])] -} - -fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] { - persp_h(r, left_inset, right_inset) -} - -fn wipe(o: &str, t: f64) -> Vec { - let mut v = vec![Layer::full(Source::Old, 1.0)]; - let diag = o.len() == 2; - if diag { - // Diagonal: columns revealed by a slanted edge. - let (mx, my) = (o.contains('l'), o.contains('u')); - let n = 32; - for i in 0..n { - let x0 = i as f64 / n as f64; - let x1 = (i + 1) as f64 / n as f64; - let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0); - if h > 0.0 { - v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0)); - } - } - return v; - } - let (mx, swap) = match o { - "l" => (true, false), - "d" => (false, true), - "u" => (true, true), - _ => (false, false), - }; - let soft = 0.15; - let e = t * (1.0 + soft); - let solid = (e - soft).clamp(0.0, 1.0); - if solid > 0.0 { - v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0)); - } - let n = 6; - for k in 0..n { - let a = e - soft + soft * k as f64 / n as f64; - let b = e - soft + soft * (k + 1) as f64 / n as f64; - let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); - if b > a { - let alpha = 1.0 - (k as f64 + 0.5) / n as f64; - v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha)); - } - } - v -} - -/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips. -fn shape_form(src: Source, form: &str, f: f64) -> Vec { - let f = f.clamp(0.0, 1.0); - if f <= 0.0 { - return vec![]; - } - match form { - "plus" => { - let h = f / 2.0; - vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)] - } - "circle" | "diamond" => { - let n = 40; - let r = if form == "circle" { f * 0.75 } else { f * 1.02 }; - let mut v = Vec::new(); - for i in 0..n { - let y0 = i as f64 / n as f64; - let y1 = (i + 1) as f64 / n as f64; - let dy = ((y0 + y1) / 2.0 - 0.5).abs(); - let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy }; - if half > 0.0 { - let half = half.min(0.5); - v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0)); - } - } - v - } - _ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)], - } -} - -/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap. -fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec { - let w = area[2] - area[0]; - let p = s * w; - if toward_right { - let edge = area[0] + p; - let stay = [edge, area[1], area[2], area[3]]; - let flap_src = [area[0], area[1], edge, area[3]]; - let x2 = edge + p; - let flap = Layer { - source: src, - src: flap_src, - quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]], - uv: corners(flap_src), - alpha: 0.85, - }; - vec![Layer::inplace(src, stay, 1.0), flap] - } else { - let edge = area[2] - p; - let stay = [area[0], area[1], edge, area[3]]; - let flap_src = [edge, area[1], area[2], area[3]]; - let x2 = edge - p; - let flap = Layer { - source: src, - src: flap_src, - quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]], - uv: corners(flap_src), - alpha: 0.85, - }; - vec![Layer::inplace(src, stay, 1.0), flap] - } -} - -/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from -/// 12 o'clock). -fn boundary(a: f64) -> [f64; 2] { - let (dx, dy) = (a.sin(), -a.cos()); - let m = dx.abs().max(dy.abs()).max(1e-9); - [0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m] -} - -fn clock(o: &str, s: f64) -> Vec { - let mut v = vec![Layer::full(Source::Old, 1.0)]; - let c = [0.5, 0.5]; - let sweep = s * 2.0 * PI; - let (a0, a1) = match o { - "counterClockwise" => (-sweep, 0.0), - "wedge" => (-sweep / 2.0, sweep / 2.0), - _ => (0.0, sweep), - }; - let span = a1 - a0; - if span <= 0.0 { - return v; - } - let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96); - let mut angles: Vec = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect(); - // Square corners inside the sweep, so the fan reaches them. - for k in -8i32..=8 { - let a = PI / 4.0 + k as f64 * PI / 2.0; - if a > a0 && a < a1 { - angles.push(a); - } - } - angles.sort_by(f64::total_cmp); - for w in angles.windows(2) { - if let [x, y] = w - && y > x - { - v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0)); - } - } - v -} diff --git a/crates/anim/src/transition_fx.rs b/crates/anim/src/transition_fx.rs new file mode 100644 index 00000000..d5fb2e16 --- /dev/null +++ b/crates/anim/src/transition_fx.rs @@ -0,0 +1,1169 @@ +//! The transition effects, as measured on PowerPoint (see [`crate::transition`]). +//! +//! `t` is linear progress (0..1) through the transition's duration; each effect applies its own +//! curve. Directions follow the file format's option: for Push, Wipe, Cover, Uncover, Pan, Vortex, +//! Cube… `dir` is where things move (`l`: toward the left); for Glitter it is the side the effect +//! starts from; Ripple's corner is where the wave heads. + +use std::f64::consts::PI; + +use crate::easing::ease; +use crate::transition::{FULL, Layer, R, Source}; +use crate::transition_geom::*; + +use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White}; + +const RANDOM_KINDS: &[&str] = &[ + "fade", + "push", + "wipe", + "split", + "reveal", + "randomBar", + "shape", + "uncover", + "cover", + "fallOver", + "drape", + "curtains", + "wind", + "prestige", + "fracture", + "crush", + "peelOff", + "pageCurlDouble", + "airplane", + "origami", + "dissolve", + "checker", + "blinds", + "clock", + "ripple", + "honeycomb", + "glitter", + "vortex", + "shred", + "switch", + "flip", + "gallery", + "cube", + "doors", + "box", + "comb", + "zoom", + "pan", + "ferris", + "conveyor", + "rotate", + "window", + "orbit", + "flythrough", +]; + +pub(crate) fn layers(kind: &str, o: &str, t: f64, a: f64, depth: u32) -> Vec { + match kind { + "none" => vec![Layer::full(New, 1.0)], + "cut" => { + if o == "throughBlack" && t < 0.5 { + vec![Layer::full(Black, 1.0)] + } else { + vec![Layer::full(New, 1.0)] + } + } + "fade" if o == "throughBlack" => through_black(t, 0.5, 0.5), + "fade" | "morph" => vec![Layer::full(Old, 1.0), Layer::full(New, ss(t))], + "push" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + let s = q2(t); + vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] + } + "uncover" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + // Diagonal: the old slide accelerates off in the first third. + let s = if o.len() == 2 { ein(win(t, 0.05, 0.35)) } else { pow_inout(t, 3.0) }; + vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)] + } + "cover" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + // Diagonal: the new slide shoots in and settles slowly. + let s = if o.len() == 2 { 1.0 - (1.0 - win(t, 0.07, 0.95)).powi(3) } else { pow_inout(t, 4.0) }; + vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] + } + "wipe" => wipe(o, t), + "split" => split(o, t), + "reveal" => reveal(o, t), + "randomBar" => random_bars(o == "vert", t), + "shape" => shape(o, t, a), + "flash" => flash(t), + "dissolve" => dissolve(t), + "checker" => checker(o == "vert", t, a), + "blinds" => blinds(o == "vert", t, a), + "clock" => clock(o, t, a), + "ripple" => ripple(o, t, a), + "comb" => comb(o == "vert", t), + "zoom" => zoom(o, t, a), + "pan" => pan(o, t), + "fallOver" => fall_over(o == "r", t, a), + "drape" => drape(o == "r", t, a), + "curtains" => curtains(t, a), + "wind" => wind(o == "r", t, a), + "prestige" => prestige(t, a), + "fracture" => fracture(t, a), + "crush" => crush(t, a), + "peelOff" => peel_off(o == "r", t), + "pageCurlDouble" => page_curl(o, t), + "airplane" => airplane(o == "r", t, a), + "origami" => origami(o == "r", t, a), + "honeycomb" => honeycomb(t, a), + "glitter" => glitter(o, t, a), + "vortex" => vortex(o, t, a), + "shred" => shred(o, t, a), + "switch" => switch(o == "r", t, a), + "flip" => flip(o == "r", t, a), + "gallery" => gallery(o == "r", t, a), + "conveyor" => conveyor(o == "r", t, a), + "cube" | "box" | "rotate" | "orbit" => prism(kind, o, t, a), + "doors" | "window" => doors(kind == "window", o == "horz", t, a), + "ferris" => ferris(o == "r", t, a), + "flythrough" => flythrough(o, t), + "random" if depth == 0 => { + let h = o.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64)); + let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade"); + let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or(""); + layers(k, opt, t, a, depth + 1) + } + _ => vec![Layer::full(Old, 1.0), Layer::full(New, ss(t))], + } +} + +/// Old fades to black over [0, `out`], new fades in from black over [`inn`, 1] (smoothstep). +fn through_black(t: f64, out: f64, inn: f64) -> Vec { + let mut v = vec![Layer::full(Black, 1.0)]; + if t < out { + v.push(Layer::full(Old, 1.0 - ss(win(t, 0.0, out)))); + } + if t > inn { + v.push(Layer::full(New, ss(win(t, inn, 1.0)))); + } + v +} + +// ---------- Subtle ---------- + +/// Width of the soft edge of Wipe and Split (slide units along the motion). +const WIPE_SOFT: f64 = 0.8; + +/// Where the middle of the soft edge is at `t`: from fully before the slide to fully past it. +fn wipe_pos(t: f64) -> f64 { + -WIPE_SOFT / 2.0 + (1.0 + WIPE_SOFT) * 1.05 * t +} + +fn wipe(o: &str, t: f64) -> Vec { + let c = wipe_pos(t); + // Position along the motion, 0 where the new slide starts showing. + let p: Box f64> = match o { + "l" => Box::new(|x, _| 1.0 - x), + "u" => Box::new(|_, y| 1.0 - y), + "d" => Box::new(|_, y| y), + "lu" => Box::new(|x, y| (2.0 - x - y) / 2.0), + "ru" => Box::new(|x, y| (1.0 + x - y) / 2.0), + "ld" => Box::new(|x, y| (1.0 - x + y) / 2.0), + "rd" => Box::new(|x, y| (x + y) / 2.0), + _ => Box::new(|x, _| x), + }; + let diag = o.len() == 2; + let (n, m) = if diag { + (32, 18) + } else if matches!(o, "u" | "d") { + (1, 36) + } else { + (48, 1) + }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(field(New, n, m, &|x, y| edge(c, p(x, y), WIPE_SOFT))); + v +} + +fn split(o: &str, t: f64) -> Vec { + let horz = o.starts_with("horz"); + let inward = o.ends_with("In"); + let c = wipe_pos(t); + let p = move |x: f64, y: f64| { + let d = if horz { (y - 0.5).abs() * 2.0 } else { (x - 0.5).abs() * 2.0 }; + if inward { 1.0 - d } else { d } + }; + let (n, m) = if horz { (1, 36) } else { (48, 1) }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(field(New, n, m, &|x, y| edge(c, p(x, y), WIPE_SOFT))); + v +} + +fn reveal(o: &str, t: f64) -> Vec { + let dx = if o.ends_with("Right") { 1.0 } else { -1.0 }; + if o.starts_with("black") { + // Old slides away fading to black, a pause on black, new slides in from black. + let mut v = vec![Layer::full(Black, 1.0)]; + if t < 0.4 { + let u = win(t, 0.0, 0.38); + v.push(moved(Old, dx * 0.12 * ein(u), 0.0, 1.0 - ss(u))); + } + if t > 0.55 { + let u = win(t, 0.55, 1.0); + v.push(moved(New, -dx * 0.12 * (1.0 - eout(u)), 0.0, ss(u))); + } + return v; + } + // Smoothly: the backgrounds cross-fade while the old content slides off fading and the new + // content follows. + let mut v = vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))]; + if t < 0.5 { + let u = win(t, 0.0, 0.45); + v.push(moved(OldContent, dx * 0.12 * ein(u), 0.0, 1.0 - ss(u))); + } + if t > 0.45 { + let u = win(t, 0.47, 0.95); + v.push(moved(NewContent, -dx * 0.12 * (1.0 - eout(u)), 0.0, ss(u))); + } + v +} + +fn random_bars(vert: bool, t: f64) -> Vec { + // Thin bars, each cross-fading quickly at its own random moment. + let n = 160usize; + let w = 0.2; + let mut v = vec![Layer::full(Old, 1.0)]; + for i in 0..n { + let start = rnd(i as u64 * 7 + 17) * (1.0 - w); + let k = win(t, start, start + w); + if k <= 0.0 { + continue; + } + let (a, b) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let r: R = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; + v.push(Layer::inplace(New, r, k)); + } + v +} + +fn shape(o: &str, t: f64, a: f64) -> Vec { + match o { + // Shape In: the old slide shrinks into the centre over the first half. + "in" => { + let s = 1.0 - win(t, 0.0, 0.5); + let mut v = vec![Layer::full(New, 1.0)]; + if s > 0.0 { + v.extend(soft_rect(Old, FULL, scale_rect(FULL, s), 0.012, 1.0)); + } + v + } + // Shape Out: the new slide grows out of the centre over the second half. + "out" => { + let s = win(t, 0.5, 1.0); + let mut v = vec![Layer::full(Old, 1.0)]; + if s > 0.0 { + v.extend(soft_rect(New, FULL, scale_rect(FULL, s), 0.012, 1.0)); + } + v + } + _ => { + let soft = 0.18; + let c = -soft / 2.0 + (1.0 + soft) * t; + let f: Box f64> = match o { + "diamond" => Box::new(move |x, y| edge(c, (x - 0.5).abs() + (y - 0.5).abs(), soft)), + "plus" => Box::new(move |x, y| edge(c, (x - 0.5).abs().min((y - 0.5).abs()) * 2.0, soft)), + _ => { + let rmax = ((0.5 * a).powi(2) + 0.25).sqrt(); + Box::new(move |x, y| edge(c, (((x - 0.5) * a).powi(2) + (y - 0.5).powi(2)).sqrt() / rmax, soft)) + } + }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(field(New, 48, 27, &*f)); + v + } + } +} + +fn flash(t: f64) -> Vec { + // A flash bulb: the old slide burns out to white fast, the white holds, then fades off the new + // slide. + let white = if t < 0.1 { + t / 0.1 + } else if t < 0.47 { + 1.0 + } else { + 1.0 - win(t, 0.47, 0.88) + }; + let base = if t < 0.2 { Old } else { New }; + vec![Layer::full(base, 1.0), Layer::full(White, white)] +} + +// ---------- Exciting: patterns ---------- + +fn dissolve(t: f64) -> Vec { + let (n, m) = (48usize, 27usize); + let mut v = vec![Layer::full(Old, 1.0)]; + let cell = |c: usize, r: usize| [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + for r in 0..m { + for c in 0..n { + if t > rnd((r * n + c) as u64 + 991) * 0.97 { + v.push(Layer::inplace(New, cell(c, r), 1.0)); + } + } + } + v +} + +/// One tile flipping in 3-D about its own vertical (or horizontal) axis: old face for the first +/// half of `k`, new face for the second. +fn flip_tile(cam: &Cam, cell: R, k: f64, vertical_axis: bool, out: &mut Vec) { + if k <= 0.0 { + out.push(Layer::inplace(Old, cell, 1.0)); + return; + } + if k >= 1.0 { + out.push(Layer::inplace(New, cell, 1.0)); + return; + } + let ang = 180.0 * k; + let cc = center(cell); + let c3 = cam.unit(cc[0], cc[1]); + let (src, deg) = if k < 0.5 { (Old, ang) } else { (New, ang - 180.0) }; + let pts = cam.rect(cell).map(|p| if vertical_axis { p.yaw(c3, deg) } else { p.pitch(c3, deg) }); + let sh = facing_shade(pts, 0.6); + out.extend(card(cam, src, cell, pts, 1.0, sh, true)); +} + +fn checker(vert: bool, t: f64, a: f64) -> Vec { + let (n, m) = (10usize, 8usize); + let cam = Cam { a, d: 3.0 }; + let mut v = vec![Layer::full(Black, 1.0)]; + for r in 0..m { + for c in 0..n { + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let across = if vert { r as f64 / m as f64 } else { c as f64 / n as f64 }; + let start = across * 0.55 + rnd((r * n + c) as u64 + 5) * 0.12; + let k = q2(win(t, start, start + 0.3)); + flip_tile(&cam, cell, k, !vert, &mut v); + } + } + v +} + +fn blinds(vert: bool, t: f64, a: f64) -> Vec { + let n = 12usize; + let cam = Cam { a, d: 3.0 }; + let mut v = vec![Layer::full(Black, 1.0)]; + for i in 0..n { + let (s, e) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let cell = if vert { [s, 0.0, e, 1.0] } else { [0.0, s, 1.0, e] }; + let start = 0.1 + 0.3 * i as f64 / n as f64; + let k = q2(win(t, start, start + 0.4)); + flip_tile(&cam, cell, k, vert, &mut v); + } + v +} + +fn clock(o: &str, t: f64, a: f64) -> Vec { + // The hand sweeps at a constant speed with a soft leading edge. + let soft = 0.12; + let c = -soft / 2.0 + (1.0 + soft) * t; + let f = move |ang: f64| -> f64 { + let p = match o { + "counterClockwise" => (2.0 * PI - ang) / (2.0 * PI), + "wedge" => { + let d = if ang > PI { 2.0 * PI - ang } else { ang }; + d / PI + } + _ => ang / (2.0 * PI), + }; + edge(c, p, soft) + }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(fan(New, a, 240, &f)); + v +} + +fn ripple(o: &str, t: f64, a: f64) -> Vec { + // A soft disc of the new slide spreads from the centre, or from the corner opposite to where + // the wave heads. + let origin = match o { + "lu" => [1.0, 1.0], + "ru" => [0.0, 1.0], + "ld" => [1.0, 0.0], + "rd" => [0.0, 0.0], + _ => [0.5, 0.5], + }; + let far = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] + .iter() + .map(|p: &[f64; 2]| (((p[0] - origin[0]) * a).powi(2) + (p[1] - origin[1]).powi(2)).sqrt()) + .fold(0.0, f64::max); + let soft = 0.16; + let c = -soft / 2.0 + (1.0 + soft) * t; + let mut v = vec![Layer::full(Old, 1.0)]; + let f = |x: f64, y: f64| { + let d = (((x - origin[0]) * a).powi(2) + (y - origin[1]).powi(2)).sqrt() / far.max(1e-6); + edge(c, d, soft) + }; + v.extend(field(New, 48, 27, &f)); + // Rings of shade travelling inside the wave. + let rings = |x: f64, y: f64| { + let d = (((x - origin[0]) * a).powi(2) + (y - origin[1]).powi(2)).sqrt() / far.max(1e-6); + let inside = edge(c, d, soft); + let w = ((d - c) * 30.0).sin().abs(); + 0.18 * inside * (1.0 - w) * (1.0 - t) + }; + v.extend(field(Black, 48, 27, &rings)); + v +} + +fn comb(vert: bool, t: f64) -> Vec { + // Bands of the old slide slide off to alternate sides, one after another from the last band, + // uncovering the new slide. + let n = if vert { 10usize } else { 7usize }; + let mut v = vec![Layer::full(New, 1.0)]; + for i in 0..n { + let (a, b) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let d = if (n - 1 - i) % 2 == 0 { 1.0 } else { -1.0 }; + let start = 0.06 + 0.05 * (n - 1 - i) as f64 * 7.0 / n as f64; + let s = ein(win(t, start, start + 0.55)); + if s >= 1.0 { + continue; + } + let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; + let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) }; + v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0)); + } + v +} + +fn zoom(o: &str, t: f64, a: f64) -> Vec { + match o { + // Zoom and Rotate: the new slide spins in from the centre, a full clockwise turn, fading + // in as it grows. + "rotate" => { + let u = win(t, 0.1, 0.98); + let s = 0.1 + 0.9 * u; + let ang = 360.0 * (1.0 - u); + let r = scale_rect(FULL, s); + let q = rot_quad(crate::transition::corners(r), [0.5, 0.5], ang, a); + vec![Layer::full(Old, 1.0), Layer::quad(New, FULL, q, 0.35 + 0.65 * ss(win(t, 0.0, 0.45)))] + } + // Zoom Out: the new slide comes down from large underneath while the old one shrinks away + // fading. + "out" => { + let s = eout(win(t, 0.0, 0.8)); + vec![ + scaled(New, 1.9 - 0.9 * s, 0.5, 0.5, 0.0, 0.0, 1.0), + scaled(Old, 1.0 - 0.5 * eout(t), 0.5, 0.5, 0.0, 0.0, 1.0 - ss(win(t, 0.0, 0.95))), + ] + } + // Zoom In: the old slide grows toward the viewer fading, the new one grows into place + // from small. + _ => vec![ + Layer::full(Black, 1.0), + scaled(Old, 1.0 + 0.3 * ss(t), 0.5, 0.5, 0.0, 0.0, 1.0 - ss(win(t, 0.0, 0.9))), + scaled(New, 0.5 + 0.5 * ss(win(t, 0.15, 0.95)), 0.5, 0.5, 0.0, 0.0, ss(win(t, 0.0, 0.85))), + ], + } +} + +fn fall_over(right: bool, t: f64, a: f64) -> Vec { + // The old slide tips forward about its bottom edge and falls out of view. + let cam = Cam { a, d: 2.2 }; + let u = ein(win(t, 0.0, 0.88)); + let ang = 92.0 * u; + let pivot = v3(0.0, 0.5, 0.0); + let side = if right { 1.0 } else { -1.0 }; + let pts = cam.rect(FULL).map(|p| p.pitch(pivot, ang).roll(pivot, side * 6.0 * u)); + let mut v = vec![Layer::full(New, 1.0)]; + if u < 1.0 { + v.extend(card(&cam, Old, FULL, pts, 1.0, facing_shade(pts, 0.4), true)); + } + v +} + +fn drape(right: bool, t: f64, a: f64) -> Vec { + // The new slide drops from its top edge like cloth over the old one. + let cam = Cam { a, d: 2.2 }; + let u = q2(win(t, 0.25, 0.62)); + let wob = 4.0 * (1.0 - win(t, 0.6, 1.0)) * (win(t, 0.6, 1.0) * PI * 2.0).sin(); + let ang = 90.0 * (1.0 - u) + wob * u; + let pivot = v3(0.0, -0.5, 0.0); + let side = if right { -1.0 } else { 1.0 }; + let pts = cam.rect(FULL).map(|p| p.pitch(pivot, -ang).roll(pivot, side * 3.0 * (1.0 - u))); + let mut v = vec![Layer::full(Old, 1.0)]; + if u > 0.0 { + v.extend(card(&cam, New, FULL, pts, 1.0, facing_shade(pts, 0.5), true)); + } + v +} + +fn curtains(t: f64, a: f64) -> Vec { + // The old slide parts in the middle into two curtains drawn to the sides, then lifted away. + let open = q2(win(t, 0.0, 0.35)); + let lift = ein(win(t, 0.62, 0.9)); + let mut v = vec![Layer::full(New, 1.0)]; + let w_top = 0.5 - 0.22 * open; + let w_bot = 0.5 - 0.12 * open; + let up = 0.15 * lift; + let sw = 0.75 * lift; + let left = [[-sw, -up], [w_top - sw, -up], [w_bot - 0.6 * sw, 1.0 - up], [-0.6 * sw, 1.0 - up]]; + let right = [[1.0 - w_top + sw, -up], [1.0 + sw, -up], [1.0 + 0.6 * sw, 1.0 - up], [1.0 - w_bot + 0.6 * sw, 1.0 - up]]; + v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], left, 1.0)); + v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], right, 1.0)); + let _ = a; + v +} + +fn wind(right: bool, t: f64, a: f64) -> Vec { + // The old slide blows away like a sheet, lifting and receding toward one side. + let cam = Cam { a, d: 2.2 }; + let u = ein(win(t, 0.12, 0.8)); + let side = if right { 1.0 } else { -1.0 }; + let pivot = v3(0.0, 0.0, 0.0); + let pts = cam.rect(FULL).map(|p| { + let w = p.pitch(v3(0.0, -0.5, 0.0), -25.0 * u).yaw(pivot, -side * 20.0 * u); + v3(w.x + side * 1.6 * u * a / 2.0, w.y - 0.8 * u, w.z + 0.5 * u) + }); + let mut v = vec![Layer::full(New, 1.0)]; + if u < 1.0 { + v.extend(card(&cam, Old, FULL, pts, 1.0, 0.0, true)); + } + v +} + +fn prestige(t: f64, a: f64) -> Vec { + // The old slide lifts at its bottom edge and flies off upward. + let cam = Cam { a, d: 2.2 }; + let lift = q2(win(t, 0.4, 0.62)); + let fly = ein(win(t, 0.5, 0.85)); + let pivot = v3(0.0, -0.5, 0.0); + let pts = cam.rect(FULL).map(|p| { + let w = p.pitch(pivot, -40.0 * lift).roll(pivot, -12.0 * fly); + v3(w.x - 0.4 * fly * a / 2.0, w.y - 1.3 * fly, w.z - 0.2 * fly) + }); + let mut v = vec![Layer::full(New, 1.0)]; + if fly < 1.0 { + v.extend(card(&cam, Old, FULL, pts, 1.0, facing_shade(pts, 0.3), true)); + } + v +} + +fn fracture(t: f64, a: f64) -> Vec { + // The old slide cracks into long shards radiating from its centre; the shards part a little, + // then fall away, the top ones first. + let mut v = vec![Layer::full(New, 1.0)]; + let crack = ss(win(t, 0.06, 0.2)); + if crack <= 0.0 { + v.push(Layer::full(Old, 1.0)); + return v; + } + let n = 36usize; + let rings = [0.0, 0.3, 0.6, 1.0]; + let c = [0.5, 0.5]; + for i in 0..n { + let a0 = 2.0 * PI * (i as f64 + 0.35 * rnd(i as u64)) / n as f64; + let a1 = 2.0 * PI * (i as f64 + 1.0 + 0.35 * rnd((i + 1) as u64 % n as u64)) / n as f64; + for r in 0..3 { + let (r0, r1) = (rings[r], if r == 2 { 1.6 } else { rings[r + 1] }); + let p = |ang: f64, rr: f64| -> [f64; 2] { + let b = boundary(ang, a); + [c[0] + (b[0] - c[0]) * rr, c[1] + (b[1] - c[1]) * rr] + }; + let pts = [p(a0, r0), p(a1, r0), p(a1, r1), p(a0, r1)]; + let id = (i * 3 + r) as u64; + let mid = [(pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4.0, (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4.0]; + let start = 0.3 + 0.28 * mid[1].clamp(0.0, 1.0) + 0.1 * rnd(id + 300); + let k = win(t, start, start + 0.35); + let gap = 0.015 * crack; + let out = [(mid[0] - c[0]) * gap * 4.0, (mid[1] - c[1]) * gap * 4.0 + 1.4 * k * k]; + let ang = (rnd(id + 7) - 0.5) * 50.0 * k; + let q = pts.map(|p| { + let p = [mid[0] + (p[0] - mid[0]) * (1.0 - 0.06 * crack) + out[0], mid[1] + (p[1] - mid[1]) * (1.0 - 0.06 * crack) + out[1]]; + rot(p, [mid[0] + out[0], mid[1] + out[1]], ang, a) + }); + let uv = pts.map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]); + if k < 1.0 { + let l = Layer { source: Old, src: FULL, quad: q, uv, alpha: 1.0, fade: [1.0; 4] }; + v.push(l); + v.push(l.shade(0.1 * crack * rnd(id + 9) + 0.2 * k)); + } + } + } + v +} + +fn crush(t: f64, a: f64) -> Vec { + // The old slide crumples into a ball that drops away. + let mut v = vec![Layer::full(New, 1.0)]; + let k = q2(win(t, 0.08, 0.55)); + let drop = ein(win(t, 0.65, 1.0)); + let size = 1.0 - 0.45 * k; + let cx = 0.5 - 0.1 * k + 0.05 * drop; + let cy = 0.5 + 0.05 * k + 0.8 * drop; + let n = 40usize; + let c = [0.5, 0.5]; + for i in 0..n { + let a0 = 2.0 * PI * i as f64 / n as f64; + let a1 = 2.0 * PI * (i + 1) as f64 / n as f64; + let j = |ang: f64, idx: usize| -> [f64; 2] { + let b = boundary(ang, a); + // Corners pull in toward a round ball, with crumpled jags. + let round = { + let (dx, dy) = ((b[0] - 0.5) * a, b[1] - 0.5); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let r = 0.45; + [0.5 + dx / len * r / a, 0.5 + dy / len * r] + }; + let jag = 1.0 - 0.18 * k * rnd(idx as u64 % n as u64 + 77); + let p = [b[0] + (round[0] - b[0]) * k, b[1] + (round[1] - b[1]) * k]; + [cx + (p[0] - c[0]) * size * jag, cy + (p[1] - c[1]) * size * jag] + }; + let (p0, p1) = (j(a0, i), j(a1, i + 1)); + let (u0, u1) = (boundary(a0, a), boundary(a1, a)); + let l = Layer::tri_uv(Old, [[cx, cy], p0, p1], [c, u0, u1], [1.0; 3], 1.0); + v.push(l); + v.push(l.shade(0.35 * k * rnd(i as u64 + 13))); + } + v +} + +fn peel_off(right: bool, t: f64) -> Vec { + // The old slide peels from a bottom corner toward the opposite top corner; the peeled flap + // shows the page's back folded over. + let u = ss(win(t, 0.2, 0.95)); + // Fold line perpendicular to the diagonal, sweeping from the corner. + let (cx, cy) = if right { (0.0, 1.0) } else { (1.0, 1.0) }; + let nd: [f64; 2] = if right { [1.0, -1.0] } else { [-1.0, -1.0] }; + let len = (nd[0] * nd[0] + nd[1] * nd[1]).sqrt(); + let n = [nd[0] / len, nd[1] / len]; + let span = 2f64.sqrt(); + let d0 = n[0] * cx + n[1] * cy; + let fold = d0 + span * 1.25 * u; + let mut v = vec![Layer::full(New, 1.0)]; + let full = rect_poly(FULL); + // Kept part: beyond the fold line from the corner. + let keep = clip(&full, [-n[0], -n[1]], -fold); + v.extend(poly_layers(Old, &keep, 1.0, &|p| p)); + // Peeled part, reflected over the fold line. + let peeled = clip(&full, n, fold); + let refl = |p: [f64; 2]| { + let d = n[0] * p[0] + n[1] * p[1] - fold; + [p[0] - 2.0 * d * n[0], p[1] - 2.0 * d * n[1]] + }; + let flap = poly_layers(Old, &peeled, 1.0, &refl); + for l in flap { + v.push(l); + v.push(l.shade(0.25)); + } + v +} + +fn page_curl(o: &str, t: f64) -> Vec { + let single = o.starts_with("single"); + let right = o.ends_with('r') || o.ends_with('R'); + let mut v = vec![Layer::full(New, 1.0)]; + // A roll travelling across the old slide: x of the roll's front edge. + let roll = |area: R, k: f64, toward_left: bool, v: &mut Vec| { + let w = area[2] - area[0]; + let r = 0.06; + if toward_left { + let e = area[2] - (w + r) * k; + let keep = [area[0], area[1], e.max(area[0]), area[3]]; + if keep[2] > keep[0] { + v.push(Layer::inplace(Old, keep, 1.0)); + } + // The roll: a band squeezing the rolled-up part. + let band = [e.max(area[0]), area[1], (e + r).min(area[2]).max(area[0]), area[3]]; + if band[2] > band[0] && k < 1.0 { + let src = [band[0], area[1], area[2], area[3]]; + let l = Layer::rect(Old, src, band, 1.0); + v.push(l); + v.push(l.shade(0.3).faded([0.2, 1.0, 1.0, 0.2])); + } + } else { + let e = area[0] + (w + r) * k; + let keep = [e.min(area[2]), area[1], area[2], area[3]]; + if keep[2] > keep[0] { + v.push(Layer::inplace(Old, keep, 1.0)); + } + let band = [(e - r).max(area[0]).min(area[2]), area[1], e.min(area[2]), area[3]]; + if band[2] > band[0] && k < 1.0 { + let src = [area[0], area[1], band[2], area[3]]; + let l = Layer::rect(Old, src, band, 1.0); + v.push(l); + v.push(l.shade(0.3).faded([1.0, 0.2, 0.2, 1.0])); + } + } + }; + if single { + let k = q2(win(t, 0.25, 0.85)); + roll(FULL, k, !right, &mut v); + } else { + // Double: the half on the far side rolls first, then the near half. + let k1 = q2(win(t, 0.1, 0.6)); + let k2 = q2(win(t, 0.45, 0.85)); + if right { + roll([0.0, 0.0, 0.5, 1.0], k1, false, &mut v); + roll([0.5, 0.0, 1.0, 1.0], k2, false, &mut v); + } else { + roll([0.5, 0.0, 1.0, 1.0], k1, true, &mut v); + roll([0.0, 0.0, 0.5, 1.0], k2, true, &mut v); + } + } + v +} + +fn airplane(right: bool, t: f64, a: f64) -> Vec { + // The old slide folds into a big paper dart (nose to the right for "l") that hangs low, then + // flies off up and away. + let fold = q2(win(t, 0.0, 0.3)); + let fly = ein(win(t, 0.82, 0.95)); + let mut v = vec![Layer::full(New, 1.0)]; + if fly >= 1.0 { + return v; + } + let mx = |p: [f64; 2]| if right { [1.0 - p[0], p[1]] } else { p }; + // The dart in its hanging pose ("l"), then flown off. + let drift = 0.04 * win(t, 0.3, 0.82); + let pose = |p: [f64; 2]| -> [f64; 2] { + let p = [p[0] + drift, p[1] + drift]; + let c = [0.75, 0.65]; + let k = 1.0 - 0.85 * fly; + [c[0] + (p[0] - c[0]) * k - 0.4 * fly, c[1] + (p[1] - c[1]) * k - 1.0 * fly] + }; + let dart = [[0.42, 0.28], [1.02, 0.66], [1.0, 0.72], [0.46, 1.08]]; + let flat = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let pts: Vec<[f64; 2]> = (0..4) + .map(|i| { + let (f, d) = (flat[i], dart[i]); + mx(pose([f[0] + (d[0] - f[0]) * fold, f[1] + (d[1] - f[1]) * fold])) + }) + .collect(); + let uv: Vec<[f64; 2]> = flat.iter().map(|p| mx(*p)).collect(); + if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) { + let l1 = Layer::tri_uv(Old, [*p0, *p1, *p2], [uv[0], uv[1], uv[2]], [1.0; 3], 1.0); + let l2 = Layer::tri_uv(Old, [*p0, *p2, *p3], [uv[0], uv[2], uv[3]], [1.0; 3], 1.0); + v.push(l1); + v.push(l1.shade(0.1 * fold)); + v.push(l2); + v.push(l2.shade(0.3 * fold)); + } + let _ = a; + v +} + +fn origami(right: bool, t: f64, a: f64) -> Vec { + // The old slide folds into a bird that flies off. + let fold = q2(win(t, 0.0, 0.2)); + let fly = ein(win(t, 0.2, 0.85)); + let side = if right { 1.0 } else { -1.0 }; + let mut v = vec![Layer::full(New, 1.0)]; + if win(t, 0.2, 0.92) >= 1.0 { + return v; + } + let size = 1.0 - 0.35 * fold; + let cx = 0.47 + side * 0.65 * fly; + let cy = 0.42 + 0.05 * fly; + let flap = (t * 18.0).sin() * 0.08 * fold; + let body = |p: [f64; 2]| [cx + (p[0] - 0.5) * size, cy + (p[1] - 0.5) * size]; + let shape = [ + ([0.0, 0.0], [-0.45 * side, -0.25 - flap]), + ([1.0, 0.0], [0.15 * side, -0.4 + flap]), + ([1.0, 1.0], [0.45 * side, 0.05]), + ([0.0, 1.0], [-0.1 * side, 0.2]), + ]; + let pts: Vec<[f64; 2]> = + shape.iter().map(|(flat, bird)| body([flat[0] + (0.5 + bird[0] - flat[0]) * fold, flat[1] + (0.5 + bird[1] - flat[1]) * fold])).collect(); + let uv: Vec<[f64; 2]> = shape.iter().map(|(flat, _)| *flat).collect(); + if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) { + let l1 = Layer::tri_uv(Old, [*p0, *p1, *p2], [uv[0], uv[1], uv[2]], [1.0; 3], 1.0); + let l2 = Layer::tri_uv(Old, [*p0, *p2, *p3], [uv[0], uv[2], uv[3]], [1.0; 3], 1.0); + v.push(l1); + v.push(l1.shade(0.15 * fold)); + v.push(l2); + v.push(l2.shade(0.3 * fold)); + } + let _ = a; + v +} + +fn honeycomb(t: f64, a: f64) -> Vec { + // The old slide splits into hexagonal cells along dark seams; the cells fly at the viewer and + // out of sight, leaving black; cells of the new slide pop in one by one and close the seams. + let cols = 11usize; + let w = 1.0 / cols as f64; + let rh = w * a * 0.5; + let rows = (1.0 / (rh * 1.5)).ceil() as usize + 2; + let mut v = vec![Layer::full(Black, 1.0)]; + let seam = ss(win(t, 0.0, 0.12)) * (1.0 - ss(win(t, 0.85, 1.0))); + let hex = |cx: f64, cy: f64, s: f64| -> Vec<[f64; 2]> { + (0..6) + .map(|k| { + let ang = PI / 3.0 * k as f64 + PI / 6.0; + [cx + ang.cos() * s * w * 0.577, cy + ang.sin() * s * rh * 0.577 / 0.5 * 0.5] + }) + .collect() + }; + for r in 0..rows { + for c in 0..=cols { + let cx = (c as f64 + if r % 2 == 1 { 0.5 } else { 0.0 }) * w; + let cy = r as f64 * rh * 0.866; + let id = (r * 64 + c) as u64; + let src_hex = hex(cx, cy, 1.0); + let out_start = 0.1 + 0.2 * rnd(id + 1); + let out_k = ein(win(t, out_start, out_start + 0.12)); + let in_start = 0.38 + 0.42 * rnd(id + 3); + let in_k = eout(win(t, in_start, in_start + 0.1)); + let gap = 1.0 - 0.1 * seam; + let mut cell = |src: Source, s: f64, alpha: f64| { + let dst = hex(cx, cy, s); + for k in 0..6 { + let (p0, p1) = (dst[k], dst[(k + 1) % 6]); + let (u0, u1) = (src_hex[k], src_hex[(k + 1) % 6]); + v.push(Layer::tri_uv(src, [[cx, cy], p0, p1], [[cx, cy], u0, u1], [1.0; 3], alpha)); + } + }; + if t < 0.5 && out_k < 1.0 { + cell(Old, gap * (1.0 + 3.0 * out_k), 1.0 - out_k * out_k); + } + if in_k > 0.0 { + cell(New, gap * (0.3 + 0.7 * in_k), 1.0); + } + } + } + v +} + +fn glitter(o: &str, t: f64, a: f64) -> Vec { + // Tiles flip from the old slide to the new in a sparkling sweep starting on the option's side. + let hexes = o.starts_with("hex"); + let d = o.trim_start_matches("hex").to_ascii_lowercase(); + let (n, m) = if hexes { (28usize, 16usize) } else { (40usize, 22usize) }; + let cam = Cam { a, d: 3.0 }; + let mut v = vec![Layer::full(Old, 1.0)]; + for r in 0..m { + for c in 0..n { + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let cc = center(cell); + // Distance from the side the sweep starts on ("r": from the right). + let grad = match d.as_str() { + "r" => 1.0 - cc[0], + "u" => cc[1], + "d" => 1.0 - cc[1], + _ => cc[0], + }; + let start = 0.75 * grad * 0.85 + rnd((r * n + c) as u64 + 77) * 0.12; + let k = win(t, start, start + 0.25); + if k <= 0.0 { + continue; + } + v.push(Layer::inplace(Black, cell, 1.0)); + flip_tile(&cam, cell, k, true, &mut v); + } + } + v +} + +fn vortex(o: &str, t: f64, a: f64) -> Vec { + // The old slide breaks into fine particles that whirl off toward the option's side, the + // trailing side first; the new slide's particles whirl in and settle, the leading side first. + let (dx, dy) = dir(o, (-1.0, 0.0)); + let (n, m) = (48usize, 27usize); + let mut v = vec![Layer::full(Black, 1.0)]; + for r in 0..m { + for c in 0..n { + let id = (r * n + c) as u64; + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let cc = center(cell); + // 1 on the side things move toward, 0 on the other. + let lead = (0.5 + (cc[0] - 0.5) * dx + (cc[1] - 0.5) * dy).clamp(0.0, 1.0); + let go = 0.4 * lead + 0.08 * rnd(id + 31); + let ko = win(t, go, go + 0.35); + let end = 0.62 + 0.3 * (1.0 - lead) + 0.06 * rnd(id + 41); + let kn = 1.0 - win(t, end - 0.35, end); + let mut piece = |src: Source, k: f64| { + let swirl = 2.0 * PI * (rnd(id + 5) - 0.5) * k; + let off = [dx * k * 0.8 + swirl.sin() * 0.15 * k, dy * k * 0.8 - swirl.cos().abs() * 0.12 * k]; + let s = 0.8 - 0.5 * k; + let r2 = offset(scale_rect(cell, s), off[0], off[1]); + let q = rot_quad(crate::transition::corners(r2), center(r2), rnd(id) * 360.0 * k, a); + v.push(Layer::quad(src, cell, q, 1.0 - k * k)); + }; + if kn < 1.0 { + piece(New, kn); + } + if ko > 0.0 && ko < 1.0 && kn >= 1.0 { + piece(Old, ko); + } else if ko <= 0.0 { + v.push(Layer::inplace(Old, cell, 1.0)); + } + } + } + v +} + +fn shred(o: &str, t: f64, a: f64) -> Vec { + let particles = o.starts_with("particles") || o.starts_with("rect"); + let out = o.ends_with("Out"); + let cam = Cam { a, d: 2.5 }; + let mut v = vec![Layer::full(Black, 1.0)]; + // First half: the old slide comes apart and recedes; second half: the new one assembles. + let (src, k) = if t < 0.55 { (Old, q2(win(t, 0.05, 0.55))) } else { (New, 1.0 - q2(win(t, 0.55, 0.95))) }; + if particles { + let (n, m) = (64usize, 36usize); + for r in 0..m { + for c in 0..n { + let id = (r * n + c) as u64; + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let cc = center(cell); + let z = if out { -0.6 } else { 1.5 } * k * (0.5 + rnd(id)); + let jitter = [(rnd(id + 1) - 0.5) * 0.4 * k, (rnd(id + 2) - 0.5) * 0.4 * k]; + let p3 = cam.unit(cc[0] + jitter[0], cc[1] + jitter[1]); + let pc = cam.p(v3(p3.x, p3.y, z)); + let s = 0.8 * (1.0 - 0.3 * k) * cam.d / (cam.d + z).max(0.1); + let r2 = offset(scale_rect(cell, s), pc[0] - cc[0], pc[1] - cc[1]); + let l = Layer::rect(src, cell, r2, 1.0 - 0.3 * k * rnd(id + 5)); + v.push(l); + v.push(l.shade(0.4 * k)); + } + } + } else { + let n = 30usize; + for i in 0..n { + let id = i as u64; + let (x0, x1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let cell = [x0, 0.0, x1, 1.0]; + let z = if out { -0.3 } else { 1.0 } * k * k * (0.6 + rnd(id + 11)); + let tilt = (rnd(id + 12) - 0.5) * 30.0 * k; + let drop = (rnd(id + 13) - 0.3) * 0.25 * k; + let inset = 0.004 * k; + let w3 = cam.rect([x0 + inset, 0.0, x1 - inset, 1.0]); + let c3 = cam.unit((x0 + x1) / 2.0, 0.5); + let pts = w3.map(|p| { + let q = p.roll(c3, tilt * 0.2).yaw(c3, tilt); + v3(q.x, q.y + drop, q.z + z) + }); + v.extend(card(&cam, src, cell, pts, 1.0, 0.3 * k, true)); + } + } + v +} + +fn switch(right: bool, t: f64, a: f64) -> Vec { + // Switching cards: the old slide is pulled out to one side ("l": left) and slipped behind + // while the new one, pulled out to the other side from behind it, comes to the front. Both + // turn a little away at their outer edges. + let cam = Cam { a, d: 2.5 }; + let side = if right { -1.0 } else { 1.0 }; + let u = (t + ss(t)) / 2.0; + let k = (u * PI).sin(); + let shift = 0.5 * k * a; + let old = cam.rect(FULL).map(|p| { + let q = p.yaw(v3(0.0, 0.0, 0.0), side * 14.0 * k); + v3(q.x - side * shift, q.y, q.z + 0.12 * k) + }); + let new = cam.rect(FULL).map(|p| { + let q = p.yaw(v3(0.0, 0.0, 0.0), -side * 14.0 * k); + v3(q.x + side * shift, q.y, q.z + 0.12 * k) + }); + let mut v = vec![Layer::full(Black, 1.0)]; + let o = card(&cam, Old, FULL, old, 1.0, facing_shade(old, 0.3), true); + let n = card(&cam, New, FULL, new, 1.0, facing_shade(new, 0.3), true); + if u < 0.5 { + v.extend(n); + v.extend(o); + } else { + v.extend(o); + v.extend(n); + } + v +} + +fn flip(right: bool, t: f64, a: f64) -> Vec { + // The slide turns over like a card: the old face, then the new one on its back. + let cam = Cam { a, d: 2.5 }; + let s = q2(t); + let side = if right { 1.0 } else { -1.0 }; + let deg = 180.0 * s * side; + let back = 0.6 * (s * PI).sin(); + let c = v3(0.0, 0.0, 0.0); + let pts = cam.rect(FULL).map(|p| { + let q = p.yaw(c, deg); + v3(q.x, q.y, q.z + back) + }); + let mut v = vec![Layer::full(Black, 1.0)]; + if s < 0.5 { + v.extend(card(&cam, Old, FULL, pts, 1.0, facing_shade(pts, 0.5), true)); + } else { + // The back face: the new slide, mirrored so it reads right way round. + let m = [pts[1], pts[0], pts[3], pts[2]]; + v.extend(card(&cam, New, FULL, m, 1.0, facing_shade(m, 0.5), true)); + } + v +} + +fn gallery(right: bool, t: f64, a: f64) -> Vec { + // The slides hang on a wall: it turns away and recedes, the slides slide along it, then it + // turns back. A faint reflection shows below. + let cam = Cam { a, d: 2.5 }; + let side = if right { 1.0 } else { -1.0 }; + let k = ss(win(t, 0.0, 0.35)) * (1.0 - ss(win(t, 0.7, 1.0))); + let s = q2(win(t, 0.3, 0.8)); + let gap = 0.15; + let back = 0.7 * k; + let yaw = -side * 22.0 * k; + let mut v = vec![Layer::full(Black, 1.0)]; + for (src, shift) in [(Old, side * s), (New, side * (s - 1.0))] { + let x0 = shift * (a + gap); + let pts = cam.rect(FULL).map(|p| v3(p.x + x0, p.y, p.z + back)).map(|p| p.yaw(v3(0.0, 0.0, back), yaw)); + // Reflection: the slide flipped below its bottom edge, faint. + let refl = [pts[3], pts[2], v3(pts[2].x, pts[2].y + 0.25, pts[2].z), v3(pts[3].x, pts[3].y + 0.25, pts[3].z)]; + let rq = cam.quad(refl); + v.push(Layer { + source: src, + src: [0.0, 0.75, 1.0, 1.0], + quad: rq, + uv: [[0.0, 1.0], [1.0, 1.0], [1.0, 0.75], [0.0, 0.75]], + alpha: 0.3 * k, + fade: [1.0, 1.0, 0.0, 0.0], + }); + v.extend(card(&cam, src, FULL, pts, 1.0, 0.0, true)); + } + v +} + +fn conveyor(right: bool, t: f64, a: f64) -> Vec { + // The contents ride a belt: it turns away, the contents slide along, it turns back; the + // backgrounds cross-fade underneath. + let cam = Cam { a, d: 2.5 }; + let side = if right { 1.0 } else { -1.0 }; + let k = ss(win(t, 0.0, 0.35)) * (1.0 - ss(win(t, 0.65, 1.0))); + let s = q2(win(t, 0.33, 0.75)); + let back = 0.45 * k; + let yaw = -side * 18.0 * k; + let mut v = vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))]; + for (src, shift) in [(OldContent, side * s), (NewContent, side * (s - 1.0))] { + let x0 = shift * a * 1.05; + let pts = cam.rect(FULL).map(|p| v3(p.x + x0, p.y, p.z + back)).map(|p| p.yaw(v3(0.0, 0.0, back), yaw)); + v.extend(card(&cam, src, FULL, pts, 1.0, 0.0, true)); + } + v +} + +fn prism(kind: &str, o: &str, t: f64, a: f64) -> Vec { + // Cube: the slides are two faces of a cube turning; Box: two inside walls of a box; Rotate and + // Orbit: the same with the contents only, over cross-fading backgrounds. + let (dx, dy) = dir(o, (-1.0, 0.0)); + let vertical = dy != 0.0 && dx == 0.0; + let content = matches!(kind, "rotate" | "orbit"); + let inverted = matches!(kind, "box" | "orbit"); + let cam = Cam { a, d: 2.5 }; + let s = q2(t); + let half = if vertical { 0.5 } else { a / 2.0 }; + // Faces on a cube around (0, 0, half) (outside), or the inside of a box around (0, 0, -half). + let c = v3(0.0, 0.0, if inverted { -half } else { half }); + // Positive turns the right (or top) side away. + let sign = if vertical { -dy } else { dx }; + let deg = 90.0 * s * sign * if inverted { -1.0 } else { 1.0 }; + let turn = |p: V3, base: f64| -> V3 { if vertical { p.pitch(c, base + deg) } else { p.yaw(c, base + deg) } }; + let side_base = -90.0 * sign * if inverted { -1.0 } else { 1.0 }; + let (os, ns) = if content { (OldContent, NewContent) } else { (Old, New) }; + let old = cam.rect(FULL).map(|p| turn(p, 0.0)); + let new = cam.rect(FULL).map(|p| turn(p, side_base)); + // Keep the nearest edge on the screen plane, as PowerPoint's camera does. + let near = old.iter().chain(new.iter()).map(|p| p.z).fold(f64::INFINITY, f64::min); + let lift = if inverted { 0.0 } else { -near.min(0.0) - near.max(0.0) }; + // The box is seen from further back mid-turn. + let back = if inverted { 0.5 } else { 0.15 } * (s * PI).sin(); + let push = |q: [V3; 4]| q.map(|p| v3(p.x, p.y, p.z + lift + back)); + let (old, new) = (push(old), push(new)); + let mut v = if content { vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))] } else { vec![Layer::full(Black, 1.0)] }; + v.extend(card(&cam, os, FULL, old, 1.0, if content { 0.0 } else { facing_shade(old, 0.6) }, false)); + v.extend(card(&cam, ns, FULL, new, 1.0, if content { 0.0 } else { facing_shade(new, 0.6) }, false)); + v +} + +fn doors(window: bool, horz: bool, t: f64, a: f64) -> Vec { + // The old slide parts in two halves that swing open toward the viewer and darken; the new one + // comes forward from the middle, fading in over them. Window does it with the contents only, + // over cross-fading backgrounds. + let cam = Cam { a, d: 2.2 }; + let open = ein(win(t, 0.05, 0.68)); + let come = q2(win(t, 0.0, 0.72)); + let (os, ns) = if window { (OldContent, NewContent) } else { (Old, New) }; + let mut v = if window { vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))] } else { vec![Layer::full(Black, 1.0)] }; + let halves: [(R, f64); 2] = + if horz { [([0.0, 0.0, 1.0, 0.5], -1.0), ([0.0, 0.5, 1.0, 1.0], 1.0)] } else { [([0.0, 0.0, 0.5, 1.0], -1.0), ([0.5, 0.0, 1.0, 1.0], 1.0)] }; + if open < 1.0 { + for (r, sgn) in halves { + let hinge = if horz { cam.unit(0.5, if sgn < 0.0 { 0.0 } else { 1.0 }) } else { cam.unit(if sgn < 0.0 { 0.0 } else { 1.0 }, 0.5) }; + let ang = 30.0 * open; + let slide = 0.55 * open; + let pts = cam.rect(r).map(|p| { + let q = if horz { p.pitch(hinge, sgn * ang) } else { p.yaw(hinge, -sgn * ang) }; + if horz { v3(q.x, q.y + sgn * slide, q.z) } else { v3(q.x + sgn * slide * a, q.y, q.z) } + }); + let dark = if window { 0.0 } else { 0.7 * open }; + let alpha = if window { 1.0 - ss(win(t, 0.2, 0.6)) } else { 1.0 }; + v.extend(card(&cam, os, r, pts, alpha, dark, true)); + } + } + let sc = 0.5 + 0.5 * come; + let fade_in = if window { ss(t) } else { ss(win(t, 0.05, 0.62)) }; + v.push(scaled(ns, sc, 0.5, 0.5, 0.0, 0.0, fade_in)); + v +} + +fn ferris(right: bool, t: f64, a: f64) -> Vec { + // The contents ride a big wheel turning about a hub far off the lower corner: the old ones + // drop away down and sideways, the new ones come down round from the top, over cross-fading + // backgrounds. + let side = if right { -1.0 } else { 1.0 }; + let mut v = vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))]; + let out = ein(win(t, 0.0, 0.5)); + if out < 1.0 { + let (dx, dy) = (-side * 0.45 * out.powf(1.5), 0.9 * out); + let q = rot_quad(crate::transition::corners(offset(FULL, dx, dy)), [0.5 + dx, 0.5 + dy], side * -15.0 * out, a); + v.push(Layer::quad(OldContent, FULL, q, 1.0)); + } + let inn = q2(win(t, 0.5, 1.0)); + if inn > 0.0 { + let k = 1.0 - inn; + let (dx, dy) = (-side * 0.5 * k.powf(1.3), -0.75 * k); + let r = offset(scale_rect(FULL, 1.0 - 0.25 * k), dx, dy); + let q = rot_quad(crate::transition::corners(r), center(r), side * -18.0 * k, a); + v.push(Layer::quad(NewContent, FULL, q, 1.0)); + } + v +} + +fn pan(o: &str, t: f64) -> Vec { + // The contents slide along together while the backgrounds cross-fade. + let (dx, dy) = dir(o, (-1.0, 0.0)); + // Quick start, long glide. + let s = ease(t, 0.2, 0.8, 0.0); + vec![ + Layer::full(OldBack, 1.0), + Layer::full(NewBack, s), + moved(OldContent, dx * s, dy * s, 1.0), + moved(NewContent, dx * (s - 1.0), dy * (s - 1.0), 1.0), + ] +} + +fn flythrough(o: &str, t: f64) -> Vec { + // The contents fly past the viewer (in) or recede (out) while the backgrounds cross-fade. + let bounce = o.ends_with("Bounce"); + let out = o.starts_with("out"); + let s = q2(t); + let b = if bounce { 0.08 * (win(t, 0.6, 1.0) * PI).sin() } else { 0.0 }; + let old_a = 1.0 - ss(win(t, 0.0, 0.6)); + let new_a = ss(win(t, 0.3, 0.95)); + let (os, ns) = if out { (1.0 - 0.5 * s, 1.8 - 0.8 * s - b) } else { (1.0 + 1.5 * s, 0.4 + 0.6 * s + b) }; + vec![ + Layer::full(OldBack, 1.0), + Layer::full(NewBack, ss(t)), + scaled(NewContent, ns, 0.5, 0.5, 0.0, 0.0, new_a), + scaled(OldContent, os, 0.5, 0.5, 0.0, 0.0, old_a), + ] +} diff --git a/crates/anim/src/transition_geom.rs b/crates/anim/src/transition_geom.rs new file mode 100644 index 00000000..8918ec43 --- /dev/null +++ b/crates/anim/src/transition_geom.rs @@ -0,0 +1,382 @@ +//! Geometry for transition effects: easing, 3-D cards, opacity fields, polygons. + +use std::f64::consts::PI; + +use crate::easing::{ease, smooth}; +use crate::transition::{FULL, Layer, P, Q, R, Source, corners}; + +// ---------- easing ---------- + +/// Smoothstep ease in-out (PowerPoint's fades). +pub(crate) fn ss(x: f64) -> f64 { + smooth(x) +} + +/// Quadratic ease in-out: velocity ramps up for the first half and down for the second +/// (PowerPoint's moves: Push, Cover, Uncover, Pan…). +pub(crate) fn q2(x: f64) -> f64 { + ease(x, 0.5, 0.5, 0.0) +} + +/// Power ease in-out of degree `n` (2: quadratic, 3: cubic, 4: quartic). +pub(crate) fn pow_inout(x: f64, n: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + if x < 0.5 { 0.5 * (2.0 * x).powf(n) } else { 1.0 - 0.5 * (2.0 - 2.0 * x).powf(n) } +} + +/// Quadratic ease-in. +pub(crate) fn ein(x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + x * x +} + +/// Quadratic ease-out. +pub(crate) fn eout(x: f64) -> f64 { + let x = 1.0 - x.clamp(0.0, 1.0); + 1.0 - x * x +} + +/// Progress through the window [a, b] of t, clamped to 0..1. +pub(crate) fn win(t: f64, a: f64, b: f64) -> f64 { + if b <= a { + return if t >= b { 1.0 } else { 0.0 }; + } + ((t - a) / (b - a)).clamp(0.0, 1.0) +} + +// ---------- randomness ---------- + +pub(crate) fn hash64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^ (x >> 31) +} + +/// Deterministic pseudo-random value in [0, 1). +pub(crate) fn rnd(i: u64) -> f64 { + (hash64(i) >> 11) as f64 / (1u64 << 53) as f64 +} + +// ---------- 2-D ---------- + +pub(crate) fn offset(r: R, dx: f64, dy: f64) -> R { + [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy] +} + +pub(crate) fn center(r: R) -> P { + [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] +} + +pub(crate) fn scale_rect(r: R, s: f64) -> R { + scale_rect_about(r, s, center(r)) +} + +pub(crate) fn scale_rect_about(r: R, s: f64, c: P) -> R { + [c[0] + (r[0] - c[0]) * s, c[1] + (r[1] - c[1]) * s, c[0] + (r[2] - c[0]) * s, c[1] + (r[3] - c[1]) * s] +} + +/// The whole source moved by (dx, dy). +pub(crate) fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer { + Layer::rect(source, FULL, offset(FULL, dx, dy), alpha) +} + +/// The whole source scaled by `s` about (cx, cy) and moved. +pub(crate) fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer { + Layer::rect(source, FULL, offset(scale_rect_about(FULL, s, [cx, cy]), dx, dy), alpha) +} + +/// Rotate a point about a pivot by `deg` (clockwise on screen), in a slide `a` wide per unit of +/// height, so angles and shapes stay true. +pub(crate) fn rot(p: P, c: P, deg: f64, a: f64) -> P { + let (s, co) = deg.to_radians().sin_cos(); + let (x, y) = ((p[0] - c[0]) * a, p[1] - c[1]); + [c[0] + (x * co - y * s) / a, c[1] + x * s + y * co] +} + +pub(crate) fn rot_quad(q: Q, c: P, deg: f64, a: f64) -> Q { + [rot(q[0], c, deg, a), rot(q[1], c, deg, a), rot(q[2], c, deg, a), rot(q[3], c, deg, a)] +} + +/// Motion direction of an option: l, r, u, d and diagonals. +pub(crate) fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) { + let mut d = (0.0, 0.0); + if opt.contains('l') { + d.0 = -1.0; + } + if opt.contains('r') { + d.0 = 1.0; + } + if opt.contains('u') { + d.1 = -1.0; + } + if opt.contains('d') { + d.1 = 1.0; + } + if d == (0.0, 0.0) { default } else { d } +} + +/// A rectangle with a soft border `f` wide (slide units) fading out to nothing: the rectangle at +/// full opacity, four edge ramps and four corner ramps. `src` maps onto `dst`; the border samples +/// the texture's edge. +pub(crate) fn soft_rect(source: Source, src: R, dst: R, f: f64, alpha: f64) -> Vec { + let mut v = vec![Layer::rect(source, src, dst, alpha)]; + if f <= 1e-6 { + return v; + } + let (l, t, r, b) = (dst[0], dst[1], dst[2], dst[3]); + let (fx, fy) = (f, f * 16.0 / 9.0); + let uv_at = |x: f64, y: f64| -> P { + let u = if r > l { src[0] + (x.clamp(l, r) - l) / (r - l) * (src[2] - src[0]) } else { src[0] }; + let w = if b > t { src[1] + (y.clamp(t, b) - t) / (b - t) * (src[3] - src[1]) } else { src[1] }; + [u, w] + }; + let mut q = |pts: Q, fade: [f64; 4]| { + let uv = pts.map(|p| uv_at(p[0], p[1])); + let xs = uv.map(|p| p[0]); + let ys = uv.map(|p| p[1]); + let mn = |a: [f64; 4]| a.iter().copied().fold(f64::INFINITY, f64::min); + let mx = |a: [f64; 4]| a.iter().copied().fold(f64::NEG_INFINITY, f64::max); + v.push(Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: pts, uv, alpha, fade }); + }; + q([[l, t - fy], [r, t - fy], [r, t], [l, t]], [0.0, 0.0, 1.0, 1.0]); + q([[l, b], [r, b], [r, b + fy], [l, b + fy]], [1.0, 1.0, 0.0, 0.0]); + q([[l - fx, t], [l, t], [l, b], [l - fx, b]], [0.0, 1.0, 1.0, 0.0]); + q([[r, t], [r + fx, t], [r + fx, b], [r, b]], [1.0, 0.0, 0.0, 1.0]); + q([[l - fx, t - fy], [l, t - fy], [l, t], [l - fx, t]], [0.0, 0.0, 1.0, 0.0]); + q([[r, t - fy], [r + fx, t - fy], [r + fx, t], [r, t]], [0.0, 0.0, 0.0, 1.0]); + q([[r, b], [r + fx, b], [r + fx, b + fy], [r, b + fy]], [1.0, 0.0, 0.0, 0.0]); + q([[l - fx, b], [l, b], [l, b + fy], [l - fx, b + fy]], [0.0, 1.0, 0.0, 0.0]); + v +} + +// ---------- opacity fields ---------- + +/// The source shown in place through an opacity field `f(x, y)` (0..1) sampled on an `n` × `m` +/// grid: each cell is a quad with its corners' opacities. Cells with nothing to show are left out +/// and runs of fully opaque cells merge, so most frames stay small. +pub(crate) fn field(source: Source, n: usize, m: usize, f: &dyn Fn(f64, f64) -> f64) -> Vec { + let (n, m) = (n.clamp(1, 256), m.clamp(1, 256)); + let mut vals = vec![0.0; (n + 1) * (m + 1)]; + for j in 0..=m { + for i in 0..=n { + let v = f(i as f64 / n as f64, j as f64 / m as f64); + if let Some(x) = vals.get_mut(j * (n + 1) + i) { + *x = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + } + } + } + let at = |i: usize, j: usize| vals.get(j * (n + 1) + i).copied().unwrap_or(0.0); + let mut out = Vec::new(); + for j in 0..m { + let (y0, y1) = (j as f64 / m as f64, (j + 1) as f64 / m as f64); + let mut run: Option = None; + for i in 0..=n { + let full = i < n && at(i, j) >= 1.0 && at(i + 1, j) >= 1.0 && at(i, j + 1) >= 1.0 && at(i + 1, j + 1) >= 1.0; + if full { + run.get_or_insert(i); + continue; + } + if let Some(s) = run.take() { + out.push(Layer::inplace(source, [s as f64 / n as f64, y0, i as f64 / n as f64, y1], 1.0)); + } + if i == n { + break; + } + let c = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; + if c.iter().all(|v| *v <= 0.0) { + continue; + } + out.push(Layer::inplace(source, [i as f64 / n as f64, y0, (i + 1) as f64 / n as f64, y1], 1.0).faded(c)); + } + } + out +} + +/// Opacity of a soft edge at position `p` when the edge's middle is at `c` and it ramps over `w`. +pub(crate) fn edge(c: f64, p: f64, w: f64) -> f64 { + if w <= 1e-9 { + return if p <= c { 1.0 } else { 0.0 }; + } + ((c - p) / w + 0.5).clamp(0.0, 1.0) +} + +/// Point on the unit square's boundary seen from its centre at angle `ang` (radians, clockwise +/// from 12 o'clock, measured on a slide `a` wide per unit of height). +pub(crate) fn boundary(ang: f64, a: f64) -> P { + let (dx, dy) = (ang.sin() / a, -ang.cos()); + let m = (dx.abs() / 0.5).max(dy.abs() / 0.5).max(1e-9); + [0.5 + dx / m, 0.5 + dy / m] +} + +/// The source shown in place through an angular opacity field `f(angle)` (radians clockwise from +/// 12 o'clock, 0..2π): a fan of thin wedges from the centre. +pub(crate) fn fan(source: Source, a: f64, steps: usize, f: &dyn Fn(f64) -> f64) -> Vec { + let n = steps.clamp(8, 720); + let mut angles: Vec = (0..=n).map(|k| 2.0 * PI * k as f64 / n as f64).collect(); + // The square's corners, so wedges reach them. + for c in [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]] { + let ang = ((c[0] - 0.5) * a).atan2(-(c[1] - 0.5)); + angles.push(ang.rem_euclid(2.0 * PI)); + } + angles.sort_by(f64::total_cmp); + angles.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + let mut out = Vec::new(); + let c = [0.5, 0.5]; + for w in angles.windows(2) { + let [x, y] = w else { continue }; + let (fx, fy) = (f(*x).clamp(0.0, 1.0), f(*y).clamp(0.0, 1.0)); + if fx <= 0.0 && fy <= 0.0 { + continue; + } + let (bx, by) = (boundary(*x, a), boundary(*y, a)); + out.push(Layer::tri_uv(source, [c, bx, by], [c, bx, by], [(fx + fy) / 2.0, fx, fy], 1.0)); + } + out +} + +// ---------- polygons ---------- + +/// Keep the part of a convex polygon where `n · p <= d`. +pub(crate) fn clip(poly: &[P], n: P, d: f64) -> Vec

{ + let mut out = Vec::with_capacity(poly.len() + 2); + let side = |p: P| n[0] * p[0] + n[1] * p[1] - d; + for (i, p) in poly.iter().enumerate() { + let q = poly.get((i + 1) % poly.len()).copied().unwrap_or(*p); + let (sp, sq) = (side(*p), side(q)); + if sp <= 0.0 { + out.push(*p); + } + if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) { + let k = sp / (sp - sq); + out.push([p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k]); + } + } + out +} + +/// A convex polygon as triangles: vertices drawn at `map(p)` with texture coordinate `p`. +pub(crate) fn poly_layers(source: Source, poly: &[P], alpha: f64, map: &dyn Fn(P) -> P) -> Vec { + let Some(first) = poly.first().copied() else { return vec![] }; + poly.windows(2) + .skip(1) + .filter_map(|w| match w { + [b, c] => Some(Layer::tri_uv(source, [map(first), map(*b), map(*c)], [first, *b, *c], [1.0; 3], alpha)), + _ => None, + }) + .collect() +} + +pub(crate) fn rect_poly(r: R) -> Vec

{ + corners(r).to_vec() +} + +// ---------- 3-D ---------- + +/// A point in the 3-D scene: x right and y down in slide heights (the slide spans x ∈ [−a/2, a/2], +/// y ∈ [−½, ½] at z = 0), z away from the viewer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct V3 { + pub x: f64, + pub y: f64, + pub z: f64, +} + +pub(crate) fn v3(x: f64, y: f64, z: f64) -> V3 { + V3 { x, y, z } +} + +impl V3 { + pub fn add(self, o: V3) -> V3 { + v3(self.x + o.x, self.y + o.y, self.z + o.z) + } + pub fn sub(self, o: V3) -> V3 { + v3(self.x - o.x, self.y - o.y, self.z - o.z) + } + /// Rotate about the vertical axis through `c` (degrees; positive turns the right side away). + pub fn yaw(self, c: V3, deg: f64) -> V3 { + let (s, co) = deg.to_radians().sin_cos(); + let p = self.sub(c); + v3(p.x * co - p.z * s, p.y, p.x * s + p.z * co).add(c) + } + /// Rotate about the horizontal axis through `c` (degrees; positive turns the top away). + pub fn pitch(self, c: V3, deg: f64) -> V3 { + let (s, co) = deg.to_radians().sin_cos(); + let p = self.sub(c); + v3(p.x, p.y * co + p.z * s, -p.y * s + p.z * co).add(c) + } + /// Rotate in the screen plane about `c` (degrees, clockwise). + pub fn roll(self, c: V3, deg: f64) -> V3 { + let (s, co) = deg.to_radians().sin_cos(); + let p = self.sub(c); + v3(p.x * co - p.y * s, p.x * s + p.y * co, p.z).add(c) + } +} + +/// A perspective camera looking at the slide plane from `d` slide heights away. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Cam { + pub a: f64, + pub d: f64, +} + +impl Cam { + pub fn p(&self, v: V3) -> P { + let k = self.d / (self.d + v.z).max(self.d * 0.02); + [v.x * k / self.a + 0.5, v.y * k + 0.5] + } + /// The 3-D corners (tl, tr, br, bl) of the unit-space rectangle `r` lying on the slide plane. + pub fn rect(&self, r: R) -> [V3; 4] { + corners(r).map(|p| v3((p[0] - 0.5) * self.a, p[1] - 0.5, 0.0)) + } + /// The unit-space point of a 3-D point on the slide plane. + pub fn unit(&self, x: f64, y: f64) -> V3 { + v3((x - 0.5) * self.a, y - 0.5, 0.0) + } + pub fn quad(&self, c: [V3; 4]) -> Q { + c.map(|v| self.p(v)) + } +} + +/// Signed area of a quad (positive: front side facing the viewer). +pub(crate) fn area(q: &Q) -> f64 { + let mut s = 0.0; + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + s += a[0] * b[1] - b[0] * a[1]; + } + s / 2.0 +} + +/// A card: `src` of the source mapped onto 3-D corners, if it faces the viewer; `shade` (0..1) +/// darkens it. Seen from behind it is left out unless `back` is set (then drawn mirrored). +pub(crate) fn card(cam: &Cam, source: Source, src: R, c: [V3; 4], alpha: f64, shade: f64, back: bool) -> Vec { + let q = cam.quad(c); + if !q.iter().all(|p| p[0].is_finite() && p[1].is_finite()) { + return vec![]; + } + let front = area(&q) > 0.0; + if !front && !back { + return vec![]; + } + let l = Layer::quad(source, src, q, alpha); + let mut v = vec![l]; + if shade > 0.001 { + v.push(l.shade(shade)); + } + v +} + +/// How much a card facing along `n` (unit normal toward the viewer is (0, 0, −1)) is darkened by +/// the light behind the viewer: 0 facing straight on. +pub(crate) fn facing_shade(c: [V3; 4], k: f64) -> f64 { + let u = c[1].sub(c[0]); + let w = c[3].sub(c[0]); + let n = v3(u.y * w.z - u.z * w.y, u.z * w.x - u.x * w.z, u.x * w.y - u.y * w.x); + let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt(); + if len < 1e-12 { + return 0.0; + } + let cos = (n.z / len).abs(); + (k * (1.0 - cos)).clamp(0.0, 1.0) +} diff --git a/crates/anim/src/transition_tests.rs b/crates/anim/src/transition_tests.rs new file mode 100644 index 00000000..66273068 --- /dev/null +++ b/crates/anim/src/transition_tests.rs @@ -0,0 +1,230 @@ +//! Transition curves and geometry against values measured on PowerPoint's video export +//! (`plan/powerpoint/tools/synth_transitions.ps1`, 2 s transitions, 30 fps). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use deckcraft_model::anim::TRANSITIONS; + +use crate::transition::{Layer, Source, transition_frame, transition_layers, uses_slide_parts}; +use crate::transition_geom::{edge, pow_inout, q2, ss}; + +const A: f64 = 16.0 / 9.0; + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +fn of(ls: &[Layer], src: Source) -> Vec<&Layer> { + ls.iter().filter(|l| l.source == src).collect() +} + +fn left(l: &Layer) -> f64 { + l.quad.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min) +} + +fn top(l: &Layer) -> f64 { + l.quad.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min) +} + +#[test] +fn fade_follows_powerpoints_s_curve() { + // Measured share of the new slide in a 2 s Fade at t = 0.25, 0.5, 0.75: 0.155, 0.49, 0.82. + for (t, want) in [(0.25, 0.155), (0.5, 0.49), (0.75, 0.82)] { + let ls = transition_layers("fade", "smoothly", t); + let new = of(&ls, Source::New); + assert!(near(new[0].alpha, want, 0.04), "t={t}: {}", new[0].alpha); + } + // Through black: the old slide is gone to black at the middle, then the new one fades in. + let mid = transition_layers("fade", "throughBlack", 0.5); + assert!(mid.iter().all(|l| l.source == Source::Black)); + let q = transition_layers("fade", "throughBlack", 0.25); + assert!(near(of(&q, Source::Old)[0].alpha, 0.5, 0.01)); +} + +#[test] +fn push_moves_both_slides_with_an_eased_curve() { + for (o, dx, dy) in [("l", -1.0, 0.0), ("r", 1.0, 0.0), ("u", 0.0, -1.0), ("d", 0.0, 1.0)] { + for t in [0.25, 0.5, 0.75] { + let ls = transition_layers("push", o, t); + let (old, new) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]); + let s = q2(t); + assert!(near(old.quad[0][0], dx * s, 1e-9) && near(old.quad[0][1], dy * s, 1e-9), "{o} {t}"); + assert!(near(new.quad[0][0], dx * (s - 1.0), 1e-9) && near(new.quad[0][1], dy * (s - 1.0), 1e-9), "{o} {t}"); + } + } + // Ease in-out: slow at both ends. + assert!(q2(0.1) < 0.05 && q2(0.9) > 0.95 && near(q2(0.5), 0.5, 1e-9)); +} + +#[test] +fn cover_and_uncover_match_measured_positions() { + // Offset of the new slide in Cover Left (fraction of the width) at measured times. + for (t, off) in [(0.445, 0.655), (0.495, 0.512), (0.544, 0.359), (0.643, 0.134), (0.742, 0.046)] { + let ls = transition_layers("cover", "l", t); + assert!(near(left(of(&ls, Source::New)[0]), off, 0.05), "cover t={t}"); + } + // Offset of the old slide in Uncover Left. + for (t, off) in [(0.244, -0.06), (0.343, -0.14), (0.442, -0.32), (0.508, -0.5), (0.558, -0.63)] { + let ls = transition_layers("uncover", "l", t); + assert!(near(left(of(&ls, Source::Old)[0]), off, 0.05), "uncover t={t}"); + } + // Diagonal Cover shoots in and settles slowly. + for (t, off) in [(0.142, 0.728), (0.257, 0.49), (0.356, 0.29), (0.455, 0.146), (0.604, 0.053)] { + let ls = transition_layers("cover", "lu", t); + let n = of(&ls, Source::New)[0]; + assert!(near(left(n), off, 0.06) && near(top(n), off, 0.06), "cover lu t={t}"); + } + assert!(near(pow_inout(0.25, 3.0), 0.0625, 1e-9)); +} + +#[test] +fn wipe_has_a_wide_soft_edge() { + // Measured along a row of Wipe Right at t = 0.5: about half-way through at x = 0.6, nearly + // nothing at the right edge, fully new on the left third. + let c = -0.4 + 1.8 * 1.05 * 0.5; + assert!(near(edge(c, 0.6, 0.8), 0.49, 0.1)); + assert!(edge(c, 1.0, 0.8) < 0.1); + assert!(edge(c, 0.2, 0.8) > 0.8); + // Directions: "r" starts on the left, "l" on the right, "u" at the bottom, "d" at the top, + // diagonals at the opposite corner. + let first = |o: &str| { + let ls = transition_layers("wipe", o, 0.15); + let n = of(&ls, Source::New); + assert!(!n.is_empty(), "{o}"); + let cx = n.iter().map(|l| (l.quad[0][0] + l.quad[2][0]) / 2.0).sum::() / n.len() as f64; + let cy = n.iter().map(|l| (l.quad[0][1] + l.quad[2][1]) / 2.0).sum::() / n.len() as f64; + (cx, cy) + }; + assert!(first("r").0 < 0.3 && first("l").0 > 0.7); + assert!(first("u").1 > 0.7 && first("d").1 < 0.3); + let ru = first("ru"); + assert!(ru.0 < 0.4 && ru.1 > 0.6, "{ru:?}"); +} + +#[test] +fn split_and_shape_open_from_the_middle() { + for o in ["horzOut", "vertOut"] { + let ls = transition_layers("split", o, 0.2); + let n = of(&ls, Source::New); + assert!(n.iter().all(|l| { + let c = [(l.quad[0][0] + l.quad[2][0]) / 2.0, (l.quad[0][1] + l.quad[2][1]) / 2.0]; + if o.starts_with("horz") { (c[1] - 0.5).abs() < 0.4 } else { (c[0] - 0.5).abs() < 0.4 } + })); + } + // Shape In is done with the old slide at half time; Shape Out starts the new one then. + assert!(of(&transition_layers("shape", "in", 0.55), Source::Old).is_empty()); + assert!(of(&transition_layers("shape", "out", 0.45), Source::New).is_empty()); +} + +#[test] +fn dynamic_content_curves() { + // Pan: the new content's offset (fraction of the width) at measured times. + for (t, off) in [(0.6, 0.134), (0.7, 0.086), (0.8, 0.039), (0.9, 0.011)] { + let ls = transition_layers("pan", "l", t); + assert!(near(left(of(&ls, Source::NewContent)[0]), off, 0.07), "pan t={t}"); + } + // The backgrounds cross-fade under the moving contents. + let ls = transition_layers("pan", "l", 0.5); + assert_eq!(ls.first().map(|l| l.source), Some(Source::OldBack)); + assert!(of(&ls, Source::NewBack)[0].alpha > 0.5); + // Ferris Wheel: the old content drops (top edge, slide heights) as measured. + for (t, dy) in [(0.147, 0.06), (0.246, 0.17), (0.345, 0.36), (0.444, 0.70)] { + let ls = transition_layers("ferris", "l", t); + let q = of(&ls, Source::OldContent)[0].quad; + let cy = q.iter().map(|p| p[1]).sum::() / 4.0 - 0.5; + assert!(near(cy, dy, 0.08), "ferris t={t}: {cy}"); + } + for k in ["pan", "ferris", "conveyor", "rotate", "window", "orbit", "flythrough"] { + assert!(uses_slide_parts(k, ""), "{k}"); + } + assert!(uses_slide_parts("reveal", "smoothlyLeft") && !uses_slide_parts("reveal", "blackLeft")); + assert!(!uses_slide_parts("cube", "l") && !uses_slide_parts("push", "l")); +} + +#[test] +fn cube_turns_toward_the_option() { + // Cube Left at the middle: the shared edge is near the middle, the old face on the left. + let ls = transition_frame("cube", "l", 0.5, A); + let (o, n) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]); + let o_right = o.quad.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max); + assert!(near(o_right, left(n), 0.02), "shared edge: {o_right} vs {}", left(n)); + assert!(o_right > 0.3 && o_right < 0.7); + // Up: the new face comes from below. + let ls = transition_frame("cube", "u", 0.5, A); + assert!(top(of(&ls, Source::New)[0]) > top(of(&ls, Source::Old)[0])); +} + +#[test] +fn switch_swaps_the_cards_past_halfway() { + let order = |t: f64| { + let ls = transition_frame("switch", "l", t, A); + let pos = |s: Source| ls.iter().position(|l| l.source == s); + (pos(Source::Old), pos(Source::New)) + }; + let (o, n) = order(0.3); + assert!(o > n, "old in front early"); + let (o, n) = order(0.8); + assert!(n > o, "new in front late"); +} + +#[test] +fn clock_hand_sweeps_at_constant_speed() { + // The share of the slide covered grows linearly. + let area = |t: f64| { + let ls = transition_layers("clock", "clockwise", t); + of(&ls, Source::New) + .iter() + .map(|l| { + let [a, b, c, _] = l.quad; + ((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])).abs() / 2.0 * (l.fade[0] + l.fade[1] + l.fade[2]) / 3.0 + }) + .sum::() + }; + let (a1, a2, a3) = (area(0.25), area(0.5), area(0.75)); + assert!(near(a2 - a1, a3 - a2, 0.08), "{a1} {a2} {a3}"); + assert!(near(a2, 0.5, 0.08), "{a2}"); +} + +#[test] +fn options_change_the_frame() { + // Every option of a transition draws something different from its other options midway. + for (id, _, _, _, opts) in TRANSITIONS { + if matches!(*id, "morph" | "none" | "random") || opts.len() < 2 { + continue; + } + for (i, a) in opts.iter().enumerate() { + for b in opts.iter().skip(i + 1) { + let differs = [0.2, 0.35, 0.5, 0.8].iter().any(|t| transition_layers(id, a, *t) != transition_layers(id, b, *t)); + assert!(differs, "{id}: {a} vs {b}"); + } + } + } +} + +#[test] +fn frames_stay_cheap() { + // Per frame, the show draws these meshes from cached slide textures: keep them small. + for (id, _, _, _, opts) in TRANSITIONS { + let mut options: Vec<&str> = opts.to_vec(); + options.push(""); + for o in options { + for t in [0.1, 0.3, 0.5, 0.7, 0.9] { + let tris: usize = transition_frame(id, o, t, A).iter().map(|l| crate::layer_mesh(l).1.len()).sum(); + assert!(tris < 30_000, "{id}/{o} t={t}: {tris} triangles"); + } + } + } +} + +#[test] +fn hostile_aspects_are_safe() { + for a in [f64::NAN, 0.0, -3.0, 1e9, f64::INFINITY] { + for (id, ..) in TRANSITIONS { + for l in transition_frame(id, "", 0.4, a) { + assert!(l.quad.iter().all(|p| p[0].is_finite() && p[1].is_finite())); + assert!(l.fade.iter().all(|f| (0.0..=1.0).contains(f))); + } + } + } + assert!(near(ss(0.5), 0.5, 1e-12)); +} diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index 3495d21e..7c45aa0f 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -63,7 +63,7 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("fracture", "Fracture", "Exciting", 2000, &[]), ("crush", "Crush", "Exciting", 2000, &[]), ("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]), - ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r"]), + ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r", "singleL", "singleR"]), ("airplane", "Airplane", "Exciting", 2000, &["l", "r"]), ("origami", "Origami", "Exciting", 2000, &["l", "r"]), ("dissolve", "Dissolve", "Exciting", 1200, &[]), @@ -72,9 +72,9 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]), ("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]), ("honeycomb", "Honeycomb", "Exciting", 3000, &[]), - ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d"]), + ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d", "hexL", "hexR", "hexU", "hexD"]), ("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]), - ("shred", "Shred", "Exciting", 2000, &["strips", "particles"]), + ("shred", "Shred", "Exciting", 2000, &["stripsIn", "stripsOut", "particlesIn", "particlesOut"]), ("switch", "Switch", "Exciting", 1250, &["l", "r"]), ("flip", "Flip", "Exciting", 1250, &["l", "r"]), ("gallery", "Gallery", "Exciting", 1500, &["l", "r"]), @@ -82,7 +82,7 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("doors", "Doors", "Exciting", 1400, &["vert", "horz"]), ("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]), ("comb", "Comb", "Exciting", 1000, &["horz", "vert"]), - ("zoom", "Zoom", "Exciting", 1000, &["in", "out"]), + ("zoom", "Zoom", "Exciting", 1000, &["in", "out", "rotate"]), ("random", "Random", "Exciting", 2000, &[]), ("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]), ("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]), @@ -93,6 +93,101 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]), ]; +/// The Effect Options label PowerPoint shows for a transition option. Options are stored as the +/// file format's values: for moving transitions (Push, Wipe, Cover, Pan, Cube…) the direction +/// things move, so "l" is the effect coming "From Right"; Glitter and the preset effects (Fall +/// Over, Wind…) name the side itself; Ripple names where the wave heads. +pub fn transition_option_label(kind: &str, option: &str) -> String { + let side = |c: char| match c { + 'l' => "Left", + 'r' => "Right", + 'u' => "Top", + _ => "Bottom", + }; + let opposite = |c: char| match c { + 'l' => 'r', + 'r' => 'l', + 'u' => 'd', + _ => 'u', + }; + let corner = |o: &str, flip: bool| -> Option { + let mut cs = o.chars(); + let (h, v) = (cs.next()?, cs.next()?); + let (h, v) = if flip { (opposite(h), opposite(v)) } else { (h, v) }; + Some(format!("From {}-{}", side(v), side(h))) + }; + let moving = matches!( + kind, + "push" + | "wipe" + | "cover" + | "uncover" + | "pan" + | "vortex" + | "cube" + | "box" + | "rotate" + | "orbit" + | "gallery" + | "conveyor" + | "ferris" + | "switch" + | "flip" + ); + match (kind, option) { + (_, "horz") => "Horizontal".into(), + (_, "vert") => "Vertical".into(), + ("split", o) => format!("{} {}", if o.starts_with("horz") { "Horizontal" } else { "Vertical" }, if o.ends_with("In") { "In" } else { "Out" }), + ("fade" | "cut", "throughBlack") => "Through Black".into(), + ("fade", _) => "Smoothly".into(), + ("cut", _) => "Cut".into(), + ("clock", "counterClockwise") => "Counterclockwise".into(), + ("clock", "wedge") => "Wedge".into(), + ("clock", _) => "Clockwise".into(), + ("zoom", "rotate") => "Zoom and Rotate".into(), + ("ripple", "center") => "Center".into(), + ("ripple", o) => corner(o, true).unwrap_or_else(|| "Center".into()), + ("glitter", o) => { + let (pat, d) = match o.strip_prefix("hex") { + Some(d) => ("Hexagons", d.to_ascii_lowercase()), + None => ("Diamonds", o.to_string()), + }; + format!("{pat} From {}", side(d.chars().next().unwrap_or('l'))) + } + ("pageCurlDouble", o) => match o { + "singleL" => "Single Left".into(), + "singleR" => "Single Right".into(), + "r" => "Double Right".into(), + _ => "Double Left".into(), + }, + ("shred", o) => { + format!("{} {}", if o.starts_with("particles") { "Particles" } else { "Strips" }, if o.ends_with("Out") { "Out" } else { "In" }) + } + ("reveal", o) => format!( + "{} {}", + if o.starts_with("black") { "Through Black" } else { "Smoothly" }, + if o.ends_with("Right") { "From Right" } else { "From Left" } + ), + ("flythrough", o) => { + format!("{}{}", if o.starts_with("out") { "Out" } else { "In" }, if o.ends_with("Bounce") { " with Bounce" } else { "" }) + } + ("morph", o) => match o { + "words" => "Words".into(), + "characters" => "Characters".into(), + _ => "Objects".into(), + }, + (_, o) if o.len() == 2 && moving => corner(o, true).unwrap_or_default(), + (_, o) if o.len() == 1 && moving => format!("From {}", side(opposite(o.chars().next().unwrap_or('l')))), + (_, "l") => "Left".into(), + (_, "r") => "Right".into(), + (_, "u") => "Top".into(), + (_, "d") => "Bottom".into(), + (_, "in") => "In".into(), + (_, "out") => "Out".into(), + (_, o) => o.to_string(), + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AnimClass { @@ -291,3 +386,29 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str])> { ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) } + +#[cfg(test)] +mod option_label_tests { + use super::*; + + #[test] + fn transition_option_labels() { + assert_eq!(transition_option_label("push", "l"), "From Right"); + assert_eq!(transition_option_label("push", "u"), "From Bottom"); + assert_eq!(transition_option_label("wipe", "r"), "From Left"); + assert_eq!(transition_option_label("cover", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("ripple", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("glitter", "hexU"), "Hexagons From Top"); + assert_eq!(transition_option_label("glitter", "l"), "Diamonds From Left"); + assert_eq!(transition_option_label("wind", "l"), "Left"); + assert_eq!(transition_option_label("pageCurlDouble", "singleR"), "Single Right"); + assert_eq!(transition_option_label("shred", "particlesOut"), "Particles Out"); + assert_eq!(transition_option_label("zoom", "rotate"), "Zoom and Rotate"); + assert_eq!(transition_option_label("split", "vertIn"), "Vertical In"); + for (id, _, _, _, opts) in TRANSITIONS { + for o in opts.iter() { + assert!(!transition_option_label(id, o).is_empty(), "{id}/{o}"); + } + } + } +} diff --git a/crates/pptx/src/maps.rs b/crates/pptx/src/maps.rs index c700d8eb..dba4b5c2 100644 --- a/crates/pptx/src/maps.rs +++ b/crates/pptx/src/maps.rs @@ -17,7 +17,12 @@ pub fn transition_from_xml(e: &El) -> Option<(String, String)> { Some((k, if has_opts { o } else { String::new() })) } +/// Preset transitions (`p15:prstTrans`) whose "Left" option PowerPoint writes with `invX="1"` +/// (the others write it for "Right"), as observed on PowerPoint's own files. +const INVX_LEFT: &[&str] = &["wind", "airplane", "origami"]; + fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { + // Defaults are what PowerPoint leaves out: e.g. it writes Push Left as ``. let dir = |d: &str| e.attr("dir").unwrap_or(d).to_string(); let k = |s: &str| s.to_string(); Some(match e.local() { @@ -25,16 +30,22 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { "cut" => (k("cut"), if e.bool("thruBlk") == Some(true) { k("throughBlack") } else { String::new() }), "push" => (k("push"), dir("l")), "wipe" => (k("wipe"), dir("l")), + // Diagonal wipes. + "strips" => (k("wipe"), dir("lu")), "split" => { let o = e.attr("orient").unwrap_or("horz"); let d = e.attr("dir").unwrap_or("out"); (k("split"), format!("{o}{}", if d == "in" { "In" } else { "Out" })) } - "randomBar" => (k("randomBar"), dir("vert")), + "randomBar" => (k("randomBar"), dir("horz")), "circle" => (k("shape"), k("circle")), "diamond" => (k("shape"), k("diamond")), "plus" => (k("shape"), k("plus")), - "zoom" => (k("zoom"), dir("in")), + // Shape In / Out (the slide zooming in a box). + "zoom" => (k("shape"), if e.attr("dir") == Some("in") { k("in") } else { k("out") }), + // The Zoom transition, and its Zoom and Rotate option. + "warp" => (k("zoom"), if e.attr("dir") == Some("in") { k("in") } else { k("out") }), + "newsflash" => (k("zoom"), k("rotate")), "pull" => (k("uncover"), dir("l")), "cover" => (k("cover"), dir("l")), "checker" => (k("checker"), dir("horz")), @@ -47,14 +58,22 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { "switch" => (k("switch"), dir("r")), "flip" => (k("flip"), dir("r")), "ripple" => (k("ripple"), dir("center")), - "doors" => (k("doors"), dir("vert")), - "window" => (k("window"), dir("vert")), + "doors" => (k("doors"), dir("horz")), + "window" => (k("window"), dir("horz")), "ferris" => (k("ferris"), dir("l")), "gallery" => (k("gallery"), dir("l")), "conveyor" => (k("conveyor"), dir("l")), - "pan" => (k("pan"), dir("d")), - "glitter" => (k("glitter"), dir("l")), - "shred" => (k("shred"), if e.attr("pattern") == Some("crush") { k("particles") } else { k("strips") }), + "pan" => (k("pan"), dir("l")), + "glitter" => { + let d = dir("l"); + if e.attr("pattern") == Some("hexagon") { (k("glitter"), format!("hex{}", d.to_ascii_uppercase())) } else { (k("glitter"), d) } + } + "shred" => { + // `crush` is what DeckCraft wrote before it knew PowerPoint's `rectangle`. + let particles = matches!(e.attr("pattern"), Some("rectangle" | "crush")); + let out = e.attr("dir") == Some("out"); + (k("shred"), format!("{}{}", if particles { "particles" } else { "strips" }, if out { "Out" } else { "In" })) + } "reveal" => { let black = e.bool("thruBlk") == Some(true); let right = e.attr("dir") == Some("r"); @@ -78,11 +97,16 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { } "prstTrans" => { let prst = e.attr("prst").unwrap_or("fallOver"); + let inv = e.bool("invX") == Some(true); + if prst == "pageCurlSingle" { + return Some((k("pageCurlDouble"), if inv { k("singleR") } else { k("singleL") })); + } let known = deckcraft_model::anim::TRANSITIONS.iter().any(|t| t.0 == prst); if !known { return None; } - (k(prst), if e.bool("invX") == Some(true) { k("r") } else { k("l") }) + let left = if INVX_LEFT.contains(&prst) { inv } else { !inv }; + (k(prst), if left { k("l") } else { k("r") }) } "morph" => ( k("morph"), @@ -119,7 +143,10 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static "fade" => w.empty("p:fade", A::new().t("thruBlk", option == "throughBlack")), "cut" => w.empty("p:cut", A::new().t("thruBlk", option == "throughBlack")), "push" => w.empty("p:push", A::new().a("dir", lrud("u"))), - "wipe" => w.empty("p:wipe", A::new().a("dir", lrud("r"))), + "wipe" => match option { + "lu" | "ru" | "ld" | "rd" => w.empty("p:strips", A::new().a("dir", option)), + _ => w.empty("p:wipe", A::new().a("dir", lrud("r"))), + }, "split" => { let (o, d) = match option { "vertIn" => ("vert", "in"), @@ -136,7 +163,13 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static "in" | "out" => w.empty("p:zoom", A::new().a("dir", option)), _ => w.empty0("p:circle"), }, - "zoom" => w.empty("p:zoom", A::new().a("dir", if option == "out" { "out" } else { "in" })), + "zoom" => match option { + "rotate" => w.empty0("p:newsflash"), + _ => { + w.empty("p14:warp", A::new().a("dir", if option == "out" { "out" } else { "in" })); + return Some("p14"); + } + }, "uncover" => w.empty("p:pull", A::new().a("dir", eight("l"))), "cover" => w.empty("p:cover", A::new().a("dir", eight("l"))), "checker" => w.empty("p:checker", A::new().a("dir", hv("horz"))), @@ -160,9 +193,15 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static w.empty0("p14:flash"); return Some("p14"); } - "vortex" | "glitter" => { - let name = if kind == "vortex" { "p14:vortex" } else { "p14:glitter" }; - w.empty(name, A::new().a("dir", lrud("l"))); + "vortex" => { + w.empty("p14:vortex", A::new().a("dir", lrud("l"))); + return Some("p14"); + } + "glitter" => { + let hex = option.starts_with("hex"); + let d = option.trim_start_matches("hex").to_ascii_lowercase(); + let d = if matches!(d.as_str(), "l" | "r" | "u" | "d") { d } else { "l".to_string() }; + w.empty("p14:glitter", A::new().a("dir", d).o("pattern", hex.then_some("hexagon"))); return Some("p14"); } "switch" | "flip" | "gallery" | "conveyor" | "ferris" => { @@ -194,7 +233,11 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static return Some("p14"); } "shred" => { - w.empty("p14:shred", A::new().a("pattern", if option == "particles" { "crush" } else { "strip" })); + let particles = option.starts_with("particles"); + w.empty( + "p14:shred", + A::new().a("pattern", if particles { "rectangle" } else { "strip" }).a("dir", if option.ends_with("Out") { "out" } else { "in" }), + ); return Some("p14"); } "reveal" => { @@ -215,8 +258,13 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static ); return Some("p14"); } + "pageCurlDouble" if option.starts_with("single") => { + w.empty("p15:prstTrans", A::new().a("prst", "pageCurlSingle").t("invX", option == "singleR")); + return Some("p15"); + } "fallOver" | "drape" | "curtains" | "wind" | "prestige" | "fracture" | "crush" | "peelOff" | "pageCurlDouble" | "airplane" | "origami" => { - w.empty("p15:prstTrans", A::new().a("prst", kind).t("invX", option == "r")); + let inv = if INVX_LEFT.contains(&kind) { option == "l" } else { option == "r" }; + w.empty("p15:prstTrans", A::new().a("prst", kind).t("invX", inv)); return Some("p15"); } "morph" => { @@ -342,16 +390,7 @@ mod tests { assert!(!ns.is_empty()); let d = parse(w.s.as_bytes()).unwrap(); let (k, opt) = transition_from_xml(&d.root).unwrap_or_else(|| panic!("no read for {kind}")); - // `shape` in/out are written as zoom; everything else must come back. - if *kind == "shape" && (o == "in" || o == "out") { - assert_eq!(k, "zoom"); - continue; - } assert_eq!(k, *kind, "kind for {kind}/{o}"); - // PresentationML wipes have no diagonal directions. - if *kind == "wipe" && o.len() == 2 { - continue; - } if !o.is_empty() { assert_eq!(opt, o, "option for {kind}"); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index c9f33fe3..eb194b8c 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -857,3 +857,135 @@ fn wordart_warps_and_their_adjusts_round_trip() { assert_eq!(inflate.warp.as_deref(), Some("textInflate")); assert!(inflate.warp_adj.is_empty()); } + +/// Slide XML of a deck exported with one slide per transition (kind, option, ms, advance after). +fn transition_deck(list: &[(&str, &str, u32, Option)]) -> (Presentation, Vec) { + let mut p = defaults::new_presentation(None); + let base = p.slides[0].as_ref().clone(); + p.slides.clear(); + for (kind, option, ms, adv) in list { + let mut s = base.clone(); + s.id = SlideId(p.alloc_id()); + s.transition = Some(Transition { + kind: kind.to_string(), + option: option.to_string(), + duration_ms: *ms, + advance_on_click: adv.is_none(), + advance_after_ms: *adv, + ..Default::default() + }); + p.slides.push(Arc::new(s)); + } + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let xml = (1..=list.len()) + .map(|i| { + let mut x = String::new(); + std::io::Read::read_to_string(&mut zip.by_name(&format!("ppt/slides/slide{i}.xml")).expect("slide"), &mut x).expect("read"); + x + }) + .collect(); + (p, xml) +} + +#[test] +fn transition_xml_matches_powerpoints_elements() { + // The elements, attributes and namespaces PowerPoint itself writes for these effect options + // (observed on files it saved), inside `mc:AlternateContent` with a `p:fade` fallback for + // the 2010+ effects. + let cases: &[(&str, &str, &[&str], &str)] = &[ + ("wipe", "ru", &[r#""#], "p14"), + ("wipe", "u", &[r#""#], "p14"), + ("zoom", "in", &[r#""#], "p14"), + ("zoom", "rotate", &[""], "p14"), + ("shape", "out", &[r#""#], "p14"), + ("glitter", "hexD", &[r#""#], "p14"), + ("shred", "particlesOut", &[r#""#], "p14"), + ("wind", "l", &[r#""#], "p15"), + ("wind", "r", &[r#""#], "p15"), + ("fallOver", "r", &[r#""#], "p15"), + ("pageCurlDouble", "singleR", &[r#""#], "p15"), + ("orbit", "u", &[r#""#], "p14"), + ("vortex", "r", &[r#""#], "p14"), + ("morph", "words", &[r#""#], "p159"), + ]; + let list: Vec<(&str, &str, u32, Option)> = cases.iter().map(|c| (c.0, c.1, 2000, Some(1000))).collect(); + let (p, xml) = transition_deck(&list); + for ((kind, option, wants, req), x) in cases.iter().zip(&xml) { + for w in *wants { + assert!(x.contains(w), "{kind}/{option}: {w} in {x}"); + } + assert!(x.contains(&format!(r#"Requires="{req}""#)), "{kind}/{option}"); + assert!(x.contains(r#"p14:dur="2000""#) && x.contains(r#"advTm="1000""#) && x.contains(r#"advClick="0""#), "{kind}/{option}"); + assert!(x.contains(""), "{kind}/{option}"); + } + // Back in, everything is as it was. + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + for ((kind, option, ..), s) in cases.iter().zip(&q.slides) { + let t = s.transition.as_ref().expect("transition"); + assert_eq!( + (t.kind.as_str(), t.option.as_str(), t.duration_ms, t.advance_after_ms, t.advance_on_click), + (*kind, *option, 2000, Some(1000), false) + ); + } +} + +#[test] +fn transition_defaults_read_like_powerpoint() { + // PowerPoint leaves out an option when it is the element's default; these are its defaults. + let cases: &[(&str, &str, &str)] = &[ + ("", "push", "l"), + ("", "wipe", "l"), + ("", "wipe", "lu"), + ("", "randomBar", "horz"), + ("", "checker", "horz"), + ("", "blinds", "horz"), + ("", "comb", "horz"), + ("", "shape", "out"), + (r#""#, "shape", "in"), + ("", "split", "horzOut"), + ("", "doors", "horz"), + ("", "window", "horz"), + ("", "pan", "l"), + ("", "zoom", "out"), + ("", "glitter", "l"), + ("", "shred", "stripsIn"), + ("", "reveal", "smoothlyLeft"), + ("", "cube", "l"), + (r#""#, "airplane", "r"), + (r#""#, "peelOff", "l"), + (r#""#, "pageCurlDouble", "singleL"), + ]; + let (_, xml) = transition_deck(&[("fade", "smoothly", 2000, None)]); + let Some(x) = xml.first() else { panic!("no slide") }; + let start = x.find("").expect("transition end") + "".len(); + let p14 = r#"xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main""#; + let p15 = r#"xmlns:p15="http://schemas.microsoft.com/office/powerpoint/2012/main""#; + for (el, kind, option) in cases { + let tr = format!(r#"{el}"#); + let edited = format!("{}{}{}", &x[..start], tr, &x[end..]); + let mut p = defaults::new_presentation(None); + let base = p.slides[0].as_ref().clone(); + p.slides = vec![Arc::new(base)]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + std::io::Read::read_to_end(&mut f, &mut data).expect("read"); + if name == "ppt/slides/slide1.xml" { + data = edited.clone().into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + std::io::Write::write_all(&mut out, &data).expect("write"); + } + let bytes = out.finish().expect("finish").into_inner(); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let t = q.slides[0].transition.as_ref().expect("transition"); + assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 6b662887..27042312 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -13,6 +13,7 @@ deckcraft-geom = { workspace = true } deckcraft-color = { workspace = true } deckcraft-metafile = { workspace = true } deckcraft-model = { workspace = true } +deckcraft-anim = { workspace = true } deckcraft-fonts = { workspace = true } deckcraft-text = { workspace = true } vello_cpu = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47a0f1e2..1621f053 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -23,6 +23,7 @@ mod pathgrad; mod placed; mod ramp; mod table; +mod transition; mod warp; use std::collections::HashMap; @@ -41,12 +42,14 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; +pub use deckcraft_anim::{transition_frame, transition_layers, uses_slide_parts}; pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; pub use table::row_heights as table_row_heights; +pub use transition::{SlideParts, composite_transition, render_transition}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). pub const MAX_SIDE: u32 = 16_000; @@ -329,6 +332,30 @@ pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) } } +/// 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"); + 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`: @@ -550,6 +577,25 @@ impl Renderer { self.finish(ctx, w, h) } + fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { + 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)] fn blend( &mut self, diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs new file mode 100644 index 00000000..05851448 --- /dev/null +++ b/crates/render/src/transition.rs @@ -0,0 +1,517 @@ +//! Slide transition frames without a GPU: the layers of [`deckcraft_anim::transition_layers`] +//! composited on the CPU from two rendered slide images. The slide show draws the same layers with +//! the UI toolkit's meshes; this is the headless twin used by the CLI, comparisons and tests. + +use deckcraft_anim::{Layer, Source, layer_mesh, transition_frame, uses_slide_parts}; +use deckcraft_model::{Presentation, ShapeId}; + +use crate::{Image, RenderOpts, ShapeState, SlidePart, render_blend, render_slide, render_slide_part}; + +/// The two slides of a transition rendered as background and content, for the transitions that +/// move contents over the backgrounds ([`deckcraft_anim::uses_slide_parts`]). `None` parts of the +/// old slide (the show's first slide) are black and nothing. +#[derive(Clone, Debug, Default)] +pub struct SlideParts { + pub old_back: Option, + pub old_content: Option, + pub new_back: Image, + pub new_content: Image, +} + +impl SlideParts { + /// Render the parts of slides `old` and `new`. + pub fn render(pres: &Presentation, old: Option, new: usize, opts: &RenderOpts) -> SlideParts { + SlideParts { + old_back: old.map(|i| render_slide_part(pres, i, SlidePart::Backdrop, opts)), + old_content: old.map(|i| render_slide_part(pres, i, SlidePart::Content, opts)), + new_back: render_slide_part(pres, new, SlidePart::Backdrop, opts), + new_content: render_slide_part(pres, new, SlidePart::Content, opts), + } + } +} + +/// Composite transition `layers` over black into an image the size of `new`. `old` is the outgoing +/// slide (`None`: black, as when the show starts); `parts` the slides' backgrounds and contents +/// (without them, a background draws its whole slide and content draws nothing). +pub fn composite_transition(old: Option<&Image>, new: &Image, parts: Option<&SlideParts>, layers: &[Layer]) -> Image { + let (w, h) = (new.width, new.height); + let n = (w as usize).saturating_mul(h as usize); + let mut out = Image { width: w, height: h, pixels: [0u8, 0, 0, 255].repeat(n) }; + for l in layers { + let src = match (l.source.is_part(), parts) { + (true, None) => match l.source.whole() { + Some(s) => s, + None => continue, + }, + _ => l.source, + }; + let tex = match (src, parts) { + (Source::Old, _) => old.map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])), + (Source::New, _) => Tex::Image(new), + (Source::Black, _) => Tex::Solid([0.0, 0.0, 0.0]), + (Source::White, _) => Tex::Solid([255.0, 255.0, 255.0]), + (Source::OldBack, Some(p)) => p.old_back.as_ref().map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])), + (Source::NewBack, Some(p)) => Tex::Image(&p.new_back), + (Source::OldContent, Some(p)) => match &p.old_content { + Some(i) => Tex::Image(i), + None => continue, + }, + (Source::NewContent, Some(p)) => Tex::Image(&p.new_content), + (_, None) => continue, + }; + draw_layer(&mut out, l, &tex); + } + out +} + +/// The frame of slide `index`'s transition at progress `t` (0..1): the previous slide (black for +/// the first) going out, slide `index` coming in, at `opts`' scale and size. Morph frames are +/// rendered shape by shape like the show does. +pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &RenderOpts) -> Image { + let Some(slide) = pres.slides.get(index) else { return Image::default() }; + let (kind, option) = slide.transition.as_ref().map(|tr| (tr.kind.as_str(), tr.option.as_str())).unwrap_or(("none", "")); + let prev = index.checked_sub(1).filter(|i| pres.slides.get(*i).is_some()); + if kind == "morph" + && let Some(from) = prev + && let Some(a) = pres.slides.get(from) + { + let tt = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + if tt < 1.0 { + let m = deckcraft_anim::morph_frame(pres, a, slide, tt, &|_| false); + let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; + let o = RenderOpts { state: Some(&f), ..copy_opts(opts) }; + let text: Vec = + m.text.iter().map(|x| crate::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect(); + let paths: Vec = m + .paths + .iter() + .map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) + .collect(); + return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o); + } + } + let new = render_slide(pres, index, opts); + let old = prev.map(|i| render_slide(pres, i, opts)); + let parts = uses_slide_parts(kind, option).then(|| SlideParts::render(pres, prev, index, opts)); + let aspect = pres.slide_size.width / pres.slide_size.height.max(1.0); + composite_transition(old.as_ref(), &new, parts.as_ref(), &transition_frame(kind, option, t, aspect)) +} + +fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> { + RenderOpts { + scale: o.scale, + edit: o.edit, + show_hidden: o.show_hidden, + state: o.state, + skip: o.skip, + skip_text: o.skip_text, + gif_times: o.gif_times, + threads: o.threads, + offset: o.offset, + size: o.size, + clear: o.clear, + date_text: o.date_text.clone(), + now: o.now, + } +} + +enum Tex<'a> { + Image(&'a Image), + /// Straight RGB, opaque. + Solid([f32; 3]), +} + +impl Tex<'_> { + /// Premultiplied RGBA (0..255) at texture coordinate (u, v), bilinear, clamped to the edge. + fn sample(&self, u: f64, v: f64) -> [f32; 4] { + match self { + Tex::Solid(c) => [c[0], c[1], c[2], 255.0], + Tex::Image(img) => { + let (w, h) = (img.width as usize, img.height as usize); + if w == 0 || h == 0 { + return [0.0; 4]; + } + let x = (u * w as f64 - 0.5).clamp(0.0, (w - 1) as f64); + let y = (v * h as f64 - 0.5).clamp(0.0, (h - 1) as f64); + let (x0, y0) = (x.floor() as usize, y.floor() as usize); + let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); + let (fx, fy) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); + let px = |xx: usize, yy: usize| -> [f32; 4] { + let i = (yy * w + xx) * 4; + match img.pixels.get(i..i + 4) { + Some(p) => [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32], + None => [0.0; 4], + } + }; + let (a, b, c, d) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1)); + let mut o = [0.0f32; 4]; + for k in 0..4 { + let top = a[k] + (b[k] - a[k]) * fx; + let bot = c[k] + (d[k] - c[k]) * fx; + o[k] = top + (bot - top) * fy; + } + o + } + } + } +} + +fn draw_layer(out: &mut Image, l: &Layer, tex: &Tex) { + if !(l.alpha.is_finite() && l.alpha > 0.0) { + return; + } + let (verts, tris) = layer_mesh(l); + let (w, h) = (out.width as f64, out.height as f64); + for t in tris { + let (Some(a), Some(b), Some(c)) = (verts.get(t[0] as usize), verts.get(t[1] as usize), verts.get(t[2] as usize)) else { continue }; + let p = [[a.pos[0] * w, a.pos[1] * h], [b.pos[0] * w, b.pos[1] * h], [c.pos[0] * w, c.pos[1] * h]]; + let uv = [a.uv, b.uv, c.uv]; + let al = [a.a, b.a, c.a].map(|x| if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }); + if al.iter().all(|x| *x <= 0.0) { + continue; + } + fill_triangle(out, p, uv, al, tex); + } +} + +/// Rasterise one textured triangle: pixel centres inside it take the texture's colour at the +/// barycentric texture coordinate, at the interpolated opacity, blended source-over. +fn fill_triangle(out: &mut Image, p: [[f64; 2]; 3], uv: [[f64; 2]; 3], al: [f64; 3], tex: &Tex) { + let area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]); + if !area.is_finite() || area.abs() < 1e-12 { + return; + } + let (w, h) = (out.width as i64, out.height as i64); + let minx = p.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64; + let maxx = (p.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(w); + let miny = p.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64; + let maxy = (p.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(h); + if minx >= maxx || miny >= maxy { + return; + } + let edge = |a: [f64; 2], b: [f64; 2], x: f64, y: f64| (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); + let width = out.width as usize; + for y in miny..maxy { + // Sample a hair off the pixel centre so a point on an edge shared by two triangles falls + // in exactly one of them (no double-blended seams). + let fy = y as f64 + 0.5 + 2.31e-6; + for x in minx..maxx { + let fx = x as f64 + 0.5 + 1.17e-6; + let w0 = edge(p[1], p[2], fx, fy) / area; + let w1 = edge(p[2], p[0], fx, fy) / area; + let w2 = edge(p[0], p[1], fx, fy) / area; + if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 { + continue; + } + let u = w0 * uv[0][0] + w1 * uv[1][0] + w2 * uv[2][0]; + let v = w0 * uv[0][1] + w1 * uv[1][1] + w2 * uv[2][1]; + let alpha = (w0 * al[0] + w1 * al[1] + w2 * al[2]) as f32; + if alpha <= 0.0 { + continue; + } + let s = tex.sample(u, v); + let i = (y as usize * width + x as usize) * 4; + let Some(d) = out.pixels.get_mut(i..i + 4) else { continue }; + let sa = s[3] * alpha / 255.0; + for k in 0..4 { + let src = s[k] * alpha; + let v = src + d[k] as f32 * (1.0 - sa); + d[k] = v.round().clamp(0.0, 255.0) as u8; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, c: [u8; 4]) -> Image { + Image { width: w, height: h, pixels: c.repeat((w * h) as usize) } + } + + #[test] + fn layers_composite_in_order() { + let old = solid(40, 20, [255, 0, 0, 255]); + let new = solid(40, 20, [0, 0, 255, 255]); + let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 0.0)); + assert_eq!(img.pixel(20, 10), [255, 0, 0, 255]); + let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 1.0)); + assert_eq!(img.pixel(20, 10), [0, 0, 255, 255]); + } + + #[test] + fn missing_old_slide_is_black() { + let new = solid(10, 10, [0, 255, 0, 255]); + let img = composite_transition(None, &new, None, &[Layer::full(Source::Old, 1.0)]); + assert_eq!(img.pixel(5, 5), [0, 0, 0, 255]); + } + + #[test] + fn half_alpha_blends() { + let old = solid(10, 10, [200, 0, 0, 255]); + let new = solid(10, 10, [0, 0, 200, 255]); + let l = [Layer::full(Source::Old, 1.0), Layer::full(Source::New, 0.5)]; + let p = composite_transition(Some(&old), &new, None, &l).pixel(5, 5); + assert!((p[0] as i32 - 100).abs() <= 1 && (p[2] as i32 - 100).abs() <= 1, "{p:?}"); + } + + // ---------- golden-ish frames on synthetic slides ---------- + + const W: u32 = 160; + const H: u32 = 90; + const OLD: [u8; 4] = [220, 40, 40, 255]; + const NEW: [u8; 4] = [30, 60, 200, 255]; + + #[derive(Clone, Copy, Debug, PartialEq)] + enum Is { + Old, + New, + Black, + White, + /// Something between (a blend). + Mix, + } + + fn classify(p: [u8; 4]) -> Is { + let d = |c: [u8; 4]| (p[0] as i32 - c[0] as i32).abs() + (p[1] as i32 - c[1] as i32).abs() + (p[2] as i32 - c[2] as i32).abs(); + let near = 40; + if d(OLD) < near { + Is::Old + } else if d(NEW) < near { + Is::New + } else if d([0, 0, 0, 255]) < near { + Is::Black + } else if d([255, 255, 255, 255]) < near { + Is::White + } else { + // A face shaded in 3-D: a darker copy of one of the slides. + let shaded = |c: [u8; 4]| { + let dot = |a: [u8; 4], b: [u8; 4]| (0..3).map(|i| a[i] as f64 * b[i] as f64).sum::(); + let k = dot(p, c) / dot(c, c); + (0.3..=1.05).contains(&k) && (0..3).all(|i| (p[i] as f64 - k * c[i] as f64).abs() < 14.0) + }; + if shaded(OLD) { + Is::Old + } else if shaded(NEW) { + Is::New + } else { + Is::Mix + } + } + } + + fn frame(kind: &str, option: &str, t: f64) -> Image { + let old = solid(W, H, OLD); + let new = solid(W, H, NEW); + composite_transition(Some(&old), &new, None, &transition_frame(kind, option, t, 16.0 / 9.0)) + } + + fn at(img: &Image, x: f64, y: f64) -> Is { + let px = ((x * W as f64) as u32).min(W - 1); + let py = ((y * H as f64) as u32).min(H - 1); + classify(img.pixel(px, py)) + } + + /// (kind, option, t, x, y, what PowerPoint shows there), read off PowerPoint's own frames. + const PROBES: &[(&str, &str, f64, f64, f64, Is)] = &[ + ("fade", "smoothly", 0.1, 0.5, 0.5, Is::Old), + ("fade", "smoothly", 0.5, 0.5, 0.5, Is::Mix), + ("fade", "smoothly", 0.9, 0.5, 0.5, Is::New), + ("fade", "throughBlack", 0.5, 0.5, 0.5, Is::Black), + ("cut", "throughBlack", 0.3, 0.5, 0.5, Is::Black), + ("cut", "", 0.3, 0.5, 0.5, Is::New), + // Push / Cover / Uncover: "l" moves things toward the left; the new slide comes from the right. + ("push", "l", 0.25, 0.97, 0.5, Is::New), + ("push", "l", 0.25, 0.1, 0.5, Is::Old), + ("push", "r", 0.25, 0.03, 0.5, Is::New), + ("push", "u", 0.25, 0.5, 0.97, Is::New), + ("push", "d", 0.25, 0.5, 0.03, Is::New), + ("cover", "l", 0.5, 0.75, 0.5, Is::New), + ("cover", "l", 0.5, 0.25, 0.5, Is::Old), + ("cover", "rd", 0.5, 0.05, 0.05, Is::New), + ("cover", "rd", 0.2, 0.97, 0.97, Is::Old), + ("uncover", "l", 0.5, 0.25, 0.5, Is::Old), + ("uncover", "l", 0.5, 0.75, 0.5, Is::New), + ("uncover", "lu", 0.5, 0.5, 0.5, Is::New), + // Wipe "r" sweeps rightward with a soft edge; "u" upward; diagonals from a corner. + ("wipe", "r", 0.5, 0.05, 0.5, Is::New), + ("wipe", "r", 0.5, 0.97, 0.5, Is::Old), + ("wipe", "l", 0.5, 0.97, 0.5, Is::New), + ("wipe", "u", 0.5, 0.5, 0.97, Is::New), + ("wipe", "u", 0.5, 0.5, 0.03, Is::Old), + ("wipe", "d", 0.5, 0.5, 0.03, Is::New), + ("wipe", "ru", 0.5, 0.03, 0.97, Is::New), + ("wipe", "ru", 0.5, 0.97, 0.03, Is::Old), + ("wipe", "r", 0.5, 0.55, 0.5, Is::Mix), + // Split: Out opens from the middle, In closes from the edges. + ("split", "horzOut", 0.5, 0.5, 0.5, Is::New), + ("split", "horzOut", 0.5, 0.5, 0.02, Is::Old), + ("split", "horzIn", 0.5, 0.5, 0.02, Is::New), + ("split", "horzIn", 0.5, 0.5, 0.5, Is::Old), + ("split", "vertOut", 0.5, 0.5, 0.5, Is::New), + ("split", "vertOut", 0.5, 0.02, 0.5, Is::Old), + ("split", "vertIn", 0.5, 0.02, 0.5, Is::New), + // Shape: grows from the centre; In shrinks the old slide over the first half, Out grows + // the new one over the second. + ("shape", "circle", 0.5, 0.5, 0.5, Is::New), + ("shape", "circle", 0.5, 0.02, 0.03, Is::Old), + ("shape", "diamond", 0.5, 0.5, 0.5, Is::New), + ("shape", "diamond", 0.5, 0.02, 0.03, Is::Old), + ("shape", "plus", 0.5, 0.5, 0.05, Is::New), + ("shape", "plus", 0.5, 0.05, 0.05, Is::Old), + ("shape", "in", 0.25, 0.5, 0.5, Is::Old), + ("shape", "in", 0.25, 0.05, 0.05, Is::New), + ("shape", "in", 0.75, 0.5, 0.5, Is::New), + ("shape", "out", 0.25, 0.5, 0.5, Is::Old), + ("shape", "out", 0.75, 0.5, 0.5, Is::New), + ("shape", "out", 0.75, 0.05, 0.05, Is::Old), + // Flash: white in the middle. + ("flash", "", 0.3, 0.5, 0.5, Is::White), + // Clock: a hand sweeping from 12 o'clock. + ("clock", "clockwise", 0.25, 0.6, 0.15, Is::New), + ("clock", "clockwise", 0.25, 0.4, 0.15, Is::Old), + ("clock", "counterClockwise", 0.25, 0.4, 0.15, Is::New), + ("clock", "counterClockwise", 0.25, 0.6, 0.15, Is::Old), + ("clock", "wedge", 0.5, 0.5, 0.05, Is::New), + ("clock", "wedge", 0.5, 0.5, 0.95, Is::Old), + // Ripple: from the centre, or from the corner opposite the option. + ("ripple", "center", 0.5, 0.5, 0.5, Is::New), + ("ripple", "center", 0.5, 0.02, 0.03, Is::Old), + ("ripple", "ru", 0.4, 0.03, 0.97, Is::New), + ("ripple", "ru", 0.4, 0.97, 0.03, Is::Old), + // Comb: the old slide's bands slide off, the bottom band first. + ("comb", "horz", 0.5, 0.5, 0.97, Is::New), + ("comb", "horz", 0.5, 0.5, 0.03, Is::Old), + // Glitter "l": the sweep starts on the left. + ("glitter", "l", 0.45, 0.03, 0.5, Is::New), + ("glitter", "l", 0.45, 0.97, 0.5, Is::Old), + ("glitter", "r", 0.45, 0.97, 0.5, Is::New), + // Vortex "l": the old slide whirls off toward the left, its right side first. + ("vortex", "l", 0.25, 0.03, 0.5, Is::Old), + ("vortex", "l", 0.97, 0.03, 0.5, Is::New), + // Cube / Box "l": the new face comes in from the right. + ("cube", "l", 0.5, 0.85, 0.5, Is::New), + ("cube", "l", 0.5, 0.15, 0.5, Is::Old), + ("cube", "r", 0.5, 0.15, 0.5, Is::New), + ("box", "l", 0.25, 0.97, 0.5, Is::New), + ("box", "l", 0.25, 0.3, 0.5, Is::Old), + ("cube", "u", 0.5, 0.5, 0.9, Is::New), + ("cube", "d", 0.5, 0.5, 0.1, Is::New), + // Switch "l": the old card is pulled out to the left, the new one to the right. + ("switch", "l", 0.25, 0.97, 0.5, Is::New), + ("switch", "l", 0.25, 0.3, 0.5, Is::Old), + ("switch", "r", 0.25, 0.03, 0.5, Is::New), + // Gallery "l": the slides travel left along the wall. + ("gallery", "l", 0.6, 0.9, 0.5, Is::New), + // Flip: the old face, then the new one. + ("flip", "l", 0.2, 0.5, 0.5, Is::Old), + ("flip", "l", 0.8, 0.5, 0.5, Is::New), + // Doors open in the middle, the new slide coming forward between them. + ("doors", "vert", 0.9, 0.5, 0.5, Is::New), + // Fall Over: the old slide tips backward, uncovering the new one at the top first. + ("fallOver", "l", 0.6, 0.5, 0.03, Is::New), + ("fallOver", "l", 0.6, 0.5, 0.97, Is::Old), + // Peel Off "l": from the bottom-right corner. + ("peelOff", "l", 0.4, 0.98, 0.98, Is::New), + ("peelOff", "l", 0.4, 0.03, 0.03, Is::Old), + ("peelOff", "r", 0.4, 0.02, 0.98, Is::New), + // Page Curl: the far half rolls up first. + ("pageCurlDouble", "l", 0.5, 0.9, 0.5, Is::New), + ("pageCurlDouble", "l", 0.5, 0.1, 0.5, Is::Old), + ("pageCurlDouble", "r", 0.5, 0.1, 0.5, Is::New), + ("pageCurlDouble", "singleL", 0.6, 0.95, 0.5, Is::New), + ("pageCurlDouble", "singleL", 0.6, 0.05, 0.5, Is::Old), + // Airplane "l": a dart with its nose to the right, hanging low. + ("airplane", "l", 0.5, 0.15, 0.1, Is::New), + ("airplane", "l", 0.5, 0.8, 0.68, Is::Old), + ("airplane", "r", 0.5, 0.2, 0.68, Is::Old), + // Origami "l": the bird flies off to the left. + ("origami", "l", 0.75, 0.97, 0.5, Is::New), + // Fracture: shards fall, the top first. + ("fracture", "", 0.6, 0.5, 0.05, Is::New), + // Crush: the old slide crumples toward the centre. + ("crush", "", 0.5, 0.03, 0.03, Is::New), + ("crush", "", 0.5, 0.45, 0.5, Is::Old), + // Curtains part in the middle. + ("curtains", "", 0.4, 0.5, 0.5, Is::New), + ("curtains", "", 0.4, 0.02, 0.5, Is::Old), + // Zoom and Rotate: the new slide spins in from the centre over the old one. + ("zoom", "rotate", 0.2, 0.03, 0.03, Is::Old), + ("zoom", "out", 0.8, 0.5, 0.5, Is::New), + ("dissolve", "", 0.02, 0.5, 0.5, Is::Old), + ("dissolve", "", 0.99, 0.5, 0.5, Is::New), + ]; + + #[test] + fn frames_match_powerpoint_probes() { + let mut bad = Vec::new(); + for (kind, option, t, x, y, want) in PROBES { + let got = at(&frame(kind, option, *t), *x, *y); + if got != *want { + bad.push(format!("{kind}/{option} t={t} ({x},{y}): want {want:?}, got {got:?}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); + } + + #[test] + fn every_transition_starts_old_and_ends_new() { + for (kind, _, _, _, opts) in deckcraft_model::anim::TRANSITIONS { + let mut options: Vec<&str> = opts.to_vec(); + options.push(""); + for o in options { + assert_eq!(at(&frame(kind, o, 0.0), 0.5, 0.5), Is::Old, "{kind}/{o}"); + assert_eq!(at(&frame(kind, o, 1.0), 0.5, 0.5), Is::New, "{kind}/{o}"); + for t in [0.25, 0.5, 0.75] { + let img = frame(kind, o, t); + assert_eq!((img.width, img.height), (W, H)); + } + } + } + } + + #[test] + fn dynamic_content_moves_contents_over_backgrounds() { + // Old: red background with a white content square in the middle; new: blue background + // with a yellow square. + let content = |c: [u8; 4]| { + let mut img = solid(W, H, [0, 0, 0, 0]); + for y in 35..55u32 { + for x in 70..90u32 { + let i = ((y * W + x) * 4) as usize; + if let Some(p) = img.pixels.get_mut(i..i + 4) { + p.copy_from_slice(&c); + } + } + } + img + }; + let parts = SlideParts { + old_back: Some(solid(W, H, OLD)), + old_content: Some(content([255, 255, 255, 255])), + new_back: solid(W, H, NEW), + new_content: content([255, 220, 0, 255]), + }; + let old = solid(W, H, OLD); + let new = solid(W, H, NEW); + let f = |kind: &str, o: &str, t: f64| composite_transition(Some(&old), &new, Some(&parts), &transition_frame(kind, o, t, 16.0 / 9.0)); + // Pan left at the middle: the backgrounds blend, the old square has moved left. + let img = f("pan", "l", 0.5); + let bg = img.pixel(5, 5); + assert!(bg[0] > 60 && bg[2] > 60, "{bg:?}"); + assert_eq!(img.pixel(80, 45), bg, "the middle shows background only"); + // Pan right: the new square arrives from the left. + let img = f("pan", "r", 0.6); + assert!((0..80).any(|x| { + let p = img.pixel(x, 45); + p[0] > 200 && p[1] > 180 && p[2] < 80 + })); + // Reveal: the new content is in place near the end. + let p = f("reveal", "smoothlyLeft", 0.97).pixel(80, 45); + assert!(p[0] > 200 && p[1] > 180, "{p:?}"); + // Without parts the dynamic transitions still draw whole slides. + let img = composite_transition(Some(&old), &new, None, &transition_frame("pan", "l", 0.5, 16.0 / 9.0)); + assert_ne!(img.pixel(80, 45), [0, 0, 0, 255]); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 8e847928..428eff0b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1382,7 +1382,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); egui::Popup::menu(&r).show(|ui| { for o in opts { - if ui.button(option_label(o)).clicked() { + if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { run(app, "transition.options", json!({"option": o})); } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1c8e6e1e..17ec527e 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -61,6 +61,10 @@ struct Transition { /// The slide left behind (Morph matches its shapes) and the Morph frame texture. from: Option, morph: Option, + /// Backgrounds and contents of the old and new slides, rendered once when the transition + /// moves contents over backgrounds (Pan, Ferris Wheel, Reveal…): old back, old content, new + /// back, new content. + parts: Option<[Option; 4]>, start: f64, kind: String, option: String, @@ -138,7 +142,7 @@ impl Show { self.last_viewed = from; } self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { - Some(Transition { old, from, morph: None, start: now, kind, option, dur }) + Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur }) } else { None }; @@ -202,24 +206,43 @@ fn fit(avail: Rect, doc: &Presentation) -> Rect { Rect::from_center_size(avail.center(), vec2(sw * s, sh * s)) } +/// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective +/// cards subdivided so their pictures stay straight) with per-vertex opacity. fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { let mut m = match tex { Some(id) => Mesh::with_texture(id), None => Mesh::default(), }; - let a = (l.alpha.clamp(0.0, 1.0) * 255.0) as u8; - let col = if tex.is_some() { Color32::from_white_alpha(a) } else { color.gamma_multiply(l.alpha.clamp(0.0, 1.0) as f32) }; - for k in 0..4 { - let q = l.quad.get(k).copied().unwrap_or([0.0, 0.0]); - let uv = l.uv.get(k).copied().unwrap_or([0.0, 0.0]); - let p = pos2(rect.min.x + q[0] as f32 * rect.width(), rect.min.y + q[1] as f32 * rect.height()); - m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(uv[0] as f32, uv[1] as f32), color: col }); + let (verts, tris) = deckcraft_anim::layer_mesh(l); + for v in &verts { + let a = if v.a.is_finite() { v.a.clamp(0.0, 1.0) } else { 0.0 }; + let col = if tex.is_some() { Color32::from_white_alpha((a * 255.0).round() as u8) } else { color.gamma_multiply(a as f32) }; + let p = pos2(rect.min.x + v.pos[0] as f32 * rect.width(), rect.min.y + v.pos[1] as f32 * rect.height()); + m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(v.uv[0] as f32, v.uv[1] as f32), color: col }); + } + for t in tris { + m.add_triangle(t[0], t[1], t[2]); } - m.add_triangle(0, 1, 2); - m.add_triangle(0, 2, 3); m } +/// Render the backgrounds and contents of a transition's two slides into textures, once. +fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, to: usize, size: (u32, u32)) -> [Option; 4] { + use deckcraft_render::SlidePart; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + let tex = |i: usize, part: SlidePart, name: &str| { + let img = deckcraft_render::render_slide_part(doc, i, part, &opts); + ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR) + }; + [ + from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), + from.map(|i| tex(i, SlidePart::Content, "trans-old-content")), + Some(tex(to, SlidePart::Backdrop, "trans-new-back")), + Some(tex(to, SlidePart::Content, "trans-new-content")), + ] +} + pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { @@ -470,18 +493,37 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { tr.morph = Some(frame); drew = true; ctx.request_repaint(); - } else if let (Some(tr), Some(new)) = (&show.trans, &tex) { + } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); if p < 1.0 { - for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) { + // Slides split into background and content are rendered once per transition; + // every frame only composites cached textures. + if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { + tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size)); + } + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); + let parts = [part(0), part(1), part(2), part(3)]; + for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { + let old_or_black = |t: Option<&TextureHandle>| match t { + Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), + None => quad_mesh(None, srect, &l, Color32::BLACK), + }; let mesh = match l.source { - Source::Old => match &tr.old { - Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), - None => quad_mesh(None, srect, &l, Color32::BLACK), - }, + Source::Old => old_or_black(tr.old.as_ref()), Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), Source::White => quad_mesh(None, srect, &l, Color32::WHITE), + Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), + Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), + Source::OldContent => match &parts[1] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + Source::NewContent => match &parts[3] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, }; painter.add(egui::Shape::mesh(mesh)); } diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766b..8ec5d0f4 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; effect lists counted from `crates/model/src/anim.rs` against PowerPoint 16.113's galleries) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -9,14 +9,31 @@ notice first when it's missing. Scores roll up into [target-app-parity.md](targe ## Transitions: 48 of 48 `crates/model/src/anim.rs` `TRANSITIONS` lists every transition in PowerPoint's gallery, in its -three groups, with its Effect Options. All 48 round-trip through PPTX -(`every_transition_kind_round_trips`), including the PowerPoint 2010+ `p14`/`p15` ones. +three groups, with its Effect Options (now also Page Curl Single, Glitter Hexagons, Shred In/Out +and Zoom and Rotate). All 48 round-trip through PPTX with the elements, attributes and defaults +PowerPoint itself writes (`every_transition_kind_round_trips`, +`transition_xml_matches_powerpoints_elements`, `transition_defaults_read_like_powerpoint`): 145 of +145 PowerPoint-built test slides reopen in PowerPoint with the same effect after a DeckCraft +save. + +**Measured against PowerPoint (2026-10-13).** Synthetic two-colour decks with asymmetric markers +were built in PowerPoint for Windows over COM with every transition and option (150 variants, +2 s each), exported with `CreateVideo` at 30 fps, and compared frame by frame with +`deckcraft-cli render-transition` at the same progress (`plan/powerpoint/tools/synth_transitions.ps1`, +`trans_compare.py`). Mean absolute difference over all variants at t = 0.1…0.9 (0–255): **35.7 +before, 15.0 after**. Curves (fades are a smoothstep, moves ease in-out; Cover, Uncover and Pan +have their own measured curves), directions and option meanings (e.g. Glitter starts on the +option's side, Ripple's corner is where the wave heads, Wind/Airplane/Origami "Left" is `invX`), +soft edges (Wipe, Split, Shape, Clock, Ripple) and the Dynamic Content transitions moving the +slides' contents over cross-fading backgrounds now follow PowerPoint. 3-D transitions are +perspective-projected cards (`crates/anim/src/transition_geom.rs`) drawn with +perspective-correct subdivided meshes. | Group | PowerPoint | DeckCraft | Fidelity | |---|---|---|---| -| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | 2-D, close to PowerPoint | -| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | 3-D ones are fake 3-D (trapezoid quads, `crates/anim/src/transition.rs`); particle effects (Vortex, Shred, Fracture, Crush) approximate | -| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Same approach | +| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | Matches PowerPoint's frames (mean diff 1–8; Random Bars and Reveal Through Black ~15) | +| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | Perspective 3-D cards with measured motion; cloth, paper and particle effects (Curtains, Peel Off, Page Curl, Airplane, Origami, Fracture, Crush, Honeycomb, Vortex, Shred) are flat approximations of PowerPoint's meshes | +| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Contents move over cross-fading backgrounds, as in PowerPoint (mean diff 5–17) | | Transition feature | PowerPoint | DeckCraft | |---|---|---| @@ -28,7 +45,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | -| Frame-by-frame comparison with PowerPoint | | **not done** | +| Frame-by-frame comparison with PowerPoint | | done (Windows, 2026-10-13); headless frames: `deckcraft-cli render-transition` | ## Animations: 64 of ~190 effects @@ -69,11 +86,12 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Rewind and other effect options on round trip (#77) | 1–2 | | Transition sounds | 1–2 | | Morph across pictures, `!!` names, align once per transition (#67) | 2–4 | -| Reference captures from PowerPoint for the 3-D transitions, then tuning | 2–4 | -| **Total** | **~14–26** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | +| Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 | +| **Total** | **~16–30** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | diff --git a/docs/architecture.md b/docs/architecture.md index 58b8c8be..2849f870 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main at d556ff9; AGENTS.md §4 keeps the rules) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -49,8 +49,12 @@ Programmatic calls never open dialogs. Pointer gestures go through `Session::poi ## Rendering and the show The renderer draws a slide to an RGBA pixmap on the CPU; the UI uploads it as an egui texture. -Transitions are layers of textured quads over the old and new slide images (fake 3-D from -trapezoids). The show window and the presenter view are egui viewports; animation state per shape +Transitions are layers of textured quads over the old and new slide images, or over their +backgrounds and contents rendered apart (Dynamic Content), from `anim::transition_frame`: 3-D +effects project cards through a perspective camera and `anim::layer_mesh` subdivides them so +textures stay straight; soft edges are per-corner opacities. The show renders the slides once per +transition and only composites cached textures each frame; `render::composite_transition` is the +CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and overlays an invisible real-text layer with embedded font subsets. @@ -74,4 +78,5 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | From cfd4f2554597071ee1607b0b9480df0f591ecd58 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:45:40 -0400 Subject: [PATCH 02/59] Text: bidi resolves spaces, punctuation and digits by the run's language, as PowerPoint does PowerPoint does not resolve neutral characters with plain UAX #9: the language of each run (a:rPr/@lang) decides. Measured with every space highlighted in its own colour: - in a run of a left-to-right language (or none) punctuation and digits are left to right and a space takes the direction of the character before it, so "abc HE1 HE2 def" shows the space after the Hebrew words on their left, and an English sentence in an RTL paragraph keeps its full stop at its end; - in a Hebrew/Arabic run, neutrals between text of one direction take it, all others are right to left, and digits are a number in the right-to-left text unless left-to-right text precedes. Paragraphs with explicit embeddings keep the full algorithm. Bidi probe deck: mean diff 11.1 -> 10.0 on the four mixed-direction slides; corpus poi 6.660 -> 6.654, others unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/bidi.rs | 179 ++++++++++++++++++++++++++++++++++++++- crates/text/src/lib.rs | 4 +- crates/text/src/tests.rs | 20 ++++- 3 files changed, 200 insertions(+), 3 deletions(-) diff --git a/crates/text/src/bidi.rs b/crates/text/src/bidi.rs index be4485c8..3be4db3e 100644 --- a/crates/text/src/bidi.rs +++ b/crates/text/src/bidi.rs @@ -6,7 +6,7 @@ //! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws //! mirrorable characters in right-to-left items with their mirrored glyphs. -use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS}; +use unicode_bidi::BidiClass::{self, AL, AN, B, BN, CS, EN, ES, ET, FSI, L, LRE, LRI, LRO, NSM, ON, PDF, PDI, R, RLE, RLI, RLO, S, WS}; use unicode_bidi::{BidiInfo, Level, bidi_class}; /// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or @@ -27,6 +27,149 @@ pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec { text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect() } +/// Is `lang` (BCP 47) a language written right to left? Its runs resolve neutral characters and +/// digits the right-to-left way (see [`levels_lang`]). +pub(crate) fn is_rtl_lang(lang: &str) -> bool { + let primary = lang.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); + matches!(primary.as_str(), "ar" | "he" | "iw" | "fa" | "ur" | "yi" | "ji" | "ps" | "sd" | "ug" | "dv" | "syr" | "ckb" | "arc") +} + +/// How a character resolves before levels are assigned. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Dir { + L, + R, + /// A number inside right-to-left text: kept left to right, placed with the right-to-left text. + Num, + /// Not resolved yet (neutral). + Open, +} + +/// Embedding levels as PowerPoint resolves them: the language of each character's run +/// (`rtl_lang`, from `a:rPr/@lang`) decides how neutral characters and digits go, where UAX #9 +/// would look at the surrounding text only. Observed on desktop PowerPoint: +/// - in a run of a left-to-right language (or none), digits and punctuation are left to right, +/// and a space takes the direction of the character before it (so the space after a Hebrew word +/// travels with that word); +/// - in a run of a right-to-left language (Hebrew, Arabic…), punctuation and spaces between text +/// of one direction take it, everywhere else they are right to left; digits are a number in +/// right-to-left text unless left-to-right text precedes them. +/// +/// Paragraphs with explicit embeddings or isolates keep the plain algorithm ([`levels`]). +pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[bool]) -> Vec { + let base = u8::from(rtl); + let any_rtl_lang = rtl_lang.iter().any(|r| *r); + if !rtl && !any_rtl_lang && !needs_bidi(chars) { + return vec![0; chars.len()]; + } + let classes: Vec = chars.iter().map(|c| bidi_class(*c)).collect(); + if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) { + return levels(text, chars, rtl); + } + let n = chars.len(); + let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false); + let sos = if rtl { Dir::R } else { Dir::L }; + // W1: combining marks take the class of the character they sit on. + let mut cls = classes; + for i in 0..n { + if cls.get(i) == Some(&NSM) { + let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; + if let Some(c) = cls.get_mut(i) { + *c = prev; + } + } + } + let mut dir = vec![Dir::Open; n]; + // Strong letters and digits. + let mut last_strong: Option = None; + for i in 0..n { + let c = cls.get(i).copied().unwrap_or(ON); + let d = match c { + L => Dir::L, + R | AL => Dir::R, + AN => Dir::Num, + EN if !rl(i) => Dir::L, + // W7: European digits after left-to-right text are left to right. + EN if last_strong == Some(L) => Dir::L, + EN => Dir::Num, + _ => Dir::Open, + }; + if matches!(c, L | R | AL) { + last_strong = Some(c); + } + if let Some(x) = dir.get_mut(i) { + *x = d; + } + } + // W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next + // to them, belong to the number. + for i in 0..n { + if !rl(i) || dir.get(i) != Some(&Dir::Open) { + continue; + } + let c = cls.get(i).copied().unwrap_or(ON); + let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); + let joined = match c { + ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), + ET => num(i.checked_sub(1)) || (i + 1..n).find(|&j| cls.get(j) != Some(&ET)).is_some_and(|j| num(Some(j))), + _ => false, + }; + if joined && let Some(x) = dir.get_mut(i) { + *x = Dir::Num; + } + } + // Punctuation in left-to-right runs is left to right. + for i in 0..n { + if !rl(i) + && dir.get(i) == Some(&Dir::Open) + && !matches!(cls.get(i), Some(WS | S)) + && let Some(x) = dir.get_mut(i) + { + *x = Dir::L; + } + } + // Neutrals in right-to-left runs: the direction around them when both sides agree, else + // right to left. Unresolved spaces of left-to-right runs are looked through. + let strong_at = |dir: &[Dir], j: usize| match dir.get(j) { + Some(Dir::L) => Some(Dir::L), + Some(Dir::R | Dir::Num) => Some(Dir::R), + _ => None, + }; + let mut i = 0; + while i < n { + if !(rl(i) && dir.get(i) == Some(&Dir::Open)) { + i += 1; + continue; + } + let start = i; + while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) { + i += 1; + } + let before = (0..start).rev().find_map(|j| strong_at(&dir, j)).unwrap_or(sos); + let after = (i..n).find_map(|j| strong_at(&dir, j)).unwrap_or(sos); + let d = if before == after { before } else { Dir::R }; + for x in dir.get_mut(start..i).into_iter().flatten() { + *x = d; + } + } + // Spaces in left-to-right runs follow the character before them. + let mut prev = Dir::L; + for d in dir.iter_mut() { + if *d == Dir::Open { + *d = prev; + } + prev = if *d == Dir::Num { Dir::R } else { *d }; + } + dir.iter() + .map(|d| match (d, base % 2 == 1) { + (Dir::L, false) | (Dir::R, true) => base, + (Dir::L, true) | (Dir::R, false) | (Dir::Open, _) => base + 1, + (Dir::Num, false) => base + 2, + (Dir::Num, true) => base + 1, + }) + .collect() +} + /// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a /// segment/paragraph separator. fn l1_whitespace(c: BidiClass) -> bool { @@ -131,6 +274,40 @@ mod tests { assert_eq!(lv("ab", true), vec![2, 2]); } + /// Visual string of `s` laid out on one line with every character in a run of language + /// direction `rtl_lang`, as PowerPoint orders it. + fn vis_lang(s: &str, rtl: bool, rtl_lang: bool) -> String { + let chars: Vec = s.chars().collect(); + let l = levels_lang(s, &chars, rtl, &vec![rtl_lang; chars.len()]); + visual_order(&line_levels(&chars, &l, u8::from(rtl))).iter().map(|&i| chars[i]).collect() + } + + #[test] + fn run_language_decides_neutrals_like_powerpoint() { + // Observed in PowerPoint with each space highlighted. An English run: the space after a + // Hebrew word goes with it, punctuation is left to right. + assert_eq!(vis_lang("x אב גד y", false, false), "x דג באy"); + assert_eq!(vis_lang("אב גד x y", false, false), " דג באx y"); + assert_eq!(vis_lang("x אב, גד! y", false, false), "x בא, דג! y"); + // Digits after Hebrew stay left to right in an English run. + assert_eq!(vis_lang("אב 12", false, false), " בא12"); + // A Hebrew run: neutrals between different directions are right to left, and digits are + // a number inside the right-to-left text. + assert_eq!(vis_lang("אב גד x y", false, true), " דג באx y"); + assert_eq!(vis_lang("12 אב 34 x", false, true), " 34 בא 12x"); + assert_eq!(vis_lang("x אב, גד! y", false, true), "x !דג ,בא y"); + // Between two left-to-right words a Hebrew run's space stays left to right. + assert_eq!(vis_lang("Version 2.0 ok", false, true), "Version 2.0 ok"); + // An English sentence in a right-to-left paragraph keeps its full stop at its end. + assert_eq!(vis_lang("Hello world.", true, false), "Hello world."); + // Right-to-left runs keep W4: "45.6%" is one number. + assert_eq!(vis_lang("אב 45.6% גד", false, true), "דג 45.6% בא"); + // Without right-to-left text or runs, the fast path. + let chars: Vec = "plain, text".chars().collect(); + assert_eq!(levels_lang("plain, text", &chars, false, &[false; 11]), vec![0; 11]); + assert!(is_rtl_lang("he-IL") && is_rtl_lang("ar") && is_rtl_lang("FA-ir") && !is_rtl_lang("en-US") && !is_rtl_lang("")); + } + #[test] fn l1_resets_trailing_whitespace_and_tabs() { let chars: Vec = "ab\tcd ".chars().collect(); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d2ee9768..d797fb21 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -560,7 +560,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa let mut byte_of: Vec = text.char_indices().map(|(b, _)| b).collect(); byte_of.push(text.len()); let char_at_byte: HashMap = byte_of.iter().enumerate().map(|(i, b)| (*b, i)).collect(); - let levels = bidi::levels(text, &chars, rtl); + let style_rtl: Vec = styles.iter().map(|st| st.lang.as_deref().is_some_and(bidi::is_rtl_lang)).collect(); + let rtl_lang: Vec = char_style.iter().map(|&si| style_rtl.get(si).copied().unwrap_or(false)).collect(); + let levels = bidi::levels_lang(text, &chars, rtl, &rtl_lang); let scripts = script::resolve(&chars); let mut boundary = vec![false; n]; let mut graphemes: Vec<(usize, usize)> = Vec::new(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 70a7f132..1bc89cd2 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -264,7 +264,11 @@ fn arabic_face() -> Option> { #[test] fn rtl_mixed_string_is_reordered_per_line() { - let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right); + // An Arabic run (PowerPoint tags typed Arabic `ar-…`): its digits and brackets go with the + // right-to-left text. + let mut run = Run::new(MIXED); + run.props.lang = Some("ar-SA".into()); + let (p, sh) = bidi_setup(vec![run], 2000.0, true, Align::Right); let l = lay(&p, &sh, 2000.0, 300.0); assert_eq!(l.lines.len(), 1); let line = &l.lines[0]; @@ -293,6 +297,20 @@ fn rtl_mixed_string_is_reordered_per_line() { assert_eq!(line.levels[10], 2); } +#[test] +fn an_english_run_keeps_digits_and_brackets_left_to_right() { + // The same text in a run without a right-to-left language: PowerPoint keeps "2024 (Retrait) " + // one left-to-right stretch, its number on the left. + let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right); + let l = lay(&p, &sh, 2000.0, 300.0); + let line = &l.lines[0]; + let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0; + assert!(mid(0) > mid(10), "first Arabic word right of the Latin"); + assert!(mid(4) < mid(10), "number left of the Latin word"); + assert!(mid(9) < mid(10) && mid(17) > mid(16), "brackets around Retrait, unmirrored"); + assert!(mid(19) < mid(4), "last Arabic word on the left"); +} + #[test] fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() { let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right); From f3f959cabf7c386e5ba314aef68a4d4a61cf6bb2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:51:08 -0400 Subject: [PATCH 03/59] Text: character spacing of 0.6 pt or more (or -1 pt) switches kerning off for the whole box PowerPoint kerns pairs (a:rPr/@kern) only while no run of the text body is spaced out by 0.6 pt or more or condensed by 1 pt or more; then no run of that box is kerned, even runs without spacing. Measured on Arial 40 pt "AVATAR": spacing 0, 0.01, 0.5, -0.6 and -0.99 pt keep the pairs (146 px wide), 0.6, 0.8, 1, 1.5 and -1 pt drop them in every paragraph of the box (158 px plus the spacing). DeckCraft kerned spaced titles, making "AVATAR"-like words 12 px too narrow at 40 pt. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 31 ++++++++++++++++++++++++------- crates/text/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d797fb21..007f63e2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -434,7 +434,8 @@ pub fn autonum(scheme: &str, n: u32) -> String { } } -fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option) -> ParaShaped { +#[allow(clippy::too_many_arguments)] +fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option, no_kern: bool) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { let face = db.face("Inter", "Regular"); @@ -459,7 +460,8 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, let mut char_style: Vec = vec![]; for run in ¶.runs { let rp = resolve::run(ctx, shape, para, &run.props); - let st = make_style(ctx, &rp, scale, opts.prompt_color); + let mut st = make_style(ctx, &rp, scale, opts.prompt_color); + st.kern &= !no_kern; let t: String = match &run.kind { RunKind::Text => run.text.clone(), RunKind::Break => "\u{b}".into(), @@ -1073,7 +1075,8 @@ fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 { pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout { let bp = resolve::body(ctx, shape); let autofit = bp.autofit.unwrap_or(AutoFit::None); - let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0); + let no_kern = spacing_stops_kerning(ctx, shape, body); + let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0, no_kern); if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) && l.overflow { @@ -1083,7 +1086,7 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay for _ in 0..12 { let mid = (lo + hi) / 2.0; let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2); - let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red); + let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red, no_kern); if t.overflow { hi = mid; } else { @@ -1091,12 +1094,26 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay best = Some(t); } } - l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2)); + l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2, no_kern)); } l } -fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { +/// Does character spacing switch pair kerning off in this body? PowerPoint stops kerning every run +/// of a text body once one of its runs is spaced out by 0.6 pt or more, or condensed by 1 pt or +/// more (measured on Arial at 40 pt: 0.5 pt and -0.99 pt keep the pairs kerned, 0.6 pt and -1 pt +/// drop them for the whole box). +fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { + body.paragraphs.iter().any(|para| { + para.runs.iter().any(|run| { + let s = resolve::run(ctx, shape, para, &run.props).spacing.unwrap_or(0.0); + s >= 0.55 || s <= -0.995 + }) + }) +} + +#[allow(clippy::too_many_arguments)] +fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); let rotation = match vert { @@ -1149,7 +1166,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } None }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number)); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern)); } for (pi, p) in shaped_paras.iter().enumerate() { let props = &p.props; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1bc89cd2..240dd47a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -101,6 +101,46 @@ fn pairs_are_kerned_only_from_the_kern_size() { assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold"); } +/// Width of the first paragraph "AVATAR WAVE" (kerned from 12 pt) when a second paragraph's run +/// has character spacing `spc`. +fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 { + let (p, mut sh) = setup("AVATAR WAVE", 2000.0, 300.0); + let t = sh.text.as_mut().unwrap(); + let mut second = t.paragraphs[0].clone(); + second.runs[0].props.spacing = Some(spc); + t.paragraphs.push(second); + for para in &mut t.paragraphs { + para.props.bullet = Some(Bullet::None); + for r in &mut para.runs { + r.props.font = Some(family.into()); + r.props.size = Some(40.0); + r.props.kern = Some(12.0); + } + } + let l = lay(&p, &sh, 2000.0, 300.0); + l.lines[0].extent.1 - l.lines[0].extent.0 +} + +#[test] +fn character_spacing_in_the_body_stops_kerning() { + let kerns = |f: &FontFace| { + let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::(); + w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::>()) + }; + let Some(family) = first_family(LATIN_FAMILIES, kerns) else { + eprintln!("skipped: no font that kerns AVATAR"); + return; + }; + let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0)); + let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0)); + // Small spacing elsewhere keeps the pairs; 0.6 pt or -1 pt anywhere in the box drops them. + for (spc, kerned) in [(0.0, true), (0.5, true), (-0.99, true), (0.6, false), (1.0, false), (-1.0, false), (3.0, false)] { + let w = kerned_width_beside_spacing(&family, spc); + let want = if kerned { on } else { off }; + assert!((w - want).abs() < 0.01, "{family} spc {spc}: {w} vs {want}"); + } +} + #[test] fn ligatures_stay_for_scripts_that_need_them() { let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag)); From 9e2f7b96dc0d066341b09b68b690d45c49ac2919 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:52:36 -0400 Subject: [PATCH 04/59] =?UTF-8?q?Text:=20small=20capitals=20draw=20at=20th?= =?UTF-8?q?eir=20size,=20and=20all=20caps=20keep=20characters=20like=20"?= =?UTF-8?q?=C3=9F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small capitals were drawn at the full size of the run's first character: glyph runs grouped cells by style and face only, so "HxHx" drew every letter at the capital's size on the small capitals' narrower advances (letters overlapped). A lowercase letter in small caps now starts a run of its own at 80 % (PowerPoint's small capital "X" is 0.81 of the capital "H" in Arial). All caps and small caps mapped "ß" to "S" (the first character of "SS"); PowerPoint leaves a character whose capital is a sequence as it is ("ß", "fi", "ʼn"), and maps the rest one to one. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 18 +++++++++++++++--- crates/text/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 007f63e2..48a466f8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -647,6 +647,17 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa cells } +/// A character in capitals (`cap="all"`/`"small"`): its uppercase when that is one character. +/// PowerPoint leaves characters whose capital is a sequence as they are: "ß" stays "ß" (not "SS"), +/// "fi" and "ʼn" stay. +fn caps_char(ch: char) -> char { + let mut up = ch.to_uppercase(); + match (up.next(), up.next()) { + (Some(u), None) => u, + _ => ch, + } +} + /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; @@ -690,7 +701,7 @@ fn shape_item( let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return }; let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script); let params = ShapeParams { features: &features, rtl, script, language: lang }; - let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch }); + let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { caps_char(ch) } else { ch }); let upem = face.upem.max(1.0); let scale_of = |cell: &Cell| { let st = styles.get(cell.style); @@ -1420,7 +1431,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } // Group visually consecutive cells by (style, face). let mut cur: Option = None; - let mut cur_key = (usize::MAX, usize::MAX); + let mut cur_key = (usize::MAX, usize::MAX, false); for &k in &place.order { let ci = line.start + k; let Some(cell) = p.cells.get(ci) else { continue }; @@ -1429,7 +1440,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let base_y = line.baseline - st.baseline * st.size; let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; - let key = (cell.style, cell.face); + // Small capitals are drawn smaller than the capitals beside them: a run of their own. + let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase()); if key != cur_key { if let Some(r) = cur.take() { out.runs.push(r); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 240dd47a..12d80dc4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -839,3 +839,35 @@ fn symbol_font_characters_draw_in_the_sym_font() { // The letters around it keep the run's font. assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}"); } + +#[test] +fn small_capitals_draw_smaller_than_the_capitals_beside_them() { + let (p, mut sh) = setup("HxHx", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.caps = Some(Caps::Small); + para.runs[0].props.size = Some(40.0); + let l = lay(&p, &sh, 2000.0, 300.0); + let sizes: Vec<(usize, f64)> = l.runs.iter().map(|r| (r.glyphs.len(), r.size)).collect(); + // H at 40 pt, x as a capital at 80 % (four runs: the size changes at every letter). + assert_eq!(sizes, vec![(1, 40.0), (1, 32.0), (1, 40.0), (1, 32.0)]); +} + +#[test] +fn capitals_keep_characters_whose_capital_is_a_sequence() { + assert_eq!(caps_char('a'), 'A'); + assert_eq!(caps_char('dž'), 'DŽ'); + // PowerPoint draws "ß", "fi" and "ʼn" unchanged in all caps. + assert_eq!(caps_char('ß'), 'ß'); + assert_eq!(caps_char('\u{FB01}'), '\u{FB01}'); + assert_eq!(caps_char('ʼn'), 'ʼn'); + let (p, mut sh) = setup("aß", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.runs[0].props.caps = Some(Caps::All); + let l = lay(&p, &sh, 2000.0, 300.0); + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + let face = &r.face; + if face.covers('ß') { + assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S"); + } +} From ef313b91b0e77f7c3ce247badbc8b5f42b886170 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:54:24 -0400 Subject: [PATCH 05/59] Text: transparent text fills are faded once, not twice A run's solid fill with a:alpha was resolved into the glyph colour's alpha and also kept as the run's fill opacity, and the renderer multiplied both: text at 60 % transparency drew at 16 % opacity instead of 40 %. The colour now carries the transparency alone. PowerPoint's export of "Transparent 60% text" (red) is a light red that now matches. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 5 +++-- crates/text/src/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 48a466f8..e4e189c2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -54,7 +54,7 @@ pub struct GlyphRun { pub para: usize, pub chars: (usize, usize), pub link: bool, - /// Alpha of the fill (0–1). + /// Opacity of the fill on top of `color`'s own alpha (0 for no fill). pub alpha: f64, pub gradient: Option, /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). @@ -269,7 +269,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl let fake_italic = italic && !face.italic; let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0); let (mut color, alpha, gradient) = match &r.fill { - Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None), + // The resolved colour carries the fill's transparency (`a:alpha`): it is applied once. + Some(Fill::Solid { color }) => (ctx.color(color, None), 1.0, None), Some(Fill::Gradient(g)) => { // Colours resolved here (theme, colour map), so the renderer needs no colour scheme. let mut g = g.clone(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 12d80dc4..58c6c7d5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -871,3 +871,17 @@ fn capitals_keep_characters_whose_capital_is_a_sequence() { assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S"); } } + +#[test] +fn transparent_text_is_faded_once() { + let (p, mut sh) = setup("Faded", 2000.0, 300.0); + let run = &mut sh.text.as_mut().unwrap().paragraphs[0].runs[0]; + run.props.fill = Some(deckcraft_model::Fill::solid( + deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)).with(deckcraft_color::ColorTransform::Alpha(40000)), + )); + let l = lay(&p, &sh, 2000.0, 300.0); + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + // 60 % transparency: 40 % opaque in all, not 40 % of 40 %. + let opacity = r.color.a as f64 / 255.0 * r.alpha; + assert!((opacity - 0.4).abs() < 0.01, "{opacity}"); +} From ed562d7305b7b6b3d95966e83634dd90488b3242 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:59:38 -0400 Subject: [PATCH 06/59] Text: centre, right and decimal tab stops align the text after them; a tab past the end wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab stops were all left stops: a:tab/@algn was read and saved but ignored. As PowerPoint draws them, the text after a tab (up to the next tab or line break) now ends at a right stop, is centred on a centre stop, and has its first "." on a decimal stop (text without one ends there); when that text is wider than the room before the stop it starts where the tab is, never further left. Measured on PowerPoint's export: "right" ends at 180 pt, "12.5" and "1234.567" put the point on the stop, "12,5" and "nodot" end at it. A tab whose stop lies past the end of the line now starts the next line, so the text after it sits on that line's first stop, as PowerPoint wraps "abcdefghijklm…". Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 95 ++++++++++++++++++++++++++++++++++++---- crates/text/src/tests.rs | 49 +++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e4e189c2..f587b73e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -990,7 +990,7 @@ fn break_lines( first_w: f64, rest_w: f64, wrap: bool, - tab_stops: &[f64], + tab_stops: &[Tab], default_tab: f64, x0_first: f64, x0_rest: f64, @@ -1019,7 +1019,11 @@ fn break_lines( next = i + 1; break; } - let adv = if c == '\t' { tab_advance(x0 + x, tab_stops, default_tab) } else { p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) }; + let adv = if c == '\t' { + tab_advance(x0 + x, tab_stops, default_tab, tab_segment(&p.cells, i)) + } else { + p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) + }; let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j)); if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) { last_break = Some(i); @@ -1031,6 +1035,15 @@ fn break_lines( next = i + 1; break; } + // A tab that would run past the end moves to the next line (and to its first stop there). + if wrap && c == '\t' && x + adv > avail + 0.01 && i > start { + end = i; + next = i; + while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') { + end -= 1; + } + break; + } if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start { // Break at the last opportunity, or force mid-word (between clusters). let b = last_break @@ -1072,10 +1085,72 @@ fn break_lines( lines } -fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 { +/// How a tab stop aligns the text after it (`a:tab/@algn`). +#[derive(Clone, Copy, Debug, PartialEq)] +enum TabAlign { + Left, + Center, + Right, + /// The first "." of the text sits at the stop (text without one ends there). + Decimal, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Tab { + pos: f64, + align: TabAlign, +} + +fn tabs_of(props: &ParaProps) -> Vec { + props + .tabs + .as_ref() + .map(|t| { + t.iter() + .map(|s| Tab { + pos: s.pos, + align: match s.align.as_str() { + "ctr" => TabAlign::Center, + "r" => TabAlign::Right, + "dec" => TabAlign::Decimal, + _ => TabAlign::Left, + }, + }) + .collect() + }) + .unwrap_or_default() +} + +/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of +/// that text before its first ".". +fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { + let mut w = 0.0; + let mut dec = None; + for c in cells.iter().skip(i + 1) { + if matches!(c.ch, '\t' | '\u{b}' | '\n') { + break; + } + if c.ch == '.' && dec.is_none() { + dec = Some(w); + } + w += c.adv; + } + (w, dec.unwrap_or(w)) +} + +/// Advance of a tab at `x` (from the start edge) to the next stop, placing the text after it +/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never +/// moving back, as PowerPoint does; past the last stop, to the next default stop. +fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { for s in stops { - if *s > x + 0.5 { - return s - x; + if s.pos > x + 0.5 { + let to = s.pos - x; + return match s.align { + TabAlign::Left => to, + TabAlign::Right => (to - seg.0).max(0.0), + TabAlign::Center => (to - seg.0 / 2.0).max(0.0), + TabAlign::Decimal => (to - seg.1).max(0.0), + }; } } let d = if default_tab > 1.0 { default_tab } else { 72.0 }; @@ -1197,7 +1272,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts (margin + indent).max(0.0) }; let rest_x = margin; - let tabs: Vec = props.tabs.as_ref().map(|t| t.iter().map(|s| s.pos).collect()).unwrap_or_default(); + let tabs = tabs_of(props); let first_w = (col_w - first_x - mr).max(1.0); let rest_w = (col_w - rest_x - mr).max(1.0); let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x); @@ -1239,8 +1314,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // Logical advances; tabs measure from the start edge. let mut adv = Vec::with_capacity(n); let mut x = 0.0; - for c in line_cells { - let a = if c.ch == '\t' { tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0)) } else { c.adv }; + for (k, c) in line_cells.iter().enumerate() { + let a = if c.ch == '\t' { + tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) + } else { + c.adv + }; adv.push(a); x += a; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 58c6c7d5..d5c25fba 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -885,3 +885,52 @@ fn transparent_text_is_faded_once() { let opacity = r.color.a as f64 / 255.0 * r.alpha; assert!((opacity - 0.4).abs() < 0.01, "{opacity}"); } + +/// One paragraph `text` with tab stops `tabs` (position, `algn`), no bullet or indent. +fn tabbed(text: &str, tabs: &[(f64, &str)], w: f64) -> TextLayout { + let (p, mut sh) = setup(text, w, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + for r in &mut para.runs { + r.props.size = Some(18.0); + } + let pp = &mut para.props; + pp.bullet = Some(Bullet::None); + pp.margin_left = Some(0.0); + pp.indent = Some(0.0); + pp.default_tab = Some(72.0); + pp.tabs = Some(tabs.iter().map(|(pos, a)| deckcraft_model::text::TabStop { pos: *pos, align: a.to_string() }).collect()); + lay(&p, &sh, w, 300.0) +} + +#[test] +fn tab_stops_align_the_text_after_them() { + let x0 = |l: &TextLayout| l.inner.x0; + // Right: the text ends at the stop. Centre: centred on it. Decimal: the "." at the stop. + let l = tabbed("a\tright", &[(200.0, "r")], 600.0); + assert!((l.lines[0].extent.1 - x0(&l) - 200.0).abs() < 0.01, "{:?}", l.lines[0].extent); + let l = tabbed("a\tmid", &[(200.0, "ctr")], 600.0); + let (s, e) = (l.lines[0].edges[2].0, l.lines[0].edges[4].1); + assert!(((s + e) / 2.0 - x0(&l) - 200.0).abs() < 0.01); + let l = tabbed("x\t1234.567", &[(150.0, "dec")], 600.0); + assert!((l.lines[0].edges[6].0 - x0(&l) - 150.0).abs() < 0.01, "the point sits at the stop"); + // Text without a point ends at a decimal stop. + let l = tabbed("x\t12,5", &[(150.0, "dec")], 600.0); + assert!((l.lines[0].extent.1 - x0(&l) - 150.0).abs() < 0.01); + // Text wider than the room before a right stop starts where the tab is; it never moves back. + let l = tabbed("x\twider than the gap", &[(20.0, "r")], 600.0); + assert!((l.lines[0].edges[2].0 - l.lines[0].edges[0].1).abs() < 0.01); + // A left stop and then default stops every 72 pt. + let l = tabbed("a\tb\tc", &[(30.0, "l")], 600.0); + assert!((l.lines[0].edges[2].0 - x0(&l) - 30.0).abs() < 0.01); + assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01); +} + +#[test] +fn a_tab_past_the_end_moves_to_the_next_line() { + // PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next + // line, so the text after it lands on that line's first stop. + let l = tabbed("abcdefghij\tk", &[], 130.0); + assert_eq!(l.lines.len(), 2); + assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::>()); + assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01); +} From 08bc46c0ea3128f4dad3e202221fda2eab6635d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:14:59 -0400 Subject: [PATCH 07/59] Text: underline styles (double, heavy, dotted, dashed, wavy) at the font's underline position Every underline was a solid line 0.06 em thick at 0.12 em below the baseline, whatever a:rPr/@u asked for. Underlines now sit at the font's own underline position and thickness (post table), where PowerPoint draws them (Arial 96 pt: 10 px below the baseline, 7 px thick), and each style is drawn as measured on PowerPoint's export: dbl two lines 0.55 thick spanning one thickness above to the single line's bottom; heavy from one thickness above to 0.55 below; dotted dots and gaps one thickness long; dashes three long with gaps of two; wavy a thin zigzag every 3.9 thicknesses. The "words" style underlines spaces too, as PowerPoint draws it. Dashes and waves are new Deco line kinds the renderer fills or strokes (warped text draws their boxes). Underline slides: w14-xml s10 11.49 -> 10.43, s11 6.64 -> 4.06; w06-charfmt s3 9.66 -> 9.51, s4 7.63 -> 7.44. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 16 +++ crates/render/src/lib.rs | 23 +++- crates/render/src/morph_text.rs | 2 +- crates/render/src/tests.rs | 31 +++++ crates/render/src/warp.rs | 6 +- crates/text/src/lib.rs | 206 +++++++++++++++++++++++++++++--- crates/text/src/tests.rs | 44 +++++++ 7 files changed, 308 insertions(+), 20 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 31cb9a18..861d2bd0 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -134,6 +134,9 @@ pub struct FontFace { /// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`), /// the split PowerPoint uses (computed by `line_split`). pub line_split: f64, + /// Underline position (top edge, below the baseline) and thickness in font units (`post` + /// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there. + pub underline: (f64, f64), pub shaper: harfrust::ShaperData, /// Variable fonts: the named instance's axis settings (user units; empty = default instance), /// the normalised location and the shaper's view of it. @@ -586,6 +589,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64), + underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64), shaper, coords, location, @@ -597,6 +601,18 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords }) } +/// The underline's top edge below the baseline and its thickness, font units, from the `post` +/// table (its position is the top edge: Arial's -217/2048 is where PowerPoint's underline starts, +/// 10 px below the baseline at 96 pt); fonts without a usable one get 0.1 em and 0.05 em. +fn underline_metrics(d: Option, upem: f64) -> (f64, f64) { + match d { + Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && (d.offset as f64).abs() < upem * 0.5 => { + (-(d.offset as f64), d.thickness as f64) + } + _ => (upem * 0.1, upem * 0.05), + } +} + /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1621f053..dc3c18a7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1399,7 +1399,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, } ctx.set_transform(m * Affine::translate(ps.offset)); ctx.set_paint(color(d.color, ps.opacity)); - ctx.fill_rect(&d.rect); + fill_deco(ctx, d); }; for d in l.decos.iter().filter(|d| d.behind) { deco(ctx, d); @@ -1447,6 +1447,27 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, } } +/// 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 { l.lines diff --git a/crates/render/src/morph_text.rs b/crates/render/src/morph_text.rs index 40798dd9..74cd4b7f 100644 --- a/crates/render/src/morph_text.rs +++ b/crates/render/src/morph_text.rs @@ -219,7 +219,7 @@ pub(crate) fn draw( for d in &l.decos { ctx.set_transform(place); ctx.set_paint(color(d.color, alpha)); - ctx.fill_rect(&d.rect); + crate::fill_deco(ctx, d); } } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 800f71ae..951c2917 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1644,3 +1644,34 @@ fn a_fill_overlay_blends_over_the_fill() { let (over, _) = draw([200, 100, 50], "over"); assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}"); } + +#[test] +fn dotted_and_wavy_underlines_draw_their_pattern() { + // Underlined spaces only, so the underline is the only ink: a red line under " ". + let row_runs = |u: &str| { + let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text(" "); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(96.0); + props.underline = Some(u.into()); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let red = |x: u32, y: u32| { + let px = img.pixel(x, y); + px[0] > 150 && px[1] < 120 + }; + // The ink's rows, and the number of separate red stretches along the most inked row. + let rows: Vec = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect(); + let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0); + let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count(); + (rows.len(), stretches) + }; + let (sng_rows, sng_runs) = row_runs("sng"); + assert_eq!(sng_runs, 1, "a single line is one stretch"); + let (_, dotted_runs) = row_runs("dotted"); + assert!(dotted_runs > 10, "dots: {dotted_runs}"); + let (wavy_rows, _) = row_runs("wavy"); + assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})"); +} diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs index de0ff126..e14340e8 100644 --- a/crates/render/src/warp.rs +++ b/crates/render/src/warp.rs @@ -140,10 +140,12 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, } let y = (d.rect.y0 + d.rect.y1) / 2.0; let Some(li) = line_of(l, d.para, y) else { return }; - let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p)); ctx.set_transform(m * Affine::translate(ps.offset)); ctx.set_paint(color(d.color, ps.opacity)); - ctx.fill_path(&path); + for r in d.pieces() { + let path = warp_path(&r.to_path(0.1), tol, seg, &|p| mapper.map(li, p)); + ctx.fill_path(&path); + } }; for d in l.decos.iter().filter(|d| d.behind) { deco(ctx, d); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index f587b73e..aece6a13 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -93,6 +93,150 @@ pub struct Deco { pub behind: bool, /// Paragraph the decoration belongs to (by-paragraph animation). pub para: usize, + /// How the line fills `rect`. + pub line: DecoLine, +} + +/// How an underline fills its box. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum DecoLine { + /// The whole box. + #[default] + Solid, + /// Dashes or dots across the box's height, from its left end. + Dashed(Dash), + /// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`. + Wave { stroke: f64, period: f64 }, +} + +/// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured +/// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three +/// thicknesses long with gaps of two. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dash { + Dot, + Dash, + Long, + DotDash, + DotDotDash, +} + +impl Dash { + /// On, off, on, off… lengths in thicknesses. + fn pattern(self) -> &'static [f64] { + match self { + Dash::Dot => &[1.0, 1.0], + Dash::Dash => &[3.0, 2.0], + Dash::Long => &[6.0, 2.0], + Dash::DotDash => &[3.0, 2.0, 1.0, 2.0], + Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0], + } + } +} + +impl Deco { + /// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy + /// line gives a thin box along its middle, for drawing that can't stroke a path. + pub fn pieces(&self) -> Vec { + let r = self.rect; + match self.line { + DecoLine::Solid => vec![r], + DecoLine::Wave { stroke, .. } => { + let c = (r.y0 + r.y1) / 2.0; + vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)] + } + DecoLine::Dashed(d) => { + let unit = r.height().max(0.1); + let pat = d.pattern(); + let mut out = Vec::new(); + let mut x = r.x0; + let mut k = 0usize; + while x < r.x1 && out.len() < 10_000 { + let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit; + if k.is_multiple_of(2) { + out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1)); + } + x += len; + k += 1; + } + out + } + } + } + + /// The zigzag of a wavy underline, left to right from the box's top, and its stroke width; + /// None for other lines. + pub fn wave(&self) -> Option<(Vec, f64)> { + let DecoLine::Wave { stroke, period } = self.line else { return None }; + let r = self.rect; + let half = (period / 2.0).max(0.1); + let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000); + let pts = (0..=n) + .map(|i| { + let x = (r.x0 + i as f64 * half).min(r.x1); + Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 }) + }) + .collect(); + Some((pts, stroke)) + } +} + +/// An underline style (`a:rPr/@u`). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Underline { + Single, + Double, + Heavy, + /// Dotted or dashed, heavy or not. + Dashed(Dash, bool), + /// Wavy, heavy or not. + Wavy(bool), +} + +impl Underline { + fn from_xml(u: &str) -> Option { + Some(match u { + "none" => return None, + // PowerPoint underlines the spaces of a "words" underline too. + "words" => Underline::Single, + // At text sizes PowerPoint's double wave reads as two straight lines. + "dbl" | "wavyDbl" => Underline::Double, + "heavy" => Underline::Heavy, + "dotted" => Underline::Dashed(Dash::Dot, false), + "dottedHeavy" => Underline::Dashed(Dash::Dot, true), + "dash" => Underline::Dashed(Dash::Dash, false), + "dashHeavy" => Underline::Dashed(Dash::Dash, true), + "dashLong" => Underline::Dashed(Dash::Long, false), + "dashLongHeavy" => Underline::Dashed(Dash::Long, true), + "dotDash" => Underline::Dashed(Dash::DotDash, false), + "dotDashHeavy" => Underline::Dashed(Dash::DotDash, true), + "dotDotDash" => Underline::Dashed(Dash::DotDotDash, false), + "dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true), + "wavy" => Underline::Wavy(false), + "wavyHeavy" => Underline::Wavy(true), + _ => Underline::Single, + }) + } + + /// The boxes of this underline below the baseline (top, bottom, fill), from the font's + /// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export + /// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two + /// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every + /// 3.9t. + fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> { + let heavy = (p - t, p + 0.55 * t); + match self { + Underline::Single => vec![(p, p + t, DecoLine::Solid)], + Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)], + Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)], + Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))], + Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))], + Underline::Wavy(h) => { + let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 }; + vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })] + } + } + } } #[derive(Clone, Debug, Default)] @@ -187,7 +331,7 @@ struct Style { baseline: f64, spacing: f64, caps: Caps, - underline: Option, + underline: Option<(Rgba, Underline)>, strike: Option, highlight: Option, outline: Option<(Rgba, f64)>, @@ -293,7 +437,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl }; let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" }); let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" }); - let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color)); + let underline = + r.underline.as_deref().and_then(Underline::from_xml).map(|u| (r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color), u)); let outline = r.outline.as_ref().and_then(|l| match &l.fill { Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))), _ => None, @@ -1580,20 +1725,47 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts continue; } if let Some(hl) = st.highlight { - out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para }); + out.decos.push(Deco { + rect: Rect::new(cx, line.top, cx1, line.bottom), + color: hl, + behind: true, + para: line.para, + line: DecoLine::Solid, + }); } if !cell.ch.is_whitespace() || st.underline.is_some() { - if let Some(uc) = st.underline { - let t = (st.size * 0.06).max(0.5); - let uy = line.baseline + st.size * 0.12; - out.decos.push(Deco { rect: Rect::new(cx, uy, cx1, uy + t), color: uc, behind: false, para: line.para }); + if let Some((uc, u)) = st.underline { + // At the underline position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); + for (y0, y1, kind) in u.boxes(p, t) { + out.decos.push(Deco { + rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), + color: uc, + behind: false, + para: line.para, + line: kind, + }); + } } if let Some(sk) = st.strike { let t = (st.size * 0.05).max(0.5); let sy = line.baseline - st.size * 0.3; - out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para }); + out.decos.push(Deco { + rect: Rect::new(cx, sy, cx1, sy + t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); if sk == Strike::Double { - out.decos.push(Deco { rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), color: st.color, behind: false, para: line.para }); + out.decos.push(Deco { + rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); } } } @@ -1622,13 +1794,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts fn merge_decos(v: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(v.len()); for d in v { - if let Some(last) = out.last_mut() - && last.color == d.color - && last.behind == d.behind - && (last.rect.y0 - d.rect.y0).abs() < 0.01 - && (last.rect.y1 - d.rect.y1).abs() < 0.01 - && (last.rect.x1 - d.rect.x0).abs() < 0.5 - { + // Lines drawn together (a double underline, a strikethrough) interleave: look a few back. + if let Some(last) = out.iter_mut().rev().take(4).find(|last| { + last.color == d.color + && last.behind == d.behind + && last.line == d.line + && (last.rect.y0 - d.rect.y0).abs() < 0.01 + && (last.rect.y1 - d.rect.y1).abs() < 0.01 + && (last.rect.x1 - d.rect.x0).abs() < 0.5 + }) { last.rect.x1 = d.rect.x1; continue; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d5c25fba..0168d2ed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -934,3 +934,47 @@ fn a_tab_past_the_end_moves_to_the_next_line() { assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::>()); assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01); } + +/// Underline decorations of "ab cd" underlined `u` at 40 pt. +fn underline_decos(u: &str) -> (TextLayout, Vec) { + let (p, mut sh) = setup("ab cd", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.size = Some(40.0); + para.runs[0].props.underline = Some(u.into()); + let l = lay(&p, &sh, 2000.0, 300.0); + let d = l.decos.iter().filter(|d| !d.behind).cloned().collect(); + (l, d) +} + +#[test] +fn underlines_sit_at_the_font_underline_in_their_style() { + let (l, sng) = underline_decos("sng"); + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + let k = 40.0 / r.face.upem; + let base = l.lines[0].baseline; + assert_eq!(sng.len(), 1); + assert!((sng[0].rect.y0 - base - r.face.underline.0 * k).abs() < 0.01, "top at the font's underline position"); + assert!((sng[0].rect.height() - (r.face.underline.1 * k).max(0.5)).abs() < 0.01); + let t = sng[0].rect.height(); + // Double: two thin lines spanning from one thickness above the single line to its bottom. + let (_, dbl) = underline_decos("dbl"); + assert_eq!(dbl.len(), 2); + assert!((dbl[0].rect.y0 - (sng[0].rect.y0 - t)).abs() < 0.01 && (dbl[1].rect.y1 - sng[0].rect.y1).abs() < 0.01); + // Heavy is thicker; dotted is dots as long as the line is thick. + let (_, heavy) = underline_decos("heavy"); + assert!(heavy[0].rect.height() > t * 1.4); + let (_, dotted) = underline_decos("dotted"); + assert_eq!(dotted[0].line, DecoLine::Dashed(Dash::Dot)); + let pieces = dotted[0].pieces(); + assert!(pieces.len() > 3); + assert!((pieces[0].width() - t).abs() < 0.01 && (pieces[1].x0 - pieces[0].x1 - t).abs() < 0.01); + // Wavy: a zigzag between the box's top and bottom. + let (_, wavy) = underline_decos("wavy"); + let (pts, w) = wavy[0].wave().expect("a wave"); + assert!(pts.len() > 4 && w > 0.0 && w < t); + assert!(pts[0].y < pts[1].y); + // "words" underlines the spaces too, as PowerPoint draws it: one line. + let (_, words) = underline_decos("words"); + assert_eq!(words.len(), 1); +} From fa3db9a7777cab4a7c6561db0acd0a9be6dda60d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:16:19 -0400 Subject: [PATCH 08/59] Text: condensed character spacing keeps pairs kerned Follow-up to the kerning rule: on the real corpus (ms6: titles condensed by 1 pt from the master's title style) PowerPoint kerns condensed text, and a closer probe agrees for -0.6, -1, -1.5, -2 and -3 pt (only -1.2 and -2.5 pt came out unkerned, which no simple rule explains). Only spacing out by 0.6 pt or more now switches kerning off for the box. ms6 slides 2/7: 4.97 -> 6.44 -> back to 4.97, 7.05 -> 8.10 -> 7.05. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 10 ++++++---- crates/text/src/tests.rs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index aece6a13..74ba7875 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1332,14 +1332,16 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay } /// Does character spacing switch pair kerning off in this body? PowerPoint stops kerning every run -/// of a text body once one of its runs is spaced out by 0.6 pt or more, or condensed by 1 pt or -/// more (measured on Arial at 40 pt: 0.5 pt and -0.99 pt keep the pairs kerned, 0.6 pt and -1 pt -/// drop them for the whole box). +/// of a text body once one of its runs is spaced out by 0.6 pt or more (measured on Arial at 40 pt: +/// 0.5 pt keeps the pairs kerned, 0.6, 0.8, 1 and 1.5 pt drop them for the whole box, even in +/// paragraphs without spacing). Condensed spacing mostly keeps them (-0.6, -1, -1.5, -2 and -3 pt; +/// -1.2 and -2.5 pt did not, which no simple rule explains, so condensing never stops kerning +/// here: titles condensed by 1 pt are common and kerned). fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { body.paragraphs.iter().any(|para| { para.runs.iter().any(|run| { let s = resolve::run(ctx, shape, para, &run.props).spacing.unwrap_or(0.0); - s >= 0.55 || s <= -0.995 + s >= 0.55 }) }) } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 0168d2ed..1dc955ed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -133,8 +133,8 @@ fn character_spacing_in_the_body_stops_kerning() { }; let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0)); let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0)); - // Small spacing elsewhere keeps the pairs; 0.6 pt or -1 pt anywhere in the box drops them. - for (spc, kerned) in [(0.0, true), (0.5, true), (-0.99, true), (0.6, false), (1.0, false), (-1.0, false), (3.0, false)] { + // Small or condensed spacing elsewhere keeps the pairs; 0.6 pt anywhere in the box drops them. + for (spc, kerned) in [(0.0, true), (0.5, true), (-0.99, true), (-1.0, true), (-2.0, true), (0.6, false), (1.0, false), (3.0, false)] { let w = kerned_width_beside_spacing(&family, spc); let want = if kerned { on } else { off }; assert!((w - want).abs() < 0.01, "{family} spc {spc}: {w} vs {want}"); From d30e4edb45c951f3dbc62ee3d50a65519f43fb19 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:20:53 -0400 Subject: [PATCH 09/59] Text: soft hyphens show a hyphen where a line breaks; no break after zero-width space or before em dash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed on PowerPoint's export of long joined words: - a line broken at a soft hyphen (U+00AD) ends with a visible hyphen ("Softhyphenatedwordsina-"); DeckCraft broke there but drew nothing. The hyphen now takes its room on the line; - PowerPoint does not break after a zero-width space (it splits the word where it overflows instead), where UAX #14 allows a break; - an em dash ends a line but never starts one ("Em—dash—joined—" / "words—that—are—long—"), where UAX #14 allows a break before it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 21 ++++++++++++++++++--- crates/text/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 74ba7875..3df5a742 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1112,7 +1112,12 @@ fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) let chars: Vec = text.chars().collect(); for (i, w) in chars.windows(2).enumerate() { let (prev, next) = (w[0], w[1]); - let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() }; + // PowerPoint never breaks after a zero-width space, nor before an em dash (only after it). + let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' { + false + } else { + latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() + }; if !keep && !mandatory.get(i + 1).copied().unwrap_or(false) && let Some(a) = allowed.get_mut(i + 1) @@ -1461,8 +1466,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // Logical advances; tabs measure from the start edge. let mut adv = Vec::with_capacity(n); let mut x = 0.0; + // A line that breaks at a soft hyphen ends with a visible hyphen. + let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); for (k, c) in line_cells.iter().enumerate() { - let a = if c.ch == '\t' { + let a = if shy_end && k + 1 == n { + p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) + } else if c.ch == '\t' { tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) } else { c.adv @@ -1694,7 +1703,13 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } if let Some(r) = cur.as_mut() { r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); - if cell.head == ci && !cell.glyphs.is_empty() { + if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { + // The soft hyphen a line breaks at is drawn as a hyphen. + if let Some(f) = p.faces.get(cell.face) { + r.glyphs.push((f.glyph_for('-'), cx, base_y)); + r.cells.push((ci, cell.ch)); + } + } else if cell.head == ci && !cell.glyphs.is_empty() { // The cluster's glyphs start at its leftmost cell; kashida (right-to-left // only) fills the gap on the cluster's left with tatweel glyphs. let cells_of = diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1dc955ed..8613e021 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -978,3 +978,38 @@ fn underlines_sit_at_the_font_underline_in_their_style() { let (_, words) = underline_decos("words"); assert_eq!(words.len(), 1); } + +#[test] +fn no_break_after_a_zero_width_space_or_before_an_em_dash() { + let (allowed, _) = break_opportunities("ab\u{200B}cd", false); + assert!(!allowed[3], "PowerPoint does not break after a zero-width space"); + let (allowed, _) = break_opportunities("ab\u{2014}cd", false); + assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one"); +} + +#[test] +fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() { + let (p, mut sh) = setup("Soft\u{AD}hyphenated\u{AD}words", 150.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.size = Some(24.0); + let l = lay(&p, &sh, 150.0, 300.0); + assert!(l.lines.len() >= 2); + let first = &l.lines[0]; + let shy = first.end - 1; + assert_eq!(first.end, 5, "breaks after the soft hyphen: {:?}", l.lines.iter().map(|x| (x.start, x.end)).collect::>()); + let (x0, x1) = first.edges[shy - first.start]; + assert!(x1 - x0 > 2.0, "the hyphen takes room"); + let r = l.runs.iter().find(|r| r.cells.iter().any(|c| c.0 == shy)).expect("a glyph for the soft hyphen"); + assert_eq!(r.glyphs[r.cells.iter().position(|c| c.0 == shy).unwrap()].0, r.face.glyph_for('-')); + // Soft hyphens draw only where a line ends; in a wide box, nowhere. + let ends: Vec = l.lines.iter().map(|x| x.end.saturating_sub(1)).collect(); + for r in &l.runs { + for (g, c) in r.glyphs.iter().zip(&r.cells) { + assert!(c.1 != '\u{AD}' || ends.contains(&c.0) || g.0 != r.face.glyph_for('-'), "mid-line soft hyphen drawn"); + } + } + let wide = lay(&p, &sh, 2000.0, 300.0); + assert_eq!(wide.lines.len(), 1); + assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-'))))); +} From 257e79de27cab241cc7ee4ee3312712c52c4f250 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:24:08 -0400 Subject: [PATCH 10/59] =?UTF-8?q?Text:=20Japanese=20line=20breaking=20keep?= =?UTF-8?q?s=20small=20kana=20and=20"=E3=83=BC"=20off=20line=20starts;=20o?= =?UTF-8?q?nly=20commas=20and=20stops=20hang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed on PowerPoint's export of a wrapped Japanese paragraph (24 pt, 11 characters a line): it breaks "けません(禁則処" / "理)。ー長音もで" / "す。っゃゅょ小書き文字", i.e. small kana (っゃゅょ…), the prolonged sound mark and iteration marks never start a line (strict kinsoku), where UAX #14's default allows it. And "し、句読点「、」や" / "「。」は…": with hanging punctuation on, a closing bracket that overflows does not hang; only the ideographic comma and full stop (and their full-width and half-width forms) do. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 65 ++++++++++++++++++++++++++-------------- crates/text/src/tests.rs | 15 ++++++++++ 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 3df5a742..dae8a6d1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1057,31 +1057,50 @@ fn is_hangul(c: char) -> bool { matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}') } -/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`). +/// East Asian commas and full stops, the punctuation PowerPoint lets hang past the end of a line +/// (`hangingPunct`). Closing brackets don't hang: "「。」" overflowing by its "」" moves to the next +/// line whole. fn is_hanging_punct(c: char) -> bool { + matches!(c, '、' | '。' | ',' | '.' | '、' | '。') +} + +/// Small kana, the prolonged sound mark and iteration marks: PowerPoint keeps them off the start +/// of a line (strict Japanese line breaking), where UAX #14's default allows a break before them. +fn is_cj_nonstarter(c: char) -> bool { matches!( c, - '、' | '。' - | ',' - | '.' - | ':' - | ';' - | '!' - | '?' - | ')' - | '」' - | '』' - | '】' - | '〕' - | '〉' - | '》' - | '〙' - | '〛' - | '}' - | ']' - | '」' - | '、' - | '。' + 'ぁ' | 'ぃ' + | 'ぅ' + | 'ぇ' + | 'ぉ' + | 'っ' + | 'ゃ' + | 'ゅ' + | 'ょ' + | 'ゎ' + | 'ゕ' + | 'ゖ' + | 'ァ' + | 'ィ' + | 'ゥ' + | 'ェ' + | 'ォ' + | 'ッ' + | 'ャ' + | 'ュ' + | 'ョ' + | 'ヮ' + | 'ヵ' + | 'ヶ' + | 'ー' + | '々' + | '〻' + | 'ゝ' + | 'ゞ' + | 'ヽ' + | 'ヾ' + | '\u{31F0}'..='\u{31FF}' + | 'ァ'..='ー' ) } @@ -1113,7 +1132,7 @@ fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) for (i, w) in chars.windows(2).enumerate() { let (prev, next) = (w[0], w[1]); // PowerPoint never breaks after a zero-width space, nor before an em dash (only after it). - let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' { + let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8613e021..112e1004 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1013,3 +1013,18 @@ fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() { assert_eq!(wide.lines.len(), 1); assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-'))))); } + +#[test] +fn small_kana_and_closing_brackets_follow_powerpoint_kinsoku() { + // "っ" and "ー" never start a line: the character before them moves down too. + let w = one_line("あいうえお") + 0.5; + for (text, first) in [("あいうえおっかき", "あいうえ"), ("あいうえおーかき", "あいうえ"), ("あいうえおかき", "あいうえお")] + { + let lines = wrapped(text, w, |p| p.hanging_punct = Some(true)); + assert_eq!(lines[0], first, "{text}: {lines:?}"); + } + // A closing bracket doesn't hang like "。" does: "「。」" overflowing by "」" moves down whole. + let w = one_line("あいうえ「。") + 0.5; + let lines = wrapped("あいうえ「。」かき", w, |p| p.hanging_punct = Some(true)); + assert_eq!(lines[0], "あいうえ", "{lines:?}"); +} From bc2fc816d494c5b1f656482b42dfaf6c3d40b195 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:27:19 -0400 Subject: [PATCH 11/59] Text: East Asian, Thai, Hindi, Arabic and Hebrew auto-number schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scheme other than the Latin, Roman and circled ones drew Arabic digits ("4." for ea1ChsPeriod, "- 10 -" for arabic2Minus). Each is now written as PowerPoint's export draws it (probe deck: one box per a:buAutoNum type and start value): Chinese numerals for ea1Chs/ea1Cht (十, 十一, 二十一), digit-by-digit kanji with 〇 for ea1Jpn* (一〇), full-width digits for arabicDb*, a full-width full stop after the East Asian ones, Thai letters (without ฃ ฅ ฆ) and digits, Devanagari vowels, consonants and digits, the Arabic alphabet (arabic1Minus) and abjad (arabic2Minus) and the Hebrew alphabet with a hyphen, and the Wingdings-style circles that go to ten (circleNumWdWhite 13 → ③, circleNumWdBlack → ❶…). w04-lists s2-s4 drew wrong labels in 24 of 36 boxes. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 115 +++++++++++++++++++++++++++++++++++---- crates/text/src/tests.rs | 35 ++++++++++++ 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index dae8a6d1..20ed541d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -550,31 +550,122 @@ fn alpha_num(n: u32, upper: bool) -> String { if upper { s.to_uppercase() } else { s } } -/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.". +/// Letters of a numbering alphabet: 1 → the first, past the end the letter doubles ("aa"), as +/// the Latin alphabetic schemes do. +fn letters(list: &[char], n: u32) -> String { + let len = list.len().max(1) as u32; + let Some(i) = n.checked_sub(1) else { return String::new() }; + let c = list.get((i % len) as usize).copied().unwrap_or('?'); + std::iter::repeat_n(c, (i / len + 1).min(50) as usize).collect() +} + +/// `n` written with the digits `zero..zero+9` (Thai, Devanagari, full-width digits). +fn digits_in(n: u32, zero: char) -> String { + n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| char::from_u32(zero as u32 + v)).unwrap_or(d)).collect() +} + +const CJK_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + +/// Chinese numerals: 10 → 十, 11 → 十一, 25 → 二十五, 105 → 一百零五. +fn chinese_num(n: u32) -> String { + if n == 0 || n > 99_999 { + return n.to_string(); + } + let units = ['\0', '十', '百', '千', '万']; + let ds: Vec = n.to_string().chars().filter_map(|c| c.to_digit(10)).collect(); + let mut out = String::new(); + let mut zero = false; + for (i, d) in ds.iter().enumerate() { + let place = ds.len() - 1 - i; + if *d == 0 { + zero = true; + continue; + } + if zero { + out.push('零'); + zero = false; + } + // "十" alone for 10–19, not "一十". + if !(*d == 1 && place == 1 && i == 0) { + out.push(CJK_DIGITS.get(*d as usize).copied().unwrap_or('?')); + } + if let Some(u) = units.get(place).filter(|u| **u != '\0') { + out.push(*u); + } + } + out +} + +/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.". The East Asian, Thai, Hindi, Arabic +/// and Hebrew schemes are written as PowerPoint draws them: `ea1ChsPeriod` 10 → "十.", +/// `ea1JpnKorPeriod` 10 → "一〇." (digit by digit), `arabicDbPeriod` 13 → "13.", +/// `thaiAlphaParenR` 4 → "ง)", `hindiAlpha1Period` 1 → "क.", `arabic2Minus` 10 → "ي-" +/// (abjad order), `circleNumWdWhitePlain` 13 → "③" (those circles go to ten). pub fn autonum(scheme: &str, n: u32) -> String { - let (core, upper) = if scheme.starts_with("romanUc") { - (roman(n, true), true) + const THAI: [char; 41] = [ + 'ก', 'ข', 'ค', 'ง', 'จ', 'ฉ', 'ช', 'ซ', 'ฌ', 'ญ', 'ฎ', 'ฏ', 'ฐ', 'ฑ', 'ฒ', 'ณ', 'ด', 'ต', 'ถ', 'ท', 'ธ', 'น', 'บ', 'ป', 'ผ', 'ฝ', 'พ', 'ฟ', + 'ภ', 'ม', 'ย', 'ร', 'ล', 'ว', 'ศ', 'ษ', 'ส', 'ห', 'ฬ', 'อ', 'ฮ', + ]; + const HINDI_VOWELS: [char; 11] = ['अ', 'आ', 'इ', 'ई', 'उ', 'ऊ', 'ऋ', 'ए', 'ऐ', 'ओ', 'औ']; + const HINDI_CONSONANTS: [char; 33] = [ + 'क', 'ख', 'ग', 'घ', 'ङ', 'च', 'छ', 'ज', 'झ', 'ञ', 'ट', 'ठ', 'ड', 'ढ', 'ण', 'त', 'थ', 'द', 'ध', 'न', 'प', 'फ', 'ब', 'भ', 'म', 'य', 'र', 'ल', + 'व', 'श', 'ष', 'स', 'ह', + ]; + const ARABIC_ALPHABET: [char; 28] = + ['ا', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي']; + const ARABIC_ABJAD: [char; 28] = + ['ا', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ']; + const HEBREW: [char; 22] = ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט', 'י', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ', 'ק', 'ר', 'ש', 'ת']; + let circled = |first: u32, count: u32| char::from_u32(first + n.saturating_sub(1) % count).map(String::from).unwrap_or_else(|| n.to_string()); + let core = if scheme.starts_with("romanUc") { + roman(n, true) } else if scheme.starts_with("romanLc") { - (roman(n, false), false) + roman(n, false) } else if scheme.starts_with("alphaUc") { - (alpha_num(n, true), true) + alpha_num(n, true) } else if scheme.starts_with("alphaLc") { - (alpha_num(n, false), false) + alpha_num(n, false) + } else if scheme.starts_with("circleNumWdWhite") { + return circled(0x2460, 10); + } else if scheme.starts_with("circleNumWdBlack") { + return circled(0x2776, 10); } else if scheme.starts_with("circleNum") { - let c = char::from_u32(0x2460 + n.saturating_sub(1).min(19)).unwrap_or('•'); - return c.to_string(); + return char::from_u32(0x2460 + n.saturating_sub(1).min(19)).map(String::from).unwrap_or_else(|| n.to_string()); + } else if scheme.starts_with("ea1Chs") || scheme.starts_with("ea1Cht") { + chinese_num(n) + } else if scheme.starts_with("ea1Jpn") { + n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| CJK_DIGITS.get(v as usize).copied()).unwrap_or(d)).collect() + } else if scheme.starts_with("arabicDb") { + digits_in(n, '0') + } else if scheme.starts_with("thaiAlpha") { + letters(&THAI, n) + } else if scheme.starts_with("thaiNum") { + digits_in(n, '๐') + } else if scheme.starts_with("hindiAlpha1") { + letters(&HINDI_CONSONANTS, n) + } else if scheme.starts_with("hindiAlpha") { + letters(&HINDI_VOWELS, n) + } else if scheme.starts_with("hindiNum") { + digits_in(n, '०') + } else if scheme.starts_with("arabic1") { + letters(&ARABIC_ALPHABET, n) + } else if scheme.starts_with("arabic2") { + letters(&ARABIC_ABJAD, n) + } else if scheme.starts_with("hebrew2") { + letters(&HEBREW, n) } else { - (n.to_string(), false) + n.to_string() }; - let _ = upper; + // East Asian schemes end with a full-width full stop. + let wide = scheme.starts_with("ea1") || scheme.starts_with("arabicDb"); if scheme.ends_with("ParenBoth") { format!("({core})") } else if scheme.ends_with("ParenR") { format!("{core})") } else if scheme.ends_with("Period") { - format!("{core}.") + format!("{core}{}", if wide { '.' } else { '.' }) } else if scheme.ends_with("Minus") { - format!("- {core} -") + format!("{core}-") } else { core } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 112e1004..04b27749 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1028,3 +1028,38 @@ fn small_kana_and_closing_brackets_follow_powerpoint_kinsoku() { let lines = wrapped("あいうえ「。」かき", w, |p| p.hanging_punct = Some(true)); assert_eq!(lines[0], "あいうえ", "{lines:?}"); } + +#[test] +fn autonumber_schemes_follow_powerpoint() { + // As PowerPoint's export draws them (start values as in the probe deck). + for (scheme, n, want) in [ + ("ea1ChsPlain", 4, "四"), + ("ea1ChsPeriod", 10, "十."), + ("ea1ChtPeriod", 21, "二十一."), + ("ea1JpnKorPeriod", 10, "一〇."), + ("ea1JpnChsDbPeriod", 13, "一三."), + ("arabicDbPlain", 10, "10"), + ("arabicDbPeriod", 13, "13."), + ("circleNumDbPlain", 13, "⑬"), + ("circleNumWdWhitePlain", 13, "③"), + ("circleNumWdBlackPlain", 2, "❷"), + ("thaiAlphaPeriod", 1, "ก."), + ("thaiAlphaParenR", 4, "ง)"), + ("thaiAlphaParenBoth", 7, "(ช)"), + ("thaiNumPeriod", 10, "๑๐."), + ("hindiAlphaPeriod", 4, "ई."), + ("hindiAlpha1Period", 1, "क."), + ("hindiNumParenR", 13, "१३)"), + ("hebrew2Minus", 1, "א-"), + ("arabic1Minus", 13, "ش-"), + ("arabic2Minus", 10, "ي-"), + ("arabicPlain", 10, "10"), + ] { + assert_eq!(autonum(scheme, n), want, "{scheme} {n}"); + } + assert_eq!(chinese_num(105), "一百零五"); + assert_eq!(chinese_num(20), "二十"); + // Past the end of an alphabet the letter doubles, like "aa". + assert_eq!(autonum("hebrew2Minus", 23), "אא-"); + assert_eq!(autonum("thaiAlphaPeriod", 0), "."); +} From 41d839d931c1944004a81e5bfdaf45a1bad800e9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:35:51 -0400 Subject: [PATCH 12/59] Text: East Asian vertical text (eaVert) stands ideographs and kana upright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eaVert text was laid out exactly like vert: the whole text turned a quarter turn, so Japanese and Chinese lay on their side. PowerPoint draws ideographs, kana, Hangul, full-width forms, CJK punctuation, circled numbers and emoji upright, each turned back about the centre of its em box, while Latin letters and digits stay on their side ("英字123も": 123 sideways). It keeps the glyphs' horizontal forms (no vert alternates: "、" "「" are simply turned), which DeckCraft now matches. Glyph runs carry an `upright` flag; the renderer turns each such glyph about its em box centre (0.38 em above the baseline). w01-vertical s3 (eaVert) 9.70 -> 8.98, s6/s7 (eaVert rotated, flipped, bulleted) 1.58 -> 1.45, 2.30 -> 1.76. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 14 ++++- crates/render/src/tests.rs | 31 +++++++++++ crates/text/src/lib.rs | 108 ++++++++++++++++++++++++++++++++++--- crates/text/src/tests.rs | 29 +++++++++- 4 files changed, 170 insertions(+), 12 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc3c18a7..e5fff044 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1426,7 +1426,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, // 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 * Affine::translate((*x, *y)); + 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; @@ -1447,6 +1447,16 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, } } +/// 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) { @@ -1518,7 +1528,7 @@ fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: T continue; } // The offset is in slide space: a shadow falls the same way whatever the text's rotation. - let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k); + 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 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 951c2917..e5b365f4 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1675,3 +1675,34 @@ fn dotted_and_wavy_underlines_draw_their_pattern() { let (wavy_rows, _) = row_runs("wavy"); assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})"); } + +#[test] +fn east_asian_vertical_text_stands_ideographs_upright() { + // "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs + // down the shape; in vert it lies on its side like Latin text. + let ink = |dir: deckcraft_model::text::TextDir| { + let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("一"); + t.body.vert = Some(dir); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(80.0); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))); + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); + for y in 0..540 { + for x in 0..960 { + if img.pixel(x, y)[0] < 128 { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + } + assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}"); + (x1 - x0, y1 - y0) + }; + let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical); + assert!(w > h * 3, "eaVert: upright stroke {w}x{h}"); + let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); + assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 20ed541d..0b02d2c1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -59,6 +59,26 @@ pub struct GlyphRun { pub gradient: Option, /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). pub effects: Option, + /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn + /// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is + /// laid out turned a quarter turn. + pub upright: bool, +} + +/// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the +/// em box from 0.88 em above to 0.12 em below it). +pub const UPRIGHT_CENTRE: f64 = 0.38; + +impl GlyphRun { + /// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box, + /// `advance` wide (`None` when the run is not upright). + pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> { + if !self.upright { + return None; + } + let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0); + Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE)) + } } /// A text run's shadow and glow, colours resolved. @@ -371,6 +391,8 @@ struct Cell { head: usize, /// A grapheme starts here: carets stand and lines break only before such cells. boundary: bool, + /// Drawn upright in East Asian vertical text. + upright: bool, } impl Cell { @@ -672,7 +694,17 @@ pub fn autonum(scheme: &str, n: u32) -> String { } #[allow(clippy::too_many_arguments)] -fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option, no_kern: bool) -> ParaShaped { +fn shape_para( + ctx: &Ctx, + shape: &Shape, + body: &TextBody, + pi: usize, + scale: f64, + opts: &Opts, + number: Option, + no_kern: bool, + vertical: bool, +) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { let face = db.face("Inter", "Regular"); @@ -710,7 +742,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, char_style.extend(std::iter::repeat_n(si, t.chars().count())); text.push_str(&t); } - let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl); + let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl, vertical); let lead = para.runs.first().map(|r| resolve::run(ctx, shape, para, &r.props)).unwrap_or_else(|| resolve::run(ctx, shape, para, ¶.end_props)); let lead_style = make_style(ctx, &lead, scale, opts.prompt_color); let lead_size = lead_style.size; @@ -791,7 +823,16 @@ fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option { (shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow }) } -fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec>, rtl: bool) -> Vec { +#[allow(clippy::too_many_arguments)] +fn shape_cells( + ctx: &Ctx, + text: &str, + char_style: &[usize], + styles: &[Style], + faces: &mut Vec>, + rtl: bool, + vertical: bool, +) -> Vec { let db = FontDb::global(); let chars: Vec = text.chars().collect(); let n = chars.len(); @@ -865,22 +906,40 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa adv: 0.0, head: i, boundary: boundary.get(i).copied().unwrap_or(true), + upright: false, }) .collect(); + // In East Asian vertical text, ideographs, kana, Hangul, full-width forms and the like stand + // upright (a whole grapheme at a time); other text lies on its side. + if vertical { + for &(a, b) in &graphemes { + let up = chars.get(a).is_some_and(|c| is_upright(*c)); + for c in cells.get_mut(a..b).into_iter().flatten() { + c.upright = up; + } + } + } // Items: maximal ranges of one level, face, script and shaping style (case mapping, language). let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None); let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref()); let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern); - let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i)); + let up = |i: usize| cells.get(i).is_some_and(|c| c.upright); + let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i), up(i)); + let mut items: Vec<(usize, usize)> = Vec::new(); let mut a = 0; while a < n { let mut b = a + 1; while b < n && key(b) == key(a) { b += 1; } - shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a)); + items.push((a, b)); a = b; } + // Upright glyphs keep their horizontal forms: PowerPoint turns "、", "。" and "「" as they are + // (no `vert` alternates). + for (a, b) in items { + shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a)); + } cells } @@ -895,6 +954,35 @@ fn caps_char(ch: char) -> char { } } +/// Does `c` stand upright in East Asian vertical text? Ideographs, kana, Hangul, Bopomofo, CJK +/// symbols and punctuation, full-width forms, enclosed and compatibility CJK, circled numbers +/// and emoji (Unicode's Vertical_Orientation U and T, roughly); Latin, digits and the rest of the +/// half-width text lie on their side as in `vert`. +fn is_upright(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF + | 0x2460..=0x24FF + | 0x2E80..=0x2FFF + | 0x3000..=0x303F + | 0x3040..=0x30FF + | 0x3100..=0x312F + | 0x3130..=0x318F + | 0x3190..=0x31FF + | 0x3200..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xA960..=0xA97F + | 0xAC00..=0xD7FF + | 0xF900..=0xFAFF + | 0xFE10..=0xFE1F + | 0xFE30..=0xFE6F + | 0xFF01..=0xFF60 + | 0xFFE0..=0xFFE6 + | 0x1F000..=0x1FAFF + | 0x20000..=0x3FFFF + ) +} + /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; @@ -1465,6 +1553,8 @@ fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); + // East Asian vertical text: CJK stands upright in text turned a quarter turn. + let upright_text = vert == TextDir::EaVertical; let rotation = match vert { TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0, TextDir::Vertical270 => 270.0, @@ -1515,7 +1605,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } None }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern)); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern, upright_text)); } for (pi, p) in shaped_paras.iter().enumerate() { let props = &p.props; @@ -1773,11 +1863,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts alpha: st.alpha, gradient: None, effects: None, + upright: false, }); } // Group visually consecutive cells by (style, face). let mut cur: Option = None; - let mut cur_key = (usize::MAX, usize::MAX, false); + let mut cur_key = (usize::MAX, usize::MAX, false, false); for &k in &place.order { let ci = line.start + k; let Some(cell) = p.cells.get(ci) else { continue }; @@ -1787,7 +1878,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; // Small capitals are drawn smaller than the capitals beside them: a run of their own. - let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase()); + let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright); if key != cur_key { if let Some(r) = cur.take() { out.runs.push(r); @@ -1808,6 +1899,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts alpha: st.alpha, gradient: st.gradient.clone(), effects: st.effects, + upright: cell.upright, }); cur_key = key; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 04b27749..46091805 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -144,8 +144,11 @@ fn character_spacing_in_the_body_stops_kerning() { #[test] fn ligatures_stay_for_scripts_that_need_them() { let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag)); - let cells = - |s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::>(); + let cells = |s: &str| { + s.chars() + .map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true, upright: false }) + .collect::>() + }; let latin = shaping_features(&cells("office"), &[], Some(*b"Latn")); assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern")); // Arabic needs its ligatures; emoji sequences are joined by them. @@ -1063,3 +1066,25 @@ fn autonumber_schemes_follow_powerpoint() { assert_eq!(autonum("hebrew2Minus", 23), "אא-"); assert_eq!(autonum("thaiAlphaPeriod", 0), "."); } + +#[test] +fn east_asian_vertical_text_marks_cjk_upright() { + let (p, mut sh) = setup("日本、ABC 12", 300.0, 600.0); + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::EaVertical); + let l = lay(&p, &sh, 300.0, 600.0); + assert_eq!(l.rotation, 90.0); + let upright: Vec = l + .runs + .iter() + .filter(|r| !r.cells.is_empty()) + .flat_map(|r| r.cells.iter().map(move |c| (c.1, r.upright))) + .filter(|(c, _)| *c != ' ') + .map(|(_, u)| u) + .collect(); + assert_eq!(upright, vec![true, true, true, false, false, false, false, false]); + // In vert nothing stands upright. + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical); + let l = lay(&p, &sh, 300.0, 600.0); + assert!(l.runs.iter().all(|r| !r.upright)); + assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1')); +} From b8a665f0caa13ad94d932ae8849cc16b823c6220 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:44:00 -0400 Subject: [PATCH 13/59] Text: mongolianVert and wordArtVert (stacked) text as PowerPoint draws them mongolianVert was read as vert (and saved back as vert). PowerPoint turns it like vert but runs its lines from left to right, the anchor mirrored with them (bottom-anchored text sits on the right). It is now its own TextDir, kept on save; its lines' places are mirrored across the box. wordArtVert (Stacked) was turned like vert. PowerPoint stands every character upright, one under the other (Latin letters about one em apart, spaces their own width), with lines running left to right. Stacked text now stands each character upright in an em of its own and mirrors the line order. Also: a bullet in the second or later column of a multi-column body is drawn on its line's baseline (it kept the first column's). w01-vertical s4 (mongolianVert) 11.54 -> 9.38, s5 (wordArtVert) 13.84 -> 13.56; w14-xml s13 6.78 -> 6.60. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/model/src/text.rs | 6 +++- crates/pptx/src/read/dml.rs | 6 ++++ crates/text/src/lib.rs | 68 ++++++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 30 ++++++++++++++++ 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index b8138a39..d4206fdd 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -55,6 +55,8 @@ pub enum TextDir { Vertical270, Stacked, EaVertical, + /// `mongolianVert`: turned like `vert`, lines running from left to right. + MongolianVertical, } impl TextDir { @@ -65,12 +67,14 @@ impl TextDir { TextDir::Vertical270 => "vert270", TextDir::Stacked => "wordArtVert", TextDir::EaVertical => "eaVert", + TextDir::MongolianVertical => "mongolianVert", } } pub fn from_xml(s: &str) -> Option { Some(match s { "horz" => TextDir::Horizontal, - "vert" | "mongolianVert" => TextDir::Vertical, + "vert" => TextDir::Vertical, + "mongolianVert" => TextDir::MongolianVertical, "vert270" => TextDir::Vertical270, "wordArtVert" | "wordArtVertRtl" => TextDir::Stacked, "eaVert" => TextDir::EaVertical, diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 7bec2e63..800df0b4 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -616,5 +616,11 @@ mod tests { assert_eq!(b.vert, Some(TextDir::Vertical270)); assert_eq!(b.columns, Some(2)); assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 })); + // mongolianVert is its own direction (PowerPoint runs its lines left to right) and is + // written back as it was read. + let d = parse(br#""#).unwrap(); + let b = body_pr(&d.root); + assert_eq!(b.vert, Some(TextDir::MongolianVertical)); + assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert")); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 0b02d2c1..1df46e10 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -703,7 +703,7 @@ fn shape_para( opts: &Opts, number: Option, no_kern: bool, - vertical: bool, + vertical: Upright, ) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { @@ -831,7 +831,7 @@ fn shape_cells( styles: &[Style], faces: &mut Vec>, rtl: bool, - vertical: bool, + vertical: Upright, ) -> Vec { let db = FontDb::global(); let chars: Vec = text.chars().collect(); @@ -911,9 +911,9 @@ fn shape_cells( .collect(); // In East Asian vertical text, ideographs, kana, Hangul, full-width forms and the like stand // upright (a whole grapheme at a time); other text lies on its side. - if vertical { + if vertical != Upright::None { for &(a, b) in &graphemes { - let up = chars.get(a).is_some_and(|c| is_upright(*c)); + let up = chars.get(a).is_some_and(|c| if vertical == Upright::All { !c.is_whitespace() } else { is_upright(*c) }); for c in cells.get_mut(a..b).into_iter().flatten() { c.upright = up; } @@ -940,6 +940,30 @@ fn shape_cells( for (a, b) in items { shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a)); } + // Stacked text: each character takes one em down the line, centred across it (PowerPoint + // stacks 18 pt Latin letters about 18 pt apart, a space only its own width). + if vertical == Upright::All { + for i in 0..n { + let Some(c) = cells.get(i) else { continue }; + if !c.upright || c.head != i || c.glyphs.is_empty() { + continue; + } + let size = styles.get(c.style).map(|st| st.size).unwrap_or(18.0); + let width: f64 = (i..n).take_while(|&j| cells.get(j).is_some_and(|d| d.head == i)).filter_map(|j| cells.get(j)).map(|d| d.adv).sum(); + let pad = (size - width) / 2.0; + if let Some(c) = cells.get_mut(i) { + for g in &mut c.glyphs { + g.1 += pad; + } + } + for j in i..n { + match cells.get_mut(j) { + Some(d) if d.head == i => d.adv = if j == i { size } else { 0.0 }, + _ => break, + } + } + } + } cells } @@ -983,6 +1007,17 @@ fn is_upright(c: char) -> bool { ) } +/// Which characters stand upright in vertical text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Upright { + /// None (horizontal text, vert, vert270, mongolianVert). + None, + /// Ideographs, kana and the like (eaVert). + Cjk, + /// Every character, one under the other (wordArtVert). + All, +} + /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; @@ -1553,10 +1588,18 @@ fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); - // East Asian vertical text: CJK stands upright in text turned a quarter turn. - let upright_text = vert == TextDir::EaVertical; + // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text + // stands every character upright, one under the other. + let upright_text = match vert { + TextDir::EaVertical => Upright::Cjk, + TextDir::Stacked => Upright::All, + _ => Upright::None, + }; + // Mongolian vertical and stacked text: turned like vert, their lines run from left to right + // (as PowerPoint draws both). + let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked); let rotation = match vert { - TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0, + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::MongolianVertical => 90.0, TextDir::Vertical270 => 270.0, TextDir::Horizontal => 0.0, } + bp.rot.unwrap_or(0.0); @@ -1840,8 +1883,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts line.baseline += oy; line.bottom += oy; line.shift_x(ox); + if lines_ltr { + // The line's place mirrored across the box: the first line ends up on the left. + let d = inner.y0 + inner.y1 - line.top - line.bottom; + line.top += d; + line.baseline += d; + line.bottom += d; + } let Some(p) = shaped_paras.get(line.para) else { continue }; - if let Some((s, bx, by)) = bullet.take() + if let Some((s, bx, _)) = bullet.take() && let Some((_, st)) = p.bullet.as_ref() { let (_, g) = measure_str(&st.face, st.size, &s); @@ -1851,7 +1901,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts color: st.color, glyphs: g .into_iter() - .map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy)) + .map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, line.baseline)) .collect(), cells: vec![], fake_bold: false, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 46091805..ac02faf9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1088,3 +1088,33 @@ fn east_asian_vertical_text_marks_cjk_upright() { assert!(l.runs.iter().all(|r| !r.upright)); assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1')); } + +#[test] +fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() { + let (p, mut sh) = setup("one\ntwo", 200.0, 400.0); + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical); + let v = lay(&p, &sh, 200.0, 400.0); + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::MongolianVertical); + let m = lay(&p, &sh, 200.0, 400.0); + assert_eq!(m.rotation, 90.0); + // In layout space (turned a quarter turn) vert puts the first line at the top, which shows on + // the right; mongolianVert mirrors the line places, so the first line shows on the left. + assert!(v.lines[0].top < v.lines[1].top); + assert!(m.lines[0].top > m.lines[1].top); + let mid = (m.inner.y0 + m.inner.y1) / 2.0; + assert!(((m.lines[0].top + m.lines[0].bottom) / 2.0 - mid + ((v.lines[0].top + v.lines[0].bottom) / 2.0 - mid)).abs() < 0.01); + // Stacked: every letter upright, one em apart. + let (p, mut sh) = setup("Lil", 200.0, 400.0); + let t = sh.text.as_mut().unwrap(); + t.body.vert = Some(deckcraft_model::text::TextDir::Stacked); + t.paragraphs[0].runs[0].props.size = Some(20.0); + let s = lay(&p, &sh, 200.0, 400.0); + assert!(s.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright)); + // Its lines run left to right too. + let (p2, mut sh2) = setup("one\ntwo", 200.0, 400.0); + sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked); + let st = lay(&p2, &sh2, 200.0, 400.0); + assert!(st.lines[0].top > st.lines[1].top); + let e = &s.lines[0].edges; + assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}"); +} From 232501a9f351570f6a9b12d2d991c18311d81f84 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:57:48 -0400 Subject: [PATCH 14/59] Fonts: emoji presentation as PowerPoint draws it; keycaps and text pictographs in a symbol font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed on PowerPoint's export of pictographs in Aptos, Arial and Segoe UI: - characters that default to emoji presentation draw in colour without a selector: ⭐ ✅ ☔ ⌚ ⏩ and the other BMP Emoji_Presentation characters (DeckCraft only knew the supplementary-plane ones and drew ⭐ ✅ as text); - text-presentation pictographs (⚠ ♥ ✔ ☎ ⬆ ➡ ▶ ⤴) draw as text even where the colour font is the first installed font that has them: they now come from a plain symbol font, symbol families first, never the colour one; and a symbol after an emoji no longer inherits its colour face; - keycap sequences ("1️⃣", "#⃣") draw as text, an outlined key, not the colour keycap. w14-xml s7 (pictographs) 9.79 -> 6.16, w09-scripts s1 (emoji) 11.56 -> 10.27. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fallback.rs | 85 ++++++++++++++++++++++++++++++------ crates/fonts/src/fontdb.rs | 73 +++++++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 9 +++- crates/text/src/lib.rs | 6 ++- crates/text/src/tests.rs | 16 +++++++ 5 files changed, 172 insertions(+), 17 deletions(-) diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index f4e1100c..8e6684f0 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -24,20 +24,72 @@ fn ignorable(c: char) -> bool { || matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF) } -/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or -/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between -/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap? +/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation +/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an +/// emoji presentation selector (U+FE0F), a zero-width joiner between pictographs, a skin tone +/// modifier or regional indicators (flags)? Keycaps ("1️⃣") are not: PowerPoint draws them as text +/// (an outlined key), as it draws ♥ ✔ ☎ ⬆ ▶ without a selector. pub fn is_emoji_cluster(cluster: &[char]) -> bool { - if cluster.contains(&'\u{FE0E}') { + if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') { return false; } let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF); - let default_emoji = - |c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF); + let default_emoji = |c: char| { + matches!( + c as u32, + 0x1F300..=0x1F64F + | 0x1F680..=0x1F6FF + | 0x1F900..=0x1F9FF + | 0x1FA70..=0x1FAFF + | 0x1F1E6..=0x1F1FF + | 0x1F004 + | 0x1F0CF + | 0x1F18E + | 0x1F191..=0x1F19A + | 0x1F201 + | 0x1F21A + | 0x1F22F + | 0x1F232..=0x1F236 + | 0x1F238..=0x1F23A + | 0x1F250..=0x1F251 + | 0x231A..=0x231B + | 0x23E9..=0x23EC + | 0x23F0 + | 0x23F3 + | 0x25FD..=0x25FE + | 0x2614..=0x2615 + | 0x2648..=0x2653 + | 0x267F + | 0x2693 + | 0x26A1 + | 0x26AA..=0x26AB + | 0x26BD..=0x26BE + | 0x26C4..=0x26C5 + | 0x26CE + | 0x26D4 + | 0x26EA + | 0x26F2..=0x26F3 + | 0x26F5 + | 0x26FA + | 0x26FD + | 0x2705 + | 0x270A..=0x270B + | 0x2728 + | 0x274C + | 0x274E + | 0x2753..=0x2755 + | 0x2757 + | 0x2795..=0x2797 + | 0x27B0 + | 0x27BF + | 0x2B1B..=0x2B1C + | 0x2B50 + | 0x2B55 + ) + }; cluster.iter().any(|&c| default_emoji(c)) - || (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*')) + || (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c))) || (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c))) - || cluster.contains(&'\u{20E3}') } /// Does `face` draw every character of the cluster? @@ -86,12 +138,17 @@ impl FontDb { if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) { return f.clone(); } - // Text presentation (U+FE0E): a plain face, not a colour emoji one. - if cluster.contains(&'\u{FE0E}') { - let faces = self.read_faces(); - if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) { - return f.clone(); - } + // Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one. + // So does a pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) when the colour font + // is the first to have it: PowerPoint draws those from a plain symbol font. + let plain = cluster.contains(&'\u{FE0E}') + || cluster.contains(&'\u{20E3}') + || (!is_emoji_cluster(cluster) && cluster.iter().any(|c| matches!(*c as u32, 0x2190..=0x2BFF | 0x1F000..=0x1FAFF))); + if plain + && let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) + && let Some(f) = self.plain_face_for(key, cluster) + { + return f; } let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style)); let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() }; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 861d2bd0..decc7bfc 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1076,6 +1076,79 @@ impl FontDb { None } + /// A loaded plain (not colour) face drawing all of `cluster`, loading an installed plain font + /// that covers `key` if none is loaded yet. Misses are remembered. + pub(crate) fn plain_face_for(&self, key: char, cluster: &[char]) -> Option> { + // Symbol fonts first (PowerPoint draws ⚠ ♥ ⬆ from the system's symbol font), then the + // usual fallback families in order, then any other. + let rank = |f: &FontFace| { + if f.family.contains("Symbol") { + return 0; + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(i) = SYSTEM_FALLBACKS.iter().position(|s| s.eq_ignore_ascii_case(&f.family)) { + return i + 1; + } + 1000 + }; + let pick = |db: &FontDb| { + let faces = db.read_faces(); + let mut v: Vec<&Arc> = faces.iter().filter(|f| !f.is_color() && crate::covers_cluster(f, cluster)).collect(); + v.sort_by_key(|f| (rank(f), f.italic, (f.weight - 400.0).abs() as i32)); + v.first().map(|f| (*f).clone()) + }; + #[cfg(not(target_arch = "wasm32"))] + { + let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); + for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) { + if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { + self.load_cataloged(fam); + } + } + } + if let Some(f) = pick(self) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_plain_fallback(key) { + return pick(self); + } + let _ = key; + None + } + + /// Load an installed font without colour tables covering `c`. True if one was loaded. + #[cfg(not(target_arch = "wasm32"))] + fn system_plain_fallback(&self, c: char) -> bool { + static MISSES: Mutex>> = Mutex::new(None); + if c.is_control() || c.is_whitespace() { + return false; + } + if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled { + return false; + } + if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) { + return false; + } + let mut paths: Vec = self.read_catalog().iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + for p in paths { + if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| { + !has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()) + }); + if hit && self.add_font(data) > 0 { + return true; + } + } + MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c); + false + } + /// Load an installed colour font covering `c`. True if one was loaded. #[cfg(not(target_arch = "wasm32"))] fn system_color_fallback(&self, c: char) -> bool { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 16be2ca5..978661bb 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -141,10 +141,15 @@ mod tests { #[test] fn emoji_clusters_are_recognised() { let c = |s: &str| s.chars().collect::>(); - for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] { + // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even + // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and + // keycaps always as text. + for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] + { assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); } - for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] { + for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] + { assert!(!is_emoji_cluster(&c(no)), "{no:?}"); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1df46e10..cd436300 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -884,7 +884,11 @@ fn shape_cells( .filter(|f| deckcraft_fonts::covers_cluster(f, cluster)); let face = match (sym_face, prev.as_ref()) { (Some(f), _) => f, - (None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(), + (None, Some(p)) + if neutral && !p.is_color() && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => + { + p.clone() + } (None, _) => { let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc)); db.cascade(cluster, chain, st.style_name) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ac02faf9..65f38aee 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1118,3 +1118,19 @@ fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() { let e = &s.lines[0].edges; assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}"); } + +#[test] +fn symbols_after_an_emoji_keep_a_text_face() { + // "✅ ⚠ ™": PowerPoint draws ✅ (emoji presentation) in colour and the symbols after it as text. + let db = FontDb::global(); + if !db.cascade(&['✅'], &[], "Regular").is_color() { + return; + } + let (p, sh) = bidi_setup(vec![Run::new("✅ ⚠ ™")], 2000.0, false, Align::Left); + let l = lay(&p, &sh, 2000.0, 300.0); + for r in &l.runs { + for (_, c) in &r.cells { + assert_eq!(r.face.is_color(), *c == '✅', "{c:?} drawn with {}", r.face.family); + } + } +} From 85a7f0ce3e4fa190fa6128461cf15f79adea6870 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:07:56 -0400 Subject: [PATCH 15/59] Text: a fallback face (emoji, symbols, a missing script) no longer moves the line's baseline Line height and baseline were the largest over every face on the line, fallback faces included, so a line with an emoji, a math symbol or Thai in an Aptos run sat 1-5 px lower than PowerPoint's and pushed the lines below it down. PowerPoint keeps the run's own font metrics (measured: the second line of six boxes starts at the same height whatever the first line holds). Faces that are the run's Latin, East Asian, complex-script or symbol font still count. w14-xml s8 (baselines) 5.54 -> 3.21, w09-scripts s1 10.27 -> 8.39, s2 9.37 -> 7.46, w15-mixed s8 13.65 -> 8.77; w15-mixed s2 (Arabic) 6.57 -> 7.05. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 6 +++++- crates/text/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index cd436300..6354d171 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1684,7 +1684,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { let sz = st.size; - let (a, d) = line_metrics(face, sz); + // The run's own fonts set the line; a fallback face (emoji, a symbol or a + // script the run's fonts lack) does not move the baseline in PowerPoint. + let own = Arc::ptr_eq(face, &st.face) + || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); + let (a, d) = line_metrics(if own { face } else { &st.face }, sz); asc = asc.max(a); desc = desc.max(d); size = size.max(sz); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 65f38aee..0b2c6ccb 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1134,3 +1134,19 @@ fn symbols_after_an_emoji_keep_a_text_face() { } } } + +#[test] +fn fallback_faces_do_not_move_the_baseline() { + // PowerPoint keeps a line's baseline and pitch from the run's own font when an emoji, a symbol + // or a script the font lacks is drawn from another face. + let base = |text: &str| { + let (p, sh) = bidi_setup(vec![Run::new(text)], 2000.0, false, Align::Left); + let l = lay(&p, &sh, 2000.0, 300.0); + (l.lines[0].baseline, l.lines[0].bottom) + }; + let plain = base("Hxg Hxg"); + for text in ["Hxg \u{1F600} Hxg", "Hxg \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}", "Hxg \u{222B}\u{2211}"] { + let b = base(text); + assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}"); + } +} From 30b2daadb65057862f4b24b70933bbb532ca4a76 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:10:30 -0400 Subject: [PATCH 16/59] Text: character spacing leaves kerning on again (only Arial drops it in PowerPoint) A wider probe (Arial, Aptos, Calibri, Segoe UI, Times New Roman, Verdana at 40 pt, each box with one paragraph spaced 2 pt) shows the earlier rule holds for Arial alone: Calibri, Segoe UI and Times New Roman keep "AVATAR" kerned beside and inside spaced runs (121 vs 131 px for Calibri). The rule made spaced Aptos and Calibri text too wide (w15-mixed s9 5.46 -> 7.86), so it is removed; Arial's behaviour stays a known difference. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 41 +++++++--------------------------------- crates/text/src/tests.rs | 11 ++++++----- 2 files changed, 13 insertions(+), 39 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 6354d171..63c88089 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -694,17 +694,7 @@ pub fn autonum(scheme: &str, n: u32) -> String { } #[allow(clippy::too_many_arguments)] -fn shape_para( - ctx: &Ctx, - shape: &Shape, - body: &TextBody, - pi: usize, - scale: f64, - opts: &Opts, - number: Option, - no_kern: bool, - vertical: Upright, -) -> ParaShaped { +fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option, vertical: Upright) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { let face = db.face("Inter", "Regular"); @@ -729,8 +719,7 @@ fn shape_para( let mut char_style: Vec = vec![]; for run in ¶.runs { let rp = resolve::run(ctx, shape, para, &run.props); - let mut st = make_style(ctx, &rp, scale, opts.prompt_color); - st.kern &= !no_kern; + let st = make_style(ctx, &rp, scale, opts.prompt_color); let t: String = match &run.kind { RunKind::Text => run.text.clone(), RunKind::Break => "\u{b}".into(), @@ -1549,8 +1538,7 @@ fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout { let bp = resolve::body(ctx, shape); let autofit = bp.autofit.unwrap_or(AutoFit::None); - let no_kern = spacing_stops_kerning(ctx, shape, body); - let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0, no_kern); + let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0); if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) && l.overflow { @@ -1560,7 +1548,7 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay for _ in 0..12 { let mid = (lo + hi) / 2.0; let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2); - let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red, no_kern); + let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red); if t.overflow { hi = mid; } else { @@ -1568,28 +1556,13 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay best = Some(t); } } - l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2, no_kern)); + l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2)); } l } -/// Does character spacing switch pair kerning off in this body? PowerPoint stops kerning every run -/// of a text body once one of its runs is spaced out by 0.6 pt or more (measured on Arial at 40 pt: -/// 0.5 pt keeps the pairs kerned, 0.6, 0.8, 1 and 1.5 pt drop them for the whole box, even in -/// paragraphs without spacing). Condensed spacing mostly keeps them (-0.6, -1, -1.5, -2 and -3 pt; -/// -1.2 and -2.5 pt did not, which no simple rule explains, so condensing never stops kerning -/// here: titles condensed by 1 pt are common and kerned). -fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { - body.paragraphs.iter().any(|para| { - para.runs.iter().any(|run| { - let s = resolve::run(ctx, shape, para, &run.props).spacing.unwrap_or(0.0); - s >= 0.55 - }) - }) -} - #[allow(clippy::too_many_arguments)] -fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { +fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text @@ -1652,7 +1625,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } None }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern, upright_text)); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text)); } for (pi, p) in shaped_paras.iter().enumerate() { let props = &p.props; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 0b2c6ccb..bbcc8087 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -122,7 +122,7 @@ fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 { } #[test] -fn character_spacing_in_the_body_stops_kerning() { +fn character_spacing_in_the_body_keeps_kerning() { let kerns = |f: &FontFace| { let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::(); w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::>()) @@ -133,11 +133,12 @@ fn character_spacing_in_the_body_stops_kerning() { }; let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0)); let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0)); - // Small or condensed spacing elsewhere keeps the pairs; 0.6 pt anywhere in the box drops them. - for (spc, kerned) in [(0.0, true), (0.5, true), (-0.99, true), (-1.0, true), (-2.0, true), (0.6, false), (1.0, false), (3.0, false)] { + assert!(on < off); + // Spacing elsewhere in the box leaves the pairs kerned (Calibri, Segoe UI, Times New Roman in + // PowerPoint; only Arial drops them). + for spc in [0.0, 0.5, -1.0, 0.6, 2.0] { let w = kerned_width_beside_spacing(&family, spc); - let want = if kerned { on } else { off }; - assert!((w - want).abs() < 0.01, "{family} spc {spc}: {w} vs {want}"); + assert!((w - on).abs() < 0.01, "{family} spc {spc}: {w} vs {on}"); } } From 3d22ca642d96bcd8cc95555636be3ff1262ab874 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:15:17 -0400 Subject: [PATCH 17/59] Fonts: only steer a symbol away from the colour font when that is the face it would get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change sent every text-presentation pictograph to a symbol font, which also moved bullets such as "▪" (Wingdings substitutes) from the face they had to a wider one: necc- presentation-0 s13 18.78 -> 19.72. Now a pictograph is redirected to a plain face only when its fallback would be the colour emoji font; keycaps and U+FE0E sequences still take a plain face. necc s12-s14 back to 26.38 / 18.78 / 20.91; w14-xml s7 (pictographs) 9.79 -> 7.49 overall. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fallback.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index 8e6684f0..fa3c830d 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -17,6 +17,11 @@ use skrifa::raw::TableProvider; use crate::script::{ScriptTag, script_of}; use crate::{CRAFT_FONTS, FontDb, FontFace}; +/// Variation selector 15: text presentation. +const TEXT_SELECTOR: char = '\u{FE0E}'; +/// Combining enclosing keycap. +const KEYCAP: char = '\u{20E3}'; + /// Characters that never decide a face: they're drawn by whatever face the cluster gets. fn ignorable(c: char) -> bool { c.is_whitespace() @@ -139,12 +144,8 @@ impl FontDb { return f.clone(); } // Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one. - // So does a pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) when the colour font - // is the first to have it: PowerPoint draws those from a plain symbol font. - let plain = cluster.contains(&'\u{FE0E}') - || cluster.contains(&'\u{20E3}') - || (!is_emoji_cluster(cluster) && cluster.iter().any(|c| matches!(*c as u32, 0x2190..=0x2BFF | 0x1F000..=0x1FAFF))); - if plain + let text_style = cluster.contains(&TEXT_SELECTOR) || cluster.contains(&KEYCAP); + if text_style && let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) && let Some(f) = self.plain_face_for(key, cluster) { @@ -181,11 +182,20 @@ impl FontDb { } } let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX); - match self.fallback_for(key, exclude) { + let f = match self.fallback_for(key, exclude) { Some(f) if covers_cluster(&f, cluster) => f, Some(f) if chain.iter().all(|c| !c.covers(key)) => f, _ => first(), + }; + // A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the + // colour emoji font: PowerPoint draws it from a plain symbol font. + if f.is_color() + && !is_emoji_cluster(cluster) + && let Some(plain) = self.plain_face_for(key, cluster) + { + return plain; } + f } /// An installed (non-bundled) face covering the cluster, loading one from the system catalog From bab3854484cfe674c5b33e2c0e50848acf5a9848 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:23:50 -0400 Subject: [PATCH 18/59] Text: strikethrough at the font's strikeout position and thickness The strikethrough was drawn 0.3 em above the baseline, 0.05 em thick, for every font. PowerPoint uses the font's own strikeout metrics (OS/2): Arial 96 pt strikes rows 347-351, 24.8 px above the baseline (530/2048 em) and 4.8 px thick; DeckCraft drew 343-347. Faces now carry their strikeout metrics (0.26/0.05 em when a font has none) and the line matches PowerPoint's rows exactly. w14-xml s11 4.06 -> 3.24. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 16 ++++++++++++++++ crates/text/src/lib.rs | 6 ++++-- crates/text/src/tests.rs | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index decc7bfc..5425c022 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -137,6 +137,9 @@ pub struct FontFace { /// Underline position (top edge, below the baseline) and thickness in font units (`post` /// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there. pub underline: (f64, f64), + /// Strikethrough position (top edge, above the baseline) and thickness in font units (OS/2; + /// 0.26 and 0.05 em when the font has none). PowerPoint draws single strikethroughs there. + pub strikeout: (f64, f64), pub shaper: harfrust::ShaperData, /// Variable fonts: the named instance's axis settings (user units; empty = default instance), /// the normalised location and the shaper's view of it. @@ -590,6 +593,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64), underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64), + strikeout: strikeout_metrics(m.strikeout, m.units_per_em.max(1) as f64), shaper, coords, location, @@ -613,6 +617,18 @@ fn underline_metrics(d: Option, upem: f64) -> (f64, } } +/// The strikethrough's top edge above the baseline and its thickness, font units, from OS/2 (its +/// position is the top edge: Arial's 530/2048 is where PowerPoint's strikethrough starts, 25 px +/// above the baseline at 96 pt); fonts without a usable one get 0.26 em and 0.05 em. +fn strikeout_metrics(d: Option, upem: f64) -> (f64, f64) { + match d { + Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && d.offset > 0.0 && (d.offset as f64) < upem => { + (d.offset as f64, d.thickness as f64) + } + _ => (upem * 0.26, upem * 0.05), + } +} + /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 63c88089..125be161 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1999,8 +1999,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } } if let Some(sk) = st.strike { - let t = (st.size * 0.05).max(0.5); - let sy = line.baseline - st.size * 0.3; + // At the strikeout position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let t = (st.face.strikeout.1 * k).max(0.5); + let sy = line.baseline - st.face.strikeout.0 * k; out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index bbcc8087..5276b7fa 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1151,3 +1151,18 @@ fn fallback_faces_do_not_move_the_baseline() { assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}"); } } + +#[test] +fn strikethrough_sits_at_the_font_strikeout_position() { + let (p, mut sh) = setup("Struck", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.size = Some(96.0); + para.runs[0].props.strike = Some(Strike::Single); + let l = lay(&p, &sh, 2000.0, 300.0); + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + let k = 96.0 / r.face.upem; + let d = l.decos.iter().find(|d| !d.behind).expect("a strikethrough"); + assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position"); + assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01); +} From f5fef503640b33d4d25732f0a40d981e79f4b5be Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:28:29 -0400 Subject: [PATCH 19/59] Text: rtlCol puts the first column on the right; just/dist anchoring at the bottom; wordArtVertRtl Observed on PowerPoint's export (w14-xml s12): - a two-column body with rtlCol="1" starts its first column on the right; DeckCraft read and saved the attribute but laid columns out left to right; - anchor="just" and anchor="dist" place the text at the bottom of the box, like anchor="b"; DeckCraft drew them at the top; - wordArtVertRtl stacks characters like wordArtVert but runs its lines from right to left; it was read as wordArtVert (and saved back as it). It is now its own TextDir, kept on save. w14-xml s12 9.47 -> 5.39. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/model/src/text.rs | 6 +++++- crates/pptx/src/read/dml.rs | 2 ++ crates/text/src/lib.rs | 23 ++++++++++++++--------- crates/text/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index d4206fdd..6a1a7527 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -57,6 +57,8 @@ pub enum TextDir { EaVertical, /// `mongolianVert`: turned like `vert`, lines running from left to right. MongolianVertical, + /// `wordArtVertRtl`: stacked, lines running from right to left. + StackedRtl, } impl TextDir { @@ -68,6 +70,7 @@ impl TextDir { TextDir::Stacked => "wordArtVert", TextDir::EaVertical => "eaVert", TextDir::MongolianVertical => "mongolianVert", + TextDir::StackedRtl => "wordArtVertRtl", } } pub fn from_xml(s: &str) -> Option { @@ -76,7 +79,8 @@ impl TextDir { "vert" => TextDir::Vertical, "mongolianVert" => TextDir::MongolianVertical, "vert270" => TextDir::Vertical270, - "wordArtVert" | "wordArtVertRtl" => TextDir::Stacked, + "wordArtVert" => TextDir::Stacked, + "wordArtVertRtl" => TextDir::StackedRtl, "eaVert" => TextDir::EaVertical, _ => return None, }) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 800df0b4..ab67a25e 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -622,5 +622,7 @@ mod tests { let b = body_pr(&d.root); assert_eq!(b.vert, Some(TextDir::MongolianVertical)); assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert")); + let d = parse(br#""#).unwrap(); + assert_eq!(body_pr(&d.root).vert.map(|v| v.xml()), Some("wordArtVertRtl")); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 125be161..d6bc66bf 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1569,14 +1569,14 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // stands every character upright, one under the other. let upright_text = match vert { TextDir::EaVertical => Upright::Cjk, - TextDir::Stacked => Upright::All, + TextDir::Stacked | TextDir::StackedRtl => Upright::All, _ => Upright::None, }; // Mongolian vertical and stacked text: turned like vert, their lines run from left to right // (as PowerPoint draws both). let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked); let rotation = match vert { - TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::MongolianVertical => 90.0, + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, TextDir::Vertical270 => 270.0, TextDir::Horizontal => 0.0, } + bp.rot.unwrap_or(0.0); @@ -1828,6 +1828,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } let total = y; out.content_height = total; + // Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the + // first column on the right). + let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; + let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap); // Columns: distribute lines by height. let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY }; let mut col = 0usize; @@ -1843,16 +1847,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts li.top -= dy; li.baseline -= dy; li.bottom -= dy; - li.shift_x(col as f64 * (col_w + col_gap)); + } + let cx = col_x(col); + if cx != 0.0 { + li.shift_x(cx); } } let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total }; out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5)); let anchor = bp.anchor.unwrap_or(Anchor::Top); let dy = match anchor { - Anchor::Top | Anchor::Justified | Anchor::Distributed => 0.0, + Anchor::Top => 0.0, Anchor::Middle => (inner.height() - used_h) / 2.0, - Anchor::Bottom => inner.height() - used_h, + // PowerPoint draws justified and distributed anchoring at the bottom. + Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, }; // anchorCtr: centre the block horizontally. let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 }; @@ -1880,10 +1888,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts face: st.face.clone(), size: st.size, color: st.color, - glyphs: g - .into_iter() - .map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, line.baseline)) - .collect(), + glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(), cells: vec![], fake_bold: false, fake_italic: false, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 5276b7fa..39e4626f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1116,6 +1116,11 @@ fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() { sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked); let st = lay(&p2, &sh2, 200.0, 400.0); assert!(st.lines[0].top > st.lines[1].top); + // wordArtVertRtl stacks the same way with lines from right to left. + sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::StackedRtl); + let sr = lay(&p2, &sh2, 200.0, 400.0); + assert!(sr.lines[0].top < sr.lines[1].top); + assert!(sr.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright)); let e = &s.lines[0].edges; assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}"); } @@ -1166,3 +1171,28 @@ fn strikethrough_sits_at_the_font_strikeout_position() { assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position"); assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01); } + +#[test] +fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() { + let text = "word ".repeat(200); + let (p, mut sh) = setup(&text, 400.0, 200.0); + let t = sh.text.as_mut().unwrap(); + t.body.columns = Some(2); + t.body.col_spacing = Some(20.0); + t.paragraphs[0].runs[0].props.size = Some(12.0); + let ltr = lay(&p, &sh, 400.0, 200.0); + sh.text.as_mut().unwrap().body.rtl_col = Some(true); + let rtl = lay(&p, &sh, 400.0, 200.0); + let first = |l: &TextLayout| l.lines.iter().find(|x| x.column == 0).map(|x| x.extent.0).unwrap(); + let second = |l: &TextLayout| l.lines.iter().find(|x| x.column == 1).map(|x| x.extent.0).unwrap(); + assert!(first(<r) < second(<r)); + assert!(first(&rtl) > second(&rtl), "with rtlCol the first column is on the right"); + // anchor="just" and "dist" draw like "b". + let (p, mut sh) = setup("one line", 400.0, 300.0); + sh.text.as_mut().unwrap().body.anchor = Some(Anchor::Bottom); + let b = lay(&p, &sh, 400.0, 300.0).lines[0].top; + for a in [Anchor::Justified, Anchor::Distributed] { + sh.text.as_mut().unwrap().body.anchor = Some(a); + assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}"); + } +} From 6912b5df76256ba372de50a6218759a20d6cf768 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:33:02 -0400 Subject: [PATCH 20/59] Fonts: a letter with combining marks the run's font lacks takes a face that has them all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Z̴̡̛͓a̸̧̘l̵̩g̶̞o̸̤" in an Aptos run drew its marks as missing-glyph boxes: no face in the chain covered the whole cluster and the fallback for its base letter neither, so the run's own font was kept. PowerPoint draws the marks stacked. A loaded plain face that covers the whole cluster is now taken before falling back to the run's font. w09-scripts s1 8.40 -> 7.86. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fallback.rs | 5 ++++- crates/text/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index fa3c830d..563e0337 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -185,7 +185,10 @@ impl FontDb { let f = match self.fallback_for(key, exclude) { Some(f) if covers_cluster(&f, cluster) => f, Some(f) if chain.iter().all(|c| !c.covers(key)) => f, - _ => first(), + // A letter with marks the run's font lacks (stacked combining marks): a loaded plain + // face that draws the whole cluster, as PowerPoint draws the marks, before the run's + // font with missing-glyph boxes. + _ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first), }; // A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the // colour emoji font: PowerPoint draws it from a plain symbol font. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 39e4626f..1e2be177 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1196,3 +1196,23 @@ fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() { assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}"); } } + +#[test] +fn stacked_combining_marks_take_a_face_that_draws_them() { + // A letter with marks the run's font lacks draws in a face that has them all (no boxes). + let db = FontDb::global(); + let cluster = ['Z', '\u{0334}', '\u{0321}', '\u{031B}']; + let run_face = db.face("Inter", "Regular"); + let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular"); + if !deckcraft_fonts::covers_cluster(&run_face, &cluster) && ["DejaVu Sans", "Segoe UI"].iter().any(|fam| db.has_family(fam)) { + // Make sure a face with the marks is loaded, then the cluster must take one. + for fam in ["DejaVu Sans", "Segoe UI"] { + if db.has_family(fam) { + let _ = db.face(fam, "Regular"); + } + } + let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular"); + assert!(deckcraft_fonts::covers_cluster(&f, &cluster), "{} lacks the marks", f.family); + } + let _ = f; +} From 5ca1dad2810e7b0f33bac787aab7ace08282ca87 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:45:13 -0400 Subject: [PATCH 21/59] Docs: progress log for the wild text cases Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 26b2ec62..20da0dca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | + +| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | @@ -148,6 +150,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | From 525ec7b1a9769c0f73fac66ca564027c3f436229 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:48:29 -0400 Subject: [PATCH 22/59] Docs: keep the progress table in one piece --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20da0dca..410c178e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,6 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | - | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | From 39adfcd62ae1bd603b5fc48fc987e5f86748b9c8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:35:22 -0400 Subject: [PATCH 23/59] Render: custom geometry sub-paths fill even-odd, so inner sub-paths cut holes A freeform whose path holds a sub-path inside another (an icon, outlined text, a frame drawn as two rectangles) showed the inner one filled when both wound the same way: DeckCraft filled custom paths non-zero. PowerPoint fills every custom path alternately (even-odd), so the inner sub-path is a hole whichever way it winds. Wild synthetic set: holes slide 7.60 -> 2.84 mean diff. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 7 ++++- crates/render/src/tests_wild.rs | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/tests_wild.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e5fff044..1164dcc9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -435,7 +435,9 @@ pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry { 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)); - out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: false }); + // 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![] } } @@ -1614,6 +1616,9 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_wild; + #[cfg(test)] mod mt_tests { use super::*; diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs new file mode 100644 index 00000000..6da19b53 --- /dev/null +++ b/crates/render/src/tests_wild.rs @@ -0,0 +1,50 @@ +//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports +//! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`). + +use super::*; +use deckcraft_color::Rgba; +use deckcraft_model::style::ColorRef; +use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, Xfrm}; + +fn deck_with(shapes: Vec) -> Presentation { + let mut p = Presentation::default(); + let s = std::sync::Arc::make_mut(&mut p.slides[0]); + s.shapes = shapes; + p +} + +fn render(shapes: Vec) -> Image { + render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +fn solid(c: Rgba) -> Option { + Some(Fill::solid(ColorRef::rgb(c))) +} + +fn no_line() -> Option { + Some(Line { fill: Some(Fill::None), ..Default::default() }) +} + +fn close(px: [u8; 4], c: Rgba, tol: i32) -> bool { + (px[0] as i32 - c.r as i32).abs() <= tol && (px[1] as i32 - c.g as i32).abs() <= tol && (px[2] as i32 - c.b as i32).abs() <= tol +} + +const WHITE: Rgba = Rgba::WHITE; +const RED: Rgba = Rgba::rgb(255, 0, 0); + +#[test] +fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() { + // Outer square clockwise, inner square also clockwise: PowerPoint still shows a hole. + let d = "M 0 0 L 100 0 L 100 100 L 0 100 Z M 25 25 L 75 25 L 75 75 L 25 75 Z"; + let sh = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)), + geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: d.into(), ..Default::default() }] }, + fill: solid(RED), + line: no_line(), + ..Default::default() + }; + let img = render(vec![sh]); + assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled"); + assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200)); +} From 2fca333caceb6a79b9f47b2e0577a4c2b40bf9e9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:43:37 -0400 Subject: [PATCH 24/59] Render: dashes with round caps keep their length; square caps only at the line's ends A round-capped dashed line (PowerPoint's "Round Dot" is sysDot with round caps) drew as a solid line: each dash grew by its caps and closed the one-width gaps. PowerPoint keeps the caps inside each dash, so dots stay separate circles. Square-capped dashes end flat in PowerPoint; only the line's own start (and its end, when it ends in a dash) is squared off. Wild synthetic set: 12 pt round-cap dashes 12.69 -> 7.05, 12 pt square-cap dashes 11.76 -> 6.12, custom dashes 2.12 -> 0.10. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 108 +++++++++++++++++++++++++++++++- crates/render/src/tests_wild.rs | 49 +++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1164dcc9..b3db3eaa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1167,10 +1167,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, pa }; // Hairlines stay visible at any zoom. let min_w = 0.5 / scale_of(m).max(1e-6); - let mut sk = kurbo::Stroke::new(lw.max(min_w)).with_caps(cap).with_join(join); + let sw = lw.max(min_w); + let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join); + // 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 = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); + let pat = dash_with_caps(pat, cap, sw); 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); } } @@ -1200,6 +1209,103 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, pa } 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, bool)> = vec![]; + let mut cur: Vec = 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 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`.) +fn dash_with_caps(mut pat: Vec, cap: kurbo::Cap, width: f64) -> Vec { + 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 } fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 6da19b53..b75ac4cf 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -48,3 +48,52 @@ fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() { assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled"); assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200)); } + +fn dotted_line(cap: deckcraft_model::style::LineCap, len: f64) -> Shape { + Shape { + id: ShapeId(11), + xfrm: Some(Xfrm::new(100.0, 100.0, len, 0.0)), + geom: Geom::preset("line"), + line: Some(Line { + fill: solid(RED), + width: Some(10.0), + dash: Some(deckcraft_model::style::Dash::Preset { name: "sysDot".into() }), + cap: Some(cap), + ..Default::default() + }), + ..Default::default() + } +} + +#[test] +fn round_dots_are_separate_circles_with_the_caps_inside_each_dash() { + use deckcraft_model::style::LineCap; + // PowerPoint's "Round Dot": one width on, one off, round caps. The caps sit inside the dash, so + // the dots stay apart (they used to grow into each other and draw a solid line). + let img = render(vec![dotted_line(LineCap::Round, 200.0)]); + assert!(close(img.pixel(100, 100), RED, 40), "a dot centred on the start {:?}", img.pixel(100, 100)); + assert!(close(img.pixel(120, 100), RED, 40), "the next dot one period on"); + assert!(close(img.pixel(110, 100), WHITE, 10), "a gap between dots {:?}", img.pixel(110, 100)); + // Flat caps: each dash is a width long, from the start. + let img = render(vec![dotted_line(LineCap::Flat, 200.0)]); + assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10)); + // Square caps: dashes end flat; only the line's own start is squared off, half a width back. + let img = render(vec![dotted_line(LineCap::Square, 200.0)]); + assert!(close(img.pixel(97, 100), RED, 40), "square cap before the start {:?}", img.pixel(97, 100)); + assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10), "dashes keep their length"); + assert!(close(img.pixel(122, 100), RED, 40) && close(img.pixel(118, 100), WHITE, 10), "no cap on the next dash"); + // The end: 200 pt is ten whole periods, so the line ends in a gap and gets no cap. + assert!(close(img.pixel(302, 100), WHITE, 10), "{:?}", img.pixel(302, 100)); + // 205 pt ends half way through a dash: squared off past the end. + let img = render(vec![dotted_line(LineCap::Square, 205.0)]); + assert!(close(img.pixel(308, 100), RED, 40), "{:?}", img.pixel(308, 100)); +} + +#[test] +fn dash_lengths_make_room_for_round_and_square_caps() { + assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Round, 1.0), vec![3.0, 4.0]); + assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Round, 2.0), vec![0.002, 3.0]); + assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Square, 2.0), vec![1.0, 1.0]); + assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Butt, 1.0), vec![4.0, 3.0]); + assert_eq!(dash_with_caps(vec![4.0, 3.0, 1.0], kurbo::Cap::Round, 1.0), vec![4.0, 3.0, 1.0]); +} From 90a9933d460492c04095c22ca654dce5a12af571 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:50:47 -0400 Subject: [PATCH 25/59] Render: arrowheads as PowerPoint draws them: the line stops inside, one shape, 2 pt minimum - A wide line poked its corners (and square caps) out past a triangle, stealth or open arrowhead: the line now stops under the head (at a triangle's base, inside a stealth notch, where an open arrow's legs meet). - A see-through line darkened where it overlapped its heads; PowerPoint draws line and heads as one shape, so they are drawn opaque into a faded layer. - Heads on lines thinner than 2 pt were too small: PowerPoint sizes them as for a 2 pt line at least (measured 6 pt for a medium head on 0.25 to 2 pt lines). - An open arrow's point sat a miter past the line's end, with flat-ended legs; PowerPoint puts the point's outside on the end and rounds the legs. Wild synthetic set: open arrows by width 6.85 -> 3.78, 8 pt open arrows 3.84 -> 1.89, triangles by width 3.62 -> 2.17, stealth by width 3.45 -> 2.38. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 174 +++++++++++++++++++++++++++++--- crates/render/src/tests_wild.rs | 64 ++++++++++++ 2 files changed, 225 insertions(+), 13 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b3db3eaa..c9fe0242 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -943,19 +943,47 @@ impl Renderer { 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); + let ends = s.is_line() || geo.is_open(); + 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), + }; + 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 path = if rel == Affine::IDENTITY { + 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. + 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, base, lw, fade(i).stroke); } - if s.is_line() || geo.is_open() { + if ends { arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); } + if see_through.is_some() { + ctx.pop_layer(); + } } if let Some(r) = soft { // Feather: the shape (fill and line) fades to nothing at its outline. Measured on @@ -1336,6 +1364,128 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { 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. +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. +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| { + 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. +fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath { + use kurbo::{ParamCurve, ParamCurveArclen}; + const ACC: f64 = 1e-3; + let mut subs: Vec<(Vec, 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; + while let Some(seg) = segs.first().copied() { + let l = seg.arclen(ACC); + if l > left { + if let Some(s0) = segs.first_mut() { + *s0 = seg.subsegment(seg.inv_arclen(left, ACC)..1.0); + } + break; + } + left -= l; + segs.remove(0); + } + } + 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)] fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, m: Affine, lw: f64) { let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return }; @@ -1346,18 +1496,12 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option Some(Fill::Solid { color: c }) => rctx.color(c, ph), _ => Rgba::BLACK, }; - let lw = lw.max(0.75); 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 sz = |s: &str| match s { - "sm" => 2.0, - "lg" => 5.0, - _ => 3.0, - }; - let (wl, ll) = (sz(&e.w) * lw, sz(&e.len) * lw); + let (wl, ll) = head_size(e, lw); let dir = (tip - from).normalize(); if !dir.x.is_finite() { continue; @@ -1385,13 +1529,17 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option 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); - o.line_to(tip); - o.line_to(base - n * wl / 2.0); + 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); ctx.set_paint(color(col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Miter)); + 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; } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index b75ac4cf..f817d8bd 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -97,3 +97,67 @@ fn dash_lengths_make_room_for_round_and_square_caps() { assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Butt, 1.0), vec![4.0, 3.0]); assert_eq!(dash_with_caps(vec![4.0, 3.0, 1.0], kurbo::Cap::Round, 1.0), vec![4.0, 3.0, 1.0]); } + +fn arrow_line(kind: &str, width: f64, c: Rgba) -> Shape { + let end = deckcraft_model::style::LineEnd { kind: kind.into(), w: "med".into(), len: "med".into() }; + Shape { + id: ShapeId(12), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 0.0)), + geom: Geom::preset("line"), + line: Some(Line { fill: solid(c), width: Some(width), tail: Some(end), ..Default::default() }), + ..Default::default() + } +} + +#[test] +fn a_wide_line_stops_inside_its_arrowhead() { + // 10 pt line, medium triangle (30 pt long, 30 wide) whose tip is the line's end at x = 300. + let img = render(vec![arrow_line("triangle", 10.0, RED)]); + assert!(close(img.pixel(297, 100), RED, 40), "the tip {:?}", img.pixel(297, 100)); + // 2 pt back from the tip the triangle is 2 pt wide: the line's corner must not show there. + assert!(close(img.pixel(298, 104), WHITE, 10), "line corner past the head {:?}", img.pixel(298, 104)); +} + +#[test] +fn a_see_through_line_and_its_arrowhead_are_one_shape() { + let half_red = Rgba::rgba(255, 0, 0, 128); + let img = render(vec![arrow_line("triangle", 10.0, half_red)]); + let line_only = img.pixel(200, 100); + let head_over_line = img.pixel(285, 100); + assert!(close(line_only, Rgba::rgb(255, 127, 127), 6), "{line_only:?}"); + assert!(close(head_over_line, Rgba::rgb(line_only[0], line_only[1], line_only[2]), 3), "no darker overlap: {head_over_line:?}"); +} + +#[test] +fn an_open_arrowheads_point_is_on_the_lines_end() { + let img = render(vec![arrow_line("arrow", 8.0, RED)]); + let reach = (290..320).rev().find(|x| img.pixel(*x, 100)[1] < 128); + assert!(reach.is_some_and(|x| x <= 300), "point reaches {reach:?}"); + assert!(reach.is_some_and(|x| x >= 297), "point reaches {reach:?}"); +} + +#[test] +fn arrowheads_are_sized_for_at_least_a_two_point_line() { + let e = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "sm".into(), len: "lg".into() }; + assert_eq!(head_size(&e, 0.5), (4.0, 10.0)); + assert_eq!(head_size(&e, 4.0), (8.0, 20.0)); +} + +#[test] +fn trimming_a_path_leaves_its_ends_off_by_arc_length() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((50.0, 0.0)); + p.line_to((50.0, 50.0)); + let t = trim_ends(&p, 10.0, 20.0); + let els = t.elements(); + assert_eq!(els.first(), Some(&PathEl::MoveTo(Point::new(10.0, 0.0)))); + match els.last() { + Some(PathEl::LineTo(q)) => assert!((q.x - 50.0).abs() < 1e-6 && (q.y - 30.0).abs() < 1e-6, "{q:?}"), + other => panic!("{other:?}"), + } + // Trims longer than the path leave nothing; closed paths are untouched. + assert!(trim_ends(&p, 60.0, 50.0).elements().is_empty()); + let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1); + assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len()); +} From 0796325a1f7357f90fdc90fadf5d5e42135fe19f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:54:53 -0400 Subject: [PATCH 26/59] Render: radial (circle path) gradients end on the circle around the whole box An off-centre radial gradient ("from corner", or any fillToRect focus other than the middle) spread from its focus out to the farthest corner, so the near side stayed light. Fitted to PowerPoint's export: the first stop is a point at the focus and the last is the circle through the box's corners (centre of the box, half its diagonal), with the circles between moving from one to the other (a two-point radial). A corner focus thus reaches its last colour at its own corner too. Centred gradients are unchanged. Wild synthetic set: off-centre radial background 20.66 -> 1.22, radial focus grid 6.34 -> 5.69. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/paint.rs | 43 ++++++++++++++++++--------------- crates/render/src/tests_wild.rs | 28 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 6dd079fb..13b4f4f2 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -60,16 +60,7 @@ pub fn fill_path( peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) .with_stops(stops.as_slice()) } - GradientShape::Path { focus, .. } => { - let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; - let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; - // Reach the farthest corner, so a gradient from a corner covers the whole box. - let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)] - .iter() - .map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt()) - .fold(1e-6, f64::max); - peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) - } + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()), }; ctx.set_paint(grad); ctx.fill_path(path); @@ -116,19 +107,31 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) .with_stops(stops.as_slice()) } - GradientShape::Path { focus, .. } => { - let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; - let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; - // Reach the farthest corner, so a gradient from a corner covers the whole box. - let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)] - .iter() - .map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt()) - .fold(1e-6, f64::max); - peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) - } + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()), } } +/// A `circle` path gradient over `bounds` with focus rectangle `focus` (`fillToRect` insets, as +/// fractions). As PowerPoint draws it (fitted to its exports): the first stop is a point at the +/// focus (the middle of the focus rectangle) and the last is the circle around the whole box (its +/// centre, half its diagonal), with circles in between moving from one to the other. A focus off +/// the centre so bunches the colours on its near side; a gradient "from a corner" reaches its +/// last colour at that corner too, not only at the far one. +pub fn circle_gradient(focus: [f64; 4], bounds: Rect) -> peniko::Gradient { + let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; + let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; + let c = bounds.center(); + let r = (bounds.width().hypot(bounds.height()) / 2.0).max(1e-6); + let f = Point::new(fx, fy); + if !(f.is_finite() && c.is_finite() && r.is_finite()) { + return peniko::Gradient::new_radial(c, r as f32); + } + // A focus on or outside the end circle has no circle through it: keep it just inside. + let d = f - c; + let f = if d.hypot() > r * 0.999 { c + d * (r * 0.999 / d.hypot()) } else { f }; + peniko::Gradient::new_two_point_radial(f, 0.0, c, r as f32) +} + /// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated /// GIF shows its frame at `gif_time` seconds. pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option) { diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index f817d8bd..5f87c7ac 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -161,3 +161,31 @@ fn trimming_a_path_leaves_its_ends_off_by_arc_length() { let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1); assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len()); } + +fn radial(focus: [f64; 4]) -> Shape { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }]; + Shape { + id: ShapeId(13), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), + fill: Some(Fill::Gradient(Gradient { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true })), + line: no_line(), + ..Default::default() + } +} + +#[test] +fn a_radial_gradient_off_centre_ends_on_the_circle_around_the_box() { + // Focus a fifth of the way in from the top-left corner. PowerPoint's last colour is reached on + // the circle through the box's corners, so the near corner is dark as well as the far one. + let img = render(vec![radial([0.2, 0.2, 0.8, 0.8])]); + assert!(img.pixel(181, 141)[0] > 240, "white at the focus {:?}", img.pixel(181, 141)); + assert!(img.pixel(101, 101)[0] < 60, "near corner dark {:?}", img.pixel(101, 101)); + assert!(img.pixel(498, 298)[0] < 60, "far corner dark {:?}", img.pixel(498, 298)); + // Straight above the focus the box's edge is well inside the circle: still light. + assert!(img.pixel(181, 101)[0] > 150, "{:?}", img.pixel(181, 101)); + // A centred one is symmetric, dark only at the corners. + let img = render(vec![radial([0.5, 0.5, 0.5, 0.5])]); + assert!(img.pixel(300, 200)[0] > 240 && img.pixel(101, 101)[0] < 60); + assert!((img.pixel(110, 200)[0] as i32 - img.pixel(490, 200)[0] as i32).abs() <= 2); +} From b3a980dcb5aea38fa453bd82d1157bebc1375979 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:59:32 -0400 Subject: [PATCH 27/59] Render: perspective shadows scale and skew about their alignment point A shadow's sx/sy/kx/ky were applied about the shape's centre with the skew unscaled, so a perspective shadow (algn=b, sy 23%, kx -20 deg) floated in the middle of the shape instead of lying along its bottom edge. As PowerPoint draws it, the shadow is skewed then scaled about the point of the box its algn names (b: middle of the bottom edge, tl: top-left...). Wild synthetic set: shadow alignment/scale/skew slide 5.57 -> 0.41, picture frames 13.71 -> 12.71. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 31 ++++++++++++++++++++++++---- crates/render/src/tests_wild.rs | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c9fe0242..fe7ca390 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1105,10 +1105,7 @@ impl Renderer { 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 c0 = Point::new(w / 2.0, h / 2.0); - let local = Affine::translate(c0.to_vec2()) - * Affine::new([sh.sx, sh.ky.to_radians().tan(), sh.kx.to_radians().tan(), sh.sy, 0.0, 0.0]) - * Affine::translate(-c0.to_vec2()); + let local = shadow_local(sh, w, h); // Shadow offset is in slide space (doesn't rotate with the shape unless asked). let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local }; ctx.set_transform(Affine::IDENTITY); @@ -1169,6 +1166,32 @@ impl Renderer { } } +/// 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. +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) +} + fn pre_translate(m: Affine, d: Vec2) -> Affine { Affine::translate(d) * m } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 5f87c7ac..c1ec2efc 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -189,3 +189,39 @@ fn a_radial_gradient_off_centre_ends_on_the_circle_around_the_box() { assert!(img.pixel(300, 200)[0] > 240 && img.pixel(101, 101)[0] < 60); assert!((img.pixel(110, 200)[0] as i32 - img.pixel(490, 200)[0] as i32).abs() <= 2); } + +fn shadow(sx: f64, sy: f64, kx: f64, align: &str) -> deckcraft_model::style::Shadow { + deckcraft_model::style::Shadow { + color: ColorRef::rgb(Rgba::BLACK), + blur: 0.0, + dist: 0.0, + dir: 0.0, + inner: false, + sx, + sy, + kx, + ky: 0.0, + align: align.into(), + rotate_with_shape: false, + } +} + +fn with_effects(fill: Option, line: Option, effects: deckcraft_model::Effects) -> Shape { + Shape { id: ShapeId(14), xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)), fill, line, effects: Some(effects), ..Default::default() } +} + +#[test] +fn a_perspective_shadow_lies_along_the_edge_its_alignment_names() { + // Half height, skewed 45° so its top leans right, anchored at the bottom middle: the shadow's + // bottom edge is the shape's (y = 200) and its top (y = 150) is 50 pt to the right. + let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.0, 0.5, -45.0, "b")), ..Default::default() }; + let img = render(vec![with_effects(solid(RED), no_line(), fx)]); + assert!(close(img.pixel(210, 175), Rgba::BLACK, 30), "{:?}", img.pixel(210, 175)); + assert!(close(img.pixel(240, 155), Rgba::BLACK, 30), "{:?}", img.pixel(240, 155)); + assert!(close(img.pixel(240, 180), WHITE, 10), "{:?}", img.pixel(240, 180)); + assert!(close(img.pixel(205, 140), WHITE, 10), "nothing above the half height {:?}", img.pixel(205, 140)); + // Anchored bottom-right and half as big again: it grows up and to the left only. + let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.5, 1.5, 0.0, "br")), ..Default::default() }; + let img = render(vec![with_effects(solid(RED), no_line(), fx)]); + assert!(close(img.pixel(70, 70), Rgba::BLACK, 30) && close(img.pixel(210, 210), WHITE, 10)); +} From 862e2175a751d790794747edc0db9218fedc2c9f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:04:02 -0400 Subject: [PATCH 28/59] Render: shadows are cast by what is drawn: no-fill shapes, see-through fills, lines An outer shadow was always the shape's filled silhouette at the shadow's opacity: an outline-only shape cast a solid block, a 40% fill cast a full shadow that darkened it from behind, and lines (and outlines) cast none. PowerPoint casts the shadow from the drawn shape: the filled part as see-through as its fill (nothing for no fill) plus the outline, which is now stroked into the shadow layer with the line's width, dash and opacity. Wild synthetic set: combined effects slide (no-fill frame, 40% fill, shadows) 21.88 -> 2.34. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 62 ++++++++++++++++++++++++++++++--- crates/render/src/tests_wild.rs | 25 +++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fe7ca390..eba31f8d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -856,7 +856,17 @@ impl Renderer { (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), _ => None, }; - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h); + // 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); + Caster { line: &line, ph: line_ph, alpha: a, base, rel } + }); + self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); } let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); if let Some(r) = soft { @@ -1087,6 +1097,8 @@ impl Renderer { ph: Option, geo: &Geometry, fill: &Option, + fill_alpha: Option, + outline: Option, content: Option<&deckcraft_model::style::PictureFill>, m: Affine, w: f64, @@ -1107,7 +1119,8 @@ impl Renderer { 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 sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local }; + 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))); @@ -1125,9 +1138,27 @@ impl Renderer { 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 => { - ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.fill_path(&silhouette); + 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.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, pre * o.base, lw, o.alpha); } } ctx.pop_layer(); @@ -1166,6 +1197,29 @@ impl Renderer { } } +/// 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`). +struct Caster<'a> { + line: &'a Line, + ph: Option, + alpha: f64, + base: Affine, + 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. +fn fill_opacity(rctx: &Ctx, fill: &Option, ph: Option) -> Option { + 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::() / 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 diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index c1ec2efc..0e1229c7 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -225,3 +225,28 @@ fn a_perspective_shadow_lies_along_the_edge_its_alignment_names() { let img = render(vec![with_effects(solid(RED), no_line(), fx)]); assert!(close(img.pixel(70, 70), Rgba::BLACK, 30) && close(img.pixel(210, 210), WHITE, 10)); } + +fn offset_shadow() -> deckcraft_model::Effects { + let mut sh = shadow(1.0, 1.0, 0.0, "b"); + sh.dist = 20.0; + sh.dir = 0.0; + deckcraft_model::Effects { outer_shadow: Some(sh), ..Default::default() } +} + +#[test] +fn shadows_are_cast_by_what_is_drawn() { + // An outline-only square: its shadow (20 pt to the right) is a frame, not a solid square. + let outline = Some(Line::solid(ColorRef::rgb(RED), 4.0)); + let img = render(vec![with_effects(Some(Fill::None), outline.clone(), offset_shadow())]); + assert!(close(img.pixel(220, 150), Rgba::BLACK, 30), "the frame's shadow {:?}", img.pixel(220, 150)); + assert!(close(img.pixel(160, 150), WHITE, 10), "no shadow inside the frame {:?}", img.pixel(160, 150)); + // A half see-through fill casts a half see-through shadow. + let img = render(vec![with_effects(solid(Rgba::rgba(255, 0, 0, 128)), no_line(), offset_shadow())]); + let px = img.pixel(210, 150); + assert!(close(px, Rgba::rgb(127, 127, 127), 8), "half shadow {px:?}"); + // A line casts a shadow too. + let mut ln = arrow_line("none", 6.0, RED); + ln.effects = Some(offset_shadow()); + let img = render(vec![ln]); + assert!(close(img.pixel(310, 100), Rgba::BLACK, 30), "line shadow {:?}", img.pixel(310, 100)); +} From 7d642e12c9338c51adee22845e02dea26e5d396f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:10:59 -0400 Subject: [PATCH 29/59] Render: a picture's border lies outside the picture DeckCraft centred a picture's outline on its edge, covering half the border's width of the picture. PowerPoint draws picture borders wholly outside: an 18 pt white photo frame leaves the whole photo showing and reaches 18 pt out (measured on its export; a 2 pt border on a cropped picture covers 18-20 pt for a picture starting at 20). Pictures now stroke their border twice as wide, clipped to outside the outline, keeping dash lengths; the border's shadow is cast from there too. Compound borders stay centred. Wild synthetic set: picture frames 13.12 -> 8.54, cropped/flipped pictures with borders 5.79 -> 2.57, crop-to-shape 8.03 -> 5.25. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 43 ++++++++++++++++++++++++++++++--- crates/render/src/tests_wild.rs | 37 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eba31f8d..ae2598e2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -864,7 +864,9 @@ impl Renderer { _ => 1.0, }; let (base, rel) = self.line_space(m, depth); - Caster { line: &line, ph: line_ph, alpha: a, base, rel } + let lw = line.width.unwrap_or(0.75).max(0.0); + let outside = if is_pic { outside_line(&line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } }); self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); } @@ -973,6 +975,22 @@ impl Renderer { } 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. + let outside = is_pic.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 { @@ -991,6 +1009,9 @@ impl Renderer { 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(); } @@ -1153,7 +1174,7 @@ impl Renderer { } // … 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.clone() }; + 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(); @@ -1197,10 +1218,26 @@ impl Renderer { } } +/// 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). +fn outside_line(line: &Line, lw: f64) -> Option { + 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`). struct Caster<'a> { - line: &'a Line, + line: std::borrow::Cow<'a, Line>, ph: Option, alpha: f64, base: Affine, diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 0e1229c7..a58bf3a7 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -250,3 +250,40 @@ fn shadows_are_cast_by_what_is_drawn() { let img = render(vec![ln]); assert!(close(img.pixel(310, 100), Rgba::BLACK, 30), "line shadow {:?}", img.pixel(310, 100)); } + +#[allow(clippy::unwrap_used)] +fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +fn picture_deck(line: Option) -> Presentation { + let mut p = Presentation::default(); + let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255])); + let sh = Shape { + id: ShapeId(15), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + line, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + p +} + +#[test] +fn a_pictures_border_lies_outside_the_picture() { + let p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0))); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let blue = Rgba::rgb(0, 0, 255); + // The whole picture shows, up to its edge; the 8 pt border is all outside it. + assert!(close(img.pixel(101, 150), blue, 30), "picture at its edge {:?}", img.pixel(101, 150)); + assert!(close(img.pixel(98, 150), RED, 30) && close(img.pixel(93, 150), RED, 30), "{:?}", img.pixel(93, 150)); + assert!(close(img.pixel(90, 150), WHITE, 10), "border no wider than the line {:?}", img.pixel(90, 150)); + // A dashed border keeps its dash lengths (a dash is 4 widths: 32 pt), drawn from the corner. + let mut line = Line::solid(ColorRef::rgb(RED), 8.0); + line.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() }); + let img = render_slide(&picture_deck(Some(line)), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert!(close(img.pixel(120, 96), RED, 30) && close(img.pixel(140, 96), WHITE, 10), "{:?}", img.pixel(140, 96)); +} From 47dfaaca6187194ea2487e7042976d3cd9dc9b31 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:14:32 -0400 Subject: [PATCH 30/59] Render: shapes filled with their group's fill (a:grpFill) show it A shape inside a group whose fill is grpFill drew nothing (the fill was read but the renderer skipped it). PowerPoint fills it with the group's fill laid out over the whole group: a gradient or pattern runs on across the children rather than restarting in each. Groups without a fill of their own pass their parent's on. Wild synthetic set: group fills slide 27.33 -> 7.88. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 21 ++++++++++++++++++++- crates/render/src/tests_wild.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ae2598e2..3f2dd432 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -244,11 +244,14 @@ pub struct Renderer { /// 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)>, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY } + Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY, group_fill: None } } } @@ -779,9 +782,16 @@ impl Renderer { 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); 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. @@ -925,6 +935,15 @@ impl Renderer { 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); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index a58bf3a7..a43a5714 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -287,3 +287,29 @@ fn a_pictures_border_lies_outside_the_picture() { let img = render_slide(&picture_deck(Some(line)), 0, &RenderOpts { scale: 1.0, ..Default::default() }); assert!(close(img.pixel(120, 96), RED, 30) && close(img.pixel(140, 96), WHITE, 10), "{:?}", img.pixel(140, 96)); } + +#[test] +fn children_filled_with_the_group_fill_show_one_fill_across_the_group() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }]; + let grad = Fill::Gradient(Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); + let child = |id: u32, x: f64| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)), + fill: Some(Fill::Group), + line: no_line(), + ..Default::default() + }; + let g = Shape { + id: ShapeId(16), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill: Some(grad), + kind: ShapeKind::Group { children: vec![child(17, 0.0), child(18, 110.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) }, + ..Default::default() + }; + let img = render(vec![g]); + let (l, r) = (img.pixel(105, 150), img.pixel(295, 150)); + assert!(l[0] > 200 && l[2] < 60, "left end of the group's gradient {l:?}"); + assert!(r[2] > 200 && r[0] < 60, "right end {r:?}"); + assert!(close(img.pixel(200, 150), WHITE, 5), "the gap between the children stays empty"); +} From 9a7e7e133e6cd8a95823250587fdde91c94ea43d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:24:15 -0400 Subject: [PATCH 31/59] Geom/Render: brackets and braces fill; arrowheads follow open paths, not brackets - Left/right brackets and braces drew only their curve: a filled bracket showed no fill. PowerPoint fills them out to the straight side they open to (the fill path is the curve closed), outlining only the curve. - Arrowheads were drawn on any shape with nothing filled (brackets among them) but not on open freeforms that had a fill. PowerPoint puts them on lines, arcs and open freeforms whether filled or not, never on brackets. Wild synthetic set: preset galleries 9.44 -> 4.65, 9.55 -> 4.94, 9.60 -> 5.38; extreme-adjust galleries 10.7 -> 6.6. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 25 +++++++++++++++++++++++-- crates/render/src/lib.rs | 7 ++++++- crates/render/src/tests_wild.rs | 18 ++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 9789f3c5..4fa51724 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -1124,7 +1124,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { if name == "rightBracket" { path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0])); } - Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. + let mut area = path.clone(); + area.close_path(); + Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } } "leftBrace" | "rightBrace" => { let r = (ss * a(0).clamp(0.0, 25000.0 * h / ss.max(1e-9)) / 1e5).min(h / 4.0); @@ -1136,7 +1139,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { if name == "rightBrace" { path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0])); } - Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. + let mut area = path.clone(); + area.close_path(); + Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } } "rightArrow" => arrow(Dir::Right, w, h, a(0), a(1)), "leftArrow" => arrow(Dir::Left, w, h, a(0), a(1)), @@ -1968,4 +1974,19 @@ mod tests { assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); } + + #[test] + fn brackets_and_braces_fill_out_to_their_straight_side() { + // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the + // straight side it opens to; only the curve is outlined. + for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { + let g = build(name, 40.0, 100.0, &[]).unwrap(); + let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); + assert_eq!(filled.len(), 1, "{name}"); + assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); + let area = filled[0].path.area().abs(); + assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); + assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); + } + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3f2dd432..495bd751 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -974,7 +974,12 @@ impl Renderer { 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); - let ends = s.is_line() || geo.is_open(); + // 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 diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index a43a5714..85ed5861 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -313,3 +313,21 @@ fn children_filled_with_the_group_fill_show_one_fill_across_the_group() { assert!(r[2] > 200 && r[0] < 60, "right end {r:?}"); assert!(close(img.pixel(200, 150), WHITE, 5), "the gap between the children stays empty"); } + +#[test] +fn open_freeforms_take_arrowheads_even_when_filled() { + // An open custom path with a fill and a tail arrow: PowerPoint draws the arrow. + let end = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }; + let sh = Shape { + id: ShapeId(19), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: "M 0 100 L 50 0 L 100 100".into(), ..Default::default() }] }, + fill: solid(Rgba::rgb(0, 200, 0)), + line: Some(Line { fill: solid(RED), width: Some(4.0), tail: Some(end), ..Default::default() }), + ..Default::default() + }; + let img = render(vec![sh]); + // The line ends at (300, 200) heading down-right at 45°; the 12 pt head's base corner reaches + // (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill. + assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189)); +} From 0c5ddb5dea457024d534173c79b4f2e76997d749 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:33:27 -0400 Subject: [PATCH 32/59] Gradients: read, write and draw a:tileRect Gradient tile rectangles were dropped on import. PowerPoint's "from corner" radial presets depend on them: fillToRect puts the focus on a corner and tileRect stretches the gradient's box a whole size past it, so the circle is centred on that corner and reaches the far one. Without it (after the two-point radial change) such fills washed out. A linear gradient now spans its tile rectangle; a radial one ends on the circle around it, with the focus still placed in the shape's own box (fitted to PowerPoint's export of the corpus deck tdf114848: mean diff over the filled box 0.77). Round-trips through pptx. Wild synthetic set: tile-rect gradients 16.74 -> 2.48. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/shape.rs | 2 +- crates/model/src/style.rs | 9 +++++++ crates/model/src/tests.rs | 3 ++- crates/model/src/theme.rs | 1 + crates/pptx/src/read/dml.rs | 3 ++- crates/pptx/src/write/dml.rs | 4 +++ crates/pptx/tests/fills.rs | 39 ++++++++++++++++++++++++++++- crates/pptx/tests/roundtrip.rs | 1 + crates/render/src/paint.rs | 37 +++++++++++++++++++++------ crates/render/src/tests.rs | 5 ++++ crates/render/src/tests_wild.rs | 44 +++++++++++++++++++++++++++++++-- crates/ui-egui/src/fillui.rs | 2 ++ 12 files changed, 137 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 22cb3851..b4c6aa4e 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -261,7 +261,7 @@ pub(crate) fn gradient_param(v: &Value) -> Option { scaled: false, }, }; - Some(Gradient { stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) + Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) } fn fill(s: &mut Session, p: &Value) -> Result { diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index bb253974..c7cd148c 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -92,6 +92,15 @@ pub struct Gradient { pub shape: GradientShape, #[serde(default = "crate::yes")] pub rotate_with_shape: bool, + /// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box + /// (fractions; negative reaches outside it). PowerPoint's "from corner" radial presets use it + /// to centre the gradient on the corner. + #[serde(default, skip_serializing_if = "is_zero4")] + pub tile: [f64; 4], +} + +fn is_zero4(v: &[f64; 4]) -> bool { + v.iter().all(|x| *x == 0.0) } /// How a picture fills its box: stretched into a (fractional) inset rect, or tiled. diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 61a8922d..2c3b4ced 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -69,7 +69,8 @@ fn a_gradient_without_stops_takes_them_from_its_style() { let p = Presentation::default(); let s = &p.slides[0]; let ctx = Ctx::for_slide(&p, s).unwrap(); - let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; + let own = + style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; let mut st = ShapeStyle::accent(SchemeSlot::Accent2); st.fill_ref.0 = 3; let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() }; diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs index 55b416b0..582f0728 100644 --- a/crates/model/src/theme.rs +++ b/crates/model/src/theme.rs @@ -108,6 +108,7 @@ fn ph() -> ColorRef { pub fn default_format() -> FormatScheme { let grad = |a: i32, b: i32| { Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![ GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) }, GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) }, diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index ab67a25e..004cd4b3 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -116,7 +116,8 @@ pub fn gradient(f: &El) -> Gradient { } else { GradientShape::Linear { angle: 90.0, scaled: false } }; - Gradient { stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) } + let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0))); + Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) } } /// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media. diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 7bae42d9..c1168ca1 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -272,6 +272,10 @@ fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) { w.close("a:path"); } } + if g.tile.iter().any(|v| *v != 0.0) { + let [l, t, r, b] = g.tile; + w.empty("a:tileRect", A::new().a("l", pct(l)).a("t", pct(t)).a("r", pct(r)).a("b", pct(b))); + } w.close("a:gradFill"); } diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index b129e646..8620b76b 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -88,7 +88,7 @@ fn a_gradient_without_stops_is_saved_without_stops() { let mut p = defaults::new_presentation(None); let id = ShapeId(p.alloc_id()); let first = p.slides[0].id; - let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; + let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; p.slide_mut(first).expect("slide").shapes.push(Shape { id, name: "Styled".into(), @@ -112,6 +112,7 @@ fn a_fill_overlay_round_trips() { let overlay = FillOverlay { blend: "mult".into(), fill: Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![ GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) }, GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) }, @@ -133,3 +134,39 @@ fn a_fill_overlay_round_trips() { let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape"); assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay)); } + +#[test] +fn a_gradient_tile_rect_is_read_and_round_trips() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + // PowerPoint's "from top-left corner" radial: focus on the corner, tile reaching a box up and + // to the left so it centres on that corner. + let g = Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 128, 255)) }, + GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE) }, + ], + shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] }, + rotate_with_shape: true, + tile: [-1.0, -1.0, 0.0, 0.0], + }; + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Corner".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Gradient(g.clone())), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape"); + assert_eq!(s.fill, Some(Fill::Gradient(g))); + // As written by PowerPoint (attributes it leaves out are 0). + let edited = + edit_slides(&bytes, |x| x.replace("", "")); + let q = deckcraft_pptx::import(&edited).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape"); + assert!(matches!(&s.fill, Some(Fill::Gradient(g)) if g.tile == [-1.0, -1.0, 0.0, 0.0]), "{:?}", s.fill); +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index eb194b8c..ed5cb425 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -105,6 +105,7 @@ pub fn rich_deck() -> Presentation { let mut a = shape(&mut p, "Gradient Star", 100.0, 120.0, 150.0, 150.0); a.geom = Geom::Preset { name: "star5".into(), adj: vec![20000.0] }; a.fill = Some(Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![ GradientStop { pos: 0.0, color: ColorRef::scheme(SchemeSlot::Accent1) }, GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)) }, diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 13b4f4f2..89b13423 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -35,6 +35,10 @@ pub fn fill_path( if g.stops.is_empty() { return; } + // A linear gradient spans its tile rectangle (the shape's box unless `tileRect` moves + // its edges); a radial one ends on the circle around it, from a focus placed in the + // shape's own box (PowerPoint's "from corner" presets centre the tile on the corner). + let tile = tile_box(bounds, g.tile); let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect(); ctx.set_paint(gradient(&g.shape, stops, bounds, alpha)); @@ -55,12 +59,12 @@ pub fn fill_path( // Angle measured clockwise from left→right; span the bounds along that direction. let a = angle.to_radians(); let (dx, dy) = (a.cos(), a.sin()); - let c = bounds.center(); - let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; + let c = tile.center(); + let half = (tile.width() * dx.abs() + tile.height() * dy.abs()) / 2.0; peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) .with_stops(stops.as_slice()) } - GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()), + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, tile).with_stops(stops.as_slice()), }; ctx.set_paint(grad); ctx.fill_path(path); @@ -88,6 +92,22 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option) -> std::b std::borrow::Cow::Owned(out) } +/// `bounds` with its edges moved in by `tile` (left, top, right, bottom insets as fractions of its +/// size; negative moves them out), as `a:tileRect` and `a:fillRect` lay out a fill. +pub fn tile_box(bounds: Rect, tile: [f64; 4]) -> Rect { + if tile.iter().all(|v| *v == 0.0) || tile.iter().any(|v| !v.is_finite()) { + return bounds; + } + let (w, h) = (bounds.width(), bounds.height()); + let [l, t, r, b] = tile; + let x0 = bounds.x0 + l * w; + let x1 = bounds.x1 - r * w; + let y0 = bounds.y0 + t * h; + let y1 = bounds.y1 - b * h; + // An inverted tile (insets past each other) lays out nothing sensible: keep the box. + if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { bounds } else { Rect::new(x0, y0, x1, y1) } +} + /// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded /// by `alpha`, ramping between stops as PowerPoint does (see `ramp`). pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient { @@ -107,7 +127,7 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) .with_stops(stops.as_slice()) } - GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()), + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, bounds).with_stops(stops.as_slice()), } } @@ -117,11 +137,14 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect /// centre, half its diagonal), with circles in between moving from one to the other. A focus off /// the centre so bunches the colours on its near side; a gradient "from a corner" reaches its /// last colour at that corner too, not only at the far one. -pub fn circle_gradient(focus: [f64; 4], bounds: Rect) -> peniko::Gradient { +/// +/// With a tile rectangle (`tile`, otherwise `bounds` itself) the end circle is the one around +/// the tile, while the focus stays placed in `bounds`. +pub fn circle_gradient(focus: [f64; 4], bounds: Rect, tile: Rect) -> peniko::Gradient { let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; - let c = bounds.center(); - let r = (bounds.width().hypot(bounds.height()) / 2.0).max(1e-6); + let c = tile.center(); + let r = (tile.width().hypot(tile.height()) / 2.0).max(1e-6); let f = Point::new(fx, fy); if !(f.is_finite() && c.is_finite() && r.is_finite()) { return peniko::Gradient::new_radial(c, r as f32); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e5b365f4..0de3d695 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -565,6 +565,7 @@ fn a_linear_gradient_ramps_like_powerpoint() { id: ShapeId(700), xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)), fill: Some(Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))], shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, @@ -589,6 +590,7 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() { id: ShapeId(701), xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)), fill: Some(Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))], shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, @@ -629,6 +631,7 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() { let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) }; std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)], shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, @@ -702,6 +705,7 @@ fn a_text_gradient_starts_over_on_every_line() { let props = &mut p.runs[0].props; props.size = Some(80.0); props.fill = Some(Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![stop(0.0, 0), stop(1.0, 255)], shape: GradientShape::Linear { angle: 90.0, scaled: false }, rotate_with_shape: true, @@ -862,6 +866,7 @@ fn rect_path_gradients_draw_x_shaped_contours() { id: ShapeId(770), xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)), fill: Some(Fill::Gradient(Gradient { + tile: [0.0; 4], stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)], shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] }, rotate_with_shape: true, diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 85ed5861..064dce09 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -168,7 +168,12 @@ fn radial(focus: [f64; 4]) -> Shape { Shape { id: ShapeId(13), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), - fill: Some(Fill::Gradient(Gradient { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true })), + fill: Some(Fill::Gradient(Gradient { + tile: [0.0; 4], + stops, + shape: GradientShape::Path { path: "circle".into(), focus }, + rotate_with_shape: true, + })), line: no_line(), ..Default::default() } @@ -292,7 +297,8 @@ fn a_pictures_border_lies_outside_the_picture() { fn children_filled_with_the_group_fill_show_one_fill_across_the_group() { use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }]; - let grad = Fill::Gradient(Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); + let grad = + Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); let child = |id: u32, x: f64| Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)), @@ -331,3 +337,37 @@ fn open_freeforms_take_arrowheads_even_when_filled() { // (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill. assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189)); } + +#[test] +fn a_corner_radial_centres_on_the_corner_through_its_tile() { + // fillToRect on the top-left corner, tileRect reaching a whole box up and left: the circle is + // centred on the corner and reaches the far corner (as PowerPoint's "from corner" presets). + let mut sh = radial([0.0, 0.0, 1.0, 1.0]); + if let Some(Fill::Gradient(g)) = &mut sh.fill { + g.tile = [-1.0, -1.0, 0.0, 0.0]; + } + let img = render(vec![sh]); + assert!(img.pixel(102, 102)[0] > 240, "white at the corner {:?}", img.pixel(102, 102)); + let (top_right, bottom_left) = (img.pixel(498, 101)[0], img.pixel(101, 298)[0]); + assert!(top_right < bottom_left, "farther is darker {top_right} {bottom_left}"); + assert!(bottom_left > 120, "the near edge stays light {bottom_left}"); + assert!(img.pixel(497, 297)[0] < 40, "dark at the far corner {:?}", img.pixel(497, 297)); +} + +#[test] +fn a_linear_gradient_spans_its_tile_rect() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + // Tile rect the left half of the box: the ramp ends half way and stays black after. + let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }]; + let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] }; + let sh = Shape { + id: ShapeId(20), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)), + fill: Some(Fill::Gradient(g)), + line: no_line(), + ..Default::default() + }; + let img = render(vec![sh]); + assert!(img.pixel(102, 150)[0] > 240); + assert!(img.pixel(305, 150)[0] < 15 && img.pixel(495, 150)[0] < 15, "{:?}", img.pixel(305, 150)); +} diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index 7f0f7d19..584d309a 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -16,6 +16,7 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient { let mut dark = base.clone(); dark.mods.push(ColorTransform::LumMod(60_000)); Gradient { + tile: [0.0; 4], stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }], shape: GradientShape::Linear { angle, scaled: false }, rotate_with_shape: true, @@ -136,6 +137,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> ..default_gradient(base.clone(), 45.0) }, Gradient { + tile: [0.0; 4], stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }], shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] }, rotate_with_shape: true, From 0cbcbebf68f7d1f09349a1f253c1b6dae8782aa8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:37:38 -0400 Subject: [PATCH 33/59] Render: a 3-D picture's border stays centred on its edge Follow-up to drawing picture borders outside the picture: PowerPoint does that for flat pictures only. A picture seen through a 3-D scene with a bevel (a common picture style) gets its border centred on the edge like any shape: observed with an 18 pt border, which then covers 9 pt of the picture. The corpus deck 60810 (white-framed bevelled screenshots) showed the screenshots' own edges once the border moved out; it now matches PowerPoint again. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 13 ++++++++++--- crates/render/src/tests_wild.rs | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 495bd751..e6cc5d1d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -875,7 +875,7 @@ impl Renderer { }; let (base, rel) = self.line_space(m, depth); let lw = line.width.unwrap_or(0.75).max(0.0); - let outside = if is_pic { outside_line(&line, lw) } else { None }; + let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } }); self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); @@ -1000,8 +1000,8 @@ impl Renderer { 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. - let outside = is_pic.then(|| outside_line(&line, lw)).flatten(); + // 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) => { @@ -1242,6 +1242,13 @@ impl Renderer { } } +/// 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). +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). diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 064dce09..99ce03c2 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -371,3 +371,19 @@ fn a_linear_gradient_spans_its_tile_rect() { assert!(img.pixel(102, 150)[0] > 240); assert!(img.pixel(305, 150)[0] < 15 && img.pixel(495, 150)[0] < 15, "{:?}", img.pixel(305, 150)); } + +#[test] +fn a_3d_pictures_border_stays_centred_on_its_edge() { + let mut p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0))); + let s = std::sync::Arc::make_mut(&mut p.slides[0]); + if let Some(sh) = s.shapes.first_mut() { + sh.effects = Some(deckcraft_model::Effects { + bevel: Some("circle".into()), + raw3d: Some("".into()), + ..Default::default() + }); + } + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert!(close(img.pixel(102, 150), RED, 30), "covers the picture's edge {:?}", img.pixel(102, 150)); + assert!(close(img.pixel(94, 150), WHITE, 10), "reaches half the width out {:?}", img.pixel(94, 150)); +} From c2b622a7973456c28e6873b11310256d6795ee53 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:43:44 -0400 Subject: [PATCH 34/59] Render: tiled picture fills flip, align and size like PowerPoint's - flip x/y/xy (every other tile mirrored) was ignored: tiles now repeat reflected across the flip axes. - algn was ignored: the first tile now sits on the corner, edge middle or centre of the box it names, before the tx/ty offset. - The tile size came from the decoded (possibly downsampled) bitmap; it is now the picture's own pixel size (96 dpi) times sx/sy, rounded to whole output pixels as PowerPoint draws it. - A scale of zero or less drew tiles at 100%; PowerPoint draws nothing. - Tiles are one repeating image paint instead of one draw per tile (no 200-tile cap, constant cost). Wild synthetic set: tiled background flipped/aligned 82.92 -> 1.80, tile variants 51.15 -> 8.82. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/images.rs | 10 +++++ crates/render/src/paint.rs | 67 +++++++++++++++++++++++++++------ crates/render/src/tests_wild.rs | 50 ++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 3071e943..b41c2de9 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -121,6 +121,16 @@ fn decode_at(bytes: &[u8], side: u32) -> Option { Some(img.to_rgba8()) } +/// A raster picture's own size in pixels, read from its header (`None` for metafiles and +/// anything unreadable). +pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { + if crate::metafile::is_metafile(bytes) { + return None; + } + let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; + (w > 0 && h > 0).then_some((w as f64, h as f64)) +} + /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Option> { let addr = Arc::as_ptr(bytes) as usize; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 89b13423..5ea7e3d6 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -191,24 +191,67 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b image: vello_cpu::ImageSource::Pixmap(pm.clone()), sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() }, }; - if let PictureMode::Tile { sx, sy, tx, ty, .. } = &pf.mode { - let (sx, sy) = (if *sx > 0.0 { *sx } else { 1.0 }, if *sy > 0.0 { *sy } else { 1.0 }); - let tile_w = iw * 0.75 * sx; - let tile_h = ih * 0.75 * sy; - let nx = ((bounds.width() / tile_w.max(1.0)).ceil() as i64).clamp(0, 200); - let ny = ((bounds.height() / tile_h.max(1.0)).ceil() as i64).clamp(0, 200); - for j in -1..=ny { - for i in -1..=nx { - let r = Rect::new(bounds.x0 + tx + i as f64 * tile_w, bounds.y0 + ty + j as f64 * tile_h, 0.0, 0.0); - let r = Rect::new(r.x0, r.y0, r.x0 + tile_w, r.y0 + tile_h); - draw_image(ctx, &image, iw, ih, r, m, alpha); - } + if let PictureMode::Tile { sx, sy, tx, ty, flip, align } = &pf.mode { + // The tile's size: the picture's own size (96 dpi) scaled. A scale of zero or less draws + // nothing, as in PowerPoint. + if !(*sx > 0.0 && *sy > 0.0 && sx.is_finite() && sy.is_finite()) { + return; + } + let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih)); + // PowerPoint draws the tile at a whole number of output pixels. + let k = px_scale.max(1e-9); + let snap = |v: f64| (v * k).round().max(1.0) / k; + let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy)); + if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) { + return; + } + let o = tile_origin(bounds, align, (tile_w, tile_h), (*tx, *ty)); + // Every other tile is mirrored across the flip axes: a reflected repeat. + let ext = |mirror: bool| if mirror { peniko::Extend::Reflect } else { peniko::Extend::Repeat }; + let tiled = vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm.clone()), + sampler: peniko::ImageSampler { + x_extend: ext(matches!(flip.as_str(), "x" | "xy")), + y_extend: ext(matches!(flip.as_str(), "y" | "xy")), + quality: peniko::ImageQuality::High, + alpha: 1.0, + }, + }; + ctx.set_transform(m); + if alpha < 0.999 { + ctx.push_layer(None, None, Some(alpha as f32), None, None); + } + ctx.set_paint(tiled); + ctx.set_paint_transform(Affine::translate(o.to_vec2()) * Affine::scale_non_uniform(tile_w / iw, tile_h / ih)); + ctx.fill_rect(&bounds); + ctx.reset_paint_transform(); + if alpha < 0.999 { + ctx.pop_layer(); } return; } draw_image(ctx, &image, iw, ih, dest, m, alpha); } +/// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or +/// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point +/// of the box, then moved by the offset (points). +pub fn tile_origin(bounds: Rect, align: &str, tile: (f64, f64), offset: (f64, f64)) -> Point { + let (ax, ay) = match align { + "t" => (0.5, 0.0), + "tr" => (1.0, 0.0), + "l" => (0.0, 0.5), + "ctr" => (0.5, 0.5), + "r" => (1.0, 0.5), + "bl" => (0.0, 1.0), + "b" => (0.5, 1.0), + "br" => (1.0, 1.0), + _ => (0.0, 0.0), + }; + let (ox, oy) = (if offset.0.is_finite() { offset.0 } else { 0.0 }, if offset.1.is_finite() { offset.1 } else { 0.0 }); + Point::new(bounds.x0 + ax * (bounds.width() - tile.0) + ox, bounds.y0 + ay * (bounds.height() - tile.1) + oy) +} + fn draw_image(ctx: &mut RenderContext, image: &vello_cpu::Image, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) { let t = m * Affine::translate(dest.origin().to_vec2()) * Affine::scale_non_uniform(dest.width() / iw, dest.height() / ih); ctx.set_transform(t); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 99ce03c2..a9b64d75 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -387,3 +387,53 @@ fn a_3d_pictures_border_stays_centred_on_its_edge() { assert!(close(img.pixel(102, 150), RED, 30), "covers the picture's edge {:?}", img.pixel(102, 150)); assert!(close(img.pixel(94, 150), WHITE, 10), "reaches half the width out {:?}", img.pixel(94, 150)); } + +#[allow(clippy::unwrap_used)] +fn half_red_half_blue() -> Vec { + let img = image::RgbImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) }); + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +fn tiled(sx: f64, flip: &str, align: &str) -> Image { + use deckcraft_model::style::{PictureFill, PictureMode}; + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx, sy: 10.0, flip: flip.into(), align: align.into() }; + let sh = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + line: no_line(), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn tiled_pictures_flip_align_and_skip_non_positive_scales() { + let blue = Rgba::rgb(0, 0, 255); + // 8×4 px at 96 dpi scaled ten times: 60×30 pt tiles, red then blue, from the top-left. + let img = tiled(10.0, "none", "tl"); + assert!(close(img.pixel(115, 115), RED, 30) && close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), RED, 30)); + // Flipped across x: every other tile mirrored, so the second starts blue. + let img = tiled(10.0, "x", "tl"); + assert!(close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), blue, 30) && close(img.pixel(205, 115), RED, 30)); + // Aligned right: a tile ends at the box's right edge (340), so 310-340 is blue. + let img = tiled(10.0, "none", "r"); + assert!(close(img.pixel(335, 130), blue, 30) && close(img.pixel(305, 130), RED, 30), "{:?}", img.pixel(305, 130)); + // A negative scale draws nothing, as in PowerPoint. + let img = tiled(-10.0, "none", "tl"); + assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5)); +} + +#[test] +fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + assert_eq!(paint::tile_origin(b, "tl", (20.0, 10.0), (0.0, 0.0)), Point::new(0.0, 0.0)); + assert_eq!(paint::tile_origin(b, "ctr", (20.0, 10.0), (0.0, 0.0)), Point::new(40.0, 20.0)); + assert_eq!(paint::tile_origin(b, "br", (20.0, 10.0), (5.0, -5.0)), Point::new(85.0, 35.0)); + assert_eq!(paint::tile_origin(b, "nonsense", (20.0, 10.0), (f64::NAN, 1.0)), Point::new(0.0, 1.0)); +} From ee4712f829aff322649f402720d48a5527a92b68 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:53:31 -0400 Subject: [PATCH 35/59] Geom: lightning bolt, explosions, heart, cloud and document flowcharts as the standard defines them These presets were hand-drawn approximations and looked visibly different from PowerPoint's (the lightning bolt had 7 points instead of 11, the explosions were different polygons, the cloud a ring of lobes without creases). They now follow ECMA-376's preset geometry, checked against the outlines PowerPoint gives for them (black-box: merging each preset with a far-away square and reading the resulting freeform): - lightningBolt, irregularSeal1/2: the standard's polygons (21600ths); - heart: its two cubic curves; - cloud (and cloudCallout's outline): the standard's eleven arcs drawn in the path's own 43200-unit square, plus the crease arcs, stretched to the box; arcs take DrawingML's angle convention (direction from the centre), new Pb::arc_spec; - flowChartDocument, flowChartMultidocument (three pages, outlined where they show), flowChartPunchedTape (half-ellipse edges). Wild synthetic set (per-shape mean diff, 1:1 gallery): lightningBolt 64.1 -> 2.8, irregularSeal1 41.1 -> 5.3, irregularSeal2 41.2 -> 4.8, cloud 28.2 -> 4.0, multidocument 30.9 -> 7.0, punched tape 27.7 -> 4.1, heart 20.9 -> 3.9, document 15.3 -> 3.8. Whole set 5.05 -> 4.84. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 379 ++++++++++++++++++++++++++++---------- 1 file changed, 277 insertions(+), 102 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 4fa51724..c72f87d8 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -6,7 +6,7 @@ //! geometry table. Adjust values use the file convention: ratios in 1/100 000 of the shorter side (or //! of the width/height), angles in 1/60 000 degree. -use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2}; +use kurbo::{Arc, BezPath, Point, Rect, Vec2}; use serde::{Deserialize, Serialize}; mod extra; @@ -388,6 +388,29 @@ impl Pb { self.cur = Point::new(c.x + wr * e.cos(), c.y + hr * e.sin()); self } + /// A DrawingML `arcTo`: angles are where the ray from the ellipse's centre at that angle + /// meets it (not the ellipse's parameter), so elliptical arcs start and end where the + /// standard's shapes expect. + fn arc_spec(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { + let par = |a: f64| { + let a = a.to_radians(); + (wr * a.sin()).atan2(hr * a.cos()) + }; + let t0 = par(st); + let mut d = par(st + sw) - t0; + let tau = std::f64::consts::TAU; + if sw.abs() >= 360.0 { + d = tau.copysign(sw); + } else { + while sw > 0.0 && d < 0.0 { + d += tau; + } + while sw < 0.0 && d > 0.0 { + d -= tau; + } + } + self.arc(wr, hr, t0.to_degrees(), d.to_degrees()) + } fn z(&mut self) -> &mut Self { self.p.close_path(); self @@ -605,6 +628,11 @@ fn double_arrow(vertical: bool, w: f64, h: f64, a1: f64, a2: f64) -> Geometry { Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: r, handles: vec![], sites: mid_sites(w, h) } } +/// Points given in 21600ths of the box (as ECMA-376 defines many fixed shapes), scaled to it. +fn spec_pts(pts: &[(i32, i32)], w: f64, h: f64) -> Vec<(f64, f64)> { + pts.iter().map(|(x, y)| (w * *x as f64 / 21600.0, h * *y as f64 / 21600.0)).collect() +} + fn simple(path: BezPath, text: Rect, w: f64, h: f64) -> Geometry { Geometry { paths: vec![fill(path)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } } @@ -1048,17 +1076,35 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { } } "heart" => { - b.m(w / 2.0, h * 0.25); - b.c(w * 0.62, h * -0.02, w, h * 0.0, w, h * 0.32); - b.c(w, h * 0.6, w * 0.62, h * 0.78, w / 2.0, h); - b.c(w * 0.38, h * 0.78, 0.0, h * 0.6, 0.0, h * 0.32); - b.c(0.0, h * 0.0, w * 0.38, h * -0.02, w / 2.0, h * 0.25); - b.z(); + // ECMA-376 heart: two cubic curves from the notch at the top to the point. + let (dx1, dx2) = (w * 49.0 / 48.0, w * 10.0 / 48.0); + let y1 = -h / 3.0; + b.m(w / 2.0, h / 4.0) + .c(w / 2.0 + dx2, y1, w / 2.0 + dx1, h / 4.0, w / 2.0, h) + .c(w / 2.0 - dx1, h / 4.0, w / 2.0 - dx2, y1, w / 2.0, h / 4.0) + .z(); simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h) } "lightningBolt" => { - let pts = normalize(&[(0.36, 0.0), (0.68, 0.0), (0.53, 0.34), (0.79, 0.34), (0.24, 1.0), (0.39, 0.53), (0.15, 0.53)], w, h); - simple(b.poly(&pts).take(), Rect::new(w * 0.3, h * 0.35, w * 0.65, h * 0.6), w, h) + // ECMA-376 lightningBolt, in 21600ths of the box. + let pts = spec_pts( + &[ + (8472, 0), + (12860, 6080), + (11050, 6797), + (16577, 12007), + (14767, 12877), + (21600, 21600), + (10012, 14915), + (12222, 13987), + (5022, 9705), + (7602, 8382), + (0, 3890), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h) } "sun" => { let k = a(0).clamp(12500.0, 46875.0) / 100000.0; @@ -1378,18 +1424,45 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { } } "flowChartDocument" => { - b.m(0.0, 0.0).l(w, 0.0).l(w, h * 0.8).c(w * 0.7, h * 0.62, w * 0.35, h * 1.08, 0.0, h * 0.88).z(); - simple(b.take(), Rect::new(0.0, 0.0, w, h * 0.78), w, h) + let u = |x: f64, y: f64| (w * x / 21600.0, h * y / 21600.0); + let (p1, p2, p3, p4) = (u(21600.0, 17322.0), u(10800.0, 17322.0), u(10800.0, 23922.0), u(0.0, 20172.0)); + b.m(0.0, 0.0).l(w, 0.0).l(p1.0, p1.1).c(p2.0, p2.1, p3.0, p3.1, p4.0, p4.1).z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h * 17322.0 / 21600.0), w, h) } "flowChartMultidocument" => { - let (dx, dy) = (w * 0.1, h * 0.1); - let mut back = Pb::new(); - back.poly(&[(2.0 * dx, 0.0), (w, 0.0), (w, h * 0.7), (w - dx * 0.5, h * 0.7), (w - dx * 0.5, dy * 0.5), (2.0 * dx, dy * 0.5)]); - back.poly(&[(dx, dy * 0.5), (w - dx * 0.5, dy * 0.5), (w - dx * 0.5, h * 0.78), (w - dx, h * 0.78), (w - dx, dy), (dx, dy)]); - let front = Pb::new().m(0.0, dy).l(w - dx, dy).l(w - dx, h * 0.84).c(w * 0.62, h * 0.7, w * 0.32, h * 1.08, 0.0, h * 0.9).z().take(); + // ECMA-376 multidocument (21600ths): two back pages peeking out above and right of + // the front one; the back pages' edges are outlined only where they show. + let x = |v: f64| w * v / 21600.0; + let y = |v: f64| h * v / 21600.0; + let front = |b: &mut Pb| { + b.m(0.0, y(20782.0)) + .c(x(9298.0), y(23542.0), x(9298.0), y(18022.0), x(18595.0), y(18022.0)) + .l(x(18595.0), y(3675.0)) + .l(0.0, y(3675.0)) + .z(); + }; + let mid = |b: &mut Pb| { + b.m(x(1532.0), y(3675.0)).l(x(1532.0), y(1815.0)).l(x(20000.0), y(1815.0)).l(x(20000.0), y(16252.0)); + b.c(x(19298.0), y(16252.0), x(18595.0), y(16352.0), x(18595.0), y(16352.0)); + }; + let back = |b: &mut Pb| { + b.m(x(2972.0), y(1815.0)).l(x(2972.0), 0.0).l(w, 0.0).l(w, y(14392.0)); + b.c(x(20800.0), y(14392.0), x(20000.0), y(14467.0), x(20000.0), y(14467.0)); + }; + let mut area = Pb::new(); + front(&mut area); + mid(&mut area); + area.l(x(18595.0), y(3675.0)).z(); + back(&mut area); + area.l(x(20000.0), y(1815.0)).z(); + let mut outline = Pb::new(); + outline.m(0.0, y(3675.0)).l(x(18595.0), y(3675.0)).l(x(18595.0), y(18022.0)); + outline.c(x(9298.0), y(18022.0), x(9298.0), y(23542.0), 0.0, y(20782.0)).z(); + mid(&mut outline); + back(&mut outline); Geometry { - paths: vec![fill(back.take()), fill(front)], - text_rect: Rect::new(0.0, dy, w - dx, h * 0.8), + paths: vec![fill_only(area.take()), stroke_only(outline.take())], + text_rect: Rect::new(0.0, y(3675.0), x(18595.0), y(17322.0)), handles: vec![], sites: mid_sites(w, h), } @@ -1407,10 +1480,11 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { simple(b.poly(&[(w * 0.2, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, h * 0.2)]).take(), Rect::new(0.0, h * 0.2, w, h), w, h) } "flowChartPunchedTape" => { - let a1 = h * 0.1; - b.m(0.0, a1).c(w * 0.25, a1 * 3.0, w * 0.25, -a1, w / 2.0, a1).c(w * 0.75, a1 * 3.0, w * 0.75, -a1, w, a1).l(w, h - a1); - b.c(w * 0.75, h - 3.0 * a1, w * 0.75, h + a1, w / 2.0, h - a1).c(w * 0.25, h - 3.0 * a1, w * 0.25, h + a1, 0.0, h - a1).z(); - simple(b.take(), Rect::new(0.0, h * 0.2, w, h * 0.8), w, h) + // ECMA-376: each long edge is two half-ellipses a quarter of the width wide and a + // tenth of the height deep, one bulging in and one out. + let (rx, ry) = (w / 4.0, h / 10.0); + b.m(0.0, ry).arc(rx, ry, 180.0, -180.0).arc(rx, ry, 180.0, 180.0).l(w, h - ry).arc(rx, ry, 0.0, -180.0).arc(rx, ry, 0.0, 180.0).z(); + simple(b.take(), Rect::new(0.0, h / 5.0, w, h * 0.8), w, h) } "flowChartSummingJunction" | "flowChartOr" => { let e = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(); @@ -1463,62 +1537,77 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); g } - "irregularSeal1" | "irregularSeal2" => { - let pts: &[(f64, f64)] = if name == "irregularSeal1" { + "irregularSeal1" => { + // ECMA-376 irregularSeal1 ("Explosion: 8 Points"), in 21600ths of the box. + let pts = spec_pts( &[ - (0.48, 0.18), - (0.62, 0.0), - (0.64, 0.2), - (0.86, 0.1), - (0.78, 0.3), - (1.0, 0.36), - (0.82, 0.5), - (0.94, 0.72), - (0.7, 0.66), - (0.68, 0.9), - (0.54, 0.72), - (0.42, 1.0), - (0.36, 0.74), - (0.14, 0.86), - (0.22, 0.62), - (0.0, 0.6), - (0.18, 0.44), - (0.04, 0.24), - (0.3, 0.3), - (0.26, 0.06), - ] - } else { + (10800, 5800), + (14522, 0), + (14155, 5325), + (18380, 4457), + (16702, 7315), + (21097, 8137), + (17607, 10475), + (21600, 13290), + (16837, 12942), + (18145, 18095), + (14020, 14457), + (13247, 19737), + (10532, 14935), + (8485, 21600), + (7715, 15627), + (4762, 17617), + (5667, 13937), + (135, 14587), + (3722, 11775), + (0, 8615), + (4627, 7617), + (370, 2295), + (7312, 6320), + (8352, 2295), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 4627.0 / 21600.0, h * 6320.0 / 21600.0, w * 16702.0 / 21600.0, h * 13937.0 / 21600.0), w, h) + } + "irregularSeal2" => { + // ECMA-376 irregularSeal2 ("Explosion: 14 Points"), in 21600ths of the box. + let pts = spec_pts( &[ - (0.5, 0.16), - (0.58, 0.0), - (0.63, 0.18), - (0.78, 0.06), - (0.76, 0.24), - (0.96, 0.2), - (0.86, 0.38), - (1.0, 0.5), - (0.84, 0.58), - (0.92, 0.76), - (0.74, 0.72), - (0.76, 0.94), - (0.6, 0.8), - (0.5, 1.0), - (0.44, 0.8), - (0.28, 0.94), - (0.28, 0.74), - (0.08, 0.82), - (0.16, 0.62), - (0.0, 0.54), - (0.14, 0.44), - (0.02, 0.28), - (0.22, 0.3), - (0.2, 0.1), - (0.36, 0.22), - (0.4, 0.04), - ] - }; - let pts: Vec<(f64, f64)> = pts.iter().map(|(x, y)| (x * w, y * h)).collect(); - simple(b.poly(&pts).take(), Rect::new(w * 0.25, h * 0.3, w * 0.75, h * 0.7), w, h) + (11462, 4342), + (14790, 0), + (14525, 5777), + (18007, 3172), + (16380, 6532), + (21600, 6645), + (16985, 9402), + (18270, 11290), + (16380, 12310), + (18877, 15632), + (14640, 14350), + (14942, 17370), + (12180, 15935), + (11612, 18842), + (9872, 17370), + (8700, 19712), + (7527, 18125), + (4917, 21600), + (4805, 18240), + (1285, 17825), + (3330, 15370), + (0, 12877), + (3935, 11592), + (1172, 8270), + (5372, 7817), + (4502, 3625), + (8550, 6382), + (9722, 1887), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h) } "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), @@ -1652,36 +1741,61 @@ fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry { g } +/// ECMA-376 cloud outline arcs (radii in 43200ths of the box, angles in 60000ths of a degree, +/// as DrawingML `arcTo` takes them), from the start point (3900, 14370). +const CLOUD_ARCS: [(f64, f64, f64, f64); 11] = [ + (6753.0, 9190.0, -11429249.0, 7426832.0), + (5333.0, 7267.0, -8646143.0, 5396714.0), + (4365.0, 5945.0, -8748475.0, 5983381.0), + (4857.0, 6595.0, -7859164.0, 7034504.0), + (5333.0, 7273.0, -4722533.0, 6541615.0), + (6775.0, 9220.0, -2776035.0, 7816140.0), + (5785.0, 7867.0, 37501.0, 6842000.0), + (6752.0, 9215.0, 1347096.0, 6910353.0), + (7720.0, 10543.0, 3974558.0, 4542661.0), + (4360.0, 5918.0, -16496525.0, 8804134.0), + (4345.0, 5945.0, -14809710.0, 9151131.0), +]; + +/// The cloud's creases: short arcs inside the outline, each from its own start point. +const CLOUD_CREASES: [(f64, f64, f64, f64, f64, f64); 11] = [ + (4693.0, 26177.0, 4345.0, 5945.0, 5204520.0, 1585770.0), + (6928.0, 34899.0, 4360.0, 5918.0, 4416628.0, 686848.0), + (16478.0, 39090.0, 6752.0, 9215.0, 8257449.0, 844866.0), + (28827.0, 34751.0, 6752.0, 9215.0, 387196.0, 959901.0), + (34129.0, 22954.0, 5785.0, 7867.0, -4217541.0, 4255042.0), + (41798.0, 15354.0, 5333.0, 7273.0, 1819082.0, 1665090.0), + (38324.0, 5426.0, 4857.0, 6595.0, -824660.0, 891534.0), + (29078.0, 3952.0, 4857.0, 6595.0, -8950887.0, 1091722.0), + (22141.0, 4720.0, 4365.0, 5945.0, -9809656.0, 1061181.0), + (14000.0, 5192.0, 6753.0, 9190.0, -4002417.0, 739161.0), + (4127.0, 15789.0, 6753.0, 9190.0, 9459261.0, 711490.0), +]; + fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { - let n = 10; - let pts: Vec = (0..n) - .map(|i| { - let t = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); - let wob = if i % 2 == 0 { 1.0 } else { 0.92 }; - Point::new(0.5 + 0.40 * wob * t.cos(), 0.5 + 0.36 * wob * t.sin()) - }) - .collect(); + // Drawn in the path's own 43200-unit square, then stretched to the box (as DrawingML lays out + // a path with its own w and h), so the arcs keep their shape's proportions at any aspect. + let to_box = kurbo::Affine::scale_non_uniform(w / 43200.0, h / 43200.0); + let deg = |v: f64| v / 60000.0; let mut b = Pb::new(); - let first = pts.first().copied().unwrap_or(Point::ZERO); - b.m(first.x, first.y); - for i in 0..n { - let p = pts.get(i).copied().unwrap_or(Point::ZERO); - let q = pts.get((i + 1) % n).copied().unwrap_or(Point::ZERO); - let mid = p.midpoint(q); - let out = (mid - Point::new(0.5, 0.5)).normalize(); - let bulge = (q - p).hypot() * 0.75; - let c1 = p + (mid - p) * 0.2 + out * bulge; - let c2 = q + (mid - q) * 0.2 + out * bulge; - b.c(c1.x, c1.y, c2.x, c2.y, q.x, q.y); + b.m(3900.0, 14370.0); + for (wr, hr, st, sw) in CLOUD_ARCS { + b.arc_spec(wr, hr, deg(st), deg(sw)); } b.z(); - let mut path = b.take(); - let bb = path.bounding_box(); - let sx = if bb.width() > 0.0 { w / bb.width() } else { 1.0 }; - let sy = if bb.height() > 0.0 { h / bb.height() } else { 1.0 }; - path.apply_affine(kurbo::Affine::scale_non_uniform(sx, sy) * kurbo::Affine::translate(-bb.origin().to_vec2())); - let mut paths = vec![fill(path)]; - let text_rect = Rect::new(w * 0.2, h * 0.22, w * 0.8, h * 0.78); + let mut creases = Pb::new(); + for (x, y, wr, hr, st, sw) in CLOUD_CREASES { + creases.m(x, y).arc_spec(wr, hr, deg(st), deg(sw)); + } + let mut outline = b.take(); + outline.apply_affine(to_box); + let mut paths = vec![fill(outline)]; + if name == "cloud" { + let mut c = creases.take(); + c.apply_affine(to_box); + paths.push(stroke_only(c)); + } + let text_rect = Rect::new(w * 2977.0 / 21600.0, h * 3262.0 / 21600.0, w * 17087.0 / 21600.0, h * 17337.0 / 21600.0); let mut handles = vec![]; if name == "cloudCallout" { let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); @@ -1975,6 +2089,67 @@ mod tests { assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); } + fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { + use kurbo::ParamCurveNearest; + g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) + } + + #[test] + fn fixed_shapes_follow_the_standards_coordinates() { + // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. + let (w, h) = (216.0, 108.0); + let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); + let g = build("lightningBolt", w, h, &[]).unwrap(); + for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { + assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); + } + let g = build("irregularSeal1", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); + let g = build("irregularSeal2", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); + // The heart's point is the middle of the bottom edge, its notch a quarter down. + let g = build("heart", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); + let g = build("flowChartDocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); + // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. + let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); + let g = build("flowChartMultidocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); + } + + #[test] + fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { + use kurbo::Shape as _; + for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { + let g = build("cloud", w, h, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); + assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); + assert_eq!(g.paths.len(), 2, "outline and creases"); + assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); + } + } + + #[test] + fn spec_arcs_take_angles_as_directions_from_the_centre() { + // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the + // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. + let mut b = Pb::new(); + b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); + let p = b.take(); + let end = p.elements().last().and_then(|e| match e { + kurbo::PathEl::CurveTo(_, _, q) => Some(*q), + _ => None, + }); + // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. + let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); + let c = Point::new(-k, -k); + let q = end.unwrap(); + assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); + } + #[test] fn brackets_and_braces_fill_out_to_their_straight_side() { // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the From 2965e69e2f2c3126ccad9a1ba63521b1c0139a6b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:57:58 -0400 Subject: [PATCH 36/59] Geom: equation shapes by the standard's formulas Plus, minus, multiply, divide, equal and not-equal were approximations (bars spanning the wrong width, the X's arms too long, the not-equal slash at the wrong angle and length, dots sized off the shorter side). They now follow ECMA-376's guide formulas: bars span 73.49% of the width; adjusts pin as the standard pins them; the X's arms follow the box's diagonals cut at 51.965%; the slash leans at adj2 through the full height. Each was checked against PowerPoint's outline at three aspect ratios and custom adjust values (max deviation 0.002 pt). Text rectangles follow the standard too. Wild synthetic set: equation galleries 10.20 -> 5.32, 10.06 -> 5.31, 5.45 -> 2.47; whole set 4.84 -> 4.71. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 169 +++++++++++++++++++++++++++----------- 1 file changed, 119 insertions(+), 50 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index c72f87d8..4fda7de5 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -1813,71 +1813,119 @@ fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { Geometry { paths, text_rect, handles, sites: mid_sites(w, h) } } +/// The equation shapes, by ECMA-376's formulas (checked against PowerPoint's outlines at several +/// sizes and adjust values). Their bars span 73.49% of the width; the plus and multiply bars are +/// `adj1` of the shorter side thick, the others' `adj1` of the height. fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let pin = |lo: f64, v: f64, hi: f64| v.max(lo).min(hi.max(lo)); let ss = w.min(h); - let t = ss * a0.clamp(0.0, 50000.0) / 1e5; - let (cx, cy) = (w / 2.0, h / 2.0); - let mx = w * 0.0735; - let my = h * 0.0735; + let (hc, vc) = (w / 2.0, h / 2.0); + let dx1 = w * 73490.0 / 200000.0; + let (x1, x2) = (hc - dx1, hc + dx1); let mut b = Pb::new(); - match name { + let text = match name { "mathPlus" => { - let (hx, hy) = (cx - mx, cy - my); - b.poly(&[ - (cx - t / 2.0, cy - hy), - (cx + t / 2.0, cy - hy), - (cx + t / 2.0, cy - t / 2.0), - (cx + hx, cy - t / 2.0), - (cx + hx, cy + t / 2.0), - (cx + t / 2.0, cy + t / 2.0), - (cx + t / 2.0, cy + hy), - (cx - t / 2.0, cy + hy), - (cx - t / 2.0, cy + t / 2.0), - (cx - hx, cy + t / 2.0), - (cx - hx, cy - t / 2.0), - (cx - t / 2.0, cy - t / 2.0), - ]); + let a = pin(0.0, a0, 73490.0); + let dy1 = h * 73490.0 / 200000.0; + let dx2 = ss * a / 200000.0; + let (y1, y2, y3, y4) = (vc - dy1, vc - dx2, vc + dx2, vc + dy1); + let (xa, xb) = (hc - dx2, hc + dx2); + b.poly(&[(x1, y2), (xa, y2), (xa, y1), (xb, y1), (xb, y2), (x2, y2), (x2, y3), (xb, y3), (xb, y4), (xa, y4), (xa, y3), (x1, y3)]); + Rect::new(x1, y2, x2, y3) } "mathMinus" => { - b.poly(&[(mx, cy - t / 2.0), (w - mx, cy - t / 2.0), (w - mx, cy + t / 2.0), (mx, cy + t / 2.0)]); + let dy1 = h * pin(0.0, a0, 100000.0) / 200000.0; + b.poly(&[(x1, vc - dy1), (x2, vc - dy1), (x2, vc + dy1), (x1, vc + dy1)]); + Rect::new(x1, vc - dy1, x2, vc + dy1) } "mathEqual" => { - let gap = h * a1.clamp(0.0, 100000.0) / 1e5; - b.poly(&[(mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0), (mx, cy - gap / 2.0)]); - b.poly(&[(mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0 + t), (mx, cy + gap / 2.0 + t)]); + let a = pin(0.0, a0, 36745.0); + let g = pin(0.0, a1, 100000.0 - 2.0 * a); + let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); + let (y2, y3) = (vc - dy2, vc + dy2); + let (y1, y4) = (y2 - dy1, y3 + dy1); + b.poly(&[(x1, y1), (x2, y1), (x2, y2), (x1, y2)]); + b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); + Rect::new(x1, y1, x2, y4) } "mathNotEqual" => { - let gap = h * a2.clamp(0.0, 100000.0) / 1e5; - b.poly(&[(mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0), (mx, cy - gap / 2.0)]); - b.poly(&[(mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0 + t), (mx, cy + gap / 2.0 + t)]); - let ang = (a1 / 60000.0).to_radians(); - let dir = Vec2::new(ang.cos(), ang.sin()); - let len = (h / 2.0 - my) / ang.sin().abs().max(0.2); - let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0); - let c = Point::new(cx, cy); - let p = [c - dir * len - perp, c + dir * len - perp, c + dir * len + perp, c - dir * len + perp]; - b.poly(&p.map(|q| (q.x, q.y))); + let a = pin(0.0, a0, 50000.0); + let cr_ang = pin(4200000.0, a1, 6600000.0); + let g = pin(0.0, a2, 100000.0 - 2.0 * a); + let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); + let hd2 = (h / 2.0).max(1e-9); + let (y2, y3) = (vc - dy2, vc + dy2); + let (y1, y4) = (y2 - dy1, y3 + dy1); + let cadj2 = cr_ang - 5400000.0; + let xadj2 = hd2 * (cadj2 / 60000.0).to_radians().tan(); + let len = xadj2.hypot(hd2); + let bhw = len * dy1 / hd2; + let x7 = hc + xadj2 - bhw / 2.0; + let at = |y: f64| x7 - xadj2 * y / hd2; + let (x6, x5, x4, x3) = (at(y1), at(y2), at(y3), at(y4)); + let rx7 = x7 + bhw; + let dx7 = dy1 * hd2 / len; + let dy3 = dy1 * xadj2 / len; + let (rx, lx, ry, ly) = if cadj2 > 0.0 { (x7 + dx7, x7, dy3, 0.0) } else { (rx7, rx7 - dx7, 0.0, -dy3) }; + b.poly(&[ + (x1, y1), + (x6, y1), + (lx, ly), + (rx, ry), + (x6 + bhw, y1), + (x2, y1), + (x2, y2), + (x5 + bhw, y2), + (x4 + bhw, y3), + (x2, y3), + (x2, y4), + (x3 + bhw, y4), + (w - lx, h - ly), + (w - rx, h - ry), + (x3, y4), + (x1, y4), + (x1, y3), + (x4, y3), + (x5, y2), + (x1, y2), + ]); + Rect::new(x1, y1, x2, y4) } "mathDivide" => { - let gap = h * a1.clamp(0.0, 100000.0) / 1e5; - let r = ss * a2.clamp(0.0, 50000.0) / 1e5 / 2.0; - b.poly(&[(mx, cy - t / 2.0), (w - mx, cy - t / 2.0), (w - mx, cy + t / 2.0), (mx, cy + t / 2.0)]); - b.ellipse(cx, cy - t / 2.0 - gap - r, r, r); - b.ellipse(cx, cy + t / 2.0 + gap + r, r, r); + let a = pin(1000.0, a0, 36745.0); + let max3 = ((73490.0 - a) / 4.0).min(36745.0 * w / h.max(1e-9)); + let r3 = pin(1000.0, a2, max3); + let g = pin(0.0, a1, 73490.0 - 4.0 * r3 - a); + let dy1 = h * a / 200000.0; + let yg = h * g / 100000.0; + let rad = h * r3 / 100000.0; + let (y3, y4) = (vc - dy1, vc + dy1); + let y2 = y3 - (yg + rad); + let y1 = y2 - rad; + b.ellipse(hc, y1 + rad, rad, rad); + b.ellipse(hc, h - y1 - rad, rad, rad); + b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); + Rect::new(x1, y3, x2, y4) } _ => { - // mathMultiply: two bars crossing at the centre along the diagonals. - let len = ((cx - mx).powi(2) + (cy - my).powi(2)).sqrt(); - let c = Point::new(cx, cy); - for d in [Vec2::new(cx - mx, cy - my), Vec2::new(cx - mx, -(cy - my))] { - let dir = d / d.hypot().max(1e-9); - let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0); - let p = [c - dir * len - perp, c + dir * len - perp, c + dir * len + perp, c - dir * len + perp]; - b.poly(&p.map(|q| (q.x, q.y))); - } + // mathMultiply: an X whose arms run along the box's diagonals. + let th = ss * pin(0.0, a0, 51965.0) / 100000.0; + let ang = h.atan2(w); + let (sa, ca, ta) = (ang.sin(), ang.cos(), ang.tan().max(1e-9)); + let lm = w.hypot(h) * (1.0 - 51965.0 / 100000.0); + let (xm, ym) = (ca * lm / 2.0, sa * lm / 2.0); + let (dxam, dyam) = (sa * th / 2.0, ca * th / 2.0); + let (xa, ya, xb, yb) = (xm - dxam, ym + dyam, xm + dxam, ym - dyam); + let yc = (hc - xb) * ta + yb; + let (xd, xe) = (w - xb, w - xa); + let xfe = (vc - ya) / ta; + let (xf, xl) = (xe - xfe, xa + xfe); + let (yg, yh, yi) = (h - ya, h - yb, h - yc); + b.poly(&[(xa, ya), (xb, yb), (hc, yc), (xd, yb), (xe, ya), (xf, vc), (xe, yg), (xd, yh), (hc, yi), (xb, yh), (xa, yg), (xl, vc)]); + Rect::new(xa, yb, xe, yh) } - } - Geometry { paths: vec![fill(b.take())], text_rect: Rect::new(mx, cy - t / 2.0, w - mx, cy + t / 2.0), handles: vec![], sites: mid_sites(w, h) } + }; + Geometry { paths: vec![fill(b.take())], text_rect: text, handles: vec![], sites: mid_sites(w, h) } } fn ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { @@ -2119,6 +2167,27 @@ mod tests { assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); } + #[test] + fn equation_shapes_match_powerpoints_outlines() { + // Corners read off PowerPoint's own outlines of the presets at their default adjusts. + let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); + for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { + assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); + } + let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); + for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); + } + let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); + assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); + // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches + // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). + let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); + } + #[test] fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { use kurbo::Shape as _; From 4012cea0bfe4138363fda378b843aa9b9d8093a1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:07:33 -0400 Subject: [PATCH 37/59] Geom: sun, wave and double wave by the standard's formulas The sun's rays were thin spikes around a smaller core, and the waves' curves had the wrong amplitude and shift. They now follow ECMA-376: the sun's eight triangular rays with bases at the standard's fractions of the core's margin around a circle of radius 50% - adj (with its handle), and the waves' cubic edges whose control points sit 10/3 of the amplitude off the edge, shifted by adj2 (text rectangles too). Checked against PowerPoint's outlines at three sizes and adjust values (within 0.13 pt). Wild synthetic set: sun 30 -> 9 per-shape diff; group fill slide (waves) 7.88 -> 5.82; whole set 4.71 -> 4.65. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 101 +++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 4fda7de5..2424bdad 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -1107,26 +1107,31 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h) } "sun" => { - let k = a(0).clamp(12500.0, 46875.0) / 100000.0; - let (cx, cy) = (w / 2.0, h / 2.0); - let cr = 0.5 - k; - let core = Pb::new().ellipse(cx, cy, w * cr * 0.72, h * cr * 0.72).take(); - let mut rays = Pb::new(); - for i in 0..8 { - let ang = (i as f64 * 45.0).to_radians(); - let half = 9f64.to_radians(); - let rb = cr * 0.72 + 0.06; - let tip = (cx + w * 0.5 * ang.cos(), cy + h * 0.5 * ang.sin()); - let b1 = (cx + w * rb * (ang - half).cos(), cy + h * rb * (ang - half).sin()); - let b2 = (cx + w * rb * (ang + half).cos(), cy + h * rb * (ang + half).sin()); - rays.poly(&[tip, b1, b2]); - } - Geometry { - paths: vec![fill(rays.take()), fill(core)], - text_rect: Rect::new(w * 0.3, h * 0.3, w * 0.7, h * 0.7), - handles: vec![], - sites: mid_sites(w, h), - } + // ECMA-376 sun: eight triangular rays (their bases at fixed fractions of the core's + // margin) around a circle of radius 50% - adj. + let a = a(0).clamp(12500.0, 46875.0); + let g0 = 50000.0 - a; + let (g1, g2) = (g0 * 30274.0 / 32768.0, g0 * 12540.0 / 32768.0); + let (g10, g11) = ((50000.0 - g1) * 0.75, (50000.0 - g2) * 0.75); + let (g12, g13, g14) = (g10 + 3662.0, g11 + 3662.0, g11 + 12500.0); + let (g15, g16, g17, g18) = (100000.0 - g10, 100000.0 - g12, 100000.0 - g13, 100000.0 - g14); + let x = |g: f64| w * g / 100000.0; + let y = |g: f64| h * g / 100000.0; + let (ox1, oy1, ox2, oy2) = (w * 18436.0 / 21600.0, h * 3163.0 / 21600.0, w * 3163.0 / 21600.0, h * 18436.0 / 21600.0); + let (hc, vc) = (w / 2.0, h / 2.0); + b.poly(&[(w, vc), (x(g15), y(g18)), (x(g15), y(g14))]); + b.poly(&[(ox1, oy1), (x(g16), y(g13)), (x(g17), y(g12))]); + b.poly(&[(hc, 0.0), (x(g18), y(g10)), (x(g14), y(g10))]); + b.poly(&[(ox2, oy1), (x(g13), y(g12)), (x(g12), y(g13))]); + b.poly(&[(0.0, vc), (x(g10), y(g14)), (x(g10), y(g18))]); + b.poly(&[(ox2, oy2), (x(g12), y(g17)), (x(g13), y(g16))]); + b.poly(&[(hc, h), (x(g14), y(g15)), (x(g18), y(g15))]); + b.poly(&[(ox1, oy2), (x(g17), y(g16)), (x(g16), y(g17))]); + b.ellipse(hc, vc, x(g0), y(g0)); + let g7 = g0 * 23170.0 / 32768.0; + let mut g = simple(b.take(), Rect::new(x(50000.0 - g7), y(50000.0 - g7), x(50000.0 + g7), y(50000.0 + g7)), w, h); + g.handles.push(Handle::new(0, Point::new(0.0, vc), Vec2::new(w / 100000.0, 0.0), a, 12500.0, 46875.0)); + g } "moon" => { let k = a(0).clamp(0.0, 87500.0) / 100000.0; @@ -1612,19 +1617,36 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), "wave" | "doubleWave" => { - let amp = h * a(0).clamp(0.0, 20000.0) / 1e5; - let shift = w * a(1).clamp(-10000.0, 10000.0) / 1e5; + // ECMA-376 wave / doubleWave: the top and bottom edges are cubic waves (one or two + // periods) of amplitude adj1, shifted sideways by adj2. + let a1 = a(0).clamp(0.0, if name == "wave" { 20000.0 } else { 12500.0 }); + let a2 = a(1).clamp(-10000.0, 10000.0); + let y1 = h * a1 / 100000.0; + let dy2 = y1 * 10.0 / 3.0; + let (y2, y3, y4) = (y1 - dy2, y1 + dy2, h - y1); + let (y5, y6) = (y4 - dy2, y4 + dy2); + let of2 = w * a2 / 50000.0; + let dx2 = of2.min(0.0); + let dxr = of2.max(0.0); + let (x2, xr, xl, x10) = (-dx2, w - dxr, dxr, w + dx2); + let text_y = h * a1 / 50000.0; if name == "wave" { - b.m(shift.max(0.0), amp).c(w / 3.0, -amp, 2.0 * w / 3.0, 3.0 * amp, w + shift.min(0.0), amp); - b.l(w - shift.max(0.0), h - amp).c(2.0 * w / 3.0, h - 3.0 * amp, w / 3.0, h + amp, -shift.min(0.0), h - amp).z(); + let dx3 = (dx2 + xr) / 3.0; + let (x3, x7) = (x2 + dx3, xl + dx3); + let (x4, x8) = ((x3 + xr) / 2.0, (x7 + x10) / 2.0); + b.m(x2, y1).c(x3, y2, x4, y3, xr, y1).l(x10, y4).c(x8, y6, x7, y5, xl, y4).z(); } else { - b.m(0.0, amp).c(w / 6.0, -amp, w / 3.0, 3.0 * amp, w / 2.0, amp).c(2.0 * w / 3.0, -amp, 5.0 * w / 6.0, 3.0 * amp, w, amp); - b.l(w, h - amp) - .c(5.0 * w / 6.0, h - 3.0 * amp, 2.0 * w / 3.0, h + amp, w / 2.0, h - amp) - .c(w / 3.0, h - 3.0 * amp, w / 6.0, h + amp, 0.0, h - amp) - .z(); + let (dx3, dx4) = ((dx2 + xr) / 6.0, (dx2 + xr) / 3.0); + let (x3, x4, x5) = (x2 + dx3, x2 + dx4, (x2 + xr) / 2.0); + let x6 = x5 + dx3; + let x7 = (x6 + xr) / 2.0; + let (x10b, x11, x12) = (xl + dx3, xl + dx4, (xl + x10) / 2.0); + let x13 = x12 + dx3; + let x14 = (x13 + x10) / 2.0; + b.m(x2, y1).c(x3, y2, x4, y3, x5, y1).c(x6, y2, x7, y3, xr, y1).l(x10, y4); + b.c(x14, y6, x13, y5, x12, y4).c(x11, y6, x10b, y5, xl, y4).z(); } - simple(b.take(), Rect::new(0.0, amp * 2.0, w, h - amp * 2.0), w, h) + simple(b.take(), Rect::new(x2.max(xl), text_y, xr.min(x10), h - text_y), w, h) } "wedgeRectCallout" | "wedgeRoundRectCallout" => { let r = if name == "wedgeRoundRectCallout" { ss * a(2).clamp(0.0, 50000.0) / 1e5 } else { 0.0 }; @@ -2188,6 +2210,25 @@ mod tests { assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); } + #[test] + fn sun_and_waves_match_powerpoints_outlines() { + // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). + let k = 216.0; + let g = build("sun", k, k, &[]).unwrap(); + for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); + } + let g = build("wave", k, k, &[]).unwrap(); + for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); + } + // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. + let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); + for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); + } + } + #[test] fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { use kurbo::Shape as _; From 4773e4ee0f557e7a4d49a719d2d04ec30c20ede6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:11:01 -0400 Subject: [PATCH 38/59] Render: tiled pictures take their size from the resolution they record A tiled picture fill (including a blipFill with neither stretch nor tile, which PowerPoint tiles) assumed 96 dpi. PowerPoint sizes a picture by the resolution its file records: a 32 px tile saved at 90 dpi is 25.6 pt, not 24. The PNG pHYs chunk and JPEG JFIF density are now read (rounded to whole dpi) and scale the tile. Corpus lo/tdf128596 (tiled checkmark texture): 8.79 -> 0.52 mean diff (it had drifted from 7.36 when tiles started snapping to whole pixels). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/images.rs | 46 ++++++++++++++++++++++++++++++--- crates/render/src/tests_wild.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index b41c2de9..4d4f96da 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -121,14 +121,54 @@ fn decode_at(bytes: &[u8], side: u32) -> Option { Some(img.to_rgba8()) } -/// A raster picture's own size in pixels, read from its header (`None` for metafiles and -/// anything unreadable). +/// A raster picture's own size in 96-dpi pixels (so 0.75 pt each), read from its header: its +/// pixel size, scaled by the resolution it records (PNG `pHYs`, JPEG JFIF density) when that +/// isn't 96 dpi, as PowerPoint sizes pictures. `None` for metafiles and anything unreadable. pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { if crate::metafile::is_metafile(bytes) { return None; } let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; - (w > 0 && h > 0).then_some((w as f64, h as f64)) + if w == 0 || h == 0 { + return None; + } + let (dx, dy) = dpi(bytes).unwrap_or((96.0, 96.0)); + Some((w as f64 * 96.0 / dx, h as f64 * 96.0 / dy)) +} + +/// The resolution (whole dots per inch across and down) a PNG or JPEG records, when it records +/// one in a plausible range. +pub fn dpi(bytes: &[u8]) -> Option<(f64, f64)> { + // Whole dots per inch: PNGs store dots per metre, so 96 dpi reads as 95.9866. + let ok = |d: f64| (d.is_finite() && (10.0..=10000.0).contains(&d)).then_some(d.round()); + let be32 = |b: &[u8], i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])); + let be16 = |b: &[u8], i: usize| b.get(i..i + 2).map(|s| u16::from_be_bytes([s[0], s[1]])); + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + let mut i = 8usize; + for _ in 0..64 { + let len = be32(bytes, i)? as usize; + let kind = bytes.get(i + 4..i + 8)?; + if kind == b"pHYs" { + let (x, y, unit) = (be32(bytes, i + 8)?, be32(bytes, i + 12)?, *bytes.get(i + 16)?); + return if unit == 1 { Some((ok(x as f64 * 0.0254)?, ok(y as f64 * 0.0254)?)) } else { None }; + } + if kind == b"IDAT" || kind == b"IEND" { + return None; + } + i = i.checked_add(12)?.checked_add(len)?; + } + return None; + } + if bytes.starts_with(&[0xFF, 0xD8]) && bytes.get(6..11) == Some(b"JFIF\0".as_slice()) { + let unit = *bytes.get(13)?; + let (x, y) = (be16(bytes, 14)? as f64, be16(bytes, 16)? as f64); + return match unit { + 1 => Some((ok(x)?, ok(y)?)), + 2 => Some((ok(x * 2.54)?, ok(y * 2.54)?)), + _ => None, + }; + } + None } /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index a9b64d75..63d381c0 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -437,3 +437,44 @@ fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { assert_eq!(paint::tile_origin(b, "br", (20.0, 10.0), (5.0, -5.0)), Point::new(85.0, 35.0)); assert_eq!(paint::tile_origin(b, "nonsense", (20.0, 10.0), (f64::NAN, 1.0)), Point::new(0.0, 1.0)); } + +/// `png` with a pHYs chunk saying `dpi` inserted after its header. +fn with_dpi(png: &[u8], dpi: f64) -> Vec { + fn crc32(data: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for b in data { + c ^= *b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let ppm = (dpi / 0.0254).round() as u32; + let mut chunk = b"pHYs".to_vec(); + chunk.extend_from_slice(&ppm.to_be_bytes()); + chunk.extend_from_slice(&ppm.to_be_bytes()); + chunk.push(1); + let mut out = png[..33].to_vec(); // signature (8) + IHDR chunk (25) + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&chunk); + out.extend_from_slice(&crc32(&chunk).to_be_bytes()); + out.extend_from_slice(&png[33..]); + out +} + +#[test] +fn picture_resolution_sizes_tiles() { + let png = half_red_half_blue(); + assert_eq!(images::dpi(&png), None); + let p48 = with_dpi(&png, 48.0); + let d = images::dpi(&p48).unwrap_or_default(); + assert!((d.0 - 48.0).abs() < 0.01 && (d.1 - 48.0).abs() < 0.01, "{d:?}"); + // 8 × 4 px at 48 dpi is as big as 16 × 8 at 96. + assert_eq!(images::natural_size(&p48), Some((16.0, 8.0))); + assert_eq!(images::natural_size(&png), Some((8.0, 4.0))); + // JFIF density in dots per inch. + let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\x01\x00\x48\x00\x48"); + assert_eq!(images::dpi(&jpeg), Some((72.0, 72.0))); +} From aafce0d2ef96c5b3ec092ca963aa1a1faf24f66a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:16:39 -0400 Subject: [PATCH 39/59] Render: gradient lines draw their gradient, heads included A line filled with a gradient drew in its first stop's colour only. As PowerPoint draws it, the gradient is laid out over the box the stroke covers (the outline's bounds grown by half the line width, before the line is cut back under its arrowheads), so a horizontal line's 90 degree gradient runs across its width; arrowheads take the gradient where they sit. Wild synthetic set: gradient lines slide 8.43 -> 0.40; picture with a gradient border 8.54 -> 7.67. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 52 +++++++++++++++++++++++++++------ crates/render/src/tests_wild.rs | 33 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e6cc5d1d..415ec368 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1025,10 +1025,11 @@ impl Renderer { 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, base, lw, fade(i).stroke); + 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); @@ -1203,7 +1204,7 @@ impl Renderer { 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, pre * o.base, lw, o.alpha); + stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha); } } ctx.pop_layer(); @@ -1326,7 +1327,20 @@ fn blur(sigma: f64) -> Filter { Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) } -fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, path: &BezPath, m: Affine, lw: f64, alpha: f64) { +/// 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)] +fn stroke( + ctx: &mut RenderContext, + rctx: &Ctx, + line: &Line, + ph: Option, + 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, @@ -1361,10 +1375,7 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, pa Some(Fill::Solid { color: c }) => { ctx.set_paint(color(rctx.color(c, ph), alpha)); } - Some(Fill::Gradient(g)) => { - let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK); - ctx.set_paint(color(c, 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. @@ -1465,6 +1476,16 @@ fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { 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, 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 @@ -1641,6 +1662,19 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option 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" { @@ -1683,7 +1717,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option o.line_to(tip - back); o.line_to(base - n * wl / 2.0 - back); ctx.set_transform(m); - ctx.set_paint(color(col, 1.0)); + 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; @@ -1696,7 +1730,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option } } ctx.set_transform(m); - ctx.set_paint(color(col, 1.0)); + set_paint(ctx); ctx.fill_path(&p); } } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 63d381c0..b87fd456 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -478,3 +478,36 @@ fn picture_resolution_sizes_tiles() { jpeg.extend_from_slice(b"JFIF\0\x01\x01\x01\x00\x48\x00\x48"); assert_eq!(images::dpi(&jpeg), Some((72.0, 72.0))); } + +#[test] +fn gradient_lines_and_their_heads_take_the_gradient() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + let blue = Rgba::rgb(0, 0, 255); + let grad = |angle: f64| { + Fill::Gradient(Gradient { + stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }], + shape: GradientShape::Linear { angle, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + }) + }; + // Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at + // the end blue too. + let mut sh = arrow_line("triangle", 20.0, RED); + if let Some(l) = &mut sh.line { + l.fill = Some(grad(0.0)); + } + let img = render(vec![sh]); + let (start, mid, head) = (img.pixel(102, 100), img.pixel(200, 100), img.pixel(296, 100)); + assert!(start[0] > 200 && start[2] < 60, "{start:?}"); + assert!(mid[0] > 60 && mid[2] > 60, "{mid:?}"); + assert!(head[2] > 200 && head[0] < 60, "head {head:?}"); + // Top to bottom across a horizontal line: the gradient runs over the line's width. + let mut sh = arrow_line("none", 20.0, RED); + if let Some(l) = &mut sh.line { + l.fill = Some(grad(90.0)); + } + let img = render(vec![sh]); + let (top, bottom) = (img.pixel(200, 91), img.pixel(200, 109)); + assert!(top[0] > 200 && top[2] < 60 && bottom[2] > 200 && bottom[0] < 60, "{top:?} {bottom:?}"); +} From 84e6c12543b275f1c5cb27ef5bbb87c72cd256db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:20:00 -0400 Subject: [PATCH 40/59] Render: a group's outer shadow is cast by its children An outer shadow set on a group (grpSpPr effectLst) was ignored. PowerPoint casts it from everything in the group: the children are now drawn once more under the shadow's offset, scale and skew (anchored on the group's box), recoloured to the shadow colour and blurred, below the group. Wild synthetic set: combined-effects slide (shadowed group) 2.00 -> 0.98. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 27 +++++++++++++++++++++++++++ crates/render/src/tests_wild.rs | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 415ec368..8a00a197 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -788,6 +788,33 @@ impl Renderer { 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 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))); + for ch in children { + self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); + } + 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); } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index b87fd456..1611302a 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -511,3 +511,27 @@ fn gradient_lines_and_their_heads_take_the_gradient() { let (top, bottom) = (img.pixel(200, 91), img.pixel(200, 109)); assert!(top[0] > 200 && top[2] < 60 && bottom[2] > 200 && bottom[0] < 60, "{top:?} {bottom:?}"); } + +#[test] +fn a_groups_shadow_is_cast_by_its_children() { + let child = |id: u32, x: f64| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 0.0, 80.0, 100.0)), + fill: solid(RED), + line: no_line(), + ..Default::default() + }; + let g = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + kind: ShapeKind::Group { children: vec![child(23, 0.0), child(24, 120.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) }, + effects: Some(offset_shadow()), + ..Default::default() + }; + let img = render(vec![g]); + // 20 pt to the right of each child: the shadow; in the gap only the left child's shadow shows. + assert!(close(img.pixel(190, 150), Rgba::BLACK, 30), "{:?}", img.pixel(190, 150)); + assert!(close(img.pixel(210, 150), WHITE, 10), "{:?}", img.pixel(210, 150)); + assert!(close(img.pixel(310, 150), Rgba::BLACK, 30), "{:?}", img.pixel(310, 150)); + assert!(close(img.pixel(150, 150), RED, 10), "children drawn over it"); +} From 772abb2154ffcf95534259720a6090a590ef3a89 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:27:13 -0400 Subject: [PATCH 41/59] Render: pattern fills blend neighbouring cells between whole-pixel sizes Pattern tiles were sampled nearest-cell, exact when a pattern cell (0.75 pt) is a whole number of output pixels (a 960-wide export of a 720 pt slide) but harsh otherwise: at 960 px for a 960 pt slide (0.75 px per cell) PowerPoint shows greys where a pixel straddles two cells, and DeckCraft showed only the two colours, in different places. The tile is now sampled bilinearly. Fitted against PowerPoint's export of all 54 presets at 0.75 px per cell: mean error 0.067 (bilinear) against 0.082 (nearest) and 0.095 (area average); still not exact (narVert reads 255/128/0 in PowerPoint, 213/128/42 here). Unchanged at whole-pixel sizes. Wild synthetic set: patterned backgrounds 21.15 -> 11.31 and 18.00 -> 9.75, pattern galleries 22.50 -> 19.44 and 13.96 -> 11.64. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/pattern.rs | 5 ++++- crates/render/src/tests_wild.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 99c6b291..1c26c977 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -109,7 +109,10 @@ pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, sampler: peniko::ImageSampler { x_extend: peniko::Extend::Repeat, y_extend: peniko::Extend::Repeat, - quality: peniko::ImageQuality::Low, + // Bilinear, as PowerPoint samples the tile: exact at a whole number of output pixels + // per cell; between, a pixel straddling two cells takes a blend of both (fitted to its + // export at 0.75 px per cell: mean error 0.067 against 0.082 for the nearest cell). + quality: peniko::ImageQuality::Medium, alpha: 1.0, }, }); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 1611302a..53deadae 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -535,3 +535,25 @@ fn a_groups_shadow_is_cast_by_its_children() { assert!(close(img.pixel(310, 150), Rgba::BLACK, 30), "{:?}", img.pixel(310, 150)); assert!(close(img.pixel(150, 150), RED, 10), "children drawn over it"); } + +#[test] +fn patterns_between_whole_pixel_cells_blend_neighbouring_cells() { + use deckcraft_model::style::PatternFill; + // narVert (alternate cells) at 0.75 output pixels per cell: PowerPoint's export reads + // 255, 128, 0 repeating across; drawing the nearest cell gave only black and white. Bilinear + // sampling gives about 213, 128, 42 (closer on average over all presets, not exact). + let g = |v: u8| ColorRef::rgb(Rgba::rgb(v, v, v)); + let sh = Shape { + id: ShapeId(25), + xfrm: Some(Xfrm::new(0.0, 0.0, 60.0, 60.0)), + fill: Some(Fill::Pattern(PatternFill { preset: "narVert".into(), fg: g(0), bg: g(255) })), + line: no_line(), + ..Default::default() + }; + let img = render(vec![sh]); + let row: Vec = (12..24).map(|x| img.pixel(x, 20)[0]).collect(); + assert!(row.iter().any(|v| (100..=156).contains(v)), "a blended column: {row:?}"); + assert!(row.iter().any(|v| *v < 60) && row.iter().any(|v| *v > 200), "{row:?}"); + // The pattern repeats every 6 px (8 cells). + assert!(row.iter().zip(row.iter().skip(6)).all(|(a, b)| (*a as i32 - *b as i32).abs() <= 2), "{row:?}"); +} From 3b04dd14be5053b8e4b5413c24ac3a962c01d2db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:32:45 -0400 Subject: [PATCH 42/59] Render: pictures with opposite corrections no longer share decoded pixels The decoded-picture cache keyed adjustments with an xor-multiply mix in which brightness b, contrast c and -b, -c collide (the sign bits cancel), so of two copies of a picture, one +40% bright and contrast and one -40%, both showed whichever was decoded first. The key is now a real hash. Wild synthetic set: brightness/contrast grid 27.86 -> 15.93. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/images.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4d4f96da..238821cb 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -66,11 +66,11 @@ fn with(f: impl FnOnce(&mut Cache) -> R) -> R { } fn adjust_key(a: &PictureAdjust) -> u64 { - let mut h: u64 = 1469598103934665603; - let mut mix = |v: u64| { - h ^= v; - h = h.wrapping_mul(1099511628211); - }; + use std::hash::Hasher; + // A real hash: an FNV-style xor-multiply mix let (b, c) and (-b, -c) collide (the sign bits + // cancel), so a picture brightened and contrasted showed the darkened copy's pixels. + let mut hs = std::hash::DefaultHasher::new(); + let mut mix = |v: u64| hs.write_u64(v); mix(a.brightness.to_bits()); mix(a.contrast.to_bits()); mix(a.saturation.unwrap_or(1.0).to_bits()); @@ -84,7 +84,7 @@ fn adjust_key(a: &PictureAdjust) -> u64 { mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41); } } - h + hs.finish() } /// The duotone's dark and light colours. The painter resolves them to plain RGB first (see @@ -368,6 +368,17 @@ mod tests { assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default())); } + #[test] + fn opposite_brightness_and_contrast_are_cached_apart() { + let a = PictureAdjust { brightness: 0.4, contrast: 0.4, ..Default::default() }; + let b = PictureAdjust { brightness: -0.4, contrast: -0.4, ..Default::default() }; + assert_ne!(adjust_key(&a), adjust_key(&b)); + let c = PictureAdjust { brightness: 0.4, contrast: -0.4, ..Default::default() }; + let d = PictureAdjust { brightness: -0.4, contrast: 0.4, ..Default::default() }; + assert_ne!(adjust_key(&c), adjust_key(&d)); + assert_eq!(adjust_key(&a), adjust_key(&a.clone())); + } + #[test] fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() { use image::{Delay, Frame, Rgba, RgbaImage}; From bf877411fb5b5584152a1451e7cafa0f783326cc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:35:22 -0400 Subject: [PATCH 43/59] Render: picture brightness with contrast as PowerPoint combines them Brightness was added after the contrast stretch. Measured on PowerPoint's export of grey ramps (5 x 5 grid of -80..80% brightness and contrast): contrast scales about mid-grey (x(1+c) below zero, /(1-c) above) and brightness adds b * 255 scaled by (1 + cf) / 2, half before the stretch and half after; +40% brightness with +40% contrast maps black to 54, not 17. DeckCraft now matches the 25 ramps within 5 grey levels. Wild synthetic set: brightness/contrast grid 15.93 -> 13.36, recolour slide 8.74 -> 7.60. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/images.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 238821cb..f3010dab 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -327,7 +327,10 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { let s = if a.grayscale { 0.0 } else { sat }; for v in &mut rgb { *v = y + (*v - y) * s; - *v = (*v - 128.0) * cf + 128.0 + b; + // Measured on PowerPoint's export of grey ramps: contrast scales about mid-grey by + // `cf`, and brightness adds b · 255 scaled by (1 + cf) / 2 (half of it is applied + // before the contrast stretch, half after): +40% bright, +40% contrast maps 0 to 54. + *v = (*v - 127.5) * cf + 127.5 + b * (1.0 + cf) / 2.0; } // Duotone: the pixel's luminance picks a colour between the dark and the light one. if let Some((d, l)) = duo { @@ -368,6 +371,24 @@ mod tests { assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default())); } + #[test] + fn brightness_and_contrast_map_greys_like_powerpoint() { + // Grey levels PowerPoint's export gives for inputs 0, 32, 64, 96 (and 128, 160). + let run = |b: f64, c: f64, input: &[u8]| -> Vec { + let mut img = image::RgbaImage::from_fn(input.len() as u32, 1, |x, _| { + let v = input[x as usize]; + image::Rgba([v, v, v, 255]) + }); + apply_adjust(&mut img, &PictureAdjust { brightness: b, contrast: c, ..Default::default() }); + img.pixels().map(|p| p[0]).collect() + }; + let near = |got: Vec, want: &[u8]| got.iter().zip(want).all(|(g, w)| (*g as i32 - *w as i32).abs() <= 5); + assert!(near(run(0.4, 0.4, &[0, 32, 64, 96]), &[54, 105, 157, 210]), "{:?}", run(0.4, 0.4, &[0, 32, 64, 96])); + assert!(near(run(0.4, -0.4, &[0, 32, 64, 96]), &[133, 152, 171, 190])); + assert!(near(run(-0.4, 0.4, &[128, 160, 192]), &[0, 45, 97])); + assert!(near(run(0.0, -0.8, &[0, 128, 255]), &[102, 127, 152])); + } + #[test] fn opposite_brightness_and_contrast_are_cached_apart() { let a = PictureAdjust { brightness: 0.4, contrast: 0.4, ..Default::default() }; From 754841aaa45947fb1b059f534ef4634cd0fc9841 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:39:06 -0400 Subject: [PATCH 44/59] Pptx: a table naming no style reads as the table grid style A table whose tblPr names no tableStyleId drew without any lines. PowerPoint draws such a table with thin dark grid lines (as its "No Style, Table Grid"); observed on a synthetic deck with and without the explicit "No Style, No Grid" GUID. Such tables now read as the grid style. Wild synthetic set: tables with cell margins/fills/styles 4.80 -> 2.82, 11.28 -> 10.15, 6.86 -> 3.16. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pptx/src/read/shapes.rs | 4 +++- crates/pptx/tests/fills.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 51b2b863..c4b3f77f 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -436,7 +436,9 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> } fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { - let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() }; + // A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table + // Grid"), not without lines. + let mut t = Table { style: "grid".into(), first_row: false, band_row: false, ..Default::default() }; if let Some(pr) = tbl.child("tblPr") { t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f)); t.first_row = pr.bool("firstRow").unwrap_or(false); diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 8620b76b..77e719ea 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -170,3 +170,36 @@ fn a_gradient_tile_rect_is_read_and_round_trips() { let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape"); assert!(matches!(&s.fill, Some(Fill::Gradient(g)) if g.tile == [-1.0, -1.0, 0.0, 0.0]), "{:?}", s.fill); } + +#[test] +fn a_table_naming_no_style_is_drawn_with_the_table_grid() { + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let mut t = deckcraft_model::Table::new(2, 2, 200.0, 40.0); + t.style = "none".into(); + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Grid".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 80.0)), + kind: deckcraft_model::ShapeKind::Table(t), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let style = |bytes: &[u8]| { + let q = deckcraft_pptx::import(bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Grid").expect("shape").clone(); + match s.kind { + deckcraft_model::ShapeKind::Table(t) => t.style, + other => panic!("{other:?}"), + } + }; + // Written with its style, it reads back as written. + assert_eq!(style(&bytes), "none"); + // Without a:tableStyleId PowerPoint draws thin dark grid lines. + let bare = edit_slides(&bytes, |x| { + let (Some(a), Some(b)) = (x.find(""), x.find("")) else { return x }; + format!("{}{}", &x[..a], &x[b + "".len()..]) + }); + assert_eq!(style(&bare), "grid"); +} From 675d20417ed0f07e024b40825f4b7a459e4bee8f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:44:03 -0400 Subject: [PATCH 45/59] Pictures: black and white, inverted alpha, and PowerPoint's grey weights - a:biLevel (Color > Black and White) was ignored. It is now read, written and drawn: white where the luminance reaches the threshold, else black (observed thresholds at 30/50/70% on colour swatches). - a:alphaInv was ignored; an opaque picture with it is invisible in PowerPoint. Now read, written and drawn. - Grayscale, duotone and saturation weighed channels 0.299/0.587/0.114; PowerPoint's export greys pure red to 54 and green to 182: Rec. 709 weights (0.2126/0.7152/0.0722). Wild synthetic set: recolour slide 7.60 -> 1.15, picture corrections 13.36 -> 7.36. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/model/src/style.rs | 6 ++++ crates/pptx/src/read/dml.rs | 2 ++ crates/pptx/src/write/dml.rs | 16 +++++++++-- crates/pptx/tests/fills.rs | 25 +++++++++++++++++ crates/render/src/images.rs | 53 ++++++++++++++++++++++++++++++++++-- 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index c7cd148c..b1b2a53c 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -157,6 +157,12 @@ pub struct PictureAdjust { /// Make a colour transparent (Set Transparent Color). pub clear_color: Option, pub sharpen: f64, + /// `a:biLevel`: black and white, split at this luminance (0–1). + #[serde(skip_serializing_if = "Option::is_none")] + pub bilevel: Option, + /// `a:alphaInv`: opacity inverted. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub alpha_inv: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 004cd4b3..b0f7a801 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -135,6 +135,8 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option { pf.adjust.contrast = pct(e, "contrast").unwrap_or(0.0).clamp(-1.0, 1.0); } "grayscl" => pf.adjust.grayscale = true, + "biLevel" => pf.adjust.bilevel = Some(pct(e, "thresh").unwrap_or(0.5).clamp(0.0, 1.0)), + "alphaInv" => pf.adjust.alpha_inv = true, "duotone" => { let cs: Vec = e.elements().filter_map(color_el).collect(); if let [a, b] = cs.as_slice() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index c1168ca1..2aa86930 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -98,8 +98,14 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); w.open(tag, A::new().a("rotWithShape", "1")); let adj = &pf.adjust; - let has_kids = - pf.alpha.is_some() || adj.brightness != 0.0 || adj.contrast != 0.0 || adj.grayscale || adj.duotone.is_some() || adj.clear_color.is_some(); + let has_kids = pf.alpha.is_some() + || adj.brightness != 0.0 + || adj.contrast != 0.0 + || adj.grayscale + || adj.duotone.is_some() + || adj.clear_color.is_some() + || adj.bilevel.is_some() + || adj.alpha_inv; let a = A::new().a("r:embed", &rid); if has_kids { w.open("a:blip", a); @@ -116,6 +122,12 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil if let Some(al) = pf.alpha { w.empty("a:alphaModFix", A::new().a("amt", pct(al.clamp(0.0, 1.0)))); } + if adj.alpha_inv { + w.empty0("a:alphaInv"); + } + if let Some(t) = adj.bilevel { + w.empty("a:biLevel", A::new().a("thresh", pct(t.clamp(0.0, 1.0)))); + } if let Some((d, l)) = &adj.duotone { w.open0("a:duotone"); color(w, d); diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 77e719ea..98e6d1bd 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -203,3 +203,28 @@ fn a_table_naming_no_style_is_drawn_with_the_table_grid() { }); assert_eq!(style(&bare), "grid"); } + +#[test] +fn black_and_white_and_inverted_alpha_pictures_round_trip() { + let mut p = defaults::new_presentation(None); + let media = p.add_media("tile.png", "image/png", png()); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let mut pf = PictureFill { media, ..Default::default() }; + pf.adjust.bilevel = Some(0.3); + pf.adjust.alpha_inv = true; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "BW".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Picture(pf)), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "BW").expect("shape"); + match &s.fill { + Some(Fill::Picture(pf)) => assert_eq!((pf.adjust.bilevel, pf.adjust.alpha_inv), (Some(0.3), true)), + other => panic!("{other:?}"), + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f3010dab..f7d56c5d 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -75,6 +75,8 @@ fn adjust_key(a: &PictureAdjust) -> u64 { mix(a.contrast.to_bits()); mix(a.saturation.unwrap_or(1.0).to_bits()); mix(a.grayscale as u64); + mix(a.bilevel.map_or(u64::MAX, f64::to_bits)); + mix(a.alpha_inv as u64); mix(a.sharpen.to_bits()); if let Some(c) = a.clear_color { mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40); @@ -306,7 +308,15 @@ pub(crate) fn gif_pixmap(bytes: &Arc>, adj: &PictureAdjust, t: f64) -> O fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { let sat = a.saturation.unwrap_or(1.0); - if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() { + if a.brightness == 0.0 + && a.contrast == 0.0 + && sat == 1.0 + && !a.grayscale + && a.clear_color.is_none() + && a.duotone.is_none() + && a.bilevel.is_none() + && !a.alpha_inv + { return; } let duo = duotone_rgb(a); @@ -323,7 +333,7 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { continue; } let mut rgb = [p[0] as f64, p[1] as f64, p[2] as f64]; - let y = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + let y = luma(rgb); let s = if a.grayscale { 0.0 } else { sat }; for v in &mut rgb { *v = y + (*v - y) * s; @@ -334,18 +344,32 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { } // Duotone: the pixel's luminance picks a colour between the dark and the light one. if let Some((d, l)) = duo { - let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0); + let t = (luma(rgb) / 255.0).clamp(0.0, 1.0); rgb = [d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t]; } + // Black and white: white where the luminance reaches the threshold. + if let Some(t) = a.bilevel { + let v = if luma(rgb) >= t * 255.0 { 255.0 } else { 0.0 }; + rgb = [v; 3]; + } for (i, v) in rgb.iter().enumerate() { if let Some(ch) = p.0.get_mut(i) { *ch = v.round().clamp(0.0, 255.0) as u8; } } + if a.alpha_inv { + p[3] = 255 - p[3]; + } } } +/// Luminance as PowerPoint weighs it for grayscale, black-and-white and duotone pictures +/// (measured: Rec. 709 weights on the stored values; pure red greys to 54, green to 182). +fn luma(rgb: [f64; 3]) -> f64 { + 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used)] @@ -389,6 +413,29 @@ mod tests { assert!(near(run(0.0, -0.8, &[0, 128, 255]), &[102, 127, 152])); } + #[test] + fn grayscale_black_and_white_and_inverted_alpha_like_powerpoint() { + let swatches = [[255u8, 0, 0], [0, 255, 0], [0, 0, 255], [200, 100, 50], [60, 160, 110], [120, 120, 130]]; + let run = |adj: PictureAdjust| -> Vec<[u8; 4]> { + let mut img = image::RgbaImage::from_fn(swatches.len() as u32, 1, |x, _| { + let c = swatches[x as usize]; + image::Rgba([c[0], c[1], c[2], 200]) + }); + apply_adjust(&mut img, &adj); + img.pixels().map(|p| p.0).collect() + }; + // PowerPoint's export of these swatches in grayscale: 54, 182, 18, 117, 135, 120. + let g = run(PictureAdjust { grayscale: true, ..Default::default() }); + for (got, want) in g.iter().zip([54u8, 182, 18, 117, 135, 120]) { + assert!((got[0] as i32 - want as i32).abs() <= 1 && got[0] == got[1] && got[1] == got[2], "{got:?} vs {want}"); + } + // Black and white at 50%: only green and the light green reach mid-grey. + let bw: Vec = run(PictureAdjust { bilevel: Some(0.5), ..Default::default() }).iter().map(|p| p[0]).collect(); + assert_eq!(bw, [0, 255, 0, 0, 255, 0]); + // Inverted alpha: 200 becomes 55. + assert!(run(PictureAdjust { alpha_inv: true, ..Default::default() }).iter().all(|p| p[3] == 55)); + } + #[test] fn opposite_brightness_and_contrast_are_cached_apart() { let a = PictureAdjust { brightness: 0.4, contrast: 0.4, ..Default::default() }; From 46dfb23dc556f12d28088c049b2331b54cb79ec9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:49:18 -0400 Subject: [PATCH 46/59] Render: outline (shape) path gradients from an off-shape focus or a focus box A "shape" path gradient with its focus off the shape (fillToRect on a corner of an ellipse's box) filled solid with its last colour: the position came from the nearest crossing of the ray from the focus with the outline, and rays from outside cross it twice or miss. The contours are the outline scaled about the focus, which a point first reaches at the ray's farthest crossing; that is now used. A focus rectangle (rather than a point) is the first colour throughout, with the colour running from its edge to the outline, as PowerPoint draws it. Wild synthetic set: shape path gradients 18.95 -> 3.54. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/pathgrad.rs | 51 ++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/render/src/pathgrad.rs b/crates/render/src/pathgrad.rs index 03855560..7402ab2c 100644 --- a/crates/render/src/pathgrad.rs +++ b/crates/render/src/pathgrad.rs @@ -30,7 +30,32 @@ pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec ); let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) }; match kind { - "shape" if !outline.is_empty() => shape_position(outline, f.center(), p), + "shape" if !outline.is_empty() => { + // A focus rectangle (not a point) is the first colour throughout, as PowerPoint draws + // it; outside, the colour runs from the rectangle's edge to the outline along the ray + // from its centre. + if f.width() > 1e-9 && f.height() > 1e-9 { + if f.contains(p) { + return 0.0; + } + let c = f.center(); + let to_outline = shape_position(outline, c, p); + let d = (p - c).hypot(); + if to_outline <= 0.0 || d <= 0.0 { + return 1.0; + } + // Where the ray leaves the focus rectangle, as a share of the way to `p`. + let v = p - c; + let kx = if v.x.abs() > 1e-12 { (f.width() / 2.0) / v.x.abs() } else { f64::INFINITY }; + let ky = if v.y.abs() > 1e-12 { (f.height() / 2.0) / v.y.abs() } else { f64::INFINITY }; + let rect_share = kx.min(ky).min(1.0); + // p is `to_outline` of the way to the outline; the rectangle edge `rect_share` of + // the way to p. + let outline_share = 1.0 / to_outline; // outline distance in units of |p - c| + return ((1.0 - rect_share) / (outline_share - rect_share).max(1e-9)).clamp(0.0, 1.0); + } + shape_position(outline, f.center(), p) + } _ => { // Distance outside the focus rect along each axis, over the room to that side's edge. let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| { @@ -55,7 +80,9 @@ fn shape_position(outline: &[Vec], c: Point, p: Point) -> f64 { if len < 1e-9 { return 0.0; } - // Nearest crossing of the ray c + s·d (s > 0) with an edge. + // The outline scaled about `c` by t reaches `p` first (smallest t) where the ray's farthest + // crossing does: the only crossing for a focus inside a convex outline, and the one that + // still works for a focus off the shape (a corner of an ellipse's box). let mut best = f64::INFINITY; for poly in outline { for w in poly.windows(2) { @@ -69,7 +96,7 @@ fn shape_position(outline: &[Vec], c: Point, p: Point) -> f64 { let s = ac.cross(e) / den; let u = ac.cross(d) / den; if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) { - best = best.min(s); + best = if best.is_finite() { best.max(s) } else { s }; } } } @@ -207,6 +234,24 @@ mod tests { assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6); } + #[test] + fn shape_paths_from_off_the_shape_or_a_focus_box() { + let b = Rect::new(0.0, 0.0, 280.0, 200.0); + let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01)); + // Focus on the box's corner, outside the ellipse: contours are the ellipse shrunk towards + // the corner, so its far edge is the end and its middle part way (it was all the end + // colour). + let corner = [0.0, 0.0, 1.0, 1.0]; + let mid = position("shape", b, corner, &ellipse, Point::new(140.0, 100.0)); + assert!(mid > 0.3 && mid < 0.8, "{mid}"); + assert!(position("shape", b, corner, &ellipse, Point::new(279.0, 100.0)) > 0.95); + // A focus rectangle is the first colour inside, and the colour runs from its edge out. + let boxed = [0.25, 0.25, 0.25, 0.25]; + assert_eq!(position("shape", b, boxed, &ellipse, Point::new(150.0, 110.0)), 0.0); + let half = position("shape", b, boxed, &ellipse, Point::new(140.0 + 70.0 + 35.0, 100.0)); + assert!((half - 0.5).abs() < 0.02, "{half}"); + } + #[test] fn the_ramp_runs_through_the_stops() { let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))]; From 9d4541b8b4106082ec1cf259af5deee19ea0df87 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:59:15 -0400 Subject: [PATCH 47/59] Render: bevelled shapes lit like PowerPoint's default 3-D light (approximation) A shape with a top bevel seen through a 3-D scene drew flat. Fitted to PowerPoint's export of the bevel presets on a solid fill with the default three-point light rig and matte material, DeckCraft now lights the face and a band as wide as the bevel inside the outline as surfaces: Lambert light from the upper left (azimuth 240, 45 degrees up), c * (0.453 + 0.731 * n.L) + 14.7 per channel, the band's normal tilting outwards by the profile's slope (quarter circle by default, straight for angle). Within about 15 grey levels RMS of PowerPoint on the circle bevel; other profiles use the circle's, pictures get light and dark veils, and other light rigs and materials (and anything without a scene) stay flat. Wild synthetic set: bevel presets slide 5.20 -> 2.48, materials/rigs 13.25 -> 12.73. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/bevel.rs | 212 ++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 19 +++ crates/render/src/tests_wild.rs | 23 ++++ 3 files changed, 254 insertions(+) create mode 100644 crates/render/src/bevel.rs diff --git a/crates/render/src/bevel.rs b/crates/render/src/bevel.rs new file mode 100644 index 00000000..85dfb3b6 --- /dev/null +++ b/crates/render/src/bevel.rs @@ -0,0 +1,212 @@ +//! 3-D bevels (`a:sp3d/a:bevelT` seen through a `a:scene3d`), approximated in 2-D: the shape's +//! face and a band inside its outline as wide as the bevel are lit as surfaces, the band's normal +//! tilting out towards the outline by the bevel profile's slope. +//! +//! Fitted to PowerPoint's export of bevel presets on a solid-filled shape with the default +//! three-point light rig and matte material: each channel comes out as +//! `c · (0.453 + 0.731 · max(0, n · L)) + 14.7` for a light from the upper left (azimuth 240°, +//! 45° up), within about 15 grey levels RMS across the band (exact profiles, specular highlights +//! and the other rigs, materials and cameras are not modelled). + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use kurbo::{Affine, BezPath, Point, Rect}; +use vello_cpu::{Pixmap, RenderContext, peniko}; + +/// Longest side of the rasterised lighting, in pixels. +const MAX_SIDE: f64 = 512.0; +const AMBIENT: f64 = 0.453; +const DIFFUSE: f64 = 0.731; +const SPECULAR: f64 = 14.7; + +/// A top bevel: its width and height in points and its preset. +#[derive(Clone, Debug, PartialEq)] +pub struct Bevel { + pub w: f64, + pub h: f64, + pub preset: String, + /// Lit as measured (the three-point rig and matte material); other rigs and materials are not + /// drawn. + pub modelled: bool, +} + +/// The top bevel of 3-D XML kept from the file (`scene3d` + `sp3d`), when it has both a scene +/// (without one PowerPoint draws the shape flat) and a `bevelT`. +pub fn from_raw(raw: &str) -> Option { + if !raw.contains("scene3d") { + return None; + } + let i = raw.find("bevelT")?; + let tag = raw.get(i..)?; + let tag = tag.get(..tag.find('>')?)?; + let attr = |name: &str| -> Option<&str> { + let key = format!(" {name}=\""); + let s = tag.find(&key)? + key.len(); + let rest = tag.get(s..)?; + rest.get(..rest.find('"')?) + }; + let emu = |name: &str| attr(name).and_then(|v| v.parse::().ok()).map(|v| v / 12700.0); + let w = emu("w").unwrap_or(6.0).clamp(0.0, 2000.0); + let h = emu("h").unwrap_or(6.0).clamp(0.0, 2000.0); + // The light rig and material, from anywhere in the XML (`a:lightRig rig=`, `prstMaterial=`). + let value_after = |key: &str| -> Option<&str> { + let s = raw.find(key)? + key.len(); + let rest = raw.get(s..)?; + rest.get(..rest.find('"')?) + }; + let rig = value_after("rig=\"").unwrap_or("threePt"); + let material = value_after("prstMaterial=\"").unwrap_or("warmMatte"); + let modelled = rig == "threePt" && matches!(material, "warmMatte" | "matte"); + (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled }) +} + +/// The light, pointing from the surface towards it (x right, y down, z towards the viewer). +fn light() -> [f64; 3] { + let (az, el) = (240f64.to_radians(), 45f64.to_radians()); + [el.cos() * az.cos(), el.cos() * az.sin(), el.sin()] +} + +/// The bevel surface's rise per unit inwards (in units of h / w) at `u` (0 at the outline, 1 at +/// the inner edge of the band), by preset. +fn slope(preset: &str, u: f64) -> f64 { + let u = u.clamp(1e-3, 1.0); + match preset { + "angle" => 1.0, + // A quarter circle, steep at the outline and flat where it meets the face; the default, + // and used for the presets not modelled. + _ => { + let v = 1.0 - u; + v / (1.0 - v * v).max(1e-6).sqrt() + } + } +} + +/// Lighting factor and outward edge normal at each position, for a polygon set: distance to the +/// nearest edge and that edge's outward normal (edges oriented by each polygon's signed area). +fn nearest_edge(polys: &[Vec], areas: &[f64], p: Point) -> Option<(f64, kurbo::Vec2)> { + let mut best: Option<(f64, kurbo::Vec2)> = None; + for (poly, area) in polys.iter().zip(areas) { + for w in poly.windows(2) { + let (a, b) = (w[0], w[1]); + let e = b - a; + let l2 = e.hypot2(); + if l2 < 1e-12 { + continue; + } + let t = ((p - a).dot(e) / l2).clamp(0.0, 1.0); + let d = (p - (a + e * t)).hypot(); + if best.is_none_or(|(bd, _)| d < bd) { + // Outward normal: for a polygon of positive (clockwise on screen, y down) area the + // outside is to the left of each edge's direction. + let n = kurbo::Vec2::new(e.y, -e.x) / l2.sqrt(); + let n = if *area > 0.0 { n } else { -n }; + best = Some((d, n)); + } + } + } + best +} + +/// Draw the lit face and bevel band over a shape filled with `fill` (a solid colour, or `None` +/// for any other fill, which gets the band's highlights and shading as light and dark veils) +/// inside `path` (shape space, laid out in `bounds`), `m` mapping it to the output. +#[allow(clippy::too_many_arguments)] +pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option, path: &BezPath, bounds: Rect, m: Affine, alpha: f64) { + // Other light rigs and materials light the face and band differently (brighter, darker, + // shinier); rather than guess, they stay flat as before. + if !bevel.modelled || bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() { + return; + } + let k = crate::scale_of(m); + let k = if k.is_finite() && k > 0.0 { k } else { 1.0 }; + let longest = bounds.width().max(bounds.height()) * k; + let s = (MAX_SIDE / longest).min(1.0) * k; + let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048)); + let polys = crate::pathgrad::polygons(path); + if polys.is_empty() { + return; + } + let areas: Vec = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::() / 2.0).collect(); + let l = light(); + let face = AMBIENT + DIFFUSE * l[2]; + let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 }; + let a = alpha.clamp(0.0, 1.0); + let mut data = Vec::with_capacity((pw * ph) as usize); + for j in 0..ph { + for i in 0..pw { + let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height()); + let f = match nearest_edge(&polys, &areas, p) { + Some((d, e)) if d < bevel.w => { + let z = slope(&bevel.preset, d / bevel.w) * ratio; + let n = [e.x * z, e.y * z, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let dot = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len; + AMBIENT + DIFFUSE * dot.max(0.0) + } + _ => face, + }; + let px = match fill { + Some(c) => { + let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0); + let ca = c.a as f64 * a / 255.0; + let pm = |v: f64| (v * ca).round().clamp(0.0, 255.0) as u8; + vello_cpu::color::PremulRgba8 { r: pm(ch(c.r)), g: pm(ch(c.g)), b: pm(ch(c.b)), a: (ca * 255.0).round() as u8 } + } + None => { + // A veil: white where the band is lighter than the face, black where darker. + let d = ((f - face) * 1.5).clamp(-1.0, 1.0); + let va = (d.abs() * a * 255.0).round().clamp(0.0, 255.0) as u8; + let v = if d > 0.0 { va } else { 0 }; + vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: va } + } + }; + data.push(px); + } + } + let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16)); + ctx.set_transform(m); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }, + }); + ctx.set_paint_transform( + Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64), + ); + ctx.fill_path(path); + ctx.reset_paint_transform(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bevels_are_read_from_kept_3d_xml() { + let raw = r#""#; + assert_eq!(from_raw(raw), Some(Bevel { w: 4.0, h: 1.3, preset: "angle".into(), modelled: true })); + // Defaults: 6 pt square, circle. + let raw = r#""#; + assert_eq!(from_raw(raw), Some(Bevel { w: 6.0, h: 6.0, preset: "circle".into(), modelled: true })); + // Another rig or material: not modelled. + let raw = r#""#; + assert!(from_raw(raw).is_some_and(|b| !b.modelled)); + // No scene: flat in PowerPoint. + assert_eq!(from_raw(r#""#), None); + assert_eq!(from_raw(r#""#), None); + } + + #[test] + fn the_light_comes_from_the_upper_left() { + let l = light(); + assert!(l[0] < 0.0 && l[1] < 0.0 && l[2] > 0.0); + // An upward-facing edge (outward normal up) is lit more than a downward one. + let lit = |e: [f64; 2]| { + let z = slope("circle", 0.3) * 0.5; + let n = [e[0] * z, e[1] * z, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len + }; + assert!(lit([0.0, -1.0]) > lit([-1.0, 0.0]) && lit([-1.0, 0.0]) > lit([1.0, 0.0]) && lit([1.0, 0.0]) > lit([0.0, 1.0])); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8a00a197..09d6ab1e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod bevel; mod chart; mod colr; @@ -997,6 +998,24 @@ impl Renderer { ctx.pop_layer(); } } + // 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 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); + } + } // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 53deadae..0581fb27 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -557,3 +557,26 @@ fn patterns_between_whole_pixel_cells_blend_neighbouring_cells() { // The pattern repeats every 6 px (8 cells). assert!(row.iter().zip(row.iter().skip(6)).all(|(a, b)| (*a as i32 - *b as i32).abs() <= 2), "{row:?}"); } + +#[test] +fn a_bevelled_shape_is_lit_from_the_upper_left() { + let base = Rgba::rgb(91, 155, 213); + let bevelled = |raw: &str| Shape { + id: ShapeId(26), + xfrm: Some(Xfrm::new(100.0, 100.0, 130.0, 130.0)), + fill: solid(base), + line: no_line(), + effects: Some(deckcraft_model::Effects { bevel: Some("circle".into()), raw3d: Some(raw.into()), ..Default::default() }), + ..Default::default() + }; + let raw = r#""#; + let img = render(vec![bevelled(raw)]); + // PowerPoint: face (99, 166, 226); top of the band saturates, its bottom and right darken. + let face = img.pixel(165, 165); + assert!(close(face, Rgba::rgb(99, 166, 226), 8), "face {face:?}"); + let (top, bottom, right) = (img.pixel(165, 104)[2], img.pixel(165, 226)[2], img.pixel(226, 165)[2]); + assert!(top > face[2] && bottom < face[2] && right < face[2], "top {top} bottom {bottom} right {right} face {}", face[2]); + // Without a 3-D scene PowerPoint draws it flat. + let img = render(vec![bevelled(r#""#)]); + assert!(close(img.pixel(165, 104), base, 2) && close(img.pixel(165, 165), base, 2)); +} From cef6e1a8c8b8e01ffb70960c9e5443cfdcfee9f0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:04:09 -0400 Subject: [PATCH 48/59] Render: table borders draw their dashes, compound bands and diagonals Explicit cell borders were stroked as plain solid lines (no dash, compound or cap), and diagonal borders (lnTlToBr, lnBlToTr) were read but never drawn. Both now go through the shape line stroker; diagonals are drawn under the cell edges. Wild synthetic set: border styles slide 5.67 -> 2.26, thick merged-cell borders 1.20 -> 0.69. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 2 +- crates/render/src/table.rs | 26 +++++++++++++++++++++----- crates/render/src/tests_wild.rs | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 09d6ab1e..4e840515 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1376,7 +1376,7 @@ fn blur(sigma: f64) -> Filter { /// 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)] -fn stroke( +pub(crate) fn stroke( ctx: &mut RenderContext, rctx: &Ctx, line: &Line, diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 685df6f9..c84e1172 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -348,6 +348,17 @@ pub fn draw( }, } } + // Diagonal borders, under the edges: drawn like any line (dashes, compound, caps). + for (r, c, rect) in &rects { + let Some(cell) = t.cell(*r, *c) else { continue }; + for (line, a, b) in [(&cell.diag_down, (rect.x0, rect.y0), (rect.x1, rect.y1)), (&cell.diag_up, (rect.x0, rect.y1), (rect.x1, rect.y0))] { + let Some(l) = line.as_ref().filter(|l| l.fill.as_ref().is_some_and(|f| !f.is_none())) else { continue }; + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + crate::stroke(ctx, rctx, l, None, &p, None, m, l.width.unwrap_or(1.0).max(0.0), 1.0); + } + } // Borders: explicit cell borders, else the style's grid. for (r, c, rect) in &rects { let Some(cell) = t.cell(*r, *c) else { continue }; @@ -361,11 +372,16 @@ pub fn draw( for (i, e) in edges.iter().enumerate() { let line = cell.borders.get(i).and_then(|b| b.as_ref()); let (col, wd) = match line { - Some(l) => match &l.fill { - Some(Fill::Solid { color: cc }) => (rctx.color(cc, None), l.width.unwrap_or(1.0)), - Some(Fill::None) => continue, - _ => continue, - }, + // An explicit border is drawn like any line: dashed, compound, gradient. + Some(l) => { + if l.fill.as_ref().is_some_and(|f| !f.is_none()) { + let mut p = BezPath::new(); + p.move_to((e.0, e.1)); + p.line_to((e.2, e.3)); + crate::stroke(ctx, rctx, l, None, &p, None, m, l.width.unwrap_or(1.0).max(0.25), 1.0); + } + continue; + } None => match lk.sides.get(i).copied().flatten().or(lk.border) { Some(b) if b.0.a > 0 && b.1 > 0.0 => b, _ => continue, diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 0581fb27..9a885c9e 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -580,3 +580,21 @@ fn a_bevelled_shape_is_lit_from_the_upper_left() { let img = render(vec![bevelled(r#""#)]); assert!(close(img.pixel(165, 104), base, 2) && close(img.pixel(165, 165), base, 2)); } + +#[test] +fn table_borders_take_their_dashes_and_diagonals_are_drawn() { + let mut t = deckcraft_model::Table::new(1, 1, 200.0, 100.0); + t.style = "none".into(); + let mut dashed = Line::solid(ColorRef::rgb(RED), 4.0); + dashed.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() }); + if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) { + c.borders[2] = Some(dashed); + c.diag_down = Some(Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 255)), 4.0)); + } + let sh = Shape { id: ShapeId(27), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() }; + let img = render(vec![sh]); + // Dashes of 4 widths (16 pt) on, 3 (12 pt) off along the top edge, from its start. + assert!(close(img.pixel(108, 100), RED, 40) && close(img.pixel(122, 100), WHITE, 30) && close(img.pixel(135, 100), RED, 40)); + // The diagonal from top-left to bottom-right passes through the middle. + assert!(close(img.pixel(200, 150), Rgba::rgb(0, 0, 255), 40), "{:?}", img.pixel(200, 150)); +} From 239e41bdf6251196c93461d3a5a5b3544f4c7d6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:09:54 -0400 Subject: [PATCH 49/59] Render: a picture's shadow covers the whole picture The shadow of a picture is the picture recoloured to the shadow colour, but drawing the picture left its pixel-space transform set, so the recolouring rectangle (sized in points) covered only part of a picture whose bitmap is bigger than it is shown: a 400 px photo at 240 pt cast a shadow of its top-left corner only. The shape's transform is restored before recolouring. Wild synthetic set: framed pictures with shadows 7.67 -> 5.95, rotated picture with a shadow 10.67 -> 10.07. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 3 +++ crates/render/src/tests_wild.rs | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4e840515..c4da5155 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1224,6 +1224,9 @@ impl Renderer { 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)); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 9a885c9e..846bce69 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -598,3 +598,37 @@ fn table_borders_take_their_dashes_and_diagonals_are_drawn() { // The diagonal from top-left to bottom-right passes through the middle. assert!(close(img.pixel(200, 150), Rgba::rgb(0, 0, 255), 40), "{:?}", img.pixel(200, 150)); } + +#[test] +fn a_large_pictures_shadow_is_whole() { + // A 400 px picture shown at 240 pt: its shadow was recoloured in the picture's pixel space and + // covered only part of it. + let mut counts = vec![]; + for side in [8u32, 400] { + let mut p = Presentation::default(); + let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255])); + let mut fx = offset_shadow(); + if let Some(o) = &mut fx.outer_shadow { + o.blur = 8.0; + } + let sh = Shape { + id: ShapeId(15), + xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(360.0, 300.0, 240.0, 160.0) }), + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + effects: Some(fx), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + counts.push( + (0..img.height) + .flat_map(|y| (0..img.width).map(move |x| (x, y))) + .filter(|(x, y)| { + let q = img.pixel(*x, *y); + q[0] < 200 && q[2] < 200 + }) + .count(), + ); + } + assert!(counts[1] > 3000 && (counts[0] as i64 - counts[1] as i64).abs() < 300, "{counts:?}"); +} From 2e68e54eec5d9cb0a5f0f5b9d54674319a26434c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:13:34 -0400 Subject: [PATCH 50/59] Render: gradients with rotWithShape=0 keep to the slide's axes A gradient fill marked not to rotate with its shape turned and flipped with it anyway. PowerPoint lays such a gradient out upright, over the box the rotated or flipped shape covers on the slide (a 90 degree gradient on a quarter-turned triangle still runs top to bottom). Linear and radial gradients now do; rect and outline path gradients still follow the shape. Wild synthetic set: gradients on rotated/flipped shapes 5.48 -> 1.34, gradient angles slide 6.75 -> 1.95. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/paint.rs | 12 ++++++++++++ crates/render/src/tests_wild.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 5ea7e3d6..82976bc8 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -35,6 +35,18 @@ pub fn fill_path( if g.stops.is_empty() { return; } + // `rotWithShape="0"`: the gradient keeps to the slide's axes when the shape is + // rotated or flipped, laid out over the box the shape covers on the slide. + let c = m.as_coeffs(); + let turned = c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0; + let path_kind = matches!(&g.shape, GradientShape::Path { path: k, .. } if matches!(k.as_str(), "rect" | "shape")); + if !g.rotate_with_shape && turned && !path_kind && m.determinant().abs() > 1e-12 { + let on_slide = m.transform_rect_bbox(bounds); + let upright = deckcraft_model::style::Gradient { rotate_with_shape: true, ..g.clone() }; + let paint_path = m * path.clone(); + fill_path(ctx, rctx, &Fill::Gradient(upright), ph, &paint_path, on_slide, Affine::IDENTITY, mode, alpha, pres); + return; + } // A linear gradient spans its tile rectangle (the shape's box unless `tileRect` moves // its edges); a radial one ends on the circle around it, from a focus placed in the // shape's own box (PowerPoint's "from corner" presets centre the tile on the corner). diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 846bce69..6d09dd45 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -632,3 +632,33 @@ fn a_large_pictures_shadow_is_whole() { } assert!(counts[1] > 3000 && (counts[0] as i64 - counts[1] as i64).abs() < 300, "{counts:?}"); } + +#[test] +fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + let blue = Rgba::rgb(0, 0, 255); + for rotate_with_shape in [true, false] { + let g = Gradient { + stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape, + tile: [0.0; 4], + }; + // 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down. + let sh = Shape { + id: ShapeId(28), + xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 100.0) }), + fill: Some(Fill::Gradient(g)), + line: no_line(), + ..Default::default() + }; + let img = render(vec![sh]); + let (left, top) = (img.pixel(152, 150), img.pixel(200, 52)); + if rotate_with_shape { + assert!(top[0] > 200 && top[2] < 60, "turned with the shape: red at the top {top:?}"); + } else { + assert!(left[0] > 200 && left[2] < 60, "upright: red at the left {left:?}"); + assert!(img.pixel(248, 150)[2] > 200, "blue at the right"); + } + } +} From 9238a915a24a3129020214450aa8a50faba30637 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:18:36 -0400 Subject: [PATCH 51/59] Render: presets PowerPoint mitres by default get sharp corners; miter limit 8 An outline naming no join was always rounded. PowerPoint rounds most presets but mitres some by default: classified per preset by comparing its export (8 pt outline, no join) with round- and miter-joined renders: the plus, right/up/down block arrows (not the left one), chevrons, stars from five points up, the equation shapes, curved ribbons and the action buttons. Miter joins also take PowerPoint's default limit of 8 widths (kurbo's default was 4), so sharp star points no longer bevel off. Wild synthetic set: joins slide 0.43 -> 0.21, outline-on-pattern slide 3.95 -> 3.76, preset galleries slightly better. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 39 +++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 10 ++++++++- crates/render/src/tests_wild.rs | 21 ++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 2424bdad..33575bf0 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -339,6 +339,45 @@ pub fn info(name: &str) -> Option<&'static PresetInfo> { CATALOG.iter().find(|p| p.name == name) } +/// Whether PowerPoint joins this preset's outline with sharp (miter) corners when the line names +/// no join (it rounds them otherwise). Observed per preset on its export: the plus, the right, +/// up and down block arrows (not the left one), chevrons, stars from five points up, the +/// equation shapes, curved ribbons and the action buttons are mitred. +pub fn mitred_by_default(name: &str) -> bool { + matches!( + name, + "plus" + | "rightArrow" + | "upArrow" + | "downArrow" + | "upDownArrow" + | "leftUpArrow" + | "bentUpArrow" + | "stripedRightArrow" + | "notchedRightArrow" + | "chevron" + | "leftArrowCallout" + | "leftRightArrowCallout" + | "upDownArrowCallout" + | "quadArrowCallout" + | "mathPlus" + | "mathMinus" + | "mathMultiply" + | "mathEqual" + | "mathNotEqual" + | "star5" + | "star6" + | "star8" + | "star10" + | "star12" + | "star16" + | "star24" + | "star32" + | "ellipseRibbon" + | "ellipseRibbon2" + ) || name.starts_with("actionButton") +} + pub fn is_line_like(name: &str) -> bool { matches!(name, "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "curvedConnector3" | "arc") || name.starts_with("bentConnector") diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c4da5155..3ad77fd7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -998,6 +998,13 @@ impl Renderer { ctx.pop_layer(); } } + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; // 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 solid = match &fill { @@ -1404,7 +1411,8 @@ pub(crate) fn stroke( // Hairlines stay visible at any zoom. let min_w = 0.5 / scale_of(m).max(1e-6); let sw = lw.max(min_w); - let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join); + // 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; diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 6d09dd45..66e73ef0 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -662,3 +662,24 @@ fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() { } } } + +#[test] +fn stars_and_right_arrows_have_sharp_corners_by_default() { + let shape = |name: &str, join: Option| Shape { + id: ShapeId(29), + xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)), + geom: Geom::preset(name), + fill: solid(WHITE), + line: Some(Line { join, ..Line::solid(ColorRef::rgb(RED), 10.0) }), + ..Default::default() + }; + // A star5's top point mitred reaches about 16 pt above the box; rounded only 5. + let img = render(vec![shape("star5", None)]); + assert!(close(img.pixel(150, 90), RED, 40), "mitred point {:?}", img.pixel(150, 90)); + let img = render(vec![shape("star5", Some(deckcraft_model::style::LineJoin::Round))]); + assert!(close(img.pixel(150, 90), WHITE, 10), "an explicit round join stays round"); + // A rectangle keeps PowerPoint's round default: its corner is cut round. + let img = render(vec![shape("rect", None)]); + assert!(close(img.pixel(95, 95), WHITE, 30), "{:?}", img.pixel(95, 95)); + assert!(deckcraft_geom::preset::mitred_by_default("rightArrow") && !deckcraft_geom::preset::mitred_by_default("leftArrow")); +} From c0dcfc1db5a264298d397f9f4e985ddad07baf3c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:22:32 -0400 Subject: [PATCH 52/59] Geom: stars stretched by the standard's hf/vf factors Five-, six-, seven- and ten-pointed stars used their own normalisation and came out narrower or shifted against PowerPoint's. ECMA-376 places a star's points on an ellipse around (hc, vc * vf) with radii half the box times hf / vf (star5 1.05146 / 1.10557, star6 1.15470, star7 1.02572 / 1.05210, star10 1.05146), so the points touch the box's edges; now done for all stars (checked exactly against PowerPoint's outlines at three sizes and inner-radius adjusts). Wild synthetic set: star galleries 4.08 -> 3.76, 3.77 -> 3.45, 4.62 -> 4.31 and the adjust galleries slightly. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 40 ++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 33575bf0..b93f94f1 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -520,14 +520,25 @@ fn normalize(pts: &[(f64, f64)], w: f64, h: f64) -> Vec<(f64, f64)> { pts.iter().map(|(x, y)| ((x - x0) * sx, (y - y0) * sy)).collect() } -fn star(n: usize, w: f64, h: f64, inner: f64, normalized: bool) -> Vec<(f64, f64)> { +/// An n-pointed star as ECMA-376 draws it: points on an ellipse around (hc, vc · vf) with radii +/// half the box stretched by the preset's `hf` / `vf` (so a five-pointed star fills its box and +/// looks regular in a square), the inner corners `inner` of the way out. +fn star(n: usize, w: f64, h: f64, inner: f64) -> Vec<(f64, f64)> { + let (hf, vf) = match n { + 5 => (1.05146, 1.10557), + 6 => (1.15470, 1.0), + 7 => (1.02572, 1.05210), + 10 => (1.05146, 1.0), + _ => (1.0, 1.0), + }; + let (rx, ry, cy) = (w / 2.0 * hf, h / 2.0 * vf, h / 2.0 * vf); let mut pts = Vec::with_capacity(n * 2); for i in 0..n * 2 { let a = (-90.0 + 180.0 * i as f64 / n as f64).to_radians(); let r = if i % 2 == 0 { 1.0 } else { inner }; - pts.push((r * a.cos(), r * a.sin())); + pts.push((w / 2.0 + r * rx * a.cos(), cy + r * ry * a.sin())); } - if normalized { normalize(&pts, w, h) } else { pts.iter().map(|(x, y)| (w / 2.0 + x * w / 2.0, h / 2.0 + y * h / 2.0)).collect() } + pts } #[derive(Clone, Copy)] @@ -1576,8 +1587,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { let n: usize = name.trim_start_matches("star").parse().unwrap_or(5); let v = a(0).clamp(0.0, 50000.0); let inner = v / 50000.0; - let normalized = matches!(n, 5 | 7); - let mut g = simple(b.poly(&star(n, w, h, inner, normalized)).take(), full.inset(-ss * (0.5 - inner * 0.5) * 0.5), w, h); + let mut g = simple(b.poly(&star(n, w, h, inner)).take(), full.inset(-ss * (0.5 - inner * 0.5) * 0.5), w, h); g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); g } @@ -2268,6 +2278,26 @@ mod tests { } } + #[test] + fn stars_are_stretched_like_the_standards() { + // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an + // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom + // points reach the box's bottom edge. + let k = 216.0; + let g = build("star5", k, k, &[]).unwrap(); + let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); + let a = 18f64.to_radians(); + assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); + assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); + // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). + let g = build("star6", k, k, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); + } + #[test] fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { use kurbo::Shape as _; From 7cb62d51976d4b9ea6915f37c5b2461d085dea23 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:27:56 -0400 Subject: [PATCH 53/59] Pptx/Render: picture fills with rotWithShape=0 stay upright A shape's picture fill marked not to rotate with the shape turned and flipped with it (rotWithShape was neither kept nor written: always 1). PowerPoint keeps such a picture upright, stretched (or tiled) over the box the rotated shape covers on the slide and clipped to the shape. Now read, written (PictureFill::upright) and drawn so. Wild synthetic set: picture fills on rotated/flipped shapes 12.89 -> 3.87. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/model/src/style.rs | 4 ++++ crates/pptx/src/read/dml.rs | 2 +- crates/pptx/src/write/dml.rs | 2 +- crates/pptx/tests/fills.rs | 21 +++++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 1 + crates/render/src/paint.rs | 13 +++++++++++++ crates/render/src/tests_wild.rs | 27 +++++++++++++++++++++++++++ 7 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index b1b2a53c..efb30e48 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -139,6 +139,10 @@ pub struct PictureFill { /// 0–1 opacity of the picture. pub alpha: Option, pub adjust: PictureAdjust, + /// `rotWithShape="0"`: the picture keeps to the slide's axes when its shape is rotated or + /// flipped. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub upright: bool, } /// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency). diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index b0f7a801..0aee935c 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -125,7 +125,7 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option { let blip = bf.child("blip"); let media = blip.and_then(|b| media_ref(imp, part, b)); let media = media?; - let mut pf = PictureFill { media, ..Default::default() }; + let mut pf = PictureFill { media, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() }; if let Some(b) = blip { for e in b.elements() { match e.local() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 2aa86930..16a069fe 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -96,7 +96,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { /// `a:blipFill` / `p:blipFill`. pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); - w.open(tag, A::new().a("rotWithShape", "1")); + w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); let adj = &pf.adjust; let has_kids = pf.alpha.is_some() || adj.brightness != 0.0 diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 98e6d1bd..e5955c27 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -228,3 +228,24 @@ fn black_and_white_and_inverted_alpha_pictures_round_trip() { other => panic!("{other:?}"), } } + +#[test] +fn an_upright_picture_fill_round_trips() { + let bytes = deck(); + let edited = edit_slides(&bytes, |x| x.replacen("rotWithShape=\"1\"", "rotWithShape=\"0\"", 1)); + let q = deckcraft_pptx::import(&edited).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape"); + let Some(Fill::Picture(pf)) = &s.fill else { panic!("{:?}", s.fill) }; + assert!(pf.upright); + let again = deckcraft_pptx::export(&q).expect("export"); + let r = deckcraft_pptx::import(&again).expect("import"); + let s = r.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape"); + assert!(matches!(&s.fill, Some(Fill::Picture(pf)) if pf.upright)); + // Rotating with the shape is the default. + assert!(matches!(textured_fill(&bytes), Some(Fill::Picture(pf)) if !pf.upright)); +} + +fn textured_fill(bytes: &[u8]) -> Option { + let q = deckcraft_pptx::import(bytes).expect("import"); + q.slides[0].shapes.iter().find(|s| s.name == "Textured").and_then(|s| s.fill.clone()) +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index ed5cb425..db6d0beb 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -231,6 +231,7 @@ pub fn rich_deck() -> Presentation { mode: PictureMode::Stretch { fill_rect: [0.0; 4] }, alpha: Some(0.5), adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() }, + upright: false, }, }; let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 82976bc8..8011f1bb 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -82,6 +82,19 @@ pub fn fill_path( ctx.fill_path(path); } Fill::Picture(pf) => { + // `rotWithShape="0"`: upright on the slide, stretched over the box the turned shape + // covers, clipped to it. + let c = m.as_coeffs(); + let turned = c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0; + if pf.upright && turned && m.determinant().abs() > 1e-12 { + let on_slide = m.transform_rect_bbox(bounds); + let on_path = m * path.clone(); + ctx.set_transform(Affine::IDENTITY); + ctx.push_clip_layer(&on_path); + picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None); + ctx.pop_layer(); + return; + } ctx.push_clip_layer(path); picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None); ctx.pop_layer(); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 66e73ef0..fd3013a6 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -683,3 +683,30 @@ fn stars_and_right_arrows_have_sharp_corners_by_default() { assert!(close(img.pixel(95, 95), WHITE, 30), "{:?}", img.pixel(95, 95)); assert!(deckcraft_geom::preset::mitred_by_default("rightArrow") && !deckcraft_geom::preset::mitred_by_default("leftArrow")); } + +#[test] +fn an_upright_picture_fill_stays_upright_in_a_turned_shape() { + use deckcraft_model::style::PictureFill; + let blue = Rgba::rgb(0, 0, 255); + for upright in [false, true] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()); + // Half red (left) half blue picture in a 200 × 200 square turned a quarter (and so + // covering the same square). + let sh = Shape { + id: ShapeId(30), + xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 200.0) }), + fill: Some(Fill::Picture(PictureFill { media, upright, ..Default::default() })), + line: no_line(), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let (left, top) = (img.pixel(110, 200), img.pixel(200, 110)); + if upright { + assert!(close(left, RED, 30) && close(img.pixel(290, 200), blue, 30), "upright: red left {left:?}"); + } else { + assert!(close(top, RED, 30), "turned with the shape: red at the top {top:?}"); + } + } +} From ee71879f80036e52a4a9c7a25b927729e37ca1a7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:31:17 -0400 Subject: [PATCH 54/59] Render: pictures reflect their pixels A reflection on a picture (PowerPoint's "reflected" picture styles) drew nothing: the reflection painted the shape's fill, and a picture has none. It now mirrors the picture itself, clipped to its outline, under the same fade. Wild synthetic set: picture effects slide 16.77 -> 16.00 (the rest is 3-D camera rotation, not modelled). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 15 +++++++++++++-- crates/render/src/tests_wild.rs | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3ad77fd7..c044b05b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1284,9 +1284,20 @@ impl Renderer { 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); - 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); + 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)))) diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index fd3013a6..4aea6908 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -710,3 +710,18 @@ fn an_upright_picture_fill_stays_upright_in_a_turned_shape() { } } } + +#[test] +fn a_picture_reflects_its_pixels() { + let mut p = picture_deck(None); + let s = std::sync::Arc::make_mut(&mut p.slides[0]); + if let Some(sh) = s.shapes.first_mut() { + let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 }; + sh.effects = Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }); + } + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // The blue picture spans 100..200 down; just below it, its reflection tints the page blue. + let below = img.pixel(200, 204); + assert!(below[2] > 200 && below[0] < 200, "{below:?}"); + assert!(close(img.pixel(200, 290), WHITE, 10), "faded out by half its height"); +} From 4e2cbaed797ca4e91a8517e3db05b2cd76edecb9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:35:50 -0400 Subject: [PATCH 55/59] Geom: moon, magnetic disk and drum by the standard - The magnetic disk's and drum's end ellipses were half the size of PowerPoint's: ECMA-376 makes them h/6 high (disk) and w/6 across (drum); measured on PowerPoint's outlines. - The moon's inner edge was a half ellipse of the box's height; the standard's is an arc of a larger ellipse through the right corners and (adj * w, middle) whose centre lies right of that point (radius g18w across, dy1 down), and so it is fuller. Text rectangle from the standard too. Wild synthetic set: preset galleries 3.76 -> 3.47, 3.45 -> 3.21, 4.31 -> 4.00 and others slightly. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 56 +++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index b93f94f1..6a295359 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -933,7 +933,8 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { g } "can" | "flowChartMagneticDisk" => { - let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 33333.0 * h / ss.max(1e-9) / 2.0 }; + // The disk's ends are a third of its height (ECMA-376: ellipses of radius h/6). + let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 100000.0 / 3.0 * h / ss.max(1e-9) }; let eh = ss * v / 100000.0; let r = eh / 2.0; let body = Pb::new().m(0.0, r).l(0.0, h - r).arc(w / 2.0, r, 180.0, -180.0).l(w, r).arc(w / 2.0, r, 0.0, -180.0).z().take(); @@ -950,7 +951,8 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { g } "flowChartMagneticDrum" => { - let r = w / 6.0 / 2.0; + // ECMA-376: end ellipses of radius w/6 across. + let r = w / 6.0; let body = Pb::new().m(r, 0.0).l(w - r, 0.0).arc(r, h / 2.0, 270.0, 180.0).l(r, h).arc(r, h / 2.0, 90.0, 180.0).z().take(); let side = Pb::new().m(w - r, 0.0).arc(r, h / 2.0, 270.0, -180.0).take(); Geometry { @@ -1184,10 +1186,36 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { g } "moon" => { + // ECMA-376 moon: the outer edge is the left half of an ellipse w × h/2 centred on the + // right edge; the inner edge an arc through the corners and (adj · w, middle) of a + // larger ellipse (rx = g18, ry = dy1) whose centre lies right of that point. let k = a(0).clamp(0.0, 87500.0) / 100000.0; - let g0 = w * k; - b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0).arc((w - g0).max(0.0), h / 2.0, 90.0, 180.0).z(); - simple(b.take(), Rect::new(w * 0.1, h * 0.25, w * (1.0 - k).max(0.2) * 0.8, h * 0.75), w, h) + let ssf = ss.max(1e-9); + let g0 = ss * k; + let g1 = (ss - g0).max(1e-9); + let g2 = g0 * g0 / g1; + let g3 = ss * ss / g1; + let g5 = 2.0 * g3 - g2; + let g6 = g5 - g0; + let dy1 = (g5 / 2.0 - g0) * (h / 2.0) / ssf; + let (g0w, g6w) = (g0 * w / ssf, g6 * w / ssf); + let rx = (g6w - g0w) / 2.0; + let cx = g0w + rx; + b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0); + if rx > 1e-9 && dy1 > 1e-9 { + // From the bottom-right corner back up to the top-right through (g0w, h/2). + let t0 = ((h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx); + let t1 = (-(h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx) + std::f64::consts::TAU; + let arc = Arc::new(Point::new(cx, h / 2.0), Vec2::new(rx, dy1), t0, t1 - t0, 0.0); + arc.to_cubic_beziers(0.05, |p1, p2, p| { + b.p.curve_to(p1, p2, p); + }); + } + b.z(); + let g12 = g0 * 9598.0 / 32768.0; + let g13 = ss - g12; + let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf; + simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), w, h) } "cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)), "bracketPair" => { @@ -2298,6 +2326,24 @@ mod tests { assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); } + #[test] + fn moon_disk_and_drum_match_powerpoints_outlines() { + let k = 216.0; + // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. + let g = build("moon", k, k, &[]).unwrap(); + for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); + } + // 300 × 120 at 30%: its inner edge reaches 30% of the width in. + let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); + assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); + // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. + let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); + let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); + } + #[test] fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { use kurbo::Shape as _; From 238157d98e5f41694471b37022721d8c20deffb4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:54:07 -0400 Subject: [PATCH 56/59] Render: 3-D cameras turn shapes (affine approximation) A shape seen through a 3-D camera preset (isometric, perspective...) or turned with a:camera/a:rot drew flat and front-on. PowerPoint turns the shape's plane in space and projects it. Fitted black-box to its exports (a picture with coloured corner markers under every preset and several rotations; every fit within half a point on a 100 x 75 pt shape): points about the centre are turned Rz(-rev) Rx(lat) Ry(lon), perspective cameras then dividing by D / (D - z) with D about 5-16 shape sizes; a:rot turns the other way about the horizontal and vertical axes. DeckCraft now draws the shape under the affine transform that best fits its turned corners: exact for orthographic cameras, within 2% of the size for perspective ones (an affine map can't foreshorten). Oblique cameras leave a flat shape as it is; depth (extrusion) is not drawn. Wild synthetic set: camera presets 15.02 -> 6.35, 3-D picture styles 16.00 -> 7.72, extruded cameras 21.41 -> 19.74. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/camera.rs | 202 ++++++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 12 +++ 2 files changed, 214 insertions(+) create mode 100644 crates/render/src/camera.rs diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs new file mode 100644 index 00000000..83c6f869 --- /dev/null +++ b/crates/render/src/camera.rs @@ -0,0 +1,202 @@ +//! 3-D cameras (`a:scene3d/a:camera`) turning a flat shape, approximated in 2-D. +//! +//! A shape seen through a 3-D camera preset (isometric, perspective…) or rotated with +//! `a:camera/a:rot` is drawn by PowerPoint as its plane turned in space and projected. DeckCraft +//! turns the shape's corners the same way and draws the shape under the affine transform that best +//! fits them: exact for orthographic cameras, within about 2% of the shape's size for the +//! perspective ones (whose foreshortening an affine transform can't show). Depth, bevels' sides and +//! lighting are not drawn. +//! +//! The presets' turns were fitted to PowerPoint's exports (black-box: a picture with coloured +//! corner markers under each preset, all fits within half a point on a 100 × 75 pt shape): +//! points relative to the shape's centre are turned `p = Rz(-rev) · Rx(lat) · Ry(lon) · (x, y, 0)` +//! and, for perspective cameras, scaled by `D / (D - p.z)` with `D = dist · max(w, h)`. + +use kurbo::{Affine, Point}; + +/// (preset, lat, lon, rev in degrees, perspective distance in shape sizes or 0 for orthographic). +const PRESETS: &[(&str, f64, f64, f64, f64)] = &[ + ("orthographicFront", 0.0, 0.0, 0.0, 0.0), + ("isometricTopUp", 215.52, 134.75, 240.17, 0.0), + ("isometricTopDown", 324.69, 45.30, 299.88, 0.0), + ("isometricBottomUp", 215.52, 134.75, 240.17, 0.0), + ("isometricBottomDown", 215.36, 225.28, 119.86, 0.0), + ("isometricLeftUp", 324.94, 44.77, 0.03, 0.0), + ("isometricLeftDown", 144.84, 224.73, 179.95, 0.0), + ("isometricRightUp", 324.81, 44.75, 359.98, 0.0), + ("isometricRightDown", 144.84, 224.73, 179.95, 0.0), + ("isometricOffAxis1Left", 17.94, 64.06, 359.94, 0.0), + ("isometricOffAxis1Right", 341.80, 25.84, 0.0, 0.0), + ("isometricOffAxis1Top", 238.56, 127.22, 237.03, 0.0), + ("isometricOffAxis2Left", 161.88, 205.84, 179.98, 0.0), + ("isometricOffAxis2Right", 197.91, 244.06, 180.06, 0.0), + ("isometricOffAxis2Top", 301.42, 52.80, 302.97, 0.0), + ("isometricOffAxis3Left", 197.95, 244.05, 180.06, 0.0), + ("isometricOffAxis3Right", 161.88, 205.84, 179.98, 0.0), + ("isometricOffAxis3Bottom", 301.42, 52.78, 302.98, 0.0), + ("isometricOffAxis4Left", 198.00, 205.84, 180.05, 0.0), + ("isometricOffAxis4Right", 17.94, 64.06, 359.94, 0.0), + ("isometricOffAxis4Bottom", 121.42, 232.75, 237.00, 0.0), + ("perspectiveLeft", 359.91, 341.75, 0.02, 12.14), + ("perspectiveRight", 180.27, 198.23, 179.94, 8.02), + ("perspectiveAbove", 18.92, 0.38, 0.09, 16.61), + ("perspectiveBelow", 198.83, 180.39, 179.89, 9.31), + ("perspectiveAboveLeftFacing", 320.78, 346.02, 340.95, 11.54), + ("perspectiveAboveRightFacing", 219.25, 194.14, 198.98, 9.28), + ("perspectiveContrastingLeftFacing", 190.42, 136.41, 176.42, 12.21), + ("perspectiveContrastingRightFacing", 190.52, 223.59, 183.56, 9.53), + ("perspectiveHeroicLeftFacing", 10.89, 346.28, 2.75, 13.22), + ("perspectiveHeroicRightFacing", 169.53, 194.00, 177.22, 8.45), + ("perspectiveHeroicExtremeLeftFacing", 351.98, 325.89, 357.00, 5.98), + ("perspectiveHeroicExtremeRightFacing", 188.39, 214.22, 182.81, 5.26), + ("perspectiveRelaxed", 50.02, 0.03, 0.0, 11.32), + ("perspectiveRelaxedModerately", 34.42, 0.33, 0.16, 11.14), +]; + +/// Perspective distance for a perspective camera given its own rotation (fitted on +/// `perspectiveFront` with `a:rot`, about 11 shape sizes). +const PERSPECTIVE_DIST: f64 = 11.0; + +/// A camera's turn of the shape's plane. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Camera { + pub lat: f64, + pub lon: f64, + pub rev: f64, + /// Perspective distance in shape sizes; 0 for an orthographic camera. + pub dist: f64, +} + +/// The value of attribute `name` in the first `tag` element of `raw` (XML as kept from the file). +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let s = tag.find(&key)? + key.len(); + let rest = tag.get(s..)?; + rest.get(..rest.find('"')?) +} + +/// The camera of 3-D XML kept from the file, when it turns the shape. +pub fn from_raw(raw: &str) -> Option { + let i = raw.find("camera ")?; + let rest = raw.get(i..)?; + let open_end = rest.find('>')?; + let open = rest.get(..open_end)?; + let prst = attr(open, "prst").unwrap_or("orthographicFront"); + let preset = PRESETS.iter().find(|p| p.0 == prst); + let perspective = prst.starts_with("perspective"); + // Its own `a:rot` (inside the camera element, not the light rig's). + let self_closing = open.ends_with('/'); + let own_rot = if self_closing { + None + } else { + let body = rest.get(open_end..rest.find("camera>").unwrap_or(rest.len()))?; + body.find("rot ").and_then(|j| { + let t = body.get(j..)?; + let t = t.get(..t.find('>')?)?; + let deg = |n: &str| attr(t, n).and_then(|v| v.parse::().ok()).map(|v| v / 60000.0).unwrap_or(0.0); + Some((deg("lat"), deg("lon"), deg("rev"))) + }) + }; + let cam = match (own_rot, preset) { + // PowerPoint turns the other way about the horizontal and vertical axes for `a:rot`. + (Some((lat, lon, rev)), p) => { + let dist = match p { + Some(p) if p.4 > 0.0 => p.4, + _ if perspective => PERSPECTIVE_DIST, + _ => 0.0, + }; + Camera { lat: -lat, lon: -lon, rev, dist } + } + (None, Some(p)) => Camera { lat: p.1, lon: p.2, rev: p.3, dist: p.4 }, + (None, None) => return None, + }; + let flat = [cam.lat, cam.lon, cam.rev].iter().all(|a| (a.rem_euclid(360.0)).min(360.0 - a.rem_euclid(360.0)) < 1e-6); + (!flat).then_some(cam) +} + +/// Where a point of the shape (relative to its centre) lands. +fn project(c: &Camera, p: Point, size: f64) -> Point { + let (a, b, r) = (c.lat.to_radians(), c.lon.to_radians(), -c.rev.to_radians()); + let (x1, y1, z1) = (p.x * b.cos(), p.y, -p.x * b.sin()); + let (x2, y2, z2) = (x1, y1 * a.cos() - z1 * a.sin(), y1 * a.sin() + z1 * a.cos()); + let (x3, y3) = (x2 * r.cos() - y2 * r.sin(), x2 * r.sin() + y2 * r.cos()); + if c.dist > 0.0 { + let d = c.dist * size; + let s = d / (d - z2); + if s.is_finite() && s > 0.0 { + return Point::new(x3 * s, y3 * s); + } + } + Point::new(x3, y3) +} + +/// The transform (shape space to shape space) that turns a `w` × `h` shape as camera `c` does: +/// the affine map best fitting its turned corners. +pub fn affine(c: &Camera, w: f64, h: f64) -> Option { + if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 { + return None; + } + let size = w.max(h); + let src = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)]; + let dst: Vec = src.iter().map(|(x, y)| project(c, Point::new(*x, *y), size)).collect(); + // Least squares over the four corners. The corners are symmetric about the centre, so the + // normal equations are diagonal: x' = a x + b y + e with a = Σ x x' / Σ x², and so on. + let (sxx, syy) = (src.iter().map(|p| p.0 * p.0).sum::(), src.iter().map(|p| p.1 * p.1).sum::()); + let dot = |f: &dyn Fn(&(f64, f64), &Point) -> f64| src.iter().zip(&dst).map(|(s, d)| f(s, d)).sum::(); + let a = dot(&|s, d| s.0 * d.x) / sxx; + let b = dot(&|s, d| s.1 * d.x) / syy; + let cc = dot(&|s, d| s.0 * d.y) / sxx; + let dd = dot(&|s, d| s.1 * d.y) / syy; + let e = dot(&|_, d| d.x) / 4.0; + let f = dot(&|_, d| d.y) / 4.0; + let local = Affine::new([a, cc, b, dd, e, f]); + let m = Affine::translate((w / 2.0, h / 2.0)) * local * Affine::translate((-w / 2.0, -h / 2.0)); + (m.is_finite() && m.determinant().abs() > 1e-9).then_some(m) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn corners(m: Affine, w: f64, h: f64) -> Vec { + [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].iter().map(|(x, y)| m * Point::new(*x, *y)).collect() + } + + #[test] + fn turning_about_the_vertical_axis_narrows_the_shape() { + // orthographicFront with lon 60: half as wide, as tall (PowerPoint: 0.506 of the width). + let c = from_raw(r#""#).unwrap(); + let m = affine(&c, 100.0, 75.0).unwrap(); + let k = corners(m, 100.0, 75.0); + assert!((k[1].x - k[0].x - 50.0).abs() < 0.01 && (k[3].y - k[0].y - 75.0).abs() < 0.01, "{k:?}"); + assert!(((k[0].x + k[1].x) / 2.0 - 50.0).abs() < 1e-9, "about the centre"); + } + + #[test] + fn presets_turn_the_shape_as_powerpoint_does() { + // isometricTopUp, 100 × 75: PowerPoint's corners at (-1.247, 0.127), (0.170, -0.955), + // (1.237, -0.141), (-0.181, 0.941) of the half sizes from the centre. + let c = from_raw(r#""#).unwrap(); + let k = corners(affine(&c, 100.0, 75.0).unwrap(), 100.0, 75.0); + for (p, (u, v)) in k.iter().zip([(-1.247, 0.127), (0.170, -0.955), (1.237, -0.141), (-0.181, 0.941)]) { + let (x, y) = ((p.x - 50.0) / 50.0, (p.y - 37.5) / 37.5); + assert!((x - u).abs() < 0.03 && (y - v).abs() < 0.03, "({x:.3}, {y:.3}) vs ({u}, {v})"); + } + // Front-facing cameras and the oblique ones leave a flat shape as it is. + for raw in [r#""#, r#""#] { + assert_eq!(from_raw(raw), None); + } + assert_eq!(from_raw(""), None); + } + + #[test] + fn a_perspective_camera_is_approximated_closely() { + // perspectiveContrastingLeftFacing on 100 × 75: PowerPoint's corners within 3% of the size. + let c = from_raw(r#""#).unwrap(); + let k = corners(affine(&c, 100.0, 75.0).unwrap(), 100.0, 75.0); + for (p, (u, v)) in k.iter().zip([(-0.657, -1.189), (0.791, -0.795), (0.685, 1.244), (-0.742, 0.729)]) { + let (dx, dy) = (p.x - 50.0 - u * 50.0, p.y - 37.5 - v * 37.5); + assert!(dx.hypot(dy) < 3.0, "{p:?} vs ({u}, {v})"); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c044b05b..eb139555 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod bevel; +mod camera; mod chart; mod colr; @@ -773,6 +774,17 @@ impl Renderer { * Affine::translate(-c.to_vec2()); m = parent * Affine::translate(st.offset) * x.affine() * around; } + // A 3-D camera turns the shape (approximated by the affine transform that fits it best). + if !matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) + && let Some(a) = resolve::effects(rctx, s) + .0 + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, x.w, x.h)) + { + m *= a; + } let layered = st.opacity < 0.999 || st.clip.is_some(); if layered { ctx.set_transform(m); From 8ee082f257e09b3f33df2c9e7e10d844d7ca4bb4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 08:00:46 -0400 Subject: [PATCH 57/59] Render: 3-D shapes' faces are lit even without a bevel A shape with scene3d and sp3d but no bevelT was drawn in its flat fill colour. PowerPoint lights the front face from the light rig whether or not there is a bevel, so a plain 3-D rotated rectangle is lighter than its fill. Model the face with the same threePt approximation as the bevel (no rim), for solid fills. Wild 3-D slides: s8 6.35 -> 4.26, s9 19.74 -> 17.68. Corpus unchanged except lo tdf114848 s4 2.13 -> 1.94. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/bevel.rs | 34 +++++++++++++++++++++++++++------ crates/render/src/tests_wild.rs | 19 ++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/render/src/bevel.rs b/crates/render/src/bevel.rs index 85dfb3b6..36de1f05 100644 --- a/crates/render/src/bevel.rs +++ b/crates/render/src/bevel.rs @@ -34,10 +34,14 @@ pub struct Bevel { /// The top bevel of 3-D XML kept from the file (`scene3d` + `sp3d`), when it has both a scene /// (without one PowerPoint draws the shape flat) and a `bevelT`. pub fn from_raw(raw: &str) -> Option { - if !raw.contains("scene3d") { + if !raw.contains("scene3d") || !raw.contains("sp3d") { return None; } - let i = raw.find("bevelT")?; + // A 3-D shape without a bevel still has its face lit: a bevel of no width. + let Some(i) = raw.find("bevelT") else { + let b = Bevel { w: 0.0, h: 0.0, preset: "circle".into(), modelled: false }; + return Some(Bevel { modelled: modelled(raw), ..b }); + }; let tag = raw.get(i..)?; let tag = tag.get(..tag.find('>')?)?; let attr = |name: &str| -> Option<&str> { @@ -49,7 +53,12 @@ pub fn from_raw(raw: &str) -> Option { let emu = |name: &str| attr(name).and_then(|v| v.parse::().ok()).map(|v| v / 12700.0); let w = emu("w").unwrap_or(6.0).clamp(0.0, 2000.0); let h = emu("h").unwrap_or(6.0).clamp(0.0, 2000.0); - // The light rig and material, from anywhere in the XML (`a:lightRig rig=`, `prstMaterial=`). + (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled: modelled(raw) }) +} + +/// Whether the light rig and material (from anywhere in the XML: `a:lightRig rig=`, +/// `prstMaterial=`) are the ones measured: the three-point rig, matte. +fn modelled(raw: &str) -> bool { let value_after = |key: &str| -> Option<&str> { let s = raw.find(key)? + key.len(); let rest = raw.get(s..)?; @@ -57,8 +66,7 @@ pub fn from_raw(raw: &str) -> Option { }; let rig = value_after("rig=\"").unwrap_or("threePt"); let material = value_after("prstMaterial=\"").unwrap_or("warmMatte"); - let modelled = rig == "threePt" && matches!(material, "warmMatte" | "matte"); - (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled }) + rig == "threePt" && matches!(material, "warmMatte" | "matte") } /// The light, pointing from the surface towards it (x right, y down, z towards the viewer). @@ -118,6 +126,18 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option, path: &B if !bevel.modelled || bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() { return; } + // No bevel: only the face, one lit colour (as PowerPoint lights a 3-D shape's front face, + // whichever way the camera turns it, within about 10 levels). + if bevel.w <= 0.0 { + if let Some(c) = fill { + let f = AMBIENT + DIFFUSE * light()[2]; + let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0) as u8; + ctx.set_transform(m); + ctx.set_paint(crate::color(Rgba::rgba(ch(c.r), ch(c.g), ch(c.b), c.a), alpha)); + ctx.fill_path(path); + } + return; + } let k = crate::scale_of(m); let k = if k.is_finite() && k > 0.0 { k } else { 1.0 }; let longest = bounds.width().max(bounds.height()) * k; @@ -193,7 +213,9 @@ mod tests { assert!(from_raw(raw).is_some_and(|b| !b.modelled)); // No scene: flat in PowerPoint. assert_eq!(from_raw(r#""#), None); - assert_eq!(from_raw(r#""#), None); + // A 3-D shape with no bevel: a bevel of no width (its face is lit). + assert!(from_raw(r#""#).is_some_and(|b| b.w == 0.0 && b.modelled)); + assert_eq!(from_raw(r#""#), None); } #[test] diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 4aea6908..7e7f0eac 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -725,3 +725,22 @@ fn a_picture_reflects_its_pixels() { assert!(below[2] > 200 && below[0] < 200, "{below:?}"); assert!(close(img.pixel(200, 290), WHITE, 10), "faded out by half its height"); } + +#[test] +fn a_3d_shapes_face_is_lit_even_without_a_bevel() { + let base = Rgba::rgb(237, 125, 49); + let shape = |raw: &str| Shape { + id: ShapeId(31), + xfrm: Some(Xfrm::new(100.0, 100.0, 150.0, 100.0)), + fill: solid(base), + line: no_line(), + effects: Some(deckcraft_model::Effects { raw3d: Some(raw.into()), ..Default::default() }), + ..Default::default() + }; + // PowerPoint shows this face as about (251, 135, 56). + let img = render(vec![shape(r#""#)]); + assert!(close(img.pixel(175, 150), Rgba::rgb(248, 136, 60), 8), "{:?}", img.pixel(175, 150)); + // No shape properties in 3-D: flat. + let img = render(vec![shape(r#""#)]); + assert!(close(img.pixel(175, 150), base, 1)); +} From e614f0d6d6264219a42bcc5bfdfda18c1cf63bff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 08:13:21 -0400 Subject: [PATCH 58/59] Docs: record the wild shape rendering fixes and partial 3-D rendering Progress log entry for the wild-case rendering fixes; "Bevel and 3-D rotation" moves from missing to partial (approximate bevels, cameras and face lighting; no extrusion, rigs or 3-D pane); gap estimate narrowed. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 ++ docs/gaps.md | 12 +++++++----- docs/parity-checklist.md | 6 +++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 410c178e..85db444a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,8 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---| | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | + +| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/docs/gaps.md b/docs/gaps.md index d14700b0..a2894b42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -168,10 +168,11 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by - 1–3 % of the path length), "Bevel and 3-D rotation" (missing; `scene3d`/`sp3d` kept as raw XML on - round trip). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 8–12 h (3-D render - approximation). + 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting + render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, + only the three-point rig, and no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, + rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -255,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b4fa71d4..2a3d450c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 14 | 27 | 37% | +| P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | |---|---| @@ -19,7 +19,7 @@ The summary between ` | Views | 90% | | Masters & themes | 85% | | Shapes | 88% | -| Format | 81% | +| Format | 83% | | Arrange | 100% | | Text | 80% | | Tables | 89% | @@ -126,7 +126,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Format | Arrowheads | P0 | D | | Format | Shadow (outer, inner, presets) | P1 | D | | Format | Reflection, glow, soft edges | P2 | P | -| Format | Bevel and 3-D rotation | P2 | M | +| Format | Bevel and 3-D rotation | P2 | P | | Format | Format Painter | P1 | D | | Format | Format Shape pane | P0 | P | | Arrange | Size and position (exact), lock aspect ratio | P0 | D | From 457406a7a5b62425e915926c8ccb0984e61a198c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 08:16:32 -0400 Subject: [PATCH 59/59] Docs: keep the progress table in one piece --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 85db444a..46a87add 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,7 +130,6 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---| | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | - | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |