Merge shaped-text-frames: Text frames: lines follow an oval or polygon frame's shape (#646)

Conflict: crates/compose/src/lib.rs: modules and exports both (outline, optical, parabox, hit_note_with); placement = next's carry/grid/wrap_slot code, entered for outlines too (and the first-word `need`); free_slots starts from the outline's intervals with cut set (1.5 em filter, segments in reading order, reversed for RTL); the branch's free_slot dropped
Conflict: crates/compose/src/lib.rs: next's bands are top..baseline (descenders hang into wraps); a shaped frame's outline holds the descenders too: wrap_slot and free_slots take the line's descent, used only for the outline's intervals (the branch's oval tests check descender corners)
Conflict: crates/compose/src/lib.rs: estimate_slots takes the band (the paragraph's ascent from line_metrics, max 0.8 em, and descent, max 0.25 em, for a shaped frame; next's 0.8 em and no descent otherwise), returns early only without wraps and outline; est_first uses that ascent; wrap_slot's 1 pt step for outlines (and contours) returns None when it makes no progress or is not finite
Conflict: crates/compose/src/tests.rs: tests both
Conflict: ROADMAP.md: logs both
This commit is contained in:
aiongg
2026-10-12 22:23:22 +00:00
7 changed files with 431 additions and 19 deletions
+1
View File
@@ -128,6 +128,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** · Colour entry: the Color Picker's hex, R/G/B and C/M/Y/K fields and the Color panel's number fields keep typed text until it is complete; panel and picker values are applied on a fixed scale (`{model, …}` colours in 0..1), so M 1 is 1 %. Picking keeps a CMYK colour CMYK; dialogs leave the page behind them undimmed; colours that round to the same swatch name share one swatch (#323). No readiness percentage change.
- **2026-10-12** · Placed photos show in their own colours: embedded ICC profiles (Adobe RGB, ProPhoto, P3, grey) are converted to sRGB on screen, and CMYK JPEGs (including YCCK), CMYK TIFFs and CMYK PSDs go through their embedded CMYK profile or the working CMYK space with the colour settings' intent and black-point compensation, instead of the profile-free formula. Unreadable profiles are ignored. No readiness percentage change.
- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change.
+57 -19
View File
@@ -20,6 +20,7 @@ pub mod kenten;
mod mojikumi;
mod notes;
pub mod optical;
mod outline;
mod overlay;
mod parabox;
mod ruby;
@@ -47,6 +48,7 @@ use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
pub use crate::outline::FrameOutline;
use crate::parabox::ParaBox;
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with};
@@ -555,6 +557,9 @@ pub struct FrameSpec {
/// area is the turned box.
pub vertical: bool,
pub exclusions: Vec<Exclusion>,
/// 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<Arc<FrameOutline>>,
pub page_name: Option<String>,
/// Marker text of the section the frame's page is in (None on parent pages).
pub section_marker: Option<String>,
@@ -715,18 +720,28 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
}
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), contour: e.contour.map(|c| c.transformed(v)), ..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,
@@ -734,6 +749,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
opts,
vertical,
exclusions: if tf.options.path.is_some() { vec![] } else { exclusions },
outline: outline.map(Arc::new),
page_name,
section_marker,
page,
@@ -1348,12 +1364,19 @@ 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_asc = base_chars.size * 0.75;
// 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, mark_leading, mark_chars.size, db, &mark_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, 0.0))
};
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 };
let est_first =
if carry.is_empty() { cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp) } else { carry_baseline + base_leading };
let est_jump = cur.jump(f, base_leading, est_asc, est_tops);
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], rtl, &carry);
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, est_band, &glyphs[g0..], rtl, &carry);
let width = |j: usize| -> f64 {
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
// Beside the drop cap (in the column the paragraph starts in), its width; else the
@@ -1410,7 +1433,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// text goes on in the next segment or line (a word wider than the column is split
// wherever it lands).
let ind = pp.left_indent + pp.right_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 };
let need = if f.exclusions.is_empty() {
let need = if f.exclusions.is_empty() && f.outline.is_none() {
0.0
} else {
let w = breaker::first_word_width(glyphs.get(s..).unwrap_or_default(), hyph_after.get(s..).unwrap_or_default(), &spacing);
@@ -1486,12 +1509,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let snap = |y: f64| grid.map_or(y, |(_, inc)| on_grid + ((y - on_grid) / inc - 1e-6).ceil().max(0.0) * inc);
// Wrap: push the line down to the first position with a slot (back on the grid).
// Segments after the first are carried to the next lines of the paragraph.
if !f.exclusions.is_empty() {
if !f.exclusions.is_empty() || f.outline.is_some() {
let jump = cur.jump(f, advance, asc, tops);
let line_start = glyphs.get(s).map_or(prange.start, |g| g.byte);
let mut tries = 0;
loop {
let Some((nb, segs)) = wrap_slot(f, col, baseline, asc, jump, base_size, rtl, line_start, &holds) else {
let Some((nb, segs)) = wrap_slot(f, col, baseline, asc, desc, jump, base_size, rtl, line_start, &holds) else {
// No room in this column.
baseline = baseline.max(col.y1) + 1.0;
carry.clear();
@@ -2904,13 +2927,15 @@ const JUMP_EPS: f64 = 1e-6;
/// A line under a Jump Object wrap moves below it as `jump` says. Around a rectangular wrap the
/// free space only grows where one in the line's band ends: the search jumps from one such bottom
/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom. A
/// contour's width changes within its box, so beside one the search moves down 1 pt at a time.
/// contour's width changes within its box, and a shaped frame's along its outline, so beside one
/// the search moves down 1 pt at a time.
#[allow(clippy::too_many_arguments)]
fn wrap_slot(
f: &FrameSpec,
col: Rect,
baseline: f64,
asc: f64,
desc: f64,
jump: Jump,
size: f64,
rtl: bool,
@@ -2937,7 +2962,7 @@ fn wrap_slot(
}
} else {
// Segments before the first that `holds` the line's first word stay empty.
let segs: Vec<(f64, f64)> = free_slots(f, col, b - asc, b, size, rtl, line_start).into_iter().skip_while(|seg| !holds(seg)).collect();
let segs: Vec<(f64, f64)> = free_slots(f, col, b - asc, b, desc, size, rtl, line_start).into_iter().skip_while(|seg| !holds(seg)).collect();
if !segs.is_empty() {
return Some((b, segs));
}
@@ -2949,7 +2974,13 @@ fn wrap_slot(
.filter(|e| e.rect.y0 < y1 && e.rect.y1 > y0)
};
let edge = in_band().fold(f64::INFINITY, |m, e| m.min(e.rect.y1)) + asc;
if in_band().any(|e| e.contour.is_some()) { edge.min(b + 1.0) } else { edge }
// A contour's or a shaped frame's width changes within the band: 1 pt at a time.
let next = if f.outline.is_some() || in_band().any(|e| e.contour.is_some()) { edge.min(b + 1.0) } else { edge };
// No progress (coordinates so large that a point no longer moves the line) or none left.
if !(next > b) || !next.is_finite() {
return None;
}
next
};
if b.is_nan() || b > col.y1 {
return None;
@@ -2959,16 +2990,21 @@ fn wrap_slot(
}
/// Free segments of `col` in the band `y0..y1` (a line's top to its baseline: descenders may reach
/// into the wrap), in the order text fills them (left to right, or
/// right to left for right-to-left paragraphs); empty if the band is blocked. A line beside an
/// into the wrap; a shaped frame's outline holds them too, `desc` below `y1`), in the order text
/// fills them (left to right, or right to left for right-to-left paragraphs); empty if the band is blocked. A line beside an
/// object that lets text through on both sides has a segment on each side. When an object cuts
/// the column, segments narrower than 1.5 em of `size` are skipped (slivers beside it); a column
/// no object reaches is free however narrow (a one-character frame such as an article's closing
/// tombstone). `line_start` is the story byte the line starts at (see [`Exclusion::wraps_line`]).
#[allow(clippy::too_many_arguments)]
fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool, line_start: usize) -> Vec<(f64, f64)> {
let mut free = vec![(col.x0, col.x1)];
let mut cut = false;
fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, desc: f64, size: f64, rtl: bool, line_start: usize) -> Vec<(f64, f64)> {
// A shaped frame: the parts of the band its outline covers (a concave one can give several),
// descenders included: they stay inside the frame.
let mut free = match &f.outline {
Some(o) => o.intervals(col.x0, col.x1, y0, y1 + desc.max(0.0)),
None => vec![(col.x0, col.x1)],
};
let mut cut = f.outline.is_some();
for ex in f.exclusions.iter().filter(|e| e.wraps_line(line_start)) {
let r = ex.rect;
let overlaps_band = r.y0 < y1 && r.y1 > y0;
@@ -3019,7 +3055,8 @@ fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool,
}
/// Line slots for the breaker: the free segments of each line from baseline `first` down, `lead`
/// apart; `carry` holds segments left on the line above (they come first).
/// apart, for lines with `band` (ascent, descent); `carry` holds segments left on the line above
/// (they come first).
#[allow(clippy::too_many_arguments)]
fn estimate_slots(
f: &FrameSpec,
@@ -3028,11 +3065,12 @@ fn estimate_slots(
jump: Jump,
lead: f64,
size: f64,
band: (f64, f64),
glyphs: &[Glyph],
rtl: bool,
carry: &[(f64, f64)],
) -> Vec<(f64, f64)> {
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.
@@ -3046,7 +3084,7 @@ fn estimate_slots(
// rest is broken again once that line is set.
let line_start = glyphs.first().map_or(0, |g| g.byte);
while v.len() < n + carry.len() {
let slots = match wrap_slot(f, col, b, size * 0.8, jump, size, rtl, line_start, &|_| true) {
let slots = match wrap_slot(f, col, b, band.0, band.1, jump, size, rtl, line_start, &|_| true) {
Some((nb, s)) => {
b = nb;
s
+1
View File
@@ -134,6 +134,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(),
section_marker: f.section_marker.clone(),
page: f.page,
+197
View File
@@ -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<FrameOutline> {
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<f64> = 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<f64> = 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<Point> = Vec::new();
let mut closed = false;
let finish = |sub: &mut Vec<Point>, 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)
}
+1
View File
@@ -128,6 +128,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(),
section_marker: opts.section_marker.clone(),
page: opts.page,
+173
View File
@@ -6686,3 +6686,176 @@ fn balance_columns_starts_after_a_column_break() {
let c = column_line_counts(ft);
assert!(c[2] > 0 && c[1] >= c[2] && c[1] - c[2] <= 1, "{c:?}");
}
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::<Vec<_>>();
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());
}
}
+1
View File
@@ -48,6 +48,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) |
| Balance columns | ✓ | ✓ (IDML `VerticalBalanceColumns`) | ✓ | evens out the columns of the frame the story ends in, after its last column break (#609) |