Files
wordcraft/crates/doc/src/wrap.rs
T
2026-10-14 21:21:49 -04:00

266 lines
12 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 => {
// As drawn: the head is half the height long, the shaft 40% of the height thick.
let head = if w > 0.0 { (0.5 * h / w).min(1.0) } else { 0.5 };
vec![(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)]
}
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)
}
/// 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));
}
#[test]
fn shape_outlines_stay_in_the_frame() {
for kind in [
ShapeKind::Ellipse,
ShapeKind::RoundedRectangle,
ShapeKind::Triangle,
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
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);
}
}