Files
craft-libs/crates/wordmodel/src/geom.rs
T
Dylan M. Taylor 08882273d1 Add craft-wordmodel: WordCraft's document model without its editing code (#963)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.
2026-11-11 19:43:08 -05:00

467 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Geometry and units.
//!
//! Layout works in **points** (1/72 inch, `f32`). File formats use other units: OOXML uses
//! twentieths of a point (twips) for most lengths, half-points for font sizes, eighths of a point
//! for borders and English Metric Units (EMU, 914 400 per inch) for drawings. The conversions live
//! here so every crate agrees on them.
use serde::{Deserialize, Serialize};
/// Points per inch.
pub const PT_PER_IN: f32 = 72.0;
/// Twips (1/20 pt) per point.
pub const TWIPS_PER_PT: f32 = 20.0;
/// English Metric Units per point.
pub const EMU_PER_PT: f32 = 12_700.0;
/// Points per centimetre.
pub const PT_PER_CM: f32 = 72.0 / 2.54;
/// Twips → points.
pub fn twips(v: i64) -> f32 {
v as f32 / TWIPS_PER_PT
}
/// Points → twips (rounded).
pub fn to_twips(pt: f32) -> i64 {
finite(pt * TWIPS_PER_PT).round() as i64
}
/// EMU → points.
pub fn emu(v: i64) -> f32 {
v as f32 / EMU_PER_PT
}
/// Points → EMU (rounded).
pub fn to_emu(pt: f32) -> i64 {
(finite(pt) as f64 * EMU_PER_PT as f64).round() as i64
}
/// Half-points (OOXML font sizes) → points.
pub fn half_points(v: i64) -> f32 {
v as f32 / 2.0
}
/// Points → half-points (rounded).
pub fn to_half_points(pt: f32) -> i64 {
(finite(pt) * 2.0).round() as i64
}
/// Replace NaN/inf with 0 so casts and arithmetic stay defined.
pub fn finite(v: f32) -> f32 {
if v.is_finite() { v } else { 0.0 }
}
/// A measurement unit shown in the UI (Word's Options › Advanced › "Show measurements in units of").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Unit {
#[default]
Inches,
Centimeters,
Millimeters,
Points,
Picas,
}
impl Unit {
pub fn suffix(self) -> &'static str {
match self {
Unit::Inches => "\"",
Unit::Centimeters => " cm",
Unit::Millimeters => " mm",
Unit::Points => " pt",
Unit::Picas => " pi",
}
}
pub fn pt_per_unit(self) -> f32 {
match self {
Unit::Inches => PT_PER_IN,
Unit::Centimeters => PT_PER_CM,
Unit::Millimeters => PT_PER_CM / 10.0,
Unit::Points => 1.0,
Unit::Picas => 12.0,
}
}
/// Format `pt` in this unit the way Word's fields show it (`1"`, `2.54 cm`, `12 pt`).
pub fn format(self, pt: f32) -> String {
self.format_with(pt, '.')
}
/// [`Unit::format`] with the region's decimal separator (`2,54 cm` with `,`).
pub fn format_with(self, pt: f32, decimal: char) -> String {
format!("{}{}", self.number(pt, decimal), self.suffix())
}
/// `pt` as a number in this unit, at most two decimals, without the unit (`2,54` with `,`).
pub fn number(self, pt: f32, decimal: char) -> String {
let v = finite(pt) / self.pt_per_unit();
let s = format!("{v:.2}");
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s };
let s = if s == "-0" { "0" } else { s };
s.replace('.', &decimal.to_string())
}
}
/// Parse a measurement typed by a user: `1"`, `1 in`, `2.5cm`, `12pt`, `3 pi`, `10 mm`, `1.5 li`
/// (lines are 12 pt). A bare number is in `default`. Returns points. Either decimal separator
/// works (`2,5 cm` and `2.5 cm`), whatever the region.
pub fn parse_measure(s: &str, default: Unit) -> Option<f32> {
let t = s.trim().to_ascii_lowercase().replace('\u{2212}', "-");
if t.is_empty() {
return None;
}
let split = t.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == ',')).unwrap_or(t.len());
let (num, unit) = t.split_at(split);
let v: f32 = num.replace(',', ".").parse().ok()?;
if !v.is_finite() {
return None;
}
let k = match unit.trim() {
"" => default.pt_per_unit(),
"\"" | "in" | "inch" | "inches" => PT_PER_IN,
"cm" => PT_PER_CM,
"mm" => PT_PER_CM / 10.0,
"pt" | "pts" | "point" | "points" => 1.0,
"pi" | "pc" | "pica" | "picas" => 12.0,
"li" | "line" | "lines" => 12.0,
"px" => 0.75,
_ => return None,
};
let res = v * k;
if !res.is_finite() {
return None;
}
Some(res)
}
/// A point in page space (points, y down).
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Point {
pub const fn new(x: f32, y: f32) -> Self {
Point { x, y }
}
}
/// An axis-aligned rectangle (points, y down). `w`/`h` are never negative when built with [`Rect::new`].
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl Rect {
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Rect { x: finite(x), y: finite(y), w: finite(w).max(0.0), h: finite(h).max(0.0) }
}
pub fn right(&self) -> f32 {
self.x + self.w
}
pub fn bottom(&self) -> f32 {
self.y + self.h
}
pub fn contains(&self, p: Point) -> bool {
p.x >= self.x && p.x <= self.right() && p.y >= self.y && p.y <= self.bottom()
}
pub fn intersects(&self, o: &Rect) -> bool {
self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom()
}
pub fn union(&self, o: &Rect) -> Rect {
let x = self.x.min(o.x);
let y = self.y.min(o.y);
Rect::new(x, y, self.right().max(o.right()) - x, self.bottom().max(o.bottom()) - y)
}
pub fn inset(&self, l: f32, t: f32, r: f32, b: f32) -> Rect {
Rect::new(self.x + l, self.y + t, self.w - l - r, self.h - t - b)
}
/// Grown by `d` on every side (shrunk for negative `d`, down to zero size).
pub fn expand(&self, d: f32) -> Rect {
self.inset(-d, -d, -d, -d)
}
}
/// An angle in degrees brought into `0..360` (NaN and infinities become 0).
pub fn normalize_degrees(deg: f32) -> f32 {
if !deg.is_finite() {
return 0.0;
}
let d = deg.rem_euclid(360.0);
// `rem_euclid` can round up to exactly 360 for tiny negative angles.
if (0.0..360.0).contains(&d) { d } else { 0.0 }
}
/// How a drawing is turned about its centre: mirrored first (`flip_h` left to right, `flip_v` top
/// to bottom), then rotated `deg` degrees clockwise (page space, y down), as DrawingML's `a:xfrm`
/// `flipH`, `flipV` and `rot` say (ECMA-376 §20.1.7.6).
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Spin {
pub deg: f32,
pub flip_h: bool,
pub flip_v: bool,
}
impl Spin {
/// A spin with its angle normalised to `0..360`.
pub fn new(deg: f32, flip_h: bool, flip_v: bool) -> Spin {
Spin { deg: normalize_degrees(deg), flip_h, flip_v }
}
/// No rotation and no mirroring.
pub fn is_identity(&self) -> bool {
normalize_degrees(self.deg) == 0.0 && !self.flip_h && !self.flip_v
}
/// The linear part (a, b, c, d: x' = a·x + c·y, y' = b·x + d·y).
fn linear(&self) -> [f32; 4] {
let r = normalize_degrees(self.deg).to_radians();
let (s, c) = r.sin_cos();
let fx = if self.flip_h { -1.0 } else { 1.0 };
let fy = if self.flip_v { -1.0 } else { 1.0 };
[c * fx, s * fx, -s * fy, c * fy]
}
/// The affine map (a, b, c, d, e, f: x' = a·x + c·y + e, y' = b·x + d·y + f) turning the page
/// about point (`cx`, `cy`).
pub fn matrix(&self, cx: f32, cy: f32) -> [f32; 6] {
let [a, b, c, d] = self.linear();
let (cx, cy) = (finite(cx), finite(cy));
[a, b, c, d, cx - (a * cx + c * cy), cy - (b * cx + d * cy)]
}
/// Where point (`x`, `y`) goes when turned about (`cx`, `cy`).
pub fn apply(&self, cx: f32, cy: f32, x: f32, y: f32) -> (f32, f32) {
let [a, b, c, d] = self.linear();
let (u, v) = (x - cx, y - cy);
(cx + a * u + c * v, cy + b * u + d * v)
}
/// The point that [`Spin::apply`] takes to (`x`, `y`).
pub fn unapply(&self, cx: f32, cy: f32, x: f32, y: f32) -> (f32, f32) {
// The linear part is orthogonal: its inverse is its transpose.
let [a, b, c, d] = self.linear();
let (u, v) = (x - cx, y - cy);
(cx + a * u + b * v, cy + c * u + d * v)
}
/// The axis-aligned box `r` covers once turned about its centre.
pub fn bounds(&self, r: Rect) -> Rect {
let (bw, bh) = self.extent(r.w, r.h);
Rect::new(r.x + (r.w - bw) / 2.0, r.y + (r.h - bh) / 2.0, bw, bh)
}
/// The width and height of the box a `w` × `h` rectangle covers once turned.
pub fn extent(&self, w: f32, h: f32) -> (f32, f32) {
let (w, h) = (finite(w).abs(), finite(h).abs());
let r = normalize_degrees(self.deg).to_radians();
let (s, c) = (r.sin().abs(), r.cos().abs());
(w * c + h * s, w * s + h * c)
}
/// This spin applied inside `outer` (a group member's own spin within its group's): the one
/// spin doing both.
pub fn within(self, outer: Spin) -> Spin {
// outer = R(θo)·Fo, self = R(θs)·Fs; Fo·R(θ) = R(±θ)·Fo (one mirror reverses the turn).
let one_mirror = outer.flip_h != outer.flip_v;
let inner = if one_mirror { -self.deg } else { self.deg };
Spin::new(outer.deg + inner, outer.flip_h != self.flip_h, outer.flip_v != self.flip_v)
}
}
/// Standard paper sizes (Word's Layout › Size gallery), in points (portrait).
pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
("Letter", 612.0, 792.0),
("Legal", 612.0, 1008.0),
("Executive", 522.0, 756.0),
("A3", 841.89, 1190.55),
("A4", 595.28, 841.89),
("A5", 419.53, 595.28),
("B4 (JIS)", 728.5, 1031.8),
("B5 (JIS)", 515.9, 728.5),
("Tabloid", 792.0, 1224.0),
("Statement", 396.0, 612.0),
("Envelope #10", 297.0, 684.0),
("Envelope DL", 311.8, 623.6),
];
/// The paper size whose dimensions match (either orientation, within half a point).
pub fn paper_name(w: f32, h: f32) -> Option<&'static str> {
PAPER_SIZES
.iter()
.find(|(_, pw, ph)| ((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5) || ((pw - h).abs() < 0.5 && (ph - w).abs() < 0.5))
.map(|(n, _, _)| *n)
}
/// The standard paper size named `name` (case-insensitive), as (name, width, height) in points.
pub fn paper_by_name(name: &str) -> Option<(&'static str, f32, f32)> {
let name = name.trim();
PAPER_SIZES.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).copied()
}
/// How a page is placed on other paper when printing with "Scale to paper size" (#2108).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PaperFit {
/// The sheet, turned to the page's orientation (points).
pub paper_w: f32,
pub paper_h: f32,
/// Page points to sheet points.
pub k: f32,
/// Where the page's top-left corner lands on the sheet (the page is centred).
pub dx: f32,
pub dy: f32,
}
/// Fits a `page_w` × `page_h` page onto `paper_w` × `paper_h` paper: the paper is turned to the
/// page's orientation, the page is scaled up or down by one factor to fill it and centred, as
/// Word's Pages per sheet › Scale to paper size does. Degenerate or non-finite sizes give the
/// page as it is (scale 1 on paper of its own size).
pub fn fit_to_paper(page_w: f32, page_h: f32, paper_w: f32, paper_h: f32) -> PaperFit {
let ok = |v: f32| v.is_finite() && v >= 1.0;
let as_is = PaperFit { paper_w: page_w, paper_h: page_h, k: 1.0, dx: 0.0, dy: 0.0 };
if !(ok(page_w) && ok(page_h) && ok(paper_w) && ok(paper_h)) {
return as_is;
}
let (short, long) = (paper_w.min(paper_h), paper_w.max(paper_h));
let (pw, ph) = if page_w > page_h { (long, short) } else { (short, long) };
let k = (pw / page_w).min(ph / page_h);
let (dx, dy) = ((pw - page_w * k) / 2.0, (ph - page_h * k) / 2.0);
if !(k.is_finite() && k > 0.0 && dx.is_finite() && dy.is_finite()) {
return as_is;
}
PaperFit { paper_w: pw, paper_h: ph, k, dx, dy }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_round_trips() {
assert_eq!(twips(1440), 72.0);
assert_eq!(to_twips(72.0), 1440);
assert_eq!(emu(914_400), 72.0);
assert_eq!(to_emu(72.0), 914_400);
assert_eq!(half_points(24), 12.0);
assert_eq!(to_half_points(10.5), 21);
assert_eq!(to_twips(f32::NAN), 0);
}
#[test]
fn parses_measurements() {
assert_eq!(parse_measure("1\"", Unit::Points), Some(72.0));
assert_eq!(parse_measure("1 in", Unit::Points), Some(72.0));
assert_eq!(parse_measure("12pt", Unit::Inches), Some(12.0));
assert_eq!(parse_measure("2", Unit::Inches), Some(144.0));
assert_eq!(parse_measure("1,5 li", Unit::Inches), Some(18.0));
let cm = parse_measure("2.54 cm", Unit::Inches).unwrap();
assert!((cm - 72.0).abs() < 0.01);
assert_eq!(parse_measure("abc", Unit::Inches), None);
assert_eq!(parse_measure("", Unit::Inches), None);
assert_eq!(parse_measure("1 furlong", Unit::Inches), None);
// Both decimal separators, whatever the region (#838).
let comma = parse_measure("2,54 cm", Unit::Inches).unwrap();
assert!((comma - 72.0).abs() < 0.01);
assert_eq!(parse_measure("1,5", Unit::Inches), Some(108.0));
assert_eq!(parse_measure("1.5\"", Unit::Centimeters), Some(108.0));
assert_eq!(parse_measure("\u{2212}1 in", Unit::Points), Some(-72.0));
assert_eq!(parse_measure("1,2,3", Unit::Inches), None);
// Overflowing values during unit conversion return None (#1359).
assert_eq!(parse_measure("3.4e38 in", Unit::Points), None);
assert_eq!(parse_measure("-3.4e38 in", Unit::Points), None);
assert_eq!(parse_measure("3.4e38 cm", Unit::Points), None);
assert_eq!(parse_measure("3.4e38 mm", Unit::Points), None);
assert_eq!(parse_measure("3.4e38 pi", Unit::Points), None);
assert_eq!(parse_measure("3.4e38 li", Unit::Points), None);
assert_eq!(parse_measure("3.4e38", Unit::Inches), None);
assert_eq!(parse_measure("1e39 pt", Unit::Points), None);
assert_eq!(parse_measure("inf", Unit::Points), None);
assert_eq!(parse_measure("nan", Unit::Points), None);
}
#[test]
fn parse_measure_rejects_overflow_to_infinity() {
// Finite inputs whose scaled result overflows `f32` must be rejected,
// never returned as an infinite measurement (#1359).
let e38 = "100000000000000000000000000000000000000"; // 1e38, finite
for s in [
format!("{e38}\""),
format!("{e38} in"),
format!("{e38} cm"),
format!("{e38} pi"),
format!("{e38} li"),
format!("-{e38} in"),
e38.to_string(), // bare number in the default unit (inches below)
] {
assert_eq!(parse_measure(&s, Unit::Inches), None, "input: {s}");
}
// Millimetres scale by ~2.83, so 2e38 is needed to overflow.
let two_e38 = "200000000000000000000000000000000000000";
assert_eq!(parse_measure(&format!("{two_e38} mm"), Unit::Inches), None);
// A large result that stays finite is still accepted, and points
// scale by 1, so a finite input in points stays finite.
let big = parse_measure("1000000000000000000000000000000 in", Unit::Points).unwrap();
assert!(big.is_finite() && big > 0.0);
assert!(parse_measure(&format!("{e38} pt"), Unit::Inches).unwrap().is_finite());
}
#[test]
fn formats_measurements() {
assert_eq!(Unit::Inches.format(72.0), "1\"");
assert_eq!(Unit::Inches.format(90.0), "1.25\"");
assert_eq!(Unit::Points.format(12.0), "12 pt");
assert_eq!(Unit::Centimeters.format(72.0), "2.54 cm");
assert_eq!(Unit::Centimeters.format_with(72.0, ','), "2,54 cm");
assert_eq!(Unit::Inches.format_with(108.0, ','), "1,5\"");
assert_eq!(Unit::Points.format_with(720.0, ','), "720 pt");
assert_eq!(Unit::Inches.format_with(f32::NAN, ','), "0\"");
}
#[test]
fn rect_ops() {
let a = Rect::new(0.0, 0.0, 10.0, 10.0);
let b = Rect::new(5.0, 5.0, 10.0, 10.0);
assert!(a.intersects(&b));
assert_eq!(a.union(&b), Rect::new(0.0, 0.0, 15.0, 15.0));
assert!(a.contains(Point::new(5.0, 5.0)));
assert_eq!(Rect::new(0.0, 0.0, -5.0, f32::NAN).w, 0.0);
assert_eq!(a.inset(1.0, 2.0, 3.0, 4.0), Rect::new(1.0, 2.0, 6.0, 4.0));
}
#[test]
fn paper_names() {
assert_eq!(paper_name(612.0, 792.0), Some("Letter"));
assert_eq!(paper_name(792.0, 612.0), Some("Letter"));
assert_eq!(paper_name(100.0, 100.0), None);
assert_eq!(paper_by_name(" a4 ").map(|p| p.0), Some("A4"));
assert_eq!(paper_by_name("Legal").map(|p| (p.1, p.2)), Some((612.0, 1008.0)));
assert_eq!(paper_by_name("Quarto"), None);
}
/// Scale to paper size (#2108): one factor, centred, paper turned to the page's orientation.
#[test]
fn pages_fit_other_paper() {
let close = |a: f32, b: f32| (a - b).abs() < 0.01;
// Letter on A4: narrower paper, so the width decides and the page is centred vertically.
let f = fit_to_paper(612.0, 792.0, 595.28, 841.89);
assert!(close(f.paper_w, 595.28) && close(f.paper_h, 841.89));
assert!(close(f.k, 595.28 / 612.0) && close(f.dx, 0.0));
assert!(close(f.dy, (841.89 - 792.0 * f.k) / 2.0) && f.dy > 0.0);
// A4 on Legal: the width decides; A5 on A4 is enlarged.
assert!(close(fit_to_paper(595.28, 841.89, 612.0, 1008.0).k, 612.0 / 595.28));
assert!(close(fit_to_paper(419.53, 595.28, 595.28, 841.89).k, 595.28 / 419.53));
// A landscape page gets landscape paper whichever way the paper is given.
let l = fit_to_paper(792.0, 612.0, 595.28, 841.89);
assert!(close(l.paper_w, 841.89) && close(l.paper_h, 595.28));
assert!(close(l.k, 595.28 / 612.0) && l.dx > 0.0 && close(l.dy, 0.0));
// The same paper is the page as it is.
let same = fit_to_paper(612.0, 792.0, 612.0, 792.0);
assert!(close(same.k, 1.0) && close(same.dx, 0.0) && close(same.dy, 0.0));
// Hostile sizes never produce NaN or infinite scales.
for (a, b, c, d) in
[(f32::NAN, 792.0, 595.0, 842.0), (612.0, 0.0, 595.0, 842.0), (612.0, 792.0, f32::INFINITY, 842.0), (612.0, 792.0, -5.0, 842.0)]
{
let f = fit_to_paper(a, b, c, d);
assert_eq!((f.k, f.dx, f.dy), (1.0, 0.0, 0.0));
}
assert!(fit_to_paper(1.0, 1.0, f32::MAX, f32::MAX).k.is_finite());
}
}