diff --git a/Cargo.lock b/Cargo.lock index ae811dc..0525589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5141,6 +5141,7 @@ dependencies = [ name = "wordcraft-layout" version = "0.5.0" dependencies = [ + "image", "js-sys", "log", "serde", diff --git a/ROADMAP.md b/ROADMAP.md index 5366d10..1b94f93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9026f81..7f58dad 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 6cd5a9f..660b6d9 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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>, } fn is_zero(v: &f32) -> bool { diff --git a/crates/doc/src/wrap.rs b/crates/doc/src/wrap.rs new file mode 100644 index 0000000..d4abb74 --- /dev/null +++ b/crates/doc/src/wrap.rs @@ -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) -> Option { + 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 { + 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> { + 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> { + 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> = 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); + } +} diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 868693e..3a2b6ce 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -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 { + 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()) diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index bca25b8..67f6482 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -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); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index ced2542..d910a07 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -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); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 09aca3a..26a5599 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -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#""#), "{xml}"); + assert!(xml.contains(r#""#), "closed: {xml}"); + let r = wordcraft_docx::read(&bytes).expect("read"); + let got = paras(&r)[0]; + let floats: Vec = 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()); +} diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index bbd5f0e..478cfb6 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -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 = 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 diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index a97175f..913b430 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -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] diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs new file mode 100644 index 0000000..a306ca1 --- /dev/null +++ b/crates/layout/src/contour.rs @@ -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 = 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>>) -> Option>> { + 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> { + 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()); + } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f857318..3a03d7a 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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>, + /// 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>>>, env: u64, used: HashSet, 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>> { + 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 { 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 = Vec::new(); // Areas of the box's floating tables, which other floating tables may have to avoid. let mut float_tables: Vec = 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, /// Wrap areas of the floating tables on this page, which others may not overlap. float_tables: Vec, /// Line numbering counter. @@ -1392,24 +1417,41 @@ fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option>); + /// 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 { 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 { +fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec { 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 diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 4834ad5..7b56362 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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>, } 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; 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, 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()) } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index f38d4ef..e2333cc 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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::>() + }; + // 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); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a7f3c42..4c30c10 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index 2155231..ae4bf04 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1e4d7d7..d6251a3 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8fb9702..3a6dc83 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) |