From 6e9517b053d053a460691b7f4bd0754d3816828b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 05:51:56 +0000 Subject: [PATCH] Text wrap: anchored objects wrap text (#557) An anchored object with text wrap pushes aside the text of other stories whose frames it overlaps on its spread, and the lines of its own story after the line holding its anchor (custom and above-line objects). Its own story is composed again with the objects where the previous pass placed them until they settle, at most four more times. The composition cache keys a story wrapped by other stories' anchored objects on their signatures, and canvas damage repaints the stories they wrap. --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 59 +++++++--- crates/compose/src/lib.rs | 47 +++++--- crates/compose/src/tests.rs | 115 ++++++++++++++++++++ crates/compose/src/wrap.rs | 207 ++++++++++++++++++++++++++++++++++++ crates/render/src/damage.rs | 27 +++++ docs/target-app-parity.md | 4 +- docs/typography-parity.md | 4 +- 8 files changed, 435 insertions(+), 29 deletions(-) create mode 100644 crates/compose/src/wrap.rs diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..a92aff9a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Anchored objects with text wrap push text aside: other stories' text under them and the lines of their own story after the anchor's line (#557). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 8aabbf0f1..3dcdc401e 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -2,8 +2,9 @@ //! //! An entry is valid while the inputs it was composed from are the *same allocations*: the story //! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every -//! wrapping item on the frames' spreads. Entries hold clones of those `Arc`s, so the pointers can't -//! be reused by other allocations while the entry lives. +//! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap +//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers +//! can't be reused by other allocations while the entry lives. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -13,12 +14,13 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode}; use crate::vars::RunningIndex; use crate::{ComposeOptions, ComposedStory}; -/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`). -type Key = (StoryId, Option); +/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`), +/// with other stories' anchored objects wrapping it (`true`) or not. +type Key = (StoryId, Option, bool); struct Entry { sig: Vec, - _keep: (Arc, Arc, Vec>), + _keep: (Arc, Arc, Vec>, Vec>), out: Arc, stamp: u64, } @@ -61,9 +63,25 @@ impl Cache { /// `None` for a story's own frames. Pages are identified by index, not name: names repeat when /// sections restart their numbering. pub fn get(&self, doc: &Document, sid: StoryId, page: Option) -> Arc { + self.get_with(doc, sid, page, true) + } + + /// [`Cache::get`], wrapped by other stories' anchored objects only when `cross` is set. + fn get_with(&self, doc: &Document, sid: StoryId, page: Option, cross: bool) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; - let (mut sig, keep_items) = signature(doc, story); - let key = (sid, page); + let (mut sig, mut keep_items) = signature(doc, story); + let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; + let mut keep_stories = Vec::new(); + for n in &neighbours { + if let Some(ns) = doc.stories.get(n) { + let (s, k) = signature(doc, ns); + sig.push(usize::MAX - 1); + sig.extend(s); + keep_items.extend(k); + keep_stories.push(ns.clone()); + } + } + let key = (sid, page, !neighbours.is_empty()); // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. let page_name = page.map(|p| doc.page_name(p)); @@ -141,19 +159,23 @@ impl Cache { // Parent-page items are composed per page: markers show its name, variables need its index. let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); - let out = Arc::new(crate::compose_story( + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect(); + let out = Arc::new(crate::wrap::compose_among( doc, sid, &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &others.iter().map(|o| &**o).collect::>(), )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); } - let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items, keep_stories), out: out.clone(), stamp }); drop(g); + // Only the composition that is shown (not one made for another story's wrap). if key.1.is_none() + && cross && let Some(old) = replaced { self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out)); @@ -170,10 +192,12 @@ impl Cache { let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..]; { let g = self.map.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(e) = g.0.get(&(sid, None)) - && matches(&e.sig) - { - return Some(e.out.clone()); + for cross in [true, false] { + if let Some(e) = g.0.get(&(sid, None, cross)) + && matches(&e.sig) + { + return Some(e.out.clone()); + } } } let g = self.previous.lock().unwrap_or_else(|e| e.into_inner()); @@ -218,6 +242,15 @@ impl Cache { out } + /// Story `sid` composed for the wrap of its anchored objects in other stories: without other + /// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide + /// indexes (they compose every story). + fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc { + let indexed = + doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st))); + if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { self.get_with(doc, sid, None, false) } + } + pub fn clear(&self) { self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear(); *self.running.lock().unwrap_or_else(|e| e.into_inner()) = None; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..2f98b1b9f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -21,6 +21,7 @@ pub mod shape; pub mod table; pub mod vars; mod warichu; +pub mod wrap; pub mod xref; use std::collections::HashMap; @@ -289,6 +290,9 @@ impl ComposedStory { pub struct Exclusion { pub rect: Rect, pub mode: WrapMode, + /// An anchored object in the frame's own story: it wraps only lines that start after this + /// story byte (its anchor). + pub after: Option, } /// One frame of the thread, ready for composition. @@ -370,7 +374,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { 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 }); + exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode, after: None }); } } let (page_name, page, left_page) = match (loc.spread, spread) { @@ -447,13 +451,12 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { out } -/// Compose a story of `doc` through its frames. +/// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored +/// objects ([`wrap`]). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { - let specs = frame_specs(doc, sid); - match doc.story(sid) { - Some(st) => compose(doc, st, &specs, opts), - None => ComposedStory { story: sid, ..Default::default() }, - } + let others: Vec = + wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect(); + wrap::compose_among(doc, sid, opts, &others.iter().collect::>()) } const DEFAULT_TAB: f64 = 36.0; @@ -931,8 +934,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let (mut x0, mut x1) = (col.x0, col.x1); if !f.exclusions.is_empty() { let mut tries = 0; + let line_start = glyphs.get(s).map_or(prange.start, |g| g.byte); loop { - match free_slot(f, col, baseline - asc, baseline + desc, base_size) { + match free_slot(f, col, baseline - asc, baseline + desc, base_size, line_start) { Some((a, b)) => { x0 = a; x1 = b; @@ -1103,6 +1107,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C moved = true; break; } + // An own-story anchored object anchored in this line wraps the lines after it, + // which were broken without it: break them again. + if k + 1 < breaks.len() + && f.exclusions.iter().any(|ex| { + ex.after.is_some_and(|a| (range_start..range_end).contains(&a)) + && ex.rect.x0 < col.x1 + && ex.rect.x1 > col.x0 + && ex.rect.y1 > baseline + && ex.rect.y0 < col.y1 + }) + { + g0 += b.next; + moved = true; + break; + } } if !moved { break; @@ -2071,10 +2090,11 @@ 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)> { +/// The widest free stretch of `col` between `y0` and `y1` for a line starting at story byte +/// `line_start` (`None`: the line must move down). +fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64, line_start: usize) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; - for ex in &f.exclusions { + for ex in f.exclusions.iter().filter(|e| e.after.is_none_or(|a| a < line_start)) { let r = ex.rect; let overlaps_band = r.y0 < y1 && r.y1 > y0; match ex.mode { @@ -2113,10 +2133,13 @@ fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, gl 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 b = first; + // Own-story anchored objects whose anchor is in these lines wrap only the lines after it: the + // rest is broken again once that line is set. + let line_start = glyphs.first().map_or(0, |g| g.byte); for _ in 0..n { let mut tries = 0; let slot = loop { - if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) { + if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size, line_start) { break s; } b += 1.0; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..5af4e83a6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2788,3 +2788,118 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +/// A 100×60 rectangle anchored at `at` in `sid`, custom-positioned with its top-left at +/// (`x`, `y`) from the top-left of the anchor's frame (`y_rel`: from the anchor's baseline). +fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel: bool, wrap: WrapMode) { + use designcraft_doc::anchored::AnchorRelative as R; + let lid = d.default_layer(); + let mut it = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 60.0)), + ); + it.wrap.mode = wrap; + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: if y_rel { R::Anchor } else { R::TextFrame }, + x_offset: x, + y_offset: y, + object_point: 0, + ref_point: 0, + keep_within_column: false, + }; + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position)); +} + +/// Lines whose band meets `y0..y1`. +fn lines_in_band(cs: &ComposedStory, y0: f64, y1: f64) -> Vec<&Line> { + all_lines(cs).into_iter().filter(|l| l.baseline - l.ascent < y1 && l.baseline + l.descent > y0).collect() +} + +fn line_layout(cs: &ComposedStory) -> Vec<(Range, u64, u64, u64)> { + all_lines(cs).iter().map(|l| (l.range.clone(), l.baseline.to_bits(), l.x0.to_bits(), l.x1.to_bits())).collect() +} + +#[test] +fn anchored_object_wraps_another_storys_text() { + let long = [LOREM; 3].join(" "); + let (mut d, a, fa) = doc_with("Anchor here.", Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let lid = d.default_layer(); + let (_, b) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 400.0), lid, &long, ParaFormat::default()).unwrap(); + let plain = compose_story(&d, b, &ComposeOptions::default()); + let left = all_lines(&plain)[0].x0; + // Over the left part of B's frame, 100–160 pt down. + let area = d.item(fa).unwrap().text_area(); + anchor_box(&mut d, a, 0, left - area.x0, 100.0 - area.y0, false, WrapMode::BoundingBox); + let cs = compose_story(&d, b, &ComposeOptions::default()); + let band = lines_in_band(&cs, 100.0, 160.0); + assert!(!band.is_empty()); + assert!(band.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), "{:?}", band.iter().map(|l| l.x0).collect::>()); + assert!(lines_in_band(&cs, 0.0, 80.0).iter().all(|l| (l.x0 - left).abs() < 1e-6)); + // The cache composes the same, and follows the object when it moves. + let cache = Cache::new(); + assert_eq!(line_layout(&cache.get(&d, b, None)), line_layout(&cs)); + anchor_box(&mut d, a, 0, left - area.x0, 200.0 - area.y0, false, WrapMode::BoundingBox); + let st = d.story_mut(a).unwrap(); + st.delete(OBJECT_LEN..2 * OBJECT_LEN); + let moved = cache.get(&d, b, None); + assert_eq!(line_layout(&moved), line_layout(&compose_story(&d, b, &ComposeOptions::default()))); + assert!(lines_in_band(&moved, 100.0, 160.0).iter().any(|l| (l.x0 - left).abs() < 1e-6)); + assert!(lines_in_band(&moved, 200.0, 260.0).iter().all(|l| l.x0 >= left + 100.0 - 1e-6)); +} + +const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8(); + +#[test] +fn anchored_object_wraps_its_own_storys_later_lines() { + let text = [LOREM; 4].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 600.0), ParaAttrs::default()); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let left = all_lines(&plain)[0].x0; + // Anchored in the third line, hanging from its baseline over the lines below. + let at = all_lines(&plain)[2].range.start + 3; + anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (li, anchor_line) = lines.iter().enumerate().find(|(_, l)| l.range.contains(&at)).unwrap(); + let top = anchor_line.baseline; + // The anchor line and those above it keep the full measure; the lines under the object move right. + assert!(lines[..=li].iter().all(|l| (l.x0 - left).abs() < 1e-6)); + let under: Vec<_> = lines[li + 1..].iter().filter(|l| l.baseline - l.ascent < top + 60.0).collect(); + assert!(under.len() >= 2); + assert!(under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), "{:?}", under.iter().map(|l| l.x0).collect::>()); + assert!(lines.iter().filter(|l| l.baseline - l.ascent > top + 61.0).all(|l| (l.x0 - left).abs() < 1e-6)); + // Settled: the object is where the final pass wrapped the text around. + let o = &cs.frames[0].objects[0]; + assert!((o.origin.y - top).abs() < 1e-6); +} + +#[test] +fn anchored_object_without_wrap_leaves_layout_unchanged() { + let long = [LOREM; 3].join(" "); + let (mut d, a, _) = doc_with(&long, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let lid = d.default_layer(); + let (_, b) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(320.0, 0.0, 620.0, 400.0), lid, &long, ParaFormat::default()).unwrap(); + anchor_box(&mut d, a, 40, 330.0, 0.0, true, WrapMode::None); + for sid in [a, b] { + let direct = compose(&d, d.story(sid).unwrap(), &frame_specs(&d, sid), &ComposeOptions::default()); + assert_eq!(line_layout(&compose_story(&d, sid, &ComposeOptions::default())), line_layout(&direct)); + } +} + +#[test] +fn anchored_wrap_that_moves_its_own_anchor_terminates() { + // Jump Object pushes the lines after the anchor below the object; vertical centring then moves + // every line, the anchor's too, and the object with it. + let text = [LOREM; 2].join(" "); + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 500.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = designcraft_doc::VerticalJustification::Center; + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let at = all_lines(&plain)[1].range.start + 2; + anchor_box(&mut d, sid, at, 0.0, -20.0, true, WrapMode::JumpObject); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(cs.line_count() > 0); + assert_eq!(cs.frames[0].objects.len(), 1); +} diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs new file mode 100644 index 000000000..31ec719d6 --- /dev/null +++ b/crates/compose/src/wrap.rs @@ -0,0 +1,207 @@ +//! Text wrap around anchored objects. +//! +//! Page items wrap text through [`crate::frame_specs`]. An anchored object's position is known +//! only once its story is composed, so its wrap is applied afterwards: +//! +//! - **Other stories.** A story whose frames share a spread with another story's wrapping +//! anchored objects is composed with those objects as obstacles, where the other story's own +//! composition places them ([`neighbours`], [`compose_among`]). That composition doesn't +//! include obstacles from further stories, so two stories whose anchored objects wrap each +//! other's text don't depend on each other in a cycle. +//! - **Its own story.** A custom or above-line anchored object wraps the lines of its own story +//! that come after the line holding its anchor; that line and the lines before it are never +//! wrapped by it (an inline object sits in its line and wraps none of its own story). The story +//! is composed again with the objects where the previous pass placed them until they stop +//! moving, at most [`MAX_PASSES`] more times. + +use designcraft_doc::{AnchorPosition, AnchoredObject, Document, OBJECT_MARK, SpreadRef, Story, StoryId, WrapMode}; +use designcraft_geom::{Affine, Rect}; + +use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; + +/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving +/// its objects (keep options, vertical justification) stops here. +pub(crate) const MAX_PASSES: usize = 4; + +/// How far an obstacle may move between passes and still count as settled (points). +const SETTLED: f64 = 0.01; + +/// A wrapping anchored object where its story's composition placed it. +#[derive(Clone, Debug)] +pub(crate) struct Obstacle { + spread: SpreadRef, + /// Wrap area (the object's bounds plus its wrap offsets) in spread space. + rect: Rect, + mode: WrapMode, + /// Story byte of the anchor, for obstacles in their own story. + anchor: Option, +} + +fn wraps(o: &AnchoredObject) -> bool { + o.item.wrap.mode != WrapMode::None && !o.item.hidden +} + +/// Does `story` have an anchored object whose wrap can push text? +pub fn has_wrapping_objects(story: &Story) -> bool { + story.objects.iter().any(|o| wraps(o)) +} + +/// Stories other than `sid` with wrapping anchored objects and a frame on a spread with one of +/// `sid`'s frames (in story id order). +pub fn neighbours(doc: &Document, sid: StoryId) -> Vec { + let mut candidates: Vec = doc.stories.iter().filter(|(id, st)| **id != sid && has_wrapping_objects(st)).map(|(id, _)| *id).collect(); + if candidates.is_empty() { + return candidates; + } + let spreads = story_spreads(doc, sid); + candidates.retain(|c| story_spreads(doc, *c).iter().any(|s| spreads.contains(s))); + candidates.sort_by_key(|c| c.0); + candidates +} + +/// Stories other than `sid` that `sid`'s wrapping anchored objects can push aside: those with a +/// frame on a spread with one of `sid`'s frames. +pub fn wrapped_by(doc: &Document, sid: StoryId) -> Vec { + if !doc.story(sid).is_some_and(has_wrapping_objects) { + return Vec::new(); + } + let spreads = story_spreads(doc, sid); + let mut out: Vec = + doc.stories.keys().filter(|id| **id != sid && story_spreads(doc, **id).iter().any(|s| spreads.contains(s))).copied().collect(); + out.sort_by_key(|c| c.0); + out +} + +fn story_spreads(doc: &Document, sid: StoryId) -> Vec { + let mut out = Vec::new(); + for f in doc.story(sid).map(|s| s.frames.as_slice()).unwrap_or_default() { + if let Some(loc) = doc.find(*f) + && !out.contains(&loc.spread) + { + out.push(loc.spread); + } + } + out +} + +/// The wrapping anchored objects `cs` (a composition of `story`) places. `own`: obstacles for the +/// story itself (inline objects left out, anchors recorded). +pub(crate) fn obstacles(doc: &Document, story: &Story, cs: &ComposedStory, own: bool) -> Vec { + let mut out = Vec::new(); + if !has_wrapping_objects(story) { + return out; + } + let anchors: Vec = story.text.match_indices(OBJECT_MARK).map(|(i, _)| i).collect(); + for ft in cs.frames.iter().filter(|ft| !ft.objects.is_empty()) { + let Some(loc) = doc.find(ft.frame) else { continue }; + let Some(frame) = doc.item_at(&loc) else { continue }; + // Text that isn't shown pushes nothing aside. + if frame.hidden || doc.layer(frame.layer).is_some_and(|l| !l.visible) { + continue; + } + let fxf = doc.parent_xf(&loc) * doc.text_xf(frame); + for o in &ft.objects { + let Some(obj) = story.objects.get(o.index).filter(|obj| wraps(obj)) else { continue }; + if own && matches!(obj.position, AnchorPosition::Inline { .. }) { + continue; + } + let w = obj.item.wrap.offsets; + let b = obj.item.bounds(); + let r = Rect::new(b.x0 - w[1], b.y0 - w[0], b.x1 + w[3], b.y1 + w[2]); + let rect = (fxf * Affine::translate(o.origin.to_vec2())).transform_rect_bbox(r); + if !rect.is_finite() { + continue; + } + let anchor = if own { + let Some(&a) = anchors.get(o.index) else { continue }; + Some(a) + } else { + None + }; + out.push(Obstacle { spread: loc.spread, rect, mode: obj.item.wrap.mode, anchor }); + } + } + out +} + +/// The exclusions `obs` add to each of `specs` (frames that ignore wrap, type on a path and +/// frames the obstacle doesn't reach get none). +pub(crate) fn exclusions(doc: &Document, specs: &[FrameSpec], obs: &[Obstacle]) -> Vec> { + let mut out = vec![Vec::new(); specs.len()]; + if obs.is_empty() { + return out; + } + for (spec, ex) in specs.iter().zip(&mut out) { + if spec.opts.ignore_wrap || spec.opts.path.is_some() { + continue; + } + let Some(loc) = doc.find(spec.id) else { continue }; + let Some(item) = doc.item_at(&loc) else { continue }; + let inv = (doc.parent_xf(&loc) * doc.text_xf(item)).inverse(); + let a = spec.area; + for ob in obs.iter().filter(|o| o.spread == loc.spread) { + let r = inv.transform_rect_bbox(ob.rect); + if r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 { + ex.push(Exclusion { rect: r, mode: ob.mode, after: ob.anchor }); + } + } + } + out +} + +fn with_exclusions(specs: &[FrameSpec], added: &[Vec]) -> Vec { + specs + .iter() + .zip(added) + .map(|(s, ex)| { + let mut s = s.clone(); + s.exclusions.extend(ex.iter().cloned()); + s + }) + .collect() +} + +fn settled(a: &[Vec], b: &[Vec]) -> bool { + let near = |x: &Exclusion, y: &Exclusion| { + x.mode == y.mode + && x.after == y.after + && (x.rect.x0 - y.rect.x0).abs() <= SETTLED + && (x.rect.y0 - y.rect.y0).abs() <= SETTLED + && (x.rect.x1 - y.rect.x1).abs() <= SETTLED + && (x.rect.y1 - y.rect.y1).abs() <= SETTLED + }; + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| near(p, q))) +} + +/// Compose story `sid` with the wrapping anchored objects of `others` (compositions of its +/// [`neighbours`]) and of its own. +pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { + let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } }; + let mut specs = crate::frame_specs(doc, sid); + if !others.is_empty() { + let mut obs = Vec::new(); + for cs in others { + if let Some(other) = doc.story(cs.story) { + obs.extend(obstacles(doc, other, cs, false)); + } + } + let added = exclusions(doc, &specs, &obs); + if added.iter().any(|e| !e.is_empty()) { + specs = with_exclusions(&specs, &added); + } + } + let mut out = crate::compose(doc, st, &specs, opts); + if !has_wrapping_objects(st) { + return out; + } + let mut prev = vec![Vec::new(); specs.len()]; + for _ in 0..MAX_PASSES { + let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); + if settled(&added, &prev) { + break; + } + out = crate::compose(doc, st, &with_exclusions(&specs, &added), opts); + prev = added; + } + out +} diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index 31f9e030b..cb353479f 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -92,6 +92,14 @@ pub fn damage_with(old: &Document, new: &Document, cache: Option<&designcraft_co if a.map_or(0, |s| s.notes.len()) != b.map_or(0, |s| s.notes.len()) { return None; } + // Its anchored objects' text wrap reflows the other stories on its spreads. + for d in [old, new] { + for other in designcraft_compose::wrap::wrapped_by(d, sid) { + for f in d.story(other).map(|s| s.frames.clone()).unwrap_or_default() { + out.push(frame_area(d, f)?); + } + } + } // Only the frames whose composition changed, when the previous composition is known. if let (Some(c), Some(_), Some(_)) = (cache, a, b) && let Some(prev) = c.composed_for(old, sid) @@ -207,6 +215,25 @@ mod tests { assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(350.0, 100.0)))); } + #[test] + fn editing_a_wrapping_anchored_object_damages_the_text_it_wraps() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let (_, a) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 200.0, 200.0), lid, "Anchor", ParaFormat::default()).unwrap(); + let (_, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 400.0, 500.0, 600.0), lid, "Wrapped", ParaFormat::default()).unwrap(); + let mut it = designcraft_doc::Item::new( + designcraft_doc::ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), + ); + it.wrap.mode = designcraft_doc::WrapMode::BoundingBox; + let old = d.clone(); + d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, Default::default())); + let dmg = damage(&old, &d).unwrap(); + assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(400.0, 500.0)))); + } + #[test] fn patched_render_equals_full_render() { use crate::{Placed, RenderOptions, Renderer}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f8..990c0e03b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (anchored objects wrap text) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -328,7 +328,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. -- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. +- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..ae0bc3192 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (anchored objects wrap text) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -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, contour options, on page items and anchored objects | ✓ | ✓ | | | 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 | ✓ | ✓ | ✓ | |