Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 6521789c2c Text warps: bands with fewer lines keep the fuller band's text height
Multi-band fill warps (Deflate-Inflate, Button Pour) map a box as tall as
the tallest band's ink onto every band, centred on the band's own ink, as
PowerPoint does: a band holding one line next to a band of two keeps the
line at the same size instead of stretching it over the whole band.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:11:15 -04:00
Brandon ThomasandClaude Opus 5.5 fe5ecc1b85 Text warps: draw WordArt prstTxWarp presets, round-trip their adjusts
All 40 DrawingML text-warp presets now render instead of drawing flat text.
A warp is a list of bands over the shape's text rectangle (insets ignored):
fill bands stretch the ink of their lines between a top and a bottom guide
(positions by arc length, one horizontal scale for the whole text), follow
bands (arch, circle, button) set lines at natural size along an ellipse arc,
lines stacking outwards on parallel curves, with no wrapping. Lines are
shared over multi-band presets in order (ceil(n/k) per band).

The guide curves were fitted to PowerPoint renders of synthetic decks of
"I" bars (black-box observation, defaults and changed adjusts, 1-3 lines);
most presets match within about a point on a 420 x 200 pt box.

- geom: warp module (guides, presets, band mapping) with unit tests
- render: glyph outlines and underlines flattened, subdivided and mapped
- text: follow warps lay out without wrapping
- model/pptx: BodyProps.warp_adj read from and written to prstTxWarp/avLst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:11:15 -04:00
Brandon ThomasandClaude Opus 5.5 5835f0a106 Text: no Latin ligatures, and kerning only from the run's kern size (#309)
PowerPoint keeps fi, ffi and Calibri's ti apart and kerns a run only when
a:rPr/@kern is set and the size reaches it; DeckCraft shaped with the font's
defaults (ligatures and kerning always on), so Calibri text read differently
and kern="0" text came out narrower.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:11:10 -04:00
Brandon ThomasandClaude Opus 5.5 a520dd4d1d Render: shadows, inner shadows and glows spread like PowerPoint's (#305)
* Render: shadows, inner shadows and glows spread like PowerPoint's

Measured on PowerPoint's export of probe shapes:
- outer and inner shadows are blurred by a third of the blur radius (was
  half), so their profile matches pixel for pixel;
- an inner shadow falls inside along the edges its direction points to (45
  degrees: bottom and right); DeckCraft put it on the opposite edges;
