Merge contour-wrap: wrap both sides of an object and around its contour

Combines contour-wrap (wrap side, contour type, multi-segment lines) with
the placed-graphic and nested wraps already on next.

- crates/compose/src/lib.rs: exclusion() and the new graphic_exclusion()
  share wrap_exclusion() (offsets, spine sides resolved per page, contour).
  wrap_obstacles() builds every obstacle through them: the item's own wrap,
  the graphic's wrap around its shown bounds, and children of groups and
  pasted-into frames with their own transform (depth <= 64). Contour type
  Bounding Box follows the shown part of the graphic, other types the frame
  path. wrap_slot() returns all free segments (free_slots) and steps 1 pt
  at a time beside a contour; Jump Object stays in wrap_slot only. The line
  loop keeps grid gyoudori, Jump Object leading steps and grid snapping for
  a line's first segment, and carries the other segments (with the line's
  grid end) to the next lines; estimate_slots() lists every segment through
  wrap_slot(). hit_line() breaks ties between segments by x, used by hit()
  next to the drop-cap lookup.
- crates/compose/src/tests.rs: both sides' tests; the hostile-geometry
  test builds Exclusion with side and contour.
- crates/engine/src/cmd/object.rs: object.textWrap is text_wrap() with
  mode/side/contour (unknown values are errors) and content?, applied to
  the frame's or the graphic's wrap.
- crates/idml/src/import.rs, export.rs: ContourOption/@ContourType is read
  in the shared text_wrap() and written by text_wrap_pref() as a child of
  TextWrapPreference after Properties, so graphic wraps carry it too.
- crates/idml/src/tests.rs: both sides' tests. tests/wrap.rs gains a test
  of a contour, right-side wrap on a placed graphic shown in part.
- crates/ui-egui/src/panels/mod.rs: the Text Wrap panel's side and contour
  combos read the targeted wrap (tw), the graphic's when content is selected.
