Files
Dylan M. Taylor 00efe17d1a craft-drawingml: readers and writers on craft-xml (#979)
* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps

The parts of DrawingML that need no document model, moved from DeckCraft
(60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU
conversions) and crates/pptx/src/custgeom.rs with CustomPath from
crates/model.

The code is unchanged except that custgeom reads craft-xml trees and writes
to a craft-xml Writer instead of DeckCraft's own XML helper, and matches
elements by local name. The 59 tests of the moved code come with it; six
more cover the custom geometry limits.

* drawingml: evaluate a custom geometry's guide lists once per coordinate space

read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every
path: up to 1,000 paths times up to 10,000 guides, each name copied and
hashed.

Guides are now cached per path coordinate space (w, h). At most 16 spaces
and 4 MiB of guide names are kept per geometry; paths in further spaces are
left out. The first space is always kept, and a geometry without guides is
not capped.

Ported from storytold/deckcraft#1789. A list counts as having guides only
when it has gd children, as that PR's review asked.

* drawingml: the model types for fills, lines, text, tables, charts and diagrams

Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs,
table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode
and the MediaId and SlideId newtypes from lib.rs.

The files are unchanged except that deckcraft_color:: is crate::color:: and
Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come
with them. Readers and writers follow.

* drawingml: the theme model (colour, font and format schemes)

Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme,
FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default
theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends
on a gallery.

* drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams

Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs,
read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto
craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink.
The chart frame budget and kept-part capture stay with the presentation reader.

* drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches

From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78.
2026-11-11 20:15:06 -05:00

222 lines
8.2 KiB
Rust

//! DrawingML (ECMA-376 part 1, chapter 20): the parts of it that PresentationML,
//! WordprocessingML and SpreadsheetML drawings share.
//!
//! - Units and placement: EMU conversion ([`emu_to_pt`], [`pt_to_emu`]) and `a:xfrm` ([`Xfrm`],
//! [`group_child_affine`]). Lengths in this crate are points (1/72 inch) as `f64`; files store
//! EMU (12 700 per point), and the conversion is exact for integer EMU values.
//! - [`color`]: sRGB colours, theme colour slots, the colour transforms (`a:lumMod`, `a:tint`,
//! `a:alpha` …) and the preset and system colour names.
//! - [`preset`]: outlines of the preset shapes (`a:prstGeom`), by their file names.
//! - [`custgeom`]: custom geometry (`a:custGeom`), with the shape guide formulas.
//! - [`warp`]: preset text warps (`a:prstTxWarp`).
//! - The model: [`style`] (colour references, fills, lines, effects), [`text`] (text bodies,
//! paragraphs, runs), [`table`], [`chart`] with [`numfmt`] (number format codes), [`theme`]
//! (colour, font and format schemes) and [`diagram`] (SmartArt outlines). Media are named by
//! [`MediaId`]; the bytes stay with the caller.
//! - Readers and writers: [`read`] and [`write`] (`a:` fills, lines, effects, `a:xfrm`, text
//! bodies), [`chart::read`], [`chart::chartex`] and [`chart::write`] (`c:chartSpace`,
//! `cx:chartSpace`), [`theme::read_theme`] and [`theme::theme_xml`], [`table::styles`]
//! (`a:tblStyleLst`) and [`diagram::parts`] (`dgm:` data, layout, style and colour parts).
//! What they need from the package is the caller's [`part::Source`] or [`part::Sink`];
//! [`xmlx`] holds the parser settings and markup-compatibility handling they share.
//!
//! Nothing here opens a package: readers take a [`craft_xml::El`] and writers a
//! [`craft_xml::Writer`], so the same code serves a slide, a document or a worksheet drawing.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod chart;
pub mod color;
pub mod custgeom;
pub mod diagram;
pub mod numfmt;
pub mod part;
pub mod preset;
pub mod read;
pub mod style;
pub mod table;
pub mod text;
pub mod theme;
pub mod write;
pub mod xmlx;
/// Namespace URIs.
pub mod ns {
pub const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
pub const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
pub const NS_A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main";
pub const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
}
mod trendline;
pub mod warp;
use kurbo::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
pub(crate) fn yes() -> bool {
true
}
macro_rules! id_type {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub u32);
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
};
}
id_type!(
/// A picture, sound or other embedded file, by the id its owner gave it. The bytes stay with
/// the owner (a package part in a file).
MediaId
);
id_type!(
/// The target of a link to a slide. Only presentations have them.
SlideId
);
/// EMU per point.
pub const EMU_PER_PT: f64 = 12_700.0;
/// EMU per inch.
pub const EMU_PER_IN: f64 = 914_400.0;
/// EMU per centimetre.
pub const EMU_PER_CM: f64 = 360_000.0;
pub fn emu_to_pt(emu: i64) -> f64 {
emu as f64 / EMU_PER_PT
}
/// Points to EMU, rounded. Non-finite input becomes 0; huge values saturate.
pub fn pt_to_emu(pt: f64) -> i64 {
if !pt.is_finite() {
return 0;
}
let v = (pt * EMU_PER_PT).round();
if v >= i64::MAX as f64 {
i64::MAX
} else if v <= i64::MIN as f64 {
i64::MIN
} else {
v as i64
}
}
/// A shape's placement on its page (or in its group's child space): offset, extent, rotation
/// (degrees clockwise) and flips. Rotation and flips are about the centre.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Xfrm {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
#[serde(skip_serializing_if = "is_zero")]
pub rot: f64,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub flip_h: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub flip_v: bool,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl Xfrm {
pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
Xfrm { x, y, w, h, ..Default::default() }
}
pub fn rect(&self) -> Rect {
Rect::new(self.x, self.y, self.x + self.w, self.y + self.h)
}
pub fn center(&self) -> Point {
Point::new(self.x + self.w / 2.0, self.y + self.h / 2.0)
}
/// Local shape space (0..w, 0..h) → parent space, applying flips and rotation about the centre.
pub fn affine(&self) -> Affine {
let c = Vec2::new(self.w / 2.0, self.h / 2.0);
let sx = if self.flip_h { -1.0 } else { 1.0 };
let sy = if self.flip_v { -1.0 } else { 1.0 };
Affine::translate(Vec2::new(self.x, self.y) + c)
* Affine::rotate(self.rot.to_radians())
* Affine::scale_non_uniform(sx, sy)
* Affine::translate(-c)
}
/// Axis-aligned bounds of the rotated box in parent space.
pub fn bounds(&self) -> Rect {
let a = self.affine();
let pts = [Point::new(0.0, 0.0), Point::new(self.w, 0.0), Point::new(self.w, self.h), Point::new(0.0, self.h)].map(|p| a * p);
let mut r = Rect::from_points(pts[0], pts[1]);
r = r.union_pt(pts[2]).union_pt(pts[3]);
r
}
/// True when no dimension is NaN/inf.
pub fn is_finite(&self) -> bool {
[self.x, self.y, self.w, self.h, self.rot].iter().all(|v| v.is_finite())
}
/// Rotation normalised to 0..360.
pub fn rot_norm(&self) -> f64 {
let r = self.rot.rem_euclid(360.0);
if r >= 360.0 { 0.0 } else { r }
}
}
/// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box.
pub fn group_child_affine(group: &Xfrm, ch: Rect) -> Affine {
let sx = if ch.width().abs() > 1e-9 { group.w / ch.width() } else { 1.0 };
let sy = if ch.height().abs() > 1e-9 { group.h / ch.height() } else { 1.0 };
group.affine() * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ch.origin().to_vec2())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emu_roundtrip_is_exact() {
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
assert_eq!(pt_to_emu(emu_to_pt(emu)), emu);
}
assert_eq!(pt_to_emu(f64::NAN), 0);
assert_eq!(pt_to_emu(f64::INFINITY), 0);
assert_eq!(pt_to_emu(1e300), i64::MAX);
assert_eq!(pt_to_emu(-1e300), i64::MIN);
}
#[test]
fn xfrm_affine_rotation_about_center() {
let x = Xfrm { x: 10.0, y: 20.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() };
let c = x.affine() * Point::new(50.0, 25.0);
assert!((c.x - 60.0).abs() < 1e-9 && (c.y - 45.0).abs() < 1e-9);
let b = x.bounds();
assert!((b.width() - 50.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9);
}
#[test]
fn flips_mirror_local_space() {
let x = Xfrm { x: 0.0, y: 0.0, w: 100.0, h: 50.0, flip_h: true, ..Default::default() };
let p = x.affine() * Point::new(0.0, 0.0);
assert!((p.x - 100.0).abs() < 1e-9 && p.y.abs() < 1e-9);
}
#[test]
fn group_child_mapping() {
let g = Xfrm::new(100.0, 100.0, 200.0, 100.0);
let a = group_child_affine(&g, Rect::new(0.0, 0.0, 100.0, 50.0));
let p = a * Point::new(100.0, 50.0);
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
}
#[test]
fn rot_norm_stays_below_360() {
for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] {
let n = Xfrm { rot, ..Default::default() }.rot_norm();
assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
}
}
}