diff --git a/ROADMAP.md b/ROADMAP.md index 801ddd8..c74bbda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 038a6e5..dd41db3 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -8,6 +8,7 @@ pub mod morph; pub mod preset; +pub mod warp; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; use serde::{Deserialize, Serialize}; diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs new file mode 100644 index 0000000..acdee99 --- /dev/null +++ b/crates/geom/src/warp.rs @@ -0,0 +1,773 @@ +//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`). +//! +//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored). +//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band +//! and so on). A band either +//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the +//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's +//! keeps the same scale, centred), and horizontally the text is scaled so that the +//! widest band is filled along the mean length of its guides (one scale for the whole text), or +//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles): +//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole +//! guide being the line's width: insets don't count) and stand on +//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer +//! than the guide are shrunk to fit. +//! +//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and +//! bottom guide's length and interpolates between them. The guide curves were derived from +//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to +//! the measured bar ends (most fit within a point on a 420 × 200 pt box). +use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2}; + +/// Flattening tolerance for guides, in points. +const TOL: f64 = 0.02; + +/// A guide curve flattened to a polyline with cumulative arc lengths. +#[derive(Clone, Debug, Default)] +pub struct Guide { + pts: Vec, + cum: Vec, + closed: bool, +} + +impl Guide { + fn from_points(raw: impl IntoIterator, closed: bool) -> Guide { + let mut pts: Vec = Vec::new(); + let mut cum: Vec = Vec::new(); + for p in raw { + if !(p.x.is_finite() && p.y.is_finite()) { + continue; + } + match pts.last() { + Some(q) => { + let d = (p - *q).hypot(); + if d > 1e-9 { + cum.push(cum.last().copied().unwrap_or(0.0) + d); + pts.push(p); + } + } + None => { + pts.push(p); + cum.push(0.0); + } + } + if pts.len() > 100_000 { + break; + } + } + Guide { pts, cum, closed } + } + + /// A guide through `path` (any curves), flattened. + pub fn from_path(path: &BezPath, closed: bool) -> Guide { + let mut pts = Vec::new(); + kurbo::flatten(path.iter(), TOL, |el| match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p), + _ => {} + }); + Guide::from_points(pts, closed) + } + + /// Total arc length. + pub fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Whether the guide is a closed loop (positions past its end wrap around). + pub fn is_closed(&self) -> bool { + self.closed + } + + /// Point and unit tangent at arc length `s`. Open guides continue along their end tangents + /// past either end; closed guides wrap. + pub fn at(&self, s: f64) -> (Point, Vec2) { + let len = self.length(); + let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else { + return (Point::ZERO, Vec2::new(1.0, 0.0)); + }; + if self.pts.len() < 2 || len <= 1e-9 { + return (first, Vec2::new(1.0, 0.0)); + } + let s = if s.is_finite() { s } else { 0.0 }; + let s = if self.closed { s.rem_euclid(len) } else { s }; + let seg = |i: usize| -> (Point, Point, f64) { + let a = self.pts.get(i).copied().unwrap_or(first); + let b = self.pts.get(i + 1).copied().unwrap_or(last); + (a, b, self.cum.get(i).copied().unwrap_or(0.0)) + }; + let n = self.pts.len(); + let i = if s <= 0.0 { + 0 + } else if s >= len { + n - 2 + } else { + self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2) + }; + let (a, b, c0) = seg(i); + let d = b - a; + let l = d.hypot(); + let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) }; + (a + t * (s - c0), t) + } + + /// The parallel curve `d` points away on the left of the direction of travel (above a + /// guide running left to right). + pub fn offset(&self, d: f64) -> Guide { + let n = self.pts.len(); + if n < 2 || !d.is_finite() || d == 0.0 { + return self.clone(); + } + let unit = |i: usize| -> Vec2 { + let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO)); + let v = b - a; + let l = v.hypot(); + if l > 1e-12 { v / l } else { Vec2::ZERO } + }; + let pts = (0..n).map(|i| { + let before = if i > 0 { + unit(i - 1) + } else if self.closed { + unit(n - 2) + } else { + Vec2::ZERO + }; + let after = if i + 1 < n { + unit(i) + } else if self.closed { + unit(0) + } else { + Vec2::ZERO + }; + let t = before + after; + let l = t.hypot(); + let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) }; + self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d + }); + Guide::from_points(pts.collect::>(), self.closed) + } + + /// The point at fraction `t` of the length. + pub fn at_frac(&self, t: f64) -> Point { + self.at(t * self.length()).0 + } +} + +/// Which line of a follow band sits on its guide. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineAnchor { + /// The last line; earlier lines stack outwards (arch up, circle). + Last, + /// The first line; later lines stack below it (arch down). + First, + /// The middle of the band's lines. + Middle, +} + +#[derive(Clone, Debug)] +pub enum Band { + /// Text stretched between two guides. + Fill { top: Guide, bottom: Guide }, + /// Text at its natural size along a guide. + Follow { path: Guide, anchor: LineAnchor }, +} + +impl Band { + /// The length text is measured against: the mean of a fill band's guides. + pub fn length(&self) -> f64 { + match self { + Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0, + Band::Follow { path, .. } => path.length(), + } + } +} + +/// A resolved warp: its bands in shape-local points. +#[derive(Clone, Debug)] +pub struct Warp { + pub bands: Vec, +} + +/// Presets whose text follows a guide at its natural size (no wrapping). +pub fn is_follow(name: &str) -> bool { + matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton") +} + +/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets. +pub fn adjust_defaults(name: &str) -> &'static [f64] { + match name { + "textPlain" => &[50_000.0], + "textStop" => &[25_000.0], + "textTriangle" | "textTriangleInverted" => &[50_000.0], + "textChevron" => &[25_000.0], + "textChevronInverted" => &[75_000.0], + "textRingInside" | "textRingOutside" => &[60_000.0], + "textArchUp" | "textCircle" | "textButton" => &[10_800_000.0], + "textArchDown" => &[0.0], + "textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0], + "textArchDownPour" => &[0.0, 25_000.0], + "textCurveUp" | "textCurveDown" => &[45_977.0], + "textCanUp" => &[85_000.0], + "textCanDown" => &[14_286.0], + "textWave1" | "textWave2" => &[12_500.0, 0.0], + "textDoubleWave1" | "textWave4" => &[6_250.0, 0.0], + "textInflate" | "textDeflate" => &[18_750.0], + "textInflateBottom" => &[60_000.0], + "textDeflateBottom" | "textDeflateTop" => &[50_000.0], + "textInflateTop" => &[40_000.0], + "textDeflateInflate" => &[35_000.0], + "textDeflateInflateDeflate" => &[25_000.0], + "textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0], + "textSlantUp" => &[55_556.0], + "textSlantDown" => &[44_444.0], + "textCascadeUp" | "textCascadeDown" => &[44_444.0], + _ => &[], + } +} + +/// Number of adjust values a warp preset takes. +pub fn adjust_count(name: &str) -> usize { + adjust_defaults(name).len() +} + +/// Whether `name` is a warp preset DeckCraft draws. +pub fn is_supported(name: &str) -> bool { + !adjust_defaults(name).is_empty() +} + +/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). +#[derive(Clone, Copy, Debug)] +enum Seg { + M(f64, f64), + L(f64, f64), + Q(f64, f64, f64, f64), + C(f64, f64, f64, f64, f64, f64), + /// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise). + A(f64, f64, f64, f64, f64, f64), +} + +enum Spec { + Fill(Vec, Vec), + Follow(Vec, LineAnchor), +} + +struct Adj<'a> { + raw: &'a [f64], + defaults: &'static [f64], +} + +impl Adj<'_> { + fn raw(&self, i: usize) -> f64 { + self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0) + } + /// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions). + fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 { + (self.raw(i) / 100_000.0).clamp(lo, hi) + } + /// Adjust `i` as an angle in degrees (1/60 000), in 0..360. + fn angle(&self, i: usize) -> f64 { + (self.raw(i) / 60_000.0).rem_euclid(360.0) + } +} + +fn line(pts: &[(f64, f64)]) -> Vec { + pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect() +} + +/// Quadratic from (0, y0) through control (0.5, c) to (1, y0). +fn quad(y0: f64, c: f64) -> Vec { + vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)] +} + +/// A sweep in (0, 360]: zero means a full turn. +fn turn(deg: f64) -> f64 { + let d = deg.rem_euclid(360.0); + if d < 1e-9 { 360.0 } else { d } +} + +/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre. +fn ellipse(s: f64, start: f64, sweep: f64) -> Vec { + vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)] +} + +/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise. +fn arch_up(a: f64) -> (f64, f64) { + (a, turn(2.0 * (270.0 - a))) +} + +/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise. +fn arch_down(a: f64) -> (f64, f64) { + (180.0 - a, -turn(180.0 - 2.0 * a)) +} + +/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°. +fn circle(a: f64) -> (f64, f64) { + (a, turn(2.0 * (360.0 - a))) +} + +/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases). +fn wave(out: &mut Vec, x0: f64, x1: f64, y: f64, k: f64) { + let l = x1 - x0; + out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y)); +} + +/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction). +fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec { + let d = 2.0 * shift; + let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) }; + let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 }; + let guide = |(x0, x1): (f64, f64), y: f64| { + let mut v = vec![Seg::M(x0, y)]; + let n = periods.max(1) as f64; + for i in 0..periods.max(1) { + let f = i as f64; + wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k); + } + v + }; + Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a)) +} + +fn spec(name: &str, a: &Adj) -> Option> { + use Seg::*; + let fill = |t: Vec, b: Vec| vec![Spec::Fill(t, b)]; + let top = || line(&[(0.0, 0.0), (1.0, 0.0)]); + let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]); + Some(match name { + "textPlain" => { + let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5); + if d < 0.0 { + fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)])) + } else { + fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)])) + } + } + "textStop" => { + let y = a.frac(0, 0.14286, 0.5); + let t = 1.0 / 3.0; + fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)])) + } + "textTriangle" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom()) + } + "textTriangleInverted" => { + let y = a.frac(0, 0.0, 1.0); + fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)])) + } + "textChevron" => { + let y = a.frac(0, 0.0, 0.5); + fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)])) + } + "textChevronInverted" => { + let y = a.frac(0, 0.5, 1.0); + fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)])) + } + "textFadeRight" => { + let y = a.frac(0, 0.0, 0.49999); + fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)])) + } + "textFadeLeft" => { + let y = a.frac(0, 0.0, 0.49999); + fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)])) + } + "textFadeUp" => { + let x = a.frac(0, 0.0, 0.49999); + fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom()) + } + "textFadeDown" => { + let x = a.frac(0, 0.0, 0.49999); + fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)])) + } + "textSlantUp" => { + let y = a.frac(0, 0.44444, 1.0); + fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)])) + } + "textSlantDown" => { + let y = a.frac(0, 0.0, 0.55556); + fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)])) + } + "textCascadeUp" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)])) + } + "textCascadeDown" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)])) + } + "textInflate" => { + let y = a.frac(0, 0.0, 0.2); + fill(quad(y, -y), quad(1.0 - y, 1.0 + y)) + } + "textDeflate" => { + let y = a.frac(0, 0.0, 0.375); + fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y)) + } + "textInflateBottom" => { + let y = a.frac(0, 0.6, 1.0); + fill(top(), quad(y, 1.0)) + } + "textDeflateBottom" => { + let y = a.frac(0, 0.0625, 1.0); + fill(top(), quad(1.0, 2.0 * y - 1.0)) + } + "textInflateTop" => { + let y = a.frac(0, 0.0, 0.5); + fill(quad(y, 0.0), bottom()) + } + "textDeflateTop" => { + let y = a.frac(0, 0.0, 0.9375); + fill(quad(0.0, 2.0 * y), bottom()) + } + "textDeflateInflate" => { + // The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of + // a band's height. + let y = a.frac(0, 0.05, 0.95); + let g = 0.05; + vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())] + } + "textDeflateInflateDeflate" => { + let y = a.frac(0, 0.01, 1.0 / 3.0); + let g = 1.0 / 30.0; + let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0); + let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2); + vec![ + Spec::Fill(top(), quad(t1 - g, c1 - g)), + Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)), + Spec::Fill(quad(t2 + g, c2 + g), bottom()), + ] + } + "textCurveUp" | "textCurveDown" => { + let y = a.frac(0, 0.0, 0.56818); + let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)]; + let t = if name == "textCurveUp" { + vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)] + } else { + vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)] + }; + fill(t, b) + } + "textCanUp" => { + let r = 1.0 - a.frac(0, 0.5, 1.0); + fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)]) + } + "textCanDown" => { + let r = a.frac(0, 0.0, 0.5); + fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)]) + } + "textRingInside" | "textRingOutside" => { + let h = a.frac(0, 0.5, 0.99); + let sweep = if name == "textRingInside" { 360.0 } else { -360.0 }; + fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)]) + } + "textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)], + "textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)], + "textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)], + "textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)], + "textArchUp" => { + let (s, w) = arch_up(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)] + } + "textArchDown" => { + let (s, w) = arch_down(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)] + } + "textCircle" => { + let (s, w) = circle(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)] + } + "textButton" => { + let t = a.angle(0); + let (s, w) = arch_up(t); + let half = 0.5 * t.to_radians().cos().abs(); + vec![ + Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last), + Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle), + Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First), + ] + } + "textArchUpPour" => { + let (s, w) = arch_up(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(1.0, s, w), ellipse(r, s, w)) + } + "textArchDownPour" => { + let (s, w) = arch_down(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(r, s, w), ellipse(1.0, s, w)) + } + "textCirclePour" => { + let (s, w) = circle(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(1.0, s, w), ellipse(r, s, w)) + } + "textButtonPour" => { + let (s, w) = arch_up(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + // The middle band runs between chords of the inner ellipse halfway to its top and + // bottom. + let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt()); + vec![ + Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)), + Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])), + Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)), + ] + } + _ => return None, + }) +} + +/// Build a guide from unit-space segments mapped onto `r`. +fn guide(segs: &[Seg], r: Rect) -> Guide { + let mut p = BezPath::new(); + let mut started = false; + let mut closed = false; + for s in segs { + match *s { + Seg::M(x, y) => { + p.move_to((x, y)); + started = true; + } + Seg::L(x, y) => { + if started { + p.line_to((x, y)) + } else { + p.move_to((x, y)) + } + started = true; + } + Seg::Q(cx, cy, x, y) => { + if started { + p.quad_to((cx, cy), (x, y)); + } + } + Seg::C(x1, y1, x2, y2, x, y) => { + if started { + p.curve_to((x1, y1), (x2, y2), (x, y)); + } + } + Seg::A(cx, cy, rx, ry, start, sweep) => { + let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0); + let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin()); + if started { + p.line_to(s0); + } else { + p.move_to(s0); + } + started = true; + for el in arc.append_iter(1e-5) { + p.push(el); + } + closed = sweep.abs() >= 359.999; + } + } + } + let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]); + p.apply_affine(m); + Guide::from_path(&p, closed) +} + +/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults) +/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect. +pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return None; + } + let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + let a = Adj { raw: adj, defaults: adjust_defaults(name) }; + let specs = spec(name, &a)?; + let bands = specs + .into_iter() + .map(|s| match s { + Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) }, + Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor }, + }) + .collect(); + Some(Warp { bands }) +} + +/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines +/// share the first band, and so on. +pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize { + if bands <= 1 || lines == 0 { + return 0; + } + // Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k). + (0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0) +} + +/// One laid-out line as the warp sees it (layout coordinates, points). +#[derive(Clone, Copy, Debug)] +pub struct WarpLine { + /// Ink box of the line's glyphs; `None` for a line without ink. + pub ink: Option, + /// Advance extent of the line (left, right). + pub extent: (f64, f64), + /// Reference height for follow bands: the y that sits on the guide. + pub ref_y: f64, +} + +#[derive(Clone, Copy, Debug)] +enum LineMap { + Fill { + band: usize, + cx: f64, + k: f64, + len: f64, + y0: f64, + y1: f64, + }, + /// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's. + Follow { + band: usize, + guide: Option, + e0: f64, + s0: f64, + k: f64, + ref_y: f64, + }, +} + +/// Maps points of laid-out lines onto a warp. +#[derive(Clone, Debug)] +pub struct Mapper<'a> { + warp: &'a Warp, + lines: Vec, + offsets: Vec, + zoom: f64, +} + +impl Warp { + /// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in + /// it gives its alignment along follow guides; the insets themselves don't shorten a guide). + pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> { + let nb = self.bands.len().max(1); + let n = lines.len(); + let band_of: Vec = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect(); + // Ink of the whole text, for the shared horizontal centre and scale of fill bands. + let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b)); + let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0); + let mut k_fill = f64::INFINITY; + let mut zoom: f64 = 1.0; + let mut band_y: Vec> = vec![None; nb]; + for (i, l) in lines.iter().enumerate() { + let Some(r) = l.ink else { continue }; + let b = band_of.get(i).copied().unwrap_or(0); + if let Some(slot) = band_y.get_mut(b) { + *slot = Some(match *slot { + Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)), + None => (r.y0, r.y1), + }); + } + } + // Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so + // a band with fewer lines keeps them at the same size, centred between its guides. + let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max); + let band_y: Vec> = + band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect(); + for (b, band) in self.bands.iter().enumerate() { + if let Band::Fill { top, bottom } = band { + let half = lines + .iter() + .enumerate() + .filter(|(i, _)| band_of.get(*i) == Some(&b)) + .filter_map(|(_, l)| l.ink) + .map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs())) + .fold(0.0, f64::max); + if half > 1e-9 { + k_fill = k_fill.min(band.length() / (2.0 * half)); + } + if let Some(Some((y0, y1))) = band_y.get(b) { + let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max); + if y1 - y0 > 1e-9 { + zoom = zoom.max(thick / (y1 - y0)); + } + } + } + } + if !k_fill.is_finite() { + k_fill = 1.0; + } + zoom = zoom.max(k_fill); + let mut maps = Vec::with_capacity(n); + let mut offsets = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let b = band_of.get(i).copied().unwrap_or(0); + let in_band = |j: &usize| band_of.get(*j) == Some(&b); + match self.bands.get(b) { + Some(Band::Fill { .. }) | None => { + let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0)); + let len = self.bands.get(b).map(Band::length).unwrap_or(0.0); + maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 }); + } + Some(Band::Follow { path, anchor }) => { + let len = path.length(); + let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max); + let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 }; + let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect(); + let ref_y = match anchor { + LineAnchor::Last => members.last().map(|l| l.ref_y), + LineAnchor::First => members.first().map(|l| l.ref_y), + LineAnchor::Middle => match (members.first(), members.last()) { + (Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0), + _ => None, + }, + } + .unwrap_or(l.ref_y); + // Lines off the guide run on a parallel curve at their natural length. + let d = (ref_y - l.ref_y) * k; + let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 { + let g = path.offset(d); + let len = g.length(); + offsets.push(g); + (Some(offsets.len() - 1), len) + } else { + (None, len) + }; + let (e0, e1) = l.extent; + let w = (e1 - e0).max(0.0) * k; + let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0)); + let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 }; + let s0 = f * (len - w); + maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y }); + } + } + } + Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } } + } +} + +impl Mapper<'_> { + /// How much the warp magnifies text at most (for flattening tolerances), ≥ 1. + pub fn zoom(&self) -> f64 { + self.zoom + } + + /// Map point `p` of line `line` (layout coordinates) onto the warp. + pub fn map(&self, line: usize, p: Point) -> Point { + let out = match self.lines.get(line) { + Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => { + let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p }; + let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 }; + let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 }; + let (t, b) = (top.at_frac(u), bottom.at_frac(u)); + t.lerp(b, v) + } + Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => { + let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) { + (Some(g), _) => g, + (None, Some(Band::Follow { path, .. })) => path, + _ => return p, + }; + let s = s0 + (p.x - e0) * k; + let d = (ref_y - p.y) * k; + let (q, t) = path.at(s); + q + Vec2::new(t.y, -t.x) * d + } + None => p, + }; + if out.x.is_finite() && out.y.is_finite() { out } else { p } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs new file mode 100644 index 0000000..2d18b13 --- /dev/null +++ b/crates/geom/src/warp/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +const ALL: &[&str] = &[ + "textPlain", + "textStop", + "textTriangle", + "textTriangleInverted", + "textChevron", + "textChevronInverted", + "textRingInside", + "textRingOutside", + "textArchUp", + "textArchDown", + "textCircle", + "textButton", + "textArchUpPour", + "textArchDownPour", + "textCirclePour", + "textButtonPour", + "textCurveUp", + "textCurveDown", + "textCanUp", + "textCanDown", + "textWave1", + "textWave2", + "textDoubleWave1", + "textWave4", + "textInflate", + "textDeflate", + "textInflateBottom", + "textDeflateBottom", + "textInflateTop", + "textDeflateTop", + "textDeflateInflate", + "textDeflateInflateDeflate", + "textFadeRight", + "textFadeLeft", + "textFadeUp", + "textFadeDown", + "textSlantUp", + "textSlantDown", + "textCascadeUp", + "textCascadeDown", +]; + +const W: f64 = 420.0; +const H: f64 = 200.0; + +fn rect() -> Rect { + Rect::new(0.0, 0.0, W, H) +} + +/// One line whose ink box is `ink`. +fn one_line(ink: Rect) -> Vec { + vec![WarpLine { ink: Some(ink), extent: (ink.x0, ink.x1), ref_y: ink.y1 }] +} + +/// Where the unit-square point (u, v) of a single line's ink box lands, in unit coordinates. +fn land(name: &str, adj: &[f64], u: f64, v: f64) -> (f64, f64) { + let w = warp(name, adj, rect()).unwrap(); + let ink = Rect::new(100.0, 50.0, 300.0, 90.0); + let lines = one_line(ink); + let m = w.mapper(&lines, rect().inset(-5.0)); + let p = m.map(0, Point::new(ink.x0 + u * ink.width(), ink.y0 + v * ink.height())); + (p.x / W, p.y / H) +} + +fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool { + (a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol +} + +#[track_caller] +fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f64), br: (f64, f64)) { + for ((u, v), want) in [((0.0, 0.0), tl), ((1.0, 0.0), tr), ((0.0, 1.0), bl), ((1.0, 1.0), br)] { + let got = land(name, adj, u, v); + assert!(close(got, want, 0.004), "{name} corner ({u},{v}): got {got:?}, want {want:?}"); + } +} + +#[test] +fn every_preset_builds_finite_guides() { + for name in ALL { + assert!(is_supported(name), "{name}"); + let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}")); + assert!(!w.bands.is_empty(), "{name}"); + for b in &w.bands { + let l = b.length(); + assert!(l.is_finite() && l > 10.0, "{name}: band length {l}"); + } + assert_eq!(adjust_count(name), adjust_defaults(name).len()); + } + assert!(warp("textNoShape", &[], rect()).is_none()); + assert!(warp("bogus", &[], rect()).is_none()); + assert!(!is_supported("textNoShape")); +} + +#[test] +fn straight_presets_put_the_ink_corners_on_their_guides() { + corners("textPlain", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + // A smaller adjust shears the text: the top edge starts 40 % in, the bottom edge ends 60 % in. + corners("textPlain", &[30_000.0], (0.4, 0.0), (1.0, 0.0), (0.0, 1.0), (0.6, 1.0)); + corners("textStop", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 0.75), (1.0, 0.75)); + corners("textTriangle", &[], (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (1.0, 1.0)); + corners("textTriangleInverted", &[25_000.0], (0.0, 0.0), (1.0, 0.0), (0.0, 0.25), (1.0, 0.25)); + corners("textChevron", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 1.0), (1.0, 1.0)); + corners("textChevronInverted", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.75), (1.0, 0.75)); + corners("textFadeRight", &[], (0.0, 0.0), (1.0, 0.33333), (0.0, 1.0), (1.0, 0.66667)); + corners("textFadeLeft", &[], (0.0, 0.33333), (1.0, 0.0), (0.0, 0.66667), (1.0, 1.0)); + corners("textFadeUp", &[], (0.33333, 0.0), (0.66667, 0.0), (0.0, 1.0), (1.0, 1.0)); + corners("textFadeDown", &[], (0.0, 0.0), (1.0, 0.0), (0.33333, 1.0), (0.66667, 1.0)); + corners("textSlantUp", &[], (0.0, 0.55556), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444)); + corners("textSlantDown", &[], (0.0, 0.0), (1.0, 0.55556), (0.0, 0.44444), (1.0, 1.0)); + corners("textCascadeUp", &[], (0.0, 0.13889), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444)); + corners("textCascadeDown", &[], (0.0, 0.0), (1.0, 0.13889), (0.0, 0.44444), (1.0, 1.0)); +} + +#[test] +fn curved_presets_bend_where_powerpoint_bends_them() { + corners("textInflate", &[], (0.0, 0.1875), (1.0, 0.1875), (0.0, 0.8125), (1.0, 0.8125)); + assert!(close(land("textInflate", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textInflate", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + corners("textDeflate", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + assert!(close(land("textDeflate", &[], 0.5, 0.0), (0.5, 0.1875), 0.004)); + corners("textInflateBottom", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.6), (1.0, 0.6)); + assert!(close(land("textInflateBottom", &[], 0.5, 1.0), (0.5, 0.8), 0.004)); + assert!(close(land("textDeflateBottom", &[], 0.5, 1.0), (0.5, 0.5), 0.004)); + assert!(close(land("textInflateTop", &[], 0.5, 0.0), (0.5, 0.2), 0.004)); + assert!(close(land("textDeflateTop", &[], 0.5, 0.0), (0.5, 0.5), 0.004)); + // Cans: half ellipses. + corners("textCanUp", &[], (0.0, 0.15), (1.0, 0.15), (0.0, 1.0), (1.0, 1.0)); + assert!(close(land("textCanUp", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textCanUp", &[], 0.5, 1.0), (0.5, 0.85), 0.004)); + assert!(close(land("textCanDown", &[], 0.5, 0.0), (0.5, 0.14286), 0.004)); + // Curves and waves. + let a = 0.45977; + corners("textCurveUp", &[], (0.0, a), (1.0, 0.0), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0)); + assert!(close(land("textCurveUp", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + corners("textCurveDown", &[], (0.0, 0.0), (1.0, a), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0)); + corners("textWave1", &[], (0.0, 0.125), (1.0, 0.125), (0.0, 0.875), (1.0, 0.875)); + // Wave 1 rises first: its top is near the box top a quarter of the way along. + assert!(land("textWave1", &[], 0.25, 0.0).1 < 0.02); + assert!(land("textWave2", &[], 0.25, 0.0).1 > 0.23); + // A horizontal shift moves the bottom guide right. + corners("textWave1", &[12_500.0, 5_000.0], (0.0, 0.125), (0.9, 0.125), (0.1, 0.875), (1.0, 0.875)); + assert!(land("textDoubleWave1", &[], 0.125, 0.0).1 < 0.02); + assert!(land("textWave4", &[], 0.125, 0.0).1 > 0.1); +} + +#[test] +fn arc_presets_follow_ellipses() { + // Pour arches: the outer arc from the left end over the top; the inner one is scaled by adj2. + corners("textArchUpPour", &[], (0.0, 0.5), (1.0, 0.5), (0.25, 0.5), (0.75, 0.5)); + assert!(close(land("textArchUpPour", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textArchUpPour", &[], 0.5, 1.0), (0.5, 0.25), 0.004)); + assert!(close(land("textArchUpPour", &[10_800_000.0, 30_000.0], 0.5, 1.0), (0.5, 0.35), 0.004)); + assert!(close(land("textArchDownPour", &[], 0.5, 0.0), (0.5, 0.625), 0.004)); + assert!(close(land("textArchDownPour", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + // Circle pour: once round from the left; the middle of the text is at the right. + assert!(close(land("textCirclePour", &[], 0.5, 0.0), (1.0, 0.5), 0.004)); + // Rings: the text goes round two stacked ellipses. + assert!(close(land("textRingInside", &[], 0.0, 0.0), (0.0, 0.3), 0.004)); + assert!(close(land("textRingInside", &[], 0.0, 1.0), (0.0, 0.7), 0.004)); + assert!(close(land("textRingInside", &[], 0.25, 0.0), (0.5, 0.0), 0.01)); + assert!(close(land("textRingOutside", &[], 0.25, 1.0), (0.5, 1.0), 0.01)); +} + +#[test] +fn follow_presets_keep_text_size_and_stack_lines_outwards() { + let w = warp("textArchUp", &[], rect()).unwrap(); + // A 40-wide centred line whose reference height is 80. + let line = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 88.0)), extent: (190.0, 230.0), ref_y: 80.0 }; + let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6); + let m = w.mapper(&[line], inner); + // The middle of the reference line lands on the top of the ellipse; 10 pt above it lands + // 10 pt above the top. + let p = m.map(0, Point::new(210.0, 80.0)); + assert!((p.x - 210.0).abs() < 0.05 && p.y.abs() < 0.05, "{p:?}"); + let q = m.map(0, Point::new(210.0, 70.0)); + assert!((q.x - 210.0).abs() < 0.15 && (q.y + 10.0).abs() < 0.05, "{q:?}"); + // Natural size: 40 pt of text spans about 40 pt of arc. + let (a, b) = (m.map(0, Point::new(190.0, 80.0)), m.map(0, Point::new(230.0, 80.0))); + assert!(((b - a).hypot() - 40.0).abs() < 0.5); + // Left-aligned text starts at the very start of the arc: insets don't shorten it. + let left = WarpLine { ink: Some(Rect::new(7.2, 50.0, 47.2, 88.0)), extent: (7.2, 47.2), ref_y: 80.0 }; + let m = w.mapper(&[left], inner); + let p = m.map(0, Point::new(7.2, 80.0)); + assert!(p.x.abs() < 0.01 && (p.y - 100.0).abs() < 0.01, "{p:?}"); + // Two lines on an arch up: the last sits on the arc, the first stacks outside it. + let l1 = WarpLine { ink: Some(Rect::new(190.0, 10.0, 230.0, 40.0)), extent: (190.0, 230.0), ref_y: 34.0 }; + let l2 = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 80.0)), extent: (190.0, 230.0), ref_y: 74.0 }; + let m = w.mapper(&[l1, l2], inner); + assert!(m.map(1, Point::new(210.0, 74.0)).y.abs() < 0.05); + assert!((m.map(0, Point::new(210.0, 34.0)).y + 40.0).abs() < 0.05); + // Arch down: the first line sits on the bottom of the ellipse. + let w = warp("textArchDown", &[], rect()).unwrap(); + let m = w.mapper(&[l1, l2], inner); + assert!((m.map(0, Point::new(210.0, 34.0)).y - H).abs() < 0.05); + assert!((m.map(1, Point::new(210.0, 74.0)).y - H - 40.0).abs() < 0.05); + // Text longer than the arc is shrunk to fit it. + let w = warp("textArchUp", &[], rect()).unwrap(); + let long = WarpLine { ink: Some(Rect::new(-500.0, 50.0, 900.0, 88.0)), extent: (-500.0, 900.0), ref_y: 80.0 }; + let m = w.mapper(&[long], inner); + let (a, b) = (m.map(0, Point::new(-500.0, 80.0)), m.map(0, Point::new(900.0, 80.0))); + assert!(a.x < 5.0 && b.x > W - 5.0 && (a.y - 100.0).abs() < 10.0 && (b.y - 100.0).abs() < 10.0, "{a:?} {b:?}"); +} + +#[test] +fn lines_share_bands_in_order() { + assert_eq!((0..2).map(|i| band_of_line(i, 2, 2)).collect::>(), [0, 1]); + assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::>(), [0, 0, 1]); + assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::>(), [0, 0, 0, 1, 1]); + assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::>(), [0, 0, 1, 1, 2]); + assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::>(), [0, 0, 1, 2]); + assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::>(), [0, 1]); + assert_eq!(band_of_line(0, 1, 3), 0); + assert_eq!(band_of_line(7, 9, 1), 0); + assert_eq!(band_of_line(0, 0, 3), 0); +} + +#[test] +fn deflate_inflate_maps_each_band_separately() { + let w = warp("textDeflateInflate", &[], rect()).unwrap(); + assert_eq!(w.bands.len(), 2); + let l1 = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 40.0)), extent: (100.0, 300.0), ref_y: 34.0 }; + let l2 = WarpLine { ink: Some(Rect::new(100.0, 50.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 74.0 }; + let m = w.mapper(&[l1, l2], rect()); + // Band 1 ends at 45 % height on the sides, band 2 starts at 55 %. + assert!((m.map(0, Point::new(100.0, 40.0)).y / H - 0.45).abs() < 0.004); + assert!((m.map(1, Point::new(100.0, 50.0)).y / H - 0.55).abs() < 0.004); + assert!((m.map(1, Point::new(300.0, 80.0)).y / H - 1.0).abs() < 0.004); + // A band with fewer lines keeps them at the size of the fuller band, centred in its band: + // two lines (ink 10..60) in band 1, one line (ink 70..80) in band 2 of three. + let a = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 30.0)), extent: (100.0, 300.0), ref_y: 28.0 }; + let b = WarpLine { ink: Some(Rect::new(100.0, 40.0, 300.0, 60.0)), extent: (100.0, 300.0), ref_y: 58.0 }; + let c = WarpLine { ink: Some(Rect::new(100.0, 70.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 78.0 }; + let m = w.mapper(&[a, b, c], rect()); + let (top, bot) = (m.map(2, Point::new(200.0, 70.0)).y / H, m.map(2, Point::new(200.0, 80.0)).y / H); + // Band 2 runs from 0.4 to 1 at the centre; the line takes the middle fifth of it. + assert!((top - 0.64).abs() < 0.005 && (bot - 0.76).abs() < 0.005, "{top} {bot}"); + // A shorter first line keeps its share of the width. + let short = WarpLine { ink: Some(Rect::new(150.0, 10.0, 250.0, 40.0)), extent: (150.0, 250.0), ref_y: 34.0 }; + let m = w.mapper(&[short, l2], rect()); + let p = m.map(0, Point::new(150.0, 10.0)); + assert!((p.x / W - 0.25).abs() < 0.01, "{p:?}"); +} + +#[test] +fn guides_extrapolate_and_wrap() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + let g = Guide::from_path(&p, false); + assert!((g.length() - 10.0).abs() < 1e-9); + assert!((g.at(15.0).0.x - 15.0).abs() < 1e-9); + assert!((g.at(-5.0).0.x + 5.0).abs() < 1e-9); + assert!(g.at(f64::NAN).0.x.is_finite()); + let w = warp("textCircle", &[], rect()).unwrap(); + let Some(Band::Follow { path, .. }) = w.bands.first() else { panic!() }; + assert!(path.is_closed()); + let l = path.length(); + let (a, b) = (path.at(5.0).0, path.at(l + 5.0).0); + assert!((a - b).hypot() < 1e-6); + let empty = Guide::default(); + assert_eq!(empty.length(), 0.0); + assert!(empty.at(3.0).0.x.is_finite()); +} + +#[test] +fn hostile_adjusts_and_boxes_stay_finite() { + let adjs: &[&[f64]] = + &[&[], &[f64::NAN, f64::INFINITY], &[f64::NEG_INFINITY, f64::NAN], &[1e300, -1e300], &[-1e9, 1e9], &[0.0, 0.0], &[21_599_999.0, 100_000.0]]; + let rects = [ + Rect::new(0.0, 0.0, 0.0, 0.0), + Rect::new(10.0, 10.0, 10.0, 200.0), + Rect::new(0.0, 0.0, 300.0, 0.0), + Rect::new(100.0, 100.0, -50.0, -20.0), + Rect::new(0.0, 0.0, 1e7, 1e7), + ]; + let lines = [ + WarpLine { ink: Some(Rect::new(0.0, 0.0, 0.0, 0.0)), extent: (0.0, 0.0), ref_y: 0.0 }, + WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 }, + WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 }, + ]; + for name in ALL { + for adj in adjs { + for r in rects { + let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") }; + for b in &w.bands { + assert!(b.length().is_finite()); + } + let m = w.mapper(&lines, r); + assert!(m.zoom().is_finite() && m.zoom() >= 1.0); + let probes = [ + (0, Point::ZERO), + (1, Point::new(5.0, 3.0)), + (2, Point::new(1e6, -5.0)), + (2, Point::new(f64::NAN, 1.0)), + (9, Point::new(1.0, 1.0)), + ]; + for (i, p) in probes { + let q = m.map(i, p); + assert!(q.x.is_finite() || !p.x.is_finite(), "{name} {adj:?} {r:?}: {q:?}"); + assert!(q.y.is_finite(), "{name} {adj:?} {r:?}: {q:?}"); + } + } + } + } + assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); +} diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 3ea7f99..54eb4c8 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -223,6 +223,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { columns: Some(1), col_spacing: Some(0.0), warp: None, + warp_adj: Vec::new(), rtl_col: Some(false), upright: Some(false), }); diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index feaff46..b8138a3 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -98,6 +98,10 @@ pub struct BodyProps { pub col_spacing: Option, /// WordArt transform (`prstTxWarp`), e.g. `textArchUp`. pub warp: Option, + /// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values); + /// empty = the preset's defaults. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warp_adj: Vec, pub rtl_col: Option, /// Keep text upright when the shape rotates. pub upright: Option, @@ -119,6 +123,7 @@ impl BodyProps { self.col_spacing = self.col_spacing.or(p.col_spacing); if self.warp.is_none() { self.warp.clone_from(&p.warp); + self.warp_adj.clone_from(&p.warp_adj); } self.rtl_col = self.rtl_col.or(p.rtl_col); self.upright = self.upright.or(p.upright); diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 36f2a66..c902776 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -321,13 +321,45 @@ pub fn body_pr(b: &El) -> BodyProps { } "spAutoFit" => p.autofit = Some(AutoFit::Shape), "noAutofit" => p.autofit = Some(AutoFit::None), - "prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from), + "prstTxWarp" => { + p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from); + p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default(); + } _ => {} } } p } +/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given); +/// slots the file leaves out keep the preset's default. +fn warp_adjusts(preset: &str, e: &El) -> Vec { + let mut adj: Vec = Vec::new(); + let Some(av) = e.child("avLst") else { return adj }; + let defaults = deckcraft_geom::warp::adjust_defaults(preset); + for gd in av.children_named("gd").take(16) { + let n = gd.attr("name").unwrap_or(""); + let idx = if n == "adj" { + 0 + } else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::().ok()).filter(|k| (1..=8).contains(k)) { + k - 1 + } else { + continue; + }; + let Some(v) = gd.attr("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + else { + continue; + }; + while adj.len() <= idx { + adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(idx) { + *slot = v.clamp(-1.0e9, 1.0e9); + } + } + adj +} + fn spacing(e: &El) -> Option { if let Some(p) = e.child("spcPct") { return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0))); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 23d3340..4c2e5d9 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -392,7 +392,19 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { w.open("a:bodyPr", a); if let Some(p) = warp { w.open("a:prstTxWarp", A::new().a("prst", p)); - w.empty0("a:avLst"); + let adj: Vec<(usize, i64)> = + b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect(); + if adj.is_empty() { + w.empty0("a:avLst"); + } else { + let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1; + w.open0("a:avLst"); + for (i, v) in adj { + let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) }; + w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}"))); + } + w.close("a:avLst"); + } w.close("a:prstTxWarp"); } match b.autofit { diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 08fcbdc..c9f33fe 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -796,3 +796,64 @@ fn a_table_fill_round_trips() { matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192))) ); } + +#[test] +fn wordart_warps_and_their_adjusts_round_trip() { + let mut p = Presentation::default(); + let cases: [(&str, &str, Vec); 5] = [ + ("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]), + ("Wave", "textWave1", vec![18_000.0, -5_000.0]), + ("Inflate", "textInflate", vec![30_000.0]), + ("Arch", "textArchUp", vec![]), + ("Ring", "textRingInside", vec![75_000.0]), + ]; + let mut shapes = vec![]; + for (i, (name, warp, adj)) in cases.iter().enumerate() { + let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0); + let mut body = TextBody::from_text("WordArt"); + body.body.warp = Some((*warp).into()); + body.body.warp_adj = adj.clone(); + s.text = Some(body); + shapes.push(s); + } + Arc::make_mut(&mut p.slides[0]).shapes = shapes; + let q = round(&p); + for (name, warp, adj) in &cases { + let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body; + assert_eq!(b.warp.as_deref(), Some(*warp), "{name}"); + assert_eq!(&b.warp_adj, adj, "{name}"); + } + // The single-adjust preset is written with the guide name `adj`, two-adjust ones with + // `adj1`/`adj2`, and a preset with no adjusts given gets an empty list. + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + // A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are + // read as numbers or dropped. + let edited = xml + .replace(r#""#, "") + .replace(r#""#, r#""#); + 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 pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body; + assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]); + let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body; + assert_eq!(inflate.warp.as_deref(), Some("textInflate")); + assert!(inflate.warp_adj.is_empty()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b493889..4e8058a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,6 +21,7 @@ mod pathgrad; mod placed; mod ramp; mod table; +mod warp; use std::collections::HashMap; use std::sync::Arc; @@ -816,7 +817,10 @@ impl Renderer { let tr = geo.text_rect; if !body.is_empty() { let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); - draw_layout_with(ctx, &l, tr, m, &st.paras); + match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) { + Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w), + None => draw_layout_with(ctx, &l, tr, m, &st.paras), + } } else if f.opts.edit && s.ph.is_some() { let kind = s.ph_type().unwrap_or(PhType::Body); let prompt_body = prompt_body(rctx, s, kind); @@ -1146,6 +1150,13 @@ pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { t } +/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws. +pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option { + let bp = resolve::body(rctx, s); + let name = bp.warp.as_deref()?; + deckcraft_geom::warp::warp(name, &bp.warp_adj, tr) +} + /// Draw an already laid-out text block. pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { draw_layout_with(ctx, l, tr, m, &[]); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9a0c6fe..adcc449 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -994,3 +994,84 @@ fn shadows_and_glows_spread_like_powerpoints() { assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120)); assert!(red(&img, 10, 120) > 245); } + +/// A 400 × 200 text box at (100, 100) holding `text` at 40 pt, warped by `warp`. +fn wordart(warp: Option<&str>, text: &str) -> Presentation { + let mut t = TextBody::from_text(text); + t.paragraphs[0].runs[0].props.size = Some(40.0); + t.paragraphs[0].props.align = Some(deckcraft_model::text::Align::Center); + t.body.warp = warp.map(String::from); + deck_with(vec![Shape { id: ShapeId(700), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), text: Some(t), ..Default::default() }]) +} + +/// Ink box (x0, y0, x1, y1) of dark pixels, and the top ink row of each column. +fn ink(img: &Image) -> ((u32, u32, u32, u32), Vec>) { + let mut b = (u32::MAX, u32::MAX, 0, 0); + let mut tops = vec![None; img.width as usize]; + for y in 0..img.height { + for x in 0..img.width { + if img.pixel(x, y)[0] < 128 { + b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + if tops[x as usize].is_none() { + tops[x as usize] = Some(y); + } + } + } + } + (b, tops) +} + +#[test] +fn wordart_arch_up_lifts_the_middle_of_the_line() { + let o = RenderOpts { scale: 1.0, ..Default::default() }; + let flat = render_slide(&wordart(None, "ARCHED WORDART"), 0, &o); + let arched = render_slide(&wordart(Some("textArchUp"), "ARCHED WORDART"), 0, &o); + assert!(diff_px(&flat, &arched) > 1000); + let ((fx0, _, fx1, _), ftops) = ink(&flat); + let ((ax0, ay0, ax1, _), atops) = ink(&arched); + let top_near = |tops: &[Option], x: u32| (x.saturating_sub(15)..x + 15).filter_map(|i| tops.get(i as usize).copied().flatten()).min(); + // Flat text: the ends and the middle stand on one line. + let (fl, fm) = (top_near(&ftops, fx0 + 20).unwrap(), top_near(&ftops, (fx0 + fx1) / 2).unwrap()); + assert!(fl.abs_diff(fm) < 4, "flat {fl} {fm}"); + // Arched: the line follows the top of the ellipse inscribed in the box, so the middle is + // at the box top and the ends sit clearly lower. + let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap()); + assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}"); + assert!((80..=110).contains(&ay0), "arch top {ay0}"); +} + +#[test] +fn wordart_plain_stretches_the_ink_over_the_text_rect() { + let img = render_slide(&wordart(Some("textPlain"), "PLAIN"), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let ((x0, y0, x1, y1), _) = ink(&img); + for (got, want) in [(x0, 100), (y0, 100), (x1, 499), (y1, 299)] { + assert!(got.abs_diff(want) <= 2, "ink box {:?}", (x0, y0, x1, y1)); + } +} + +#[test] +fn unknown_or_absent_warps_draw_flat_text() { + let o = RenderOpts { scale: 0.5, ..Default::default() }; + // Warm the font database first (other tests load fonts concurrently). + let _ = render_slide(&wordart(None, "FLAT"), 0, &o); + let flat = render_slide(&wordart(None, "FLAT"), 0, &o); + for w in ["textNoShape", "bogus", ""] { + assert_eq!(diff_px(&flat, &render_slide(&wordart(Some(w), "FLAT"), 0, &o)), 0, "{w}"); + } +} + +#[test] +fn every_warp_preset_renders_text_inside_the_slide() { + let o = RenderOpts { scale: 0.5, ..Default::default() }; + for name in + ["textArchUp", "textArchDown", "textCircle", "textButton", "textWave1", "textRingInside", "textCirclePour", "textDeflateInflateDeflate"] + { + let mut p = wordart(Some(name), "One line\nTwo line\nThree"); + if let Some(b) = std::sync::Arc::make_mut(&mut p.slides[0]).shapes[0].text.as_mut() { + b.body.warp_adj = vec![f64::NAN, -1e12]; + } + let img = render_slide(&p, 0, &o); + let ((x0, y0, x1, y1), _) = ink(&img); + assert!(x0 <= x1 && y0 <= y1, "{name}: no ink"); + } +} diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs new file mode 100644 index 0000000..de0ff12 --- /dev/null +++ b/crates/render/src/warp.rs @@ -0,0 +1,209 @@ +//! Drawing laid-out text through a WordArt warp ([`deckcraft_geom::warp`]). +//! +//! Glyph outlines are placed in layout space, flattened, cut into short segments and every point +//! is mapped through the warp, so straight glyph edges bend with the guides. Underlines and +//! highlights are warped the same way. + +use deckcraft_geom::warp::{Warp, WarpLine}; +use deckcraft_text::{GlyphRun, TextLayout}; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _}; +use vello_cpu::{RenderContext, peniko}; + +use crate::{ParaState, color, glyph, scale_of, text_transform}; + +/// Most points one warped glyph may have (a hostile size or zoom can't explode the work). +const MAX_POINTS: usize = 40_000; + +/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to. +fn line_of(l: &TextLayout, para: usize, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) { + let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01; + let d = if inside { 0.0 } else { (li.baseline - y).abs() }; + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((i, d)); + } + } + best.map(|(i, _)| i) +} + +/// Where a line meets a follow guide, above its baseline: 3/8 of the way up the font's +/// ascent + descent box (measured on Arial, Times New Roman and Courier New in PowerPoint). +fn ref_offset(run: &GlyphRun) -> f64 { + let f = &run.face; + let k = run.size / f.upem.max(1.0); + ((0.375 * (f.ascent + f.descent) - f.descent) * k).clamp(-run.size, run.size) +} + +/// `path` flattened with tolerance `tol`, cut into pieces of at most `seg` and mapped by `f`. +fn warp_path(path: &BezPath, tol: f64, seg: f64, f: &dyn Fn(Point) -> Point) -> BezPath { + let mut out = BezPath::new(); + let mut last: Option = None; + let mut start: Option = None; + let mut budget = MAX_POINTS; + let segment = |out: &mut BezPath, a: Point, b: Point, budget: &mut usize| { + let n = ((b - a).hypot() / seg).ceil(); + let n = if n.is_finite() { (n as usize).clamp(1, 512) } else { 1 }; + for i in 1..=n { + if *budget == 0 { + return; + } + *budget -= 1; + out.line_to(f(a.lerp(b, i as f64 / n as f64))); + } + }; + kurbo::flatten(path.iter(), tol, |el| match el { + PathEl::MoveTo(p) => { + if let (Some(a), Some(s)) = (last, start) + && (a - s).hypot() > 1e-9 + { + segment(&mut out, a, s, &mut budget); + } + out.move_to(f(p)); + last = Some(p); + start = Some(p); + } + PathEl::LineTo(p) => { + if let Some(a) = last { + segment(&mut out, a, p, &mut budget); + } + last = Some(p); + } + PathEl::ClosePath => { + if let (Some(a), Some(s)) = (last, start) { + segment(&mut out, a, s, &mut budget); + } + out.close_path(); + last = start; + } + _ => {} + }); + out +} + +struct Item<'a> { + run: &'a GlyphRun, + path: BezPath, + line: usize, + state: ParaState, +} + +/// Draw `l` (laid out in text rect `tr`, shape transform `m`) through `warp`. +pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) { + let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); + let m = text_transform(m, tr, l.rotation); + let n = l.lines.len(); + let mut ink: Vec> = vec![None; n]; + let mut off: Vec> = vec![None; n]; + let mut items: Vec = Vec::new(); + for run in &l.runs { + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + for (gid, x, y) in &run.glyphs { + let Some(li) = line_of(l, run.para, *y) else { continue }; + if let Some(o) = off.get_mut(li) { + *o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run)))); + } + let outline = glyph(&run.face, *gid); + if outline.elements().is_empty() { + continue; + } + let mut path = (*outline).clone(); + path.apply_affine(Affine::translate((*x, *y)) * skew * Affine::scale(k)); + let b = path.bounding_box(); + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + continue; + } + if let Some(slot) = ink.get_mut(li) { + *slot = Some(slot.map_or(b, |r| r.union(b))); + } + items.push(Item { run, path, line: li, state: para(run.para) }); + } + } + let lines: Vec = l + .lines + .iter() + .enumerate() + .map(|(i, li)| { + let o = off.get(i).copied().flatten().unwrap_or(0.18 * (li.bottom - li.top)); + WarpLine { ink: ink.get(i).copied().flatten(), extent: li.extent, ref_y: li.baseline - o } + }) + .collect(); + let mapper = warp.mapper(&lines, l.inner); + let px = scale_of(m).max(1e-6) * mapper.zoom(); + let tol = (0.1 / px).clamp(1e-4, 2.0); + let seg = (2.0 / px).clamp(1e-3, 50.0); + let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { + let ps = para(d.para); + if !ps.visible || ps.opacity <= 0.001 { + return; + } + 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 d in l.decos.iter().filter(|d| d.behind) { + deco(ctx, d); + } + for it in &items { + let (run, ps) = (it.run, it.state); + if !ps.visible || ps.opacity <= 0.001 { + continue; + } + let alpha = run.alpha * ps.opacity; + if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + continue; + } + let path = warp_path(&it.path, tol, seg, &|p| mapper.map(it.line, p)); + ctx.set_transform(m * Affine::translate(ps.offset)); + if alpha > 0.0 && run.color.a > 0 { + ctx.set_paint(color(run.color, alpha)); + ctx.fill_path(&path); + if run.fake_bold { + ctx.set_stroke(kurbo::Stroke::new(run.size * 0.025).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + if let Some((oc, ow)) = run.outline { + ctx.set_paint(color(oc, ps.opacity)); + ctx.set_stroke(kurbo::Stroke::new(ow.max(0.0)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + for d in l.decos.iter().filter(|d| !d.behind) { + deco(ctx, d); + } + ctx.set_fill_rule(peniko::Fill::NonZero); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warp_path_subdivides_and_maps_every_point() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + p.line_to((10.0, 10.0)); + p.close_path(); + let out = warp_path(&p, 0.01, 1.0, &|q| Point::new(q.x, q.y + q.x * q.x * 0.01)); + // 10 + 10 + ~15 pieces of at most 1 pt. + let n = out.elements().len(); + assert!(n > 30, "{n}"); + // The first edge is bent onto the parabola. + let mid = out.elements().iter().find_map(|e| if let PathEl::LineTo(q) = e { (q.x == 5.0).then_some(*q) } else { None }); + assert_eq!(mid, Some(Point::new(5.0, 0.25))); + // A huge path with a tiny segment length stays bounded. + let mut big = BezPath::new(); + big.move_to((0.0, 0.0)); + for i in 0..1000 { + big.line_to((1e6 * (i % 2) as f64, i as f64)); + } + let out = warp_path(&big, 0.01, 1e-3, &|q| q); + assert!(out.elements().len() <= MAX_POINTS + 2); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 32ef2f1..7144c55 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1074,7 +1074,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), ); - let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); + // Text that follows a WordArt arch or circle is set on one line per paragraph. + let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); + let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1d87c9a..6a8c992 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -69,7 +69,7 @@ the round trip even though DeckCraft doesn't model it. | Effects: shadow, glow, soft edges, reflection | yes | yes | | | 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered | | Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields | -| Text warp (`prstTxWarp`) | yes | yes | Not rendered | +| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | @@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text warps rendered; their adjust values read and written | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | diff --git a/docs/gaps.md b/docs/gaps.md index 6ea4130..a13383a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,9 +165,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Estimate:** 4–6 h. ### Edit points, text warp, 3-D -- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms", "Bevel and 3-D - rotation" (missing; `scene3d`/`sp3d` kept as raw XML on round trip). -- **Estimate:** 4–6 h (edit points), 6–10 h (warp), 8–12 h (3-D render approximation). +- **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). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -247,5 +251,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8c7337f..bfd5c07 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 60 | 12 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 13 | 28 | 36% | +| P2 | 54 | 13 | 14 | 27 | 37% | | Area | Parity | |---|---| @@ -18,7 +18,7 @@ The summary between ` | Slides | 90% | | Views | 90% | | Masters & themes | 85% | -| Shapes | 86% | +| Shapes | 88% | | Format | 81% | | Arrange | 100% | | Text | 80% | @@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Shapes | Change shape | P1 | D | | Shapes | Text boxes | P0 | D | | Shapes | WordArt | P1 | P | -| Shapes | Text warp transforms | P2 | M | +| Shapes | Text warp transforms | P2 | P | | Shapes | Shape quick styles | P1 | D | | Shapes | Set as default shape | P2 | D | | Format | Solid fill and transparency | P0 | D |