This commit is contained in:
aiongg
2026-10-10 21:39:26 +00:00
14 changed files with 662 additions and 179 deletions
+359 -141
View File
@@ -36,7 +36,7 @@ use std::sync::{Arc, Mutex};
use designcraft_doc::{
Align, Content, Document, FirstBaseline, GridAlign, Item, ItemId, ListAlign, ListCounter, ParaProps, SpanColumns, StartParagraph, Story, StoryId,
TabAlign, TextFrameOptions, VerticalJustification, WrapMode, story,
TabAlign, TextFrameOptions, VerticalJustification, WrapMode, WrapSide, story,
};
use designcraft_fonts::{FontDb, ScopedFonts};
use designcraft_geom::{Affine, Point, Rect};
@@ -328,8 +328,140 @@ 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: &Item, xf: Affine, inv: Affine, left_page: bool) -> Exclusion {
let group = other.shape == designcraft_doc::Shape::Group;
let b =
if group { (xf * other.xf.inverse()).transform_rect_bbox(other.bounds()) } else { other.path.transformed(xf).bounds().unwrap_or(Rect::ZERO) };
wrap_exclusion(&other.wrap, b, || (!group).then(|| contour_path(other, other.wrap.contour)), xf, inv, left_page)
}
/// The wrap exclusion of the graphic placed in frame `it` (arguments as for [`exclusion`]): around
/// the part of the graphic the frame shows. None if there is no graphic or it is not shown.
pub fn graphic_exclusion(it: &Item, xf: Affine, inv: Affine, left_page: bool) -> Option<Exclusion> {
let g = it.graphic()?;
let shown = g.shown_bounds(it.inner_bounds())?;
Some(wrap_exclusion(&g.wrap, xf.transform_rect_bbox(shown), || Some(contour_path(it, g.wrap.contour)), xf, inv, left_page))
}
/// The exclusion of `wrap` around an object with spread-space bounds `b`; `outline` gives its
/// contour in the inner space `xf` maps to the spread (None: around the bounds).
fn wrap_exclusion(
wrap: &designcraft_doc::TextWrap,
b: Rect,
outline: impl FnOnce() -> Option<designcraft_geom::BezPath>,
xf: Affine,
inv: Affine,
left_page: bool,
) -> Exclusion {
let o = wrap.offsets;
let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]);
let side = match 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 = if wrap.mode == WrapMode::Contour { outline() } else { None }.and_then(|path| Contour::of(&path, inv * xf, o[0]));
let rect = contour.as_ref().map_or_else(|| inv.transform_rect_bbox(r), Contour::bounds);
Exclusion { rect, mode: wrap.mode, side, contour }
}
/// The outline a contour wrap of `kind` around frame `it` follows, in the frame's inner space.
/// Bounding Box follows the part of the placed graphic the frame shows; 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: &Item, kind: designcraft_doc::ContourType) -> designcraft_geom::BezPath {
if kind == designcraft_doc::ContourType::BoundingBox
&& let Some(r) = it.graphic().and_then(|g| g.shown_bounds(it.inner_bounds()))
{
return designcraft_geom::kurbo::Shape::to_path(&r, 0.1);
}
designcraft_geom::corners::apply(&it.path, &it.corners)
}
/// One frame of the thread, ready for composition.
@@ -388,28 +520,25 @@ pub struct ComposeOptions {
/// Nesting depth past which wrap obstacles inside groups are not collected.
const MAX_WRAP_DEPTH: usize = 64;
/// The text wrap obstacles of a page item, the graphic it holds and the items in it, as
/// (spread-space box grown by the wrap offsets, mode). `parent` maps the item's parent space to
/// the spread; `skip` is the text frame being wrapped.
fn wrap_obstacles(it: &Item, parent: Affine, skip: ItemId, depth: usize, out: &mut Vec<(Rect, WrapMode)>) {
/// The text wrap exclusions of a page item, the graphic it holds and the items in it, for a frame
/// whose inner space is reached from the spread by `inv`. `parent` maps the item's parent space to
/// the spread; `skip` is the text frame being wrapped; `left_page` tells whether a spread x is on
/// a left page.
#[allow(clippy::too_many_arguments)]
fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_page: &dyn Fn(f64) -> bool, depth: usize, out: &mut Vec<Exclusion>) {
if it.id == skip || it.hidden || depth > MAX_WRAP_DEPTH {
return;
}
let grow = |r: Rect, o: [f64; 4]| Rect::new(r.x0 - o[1], r.y0 - o[0], r.x1 + o[3], r.y1 + o[2]);
if it.wrap.mode != WrapMode::None {
out.push((grow(parent.transform_rect_bbox(it.bounds()), it.wrap.offsets), it.wrap.mode));
}
let xf = parent * it.xf;
let left = || left_page(parent.transform_rect_bbox(it.bounds()).center().x);
if it.wrap.mode != WrapMode::None {
out.push(exclusion(it, xf, inv, left()));
}
match &it.content {
// A wrap on the placed graphic follows the part of it the frame shows.
Content::Graphic(g) if g.wrap.mode != WrapMode::None => {
if let Some(r) = g.shown_bounds(it.inner_bounds()) {
out.push((grow(xf.transform_rect_bbox(r), g.wrap.offsets), g.wrap.mode));
}
}
Content::Graphic(g) if g.wrap.mode != WrapMode::None => out.extend(graphic_exclusion(it, xf, inv, left())),
Content::Group { .. } => {
for c in it.shown_children() {
wrap_obstacles(c, xf, skip, depth + 1, out);
wrap_obstacles(c, xf, inv, skip, left_page, depth + 1, out);
}
}
_ => {}
@@ -432,14 +561,13 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
if !tf.options.ignore_wrap
&& let Some(sp) = spread
{
let mut obstacles = Vec::new();
let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
for other in &sp.items {
if doc.layer(other.layer).is_some_and(|l| !l.visible) {
continue;
}
wrap_obstacles(other, Affine::IDENTITY, item.id, 0, &mut obstacles);
wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, &mut exclusions);
}
exclusions.extend(obstacles.into_iter().map(|(r, mode)| Exclusion { rect: inv.transform_rect_bbox(r), mode }));
}
let (page_name, page, left_page) = match (spread_ref, spread) {
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
@@ -493,7 +621,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 {
@@ -1037,6 +1172,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
// Paragraph gyoudori: the (frame, column) of the block and the grid line after it.
let mut para_block: Option<((usize, usize), f64)> = None;
// 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;
// The grid line after the carried line's band (see `grid_end`).
let mut carry_grid: Option<f64> = None;
// 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) {
@@ -1057,9 +1200,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
// Estimate slots for the breaker with the paragraph's base leading.
let est_asc = base_chars.size * 0.75;
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 };
let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp);
let est_first =
if carry.is_empty() { cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp) } else { carry_baseline + base_leading };
let est_jump = cur.jump(f, base_leading, est_asc, est_tops);
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let slots = estimate_slots(f, col, est_first, est_jump, 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));
// Beside the drop cap (in the column the paragraph starts in), its width; else the
@@ -1111,83 +1255,112 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
let (reference, model) = cjk_line_reference(line_glyphs);
let advance = if cur.last_baseline.is_some() { cur.aki_below.unwrap_or(lead) } else { lead };
let mut baseline = cur.next_baseline(f, col, advance, asc, tops, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
// Baseline grid: grid alignment and gyoudori (see `grid`).
let grid = f.grid.filter(|&(_, inc)| {
inc > 0.0
&& inc.is_finite()
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
});
let (mut x0, mut x1) = (col.x0, col.x1);
let continues = !carry.is_empty();
let mut baseline;
let mut grid_end = None;
if let Some((g_start, inc)) = grid {
let n = pp.grid_gyoudori.min(grid::MAX_GYOUDORI);
match para_block.filter(|(at, _)| line_no > 0 && *at == (cur.fi, cur.col)) {
// Inside a paragraph-gyoudori block, lines after the first keep their leading.
Some((_, end)) => grid_end = Some(end),
None => {
let r = grid::reference_offset(line_glyphs, pp.grid_reference);
let block = n > 0 && pp.paragraph_gyoudori && line_no == 0;
let span = if block {
// From this line's reference point to the paragraph's last line's.
let aki_below = |model, lead| (model == designcraft_doc::cjk::LeadingModel::AkiBelow).then_some(lead);
let (mut down, mut prev, mut prev_aki, mut last_r) = (0.0, reference, aki_below(model, lead), r);
for (j, nb) in breaks.iter().enumerate().skip(k + 1) {
let (ns, ne) = (g0 + nb.start, g0 + nb.end);
let lg = glyphs.get(ns..ne.max(ns)).unwrap_or_default();
let (_, _, _, nlead) = line_metrics(lg, &glyphs, ns, mark_leading, mark_chars.size, db, &mark_chars);
let nlead = match last_line_mark {
Some(m) if j + 1 == breaks.len() => nlead.max(m),
_ => nlead,
};
let (nref, nmodel) = cjk_line_reference(lg);
down += prev_aki.unwrap_or(nlead) + prev - nref;
prev = nref;
prev_aki = aki_below(nmodel, nlead);
last_r = grid::reference_offset(lg, pp.grid_reference);
if continues {
// The next segment of a line already placed.
baseline = carry_baseline;
grid_end = carry_grid;
(x0, x1) = carry.remove(0);
} else {
carry.clear();
baseline = cur.next_baseline(f, col, advance, asc, tops, &pp);
if cur.last_baseline.is_some() {
baseline += cur.last_reference - reference;
}
// Baseline grid: grid alignment and gyoudori (see `grid`).
let grid = f.grid.filter(|&(_, inc)| {
inc > 0.0
&& inc.is_finite()
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
});
if let Some((g_start, inc)) = grid {
let n = pp.grid_gyoudori.min(grid::MAX_GYOUDORI);
match para_block.filter(|(at, _)| line_no > 0 && *at == (cur.fi, cur.col)) {
// Inside a paragraph-gyoudori block, lines after the first keep their leading.
Some((_, end)) => grid_end = Some(end),
None => {
let r = grid::reference_offset(line_glyphs, pp.grid_reference);
let block = n > 0 && pp.paragraph_gyoudori && line_no == 0;
let span = if block {
// From this line's reference point to the paragraph's last line's.
let aki_below = |model, lead| (model == designcraft_doc::cjk::LeadingModel::AkiBelow).then_some(lead);
let (mut down, mut prev, mut prev_aki, mut last_r) = (0.0, reference, aki_below(model, lead), r);
for (j, nb) in breaks.iter().enumerate().skip(k + 1) {
let (ns, ne) = (g0 + nb.start, g0 + nb.end);
let lg = glyphs.get(ns..ne.max(ns)).unwrap_or_default();
let (_, _, _, nlead) = line_metrics(lg, &glyphs, ns, mark_leading, mark_chars.size, db, &mark_chars);
let nlead = match last_line_mark {
Some(m) if j + 1 == breaks.len() => nlead.max(m),
_ => nlead,
};
let (nref, nmodel) = cjk_line_reference(lg);
down += prev_aki.unwrap_or(nlead) + prev - nref;
prev = nref;
prev_aki = aki_below(nmodel, nlead);
last_r = grid::reference_offset(lg, pp.grid_reference);
}
(down + last_r - r).max(0.0)
} else {
0.0
};
let lines = if block { grid::lines_for(span, inc, n) } else { n.max(1) };
let free = cur.grid_next.filter(|(b, _)| cur.last_baseline == Some(*b)).map(|(_, y)| y);
let band = grid::Band { natural: baseline + r, free, pending: cur.pending, fixed: n > 0, lines, span };
let (y, end) = grid::place((g_start, inc), pp.grid_reference, band);
baseline = y - r;
grid_end = Some(end);
if block {
para_block = Some(((cur.fi, cur.col), end));
}
(down + last_r - r).max(0.0)
} else {
0.0
};
let lines = if block { grid::lines_for(span, inc, n) } else { n.max(1) };
let free = cur.grid_next.filter(|(b, _)| cur.last_baseline == Some(*b)).map(|(_, y)| y);
let band = grid::Band { natural: baseline + r, free, pending: cur.pending, fixed: n > 0, lines, span };
let (y, end) = grid::place((g_start, inc), pp.grid_reference, band);
baseline = y - r;
grid_end = Some(end);
if block {
para_block = Some(((cur.fi, cur.col), end));
}
}
}
}
// A line a wrap pushes down moves by whole grid lines, keeping its place in its band.
let on_grid = baseline;
let snap = |y: f64| grid.map_or(y, |(_, inc)| on_grid + ((y - on_grid) / inc - 1e-6).ceil().max(0.0) * inc);
// Wrap: push the line down to the first position with a slot (back on the grid).
let (mut x0, mut x1) = (col.x0, col.x1);
if !f.exclusions.is_empty() {
let jump = cur.jump(f, advance, asc, tops);
let mut tries = 0;
loop {
let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else {
// No room in this column.
baseline = baseline.max(col.y1) + 1.0;
break;
};
let pushed = nb > baseline;
(baseline, x0, x1) = (nb, a, z);
tries += 1;
if !pushed || grid.is_none() || tries > 4000 {
break;
// A line a wrap pushes down moves by whole grid lines, keeping its place in its band.
let on_grid = baseline;
let snap = |y: f64| grid.map_or(y, |(_, inc)| on_grid + ((y - on_grid) / inc - 1e-6).ceil().max(0.0) * inc);
// Wrap: push the line down to the first position with a slot (back on the grid).
// Segments after the first are carried to the next lines of the paragraph.
if !f.exclusions.is_empty() {
let jump = cur.jump(f, advance, asc, tops);
let mut tries = 0;
loop {
let Some((nb, segs)) = wrap_slot(f, col, baseline, asc, desc, jump, base_size, rtl) else {
// No room in this column.
baseline = baseline.max(col.y1) + 1.0;
carry.clear();
break;
};
let pushed = nb > baseline;
baseline = nb;
if let Some((&first, rest)) = segs.split_first() {
(x0, x1) = first;
carry = rest.to_vec();
}
tries += 1;
if !pushed || grid.is_none() || tries > 4000 {
break;
}
baseline = snap(baseline);
}
baseline = snap(baseline);
}
grid_end = grid_end.map(|end| end + baseline - on_grid);
carry_baseline = baseline;
carry_grid = grid_end;
}
// 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));
g0 = s;
moved = true;
break;
}
grid_end = grid_end.map(|end| end + baseline - on_grid);
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).
@@ -1214,6 +1387,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;
}
@@ -1271,6 +1445,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;
}
@@ -1396,6 +1571,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
go(&mut cur, start);
col_first_line = line_no;
g0 += b.next;
carry.clear();
moved = true;
break;
}
@@ -2446,13 +2622,16 @@ struct Jump {
/// Slack for float error when a line is tested against a Jump Object wrap's edges.
const JUMP_EPS: f64 = 1e-6;
/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has a
/// free slot in `col`, and that slot; None if there is none above the column bottom.
/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has
/// free segments in `col`, and those segments (see [`free_slots`], never empty); None if there are
/// none above the column bottom.
///
/// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so the
/// A line under a Jump Object wrap moves below it as `jump` says. Around a rectangular wrap the
/// free space only grows where one in the line's band ends: the search jumps from one such bottom
/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom.
fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64) -> Option<(f64, (f64, f64))> {
/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom. A
/// contour's width changes within its box, so beside one the search moves down 1 pt at a time.
#[allow(clippy::too_many_arguments)]
fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64, rtl: bool) -> Option<(f64, Vec<(f64, f64)>)> {
let top = jump.top.max(0.0);
let mut b = baseline;
// Each step passes at least one wrap; the cap bounds hostile geometry.
@@ -2471,18 +2650,15 @@ fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump:
} else {
jumped + top
}
} else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size) {
return Some((b, slot));
} else {
let segs = free_slots(f, col, b - asc, b + desc, size, rtl);
if !segs.is_empty() {
return Some((b, segs));
}
let (y0, y1) = (b - asc, b + desc);
let edge = f
.exclusions
.iter()
.filter(|e| e.mode != WrapMode::JumpObject)
.map(|e| e.rect)
.filter(|r| r.y0 < y1 && r.y1 > y0)
.fold(f64::INFINITY, |m, r| m.min(r.y1));
edge + asc
let in_band = || f.exclusions.iter().filter(|e| e.mode != WrapMode::JumpObject).filter(|e| e.rect.y0 < y1 && e.rect.y1 > y0);
let edge = in_band().fold(f64::INFINITY, |m, e| m.min(e.rect.y1)) + asc;
if in_band().any(|e| e.contour.is_some()) { edge.min(b + 1.0) } else { edge }
};
if b.is_nan() || b > col.y1 {
return None;
@@ -2491,36 +2667,61 @@ fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump:
None
}
/// 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::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::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,
@@ -2530,31 +2731,31 @@ fn estimate_slots(
lead: f64,
size: f64,
glyphs: &[Glyph],
pp: &ParaProps,
line_no: usize,
rtl: bool,
carry: &[(f64, f64)],
) -> Vec<(f64, f64)> {
let _ = (pp, line_no);
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;
let mut jump = jump;
for _ in 0..n {
let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) {
while v.len() < n + carry.len() {
let slots = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size, rtl) {
Some((nb, s)) => {
b = nb;
s
}
None => {
b = b.max(col.y1);
(col.x0, col.x1)
vec![(col.x0, col.x1)]
}
};
v.push(slot);
v.extend(slots);
b += lead;
jump = Jump { top: lead, step: lead };
}
@@ -3440,10 +3641,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;
}
}
}
_ => {}
@@ -3659,12 +3875,14 @@ pub fn line_rtl(cs: &ComposedStory, pos: usize) -> bool {
caret_line(cs, pos).and_then(|(_, l)| l.glyphs.iter().find(|g| g.len > 0)).is_some_and(|g| g.rtl)
}
/// The line of frame `ft` closest to point `p` vertically (within its column).
/// The line of frame `ft` closest to point `p` vertically (within its column), then horizontally
/// (segments of a line beside an object share its baseline).
fn hit_line(ft: &FrameText, p: Point) -> Option<&Line> {
let x_dist = |l: &Line| (l.x0 - p.x).max(p.x - l.x1).max(0.0);
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())
}
+79 -1
View File
@@ -362,7 +362,7 @@ fn jump_object_keeps_grid_aligned_lines_on_the_grid() {
fn hostile_wrap_geometry_terminates() {
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]);
let mut specs = frame_specs(&d, sid);
let ex = |x0: f64, y0: f64, x1: f64, y1: f64, mode| Exclusion { rect: Rect::new(x0, y0, x1, y1), mode };
let ex = |x0: f64, y0: f64, x1: f64, y1: f64, mode| Exclusion { rect: Rect::new(x0, y0, x1, y1), mode, side: WrapSide::BothSides, contour: None };
let f = &mut specs[0];
f.exclusions.push(ex(f64::NAN, f64::NAN, f64::NAN, f64::NAN, WrapMode::JumpObject));
f.exclusions.push(ex(-1e308, 300.0, 1e308, f64::INFINITY, WrapMode::JumpObject));
@@ -381,6 +381,84 @@ fn hostile_wrap_geometry_terminates() {
}
}
/// 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
@@ -417,6 +417,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)]
@@ -426,6 +446,7 @@ pub struct TextWrap {
pub offsets: [f64; 4],
pub invert: bool,
pub side: WrapSide,
pub contour: ContourType,
}
impl TextWrap {
+58 -32
View File
@@ -512,39 +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, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), 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, content?: bool (the placed graphic instead of its frame; default: whether the selection is the frame's content), 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);
let content = match p.get("content").and_then(Value::as_bool) {
Some(c) => c,
None => p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.selection.content,
};
set_flag(
s,
p,
move |i| {
let w = match &mut i.content {
Content::Graphic(g) if content => &mut g.wrap,
_ => &mut i.wrap,
};
w.mode = mode;
if let Some(o) = off {
w.offsets = o;
}
if let Some(v) = invert {
w.invert = v;
}
},
false,
)
}
text_wrap
),
cmd!(
"object.fit",
@@ -1531,6 +1501,62 @@ 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);
let content = match p.get("content").and_then(Value::as_bool) {
Some(c) => c,
None => p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.selection.content,
};
set_flag(
s,
p,
move |i| {
let w = match &mut i.content {
Content::Graphic(g) if content => &mut g.wrap,
_ => &mut i.wrap,
};
if let Some(m) = mode {
w.mode = m;
}
if let Some(o) = off {
w.offsets = o;
}
if let Some(v) = invert {
w.invert = v;
}
if let Some(v) = side {
w.side = v;
}
if let Some(c) = contour {
w.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
@@ -879,8 +879,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]
+1
View File
@@ -2685,6 +2685,7 @@ fn text_wrap_pref(w: &designcraft_doc::TextWrap) -> El {
.attr("Right", num(w.offsets[3])),
],
)
.child(El::new("ContourOption").attr("ContourType", names::contour_type_out(w.contour)).attr("IncludeInsideEdges", "false"))
}
fn text_frame_pref(o: &TextFrameOptions, rule_color: &str) -> El {
+1
View File
@@ -2737,6 +2737,7 @@ fn text_wrap(w: &El) -> TextWrap {
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;
@@ -470,6 +470,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
@@ -2731,3 +2731,33 @@ fn em_box_first_baseline_offset_round_trips() {
let unknown = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="SomethingElse"/>"#, "");
assert_eq!(first(&unknown), FirstBaseline::Ascent);
}
#[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));
}
+30
View File
@@ -112,3 +112,33 @@ fn wrap_on_a_placed_graphic_follows_its_crop_and_round_trips() {
let full = cs.frames[0].lines.iter().find(|l| l.baseline - l.ascent > shown.y1 + 6.0).unwrap();
assert!(full.x1 - full.x0 > 300.0 && full.baseline < image.y1, "{:?} vs {image:?}", (full.x0, full.x1, full.baseline));
}
#[test]
fn contour_wrap_on_a_placed_graphic_follows_its_shown_part_on_one_side() {
use designcraft_doc::{ContourType, WrapSide};
let pref = r#"<TextWrapPreference Inverse="false" ApplyToMasterPageOnly="false" TextWrapSide="RightSide" TextWrapMode="Contour"><Properties><TextWrapOffset Top="4" Left="4" Bottom="4" Right="4"/></Properties><ContourOption ContourType="BoundingBox" IncludeInsideEdges="false"/></TextWrapPreference>"#;
let mut d = wrap_doc(&wrap_pref("None", 0.0), pref);
let w = d.spreads[0].items[1].graphic().unwrap().wrap;
assert_eq!((w.side, w.contour), (WrapSide::RightSide, ContourType::BoundingBox));
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.spreads[0].items.iter().find_map(|i| i.graphic()).unwrap().wrap, w);
// Half of the image below its frame: the contour is the shown half, 4 pt around it.
let sp = std::sync::Arc::make_mut(&mut d.spreads[0]);
if let designcraft_doc::Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut sp.items[1]).content {
g.xf = designcraft_geom::Affine::translate((0.0, 50.0)) * g.xf;
}
let sid = *d.stories.keys().next().unwrap();
let specs = designcraft_compose::frame_specs(&d, sid);
let [ex] = specs[0].exclusions.as_slice() else { panic!("{:?}", specs[0].exclusions) };
assert!(ex.contour.is_some());
assert_eq!(ex.side, WrapSide::RightSide);
let r = ex.rect;
assert!([r.x0 - 156.0, r.y0 - 196.0, r.x1 - 264.0, r.y1 - 254.0].iter().all(|v| v.abs() < 0.05), "{r:?}");
// Text runs only to its right.
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
let beside: Vec<_> = cs.frames[0].lines.iter().filter(|l| l.baseline + l.descent > 196.0 && l.baseline - l.ascent < 254.0).collect();
assert!(!beside.is_empty());
for l in beside {
assert!(l.x0 >= r.x1 - 0.01, "{:?} vs {r:?}", (l.x0, l.x1));
}
}
+5
View File
@@ -45,6 +45,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,
}
@@ -141,6 +144,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: tw.invert,
wrap_side: serde_json::to_value(tw.side).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(),
wrap_contour: serde_json::to_value(tw.contour).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(),
locked: first.locked,
})
}
+45
View File
@@ -1767,6 +1767,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 |