From 116b3b4ec98b7790903ed9d734ef961a6298dcc3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 22:07:43 +0000 Subject: [PATCH] Text frames: lines follow an oval or polygon frame's shape (#646) --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 67 +++++++++--- crates/compose/src/notes.rs | 1 + crates/compose/src/outline.rs | 197 ++++++++++++++++++++++++++++++++++ crates/compose/src/table.rs | 1 + crates/compose/src/tests.rs | 173 +++++++++++++++++++++++++++++ docs/typography-parity.md | 1 + 7 files changed, 428 insertions(+), 13 deletions(-) create mode 100644 crates/compose/src/outline.rs diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..7c48ff678 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text in oval, polygon and other non-rectangular text frames follows the frame's outline: each line is set in the part of its band inside the shape, one inset away from the outline, in every column and in vertical frames; text wrap still applies on top (#646). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..7590681f3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -15,6 +15,7 @@ pub mod breaker; mod cache; pub mod hyphen; mod notes; +mod outline; mod overlay; mod ruby; pub mod shape; @@ -39,6 +40,7 @@ use crate::breaker::{Break, Spacing}; pub use crate::cache::Cache; use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, note_caret}; +pub use crate::outline::FrameOutline; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell}; @@ -302,6 +304,9 @@ pub struct FrameSpec { /// area is the turned box. pub vertical: bool, pub exclusions: Vec, + /// The frame's outline when it isn't its rectangle (area space): each line's slot is the part + /// of its band the outline covers, minus the inset. + pub outline: Option>, pub page_name: Option, /// Absolute document page the frame is on (None on parent pages). pub page: Option, @@ -421,15 +426,25 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { } None => (item.text_area(), tf.options.clone()), }; + // A non-rectangular frame keeps the top inset inward from its outline; its rectangular text + // area (all four insets) still bounds the lines. + let outline = match &tf.options.path { + Some(_) => None, + None => { + let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) }; + FrameOutline::new(&bp, tf.options.inset[0]) + } + }; // Vertical type: composed in the turned box; wraps and page rects turned with it. let vertical = doc.frame_vertical(item); - let (area, exclusions, grid, page_rect) = if vertical { + let (area, exclusions, grid, page_rect, outline) = if vertical { let v = designcraft_doc::vertical_text_xf(area).inverse(); let ex = exclusions.into_iter().map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), ..e }).collect(); let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b))); - (Rect::new(0.0, 0.0, area.height(), area.width()), ex, None, pr) + let outline = outline.map(|o| o.transformed(v)); + (Rect::new(0.0, 0.0, area.height(), area.width()), ex, None, pr, outline) } else { - (area, exclusions, grid, page_rect) + (area, exclusions, grid, page_rect, outline) }; out.push(FrameSpec { id: fid, @@ -437,6 +452,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { opts, vertical, exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, + outline: outline.map(Arc::new), page_name, page, grid, @@ -887,8 +903,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; let col = cur.split_rect(col); // Estimate slots for the breaker with the paragraph's base leading. - let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); - let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); + // A shaped frame's slots change from line to line, so they are estimated with the + // band the lines are placed with (or a taller one, so no line outgrows its slot). + let (est_asc, est_band) = if f.outline.is_some() { + let (a, d, _) = line_metrics(&[], &glyphs, g0, base_leading, base_chars.size, db, &base_chars); + (a, (a.max(base_chars.size * 0.8), d.max(base_chars.size * 0.25))) + } else { + (base_chars.size * 0.75, (base_chars.size * 0.8, base_chars.size * 0.25)) + }; + let est_first = cur.next_baseline(f, col, base_leading, est_asc, &pp); + let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, est_band, &glyphs[g0..], &pp, line_no); let width = |j: usize| -> f64 { let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 }; @@ -929,7 +953,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // Wrap: push the line down until a slot exists. let (mut x0, mut x1) = (col.x0, col.x1); - if !f.exclusions.is_empty() { + if !f.exclusions.is_empty() || f.outline.is_some() { let mut tries = 0; loop { match free_slot(f, col, baseline - asc, baseline + desc, base_size) { @@ -939,9 +963,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C break; } None => { + let prev = baseline; baseline += 1.0; tries += 1; - if baseline > col.y1 || tries > 4000 { + if baseline > col.y1 || tries > 4000 || baseline == prev || !baseline.is_finite() { break; } } @@ -2071,9 +2096,13 @@ fn line_metrics( (asc, desc, lead) } -/// Widest free horizontal interval of `col` in the band, or None if blocked. +/// Widest free horizontal interval of `col` in the band (inside the frame's outline, outside +/// wraps), or None if blocked. fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { - let mut free = vec![(col.x0, col.x1)]; + let mut free = match &f.outline { + Some(o) => o.intervals(col.x0, col.x1, y0, y1), + None => vec![(col.x0, col.x1)], + }; for ex in &f.exclusions { let r = ex.rect; let overlaps_band = r.y0 < y1 && r.y1 > y0; @@ -2103,9 +2132,19 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f } #[allow(clippy::too_many_arguments)] -fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> { +fn estimate_slots( + f: &FrameSpec, + col: Rect, + first: f64, + lead: f64, + size: f64, + band: (f64, f64), + glyphs: &[Glyph], + pp: &ParaProps, + line_no: usize, +) -> Vec<(f64, f64)> { let _ = (pp, line_no); - if f.exclusions.is_empty() { + if f.exclusions.is_empty() && f.outline.is_none() { return vec![]; } // Rough upper bound on lines: total advance / column width × 2. @@ -2116,12 +2155,14 @@ fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, gl for _ in 0..n { let mut tries = 0; let slot = loop { - if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) { + if let Some(s) = free_slot(f, col, b - band.0, b + band.1, size) { break s; } + let prev = b; b += 1.0; tries += 1; - if b > col.y1 || tries > 4000 { + // `b == prev`: coordinates so large that a point no longer moves the band. + if b > col.y1 || tries > 4000 || b == prev || !b.is_finite() { break (col.x0, col.x1); } }; diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..c52419515 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -101,6 +101,7 @@ impl Notes { opts: TextFrameOptions { first_baseline: o.first_baseline, first_baseline_min: o.first_baseline_min, ..Default::default() }, vertical: false, exclusions: vec![], + outline: None, page_name: f.page_name.clone(), page: f.page, grid: None, diff --git a/crates/compose/src/outline.rs b/crates/compose/src/outline.rs new file mode 100644 index 000000000..bfc709710 --- /dev/null +++ b/crates/compose/src/outline.rs @@ -0,0 +1,197 @@ +//! The outline of a non-rectangular text frame (ovals, polygons, corner options, Pathfinder +//! results, freeform closed paths): lines are set in the part of each line band the outline covers. + +use designcraft_geom::{Affine, BezPath, PathEl, Point}; + +/// Flattening tolerance (points) for curved frame outlines. +const TOLERANCE: f64 = 0.1; +/// Most edges a flattened outline may have; a larger one is flattened coarser. +const MAX_EDGES: usize = 4096; +/// Scanlines sampled across each inset cap above and below a band. +const CAP_SAMPLES: usize = 6; + +/// A closed frame outline flattened to straight edges, in the frame spec's area space. +#[derive(Clone, Debug, PartialEq)] +pub struct FrameOutline { + edges: Vec<(Point, Point)>, + /// Distance kept between text and the outline. + inset: f64, +} + +impl FrameOutline { + /// The outline of `bp` (closed subpaths only), or None when it is its own axis-aligned + /// rectangle (or has no area), so the frame keeps its rectangular text area. + pub fn new(bp: &BezPath, inset: f64) -> Option { + let bb = designcraft_geom::Shape::bounding_box(bp); + let extent = bb.width().max(bb.height()); + if !extent.is_finite() { + return None; + } + // Huge outlines are flattened relative to their size, so the edge count stays bounded. + let mut tol = TOLERANCE.max(extent * 1e-6); + let edges = loop { + let (edges, over) = flatten_closed(bp, tol); + if !over && edges.len() <= MAX_EDGES { + break edges; + } + tol *= 4.0; + if tol > 64.0 { + return None; + } + }; + if edges.is_empty() || edges.iter().any(|(a, b)| !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite())) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for (a, _) in &edges { + x0 = x0.min(a.x); + y0 = y0.min(a.y); + x1 = x1.max(a.x); + y1 = y1.max(a.y); + } + let eps = 1e-6 * (x1 - x0).abs().max((y1 - y0).abs()).max(1.0); + let on_box = |p: Point| (p.x - x0).abs() < eps || (p.x - x1).abs() < eps || (p.y - y0).abs() < eps || (p.y - y1).abs() < eps; + let rect = edges.iter().all(|&(a, b)| ((a.x - b.x).abs() < eps || (a.y - b.y).abs() < eps) && on_box(a) && on_box(b)); + if rect { + return None; + } + let inset = if inset.is_finite() { inset.max(0.0) } else { 0.0 }; + Some(FrameOutline { edges, inset }) + } + + /// The outline mapped by `xf` (into a vertical frame's turned box). + pub fn transformed(&self, xf: Affine) -> FrameOutline { + FrameOutline { edges: self.edges.iter().map(|&(a, b)| (xf * a, xf * b)).collect(), inset: self.inset } + } + + /// The parts of `[x0, x1]` inside the outline over the whole band `[y0, y1]`, at least the + /// inset away from it: the inside intervals at every height of the band are intersected, and + /// the inset is kept by testing the heights within the inset above and below the band, each + /// shrunk by the half-chord of an inset-radius circle there. + pub fn intervals(&self, x0: f64, x1: f64, y0: f64, y1: f64) -> Vec<(f64, f64)> { + let d = self.inset; + if !(y0.is_finite() && y1.is_finite() && x0.is_finite() && x1.is_finite()) || y1 < y0 { + return vec![]; + } + let (lo, hi) = (y0 - d, y1 + d); + let near: Vec<(Point, Point)> = self.edges.iter().copied().filter(|(a, b)| a.y.max(b.y) >= lo && a.y.min(b.y) <= hi).collect(); + // Heights where an edge ends (the inside intervals change linearly between them). + let mut ys: Vec = vec![y0, y1]; + for (a, _) in &near { + if a.y > lo && a.y < hi { + ys.push(a.y); + } + } + if d > 0.0 { + for k in 0..=CAP_SAMPLES { + let t = k as f64 / CAP_SAMPLES as f64; + ys.push(lo + t * d); + ys.push(y1 + t * d); + } + } + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + // Midpoints catch spans that close between two sampled heights. + let mids: Vec = ys.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect(); + ys.extend(mids); + let mut free = vec![(x0, x1)]; + for y in ys { + let off = y0 - y; + let off = if off > 0.0 { off } else { (y - y1).max(0.0) }; + let shrink = (d * d - off * off).max(0.0).sqrt(); + let row: Vec<(f64, f64)> = inside_at(&near, y).into_iter().map(|(a, b)| (a + shrink, b - shrink)).filter(|(a, b)| b > a).collect(); + free = intersect(&free, &row); + if free.is_empty() { + break; + } + } + free + } +} + +/// Inside intervals of the scanline at `y` through `edges` (non-zero winding, as the frame is +/// filled). +fn inside_at(edges: &[(Point, Point)], y: f64) -> Vec<(f64, f64)> { + let mut xs: Vec<(f64, i32)> = Vec::new(); + for &(a, b) in edges { + if (a.y <= y) != (b.y <= y) { + let t = (y - a.y) / (b.y - a.y); + let x = a.x + t * (b.x - a.x); + if x.is_finite() { + xs.push((x, if b.y > a.y { 1 } else { -1 })); + } + } + } + xs.sort_by(|p, q| p.0.total_cmp(&q.0)); + let mut out = Vec::new(); + let mut wind = 0; + let mut start = 0.0; + for (x, w) in xs { + let was = wind != 0; + wind += w; + match (was, wind != 0) { + (false, true) => start = x, + (true, false) if x > start => out.push((start, x)), + _ => {} + } + } + out +} + +/// Intersection of two sorted lists of disjoint intervals. +fn intersect(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + let (mut i, mut j) = (0, 0); + while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) { + let (s, e) = (a0.max(b0), a1.min(b1)); + if e > s { + out.push((s, e)); + } + if a1 < b1 { + i += 1; + } else { + j += 1; + } + } + out +} + +/// The closed subpaths of `bp` as straight edges (each subpath closed back to its start), and +/// whether flattening stopped at the edge limit. +fn flatten_closed(bp: &BezPath, tol: f64) -> (Vec<(Point, Point)>, bool) { + let mut edges = Vec::new(); + let mut sub: Vec = Vec::new(); + let mut closed = false; + let finish = |sub: &mut Vec, closed: bool, edges: &mut Vec<(Point, Point)>| { + if closed && sub.len() >= 3 { + for w in sub.windows(2) { + edges.push((w[0], w[1])); + } + if let (Some(&l), Some(&f)) = (sub.last(), sub.first()) { + edges.push((l, f)); + } + } + sub.clear(); + }; + let mut over = false; + designcraft_geom::kurbo::flatten(bp.iter(), tol, |el| { + if over { + return; + } + match el { + PathEl::MoveTo(p) => { + finish(&mut sub, closed, &mut edges); + closed = false; + sub.push(p); + } + PathEl::LineTo(p) => sub.push(p), + PathEl::ClosePath => closed = true, + _ => {} + } + if sub.len() + edges.len() > MAX_EDGES * 4 { + over = true; + } + }); + finish(&mut sub, closed, &mut edges); + (edges, over) +} diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c70f477be..43f3bbfe9 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -111,6 +111,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left opts: TextFrameOptions::default(), vertical: false, exclusions: vec![], + outline: None, page_name: opts.page_name.clone(), page: opts.page, grid: None, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..ead776f4a 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2788,3 +2788,176 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +fn shaped_doc(path: designcraft_geom::PathData, rect: Rect, inset: f64) -> (Document, StoryId, ItemId) { + let (mut d, sid, fid) = doc_with(&[LOREM; 4].join(" "), rect, ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + it.path = path; + it.text_frame_mut().unwrap().options.inset = [inset; 4]; + (d, sid, fid) +} + +/// The corners of a line's band (slot ends from its top to its bottom). +fn line_corners(l: &Line) -> [(f64, f64); 4] { + let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent); + [(l.x0, t), (l.x1, t), (l.x0, b), (l.x1, b)] +} + +/// Is `p` inside the ellipse in `r` (allowing for the Bézier bulge)? +fn in_ellipse(r: Rect, p: (f64, f64)) -> bool { + let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); + ((p.0 - c.x) / a).powi(2) + ((p.1 - c.y) / b).powi(2) <= 1.001 +} + +/// Distance from `p` inside the ellipse in `r` to its outline (sampled). +fn ellipse_clearance(r: Rect, p: (f64, f64)) -> f64 { + let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); + let inside = ((p.0 - c.x) / a).powi(2) + ((p.1 - c.y) / b).powi(2) <= 1.0 + 1e-9; + let d = (0..7200) + .map(|k| { + let t = k as f64 / 7200.0 * std::f64::consts::TAU; + (c.x + a * t.cos() - p.0).hypot(c.y + b * t.sin() - p.1) + }) + .fold(f64::INFINITY, f64::min); + if inside { d } else { -d } +} + +#[test] +fn oval_frame_sets_lines_inside_the_ellipse() { + let r = Rect::new(0.0, 0.0, 300.0, 200.0); + let (d, sid, _) = shaped_doc(designcraft_geom::shapes::ellipse(r), r, 0.0); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() >= 8, "{}", lines.len()); + for l in &lines { + for p in line_corners(l) { + assert!(in_ellipse(r, p), "line corner {p:?} outside the oval"); + } + assert!(l.end_x <= l.x1 + 0.5, "line overflows its slot: {} > {}", l.end_x, l.x1); + } + let w = |l: &Line| l.x1 - l.x0; + let widest = lines.iter().map(|l| w(l)).fold(0.0, f64::max); + assert!(w(lines[0]) < widest - 50.0, "first {} widest {widest}", w(lines[0])); + // Bands where the oval is narrower than 1.5 em hold no line. + assert!(lines.iter().all(|l| w(l) >= 18.0 - 1e-6)); +} + +#[test] +fn oval_frame_keeps_lines_an_inset_inside_the_outline() { + let r = Rect::new(0.0, 0.0, 300.0, 240.0); + let (d, sid, _) = shaped_doc(designcraft_geom::shapes::ellipse(r), r, 10.0); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() >= 6, "{}", lines.len()); + for l in &lines { + for p in line_corners(l) { + assert!(ellipse_clearance(r, p) >= 10.0 - 0.1, "line corner {p:?} closer than the inset: {}", ellipse_clearance(r, p)); + } + } +} + +#[test] +fn triangle_frame_lines_widen_downward() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + let r = Rect::new(0.0, 0.0, 300.0, 300.0); + let tri = [Point::new(150.0, 0.0), Point::new(300.0, 300.0), Point::new(0.0, 300.0)]; + let path = PathData::single(SubPath { anchors: tri.iter().map(|&p| Anchor::corner(p)).collect(), closed: true }); + let (d, sid, _) = shaped_doc(path, r, 0.0); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() >= 8, "{}", lines.len()); + for w in lines.windows(2) { + assert!(w[1].x1 - w[1].x0 > w[0].x1 - w[0].x0, "{} then {}", w[0].x1 - w[0].x0, w[1].x1 - w[1].x0); + } + // Every slot end lies inside the triangle: |x - 150| ≤ y / 2 over the whole band. + for l in &lines { + for (x, y) in line_corners(l) { + assert!((x - 150.0).abs() <= y / 2.0 + 1e-6, "({x}, {y}) outside the triangle"); + } + } +} + +#[test] +fn rectangular_frame_paths_keep_the_rectangular_layout() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + let r = Rect::new(20.0, 30.0, 260.0, 400.0); + let (d, sid, _) = doc_with(&[LOREM; 4].join(" "), r, ParaAttrs::default()); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + // The same rectangle drawn with an extra anchor on its top edge. + let pts = [(20.0, 30.0), (100.0, 30.0), (260.0, 30.0), (260.0, 400.0), (20.0, 400.0)]; + let path = PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }); + let (d2, sid2, _) = shaped_doc(path, r, 0.0); + assert!(frame_specs(&d2, sid2)[0].outline.is_none()); + assert!(frame_specs(&d, sid)[0].outline.is_none()); + let redrawn = compose_story(&d2, sid2, &ComposeOptions::default()); + let key = |cs: &ComposedStory| all_lines(cs).iter().map(|l| (l.range.clone(), l.baseline, l.x0, l.x1)).collect::>(); + assert_eq!(key(&plain), key(&redrawn)); +} + +#[test] +fn resizing_an_oval_frame_reflows_its_lines() { + let r = Rect::new(0.0, 0.0, 240.0, 200.0); + let (mut d, sid, fid) = shaped_doc(designcraft_geom::shapes::ellipse(r), r, 0.0); + let cache = Cache::new(); + let small = cache.get(&d, sid, None); + let big_r = Rect::new(0.0, 0.0, 400.0, 300.0); + d.item_mut(fid).unwrap().path = designcraft_geom::shapes::ellipse(big_r); + let big = cache.get(&d, sid, None); + let (ls, lb) = (all_lines(&small), all_lines(&big)); + assert!(lb.len() > ls.len(), "{} vs {}", lb.len(), ls.len()); + for l in &lb { + for p in line_corners(l) { + assert!(in_ellipse(big_r, p), "line corner {p:?} outside the resized oval"); + } + } + assert!(lb.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max) > 300.0); +} + +#[test] +fn vertical_oval_frame_sets_columns_inside_the_turned_ellipse() { + let r = Rect::new(0.0, 0.0, 300.0, 200.0); + let (mut d, sid, _) = shaped_doc(designcraft_geom::shapes::ellipse(r), r, 0.0); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(!lines.is_empty()); + // The turned box is 200 wide and 300 tall; the ellipse turns with it. + let turned = Rect::new(0.0, 0.0, 200.0, 300.0); + for l in &lines { + for p in line_corners(l) { + assert!(in_ellipse(turned, p), "line corner {p:?} outside the turned oval"); + } + } +} + +#[test] +fn oval_frame_columns_each_follow_the_outline() { + let r = Rect::new(0.0, 0.0, 400.0, 300.0); + let (mut d, sid, fid) = shaped_doc(designcraft_geom::shapes::ellipse(r), r, 0.0); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.iter().any(|l| l.column == 1)); + for l in &lines { + for p in line_corners(l) { + assert!(in_ellipse(r, p), "line corner {p:?} outside the oval"); + } + } +} + +#[test] +fn degenerate_frame_outlines_compose_without_panicking() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + let tri = |pts: [(f64, f64); 3]| { + PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }) + }; + let r = Rect::new(0.0, 0.0, 200.0, 200.0); + for path in [ + tri([(0.0, 0.0), (100.0, 100.0), (200.0, 200.0)]), + tri([(0.0, 0.0), (f64::NAN, 100.0), (200.0, 200.0)]), + designcraft_geom::shapes::ellipse(Rect::new(-1e15, -1e15, 1e15, 1e15)), + ] { + let (d, sid, _) = shaped_doc(path, r, 1e300); + let _ = compose_story(&d, sid, &ComposeOptions::default()); + } +} diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..dd5c5fd19 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -47,6 +47,7 @@ Fidelity: **unmeasured** unless stated. | Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits | | Baseline grid | document and frame grids | ✓ | ✓ | | | Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | | +| Text in shaped frames | lines follow oval, polygon and freeform frame outlines, one inset from the outline | ✓ | ~ | concave shapes set one slot per line (the widest); vertical justification and paragraph shading use the frame's rectangle (#646) | | Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) | | Column rules | ✓ | ✓ (screen, PDF, IDML) | ~ | no stroke types or overprint (#274) | | Keep options and breaks | keep with next, lines together, start in next column/frame/page/odd/even | ✓ | ✓ | break characters end paragraphs (#285); the line before a break is a last line (#283) |