Files
craft-libs/crates/wordmodel/src/wrap.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

295 lines
13 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.
//! Wrap outlines: the shape text follows around a floating object with Tight or Through
//! wrapping (ECMA-376 Part 1, §20.4.2.16 `wrapPolygon`, §20.4.2.17 `wrapThrough`, §20.4.2.18
//! `wrapTight`).
//!
//! A wrap polygon is given in a coordinate space where 21600 is the object's width or height.
//! Without one, an outline is derived: a shape's geometry, flattened; a picture's opaque pixels
//! (see [`alpha_outline`]); anything else its rectangle.
use serde::{Deserialize, Serialize};
use crate::para::ShapeKind;
/// The wrap polygon coordinate space: 21600 units span the object's extent on each axis.
pub const WRAP_SPACE: i32 = 21_600;
/// Most points a wrap polygon keeps (hostile files).
pub const MAX_WRAP_POINTS: usize = 1024;
/// Most points a derived outline has.
pub const MAX_DERIVED_POINTS: usize = 64;
/// How far outside the object a polygon point may lie (wrap space units, each way).
const MAX_COORD: i64 = WRAP_SPACE as i64 * 4;
/// A wrap polygon (`wp:wrapPolygon`): `wp:start` and the `wp:lineTo` points after it, closed.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct WrapPolygon {
/// Edited by hand (`edited`) rather than worked out by the application.
pub edited: bool,
/// Points in the wrap space (x, y), the first being `wp:start`.
pub points: Vec<[i32; 2]>,
}
impl WrapPolygon {
/// A polygon from raw coordinates: clamped, capped at [`MAX_WRAP_POINTS`]. `None` with fewer
/// than three points (no area).
pub fn new(edited: bool, points: impl IntoIterator<Item = (i64, i64)>) -> Option<WrapPolygon> {
let points: Vec<[i32; 2]> = points
.into_iter()
.take(MAX_WRAP_POINTS)
.map(|(x, y)| [x.clamp(-MAX_COORD, MAX_COORD) as i32, y.clamp(-MAX_COORD, MAX_COORD) as i32])
.collect();
(points.len() >= 3).then_some(WrapPolygon { edited, points })
}
/// From an outline in unit coordinates (0..1 across the object).
pub fn from_unit(edited: bool, pts: &[(f32, f32)]) -> Option<WrapPolygon> {
let s = WRAP_SPACE as f32;
WrapPolygon::new(
edited,
pts.iter().filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| ((x * s).round() as i64, (y * s).round() as i64)),
)
}
/// The points in unit coordinates (0..1 across the object).
pub fn unit(&self) -> Vec<(f32, f32)> {
let s = WRAP_SPACE as f32;
self.points.iter().take(MAX_WRAP_POINTS).map(|[x, y]| (*x as f32 / s, *y as f32 / s)).collect()
}
}
/// A shape's outline in unit coordinates (0..1 across a `w` × `h` frame), flattened to straight
/// lines. `None` for shapes whose outline is their rectangle (rectangles, text boxes, lines,
/// freeforms). `corner` is a rounded rectangle's (see [`crate::connector::ShapeExtra::corner`]).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Option<Vec<(f32, f32)>> {
use std::f32::consts::{FRAC_PI_2, PI, TAU};
let pos = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { 0.0 };
let (w, h) = (pos(w), pos(h));
let pts = match kind {
// A freeform's paths (ink among them) aren't known from its kind: its frame is its outline.
ShapeKind::Rectangle
| ShapeKind::TextBox
| ShapeKind::Line
| ShapeKind::Freeform
| ShapeKind::StraightConnector
| ShapeKind::ElbowConnector
| ShapeKind::CurvedConnector => return None,
ShapeKind::Ellipse | ShapeKind::FlowchartOr => {
(0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect()
}
ShapeKind::RoundedRectangle => {
// Corner radius as drawn.
let r = crate::connector::corner_radius(corner, w, h);
let (rx, ry) = if w > 0.0 && h > 0.0 { (r / w, r / h) } else { (0.0, 0.0) };
let mut pts = Vec::new();
for (cx, cy, a0) in [(1.0 - rx, 1.0 - ry, 0.0), (rx, 1.0 - ry, FRAC_PI_2), (rx, ry, PI), (1.0 - rx, ry, PI + FRAC_PI_2)] {
for k in 0..=6 {
let a = a0 + FRAC_PI_2 * k as f32 / 6.0;
pts.push((cx + rx * a.cos(), cy + ry * a.sin()));
}
}
pts
}
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
ShapeKind::Star => (0..10)
.map(|i| {
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
let r = if i % 2 == 0 { 0.5 } else { 0.2 };
(0.5 + r * a.cos(), 0.5 + r * a.sin())
})
.collect(),
ShapeKind::Heart => {
let curves = [
[(0.5, 0.3), (0.5, 0.0), (0.0, 0.0), (0.0, 0.3)],
[(0.0, 0.3), (0.0, 0.6), (0.4, 0.75), (0.5, 1.0)],
[(0.5, 1.0), (0.6, 0.75), (1.0, 0.6), (1.0, 0.3)],
[(1.0, 0.3), (1.0, 0.0), (0.5, 0.0), (0.5, 0.3)],
];
let mut pts = Vec::new();
for [p0, p1, p2, p3] in curves {
for k in 0..10 {
let t = k as f32 / 10.0;
let u = 1.0 - t;
let b = |a: f32, b: f32, c: f32, d: f32| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
pts.push((b(p0.0, p1.0, p2.0, p3.0), b(p0.1, p1.1, p2.1, p3.1)));
}
}
pts
}
};
Some(pts)
}
/// A block arrow's outline in unit coordinates (0..1 across a `w` × `h` frame), pointing the way
/// `kind` says (right for anything but the left, up and down arrows). The head is half the arrow's
/// thickness long, the shaft 40% of the thickness thick.
pub fn arrow_outline(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
let vertical = matches!(kind, ShapeKind::ArrowUp | ShapeKind::ArrowDown);
let (len, thick) = if vertical { (h, w) } else { (w, h) };
let head = if len > 0.0 { (0.5 * thick / len).min(1.0) } else { 0.5 };
// Pointing right: `u` runs along the arrow from its tail, `v` across it.
let right = [(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)];
right
.iter()
.map(|&(u, v)| match kind {
ShapeKind::ArrowLeft => (1.0 - u, v),
ShapeKind::ArrowUp => (v, 1.0 - u),
ShapeKind::ArrowDown => (v, u),
_ => (u, v),
})
.collect()
}
/// Largest side of the grid [`alpha_outline`] looks at.
pub const ALPHA_GRID: usize = 48;
/// The outline of a picture's opaque pixels, in unit coordinates: the convex hull of the cells
/// of a `gw` × `gh` grid laid over the picture that `opaque` says hold visible pixels, at most
/// [`MAX_DERIVED_POINTS`] points. `None` when every cell is opaque (or none is): the picture's
/// rectangle is its outline.
pub fn alpha_outline(gw: usize, gh: usize, opaque: impl Fn(usize, usize) -> bool) -> Option<Vec<(f32, f32)>> {
let (gw, gh) = (gw.clamp(1, 1024), gh.clamp(1, 1024));
// Each row's first and last opaque cells are all the hull can need.
let mut pts: Vec<(i32, i32)> = Vec::new();
let mut full = true;
for y in 0..gh {
let first = (0..gw).find(|&x| opaque(x, y));
let Some(first) = first else {
full = false;
continue;
};
let last = (first..gw).rev().find(|&x| opaque(x, y)).unwrap_or(first);
full &= first == 0 && last + 1 == gw;
let (y0, y1) = (y as i32, y as i32 + 1);
pts.extend([(first as i32, y0), (first as i32, y1), (last as i32 + 1, y0), (last as i32 + 1, y1)]);
}
if full || pts.is_empty() {
return None;
}
let hull = simplify_convex(convex_hull(pts), MAX_DERIVED_POINTS);
(hull.len() >= 3).then(|| hull.into_iter().map(|(x, y)| (x as f32 / gw as f32, y as f32 / gh as f32)).collect())
}
/// The convex hull of integer points, counter-clockwise (monotone chain).
fn convex_hull(mut pts: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
pts.sort_unstable();
pts.dedup();
if pts.len() < 3 {
return pts;
}
let cross = |o: (i32, i32), a: (i32, i32), b: (i32, i32)| {
(a.0 as i64 - o.0 as i64) * (b.1 as i64 - o.1 as i64) - (a.1 as i64 - o.1 as i64) * (b.0 as i64 - o.0 as i64)
};
let mut hull: Vec<(i32, i32)> = Vec::with_capacity(pts.len() * 2);
for pass in 0..2 {
let start = hull.len();
let iter: Box<dyn Iterator<Item = &(i32, i32)>> = if pass == 0 { Box::new(pts.iter()) } else { Box::new(pts.iter().rev()) };
for &p in iter {
while hull.len() >= start + 2 {
let (Some(&a), Some(&b)) = (hull.get(hull.len() - 2), hull.last()) else { break };
if cross(a, b, p) <= 0 {
hull.pop();
} else {
break;
}
}
hull.push(p);
}
hull.pop();
}
hull
}
/// A convex polygon cut down to `max` points by dropping, one at a time, the corner whose
/// removal loses the least area.
fn simplify_convex(mut pts: Vec<(i32, i32)>, max: usize) -> Vec<(i32, i32)> {
while pts.len() > max.max(3) {
let n = pts.len();
let area = |i: usize| {
let (a, b, c) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]);
((b.0 as i64 - a.0 as i64) * (c.1 as i64 - a.1 as i64) - (b.1 as i64 - a.1 as i64) * (c.0 as i64 - a.0 as i64)).abs()
};
let Some(i) = (0..n).min_by_key(|&i| area(i)) else { break };
pts.remove(i);
}
pts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polygon_caps_and_clamps() {
assert!(WrapPolygon::new(false, [(0, 0), (1, 1)]).is_none());
let p = WrapPolygon::new(true, (0..5000).map(|i| (i, i64::MAX))).unwrap();
assert_eq!(p.points.len(), MAX_WRAP_POINTS);
assert!(p.points.iter().all(|[_, y]| *y as i64 == MAX_COORD));
assert!(WrapPolygon::from_unit(false, &[(f32::NAN, 0.0), (0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]).is_some_and(|p| p.points.len() == 3));
}
/// #3919: the tip of each block arrow lies on the side it points to.
#[test]
fn arrow_outlines_point_the_right_way() {
let tip = |kind| shape_outline(kind, 100.0, 100.0, None).unwrap()[3];
assert_eq!(tip(ShapeKind::Arrow), (1.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowLeft), (0.0, 0.5));
assert_eq!(tip(ShapeKind::ArrowUp), (0.5, 0.0));
assert_eq!(tip(ShapeKind::ArrowDown), (0.5, 1.0));
}
#[test]
fn shape_outlines_stay_in_the_frame() {
for kind in [
ShapeKind::Ellipse,
ShapeKind::RoundedRectangle,
ShapeKind::Triangle,
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
ShapeKind::ArrowLeft,
ShapeKind::ArrowUp,
ShapeKind::ArrowDown,
ShapeKind::Star,
ShapeKind::Heart,
] {
for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] {
for corner in [None, Some(0), Some(3_565), Some(u32::MAX)] {
let pts = shape_outline(kind, w, h, corner).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
}
}
}
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0, None).is_none());
// The end of the bottom-right corner: in the frame's corner when the corner is square,
// 16% of the height (8 of 100 wide) in from it by default.
let corner_pt = |corner| shape_outline(ShapeKind::RoundedRectangle, 100.0, 50.0, corner).unwrap()[6];
assert!(corner_pt(Some(0)).0 > 0.999 && corner_pt(Some(0)).1 > 0.999, "{:?}", corner_pt(Some(0)));
assert!((corner_pt(None).0 - 0.92).abs() < 0.001, "{:?}", corner_pt(None));
}
#[test]
fn alpha_outline_is_the_hull_of_opaque_cells() {
// A disc in a 20×20 grid.
let disc = |x: usize, y: usize| {
let (dx, dy) = (x as f32 + 0.5 - 10.0, y as f32 + 0.5 - 10.0);
dx * dx + dy * dy < 64.0
};
let pts = alpha_outline(20, 20, disc).unwrap();
assert!(pts.len() >= 8 && pts.len() <= MAX_DERIVED_POINTS);
assert!(pts.iter().all(|(x, y)| (0.05..=0.95).contains(x) && (0.05..=0.95).contains(y)));
assert!(alpha_outline(20, 20, |_, _| true).is_none());
assert!(alpha_outline(20, 20, |_, _| false).is_none());
// A big grid with a noisy outline is cut down to the cap.
let noisy = alpha_outline(1024, 1024, |x, y| {
let (dx, dy) = (x as f32 - 512.0, y as f32 - 512.0);
dx * dx + dy * dy < 500.0 * 500.0
})
.unwrap();
assert!(noisy.len() <= MAX_DERIVED_POINTS);
}
}