- a glow of radius r is half strength about r/2 out and gone by r (was a
  wider, softer halo).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Render tests: probe the outer shadow where the narrower blur still reaches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 fd2f8146b4 Bullets: an installed symbol font draws its own glyph (#293)
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:06:30 -04:00
Brandon ThomasandClaude Opus 5.5 1679836540 Tables: the table's own fill (a:tblPr fill) is drawn behind its cells (#294)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:05:12 -04:00
Brandon ThomasandClaude Opus 5.5 d8407c11f9 Tables: styles the file defines are read, drawn and kept (#292)
* Tables: styles the file defines are read, drawn and kept

PowerPoint writes the definition of every table style a deck uses into
ppt/tableStyles.xml. DeckCraft mapped a handful of GUIDs to its own looks
and drew any other style as Medium Style 2, so themed and light styles came
out in the wrong colours, and dropped the definitions on save.

The reader now keeps each definition (whole table, bands, first/last
rows and columns, corner cells, table background; fills, theme fill and
line references, borders, text colour and bold) on the presentation, and
tables using one keep its GUID. The renderer applies the parts that cover
each cell in the spec's order, a part's edge lines on its region's edges
and its inside lines within it, and paints fills as real fills (gradients
and see-through colours). The writer puts the definitions back as read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tables: DeckCraft's own table styles still map back to their ids

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix a brace lost in the rebase

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:03:19 -04:00
Brandon ThomasandClaude Opus 5.5 880b974cff Render: soft edges fade to the shape's outline (#289)
The soft-edge mask was the bounding box shrunk and blurred, so ovals and
other non-rectangular shapes kept hard edges where the box was wider, and
a rectangle's fade began outside it. The mask is now the outline itself,
shrunk by 0.85 r (a stroke punched out of the fill) and blurred by r/3:
transparent at the outline, half opaque 0.85 r inside, as measured on
PowerPoint's export of a 12 pt soft edge.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:00:38 -04:00
Brandon ThomasandClaude Opus 5.5 b3e33928e2 Render: rectangle and outline path gradients (#288)
Path gradients that follow a rectangle (path="rect") or the shape's own
outline (path="shape") were drawn as circles. They're now rasterised into
a small image of the bounds: rect takes the larger of the horizontal and
vertical shares of the distance from the focus to the edge (X-shaped
contours), shape the share of the way from the focus to the outline along
the same ray (an ellipse gives ellipses, a triangle triangles), as
measured on PowerPoint's export. The two path probe decks now match it to
a mean pixel difference of 1.7 and 0.6 (from 15.6 and 22).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:59:32 -04:00
Brandon ThomasandClaude Opus 5.5 d0a3d924bf Render: compound outlines (double, thick-thin, thin-thick, triple) (#287)
a:ln/@cmpd was read but every outline was drawn as one band. Each band is
now stroked along the path moved to its place across the width, in the
shares PowerPoint uses (measured on its export of 24 pt lines: double
1/3-1/3-1/3, thin-thick 1/5-1/5-3/5, triple 1/6-1/6-1/3-1/6-1/6), the
first band on the left of travel (the outside of a shape). Line corners
meet at their miter points; curves are offset with kurbo.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:58:10 -04:00
Brandon ThomasandClaude Opus 5.5 81b6a9df39 Render: pattern fills draw PowerPoint's preset tiles (#285)
Pattern fills were approximated (dots, bars or a grid per family, on an
8-point cell), so pct30 looked like sparse dots and wave like a dot grid.
Every one of the 54 DrawingML presets now draws its 8x8 tile, each cell one
96-dpi pixel (0.75 pt), laid out from the slide's origin. The tiles were
read from PowerPoint's own export of each preset at two output pixels per
cell; the pattern probe deck now matches it to a mean pixel difference of
0.2 (from 62).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:56:30 -04:00
Brandon ThomasandClaude Opus 5.5 d7fbbaeab1 Bullets: a symbol the text font lacks falls back to a font that has it (#284)
Circled-number lists (circleNumDbPlain) and character bullets whose glyph
isn't in the paragraph's font were drawn as missing-glyph boxes (numbers)
or always from Inter (characters). Both now go through the usual font
fallback, as PowerPoint draws them.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:54:25 -04:00
Brandon ThomasandClaude Opus 5.5 2bf62c97e2 Text: shadows and glows are drawn (#283)
Run shadows and glows (a:rPr/a:effectLst outerShdw, glow) were read but
never drawn. The text layout now carries them on each glyph run (colours
resolved, sizes scaled with autofit) and the renderer draws every run's
shadow, then every glow, blurred, under the text. A text glow is solid to
about a third of its radius past the letters and fades out by two thirds,
as measured on PowerPoint's export. needs_filters also looks at text
effects (runs, list styles, master text styles), so they render with the
blur filter.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:53:17 -04:00
31 changed files with 3327 additions and 90 deletions
+1
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
+1
View File
@@ -8,6 +8,7 @@
pub mod morph;
pub mod preset;
pub mod warp;
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
use serde::{Deserialize, Serialize};
+773
View File
@@ -0,0 +1,773 @@
//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`).
//!
//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored).
//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band
//! and so on). A band either
//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the
//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's
//! keeps the same scale, centred), and horizontally the text is scaled so that the
//! widest band is filled along the mean length of its guides (one scale for the whole text), or
//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles):
//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole
//! guide being the line's width: insets don't count) and stand on
//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer
//! than the guide are shrunk to fit.
//!
//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and
//! bottom guide's length and interpolates between them. The guide curves were derived from
//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to
//! the measured bar ends (most fit within a point on a 420 × 200 pt box).
use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2};
/// Flattening tolerance for guides, in points.
const TOL: f64 = 0.02;
/// A guide curve flattened to a polyline with cumulative arc lengths.
#[derive(Clone, Debug, Default)]
pub struct Guide {
pts: Vec<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
@@ -0,0 +1,309 @@
use super::*;
const ALL: &[&str] = &[
"textPlain",
"textStop",
"textTriangle",
"textTriangleInverted",
"textChevron",
"textChevronInverted",
"textRingInside",
"textRingOutside",
"textArchUp",
"textArchDown",
"textCircle",
"textButton",
"textArchUpPour",
"textArchDownPour",
"textCirclePour",
"textButtonPour",
"textCurveUp",
"textCurveDown",
"textCanUp",
"textCanDown",
"textWave1",
"textWave2",
"textDoubleWave1",
"textWave4",
"textInflate",
"textDeflate",
"textInflateBottom",
"textDeflateBottom",
"textInflateTop",
"textDeflateTop",
"textDeflateInflate",
"textDeflateInflateDeflate",
"textFadeRight",
"textFadeLeft",
"textFadeUp",
"textFadeDown",
"textSlantUp",
"textSlantDown",
"textCascadeUp",
"textCascadeDown",
];
const W: f64 = 420.0;
const H: f64 = 200.0;
fn rect() -> Rect {
Rect::new(0.0, 0.0, W, H)
}
/// One line whose ink box is `ink`.
fn one_line(ink: Rect) -> Vec<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
@@ -512,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
first_slide_number: 1,
next_id: next.max(256),
embedded_fonts: vec![],
table_styles: vec![],
raw_ext: None,
};
if title_slide {
+3
View File
@@ -798,6 +798,9 @@ pub struct Presentation {
pub next_id: u32,
/// Embedded fonts (family names; bytes in `media`).
pub embedded_fonts: Vec<(String, MediaId)>,
/// Table styles defined in the file (`ppt/tableStyles.xml`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub table_styles: Vec<table::TableStyleDef>,
/// Unmodelled presentation-level XML kept for round-trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
+1
View File
@@ -223,6 +223,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
columns: Some(1),
col_spacing: Some(0.0),
warp: None,
warp_adj: Vec::new(),
rtl_col: Some(false),
upright: Some(false),
});
+109 -1
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::style::{Fill, Line};
use crate::style::{ColorRef, Fill, Line};
use crate::text::{Anchor, TextBody};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -55,6 +55,9 @@ pub struct Table {
pub last_col: bool,
pub band_row: bool,
pub band_col: bool,
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<Fill>,
}
impl Default for Table {
@@ -69,6 +72,7 @@ impl Default for Table {
last_col: false,
band_row: true,
band_col: false,
background: None,
}
}
}
@@ -199,6 +203,110 @@ impl Table {
}
}
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
/// last rows and columns, corner cells) is filled, bordered and lettered.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableStyleDef {
/// The style's GUID (`a:tableStyleId` on tables).
pub id: String,
pub name: String,
pub parts: Vec<(TablePart, TablePartStyle)>,
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
pub background: Option<TablePartStyle>,
/// The definition as read, written back unchanged.
#[serde(skip_serializing_if = "String::is_empty")]
pub raw: String,
}
impl TableStyleDef {
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
}
}
/// The parts of a table style, in the order they apply (later ones win).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TablePart {
WholeTbl,
Band1H,
Band2H,
Band1V,
Band2V,
LastCol,
FirstCol,
LastRow,
FirstRow,
SeCell,
SwCell,
NeCell,
NwCell,
}
impl TablePart {
pub const ALL: [TablePart; 13] = [
TablePart::WholeTbl,
TablePart::Band1H,
TablePart::Band2H,
TablePart::Band1V,
TablePart::Band2V,
TablePart::LastCol,
TablePart::FirstCol,
TablePart::LastRow,
TablePart::FirstRow,
TablePart::SeCell,
TablePart::SwCell,
TablePart::NeCell,
TablePart::NwCell,
];
pub fn xml(self) -> &'static str {
match self {
TablePart::WholeTbl => "wholeTbl",
TablePart::Band1H => "band1H",
TablePart::Band2H => "band2H",
TablePart::Band1V => "band1V",
TablePart::Band2V => "band2V",
TablePart::LastCol => "lastCol",
TablePart::FirstCol => "firstCol",
TablePart::LastRow => "lastRow",
TablePart::FirstRow => "firstRow",
TablePart::SeCell => "seCell",
TablePart::SwCell => "swCell",
TablePart::NeCell => "neCell",
TablePart::NwCell => "nwCell",
}
}
pub fn from_xml(s: &str) -> Option<TablePart> {
TablePart::ALL.into_iter().find(|p| p.xml() == s)
}
}
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
/// style index with its colour.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TablePartStyle {
pub fill: Option<Fill>,
pub fill_ref: Option<(u32, ColorRef)>,
/// Left, right, top, bottom, inside horizontal, inside vertical.
pub borders: [Option<TableBorder>; 6],
pub text_color: Option<ColorRef>,
pub bold: Option<bool>,
pub italic: Option<bool>,
}
/// A border of a table style part: a line, or a theme line style reference.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TableBorder {
Line { line: Line },
Ref { idx: u32, color: ColorRef },
}
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
pub fn table_styles() -> Vec<(String, String)> {
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
+5
View File
@@ -98,6 +98,10 @@ 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>,
@@ -119,6 +123,7 @@ impl BodyProps {
self.col_spacing = self.col_spacing.or(p.col_spacing);
if self.warp.is_none() {
self.warp.clone_from(&p.warp);
self.warp_adj.clone_from(&p.warp_adj);
}
self.rtl_col = self.rtl_col.or(p.rtl_col);
self.upright = self.upright.or(p.upright);
+33 -1
View File
@@ -321,13 +321,45 @@ pub fn body_pr(b: &El) -> BodyProps {
}
"spAutoFit" => p.autofit = Some(AutoFit::Shape),
"noAutofit" => p.autofit = Some(AutoFit::None),
"prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from),
"prstTxWarp" => {
p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from);
p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default();
}
_ => {}
}
}
p
}
/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given);
/// slots the file leaves out keep the preset's default.
fn warp_adjusts(preset: &str, e: &El) -> Vec<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)));
+73
View File
@@ -30,6 +30,8 @@ pub struct Imp<'a> {
pub next: u32,
pub slide_by_part: HashMap<String, SlideId>,
pub custom_show_names: HashMap<String, String>,
/// GUIDs (upper case) of the table styles the file defines.
pub table_style_ids: Vec<String>,
}
impl Imp<'_> {
@@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
next: max_id.max(1),
slide_by_part: HashMap::new(),
custom_show_names: HashMap::new(),
table_style_ids: Vec::new(),
};
// Slide ids first so hyperlinks anywhere can point at slides.
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
@@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
if let Some(dts) = pres_el.child("defaultTextStyle") {
p.default_text_style = dml::list_style(&mut imp, &pres_part, dts);
}
if let Some(r) = pres_part.rels.first_of("tableStyles")
&& let Some(d) = load(&pkg, &r.target)
{
let part = Part::new(&pkg, &r.target, &d);
p.table_styles = table_styles(&mut imp, &part, &d.root);
imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect();
}
// Masters and layouts.
let mut layout_by_part: HashMap<String, LayoutId> = HashMap::new();
@@ -505,6 +515,69 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut
}
}
/// `a:tblStyleLst`: the table styles the file defines.
fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec<deckcraft_model::table::TableStyleDef> {
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
root.children_named("tblStyle")
.take(256)
.filter_map(|st| {
let id = st.attr("styleId")?.to_ascii_uppercase();
let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() };
if let Some(bg) = st.child("tblBg") {
let mut ps = TablePartStyle::default();
if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) {
ps.fill = dml::fill_el(imp, part, f);
}
if let Some(fr) = bg.child("fillRef") {
ps.fill_ref = Some((
fr.u32("idx").unwrap_or(0),
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
));
}
def.background = Some(ps);
}
for el in st.elements() {
let Some(kind) = TablePart::from_xml(el.local()) else { continue };
let mut ps = TablePartStyle::default();
if let Some(tx) = el.child("tcTxStyle") {
ps.text_color = dml::color(tx);
ps.bold = tx.attr("b").map(|v| v == "on");
ps.italic = tx.attr("i").map(|v| v == "on");
}
if let Some(tc) = el.child("tcStyle") {
if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) {
ps.fill = dml::fill_el(imp, part, f);
}
if let Some(fr) = tc.child("fillRef") {
ps.fill_ref = Some((
fr.u32("idx").unwrap_or(0),
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
));
}
if let Some(bd) = tc.child("tcBdr") {
for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() {
let Some(b) = bd.child(side) else { continue };
let border = if let Some(ln) = b.child("ln") {
Some(TableBorder::Line { line: dml::line(imp, part, ln) })
} else {
b.child("lnRef").map(|r| TableBorder::Ref {
idx: r.u32("idx").unwrap_or(0),
color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
})
};
if let Some(slot) = ps.borders.get_mut(i) {
*slot = border;
}
}
}
}
def.parts.push((kind, ps));
}
Some(def)
})
.collect()
}
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
let bg = csld.child("bg")?;
if let Some(pr) = bg.child("bgPr") {
+6 -1
View File
@@ -394,6 +394,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
if let Some(pr) = tbl.child("tblPr") {
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
t.first_row = pr.bool("firstRow").unwrap_or(false);
t.first_col = pr.bool("firstCol").unwrap_or(false);
t.last_row = pr.bool("lastRow").unwrap_or(false);
@@ -401,7 +402,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
t.band_row = pr.bool("bandRow").unwrap_or(false);
t.band_col = pr.bool("bandCol").unwrap_or(false);
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
t.style = crate::tables::style_from_guid(id.trim());
// A style the file defines is drawn from its definition (by its GUID).
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
let up = id.trim().to_ascii_uppercase();
t.style =
if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) };
}
}
t.cols = tbl
+16 -3
View File
@@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String {
guid(&format!("deckcraft-table-style:{style}"))
}
/// Is `g` the GUID DeckCraft writes for one of its own styles?
pub fn is_own_guid(g: &str) -> bool {
let up = g.to_ascii_uppercase();
deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up)
}
/// Table style GUID → our style id (or the GUID itself when unknown).
pub fn style_from_guid(g: &str) -> String {
let up = g.to_ascii_uppercase();
@@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) {
w.close("a:tblStyle");
}
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the
/// opened file defined (`defs`), written back as they were read.
pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec<u8> {
let mut w = W::new();
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
let names = deckcraft_model::table::table_styles();
@@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
continue;
}
done.push(s);
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
{
w.raw(&d.raw);
continue;
}
let (_, define) = guid_for_style(s);
if !define {
continue;
@@ -231,7 +244,7 @@ mod tests {
"nwCell",
];
let used: Vec<String> = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect();
let d = crate::xml::parse(&table_styles_xml(&used)).unwrap();
let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap();
let mut n = 0;
for st in d.root.children_named("tblStyle") {
n += 1;
+13 -1
View File
@@ -392,7 +392,19 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
w.open("a:bodyPr", a);
if let Some(p) = warp {
w.open("a:prstTxWarp", A::new().a("prst", p));
w.empty0("a:avLst");
let adj: Vec<(usize, i64)> =
b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect();
if adj.is_empty() {
w.empty0("a:avLst");
} else {
let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1;
w.open0("a:avLst");
for (i, v) in adj {
let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) };
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
}
w.close("a:avLst");
}
w.close("a:prstTxWarp");
}
match b.autofit {
+1 -1
View File
@@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
pres.rels.add("tableStyles", "tableStyles.xml", false);
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
let ts = crate::tables::table_styles_xml(&x.table_styles);
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
+3
View File
@@ -494,6 +494,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
.t("bandRow", t.band_row)
.t("bandCol", t.band_col),
);
if let Some(f) = &t.background {
dml::fill(w, x, o, f);
}
w.elt("a:tableStyleId", &guid);
w.close("a:tblPr");
w.open0("a:tblGrid");
+77
View File
@@ -780,3 +780,80 @@ fn line_break_options_round_trip() {
assert_eq!(props.latin_line_break, Some(true));
assert_eq!(props.hanging_punct, Some(false));
}
#[test]
fn a_table_fill_round_trips() {
let mut p = defaults::new_presentation(None);
let mut t = Table::new(2, 2, 200.0, 40.0);
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
sh.kind = deckcraft_model::ShapeKind::Table(t);
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(sh);
let q = round(&p);
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
assert!(
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());
}
+84
View File
@@ -0,0 +1,84 @@
//! Fills and styles read from decks written by other tools.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::Fill;
use deckcraft_model::{Shape, ShapeId, defaults};
const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}";
/// A table style definition as PowerPoint writes one: red table background, see-through white
/// bands, 2 pt blue outer borders and no inside lines.
fn style_xml() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
)
}
/// An exported deck with a 3x2 table using the probe style.
fn table_deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
t.style = "medium2-accent1".into();
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Styled".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)),
kind: deckcraft_model::ShapeKind::Table(t),
..Default::default()
});
let bytes = deckcraft_pptx::export(&p).expect("export");
// Point the table at the probe style and replace the style part.
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name == "ppt/tableStyles.xml" {
d = style_xml().into_bytes();
} else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
let s = String::from_utf8(d).expect("utf8");
let start = s.find("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
let end = s.find("</a:tableStyleId>");
d = match (start, end) {
(Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(),
_ => s.into_bytes(),
};
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn table_styles_the_file_defines_are_read_used_and_kept() {
use deckcraft_model::table::{TableBorder, TablePart};
let p = deckcraft_pptx::import(&table_deck()).expect("import");
let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read");
assert_eq!(def.name, "Probe Style");
let bg = def.background.as_ref().expect("table background");
assert!(matches!(bg.fill_ref, Some((1, _))));
let whole = def.part(TablePart::WholeTbl).expect("whole table");
assert_eq!(whole.bold, Some(true));
assert!(whole.text_color.is_some());
assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0)));
assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None))));
assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. })));
// The table keeps the style's GUID, so it's drawn from the definition.
let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table");
let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") };
assert_eq!(t.style, STYLE_GUID);
// Saving writes the definition back, unchanged.
let again = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&again).expect("reimport");
let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save");
assert_eq!(def2.parts, def.parts);
}
+242
View File
@@ -0,0 +1,242 @@
//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines.
//!
//! PowerPoint splits the line's width into bands across the path, measured from its left side
//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's
//! export of 24 pt lines of each kind):
//! - double: 1/3 line, 1/3 gap, 1/3 line;
//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror;
//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line.
//!
//! On a shape drawn clockwise (all presets) the left side is the outside.
use deckcraft_model::style::Compound;
use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2};
/// The bands of `c` as (from, to) shares of the width, left side first.
pub fn bands(c: Compound) -> &'static [(f64, f64)] {
match c {
Compound::Single => &[(0.0, 1.0)],
Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)],
Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)],
Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)],
Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)],
}
}
/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent.
fn right(t: Vec2) -> Option<Vec2> {
let l = t.hypot();
(l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l)
}
/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their
/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are
/// bridged by a straight line.
pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath {
let mut out = BezPath::new();
if d.abs() < 1e-9 {
return path.clone();
}
// Each subpath as segments, offset one by one.
let mut segs: Vec<PathSeg> = Vec::new();
let mut closed = false;
let flush = |segs: &mut Vec<PathSeg>, closed: bool, out: &mut BezPath| {
let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect();
let begin = out.elements().len();
for (i, (seg, piece)) in pieces.iter().enumerate() {
let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue };
match i.checked_sub(1).and_then(|j| pieces.get(j)) {
None => out.move_to(p),
Some((prev, _)) => join(out, *prev, *seg, p, d),
}
for e in piece.elements().iter().skip(1) {
out.push(*e);
}
}
if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) {
if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() {
// Lines meeting at the start: both ends move to their miter point.
match miter(*last, *first, d) {
Some(m) if is_line(*last) && is_line(*first) => {
set_last(out, m);
if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) {
out.elements_mut()[begin] = PathEl::MoveTo(m);
}
}
_ => out.line_to(p),
}
}
out.close_path();
}
segs.clear();
};
let mut start = Point::ZERO;
let mut cur = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
flush(&mut segs, closed, &mut out);
closed = false;
start = p;
cur = p;
}
PathEl::LineTo(p) => {
segs.push(PathSeg::Line(kurbo::Line::new(cur, p)));
cur = p;
}
PathEl::QuadTo(a, b) => {
segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b)));
cur = b;
}
PathEl::CurveTo(a, b, c) => {
segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c)));
cur = c;
}
PathEl::ClosePath => {
if (cur - start).hypot() > 1e-9 {
segs.push(PathSeg::Line(kurbo::Line::new(cur, start)));
}
closed = true;
flush(&mut segs, closed, &mut out);
closed = false;
cur = start;
}
}
}
flush(&mut segs, closed, &mut out);
out
}
fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option<BezPath> {
// Far-off or non-finite geometry (hostile files) is left out rather than offset.
let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7;
let ok = match s {
PathSeg::Line(l) => sane(l.p0) && sane(l.p1),
PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2),
PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3),
};
if !ok || !d.is_finite() {
return None;
}
match s {
PathSeg::Line(l) => {
let n = right(l.p1 - l.p0)?;
let mut p = BezPath::new();
p.move_to(l.p0 + n * d);
p.line_to(l.p1 + n * d);
Some(p)
}
PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance),
PathSeg::Cubic(c) => {
// Nearly straight cubics (collinear control points) are offset as lines.
let chord = c.p3 - c.p0;
let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18);
if flat(c.p1) && flat(c.p2) {
return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance);
}
let mut p = BezPath::new();
kurbo::offset::offset_cubic(c, d, tolerance, &mut p);
Some(p)
}
}
}
/// The segment's tangent at its start and at its end (the first non-zero control vector).
fn tangents(s: PathSeg) -> (Vec2, Vec2) {
let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO);
match s {
PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0),
PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])),
PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])),
}
}
fn is_line(s: PathSeg) -> bool {
matches!(s, PathSeg::Line(_))
}
/// Move the path's last point to `p`.
fn set_last(out: &mut BezPath, p: Point) {
if let Some(e) = out.elements_mut().last_mut() {
match e {
PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p,
PathEl::QuadTo(_, q) => *q = p,
PathEl::CurveTo(_, _, q) => *q = p,
PathEl::ClosePath => {}
}
}
}
/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or
/// None for a corner sharper than about 11 degrees or a degenerate tangent.
fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option<Point> {
let (_, t0) = tangents(prev);
let (t1, _) = tangents(next);
let (n0, n1) = (right(t0)?, right(t1)?);
let denom = 1.0 + n0.dot(n1);
(denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite())
}
/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which
/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner
/// doesn't loop); anything else is bridged straight.
fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) {
match miter(prev, next, d) {
Some(m) if is_line(prev) && is_line(next) => set_last(out, m),
Some(m) => {
out.line_to(m);
out.line_to(p);
}
None => out.line_to(p),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
#[test]
fn bands_split_the_width_like_powerpoint() {
for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] {
let b = bands(c);
assert_eq!(b.first().map(|x| x.0), Some(0.0));
assert_eq!(b.last().map(|x| x.1), Some(1.0));
assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps");
}
// Thin-thick: the thin line on the left, thick-thin its mirror.
let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin));
assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0));
assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0));
}
#[test]
fn offsetting_a_rectangle_grows_or_shrinks_it() {
// Clockwise in y-down coordinates: the left of travel is the outside.
let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let out = offset(&r, -5.0, 0.01).bounding_box();
assert!(
(out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6,
"{out:?}"
);
let inn = offset(&r, 5.0, 0.01).bounding_box();
assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}");
// Corners meet at their miter points: the area is the offset rectangle's.
assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6);
}
#[test]
fn offsetting_a_circle_changes_its_radius() {
let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01);
let b = offset(&c, 4.0, 0.01).bounding_box();
let w = b.width() / 2.0;
assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}");
// A hostile path never panics and yields finite output.
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((0.0, 0.0));
p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0));
p.close_path();
let _ = offset(&p, 3.0, 0.01);
}
}
+117 -11
View File
@@ -9,13 +9,19 @@
mod chart;
mod colr;
mod compound;
mod images;
mod morph_path;
mod morph_text;
mod paint;
mod pattern;
mod pathgrad;
mod placed;
mod ramp;
mod table;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
@@ -248,7 +254,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) {
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
any = true;
}
});
@@ -261,6 +267,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
any
}
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
/// text styles it inherits)? Such text is blurred, which needs filters.
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
use deckcraft_model::text::{ListStyle, RunProps};
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
let Some(t) = &s.text else { return false };
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|| styled(&t.list_style)
|| styled(&rctx.master.title_style)
|| styled(&rctx.master.body_style)
|| styled(&rctx.master.other_style)
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
@@ -746,18 +766,23 @@ impl Renderer {
}
}
if let Some(r) = soft {
// Feather: fade the shape's alpha towards the outline.
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
// PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
// r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
ctx.set_transform(m);
ctx.push_layer(
None,
Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)),
None,
None,
Some(blur(r * 0.5 * scale_of(m))),
Some(blur(r / 3.0 * scale_of(m))),
);
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6);
ctx.fill_rect(&inset);
ctx.fill_path(&outline);
ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut));
ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
ctx.stroke_path(&outline);
ctx.pop_layer();
ctx.pop_layer();
ctx.pop_layer();
}
@@ -769,11 +794,14 @@ 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;
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
// 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.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);
@@ -789,7 +817,10 @@ impl Renderer {
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
draw_layout_with(ctx, &l, tr, m, &st.paras);
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
}
} else if f.opts.edit && s.ph.is_some() {
let kind = s.ph_type().unwrap_or(PhType::Body);
let prompt_body = prompt_body(rctx, s, kind);
@@ -844,7 +875,8 @@ 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);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
// 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.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
@@ -853,10 +885,12 @@ impl Renderer {
if let Some(g) = &e.glow {
let c = rctx.color(&g.color, ph);
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
// 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.set_transform(m);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
ctx.stroke_path(&silhouette);
ctx.fill_path(&silhouette);
ctx.pop_layer();
@@ -926,6 +960,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
}
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
}
// Compound lines: each band stroked along the path moved to its place across the width.
let compound = line.compound.unwrap_or_default();
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
let tolerance = 0.1 / scale_of(m).max(1e-6);
for &(a, b) in compound::bands(compound) {
let d = (-0.5 + (a + b) / 2.0) * lw;
let mut band = sk.clone();
band.width = (b - a) * lw;
ctx.set_stroke(band);
ctx.stroke_path(&compound::offset(path, d, tolerance));
}
return;
}
ctx.set_stroke(sk);
ctx.stroke_path(path);
}
@@ -1103,6 +1150,13 @@ pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
t
}
/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<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, &[]);
@@ -1124,6 +1178,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
// Text shadows, then glows, under all the text.
for pass in [TextEffect::Shadow, TextEffect::Glow] {
for run in &l.runs {
let ps = para(run.para);
if ps.visible && ps.opacity > 0.001 {
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
}
}
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
@@ -1176,6 +1239,49 @@ fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
#[derive(Clone, Copy, PartialEq)]
enum TextEffect {
Shadow,
Glow,
}
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
let Some(fx) = run.effects else { return };
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let px = scale_of(m);
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
// past the glyph's edge and fades out by about two thirds of r.
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
_ => return,
};
let alpha = (c.a as f64 / 255.0) * opacity;
if alpha <= 0.0 {
return;
}
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
ctx.set_paint(color(c.with_alpha(255), 1.0));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
ctx.pop_layer();
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
+36 -45
View File
@@ -37,6 +37,41 @@ pub fn fill_path(
}
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
// Rectangle and outline paths: rasterised (see `pathgrad`).
if let GradientShape::Path { path: kind, focus } = &g.shape
&& matches!(kind.as_str(), "rect" | "shape")
{
crate::pathgrad::fill(ctx, kind, *focus, &crate::ramp::expand(&stops), bounds, path, m, alpha);
return;
}
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
let grad = match &g.shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
ctx.fill_path(path);
}
Fill::Picture(pf) => {
@@ -47,55 +82,11 @@ pub fn fill_path(
Fill::Pattern(p) => {
let fg = rctx.color(&p.fg, ph);
let bg = rctx.color(&p.bg, ph);
ctx.set_paint(color(bg, alpha));
ctx.fill_path(path);
ctx.push_clip_layer(path);
ctx.set_paint(color(fg, alpha));
pattern(ctx, &p.preset, bounds, m);
ctx.pop_layer();
crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m);
}
}
}
/// Draw a pattern's foreground marks over `bounds` (8 pt cells).
fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) {
let k = crate::scale_of(m);
let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 };
let cell = cell.max(2.0);
let mut p = BezPath::new();
let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000));
let lw = cell * 0.18;
let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90");
for j in 0..=ny {
for i in 0..=nx {
let x = b.x0 + i as f64 * cell;
let y = b.y0 + j as f64 * cell;
let r = match preset {
p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw),
p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell),
p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => {
p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new());
Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell))
}
_ => {
let d = if dense { cell * 0.35 } else { cell * 0.2 };
Rect::new(x, y, x + d, y + d)
}
};
p.extend(r.to_path(0.1).iter());
if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") {
p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter());
}
}
}
ctx.fill_path(&p);
}
fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
Rect::new(x, y, x + w, y + h).to_path(0.1)
}
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
/// cache can apply and key them without a colour scheme.
+220
View File
@@ -0,0 +1,220 @@
//! Path gradients (`a:path`) that follow a rectangle or the shape's own outline, which no vello
//! gradient draws: they're rasterised into a small image of the bounds and painted with it.
//!
//! Measured on PowerPoint's export (black to grey to white from the centre):
//! - `rect`: the colour position is the larger of the horizontal and vertical distances from the
//! focus, each as a share of the distance to that side's edge (X-shaped contours);
//! - `shape`: contours are the outline scaled about the focus (an ellipse gives ellipses, a
//! triangle triangles): the share of the way from the focus to the outline along the same ray.
//!
//! `circle` stays a radial vello gradient (see `paint::gradient`).
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath, PathEl, Point, Rect};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest).
const MAX_SIDE: f64 = 384.0;
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
/// shape's flattened polygon(s) for `shape`.
pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec<Point>], p: Point) -> f64 {
let f = Rect::new(
bounds.x0 + bounds.width() * focus[0].clamp(0.0, 1.0),
bounds.y0 + bounds.height() * focus[1].clamp(0.0, 1.0),
bounds.x1 - bounds.width() * focus[2].clamp(0.0, 1.0),
bounds.y1 - bounds.height() * focus[3].clamp(0.0, 1.0),
);
let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) };
match kind {
"shape" if !outline.is_empty() => shape_position(outline, f.center(), p),
_ => {
// Distance outside the focus rect along each axis, over the room to that side's edge.
let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| {
if v < lo {
(lo - v) / (lo - edge_lo).max(1e-9)
} else if v > hi {
(v - hi) / (edge_hi - hi).max(1e-9)
} else {
0.0
}
};
axis(p.x, f.x0, f.x1, bounds.x0, bounds.x1).max(axis(p.y, f.y0, f.y1, bounds.y0, bounds.y1)).clamp(0.0, 1.0)
}
}
}
/// The share of the way from `c` to the outline along the ray through `p` (1 when the ray meets
/// no edge).
fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
let d = p - c;
let len = d.hypot();
if len < 1e-9 {
return 0.0;
}
// Nearest crossing of the ray c + s·d (s > 0) with an edge.
let mut best = f64::INFINITY;
for poly in outline {
for w in poly.windows(2) {
let (a, b) = (w[0], w[1]);
let e = b - a;
let den = d.cross(e);
if den.abs() < 1e-12 {
continue;
}
let ac = a - c;
let s = ac.cross(e) / den;
let u = ac.cross(d) / den;
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
best = best.min(s);
}
}
}
if best.is_finite() { (1.0 / best).clamp(0.0, 1.0) } else { 1.0 }
}
/// The outline flattened into closed polygons.
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
let mut out: Vec<Vec<Point>> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
kurbo::flatten(path.iter(), 0.25, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
for poly in &mut out {
if let (Some(f), Some(l)) = (poly.first().copied(), poly.last().copied())
&& (f - l).hypot() > 1e-9
{
poly.push(f);
}
}
out.retain(|p| p.len() > 2 && p.len() < 100_000);
out
}
/// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them).
pub fn ramp(stops: &[(f64, Rgba)]) -> Vec<Rgba> {
(0..256)
.map(|i| {
let t = i as f64 / 255.0;
let after = stops.iter().position(|s| s.0 >= t);
match after {
Some(0) | None => stops.get(after.unwrap_or(stops.len().saturating_sub(1))).map(|s| s.1).unwrap_or(Rgba::BLACK),
Some(j) => {
let (Some(a), Some(b)) = (stops.get(j - 1), stops.get(j)) else { return Rgba::BLACK };
let k = if b.0 - a.0 > 1e-12 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
a.1.lerp(b.1, k)
}
}
})
.collect()
}
/// Fill `path` with a `rect` or `shape` path gradient of `stops` (sorted and expanded, see
/// `ramp::expand`) over `bounds`, faded by `alpha`; `m` maps the path to the output.
#[allow(clippy::too_many_arguments)]
pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, Rgba)], bounds: Rect, path: &BezPath, m: Affine, alpha: f64) {
if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() || stops.is_empty() {
return;
}
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
let outline = if kind == "shape" { polygons(path) } else { Vec::new() };
let lut = ramp(stops);
let a = alpha.clamp(0.0, 1.0);
let mut data = Vec::with_capacity((pw * ph) as usize);
for j in 0..ph {
for i in 0..pw {
let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height());
let t = position(kind, bounds, focus, &outline, p);
let c = lut.get((t * 255.0).round().clamp(0.0, 255.0) as usize).copied().unwrap_or(Rgba::BLACK);
let ca = (c.a as f64 * a).round().clamp(0.0, 255.0);
let mul = |v: u8| (v as f64 * ca / 255.0).round().clamp(0.0, 255.0) as u8;
data.push(vello_cpu::color::PremulRgba8 { r: mul(c.r), g: mul(c.g), b: mul(c.b), a: ca as u8 });
}
}
let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() },
});
ctx.set_paint_transform(
Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64),
);
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
const CENTRE: [f64; 4] = [0.5, 0.5, 0.5, 0.5];
#[test]
fn rect_paths_take_the_larger_axis_share() {
// PowerPoint, 280x200 box from the centre: 20 px across is 1/7, 20 px down is 1/5.
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
let at = |x: f64, y: f64| position("rect", b, CENTRE, &[], Point::new(x, y));
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 1e-9);
assert!((at(140.0, 120.0) - 20.0 / 100.0).abs() < 1e-9);
assert!((at(168.0, 120.0) - 0.2).abs() < 1e-9, "the diagonal takes the larger share");
assert_eq!(at(0.0, 0.0), 1.0);
// From a corner (fillToRect 0,0,100%,100%): the far corner is the end.
let corner = [0.0, 0.0, 1.0, 1.0];
assert!((position("rect", b, corner, &[], Point::new(140.0, 50.0)) - 0.5).abs() < 1e-9);
}
#[test]
fn shape_paths_follow_the_outline() {
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
let at = |x: f64, y: f64| position("shape", b, CENTRE, &ellipse, Point::new(x, y));
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 0.01);
assert!((at(140.0, 120.0) - 0.2).abs() < 0.01);
// Half way to the outline along any ray is 0.5.
let (dx, dy) = (140.0 * 0.6, 100.0 * 0.8);
assert!((at(140.0 + dx * 0.5, 100.0 + dy * 0.5) - 0.5).abs() < 0.01);
// A triangle: half way from its centre to its apex is 0.5.
let mut tri = BezPath::new();
tri.move_to((140.0, 0.0));
tri.line_to((280.0, 200.0));
tri.line_to((0.0, 200.0));
tri.close_path();
let polys = polygons(&tri);
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
}
#[test]
fn the_ramp_runs_through_the_stops() {
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
let r = ramp(&stops);
assert_eq!(r.len(), 256);
assert_eq!(r[0], Rgba::rgb(0, 0, 0));
assert_eq!(r[255], Rgba::rgb(255, 255, 255));
assert!((r[64].r as i32 - 64).abs() <= 2);
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel
//! (0.75 pt) of the slide, as PowerPoint draws them.
//!
//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at
//! exactly two output pixels per pattern pixel), so the marks, their density and their phase
//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour.
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Side of one pattern cell, in points.
pub const CELL: f64 = 0.75;
const TILES: &[(&str, [u8; 8])] = &[
("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]),
("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]),
("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]),
("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]),
("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]),
("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]),
("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]),
("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]),
("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]),
("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]),
("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]),
("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]),
("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]),
("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]),
("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]),
("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]),
("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]),
("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]),
("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]),
("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]),
("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]),
("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]),
("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]),
("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]),
("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]),
("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]),
("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]),
("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]),
("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]),
("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]),
("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]),
("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]),
("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]),
("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]),
("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]),
("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]),
("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]),
("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]),
("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]),
("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]),
("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]),
("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]),
("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]),
("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]),
("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]),
("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]),
("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]),
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
];
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
pub fn tile(name: &str) -> [u8; 8] {
TILES
.iter()
.find(|(n, _)| *n == name)
.or_else(|| TILES.iter().find(|(n, _)| *n == "pct50"))
.map(|(_, t)| *t)
.unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])
}
/// Is cell (`x`, `y`) of the tile the foreground?
pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool {
tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0)
}
fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 {
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0);
let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8;
vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 }
}
/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by
/// `alpha`.
pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) {
let det = m.determinant();
if !det.is_finite() || det.abs() <= 1e-12 {
return;
}
let t = tile(name);
let (f, b) = (premul(fg, alpha), premul(bg, alpha));
let data: Vec<vello_cpu::color::PremulRgba8> = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect();
let pm = Arc::new(Pixmap::from_parts(data, 8, 8));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
quality: peniko::ImageQuality::Low,
alpha: 1.0,
},
});
// Cells are laid out on the output from its origin (the slide's top-left corner), so
// neighbouring shapes' patterns line up, as in PowerPoint.
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k));
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_drawingml_preset_has_a_tile() {
// ECMA-376 ST_PresetPatternVal.
let all = [
"pct5",
"pct10",
"pct20",
"pct25",
"pct30",
"pct40",
"pct50",
"pct60",
"pct70",
"pct75",
"pct80",
"pct90",
"horz",
"vert",
"ltHorz",
"ltVert",
"dkHorz",
"dkVert",
"narHorz",
"narVert",
"dashHorz",
"dashVert",
"cross",
"dnDiag",
"upDiag",
"ltDnDiag",
"ltUpDiag",
"dkDnDiag",
"dkUpDiag",
"wdDnDiag",
"wdUpDiag",
"dashDnDiag",
"dashUpDiag",
"diagCross",
"smCheck",
"lgCheck",
"smGrid",
"lgGrid",
"dotGrid",
"smConfetti",
"lgConfetti",
"horzBrick",
"diagBrick",
"solidDmnd",
"openDmnd",
"dotDmnd",
"plaid",
"sphere",
"weave",
"divot",
"shingle",
"wave",
"trellis",
"zigZag",
];
assert_eq!(all.len(), 54);
for n in all {
assert!(TILES.iter().any(|(t, _)| *t == n), "{n}");
}
assert_eq!(TILES.len(), 54);
}
#[test]
fn percent_presets_get_denser() {
// The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50
// half, pct90 all but 2).
let share = |n: &str| {
let t = tile(n);
(0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count()
};
let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"];
assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share));
assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62));
assert_eq!(tile("nonsense"), tile("pct50"));
}
}
+149 -11
View File
@@ -3,7 +3,7 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef};
use deckcraft_model::text::{Anchor, TextBody, TextDir};
use deckcraft_model::{Presentation, Shape};
use deckcraft_text::Fields;
@@ -18,6 +18,12 @@ pub struct CellLook {
pub text: Option<Rgba>,
pub bold: bool,
pub border: Option<(Rgba, f64)>,
/// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
/// means no border); `None` falls back to `border`.
pub sides: [Option<(Rgba, f64)>; 4],
/// A file-defined style's fill as a paint (gradients, see-through colours), with its
/// placeholder colour; drawn instead of `fill`.
pub paint: Option<(Fill, Option<Rgba>)>,
}
fn accent(ctx: &Ctx, style: &str) -> Rgba {
@@ -33,7 +39,109 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba {
ctx.color(&ColorRef::scheme(slot), None)
}
/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
/// fill, text and the borders on its region's edges (its inside lines within it).
fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
let (rows, cols) = (t.rows.len(), t.cols.len());
let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c));
let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1)));
let last_r = rows.saturating_sub(1);
let last_c = cols.saturating_sub(1);
let header = t.first_row && r == 0;
let total = t.last_row && r1 == last_r && rows > 1;
let first_col = t.first_col && c == 0;
let last_col = t.last_col && c1 == last_c && cols > 1;
let body_r = r.saturating_sub(usize::from(t.first_row));
let body_c = c.saturating_sub(usize::from(t.first_col));
let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None };
for part in TablePart::ALL {
// (applies, region rows, region cols)
let (on, region) = match part {
TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)),
TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)),
TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)),
TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)),
TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
TablePart::LastRow => (total, (r, last_r, 0, last_c)),
TablePart::FirstRow => (header, (0, r1, 0, last_c)),
TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
};
if !on {
continue;
}
let Some(ps) = def.part(part) else { continue };
if let Some(p) = style_fill(ctx, ps) {
look.fill = fill_color(ctx, &p.0, p.1);
look.paint = Some(p);
}
if let Some(tc) = &ps.text_color {
look.text = Some(ctx.color(tc, None));
}
if let Some(b) = ps.bold {
look.bold = b;
}
let (r0, rr1, c0, cc1) = region;
// Left, right, top, bottom of the cell: the part's edge or its inside line.
let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }];
for (side, i) in pick.iter().enumerate() {
if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
*slot = Some(border_look(ctx, b));
}
}
}
look
}
/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
/// the placeholder colour.
fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
if let Some((idx, col)) = &ps.fill_ref {
let ph = ctx.color(col, None);
return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) {
Some(f) if *idx > 0 => (f.clone(), Some(ph)),
_ if *idx == 0 => (Fill::None, None),
_ => (Fill::solid(ColorRef::rgb(ph)), None),
});
}
ps.fill.clone().map(|f| (f, None))
}
/// A fill's colour for a table cell (gradients and pictures by their first colour).
fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
match f {
Fill::None => Some(Rgba::TRANSPARENT),
Fill::Solid { color: c } => Some(ctx.color(c, ph)),
Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
_ => None,
}
}
/// A style border's colour and width (a transparent colour for no line).
fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
match b {
TableBorder::Line { line } => match &line.fill {
Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)),
_ => (Rgba::TRANSPARENT, 0.0),
},
TableBorder::Ref { idx, color } => {
let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75);
if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) }
}
}
}
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
return look_def(ctx, def, t, r, c);
}
let a = accent(ctx, &t.style);
let kind = t.style.split('-').next().unwrap_or("medium2");
let header = t.first_row && r == 0;
@@ -47,19 +155,23 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
let white = Rgba::WHITE;
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
match kind {
"none" => CellLook { fill: None, text: None, bold: false, border: None },
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) },
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
"light1" => CellLook {
fill: if band || band_c { Some(tint(20000)) } else { None },
text: None,
bold: header || total || first_col || last_col,
border: if header { Some((a, 1.0)) } else { None },
sides: [None; 4],
paint: None,
},
"light2" => CellLook {
fill: if header { Some(a) } else { None },
text: if header { Some(white) } else { None },
bold: header || total || first_col,
border: Some((a, 1.0)),
sides: [None; 4],
paint: None,
},
"dark1" => CellLook {
fill: Some(if header {
@@ -72,12 +184,16 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
text: Some(white),
bold: header || total || first_col,
border: Some((white, 1.0)),
sides: [None; 4],
paint: None,
},
"medium4" => CellLook {
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
text: None,
bold: header || total || first_col || last_col,
border: Some((a, 0.75)),
sides: [None; 4],
paint: None,
},
_ => CellLook {
// medium2: strong header, banded light tints, white grid.
@@ -91,6 +207,8 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
text: if header || total || first_col || last_col { Some(white) } else { None },
bold: header || total || first_col || last_col,
border: Some((white, 1.0)),
sides: [None; 4],
paint: None,
},
}
}
@@ -182,6 +300,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
let style_bg = rctx
.pres
.table_styles
.iter()
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
.and_then(|d| d.background.as_ref())
.and_then(|b| style_fill(rctx, b));
let own_bg = t.background.clone().map(|f| (f, None));
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
ctx.set_transform(m);
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
}
}
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
@@ -190,12 +323,17 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
Some(f) => {
crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
}
None => {
if let Some(fc) = lk.fill {
ctx.set_paint(color(fc, 1.0));
ctx.fill_rect(rect);
None => match &lk.paint {
Some((f, ph)) => {
crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
}
}
None => {
if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
ctx.set_paint(color(fc, 1.0));
ctx.fill_rect(rect);
}
}
},
}
}
// Borders: explicit cell borders, else the style's grid.
@@ -216,9 +354,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
Some(Fill::None) => continue,
_ => continue,
},
None => match lk.border {
Some(b) => b,
None => continue,
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
_ => continue,
},
};
let mut p = BezPath::new();
+377 -3
View File
@@ -119,9 +119,10 @@ 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.
let px = img.pixel(258, 156);
assert!(px[0] < 250, "{px:?}");
// 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:?}");
}
#[test]
@@ -701,3 +702,376 @@ fn a_text_gradient_starts_over_on_every_line() {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}
/// A text box of big black "IIII" with `set` applied to its run.
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("IIII");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(100.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
set(props);
sh.text = Some(t);
deck_with(vec![sh])
}
#[test]
fn text_glow_and_shadow_are_drawn() {
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
let plain = effect_text(|_| {});
assert!(!needs_filters(&plain, &plain.slides[0]));
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(count(&img, &green), 0);
// Glow: a green halo hugging the letters, needing the blur filter.
let glow =
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let halo = count(&img, &green);
assert!(halo > 300, "green halo pixels {halo}");
// Shadow: grey ink below and right of the letters, where the plain text has none.
let shadow = effect_text(|p| {
p.shadow = Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
blur: 2.0,
dist: 12.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
});
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
}
#[test]
fn pattern_fills_draw_powerpoints_tiles() {
// dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as
// PowerPoint's export draws it; pct50 is a checkerboard.
use deckcraft_model::style::{Fill, PatternFill};
let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v));
let shape = |id, x: f64, preset: &str| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)),
fill: Some(Fill::Pattern(PatternFill { preset: preset.into(), fg: rgb(0), bg: rgb(255) })),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]);
let img = render_slide(&p, 0, &RenderOpts { scale: 4.0 / 3.0, ..Default::default() });
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128;
let row: Vec<bool> = (8..16).map(|x| dark(x, 20)).collect();
assert_eq!(row, [true, true, false, false, true, true, false, false], "dkVert columns");
assert!((8..16).all(|y| dark(8, y) == dark(8, 20)), "dkVert is vertical");
// pct50 starts at slide x = 120 pt = 160 px.
for (x, y) in [(168, 8), (169, 9), (170, 12)] {
assert_ne!(dark(x, y), dark(x + 1, y), "pct50 alternates across at {x},{y}");
assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}");
}
}
#[test]
fn compound_lines_draw_their_bands() {
// A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside
// first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4.
use deckcraft_model::style::{Compound, Fill, Line};
let runs = |c: Compound| {
let sh = Shape {
id: ShapeId(760),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line {
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
width: Some(24.0),
compound: Some(c),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 2.0, ..Default::default() });
// Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and
// light runs in points.
let mut out: Vec<(bool, f64)> = vec![];
for y in 160..240 {
let dark = img.pixel(500, y)[0] < 128;
match out.last_mut() {
Some((d, n)) if *d == dark => *n += 0.5,
_ => out.push((dark, 0.5)),
}
}
out.into_iter().filter(|(d, _)| *d).map(|(_, n)| n).collect::<Vec<f64>>()
};
let close = |got: Vec<f64>, want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0);
assert!(close(runs(Compound::Single), &[24.0]));
let d = runs(Compound::Double);
assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}");
let t = runs(Compound::ThinThick);
assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}");
let k = runs(Compound::ThickThin);
assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}");
let tri = runs(Compound::Triple);
assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}");
}
#[test]
fn rect_path_gradients_draw_x_shaped_contours() {
// path="rect" from the centre, black to grey to white (linear): PowerPoint's colour position is
// the larger axis share, so 20 pt right of centre in a 280x200 box is 1/7 and 20 pt down 1/5.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let sh = Shape {
id: ShapeId(770),
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let v = |x: u32, y: u32| img.pixel(x, y)[0] as f64;
assert!((v(160, 100) - 255.0 / 7.0).abs() < 6.0, "{}", v(160, 100));
assert!((v(140, 120) - 51.0).abs() < 6.0, "{}", v(140, 120));
// Along the diagonal to a corner the colour follows the vertical share.
assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120));
}
#[test]
fn soft_edges_fade_to_the_outline() {
// An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt
// inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the
// bounding box).
let sh = Shape {
id: ShapeId(780),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
geom: Geom::Preset { name: "ellipse".into(), adj: vec![] },
fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
line: Some(deckcraft_model::style::Line { fill: Some(deckcraft_model::style::Fill::None), ..Default::default() }),
effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0;
// Top of the ellipse is at y = 100, x = 250.
assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101));
let mid = alpha(250, 110);
assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}");
assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130));
// Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn.
assert!(alpha(110, 105) < 0.01);
}
#[test]
fn a_file_defined_table_style_draws_its_parts() {
// Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and
// no inside lines: drawn from the definition, not DeckCraft's own look for the style.
use deckcraft_model::style::{Fill, Line};
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
let mut t = Table::new(4, 2, 400.0, 50.0);
t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into();
t.first_row = false;
t.band_row = true;
let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } };
let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() };
whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0));
whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } });
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 });
let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() };
let def = TableStyleDef {
id: t.style.clone(),
name: "Probe".into(),
parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)],
background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }),
raw: String::new(),
};
let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() };
let mut p = deck_with(vec![tbl]);
p.table_styles.push(def);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let px = |x, y| img.pixel(x, y);
// Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red.
let banded = px(300, 125);
assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}");
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
// No line between the rows; a blue left border.
let edge = px(300, 150);
assert!(edge[0] > 240, "no inside line: {edge:?}");
let left = px(100, 175);
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
}
#[test]
fn a_table_fill_shows_behind_cells_without_their_own() {
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
use deckcraft_model::style::Fill;
let mut t = Table::new(2, 2, 200.0, 50.0);
t.style = "none".into();
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192))));
for r in 0..2 {
for c in 0..2 {
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
}
}
let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
let p = deck_with(vec![tbl]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
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
@@ -0,0 +1,209 @@
//! Drawing laid-out text through a WordArt warp ([`deckcraft_geom::warp`]).
//!
//! Glyph outlines are placed in layout space, flattened, cut into short segments and every point
//! is mapped through the warp, so straight glyph edges bend with the guides. Underlines and
//! highlights are warped the same way.
use deckcraft_geom::warp::{Warp, WarpLine};
use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<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);
}
}
+131 -5
View File
@@ -57,6 +57,25 @@ pub struct GlyphRun {
/// Alpha of the fill (0–1).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
}
/// A text run's shadow and glow, colours resolved.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RunEffects {
pub shadow: Option<TextShadow>,
/// Glow colour (with its alpha) and radius in points.
pub glow: Option<(Rgba, f64)>,
}
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextShadow {
pub color: Rgba,
pub blur: f64,
pub dx: f64,
pub dy: f64,
}
impl std::fmt::Debug for GlyphRun {
@@ -174,6 +193,7 @@ struct Style {
outline: Option<(Rgba, f64)>,
link: bool,
gradient: Option<deckcraft_model::style::Gradient>,
effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces.
style_name: &'static str,
/// BCP 47 language (`a:rPr/@lang`), for shaping.
@@ -183,6 +203,8 @@ 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.
@@ -288,11 +310,13 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
outline,
link: r.link.is_some(),
gradient,
effects: run_effects(ctx, r, scale),
style_name: style_name(bold, italic),
lang: r.lang.clone().filter(|l| !l.is_empty()),
ea,
cs,
major,
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
}
}
@@ -471,6 +495,15 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
@@ -483,8 +516,10 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
let st = make_style(ctx, &rp, scale, opts.prompt_color);
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
}
@@ -492,8 +527,27 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
/// and other symbols missing from the text font), by the usual fallback.
fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
let chars: Vec<char> = label.chars().collect();
if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
}
}
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
/// script, face and shaping style, each shaped with the whole paragraph as context.
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
let a = s.dir.to_radians();
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
});
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
}
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
let db = FontDb::global();
let chars: Vec<char> = text.chars().collect();
@@ -562,7 +616,8 @@ 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 key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
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 mut a = 0;
while a < n {
let mut b = a + 1;
@@ -578,6 +633,24 @@ 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)]
@@ -598,7 +671,8 @@ 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 params = ShapeParams { features: &[], rtl, script, language: lang };
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
let params = ShapeParams { features: &features, rtl, script, language: lang };
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, &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| {
@@ -675,11 +749,59 @@ fn shape_item(
}
}
/// Is `family` a symbol font (characters at U+F000 + code)?
fn is_symbol_font(family: &str) -> bool {
let f = family.to_ascii_lowercase();
f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett")
}
/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
/// symbol font that `face` is (not a substitute) and it has the glyph.
fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
let font = font?;
if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
return None;
}
let first = c.chars().next()?;
let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
if face.covers(pua) {
Some(pua)
} else if face.covers(first) {
Some(first)
} else {
None
}
}
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
let f = font.unwrap_or("").to_ascii_lowercase();
let first = c.chars().next().unwrap_or('•');
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
let code = (first as u32) & 0xff;
// Wingdings 2 and 3 put different shapes at the same codes.
if f.starts_with("wingdings 2") {
let m = match code {
0x97 | 0x98 => '●',
0x9e..=0xa1 => '■',
0xa2..=0xa5 => '◆',
0xea..=0xf3 => '★',
0xb7..=0xbf => '○',
0x50 => '✓',
_ => '•',
};
return m.to_string();
}
if f.starts_with("wingdings 3") {
let m = match code {
0x7d | 0x75 => '►',
0x7c | 0x74 => '◄',
0x70 => '▲',
0x71 => '▼',
0xc6 | 0xc7 => '➔',
_ => '▶',
};
return m.to_string();
}
let m = match code {
0xa7 => '▪',
0xd8 => '➢',
@@ -952,7 +1074,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
);
let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
// Text that follows a WordArt arch or circle is set on one line per paragraph.
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize;
let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0);
let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);
@@ -1231,6 +1355,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: false,
alpha: st.alpha,
gradient: None,
effects: None,
});
}
// Group visually consecutive cells by (style, face).
@@ -1264,6 +1389,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: st.link,
alpha: st.alpha,
gradient: st.gradient.clone(),
effects: st.effects,
});
cur_key = key;
}
+116
View File
@@ -45,6 +45,75 @@ 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.
@@ -602,3 +671,50 @@ fn emoji_take_a_colour_face_when_one_is_installed() {
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
}
}
#[test]
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
// bundled face has ①.
let db = FontDb::global();
if !db.cascade(&['①'], &[], "Regular").covers('①') {
return;
}
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
assert_eq!(bullets.len(), 2);
for b in bullets {
assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family);
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
}
}
#[test]
fn symbol_font_bullets_map_by_font() {
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
// Wingdings keeps its own table (0xA7 is a small square).
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
}
#[test]
fn an_installed_symbol_font_draws_its_own_bullet() {
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
// look-alike. Skipped without Wingdings 2.
let db = FontDb::global();
if !db.has_family("Wingdings 2") {
return;
}
let face = db.face("Wingdings 2", "Regular");
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
}
+2 -1
View File
@@ -69,7 +69,7 @@ the round trip even though DeckCraft doesn't model it.
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered |
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields |
| Text warp (`prstTxWarp`) | yes | yes | Not rendered |
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
@@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
+8 -3
View File
@@ -165,9 +165,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
- **Estimate:** 4–6 h.
### Edit points, text warp, 3-D
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms", "Bevel and 3-D
rotation" (missing; `scene3d`/`sp3d` kept as raw XML on round trip).
- **Estimate:** 4–6 h (edit points), 6–10 h (warp), 8–12 h (3-D render approximation).
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
`prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or
command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by
1–3 % of the path length), "Bevel and 3-D rotation" (missing; `scene3d`/`sp3d` kept as raw XML on
round trip).
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 8–12 h (3-D render
approximation).
### Proofing: spelling, thesaurus, AutoCorrect
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
@@ -247,5 +251,6 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+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 | 13 | 28 | 36% |
| P2 | 54 | 13 | 14 | 27 | 37% |
| Area | Parity |
|---|---|
@@ -18,7 +18,7 @@ The summary between `<!-- SUMMARY -->
| Slides | 90% |
| Views | 90% |
| Masters & themes | 85% |
| Shapes | 86% |
| Shapes | 88% |
| Format | 81% |
| Arrange | 100% |
| Text | 80% |
@@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
| Shapes | Change shape | P1 | D |
| Shapes | Text boxes | P0 | D |
| Shapes | WordArt | P1 | P |
| Shapes | Text warp transforms | P2 | M |
| Shapes | Text warp transforms | P2 | P |
| Shapes | Shape quick styles | P1 | D |
| Shapes | Set as default shape | P2 | D |
| Format | Solid fill and transparency | P0 | D |