Layout: wrap both sides of an object and around its contour

Lines beside an object that lets text through on both sides are set in
segments, left then right, and the paragraph runs on from one to the
next. One-sided wraps (left, right, spine sides, largest area) block the
other side. A contour wrap follows the object's outline band by band,
with the offset around it; graphic frames follow their contour type
(bounding box of the graphic, frame path, same as clipping; detected
edges, alpha channel and image paths follow the frame path). The Text
Wrap panel sets the wrap side and contour type through object.textWrap,
and IDML import and export keep ContourOption.
This commit is contained in:
aiongg
2026-10-10 21:28:03 +00:00
parent a127c6d5c3
commit fbda023d7f
13 changed files with 553 additions and 114 deletions
+273 -71
View File
@@ -30,7 +30,7 @@ use std::sync::{Arc, Mutex};
use designcraft_doc::{
Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions,
VerticalJustification, WrapMode, story,
VerticalJustification, WrapMode, WrapSide, story,
};
use designcraft_fonts::{FontDb, ScopedFonts};
use designcraft_geom::{Point, Rect};
@@ -287,8 +287,128 @@ impl ComposedStory {
/// A text-wrap exclusion in a frame's inner space.
#[derive(Clone, Debug, PartialEq)]
pub struct Exclusion {
/// Bounding box, offsets included.
pub rect: Rect,
pub mode: WrapMode,
/// Where text may go beside the object. Spine sides are resolved to `LeftSide`/`RightSide`.
pub side: WrapSide,
/// Contour wrap: the outline the text follows (None wraps around `rect`).
pub contour: Option<Contour>,
}
/// An object outline for contour wrap: closed polylines in the frame's inner space and the
/// distance text keeps from them.
#[derive(Clone, Debug, PartialEq)]
pub struct Contour {
pub rings: Vec<Vec<Point>>,
pub offset: f64,
}
/// Points a contour outline may have; a larger outline wraps around its bounding box.
const MAX_CONTOUR_POINTS: usize = 8192;
impl Contour {
/// The outline of `path` (after `to_inner`), flattened; None if it is empty or too detailed.
fn of(path: &designcraft_geom::BezPath, to_inner: designcraft_geom::Affine, offset: f64) -> Option<Contour> {
let mut rings: Vec<Vec<Point>> = Vec::new();
let mut n = 0usize;
let mut too_many = false;
designcraft_geom::kurbo::flatten(to_inner * path.clone(), 0.25, |el| {
n += 1;
too_many |= n > MAX_CONTOUR_POINTS;
match el {
designcraft_geom::PathEl::MoveTo(p) => rings.push(vec![p]),
designcraft_geom::PathEl::LineTo(p) => match rings.last_mut() {
Some(r) => r.push(p),
None => rings.push(vec![p]),
},
_ => {}
}
});
rings.retain(|r| r.len() >= 2 && r.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
(!too_many && !rings.is_empty() && offset.is_finite()).then_some(Contour { rings, offset: offset.max(0.0) })
}
fn bounds(&self) -> Rect {
let mut pts = self.rings.iter().flatten();
let Some(&p0) = pts.next() else { return Rect::ZERO };
let r = pts.fold(Rect::from_points(p0, p0), |r, &p| r.union_pt(p));
r.inflate(self.offset, self.offset)
}
/// Horizontal extent of the outline (offset included) within the band `y0..y1`: the band is
/// widened by the offset, and the outline's x-range there is widened by it too.
fn band_extent(&self, y0: f64, y1: f64) -> Option<(f64, f64)> {
let (y0, y1) = (y0 - self.offset, y1 + self.offset);
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for ring in &self.rings {
let Some(&last) = ring.last() else { continue };
let mut p = last;
for &q in ring {
let (a, b) = (p, q);
p = q;
if a.y.max(b.y) < y0 || a.y.min(b.y) > y1 {
continue;
}
let dy = b.y - a.y;
let (t0, t1) = if dy.abs() < 1e-12 {
(0.0, 1.0)
} else {
let (ta, tb) = ((y0 - a.y) / dy, (y1 - a.y) / dy);
(ta.min(tb).clamp(0.0, 1.0), ta.max(tb).clamp(0.0, 1.0))
};
for t in [t0, t1] {
let x = a.x + t * (b.x - a.x);
lo = lo.min(x);
hi = hi.max(x);
}
}
}
(lo <= hi).then_some((lo - self.offset, hi + self.offset))
}
}
/// The wrap exclusion of `other` (an item whose inner space maps to the spread by `xf`) for a
/// frame whose inner space is reached from the spread by `inv`. `left_page` places the object on
/// a left page (for the spine sides).
pub fn exclusion(other: &designcraft_doc::Item, xf: designcraft_geom::Affine, inv: designcraft_geom::Affine, left_page: bool) -> Exclusion {
let o = other.wrap.offsets;
let b = if other.shape == designcraft_doc::Shape::Group {
(xf * other.xf.inverse()).transform_rect_bbox(other.bounds())
} else {
other.path.transformed(xf).bounds().unwrap_or(Rect::ZERO)
};
let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]);
let side = match other.wrap.side {
WrapSide::TowardsSpine if left_page => WrapSide::RightSide,
WrapSide::TowardsSpine => WrapSide::LeftSide,
WrapSide::AwayFromSpine if left_page => WrapSide::LeftSide,
WrapSide::AwayFromSpine => WrapSide::RightSide,
s => s,
};
let contour = (other.wrap.mode == WrapMode::Contour && other.shape != designcraft_doc::Shape::Group)
.then(|| contour_path(other))
.and_then(|path| Contour::of(&path, inv * xf, other.wrap.offsets[0]));
let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds);
Exclusion { rect, mode: other.wrap.mode, side, contour }
}
/// The outline a contour wrap follows, in the item's inner space. Graphic frames follow their contour type; detected edges, alpha channels and image paths need
/// the image, so they follow the frame's path (as other frames do).
fn contour_path(it: &designcraft_doc::Item) -> designcraft_geom::BezPath {
let frame = designcraft_geom::corners::apply(&it.path, &it.corners);
if it.wrap.contour == designcraft_doc::ContourType::BoundingBox
&& let Some(g) = it.graphic()
&& let Some(fb) = it.path.bounds()
{
let gb = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1));
let r = gb.intersect(fb);
if r.width() > 0.0 && r.height() > 0.0 {
return designcraft_geom::kurbo::Shape::to_path(&r, 0.1);
}
}
frame
}
/// One frame of the thread, ready for composition.
@@ -366,11 +486,11 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
if doc.layer(other.layer).is_some_and(|l| !l.visible) {
continue;
}
let o = other.wrap.offsets;
let b = other.bounds();
let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]);
let inner = inv.transform_rect_bbox(r);
exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode });
let left = sp
.page_at_x(other.bounds().center().x)
.and_then(|pi| sp.pages.get(pi))
.is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
exclusions.push(exclusion(other, other.xf, inv, left));
}
}
let (page_name, page, left_page) = match (loc.spread, spread) {
@@ -425,7 +545,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
let vertical = doc.frame_vertical(item);
let (area, exclusions, grid, page_rect) = if vertical {
let v = designcraft_doc::vertical_text_xf(area).inverse();
let ex = exclusions.into_iter().map(|e| Exclusion { rect: v.transform_rect_bbox(e.rect), ..e }).collect();
let ex = exclusions
.into_iter()
.map(|e| Exclusion {
rect: v.transform_rect_bbox(e.rect),
contour: e.contour.map(|c| Contour { rings: c.rings.iter().map(|r| r.iter().map(|&p| v * p).collect()).collect(), ..c }),
..e
})
.collect();
let pr = page_rect.map(|(a, b)| (v.transform_rect_bbox(a), v.transform_rect_bbox(b)));
(Rect::new(0.0, 0.0, area.height(), area.width()), ex, None, pr)
} else {
@@ -867,6 +994,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut col_first_line = 0usize;
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
// Segments of a line already placed (beside an object with text on both sides) that the
// next lines of the paragraph fill before a new line starts, and that line's baseline.
let mut carry: Vec<(f64, f64)> = Vec::new();
let mut carry_baseline = 0.0;
// Re-breaks after a line's segment turned out other than the breaker assumed.
let mut rebreaks = 0usize;
loop {
if cur.fi >= frames.len() {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
@@ -887,8 +1021,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col };
let col = cur.split_rect(col);
// Estimate slots for the breaker with the paragraph's base leading.
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let est_first =
if carry.is_empty() { cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp) } else { carry_baseline + base_leading };
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], rtl, &carry);
let width = |j: usize| -> f64 {
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
@@ -915,39 +1050,60 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let line_glyphs = &glyphs[s..e.max(s)];
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let reference = cjk_line_reference(line_glyphs);
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
// Baseline grid.
if let Some((g_start, inc)) = f.grid
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
&& inc > 0.0
{
let n = ((baseline - g_start) / inc - 1e-6).ceil();
baseline = g_start + n * inc;
}
// Wrap: push the line down until a slot exists.
let (mut x0, mut x1) = (col.x0, col.x1);
if !f.exclusions.is_empty() {
let mut tries = 0;
loop {
match free_slot(f, col, baseline - asc, baseline + desc, base_size) {
Some((a, b)) => {
x0 = a;
x1 = b;
let continues = !carry.is_empty();
let mut baseline;
if continues {
// The next segment of a line already placed.
baseline = carry_baseline;
(x0, x1) = carry.remove(0);
} else {
carry.clear();
baseline = cur.next_baseline(f, col, lead, asc, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
// Baseline grid.
if let Some((g_start, inc)) = f.grid
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
&& inc > 0.0
{
let n = ((baseline - g_start) / inc - 1e-6).ceil();
baseline = g_start + n * inc;
}
// Wrap: push the line down until a slot exists.
if !f.exclusions.is_empty() {
let mut tries = 0;
loop {
let segs = free_slots(f, col, baseline - asc, baseline + desc, base_size, rtl);
if let Some((&first, rest)) = segs.split_first() {
(x0, x1) = first;
carry = rest.to_vec();
carry_baseline = baseline;
break;
}
None => {
baseline += 1.0;
tries += 1;
if baseline > col.y1 || tries > 4000 {
break;
}
baseline += 1.0;
tries += 1;
if baseline > col.y1 || tries > 4000 {
break;
}
}
}
}
// The breaker set this line for another segment width: re-break the rest of the
// paragraph from here, starting with this line's segments as they are. A pass
// re-breaks at its first line only for a new line, whose segments it carries, so
// every re-break makes progress; their number is capped too.
if (k > 0 || !continues) && rebreaks < 256 && slots.get(k).is_some_and(|&(a, b)| ((b - a) - (x1 - x0)).abs() > 0.5) {
rebreaks += 1;
carry.insert(0, (x0, x1));
if !continues {
carry_baseline = baseline;
}
g0 = s;
moved = true;
break;
}
let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line;
// Footnotes referenced on this line need room at the bottom of the column too
// (a line at the top of a column is set anyway).
@@ -961,6 +1117,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
g0 = s;
cur.next_sub();
col_first_line = line_no;
carry.clear();
moved = true;
break;
}
@@ -1018,6 +1175,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
g0 = s;
next_column(&mut cur);
col_first_line = line_no;
carry.clear();
moved = true;
break;
}
@@ -1100,6 +1258,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
begin_at(&mut cur, start);
col_first_line = line_no;
g0 += b.next;
carry.clear();
moved = true;
break;
}
@@ -2071,61 +2230,87 @@ fn line_metrics(
(asc, desc, lead)
}
/// Widest free horizontal interval of `col` in the band, or None if blocked.
fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> {
/// Free segments of `col` in the band `y0..y1`, in the order text fills them (left to right, or
/// right to left for right-to-left paragraphs); empty if the band is blocked. A line beside an
/// object that lets text through on both sides has a segment on each side. Segments narrower than
/// 1.5 em of `size` are skipped (the model has no minimum segment width).
fn free_slots(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, rtl: bool) -> Vec<(f64, f64)> {
let mut free = vec![(col.x0, col.x1)];
for ex in &f.exclusions {
let r = ex.rect;
let overlaps_band = r.y0 < y1 && r.y1 > y0;
match ex.mode {
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None,
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => {
let mut next = Vec::new();
for (a, b) in free {
if r.x1 <= a || r.x0 >= b {
next.push((a, b));
continue;
}
if r.x0 > a {
next.push((a, r.x0));
}
if r.x1 < b {
next.push((r.x1, b));
}
}
free = next;
let blocked = match ex.mode {
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return vec![],
WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return vec![],
WrapMode::BoundingBox if overlaps_band => Some((r.x0, r.x1)),
WrapMode::Contour if overlaps_band => match &ex.contour {
Some(c) => c.band_extent(y0, y1),
None => Some((r.x0, r.x1)),
},
_ => None,
};
let Some((a, b)) = blocked.filter(|&(a, b)| a < col.x1 && b > col.x0) else { continue };
// One-sided wraps also block the column on the other side of the object.
let text_left = match ex.side {
WrapSide::LeftSide => Some(true),
WrapSide::RightSide => Some(false),
WrapSide::LargestArea => Some(r.x0 - col.x0 >= col.x1 - r.x1),
WrapSide::BothSides | WrapSide::TowardsSpine | WrapSide::AwayFromSpine => None,
};
let (a, b) = match text_left {
Some(true) => (a, b.max(col.x1)),
Some(false) => (a.min(col.x0), b),
None => (a, b),
};
let mut next = Vec::with_capacity(free.len() + 1);
for (fa, fb) in free {
if b <= fa || a >= fb {
next.push((fa, fb));
continue;
}
if a > fa {
next.push((fa, a));
}
if b < fb {
next.push((b, fb));
}
_ => {}
}
free = next;
}
free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))
free.retain(|(a, b)| b - a >= size * 1.5);
if rtl {
free.reverse();
}
free
}
/// Line slots for the breaker: the free segments of each line from baseline `first` down, `lead`
/// apart; `carry` holds segments left on the line above (they come first).
#[allow(clippy::too_many_arguments)]
fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> {
let _ = (pp, line_no);
fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], rtl: bool, carry: &[(f64, f64)]) -> Vec<(f64, f64)> {
if f.exclusions.is_empty() {
return vec![];
}
// Rough upper bound on lines: total advance / column width × 2.
let total: f64 = glyphs.iter().map(|g| g.adv).sum();
let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000);
let mut v = Vec::with_capacity(n);
let mut v = Vec::with_capacity(n + carry.len());
v.extend_from_slice(carry);
let mut b = first;
for _ in 0..n {
while v.len() < n + carry.len() {
let mut tries = 0;
let slot = loop {
if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) {
let slots = loop {
let s = free_slots(f, col, b - size * 0.8, b + size * 0.25, size, rtl);
if !s.is_empty() {
break s;
}
b += 1.0;
tries += 1;
if b > col.y1 || tries > 4000 {
break (col.x0, col.x1);
break vec![(col.x0, col.x1)];
}
};
v.push(slot);
v.extend(slots);
b += lead;
}
v
@@ -2802,10 +2987,25 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
match f.opts.vertical_justification {
VerticalJustification::Center => idx.iter().for_each(|&i| ft.lines[i].baseline += space / 2.0),
VerticalJustification::Bottom => idx.iter().for_each(|&i| ft.lines[i].baseline += space),
VerticalJustification::Justify if idx.len() > 1 => {
let per = space / (idx.len() - 1) as f64;
for (k, &i) in idx.iter().enumerate() {
ft.lines[i].baseline += per * k as f64;
VerticalJustification::Justify => {
// Segments of one line (beside an object) share a row and move together.
let rows: Vec<usize> = idx
.iter()
.scan((0usize, f64::NAN), |(row, prev), &i| {
let b = ft.lines[i].baseline;
if !prev.is_nan() && (b - *prev).abs() > 1e-9 {
*row += 1;
}
*prev = b;
Some(*row)
})
.collect();
let n = rows.last().copied().unwrap_or(0);
if n > 0 {
let per = space / n as f64;
for (&i, &k) in idx.iter().zip(&rows) {
ft.lines[i].baseline += per * k as f64;
}
}
}
_ => {}
@@ -3019,12 +3219,14 @@ pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
if ft.lines.is_empty() {
return Some(ft.range.start);
}
// Closest line vertically (within its column).
// Closest line vertically (within its column), then horizontally (segments of a line beside
// an object share its baseline).
let x_dist = |l: &Line| (l.x0 - p.x).max(p.x - l.x1).max(0.0);
let l = ft
.lines
.iter()
.filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1)
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)))
.min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y)).then(x_dist(a).total_cmp(&x_dist(b))))
.or_else(|| ft.lines.first())?;
if l.glyphs.iter().any(|g| g.rtl) {
// Bidi: the caret position drawn nearest the point.
+78
View File
@@ -146,6 +146,84 @@ fn wrap_pushes_text_aside() {
assert!(below.x0 < 1.0);
}
/// A text frame (0,0)-(300,800) with an ellipse (110,100)-(190,200) wrapped as `mode`/`side`.
fn doc_with_ellipse(mode: WrapMode, side: WrapSide) -> (Document, StoryId) {
let (mut d, sid, _) = doc_with(&[LOREM; 4].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it =
designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Oval, designcraft_geom::shapes::ellipse(Rect::new(110.0, 100.0, 190.0, 200.0)));
it.wrap.mode = mode;
it.wrap.side = side;
it.wrap.offsets = [4.0; 4];
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
(d, sid)
}
#[test]
fn wrap_on_both_sides_sets_text_left_then_right_of_the_object() {
let (d, sid) = doc_with_ellipse(WrapMode::Contour, WrapSide::BothSides);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = &cs.frames[0].lines;
let pairs: Vec<(&Line, &Line)> = lines.windows(2).filter(|w| w[0].baseline == w[1].baseline).map(|w| (&w[0], &w[1])).collect();
assert!(pairs.len() >= 4, "{} lines share a baseline", pairs.len());
let ex = &frame_specs(&d, sid)[0].exclusions[0];
for (l, r) in &pairs {
// Left segment, then the right one; the text runs on from one to the other.
assert!(l.x1 < 150.0 && r.x0 > 150.0, "{} {}", l.x1, r.x0);
assert_eq!(l.range.end, r.range.start);
// No glyph inside the wrapped outline at its line.
let (a, b) = ex.contour.as_ref().unwrap().band_extent(l.baseline - l.ascent, l.baseline + l.descent).unwrap();
let n = l.glyphs.len().saturating_sub(1);
// The left segment's last glyph may be the space the line breaks at.
for g in l.glyphs.iter().take(n).chain(&r.glyphs).filter(|g| g.visible) {
assert!(g.x + g.adv <= a + 0.5 || g.x >= b - 0.5, "glyph at {}..{} in {a}..{b}", g.x, g.x + g.adv);
}
}
// Lines beside the narrow top of the ellipse reach closer to its centre than those at its middle.
let mid = pairs.iter().map(|(l, _)| l.x1).fold(f64::INFINITY, f64::min);
let top = pairs.first().unwrap().0.x1;
assert!(top > mid + 5.0, "top {top}, middle {mid}");
assert!(!cs.is_overset());
}
#[test]
fn one_sided_wrap_leaves_the_other_side_empty() {
for (side, right) in [(WrapSide::RightSide, true), (WrapSide::LeftSide, false), (WrapSide::LargestArea, false)] {
let (d, sid) = doc_with_ellipse(WrapMode::BoundingBox, side);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let beside: Vec<&Line> = cs.frames[0].lines.iter().filter(|l| l.baseline - l.ascent < 200.0 && l.baseline + l.descent > 100.0).collect();
assert!(!beside.is_empty());
let mut rows: Vec<f64> = beside.iter().map(|l| l.baseline).collect();
rows.dedup();
assert_eq!(rows.len(), beside.len(), "{side:?}: one segment per line");
for l in beside {
assert!(if right { l.x0 >= 194.0 - 1e-6 } else { l.x1 <= 106.0 + 1e-6 }, "{side:?}: {}..{}", l.x0, l.x1);
}
}
}
#[test]
fn contour_wrap_follows_the_outline_band_by_band() {
let (d, sid) = doc_with_ellipse(WrapMode::Contour, WrapSide::BothSides);
let ex = &frame_specs(&d, sid)[0].exclusions[0];
let c = ex.contour.as_ref().unwrap();
let width = |y0: f64, y1: f64| c.band_extent(y0, y1).map_or(0.0, |(a, b)| b - a);
let (top, mid, bottom) = (width(100.0, 104.0), width(146.0, 154.0), width(196.0, 200.0));
// 80 wide plus the 4 pt offset each side at the middle; much narrower at the ends.
assert!((mid - 88.0).abs() < 0.5, "{mid}");
assert!(top < mid - 20.0 && bottom < mid - 20.0, "{top} {mid} {bottom}");
// The band is the line's height, not its baseline: a band reaching the middle is full width.
assert!((width(120.0, 150.0) - 88.0).abs() < 0.5);
// Outside the outline and its offset: no block.
assert!(c.band_extent(205.0, 210.0).is_none());
// A bounding-box wrap blocks the rectangle at every band.
let (d, sid) = doc_with_ellipse(WrapMode::BoundingBox, WrapSide::BothSides);
let ex = &frame_specs(&d, sid)[0].exclusions[0];
assert!(ex.contour.is_none());
assert_eq!(ex.rect, Rect::new(106.0, 96.0, 194.0, 204.0));
}
#[test]
fn paragraph_composer_is_no_worse_than_greedy() {
// Sum of squared slack over lines (excluding last) should not exceed the greedy result.
+21
View File
@@ -415,6 +415,26 @@ pub enum WrapSide {
LargestArea,
}
/// Text Wrap panel › Contour Options › Type: the outline a contour wrap follows on a graphic frame.
/// Other frames always wrap around their own path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ContourType {
/// The placed graphic's rectangle (within the frame).
BoundingBox,
/// Edges detected in the image. Composition follows the frame path.
DetectEdges,
/// The image's alpha channel. Composition follows the frame path.
AlphaChannel,
/// A path saved in the image. Composition follows the frame path.
PhotoshopPath,
/// The frame's path.
GraphicFrame,
/// The graphic's clipping path, which is the frame's path.
#[default]
SameAsClipping,
}
/// Text Wrap panel settings.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@@ -424,6 +444,7 @@ pub struct TextWrap {
pub offsets: [f64; 4],
pub invert: bool,
pub side: WrapSide,
pub contour: ContourType,
}
/// Frame fitting options (Object → Fitting → Frame Fitting Options).
+50 -24
View File
@@ -512,31 +512,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Wrap",
["Window"],
Some("Cmd+Alt+W"),
"{mode: none|boundingBox|contour|jumpObject|jumpToNextColumn, offset?: number|[t,l,b,r], invert?: bool, ids?}",
"{mode?: none|boundingBox|contour|jumpObject|jumpToNextColumn (boundingBox when absent, unless side or contour is given: then unchanged), offset?: number|[t,l,b,r], invert?: bool, side?: bothSides|leftSide|rightSide|towardsSpine|awayFromSpine|largestArea, contour?: boundingBox|graphicFrame|sameAsClipping, ids?}",
has_selection,
|s, p| {
let mode = p.get("mode").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_doc::WrapMode::BoundingBox);
let off = match p.get("offset") {
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
Some(v) => serde_json::from_value(v.clone()).ok(),
None => None,
};
let invert = p.get("invert").and_then(Value::as_bool);
set_flag(
s,
p,
move |i| {
i.wrap.mode = mode;
if let Some(o) = off {
i.wrap.offsets = o;
}
if let Some(v) = invert {
i.wrap.invert = v;
}
},
false,
)
}
text_wrap
),
cmd!(
"object.fit",
@@ -1352,6 +1330,54 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn text_wrap(s: &mut Session, p: &Value) -> Result<Value> {
use designcraft_doc::{ContourType, WrapMode, WrapSide};
let side: Option<WrapSide> = match p.get("side") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown side"))?),
None => None,
};
// Detected edges, alpha channels and image paths need the image's pixels or paths, which
// composition doesn't read; those types aren't offered.
let contour = match p.get("contour").map(|v| serde_json::from_value::<ContourType>(v.clone())) {
Some(Ok(c @ (ContourType::BoundingBox | ContourType::GraphicFrame | ContourType::SameAsClipping))) => Some(c),
Some(_) => return Err(bad("object.textWrap", "unsupported contour type")),
None => None,
};
let mode: Option<WrapMode> = match p.get("mode") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad("object.textWrap", "unknown mode"))?),
None if side.is_some() || contour.is_some() => None,
None => Some(WrapMode::BoundingBox),
};
let off = match p.get("offset") {
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
Some(v) => serde_json::from_value(v.clone()).ok(),
None => None,
};
let invert = p.get("invert").and_then(Value::as_bool);
set_flag(
s,
p,
move |i| {
if let Some(m) = mode {
i.wrap.mode = m;
}
if let Some(o) = off {
i.wrap.offsets = o;
}
if let Some(v) = invert {
i.wrap.invert = v;
}
if let Some(v) = side {
i.wrap.side = v;
}
if let Some(c) = contour {
i.wrap.contour = c;
}
},
false,
)
}
fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
+3 -2
View File
@@ -735,8 +735,9 @@ fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize>
{
return Some(p);
}
let target = if up { cur.checked_sub(1)? } else { cur + 1 };
let (tf, tl) = lines.get(target)?;
// The next row: segments of the same line (beside a wrap object) share its baseline.
let other_row = |(i, t): &&(usize, &compose::Line)| *i != fi || (t.baseline - l.baseline).abs() > 1e-6;
let (tf, tl) = if up { lines.get(..cur)?.iter().rfind(other_row)? } else { lines.get(cur + 1..)?.iter().find(other_row)? };
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
}
+10
View File
@@ -511,6 +511,16 @@ fn object_set_flags_and_wrap_invert() {
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
s.execute("object.textWrap", &json!({"mode": "boundingBox", "invert": true})).unwrap();
assert!(item(&s).wrap.invert);
// Side and contour type keep the mode; contour types that need the image aren't accepted.
s.execute("object.textWrap", &json!({"mode": "contour"})).unwrap();
s.execute("object.textWrap", &json!({"side": "largestArea", "contour": "graphicFrame"})).unwrap();
let w = item(&s).wrap;
assert_eq!(
(w.mode, w.side, w.contour),
(designcraft_doc::WrapMode::Contour, designcraft_doc::WrapSide::LargestArea, designcraft_doc::ContourType::GraphicFrame)
);
assert!(s.execute("object.textWrap", &json!({"contour": "detectEdges"})).is_err());
assert!(s.execute("object.textWrap", &json!({"side": "middle"})).is_err());
}
#[test]
+17 -14
View File
@@ -1716,20 +1716,23 @@ impl<'a> Ex<'a> {
}
}
let w = &it.wrap;
el.push(with_props(
El::new("TextWrapPreference")
.attr("Inverse", bool_s(w.invert))
.attr("ApplyToMasterPageOnly", "false")
.attr("TextWrapSide", names::wrap_side_out(w.side))
.attr("TextWrapMode", names::wrap_mode_out(w.mode)),
vec![
El::new("TextWrapOffset")
.attr("Top", num(w.offsets[0]))
.attr("Left", num(w.offsets[1]))
.attr("Bottom", num(w.offsets[2]))
.attr("Right", num(w.offsets[3])),
],
));
el.push(
with_props(
El::new("TextWrapPreference")
.attr("Inverse", bool_s(w.invert))
.attr("ApplyToMasterPageOnly", "false")
.attr("TextWrapSide", names::wrap_side_out(w.side))
.attr("TextWrapMode", names::wrap_mode_out(w.mode)),
vec![
El::new("TextWrapOffset")
.attr("Top", num(w.offsets[0]))
.attr("Left", num(w.offsets[1]))
.attr("Bottom", num(w.offsets[2]))
.attr("Right", num(w.offsets[3])),
],
)
.child(El::new("ContourOption").attr("ContourType", names::contour_type_out(w.contour)).attr("IncludeInsideEdges", "false")),
);
match &it.content {
Content::Group { items } => {
for c in items {
+1
View File
@@ -2099,6 +2099,7 @@ impl<'r> Importer<'r> {
mode: names::wrap_mode_in(w.get("TextWrapMode").unwrap_or("None")),
invert: w.get("Inverse") == Some("true"),
side: names::wrap_side_in(w.get("TextWrapSide").unwrap_or("BothSides")),
contour: names::contour_type_in(w.find("ContourOption").and_then(|c| c.get("ContourType")).unwrap_or("SameAsClipping")),
..TextWrap::default()
};
if let Some(o) = w.prop_el("TextWrapOffset") {
+18 -2
View File
@@ -2,8 +2,8 @@
use designcraft_color::BlendMode;
use designcraft_doc::{
Align, Arrowhead, AutoSize, Cap, Capitalization, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, StrokeType,
TabAlign, VerticalJustification, WrapMode, WrapSide,
Align, Arrowhead, AutoSize, Cap, Capitalization, ContourType, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign,
StrokeType, TabAlign, VerticalJustification, WrapMode, WrapSide,
};
use designcraft_geom::Unit;
use designcraft_geom::corners::CornerShape;
@@ -384,6 +384,22 @@ pub fn wrap_mode_in(s: &str) -> WrapMode {
.find(|m| wrap_mode_out(*m) == s)
.unwrap_or(WrapMode::None)
}
pub fn contour_type_out(c: ContourType) -> &'static str {
match c {
ContourType::BoundingBox => "BoundingBox",
ContourType::DetectEdges => "DetectEdges",
ContourType::AlphaChannel => "AlphaChannel",
ContourType::PhotoshopPath => "PhotoshopPath",
ContourType::GraphicFrame => "GraphicFrame",
ContourType::SameAsClipping => "SameAsClipping",
}
}
pub fn contour_type_in(s: &str) -> ContourType {
[ContourType::BoundingBox, ContourType::DetectEdges, ContourType::AlphaChannel, ContourType::PhotoshopPath, ContourType::GraphicFrame]
.into_iter()
.find(|c| contour_type_out(*c) == s)
.unwrap_or(ContourType::SameAsClipping)
}
pub fn wrap_side_out(s: WrapSide) -> &'static str {
match s {
WrapSide::BothSides => "BothSides",
+30
View File
@@ -1399,3 +1399,33 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() {
check(&d);
check(&import_idml(&export_idml(&d)).unwrap());
}
#[test]
fn imports_and_round_trips_text_wrap_side_and_contour_type() {
let wrap = r#"<TextWrapPreference TextWrapMode="Contour" TextWrapSide="LeftSide"><ContourOption ContourType="BoundingBox" IncludeInsideEdges="false"/></TextWrapPreference><Image Self="im""#;
let spread = SPREAD.replace(r#"<Image Self="im""#, wrap);
assert_ne!(spread, SPREAD);
let bytes = zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", STORY),
]);
let mut d = import_idml(&bytes).unwrap();
let id = d.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id;
let w = d.item(id).unwrap().wrap;
assert_eq!(
(w.mode, w.side, w.contour),
(designcraft_doc::WrapMode::Contour, designcraft_doc::WrapSide::LeftSide, designcraft_doc::ContourType::BoundingBox)
);
// Every side and contour type survives export and import (detected edges included, which
// composition follows as the frame's path).
d.item_mut(id).unwrap().wrap.side = designcraft_doc::WrapSide::AwayFromSpine;
d.item_mut(id).unwrap().wrap.contour = designcraft_doc::ContourType::DetectEdges;
let back = import_idml(&export_idml(&d)).unwrap();
let w = back.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().wrap;
assert_eq!((w.side, w.contour), (designcraft_doc::WrapSide::AwayFromSpine, designcraft_doc::ContourType::DetectEdges));
}
+5
View File
@@ -43,6 +43,9 @@ pub struct SelInfo {
/// Uniform corner (shape, size) — the first corner.
pub corner: (designcraft_geom::corners::CornerShape, f64),
pub wrap_invert: bool,
/// Text wrap side and contour type ids (`bothSides`, `sameAsClipping`, …).
pub wrap_side: String,
pub wrap_contour: String,
pub locked: bool,
}
@@ -98,6 +101,8 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
.unwrap_or_else(|| "solid".into()),
corner: (first.corners.corners[0].shape, first.corners.corners[0].size),
wrap_invert: first.wrap.invert,
wrap_side: serde_json::to_value(first.wrap.side).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(),
wrap_contour: serde_json::to_value(first.wrap.contour).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(),
locked: first.locked,
})
}
+45
View File
@@ -1722,6 +1722,51 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let _ = app.run("object.textWrap", json!({"mode": info.as_ref().map(|i| i.wrap).unwrap_or("boundingBox"), "offset": v}));
}
});
let Some(i) = info.as_ref().filter(|i| i.wrap == "boundingBox" || i.wrap == "contour") else { return };
let sides = [
("bothSides", "Both Right & Left Sides"),
("rightSide", "Right Side"),
("leftSide", "Left Side"),
("towardsSpine", "Side Towards Spine"),
("awayFromSpine", "Side Away from Spine"),
("largestArea", "Largest Area"),
];
wrap_combo(app, ui, "wrap_side", "Wrap To", &sides, &[], &i.wrap_side, "side");
if i.wrap == "contour" && i.is_graphic {
// Detected edges, alpha channels and image paths aren't offered: composition doesn't read
// the image. An imported one shows by name and wraps around the frame.
let types = [("boundingBox", "Bounding Box"), ("graphicFrame", "Graphic Frame"), ("sameAsClipping", "Same as Clipping")];
let shown_only = [("detectEdges", "Detect Edges"), ("alphaChannel", "Alpha Channel"), ("photoshopPath", "Photoshop Path")];
wrap_combo(app, ui, "wrap_contour", "Contour Type", &types, &shown_only, &i.wrap_contour, "contour");
}
}
/// A Text Wrap panel choice: `opts` are offered, `shown_only` only name the current value.
#[allow(clippy::too_many_arguments)]
fn wrap_combo(
app: &mut DesignApp,
ui: &mut egui::Ui,
salt: &str,
caption_text: &str,
opts: &[(&str, &str)],
shown_only: &[(&str, &str)],
cur: &str,
key: &str,
) {
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, caption_text));
let shown = opts.iter().chain(shown_only).find(|o| o.0 == cur).map_or(cur, |o| o.1).to_string();
egui::ComboBox::from_id_salt(salt).selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &shown))).width(170.0).show_ui(
ui,
|ui| {
for (k, l) in opts {
if ui.selectable_label(*k == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
let _ = app.run("object.textWrap", json!({ key: k }));
}
}
},
);
});
}
/// Pathfinder panel: combine the selected shapes; make or release compound paths.
+2 -1
View File
@@ -56,7 +56,7 @@ Fidelity: **unmeasured** unless stated.
| Paragraph rules, borders, shading | ✓ | ✓ | ✓ | |
| Balance ragged lines | ✓ | ✓ | ✓ | |
| Optical margin alignment | ✓ | ✓ (hang table of our own) | ~ | hang amounts are ours, InDesign's unknown |
| Text wrap | bounding box, shape, jump object/column, contour options | ✓ | ✓ | |
| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options | ✓ | ~ | Detect Edges, Alpha Channel and Photoshop Path contours follow the frame's path (composition doesn't read the image) |
| Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) |
| Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet |
| Type on a path | ✓ | ✓ | ✓ | |
@@ -88,3 +88,4 @@ Measured from the table above (2026-10-10): 34 rows, 18 ✓, 16 ~, 0 ✗, scorin
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | Created: typography checklist (34 rows), fidelity gap and oracle plan |
| 2026-10-10 | minor | Text wrap: wrap sides and contour types |