Skip to main content

deckcraft_geom/
warp.rs

1//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`).
2//!
3//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored).
4//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band
5//! and so on). A band either
6//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the
7//!   band's lines fills the band vertically (a band whose ink is shorter than the tallest band's
8//!   keeps the same scale, centred), and horizontally the text is scaled so that the
9//!   widest band is filled along the mean length of its guides (one scale for the whole text), or
10//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles):
11//!   glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole
12//!   guide being the line's width: insets don't count) and stand on
13//!   the guide's left normal; lines further from the guide stack outwards. Lines that are longer
14//!   than the guide are shrunk to fit.
15//!
16//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and
17//! bottom guide's length and interpolates between them. The guide curves were derived from
18//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to
19//! the measured bar ends (most fit within a point on a 420 × 200 pt box).
20use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2};
21
22/// Flattening tolerance for guides, in points.
23const TOL: f64 = 0.02;
24
25/// A guide curve flattened to a polyline with cumulative arc lengths.
26#[derive(Clone, Debug, Default)]
27pub struct Guide {
28    pts: Vec<Point>,
29    cum: Vec<f64>,
30    closed: bool,
31}
32
33impl Guide {
34    fn from_points(raw: impl IntoIterator<Item = Point>, closed: bool) -> Guide {
35        let mut pts: Vec<Point> = Vec::new();
36        let mut cum: Vec<f64> = Vec::new();
37        for p in raw {
38            if !(p.x.is_finite() && p.y.is_finite()) {
39                continue;
40            }
41            match pts.last() {
42                Some(q) => {
43                    let d = (p - *q).hypot();
44                    if d > 1e-9 {
45                        cum.push(cum.last().copied().unwrap_or(0.0) + d);
46                        pts.push(p);
47                    }
48                }
49                None => {
50                    pts.push(p);
51                    cum.push(0.0);
52                }
53            }
54            if pts.len() > 100_000 {
55                break;
56            }
57        }
58        Guide { pts, cum, closed }
59    }
60
61    /// A guide through `path` (any curves), flattened.
62    pub fn from_path(path: &BezPath, closed: bool) -> Guide {
63        let mut pts = Vec::new();
64        kurbo::flatten(path.iter(), TOL, |el| match el {
65            PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
66            _ => {}
67        });
68        Guide::from_points(pts, closed)
69    }
70
71    /// Total arc length.
72    pub fn length(&self) -> f64 {
73        self.cum.last().copied().unwrap_or(0.0)
74    }
75
76    /// Whether the guide is a closed loop (positions past its end wrap around).
77    pub fn is_closed(&self) -> bool {
78        self.closed
79    }
80
81    /// Point and unit tangent at arc length `s`. Open guides continue along their end tangents
82    /// past either end; closed guides wrap.
83    pub fn at(&self, s: f64) -> (Point, Vec2) {
84        let len = self.length();
85        let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else {
86            return (Point::ZERO, Vec2::new(1.0, 0.0));
87        };
88        if self.pts.len() < 2 || len <= 1e-9 {
89            return (first, Vec2::new(1.0, 0.0));
90        }
91        let s = if s.is_finite() { s } else { 0.0 };
92        let s = if self.closed { s.rem_euclid(len) } else { s };
93        let seg = |i: usize| -> (Point, Point, f64) {
94            let a = self.pts.get(i).copied().unwrap_or(first);
95            let b = self.pts.get(i + 1).copied().unwrap_or(last);
96            (a, b, self.cum.get(i).copied().unwrap_or(0.0))
97        };
98        let n = self.pts.len();
99        let i = if s <= 0.0 {
100            0
101        } else if s >= len {
102            n - 2
103        } else {
104            self.cum
105                .partition_point(|c| *c <= s)
106                .saturating_sub(1)
107                .min(n - 2)
108        };
109        let (a, b, c0) = seg(i);
110        let d = b - a;
111        let l = d.hypot();
112        let t = if l > 1e-12 {
113            d / l
114        } else {
115            Vec2::new(1.0, 0.0)
116        };
117        (a + t * (s - c0), t)
118    }
119
120    /// The parallel curve `d` points away on the left of the direction of travel (above a
121    /// guide running left to right).
122    pub fn offset(&self, d: f64) -> Guide {
123        let n = self.pts.len();
124        if n < 2 || !d.is_finite() || d == 0.0 {
125            return self.clone();
126        }
127        let unit = |i: usize| -> Vec2 {
128            let (a, b) = (
129                self.pts.get(i).copied().unwrap_or(Point::ZERO),
130                self.pts.get(i + 1).copied().unwrap_or(Point::ZERO),
131            );
132            let v = b - a;
133            let l = v.hypot();
134            if l > 1e-12 { v / l } else { Vec2::ZERO }
135        };
136        let pts = (0..n).map(|i| {
137            let before = if i > 0 {
138                unit(i - 1)
139            } else if self.closed {
140                unit(n - 2)
141            } else {
142                Vec2::ZERO
143            };
144            let after = if i + 1 < n {
145                unit(i)
146            } else if self.closed {
147                unit(0)
148            } else {
149                Vec2::ZERO
150            };
151            let t = before + after;
152            let l = t.hypot();
153            let t = if l > 1e-12 {
154                t / l
155            } else {
156                Vec2::new(1.0, 0.0)
157            };
158            self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d
159        });
160        Guide::from_points(pts.collect::<Vec<_>>(), self.closed)
161    }
162
163    /// The point at fraction `t` of the length.
164    pub fn at_frac(&self, t: f64) -> Point {
165        self.at(t * self.length()).0
166    }
167}
168
169/// Which line of a follow band sits on its guide.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub enum LineAnchor {
172    /// The last line; earlier lines stack outwards (arch up, circle).
173    Last,
174    /// The first line; later lines stack below it (arch down).
175    First,
176    /// The middle of the band's lines.
177    Middle,
178}
179
180#[derive(Clone, Debug)]
181pub enum Band {
182    /// Text stretched between two guides.
183    Fill { top: Guide, bottom: Guide },
184    /// Text at its natural size along a guide.
185    Follow { path: Guide, anchor: LineAnchor },
186}
187
188impl Band {
189    /// The length text is measured against: the mean of a fill band's guides.
190    pub fn length(&self) -> f64 {
191        match self {
192            Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0,
193            Band::Follow { path, .. } => path.length(),
194        }
195    }
196}
197
198/// A resolved warp: its bands in shape-local points.
199#[derive(Clone, Debug)]
200pub struct Warp {
201    pub bands: Vec<Band>,
202}
203
204/// Presets whose text follows a guide at its natural size (no wrapping).
205pub fn is_follow(name: &str) -> bool {
206    matches!(
207        name,
208        "textArchUp" | "textArchDown" | "textCircle" | "textButton"
209    )
210}
211
212/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets.
213pub fn adjust_defaults(name: &str) -> &'static [f64] {
214    match name {
215        "textPlain" => &[50_000.0],
216        "textStop" => &[25_000.0],
217        "textTriangle" | "textTriangleInverted" => &[50_000.0],
218        "textChevron" => &[25_000.0],
219        "textChevronInverted" => &[75_000.0],
220        "textRingInside" | "textRingOutside" => &[60_000.0],
221        "textArchUp" | "textCircle" | "textButton" => &[10_800_000.0],
222        "textArchDown" => &[0.0],
223        "textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0],
224        "textArchDownPour" => &[0.0, 25_000.0],
225        "textCurveUp" | "textCurveDown" => &[45_977.0],
226        "textCanUp" => &[85_000.0],
227        "textCanDown" => &[14_286.0],
228        "textWave1" | "textWave2" => &[12_500.0, 0.0],
229        "textDoubleWave1" | "textWave4" => &[6_250.0, 0.0],
230        "textInflate" | "textDeflate" => &[18_750.0],
231        "textInflateBottom" => &[60_000.0],
232        "textDeflateBottom" | "textDeflateTop" => &[50_000.0],
233        "textInflateTop" => &[40_000.0],
234        "textDeflateInflate" => &[35_000.0],
235        "textDeflateInflateDeflate" => &[25_000.0],
236        "textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0],
237        "textSlantUp" => &[55_556.0],
238        "textSlantDown" => &[44_444.0],
239        "textCascadeUp" | "textCascadeDown" => &[44_444.0],
240        _ => &[],
241    }
242}
243
244/// Number of adjust values a warp preset takes.
245pub fn adjust_count(name: &str) -> usize {
246    adjust_defaults(name).len()
247}
248
249/// Whether `name` is a warp preset DeckCraft draws.
250pub fn is_supported(name: &str) -> bool {
251    !adjust_defaults(name).is_empty()
252}
253
254/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
255#[derive(Clone, Copy, Debug)]
256enum Seg {
257    M(f64, f64),
258    L(f64, f64),
259    Q(f64, f64, f64, f64),
260    C(f64, f64, f64, f64, f64, f64),
261    /// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise).
262    A(f64, f64, f64, f64, f64, f64),
263}
264
265enum Spec {
266    Fill(Vec<Seg>, Vec<Seg>),
267    Follow(Vec<Seg>, LineAnchor),
268}
269
270struct Adj<'a> {
271    raw: &'a [f64],
272    defaults: &'static [f64],
273}
274
275impl Adj<'_> {
276    fn raw(&self, i: usize) -> f64 {
277        self.raw
278            .get(i)
279            .copied()
280            .filter(|v| v.is_finite())
281            .or_else(|| self.defaults.get(i).copied())
282            .unwrap_or(0.0)
283    }
284    /// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions).
285    fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 {
286        (self.raw(i) / 100_000.0).clamp(lo, hi)
287    }
288    /// Adjust `i` as an angle in degrees (1/60 000), in 0..360.
289    fn angle(&self, i: usize) -> f64 {
290        (self.raw(i) / 60_000.0).rem_euclid(360.0)
291    }
292}
293
294fn line(pts: &[(f64, f64)]) -> Vec<Seg> {
295    pts.iter()
296        .enumerate()
297        .map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) })
298        .collect()
299}
300
301/// Quadratic from (0, y0) through control (0.5, c) to (1, y0).
302fn quad(y0: f64, c: f64) -> Vec<Seg> {
303    vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)]
304}
305
306/// A sweep in (0, 360]: zero means a full turn.
307fn turn(deg: f64) -> f64 {
308    let d = deg.rem_euclid(360.0);
309    if d < 1e-9 { 360.0 } else { d }
310}
311
312/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre.
313fn ellipse(s: f64, start: f64, sweep: f64) -> Vec<Seg> {
314    vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)]
315}
316
317/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise.
318fn arch_up(a: f64) -> (f64, f64) {
319    (a, turn(2.0 * (270.0 - a)))
320}
321
322/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise.
323fn arch_down(a: f64) -> (f64, f64) {
324    (180.0 - a, -turn(180.0 - 2.0 * a))
325}
326
327/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°.
328fn circle(a: f64) -> (f64, f64) {
329    (a, turn(2.0 * (360.0 - a)))
330}
331
332/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases).
333fn wave(out: &mut Vec<Seg>, x0: f64, x1: f64, y: f64, k: f64) {
334    let l = x1 - x0;
335    out.push(Seg::C(
336        x0 + l / 3.0,
337        y - k,
338        x0 + 2.0 * l / 3.0,
339        y + k,
340        x1,
341        y,
342    ));
343}
344
345/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction).
346fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec {
347    let d = 2.0 * shift;
348    let (top_x, bot_x) = if d >= 0.0 {
349        ((0.0, 1.0 - d), (d, 1.0))
350    } else {
351        ((-d, 1.0), (0.0, 1.0 + d))
352    };
353    let k = if up_first {
354        a * 10.0 / 3.0
355    } else {
356        -a * 10.0 / 3.0
357    };
358    let guide = |(x0, x1): (f64, f64), y: f64| {
359        let mut v = vec![Seg::M(x0, y)];
360        let n = periods.max(1) as f64;
361        for i in 0..periods.max(1) {
362            let f = i as f64;
363            wave(
364                &mut v,
365                x0 + (x1 - x0) * f / n,
366                x0 + (x1 - x0) * (f + 1.0) / n,
367                y,
368                k,
369            );
370        }
371        v
372    };
373    Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a))
374}
375
376fn spec(name: &str, a: &Adj) -> Option<Vec<Spec>> {
377    use Seg::*;
378    let fill = |t: Vec<Seg>, b: Vec<Seg>| vec![Spec::Fill(t, b)];
379    let top = || line(&[(0.0, 0.0), (1.0, 0.0)]);
380    let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]);
381    Some(match name {
382        "textPlain" => {
383            let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5);
384            if d < 0.0 {
385                fill(
386                    line(&[(-d, 0.0), (1.0, 0.0)]),
387                    line(&[(0.0, 1.0), (1.0 + d, 1.0)]),
388                )
389            } else {
390                fill(
391                    line(&[(0.0, 0.0), (1.0 - d, 0.0)]),
392                    line(&[(d, 1.0), (1.0, 1.0)]),
393                )
394            }
395        }
396        "textStop" => {
397            let y = a.frac(0, 0.14286, 0.5);
398            let t = 1.0 / 3.0;
399            fill(
400                line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]),
401                line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)]),
402            )
403        }
404        "textTriangle" => {
405            let y = a.frac(0, 0.0, 1.0);
406            fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom())
407        }
408        "textTriangleInverted" => {
409            let y = a.frac(0, 0.0, 1.0);
410            fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
411        }
412        "textChevron" => {
413            let y = a.frac(0, 0.0, 0.5);
414            fill(
415                line(&[(0.0, y), (0.5, 0.0), (1.0, y)]),
416                line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)]),
417            )
418        }
419        "textChevronInverted" => {
420            let y = a.frac(0, 0.5, 1.0);
421            fill(
422                line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]),
423                line(&[(0.0, y), (0.5, 1.0), (1.0, y)]),
424            )
425        }
426        "textFadeRight" => {
427            let y = a.frac(0, 0.0, 0.49999);
428            fill(
429                line(&[(0.0, 0.0), (1.0, y)]),
430                line(&[(0.0, 1.0), (1.0, 1.0 - y)]),
431            )
432        }
433        "textFadeLeft" => {
434            let y = a.frac(0, 0.0, 0.49999);
435            fill(
436                line(&[(0.0, y), (1.0, 0.0)]),
437                line(&[(0.0, 1.0 - y), (1.0, 1.0)]),
438            )
439        }
440        "textFadeUp" => {
441            let x = a.frac(0, 0.0, 0.49999);
442            fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom())
443        }
444        "textFadeDown" => {
445            let x = a.frac(0, 0.0, 0.49999);
446            fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)]))
447        }
448        "textSlantUp" => {
449            let y = a.frac(0, 0.44444, 1.0);
450            fill(
451                line(&[(0.0, y), (1.0, 0.0)]),
452                line(&[(0.0, 1.0), (1.0, 1.0 - y)]),
453            )
454        }
455        "textSlantDown" => {
456            let y = a.frac(0, 0.0, 0.55556);
457            fill(
458                line(&[(0.0, 0.0), (1.0, 1.0 - y)]),
459                line(&[(0.0, y), (1.0, 1.0)]),
460            )
461        }
462        "textCascadeUp" => {
463            let y = a.frac(0, 0.0, 1.0);
464            fill(
465                line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]),
466                line(&[(0.0, 1.0), (1.0, y)]),
467            )
468        }
469        "textCascadeDown" => {
470            let y = a.frac(0, 0.0, 1.0);
471            fill(
472                line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]),
473                line(&[(0.0, y), (1.0, 1.0)]),
474            )
475        }
476        "textInflate" => {
477            let y = a.frac(0, 0.0, 0.2);
478            fill(quad(y, -y), quad(1.0 - y, 1.0 + y))
479        }
480        "textDeflate" => {
481            let y = a.frac(0, 0.0, 0.375);
482            fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y))
483        }
484        "textInflateBottom" => {
485            let y = a.frac(0, 0.6, 1.0);
486            fill(top(), quad(y, 1.0))
487        }
488        "textDeflateBottom" => {
489            let y = a.frac(0, 0.0625, 1.0);
490            fill(top(), quad(1.0, 2.0 * y - 1.0))
491        }
492        "textInflateTop" => {
493            let y = a.frac(0, 0.0, 0.5);
494            fill(quad(y, 0.0), bottom())
495        }
496        "textDeflateTop" => {
497            let y = a.frac(0, 0.0, 0.9375);
498            fill(quad(0.0, 2.0 * y), bottom())
499        }
500        "textDeflateInflate" => {
501            // The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of
502            // a band's height.
503            let y = a.frac(0, 0.05, 0.95);
504            let g = 0.05;
505            vec![
506                Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)),
507                Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom()),
508            ]
509        }
510        "textDeflateInflateDeflate" => {
511            let y = a.frac(0, 0.01, 1.0 / 3.0);
512            let g = 1.0 / 30.0;
513            let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0);
514            let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2);
515            vec![
516                Spec::Fill(top(), quad(t1 - g, c1 - g)),
517                Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)),
518                Spec::Fill(quad(t2 + g, c2 + g), bottom()),
519            ]
520        }
521        "textCurveUp" | "textCurveDown" => {
522            let y = a.frac(0, 0.0, 0.56818);
523            let b = vec![
524                M(0.0, 1.0 - 3.0 * y / 8.0),
525                C(
526                    1.0 / 3.0,
527                    1.0 + y / 8.0,
528                    2.0 / 3.0,
529                    1.0 + y / 8.0,
530                    1.0,
531                    1.0 - 3.0 * y / 8.0,
532                ),
533            ];
534            let t = if name == "textCurveUp" {
535                vec![
536                    M(0.0, y),
537                    C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0),
538                ]
539            } else {
540                vec![
541                    M(0.0, 0.0),
542                    C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y),
543                ]
544            };
545            fill(t, b)
546        }
547        "textCanUp" => {
548            let r = 1.0 - a.frac(0, 0.5, 1.0);
549            fill(
550                vec![A(0.5, r, 0.5, r, 180.0, 180.0)],
551                vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)],
552            )
553        }
554        "textCanDown" => {
555            let r = a.frac(0, 0.0, 0.5);
556            fill(
557                vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)],
558                vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)],
559            )
560        }
561        "textRingInside" | "textRingOutside" => {
562            let h = a.frac(0, 0.5, 0.99);
563            let sweep = if name == "textRingInside" {
564                360.0
565            } else {
566                -360.0
567            };
568            fill(
569                vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)],
570                vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)],
571            )
572        }
573        "textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)],
574        "textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)],
575        "textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)],
576        "textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)],
577        "textArchUp" => {
578            let (s, w) = arch_up(a.angle(0));
579            vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
580        }
581        "textArchDown" => {
582            let (s, w) = arch_down(a.angle(0));
583            vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)]
584        }
585        "textCircle" => {
586            let (s, w) = circle(a.angle(0));
587            vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
588        }
589        "textButton" => {
590            let t = a.angle(0);
591            let (s, w) = arch_up(t);
592            let half = 0.5 * t.to_radians().cos().abs();
593            vec![
594                Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last),
595                Spec::Follow(
596                    line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]),
597                    LineAnchor::Middle,
598                ),
599                Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First),
600            ]
601        }
602        "textArchUpPour" => {
603            let (s, w) = arch_up(a.angle(0));
604            let r = a.frac(1, 0.0, 0.99);
605            fill(ellipse(1.0, s, w), ellipse(r, s, w))
606        }
607        "textArchDownPour" => {
608            let (s, w) = arch_down(a.angle(0));
609            let r = a.frac(1, 0.0, 0.99);
610            fill(ellipse(r, s, w), ellipse(1.0, s, w))
611        }
612        "textCirclePour" => {
613            let (s, w) = circle(a.angle(0));
614            let r = a.frac(1, 0.0, 0.99);
615            fill(ellipse(1.0, s, w), ellipse(r, s, w))
616        }
617        "textButtonPour" => {
618            let (s, w) = arch_up(a.angle(0));
619            let r = a.frac(1, 0.0, 0.99);
620            // The middle band runs between chords of the inner ellipse halfway to its top and
621            // bottom.
622            let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt());
623            vec![
624                Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)),
625                Spec::Fill(
626                    line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]),
627                    line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)]),
628                ),
629                Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)),
630            ]
631        }
632        _ => return None,
633    })
634}
635
636/// Build a guide from unit-space segments mapped onto `r`.
637fn guide(segs: &[Seg], r: Rect) -> Guide {
638    let mut p = BezPath::new();
639    let mut started = false;
640    let mut closed = false;
641    for s in segs {
642        match *s {
643            Seg::M(x, y) => {
644                p.move_to((x, y));
645                started = true;
646            }
647            Seg::L(x, y) => {
648                if started {
649                    p.line_to((x, y))
650                } else {
651                    p.move_to((x, y))
652                }
653                started = true;
654            }
655            Seg::Q(cx, cy, x, y) => {
656                if started {
657                    p.quad_to((cx, cy), (x, y));
658                }
659            }
660            Seg::C(x1, y1, x2, y2, x, y) => {
661                if started {
662                    p.curve_to((x1, y1), (x2, y2), (x, y));
663                }
664            }
665            Seg::A(cx, cy, rx, ry, start, sweep) => {
666                let arc = Arc::new(
667                    (cx, cy),
668                    (rx.max(0.0), ry.max(0.0)),
669                    start.to_radians(),
670                    sweep.to_radians(),
671                    0.0,
672                );
673                let s0 = Point::new(
674                    cx + rx * start.to_radians().cos(),
675                    cy + ry * start.to_radians().sin(),
676                );
677                if started {
678                    p.line_to(s0);
679                } else {
680                    p.move_to(s0);
681                }
682                started = true;
683                for el in arc.append_iter(1e-5) {
684                    p.push(el);
685                }
686                closed = sweep.abs() >= 359.999;
687            }
688        }
689    }
690    let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]);
691    p.apply_affine(m);
692    Guide::from_path(&p, closed)
693}
694
695/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults)
696/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect.
697pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option<Warp> {
698    if ![rect.x0, rect.y0, rect.x1, rect.y1]
699        .iter()
700        .all(|v| v.is_finite())
701    {
702        return None;
703    }
704    let r = Rect::new(
705        rect.x0.min(rect.x1),
706        rect.y0.min(rect.y1),
707        rect.x0.max(rect.x1),
708        rect.y0.max(rect.y1),
709    );
710    let a = Adj {
711        raw: adj,
712        defaults: adjust_defaults(name),
713    };
714    let specs = spec(name, &a)?;
715    let bands = specs
716        .into_iter()
717        .map(|s| match s {
718            Spec::Fill(t, b) => Band::Fill {
719                top: guide(&t, r),
720                bottom: guide(&b, r),
721            },
722            Spec::Follow(p, anchor) => Band::Follow {
723                path: guide(&p, r),
724                anchor,
725            },
726        })
727        .collect();
728    Some(Warp { bands })
729}
730
731/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines
732/// share the first band, and so on.
733pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize {
734    if bands <= 1 || lines == 0 {
735        return 0;
736    }
737    // Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k).
738    (0..bands)
739        .rev()
740        .find(|&b| (b * lines).div_ceil(bands) <= line)
741        .unwrap_or(0)
742}
743
744/// One laid-out line as the warp sees it (layout coordinates, points).
745#[derive(Clone, Copy, Debug)]
746pub struct WarpLine {
747    /// Ink box of the line's glyphs; `None` for a line without ink.
748    pub ink: Option<Rect>,
749    /// Advance extent of the line (left, right).
750    pub extent: (f64, f64),
751    /// Reference height for follow bands: the y that sits on the guide.
752    pub ref_y: f64,
753}
754
755#[derive(Clone, Copy, Debug)]
756enum LineMap {
757    Fill {
758        band: usize,
759        cx: f64,
760        k: f64,
761        len: f64,
762        y0: f64,
763        y1: f64,
764    },
765    /// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's.
766    Follow {
767        band: usize,
768        guide: Option<usize>,
769        e0: f64,
770        s0: f64,
771        k: f64,
772        ref_y: f64,
773    },
774}
775
776/// Maps points of laid-out lines onto a warp.
777#[derive(Clone, Debug)]
778pub struct Mapper<'a> {
779    warp: &'a Warp,
780    lines: Vec<LineMap>,
781    offsets: Vec<Guide>,
782    zoom: f64,
783}
784
785impl Warp {
786    /// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in
787    /// it gives its alignment along follow guides; the insets themselves don't shorten a guide).
788    pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> {
789        let nb = self.bands.len().max(1);
790        let n = lines.len();
791        let band_of: Vec<usize> = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect();
792        // Ink of the whole text, for the shared horizontal centre and scale of fill bands.
793        let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b));
794        let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0);
795        let mut k_fill = f64::INFINITY;
796        let mut zoom: f64 = 1.0;
797        let mut band_y: Vec<Option<(f64, f64)>> = vec![None; nb];
798        for (i, l) in lines.iter().enumerate() {
799            let Some(r) = l.ink else { continue };
800            let b = band_of.get(i).copied().unwrap_or(0);
801            if let Some(slot) = band_y.get_mut(b) {
802                *slot = Some(match *slot {
803                    Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)),
804                    None => (r.y0, r.y1),
805                });
806            }
807        }
808        // Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so
809        // a band with fewer lines keeps them at the same size, centred between its guides.
810        let tallest = band_y
811            .iter()
812            .flatten()
813            .map(|(y0, y1)| y1 - y0)
814            .fold(0.0, f64::max);
815        let band_y: Vec<Option<(f64, f64)>> = band_y
816            .iter()
817            .map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0)))
818            .collect();
819        for (b, band) in self.bands.iter().enumerate() {
820            if let Band::Fill { top, bottom } = band {
821                let half = lines
822                    .iter()
823                    .enumerate()
824                    .filter(|(i, _)| band_of.get(*i) == Some(&b))
825                    .filter_map(|(_, l)| l.ink)
826                    .map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs()))
827                    .fold(0.0, f64::max);
828                if half > 1e-9 {
829                    k_fill = k_fill.min(band.length() / (2.0 * half));
830                }
831                if let Some(Some((y0, y1))) = band_y.get(b) {
832                    let thick = [0.0, 0.25, 0.5, 0.75, 1.0]
833                        .iter()
834                        .map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot())
835                        .fold(0.0, f64::max);
836                    if y1 - y0 > 1e-9 {
837                        zoom = zoom.max(thick / (y1 - y0));
838                    }
839                }
840            }
841        }
842        if !k_fill.is_finite() {
843            k_fill = 1.0;
844        }
845        zoom = zoom.max(k_fill);
846        let mut maps = Vec::with_capacity(n);
847        let mut offsets = Vec::new();
848        for (i, l) in lines.iter().enumerate() {
849            let b = band_of.get(i).copied().unwrap_or(0);
850            let in_band = |j: &usize| band_of.get(*j) == Some(&b);
851            match self.bands.get(b) {
852                Some(Band::Fill { .. }) | None => {
853                    let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
854                    let len = self.bands.get(b).map(Band::length).unwrap_or(0.0);
855                    maps.push(LineMap::Fill {
856                        band: b,
857                        cx,
858                        k: k_fill,
859                        len,
860                        y0,
861                        y1,
862                    });
863                }
864                Some(Band::Follow { path, anchor }) => {
865                    let len = path.length();
866                    let widest = (0..n)
867                        .filter(in_band)
868                        .filter_map(|j| lines.get(j))
869                        .map(|l| l.extent.1 - l.extent.0)
870                        .fold(0.0, f64::max);
871                    let k = if widest > len && widest > 1e-9 {
872                        len / widest
873                    } else {
874                        1.0
875                    };
876                    let members: Vec<&WarpLine> = (0..n)
877                        .filter(in_band)
878                        .filter_map(|j| lines.get(j))
879                        .collect();
880                    let ref_y = match anchor {
881                        LineAnchor::Last => members.last().map(|l| l.ref_y),
882                        LineAnchor::First => members.first().map(|l| l.ref_y),
883                        LineAnchor::Middle => match (members.first(), members.last()) {
884                            (Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
885                            _ => None,
886                        },
887                    }
888                    .unwrap_or(l.ref_y);
889                    // Lines off the guide run on a parallel curve at their natural length.
890                    let d = (ref_y - l.ref_y) * k;
891                    let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 {
892                        let g = path.offset(d);
893                        let len = g.length();
894                        offsets.push(g);
895                        (Some(offsets.len() - 1), len)
896                    } else {
897                        (None, len)
898                    };
899                    let (e0, e1) = l.extent;
900                    let w = (e1 - e0).max(0.0) * k;
901                    let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0));
902                    let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 };
903                    let s0 = f * (len - w);
904                    maps.push(LineMap::Follow {
905                        band: b,
906                        guide,
907                        e0,
908                        s0,
909                        k,
910                        ref_y: l.ref_y,
911                    });
912                }
913            }
914        }
915        Mapper {
916            warp: self,
917            lines: maps,
918            offsets,
919            zoom: if zoom.is_finite() {
920                zoom.clamp(1.0, 1.0e4)
921            } else {
922                1.0
923            },
924        }
925    }
926}
927
928impl Mapper<'_> {
929    /// How much the warp magnifies text at most (for flattening tolerances), ≥ 1.
930    pub fn zoom(&self) -> f64 {
931        self.zoom
932    }
933
934    /// Map point `p` of line `line` (layout coordinates) onto the warp.
935    pub fn map(&self, line: usize, p: Point) -> Point {
936        let out = match self.lines.get(line) {
937            Some(&LineMap::Fill {
938                band,
939                cx,
940                k,
941                len,
942                y0,
943                y1,
944            }) => {
945                let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else {
946                    return p;
947                };
948                let u = if len > 1e-9 {
949                    0.5 + (p.x - cx) * k / len
950                } else {
951                    0.5
952                };
953                let v = if y1 - y0 > 1e-9 {
954                    (p.y - y0) / (y1 - y0)
955                } else {
956                    0.5
957                };
958                let (t, b) = (top.at_frac(u), bottom.at_frac(u));
959                t.lerp(b, v)
960            }
961            Some(&LineMap::Follow {
962                band,
963                guide,
964                e0,
965                s0,
966                k,
967                ref_y,
968            }) => {
969                let path = match (
970                    guide.and_then(|g| self.offsets.get(g)),
971                    self.warp.bands.get(band),
972                ) {
973                    (Some(g), _) => g,
974                    (None, Some(Band::Follow { path, .. })) => path,
975                    _ => return p,
976                };
977                let s = s0 + (p.x - e0) * k;
978                let d = (ref_y - p.y) * k;
979                let (q, t) = path.at(s);
980                q + Vec2::new(t.y, -t.x) * d
981            }
982            None => p,
983        };
984        if out.x.is_finite() && out.y.is_finite() {
985            out
986        } else {
987            p
988        }
989    }
990}
991
992#[cfg(test)]
993mod tests;