Compare commits
37 Commits
fix/wild-s
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
457406a7a5 | |
|
|
e614f0d6d6 | |
|
|
8ee082f257 | |
|
|
238157d98e | |
|
|
4e2cbaed79 | |
|
|
ee71879f80 | |
|
|
7cb62d5197 | |
|
|
c0dcfc1db5 | |
|
|
9238a915a2 | |
|
|
2e68e54eec | |
|
|
239e41bdf6 | |
|
|
cef6e1a8c8 | |
|
|
9d4541b8b4 | |
|
|
46dfb23dc5 | |
|
|
675d20417e | |
|
|
754841aaa4 | |
|
|
bf877411fb | |
|
|
3b04dd14be | |
|
|
772abb2154 | |
|
|
84e6c12543 | |
|
|
aafce0d2ef | |
|
|
4773e4ee0f | |
|
|
4012cea0bf | |
|
|
2965e69e2f | |
|
|
ee4712f829 | |
|
|
c2b622a797 | |
|
|
0cbcbebf68 | |
|
|
0c5ddb5dea | |
|
|
9a7e7e133e | |
|
|
47dfaaca61 | |
|
|
7d642e12c9 | |
|
|
862e2175a7 | |
|
|
b3a980dcb5 | |
|
|
0796325a1f | |
|
|
90a9933d46 | |
|
|
2fca333cac | |
|
|
39adfcd62a |
|
|
@ -130,6 +130,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||||
|
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||||
scaled: false,
|
scaled: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Some(Gradient { stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
//! geometry table. Adjust values use the file convention: ratios in 1/100 000 of the shorter side (or
|
//! geometry table. Adjust values use the file convention: ratios in 1/100 000 of the shorter side (or
|
||||||
//! of the width/height), angles in 1/60 000 degree.
|
//! of the width/height), angles in 1/60 000 degree.
|
||||||
|
|
||||||
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
|
use kurbo::{Arc, BezPath, Point, Rect, Vec2};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
mod extra;
|
mod extra;
|
||||||
|
|
@ -339,6 +339,45 @@ pub fn info(name: &str) -> Option<&'static PresetInfo> {
|
||||||
CATALOG.iter().find(|p| p.name == name)
|
CATALOG.iter().find(|p| p.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether PowerPoint joins this preset's outline with sharp (miter) corners when the line names
|
||||||
|
/// no join (it rounds them otherwise). Observed per preset on its export: the plus, the right,
|
||||||
|
/// up and down block arrows (not the left one), chevrons, stars from five points up, the
|
||||||
|
/// equation shapes, curved ribbons and the action buttons are mitred.
|
||||||
|
pub fn mitred_by_default(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
"plus"
|
||||||
|
| "rightArrow"
|
||||||
|
| "upArrow"
|
||||||
|
| "downArrow"
|
||||||
|
| "upDownArrow"
|
||||||
|
| "leftUpArrow"
|
||||||
|
| "bentUpArrow"
|
||||||
|
| "stripedRightArrow"
|
||||||
|
| "notchedRightArrow"
|
||||||
|
| "chevron"
|
||||||
|
| "leftArrowCallout"
|
||||||
|
| "leftRightArrowCallout"
|
||||||
|
| "upDownArrowCallout"
|
||||||
|
| "quadArrowCallout"
|
||||||
|
| "mathPlus"
|
||||||
|
| "mathMinus"
|
||||||
|
| "mathMultiply"
|
||||||
|
| "mathEqual"
|
||||||
|
| "mathNotEqual"
|
||||||
|
| "star5"
|
||||||
|
| "star6"
|
||||||
|
| "star8"
|
||||||
|
| "star10"
|
||||||
|
| "star12"
|
||||||
|
| "star16"
|
||||||
|
| "star24"
|
||||||
|
| "star32"
|
||||||
|
| "ellipseRibbon"
|
||||||
|
| "ellipseRibbon2"
|
||||||
|
) || name.starts_with("actionButton")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_line_like(name: &str) -> bool {
|
pub fn is_line_like(name: &str) -> bool {
|
||||||
matches!(name, "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "curvedConnector3" | "arc")
|
matches!(name, "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "curvedConnector3" | "arc")
|
||||||
|| name.starts_with("bentConnector")
|
|| name.starts_with("bentConnector")
|
||||||
|
|
@ -388,6 +427,29 @@ impl Pb {
|
||||||
self.cur = Point::new(c.x + wr * e.cos(), c.y + hr * e.sin());
|
self.cur = Point::new(c.x + wr * e.cos(), c.y + hr * e.sin());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
/// A DrawingML `arcTo`: angles are where the ray from the ellipse's centre at that angle
|
||||||
|
/// meets it (not the ellipse's parameter), so elliptical arcs start and end where the
|
||||||
|
/// standard's shapes expect.
|
||||||
|
fn arc_spec(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self {
|
||||||
|
let par = |a: f64| {
|
||||||
|
let a = a.to_radians();
|
||||||
|
(wr * a.sin()).atan2(hr * a.cos())
|
||||||
|
};
|
||||||
|
let t0 = par(st);
|
||||||
|
let mut d = par(st + sw) - t0;
|
||||||
|
let tau = std::f64::consts::TAU;
|
||||||
|
if sw.abs() >= 360.0 {
|
||||||
|
d = tau.copysign(sw);
|
||||||
|
} else {
|
||||||
|
while sw > 0.0 && d < 0.0 {
|
||||||
|
d += tau;
|
||||||
|
}
|
||||||
|
while sw < 0.0 && d > 0.0 {
|
||||||
|
d -= tau;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.arc(wr, hr, t0.to_degrees(), d.to_degrees())
|
||||||
|
}
|
||||||
fn z(&mut self) -> &mut Self {
|
fn z(&mut self) -> &mut Self {
|
||||||
self.p.close_path();
|
self.p.close_path();
|
||||||
self
|
self
|
||||||
|
|
@ -458,14 +520,25 @@ fn normalize(pts: &[(f64, f64)], w: f64, h: f64) -> Vec<(f64, f64)> {
|
||||||
pts.iter().map(|(x, y)| ((x - x0) * sx, (y - y0) * sy)).collect()
|
pts.iter().map(|(x, y)| ((x - x0) * sx, (y - y0) * sy)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn star(n: usize, w: f64, h: f64, inner: f64, normalized: bool) -> Vec<(f64, f64)> {
|
/// An n-pointed star as ECMA-376 draws it: points on an ellipse around (hc, vc · vf) with radii
|
||||||
|
/// half the box stretched by the preset's `hf` / `vf` (so a five-pointed star fills its box and
|
||||||
|
/// looks regular in a square), the inner corners `inner` of the way out.
|
||||||
|
fn star(n: usize, w: f64, h: f64, inner: f64) -> Vec<(f64, f64)> {
|
||||||
|
let (hf, vf) = match n {
|
||||||
|
5 => (1.05146, 1.10557),
|
||||||
|
6 => (1.15470, 1.0),
|
||||||
|
7 => (1.02572, 1.05210),
|
||||||
|
10 => (1.05146, 1.0),
|
||||||
|
_ => (1.0, 1.0),
|
||||||
|
};
|
||||||
|
let (rx, ry, cy) = (w / 2.0 * hf, h / 2.0 * vf, h / 2.0 * vf);
|
||||||
let mut pts = Vec::with_capacity(n * 2);
|
let mut pts = Vec::with_capacity(n * 2);
|
||||||
for i in 0..n * 2 {
|
for i in 0..n * 2 {
|
||||||
let a = (-90.0 + 180.0 * i as f64 / n as f64).to_radians();
|
let a = (-90.0 + 180.0 * i as f64 / n as f64).to_radians();
|
||||||
let r = if i % 2 == 0 { 1.0 } else { inner };
|
let r = if i % 2 == 0 { 1.0 } else { inner };
|
||||||
pts.push((r * a.cos(), r * a.sin()));
|
pts.push((w / 2.0 + r * rx * a.cos(), cy + r * ry * a.sin()));
|
||||||
}
|
}
|
||||||
if normalized { normalize(&pts, w, h) } else { pts.iter().map(|(x, y)| (w / 2.0 + x * w / 2.0, h / 2.0 + y * h / 2.0)).collect() }
|
pts
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|
@ -605,6 +678,11 @@ fn double_arrow(vertical: bool, w: f64, h: f64, a1: f64, a2: f64) -> Geometry {
|
||||||
Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: r, handles: vec![], sites: mid_sites(w, h) }
|
Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: r, handles: vec![], sites: mid_sites(w, h) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points given in 21600ths of the box (as ECMA-376 defines many fixed shapes), scaled to it.
|
||||||
|
fn spec_pts(pts: &[(i32, i32)], w: f64, h: f64) -> Vec<(f64, f64)> {
|
||||||
|
pts.iter().map(|(x, y)| (w * *x as f64 / 21600.0, h * *y as f64 / 21600.0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn simple(path: BezPath, text: Rect, w: f64, h: f64) -> Geometry {
|
fn simple(path: BezPath, text: Rect, w: f64, h: f64) -> Geometry {
|
||||||
Geometry { paths: vec![fill(path)], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
Geometry { paths: vec![fill(path)], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
||||||
}
|
}
|
||||||
|
|
@ -855,7 +933,8 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
"can" | "flowChartMagneticDisk" => {
|
"can" | "flowChartMagneticDisk" => {
|
||||||
let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 33333.0 * h / ss.max(1e-9) / 2.0 };
|
// The disk's ends are a third of its height (ECMA-376: ellipses of radius h/6).
|
||||||
|
let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 100000.0 / 3.0 * h / ss.max(1e-9) };
|
||||||
let eh = ss * v / 100000.0;
|
let eh = ss * v / 100000.0;
|
||||||
let r = eh / 2.0;
|
let r = eh / 2.0;
|
||||||
let body = Pb::new().m(0.0, r).l(0.0, h - r).arc(w / 2.0, r, 180.0, -180.0).l(w, r).arc(w / 2.0, r, 0.0, -180.0).z().take();
|
let body = Pb::new().m(0.0, r).l(0.0, h - r).arc(w / 2.0, r, 180.0, -180.0).l(w, r).arc(w / 2.0, r, 0.0, -180.0).z().take();
|
||||||
|
|
@ -872,7 +951,8 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
"flowChartMagneticDrum" => {
|
"flowChartMagneticDrum" => {
|
||||||
let r = w / 6.0 / 2.0;
|
// ECMA-376: end ellipses of radius w/6 across.
|
||||||
|
let r = w / 6.0;
|
||||||
let body = Pb::new().m(r, 0.0).l(w - r, 0.0).arc(r, h / 2.0, 270.0, 180.0).l(r, h).arc(r, h / 2.0, 90.0, 180.0).z().take();
|
let body = Pb::new().m(r, 0.0).l(w - r, 0.0).arc(r, h / 2.0, 270.0, 180.0).l(r, h).arc(r, h / 2.0, 90.0, 180.0).z().take();
|
||||||
let side = Pb::new().m(w - r, 0.0).arc(r, h / 2.0, 270.0, -180.0).take();
|
let side = Pb::new().m(w - r, 0.0).arc(r, h / 2.0, 270.0, -180.0).take();
|
||||||
Geometry {
|
Geometry {
|
||||||
|
|
@ -1048,45 +1128,94 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"heart" => {
|
"heart" => {
|
||||||
b.m(w / 2.0, h * 0.25);
|
// ECMA-376 heart: two cubic curves from the notch at the top to the point.
|
||||||
b.c(w * 0.62, h * -0.02, w, h * 0.0, w, h * 0.32);
|
let (dx1, dx2) = (w * 49.0 / 48.0, w * 10.0 / 48.0);
|
||||||
b.c(w, h * 0.6, w * 0.62, h * 0.78, w / 2.0, h);
|
let y1 = -h / 3.0;
|
||||||
b.c(w * 0.38, h * 0.78, 0.0, h * 0.6, 0.0, h * 0.32);
|
b.m(w / 2.0, h / 4.0)
|
||||||
b.c(0.0, h * 0.0, w * 0.38, h * -0.02, w / 2.0, h * 0.25);
|
.c(w / 2.0 + dx2, y1, w / 2.0 + dx1, h / 4.0, w / 2.0, h)
|
||||||
b.z();
|
.c(w / 2.0 - dx1, h / 4.0, w / 2.0 - dx2, y1, w / 2.0, h / 4.0)
|
||||||
|
.z();
|
||||||
simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h)
|
simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h)
|
||||||
}
|
}
|
||||||
"lightningBolt" => {
|
"lightningBolt" => {
|
||||||
let pts = normalize(&[(0.36, 0.0), (0.68, 0.0), (0.53, 0.34), (0.79, 0.34), (0.24, 1.0), (0.39, 0.53), (0.15, 0.53)], w, h);
|
// ECMA-376 lightningBolt, in 21600ths of the box.
|
||||||
simple(b.poly(&pts).take(), Rect::new(w * 0.3, h * 0.35, w * 0.65, h * 0.6), w, h)
|
let pts = spec_pts(
|
||||||
|
&[
|
||||||
|
(8472, 0),
|
||||||
|
(12860, 6080),
|
||||||
|
(11050, 6797),
|
||||||
|
(16577, 12007),
|
||||||
|
(14767, 12877),
|
||||||
|
(21600, 21600),
|
||||||
|
(10012, 14915),
|
||||||
|
(12222, 13987),
|
||||||
|
(5022, 9705),
|
||||||
|
(7602, 8382),
|
||||||
|
(0, 3890),
|
||||||
|
],
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
);
|
||||||
|
simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h)
|
||||||
}
|
}
|
||||||
"sun" => {
|
"sun" => {
|
||||||
let k = a(0).clamp(12500.0, 46875.0) / 100000.0;
|
// ECMA-376 sun: eight triangular rays (their bases at fixed fractions of the core's
|
||||||
let (cx, cy) = (w / 2.0, h / 2.0);
|
// margin) around a circle of radius 50% - adj.
|
||||||
let cr = 0.5 - k;
|
let a = a(0).clamp(12500.0, 46875.0);
|
||||||
let core = Pb::new().ellipse(cx, cy, w * cr * 0.72, h * cr * 0.72).take();
|
let g0 = 50000.0 - a;
|
||||||
let mut rays = Pb::new();
|
let (g1, g2) = (g0 * 30274.0 / 32768.0, g0 * 12540.0 / 32768.0);
|
||||||
for i in 0..8 {
|
let (g10, g11) = ((50000.0 - g1) * 0.75, (50000.0 - g2) * 0.75);
|
||||||
let ang = (i as f64 * 45.0).to_radians();
|
let (g12, g13, g14) = (g10 + 3662.0, g11 + 3662.0, g11 + 12500.0);
|
||||||
let half = 9f64.to_radians();
|
let (g15, g16, g17, g18) = (100000.0 - g10, 100000.0 - g12, 100000.0 - g13, 100000.0 - g14);
|
||||||
let rb = cr * 0.72 + 0.06;
|
let x = |g: f64| w * g / 100000.0;
|
||||||
let tip = (cx + w * 0.5 * ang.cos(), cy + h * 0.5 * ang.sin());
|
let y = |g: f64| h * g / 100000.0;
|
||||||
let b1 = (cx + w * rb * (ang - half).cos(), cy + h * rb * (ang - half).sin());
|
let (ox1, oy1, ox2, oy2) = (w * 18436.0 / 21600.0, h * 3163.0 / 21600.0, w * 3163.0 / 21600.0, h * 18436.0 / 21600.0);
|
||||||
let b2 = (cx + w * rb * (ang + half).cos(), cy + h * rb * (ang + half).sin());
|
let (hc, vc) = (w / 2.0, h / 2.0);
|
||||||
rays.poly(&[tip, b1, b2]);
|
b.poly(&[(w, vc), (x(g15), y(g18)), (x(g15), y(g14))]);
|
||||||
}
|
b.poly(&[(ox1, oy1), (x(g16), y(g13)), (x(g17), y(g12))]);
|
||||||
Geometry {
|
b.poly(&[(hc, 0.0), (x(g18), y(g10)), (x(g14), y(g10))]);
|
||||||
paths: vec![fill(rays.take()), fill(core)],
|
b.poly(&[(ox2, oy1), (x(g13), y(g12)), (x(g12), y(g13))]);
|
||||||
text_rect: Rect::new(w * 0.3, h * 0.3, w * 0.7, h * 0.7),
|
b.poly(&[(0.0, vc), (x(g10), y(g14)), (x(g10), y(g18))]);
|
||||||
handles: vec![],
|
b.poly(&[(ox2, oy2), (x(g12), y(g17)), (x(g13), y(g16))]);
|
||||||
sites: mid_sites(w, h),
|
b.poly(&[(hc, h), (x(g14), y(g15)), (x(g18), y(g15))]);
|
||||||
}
|
b.poly(&[(ox1, oy2), (x(g17), y(g16)), (x(g16), y(g17))]);
|
||||||
|
b.ellipse(hc, vc, x(g0), y(g0));
|
||||||
|
let g7 = g0 * 23170.0 / 32768.0;
|
||||||
|
let mut g = simple(b.take(), Rect::new(x(50000.0 - g7), y(50000.0 - g7), x(50000.0 + g7), y(50000.0 + g7)), w, h);
|
||||||
|
g.handles.push(Handle::new(0, Point::new(0.0, vc), Vec2::new(w / 100000.0, 0.0), a, 12500.0, 46875.0));
|
||||||
|
g
|
||||||
}
|
}
|
||||||
"moon" => {
|
"moon" => {
|
||||||
|
// ECMA-376 moon: the outer edge is the left half of an ellipse w × h/2 centred on the
|
||||||
|
// right edge; the inner edge an arc through the corners and (adj · w, middle) of a
|
||||||
|
// larger ellipse (rx = g18, ry = dy1) whose centre lies right of that point.
|
||||||
let k = a(0).clamp(0.0, 87500.0) / 100000.0;
|
let k = a(0).clamp(0.0, 87500.0) / 100000.0;
|
||||||
let g0 = w * k;
|
let ssf = ss.max(1e-9);
|
||||||
b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0).arc((w - g0).max(0.0), h / 2.0, 90.0, 180.0).z();
|
let g0 = ss * k;
|
||||||
simple(b.take(), Rect::new(w * 0.1, h * 0.25, w * (1.0 - k).max(0.2) * 0.8, h * 0.75), w, h)
|
let g1 = (ss - g0).max(1e-9);
|
||||||
|
let g2 = g0 * g0 / g1;
|
||||||
|
let g3 = ss * ss / g1;
|
||||||
|
let g5 = 2.0 * g3 - g2;
|
||||||
|
let g6 = g5 - g0;
|
||||||
|
let dy1 = (g5 / 2.0 - g0) * (h / 2.0) / ssf;
|
||||||
|
let (g0w, g6w) = (g0 * w / ssf, g6 * w / ssf);
|
||||||
|
let rx = (g6w - g0w) / 2.0;
|
||||||
|
let cx = g0w + rx;
|
||||||
|
b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0);
|
||||||
|
if rx > 1e-9 && dy1 > 1e-9 {
|
||||||
|
// From the bottom-right corner back up to the top-right through (g0w, h/2).
|
||||||
|
let t0 = ((h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx);
|
||||||
|
let t1 = (-(h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx) + std::f64::consts::TAU;
|
||||||
|
let arc = Arc::new(Point::new(cx, h / 2.0), Vec2::new(rx, dy1), t0, t1 - t0, 0.0);
|
||||||
|
arc.to_cubic_beziers(0.05, |p1, p2, p| {
|
||||||
|
b.p.curve_to(p1, p2, p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
b.z();
|
||||||
|
let g12 = g0 * 9598.0 / 32768.0;
|
||||||
|
let g13 = ss - g12;
|
||||||
|
let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf;
|
||||||
|
simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), w, h)
|
||||||
}
|
}
|
||||||
"cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)),
|
"cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)),
|
||||||
"bracketPair" => {
|
"bracketPair" => {
|
||||||
|
|
@ -1124,7 +1253,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
if name == "rightBracket" {
|
if name == "rightBracket" {
|
||||||
path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]));
|
path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]));
|
||||||
}
|
}
|
||||||
Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
|
// Filled (when the shape has a fill) out to the straight side, as PowerPoint does.
|
||||||
|
let mut area = path.clone();
|
||||||
|
area.close_path();
|
||||||
|
Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
|
||||||
}
|
}
|
||||||
"leftBrace" | "rightBrace" => {
|
"leftBrace" | "rightBrace" => {
|
||||||
let r = (ss * a(0).clamp(0.0, 25000.0 * h / ss.max(1e-9)) / 1e5).min(h / 4.0);
|
let r = (ss * a(0).clamp(0.0, 25000.0 * h / ss.max(1e-9)) / 1e5).min(h / 4.0);
|
||||||
|
|
@ -1136,7 +1268,10 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
if name == "rightBrace" {
|
if name == "rightBrace" {
|
||||||
path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]));
|
path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]));
|
||||||
}
|
}
|
||||||
Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
|
// Filled (when the shape has a fill) out to the straight side, as PowerPoint does.
|
||||||
|
let mut area = path.clone();
|
||||||
|
area.close_path();
|
||||||
|
Geometry { paths: vec![fill_only(area), stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
|
||||||
}
|
}
|
||||||
"rightArrow" => arrow(Dir::Right, w, h, a(0), a(1)),
|
"rightArrow" => arrow(Dir::Right, w, h, a(0), a(1)),
|
||||||
"leftArrow" => arrow(Dir::Left, w, h, a(0), a(1)),
|
"leftArrow" => arrow(Dir::Left, w, h, a(0), a(1)),
|
||||||
|
|
@ -1372,18 +1507,45 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"flowChartDocument" => {
|
"flowChartDocument" => {
|
||||||
b.m(0.0, 0.0).l(w, 0.0).l(w, h * 0.8).c(w * 0.7, h * 0.62, w * 0.35, h * 1.08, 0.0, h * 0.88).z();
|
let u = |x: f64, y: f64| (w * x / 21600.0, h * y / 21600.0);
|
||||||
simple(b.take(), Rect::new(0.0, 0.0, w, h * 0.78), w, h)
|
let (p1, p2, p3, p4) = (u(21600.0, 17322.0), u(10800.0, 17322.0), u(10800.0, 23922.0), u(0.0, 20172.0));
|
||||||
|
b.m(0.0, 0.0).l(w, 0.0).l(p1.0, p1.1).c(p2.0, p2.1, p3.0, p3.1, p4.0, p4.1).z();
|
||||||
|
simple(b.take(), Rect::new(0.0, 0.0, w, h * 17322.0 / 21600.0), w, h)
|
||||||
}
|
}
|
||||||
"flowChartMultidocument" => {
|
"flowChartMultidocument" => {
|
||||||
let (dx, dy) = (w * 0.1, h * 0.1);
|
// ECMA-376 multidocument (21600ths): two back pages peeking out above and right of
|
||||||
let mut back = Pb::new();
|
// the front one; the back pages' edges are outlined only where they show.
|
||||||
back.poly(&[(2.0 * dx, 0.0), (w, 0.0), (w, h * 0.7), (w - dx * 0.5, h * 0.7), (w - dx * 0.5, dy * 0.5), (2.0 * dx, dy * 0.5)]);
|
let x = |v: f64| w * v / 21600.0;
|
||||||
back.poly(&[(dx, dy * 0.5), (w - dx * 0.5, dy * 0.5), (w - dx * 0.5, h * 0.78), (w - dx, h * 0.78), (w - dx, dy), (dx, dy)]);
|
let y = |v: f64| h * v / 21600.0;
|
||||||
let front = Pb::new().m(0.0, dy).l(w - dx, dy).l(w - dx, h * 0.84).c(w * 0.62, h * 0.7, w * 0.32, h * 1.08, 0.0, h * 0.9).z().take();
|
let front = |b: &mut Pb| {
|
||||||
|
b.m(0.0, y(20782.0))
|
||||||
|
.c(x(9298.0), y(23542.0), x(9298.0), y(18022.0), x(18595.0), y(18022.0))
|
||||||
|
.l(x(18595.0), y(3675.0))
|
||||||
|
.l(0.0, y(3675.0))
|
||||||
|
.z();
|
||||||
|
};
|
||||||
|
let mid = |b: &mut Pb| {
|
||||||
|
b.m(x(1532.0), y(3675.0)).l(x(1532.0), y(1815.0)).l(x(20000.0), y(1815.0)).l(x(20000.0), y(16252.0));
|
||||||
|
b.c(x(19298.0), y(16252.0), x(18595.0), y(16352.0), x(18595.0), y(16352.0));
|
||||||
|
};
|
||||||
|
let back = |b: &mut Pb| {
|
||||||
|
b.m(x(2972.0), y(1815.0)).l(x(2972.0), 0.0).l(w, 0.0).l(w, y(14392.0));
|
||||||
|
b.c(x(20800.0), y(14392.0), x(20000.0), y(14467.0), x(20000.0), y(14467.0));
|
||||||
|
};
|
||||||
|
let mut area = Pb::new();
|
||||||
|
front(&mut area);
|
||||||
|
mid(&mut area);
|
||||||
|
area.l(x(18595.0), y(3675.0)).z();
|
||||||
|
back(&mut area);
|
||||||
|
area.l(x(20000.0), y(1815.0)).z();
|
||||||
|
let mut outline = Pb::new();
|
||||||
|
outline.m(0.0, y(3675.0)).l(x(18595.0), y(3675.0)).l(x(18595.0), y(18022.0));
|
||||||
|
outline.c(x(9298.0), y(18022.0), x(9298.0), y(23542.0), 0.0, y(20782.0)).z();
|
||||||
|
mid(&mut outline);
|
||||||
|
back(&mut outline);
|
||||||
Geometry {
|
Geometry {
|
||||||
paths: vec![fill(back.take()), fill(front)],
|
paths: vec![fill_only(area.take()), stroke_only(outline.take())],
|
||||||
text_rect: Rect::new(0.0, dy, w - dx, h * 0.8),
|
text_rect: Rect::new(0.0, y(3675.0), x(18595.0), y(17322.0)),
|
||||||
handles: vec![],
|
handles: vec![],
|
||||||
sites: mid_sites(w, h),
|
sites: mid_sites(w, h),
|
||||||
}
|
}
|
||||||
|
|
@ -1401,10 +1563,11 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
simple(b.poly(&[(w * 0.2, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, h * 0.2)]).take(), Rect::new(0.0, h * 0.2, w, h), w, h)
|
simple(b.poly(&[(w * 0.2, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, h * 0.2)]).take(), Rect::new(0.0, h * 0.2, w, h), w, h)
|
||||||
}
|
}
|
||||||
"flowChartPunchedTape" => {
|
"flowChartPunchedTape" => {
|
||||||
let a1 = h * 0.1;
|
// ECMA-376: each long edge is two half-ellipses a quarter of the width wide and a
|
||||||
b.m(0.0, a1).c(w * 0.25, a1 * 3.0, w * 0.25, -a1, w / 2.0, a1).c(w * 0.75, a1 * 3.0, w * 0.75, -a1, w, a1).l(w, h - a1);
|
// tenth of the height deep, one bulging in and one out.
|
||||||
b.c(w * 0.75, h - 3.0 * a1, w * 0.75, h + a1, w / 2.0, h - a1).c(w * 0.25, h - 3.0 * a1, w * 0.25, h + a1, 0.0, h - a1).z();
|
let (rx, ry) = (w / 4.0, h / 10.0);
|
||||||
simple(b.take(), Rect::new(0.0, h * 0.2, w, h * 0.8), w, h)
|
b.m(0.0, ry).arc(rx, ry, 180.0, -180.0).arc(rx, ry, 180.0, 180.0).l(w, h - ry).arc(rx, ry, 0.0, -180.0).arc(rx, ry, 0.0, 180.0).z();
|
||||||
|
simple(b.take(), Rect::new(0.0, h / 5.0, w, h * 0.8), w, h)
|
||||||
}
|
}
|
||||||
"flowChartSummingJunction" | "flowChartOr" => {
|
"flowChartSummingJunction" | "flowChartOr" => {
|
||||||
let e = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take();
|
let e = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take();
|
||||||
|
|
@ -1452,84 +1615,115 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||||
let n: usize = name.trim_start_matches("star").parse().unwrap_or(5);
|
let n: usize = name.trim_start_matches("star").parse().unwrap_or(5);
|
||||||
let v = a(0).clamp(0.0, 50000.0);
|
let v = a(0).clamp(0.0, 50000.0);
|
||||||
let inner = v / 50000.0;
|
let inner = v / 50000.0;
|
||||||
let normalized = matches!(n, 5 | 7);
|
let mut g = simple(b.poly(&star(n, w, h, inner)).take(), full.inset(-ss * (0.5 - inner * 0.5) * 0.5), w, h);
|
||||||
let mut g = simple(b.poly(&star(n, w, h, inner, normalized)).take(), full.inset(-ss * (0.5 - inner * 0.5) * 0.5), w, h);
|
|
||||||
g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0));
|
g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0));
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
"irregularSeal1" | "irregularSeal2" => {
|
"irregularSeal1" => {
|
||||||
let pts: &[(f64, f64)] = if name == "irregularSeal1" {
|
// ECMA-376 irregularSeal1 ("Explosion: 8 Points"), in 21600ths of the box.
|
||||||
|
let pts = spec_pts(
|
||||||
&[
|
&[
|
||||||
(0.48, 0.18),
|
(10800, 5800),
|
||||||
(0.62, 0.0),
|
(14522, 0),
|
||||||
(0.64, 0.2),
|
(14155, 5325),
|
||||||
(0.86, 0.1),
|
(18380, 4457),
|
||||||
(0.78, 0.3),
|
(16702, 7315),
|
||||||
(1.0, 0.36),
|
(21097, 8137),
|
||||||
(0.82, 0.5),
|
(17607, 10475),
|
||||||
(0.94, 0.72),
|
(21600, 13290),
|
||||||
(0.7, 0.66),
|
(16837, 12942),
|
||||||
(0.68, 0.9),
|
(18145, 18095),
|
||||||
(0.54, 0.72),
|
(14020, 14457),
|
||||||
(0.42, 1.0),
|
(13247, 19737),
|
||||||
(0.36, 0.74),
|
(10532, 14935),
|
||||||
(0.14, 0.86),
|
(8485, 21600),
|
||||||
(0.22, 0.62),
|
(7715, 15627),
|
||||||
(0.0, 0.6),
|
(4762, 17617),
|
||||||
(0.18, 0.44),
|
(5667, 13937),
|
||||||
(0.04, 0.24),
|
(135, 14587),
|
||||||
(0.3, 0.3),
|
(3722, 11775),
|
||||||
(0.26, 0.06),
|
(0, 8615),
|
||||||
]
|
(4627, 7617),
|
||||||
} else {
|
(370, 2295),
|
||||||
|
(7312, 6320),
|
||||||
|
(8352, 2295),
|
||||||
|
],
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
);
|
||||||
|
simple(b.poly(&pts).take(), Rect::new(w * 4627.0 / 21600.0, h * 6320.0 / 21600.0, w * 16702.0 / 21600.0, h * 13937.0 / 21600.0), w, h)
|
||||||
|
}
|
||||||
|
"irregularSeal2" => {
|
||||||
|
// ECMA-376 irregularSeal2 ("Explosion: 14 Points"), in 21600ths of the box.
|
||||||
|
let pts = spec_pts(
|
||||||
&[
|
&[
|
||||||
(0.5, 0.16),
|
(11462, 4342),
|
||||||
(0.58, 0.0),
|
(14790, 0),
|
||||||
(0.63, 0.18),
|
(14525, 5777),
|
||||||
(0.78, 0.06),
|
(18007, 3172),
|
||||||
(0.76, 0.24),
|
(16380, 6532),
|
||||||
(0.96, 0.2),
|
(21600, 6645),
|
||||||
(0.86, 0.38),
|
(16985, 9402),
|
||||||
(1.0, 0.5),
|
(18270, 11290),
|
||||||
(0.84, 0.58),
|
(16380, 12310),
|
||||||
(0.92, 0.76),
|
(18877, 15632),
|
||||||
(0.74, 0.72),
|
(14640, 14350),
|
||||||
(0.76, 0.94),
|
(14942, 17370),
|
||||||
(0.6, 0.8),
|
(12180, 15935),
|
||||||
(0.5, 1.0),
|
(11612, 18842),
|
||||||
(0.44, 0.8),
|
(9872, 17370),
|
||||||
(0.28, 0.94),
|
(8700, 19712),
|
||||||
(0.28, 0.74),
|
(7527, 18125),
|
||||||
(0.08, 0.82),
|
(4917, 21600),
|
||||||
(0.16, 0.62),
|
(4805, 18240),
|
||||||
(0.0, 0.54),
|
(1285, 17825),
|
||||||
(0.14, 0.44),
|
(3330, 15370),
|
||||||
(0.02, 0.28),
|
(0, 12877),
|
||||||
(0.22, 0.3),
|
(3935, 11592),
|
||||||
(0.2, 0.1),
|
(1172, 8270),
|
||||||
(0.36, 0.22),
|
(5372, 7817),
|
||||||
(0.4, 0.04),
|
(4502, 3625),
|
||||||
]
|
(8550, 6382),
|
||||||
};
|
(9722, 1887),
|
||||||
let pts: Vec<(f64, f64)> = pts.iter().map(|(x, y)| (x * w, y * h)).collect();
|
],
|
||||||
simple(b.poly(&pts).take(), Rect::new(w * 0.25, h * 0.3, w * 0.75, h * 0.7), w, h)
|
w,
|
||||||
|
h,
|
||||||
|
);
|
||||||
|
simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h)
|
||||||
}
|
}
|
||||||
"ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)),
|
"ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)),
|
||||||
"verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)),
|
"verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)),
|
||||||
"wave" | "doubleWave" => {
|
"wave" | "doubleWave" => {
|
||||||
let amp = h * a(0).clamp(0.0, 20000.0) / 1e5;
|
// ECMA-376 wave / doubleWave: the top and bottom edges are cubic waves (one or two
|
||||||
let shift = w * a(1).clamp(-10000.0, 10000.0) / 1e5;
|
// periods) of amplitude adj1, shifted sideways by adj2.
|
||||||
|
let a1 = a(0).clamp(0.0, if name == "wave" { 20000.0 } else { 12500.0 });
|
||||||
|
let a2 = a(1).clamp(-10000.0, 10000.0);
|
||||||
|
let y1 = h * a1 / 100000.0;
|
||||||
|
let dy2 = y1 * 10.0 / 3.0;
|
||||||
|
let (y2, y3, y4) = (y1 - dy2, y1 + dy2, h - y1);
|
||||||
|
let (y5, y6) = (y4 - dy2, y4 + dy2);
|
||||||
|
let of2 = w * a2 / 50000.0;
|
||||||
|
let dx2 = of2.min(0.0);
|
||||||
|
let dxr = of2.max(0.0);
|
||||||
|
let (x2, xr, xl, x10) = (-dx2, w - dxr, dxr, w + dx2);
|
||||||
|
let text_y = h * a1 / 50000.0;
|
||||||
if name == "wave" {
|
if name == "wave" {
|
||||||
b.m(shift.max(0.0), amp).c(w / 3.0, -amp, 2.0 * w / 3.0, 3.0 * amp, w + shift.min(0.0), amp);
|
let dx3 = (dx2 + xr) / 3.0;
|
||||||
b.l(w - shift.max(0.0), h - amp).c(2.0 * w / 3.0, h - 3.0 * amp, w / 3.0, h + amp, -shift.min(0.0), h - amp).z();
|
let (x3, x7) = (x2 + dx3, xl + dx3);
|
||||||
|
let (x4, x8) = ((x3 + xr) / 2.0, (x7 + x10) / 2.0);
|
||||||
|
b.m(x2, y1).c(x3, y2, x4, y3, xr, y1).l(x10, y4).c(x8, y6, x7, y5, xl, y4).z();
|
||||||
} else {
|
} else {
|
||||||
b.m(0.0, amp).c(w / 6.0, -amp, w / 3.0, 3.0 * amp, w / 2.0, amp).c(2.0 * w / 3.0, -amp, 5.0 * w / 6.0, 3.0 * amp, w, amp);
|
let (dx3, dx4) = ((dx2 + xr) / 6.0, (dx2 + xr) / 3.0);
|
||||||
b.l(w, h - amp)
|
let (x3, x4, x5) = (x2 + dx3, x2 + dx4, (x2 + xr) / 2.0);
|
||||||
.c(5.0 * w / 6.0, h - 3.0 * amp, 2.0 * w / 3.0, h + amp, w / 2.0, h - amp)
|
let x6 = x5 + dx3;
|
||||||
.c(w / 3.0, h - 3.0 * amp, w / 6.0, h + amp, 0.0, h - amp)
|
let x7 = (x6 + xr) / 2.0;
|
||||||
.z();
|
let (x10b, x11, x12) = (xl + dx3, xl + dx4, (xl + x10) / 2.0);
|
||||||
|
let x13 = x12 + dx3;
|
||||||
|
let x14 = (x13 + x10) / 2.0;
|
||||||
|
b.m(x2, y1).c(x3, y2, x4, y3, x5, y1).c(x6, y2, x7, y3, xr, y1).l(x10, y4);
|
||||||
|
b.c(x14, y6, x13, y5, x12, y4).c(x11, y6, x10b, y5, xl, y4).z();
|
||||||
}
|
}
|
||||||
simple(b.take(), Rect::new(0.0, amp * 2.0, w, h - amp * 2.0), w, h)
|
simple(b.take(), Rect::new(x2.max(xl), text_y, xr.min(x10), h - text_y), w, h)
|
||||||
}
|
}
|
||||||
"wedgeRectCallout" | "wedgeRoundRectCallout" => {
|
"wedgeRectCallout" | "wedgeRoundRectCallout" => {
|
||||||
let r = if name == "wedgeRoundRectCallout" { ss * a(2).clamp(0.0, 50000.0) / 1e5 } else { 0.0 };
|
let r = if name == "wedgeRoundRectCallout" { ss * a(2).clamp(0.0, 50000.0) / 1e5 } else { 0.0 };
|
||||||
|
|
@ -1646,36 +1840,61 @@ fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ECMA-376 cloud outline arcs (radii in 43200ths of the box, angles in 60000ths of a degree,
|
||||||
|
/// as DrawingML `arcTo` takes them), from the start point (3900, 14370).
|
||||||
|
const CLOUD_ARCS: [(f64, f64, f64, f64); 11] = [
|
||||||
|
(6753.0, 9190.0, -11429249.0, 7426832.0),
|
||||||
|
(5333.0, 7267.0, -8646143.0, 5396714.0),
|
||||||
|
(4365.0, 5945.0, -8748475.0, 5983381.0),
|
||||||
|
(4857.0, 6595.0, -7859164.0, 7034504.0),
|
||||||
|
(5333.0, 7273.0, -4722533.0, 6541615.0),
|
||||||
|
(6775.0, 9220.0, -2776035.0, 7816140.0),
|
||||||
|
(5785.0, 7867.0, 37501.0, 6842000.0),
|
||||||
|
(6752.0, 9215.0, 1347096.0, 6910353.0),
|
||||||
|
(7720.0, 10543.0, 3974558.0, 4542661.0),
|
||||||
|
(4360.0, 5918.0, -16496525.0, 8804134.0),
|
||||||
|
(4345.0, 5945.0, -14809710.0, 9151131.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The cloud's creases: short arcs inside the outline, each from its own start point.
|
||||||
|
const CLOUD_CREASES: [(f64, f64, f64, f64, f64, f64); 11] = [
|
||||||
|
(4693.0, 26177.0, 4345.0, 5945.0, 5204520.0, 1585770.0),
|
||||||
|
(6928.0, 34899.0, 4360.0, 5918.0, 4416628.0, 686848.0),
|
||||||
|
(16478.0, 39090.0, 6752.0, 9215.0, 8257449.0, 844866.0),
|
||||||
|
(28827.0, 34751.0, 6752.0, 9215.0, 387196.0, 959901.0),
|
||||||
|
(34129.0, 22954.0, 5785.0, 7867.0, -4217541.0, 4255042.0),
|
||||||
|
(41798.0, 15354.0, 5333.0, 7273.0, 1819082.0, 1665090.0),
|
||||||
|
(38324.0, 5426.0, 4857.0, 6595.0, -824660.0, 891534.0),
|
||||||
|
(29078.0, 3952.0, 4857.0, 6595.0, -8950887.0, 1091722.0),
|
||||||
|
(22141.0, 4720.0, 4365.0, 5945.0, -9809656.0, 1061181.0),
|
||||||
|
(14000.0, 5192.0, 6753.0, 9190.0, -4002417.0, 739161.0),
|
||||||
|
(4127.0, 15789.0, 6753.0, 9190.0, 9459261.0, 711490.0),
|
||||||
|
];
|
||||||
|
|
||||||
fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||||
let n = 10;
|
// Drawn in the path's own 43200-unit square, then stretched to the box (as DrawingML lays out
|
||||||
let pts: Vec<Point> = (0..n)
|
// a path with its own w and h), so the arcs keep their shape's proportions at any aspect.
|
||||||
.map(|i| {
|
let to_box = kurbo::Affine::scale_non_uniform(w / 43200.0, h / 43200.0);
|
||||||
let t = (i as f64 / n as f64 * 360.0 - 90.0).to_radians();
|
let deg = |v: f64| v / 60000.0;
|
||||||
let wob = if i % 2 == 0 { 1.0 } else { 0.92 };
|
|
||||||
Point::new(0.5 + 0.40 * wob * t.cos(), 0.5 + 0.36 * wob * t.sin())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut b = Pb::new();
|
let mut b = Pb::new();
|
||||||
let first = pts.first().copied().unwrap_or(Point::ZERO);
|
b.m(3900.0, 14370.0);
|
||||||
b.m(first.x, first.y);
|
for (wr, hr, st, sw) in CLOUD_ARCS {
|
||||||
for i in 0..n {
|
b.arc_spec(wr, hr, deg(st), deg(sw));
|
||||||
let p = pts.get(i).copied().unwrap_or(Point::ZERO);
|
|
||||||
let q = pts.get((i + 1) % n).copied().unwrap_or(Point::ZERO);
|
|
||||||
let mid = p.midpoint(q);
|
|
||||||
let out = (mid - Point::new(0.5, 0.5)).normalize();
|
|
||||||
let bulge = (q - p).hypot() * 0.75;
|
|
||||||
let c1 = p + (mid - p) * 0.2 + out * bulge;
|
|
||||||
let c2 = q + (mid - q) * 0.2 + out * bulge;
|
|
||||||
b.c(c1.x, c1.y, c2.x, c2.y, q.x, q.y);
|
|
||||||
}
|
}
|
||||||
b.z();
|
b.z();
|
||||||
let mut path = b.take();
|
let mut creases = Pb::new();
|
||||||
let bb = path.bounding_box();
|
for (x, y, wr, hr, st, sw) in CLOUD_CREASES {
|
||||||
let sx = if bb.width() > 0.0 { w / bb.width() } else { 1.0 };
|
creases.m(x, y).arc_spec(wr, hr, deg(st), deg(sw));
|
||||||
let sy = if bb.height() > 0.0 { h / bb.height() } else { 1.0 };
|
}
|
||||||
path.apply_affine(kurbo::Affine::scale_non_uniform(sx, sy) * kurbo::Affine::translate(-bb.origin().to_vec2()));
|
let mut outline = b.take();
|
||||||
let mut paths = vec![fill(path)];
|
outline.apply_affine(to_box);
|
||||||
let text_rect = Rect::new(w * 0.2, h * 0.22, w * 0.8, h * 0.78);
|
let mut paths = vec![fill(outline)];
|
||||||
|
if name == "cloud" {
|
||||||
|
let mut c = creases.take();
|
||||||
|
c.apply_affine(to_box);
|
||||||
|
paths.push(stroke_only(c));
|
||||||
|
}
|
||||||
|
let text_rect = Rect::new(w * 2977.0 / 21600.0, h * 3262.0 / 21600.0, w * 17087.0 / 21600.0, h * 17337.0 / 21600.0);
|
||||||
let mut handles = vec![];
|
let mut handles = vec![];
|
||||||
if name == "cloudCallout" {
|
if name == "cloudCallout" {
|
||||||
let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5);
|
let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5);
|
||||||
|
|
@ -1693,71 +1912,119 @@ fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||||
Geometry { paths, text_rect, handles, sites: mid_sites(w, h) }
|
Geometry { paths, text_rect, handles, sites: mid_sites(w, h) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The equation shapes, by ECMA-376's formulas (checked against PowerPoint's outlines at several
|
||||||
|
/// sizes and adjust values). Their bars span 73.49% of the width; the plus and multiply bars are
|
||||||
|
/// `adj1` of the shorter side thick, the others' `adj1` of the height.
|
||||||
fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
|
||||||
|
let pin = |lo: f64, v: f64, hi: f64| v.max(lo).min(hi.max(lo));
|
||||||
let ss = w.min(h);
|
let ss = w.min(h);
|
||||||
let t = ss * a0.clamp(0.0, 50000.0) / 1e5;
|
let (hc, vc) = (w / 2.0, h / 2.0);
|
||||||
let (cx, cy) = (w / 2.0, h / 2.0);
|
let dx1 = w * 73490.0 / 200000.0;
|
||||||
let mx = w * 0.0735;
|
let (x1, x2) = (hc - dx1, hc + dx1);
|
||||||
let my = h * 0.0735;
|
|
||||||
let mut b = Pb::new();
|
let mut b = Pb::new();
|
||||||
match name {
|
let text = match name {
|
||||||
"mathPlus" => {
|
"mathPlus" => {
|
||||||
let (hx, hy) = (cx - mx, cy - my);
|
let a = pin(0.0, a0, 73490.0);
|
||||||
b.poly(&[
|
let dy1 = h * 73490.0 / 200000.0;
|
||||||
(cx - t / 2.0, cy - hy),
|
let dx2 = ss * a / 200000.0;
|
||||||
(cx + t / 2.0, cy - hy),
|
let (y1, y2, y3, y4) = (vc - dy1, vc - dx2, vc + dx2, vc + dy1);
|
||||||
(cx + t / 2.0, cy - t / 2.0),
|
let (xa, xb) = (hc - dx2, hc + dx2);
|
||||||
(cx + hx, cy - t / 2.0),
|
b.poly(&[(x1, y2), (xa, y2), (xa, y1), (xb, y1), (xb, y2), (x2, y2), (x2, y3), (xb, y3), (xb, y4), (xa, y4), (xa, y3), (x1, y3)]);
|
||||||
(cx + hx, cy + t / 2.0),
|
Rect::new(x1, y2, x2, y3)
|
||||||
(cx + t / 2.0, cy + t / 2.0),
|
|
||||||
(cx + t / 2.0, cy + hy),
|
|
||||||
(cx - t / 2.0, cy + hy),
|
|
||||||
(cx - t / 2.0, cy + t / 2.0),
|
|
||||||
(cx - hx, cy + t / 2.0),
|
|
||||||
(cx - hx, cy - t / 2.0),
|
|
||||||
(cx - t / 2.0, cy - t / 2.0),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
"mathMinus" => {
|
"mathMinus" => {
|
||||||
b.poly(&[(mx, cy - t / 2.0), (w - mx, cy - t / 2.0), (w - mx, cy + t / 2.0), (mx, cy + t / 2.0)]);
|
let dy1 = h * pin(0.0, a0, 100000.0) / 200000.0;
|
||||||
|
b.poly(&[(x1, vc - dy1), (x2, vc - dy1), (x2, vc + dy1), (x1, vc + dy1)]);
|
||||||
|
Rect::new(x1, vc - dy1, x2, vc + dy1)
|
||||||
}
|
}
|
||||||
"mathEqual" => {
|
"mathEqual" => {
|
||||||
let gap = h * a1.clamp(0.0, 100000.0) / 1e5;
|
let a = pin(0.0, a0, 36745.0);
|
||||||
b.poly(&[(mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0), (mx, cy - gap / 2.0)]);
|
let g = pin(0.0, a1, 100000.0 - 2.0 * a);
|
||||||
b.poly(&[(mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0 + t), (mx, cy + gap / 2.0 + t)]);
|
let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0);
|
||||||
|
let (y2, y3) = (vc - dy2, vc + dy2);
|
||||||
|
let (y1, y4) = (y2 - dy1, y3 + dy1);
|
||||||
|
b.poly(&[(x1, y1), (x2, y1), (x2, y2), (x1, y2)]);
|
||||||
|
b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]);
|
||||||
|
Rect::new(x1, y1, x2, y4)
|
||||||
}
|
}
|
||||||
"mathNotEqual" => {
|
"mathNotEqual" => {
|
||||||
let gap = h * a2.clamp(0.0, 100000.0) / 1e5;
|
let a = pin(0.0, a0, 50000.0);
|
||||||
b.poly(&[(mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0), (mx, cy - gap / 2.0)]);
|
let cr_ang = pin(4200000.0, a1, 6600000.0);
|
||||||
b.poly(&[(mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0 + t), (mx, cy + gap / 2.0 + t)]);
|
let g = pin(0.0, a2, 100000.0 - 2.0 * a);
|
||||||
let ang = (a1 / 60000.0).to_radians();
|
let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0);
|
||||||
let dir = Vec2::new(ang.cos(), ang.sin());
|
let hd2 = (h / 2.0).max(1e-9);
|
||||||
let len = (h / 2.0 - my) / ang.sin().abs().max(0.2);
|
let (y2, y3) = (vc - dy2, vc + dy2);
|
||||||
let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0);
|
let (y1, y4) = (y2 - dy1, y3 + dy1);
|
||||||
let c = Point::new(cx, cy);
|
let cadj2 = cr_ang - 5400000.0;
|
||||||
let p = [c - dir * len - perp, c + dir * len - perp, c + dir * len + perp, c - dir * len + perp];
|
let xadj2 = hd2 * (cadj2 / 60000.0).to_radians().tan();
|
||||||
b.poly(&p.map(|q| (q.x, q.y)));
|
let len = xadj2.hypot(hd2);
|
||||||
|
let bhw = len * dy1 / hd2;
|
||||||
|
let x7 = hc + xadj2 - bhw / 2.0;
|
||||||
|
let at = |y: f64| x7 - xadj2 * y / hd2;
|
||||||
|
let (x6, x5, x4, x3) = (at(y1), at(y2), at(y3), at(y4));
|
||||||
|
let rx7 = x7 + bhw;
|
||||||
|
let dx7 = dy1 * hd2 / len;
|
||||||
|
let dy3 = dy1 * xadj2 / len;
|
||||||
|
let (rx, lx, ry, ly) = if cadj2 > 0.0 { (x7 + dx7, x7, dy3, 0.0) } else { (rx7, rx7 - dx7, 0.0, -dy3) };
|
||||||
|
b.poly(&[
|
||||||
|
(x1, y1),
|
||||||
|
(x6, y1),
|
||||||
|
(lx, ly),
|
||||||
|
(rx, ry),
|
||||||
|
(x6 + bhw, y1),
|
||||||
|
(x2, y1),
|
||||||
|
(x2, y2),
|
||||||
|
(x5 + bhw, y2),
|
||||||
|
(x4 + bhw, y3),
|
||||||
|
(x2, y3),
|
||||||
|
(x2, y4),
|
||||||
|
(x3 + bhw, y4),
|
||||||
|
(w - lx, h - ly),
|
||||||
|
(w - rx, h - ry),
|
||||||
|
(x3, y4),
|
||||||
|
(x1, y4),
|
||||||
|
(x1, y3),
|
||||||
|
(x4, y3),
|
||||||
|
(x5, y2),
|
||||||
|
(x1, y2),
|
||||||
|
]);
|
||||||
|
Rect::new(x1, y1, x2, y4)
|
||||||
}
|
}
|
||||||
"mathDivide" => {
|
"mathDivide" => {
|
||||||
let gap = h * a1.clamp(0.0, 100000.0) / 1e5;
|
let a = pin(1000.0, a0, 36745.0);
|
||||||
let r = ss * a2.clamp(0.0, 50000.0) / 1e5 / 2.0;
|
let max3 = ((73490.0 - a) / 4.0).min(36745.0 * w / h.max(1e-9));
|
||||||
b.poly(&[(mx, cy - t / 2.0), (w - mx, cy - t / 2.0), (w - mx, cy + t / 2.0), (mx, cy + t / 2.0)]);
|
let r3 = pin(1000.0, a2, max3);
|
||||||
b.ellipse(cx, cy - t / 2.0 - gap - r, r, r);
|
let g = pin(0.0, a1, 73490.0 - 4.0 * r3 - a);
|
||||||
b.ellipse(cx, cy + t / 2.0 + gap + r, r, r);
|
let dy1 = h * a / 200000.0;
|
||||||
|
let yg = h * g / 100000.0;
|
||||||
|
let rad = h * r3 / 100000.0;
|
||||||
|
let (y3, y4) = (vc - dy1, vc + dy1);
|
||||||
|
let y2 = y3 - (yg + rad);
|
||||||
|
let y1 = y2 - rad;
|
||||||
|
b.ellipse(hc, y1 + rad, rad, rad);
|
||||||
|
b.ellipse(hc, h - y1 - rad, rad, rad);
|
||||||
|
b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]);
|
||||||
|
Rect::new(x1, y3, x2, y4)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// mathMultiply: two bars crossing at the centre along the diagonals.
|
// mathMultiply: an X whose arms run along the box's diagonals.
|
||||||
let len = ((cx - mx).powi(2) + (cy - my).powi(2)).sqrt();
|
let th = ss * pin(0.0, a0, 51965.0) / 100000.0;
|
||||||
let c = Point::new(cx, cy);
|
let ang = h.atan2(w);
|
||||||
for d in [Vec2::new(cx - mx, cy - my), Vec2::new(cx - mx, -(cy - my))] {
|
let (sa, ca, ta) = (ang.sin(), ang.cos(), ang.tan().max(1e-9));
|
||||||
let dir = d / d.hypot().max(1e-9);
|
let lm = w.hypot(h) * (1.0 - 51965.0 / 100000.0);
|
||||||
let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0);
|
let (xm, ym) = (ca * lm / 2.0, sa * lm / 2.0);
|
||||||
let p = [c - dir * len - perp, c + dir * len - perp, c + dir * len + perp, c - dir * len + perp];
|
let (dxam, dyam) = (sa * th / 2.0, ca * th / 2.0);
|
||||||
b.poly(&p.map(|q| (q.x, q.y)));
|
let (xa, ya, xb, yb) = (xm - dxam, ym + dyam, xm + dxam, ym - dyam);
|
||||||
}
|
let yc = (hc - xb) * ta + yb;
|
||||||
|
let (xd, xe) = (w - xb, w - xa);
|
||||||
|
let xfe = (vc - ya) / ta;
|
||||||
|
let (xf, xl) = (xe - xfe, xa + xfe);
|
||||||
|
let (yg, yh, yi) = (h - ya, h - yb, h - yc);
|
||||||
|
b.poly(&[(xa, ya), (xb, yb), (hc, yc), (xd, yb), (xe, ya), (xf, vc), (xe, yg), (xd, yh), (hc, yi), (xb, yh), (xa, yg), (xl, vc)]);
|
||||||
|
Rect::new(xa, yb, xe, yh)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
Geometry { paths: vec![fill(b.take())], text_rect: Rect::new(mx, cy - t / 2.0, w - mx, cy + t / 2.0), handles: vec![], sites: mid_sites(w, h) }
|
Geometry { paths: vec![fill(b.take())], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
fn ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
|
||||||
|
|
@ -1968,4 +2235,158 @@ mod tests {
|
||||||
assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open());
|
assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open());
|
||||||
assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open());
|
assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_path(g: &Geometry, p: Point, tol: f64) -> bool {
|
||||||
|
use kurbo::ParamCurveNearest;
|
||||||
|
g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_shapes_follow_the_standards_coordinates() {
|
||||||
|
// Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box.
|
||||||
|
let (w, h) = (216.0, 108.0);
|
||||||
|
let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0);
|
||||||
|
let g = build("lightningBolt", w, h, &[]).unwrap();
|
||||||
|
for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] {
|
||||||
|
assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})");
|
||||||
|
}
|
||||||
|
let g = build("irregularSeal1", w, h, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6));
|
||||||
|
let g = build("irregularSeal2", w, h, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6));
|
||||||
|
// The heart's point is the middle of the bottom edge, its notch a quarter down.
|
||||||
|
let g = build("heart", w, h, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6));
|
||||||
|
let g = build("flowChartDocument", w, h, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6));
|
||||||
|
// Punched tape: the top edge dips to a fifth of the height a quarter of the way along.
|
||||||
|
let g = build("flowChartPunchedTape", w, h, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3));
|
||||||
|
let g = build("flowChartMultidocument", w, h, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equation_shapes_match_powerpoints_outlines() {
|
||||||
|
// Corners read off PowerPoint's own outlines of the presets at their default adjusts.
|
||||||
|
let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap();
|
||||||
|
for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] {
|
||||||
|
assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})");
|
||||||
|
}
|
||||||
|
let g = build("mathEqual", 300.0, 120.0, &[]).unwrap();
|
||||||
|
for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] {
|
||||||
|
assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})");
|
||||||
|
}
|
||||||
|
let g = build("mathPlus", 216.0, 216.0, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01));
|
||||||
|
// A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches
|
||||||
|
// past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242).
|
||||||
|
let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap();
|
||||||
|
use kurbo::Shape as _;
|
||||||
|
let bb = g.paths[0].path.bounding_box();
|
||||||
|
assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sun_and_waves_match_powerpoints_outlines() {
|
||||||
|
// Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts).
|
||||||
|
let k = 216.0;
|
||||||
|
let g = build("sun", k, k, &[]).unwrap();
|
||||||
|
for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] {
|
||||||
|
assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})");
|
||||||
|
}
|
||||||
|
let g = build("wave", k, k, &[]).unwrap();
|
||||||
|
for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] {
|
||||||
|
assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})");
|
||||||
|
}
|
||||||
|
// Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in.
|
||||||
|
let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap();
|
||||||
|
for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] {
|
||||||
|
assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stars_are_stretched_like_the_standards() {
|
||||||
|
// star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an
|
||||||
|
// ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom
|
||||||
|
// points reach the box's bottom edge.
|
||||||
|
let k = 216.0;
|
||||||
|
let g = build("star5", k, k, &[]).unwrap();
|
||||||
|
let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557);
|
||||||
|
let a = 18f64.to_radians();
|
||||||
|
assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6));
|
||||||
|
assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6));
|
||||||
|
use kurbo::Shape as _;
|
||||||
|
let bb = g.paths[0].path.bounding_box();
|
||||||
|
assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}");
|
||||||
|
// star6 is 15.47% wider than its box's half-width allows (its points touch the sides).
|
||||||
|
let g = build("star6", k, k, &[]).unwrap();
|
||||||
|
let bb = g.paths[0].path.bounding_box();
|
||||||
|
assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moon_disk_and_drum_match_powerpoints_outlines() {
|
||||||
|
let k = 216.0;
|
||||||
|
// Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box.
|
||||||
|
let g = build("moon", k, k, &[]).unwrap();
|
||||||
|
for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] {
|
||||||
|
assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})");
|
||||||
|
}
|
||||||
|
// 300 × 120 at 30%: its inner edge reaches 30% of the width in.
|
||||||
|
let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap();
|
||||||
|
assert!(on_path(&g, Point::new(90.0, 60.0), 0.5));
|
||||||
|
// Magnetic disk ends are ellipses h/6 high, drum ends w/6 across.
|
||||||
|
let g = build("flowChartMagneticDisk", k, k, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5));
|
||||||
|
let g = build("flowChartMagneticDrum", k, k, &[]).unwrap();
|
||||||
|
assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() {
|
||||||
|
use kurbo::Shape as _;
|
||||||
|
for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] {
|
||||||
|
let g = build("cloud", w, h, &[]).unwrap();
|
||||||
|
let bb = g.paths[0].path.bounding_box();
|
||||||
|
assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}");
|
||||||
|
assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}");
|
||||||
|
assert_eq!(g.paths.len(), 2, "outline and creases");
|
||||||
|
assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spec_arcs_take_angles_as_directions_from_the_centre() {
|
||||||
|
// A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the
|
||||||
|
// centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it.
|
||||||
|
let mut b = Pb::new();
|
||||||
|
b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0);
|
||||||
|
let p = b.take();
|
||||||
|
let end = p.elements().last().and_then(|e| match e {
|
||||||
|
kurbo::PathEl::CurveTo(_, _, q) => Some(*q),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
// Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1.
|
||||||
|
let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt();
|
||||||
|
let c = Point::new(-k, -k);
|
||||||
|
let q = end.unwrap();
|
||||||
|
assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brackets_and_braces_fill_out_to_their_straight_side() {
|
||||||
|
// PowerPoint fills a bracket or brace (when it has a fill) between its curve and the
|
||||||
|
// straight side it opens to; only the curve is outlined.
|
||||||
|
for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] {
|
||||||
|
let g = build(name, 40.0, 100.0, &[]).unwrap();
|
||||||
|
let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect();
|
||||||
|
assert_eq!(filled.len(), 1, "{name}");
|
||||||
|
assert!(!filled[0].stroke, "{name}: the filled area is not outlined");
|
||||||
|
let area = filled[0].path.area().abs();
|
||||||
|
assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}");
|
||||||
|
assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,15 @@ pub struct Gradient {
|
||||||
pub shape: GradientShape,
|
pub shape: GradientShape,
|
||||||
#[serde(default = "crate::yes")]
|
#[serde(default = "crate::yes")]
|
||||||
pub rotate_with_shape: bool,
|
pub rotate_with_shape: bool,
|
||||||
|
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
|
||||||
|
/// (fractions; negative reaches outside it). PowerPoint's "from corner" radial presets use it
|
||||||
|
/// to centre the gradient on the corner.
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero4")]
|
||||||
|
pub tile: [f64; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_zero4(v: &[f64; 4]) -> bool {
|
||||||
|
v.iter().all(|x| *x == 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a picture fills its box: stretched into a (fractional) inset rect, or tiled.
|
/// How a picture fills its box: stretched into a (fractional) inset rect, or tiled.
|
||||||
|
|
@ -130,6 +139,10 @@ pub struct PictureFill {
|
||||||
/// 0–1 opacity of the picture.
|
/// 0–1 opacity of the picture.
|
||||||
pub alpha: Option<f64>,
|
pub alpha: Option<f64>,
|
||||||
pub adjust: PictureAdjust,
|
pub adjust: PictureAdjust,
|
||||||
|
/// `rotWithShape="0"`: the picture keeps to the slide's axes when its shape is rotated or
|
||||||
|
/// flipped.
|
||||||
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub upright: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency).
|
/// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency).
|
||||||
|
|
@ -148,6 +161,12 @@ pub struct PictureAdjust {
|
||||||
/// Make a colour transparent (Set Transparent Color).
|
/// Make a colour transparent (Set Transparent Color).
|
||||||
pub clear_color: Option<Rgba>,
|
pub clear_color: Option<Rgba>,
|
||||||
pub sharpen: f64,
|
pub sharpen: f64,
|
||||||
|
/// `a:biLevel`: black and white, split at this luminance (0–1).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bilevel: Option<f64>,
|
||||||
|
/// `a:alphaInv`: opacity inverted.
|
||||||
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub alpha_inv: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,8 @@ fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||||
let p = Presentation::default();
|
let p = Presentation::default();
|
||||||
let s = &p.slides[0];
|
let s = &p.slides[0];
|
||||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||||
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
let own =
|
||||||
|
style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||||
st.fill_ref.0 = 3;
|
st.fill_ref.0 = 3;
|
||||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ fn ph() -> ColorRef {
|
||||||
pub fn default_format() -> FormatScheme {
|
pub fn default_format() -> FormatScheme {
|
||||||
let grad = |a: i32, b: i32| {
|
let grad = |a: i32, b: i32| {
|
||||||
Fill::Gradient(Gradient {
|
Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![
|
stops: vec![
|
||||||
GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) },
|
GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) },
|
||||||
GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) },
|
GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) },
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,8 @@ pub fn gradient(f: &El) -> Gradient {
|
||||||
} else {
|
} else {
|
||||||
GradientShape::Linear { angle: 90.0, scaled: false }
|
GradientShape::Linear { angle: 90.0, scaled: false }
|
||||||
};
|
};
|
||||||
Gradient { stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
|
let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0)));
|
||||||
|
Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media.
|
/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media.
|
||||||
|
|
@ -124,7 +125,7 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option<PictureFill> {
|
||||||
let blip = bf.child("blip");
|
let blip = bf.child("blip");
|
||||||
let media = blip.and_then(|b| media_ref(imp, part, b));
|
let media = blip.and_then(|b| media_ref(imp, part, b));
|
||||||
let media = media?;
|
let media = media?;
|
||||||
let mut pf = PictureFill { media, ..Default::default() };
|
let mut pf = PictureFill { media, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() };
|
||||||
if let Some(b) = blip {
|
if let Some(b) = blip {
|
||||||
for e in b.elements() {
|
for e in b.elements() {
|
||||||
match e.local() {
|
match e.local() {
|
||||||
|
|
@ -134,6 +135,8 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option<PictureFill> {
|
||||||
pf.adjust.contrast = pct(e, "contrast").unwrap_or(0.0).clamp(-1.0, 1.0);
|
pf.adjust.contrast = pct(e, "contrast").unwrap_or(0.0).clamp(-1.0, 1.0);
|
||||||
}
|
}
|
||||||
"grayscl" => pf.adjust.grayscale = true,
|
"grayscl" => pf.adjust.grayscale = true,
|
||||||
|
"biLevel" => pf.adjust.bilevel = Some(pct(e, "thresh").unwrap_or(0.5).clamp(0.0, 1.0)),
|
||||||
|
"alphaInv" => pf.adjust.alpha_inv = true,
|
||||||
"duotone" => {
|
"duotone" => {
|
||||||
let cs: Vec<ColorRef> = e.elements().filter_map(color_el).collect();
|
let cs: Vec<ColorRef> = e.elements().filter_map(color_el).collect();
|
||||||
if let [a, b] = cs.as_slice() {
|
if let [a, b] = cs.as_slice() {
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,9 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
|
||||||
}
|
}
|
||||||
|
|
||||||
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
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() };
|
// A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table
|
||||||
|
// Grid"), not without lines.
|
||||||
|
let mut t = Table { style: "grid".into(), first_row: false, band_row: false, ..Default::default() };
|
||||||
if let Some(pr) = tbl.child("tblPr") {
|
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.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_row = pr.bool("firstRow").unwrap_or(false);
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,16 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||||
/// `a:blipFill` / `p:blipFill`.
|
/// `a:blipFill` / `p:blipFill`.
|
||||||
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
|
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
|
||||||
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
|
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
|
||||||
w.open(tag, A::new().a("rotWithShape", "1"));
|
w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));
|
||||||
let adj = &pf.adjust;
|
let adj = &pf.adjust;
|
||||||
let has_kids =
|
let has_kids = pf.alpha.is_some()
|
||||||
pf.alpha.is_some() || adj.brightness != 0.0 || adj.contrast != 0.0 || adj.grayscale || adj.duotone.is_some() || adj.clear_color.is_some();
|
|| adj.brightness != 0.0
|
||||||
|
|| adj.contrast != 0.0
|
||||||
|
|| adj.grayscale
|
||||||
|
|| adj.duotone.is_some()
|
||||||
|
|| adj.clear_color.is_some()
|
||||||
|
|| adj.bilevel.is_some()
|
||||||
|
|| adj.alpha_inv;
|
||||||
let a = A::new().a("r:embed", &rid);
|
let a = A::new().a("r:embed", &rid);
|
||||||
if has_kids {
|
if has_kids {
|
||||||
w.open("a:blip", a);
|
w.open("a:blip", a);
|
||||||
|
|
@ -116,6 +122,12 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil
|
||||||
if let Some(al) = pf.alpha {
|
if let Some(al) = pf.alpha {
|
||||||
w.empty("a:alphaModFix", A::new().a("amt", pct(al.clamp(0.0, 1.0))));
|
w.empty("a:alphaModFix", A::new().a("amt", pct(al.clamp(0.0, 1.0))));
|
||||||
}
|
}
|
||||||
|
if adj.alpha_inv {
|
||||||
|
w.empty0("a:alphaInv");
|
||||||
|
}
|
||||||
|
if let Some(t) = adj.bilevel {
|
||||||
|
w.empty("a:biLevel", A::new().a("thresh", pct(t.clamp(0.0, 1.0))));
|
||||||
|
}
|
||||||
if let Some((d, l)) = &adj.duotone {
|
if let Some((d, l)) = &adj.duotone {
|
||||||
w.open0("a:duotone");
|
w.open0("a:duotone");
|
||||||
color(w, d);
|
color(w, d);
|
||||||
|
|
@ -272,6 +284,10 @@ fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
|
||||||
w.close("a:path");
|
w.close("a:path");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if g.tile.iter().any(|v| *v != 0.0) {
|
||||||
|
let [l, t, r, b] = g.tile;
|
||||||
|
w.empty("a:tileRect", A::new().a("l", pct(l)).a("t", pct(t)).a("r", pct(r)).a("b", pct(b)));
|
||||||
|
}
|
||||||
w.close("a:gradFill");
|
w.close("a:gradFill");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ fn a_gradient_without_stops_is_saved_without_stops() {
|
||||||
let mut p = defaults::new_presentation(None);
|
let mut p = defaults::new_presentation(None);
|
||||||
let id = ShapeId(p.alloc_id());
|
let id = ShapeId(p.alloc_id());
|
||||||
let first = p.slides[0].id;
|
let first = p.slides[0].id;
|
||||||
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||||
id,
|
id,
|
||||||
name: "Styled".into(),
|
name: "Styled".into(),
|
||||||
|
|
@ -112,6 +112,7 @@ fn a_fill_overlay_round_trips() {
|
||||||
let overlay = FillOverlay {
|
let overlay = FillOverlay {
|
||||||
blend: "mult".into(),
|
blend: "mult".into(),
|
||||||
fill: Fill::Gradient(Gradient {
|
fill: Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![
|
stops: vec![
|
||||||
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
|
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
|
||||||
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
|
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
|
||||||
|
|
@ -133,3 +134,118 @@ fn a_fill_overlay_round_trips() {
|
||||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape");
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape");
|
||||||
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
|
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gradient_tile_rect_is_read_and_round_trips() {
|
||||||
|
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||||
|
// PowerPoint's "from top-left corner" radial: focus on the corner, tile reaching a box up and
|
||||||
|
// to the left so it centres on that corner.
|
||||||
|
let g = Gradient {
|
||||||
|
stops: vec![
|
||||||
|
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 128, 255)) },
|
||||||
|
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE) },
|
||||||
|
],
|
||||||
|
shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] },
|
||||||
|
rotate_with_shape: true,
|
||||||
|
tile: [-1.0, -1.0, 0.0, 0.0],
|
||||||
|
};
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let id = ShapeId(p.alloc_id());
|
||||||
|
let first = p.slides[0].id;
|
||||||
|
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||||
|
id,
|
||||||
|
name: "Corner".into(),
|
||||||
|
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||||
|
fill: Some(Fill::Gradient(g.clone())),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||||
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape");
|
||||||
|
assert_eq!(s.fill, Some(Fill::Gradient(g)));
|
||||||
|
// As written by PowerPoint (attributes it leaves out are 0).
|
||||||
|
let edited =
|
||||||
|
edit_slides(&bytes, |x| x.replace("<a:tileRect l=\"-100000\" t=\"-100000\" r=\"0\" b=\"0\"/>", "<a:tileRect l=\"-100000\" t=\"-100000\"/>"));
|
||||||
|
let q = deckcraft_pptx::import(&edited).expect("import");
|
||||||
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape");
|
||||||
|
assert!(matches!(&s.fill, Some(Fill::Gradient(g)) if g.tile == [-1.0, -1.0, 0.0, 0.0]), "{:?}", s.fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_naming_no_style_is_drawn_with_the_table_grid() {
|
||||||
|
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(2, 2, 200.0, 40.0);
|
||||||
|
t.style = "none".into();
|
||||||
|
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||||
|
id,
|
||||||
|
name: "Grid".into(),
|
||||||
|
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 80.0)),
|
||||||
|
kind: deckcraft_model::ShapeKind::Table(t),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
let style = |bytes: &[u8]| {
|
||||||
|
let q = deckcraft_pptx::import(bytes).expect("import");
|
||||||
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "Grid").expect("shape").clone();
|
||||||
|
match s.kind {
|
||||||
|
deckcraft_model::ShapeKind::Table(t) => t.style,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Written with its style, it reads back as written.
|
||||||
|
assert_eq!(style(&bytes), "none");
|
||||||
|
// Without a:tableStyleId PowerPoint draws thin dark grid lines.
|
||||||
|
let bare = edit_slides(&bytes, |x| {
|
||||||
|
let (Some(a), Some(b)) = (x.find("<a:tableStyleId>"), x.find("</a:tableStyleId>")) else { return x };
|
||||||
|
format!("{}{}", &x[..a], &x[b + "</a:tableStyleId>".len()..])
|
||||||
|
});
|
||||||
|
assert_eq!(style(&bare), "grid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn black_and_white_and_inverted_alpha_pictures_round_trip() {
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let media = p.add_media("tile.png", "image/png", png());
|
||||||
|
let id = ShapeId(p.alloc_id());
|
||||||
|
let first = p.slides[0].id;
|
||||||
|
let mut pf = PictureFill { media, ..Default::default() };
|
||||||
|
pf.adjust.bilevel = Some(0.3);
|
||||||
|
pf.adjust.alpha_inv = true;
|
||||||
|
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||||
|
id,
|
||||||
|
name: "BW".into(),
|
||||||
|
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||||
|
fill: Some(Fill::Picture(pf)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||||
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "BW").expect("shape");
|
||||||
|
match &s.fill {
|
||||||
|
Some(Fill::Picture(pf)) => assert_eq!((pf.adjust.bilevel, pf.adjust.alpha_inv), (Some(0.3), true)),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upright_picture_fill_round_trips() {
|
||||||
|
let bytes = deck();
|
||||||
|
let edited = edit_slides(&bytes, |x| x.replacen("rotWithShape=\"1\"", "rotWithShape=\"0\"", 1));
|
||||||
|
let q = deckcraft_pptx::import(&edited).expect("import");
|
||||||
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
|
||||||
|
let Some(Fill::Picture(pf)) = &s.fill else { panic!("{:?}", s.fill) };
|
||||||
|
assert!(pf.upright);
|
||||||
|
let again = deckcraft_pptx::export(&q).expect("export");
|
||||||
|
let r = deckcraft_pptx::import(&again).expect("import");
|
||||||
|
let s = r.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
|
||||||
|
assert!(matches!(&s.fill, Some(Fill::Picture(pf)) if pf.upright));
|
||||||
|
// Rotating with the shape is the default.
|
||||||
|
assert!(matches!(textured_fill(&bytes), Some(Fill::Picture(pf)) if !pf.upright));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn textured_fill(bytes: &[u8]) -> Option<Fill> {
|
||||||
|
let q = deckcraft_pptx::import(bytes).expect("import");
|
||||||
|
q.slides[0].shapes.iter().find(|s| s.name == "Textured").and_then(|s| s.fill.clone())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ pub fn rich_deck() -> Presentation {
|
||||||
let mut a = shape(&mut p, "Gradient Star", 100.0, 120.0, 150.0, 150.0);
|
let mut a = shape(&mut p, "Gradient Star", 100.0, 120.0, 150.0, 150.0);
|
||||||
a.geom = Geom::Preset { name: "star5".into(), adj: vec![20000.0] };
|
a.geom = Geom::Preset { name: "star5".into(), adj: vec![20000.0] };
|
||||||
a.fill = Some(Fill::Gradient(Gradient {
|
a.fill = Some(Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![
|
stops: vec![
|
||||||
GradientStop { pos: 0.0, color: ColorRef::scheme(SchemeSlot::Accent1) },
|
GradientStop { pos: 0.0, color: ColorRef::scheme(SchemeSlot::Accent1) },
|
||||||
GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)) },
|
GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)) },
|
||||||
|
|
@ -230,6 +231,7 @@ pub fn rich_deck() -> Presentation {
|
||||||
mode: PictureMode::Stretch { fill_rect: [0.0; 4] },
|
mode: PictureMode::Stretch { fill_rect: [0.0; 4] },
|
||||||
alpha: Some(0.5),
|
alpha: Some(0.5),
|
||||||
adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() },
|
adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() },
|
||||||
|
upright: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0);
|
let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
//! 3-D bevels (`a:sp3d/a:bevelT` seen through a `a:scene3d`), approximated in 2-D: the shape's
|
||||||
|
//! face and a band inside its outline as wide as the bevel are lit as surfaces, the band's normal
|
||||||
|
//! tilting out towards the outline by the bevel profile's slope.
|
||||||
|
//!
|
||||||
|
//! Fitted to PowerPoint's export of bevel presets on a solid-filled shape with the default
|
||||||
|
//! three-point light rig and matte material: each channel comes out as
|
||||||
|
//! `c · (0.453 + 0.731 · max(0, n · L)) + 14.7` for a light from the upper left (azimuth 240°,
|
||||||
|
//! 45° up), within about 15 grey levels RMS across the band (exact profiles, specular highlights
|
||||||
|
//! and the other rigs, materials and cameras are not modelled).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use deckcraft_color::Rgba;
|
||||||
|
use kurbo::{Affine, BezPath, Point, Rect};
|
||||||
|
use vello_cpu::{Pixmap, RenderContext, peniko};
|
||||||
|
|
||||||
|
/// Longest side of the rasterised lighting, in pixels.
|
||||||
|
const MAX_SIDE: f64 = 512.0;
|
||||||
|
const AMBIENT: f64 = 0.453;
|
||||||
|
const DIFFUSE: f64 = 0.731;
|
||||||
|
const SPECULAR: f64 = 14.7;
|
||||||
|
|
||||||
|
/// A top bevel: its width and height in points and its preset.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Bevel {
|
||||||
|
pub w: f64,
|
||||||
|
pub h: f64,
|
||||||
|
pub preset: String,
|
||||||
|
/// Lit as measured (the three-point rig and matte material); other rigs and materials are not
|
||||||
|
/// drawn.
|
||||||
|
pub modelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top bevel of 3-D XML kept from the file (`scene3d` + `sp3d`), when it has both a scene
|
||||||
|
/// (without one PowerPoint draws the shape flat) and a `bevelT`.
|
||||||
|
pub fn from_raw(raw: &str) -> Option<Bevel> {
|
||||||
|
if !raw.contains("scene3d") || !raw.contains("sp3d") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A 3-D shape without a bevel still has its face lit: a bevel of no width.
|
||||||
|
let Some(i) = raw.find("bevelT") else {
|
||||||
|
let b = Bevel { w: 0.0, h: 0.0, preset: "circle".into(), modelled: false };
|
||||||
|
return Some(Bevel { modelled: modelled(raw), ..b });
|
||||||
|
};
|
||||||
|
let tag = raw.get(i..)?;
|
||||||
|
let tag = tag.get(..tag.find('>')?)?;
|
||||||
|
let attr = |name: &str| -> Option<&str> {
|
||||||
|
let key = format!(" {name}=\"");
|
||||||
|
let s = tag.find(&key)? + key.len();
|
||||||
|
let rest = tag.get(s..)?;
|
||||||
|
rest.get(..rest.find('"')?)
|
||||||
|
};
|
||||||
|
let emu = |name: &str| attr(name).and_then(|v| v.parse::<f64>().ok()).map(|v| v / 12700.0);
|
||||||
|
let w = emu("w").unwrap_or(6.0).clamp(0.0, 2000.0);
|
||||||
|
let h = emu("h").unwrap_or(6.0).clamp(0.0, 2000.0);
|
||||||
|
(w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled: modelled(raw) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the light rig and material (from anywhere in the XML: `a:lightRig rig=`,
|
||||||
|
/// `prstMaterial=`) are the ones measured: the three-point rig, matte.
|
||||||
|
fn modelled(raw: &str) -> bool {
|
||||||
|
let value_after = |key: &str| -> Option<&str> {
|
||||||
|
let s = raw.find(key)? + key.len();
|
||||||
|
let rest = raw.get(s..)?;
|
||||||
|
rest.get(..rest.find('"')?)
|
||||||
|
};
|
||||||
|
let rig = value_after("rig=\"").unwrap_or("threePt");
|
||||||
|
let material = value_after("prstMaterial=\"").unwrap_or("warmMatte");
|
||||||
|
rig == "threePt" && matches!(material, "warmMatte" | "matte")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The light, pointing from the surface towards it (x right, y down, z towards the viewer).
|
||||||
|
fn light() -> [f64; 3] {
|
||||||
|
let (az, el) = (240f64.to_radians(), 45f64.to_radians());
|
||||||
|
[el.cos() * az.cos(), el.cos() * az.sin(), el.sin()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bevel surface's rise per unit inwards (in units of h / w) at `u` (0 at the outline, 1 at
|
||||||
|
/// the inner edge of the band), by preset.
|
||||||
|
fn slope(preset: &str, u: f64) -> f64 {
|
||||||
|
let u = u.clamp(1e-3, 1.0);
|
||||||
|
match preset {
|
||||||
|
"angle" => 1.0,
|
||||||
|
// A quarter circle, steep at the outline and flat where it meets the face; the default,
|
||||||
|
// and used for the presets not modelled.
|
||||||
|
_ => {
|
||||||
|
let v = 1.0 - u;
|
||||||
|
v / (1.0 - v * v).max(1e-6).sqrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lighting factor and outward edge normal at each position, for a polygon set: distance to the
|
||||||
|
/// nearest edge and that edge's outward normal (edges oriented by each polygon's signed area).
|
||||||
|
fn nearest_edge(polys: &[Vec<Point>], areas: &[f64], p: Point) -> Option<(f64, kurbo::Vec2)> {
|
||||||
|
let mut best: Option<(f64, kurbo::Vec2)> = None;
|
||||||
|
for (poly, area) in polys.iter().zip(areas) {
|
||||||
|
for w in poly.windows(2) {
|
||||||
|
let (a, b) = (w[0], w[1]);
|
||||||
|
let e = b - a;
|
||||||
|
let l2 = e.hypot2();
|
||||||
|
if l2 < 1e-12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t = ((p - a).dot(e) / l2).clamp(0.0, 1.0);
|
||||||
|
let d = (p - (a + e * t)).hypot();
|
||||||
|
if best.is_none_or(|(bd, _)| d < bd) {
|
||||||
|
// Outward normal: for a polygon of positive (clockwise on screen, y down) area the
|
||||||
|
// outside is to the left of each edge's direction.
|
||||||
|
let n = kurbo::Vec2::new(e.y, -e.x) / l2.sqrt();
|
||||||
|
let n = if *area > 0.0 { n } else { -n };
|
||||||
|
best = Some((d, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the lit face and bevel band over a shape filled with `fill` (a solid colour, or `None`
|
||||||
|
/// for any other fill, which gets the band's highlights and shading as light and dark veils)
|
||||||
|
/// inside `path` (shape space, laid out in `bounds`), `m` mapping it to the output.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &BezPath, bounds: Rect, m: Affine, alpha: f64) {
|
||||||
|
// Other light rigs and materials light the face and band differently (brighter, darker,
|
||||||
|
// shinier); rather than guess, they stay flat as before.
|
||||||
|
if !bevel.modelled || bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No bevel: only the face, one lit colour (as PowerPoint lights a 3-D shape's front face,
|
||||||
|
// whichever way the camera turns it, within about 10 levels).
|
||||||
|
if bevel.w <= 0.0 {
|
||||||
|
if let Some(c) = fill {
|
||||||
|
let f = AMBIENT + DIFFUSE * light()[2];
|
||||||
|
let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0) as u8;
|
||||||
|
ctx.set_transform(m);
|
||||||
|
ctx.set_paint(crate::color(Rgba::rgba(ch(c.r), ch(c.g), ch(c.b), c.a), alpha));
|
||||||
|
ctx.fill_path(path);
|
||||||
|
}
|
||||||
|
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, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
|
||||||
|
let polys = crate::pathgrad::polygons(path);
|
||||||
|
if polys.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let areas: Vec<f64> = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::<f64>() / 2.0).collect();
|
||||||
|
let l = light();
|
||||||
|
let face = AMBIENT + DIFFUSE * l[2];
|
||||||
|
let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 };
|
||||||
|
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 f = match nearest_edge(&polys, &areas, p) {
|
||||||
|
Some((d, e)) if d < bevel.w => {
|
||||||
|
let z = slope(&bevel.preset, d / bevel.w) * ratio;
|
||||||
|
let n = [e.x * z, e.y * z, 1.0];
|
||||||
|
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||||
|
let dot = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len;
|
||||||
|
AMBIENT + DIFFUSE * dot.max(0.0)
|
||||||
|
}
|
||||||
|
_ => face,
|
||||||
|
};
|
||||||
|
let px = match fill {
|
||||||
|
Some(c) => {
|
||||||
|
let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0);
|
||||||
|
let ca = c.a as f64 * a / 255.0;
|
||||||
|
let pm = |v: f64| (v * ca).round().clamp(0.0, 255.0) as u8;
|
||||||
|
vello_cpu::color::PremulRgba8 { r: pm(ch(c.r)), g: pm(ch(c.g)), b: pm(ch(c.b)), a: (ca * 255.0).round() as u8 }
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// A veil: white where the band is lighter than the face, black where darker.
|
||||||
|
let d = ((f - face) * 1.5).clamp(-1.0, 1.0);
|
||||||
|
let va = (d.abs() * a * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||||
|
let v = if d > 0.0 { va } else { 0 };
|
||||||
|
vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: va }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
data.push(px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bevels_are_read_from_kept_3d_xml() {
|
||||||
|
let raw = r#"<a:scene3d><a:camera prst="orthographicFront"/></a:scene3d><a:sp3d><a:bevelT w="50800" h="16510" prst="angle"/></a:sp3d>"#;
|
||||||
|
assert_eq!(from_raw(raw), Some(Bevel { w: 4.0, h: 1.3, preset: "angle".into(), modelled: true }));
|
||||||
|
// Defaults: 6 pt square, circle.
|
||||||
|
let raw = r#"<a:scene3d/><a:sp3d><a:bevelT/></a:sp3d>"#;
|
||||||
|
assert_eq!(from_raw(raw), Some(Bevel { w: 6.0, h: 6.0, preset: "circle".into(), modelled: true }));
|
||||||
|
// Another rig or material: not modelled.
|
||||||
|
let raw = r#"<a:scene3d><a:lightRig rig="flood" dir="t"/></a:scene3d><a:sp3d prstMaterial="metal"><a:bevelT/></a:sp3d>"#;
|
||||||
|
assert!(from_raw(raw).is_some_and(|b| !b.modelled));
|
||||||
|
// No scene: flat in PowerPoint.
|
||||||
|
assert_eq!(from_raw(r#"<a:sp3d><a:bevelT w="50800"/></a:sp3d>"#), None);
|
||||||
|
// A 3-D shape with no bevel: a bevel of no width (its face is lit).
|
||||||
|
assert!(from_raw(r#"<a:scene3d/><a:sp3d extrusionH="10"/>"#).is_some_and(|b| b.w == 0.0 && b.modelled));
|
||||||
|
assert_eq!(from_raw(r#"<a:scene3d/>"#), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_light_comes_from_the_upper_left() {
|
||||||
|
let l = light();
|
||||||
|
assert!(l[0] < 0.0 && l[1] < 0.0 && l[2] > 0.0);
|
||||||
|
// An upward-facing edge (outward normal up) is lit more than a downward one.
|
||||||
|
let lit = |e: [f64; 2]| {
|
||||||
|
let z = slope("circle", 0.3) * 0.5;
|
||||||
|
let n = [e[0] * z, e[1] * z, 1.0];
|
||||||
|
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||||
|
(n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len
|
||||||
|
};
|
||||||
|
assert!(lit([0.0, -1.0]) > lit([-1.0, 0.0]) && lit([-1.0, 0.0]) > lit([1.0, 0.0]) && lit([1.0, 0.0]) > lit([0.0, 1.0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
//! 3-D cameras (`a:scene3d/a:camera`) turning a flat shape, approximated in 2-D.
|
||||||
|
//!
|
||||||
|
//! A shape seen through a 3-D camera preset (isometric, perspective…) or rotated with
|
||||||
|
//! `a:camera/a:rot` is drawn by PowerPoint as its plane turned in space and projected. DeckCraft
|
||||||
|
//! turns the shape's corners the same way and draws the shape under the affine transform that best
|
||||||
|
//! fits them: exact for orthographic cameras, within about 2% of the shape's size for the
|
||||||
|
//! perspective ones (whose foreshortening an affine transform can't show). Depth, bevels' sides and
|
||||||
|
//! lighting are not drawn.
|
||||||
|
//!
|
||||||
|
//! The presets' turns were fitted to PowerPoint's exports (black-box: a picture with coloured
|
||||||
|
//! corner markers under each preset, all fits within half a point on a 100 × 75 pt shape):
|
||||||
|
//! points relative to the shape's centre are turned `p = Rz(-rev) · Rx(lat) · Ry(lon) · (x, y, 0)`
|
||||||
|
//! and, for perspective cameras, scaled by `D / (D - p.z)` with `D = dist · max(w, h)`.
|
||||||
|
|
||||||
|
use kurbo::{Affine, Point};
|
||||||
|
|
||||||
|
/// (preset, lat, lon, rev in degrees, perspective distance in shape sizes or 0 for orthographic).
|
||||||
|
const PRESETS: &[(&str, f64, f64, f64, f64)] = &[
|
||||||
|
("orthographicFront", 0.0, 0.0, 0.0, 0.0),
|
||||||
|
("isometricTopUp", 215.52, 134.75, 240.17, 0.0),
|
||||||
|
("isometricTopDown", 324.69, 45.30, 299.88, 0.0),
|
||||||
|
("isometricBottomUp", 215.52, 134.75, 240.17, 0.0),
|
||||||
|
("isometricBottomDown", 215.36, 225.28, 119.86, 0.0),
|
||||||
|
("isometricLeftUp", 324.94, 44.77, 0.03, 0.0),
|
||||||
|
("isometricLeftDown", 144.84, 224.73, 179.95, 0.0),
|
||||||
|
("isometricRightUp", 324.81, 44.75, 359.98, 0.0),
|
||||||
|
("isometricRightDown", 144.84, 224.73, 179.95, 0.0),
|
||||||
|
("isometricOffAxis1Left", 17.94, 64.06, 359.94, 0.0),
|
||||||
|
("isometricOffAxis1Right", 341.80, 25.84, 0.0, 0.0),
|
||||||
|
("isometricOffAxis1Top", 238.56, 127.22, 237.03, 0.0),
|
||||||
|
("isometricOffAxis2Left", 161.88, 205.84, 179.98, 0.0),
|
||||||
|
("isometricOffAxis2Right", 197.91, 244.06, 180.06, 0.0),
|
||||||
|
("isometricOffAxis2Top", 301.42, 52.80, 302.97, 0.0),
|
||||||
|
("isometricOffAxis3Left", 197.95, 244.05, 180.06, 0.0),
|
||||||
|
("isometricOffAxis3Right", 161.88, 205.84, 179.98, 0.0),
|
||||||
|
("isometricOffAxis3Bottom", 301.42, 52.78, 302.98, 0.0),
|
||||||
|
("isometricOffAxis4Left", 198.00, 205.84, 180.05, 0.0),
|
||||||
|
("isometricOffAxis4Right", 17.94, 64.06, 359.94, 0.0),
|
||||||
|
("isometricOffAxis4Bottom", 121.42, 232.75, 237.00, 0.0),
|
||||||
|
("perspectiveLeft", 359.91, 341.75, 0.02, 12.14),
|
||||||
|
("perspectiveRight", 180.27, 198.23, 179.94, 8.02),
|
||||||
|
("perspectiveAbove", 18.92, 0.38, 0.09, 16.61),
|
||||||
|
("perspectiveBelow", 198.83, 180.39, 179.89, 9.31),
|
||||||
|
("perspectiveAboveLeftFacing", 320.78, 346.02, 340.95, 11.54),
|
||||||
|
("perspectiveAboveRightFacing", 219.25, 194.14, 198.98, 9.28),
|
||||||
|
("perspectiveContrastingLeftFacing", 190.42, 136.41, 176.42, 12.21),
|
||||||
|
("perspectiveContrastingRightFacing", 190.52, 223.59, 183.56, 9.53),
|
||||||
|
("perspectiveHeroicLeftFacing", 10.89, 346.28, 2.75, 13.22),
|
||||||
|
("perspectiveHeroicRightFacing", 169.53, 194.00, 177.22, 8.45),
|
||||||
|
("perspectiveHeroicExtremeLeftFacing", 351.98, 325.89, 357.00, 5.98),
|
||||||
|
("perspectiveHeroicExtremeRightFacing", 188.39, 214.22, 182.81, 5.26),
|
||||||
|
("perspectiveRelaxed", 50.02, 0.03, 0.0, 11.32),
|
||||||
|
("perspectiveRelaxedModerately", 34.42, 0.33, 0.16, 11.14),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Perspective distance for a perspective camera given its own rotation (fitted on
|
||||||
|
/// `perspectiveFront` with `a:rot`, about 11 shape sizes).
|
||||||
|
const PERSPECTIVE_DIST: f64 = 11.0;
|
||||||
|
|
||||||
|
/// A camera's turn of the shape's plane.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Camera {
|
||||||
|
pub lat: f64,
|
||||||
|
pub lon: f64,
|
||||||
|
pub rev: f64,
|
||||||
|
/// Perspective distance in shape sizes; 0 for an orthographic camera.
|
||||||
|
pub dist: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of attribute `name` in the first `tag` element of `raw` (XML as kept from the file).
|
||||||
|
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let key = format!(" {name}=\"");
|
||||||
|
let s = tag.find(&key)? + key.len();
|
||||||
|
let rest = tag.get(s..)?;
|
||||||
|
rest.get(..rest.find('"')?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera of 3-D XML kept from the file, when it turns the shape.
|
||||||
|
pub fn from_raw(raw: &str) -> Option<Camera> {
|
||||||
|
let i = raw.find("camera ")?;
|
||||||
|
let rest = raw.get(i..)?;
|
||||||
|
let open_end = rest.find('>')?;
|
||||||
|
let open = rest.get(..open_end)?;
|
||||||
|
let prst = attr(open, "prst").unwrap_or("orthographicFront");
|
||||||
|
let preset = PRESETS.iter().find(|p| p.0 == prst);
|
||||||
|
let perspective = prst.starts_with("perspective");
|
||||||
|
// Its own `a:rot` (inside the camera element, not the light rig's).
|
||||||
|
let self_closing = open.ends_with('/');
|
||||||
|
let own_rot = if self_closing {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let body = rest.get(open_end..rest.find("camera>").unwrap_or(rest.len()))?;
|
||||||
|
body.find("rot ").and_then(|j| {
|
||||||
|
let t = body.get(j..)?;
|
||||||
|
let t = t.get(..t.find('>')?)?;
|
||||||
|
let deg = |n: &str| attr(t, n).and_then(|v| v.parse::<f64>().ok()).map(|v| v / 60000.0).unwrap_or(0.0);
|
||||||
|
Some((deg("lat"), deg("lon"), deg("rev")))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let cam = match (own_rot, preset) {
|
||||||
|
// PowerPoint turns the other way about the horizontal and vertical axes for `a:rot`.
|
||||||
|
(Some((lat, lon, rev)), p) => {
|
||||||
|
let dist = match p {
|
||||||
|
Some(p) if p.4 > 0.0 => p.4,
|
||||||
|
_ if perspective => PERSPECTIVE_DIST,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
Camera { lat: -lat, lon: -lon, rev, dist }
|
||||||
|
}
|
||||||
|
(None, Some(p)) => Camera { lat: p.1, lon: p.2, rev: p.3, dist: p.4 },
|
||||||
|
(None, None) => return None,
|
||||||
|
};
|
||||||
|
let flat = [cam.lat, cam.lon, cam.rev].iter().all(|a| (a.rem_euclid(360.0)).min(360.0 - a.rem_euclid(360.0)) < 1e-6);
|
||||||
|
(!flat).then_some(cam)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a point of the shape (relative to its centre) lands.
|
||||||
|
fn project(c: &Camera, p: Point, size: f64) -> Point {
|
||||||
|
let (a, b, r) = (c.lat.to_radians(), c.lon.to_radians(), -c.rev.to_radians());
|
||||||
|
let (x1, y1, z1) = (p.x * b.cos(), p.y, -p.x * b.sin());
|
||||||
|
let (x2, y2, z2) = (x1, y1 * a.cos() - z1 * a.sin(), y1 * a.sin() + z1 * a.cos());
|
||||||
|
let (x3, y3) = (x2 * r.cos() - y2 * r.sin(), x2 * r.sin() + y2 * r.cos());
|
||||||
|
if c.dist > 0.0 {
|
||||||
|
let d = c.dist * size;
|
||||||
|
let s = d / (d - z2);
|
||||||
|
if s.is_finite() && s > 0.0 {
|
||||||
|
return Point::new(x3 * s, y3 * s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Point::new(x3, y3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transform (shape space to shape space) that turns a `w` × `h` shape as camera `c` does:
|
||||||
|
/// the affine map best fitting its turned corners.
|
||||||
|
pub fn affine(c: &Camera, w: f64, h: f64) -> Option<Affine> {
|
||||||
|
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = w.max(h);
|
||||||
|
let src = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];
|
||||||
|
let dst: Vec<Point> = src.iter().map(|(x, y)| project(c, Point::new(*x, *y), size)).collect();
|
||||||
|
// Least squares over the four corners. The corners are symmetric about the centre, so the
|
||||||
|
// normal equations are diagonal: x' = a x + b y + e with a = Σ x x' / Σ x², and so on.
|
||||||
|
let (sxx, syy) = (src.iter().map(|p| p.0 * p.0).sum::<f64>(), src.iter().map(|p| p.1 * p.1).sum::<f64>());
|
||||||
|
let dot = |f: &dyn Fn(&(f64, f64), &Point) -> f64| src.iter().zip(&dst).map(|(s, d)| f(s, d)).sum::<f64>();
|
||||||
|
let a = dot(&|s, d| s.0 * d.x) / sxx;
|
||||||
|
let b = dot(&|s, d| s.1 * d.x) / syy;
|
||||||
|
let cc = dot(&|s, d| s.0 * d.y) / sxx;
|
||||||
|
let dd = dot(&|s, d| s.1 * d.y) / syy;
|
||||||
|
let e = dot(&|_, d| d.x) / 4.0;
|
||||||
|
let f = dot(&|_, d| d.y) / 4.0;
|
||||||
|
let local = Affine::new([a, cc, b, dd, e, f]);
|
||||||
|
let m = Affine::translate((w / 2.0, h / 2.0)) * local * Affine::translate((-w / 2.0, -h / 2.0));
|
||||||
|
(m.is_finite() && m.determinant().abs() > 1e-9).then_some(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn corners(m: Affine, w: f64, h: f64) -> Vec<Point> {
|
||||||
|
[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].iter().map(|(x, y)| m * Point::new(*x, *y)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turning_about_the_vertical_axis_narrows_the_shape() {
|
||||||
|
// orthographicFront with lon 60: half as wide, as tall (PowerPoint: 0.506 of the width).
|
||||||
|
let c = from_raw(r#"<a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="3600000" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d>"#).unwrap();
|
||||||
|
let m = affine(&c, 100.0, 75.0).unwrap();
|
||||||
|
let k = corners(m, 100.0, 75.0);
|
||||||
|
assert!((k[1].x - k[0].x - 50.0).abs() < 0.01 && (k[3].y - k[0].y - 75.0).abs() < 0.01, "{k:?}");
|
||||||
|
assert!(((k[0].x + k[1].x) / 2.0 - 50.0).abs() < 1e-9, "about the centre");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presets_turn_the_shape_as_powerpoint_does() {
|
||||||
|
// isometricTopUp, 100 × 75: PowerPoint's corners at (-1.247, 0.127), (0.170, -0.955),
|
||||||
|
// (1.237, -0.141), (-0.181, 0.941) of the half sizes from the centre.
|
||||||
|
let c = from_raw(r#"<a:scene3d><a:camera prst="isometricTopUp"/><a:lightRig rig="threePt" dir="t"/></a:scene3d>"#).unwrap();
|
||||||
|
let k = corners(affine(&c, 100.0, 75.0).unwrap(), 100.0, 75.0);
|
||||||
|
for (p, (u, v)) in k.iter().zip([(-1.247, 0.127), (0.170, -0.955), (1.237, -0.141), (-0.181, 0.941)]) {
|
||||||
|
let (x, y) = ((p.x - 50.0) / 50.0, (p.y - 37.5) / 37.5);
|
||||||
|
assert!((x - u).abs() < 0.03 && (y - v).abs() < 0.03, "({x:.3}, {y:.3}) vs ({u}, {v})");
|
||||||
|
}
|
||||||
|
// Front-facing cameras and the oblique ones leave a flat shape as it is.
|
||||||
|
for raw in [r#"<a:scene3d><a:camera prst="orthographicFront"/></a:scene3d>"#, r#"<a:scene3d><a:camera prst="obliqueTopLeft"/></a:scene3d>"#] {
|
||||||
|
assert_eq!(from_raw(raw), None);
|
||||||
|
}
|
||||||
|
assert_eq!(from_raw("<a:sp3d/>"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_perspective_camera_is_approximated_closely() {
|
||||||
|
// perspectiveContrastingLeftFacing on 100 × 75: PowerPoint's corners within 3% of the size.
|
||||||
|
let c = from_raw(r#"<a:scene3d><a:camera prst="perspectiveContrastingLeftFacing"/></a:scene3d>"#).unwrap();
|
||||||
|
let k = corners(affine(&c, 100.0, 75.0).unwrap(), 100.0, 75.0);
|
||||||
|
for (p, (u, v)) in k.iter().zip([(-0.657, -1.189), (0.791, -0.795), (0.685, 1.244), (-0.742, 0.729)]) {
|
||||||
|
let (dx, dy) = (p.x - 50.0 - u * 50.0, p.y - 37.5 - v * 37.5);
|
||||||
|
assert!(dx.hypot(dy) < 3.0, "{p:?} vs ({u}, {v})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,15 +66,17 @@ fn with<R>(f: impl FnOnce(&mut Cache) -> R) -> R {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn adjust_key(a: &PictureAdjust) -> u64 {
|
fn adjust_key(a: &PictureAdjust) -> u64 {
|
||||||
let mut h: u64 = 1469598103934665603;
|
use std::hash::Hasher;
|
||||||
let mut mix = |v: u64| {
|
// A real hash: an FNV-style xor-multiply mix let (b, c) and (-b, -c) collide (the sign bits
|
||||||
h ^= v;
|
// cancel), so a picture brightened and contrasted showed the darkened copy's pixels.
|
||||||
h = h.wrapping_mul(1099511628211);
|
let mut hs = std::hash::DefaultHasher::new();
|
||||||
};
|
let mut mix = |v: u64| hs.write_u64(v);
|
||||||
mix(a.brightness.to_bits());
|
mix(a.brightness.to_bits());
|
||||||
mix(a.contrast.to_bits());
|
mix(a.contrast.to_bits());
|
||||||
mix(a.saturation.unwrap_or(1.0).to_bits());
|
mix(a.saturation.unwrap_or(1.0).to_bits());
|
||||||
mix(a.grayscale as u64);
|
mix(a.grayscale as u64);
|
||||||
|
mix(a.bilevel.map_or(u64::MAX, f64::to_bits));
|
||||||
|
mix(a.alpha_inv as u64);
|
||||||
mix(a.sharpen.to_bits());
|
mix(a.sharpen.to_bits());
|
||||||
if let Some(c) = a.clear_color {
|
if let Some(c) = a.clear_color {
|
||||||
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
|
mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40);
|
||||||
|
|
@ -84,7 +86,7 @@ fn adjust_key(a: &PictureAdjust) -> u64 {
|
||||||
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
|
mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h
|
hs.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
|
/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see
|
||||||
|
|
@ -121,6 +123,56 @@ fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||||
Some(img.to_rgba8())
|
Some(img.to_rgba8())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A raster picture's own size in 96-dpi pixels (so 0.75 pt each), read from its header: its
|
||||||
|
/// pixel size, scaled by the resolution it records (PNG `pHYs`, JPEG JFIF density) when that
|
||||||
|
/// isn't 96 dpi, as PowerPoint sizes pictures. `None` for metafiles and anything unreadable.
|
||||||
|
pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||||
|
if crate::metafile::is_metafile(bytes) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (dx, dy) = dpi(bytes).unwrap_or((96.0, 96.0));
|
||||||
|
Some((w as f64 * 96.0 / dx, h as f64 * 96.0 / dy))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolution (whole dots per inch across and down) a PNG or JPEG records, when it records
|
||||||
|
/// one in a plausible range.
|
||||||
|
pub fn dpi(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||||
|
// Whole dots per inch: PNGs store dots per metre, so 96 dpi reads as 95.9866.
|
||||||
|
let ok = |d: f64| (d.is_finite() && (10.0..=10000.0).contains(&d)).then_some(d.round());
|
||||||
|
let be32 = |b: &[u8], i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
|
||||||
|
let be16 = |b: &[u8], i: usize| b.get(i..i + 2).map(|s| u16::from_be_bytes([s[0], s[1]]));
|
||||||
|
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||||
|
let mut i = 8usize;
|
||||||
|
for _ in 0..64 {
|
||||||
|
let len = be32(bytes, i)? as usize;
|
||||||
|
let kind = bytes.get(i + 4..i + 8)?;
|
||||||
|
if kind == b"pHYs" {
|
||||||
|
let (x, y, unit) = (be32(bytes, i + 8)?, be32(bytes, i + 12)?, *bytes.get(i + 16)?);
|
||||||
|
return if unit == 1 { Some((ok(x as f64 * 0.0254)?, ok(y as f64 * 0.0254)?)) } else { None };
|
||||||
|
}
|
||||||
|
if kind == b"IDAT" || kind == b"IEND" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
i = i.checked_add(12)?.checked_add(len)?;
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if bytes.starts_with(&[0xFF, 0xD8]) && bytes.get(6..11) == Some(b"JFIF\0".as_slice()) {
|
||||||
|
let unit = *bytes.get(13)?;
|
||||||
|
let (x, y) = (be16(bytes, 14)? as f64, be16(bytes, 16)? as f64);
|
||||||
|
return match unit {
|
||||||
|
1 => Some((ok(x)?, ok(y)?)),
|
||||||
|
2 => Some((ok(x * 2.54)?, ok(y * 2.54)?)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px.
|
/// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px.
|
||||||
pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Option<Arc<Pixmap>> {
|
pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Option<Arc<Pixmap>> {
|
||||||
let addr = Arc::as_ptr(bytes) as usize;
|
let addr = Arc::as_ptr(bytes) as usize;
|
||||||
|
|
@ -256,7 +308,15 @@ pub(crate) fn gif_pixmap(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust, t: f64) -> O
|
||||||
|
|
||||||
fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||||
let sat = a.saturation.unwrap_or(1.0);
|
let sat = a.saturation.unwrap_or(1.0);
|
||||||
if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() {
|
if a.brightness == 0.0
|
||||||
|
&& a.contrast == 0.0
|
||||||
|
&& sat == 1.0
|
||||||
|
&& !a.grayscale
|
||||||
|
&& a.clear_color.is_none()
|
||||||
|
&& a.duotone.is_none()
|
||||||
|
&& a.bilevel.is_none()
|
||||||
|
&& !a.alpha_inv
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let duo = duotone_rgb(a);
|
let duo = duotone_rgb(a);
|
||||||
|
|
@ -273,26 +333,43 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut rgb = [p[0] as f64, p[1] as f64, p[2] as f64];
|
let mut rgb = [p[0] as f64, p[1] as f64, p[2] as f64];
|
||||||
let y = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
let y = luma(rgb);
|
||||||
let s = if a.grayscale { 0.0 } else { sat };
|
let s = if a.grayscale { 0.0 } else { sat };
|
||||||
for v in &mut rgb {
|
for v in &mut rgb {
|
||||||
*v = y + (*v - y) * s;
|
*v = y + (*v - y) * s;
|
||||||
*v = (*v - 128.0) * cf + 128.0 + b;
|
// Measured on PowerPoint's export of grey ramps: contrast scales about mid-grey by
|
||||||
|
// `cf`, and brightness adds b · 255 scaled by (1 + cf) / 2 (half of it is applied
|
||||||
|
// before the contrast stretch, half after): +40% bright, +40% contrast maps 0 to 54.
|
||||||
|
*v = (*v - 127.5) * cf + 127.5 + b * (1.0 + cf) / 2.0;
|
||||||
}
|
}
|
||||||
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
|
// Duotone: the pixel's luminance picks a colour between the dark and the light one.
|
||||||
if let Some((d, l)) = duo {
|
if let Some((d, l)) = duo {
|
||||||
let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0);
|
let t = (luma(rgb) / 255.0).clamp(0.0, 1.0);
|
||||||
rgb =
|
rgb =
|
||||||
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
|
[d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t];
|
||||||
}
|
}
|
||||||
|
// Black and white: white where the luminance reaches the threshold.
|
||||||
|
if let Some(t) = a.bilevel {
|
||||||
|
let v = if luma(rgb) >= t * 255.0 { 255.0 } else { 0.0 };
|
||||||
|
rgb = [v; 3];
|
||||||
|
}
|
||||||
for (i, v) in rgb.iter().enumerate() {
|
for (i, v) in rgb.iter().enumerate() {
|
||||||
if let Some(ch) = p.0.get_mut(i) {
|
if let Some(ch) = p.0.get_mut(i) {
|
||||||
*ch = v.round().clamp(0.0, 255.0) as u8;
|
*ch = v.round().clamp(0.0, 255.0) as u8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if a.alpha_inv {
|
||||||
|
p[3] = 255 - p[3];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Luminance as PowerPoint weighs it for grayscale, black-and-white and duotone pictures
|
||||||
|
/// (measured: Rec. 709 weights on the stored values; pure red greys to 54, green to 182).
|
||||||
|
fn luma(rgb: [f64; 3]) -> f64 {
|
||||||
|
0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#![allow(clippy::expect_used)]
|
#![allow(clippy::expect_used)]
|
||||||
|
|
@ -318,6 +395,58 @@ mod tests {
|
||||||
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
|
assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brightness_and_contrast_map_greys_like_powerpoint() {
|
||||||
|
// Grey levels PowerPoint's export gives for inputs 0, 32, 64, 96 (and 128, 160).
|
||||||
|
let run = |b: f64, c: f64, input: &[u8]| -> Vec<u8> {
|
||||||
|
let mut img = image::RgbaImage::from_fn(input.len() as u32, 1, |x, _| {
|
||||||
|
let v = input[x as usize];
|
||||||
|
image::Rgba([v, v, v, 255])
|
||||||
|
});
|
||||||
|
apply_adjust(&mut img, &PictureAdjust { brightness: b, contrast: c, ..Default::default() });
|
||||||
|
img.pixels().map(|p| p[0]).collect()
|
||||||
|
};
|
||||||
|
let near = |got: Vec<u8>, want: &[u8]| got.iter().zip(want).all(|(g, w)| (*g as i32 - *w as i32).abs() <= 5);
|
||||||
|
assert!(near(run(0.4, 0.4, &[0, 32, 64, 96]), &[54, 105, 157, 210]), "{:?}", run(0.4, 0.4, &[0, 32, 64, 96]));
|
||||||
|
assert!(near(run(0.4, -0.4, &[0, 32, 64, 96]), &[133, 152, 171, 190]));
|
||||||
|
assert!(near(run(-0.4, 0.4, &[128, 160, 192]), &[0, 45, 97]));
|
||||||
|
assert!(near(run(0.0, -0.8, &[0, 128, 255]), &[102, 127, 152]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grayscale_black_and_white_and_inverted_alpha_like_powerpoint() {
|
||||||
|
let swatches = [[255u8, 0, 0], [0, 255, 0], [0, 0, 255], [200, 100, 50], [60, 160, 110], [120, 120, 130]];
|
||||||
|
let run = |adj: PictureAdjust| -> Vec<[u8; 4]> {
|
||||||
|
let mut img = image::RgbaImage::from_fn(swatches.len() as u32, 1, |x, _| {
|
||||||
|
let c = swatches[x as usize];
|
||||||
|
image::Rgba([c[0], c[1], c[2], 200])
|
||||||
|
});
|
||||||
|
apply_adjust(&mut img, &adj);
|
||||||
|
img.pixels().map(|p| p.0).collect()
|
||||||
|
};
|
||||||
|
// PowerPoint's export of these swatches in grayscale: 54, 182, 18, 117, 135, 120.
|
||||||
|
let g = run(PictureAdjust { grayscale: true, ..Default::default() });
|
||||||
|
for (got, want) in g.iter().zip([54u8, 182, 18, 117, 135, 120]) {
|
||||||
|
assert!((got[0] as i32 - want as i32).abs() <= 1 && got[0] == got[1] && got[1] == got[2], "{got:?} vs {want}");
|
||||||
|
}
|
||||||
|
// Black and white at 50%: only green and the light green reach mid-grey.
|
||||||
|
let bw: Vec<u8> = run(PictureAdjust { bilevel: Some(0.5), ..Default::default() }).iter().map(|p| p[0]).collect();
|
||||||
|
assert_eq!(bw, [0, 255, 0, 0, 255, 0]);
|
||||||
|
// Inverted alpha: 200 becomes 55.
|
||||||
|
assert!(run(PictureAdjust { alpha_inv: true, ..Default::default() }).iter().all(|p| p[3] == 55));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opposite_brightness_and_contrast_are_cached_apart() {
|
||||||
|
let a = PictureAdjust { brightness: 0.4, contrast: 0.4, ..Default::default() };
|
||||||
|
let b = PictureAdjust { brightness: -0.4, contrast: -0.4, ..Default::default() };
|
||||||
|
assert_ne!(adjust_key(&a), adjust_key(&b));
|
||||||
|
let c = PictureAdjust { brightness: 0.4, contrast: -0.4, ..Default::default() };
|
||||||
|
let d = PictureAdjust { brightness: -0.4, contrast: 0.4, ..Default::default() };
|
||||||
|
assert_ne!(adjust_key(&c), adjust_key(&d));
|
||||||
|
assert_eq!(adjust_key(&a), adjust_key(&a.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
|
fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() {
|
||||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod bevel;
|
||||||
|
mod camera;
|
||||||
mod chart;
|
mod chart;
|
||||||
mod colr;
|
mod colr;
|
||||||
|
|
||||||
|
|
@ -244,11 +246,14 @@ pub struct Renderer {
|
||||||
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
|
||||||
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
|
||||||
view: Affine,
|
view: Affine,
|
||||||
|
/// The fill of the group being drawn, with the transform and box it is laid out in: children
|
||||||
|
/// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group.
|
||||||
|
group_fill: Option<(Fill, Affine, Rect)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Renderer {
|
impl Default for Renderer {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY }
|
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY, group_fill: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +440,9 @@ pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry {
|
||||||
let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) });
|
let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) });
|
||||||
let mut bp = parse_path(&p.d);
|
let mut bp = parse_path(&p.d);
|
||||||
bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph));
|
bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph));
|
||||||
out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: false });
|
// PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path
|
||||||
|
// inside another cuts a hole whichever way it winds, as in icons and outlined text.
|
||||||
|
out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true });
|
||||||
}
|
}
|
||||||
Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] }
|
Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] }
|
||||||
}
|
}
|
||||||
|
|
@ -767,6 +774,17 @@ impl Renderer {
|
||||||
* Affine::translate(-c.to_vec2());
|
* Affine::translate(-c.to_vec2());
|
||||||
m = parent * Affine::translate(st.offset) * x.affine() * around;
|
m = parent * Affine::translate(st.offset) * x.affine() * around;
|
||||||
}
|
}
|
||||||
|
// A 3-D camera turns the shape (approximated by the affine transform that fits it best).
|
||||||
|
if !matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_))
|
||||||
|
&& let Some(a) = resolve::effects(rctx, s)
|
||||||
|
.0
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| e.raw3d.as_deref())
|
||||||
|
.and_then(camera::from_raw)
|
||||||
|
.and_then(|c| camera::affine(&c, x.w, x.h))
|
||||||
|
{
|
||||||
|
m *= a;
|
||||||
|
}
|
||||||
let layered = st.opacity < 0.999 || st.clip.is_some();
|
let layered = st.opacity < 0.999 || st.clip.is_some();
|
||||||
if layered {
|
if layered {
|
||||||
ctx.set_transform(m);
|
ctx.set_transform(m);
|
||||||
|
|
@ -777,9 +795,43 @@ impl Renderer {
|
||||||
ShapeKind::Group { children, child } => {
|
ShapeKind::Group { children, child } => {
|
||||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||||
let gm = parent * deckcraft_geom::group_child_affine(&x, ch);
|
let gm = parent * deckcraft_geom::group_child_affine(&x, ch);
|
||||||
|
// A group's own fill (a group filled with its parent's passes that on).
|
||||||
|
let own = match &s.fill {
|
||||||
|
Some(Fill::Group) | None => self.group_fill.clone(),
|
||||||
|
Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))),
|
||||||
|
};
|
||||||
|
let outer = std::mem::replace(&mut self.group_fill, own);
|
||||||
|
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
|
||||||
|
// children drawn once more under the shadow's offset, scale and skew, recoloured to
|
||||||
|
// the shadow colour and blurred, below the group.
|
||||||
|
if let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) {
|
||||||
|
let c = rctx.color(&sh.color, None);
|
||||||
|
let k = scale_of(m);
|
||||||
|
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||||||
|
let local = shadow_local(sh, x.w, x.h);
|
||||||
|
let (own, pre) =
|
||||||
|
if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) };
|
||||||
|
let det = m.determinant();
|
||||||
|
if det.is_finite() && det.abs() > 1e-12 {
|
||||||
|
let shift = pre * m * own * m.inverse();
|
||||||
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
|
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||||
|
for ch in children {
|
||||||
|
self.shape(ctx, f, rctx, ch, shift * gm, depth + 1);
|
||||||
|
}
|
||||||
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
|
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||||
|
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||||
|
let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height);
|
||||||
|
ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4));
|
||||||
|
ctx.pop_layer();
|
||||||
|
ctx.pop_layer();
|
||||||
|
}
|
||||||
|
}
|
||||||
for c in children {
|
for c in children {
|
||||||
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
self.shape(ctx, f, rctx, c, gm, depth + 1);
|
||||||
}
|
}
|
||||||
|
self.group_fill = outer;
|
||||||
}
|
}
|
||||||
ShapeKind::Table(t) => {
|
ShapeKind::Table(t) => {
|
||||||
// Rows grow with their text: what the cells cover, borders included.
|
// Rows grow with their text: what the cells cover, borders included.
|
||||||
|
|
@ -854,7 +906,19 @@ impl Renderer {
|
||||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
|
// What casts the shadow: the fill as see-through as it is, and the outline.
|
||||||
|
let fill_alpha = fill_opacity(rctx, &fill, fill_ph);
|
||||||
|
let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| {
|
||||||
|
let a = match lf {
|
||||||
|
Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0,
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
|
let (base, rel) = self.line_space(m, depth);
|
||||||
|
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||||
|
let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None };
|
||||||
|
Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel }
|
||||||
|
});
|
||||||
|
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h);
|
||||||
}
|
}
|
||||||
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||||||
if let Some(r) = soft {
|
if let Some(r) = soft {
|
||||||
|
|
@ -911,6 +975,15 @@ impl Renderer {
|
||||||
let path = (view.inverse() * m) * sp.path.clone();
|
let path = (view.inverse() * m) * sp.path.clone();
|
||||||
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
|
||||||
}
|
}
|
||||||
|
// `grpFill`: the group's fill, laid out over the whole group.
|
||||||
|
(Fill::Group, _) => {
|
||||||
|
if let Some((gf, gm, gbox)) = &self.group_fill
|
||||||
|
&& gm.determinant().abs() > 1e-12
|
||||||
|
{
|
||||||
|
let path = (gm.inverse() * m) * sp.path.clone();
|
||||||
|
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||||
}
|
}
|
||||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||||
|
|
@ -937,23 +1010,101 @@ impl Renderer {
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Some presets' outlines default to sharp corners (PowerPoint's own per-shape default).
|
||||||
|
let line = match (&s.geom, line.join) {
|
||||||
|
(Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => {
|
||||||
|
Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line }
|
||||||
|
}
|
||||||
|
_ => line,
|
||||||
|
};
|
||||||
|
// A 3-D bevel: the face and a band inside the outline lit as surfaces.
|
||||||
|
if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) {
|
||||||
|
let solid = match &fill {
|
||||||
|
Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none());
|
||||||
|
if filled {
|
||||||
|
let mut area = BezPath::new();
|
||||||
|
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
||||||
|
area.extend(sp.path.iter());
|
||||||
|
}
|
||||||
|
if is_pic || area.elements().is_empty() {
|
||||||
|
area = outline.clone();
|
||||||
|
}
|
||||||
|
bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Outline.
|
// Outline.
|
||||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||||
let (base, rel) = self.line_space(m, depth);
|
let (base, rel) = self.line_space(m, depth);
|
||||||
|
// Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed
|
||||||
|
// outlines or on presets like brackets.
|
||||||
|
let ends = s.is_line()
|
||||||
|
|| geo.is_open()
|
||||||
|
|| (matches!(s.geom, Geom::Custom { .. })
|
||||||
|
&& geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath))));
|
||||||
|
let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) };
|
||||||
|
let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none"));
|
||||||
|
// A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into
|
||||||
|
// a layer that is then faded, so they don't darken where they meet.
|
||||||
|
let see_through = match &line.fill {
|
||||||
|
Some(Fill::Solid { color: c }) if has_heads => Some(rctx.color(c, line_ph)).filter(|c| c.a < 255),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let line = match see_through {
|
||||||
|
Some(c) => {
|
||||||
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
|
ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None);
|
||||||
|
std::borrow::Cow::Owned(Line {
|
||||||
|
fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))),
|
||||||
|
..line.clone()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => std::borrow::Cow::Borrowed(&line),
|
||||||
|
};
|
||||||
|
// A picture's border lies outside the picture, as PowerPoint draws it: stroked twice
|
||||||
|
// as wide, outside the outline only (see `border_outside`).
|
||||||
|
let outside = border_outside(is_pic, effects.as_ref()).then(|| outside_line(&line, lw)).flatten();
|
||||||
|
let clipped = outside.is_some();
|
||||||
|
let (line, lw) = match outside {
|
||||||
|
Some(o) => {
|
||||||
|
let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1);
|
||||||
|
clip.extend(outline.iter());
|
||||||
|
ctx.set_transform(m);
|
||||||
|
ctx.set_fill_rule(peniko::Fill::EvenOdd);
|
||||||
|
ctx.push_clip_layer(&clip);
|
||||||
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||||
|
(std::borrow::Cow::Owned(o), lw * 2.0)
|
||||||
|
}
|
||||||
|
None => (line, lw),
|
||||||
|
};
|
||||||
|
let first_stroked = geo.paths.iter().position(|p| p.stroke);
|
||||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
|
||||||
let path = if rel == Affine::IDENTITY {
|
let mut path = if rel == Affine::IDENTITY {
|
||||||
std::borrow::Cow::Borrowed(&sp.path)
|
std::borrow::Cow::Borrowed(&sp.path)
|
||||||
} else {
|
} else {
|
||||||
let mut moved = sp.path.clone();
|
let mut moved = sp.path.clone();
|
||||||
moved.apply_affine(rel);
|
moved.apply_affine(rel);
|
||||||
std::borrow::Cow::Owned(moved)
|
std::borrow::Cow::Owned(moved)
|
||||||
};
|
};
|
||||||
stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke);
|
// The line stops inside its arrowheads, so a wide line doesn't poke out of them.
|
||||||
|
let whole = path.clone();
|
||||||
|
if Some(i) == first_stroked && trims != (0.0, 0.0) {
|
||||||
|
path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1));
|
||||||
|
}
|
||||||
|
stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke);
|
||||||
}
|
}
|
||||||
if s.is_line() || geo.is_open() {
|
if ends {
|
||||||
arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw);
|
arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw);
|
||||||
}
|
}
|
||||||
|
if clipped {
|
||||||
|
ctx.pop_layer();
|
||||||
|
}
|
||||||
|
if see_through.is_some() {
|
||||||
|
ctx.pop_layer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(r) = soft {
|
if let Some(r) = soft {
|
||||||
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
|
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
|
||||||
|
|
@ -1057,6 +1208,8 @@ impl Renderer {
|
||||||
ph: Option<Rgba>,
|
ph: Option<Rgba>,
|
||||||
geo: &Geometry,
|
geo: &Geometry,
|
||||||
fill: &Option<Fill>,
|
fill: &Option<Fill>,
|
||||||
|
fill_alpha: Option<f64>,
|
||||||
|
outline: Option<Caster>,
|
||||||
content: Option<&deckcraft_model::style::PictureFill>,
|
content: Option<&deckcraft_model::style::PictureFill>,
|
||||||
m: Affine,
|
m: Affine,
|
||||||
w: f64,
|
w: f64,
|
||||||
|
|
@ -1075,12 +1228,10 @@ impl Renderer {
|
||||||
if let Some(sh) = &e.outer_shadow {
|
if let Some(sh) = &e.outer_shadow {
|
||||||
let c = rctx.color(&sh.color, ph);
|
let c = rctx.color(&sh.color, ph);
|
||||||
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
|
||||||
let c0 = Point::new(w / 2.0, h / 2.0);
|
let local = shadow_local(sh, w, h);
|
||||||
let local = Affine::translate(c0.to_vec2())
|
|
||||||
* Affine::new([sh.sx, sh.ky.to_radians().tan(), sh.kx.to_radians().tan(), sh.sy, 0.0, 0.0])
|
|
||||||
* Affine::translate(-c0.to_vec2());
|
|
||||||
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
|
// 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 };
|
let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) };
|
||||||
|
let sm = pre * m * own;
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||||
|
|
@ -1092,15 +1243,36 @@ impl Renderer {
|
||||||
Some(pf) => {
|
Some(pf) => {
|
||||||
ctx.push_clip_layer(&silhouette);
|
ctx.push_clip_layer(&silhouette);
|
||||||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
||||||
|
// Drawing the picture leaves its own pixel transform set: back to the shape's,
|
||||||
|
// or the recolouring below covers only part of a large picture's shadow.
|
||||||
|
ctx.set_transform(sm);
|
||||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
|
// The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as
|
||||||
|
// see-through as its fill (none for an outline-only shape) …
|
||||||
None => {
|
None => {
|
||||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
if let Some(a) = fill_alpha {
|
||||||
ctx.fill_path(&silhouette);
|
let mut filled = BezPath::new();
|
||||||
|
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
||||||
|
filled.extend(sp.path.iter());
|
||||||
|
}
|
||||||
|
ctx.set_paint(color(c.with_alpha(255), a));
|
||||||
|
ctx.fill_path(&filled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// … and the outline (a picture's border too).
|
||||||
|
if let Some(o) = &outline {
|
||||||
|
let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() };
|
||||||
|
let lw = o.line.width.unwrap_or(0.75).max(0.0);
|
||||||
|
for sp in geo.paths.iter().filter(|sp| sp.stroke) {
|
||||||
|
let mut path = sp.path.clone();
|
||||||
|
path.apply_affine(o.rel * own);
|
||||||
|
stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
|
|
@ -1124,9 +1296,20 @@ impl Renderer {
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
ctx.push_layer(None, None, Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6), None, Some(blur(rf.blur * 0.5 * k)));
|
ctx.push_layer(None, None, Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6), None, Some(blur(rf.blur * 0.5 * k)));
|
||||||
ctx.set_transform(mm);
|
ctx.set_transform(mm);
|
||||||
if let Some(fl) = fill {
|
match content {
|
||||||
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
// A picture reflects its pixels (it has no fill of its own).
|
||||||
|
Some(pf) => {
|
||||||
|
ctx.push_clip_layer(&silhouette);
|
||||||
|
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), mm, None);
|
||||||
|
ctx.pop_layer();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(fl) = fill {
|
||||||
|
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ctx.set_transform(mm);
|
||||||
// Fade mask.
|
// Fade mask.
|
||||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None);
|
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None);
|
||||||
let grad = peniko::Gradient::new_linear(Point::new(0.0, h), Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0))))
|
let grad = peniko::Gradient::new_linear(Point::new(0.0, h), Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0))))
|
||||||
|
|
@ -1139,6 +1322,78 @@ impl Renderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a shape's border is drawn outside its outline: a picture's is, unless the picture is
|
||||||
|
/// drawn in 3-D (a bevel seen through a 3-D scene), where PowerPoint centres it on the edge as on
|
||||||
|
/// any shape (observed: an 18 pt border covers 9 pt of the picture then).
|
||||||
|
fn border_outside(is_pic: bool, effects: Option<&Effects>) -> bool {
|
||||||
|
is_pic && !effects.is_some_and(|e| e.bevel.is_some() && e.raw3d.as_deref().is_some_and(|r| r.contains("scene3d")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside
|
||||||
|
/// the outline is the border, with dashes kept their length. `None` for compound lines, whose
|
||||||
|
/// bands would not survive the doubling (they stay centred on the outline).
|
||||||
|
fn outside_line(line: &Line, lw: f64) -> Option<Line> {
|
||||||
|
if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dash = line.dash.as_ref().map(|d| {
|
||||||
|
let p = d.pattern();
|
||||||
|
deckcraft_model::style::Dash::Custom {
|
||||||
|
pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity,
|
||||||
|
/// and the space it is stroked in (see `Renderer::line_space`).
|
||||||
|
struct Caster<'a> {
|
||||||
|
line: std::borrow::Cow<'a, Line>,
|
||||||
|
ph: Option<Rgba>,
|
||||||
|
alpha: f64,
|
||||||
|
base: Affine,
|
||||||
|
rel: Affine,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a
|
||||||
|
/// gradient's mean stop alpha, otherwise opaque.
|
||||||
|
fn fill_opacity(rctx: &Ctx, fill: &Option<Fill>, ph: Option<Rgba>) -> Option<f64> {
|
||||||
|
match fill {
|
||||||
|
None | Some(Fill::None) => None,
|
||||||
|
Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0),
|
||||||
|
Some(Fill::Gradient(g)) if !g.stops.is_empty() => {
|
||||||
|
Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::<f64>() / g.stops.len() as f64)
|
||||||
|
}
|
||||||
|
Some(_) => Some(1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point
|
||||||
|
/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the
|
||||||
|
/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow
|
||||||
|
/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its
|
||||||
|
/// middle.
|
||||||
|
fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine {
|
||||||
|
let (ax, ay) = match sh.align.as_str() {
|
||||||
|
"tl" => (0.0, 0.0),
|
||||||
|
"t" => (0.5, 0.0),
|
||||||
|
"tr" => (1.0, 0.0),
|
||||||
|
"l" => (0.0, 0.5),
|
||||||
|
"r" => (1.0, 0.5),
|
||||||
|
"bl" => (0.0, 1.0),
|
||||||
|
"b" => (0.5, 1.0),
|
||||||
|
"br" => (1.0, 1.0),
|
||||||
|
_ => (0.5, 0.5),
|
||||||
|
};
|
||||||
|
let a = Vec2::new(w * ax, h * ay);
|
||||||
|
let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan());
|
||||||
|
let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]);
|
||||||
|
if !k.is_finite() {
|
||||||
|
return Affine::IDENTITY;
|
||||||
|
}
|
||||||
|
Affine::translate(a) * k * Affine::translate(-a)
|
||||||
|
}
|
||||||
|
|
||||||
fn pre_translate(m: Affine, d: Vec2) -> Affine {
|
fn pre_translate(m: Affine, d: Vec2) -> Affine {
|
||||||
Affine::translate(d) * m
|
Affine::translate(d) * m
|
||||||
}
|
}
|
||||||
|
|
@ -1151,7 +1406,20 @@ fn blur(sigma: f64) -> Filter {
|
||||||
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
|
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, path: &BezPath, m: Affine, lw: f64, alpha: f64) {
|
/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the
|
||||||
|
/// line belongs to; `path` itself when `None`).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn stroke(
|
||||||
|
ctx: &mut RenderContext,
|
||||||
|
rctx: &Ctx,
|
||||||
|
line: &Line,
|
||||||
|
ph: Option<Rgba>,
|
||||||
|
path: &BezPath,
|
||||||
|
extent: Option<&BezPath>,
|
||||||
|
m: Affine,
|
||||||
|
lw: f64,
|
||||||
|
alpha: f64,
|
||||||
|
) {
|
||||||
use deckcraft_model::style::{LineCap, LineJoin};
|
use deckcraft_model::style::{LineCap, LineJoin};
|
||||||
let cap = match line.cap.unwrap_or_default() {
|
let cap = match line.cap.unwrap_or_default() {
|
||||||
LineCap::Flat => kurbo::Cap::Butt,
|
LineCap::Flat => kurbo::Cap::Butt,
|
||||||
|
|
@ -1165,10 +1433,20 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
||||||
};
|
};
|
||||||
// Hairlines stay visible at any zoom.
|
// Hairlines stay visible at any zoom.
|
||||||
let min_w = 0.5 / scale_of(m).max(1e-6);
|
let min_w = 0.5 / scale_of(m).max(1e-6);
|
||||||
let mut sk = kurbo::Stroke::new(lw.max(min_w)).with_caps(cap).with_join(join);
|
let sw = lw.max(min_w);
|
||||||
|
// PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`).
|
||||||
|
let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0);
|
||||||
|
// Square caps on a dashed line square off only the line's own two ends (where a dash is
|
||||||
|
// drawn): each dash itself ends flat, as PowerPoint draws them.
|
||||||
|
let mut square_ends = None;
|
||||||
if let Some(d) = &line.dash {
|
if let Some(d) = &line.dash {
|
||||||
let pat: Vec<f64> = d.pattern().iter().map(|v| v * lw.max(0.75)).collect();
|
let pat: Vec<f64> = d.pattern().iter().map(|v| v * lw.max(0.75)).collect();
|
||||||
|
let pat = dash_with_caps(pat, cap, sw);
|
||||||
if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) {
|
if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) {
|
||||||
|
if cap == kurbo::Cap::Square {
|
||||||
|
sk = sk.with_caps(kurbo::Cap::Butt);
|
||||||
|
square_ends = Some(square_dash_ends(path, &pat, sw));
|
||||||
|
}
|
||||||
sk = sk.with_dashes(0.0, pat);
|
sk = sk.with_dashes(0.0, pat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1177,10 +1455,7 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
||||||
Some(Fill::Solid { color: c }) => {
|
Some(Fill::Solid { color: c }) => {
|
||||||
ctx.set_paint(color(rctx.color(c, ph), alpha));
|
ctx.set_paint(color(rctx.color(c, ph), alpha));
|
||||||
}
|
}
|
||||||
Some(Fill::Gradient(g)) => {
|
Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)),
|
||||||
let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK);
|
|
||||||
ctx.set_paint(color(c, alpha));
|
|
||||||
}
|
|
||||||
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
|
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
|
||||||
}
|
}
|
||||||
// Compound lines: each band stroked along the path moved to its place across the width.
|
// Compound lines: each band stroked along the path moved to its place across the width.
|
||||||
|
|
@ -1198,6 +1473,113 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
||||||
}
|
}
|
||||||
ctx.set_stroke(sk);
|
ctx.set_stroke(sk);
|
||||||
ctx.stroke_path(path);
|
ctx.stroke_path(path);
|
||||||
|
if let Some(ends) = square_ends {
|
||||||
|
ctx.fill_path(&ends);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square
|
||||||
|
/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end
|
||||||
|
/// when the pattern is in a dash there.
|
||||||
|
fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath {
|
||||||
|
use kurbo::{ParamCurve, ParamCurveArclen};
|
||||||
|
let mut out = BezPath::new();
|
||||||
|
let period: f64 = pattern.iter().sum();
|
||||||
|
let mut cap = |at: Point, dir: Vec2| {
|
||||||
|
let n = Vec2::new(-dir.y, dir.x) * (width / 2.0);
|
||||||
|
let back = dir * (width / 2.0);
|
||||||
|
out.move_to(at + n);
|
||||||
|
out.line_to(at + n + back);
|
||||||
|
out.line_to(at - n + back);
|
||||||
|
out.line_to(at - n);
|
||||||
|
out.close_path();
|
||||||
|
};
|
||||||
|
let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| {
|
||||||
|
let d = seg.eval(t1) - seg.eval(t0);
|
||||||
|
let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() };
|
||||||
|
if d.hypot() > 1e-9 { Some(d.normalize()) } else { None }
|
||||||
|
};
|
||||||
|
let mut subs: Vec<(Vec<kurbo::PathSeg>, bool)> = vec![];
|
||||||
|
let mut cur: Vec<kurbo::PathSeg> = vec![];
|
||||||
|
let mut closed = false;
|
||||||
|
let mut last = Point::ZERO;
|
||||||
|
let mut start = Point::ZERO;
|
||||||
|
for el in path.elements() {
|
||||||
|
let seg = match *el {
|
||||||
|
PathEl::MoveTo(p) => {
|
||||||
|
if !cur.is_empty() {
|
||||||
|
subs.push((std::mem::take(&mut cur), closed));
|
||||||
|
}
|
||||||
|
closed = false;
|
||||||
|
last = p;
|
||||||
|
start = p;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)),
|
||||||
|
PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)),
|
||||||
|
PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)),
|
||||||
|
PathEl::ClosePath => {
|
||||||
|
closed = true;
|
||||||
|
last = start;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
last = seg.end();
|
||||||
|
cur.push(seg);
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
subs.push((cur, closed));
|
||||||
|
}
|
||||||
|
for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) {
|
||||||
|
let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue };
|
||||||
|
if let Some(d) = dir(first, 1e-3, 0.0) {
|
||||||
|
cap(first.start(), d);
|
||||||
|
}
|
||||||
|
let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum();
|
||||||
|
if !(period > 0.0 && len.is_finite()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut pos = len % period;
|
||||||
|
// Ending exactly on a period boundary is the end of a gap.
|
||||||
|
let mut on = pos > period * 1e-6;
|
||||||
|
for v in pattern.iter().cycle().take(pattern.len()) {
|
||||||
|
if pos <= *v {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos -= v;
|
||||||
|
on = !on;
|
||||||
|
}
|
||||||
|
if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) {
|
||||||
|
cap(end.end(), d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the
|
||||||
|
/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs
|
||||||
|
/// across its width.
|
||||||
|
fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option<Rgba>, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient {
|
||||||
|
let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0);
|
||||||
|
let bounds = paint::tile_box(bounds, g.tile);
|
||||||
|
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect();
|
||||||
|
paint::gradient(&g.shape, stops, bounds, alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As
|
||||||
|
/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a
|
||||||
|
/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid
|
||||||
|
/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a
|
||||||
|
/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see
|
||||||
|
/// `square_dash_ends`.)
|
||||||
|
fn dash_with_caps(mut pat: Vec<f64>, cap: kurbo::Cap, width: f64) -> Vec<f64> {
|
||||||
|
if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) {
|
||||||
|
return pat;
|
||||||
|
}
|
||||||
|
for (i, v) in pat.iter_mut().enumerate() {
|
||||||
|
*v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width };
|
||||||
|
}
|
||||||
|
pat
|
||||||
}
|
}
|
||||||
|
|
||||||
fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
||||||
|
|
@ -1228,6 +1610,128 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
|
||||||
Some(((first, second), (last, prev)))
|
Some(((first, second), (last, prev)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An arrowhead's width and length for a line `lw` wide. PowerPoint sizes them in multiples of
|
||||||
|
/// the line width (small 2, medium 3, large 5), but never smaller than for a 2 pt line.
|
||||||
|
fn head_size(e: &deckcraft_model::style::LineEnd, lw: f64) -> (f64, f64) {
|
||||||
|
let sz = |s: &str| match s {
|
||||||
|
"sm" => 2.0,
|
||||||
|
"lg" => 5.0,
|
||||||
|
_ => 3.0,
|
||||||
|
};
|
||||||
|
let k = lw.max(2.0);
|
||||||
|
(sz(&e.w) * k, sz(&e.len) * k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far back from the tip an open arrowhead's legs (`lw` wide) meet along their centre lines:
|
||||||
|
/// PowerPoint puts the outside of the point, not the legs' centre lines, on the line's end.
|
||||||
|
fn open_arrow_setback(wl: f64, ll: f64, lw: f64) -> f64 {
|
||||||
|
let half = (wl / 2.0).atan2(ll);
|
||||||
|
if half.sin() > 1e-6 { (lw / 2.0 / half.sin()).min(ll) } else { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of a `lw`-wide line to leave off at its start and end, under its arrowheads: up to
|
||||||
|
/// a triangle's base, into a stealth arrow's notch far enough for its barbs to cover the line's
|
||||||
|
/// corners, and to where an open arrow's legs meet. Ovals and diamonds sit on the end itself.
|
||||||
|
fn head_trims(line: &Line, lw: f64) -> (f64, f64) {
|
||||||
|
// A square cap reaches half a width past the line's end: leave that off too.
|
||||||
|
let cap = if line.cap == Some(deckcraft_model::style::LineCap::Square) { lw / 2.0 } else { 0.0 };
|
||||||
|
let trim = |e: &Option<deckcraft_model::style::LineEnd>| {
|
||||||
|
let Some(e) = e else { return 0.0 };
|
||||||
|
let (wl, ll) = head_size(e, lw);
|
||||||
|
match e.kind.as_str() {
|
||||||
|
"triangle" => ll + cap,
|
||||||
|
"stealth" => (ll * (0.6 + 0.4 * lw / wl.max(1e-9))).min(ll) + cap,
|
||||||
|
"arrow" => open_arrow_setback(wl, ll, lw.max(0.75)) + cap,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(trim(&line.head), trim(&line.tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `path` with `start` left off the beginning of its first sub-path and `end` off the end of its
|
||||||
|
/// last (by arc length), where those are open. A sub-path shorter than its trims disappears.
|
||||||
|
fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath {
|
||||||
|
use kurbo::{ParamCurve, ParamCurveArclen};
|
||||||
|
const ACC: f64 = 1e-3;
|
||||||
|
let mut subs: Vec<(Vec<kurbo::PathSeg>, bool)> = vec![];
|
||||||
|
let (mut last, mut first) = (Point::ZERO, Point::ZERO);
|
||||||
|
for el in path.elements() {
|
||||||
|
let seg = match *el {
|
||||||
|
PathEl::MoveTo(p) => {
|
||||||
|
subs.push((vec![], false));
|
||||||
|
last = p;
|
||||||
|
first = p;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PathEl::ClosePath => {
|
||||||
|
if let Some(s) = subs.last_mut() {
|
||||||
|
s.1 = true;
|
||||||
|
}
|
||||||
|
last = first;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)),
|
||||||
|
PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)),
|
||||||
|
PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)),
|
||||||
|
};
|
||||||
|
last = seg.end();
|
||||||
|
if subs.is_empty() {
|
||||||
|
subs.push((vec![], false));
|
||||||
|
}
|
||||||
|
if let Some(s) = subs.last_mut() {
|
||||||
|
s.0.push(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((segs, false)) = subs.first_mut()
|
||||||
|
&& start > 0.0
|
||||||
|
{
|
||||||
|
let mut left = start;
|
||||||
|
while let Some(seg) = segs.first().copied() {
|
||||||
|
let l = seg.arclen(ACC);
|
||||||
|
if l > left {
|
||||||
|
if let Some(s0) = segs.first_mut() {
|
||||||
|
*s0 = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
left -= l;
|
||||||
|
segs.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((segs, false)) = subs.last_mut()
|
||||||
|
&& end > 0.0
|
||||||
|
{
|
||||||
|
let mut left = end;
|
||||||
|
while let Some(seg) = segs.last().copied() {
|
||||||
|
let l = seg.arclen(ACC);
|
||||||
|
if l > left {
|
||||||
|
if let Some(s1) = segs.last_mut() {
|
||||||
|
*s1 = seg.subsegment(0.0..seg.inv_arclen(l - left, ACC));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
left -= l;
|
||||||
|
segs.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = BezPath::new();
|
||||||
|
for (segs, closed) in subs {
|
||||||
|
let Some(s0) = segs.first() else { continue };
|
||||||
|
out.move_to(s0.start());
|
||||||
|
for seg in &segs {
|
||||||
|
match seg {
|
||||||
|
kurbo::PathSeg::Line(l) => out.line_to(l.p1),
|
||||||
|
kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
|
||||||
|
kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closed {
|
||||||
|
out.close_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
|
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
|
||||||
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
|
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
|
||||||
|
|
@ -1238,18 +1742,25 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>
|
||||||
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
|
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
|
||||||
_ => Rgba::BLACK,
|
_ => Rgba::BLACK,
|
||||||
};
|
};
|
||||||
let lw = lw.max(0.75);
|
// A gradient line's heads take the gradient where they are.
|
||||||
|
let grad = match &line.fill {
|
||||||
|
Some(Fill::Gradient(g)) if !g.stops.is_empty() => {
|
||||||
|
let mut moved = sp.path.clone();
|
||||||
|
moved.apply_affine(rel);
|
||||||
|
Some(line_gradient(rctx, g, ph, &moved, lw, 1.0))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let set_paint = |ctx: &mut RenderContext| match &grad {
|
||||||
|
Some(g) => ctx.set_paint(g.clone()),
|
||||||
|
None => ctx.set_paint(color(col, 1.0)),
|
||||||
|
};
|
||||||
for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] {
|
for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] {
|
||||||
let Some(e) = end else { continue };
|
let Some(e) = end else { continue };
|
||||||
if e.kind == "none" {
|
if e.kind == "none" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sz = |s: &str| match s {
|
let (wl, ll) = head_size(e, lw);
|
||||||
"sm" => 2.0,
|
|
||||||
"lg" => 5.0,
|
|
||||||
_ => 3.0,
|
|
||||||
};
|
|
||||||
let (wl, ll) = (sz(&e.w) * lw, sz(&e.len) * lw);
|
|
||||||
let dir = (tip - from).normalize();
|
let dir = (tip - from).normalize();
|
||||||
if !dir.x.is_finite() {
|
if !dir.x.is_finite() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1277,13 +1788,17 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>
|
||||||
p.close_path();
|
p.close_path();
|
||||||
}
|
}
|
||||||
"arrow" => {
|
"arrow" => {
|
||||||
|
// Legs the line's width with round ends, meeting in a sharp point whose outside is
|
||||||
|
// on the line's end.
|
||||||
|
let leg = lw.max(0.75);
|
||||||
|
let back = dir * open_arrow_setback(wl, ll, leg);
|
||||||
let mut o = BezPath::new();
|
let mut o = BezPath::new();
|
||||||
o.move_to(base + n * wl / 2.0);
|
o.move_to(base + n * wl / 2.0 - back);
|
||||||
o.line_to(tip);
|
o.line_to(tip - back);
|
||||||
o.line_to(base - n * wl / 2.0);
|
o.line_to(base - n * wl / 2.0 - back);
|
||||||
ctx.set_transform(m);
|
ctx.set_transform(m);
|
||||||
ctx.set_paint(color(col, 1.0));
|
set_paint(ctx);
|
||||||
ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Miter));
|
ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round));
|
||||||
ctx.stroke_path(&o);
|
ctx.stroke_path(&o);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -1295,7 +1810,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.set_transform(m);
|
ctx.set_transform(m);
|
||||||
ctx.set_paint(color(col, 1.0));
|
set_paint(ctx);
|
||||||
ctx.fill_path(&p);
|
ctx.fill_path(&p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1614,6 +2129,9 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64)
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_wild;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod mt_tests {
|
mod mt_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,22 @@ pub fn fill_path(
|
||||||
if g.stops.is_empty() {
|
if g.stops.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// `rotWithShape="0"`: the gradient keeps to the slide's axes when the shape is
|
||||||
|
// rotated or flipped, laid out over the box the shape covers on the slide.
|
||||||
|
let c = m.as_coeffs();
|
||||||
|
let turned = c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0;
|
||||||
|
let path_kind = matches!(&g.shape, GradientShape::Path { path: k, .. } if matches!(k.as_str(), "rect" | "shape"));
|
||||||
|
if !g.rotate_with_shape && turned && !path_kind && m.determinant().abs() > 1e-12 {
|
||||||
|
let on_slide = m.transform_rect_bbox(bounds);
|
||||||
|
let upright = deckcraft_model::style::Gradient { rotate_with_shape: true, ..g.clone() };
|
||||||
|
let paint_path = m * path.clone();
|
||||||
|
fill_path(ctx, rctx, &Fill::Gradient(upright), ph, &paint_path, on_slide, Affine::IDENTITY, mode, alpha, pres);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A linear gradient spans its tile rectangle (the shape's box unless `tileRect` moves
|
||||||
|
// its edges); a radial one ends on the circle around it, from a focus placed in the
|
||||||
|
// shape's own box (PowerPoint's "from corner" presets centre the tile on the corner).
|
||||||
|
let tile = tile_box(bounds, g.tile);
|
||||||
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
|
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));
|
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
|
||||||
|
|
||||||
|
|
@ -55,26 +71,30 @@ pub fn fill_path(
|
||||||
// Angle measured clockwise from left→right; span the bounds along that direction.
|
// Angle measured clockwise from left→right; span the bounds along that direction.
|
||||||
let a = angle.to_radians();
|
let a = angle.to_radians();
|
||||||
let (dx, dy) = (a.cos(), a.sin());
|
let (dx, dy) = (a.cos(), a.sin());
|
||||||
let c = bounds.center();
|
let c = tile.center();
|
||||||
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
|
let half = (tile.width() * dx.abs() + tile.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))
|
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())
|
.with_stops(stops.as_slice())
|
||||||
}
|
}
|
||||||
GradientShape::Path { focus, .. } => {
|
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, tile).with_stops(stops.as_slice()),
|
||||||
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.set_paint(grad);
|
||||||
ctx.fill_path(path);
|
ctx.fill_path(path);
|
||||||
}
|
}
|
||||||
Fill::Picture(pf) => {
|
Fill::Picture(pf) => {
|
||||||
|
// `rotWithShape="0"`: upright on the slide, stretched over the box the turned shape
|
||||||
|
// covers, clipped to it.
|
||||||
|
let c = m.as_coeffs();
|
||||||
|
let turned = c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0;
|
||||||
|
if pf.upright && turned && m.determinant().abs() > 1e-12 {
|
||||||
|
let on_slide = m.transform_rect_bbox(bounds);
|
||||||
|
let on_path = m * path.clone();
|
||||||
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
|
ctx.push_clip_layer(&on_path);
|
||||||
|
picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None);
|
||||||
|
ctx.pop_layer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.push_clip_layer(path);
|
ctx.push_clip_layer(path);
|
||||||
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
|
picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None);
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
|
|
@ -97,6 +117,22 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::b
|
||||||
std::borrow::Cow::Owned(out)
|
std::borrow::Cow::Owned(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `bounds` with its edges moved in by `tile` (left, top, right, bottom insets as fractions of its
|
||||||
|
/// size; negative moves them out), as `a:tileRect` and `a:fillRect` lay out a fill.
|
||||||
|
pub fn tile_box(bounds: Rect, tile: [f64; 4]) -> Rect {
|
||||||
|
if tile.iter().all(|v| *v == 0.0) || tile.iter().any(|v| !v.is_finite()) {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
let (w, h) = (bounds.width(), bounds.height());
|
||||||
|
let [l, t, r, b] = tile;
|
||||||
|
let x0 = bounds.x0 + l * w;
|
||||||
|
let x1 = bounds.x1 - r * w;
|
||||||
|
let y0 = bounds.y0 + t * h;
|
||||||
|
let y1 = bounds.y1 - b * h;
|
||||||
|
// An inverted tile (insets past each other) lays out nothing sensible: keep the box.
|
||||||
|
if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { bounds } else { Rect::new(x0, y0, x1, y1) }
|
||||||
|
}
|
||||||
|
|
||||||
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
|
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
|
||||||
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
|
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
|
||||||
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
|
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
|
||||||
|
|
@ -116,19 +152,34 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect
|
||||||
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
|
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())
|
.with_stops(stops.as_slice())
|
||||||
}
|
}
|
||||||
GradientShape::Path { focus, .. } => {
|
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, bounds).with_stops(stops.as_slice()),
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `circle` path gradient over `bounds` with focus rectangle `focus` (`fillToRect` insets, as
|
||||||
|
/// fractions). As PowerPoint draws it (fitted to its exports): the first stop is a point at the
|
||||||
|
/// focus (the middle of the focus rectangle) and the last is the circle around the whole box (its
|
||||||
|
/// centre, half its diagonal), with circles in between moving from one to the other. A focus off
|
||||||
|
/// the centre so bunches the colours on its near side; a gradient "from a corner" reaches its
|
||||||
|
/// last colour at that corner too, not only at the far one.
|
||||||
|
///
|
||||||
|
/// With a tile rectangle (`tile`, otherwise `bounds` itself) the end circle is the one around
|
||||||
|
/// the tile, while the focus stays placed in `bounds`.
|
||||||
|
pub fn circle_gradient(focus: [f64; 4], bounds: Rect, tile: Rect) -> peniko::Gradient {
|
||||||
|
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;
|
||||||
|
let c = tile.center();
|
||||||
|
let r = (tile.width().hypot(tile.height()) / 2.0).max(1e-6);
|
||||||
|
let f = Point::new(fx, fy);
|
||||||
|
if !(f.is_finite() && c.is_finite() && r.is_finite()) {
|
||||||
|
return peniko::Gradient::new_radial(c, r as f32);
|
||||||
|
}
|
||||||
|
// A focus on or outside the end circle has no circle through it: keep it just inside.
|
||||||
|
let d = f - c;
|
||||||
|
let f = if d.hypot() > r * 0.999 { c + d * (r * 0.999 / d.hypot()) } else { f };
|
||||||
|
peniko::Gradient::new_two_point_radial(f, 0.0, c, r as f32)
|
||||||
|
}
|
||||||
|
|
||||||
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
|
||||||
/// GIF shows its frame at `gif_time` seconds.
|
/// GIF shows its frame at `gif_time` seconds.
|
||||||
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
|
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
|
||||||
|
|
@ -165,24 +216,67 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||||
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() },
|
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() },
|
||||||
};
|
};
|
||||||
if let PictureMode::Tile { sx, sy, tx, ty, .. } = &pf.mode {
|
if let PictureMode::Tile { sx, sy, tx, ty, flip, align } = &pf.mode {
|
||||||
let (sx, sy) = (if *sx > 0.0 { *sx } else { 1.0 }, if *sy > 0.0 { *sy } else { 1.0 });
|
// The tile's size: the picture's own size (96 dpi) scaled. A scale of zero or less draws
|
||||||
let tile_w = iw * 0.75 * sx;
|
// nothing, as in PowerPoint.
|
||||||
let tile_h = ih * 0.75 * sy;
|
if !(*sx > 0.0 && *sy > 0.0 && sx.is_finite() && sy.is_finite()) {
|
||||||
let nx = ((bounds.width() / tile_w.max(1.0)).ceil() as i64).clamp(0, 200);
|
return;
|
||||||
let ny = ((bounds.height() / tile_h.max(1.0)).ceil() as i64).clamp(0, 200);
|
}
|
||||||
for j in -1..=ny {
|
let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih));
|
||||||
for i in -1..=nx {
|
// PowerPoint draws the tile at a whole number of output pixels.
|
||||||
let r = Rect::new(bounds.x0 + tx + i as f64 * tile_w, bounds.y0 + ty + j as f64 * tile_h, 0.0, 0.0);
|
let k = px_scale.max(1e-9);
|
||||||
let r = Rect::new(r.x0, r.y0, r.x0 + tile_w, r.y0 + tile_h);
|
let snap = |v: f64| (v * k).round().max(1.0) / k;
|
||||||
draw_image(ctx, &image, iw, ih, r, m, alpha);
|
let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy));
|
||||||
}
|
if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let o = tile_origin(bounds, align, (tile_w, tile_h), (*tx, *ty));
|
||||||
|
// Every other tile is mirrored across the flip axes: a reflected repeat.
|
||||||
|
let ext = |mirror: bool| if mirror { peniko::Extend::Reflect } else { peniko::Extend::Repeat };
|
||||||
|
let tiled = vello_cpu::Image {
|
||||||
|
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||||
|
sampler: peniko::ImageSampler {
|
||||||
|
x_extend: ext(matches!(flip.as_str(), "x" | "xy")),
|
||||||
|
y_extend: ext(matches!(flip.as_str(), "y" | "xy")),
|
||||||
|
quality: peniko::ImageQuality::High,
|
||||||
|
alpha: 1.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.set_transform(m);
|
||||||
|
if alpha < 0.999 {
|
||||||
|
ctx.push_layer(None, None, Some(alpha as f32), None, None);
|
||||||
|
}
|
||||||
|
ctx.set_paint(tiled);
|
||||||
|
ctx.set_paint_transform(Affine::translate(o.to_vec2()) * Affine::scale_non_uniform(tile_w / iw, tile_h / ih));
|
||||||
|
ctx.fill_rect(&bounds);
|
||||||
|
ctx.reset_paint_transform();
|
||||||
|
if alpha < 0.999 {
|
||||||
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draw_image(ctx, &image, iw, ih, dest, m, alpha);
|
draw_image(ctx, &image, iw, ih, dest, m, alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or
|
||||||
|
/// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point
|
||||||
|
/// of the box, then moved by the offset (points).
|
||||||
|
pub fn tile_origin(bounds: Rect, align: &str, tile: (f64, f64), offset: (f64, f64)) -> Point {
|
||||||
|
let (ax, ay) = match align {
|
||||||
|
"t" => (0.5, 0.0),
|
||||||
|
"tr" => (1.0, 0.0),
|
||||||
|
"l" => (0.0, 0.5),
|
||||||
|
"ctr" => (0.5, 0.5),
|
||||||
|
"r" => (1.0, 0.5),
|
||||||
|
"bl" => (0.0, 1.0),
|
||||||
|
"b" => (0.5, 1.0),
|
||||||
|
"br" => (1.0, 1.0),
|
||||||
|
_ => (0.0, 0.0),
|
||||||
|
};
|
||||||
|
let (ox, oy) = (if offset.0.is_finite() { offset.0 } else { 0.0 }, if offset.1.is_finite() { offset.1 } else { 0.0 });
|
||||||
|
Point::new(bounds.x0 + ax * (bounds.width() - tile.0) + ox, bounds.y0 + ay * (bounds.height() - tile.1) + oy)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_image(ctx: &mut RenderContext, image: &vello_cpu::Image, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) {
|
fn draw_image(ctx: &mut RenderContext, image: &vello_cpu::Image, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) {
|
||||||
let t = m * Affine::translate(dest.origin().to_vec2()) * Affine::scale_non_uniform(dest.width() / iw, dest.height() / ih);
|
let t = m * Affine::translate(dest.origin().to_vec2()) * Affine::scale_non_uniform(dest.width() / iw, dest.height() / ih);
|
||||||
ctx.set_transform(t);
|
ctx.set_transform(t);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,32 @@ pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec<Point>
|
||||||
);
|
);
|
||||||
let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.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 {
|
match kind {
|
||||||
"shape" if !outline.is_empty() => shape_position(outline, f.center(), p),
|
"shape" if !outline.is_empty() => {
|
||||||
|
// A focus rectangle (not a point) is the first colour throughout, as PowerPoint draws
|
||||||
|
// it; outside, the colour runs from the rectangle's edge to the outline along the ray
|
||||||
|
// from its centre.
|
||||||
|
if f.width() > 1e-9 && f.height() > 1e-9 {
|
||||||
|
if f.contains(p) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let c = f.center();
|
||||||
|
let to_outline = shape_position(outline, c, p);
|
||||||
|
let d = (p - c).hypot();
|
||||||
|
if to_outline <= 0.0 || d <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
// Where the ray leaves the focus rectangle, as a share of the way to `p`.
|
||||||
|
let v = p - c;
|
||||||
|
let kx = if v.x.abs() > 1e-12 { (f.width() / 2.0) / v.x.abs() } else { f64::INFINITY };
|
||||||
|
let ky = if v.y.abs() > 1e-12 { (f.height() / 2.0) / v.y.abs() } else { f64::INFINITY };
|
||||||
|
let rect_share = kx.min(ky).min(1.0);
|
||||||
|
// p is `to_outline` of the way to the outline; the rectangle edge `rect_share` of
|
||||||
|
// the way to p.
|
||||||
|
let outline_share = 1.0 / to_outline; // outline distance in units of |p - c|
|
||||||
|
return ((1.0 - rect_share) / (outline_share - rect_share).max(1e-9)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
shape_position(outline, f.center(), p)
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Distance outside the focus rect along each axis, over the room to that side's edge.
|
// 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| {
|
let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| {
|
||||||
|
|
@ -55,7 +80,9 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||||||
if len < 1e-9 {
|
if len < 1e-9 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
// Nearest crossing of the ray c + s·d (s > 0) with an edge.
|
// The outline scaled about `c` by t reaches `p` first (smallest t) where the ray's farthest
|
||||||
|
// crossing does: the only crossing for a focus inside a convex outline, and the one that
|
||||||
|
// still works for a focus off the shape (a corner of an ellipse's box).
|
||||||
let mut best = f64::INFINITY;
|
let mut best = f64::INFINITY;
|
||||||
for poly in outline {
|
for poly in outline {
|
||||||
for w in poly.windows(2) {
|
for w in poly.windows(2) {
|
||||||
|
|
@ -69,7 +96,7 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||||||
let s = ac.cross(e) / den;
|
let s = ac.cross(e) / den;
|
||||||
let u = ac.cross(d) / den;
|
let u = ac.cross(d) / den;
|
||||||
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
|
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
|
||||||
best = best.min(s);
|
best = if best.is_finite() { best.max(s) } else { s };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +234,24 @@ mod tests {
|
||||||
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
|
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_paths_from_off_the_shape_or_a_focus_box() {
|
||||||
|
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
|
||||||
|
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
|
||||||
|
// Focus on the box's corner, outside the ellipse: contours are the ellipse shrunk towards
|
||||||
|
// the corner, so its far edge is the end and its middle part way (it was all the end
|
||||||
|
// colour).
|
||||||
|
let corner = [0.0, 0.0, 1.0, 1.0];
|
||||||
|
let mid = position("shape", b, corner, &ellipse, Point::new(140.0, 100.0));
|
||||||
|
assert!(mid > 0.3 && mid < 0.8, "{mid}");
|
||||||
|
assert!(position("shape", b, corner, &ellipse, Point::new(279.0, 100.0)) > 0.95);
|
||||||
|
// A focus rectangle is the first colour inside, and the colour runs from its edge out.
|
||||||
|
let boxed = [0.25, 0.25, 0.25, 0.25];
|
||||||
|
assert_eq!(position("shape", b, boxed, &ellipse, Point::new(150.0, 110.0)), 0.0);
|
||||||
|
let half = position("shape", b, boxed, &ellipse, Point::new(140.0 + 70.0 + 35.0, 100.0));
|
||||||
|
assert!((half - 0.5).abs() < 0.02, "{half}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_ramp_runs_through_the_stops() {
|
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 stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,10 @@ pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64,
|
||||||
sampler: peniko::ImageSampler {
|
sampler: peniko::ImageSampler {
|
||||||
x_extend: peniko::Extend::Repeat,
|
x_extend: peniko::Extend::Repeat,
|
||||||
y_extend: peniko::Extend::Repeat,
|
y_extend: peniko::Extend::Repeat,
|
||||||
quality: peniko::ImageQuality::Low,
|
// Bilinear, as PowerPoint samples the tile: exact at a whole number of output pixels
|
||||||
|
// per cell; between, a pixel straddling two cells takes a blend of both (fitted to its
|
||||||
|
// export at 0.75 px per cell: mean error 0.067 against 0.082 for the nearest cell).
|
||||||
|
quality: peniko::ImageQuality::Medium,
|
||||||
alpha: 1.0,
|
alpha: 1.0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,17 @@ pub fn draw(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Diagonal borders, under the edges: drawn like any line (dashes, compound, caps).
|
||||||
|
for (r, c, rect) in &rects {
|
||||||
|
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||||
|
for (line, a, b) in [(&cell.diag_down, (rect.x0, rect.y0), (rect.x1, rect.y1)), (&cell.diag_up, (rect.x0, rect.y1), (rect.x1, rect.y0))] {
|
||||||
|
let Some(l) = line.as_ref().filter(|l| l.fill.as_ref().is_some_and(|f| !f.is_none())) else { continue };
|
||||||
|
let mut p = BezPath::new();
|
||||||
|
p.move_to(a);
|
||||||
|
p.line_to(b);
|
||||||
|
crate::stroke(ctx, rctx, l, None, &p, None, m, l.width.unwrap_or(1.0).max(0.0), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Borders: explicit cell borders, else the style's grid.
|
// Borders: explicit cell borders, else the style's grid.
|
||||||
for (r, c, rect) in &rects {
|
for (r, c, rect) in &rects {
|
||||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||||
|
|
@ -361,11 +372,16 @@ pub fn draw(
|
||||||
for (i, e) in edges.iter().enumerate() {
|
for (i, e) in edges.iter().enumerate() {
|
||||||
let line = cell.borders.get(i).and_then(|b| b.as_ref());
|
let line = cell.borders.get(i).and_then(|b| b.as_ref());
|
||||||
let (col, wd) = match line {
|
let (col, wd) = match line {
|
||||||
Some(l) => match &l.fill {
|
// An explicit border is drawn like any line: dashed, compound, gradient.
|
||||||
Some(Fill::Solid { color: cc }) => (rctx.color(cc, None), l.width.unwrap_or(1.0)),
|
Some(l) => {
|
||||||
Some(Fill::None) => continue,
|
if l.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||||
_ => continue,
|
let mut p = BezPath::new();
|
||||||
},
|
p.move_to((e.0, e.1));
|
||||||
|
p.line_to((e.2, e.3));
|
||||||
|
crate::stroke(ctx, rctx, l, None, &p, None, m, l.width.unwrap_or(1.0).max(0.25), 1.0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
|
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
|
||||||
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
|
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
|
|
|
||||||
|
|
@ -565,6 +565,7 @@ fn a_linear_gradient_ramps_like_powerpoint() {
|
||||||
id: ShapeId(700),
|
id: ShapeId(700),
|
||||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||||
fill: Some(Fill::Gradient(Gradient {
|
fill: Some(Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
@ -589,6 +590,7 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() {
|
||||||
id: ShapeId(701),
|
id: ShapeId(701),
|
||||||
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
|
||||||
fill: Some(Fill::Gradient(Gradient {
|
fill: Some(Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
@ -629,6 +631,7 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() {
|
||||||
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
|
||||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
|
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
|
||||||
fill: Fill::Gradient(Gradient {
|
fill: Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
|
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
|
||||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
@ -702,6 +705,7 @@ fn a_text_gradient_starts_over_on_every_line() {
|
||||||
let props = &mut p.runs[0].props;
|
let props = &mut p.runs[0].props;
|
||||||
props.size = Some(80.0);
|
props.size = Some(80.0);
|
||||||
props.fill = Some(Fill::Gradient(Gradient {
|
props.fill = Some(Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
||||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
@ -862,6 +866,7 @@ fn rect_path_gradients_draw_x_shaped_contours() {
|
||||||
id: ShapeId(770),
|
id: ShapeId(770),
|
||||||
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
|
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
|
||||||
fill: Some(Fill::Gradient(Gradient {
|
fill: Some(Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
|
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] },
|
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,746 @@
|
||||||
|
//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports
|
||||||
|
//! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use deckcraft_color::Rgba;
|
||||||
|
use deckcraft_model::style::ColorRef;
|
||||||
|
use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, Xfrm};
|
||||||
|
|
||||||
|
fn deck_with(shapes: Vec<Shape>) -> Presentation {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
s.shapes = shapes;
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(shapes: Vec<Shape>) -> Image {
|
||||||
|
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn solid(c: Rgba) -> Option<Fill> {
|
||||||
|
Some(Fill::solid(ColorRef::rgb(c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_line() -> Option<Line> {
|
||||||
|
Some(Line { fill: Some(Fill::None), ..Default::default() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(px: [u8; 4], c: Rgba, tol: i32) -> bool {
|
||||||
|
(px[0] as i32 - c.r as i32).abs() <= tol && (px[1] as i32 - c.g as i32).abs() <= tol && (px[2] as i32 - c.b as i32).abs() <= tol
|
||||||
|
}
|
||||||
|
|
||||||
|
const WHITE: Rgba = Rgba::WHITE;
|
||||||
|
const RED: Rgba = Rgba::rgb(255, 0, 0);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_geometry_sub_paths_cut_holes_whichever_way_they_wind() {
|
||||||
|
// Outer square clockwise, inner square also clockwise: PowerPoint still shows a hole.
|
||||||
|
let d = "M 0 0 L 100 0 L 100 100 L 0 100 Z M 25 25 L 75 25 L 75 75 L 25 75 Z";
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(10),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 200.0)),
|
||||||
|
geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: d.into(), ..Default::default() }] },
|
||||||
|
fill: solid(RED),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
assert!(close(img.pixel(120, 120), RED, 2), "outer ring filled");
|
||||||
|
assert!(close(img.pixel(200, 200), WHITE, 2), "inner square is a hole: {:?}", img.pixel(200, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotted_line(cap: deckcraft_model::style::LineCap, len: f64) -> Shape {
|
||||||
|
Shape {
|
||||||
|
id: ShapeId(11),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, len, 0.0)),
|
||||||
|
geom: Geom::preset("line"),
|
||||||
|
line: Some(Line {
|
||||||
|
fill: solid(RED),
|
||||||
|
width: Some(10.0),
|
||||||
|
dash: Some(deckcraft_model::style::Dash::Preset { name: "sysDot".into() }),
|
||||||
|
cap: Some(cap),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_dots_are_separate_circles_with_the_caps_inside_each_dash() {
|
||||||
|
use deckcraft_model::style::LineCap;
|
||||||
|
// PowerPoint's "Round Dot": one width on, one off, round caps. The caps sit inside the dash, so
|
||||||
|
// the dots stay apart (they used to grow into each other and draw a solid line).
|
||||||
|
let img = render(vec![dotted_line(LineCap::Round, 200.0)]);
|
||||||
|
assert!(close(img.pixel(100, 100), RED, 40), "a dot centred on the start {:?}", img.pixel(100, 100));
|
||||||
|
assert!(close(img.pixel(120, 100), RED, 40), "the next dot one period on");
|
||||||
|
assert!(close(img.pixel(110, 100), WHITE, 10), "a gap between dots {:?}", img.pixel(110, 100));
|
||||||
|
// Flat caps: each dash is a width long, from the start.
|
||||||
|
let img = render(vec![dotted_line(LineCap::Flat, 200.0)]);
|
||||||
|
assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10));
|
||||||
|
// Square caps: dashes end flat; only the line's own start is squared off, half a width back.
|
||||||
|
let img = render(vec![dotted_line(LineCap::Square, 200.0)]);
|
||||||
|
assert!(close(img.pixel(97, 100), RED, 40), "square cap before the start {:?}", img.pixel(97, 100));
|
||||||
|
assert!(close(img.pixel(105, 100), RED, 40) && close(img.pixel(115, 100), WHITE, 10), "dashes keep their length");
|
||||||
|
assert!(close(img.pixel(122, 100), RED, 40) && close(img.pixel(118, 100), WHITE, 10), "no cap on the next dash");
|
||||||
|
// The end: 200 pt is ten whole periods, so the line ends in a gap and gets no cap.
|
||||||
|
assert!(close(img.pixel(302, 100), WHITE, 10), "{:?}", img.pixel(302, 100));
|
||||||
|
// 205 pt ends half way through a dash: squared off past the end.
|
||||||
|
let img = render(vec![dotted_line(LineCap::Square, 205.0)]);
|
||||||
|
assert!(close(img.pixel(308, 100), RED, 40), "{:?}", img.pixel(308, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dash_lengths_make_room_for_round_and_square_caps() {
|
||||||
|
assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Round, 1.0), vec![3.0, 4.0]);
|
||||||
|
assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Round, 2.0), vec![0.002, 3.0]);
|
||||||
|
assert_eq!(dash_with_caps(vec![1.0, 1.0], kurbo::Cap::Square, 2.0), vec![1.0, 1.0]);
|
||||||
|
assert_eq!(dash_with_caps(vec![4.0, 3.0], kurbo::Cap::Butt, 1.0), vec![4.0, 3.0]);
|
||||||
|
assert_eq!(dash_with_caps(vec![4.0, 3.0, 1.0], kurbo::Cap::Round, 1.0), vec![4.0, 3.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrow_line(kind: &str, width: f64, c: Rgba) -> Shape {
|
||||||
|
let end = deckcraft_model::style::LineEnd { kind: kind.into(), w: "med".into(), len: "med".into() };
|
||||||
|
Shape {
|
||||||
|
id: ShapeId(12),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 0.0)),
|
||||||
|
geom: Geom::preset("line"),
|
||||||
|
line: Some(Line { fill: solid(c), width: Some(width), tail: Some(end), ..Default::default() }),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wide_line_stops_inside_its_arrowhead() {
|
||||||
|
// 10 pt line, medium triangle (30 pt long, 30 wide) whose tip is the line's end at x = 300.
|
||||||
|
let img = render(vec![arrow_line("triangle", 10.0, RED)]);
|
||||||
|
assert!(close(img.pixel(297, 100), RED, 40), "the tip {:?}", img.pixel(297, 100));
|
||||||
|
// 2 pt back from the tip the triangle is 2 pt wide: the line's corner must not show there.
|
||||||
|
assert!(close(img.pixel(298, 104), WHITE, 10), "line corner past the head {:?}", img.pixel(298, 104));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_see_through_line_and_its_arrowhead_are_one_shape() {
|
||||||
|
let half_red = Rgba::rgba(255, 0, 0, 128);
|
||||||
|
let img = render(vec![arrow_line("triangle", 10.0, half_red)]);
|
||||||
|
let line_only = img.pixel(200, 100);
|
||||||
|
let head_over_line = img.pixel(285, 100);
|
||||||
|
assert!(close(line_only, Rgba::rgb(255, 127, 127), 6), "{line_only:?}");
|
||||||
|
assert!(close(head_over_line, Rgba::rgb(line_only[0], line_only[1], line_only[2]), 3), "no darker overlap: {head_over_line:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_open_arrowheads_point_is_on_the_lines_end() {
|
||||||
|
let img = render(vec![arrow_line("arrow", 8.0, RED)]);
|
||||||
|
let reach = (290..320).rev().find(|x| img.pixel(*x, 100)[1] < 128);
|
||||||
|
assert!(reach.is_some_and(|x| x <= 300), "point reaches {reach:?}");
|
||||||
|
assert!(reach.is_some_and(|x| x >= 297), "point reaches {reach:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrowheads_are_sized_for_at_least_a_two_point_line() {
|
||||||
|
let e = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "sm".into(), len: "lg".into() };
|
||||||
|
assert_eq!(head_size(&e, 0.5), (4.0, 10.0));
|
||||||
|
assert_eq!(head_size(&e, 4.0), (8.0, 20.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trimming_a_path_leaves_its_ends_off_by_arc_length() {
|
||||||
|
let mut p = BezPath::new();
|
||||||
|
p.move_to((0.0, 0.0));
|
||||||
|
p.line_to((50.0, 0.0));
|
||||||
|
p.line_to((50.0, 50.0));
|
||||||
|
let t = trim_ends(&p, 10.0, 20.0);
|
||||||
|
let els = t.elements();
|
||||||
|
assert_eq!(els.first(), Some(&PathEl::MoveTo(Point::new(10.0, 0.0))));
|
||||||
|
match els.last() {
|
||||||
|
Some(PathEl::LineTo(q)) => assert!((q.x - 50.0).abs() < 1e-6 && (q.y - 30.0).abs() < 1e-6, "{q:?}"),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
// Trims longer than the path leave nothing; closed paths are untouched.
|
||||||
|
assert!(trim_ends(&p, 60.0, 50.0).elements().is_empty());
|
||||||
|
let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
|
||||||
|
assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn radial(focus: [f64; 4]) -> Shape {
|
||||||
|
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||||
|
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
|
||||||
|
Shape {
|
||||||
|
id: ShapeId(13),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||||
|
fill: Some(Fill::Gradient(Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
|
stops,
|
||||||
|
shape: GradientShape::Path { path: "circle".into(), focus },
|
||||||
|
rotate_with_shape: true,
|
||||||
|
})),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_radial_gradient_off_centre_ends_on_the_circle_around_the_box() {
|
||||||
|
// Focus a fifth of the way in from the top-left corner. PowerPoint's last colour is reached on
|
||||||
|
// the circle through the box's corners, so the near corner is dark as well as the far one.
|
||||||
|
let img = render(vec![radial([0.2, 0.2, 0.8, 0.8])]);
|
||||||
|
assert!(img.pixel(181, 141)[0] > 240, "white at the focus {:?}", img.pixel(181, 141));
|
||||||
|
assert!(img.pixel(101, 101)[0] < 60, "near corner dark {:?}", img.pixel(101, 101));
|
||||||
|
assert!(img.pixel(498, 298)[0] < 60, "far corner dark {:?}", img.pixel(498, 298));
|
||||||
|
// Straight above the focus the box's edge is well inside the circle: still light.
|
||||||
|
assert!(img.pixel(181, 101)[0] > 150, "{:?}", img.pixel(181, 101));
|
||||||
|
// A centred one is symmetric, dark only at the corners.
|
||||||
|
let img = render(vec![radial([0.5, 0.5, 0.5, 0.5])]);
|
||||||
|
assert!(img.pixel(300, 200)[0] > 240 && img.pixel(101, 101)[0] < 60);
|
||||||
|
assert!((img.pixel(110, 200)[0] as i32 - img.pixel(490, 200)[0] as i32).abs() <= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shadow(sx: f64, sy: f64, kx: f64, align: &str) -> deckcraft_model::style::Shadow {
|
||||||
|
deckcraft_model::style::Shadow {
|
||||||
|
color: ColorRef::rgb(Rgba::BLACK),
|
||||||
|
blur: 0.0,
|
||||||
|
dist: 0.0,
|
||||||
|
dir: 0.0,
|
||||||
|
inner: false,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
kx,
|
||||||
|
ky: 0.0,
|
||||||
|
align: align.into(),
|
||||||
|
rotate_with_shape: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_effects(fill: Option<Fill>, line: Option<Line>, effects: deckcraft_model::Effects) -> Shape {
|
||||||
|
Shape { id: ShapeId(14), xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)), fill, line, effects: Some(effects), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_perspective_shadow_lies_along_the_edge_its_alignment_names() {
|
||||||
|
// Half height, skewed 45° so its top leans right, anchored at the bottom middle: the shadow's
|
||||||
|
// bottom edge is the shape's (y = 200) and its top (y = 150) is 50 pt to the right.
|
||||||
|
let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.0, 0.5, -45.0, "b")), ..Default::default() };
|
||||||
|
let img = render(vec![with_effects(solid(RED), no_line(), fx)]);
|
||||||
|
assert!(close(img.pixel(210, 175), Rgba::BLACK, 30), "{:?}", img.pixel(210, 175));
|
||||||
|
assert!(close(img.pixel(240, 155), Rgba::BLACK, 30), "{:?}", img.pixel(240, 155));
|
||||||
|
assert!(close(img.pixel(240, 180), WHITE, 10), "{:?}", img.pixel(240, 180));
|
||||||
|
assert!(close(img.pixel(205, 140), WHITE, 10), "nothing above the half height {:?}", img.pixel(205, 140));
|
||||||
|
// Anchored bottom-right and half as big again: it grows up and to the left only.
|
||||||
|
let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.5, 1.5, 0.0, "br")), ..Default::default() };
|
||||||
|
let img = render(vec![with_effects(solid(RED), no_line(), fx)]);
|
||||||
|
assert!(close(img.pixel(70, 70), Rgba::BLACK, 30) && close(img.pixel(210, 210), WHITE, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset_shadow() -> deckcraft_model::Effects {
|
||||||
|
let mut sh = shadow(1.0, 1.0, 0.0, "b");
|
||||||
|
sh.dist = 20.0;
|
||||||
|
sh.dir = 0.0;
|
||||||
|
deckcraft_model::Effects { outer_shadow: Some(sh), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shadows_are_cast_by_what_is_drawn() {
|
||||||
|
// An outline-only square: its shadow (20 pt to the right) is a frame, not a solid square.
|
||||||
|
let outline = Some(Line::solid(ColorRef::rgb(RED), 4.0));
|
||||||
|
let img = render(vec![with_effects(Some(Fill::None), outline.clone(), offset_shadow())]);
|
||||||
|
assert!(close(img.pixel(220, 150), Rgba::BLACK, 30), "the frame's shadow {:?}", img.pixel(220, 150));
|
||||||
|
assert!(close(img.pixel(160, 150), WHITE, 10), "no shadow inside the frame {:?}", img.pixel(160, 150));
|
||||||
|
// A half see-through fill casts a half see-through shadow.
|
||||||
|
let img = render(vec![with_effects(solid(Rgba::rgba(255, 0, 0, 128)), no_line(), offset_shadow())]);
|
||||||
|
let px = img.pixel(210, 150);
|
||||||
|
assert!(close(px, Rgba::rgb(127, 127, 127), 8), "half shadow {px:?}");
|
||||||
|
// A line casts a shadow too.
|
||||||
|
let mut ln = arrow_line("none", 6.0, RED);
|
||||||
|
ln.effects = Some(offset_shadow());
|
||||||
|
let img = render(vec![ln]);
|
||||||
|
assert!(close(img.pixel(310, 100), Rgba::BLACK, 30), "line shadow {:?}", img.pixel(310, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
|
||||||
|
let mut out = std::io::Cursor::new(vec![]);
|
||||||
|
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||||
|
out.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn picture_deck(line: Option<Line>) -> Presentation {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255]));
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(15),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||||
|
kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||||
|
line,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pictures_border_lies_outside_the_picture() {
|
||||||
|
let p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0)));
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
let blue = Rgba::rgb(0, 0, 255);
|
||||||
|
// The whole picture shows, up to its edge; the 8 pt border is all outside it.
|
||||||
|
assert!(close(img.pixel(101, 150), blue, 30), "picture at its edge {:?}", img.pixel(101, 150));
|
||||||
|
assert!(close(img.pixel(98, 150), RED, 30) && close(img.pixel(93, 150), RED, 30), "{:?}", img.pixel(93, 150));
|
||||||
|
assert!(close(img.pixel(90, 150), WHITE, 10), "border no wider than the line {:?}", img.pixel(90, 150));
|
||||||
|
// A dashed border keeps its dash lengths (a dash is 4 widths: 32 pt), drawn from the corner.
|
||||||
|
let mut line = Line::solid(ColorRef::rgb(RED), 8.0);
|
||||||
|
line.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() });
|
||||||
|
let img = render_slide(&picture_deck(Some(line)), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
assert!(close(img.pixel(120, 96), RED, 30) && close(img.pixel(140, 96), WHITE, 10), "{:?}", img.pixel(140, 96));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
|
||||||
|
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||||
|
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }];
|
||||||
|
let grad =
|
||||||
|
Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true });
|
||||||
|
let child = |id: u32, x: f64| Shape {
|
||||||
|
id: ShapeId(id),
|
||||||
|
xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)),
|
||||||
|
fill: Some(Fill::Group),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let g = Shape {
|
||||||
|
id: ShapeId(16),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||||
|
fill: Some(grad),
|
||||||
|
kind: ShapeKind::Group { children: vec![child(17, 0.0), child(18, 110.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) },
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![g]);
|
||||||
|
let (l, r) = (img.pixel(105, 150), img.pixel(295, 150));
|
||||||
|
assert!(l[0] > 200 && l[2] < 60, "left end of the group's gradient {l:?}");
|
||||||
|
assert!(r[2] > 200 && r[0] < 60, "right end {r:?}");
|
||||||
|
assert!(close(img.pixel(200, 150), WHITE, 5), "the gap between the children stays empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_freeforms_take_arrowheads_even_when_filled() {
|
||||||
|
// An open custom path with a fill and a tail arrow: PowerPoint draws the arrow.
|
||||||
|
let end = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() };
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(19),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||||
|
geom: Geom::Custom { paths: vec![CustomPath { w: 100.0, h: 100.0, d: "M 0 100 L 50 0 L 100 100".into(), ..Default::default() }] },
|
||||||
|
fill: solid(Rgba::rgb(0, 200, 0)),
|
||||||
|
line: Some(Line { fill: solid(RED), width: Some(4.0), tail: Some(end), ..Default::default() }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
// The line ends at (300, 200) heading down-right at 45°; the 12 pt head's base corner reaches
|
||||||
|
// (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill.
|
||||||
|
assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corner_radial_centres_on_the_corner_through_its_tile() {
|
||||||
|
// fillToRect on the top-left corner, tileRect reaching a whole box up and left: the circle is
|
||||||
|
// centred on the corner and reaches the far corner (as PowerPoint's "from corner" presets).
|
||||||
|
let mut sh = radial([0.0, 0.0, 1.0, 1.0]);
|
||||||
|
if let Some(Fill::Gradient(g)) = &mut sh.fill {
|
||||||
|
g.tile = [-1.0, -1.0, 0.0, 0.0];
|
||||||
|
}
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
assert!(img.pixel(102, 102)[0] > 240, "white at the corner {:?}", img.pixel(102, 102));
|
||||||
|
let (top_right, bottom_left) = (img.pixel(498, 101)[0], img.pixel(101, 298)[0]);
|
||||||
|
assert!(top_right < bottom_left, "farther is darker {top_right} {bottom_left}");
|
||||||
|
assert!(bottom_left > 120, "the near edge stays light {bottom_left}");
|
||||||
|
assert!(img.pixel(497, 297)[0] < 40, "dark at the far corner {:?}", img.pixel(497, 297));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_linear_gradient_spans_its_tile_rect() {
|
||||||
|
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||||
|
// Tile rect the left half of the box: the ramp ends half way and stays black after.
|
||||||
|
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
|
||||||
|
let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] };
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(20),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)),
|
||||||
|
fill: Some(Fill::Gradient(g)),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
assert!(img.pixel(102, 150)[0] > 240);
|
||||||
|
assert!(img.pixel(305, 150)[0] < 15 && img.pixel(495, 150)[0] < 15, "{:?}", img.pixel(305, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_3d_pictures_border_stays_centred_on_its_edge() {
|
||||||
|
let mut p = picture_deck(Some(Line::solid(ColorRef::rgb(RED), 8.0)));
|
||||||
|
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
if let Some(sh) = s.shapes.first_mut() {
|
||||||
|
sh.effects = Some(deckcraft_model::Effects {
|
||||||
|
bevel: Some("circle".into()),
|
||||||
|
raw3d: Some("<a:scene3d><a:camera prst=\"orthographicFront\"/></a:scene3d><a:sp3d><a:bevelT/></a:sp3d>".into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
assert!(close(img.pixel(102, 150), RED, 30), "covers the picture's edge {:?}", img.pixel(102, 150));
|
||||||
|
assert!(close(img.pixel(94, 150), WHITE, 10), "reaches half the width out {:?}", img.pixel(94, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
fn half_red_half_blue() -> Vec<u8> {
|
||||||
|
let img = image::RgbImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) });
|
||||||
|
let mut out = std::io::Cursor::new(vec![]);
|
||||||
|
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||||
|
out.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tiled(sx: f64, flip: &str, align: &str) -> Image {
|
||||||
|
use deckcraft_model::style::{PictureFill, PictureMode};
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let media = p.add_media("t.png", "image/png", half_red_half_blue());
|
||||||
|
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx, sy: 10.0, flip: flip.into(), align: align.into() };
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(21),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
|
||||||
|
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||||
|
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiled_pictures_flip_align_and_skip_non_positive_scales() {
|
||||||
|
let blue = Rgba::rgb(0, 0, 255);
|
||||||
|
// 8×4 px at 96 dpi scaled ten times: 60×30 pt tiles, red then blue, from the top-left.
|
||||||
|
let img = tiled(10.0, "none", "tl");
|
||||||
|
assert!(close(img.pixel(115, 115), RED, 30) && close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), RED, 30));
|
||||||
|
// Flipped across x: every other tile mirrored, so the second starts blue.
|
||||||
|
let img = tiled(10.0, "x", "tl");
|
||||||
|
assert!(close(img.pixel(145, 115), blue, 30) && close(img.pixel(175, 115), blue, 30) && close(img.pixel(205, 115), RED, 30));
|
||||||
|
// Aligned right: a tile ends at the box's right edge (340), so 310-340 is blue.
|
||||||
|
let img = tiled(10.0, "none", "r");
|
||||||
|
assert!(close(img.pixel(335, 130), blue, 30) && close(img.pixel(305, 130), RED, 30), "{:?}", img.pixel(305, 130));
|
||||||
|
// A negative scale draws nothing, as in PowerPoint.
|
||||||
|
let img = tiled(-10.0, "none", "tl");
|
||||||
|
assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() {
|
||||||
|
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||||
|
assert_eq!(paint::tile_origin(b, "tl", (20.0, 10.0), (0.0, 0.0)), Point::new(0.0, 0.0));
|
||||||
|
assert_eq!(paint::tile_origin(b, "ctr", (20.0, 10.0), (0.0, 0.0)), Point::new(40.0, 20.0));
|
||||||
|
assert_eq!(paint::tile_origin(b, "br", (20.0, 10.0), (5.0, -5.0)), Point::new(85.0, 35.0));
|
||||||
|
assert_eq!(paint::tile_origin(b, "nonsense", (20.0, 10.0), (f64::NAN, 1.0)), Point::new(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `png` with a pHYs chunk saying `dpi` inserted after its header.
|
||||||
|
fn with_dpi(png: &[u8], dpi: f64) -> Vec<u8> {
|
||||||
|
fn crc32(data: &[u8]) -> u32 {
|
||||||
|
let mut c = 0xFFFF_FFFFu32;
|
||||||
|
for b in data {
|
||||||
|
c ^= *b as u32;
|
||||||
|
for _ in 0..8 {
|
||||||
|
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!c
|
||||||
|
}
|
||||||
|
let ppm = (dpi / 0.0254).round() as u32;
|
||||||
|
let mut chunk = b"pHYs".to_vec();
|
||||||
|
chunk.extend_from_slice(&ppm.to_be_bytes());
|
||||||
|
chunk.extend_from_slice(&ppm.to_be_bytes());
|
||||||
|
chunk.push(1);
|
||||||
|
let mut out = png[..33].to_vec(); // signature (8) + IHDR chunk (25)
|
||||||
|
out.extend_from_slice(&9u32.to_be_bytes());
|
||||||
|
out.extend_from_slice(&chunk);
|
||||||
|
out.extend_from_slice(&crc32(&chunk).to_be_bytes());
|
||||||
|
out.extend_from_slice(&png[33..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picture_resolution_sizes_tiles() {
|
||||||
|
let png = half_red_half_blue();
|
||||||
|
assert_eq!(images::dpi(&png), None);
|
||||||
|
let p48 = with_dpi(&png, 48.0);
|
||||||
|
let d = images::dpi(&p48).unwrap_or_default();
|
||||||
|
assert!((d.0 - 48.0).abs() < 0.01 && (d.1 - 48.0).abs() < 0.01, "{d:?}");
|
||||||
|
// 8 × 4 px at 48 dpi is as big as 16 × 8 at 96.
|
||||||
|
assert_eq!(images::natural_size(&p48), Some((16.0, 8.0)));
|
||||||
|
assert_eq!(images::natural_size(&png), Some((8.0, 4.0)));
|
||||||
|
// JFIF density in dots per inch.
|
||||||
|
let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 16];
|
||||||
|
jpeg.extend_from_slice(b"JFIF\0\x01\x01\x01\x00\x48\x00\x48");
|
||||||
|
assert_eq!(images::dpi(&jpeg), Some((72.0, 72.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_lines_and_their_heads_take_the_gradient() {
|
||||||
|
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||||
|
let blue = Rgba::rgb(0, 0, 255);
|
||||||
|
let grad = |angle: f64| {
|
||||||
|
Fill::Gradient(Gradient {
|
||||||
|
stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }],
|
||||||
|
shape: GradientShape::Linear { angle, scaled: false },
|
||||||
|
rotate_with_shape: true,
|
||||||
|
tile: [0.0; 4],
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at
|
||||||
|
// the end blue too.
|
||||||
|
let mut sh = arrow_line("triangle", 20.0, RED);
|
||||||
|
if let Some(l) = &mut sh.line {
|
||||||
|
l.fill = Some(grad(0.0));
|
||||||
|
}
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
let (start, mid, head) = (img.pixel(102, 100), img.pixel(200, 100), img.pixel(296, 100));
|
||||||
|
assert!(start[0] > 200 && start[2] < 60, "{start:?}");
|
||||||
|
assert!(mid[0] > 60 && mid[2] > 60, "{mid:?}");
|
||||||
|
assert!(head[2] > 200 && head[0] < 60, "head {head:?}");
|
||||||
|
// Top to bottom across a horizontal line: the gradient runs over the line's width.
|
||||||
|
let mut sh = arrow_line("none", 20.0, RED);
|
||||||
|
if let Some(l) = &mut sh.line {
|
||||||
|
l.fill = Some(grad(90.0));
|
||||||
|
}
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
let (top, bottom) = (img.pixel(200, 91), img.pixel(200, 109));
|
||||||
|
assert!(top[0] > 200 && top[2] < 60 && bottom[2] > 200 && bottom[0] < 60, "{top:?} {bottom:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_groups_shadow_is_cast_by_its_children() {
|
||||||
|
let child = |id: u32, x: f64| Shape {
|
||||||
|
id: ShapeId(id),
|
||||||
|
xfrm: Some(Xfrm::new(x, 0.0, 80.0, 100.0)),
|
||||||
|
fill: solid(RED),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let g = Shape {
|
||||||
|
id: ShapeId(22),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||||
|
kind: ShapeKind::Group { children: vec![child(23, 0.0), child(24, 120.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) },
|
||||||
|
effects: Some(offset_shadow()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![g]);
|
||||||
|
// 20 pt to the right of each child: the shadow; in the gap only the left child's shadow shows.
|
||||||
|
assert!(close(img.pixel(190, 150), Rgba::BLACK, 30), "{:?}", img.pixel(190, 150));
|
||||||
|
assert!(close(img.pixel(210, 150), WHITE, 10), "{:?}", img.pixel(210, 150));
|
||||||
|
assert!(close(img.pixel(310, 150), Rgba::BLACK, 30), "{:?}", img.pixel(310, 150));
|
||||||
|
assert!(close(img.pixel(150, 150), RED, 10), "children drawn over it");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patterns_between_whole_pixel_cells_blend_neighbouring_cells() {
|
||||||
|
use deckcraft_model::style::PatternFill;
|
||||||
|
// narVert (alternate cells) at 0.75 output pixels per cell: PowerPoint's export reads
|
||||||
|
// 255, 128, 0 repeating across; drawing the nearest cell gave only black and white. Bilinear
|
||||||
|
// sampling gives about 213, 128, 42 (closer on average over all presets, not exact).
|
||||||
|
let g = |v: u8| ColorRef::rgb(Rgba::rgb(v, v, v));
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(25),
|
||||||
|
xfrm: Some(Xfrm::new(0.0, 0.0, 60.0, 60.0)),
|
||||||
|
fill: Some(Fill::Pattern(PatternFill { preset: "narVert".into(), fg: g(0), bg: g(255) })),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
let row: Vec<u8> = (12..24).map(|x| img.pixel(x, 20)[0]).collect();
|
||||||
|
assert!(row.iter().any(|v| (100..=156).contains(v)), "a blended column: {row:?}");
|
||||||
|
assert!(row.iter().any(|v| *v < 60) && row.iter().any(|v| *v > 200), "{row:?}");
|
||||||
|
// The pattern repeats every 6 px (8 cells).
|
||||||
|
assert!(row.iter().zip(row.iter().skip(6)).all(|(a, b)| (*a as i32 - *b as i32).abs() <= 2), "{row:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bevelled_shape_is_lit_from_the_upper_left() {
|
||||||
|
let base = Rgba::rgb(91, 155, 213);
|
||||||
|
let bevelled = |raw: &str| Shape {
|
||||||
|
id: ShapeId(26),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 130.0, 130.0)),
|
||||||
|
fill: solid(base),
|
||||||
|
line: no_line(),
|
||||||
|
effects: Some(deckcraft_model::Effects { bevel: Some("circle".into()), raw3d: Some(raw.into()), ..Default::default() }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let raw = r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d><a:bevelT w="152400" h="76200"/></a:sp3d>"#;
|
||||||
|
let img = render(vec![bevelled(raw)]);
|
||||||
|
// PowerPoint: face (99, 166, 226); top of the band saturates, its bottom and right darken.
|
||||||
|
let face = img.pixel(165, 165);
|
||||||
|
assert!(close(face, Rgba::rgb(99, 166, 226), 8), "face {face:?}");
|
||||||
|
let (top, bottom, right) = (img.pixel(165, 104)[2], img.pixel(165, 226)[2], img.pixel(226, 165)[2]);
|
||||||
|
assert!(top > face[2] && bottom < face[2] && right < face[2], "top {top} bottom {bottom} right {right} face {}", face[2]);
|
||||||
|
// Without a 3-D scene PowerPoint draws it flat.
|
||||||
|
let img = render(vec![bevelled(r#"<a:sp3d><a:bevelT w="152400" h="76200"/></a:sp3d>"#)]);
|
||||||
|
assert!(close(img.pixel(165, 104), base, 2) && close(img.pixel(165, 165), base, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_borders_take_their_dashes_and_diagonals_are_drawn() {
|
||||||
|
let mut t = deckcraft_model::Table::new(1, 1, 200.0, 100.0);
|
||||||
|
t.style = "none".into();
|
||||||
|
let mut dashed = Line::solid(ColorRef::rgb(RED), 4.0);
|
||||||
|
dashed.dash = Some(deckcraft_model::style::Dash::Preset { name: "dash".into() });
|
||||||
|
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
|
||||||
|
c.borders[2] = Some(dashed);
|
||||||
|
c.diag_down = Some(Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 255)), 4.0));
|
||||||
|
}
|
||||||
|
let sh = Shape { id: ShapeId(27), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
// Dashes of 4 widths (16 pt) on, 3 (12 pt) off along the top edge, from its start.
|
||||||
|
assert!(close(img.pixel(108, 100), RED, 40) && close(img.pixel(122, 100), WHITE, 30) && close(img.pixel(135, 100), RED, 40));
|
||||||
|
// The diagonal from top-left to bottom-right passes through the middle.
|
||||||
|
assert!(close(img.pixel(200, 150), Rgba::rgb(0, 0, 255), 40), "{:?}", img.pixel(200, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_large_pictures_shadow_is_whole() {
|
||||||
|
// A 400 px picture shown at 240 pt: its shadow was recoloured in the picture's pixel space and
|
||||||
|
// covered only part of it.
|
||||||
|
let mut counts = vec![];
|
||||||
|
for side in [8u32, 400] {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255]));
|
||||||
|
let mut fx = offset_shadow();
|
||||||
|
if let Some(o) = &mut fx.outer_shadow {
|
||||||
|
o.blur = 8.0;
|
||||||
|
}
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(15),
|
||||||
|
xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(360.0, 300.0, 240.0, 160.0) }),
|
||||||
|
kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||||
|
effects: Some(fx),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
counts.push(
|
||||||
|
(0..img.height)
|
||||||
|
.flat_map(|y| (0..img.width).map(move |x| (x, y)))
|
||||||
|
.filter(|(x, y)| {
|
||||||
|
let q = img.pixel(*x, *y);
|
||||||
|
q[0] < 200 && q[2] < 200
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(counts[1] > 3000 && (counts[0] as i64 - counts[1] as i64).abs() < 300, "{counts:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() {
|
||||||
|
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||||
|
let blue = Rgba::rgb(0, 0, 255);
|
||||||
|
for rotate_with_shape in [true, false] {
|
||||||
|
let g = Gradient {
|
||||||
|
stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }],
|
||||||
|
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||||
|
rotate_with_shape,
|
||||||
|
tile: [0.0; 4],
|
||||||
|
};
|
||||||
|
// 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down.
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(28),
|
||||||
|
xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 100.0) }),
|
||||||
|
fill: Some(Fill::Gradient(g)),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let img = render(vec![sh]);
|
||||||
|
let (left, top) = (img.pixel(152, 150), img.pixel(200, 52));
|
||||||
|
if rotate_with_shape {
|
||||||
|
assert!(top[0] > 200 && top[2] < 60, "turned with the shape: red at the top {top:?}");
|
||||||
|
} else {
|
||||||
|
assert!(left[0] > 200 && left[2] < 60, "upright: red at the left {left:?}");
|
||||||
|
assert!(img.pixel(248, 150)[2] > 200, "blue at the right");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stars_and_right_arrows_have_sharp_corners_by_default() {
|
||||||
|
let shape = |name: &str, join: Option<deckcraft_model::style::LineJoin>| Shape {
|
||||||
|
id: ShapeId(29),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)),
|
||||||
|
geom: Geom::preset(name),
|
||||||
|
fill: solid(WHITE),
|
||||||
|
line: Some(Line { join, ..Line::solid(ColorRef::rgb(RED), 10.0) }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// A star5's top point mitred reaches about 16 pt above the box; rounded only 5.
|
||||||
|
let img = render(vec![shape("star5", None)]);
|
||||||
|
assert!(close(img.pixel(150, 90), RED, 40), "mitred point {:?}", img.pixel(150, 90));
|
||||||
|
let img = render(vec![shape("star5", Some(deckcraft_model::style::LineJoin::Round))]);
|
||||||
|
assert!(close(img.pixel(150, 90), WHITE, 10), "an explicit round join stays round");
|
||||||
|
// A rectangle keeps PowerPoint's round default: its corner is cut round.
|
||||||
|
let img = render(vec![shape("rect", None)]);
|
||||||
|
assert!(close(img.pixel(95, 95), WHITE, 30), "{:?}", img.pixel(95, 95));
|
||||||
|
assert!(deckcraft_geom::preset::mitred_by_default("rightArrow") && !deckcraft_geom::preset::mitred_by_default("leftArrow"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upright_picture_fill_stays_upright_in_a_turned_shape() {
|
||||||
|
use deckcraft_model::style::PictureFill;
|
||||||
|
let blue = Rgba::rgb(0, 0, 255);
|
||||||
|
for upright in [false, true] {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let media = p.add_media("t.png", "image/png", half_red_half_blue());
|
||||||
|
// Half red (left) half blue picture in a 200 × 200 square turned a quarter (and so
|
||||||
|
// covering the same square).
|
||||||
|
let sh = Shape {
|
||||||
|
id: ShapeId(30),
|
||||||
|
xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 200.0) }),
|
||||||
|
fill: Some(Fill::Picture(PictureFill { media, upright, ..Default::default() })),
|
||||||
|
line: no_line(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
let (left, top) = (img.pixel(110, 200), img.pixel(200, 110));
|
||||||
|
if upright {
|
||||||
|
assert!(close(left, RED, 30) && close(img.pixel(290, 200), blue, 30), "upright: red left {left:?}");
|
||||||
|
} else {
|
||||||
|
assert!(close(top, RED, 30), "turned with the shape: red at the top {top:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_reflects_its_pixels() {
|
||||||
|
let mut p = picture_deck(None);
|
||||||
|
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
if let Some(sh) = s.shapes.first_mut() {
|
||||||
|
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 };
|
||||||
|
sh.effects = Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() });
|
||||||
|
}
|
||||||
|
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
// The blue picture spans 100..200 down; just below it, its reflection tints the page blue.
|
||||||
|
let below = img.pixel(200, 204);
|
||||||
|
assert!(below[2] > 200 && below[0] < 200, "{below:?}");
|
||||||
|
assert!(close(img.pixel(200, 290), WHITE, 10), "faded out by half its height");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_3d_shapes_face_is_lit_even_without_a_bevel() {
|
||||||
|
let base = Rgba::rgb(237, 125, 49);
|
||||||
|
let shape = |raw: &str| Shape {
|
||||||
|
id: ShapeId(31),
|
||||||
|
xfrm: Some(Xfrm::new(100.0, 100.0, 150.0, 100.0)),
|
||||||
|
fill: solid(base),
|
||||||
|
line: no_line(),
|
||||||
|
effects: Some(deckcraft_model::Effects { raw3d: Some(raw.into()), ..Default::default() }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// PowerPoint shows this face as about (251, 135, 56).
|
||||||
|
let img = render(vec![shape(r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d/>"#)]);
|
||||||
|
assert!(close(img.pixel(175, 150), Rgba::rgb(248, 136, 60), 8), "{:?}", img.pixel(175, 150));
|
||||||
|
// No shape properties in 3-D: flat.
|
||||||
|
let img = render(vec![shape(r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d>"#)]);
|
||||||
|
assert!(close(img.pixel(175, 150), base, 1));
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient {
|
||||||
let mut dark = base.clone();
|
let mut dark = base.clone();
|
||||||
dark.mods.push(ColorTransform::LumMod(60_000));
|
dark.mods.push(ColorTransform::LumMod(60_000));
|
||||||
Gradient {
|
Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }],
|
stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }],
|
||||||
shape: GradientShape::Linear { angle, scaled: false },
|
shape: GradientShape::Linear { angle, scaled: false },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
@ -136,6 +137,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
|
||||||
..default_gradient(base.clone(), 45.0)
|
..default_gradient(base.clone(), 45.0)
|
||||||
},
|
},
|
||||||
Gradient {
|
Gradient {
|
||||||
|
tile: [0.0; 4],
|
||||||
stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }],
|
stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }],
|
||||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||||||
rotate_with_shape: true,
|
rotate_with_shape: true,
|
||||||
|
|
|
||||||
12
docs/gaps.md
12
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Where DeckCraft falls short of PowerPoint
|
# Where DeckCraft falls short of PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||||
|
|
@ -168,10 +168,11 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
|
||||||
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
|
- **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
|
`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
|
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
|
1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting
|
||||||
round trip).
|
render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces,
|
||||||
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 8–12 h (3-D render
|
only the three-point rig, and no command or pane to apply 3-D).
|
||||||
approximation).
|
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion,
|
||||||
|
rigs and materials, Format Shape page).
|
||||||
|
|
||||||
### Proofing: spelling, thesaurus, AutoCorrect
|
### Proofing: spelling, thesaurus, AutoCorrect
|
||||||
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
|
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
|
||||||
|
|
@ -255,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
|
||||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||||
| 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-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| P0 | 72 | 61 | 11 | 0 | 92% |
|
| P0 | 72 | 61 | 11 | 0 | 92% |
|
||||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||||
| P2 | 54 | 13 | 14 | 27 | 37% |
|
| P2 | 54 | 13 | 15 | 26 | 38% |
|
||||||
|
|
||||||
| Area | Parity |
|
| Area | Parity |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -19,7 +19,7 @@ The summary between `<!-- SUMMARY -->
|
||||||
| Views | 90% |
|
| Views | 90% |
|
||||||
| Masters & themes | 85% |
|
| Masters & themes | 85% |
|
||||||
| Shapes | 88% |
|
| Shapes | 88% |
|
||||||
| Format | 81% |
|
| Format | 83% |
|
||||||
| Arrange | 100% |
|
| Arrange | 100% |
|
||||||
| Text | 80% |
|
| Text | 80% |
|
||||||
| Tables | 89% |
|
| Tables | 89% |
|
||||||
|
|
@ -126,7 +126,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||||
| Format | Arrowheads | P0 | D |
|
| Format | Arrowheads | P0 | D |
|
||||||
| Format | Shadow (outer, inner, presets) | P1 | D |
|
| Format | Shadow (outer, inner, presets) | P1 | D |
|
||||||
| Format | Reflection, glow, soft edges | P2 | P |
|
| Format | Reflection, glow, soft edges | P2 | P |
|
||||||
| Format | Bevel and 3-D rotation | P2 | M |
|
| Format | Bevel and 3-D rotation | P2 | P |
|
||||||
| Format | Format Painter | P1 | D |
|
| Format | Format Painter | P1 | D |
|
||||||
| Format | Format Shape pane | P0 | P |
|
| Format | Format Shape pane | P0 | P |
|
||||||
| Arrange | Size and position (exact), lock aspect ratio | P0 | D |
|
| Arrange | Size and position (exact), lock aspect ratio | P0 | D |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue