Tight and Through wrapping follow the object's outline (#354) (#360)

Text now wraps around a Tight or Through object's shape instead of its
bounding box. The outline is the object's wrap polygon (wp:wrapPolygon,
in the 21600-unit space of its frame) when it has one; otherwise it is
derived: a shape's geometry (ellipse, triangle, star, heart... flattened),
the convex hull of a picture's opaque pixels (alpha threshold on a coarse
grid, at most 64 points), or the rectangle. The outline turns and flips
with the object, keeps the left/right and top/bottom distances from text,
and is built once per placed object; each line asks it which stretches of
its band it covers. Through also lets text into gaps inside the outline
that are wide enough.

DOCX reads and writes wp:wrapPolygon (with edited) for wrapTight and
wrapThrough; a shape without one gets its outline written, other objects
their rectangle, since the schema requires a polygon. Square, top and
bottom, behind and in front of text are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 18:10:48 +02:00 committed by GitHub
parent 34511263a7
commit 860f4ca260
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 819 additions and 47 deletions

1
Cargo.lock generated
View File

@ -5141,6 +5141,7 @@ dependencies = [
name = "wordcraft-layout"
version = "0.5.0"
dependencies = [
"image",
"js-sys",
"log",
"serde",

View File

@ -163,6 +163,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
| 2026-10-10 | trivial | Progress log: contour wrap (#354) |
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |

View File

@ -29,6 +29,7 @@ pub mod resolve;
pub mod section;
pub mod styles;
pub mod table;
pub mod wrap;
use std::collections::BTreeMap;
use std::sync::Arc;

View File

@ -126,7 +126,7 @@ impl FloatAlign {
}
/// Floating placement (ignored when `wrap` is `Inline`).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Float {
pub wrap: Wrap,
@ -158,6 +158,10 @@ pub struct Float {
pub flip_h: bool,
#[serde(skip_serializing_if = "is_false")]
pub flip_v: bool,
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
}
fn is_zero(v: &f32) -> bool {

248
crates/doc/src/wrap.rs Normal file
View File

@ -0,0 +1,248 @@
//! 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).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> 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 => return None,
ShapeKind::Ellipse => (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: 16% of the shorter side.
let r = 0.16 * w.min(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::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::Diamond,
ShapeKind::Arrow,
ShapeKind::Star,
ShapeKind::Heart,
] {
for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] {
let pts = shape_outline(kind, w, h).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).is_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);
}
}

View File

@ -1045,8 +1045,10 @@ fn anchor_float(c: &El) -> Float {
for k in c.els() {
match k.name.as_str() {
"wp:wrapSquare" => f.wrap = Wrap::Square,
"wp:wrapTight" => f.wrap = Wrap::Tight,
"wp:wrapThrough" => f.wrap = Wrap::Through,
"wp:wrapTight" | "wp:wrapThrough" => {
f.wrap = if k.name == "wp:wrapTight" { Wrap::Tight } else { Wrap::Through };
f.wrap_polygon = k.child("wp:wrapPolygon").and_then(wrap_polygon).map(Arc::new);
}
"wp:wrapTopAndBottom" => f.wrap = Wrap::TopAndBottom,
"wp:wrapNone" => f.wrap = if behind { Wrap::BehindText } else { Wrap::InFrontOfText },
"wp:positionH" | "wp:positionV" => {
@ -1088,6 +1090,30 @@ fn anchor_float(c: &El) -> Float {
f
}
/// A `wp:wrapPolygon`: `wp:start`, then the `wp:lineTo` points (ECMA-376 Part 1, §20.4.2.16).
fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
let pts = e
.els()
.filter(|k| k.name == "wp:start" || k.name == "wp:lineTo")
.take(wordcraft_doc::wrap::MAX_WRAP_POINTS + 1)
.filter_map(|k| Some((int(k.attr("x")?)?, int(k.attr("y")?)?)));
let mut pts: Vec<(i64, i64)> = pts.collect();
// The last point usually repeats the start to close the polygon.
if pts.len() > 3 && pts.first() == pts.last() {
pts.pop();
}
let edited = on_off_attr(e, "edited");
// An unedited polygon round the whole frame says nothing the object doesn't (it's what we
// write when there's no outline): keep deriving the outline.
let s = i64::from(wordcraft_doc::wrap::WRAP_SPACE);
let mut corners = pts.clone();
corners.sort_unstable();
if !edited && corners == [(0, 0), (0, s), (s, 0), (s, s)] {
return None;
}
wordcraft_doc::wrap::WrapPolygon::new(edited, pts)
}
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())

View File

@ -478,10 +478,16 @@ impl Writer<'_> {
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
let mut float = *float;
let mut float = float.clone();
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
*e = wordcraft_geom::finite(*e).max(fx);
}
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh)
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
w.empty("wp:cNvGraphicFramePr", &[]);
@ -807,10 +813,16 @@ impl Writer<'_> {
Wrap::Tight | Wrap::Through => {
let tag = if float.wrap == Wrap::Tight { "wp:wrapTight" } else { "wp:wrapThrough" };
w.open(tag, &[("wrapText", "bothSides")]);
w.open("wp:wrapPolygon", &[("edited", "0")]);
w.empty("wp:start", &[("x", "0"), ("y", "0")]);
for (x, y) in [("0", "21600"), ("21600", "21600"), ("21600", "0"), ("0", "0")] {
w.empty("wp:lineTo", &[("x", x), ("y", y)]);
// The object's own polygon, or its rectangle (the schema requires one).
let s = wordcraft_doc::wrap::WRAP_SPACE;
let (edited, pts) = match float.wrap_polygon.as_deref() {
Some(p) if p.points.len() >= 3 => (p.edited, p.points.clone()),
_ => (false, vec![[0, 0], [0, s], [s, s], [s, 0]]),
};
w.open("wp:wrapPolygon", &[("edited", if edited { "1" } else { "0" })]);
// Closed: back to the start.
for (i, [x, y]) in pts.iter().chain(pts.first()).enumerate() {
w.empty(if i == 0 { "wp:start" } else { "wp:lineTo" }, &[("x", &x.to_string()), ("y", &y.to_string())]);
}
w.close("wp:wrapPolygon");
w.close(tag);

View File

@ -600,7 +600,8 @@ fn anchor_alignment_reference_areas_and_distances() {
);
let d = read_body(&body);
let p = paras(&d);
let floats: Vec<_> = p[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(*float) } else { None }).collect();
let floats: Vec<_> =
p[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.clone()) } else { None }).collect();
let [a, b] = floats.as_slice() else { panic!("{floats:?}") };
assert_eq!((a.h_rel, a.h_align, a.v_rel, a.v_align, a.y), (Anchor::Margin, Some(FloatAlign::Center), Anchor::TopMargin, None, -1.0));
assert_eq!((a.dist, a.dist_top, a.dist_bottom), (9.0, 2.0, 4.0));
@ -662,7 +663,9 @@ fn absolute_vml_text_box_in_header_floats() {
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| &p.objects)
.filter_map(|o| if let InlineObject::Shape { kind, w, h, float, story, .. } = o { Some((*kind, *w, *h, *float, *story)) } else { None })
.filter_map(|o| {
if let InlineObject::Shape { kind, w, h, float, story, .. } = o { Some((*kind, *w, *h, float.clone(), *story)) } else { None }
})
.collect();
let [(k0, w0, h0, f0, s0), (_, w1, h1, f1, _), (_, _, _, f2, _), (_, _, _, f3, _)] = shapes.as_slice() else { panic!("{shapes:?}") };
assert_eq!(*k0, ShapeKind::TextBox);

View File

@ -918,7 +918,7 @@ fn groups_round_trip() {
let group = InlineObject::Group {
w: 240.0,
h: 120.0,
float,
float: float.clone(),
ch_w: 200.0,
ch_h: 100.0,
children: vec![
@ -1376,8 +1376,8 @@ fn rotation_and_flips_round_trip() {
effects: Default::default(),
};
let square = Float { wrap: Wrap::Square, ..Default::default() };
let tri = shape(ShapeKind::Triangle, Float { rot: 90.0, flip_h: true, ..square });
let flipped = shape(ShapeKind::Rectangle, Float { rot: 315.5, flip_v: true, ..square });
let tri = shape(ShapeKind::Triangle, Float { rot: 90.0, flip_h: true, ..square.clone() });
let flipped = shape(ShapeKind::Rectangle, Float { rot: 315.5, flip_v: true, ..square.clone() });
let group = InlineObject::Group {
w: 120.0,
h: 60.0,
@ -1461,3 +1461,45 @@ fn rotated_group_and_shadow_rotation_round_trip() {
.collect();
assert_eq!(got, [(20.0, Some(true)), (20.0, Some(false))]);
}
#[test]
fn wrap_polygons_round_trip() {
use wordcraft_doc::wrap::{WrapPolygon, shape_outline};
let mut d = Document::new();
let shape = |kind, float| InlineObject::Shape {
kind,
w: 80.0,
h: 40.0,
fill: Some(Rgb(1, 2, 3)),
stroke: None,
stroke_width: 0.0,
float,
story: None,
effects: Default::default(),
freeform: None,
};
// A hand-edited polygon, including points outside the frame.
let poly = WrapPolygon::new(true, [(-300, 0), (21_600, 1200), (18_000, 21_900), (0, 21_600)]).map(Arc::new);
let edited = Float { wrap: Wrap::Tight, v_rel: Anchor::Paragraph, wrap_polygon: poly.clone(), ..Default::default() };
// None: Through wrapping needs one, so the ellipse's own outline is written.
let derived = Float { wrap: Wrap::Through, ..Default::default() };
let mut p = Paragraph::with_text("wrapped ", CharProps::default());
for o in [shape(ShapeKind::Rectangle, edited.clone()), shape(ShapeKind::Ellipse, derived)] {
let end = p.len();
p.insert_object(end, o, &CharProps::default()).unwrap();
}
d.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
let got = paras(&r)[0];
let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
assert_eq!(floats[0], edited);
let ellipse = WrapPolygon::from_unit(false, &shape_outline(ShapeKind::Ellipse, 80.0, 40.0).unwrap());
assert_eq!(floats[1].wrap, Wrap::Through);
assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref());
}

View File

@ -984,7 +984,7 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
GroupChild { x: x - x0, y: y - y0, obj }
})
.collect();
let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| f.clone()).unwrap_or_default();
float.set_spin(Spin::default());
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
// Take the members out, last first so the earlier positions hold, and put the group where
@ -1023,7 +1023,7 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
};
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
let float = *float;
let float = float.clone();
// Each member where the group shows it: the group's turn moves its centre about the group's
// centre and adds to its own turn.
let members: Vec<InlineObject> = shown_members(&obj, [0.0, 0.0, w, h])
@ -1035,7 +1035,7 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
// Grown to its smallest size about its centre, so it stays centred where it was.
let (x, y) = (x - (mw - cw) / 2.0, y - (mh - ch) / 2.0);
if let Some(f) = c.float_mut() {
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float.clone() };
f.set_spin(spin);
}
c

View File

@ -18,6 +18,7 @@ unicode-linebreak = { workspace = true }
unicode-segmentation = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
image = { workspace = true }
log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]

View File

@ -0,0 +1,276 @@
//! Contour wrapping: the outline Tight and Through wrapping follow around a floating object
//! (ECMA-376 Part 1, §20.4.2.17 `wrapThrough`, §20.4.2.18 `wrapTight`), and the picture
//! outlines worked out from transparency.
//!
//! A [`Contour`] is built once per placed object; each line then asks it which stretches of
//! the line's band it takes ([`Contour::occupied`]), a walk over its edges.
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use wordcraft_doc::para::{Float, InlineObject, Wrap};
use wordcraft_doc::wrap::{ALPHA_GRID, MAX_WRAP_POINTS, alpha_outline, shape_outline};
use wordcraft_geom::{Rect, finite};
/// A wrap outline placed on the page, in the coordinates of its wrap area's top-left corner
/// (see [`crate::para::Exclusion`]).
#[derive(Clone, PartialEq)]
pub struct Contour {
/// The object's outline (rotated and flipped as the object is), closed.
pts: Vec<(f32, f32)>,
/// Distance from text at the left and right, above and below.
pad_x: f32,
pad_top: f32,
pad_bottom: f32,
/// Through wrapping: text may also go into gaps inside the outline.
through: bool,
/// A digest of the above (paragraph layout cache keys print it instead of the points).
id: u64,
}
impl std::fmt::Debug for Contour {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Contour#{:x}", self.id)
}
}
impl Contour {
/// A contour around `pts` (page coordinates; non-finite points are dropped, at most
/// [`MAX_WRAP_POINTS`]) kept `pad_x` from text at the sides and `pad_top` / `pad_bottom`
/// above and below. Returns the wrap area (the outline's bounds grown by the distances)
/// and the contour relative to its top-left; `None` without at least three points.
pub fn new(pts: &[(f32, f32)], pad_x: f32, pad_top: f32, pad_bottom: f32, through: bool) -> Option<(Rect, Contour)> {
let pts: Vec<(f32, f32)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_WRAP_POINTS).collect();
if pts.len() < 3 {
return None;
}
let pad = |v: f32| finite(v).clamp(0.0, 1584.0);
let (pad_x, pad_top, pad_bottom) = (pad(pad_x), pad(pad_top), pad(pad_bottom));
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (x, y) in &pts {
(x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y));
}
let area = Rect::new(x0 - pad_x, y0 - pad_top, x1 - x0 + 2.0 * pad_x, y1 - y0 + pad_top + pad_bottom);
let pts: Vec<(f32, f32)> = pts.into_iter().map(|(x, y)| (x - area.x, y - area.y)).collect();
Some((area, Contour::with_id(pts, pad_x, pad_top, pad_bottom, through)))
}
fn with_id(pts: Vec<(f32, f32)>, pad_x: f32, pad_top: f32, pad_bottom: f32, through: bool) -> Contour {
let mut h = std::collections::hash_map::DefaultHasher::new();
for (x, y) in &pts {
(x.to_bits(), y.to_bits()).hash(&mut h);
}
(pad_x.to_bits(), pad_top.to_bits(), pad_bottom.to_bits(), through).hash(&mut h);
Contour { pts, pad_x, pad_top, pad_bottom, through, id: h.finish() }
}
/// This contour mirrored left to right in a wrap area `width` wide (right-to-left text).
pub fn mirrored(&self, width: f32) -> Contour {
let pts = self.pts.iter().map(|(x, y)| (width - x, *y)).collect();
Contour::with_id(pts, self.pad_x, self.pad_top, self.pad_bottom, self.through)
}
/// The stretches (x0, x1), left to right, that the object and its distances from text take
/// of the band `top..bottom` (both relative to the wrap area's top-left). Tight: one
/// stretch, from the outline's leftmost to its rightmost point in the band. Through: the
/// outline's own pieces, so text can go into the gaps between them. Empty when the band
/// misses the outline.
pub fn occupied(&self, top: f32, bottom: f32) -> Vec<(f32, f32)> {
// Distance above the object reaches down to bands above it, and the other way round.
let (y0, y1) = (top - self.pad_bottom, bottom + self.pad_top);
if !(y0.is_finite() && y1.is_finite()) || y1 < y0 {
return Vec::new();
}
let n = self.pts.len();
let mut spans: Vec<(f32, f32)> = Vec::new();
// The outline's part in the band is bounded by its edges (clipped to the band) and by
// the band's top and bottom lines where they're inside it: what those cover across is
// what the outline covers.
for (i, &(ax, ay)) in self.pts.iter().enumerate() {
let Some(&(bx, by)) = self.pts.get((i + 1) % n) else { continue };
let (lo, hi) = (ay.min(by), ay.max(by));
if hi < y0 || lo > y1 {
continue;
}
if (by - ay).abs() < 1e-6 {
spans.push((ax.min(bx), ax.max(bx)));
continue;
}
let x_at = |y: f32| ax + (bx - ax) * (y - ay) / (by - ay);
let (xa, xb) = (x_at(lo.max(y0)), x_at(hi.min(y1)));
spans.push((xa.min(xb), xa.max(xb)));
}
for y in [y0, y1] {
let mut xs: Vec<f32> = Vec::new();
for (i, &(ax, ay)) in self.pts.iter().enumerate() {
let Some(&(bx, by)) = self.pts.get((i + 1) % n) else { continue };
if (ay <= y) != (by <= y) {
xs.push(ax + (bx - ax) * (y - ay) / (by - ay));
}
}
xs.sort_by(f32::total_cmp);
spans.extend(xs.as_chunks::<2>().0.iter().map(|[a, b]| (*a, *b)));
}
if spans.is_empty() {
return spans;
}
// Distance from text at the sides widens each piece; pieces that then touch merge.
let mut spans: Vec<(f32, f32)> = spans.into_iter().map(|(a, b)| (a - self.pad_x, b + self.pad_x)).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f32, f32)> = Vec::with_capacity(spans.len());
for (a, b) in spans {
match merged.last_mut() {
Some(last) if a <= last.1 + 0.01 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
if !self.through && merged.len() > 1 {
let (a, b) = (merged.first().map_or(0.0, |s| s.0), merged.last().map_or(0.0, |s| s.1));
return vec![(a, b)];
}
merged
}
}
/// The outline (unit coordinates across its frame, unrotated) that Tight and Through wrapping
/// follow around `o`: its wrap polygon, a shape's geometry, or a picture's opaque pixels.
/// `None`: its rectangle (or it isn't wrapped that way). `picture` works out a picture's
/// outline from its media key and crop.
pub fn wrap_outline(o: &InlineObject, picture: impl FnOnce(&str, [f32; 4]) -> Option<Arc<Vec<(f32, f32)>>>) -> Option<Arc<Vec<(f32, f32)>>> {
let (w, h, float) = o.frame()?;
if !matches!(float.wrap, Wrap::Tight | Wrap::Through) {
return None;
}
if let Some(p) = &float.wrap_polygon {
return Some(Arc::new(p.unit()));
}
match o {
InlineObject::Shape { kind, .. } => shape_outline(*kind, w, h).map(Arc::new),
InlineObject::Image { media, crop, .. } => picture(media, *crop),
_ => None,
}
}
/// The wrap area of a Tight or Through object at `r` (its unrotated frame) following `outline`
/// (unit coordinates), turned and mirrored as the object is.
pub fn contour_area(r: Rect, float: &Float, outline: &[(f32, f32)]) -> Option<(Rect, Contour)> {
let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
let spin = float.spin();
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
let pts: Vec<(f32, f32)> = outline.iter().take(MAX_WRAP_POINTS).map(|(u, v)| spin.apply(cx, cy, r.x + u * r.w, r.y + v * r.h)).collect();
Contour::new(&pts, d(float.dist), d(float.dist_top), d(float.dist_bottom), float.wrap == Wrap::Through)
}
/// Largest picture (each side, pixels) whose transparency is looked at.
const MAX_PICTURE_SIDE: u32 = 8192;
/// The outline of a picture's opaque pixels within `crop` (left, top, right, bottom fractions),
/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or
/// that don't decode): their rectangle is their outline.
pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
// Formats that can't be transparent aren't decoded.
if matches!(reader.format(), None | Some(image::ImageFormat::Jpeg)) {
return None;
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_PICTURE_SIDE);
limits.max_image_height = Some(MAX_PICTURE_SIDE);
limits.max_alloc = Some(512 << 20);
reader.limits(limits);
let img = reader.decode().ok()?;
if !img.color().has_alpha() {
return None;
}
let img = img.into_rgba8();
let (iw, ih) = img.dimensions();
let frac = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
let [cl, ct, cr, cb] = crop.map(frac);
let x0 = ((iw as f32 * cl) as u32).min(iw);
let y0 = ((ih as f32 * ct) as u32).min(ih);
let x1 = ((iw as f32 * (1.0 - cr)) as u32).clamp(x0, iw);
let y1 = ((ih as f32 * (1.0 - cb)) as u32).clamp(y0, ih);
let (cw, ch) = (x1 - x0, y1 - y0);
if cw == 0 || ch == 0 {
return None;
}
// A grid of cells at most ALPHA_GRID a side; a cell is opaque when a pixel sampled in it is.
let cell = cw.max(ch).div_ceil(ALPHA_GRID as u32).max(1);
let (gw, gh) = (cw.div_ceil(cell), ch.div_ceil(cell));
let step = (cell / 8).max(1);
let opaque = |gx: usize, gy: usize| {
let (px0, py0) = (x0 + gx as u32 * cell, y0 + gy as u32 * cell);
let (px1, py1) = ((px0 + cell).min(x1), (py0 + cell).min(y1));
(py0..py1)
.step_by(step as usize)
.any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128)))
};
let pts = alpha_outline(gw as usize, gh as usize, opaque)?;
// The grid may reach a little past the picture (its last cells are partial).
let (sx, sy) = ((gw * cell) as f32 / cw as f32, (gh * cell) as f32 / ch as f32);
Some(pts.into_iter().map(|(x, y)| ((x * sx).min(1.0), (y * sy).min(1.0))).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn diamond(through: bool) -> Contour {
Contour::new(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], 0.0, 0.0, 0.0, through).unwrap().1
}
#[test]
fn occupied_follows_the_outline() {
let c = diamond(false);
let s = c.occupied(0.0, 10.0);
assert_eq!(s.len(), 1);
assert!((s[0].0 - 40.0).abs() < 0.01 && (s[0].1 - 60.0).abs() < 0.01, "{s:?}");
let s = c.occupied(45.0, 55.0);
assert!((s[0].0 - 0.0).abs() < 0.01 && (s[0].1 - 100.0).abs() < 0.01, "{s:?}");
assert!(c.occupied(120.0, 130.0).is_empty());
// A band wholly inside the outline.
let big = Contour::new(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], 5.0, 0.0, 0.0, false).unwrap().1;
assert_eq!(big.occupied(40.0, 50.0), vec![(0.0, 110.0)]);
}
#[test]
fn through_keeps_interior_gaps() {
// A U: two prongs joined at the bottom.
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
let (_, through) = Contour::new(&u, 0.0, 0.0, 0.0, true).unwrap();
assert_eq!(tight.occupied(10.0, 20.0).len(), 1);
assert_eq!(through.occupied(10.0, 20.0).len(), 2);
assert_eq!(through.occupied(85.0, 90.0).len(), 1);
}
#[test]
fn pictures_wrap_around_their_opaque_pixels() {
let png = |img: image::DynamicImage| {
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
buf
};
// An opaque disc in the middle of a transparent 64 × 64 picture.
let disc = image::RgbaImage::from_fn(64, 64, |x, y| {
let (dx, dy) = (x as f32 + 0.5 - 32.0, y as f32 + 0.5 - 32.0);
image::Rgba([0, 0, 0, if dx * dx + dy * dy < 400.0 { 255 } else { 0 }])
});
let pts = picture_outline(&png(disc.clone().into()), [0.0; 4]).unwrap();
assert!(pts.len() >= 8 && pts.len() <= wordcraft_doc::wrap::MAX_DERIVED_POINTS);
assert!(pts.iter().all(|(x, y)| (0.15..=0.85).contains(x) && (0.15..=0.85).contains(y)), "{pts:?}");
// Cropped to its left half, the disc reaches the right edge.
let half = picture_outline(&png(disc.into()), [0.0, 0.0, 0.5, 0.0]).unwrap();
assert!(half.iter().any(|(x, _)| *x > 0.99), "{half:?}");
// No transparency: the rectangle.
assert!(picture_outline(&png(image::RgbImage::new(8, 8).into()), [0.0; 4]).is_none());
}
#[test]
fn hostile_points_are_dropped() {
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
let c = diamond(true);
assert!(c.occupied(f32::NAN, 1.0).is_empty());
assert!(picture_outline(b"not a picture", [0.0; 4]).is_none());
}
}

View File

@ -8,6 +8,7 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod contour;
pub mod display;
pub mod fields;
pub mod hit;
@ -296,6 +297,9 @@ struct Key {
#[derive(Default)]
pub struct LayoutCache {
paras: HashMap<Key, Arc<ParaLayout>>,
/// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the
/// media's bytes (address and length) and crop.
outlines: HashMap<(String, usize, usize, [u32; 4]), Option<Arc<Vec<(f32, f32)>>>>,
env: u64,
used: HashSet<Key>,
pub hits: u64,
@ -314,6 +318,7 @@ impl LayoutCache {
}
pub fn clear(&mut self) {
self.paras.clear();
self.outlines.clear();
}
}
@ -361,6 +366,25 @@ struct Ctx<'a> {
}
impl Ctx<'_> {
/// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]);
/// pictures' are worked out once per picture and crop.
fn wrap_outline(&mut self, o: &InlineObject) -> Option<Arc<Vec<(f32, f32)>>> {
let (doc, cache) = (self.doc, &mut *self.cache);
contour::wrap_outline(o, |media, crop| {
let bytes = doc.media.get(media)?;
let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits));
if let Some(o) = cache.outlines.get(&key) {
return o.clone();
}
if cache.outlines.len() >= 256 {
cache.outlines.clear();
}
let outline = contour::picture_outline(bytes, crop).map(Arc::new);
cache.outlines.insert(key, outline.clone());
outline
})
}
/// A laid-out line number (cached).
fn number_para(&mut self, n: u32) -> Arc<ParaLayout> {
if let Some(p) = self.numbers.get(&n) {
@ -544,7 +568,7 @@ fn layout_box(
// Pictures behind the text go first; `behind` counts them.
let mut behind = 0;
// Wrap areas of the box's floating objects.
let mut excl: Vec<(Rect, bool)> = Vec::new();
let mut excl: Vec<WrapArea> = Vec::new();
// Areas of the box's floating tables, which other floating tables may have to avoid.
let mut float_tables: Vec<Rect> = Vec::new();
let mut y = 0.0f32;
@ -575,7 +599,8 @@ fn layout_box(
continue;
}
let r = float_rect(frame, (0.0, width), y, w, h, float);
excl.extend(wrap_area(r, float));
let outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())));
floats.insert(oi, r);
}
let rel = rel_exclusions(&excl, 0.0, y);
@ -608,7 +633,7 @@ fn layout_box(
Some(f) => {
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables);
excl.push((area, false));
excl.push((area, false, None));
float_tables.push(area);
(r.x, r.y)
}
@ -817,7 +842,7 @@ struct PageBuilder<'a> {
/// The previous paragraph: (style, contextual spacing, space after) for contextual spacing.
prev: Option<(String, bool, f32)>,
/// Wrap areas of floating objects on this page (see [`wrap_area`]).
excl: Vec<(Rect, bool)>,
excl: Vec<WrapArea>,
/// Wrap areas of the floating tables on this page, which others may not overlap.
float_tables: Vec<Rect>,
/// Line numbering counter.
@ -1392,24 +1417,41 @@ fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option<F
}
}
/// An area text keeps clear of: its bounds, whether text may only go above and below it, and
/// (Tight and Through wrapping) the outline text follows inside it.
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>);
/// The area text keeps clear of around a floating object at `r` (its rectangle grown by the
/// distances from text), and whether text may only go above and below it. `None` when text
/// flows over or under the object.
fn wrap_area(r: Rect, float: &Float) -> Option<(Rect, bool)> {
/// distances from text; Tight and Through: around `outline`, unit coordinates across `r`, when
/// it has one), and whether text may only go above and below it. `None` when text flows over
/// or under the object.
fn wrap_area(r: Rect, float: &Float, outline: Option<&[(f32, f32)]>) -> Option<WrapArea> {
if matches!(float.wrap, Wrap::Inline | Wrap::BehindText | Wrap::InFrontOfText) {
return None;
}
if matches!(float.wrap, Wrap::Tight | Wrap::Through)
&& let Some((area, c)) = outline.and_then(|o| contour::contour_area(r, float, o))
{
return Some((area, false, Some(Arc::new(c))));
}
let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
let (side, top, bottom) = (d(float.dist), d(float.dist_top), d(float.dist_bottom));
// A rotated object keeps text clear of its rotated bounds.
let r = float.spin().bounds(r);
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom))
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None))
}
/// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`.
fn rel_exclusions(excl: &[(Rect, bool)], x0: f32, y0: f32) -> Vec<para::Exclusion> {
fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
excl.iter()
.map(|(r, tb)| para::Exclusion { top: r.y - y0, bottom: r.bottom() - y0, left: r.x - x0, right: r.right() - x0, top_bottom: *tb })
.map(|(r, tb, c)| para::Exclusion {
top: r.y - y0,
bottom: r.bottom() - y0,
left: r.x - x0,
right: r.right() - x0,
top_bottom: *tb,
contour: c.clone(),
})
.filter(|e| e.bottom > 0.0)
.collect()
}
@ -1488,7 +1530,8 @@ fn anchor_floats(
}
let r = float_rect(Some(frame), (col_x, width), y0, w, h, float);
rects.insert(oi, r);
if let Some(area) = wrap_area(r, float) {
let outline = ctx.wrap_outline(o);
if let Some(area) = wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())) {
pb.excl.push(area);
}
}
@ -1956,7 +1999,7 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table
}
}
let area = r.inset(-dl, -dt, -dr, -db);
pb.excl.push((area, false));
pb.excl.push((area, false, None));
pb.float_tables.push(area);
return;
}
@ -1987,10 +2030,10 @@ fn below_floats(pb: &mut PageBuilder, x0: f32, x1: f32) {
/// `y`, moved below the wrap areas in `excl` in the way of something spanning `x0..x1` that
/// starts there.
fn below(excl: &[(Rect, bool)], mut y: f32, x0: f32, x1: f32) -> f32 {
fn below(excl: &[WrapArea], mut y: f32, x0: f32, x1: f32) -> f32 {
for _ in 0..64 {
let in_the_way = excl.iter().filter(|(r, _)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0);
let Some(bottom) = in_the_way.map(|(r, _)| r.bottom()).reduce(f32::max) else { break };
let in_the_way = excl.iter().filter(|(r, ..)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0);
let Some(bottom) = in_the_way.map(|(r, ..)| r.bottom()).reduce(f32::max) else { break };
y = bottom;
}
y

View File

@ -293,7 +293,7 @@ pub struct ParaEnv<'a> {
}
/// An area text wraps around.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Exclusion {
pub top: f32,
pub bottom: f32,
@ -301,6 +301,9 @@ pub struct Exclusion {
pub right: f32,
/// Text only above and below (no text beside it).
pub top_bottom: bool,
/// Tight and Through wrapping: the outline text follows inside the area (relative to its
/// top-left); `None` = the whole area.
pub contour: Option<Arc<crate::contour::Contour>>,
}
struct Builder<'a> {
@ -964,7 +967,16 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
let bidi = rtl_para || !pl.bidi_levels.is_empty();
let mirrored: Vec<Exclusion>;
let exclusions: &[Exclusion] = if rtl_para {
mirrored = env.exclusions.iter().map(|e| Exclusion { left: width - e.right, right: width - e.left, ..*e }).collect();
mirrored = env
.exclusions
.iter()
.map(|e| Exclusion {
left: width - e.right,
right: width - e.left,
contour: e.contour.as_ref().map(|c| Arc::new(c.mirrored(e.right - e.left))),
..e.clone()
})
.collect();
&mirrored
} else {
env.exclusions
@ -1538,23 +1550,33 @@ const MIN_SPAN: f32 = 20.0;
/// here, try again at `y` (below what's in the way).
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.bottom > top && e.top < top + h && e.right > lo && e.left < hi).collect();
if here.is_empty() {
return Ok(VecDeque::from([(lo, hi)]));
}
if let Some(below) = here.iter().filter(|e| e.top_bottom).map(|e| e.bottom).reduce(f32::max) {
return Err(below);
}
let mut spans = vec![(lo, hi)];
// What each object takes of the row: its whole area, or (Tight, Through) the stretches its
// outline takes, as (left, right, bottom of the object's area).
let mut taken: Vec<(f32, f32, f32)> = Vec::new();
for e in &here {
match &e.contour {
None => taken.push((e.left, e.right, e.bottom)),
Some(c) => taken.extend(c.occupied(top - e.top, top + h - e.top).into_iter().map(|(a, b)| (a + e.left, b + e.left, e.bottom))),
}
}
taken.retain(|(l, r, _)| *r > lo && *l < hi);
if taken.is_empty() {
return Ok(VecDeque::from([(lo, hi)]));
}
let mut spans = vec![(lo, hi)];
for &(left, right, _) in &taken {
spans = spans
.into_iter()
.flat_map(|(a, b)| if e.right <= a || e.left >= b { vec![(a, b)] } else { vec![(a, e.left.min(b)), (e.right.max(a), b)] })
.flat_map(|(a, b)| if right <= a || left >= b { vec![(a, b)] } else { vec![(a, left.min(b)), (right.max(a), b)] })
.collect();
}
spans.retain(|(a, b)| b - a >= MIN_SPAN);
if spans.is_empty() {
// Nothing wide enough: the line goes below the first object that ends.
return Err(here.iter().map(|e| e.bottom).reduce(f32::min).unwrap_or(top));
return Err(taken.iter().map(|t| t.2).reduce(f32::min).unwrap_or(top));
}
Ok(spans.into())
}

View File

@ -1924,7 +1924,7 @@ fn inline_picture_keeps_room_for_its_effects() {
let mut d = Document::from_text("");
let shadow = wordcraft_doc::para::Float { effect: [6.0, 12.0, 18.0, 27.0], ..Default::default() };
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.insert_object(0, picture(100.0, 50.0, shadow), &Default::default()).unwrap();
p.insert_object(0, picture(100.0, 50.0, shadow.clone()), &Default::default()).unwrap();
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
@ -2126,12 +2126,12 @@ fn floats_in_headers_and_table_cells_are_drawn() {
fn wrap_area_uses_each_distance() {
use wordcraft_doc::para::{Float, Wrap};
let f = Float { wrap: Wrap::Square, dist: 9.0, dist_top: 0.0, dist_bottom: 4.0, ..Default::default() };
let (r, tb) = wrap_area(Rect::new(10.0, 20.0, 100.0, 50.0), &f).unwrap();
let (r, tb, _) = wrap_area(Rect::new(10.0, 20.0, 100.0, 50.0), &f, None).unwrap();
assert_eq!((r.x, r.y, r.w, r.h, tb), (1.0, 20.0, 118.0, 54.0, false));
let hostile = Float { wrap: Wrap::TopAndBottom, dist: f32::NAN, dist_top: -5.0, dist_bottom: f32::INFINITY, ..Default::default() };
let (r, tb) = wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &hostile).unwrap();
let (r, tb, _) = wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &hostile, None).unwrap();
assert!(r.w.is_finite() && r.h.is_finite() && tb, "{r:?}");
assert!(wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &Float { wrap: Wrap::BehindText, ..Default::default() }).is_none());
assert!(wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &Float { wrap: Wrap::BehindText, ..Default::default() }, None).is_none());
}
#[test]
@ -2990,3 +2990,91 @@ fn footnote_longer_than_pages_ends() {
assert!(pieces.len() >= 3 && l.pages.len() == pieces.len(), "{} pages, {pieces:?}", l.pages.len());
assert!(pieces.windows(2).all(|w| w[0].2 == w[1].1 && w[1].0 == w[0].0 + 1), "{pieces:?}");
}
/// The first paragraph's lines on page one: (top, bottom, left, right), relative to its column.
fn line_spans(l: &DocLayout) -> Vec<(f32, f32, f32, f32)> {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { return Vec::new() };
para.lines.iter().map(|ln| (ln.top, ln.top + ln.height, ln.left, ln.right)).collect()
}
/// A shape floating at the column's top-left with `wrap`, beside a long paragraph.
fn wrapped_shape_doc(kind: wordcraft_doc::para::ShapeKind, w: f32, h: f32, float: wordcraft_doc::para::Float) -> Document {
let mut d = Document::from_text(&"Words flow around the shape here. ".repeat(40));
let float =
wordcraft_doc::para::Float { h_rel: wordcraft_doc::para::Anchor::Column, v_rel: wordcraft_doc::para::Anchor::Paragraph, dist: 9.0, ..float };
let shape = InlineObject::Shape {
kind,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
freeform: None,
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
d
}
#[test]
fn tight_wrap_follows_an_ellipse() {
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
let spans = |wrap| line_spans(&lay(&wrapped_shape_doc(ShapeKind::Ellipse, 160.0, 160.0, Float { wrap, ..Default::default() })));
let (square, tight, through) = (spans(Wrap::Square), spans(Wrap::Tight), spans(Wrap::Through));
// Square: every line beside the shape starts past its frame and distance from text.
assert!(square.iter().filter(|l| l.0 < 160.0).all(|l| l.2 >= 169.0 - 0.01), "{square:?}");
// Tight: no line runs into the ellipse (a 160pt circle at 80, 80) or its distance from text.
// Where a line from `top` to `bottom` meets the circle plus that distance: (left, right) edges.
let contour = |top: f32, bottom: f32| {
let y = if bottom < 80.0 {
80.0 - bottom
} else if top > 80.0 {
top - 80.0
} else {
0.0
};
let half = (80.0f32 * 80.0 - y.min(80.0).powi(2)).sqrt();
(80.0 - half - 9.0, 80.0 + half + 9.0)
};
for &(top, bottom, left, right) in tight.iter().filter(|l| l.0 < 160.0) {
let (a, b) = contour(top, bottom);
assert!(right <= a + 1.5 || left >= b - 1.5, "line {top}..{bottom} at {left}..{right} runs into {a}..{b}: {tight:?}");
}
// Lines near the ellipse's top and bottom start at the curve, well inside its frame; beside
// the middle they don't. Measured against each line's own contour edge, so the check holds
// whatever line height the test machine's fonts give.
let line = |pred: &dyn Fn(f32) -> bool| *tight.iter().find(|l| pred(l.0) && l.2 > 60.0).unwrap();
for (what, l) in [("first line", line(&|top| top < 1.0)), ("near the bottom", line(&|top| top > 140.0 && top < 160.0))] {
let edge = contour(l.0, l.1).1;
assert!(l.2 <= edge + 3.0 && l.2 < 169.0 - 10.0, "{what} hugs the curve at {edge}: {l:?} in {tight:?}");
}
assert!(line(&|top| top > 65.0 && top < 80.0).2 >= 160.0, "beside the widest part: {tight:?}");
// A convex shape has no gaps for Through to use: it wraps as Tight.
assert_eq!(through, tight);
}
#[test]
fn contour_wrap_turns_with_the_object() {
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
use wordcraft_doc::wrap::WrapPolygon;
// A wrap polygon covering the left half of a 200 × 100 frame.
let poly = WrapPolygon::new(true, [(0, 0), (10_800, 0), (10_800, 21_600), (0, 21_600)]).map(Arc::new);
// Where text goes on the first row: (left, right) of each line there.
let first_row = |wrap: Wrap, rot: f32, flip_h: bool| {
let f = Float { wrap, rot, flip_h, wrap_polygon: poly.clone(), ..Default::default() };
let l = line_spans(&lay(&wrapped_shape_doc(ShapeKind::Rectangle, 200.0, 100.0, f)));
l.iter().filter(|s| s.0 < 1.0).map(|s| (s.2.round(), s.3.round())).collect::<Vec<_>>()
};
// Unturned, text comes up to the polygon (half the frame) plus the distance from text.
assert_eq!(first_row(Wrap::Tight, 0.0, false)[0].0, 109.0);
// Turned half a turn or mirrored, the polygon covers the frame's right half: text goes to
// its left and past the frame.
for (rot, flip) in [(180.0, false), (0.0, true)] {
let row = first_row(Wrap::Tight, rot, flip);
assert_eq!((row[0].0, row[0].1, row[1].0), (0.0, 91.0, 209.0), "{rot} {flip}");
}
// Square wrapping ignores the polygon.
assert_eq!(first_row(Wrap::Square, 0.0, false)[0].0, 209.0);
}

View File

@ -55,7 +55,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result |
| Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written, laid out as square |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle) |
| Group shapes (`wpg:`), drawing canvas | 🟡 | 🟡 | Group shapes read and written (#267); drawing canvas (`wpc:`) dropped |
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds |
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
@ -88,6 +88,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
| 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded |
| 2026-10-10 | trivial | Wrap polygons for Tight and Through wrapping read and written (#354) |
| 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) |
| 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) |
| 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) |

View File

@ -20,7 +20,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332), contour (tight/through) wrap, shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap landed, #354), shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) |
| 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) |
@ -77,6 +77,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain |
| 2026-10-10 | trivial | #4: long footnotes continue onto the next page (#352) |
| 2026-10-10 | trivial | Define New Multilevel List and Track Changes Options dialogs landed (#328); removed from the dialog-depth list |
| 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) |
| 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright |
| 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) |
| 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) |

View File

@ -38,7 +38,7 @@ font can't match Word's metrics.
| Nested tables | 🟡 | render; polish missing | 2–4 |
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — |
| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — |
| Text wrap: tight, through (contour, wrap polygon) | ❌ | laid out as square | 6–10 |
| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Text always wraps on both sides (Word's left/right/largest-side choice isn't modelled); picture outlines are convex; not compared page-for-page with Word | 1–2 |
| Text boxes, overflow, linked text boxes | 🟡 | #46; linked text boxes missing | 3–5 |
| Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 |
| Page borders (from page edge or text), page colour, watermark | ✅ | | — |
@ -70,6 +70,7 @@ font can't match Word's metrics.
|---|---|---|
| 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) |
| 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) |
| 2026-10-10 | trivial | Tight and Through wrapping follow the object's outline (#354) |
| 2026-10-10 | trivial | Rotated and flipped objects laid out and drawn (#332) |
| 2026-10-10 | trivial | Long display equations wrap across lines (#326) |
| 2026-10-10 | trivial | AutoFit Contents measures the text (#44) |

View File

@ -55,7 +55,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
| M8 | View | mostly done; window commands missing | 3–5 h |
| M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h |
| M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h |
| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307) done; lasso and ink conversion, grouping, contour wrap missing | 35–55 h |
| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354) done; lasso and ink conversion, grouping missing | 35–55 h |
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h |
| M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h |
| M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — |
@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) |
| 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) |
| 2026-10-11 | trivial | M7: formatting revisions landed (#41) |
| 2026-10-10 | trivial | M5: Draw Table and Eraser landed (#303) |