Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 d78522469b Bullets: an installed symbol font draws its own glyph
Bullets in Wingdings, Wingdings 2/3, Symbol and other symbol fonts were
always swapped for a look-alike from one Wingdings table, so a Wingdings 2
star (0xEA) became a round dot. When the deck's symbol font is installed,
its glyph is drawn (symbol fonts map character codes at U+F000 + code), as
PowerPoint does; otherwise Wingdings 2 and 3 codes get their own
look-alikes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:05:17 -04:00
21 changed files with 25 additions and 2531 deletions
-1
View File
@@ -128,7 +128,6 @@ 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 |
-1
View File
@@ -8,7 +8,6 @@
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};
+1 -46
View File
@@ -9,8 +9,6 @@
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
use serde::{Deserialize, Serialize};
mod extra;
/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -150,7 +148,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("bentConnector3", "Connector: Elbow", Lines, [50000]),
p!("curvedConnector3", "Connector: Curved", Lines, [50000]),
p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []),
p!("lineInv", "Line: Rising", Lines, []),
p!("rect", "Rectangle", Rectangles, []),
p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]),
p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]),
@@ -166,7 +163,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("rtTriangle", "Right Triangle", BasicShapes, []),
p!("parallelogram", "Parallelogram", BasicShapes, [25000]),
p!("trapezoid", "Trapezoid", BasicShapes, [25000]),
p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]),
p!("diamond", "Diamond", BasicShapes, []),
p!("pentagon", "Regular Pentagon", BasicShapes, []),
p!("hexagon", "Hexagon", BasicShapes, [25000]),
@@ -175,13 +171,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("decagon", "Decagon", BasicShapes, []),
p!("dodecagon", "Dodecagon", BasicShapes, []),
p!("pie", "Partial Circle", BasicShapes, [0, 16200000]),
p!("pieWedge", "Quarter Wedge", BasicShapes, []),
p!("chord", "Chord", BasicShapes, [2700000, 16200000]),
p!("teardrop", "Teardrop", BasicShapes, [100000]),
p!("frame", "Frame", BasicShapes, [12500]),
p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []),
p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []),
p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []),
p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]),
p!("corner", "L-Shape", BasicShapes, [50000, 50000]),
p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]),
@@ -200,9 +192,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("sun", "Sun", BasicShapes, [25000]),
p!("moon", "Moon", BasicShapes, [50000]),
p!("cloud", "Cloud", BasicShapes, []),
p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]),
p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]),
p!("funnel", "Funnel", BasicShapes, []),
p!("arc", "Arc", BasicShapes, [16200000, 0]),
p!("bracketPair", "Double Bracket", BasicShapes, [16667]),
p!("bracePair", "Double Brace", BasicShapes, [8333]),
@@ -210,9 +199,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("rightBracket", "Right Bracket", BasicShapes, [8333]),
p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]),
p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]),
p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []),
p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []),
p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []),
p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]),
p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]),
p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]),
@@ -223,27 +209,16 @@ pub static CATALOG: &[PresetInfo] = &[
p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]),
p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]),
p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]),
p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]),
p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]),
p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]),
p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]),
p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]),
p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]),
p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]),
p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]),
p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]),
p!("chevron", "Arrow: Chevron", BlockArrows, [50000]),
p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]),
p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]),
p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]),
p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]),
p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]),
p!("mathPlus", "Plus Sign", EquationShapes, [23520]),
p!("mathMinus", "Minus Sign", EquationShapes, [23520]),
p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]),
@@ -278,7 +253,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []),
p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []),
p!("flowChartDisplay", "Flowchart: Display", Flowchart, []),
p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []),
p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []),
p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []),
p!("star4", "Star: 4 Points", StarsAndBanners, [12500]),
@@ -293,9 +267,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("star32", "Star: 32 Points", StarsAndBanners, [37500]),
p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]),
p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]),
p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]),
p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]),
p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]),
p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]),
p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]),
p!("wave", "Wave", StarsAndBanners, [12500, 0]),
@@ -305,22 +276,6 @@ pub static CATALOG: &[PresetInfo] = &[
p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]),
p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]),
p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]),
p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]),
p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!(
"accentBorderCallout3",
"Callout: Double Bent Line with Border and Accent Bar",
Callouts,
[18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]
),
p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []),
p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []),
p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []),
@@ -1580,7 +1535,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
}
_ => return extra::build(name, w, h, &a),
_ => return None,
};
Some(g)
}
-796
View File
@@ -1,796 +0,0 @@
//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
//! construction, fitted to how the shapes look and to what their adjust values mean.
use super::*;
/// Builds the presets this module knows; `None` for any other name.
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
let ss = w.min(h);
let full = Rect::new(0.0, 0.0, w, h);
let mut b = Pb::new();
let g = match name {
"lineInv" => {
let l = b.m(0.0, h).l(w, 0.0).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] }
}
"nonIsoscelesTrapezoid" => {
let max = 50000.0 * w / ss.max(1e-9);
let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max));
let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5);
let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h);
g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max));
g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max));
g
}
"pieWedge" => {
b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z();
simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h)
}
"flowChartOfflineStorage" => {
let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take();
let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take();
Geometry {
paths: vec![fill(tri), stroke_only(bar)],
text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8),
handles: vec![],
sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)],
}
}
"chartX" | "chartStar" | "chartPlus" => {
let body = corner_rect(w, h, [Corner::Square; 4]);
let mut l = Pb::new();
if name != "chartPlus" {
l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0);
}
if name != "chartX" {
l.m(w / 2.0, 0.0).l(w / 2.0, h);
}
if name == "chartPlus" {
l.m(0.0, h / 2.0).l(w, h / 2.0);
}
Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
}
"cornerTabs" | "squareTabs" | "plaqueTabs" => {
// Each tab's size is a twentieth of the diagonal.
let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0);
match name {
"cornerTabs" => {
b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]);
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]);
b.poly(&[(w, h - d), (w, h), (w - d, h)]);
b.poly(&[(0.0, h - d), (d, h), (0.0, h)]);
}
"squareTabs" => {
b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]);
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]);
b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]);
b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]);
}
_ => {
if d > 0.0 {
b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z();
b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z();
b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z();
b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z();
} else {
b.m(0.0, 0.0).z();
}
}
}
Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) }
}
"callout1"
| "callout2"
| "callout3"
| "accentCallout1"
| "accentCallout2"
| "accentCallout3"
| "borderCallout2"
| "borderCallout3"
| "accentBorderCallout1"
| "accentBorderCallout2"
| "accentBorderCallout3" => {
let n = match name.chars().last() {
Some('2') => 2,
Some('3') => 3,
_ => 1,
};
line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order"))
}
"leftRightArrowCallout" | "upDownArrowCallout" => {
let vertical = name == "upDownArrowCallout";
let (l, t) = if vertical { (h, w) } else { (w, h) };
let ss = l.min(t);
let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0);
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0);
let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl);
let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0);
let pts = [
(0.0, c),
(hl, c - head),
(hl, c - shaft / 2.0),
(b0, c - shaft / 2.0),
(b0, 0.0),
(b1, 0.0),
(b1, c - shaft / 2.0),
(l - hl, c - shaft / 2.0),
(l - hl, c - head),
(l, c),
(l - hl, c + head),
(l - hl, c + shaft / 2.0),
(b1, c + shaft / 2.0),
(b1, t),
(b0, t),
(b0, c + shaft / 2.0),
(hl, c + shaft / 2.0),
(hl, c + head),
];
let d = if vertical { Dir::Down } else { Dir::Right };
let pts = orient(d, w, h, &pts);
let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]);
simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h)
}
"quadArrowCallout" => {
let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0);
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0);
let k = a(3).clamp(0.0, 100000.0) / 1e5;
let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft));
let (cx, cy) = (w / 2.0, h / 2.0);
let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0);
let (s2, hd) = (shaft / 2.0, head);
let pts = [
(0.0, cy),
(hl, cy - hd),
(hl, cy - s2),
(bx0, cy - s2),
(bx0, by0),
(cx - s2, by0),
(cx - s2, hl),
(cx - hd, hl),
(cx, 0.0),
(cx + hd, hl),
(cx + s2, hl),
(cx + s2, by0),
(bx1, by0),
(bx1, cy - s2),
(w - hl, cy - s2),
(w - hl, cy - hd),
(w, cy),
(w - hl, cy + hd),
(w - hl, cy + s2),
(bx1, cy + s2),
(bx1, by1),
(cx + s2, by1),
(cx + s2, h - hl),
(cx + hd, h - hl),
(cx, h),
(cx - hd, h - hl),
(cx - s2, h - hl),
(cx - s2, by1),
(bx0, by1),
(bx0, cy + s2),
(hl, cy + s2),
(hl, cy + hd),
];
simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h)
}
"curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)),
"swooshArrow" => swoosh(w, h, a(0), a(1)),
"ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)),
"leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)),
"gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)),
"funnel" => funnel(w, h),
"circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]),
_ => return None,
};
Some(g)
}
/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse
/// `(rx, ry)`, its edges `th/2` either side.
struct Ring {
c: Point,
rx: f64,
ry: f64,
th: f64,
hw: f64,
}
impl Ring {
/// The point at visual angle `deg` on the ellipse grown by `d`.
fn at(&self, deg: f64, d: f64) -> Point {
let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0));
let t = vis(deg, ax, ay).to_radians();
Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin())
}
/// Parametric angle (degrees) of `p` on the ellipse grown by `d`.
fn param(&self, p: Point, d: f64) -> f64 {
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees()
}
/// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b`
/// on the side `sign` (+1 outwards, −1 inwards).
fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point {
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay);
let (ux, uy) = (u.x / ax, u.y / ay);
let qa = ux * ux + uy * uy;
let qb = 2.0 * (px * ux + py * uy);
let qc = px * px + py * py - 1.0;
let disc = qb * qb - 4.0 * qa * qc;
if qa < 1e-18 || disc < 0.0 {
// The line misses (a long head on a thin ring): the edge then carries on from the
// base centre itself, as the reference does.
return b;
}
let r = disc.sqrt();
let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa));
let s = if sign > 0.0 {
if s0 >= 0.0 { s0 } else { s1 }
} else if s1 <= 0.0 {
s1
} else {
s0
};
b + u * s
}
/// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip
/// sits there at `base + sweep`. The base runs along the line from the centre to the tip.
/// Returns (band outer edge, outer wing, tip, inner wing, band inner edge).
fn head(&self, base: f64, sweep: f64) -> [Point; 5] {
let b = self.at(base, 0.0);
let tip = self.at(base + sweep, 0.0);
let d = tip - self.c;
let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) };
let half = self.th / 2.0;
[self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)]
}
}
/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`.
fn turn(a: f64, b: f64, dir: f64) -> f64 {
if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) }
}
/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise,
/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`).
/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width.
fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry {
let ss = w.min(h);
if ss <= 0.0 {
return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h);
}
let a5 = a[4].clamp(0.0, 25000.0);
let hw = ss * a5 / 1e5;
let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5;
let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw };
let deg = |v: f64| (v / 60000.0) % 360.0;
let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3]));
let half = th / 2.0;
let mut b = Pb::new();
// Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in
// the degenerate case above, where the arc shifts with it like the reference's).
let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| {
let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0));
let (s, e) = (ring.param(from, d), ring.param(to, d));
b.arc(ax, ay, s, turn(s, e, dir));
};
let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir);
let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir);
if name == "leftRightCircularArrow" {
let hs = ring.head(start, -sweep);
let he = ring.head(end, sweep);
b.m(hs[0].x, hs[0].y);
outer_arc(&mut b, hs[0], he[0], 1.0);
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
inner_arc(&mut b, he[4], hs[4], -1.0);
b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z();
} else {
let dir = if name == "circularArrow" { 1.0 } else { -1.0 };
let he = ring.head(end, sweep);
let (o0, i0) = (ring.at(start, half), ring.at(start, -half));
b.m(o0.x, o0.y);
outer_arc(&mut b, o0, he[0], dir);
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
inner_arc(&mut b, he[4], i0, -dir);
b.z();
}
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
}
/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides
/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed
/// angles, fitted to the reference look.
fn funnel(w: f64, h: f64) -> Geometry {
const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse
const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse
let ss = w.min(h);
let (cx, rim_y) = (w / 2.0, h / 4.0);
let (sx, sy) = (w / 8.0, h / 15.0);
let spout_y = h - sy;
let at = |rx: f64, ry: f64, cy: f64, deg: f64| {
let t = deg.to_radians();
Point::new(cx + rx * t.cos(), cy + ry * t.sin())
};
let p0 = at(cx, rim_y, rim_y, RIM);
let p1 = at(sx, sy, spout_y, 180.0 - SPOUT);
let mut b = Pb::new();
b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM);
b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z();
let d = ss / 20.0;
b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0));
Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) }
}
/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves
/// the body at two points an equal angle either side of its centre, and its tip corners sit a
/// tooth height further out along the body's normal and a flank lean further in.
fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
let ss = w.min(h);
let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height
let lean_max = if n == 6 { 5380.0 } else { 2730.0 };
let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean
let (cx, cy) = (w / 2.0, h / 2.0);
let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0));
let r = (ss / 2.0 - th).max(1e-9);
let half = ((th + l) / 2.0 / r).atan();
let step = std::f64::consts::TAU / n as f64;
let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin());
let mut b = Pb::new();
for k in 0..n {
let t = -std::f64::consts::FRAC_PI_2 + step * k as f64;
// The tooth stands along the body's normal at its centre.
let nrm = Vec2::new(t.cos() * ry, t.sin() * rx);
let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) };
let v = Vec2::new(-u.y, u.x);
let (r0, r1) = (on_body(t - half), on_body(t + half));
let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l);
if k == 0 {
b.m(r0.x, r0.y);
} else {
b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees());
}
b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y);
}
let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64;
b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z();
let k = std::f64::consts::FRAC_1_SQRT_2;
let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k);
simple(b.take(), text, w, h)
}
/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low,
/// and the band rolls over between them in a shaded half-ellipse curl.
fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let ss = w.min(h);
let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves
let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness
let cx = w / 2.0;
let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0));
let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length
let yc = (h - sh) / 2.0;
let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0);
let (rt, rb) = (lt + sh, lb + sh);
// The left half: filled as a closed outline, stroked only where it can show.
let mut edge = Pb::new();
edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt);
edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0);
let edge = edge.take();
let mut left = Pb::new();
left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0);
left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z();
let left = left.take();
let mut curl = Pb::new();
curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z();
let mut right = Pb::new();
right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0);
right.l(w - hb, rt).l(w - hb, sh).z();
Geometry {
paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())],
text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)),
handles: vec![],
sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)],
}
}
/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at
/// `xa`) to `xb`, as one quadratic curve.
fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) {
let hw = (w / 2.0).max(1e-9);
let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2));
let slope = -2.0 * d * (xa - hw) / (hw * hw);
b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb));
}
/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends
/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically.
fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre
// Sag of every edge: never more than the drop, never so little that the band would rise
// above the ends by more than half their height.
let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th);
let bw = w * a1.clamp(25000.0, 75000.0) / 1e5;
let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0);
let ear = w / 8.0;
let (xf, xg) = (x0 + ear, x1 - ear);
let e = h - th; // band height
let off = th - d; // band top below the ends' top
let t = |x: f64| {
let hw = (w / 2.0).max(1e-9);
d * (1.0 - ((x - hw) / hw).powi(2))
};
let mut ends = Pb::new();
ends.m(0.0, 0.0);
sag(&mut ends, w, d, 0.0, 0.0, xf);
ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e);
sag(&mut ends, w, d, e, x0, 0.0);
ends.l(ear, t(ear) + e / 2.0).z();
ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e);
sag(&mut ends, w, d, e, w, x1);
ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg));
sag(&mut ends, w, d, 0.0, xg, w);
ends.z();
let mut folds = Pb::new();
folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]);
folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]);
let mut band = Pb::new();
band.m(x0, t(x0) + off);
sag(&mut band, w, d, off, x0, x1);
band.l(x1, t(x1) + off + e);
sag(&mut band, w, d, off + e, x1, x0);
band.z();
let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())];
let mut text = Rect::new(x0, th, x1, t(x0) + off + e);
if up {
let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]);
for p in &mut paths {
p.path.apply_affine(m);
}
text = m.transform_rect_bbox(text);
}
Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) }
}
/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves
/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter
/// side above and below the band. The tilt and the tip's lift were fitted to the reference look.
fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
const TILT: f64 = 0.1126; // tan of the head base's lean from vertical
const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height
let ss = w.min(h);
let e = ss / 8.0;
let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0));
// The head is at least long enough to keep its leaning base inside the frame.
let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0));
let qx = w - hl;
let mut b = Pb::new();
b.m(0.0, h)
.q(w / 6.0, h / 3.0, qx, e)
.l(qx - e * TILT, 0.0)
.l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th))
.l((qx + (e + th) * TILT).min(w), 2.0 * e + th)
.l((qx + th * TILT).min(w), e + th)
.q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h)
.z();
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
}
/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right
/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions.
///
/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the
/// head side's edge, its horizontal radius is the full length, and the band has a constant
/// vertical thickness. The back half (shaded) starts flat against the head side; the front half
/// runs to the head, centred on the band where it ends.
fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow");
let (cw, ch) = if along_x { (w, h) } else { (h, w) };
let ss = cw.min(ch);
let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0);
let th = (ss * a0.max(0.0) / 1e5).min(head);
let hl = (ss * a2.max(0.0) / 1e5).min(cw);
let hh = head / 2.0;
// The band's centre line, carried on to the head side, ends at the tip, which sits half a
// head width above the bottom.
let r = ((ch - hh - th / 2.0) / 2.0).max(0.0);
// Parametric angle where the front band reaches the head base.
let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 };
let mut back = Pb::new();
back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z();
let mut front = Pb::new();
front.m(0.0, r).arc(cw, r, 180.0, te - 180.0);
let yb = front.cur.y;
let yc = yb + th / 2.0;
front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z();
let m = match name {
"curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]),
"curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]),
"curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]),
_ => kurbo::Affine::IDENTITY,
};
let (mut back, mut front) = (back.take(), front.take());
back.apply_affine(m);
front.apply_affine(m);
Geometry {
paths: vec![shade(back, FillMode::DarkenLess), fill(front)],
text_rect: Rect::new(0.0, 0.0, w, h),
handles: vec![],
sites: mid_sites(w, h),
}
}
/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of
/// the height and width), optionally with an accent bar at the first point's x and a border.
fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry {
let body = corner_rect(w, h, [Corner::Square; 4]);
// Leaders may reach well outside the box, but not without bound.
let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5);
let mut l = Pb::new();
let (x0, y0) = pt(0);
l.m(x0, y0);
for i in 1..pts {
let (x, y) = pt(i);
l.l(x, y);
}
let mut paths = vec![if border { fill(body) } else { fill_only(body) }];
if accent {
paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take()));
}
paths.push(stroke_only(l.take()));
Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) }
}
#[cfg(test)]
mod tests {
use super::super::{CATALOG, FillMode, build, info};
use kurbo::{BezPath, PathEl, Point, Shape};
const NEW: [&str; 38] = [
"lineInv",
"nonIsoscelesTrapezoid",
"pieWedge",
"leftRightArrowCallout",
"upDownArrowCallout",
"quadArrowCallout",
"circularArrow",
"leftCircularArrow",
"leftRightCircularArrow",
"curvedRightArrow",
"curvedLeftArrow",
"curvedUpArrow",
"curvedDownArrow",
"swooshArrow",
"callout1",
"callout2",
"callout3",
"accentCallout1",
"accentCallout2",
"accentCallout3",
"borderCallout2",
"borderCallout3",
"accentBorderCallout1",
"accentBorderCallout2",
"accentBorderCallout3",
"ellipseRibbon",
"ellipseRibbon2",
"leftRightRibbon",
"flowChartOfflineStorage",
"gear6",
"gear9",
"funnel",
"cornerTabs",
"squareTabs",
"plaqueTabs",
"chartX",
"chartStar",
"chartPlus",
];
fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath {
build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default()
}
/// Distance from `p` to the nearest point of the (flattened) outline.
fn dist(path: &BezPath, p: Point) -> f64 {
let mut best = f64::MAX;
let mut prev = Point::ZERO;
let mut start = Point::ZERO;
kurbo::flatten(path.iter(), 0.01, |el| {
let (a, b) = match el {
PathEl::MoveTo(q) => {
prev = q;
start = q;
return;
}
PathEl::LineTo(q) => (prev, q),
PathEl::ClosePath => (prev, start),
_ => return,
};
let d = b - a;
let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
best = best.min((a + d * t - p).hypot());
prev = b;
});
best
}
/// Asserts every point lies within 1pt of the outline.
fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) {
let path = outline(name, w, h, adj);
for &(x, y) in pts {
let d = dist(&path, Point::new(x, y));
assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline");
}
}
#[test]
fn new_presets_are_in_the_catalog() {
for n in NEW {
let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog"));
assert!(!i.label.is_empty());
}
assert!(CATALOG.len() >= 183);
}
#[test]
fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() {
let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)];
let adjusts: [&[f64]; 8] = [
&[],
&[0.0; 8],
&[100000.0; 8],
&[-50000.0; 8],
&[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12],
&[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN],
&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0],
&[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0],
];
for n in NEW {
// Line callouts' leaders may reach anywhere, by design.
let callout = n.contains("allout") && !n.contains("Arrow");
for (w, h) in sizes {
for adj in adjusts {
let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build"));
assert!(!g.paths.is_empty(), "{n} has no paths");
for s in &g.paths {
let bb = s.path.bounding_box();
assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}");
if !callout {
// Circular arrows with a head longer than their ring can hold carry the
// inner edge on from the head, as the reference does, so may spill.
let spill = if n.contains("ircular") { 0.5 } else { 0.01 };
let tol = 0.5 + w.max(h) * spill;
assert!(
bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol,
"{n} {w}x{h} {adj:?} out of bounds: {bb:?}"
);
}
}
let t = g.text_rect;
assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}");
}
}
let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}"));
assert!(g.outline().elements().len() >= 2, "{n} default outline is empty");
}
}
// The spot checks below are outline points measured on reference renders.
#[test]
fn simple_new_presets_match_reference_points() {
on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]);
on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]);
on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]);
on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]);
on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]);
on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]);
on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]);
on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]);
on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]);
on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]);
// Chart markers and plain callouts leave the box unstroked.
for n in ["chartX", "callout1", "accentCallout2"] {
let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}"));
assert!(!g.paths[0].stroke, "{n}");
}
}
#[test]
fn line_callouts_follow_their_adjusts() {
on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]);
let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0];
let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3"));
assert_eq!(g.paths.len(), 3);
assert!(g.paths[0].stroke);
let accent = g.paths[1].path.bounding_box();
assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}");
on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]);
}
#[test]
fn arrow_callouts_match_reference_points() {
on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]);
on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]);
on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]);
on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]);
}
#[test]
fn curved_arrows_match_reference_points() {
on(
"curvedRightArrow",
480.0,
300.0,
&[],
&[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)],
);
on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]);
// The band never outgrows the head; the head never outgrows half the frame.
on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]);
on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]);
on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]);
on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]);
let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow"));
assert_eq!(g.paths[0].fill, FillMode::DarkenLess);
}
#[test]
fn swoosh_matches_reference_points() {
on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]);
on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]);
on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]);
}
#[test]
fn ribbons_match_reference_points() {
on(
"ellipseRibbon",
480.0,
300.0,
&[],
&[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)],
);
on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]);
on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]);
on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]);
on(
"leftRightRibbon",
480.0,
300.0,
&[],
&[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)],
);
on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]);
}
#[test]
fn gears_and_funnel_match_reference_points() {
// Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3.
on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]);
on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]);
on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]);
on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]);
let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel"));
assert!(g.paths[0].even_odd);
}
#[test]
fn circular_arrows_match_reference_points() {
on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]);
on(
"circularArrow",
300.0,
300.0,
&[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0],
&[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)],
);
on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]);
on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]);
// The band never grows past twice the head's half-width.
on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]);
}
}
-773
View File
@@ -1,773 +0,0 @@
//! 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<Point>,
cum: Vec<f64>,
closed: bool,
}
impl Guide {
fn from_points(raw: impl IntoIterator<Item = Point>, closed: bool) -> Guide {
let mut pts: Vec<Point> = Vec::new();
let mut cum: Vec<f64> = 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::<Vec<_>>(), 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<Band>,
}
/// 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<Seg>, Vec<Seg>),
Follow(Vec<Seg>, 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<Seg> {
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<Seg> {
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<Seg> {
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<Seg>, 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<Vec<Spec>> {
use Seg::*;
let fill = |t: Vec<Seg>, b: Vec<Seg>| 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<Warp> {
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<Rect>,
/// 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<usize>,
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<LineMap>,
offsets: Vec<Guide>,
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<usize> = (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<Option<(f64, f64)>> = 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<Option<(f64, f64)>> =
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;
-309
View File
@@ -1,309 +0,0 @@
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<WarpLine> {
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::<Vec<_>>(), [0, 1]);
assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::<Vec<_>>(), [0, 0, 1]);
assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::<Vec<_>>(), [0, 0, 0, 1, 1]);
assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::<Vec<_>>(), [0, 0, 1, 1, 2]);
assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::<Vec<_>>(), [0, 0, 1, 2]);
assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::<Vec<_>>(), [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());
}
-1
View File
@@ -223,7 +223,6 @@ 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),
});
-5
View File
@@ -98,10 +98,6 @@ pub struct BodyProps {
pub col_spacing: Option<f64>,
/// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
pub warp: Option<String>,
/// 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<f64>,
pub rtl_col: Option<bool>,
/// Keep text upright when the shape rotates.
pub upright: Option<bool>,
@@ -123,7 +119,6 @@ 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);
+1 -33
View File
@@ -321,45 +321,13 @@ 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" && !w.is_empty()).map(String::from);
p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default();
}
"prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from),
_ => {}
}
}
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<f64> {
let mut adj: Vec<f64> = 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::<usize>().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::<f64>().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<Spacing> {
if let Some(p) = e.child("spcPct") {
return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0)));
+1 -13
View File
@@ -392,19 +392,7 @@ 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));
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.empty0("a:avLst");
w.close("a:prstTxWarp");
}
match b.autofit {
-61
View File
@@ -796,64 +796,3 @@ 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<f64>); 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#"<a:prstTxWarp prst="textInflate"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstTxWarp>"#), "{xml}");
assert!(xml.contains(r#"<a:gd name="adj1" fmla="val 13500000"/><a:gd name="adj2" fmla="val 30000"/>"#));
assert!(xml.contains(r#"<a:prstTxWarp prst="textArchUp"><a:avLst/></a:prstTxWarp>"#));
// 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#"<a:gd name="adj1" fmla="val 13500000"/>"#, "")
.replace(r#"<a:gd name="adj" fmla="val 30000"/>"#, r#"<a:gd name="adj" fmla="val NaN"/>"#);
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());
}
+6 -23
View File
@@ -21,7 +21,6 @@ mod pathgrad;
mod placed;
mod ramp;
mod table;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
@@ -794,14 +793,11 @@ impl Renderer {
ctx.push_clip_layer(&outline);
let c = rctx.color(&sh.color, fx_ph);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
// blur radius.
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
ctx.set_paint(color(c, 1.0));
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
let mut hole = outline.clone();
hole.apply_affine(Affine::translate(-d));
hole.apply_affine(Affine::translate(d));
ring.extend(hole.iter());
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&ring);
@@ -817,10 +813,7 @@ 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 });
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),
}
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);
@@ -875,8 +868,7 @@ impl Renderer {
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
ctx.set_transform(Affine::IDENTITY);
// Blurred by a third of the blur radius, as PowerPoint's export shows.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
ctx.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
@@ -885,12 +877,10 @@ impl Renderer {
if let Some(g) = &e.glow {
let c = rctx.color(&g.color, ph);
ctx.set_transform(Affine::IDENTITY);
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
// out from the outline and gone by r.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
ctx.set_transform(m);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
ctx.stroke_path(&silhouette);
ctx.fill_path(&silhouette);
ctx.pop_layer();
@@ -1150,13 +1140,6 @@ 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<deckcraft_geom::warp::Warp> {
let bp = resolve::body(rctx, s);
let name = bp.warp.as_deref()?;
deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
}
/// Draw an already laid-out text block.
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
draw_layout_with(ctx, l, tr, m, &[]);
+3 -147
View File
@@ -119,10 +119,9 @@ fn effects_tables_charts_render() {
all.extend(charts);
let p = deck_with(all);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
// 8 pt blur radius.
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
// Shadow darkens below-right of the shape.
let px = img.pixel(258, 156);
assert!(px[0] < 250, "{px:?}");
}
#[test]
@@ -932,146 +931,3 @@ fn a_table_fill_shows_behind_cells_without_their_own() {
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
}
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
fn effect_box(effects: deckcraft_model::Effects) -> Image {
use deckcraft_model::style::{Fill, Line};
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
effects: Some(effects),
..Default::default()
};
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
blur,
dist,
dir,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}
}
#[test]
fn shadows_and_glows_spread_like_powerpoints() {
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
// px outside the left edge.
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
}
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
// edges, not the top and left.
let mut sh = shadow(0.0, 10.0, 45.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
let mut sh = shadow(9.0, 0.0, 0.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
}
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
assert!(red(&img, 19, 120) < 20);
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<Option<u32>>) {
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<u32>], 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");
}
}
-209
View File
@@ -1,209 +0,0 @@
//! 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<usize> {
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<Point> = None;
let mut start: Option<Point> = 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<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = 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<WarpLine> = 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);
}
}
+3 -28
View File
@@ -203,8 +203,6 @@ struct Style {
cs: Option<String>,
/// Heading font (`+mj-…`): the theme's major font set applies.
major: bool,
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
kern: bool,
}
/// One character of a paragraph after shaping.
@@ -316,7 +314,6 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
ea,
cs,
major,
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
}
}
@@ -616,8 +613,7 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern);
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i));
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
let mut a = 0;
while a < n {
let mut b = a + 1;
@@ -633,24 +629,6 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
/// Size of superscript and subscript text relative to the run's size.
const SUB_SUPER: f64 = 0.66;
/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws
/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in
/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need
/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them.
fn shaping_features(cells: &[Cell], styles: &[Style], script: Option<ScriptTag>) -> Vec<deckcraft_fonts::Feature> {
let mut f = vec![];
if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) {
f.extend(deckcraft_fonts::feature("-kern"));
}
let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl"));
let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F));
if alphabetic && !emoji {
f.extend(deckcraft_fonts::feature("-liga"));
f.extend(deckcraft_fonts::feature("-clig"));
}
f
}
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
#[allow(clippy::too_many_arguments)]
@@ -671,8 +649,7 @@ fn shape_item(
let Some(face) = faces.get(fi) else { return };
let rtl = level % 2 == 1;
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
let params = ShapeParams { features: &features, rtl, script, language: lang };
let params = ShapeParams { features: &[], rtl, script, language: lang };
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, &params, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
let upem = face.upem.max(1.0);
let scale_of = |cell: &Cell| {
@@ -1074,9 +1051,7 @@ 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)),
);
// 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 wrap = bp.wrap.unwrap_or(true) && !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);
-69
View File
@@ -45,75 +45,6 @@ fn bullets_are_drawn_for_body_text() {
assert_eq!(l.para_lines.len(), 2);
}
/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`.
fn glyphs_and_width(family: &str, text: &str, kern: Option<f64>) -> (usize, f64) {
let (p, mut sh) = setup(text, 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
for r in &mut para.runs {
r.props.font = Some(family.into());
r.props.size = Some(40.0);
r.props.kern = kern;
}
let l = lay(&p, &sh, 2000.0, 300.0);
let n = l.runs.iter().map(|r| r.glyphs.len()).sum();
(n, l.lines[0].extent.1 - l.lines[0].extent.0)
}
/// The first installed or bundled family of `families` for which `pick` holds.
fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option<String> {
let db = FontDb::global();
families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone())
}
const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"];
#[test]
fn latin_text_draws_no_ligatures() {
// PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as
// standard ligatures.
let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6;
let Some(family) = first_family(LATIN_FAMILIES, ligates) else {
eprintln!("skipped: no font with an ffi ligature");
return;
};
let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0));
assert_eq!(n, 12, "{family}: one glyph per character");
}
#[test]
fn pairs_are_kerned_only_from_the_kern_size() {
let kerns = |f: &FontFace| {
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
};
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
eprintln!("skipped: no font that kerns AVATAR");
return;
};
// `kern` is the smallest size kerned; 0 is no kerning.
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0));
let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0));
assert!(on < off - 2.0, "{family}: kerned {on} vs {off}");
assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size");
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
}
#[test]
fn ligatures_stay_for_scripts_that_need_them() {
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
let cells =
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
// Arabic needs its ligatures; emoji sequences are joined by them.
assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga"));
assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga"));
assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga"));
}
#[test]
fn only_the_first_character_of_a_bullet_is_drawn() {
// "••" (seen in the wild) draws one dot in PowerPoint.
+1 -1
View File
@@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| Layer | Crates | What they do |
|---|---|---|
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
+2 -3
View File
@@ -63,13 +63,13 @@ the round trip even though DeckCraft doesn't model it.
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
| Notes slides and notes master | yes | yes | No notes-master editing view |
| Handout master | no | no | |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) |
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| 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 | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
| Text warp (`prstTxWarp`) | yes | yes | Not rendered |
| 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,5 +99,4 @@ 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 |
+3 -8
View File
@@ -165,13 +165,9 @@ 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" (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).
- **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).
### Proofing: spelling, thesaurus, AutoCorrect
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
@@ -251,6 +247,5 @@ 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 |
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 60 | 12 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 14 | 27 | 37% |
| P2 | 54 | 13 | 13 | 28 | 36% |
| Area | Parity |
|---|---|
@@ -18,7 +18,7 @@ The summary between `<!-- SUMMARY -->
| Slides | 90% |
| Views | 90% |
| Masters & themes | 85% |
| Shapes | 88% |
| Shapes | 86% |
| 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 | P |
| Shapes | Text warp transforms | P2 | M |
| Shapes | Shape quick styles | P1 | D |
| Shapes | Set as default shape | P2 | D |
| Format | Solid fill and transparency | P0 | D |
+1 -1
View File
@@ -212,7 +212,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
header/footer fields, embedded media, unlimited undo on shared slide snapshots.
- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts,
backgrounds (solid, gradient, picture, pattern), slide size presets.
- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop,
- **Shapes:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop,
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
freeform/curve/scribble, merge shapes, ink, action buttons, WordArt; flipped shapes keep their