mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
* 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.
222 lines
8.2 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|