Merge latest main into baseline grid justification PR

This commit is contained in:
Dylan M. Taylor committed 2026-11-12 15:54:56 -05:00
commit f88e6711e0
8 files changed
+896 -25

No files matched your search

+190 -8
View File
@@ -365,6 +365,9 @@ pub struct Exclusion {
/// An anchored object in the frame's own story: it wraps only lines that start after this
/// story byte (its anchor).
pub after: Option<usize>,
/// For an anchored object in the frame's own story: the baseline of the line holding its
/// anchor when the exclusion was taken from it.
pub anchor_baseline: Option<f64>,
}
impl Exclusion {
@@ -897,7 +900,7 @@ fn wrap_exclusion(
if let Some(custom) = wrap.path.as_ref().and_then(|p| Contour::of(&p.to_bezpath(), inv * xf, 0.0)) {
let rect = custom.bounds();
let contour = matches!(wrap.mode, WrapMode::BoundingBox | WrapMode::Contour).then_some(custom);
return Exclusion { rect, mode: wrap.mode, side, contour, after: None };
return Exclusion { rect, mode: wrap.mode, side, contour, after: None, anchor_baseline: None };
}
let contour = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|src| {
let mut c = Contour::of(&src.path, inv * xf, o[0])?;
@@ -910,7 +913,7 @@ fn wrap_exclusion(
Some(c)
});
let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds);
Exclusion { rect, mode: wrap.mode, side, contour, after: None }
Exclusion { rect, mode: wrap.mode, side, contour, after: None, anchor_baseline: None }
}
/// The outline a contour wrap follows, in the frame's inner space, with what clips it.
@@ -3635,7 +3638,9 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory {
let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true))
|| ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true));
vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras);
let anchors: Vec<usize> =
if ft.objects.is_empty() { Vec::new() } else { story.text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect() };
vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras, &anchors);
for (t, b) in ft.tables.iter_mut().zip(before) {
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
t.shift(now - b);
@@ -5961,9 +5966,17 @@ fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tin
out
}
/// `spanned`: a paragraph spans the frame's columns, so the lines move together. `grid_aligns`:
/// each paragraph's Align to Baseline Grid setting.
fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions, grid_aligns: &[GridAlign]) {
/// `spanned`: a paragraph spans the frame's columns, so the lines move together. `anchors`: the
/// story byte of each anchored object's mark, by object index.
/// `grid_aligns`: each paragraph's Align to Baseline Grid setting.
fn vertical_justify(
ft: &mut FrameText,
f: &FrameSpec,
spanned: bool,
notes: &designcraft_doc::FootnoteOptions,
grid_aligns: &[GridAlign],
anchors: &[usize],
) {
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
return;
}
@@ -5973,6 +5986,7 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
// Per column (or all lines together).
let ncols = if spanned { 1 } else { ft.columns.len().max(1) as u32 };
for c in 0..ncols {
let (rooms, follow) = wrap_rooms(ft, f, c, spanned, anchors);
let idx: Vec<usize> = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect();
if idx.is_empty() {
continue;
@@ -5994,6 +6008,13 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
// `space`: the room under the bottom of the text.
let inside = f.vertical || note_top <= f.area.y1;
let space = if inside { limit - ink_bottom } else { limit - last_baseline };
// Center and Bottom move the lines together: the nearest text wrap under them is the
// bottom of the room they move down into, so the room is the least any line has before a
// wrap stops it. Justify checks each row's room below.
let space = match f.opts.vertical_justification {
VerticalJustification::Justify => space,
_ => idx.iter().map(|&i| rooms[i]).fold(space, f64::min),
};
if space <= 0.0 {
continue;
}
@@ -6050,16 +6071,31 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
}
None => true,
};
// Each row moves no further than a text wrap under it allows. An object that moves with its
// anchor's row (Justify) limits how much further than that row a later row moves.
let mut row_of = vec![None; ft.lines.len()];
for (&i, &k) in idx.iter().zip(&rows) {
row_of[i] = Some(k);
}
let follow: Vec<(usize, usize, f64)> = follow
.iter()
.filter_map(|&(i, a, room)| Some((row_of.get(i).copied().flatten()?, row_of.get(a).copied().flatten()?, room.max(0.0))))
.collect();
let clear = |shift: &[f64]| {
idx.iter().zip(&rows).all(|(&i, &k)| shift[k] <= rooms[i].max(0.0) + 1e-9)
&& follow.iter().all(|&(k, m, room)| shift[k] - shift[m] <= room + 1e-9)
};
// The lowest lines are tested first: they are the ones a narrowing outline stops.
let fits = |shift: &[f64]| {
idx.iter().zip(&rows).rev().all(|(&i, &k)| {
let l = &ft.lines[i];
shift[k] <= 0.0 || ((k == n || l.baseline + l.descent + shift[k] <= limit + 1e-9) && in_outline(l, k == n, shift[k]))
})
}) && clear(shift)
};
// In a rectangular frame fit decreases monotonically as the shift grows. A shaped
// frame can narrow and widen again, so scan from the largest shift before refining the
// first fitting interval. This also bounds the room used for baseline-grid quantization.
// first fitting interval. This also bounds the room used for baseline-grid quantization
// and preserves clearance from fixed and anchor-following wraps.
let most_room = |top: f64| -> f64 {
if fits(&shifts(top)) {
return top;
@@ -6140,6 +6176,152 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
}
}
/// How far each line of column `c` (every column when `spanned`) may move down before a text
/// wrap stops it, by index in `ft.lines` (infinite without one); a table fragment's room counts
/// for its line. No line moves into a wrap under its slot. As when lines are set around a wrap, a
/// line's band runs from its top to its baseline, so a baseline may come down to a wrap's top; a
/// table fragment stops with its bottom there. A wrap without a contour counts by its bounding
/// box. Beside or under a contour, the line moves down only as far as its band stays clear of the
/// contour on its way down (to 0.01 pt). Only wraps that reach into a line's column (or the
/// columns it spans) count, as when the lines are set. An anchored object that moves with the
/// lines being justified (inline, above the line, or positioned from its anchor and not kept
/// within its column) is skipped, except under Justify, where it moves with the row of its
/// anchor's line: its wrap is taken where the object sits before the lines move, and its rooms go
/// in the second list as (line, anchor's line, room), so a line moves down at most that much
/// further than the anchor's row. Justify keeps the first row in place, so its room stays
/// infinite. `anchors` holds the story byte of each anchored object's mark, by object index.
fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[usize]) -> (Vec<f64>, Vec<(usize, usize, f64)>) {
const EPS: f64 = 1e-6;
// One-sided wraps also block the column on the other side of the object: the x-range wrap
// `e` keeps text out of in column `col`, from the object's x-range `a..b` there (None when that
// range misses the column).
let sided = |e: &Exclusion, col: Rect, (a, b): (f64, f64)| -> Option<(f64, f64)> {
let r = e.rect;
(a < col.x1 && b > col.x0).then_some(match e.side {
WrapSide::LeftSide => (a, f64::INFINITY),
WrapSide::RightSide => (f64::NEG_INFINITY, b),
WrapSide::LargestArea if r.x0 - col.x0 >= col.x1 - r.x1 => (a, f64::INFINITY),
WrapSide::LargestArea => (f64::NEG_INFINITY, b),
WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => (a, b),
})
};
// The x-range a wrap under the text keeps it out of in column `col`; None when text may stay beside it.
let blocked = |e: &Exclusion, col: Rect| -> Option<(f64, f64)> {
let r = e.rect;
match e.mode {
WrapMode::None => None,
WrapMode::JumpObject | WrapMode::JumpToNextColumn => (r.x0 < col.x1 && r.x1 > col.x0).then_some((f64::NEG_INFINITY, f64::INFINITY)),
WrapMode::BoundingBox | WrapMode::Contour => sided(e, col, (r.x0, r.x1)),
}
};
let in_col = |k: u32| spanned || k == c;
let justify = f.opts.vertical_justification == VerticalJustification::Justify;
// The object whose mark is at each story byte, placed on one of these lines (the first one
// listed).
let mut by_anchor = HashMap::new();
for o in ft.objects.iter().filter(|o| ft.lines.get(o.line).is_some_and(|l| in_col(l.column))) {
if let Some(&a) = anchors.get(o.index) {
by_anchor.entry(a).or_insert(o);
}
}
// An own-story anchored object whose anchor is on one of these lines and whose position
// follows its line moves with the text: the index in `ft.lines` of its anchor's line, and how
// much lower that line was when the wrap was taken (an earlier pass placed the object after
// moving the lines).
let moves_with_text = |e: &Exclusion| -> Option<(usize, f64)> {
let o = *by_anchor.get(&e.after?)?;
let follows = match &o.object.position {
designcraft_doc::AnchorPosition::Inline { .. } | designcraft_doc::AnchorPosition::AboveLine { .. } => true,
// Kept within the column, it can stop at the column's edge instead of following.
designcraft_doc::AnchorPosition::Custom { y_relative, keep_within_column, .. } => {
*y_relative == designcraft_doc::anchored::AnchorRelative::Anchor && !keep_within_column
}
};
let now = ft.lines.get(o.line).map_or(0.0, |l| l.baseline);
follows.then(|| (o.line, e.anchor_baseline.map_or(0.0, |b| b - now)))
};
// Each wrap's object is looked up once, not for every line.
let moving: Vec<Option<(usize, f64)>> = f.exclusions.iter().map(moves_with_text).collect();
// Justify keeps the first row (its segments and a table on them) in place, so wraps under it
// set no limit.
let first = ft.lines.iter().find(|l| in_col(l.column)).map(|l| l.baseline);
let fixed = |line: usize| justify && ft.lines.get(line).zip(first).is_some_and(|(l, b)| (l.baseline - b).abs() <= 1e-9);
// (line, story byte, column, slot x-range, top, bottom) of each line (its band runs from its
// top to its baseline) and table fragment.
let lines = ft
.lines
.iter()
.enumerate()
.filter(|(_, l)| in_col(l.column))
.map(|(i, l)| (i, l.range.start, l.column, l.x0, l.x1, l.baseline - l.ascent, l.baseline));
let tables = ft.tables.iter().filter(|t| in_col(t.column)).map(|t| (t.line, t.anchor, t.column, t.rect.x0, t.rect.x1, t.rect.y0, t.rect.y1));
let mut rooms = vec![f64::INFINITY; ft.lines.len()];
let mut follow = Vec::new();
for (line, start, k, x0, x1, top, bottom) in lines.chain(tables) {
let Some(&before) = rooms.get(line) else { continue };
if fixed(line) {
continue;
}
let mut room = before;
// A line that spans columns reaches past its own column.
let col = ft.columns.get(k as usize).copied().unwrap_or(f.area);
let col = Rect { x0: col.x0.min(x0), x1: col.x1.max(x1), ..col };
let meets = |(a, b): (f64, f64)| a < x1 - EPS && b > x0 + EPS;
for (e, &anchor) in f.exclusions.iter().zip(&moving).filter(|(e, _)| e.wraps_line(start)) {
if anchor.is_some() && !justify {
continue;
}
// The wrap where its object sits before the lines move (moved up by `dy`), and how
// far it lets this line move down (relative to its anchor's row for an object that
// moves with the text).
let dy = anchor.map_or(0.0, |(_, dy)| dy);
let cap = if anchor.is_some() { f64::INFINITY } else { room };
let mut room_e = f64::INFINITY;
let r = Rect { y0: e.rect.y0 - dy, y1: e.rect.y1 - dy, ..e.rect };
if let (WrapMode::BoundingBox | WrapMode::Contour, Some(contour)) = (e.mode, &e.contour)
&& r.y1 > top
&& sided(e, col, (r.x0, r.x1)).is_some_and(meets)
{
// A contour beside or under this line whose box reaches the line's slot (the
// contour stays inside its box). Moved down by `d`, the line's band sweeps from its
// top to its baseline plus `d`; the contour's extent there only grows with `d`, so
// the furthest clear move is found by bisection to 0.01 pt.
let hits = |d: f64| contour.band_extent(top + dy, bottom + d + dy).and_then(|x| sided(e, col, x)).is_some_and(meets);
let (mut lo, mut hi) = ((r.y0 - bottom).max(0.0), (r.y1 - top).min(f.area.y1 - bottom).min(cap));
if lo < hi && hits(hi) {
if hits(lo) {
hi = lo;
}
for _ in 0..64 {
if hi - lo <= 0.01 {
break;
}
let mid = (lo + hi) / 2.0;
if hits(mid) {
hi = mid;
} else {
lo = mid;
}
}
room_e = lo;
}
} else if r.y0 >= bottom - EPS {
// A wrap at or under this line.
if blocked(e, col).is_some_and(meets) {
room_e = r.y0 - bottom;
}
}
match anchor {
Some((a, _)) if room_e.is_finite() => follow.push((line, a, room_e)),
Some(_) => {}
None => room = room.min(room_e),
}
}
rooms[line] = room;
}
(rooms, follow)
}
// ---------- caret and hit testing (Type tool) ----------
/// Two stacked glyphs this far apart in `y` sit on different rows (warichu).
+376
View File
@@ -513,6 +513,7 @@ fn hostile_wrap_geometry_terminates() {
side: WrapSide::BothSides,
contour: None,
after: None,
anchor_baseline: None,
};
let f = &mut specs[0];
f.exclusions.push(ex(f64::NAN, f64::NAN, f64::NAN, f64::NAN, WrapMode::JumpObject));
@@ -573,6 +574,318 @@ fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() {
assert!(!cs.is_overset());
}
/// A 60 × 200 pt frame at the origin holding `text` with vertical justification `vj`, and a bar
/// at `bar` whose text wrap `wrap` keeps 1 mm above it: (baseline, descent) of each line, the
/// text area and the wrap's top (None when the bar doesn't wrap the frame).
fn bar_chart_lines(
text: &str,
vj: designcraft_doc::VerticalJustification,
wrap: WrapMode,
side: WrapSide,
bar: Rect,
) -> (Vec<(f64, f64)>, Rect, Option<f64>) {
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj;
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(bar));
it.wrap.mode = wrap;
it.wrap.side = side;
it.wrap.offsets = [72.0 / 25.4, 0.0, 0.0, 0.0];
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let spec = frame_specs(&d, sid).remove(0);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let lines = all_lines(&cs).iter().map(|l| (l.baseline, l.descent)).collect();
(lines, spec.area, spec.exclusions.first().map(|e| e.rect.y0))
}
/// Bottom, Center and Justify keep the lines out of a text wrap under them: a number aligned to
/// the bottom of its frame sits on the wrap's top offset over a bar instead of on the bar (#1831),
/// with every wrap mode that stops text there.
#[test]
fn vertical_justification_stops_at_a_wrap_under_the_text() {
use designcraft_doc::VerticalJustification::{Bottom, Center, Justify, Top};
let bar = Rect::new(0.0, 150.0, 60.0, 300.0);
for wrap in [WrapMode::BoundingBox, WrapMode::Contour, WrapMode::JumpObject, WrapMode::JumpToNextColumn] {
let run = |vj, text| bar_chart_lines(text, vj, wrap, WrapSide::BothSides, bar);
let (top, _, wrap_top) = run(Top, "42");
let wrap_top = wrap_top.unwrap();
assert!((wrap_top - (150.0 - 72.0 / 25.4)).abs() < 1e-6, "{wrap:?}: the wrap's top is 1 mm above the bar: {wrap_top}");
let (bottom, _, _) = run(Bottom, "42");
let b = bottom[0].0;
assert!((b - wrap_top).abs() < 1e-6, "{wrap:?}: Bottom put the baseline at {b}, not on the wrap's top at {wrap_top}");
assert!(b > top[0].0 + 50.0, "{wrap:?}: Bottom moved the line down: {b}, {}", top[0].0);
// Center takes half the room Bottom takes.
let (center, _, _) = run(Center, "42");
assert!((center[0].0 - top[0].0 - (b - top[0].0) / 2.0).abs() < 1e-6, "{wrap:?}: Center at {}, Bottom at {b}", center[0].0);
// Justify keeps the first baseline and puts the last where Bottom puts it.
let (top2, _, _) = run(Top, "12\n34");
let (bottom2, _, _) = run(Bottom, "12\n34");
let (justify, _, _) = run(Justify, "12\n34");
assert_eq!(justify.len(), 2);
assert!((justify[0].0 - top2[0].0).abs() < 1e-6, "{wrap:?}: Justify moved the first line: {justify:?}");
assert!((justify[1].0 - bottom2[1].0).abs() < 1e-6, "{wrap:?}: Justify {justify:?}, Bottom {bottom2:?}");
assert!(justify[1].0 <= wrap_top + 1e-6);
}
}
/// Without a wrap, or beside one, Bottom still moves the text down to the text area's bottom.
#[test]
fn vertical_justification_passes_wraps_that_do_not_stop_the_text() {
use designcraft_doc::VerticalJustification::Bottom;
// Wrap off: the number goes down over the bar.
let (lines, area, wrap_top) = bar_chart_lines("42", Bottom, WrapMode::None, WrapSide::BothSides, Rect::new(0.0, 150.0, 60.0, 300.0));
assert!(wrap_top.is_none());
assert!((lines[0].0 - area.y1).abs() < 1e-6, "{lines:?}, bottom {}", area.y1);
// A bar on the right half from the frame's top: the number is set beside it and goes down
// beside it.
let (lines, area, wrap_top) = bar_chart_lines("42", Bottom, WrapMode::BoundingBox, WrapSide::BothSides, Rect::new(30.0, 0.0, 60.0, 300.0));
assert!(wrap_top.is_some());
assert!((lines[0].0 - area.y1).abs() < 1e-6, "{lines:?}, bottom {}", area.y1);
}
/// A one-sided wrap in the second column leaves the first column's lines free to go down, as it
/// leaves them when they are set.
#[test]
fn vertical_justification_passes_a_one_sided_wrap_in_another_column() {
let (mut d, sid, fid) = doc_with("One", Rect::new(0.0, 0.0, 400.0, 200.0), ParaAttrs::default());
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
o.columns = 2;
o.vertical_justification = designcraft_doc::VerticalJustification::Bottom;
let lid = d.default_layer();
let mut it = designcraft_doc::Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(250.0, 100.0, 350.0, 300.0)),
);
it.wrap.mode = WrapMode::BoundingBox;
it.wrap.side = WrapSide::RightSide;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = all_lines(&cs)[0];
assert_eq!(l.column, 0);
assert!((l.baseline - 200.0).abs() < 1e-6, "baseline {}", l.baseline);
}
/// Beside a contour that widens further down, Bottom moves a line only as far as its band stays
/// clear of the contour.
#[test]
fn vertical_justification_stops_where_a_contour_beside_the_text_widens() {
use designcraft_doc::VerticalJustification::{Bottom, Top};
// Right edge fixed at 60, left edge x = 55 − 50y/190 from y = 0 down to y = 190.
let edge = |y: f64| 55.0 - 50.0 * y / 190.0;
let pts = [Point::new(55.0, 0.0), Point::new(60.0, 0.0), Point::new(60.0, 190.0), Point::new(5.0, 190.0)];
let run = |vj| {
let (mut d, sid, fid) = doc_with("42", Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj;
let lid = d.default_layer();
let path = designcraft_geom::path::PathData::single(designcraft_geom::path::SubPath::polyline(&pts, true));
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, path);
it.wrap.mode = WrapMode::Contour;
it.wrap.side = WrapSide::BothSides;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let l = all_lines(&cs)[0];
(l.baseline, l.x1)
};
let (top, x1) = run(Top);
assert!(x1 <= edge(top) + 1e-6, "Top sets the line clear of the contour: x1 {x1}, edge {}", edge(top));
let (bottom, x1) = run(Bottom);
assert!(bottom >= top - 1e-6 && bottom < 190.0, "Bottom at {bottom}, Top at {top}");
assert!(x1 <= edge(bottom) + 1e-6, "Bottom moved the line into the contour: baseline {bottom}, x1 {x1}, edge {}", edge(bottom));
}
/// Under a contour whose box starts beside the column and which widens into it further down,
/// Bottom moves a line down to where the contour meets its slot, not to the top of the box.
#[test]
fn vertical_justification_stops_where_a_contour_under_the_text_meets_it() {
// Left edge x = 70 − 40(y − 100)/90 from y = 100 down to y = 190: it crosses the column's
// right edge (60) at y = 122.5.
let pts = [Point::new(70.0, 100.0), Point::new(80.0, 100.0), Point::new(80.0, 190.0), Point::new(30.0, 190.0)];
let (mut d, sid, fid) = doc_with("42", Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom;
let lid = d.default_layer();
let path = designcraft_geom::path::PathData::single(designcraft_geom::path::SubPath::polyline(&pts, true));
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, path);
it.wrap.mode = WrapMode::Contour;
it.wrap.side = WrapSide::BothSides;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let b = all_lines(&cs)[0].baseline;
assert!(b <= 122.5 + 1e-6, "Bottom moved the baseline to {b}, past where the contour meets the column at 122.5");
assert!(b > 122.0, "Bottom stopped the line at {b}, short of where the contour meets the column at 122.5");
}
/// An object anchored in the text and positioned from its anchor moves down with the lines, so
/// Center places lines above it as it places them without its wrap.
#[test]
fn vertical_justification_passes_an_anchored_object_that_moves_with_the_text() {
let baselines = |wrap| {
let (mut d, sid, fid) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Center;
// Hung 40 pt under the first line's baseline, under the second line.
anchor_box(&mut d, sid, 1, 0.0, 40.0, true, wrap);
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs).iter().map(|l| l.baseline).collect::<Vec<_>>()
};
let plain = baselines(WrapMode::None);
let wrapped = baselines(WrapMode::BoundingBox);
assert_eq!(plain.len(), 2);
assert!(plain[0] > 150.0, "Center moved the lines down: {plain:?}");
assert_eq!(plain.len(), wrapped.len());
assert!(plain.iter().zip(&wrapped).all(|(a, b)| (a - b).abs() < 1e-6), "without the wrap {plain:?}, with it {wrapped:?}");
}
/// Justify keeps the first line in place, so a wrap between it and the next line does not stop
/// the later lines from spreading down to the text area's bottom.
#[test]
fn vertical_justify_passes_a_wrap_under_the_first_line() {
let (mut d, sid, fid) = doc_with("One\nTwo\nSix", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Justify;
let lid = d.default_layer();
let mut it = designcraft_doc::Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(0.0, 20.0, 300.0, 40.0)),
);
it.wrap.mode = WrapMode::JumpObject;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 3);
assert!(
lines[0].baseline < 20.0 && lines[1].baseline - lines[1].ascent >= 40.0 - 1e-6,
"{:?}",
lines.iter().map(|l| l.baseline).collect::<Vec<_>>()
);
let last = lines[2];
assert!((last.baseline - 400.0).abs() < 1e-6, "{:?}", lines.iter().map(|l| l.baseline).collect::<Vec<_>>());
}
/// Justify moves each row by its own share of the space, so a wrap between the second and third
/// rows limits the second row's move, not the third's.
#[test]
fn vertical_justify_limits_each_row_by_the_wrap_under_it() {
use designcraft_doc::VerticalJustification::{Justify, Top};
let para = ParaAttrs { space_before: Some(40.0), ..Default::default() };
let run = |vj, bar: Option<Rect>| {
let (mut d, sid, fid) = doc_with("One\nTwo\nSix", Rect::new(0.0, 0.0, 300.0, 400.0), para.clone());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj;
if let Some(bar) = bar {
let lid = d.default_layer();
let mut it =
designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(bar));
it.wrap.mode = WrapMode::JumpObject;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs).iter().map(|l| (l.baseline, l.ascent, l.descent)).collect::<Vec<_>>()
};
let top = run(Top, None);
assert_eq!(top.len(), 3);
let b2 = top[1].0;
let (b3, a3, _) = top[2];
// A bar 20 pt under the second baseline, clear of the third line.
let bar = Rect::new(0.0, b2 + 20.0, 300.0, b2 + 30.0);
assert!(b3 - a3 > bar.y1, "the third line starts under the bar: {top:?}");
let set = run(Top, Some(bar));
assert!(set.iter().zip(&top).all(|(a, b)| (a.0 - b.0).abs() < 1e-6), "the bar moves no line: {set:?}");
let justify = run(Justify, Some(bar));
assert!((justify[0].0 - top[0].0).abs() < 1e-6, "{justify:?}");
// The second row's baseline comes down to the bar; the third row moves twice as far.
let moved = justify[1].0 - b2;
assert!((moved - 20.0).abs() < 1e-6, "the second row moved {moved}: {justify:?}");
assert!((justify[2].0 - b3 - 2.0 * moved).abs() < 1e-6, "{justify:?}");
}
/// Each anchored object's own position decides whether its wrap moves with the text: a fixed
/// object without a wrap on the same line leaves the moving one skipped.
#[test]
fn vertical_justification_matches_each_wrap_to_its_anchored_object() {
let baselines = |wrap| {
let (mut d, sid, fid) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Center;
// A fixed object from the frame's top, then one hung 40 pt under the first line.
anchor_box(&mut d, sid, 1, 150.0, 0.0, false, WrapMode::None);
anchor_box(&mut d, sid, 1 + OBJECT_LEN, 0.0, 40.0, true, wrap);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert_eq!(cs.frames[0].objects.len(), 2);
all_lines(&cs).iter().map(|l| l.baseline).collect::<Vec<_>>()
};
let plain = baselines(WrapMode::None);
let wrapped = baselines(WrapMode::BoundingBox);
assert!(plain[0] > 150.0, "Center moved the lines down: {plain:?}");
assert_eq!(plain.len(), wrapped.len());
assert!(plain.iter().zip(&wrapped).all(|(a, b)| (a - b).abs() < 1e-6), "without the wrap {plain:?}, with it {wrapped:?}");
}
/// An object positioned from its anchor but kept within its column can stop at the column's
/// bottom, so Bottom does not move the lines after its anchor into it.
#[test]
fn vertical_justification_stops_at_an_anchored_object_kept_within_its_column() {
let (mut d, sid, fid) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Bottom;
anchor_box(&mut d, sid, 1, 0.0, 40.0, true, WrapMode::BoundingBox);
if let designcraft_doc::AnchorPosition::Custom { keep_within_column, .. } =
&mut std::sync::Arc::make_mut(&mut d.story_mut(sid).unwrap().objects[0]).position
{
*keep_within_column = true;
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
let o = &cs.frames[0].objects[0];
let (top, bottom) = (o.origin.y, o.origin.y + 60.0);
assert!(bottom <= 400.0 + 1e-6, "the object stays in the column: {top}..{bottom}");
let l = lines[1];
assert!(
l.baseline <= top + 1e-6 || l.baseline - l.ascent >= bottom - 1e-6,
"the second line at {} meets the object at {top}..{bottom}",
l.baseline
);
}
/// Justify keeps the first line in place and moves later lines further, so an object anchored in
/// the first line and hung under the last one still stops the lines after its anchor.
#[test]
fn vertical_justify_stops_at_an_object_anchored_above_the_lines_it_wraps() {
let (mut d, sid, fid) = doc_with("One\nTwo\nSix", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Justify;
anchor_box(&mut d, sid, 1, 0.0, 50.0, true, WrapMode::BoundingBox);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 3);
let top = cs.frames[0].objects[0].origin.y;
assert!((top - lines[0].baseline - 50.0).abs() < 1e-6, "the object hangs 50 pt under the first line: {top}, {}", lines[0].baseline);
let last = lines[2].baseline;
assert!(last <= top + 1e-6, "Justify moved the last line to {last}, into the object at {top}");
assert!(last > lines[0].baseline + 40.0, "Justify spread the lines: {:?}", lines.iter().map(|l| l.baseline).collect::<Vec<_>>());
}
/// Justify moves an object anchored in the second line with that line, so the third line spreads
/// down until it meets the object where it ends up, not where it started.
#[test]
fn vertical_justify_follows_an_object_anchored_in_a_later_line() {
let (mut d, sid, fid) = doc_with("One\nTwo\nSix", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Justify;
anchor_box(&mut d, sid, 5, 0.0, 40.0, true, WrapMode::BoundingBox);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 3);
let baselines = lines.iter().map(|l| l.baseline).collect::<Vec<_>>();
let top = cs.frames[0].objects[0].origin.y;
assert!((top - lines[1].baseline - 40.0).abs() < 1e-6, "the object hangs 40 pt under the second line: {top}, {baselines:?}");
let last = lines[2].baseline;
assert!((top - last).abs() < 1e-6, "Justify moved the last line to {last}, not down to the object at {top}: {baselines:?}");
}
/// An Alpha Channel contour counts only where the graphic's clipping path shows the image: here
/// the visible pixels are in the right half and the clipping path shows the left half, so the
/// image wraps nothing; inverted, the clipping path shows the right half and the image wraps.
@@ -2660,6 +2973,69 @@ fn vertical_justification_keeps_lines_inside_a_shaped_frame() {
assert!((last.baseline - 260.0).abs() < 1e-3, "Bottom: last baseline at {}", last.baseline);
}
/// In a shaped frame with a text wrap under the text, Center, Bottom and Justify keep the lines
/// inside the outline and out of the wrap together: in a triangle that widens downward, Bottom and
/// Justify put the last baseline on the wrap's top and Center moves half as far; in one that
/// narrows downward, the outline stops the lines above the wrap, where they stop without it.
#[test]
fn vertical_justification_keeps_a_shaped_frame_and_a_wrap_under_the_text_together() {
use designcraft_doc::VerticalJustification::{self, Bottom, Center, Justify, Top};
use designcraft_geom::{Anchor, PathData, Point, SubPath};
let compose_with = |tri: [Point; 3], mode: VerticalJustification, bar: bool| {
let (mut d, sid, fid) = doc_with("Typography\ngypsy", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default());
let it = d.item_mut(fid).unwrap();
it.path = PathData::single(SubPath { anchors: tri.iter().map(|&p| Anchor::corner(p)).collect(), closed: true });
let o = &mut it.text_frame_mut().unwrap().options;
o.vertical_justification = mode;
o.inset = [0.0; 4];
if bar {
let lid = d.default_layer();
let mut it = designcraft_doc::Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(0.0, 250.0, 300.0, 400.0)),
);
it.wrap.mode = WrapMode::JumpObject;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset(), "{mode:?}");
let lines: Vec<Line> = all_lines(&cs).into_iter().cloned().collect();
assert!(lines.len() >= 2, "{mode:?}: {lines:?}");
lines
};
let up = [Point::new(150.0, 0.0), Point::new(300.0, 300.0), Point::new(0.0, 300.0)];
let top = compose_with(up, Top, true);
let top_last = top[top.len() - 1].baseline;
for mode in [Bottom, Justify] {
let lines = compose_with(up, mode, true);
let last = lines[lines.len() - 1].baseline;
assert!((last - 250.0).abs() < 1e-3, "{mode:?}: last baseline at {last}, wrap top at 250: {lines:?}");
for (k, l) in lines.iter().enumerate() {
let bottom = if k + 1 == lines.len() { l.baseline } else { l.baseline + l.descent };
let top = l.baseline - l.ascent;
for (x, y) in [(l.x0, top), (l.x1, top), (l.x0, bottom), (l.x1, bottom)] {
assert!((x - 150.0).abs() <= y / 2.0 + 1e-3, "{mode:?}: line {k} corner ({x}, {y}) outside the triangle: {lines:?}");
}
}
}
let center = compose_with(up, Center, true);
let center_last = center[center.len() - 1].baseline;
assert!((center_last - (top_last + 250.0) / 2.0).abs() < 1e-3, "Center: last baseline at {center_last}, Top at {top_last}");
let down = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(150.0, 300.0)];
for mode in [Center, Bottom, Justify] {
let with = compose_with(down, mode, true);
let without = compose_with(down, mode, false);
assert!(without[without.len() - 1].baseline < 250.0, "{mode:?}: {without:?}");
assert_eq!(with.len(), without.len());
for (a, b) in with.iter().zip(&without) {
assert!((a.baseline - b.baseline).abs() < 1e-3, "{mode:?}: {with:?} with the wrap, {without:?} without");
}
}
}
#[test]
fn table_rows_clear_footnotes_and_continue_in_the_next_column() {
for span in [false, true] {
+5 -1
View File
@@ -47,6 +47,8 @@ pub(crate) struct Obstacle<'a> {
xf: Affine,
/// Story byte of the anchor, for obstacles in their own story.
anchor: Option<usize>,
/// Baseline of the line holding the anchor, for obstacles in their own story.
anchor_baseline: Option<f64>,
}
/// The wrap may be set on the frame or on its placed graphic (Direct Selection).
@@ -128,7 +130,8 @@ pub(crate) fn obstacles<'a>(doc: &Document, story: &'a Story, cs: &ComposedStory
} else {
None
};
out.push(Obstacle { spread: loc.spread, item: &obj.item, xf, anchor });
let anchor_baseline = anchor.and_then(|_| ft.lines.get(o.line)).map(|l| l.baseline);
out.push(Obstacle { spread: loc.spread, item: &obj.item, xf, anchor, anchor_baseline });
}
}
out
@@ -159,6 +162,7 @@ pub(crate) fn exclusions(doc: &Document, specs: &[FrameSpec], obs: &[Obstacle])
let r = e.rect;
if r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 {
e.after = ob.anchor;
e.anchor_baseline = ob.anchor_baseline;
ex.push(e);
}
}
+43 -2
View File
@@ -371,10 +371,18 @@ impl Renderer {
}
}
// What an object can paint on the spread: its bounds with its (and its children's)
// strokes and effects.
// strokes and effects, which are drawn through each one's transform and grow with it.
fn outset(it: &Item, outer: Affine, o: &mut f64) {
let xf = outer * it.xf;
let scale = crate::fx::max_scale(xf).unwrap_or(1.0);
*o = o.max(crate::fx::outset_scaled(it, scale) + it.stroke.extent() * scale);
for c in it.children() {
outset(c, xf, o);
}
}
let reach = |it: &Item| {
let mut o: f64 = 0.0;
it.walk(&mut |c| o = o.max(crate::fx::outset(c) + c.stroke.extent()));
outset(it, Affine::IDENTITY, &mut o);
it.bounds().inflate(o + 2.0, o + 2.0)
};
let items: Vec<(Rect, bool)> = sp.items.iter().filter(|it| visible(it)).map(|it| (reach(it), transparent(it))).collect();
@@ -601,6 +609,39 @@ mod tests {
assert_eq!(draw(&doc, &mut renderer).pixels, rgb.pixels);
}
/// A hardened outer glow is stroked through the object's transform, and through its group's,
/// so the ink-pass area grows by the scaled growth: the square at 30..60 glows out to -30.
#[test]
fn cmyk_area_holds_a_scaled_objects_glow() {
for nested in [false, true] {
let mut doc = document();
let mut item = Item::new(
ItemId(doc.alloc()),
doc.default_layer(),
Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)),
);
item.fill = Fill::swatch("C=100 M=0 Y=0 K=0");
let g = &mut item.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
if nested {
let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default());
group.xf = Affine::scale(3.0);
group.content = Content::Group { items: vec![Arc::new(item)] };
doc.insert_item(SpreadRef::Doc(0), group, None).unwrap();
} else {
item.xf = Affine::scale(3.0);
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
let cache = Cache::new();
let opts = RenderOptions::default();
let f =
Frame { doc: &doc, cache: &cache, view: Affine::IDENTITY, visible: Rect::new(-1e4, -1e4, 1e4, 1e4), px: 1.0, opts: &opts, mt: false };
let area = Renderer::new().transparent_area(&f, SpreadRef::Doc(0)).unwrap();
assert!(area.x0 <= -30.0, "nested {nested}: the area {area:?} holds the glow out to -30");
}
}
#[test]
fn cmyk_pass_count_is_constant_and_unchanged_views_are_cached() {
let mut doc = document();
+141 -8
View File
@@ -11,6 +11,8 @@ use std::sync::Arc;
use designcraft_doc::{Document, Item, ItemId, SpreadRef, Story, StoryId};
use designcraft_geom::Rect;
use crate::fx::max_scale;
/// Changed regions in spread coordinates, or `None` when the whole view must be repainted.
pub fn damage(old: &Document, new: &Document) -> Option<Vec<(SpreadRef, Rect)>> {
damage_with(old, new, None)
@@ -158,36 +160,47 @@ fn shows_other_text(d: &Document, s: &Story) -> bool {
fn item_area(it: &Item) -> Option<Rect> {
let mut ok = true;
it.walk(&mut |i| {
// Effects spill outside; text wrap reflows other frames.
if i.effects.any() || i.has_wrap() {
// Text wrap reflows other frames. A nested item's effects are drawn through its parents'
// transforms, which can scale how far they reach.
if i.has_wrap() || !std::ptr::eq(i, it) && i.effects.any() {
ok = false;
}
});
if !ok {
return None;
}
let pad = it.stroke.extent().max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 };
Some(painted_bounds(it).inflate(pad, pad))
// The stroke and the effects' spread are drawn through the item's transform. The item's own
// drop shadow and outer glow reach `fx::outset_scaled` past what it paints; its inner effects
// and feathers stay inside the shape.
let scale = max_scale(it.xf)?;
let stroke = it.stroke.extent().max(0.0) * scale;
let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset_scaled(it, scale);
if !pad.is_finite() {
return None;
}
Some(painted_bounds(it)?.inflate(pad, pad))
}
/// An item's bounds, grown to its text: text is composed in the box around the frame's path in
/// the frame's own coordinates, which a sheared then rotated frame turns past its path's bounds.
fn painted_bounds(it: &Item) -> Rect {
/// Its children's bounds include their strokes. None when a child's transform is not finite.
fn painted_bounds(it: &Item) -> Option<Rect> {
let mut b = it.bounds();
if it.is_text_frame() {
b = b.union(it.xf.transform_rect_bbox(it.inner_bounds()));
}
for c in it.children() {
b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)));
let stroke = c.stroke.extent().max(0.0) * max_scale(c.xf)?;
b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)?.inflate(stroke, stroke)));
}
b
Some(b)
}
/// A text frame's area: its spread and bounds with room for glyph overhang.
fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> {
let loc = d.find(f)?;
let it = d.item_at(&loc)?;
let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it));
let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)?);
// Overhang of italics, swashes and big initials, and anchored objects pinned to lines.
Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0)))
}
@@ -384,4 +397,124 @@ mod tests {
let spreads: Vec<SpreadRef> = dmg.iter().map(|(r, _)| *r).collect();
assert!(spreads.contains(&SpreadRef::Doc(1)), "{dmg:?}");
}
/// Moving an object with a drop shadow or an outer glow damages where its effect was and is,
/// and every pixel the move changes lies in that damage: the canvas repaints those regions,
/// not the whole view.
#[test]
fn moving_an_object_with_a_shadow_or_glow_damages_its_effect_area() {
for glow in [false, true] {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0)));
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
if glow {
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 30.0, 20.0);
} else {
let s = &mut it.effects.drop_shadow;
(s.on, s.color, s.opacity, s.size, s.distance, s.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 25.0, 30.0);
}
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let old = d.clone();
let it = d.item_mut(id).unwrap();
it.path.transform(designcraft_geom::Affine::translate((60.0, 40.0)));
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| !Rect::new(98.0, 98.0, 312.0, 242.0).contains(*p)), "glow {glow}: the effect paints outside the object");
}
// A hardened glow on a scaled object: its growth is stroked through the transform, so it
// reaches three times as far on the spread as the glow's size says.
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)));
it.xf = designcraft_geom::Affine::scale(3.0);
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let old = d.clone();
let it = d.item_mut(id).unwrap();
it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf;
// The square covers 150..270 before the move; its glow reaches 60 pt past that.
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| p.x < 110.0), "the hard glow grows with the object's scale");
// An effect on an object inside a group is drawn through the group's transform: unbounded.
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let mut child =
Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 9.0, 9.0)));
child.effects.drop_shadow.on = true;
let gid = ItemId(d.alloc());
let mut group = Item::new(gid, lid, designcraft_doc::Shape::Group, Default::default());
group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] };
d.insert_item(SpreadRef::Doc(0), group, None).unwrap();
let old = d.clone();
d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::scale(4.0);
assert!(damage(&old, &d).is_none());
}
/// A stroke is drawn through its item's transform: a frame scaled up paints its stroke that
/// much wider, and moving it damages all of it.
#[test]
fn moving_a_scaled_frame_damages_its_whole_stroke() {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 125.0, 100.0)));
it.xf = designcraft_geom::Affine::scale(2.0);
it.stroke.swatch = designcraft_color::swatch::BLACK.into();
it.stroke.weight = 20.0;
it.stroke.align = designcraft_doc::StrokeAlign::Outside;
it.effects.outer_glow.on = true;
it.effects.outer_glow.size = 0.0;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let old = d.clone();
let it = d.item_mut(id).unwrap();
it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf;
// The stroke reaches 40 pt past the frame's 100..250 × 100..200 outline on the spread.
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| p.x < 70.0), "the stroke paints its scaled width");
// The same frame inside a group with a gradient feather: the child's stroke counts too.
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let mut child = old.spreads[0].items[0].as_ref().clone();
child.effects = Default::default();
child.id = ItemId(d.alloc());
let gid = ItemId(d.alloc());
let outline = designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0));
let mut group = Item::new(gid, d.default_layer(), designcraft_doc::Shape::Group, outline);
group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] };
let gf = &mut group.effects.gradient_feather;
(gf.on, gf.start, gf.end) = (true, 1.0, 0.5);
d.insert_item(SpreadRef::Doc(0), group, None).unwrap();
let old = d.clone();
d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::translate((60.0, 40.0));
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| p.x < 70.0), "the child's stroke paints its scaled width");
}
/// The pixels a page render changes from `old` to `new`, each checked to lie in the damage.
fn changed_pixels_in_damage(old: &Document, new: &Document) -> Vec<designcraft_geom::Point> {
let dmg = damage(old, new).expect("bounded damage");
let cache = designcraft_compose::Cache::new();
let mut r = crate::Renderer::new();
r.threads = 0;
let opts = crate::RenderOptions::default();
let before = r.render_page(old, &cache, 0, 1.0, false, &opts).unwrap();
let after = r.render_page(new, &cache, 0, 1.0, false, &opts).unwrap();
let mut changed = Vec::new();
for y in 0..before.height {
for x in 0..before.width {
if before.pixel(x, y) == after.pixel(x, y) {
continue;
}
let p = designcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5);
assert!(dmg.iter().any(|(_, r)| r.inflate(1.0, 1.0).contains(p)), "changed pixel {p:?} outside {dmg:?}");
changed.push(p);
}
}
changed
}
}
+5 -2
View File
@@ -179,7 +179,10 @@ impl Renderer {
return;
}
let xf = parent * it.xf;
let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0);
// The stroke and the effects' growth are drawn through `xf`, which stretches them.
let scale = fx::max_scale(xf).unwrap_or(1.0);
let stroke = it.stroke.extent() * scale;
let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(stroke + 2.0, stroke + 2.0);
// A frame with items pasted into it draws like any other object (draw_body: fill, the items
// clipped to the frame, stroke), so its effects apply to all of it.
if !it.children().is_empty() && !it.has_nested_items() {
@@ -193,7 +196,7 @@ impl Renderer {
_ => 0.0,
};
let effects = it.effects.any() && f.opts.quality != DisplayQuality::Fast;
let fx_outset = if effects { fx::outset(it) } else { 0.0 };
let fx_outset = if effects { fx::outset_scaled(it, scale) } else { 0.0 };
if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) {
return;
}
+108 -4
View File
@@ -40,17 +40,37 @@ pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
/// How far (points) the effects of `it` reach outside its geometry.
pub(crate) fn outset(it: &Item) -> f64 {
outset_scaled(it, 1.0)
}
/// [`outset`] for an item whose transform stretches lengths by up to `scale`: the blur and the
/// shadow's distance are spread lengths, but the spread's growth is stroked through the item's
/// transform and grows with it.
pub(crate) fn outset_scaled(it: &Item, scale: f64) -> f64 {
let e = &it.effects;
let extra = |size: f64, pct: f64| split(size, pct).1 * (scale - 1.0).max(0.0);
let mut o: f64 = 0.0;
if e.drop_shadow.on {
o = o.max(bound(e.drop_shadow.distance).abs() + bound(e.drop_shadow.size).abs() * 1.6 + 1.0);
let s = &e.drop_shadow;
o = o.max(bound(s.distance).abs() + bound(s.size).abs() * 1.6 + 1.0 + extra(s.size, s.spread));
}
if e.outer_glow.on {
o = o.max(bound(e.outer_glow.size).abs() * 1.6 + 1.0);
let g = &e.outer_glow;
o = o.max(bound(g.size).abs() * 1.6 + 1.0 + extra(g.size, g.spread));
}
o
}
/// The largest factor `m` stretches a length by (its linear part's largest singular value), or
/// None when that is not finite.
pub(crate) fn max_scale(m: Affine) -> Option<f64> {
let [a, b, c, d, _, _] = m.as_coeffs();
let s = a * a + b * b + c * c + d * d;
let det = a * d - b * c;
let v = ((s + (s * s - 4.0 * det * det).max(0.0).sqrt()) / 2.0).sqrt();
v.is_finite().then_some(v)
}
/// Whether `it` has a basic feather: a width on a closed shape.
fn basic_feather(it: &Item) -> bool {
it.effects.feather > 0.0 && it.path.is_closed()
@@ -120,7 +140,9 @@ impl Renderer {
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {
let doc = f.doc;
let e = &it.effects;
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
// The stroke and the effects' growth are drawn through `xf`, which stretches them.
let scale = max_scale(xf).unwrap_or(1.0);
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() * scale };
// Spread-space reach of what the object paints.
let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);
// The silhouette is the shape when the object paints its area (fill / image); text frames
@@ -150,7 +172,7 @@ impl Renderer {
let filter = shadow(sigma, paint);
// The offset moves the geometry, not the filter: vello drops layer content that lies
// entirely outside the viewport before filtering.
let r = (reach + off).inflate(grow, grow);
let r = (reach + off).inflate(grow * scale, grow * scale);
// The effect's blend sees what's behind the object: a layer around the filtered one.
let mix = crate::blend_mode(mode).mix;
if mix != Mix::Normal {
@@ -1516,4 +1538,86 @@ mod tests {
}
}
}
/// A hardened outer glow is stroked through the item's transform, so a scaled item's glow
/// grows with it. The multithreaded renderer draws the glow offscreen, cropped to its reach,
/// and that reach scales too.
#[test]
fn a_scaled_items_hard_glow_is_not_cropped() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, SpreadRef};
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut it = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)),
);
it.xf = Affine::scale(3.0);
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
// max_scale's own cases: a plain scale, a rotation and a shear.
assert_eq!(max_scale(it.xf), Some(3.0));
assert!((max_scale(Affine::rotate(0.7)).unwrap() - 1.0).abs() < 1e-12);
assert!((max_scale(Affine::skew(1.0, 0.0)).unwrap() - (1.0 + 5f64.sqrt()) / 2.0).abs() < 1e-12);
assert_eq!(max_scale(Affine::scale(f64::INFINITY)), None);
// The 20 pt growth is 60 pt on the spread: the glow covers 90..330.
assert_eq!(outset_scaled(&it, 3.0), outset(&it) + 40.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = crate::Cache::new();
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap();
let p = page.pixel(100, 210);
let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
assert!(lum < 200, "{threads} threads: the glow 50 pt left of the scaled square is drawn, luminance {lum}");
}
}
/// Culling measures a scaled item's stroke and hardened glow as they are drawn, through its
/// transform, so a view (or a repaint patch) that shows only that part still draws it.
#[test]
fn a_scaled_items_hard_glow_and_stroke_are_not_culled() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, SpreadRef};
let square = |glow: bool| {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut it = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)),
);
it.xf = Affine::scale(3.0);
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
if glow {
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
} else {
(it.stroke.swatch, it.stroke.weight) = (designcraft_color::swatch::BLACK.into(), 20.0);
}
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
d
};
let cache = crate::Cache::new();
let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
// The square covers 150..270 on the spread. Its glow grows 60 pt (to 90), past the
// unscaled 33 pt reach; its 20 pt stroke is 60 pt wide (to 120), past the unscaled 22 pt.
for (glow, x) in [(true, 95.0), (false, 123.0)] {
let d = square(glow);
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let place = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let image = r.render(&d, &cache, &place, (4, 20), Affine::translate((-x, -200.0)), &opts);
let p = image.pixel(1, 10);
let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
assert!(lum < 200, "glow {glow}, {threads} threads: the view at x {x} shows the scaled square's edge, luminance {lum}");
}
}
}
}
+28
View File
@@ -2420,6 +2420,34 @@ fn edits_patch_the_canvas_only_at_full_resolution() {
}
}
/// Moving a frame with a drop shadow repaints the regions the frame and its shadow cover, as
/// moving one without a shadow does, instead of rendering the whole canvas again on every step.
#[test]
fn moving_a_frame_with_a_drop_shadow_patches_the_canvas() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let frame = |app: &mut DesignApp| {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
};
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [72, 72, 200, 180], "content": "unassigned"})).unwrap();
app.run("object.fill", json!({"swatch": "[Black]"})).unwrap();
app.run("object.dropShadow", json!({"on": true})).unwrap();
for _ in 0..3 {
frame(&mut app);
}
for step in 1..=3 {
app.run("transform.move", json!({"dx": 4, "dy": 2})).unwrap();
frame(&mut app);
assert_eq!(app.canvas.patches, step, "drag step {step} repaints only what changed");
}
}
/// The Type tool's caret at the end of a new text frame holding `text`, the app's first frames
/// drawn; returns the story id.
fn typing_app(app: &mut DesignApp, ctx: &egui::Context, text: &str) -> Value {