mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge latest main into baseline grid justification PR
This commit is contained in:
8 files changed
+896
-25
No files matched your search
+190
-8
@@ -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).
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user