craft-drawingml: Xfrm::from_affine, decompose and CustomPath::from_bez (#1036)

From DesignCraft crates/pptx/src/slide.rs (27d8eabb): decompose, box_of and custom_geometry, which
place a drawing's items on a slide.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 20:56:32 -05:00
1 parent 9d21479d7a
commit 2bd8f292ca
2 files changed
+114

No files matched your search

+58
View File
@@ -28,6 +28,48 @@ pub struct CustomPath {
pub stroke: bool,
}
impl CustomPath {
/// A path from a `kurbo` path given in a `w` × `h` box (points), its coordinates kept as
/// they are. Quadratic segments become cubics: not every reader handles `a:quadBezTo`.
pub fn from_bez(bp: &kurbo::BezPath, w: f64, h: f64, filled: bool) -> CustomPath {
let mut d = String::new();
let mut last = kurbo::Point::ZERO;
let mut put = |c: char, pts: &[kurbo::Point]| {
if !d.is_empty() {
d.push(' ');
}
d.push(c);
for p in pts {
let _ = write!(d, " {} {}", finite(p.x), finite(p.y));
}
};
for el in bp.elements() {
match *el {
kurbo::PathEl::MoveTo(a) => {
put('M', &[a]);
last = a;
}
kurbo::PathEl::LineTo(a) => {
put('L', &[a]);
last = a;
}
kurbo::PathEl::QuadTo(a, b) => {
let c1 = last + (a - last) * (2.0 / 3.0);
let c2 = b + (a - b) * (2.0 / 3.0);
put('C', &[c1, c2, b]);
last = b;
}
kurbo::PathEl::CurveTo(a, b, c) => {
put('C', &[a, b, c]);
last = c;
}
kurbo::PathEl::ClosePath => put('Z', &[]),
}
}
CustomPath { w, h, d, fill: if filled { FillMode::Norm } else { FillMode::None }, stroke: true }
}
}
impl Default for CustomPath {
fn default() -> Self {
CustomPath { w: 0.0, h: 0.0, d: String::new(), fill: FillMode::Norm, stroke: true }
@@ -651,4 +693,20 @@ mod tests {
let d = format!("M{}", " 1 2".repeat(1_000_001));
assert_eq!(parse_path(&d).len(), 1_000_000);
}
#[test]
fn a_kurbo_path_becomes_a_custom_path_with_quads_as_cubics() {
let mut bp = kurbo::BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((30.0, 0.0));
bp.quad_to((30.0, 30.0), (0.0, 30.0));
bp.close_path();
let p = CustomPath::from_bez(&bp, 30.0, 30.0, false);
assert_eq!(p.d, "M 0 0 L 30 0 C 30 20 20 30 0 30 Z");
assert_eq!(p.fill, FillMode::None);
let mut w = Writer::new();
write_cust_geom(&mut w, std::slice::from_ref(&p));
let back = read_cust_geom(&parse(w.finish().as_bytes()).unwrap(), 1.0, 1.0);
assert_eq!(parse_path(&back[0].d).len(), 4);
}
}
+56
View File
@@ -163,6 +163,42 @@ impl Xfrm {
let r = self.rot.rem_euclid(360.0);
if r >= 360.0 { 0.0 } else { r }
}
/// The box `inner` occupies under `m` (inner space → parent), as an `a:xfrm`: rotated about
/// its centre, flipped vertically for a negative determinant. `None` when `m` shears (an
/// `a:xfrm` cannot), or is not finite. A shape drawn in `inner`'s space then maps onto the
/// box by the per-axis scales of [`decompose`]`(m)`.
pub fn from_affine(inner: Rect, m: Affine) -> Option<Xfrm> {
let dm = decompose(m);
if dm.shear.abs() > 1e-6 || ![dm.sx, dm.sy, dm.rot].iter().all(|v| v.is_finite()) {
return None;
}
let c = m * inner.center();
let (w, h) = (inner.width() * dm.sx.abs(), inner.height() * dm.sy.abs());
let x = Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, rot: dm.rot, flip_h: false, flip_v: dm.sy < 0.0 };
x.is_finite().then_some(x)
}
}
/// An affine split into rotation, axis scales and shear: `m = translate · rotate(rot) ·
/// [sx, shear·sy; 0, sy]`. `rot` is in degrees; `sy` is negative for a mirroring transform.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Decomposed {
pub rot: f64,
pub sx: f64,
pub sy: f64,
pub shear: f64,
}
/// See [`Decomposed`].
pub fn decompose(m: Affine) -> Decomposed {
let [a, b, c, d, _, _] = m.as_coeffs();
let sx = a.hypot(b);
let rot = b.atan2(a);
let (cos, sin) = (rot.cos(), rot.sin());
let c2 = cos * c + sin * d;
let d2 = -sin * c + cos * d;
let shear = if d2.abs() > 1e-12 { c2 / d2 } else { 0.0 };
Decomposed { rot: rot.to_degrees(), sx, sy: d2, shear }
}
/// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box.
@@ -218,4 +254,24 @@ mod tests {
assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
}
}
#[test]
fn an_affine_without_shear_becomes_an_xfrm_that_places_the_box_the_same_way() {
let inner = Rect::new(10.0, 20.0, 110.0, 70.0);
for (rot, sx, sy) in [(0.0, 1.0, 1.0), (30.0, 2.0, 0.5), (-120.0, 1.5, -2.0), (90.0, 1.0, -1.0)] {
let m = Affine::translate((300.0, 200.0)) * Affine::rotate(f64::to_radians(rot)) * Affine::scale_non_uniform(sx, sy);
let x = Xfrm::from_affine(inner, m).unwrap();
let dm = decompose(m);
assert!((dm.rot - rot).abs() < 1e-9 && (dm.sx - sx).abs() < 1e-9 && (dm.sy - sy).abs() < 1e-9, "{dm:?}");
assert_eq!(x.flip_v, sy < 0.0);
// The xfrm's local box, scaled from inner space, lands where `m` puts each corner.
let local = |p: Point| Point::new((p.x - inner.x0) * sx.abs(), (p.y - inner.y0) * sy.abs());
for p in [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1)] {
let (a, b) = (x.affine() * local(p), m * p);
assert!((a - b).hypot() < 1e-9, "rot {rot} sy {sy}: {a:?} vs {b:?}");
}
}
assert!(Xfrm::from_affine(inner, Affine::skew(0.3, 0.0)).is_none(), "a shear has no xfrm");
assert!(Xfrm::from_affine(inner, Affine::scale(f64::NAN)).is_none());
}
}