mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6521789c2c | ||
|
|
fe5ecc1b85 | ||
|
|
5835f0a106 | ||
|
|
a520dd4d1d | ||
|
|
fd2f8146b4 | ||
|
|
1679836540 | ||
|
|
d8407c11f9 | ||
|
|
880b974cff | ||
|
|
b3e33928e2 |
@@ -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 |
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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())];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+34
-10
@@ -16,9 +16,12 @@ 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;
|
||||
@@ -763,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();
|
||||
}
|
||||
@@ -786,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);
|
||||
@@ -806,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);
|
||||
@@ -861,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);
|
||||
@@ -870,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();
|
||||
@@ -1133,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, &[]);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
+149
-11
@@ -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();
|
||||
|
||||
+258
-3
@@ -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]
|
||||
@@ -820,3 +821,257 @@ fn compound_lines_draw_their_bands() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
-3
@@ -203,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.
|
||||
@@ -314,6 +316,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
ea,
|
||||
cs,
|
||||
major,
|
||||
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +496,17 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
// 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");
|
||||
}
|
||||
Some((ch, st))
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
@@ -602,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;
|
||||
@@ -618,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)]
|
||||
@@ -638,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, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
|
||||
let upem = face.upem.max(1.0);
|
||||
let scale_of = |cell: &Cell| {
|
||||
@@ -715,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 => '➢',
|
||||
@@ -992,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);
|
||||
|
||||
@@ -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.
|
||||
@@ -624,3 +693,28 @@ fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user