Files
wordcraft/crates/layout/src/contour.rs
T
2026-10-25 21:01:51 -04:00

394 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::collections::{HashMap, HashSet};
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, extra, .. } => shape_outline(*kind, w, h, extra.corner).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;
/// Most picture pixels decoded for their transparency in one layout. Every picture a document
/// wraps tightly is decoded in full whatever its crop; past this, pictures wrap as their
/// rectangles. Room for four pictures of the largest size looked at, or hundreds of photos.
pub(crate) const MAX_DECODED_PIXELS: u64 = 1 << 28;
/// A picture's transparency: one alpha byte per pixel, row by row.
struct Alpha {
w: u32,
h: u32,
a: Vec<u8>,
}
impl Alpha {
fn opaque(&self, x: u32, y: u32) -> bool {
x < self.w
&& (y as usize)
.checked_mul(self.w as usize)
.and_then(|r| r.checked_add(x as usize))
.and_then(|i| self.a.get(i))
.is_some_and(|a| *a >= 128)
}
}
/// A picture whose transparency would take its layout past [`MAX_DECODED_PIXELS`]: it wraps
/// as its rectangle in this layout only.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct OverBudget;
/// Pictures' transparency decoded during one layout. A picture shown with many crops (each an
/// outline of its own) is decoded once, however its crops interleave with other pictures', and
/// decoding stops at [`MAX_DECODED_PIXELS`] (which also bounds what is kept).
#[derive(Default)]
pub(crate) struct Alphas {
/// Pictures decoded so far, by their bytes (address and length): their transparency (`None`: none).
///
/// The address tells pictures apart only while their bytes can't be freed and the address
/// reused for other bytes of the same length: an `Alphas` lives for one layout pass (in its
/// `Ctx`), and the document whose media hold the bytes is borrowed for the whole pass, so no
/// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to
/// the bytes, as [`crate::LayoutCache`]'s outlines do.
seen: HashMap<(usize, usize), Option<Arc<Alpha>>>,
/// Pictures refused for the budget (bytes' address and length), not looked at again.
refused: HashSet<(usize, usize)>,
/// Pixels decoded so far.
pub(crate) decoded: u64,
}
impl Alphas {
/// The outline of picture `bytes`'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), and [`OverBudget`] past the budget: their rectangle
/// is their outline.
pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Result<Option<Vec<(f32, f32)>>, OverBudget> {
let key = (bytes.as_ptr() as usize, bytes.len());
let alpha = match self.seen.get(&key) {
Some(alpha) => alpha.clone(),
None if self.refused.contains(&key) => return Err(OverBudget),
None => {
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
self.refused.insert(key);
})?;
let alpha = alpha.map(Arc::new);
self.seen.insert(key, alpha.clone());
alpha
}
};
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
}
}
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
use image::ImageDecoder;
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
// Formats that can't be transparent aren't decoded.
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return Ok(None);
}
// Only the header is read here (a PNG's `tRNS` counts as transparency).
let has_alpha =
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
if !has_alpha {
return Ok(None);
}
let pixels = u64::from(pic.width) * u64::from(pic.height);
*decoded = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS).ok_or(OverBudget)?;
let Ok(img) = pic.decode(wordcraft_geom::MAX_DECODED_BYTES) else { return Ok(None) };
let img = img.into_rgba8();
let (w, h) = img.dimensions();
Ok(Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() }))
}
/// The outline of `img`'s opaque pixels within `crop`: see [`Alphas::outline`].
fn alpha_in_crop(img: &Alpha, crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
let (iw, ih) = (img.w, img.h);
// The part the renderers draw (#1899): a sliver keeps its partly covered pixels.
let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop);
let (fw, fh) = (f64::from(iw), f64::from(ih));
let x0 = ((fw * cl) as u32).min(iw);
let y0 = ((fh * ct) as u32).min(ih);
let x1 = ((fw * (cl + vw)).ceil() as u32).clamp(x0, iw);
let y1 = ((fh * (ct + vh)).ceil() 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.opaque(x, y)))
};
let pts = alpha_outline(gw as usize, gh as usize, opaque)?;
// Grid points onto the picture as the renderers draw the window, whose edges fall within
// pixels (crop fractions are f32 too: 0.7 is 0.69999…). The grid may reach a little past the
// pixels (its last cells are partial).
let place =
|g: f32, cells: u32, p0: u32, lo: f64, len: f64| ((f64::from(p0) + f64::from(g) * f64::from(cells * cell) - lo) / len).clamp(0.0, 1.0) as f32;
Some(pts.into_iter().map(|(x, y)| (place(x, gw, x0, fw * cl, fw * vw), place(y, gh, y0, fh * ct, fh * vh))).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
Alphas::default().outline(bytes, crop).ok().flatten()
}
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());
// #1899: the sliver the renderers draw (0.5% of the axis, when two sides overlap or keep
// less) has an outline too. Only the bottom-right quarter is opaque, so a column or a row
// through it is opaque in its second half.
let quarter = png(image::RgbaImage::from_fn(100, 100, |x, y| image::Rgba([0, 0, 0, if x >= 50 && y >= 50 { 255 } else { 0 }])).into());
for (crop, column) in [([0.6, 0.0, 0.6, 0.0], true), ([0.6, 0.0, 0.398, 0.0], true), ([0.0, 0.6, 0.0, 0.6], false)] {
let pts = picture_outline(&quarter, crop).unwrap_or_default();
let across = |p: &(f32, f32)| if column { p.1 } else { p.0 };
// The grid's cells are 3 pixels, so the half starts a little before 0.5.
assert!(pts.len() >= 3 && pts.iter().all(|p| across(p) > 0.4), "{crop:?}: {pts:?}");
}
// Crop fractions are f32 (0.7 is 0.69999…), so an edge on a whole pixel comes out a hair
// off it: the outline neither takes in the neighbouring column nor drops the last one. Only
// the top half of the 30 columns each crop keeps (of 100) is opaque, so it spans 0–1 across.
for (crop, kept) in [([0.7, 0.0, 0.0, 0.0], 70..100), ([0.0, 0.0, 0.7, 0.0], 0..30)] {
let img = image::RgbaImage::from_fn(100, 20, |x, y| image::Rgba([0, 0, 0, if kept.contains(&x) && y < 10 { 255 } else { 0 }]));
let pts = picture_outline(&png(img.into()), crop).unwrap_or_default();
let xs = pts.iter().map(|p| p.0);
let (lo, hi) = (xs.clone().fold(f32::MAX, f32::min), xs.fold(f32::MIN, f32::max));
assert!(lo < 1e-6 && hi > 1.0 - 1e-6, "{crop:?}: {pts:?}");
}
// A window whose edges fall within pixels (2.5–5.5 of 10) is placed as the renderers draw
// it: pixel 2, the only opaque one (in half the rows), covers the first sixth of the
// picture, not a quarter (whole pixels 2–5 stretched over it). Across and down alike.
for down in [false, true] {
let img = image::RgbaImage::from_fn(10, 10, |x, y| {
let (a, b) = if down { (y, x) } else { (x, y) };
image::Rgba([0, 0, 0, if a == 2 && b < 5 { 255 } else { 0 }])
});
let crop = if down { [0.0, 0.25, 0.0, 0.45] } else { [0.25, 0.0, 0.45, 0.0] };
let pts = picture_outline(&png(img.into()), crop).unwrap_or_default();
let far = pts.iter().map(|p| if down { p.1 } else { p.0 }).fold(f32::MIN, f32::max);
assert!(pts.len() >= 3 && (far - 1.0 / 6.0).abs() < 1e-3, "{crop:?}: {pts:?}");
}
}
#[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());
}
}