Merge pull request #7295 from dotCooCoo/fix/negative-wrap-offset

Text Wrap: a negative Offset lets text run into the wrap object
This commit is contained in:
BFlat
2026-11-14 06:26:39 +07:00
committed by GitHub
4 changed files with 210 additions and 9 deletions
+29 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Text Wrap"],
None,
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them → {ids}",
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; a negative offset (to -1000) lets the text that far into them → {ids}",
has_selection,
make
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Wrap Options…",
["Object", "Text Wrap"],
None,
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects; no options → the current {offset, invert}",
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); no options → the current {offset, invert}",
has_selection,
options
),
@@ -186,6 +186,33 @@ mod tests {
assert_eq!(lines(&s, t), plain);
}
/// A negative offset from Text Wrap Options lets the lines beside a wrap object run that far
/// into it, the options report it, and one undo puts back the offset before it.
#[test]
fn text_wrap_options_negative_offset_reflows_and_round_trips() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let t = s
.execute("text.create", &json!({"x": 0, "y": 0, "text": "word ".repeat(200), "size": 12, "area": {"width": 400, "height": 400}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("shape.rectangle", &json!({"x": -50, "y": 50, "width": 250, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
let beside = |s: &Session| lines(s, t).into_iter().filter(|l| l.0 > 1.0).map(|l| l.0).collect::<Vec<_>>();
assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -20})).unwrap()["offset"], json!(-20.0));
assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 180.0).abs() < 1e-6), "{:?}", beside(&s));
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(-20.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0));
assert!(beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
// An offset past -1000 is kept at -1000: the whole object lets text through.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -5000})).unwrap()["offset"], json!(-1000.0));
assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0));
}
#[test]
fn text_flows_on_both_sides_of_a_centred_object() {
let mut s = Session::new();
+1 -1
View File
@@ -511,7 +511,7 @@ pub fn tool_definitions() -> Vec<Value> {
obj(
json!({
"ids": {"type": "array", "items": {"type": "integer"}},
"offset": num("Gap between text and object (default 6 pt)"),
"offset": num("Gap between text and object in pt, -1000 to 1000 (default 6 pt); a negative gap lets text that far into the object"),
"invert": {"type": "boolean"},
"release": {"type": "boolean"},
}),
+179 -6
View File
@@ -384,6 +384,80 @@ fn band_boundaries(polys: &[Vec<Point>], top: f64, bottom: f64) -> (Vec<(f64, f6
(union_intervals(edges), paired)
}
/// The largest magnitude a text wrap offset may have (points), the bound the Text Wrap commands
/// and `.ai` import keep too.
const MAX_WRAP_OFFSET: f64 = 1000.0;
/// A wrap object's offset as layout uses it: within [`MAX_WRAP_OFFSET`] either way, and 0 when it
/// is not a number.
fn wrap_offset(offset: f64) -> f64 {
if offset.is_nan() { 0.0 } else { offset.clamp(-MAX_WRAP_OFFSET, MAX_WRAP_OFFSET) }
}
/// The x at which some point of the band `top..bottom` (`top <= bottom`) is inside `polys`
/// (even-odd) and farther than `d` (> 0) from every edge of it, holes included: the cover of the
/// shape shrunk by `d`. The band is cut into pieces (at most [`INVERT_WRAP_PIECES`] even ones, and
/// at every anchor's height and `d` above and below it when there are at most that many of those);
/// in each piece, an x is left out when the shape is nowhere in the piece there, or when the whole
/// piece there lies within `d` of one edge. A piece where several edges together cover it keeps
/// its x, so the result can be wider than the shrunk shape, never narrower, and never wider than
/// the shape's own cover of the band.
fn eroded_cover(polys: &[Vec<Point>], top: f64, bottom: f64, d: f64) -> Vec<(f64, f64)> {
// A point `d` from an edge, up to rounding, counts as near it, so a band that only touches the
// shrunk shape is left whole.
let r = d * (1.0 + 1e-12);
let span = |p: &[Point]| p.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
// The shrunk shape lies more than `d` below the shape's top and above its bottom, so a band
// outside that holds none of it; within it, only contours that come within `d` of the band
// count (the others neither cross it nor bound the shape near it).
let (lo, hi) = polys.iter().map(|p| span(p)).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (c, e)| (a.min(c), b.max(e)));
if bottom <= lo + r || top >= hi - r {
return vec![];
}
let near_band: Vec<Vec<Point>> = polys
.iter()
.filter(|p| {
let (lo, hi) = span(p);
hi >= top - r && lo <= bottom + r
})
.cloned()
.collect();
let polys = near_band.as_slice();
let pieces = ((bottom - top) * 4.0 / d).ceil().clamp(1.0, INVERT_WRAP_PIECES);
let mut cuts: Vec<f64> = (0..=pieces as usize).map(|k| top + (bottom - top) * k as f64 / pieces).collect();
if bottom > top {
let mut anchors: Vec<f64> = polys.iter().flatten().flat_map(|p| [p.y - d, p.y, p.y + d]).filter(|&y| y > top && y < bottom).collect();
anchors.sort_by(f64::total_cmp);
anchors.dedup();
if anchors.len() <= INVERT_WRAP_PIECES as usize {
cuts.extend(anchors);
cuts.sort_by(f64::total_cmp);
cuts.dedup();
}
}
let mut out = Vec::new();
// A band of no height keeps its two equal cuts: one piece of no height, measured at its y.
for w in cuts.windows(2) {
let (a, b) = (w[0], w[1]);
let cover = band_cover(polys, a, b, false);
if cover.is_empty() {
continue;
}
// The points closer than `d` to an edge make a convex shape, so a vertical line within
// the piece lies in it when both its ends do.
let near = polys.iter().flat_map(|p| {
(0..p.len()).filter_map(move |i| {
let (s, t) = (p[i], p[(i + 1) % p.len()]);
let (u, v) = (near_segment(s, t, a, r)?, near_segment(s, t, b, r)?);
let (x, y) = (u.0.max(v.0), u.1.min(v.1));
(y > x).then_some((x, y))
})
});
out.extend(subtract_intervals(&cover, &union_intervals(near.collect())));
}
union_intervals(out)
}
impl Region {
pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
// A frame out of range has no cells, and a wrap shape out of range is left out.
@@ -392,7 +466,7 @@ impl Region {
let polys: std::sync::Arc<[Vec<Point>]> = polys.into();
let wraps: std::sync::Arc<[FlatWrap]> = wrap
.iter()
.filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert)))
.filter_map(|w| Some((polygons(&w.path.to_bezpath())?, wrap_offset(w.wrap.offset), w.wrap.invert)))
.filter(|w| !w.0.is_empty())
.collect();
let bbox = path.bounding_box();
@@ -431,8 +505,10 @@ impl Region {
}
/// `iv` minus every x at which a wrap object lies somewhere in the band `top..bottom` (or the x
/// inside it over the whole band, for Invert Wrap). Offsets grow each wrap shape vertically
/// and horizontally. Along the line, the text also keeps the inset beside each wrap object
/// inside it over the whole band, for Invert Wrap). A positive offset grows each wrap shape
/// vertically and horizontally; a negative one shrinks it by that distance from its edges
/// (holes included), so text runs that far into the object, or stays that far inside it for
/// Invert Wrap. Along the line, the text also keeps the inset beside each wrap object
/// (outside it, or inside it for Invert Wrap); above and below a wrap object only its offset
/// applies.
fn wrapped(&self, mut iv: Vec<(f64, f64)>, top: f64, bottom: f64) -> Vec<(f64, f64)> {
@@ -444,7 +520,17 @@ impl Region {
}
let grow = |v: Vec<(f64, f64)>| union_intervals(v.into_iter().map(|(a, b)| (a - off, b + off)).collect());
let shrink = |v: Vec<(f64, f64)>| v.into_iter().map(|(a, b)| (a + inset, b - inset)).filter(|(a, b)| b > a).collect::<Vec<_>>();
iv = if *invert {
let pad = |v: Vec<(f64, f64)>| union_intervals(v.into_iter().map(|(a, b)| (a - inset, b + inset)).collect());
iv = if *off < 0.0 {
// Invert Wrap keeps the x at which the shape shrunk by the offset holds the whole
// band; otherwise the text leaves out the x at which the shrunk shape lies
// somewhere in the band.
if *invert {
intersect_intervals(&iv, &shrink(band_inset_intervals(polys, top, bottom, -off)))
} else {
subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off)))
}
} else if *invert {
// The band is cut into pieces. Each height of a piece `y0..y1` is within the offset
// of some height from `y0` plus the offset to `y1` less the offset, so an x the
// shape holds at all of those heights is inside the grown shape over the piece. In
@@ -485,8 +571,7 @@ impl Region {
}
iv
} else {
let w = grow(band_cover(polys, top - off, bottom + off, false));
subtract_intervals(&iv, &union_intervals(w.into_iter().map(|(a, b)| (a - inset, b + inset)).collect()))
subtract_intervals(&iv, &pad(grow(band_cover(polys, top - off, bottom + off, false))))
};
}
iv
@@ -640,6 +725,94 @@ fn a_line_stays_clear_of_a_wrap_object_that_reaches_into_its_band() {
assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(50.0, 50.0), &[(15.0, 45.0)]);
}
/// The spans of a 100 pt square frame over the band `top..bottom` with one wrap object through
/// `contours` (even-odd).
#[cfg(test)]
fn wrap_spans(contours: &[&[(f64, f64)]], offset: f64, invert: bool, top: f64, bottom: f64) -> Vec<(f64, f64)> {
let frame = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
let wrap = vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&closed(contours)), wrap: vectorcraft_doc::TextWrap { offset, invert } };
Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom)
}
/// A negative offset lets text run into a wrap object by that distance, across and along the
/// line: beside a square, the line reaches 5 pt into it, and a line whose band reaches no more than
/// 5 pt into its top or bottom runs across it.
#[cfg(test)]
#[test]
fn negative_wrap_offset_insets_rectangle_on_both_axes() {
let square: &[(f64, f64)] = &[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)];
assert_spans(&wrap_spans(&[square], -5.0, false, 44.0, 50.0), &[(0.0, 45.0), (55.0, 100.0)]);
assert_spans(&wrap_spans(&[square], -5.0, false, 30.0, 45.0), &[(0.0, 100.0)]);
assert_spans(&wrap_spans(&[square], -5.0, false, 55.0, 70.0), &[(0.0, 100.0)]);
assert_spans(&wrap_spans(&[square], -5.0, false, 30.0, 46.0), &[(0.0, 45.0), (55.0, 100.0)]);
// The most an offset may take away is 1000 pt, and an offset that is not a number is 0.
assert_spans(&wrap_spans(&[square], f64::NEG_INFINITY, false, 44.0, 50.0), &[(0.0, 100.0)]);
assert_spans(&wrap_spans(&[square], f64::NAN, false, 44.0, 50.0), &[(0.0, 40.0), (60.0, 100.0)]);
}
/// A negative offset is measured from a sloped edge itself (10 pt across the edge of a triangle is
/// 10√2 pt along the line), and from the edges of a hole too.
#[cfg(test)]
#[test]
fn negative_wrap_offset_insets_sloped_shape_and_hole() {
let triangle: &[(f64, f64)] = &[(0.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
assert_spans(&wrap_spans(&[triangle], -10.0, false, 40.0, 60.0), &[(0.0, 10.0), (60.0 - 10.0 * std::f64::consts::SQRT_2, 100.0)]);
let outer: &[(f64, f64)] = &[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0), (20.0, 80.0)];
let hole: &[(f64, f64)] = &[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)];
assert_spans(&wrap_spans(&[outer, hole], -5.0, false, 48.0, 52.0), &[(0.0, 25.0), (35.0, 65.0), (75.0, 100.0)]);
}
/// With Invert Wrap, a negative offset keeps the text that far inside the object, along and across
/// the line.
#[cfg(test)]
#[test]
fn negative_invert_wrap_keeps_only_eroded_interior() {
let square: &[(f64, f64)] = &[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)];
assert_spans(&wrap_spans(&[square], -5.0, true, 46.0, 54.0), &[(45.0, 55.0)]);
assert!(wrap_spans(&[square], -5.0, true, 42.0, 48.0).is_empty());
let triangle: &[(f64, f64)] = &[(0.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
assert_spans(&wrap_spans(&[triangle], -10.0, true, 40.0, 60.0), &[(10.0, 40.0 - 10.0 * std::f64::consts::SQRT_2)]);
}
/// A negative offset at least half as large as a wrap object is wide leaves nothing of it: text
/// runs across it, and with Invert Wrap no text fits inside it.
#[cfg(test)]
#[test]
fn negative_offset_erases_narrow_wrap_shape() {
let strip: &[(f64, f64)] = &[(40.0, 0.0), (48.0, 0.0), (48.0, 100.0), (40.0, 100.0)];
for offset in [-4.0, -5.0, -1000.0] {
assert_spans(&wrap_spans(&[strip], offset, false, 40.0, 60.0), &[(0.0, 100.0)]);
assert!(wrap_spans(&[strip], offset, true, 40.0, 60.0).is_empty(), "offset {offset}");
}
// A band of no height.
assert_spans(&wrap_spans(&[strip], -1.0, false, 50.0, 50.0), &[(0.0, 41.0), (47.0, 100.0)]);
}
/// A negative offset never keeps text farther from a wrap object than an offset of 0 does, on a
/// shape whose edges meet at many angles.
#[cfg(test)]
#[test]
fn negative_offset_never_wraps_more_than_no_offset() {
let star: Vec<(f64, f64)> = (0..40)
.map(|k| {
let (r, a) = (if k % 2 == 0 { 40.0 } else { 15.0 }, f64::from(k) * std::f64::consts::PI / 20.0);
(50.0 + r * a.cos(), 50.0 + r * a.sin())
})
.collect();
for (top, bottom) in [(10.0, 22.0), (44.0, 56.0), (30.0, 31.0), (70.0, 90.0)] {
for invert in [false, true] {
let zero = wrap_spans(&[star.as_slice()], 0.0, invert, top, bottom);
let neg = wrap_spans(&[star.as_slice()], -3.0, invert, top, bottom);
let width = |v: &[(f64, f64)]| v.iter().map(|(a, b)| b - a).sum::<f64>();
if invert {
assert!(width(&neg) <= width(&zero) + 1e-9, "{top}..{bottom}: {neg:?} {zero:?}");
} else {
assert!(width(&neg) >= width(&zero) - 1e-9, "{top}..{bottom}: {neg:?} {zero:?}");
}
}
}
}
/// A hole that starts and ends inside a line's band splits the line, and a band below the hole, or
/// one that only touches it, keeps the whole width.
#[cfg(test)]
File diff suppressed because one or more lines are too long