diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..8f8523f0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,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-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 8f436a31f..73bace82e 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, } @@ -72,9 +74,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)); @@ -160,19 +178,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, section_marker, 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)); @@ -189,10 +211,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()); @@ -237,6 +261,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 269b9be69..5689ced9f 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; @@ -305,6 +306,16 @@ 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, +} + +impl Exclusion { + /// Does it wrap a line starting at story byte `line_start`? + fn wraps(&self, line_start: usize) -> bool { + self.after.is_none_or(|a| a < line_start) + } } /// One frame of the thread, ready for composition. @@ -403,7 +414,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { if !(reach.width() > 0.0 && reach.height() > 0.0) { continue; } - 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) { @@ -483,13 +494,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; @@ -1037,8 +1047,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !f.exclusions.is_empty() { let jump = cur.jump(f, lead, asc, tops); let mut tries = 0; + let line_start = glyphs.get(s).map_or(prange.start, |g| g.byte); loop { - let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else { + let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size, line_start) else { // No room in this column. baseline = baseline.max(col.y1) + 1.0; break; @@ -1250,6 +1261,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; @@ -2306,7 +2332,8 @@ const JUMP_EPS: f64 = 1e-6; /// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so 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))> { +#[allow(clippy::too_many_arguments)] +fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64, line_start: usize) -> Option<(f64, (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. @@ -2314,7 +2341,7 @@ fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: let jumped = f .exclusions .iter() - .filter(|e| e.mode == WrapMode::JumpObject) + .filter(|e| e.wraps(line_start) && e.mode == WrapMode::JumpObject) .map(|e| e.rect) .filter(|r| r.x0 < col.x1 && r.x1 > col.x0 && b > r.y0 + JUMP_EPS && b - top < r.y1 - JUMP_EPS) .fold(f64::NEG_INFINITY, |m, r| m.max(r.y1)); @@ -2325,14 +2352,14 @@ 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) { + } else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size, line_start) { return Some((b, slot)); } else { let (y0, y1) = (b - asc, b + desc); let edge = f .exclusions .iter() - .filter(|e| e.mode != WrapMode::JumpObject) + .filter(|e| e.wraps(line_start) && 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)); @@ -2349,10 +2376,10 @@ fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: /// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object /// reaches the column in the band the whole column is free, however narrow (a one-character frame /// such as an article's closing tombstone, or a narrow column beside another that wraps). -fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { +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)]; let mut cut = false; - for ex in &f.exclusions { + for ex in f.exclusions.iter().filter(|e| e.wraps(line_start)) { let r = ex.rect; let overlaps_band = r.y0 < y1 && r.y1 > y0; match ex.mode { @@ -2406,9 +2433,12 @@ fn estimate_slots( 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); let mut jump = jump; for _ in 0..n { - let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) { + let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size, line_start) { Some((nb, s)) => { b = nb; s diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 97eeb8cd4..1f01498f9 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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, after: 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)); @@ -4454,3 +4454,118 @@ fn an_empty_last_paragraph_isnt_overset() { let (d, sid, _) = doc_with("One\nTwo\n\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset(), "an empty paragraph before the last one is text"); } + +/// 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 add8d011c..1b1e1e96b 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) @@ -220,6 +228,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)))); + } + /// Channels where `old`'s render patched over `new`'s damage differs from `new`'s full render. fn patched_diff(old: &Document, new: &Document, (w, h): (u32, u32), zoom: f64) -> usize { use crate::{Placed, RenderOptions, Renderer}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 62ba7f08a..4ea00840e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -334,7 +334,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. Essentials Classic shows the Control panel and InDesign's iconic panel groups with names. The Window menu follows InDesign's: Arrange, Workspace, then the panels alphabetically with their Color, Editorial, Interactive, Object & Layout, Output, Styles, Type & Tables and Utilities groups. - 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 0c63d206e..499a7dae7 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -57,7 +57,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 | ✓ | ✓ | ✓ | |