diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..30b81c7d6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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, +} + +/// 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>, + 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 { + let mut rings: Vec> = 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 { 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 { 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 = 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 { 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. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..3aaa64645 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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 = 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. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe2..4c480808e 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -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). diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27a..2facf6780 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -512,31 +512,9 @@ pub fn specs() -> Vec { "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 { }) } +fn text_wrap(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::{ContourType, WrapMode, WrapSide}; + let side: Option = 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::(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 = 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 { let ids = targets(s, p)?; s.edit(|d, sel| { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4950d82b9..71cfd730b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -735,8 +735,9 @@ fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option { 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)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c1032bccf..a0c6a66cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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] diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..84ed20c7c 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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 { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..02ced7940 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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") { diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d5..b712a4f65 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -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", diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..c006ea1ad 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -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#" Option { .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, }) } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..8387ff22d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..3395bd224 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -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 |