diff --git a/ROADMAP.md b/ROADMAP.md index 5c739f11e..c997b9e35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 70e22710d..66122ccf4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -3,7 +3,8 @@ //! 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, 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 +//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host +//! story's composition places it. 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; @@ -99,11 +100,12 @@ impl Cache { /// [`Cache::get`] for any [`ShownOn`]. pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc { - self.get_with(doc, sid, on, true) + self.get_with(doc, sid, on, true, 0) } - /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set. - fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc { + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for + /// a story `depth` hosts down from the story asked for. + fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, mut keep_items) = signature(doc, story); let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; @@ -117,6 +119,24 @@ impl Cache { keep_stories.push(ns.clone()); } } + // Anchored frames compose where their host story places them, so the host is composed + // first and the placements are part of the signature. + let placed = if depth < crate::placed::MAX_DEPTH { + crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1)) + } else { + Vec::new() + }; + for (id, p) in &placed { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h); + // The placed spread's pages give the frame its page, side and page rect. + for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) { + let m = &pg.margins; + (pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h); + } + sig.push(h.finish() as usize); + } let key = (sid, on, !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. @@ -209,11 +229,11 @@ 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 others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect(); + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).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)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)), placed }, &others.iter().map(|o| &**o).collect::>(), )); let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); @@ -295,13 +315,13 @@ impl Cache { /// 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 { + fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> 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, ShownOn::Own, false) + self.get_with(doc, sid, ShownOn::Own, false, depth) } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e80121543..fc166167d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -23,6 +23,7 @@ pub mod optical; mod outline; mod overlay; mod parabox; +mod placed; mod ruby; pub mod shape; pub mod shatai; @@ -50,6 +51,7 @@ use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; pub use crate::outline::FrameOutline; use crate::parabox::ParaBox; +pub use crate::placed::Placement; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with}; @@ -627,6 +629,9 @@ pub struct ComposeOptions { pub label: Option, /// Anchor pages for cross-references (needed when the story contains cross-references). pub xrefs: Option>, + /// Where the story's anchored frames sit on their spreads; an anchored frame not listed is + /// composed where it is stored, on no page. + pub placed: Vec<(ItemId, Placement)>, } /// Nesting depth past which wrap obstacles inside groups are not collected. @@ -659,21 +664,34 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { + frame_specs_placed(doc, sid, &[]) +} + +/// [`frame_specs`] with anchored frames where their host story placed them (`placed`). +pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { let loc = doc.find(fid); let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY); + // An anchored frame takes its page, page side, page rect and grid from where it is placed. + let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None }; + let parent = match (&loc, placement) { + (Some(l), _) => doc.parent_xf(l), + (None, Some(p)) => p.xf, + (None, None) => Affine::IDENTITY, + }; let xf = parent * item.xf; // Where the frame is on the spread (a group's transform included), to find its page. let center_x = parent.transform_rect_bbox(item.bounds()).center().x; let inv = xf.inverse(); let mut exclusions = Vec::new(); - let spread_ref = loc.as_ref().map(|l| l.spread); + let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread)); let spread = spread_ref.and_then(|r| doc.spread(r)); + // Page items wrap the text of frames on the spread, not of anchored frames. if !tf.options.ignore_wrap + && loc.is_some() && let Some(sp) = spread { let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); @@ -783,10 +801,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { /// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored /// objects ([`wrap`]). +/// Anchored frames are composed where their host story places them (unless `opts` places them). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { + compose_story_at(doc, sid, opts, 0) +} + +/// [`compose_story`] for a story `depth` hosts down from the story asked for. +fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory { 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::>()) + let others: Vec<&ComposedStory> = others.iter().collect(); + if opts.placed.is_empty() && depth < placed::MAX_DEPTH { + let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1))); + if !placed.is_empty() { + return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others); + } + } + wrap::compose_among(doc, sid, opts, &others) } const DEFAULT_TAB: f64 = 36.0; diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 2a465fbfa..9b3e7e40b 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -180,6 +180,7 @@ impl Notes { running: opts.running.clone(), label: Some(label), xrefs: opts.xrefs.clone(), + placed: Vec::new(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); diff --git a/crates/compose/src/placed.rs b/crates/compose/src/placed.rs new file mode 100644 index 000000000..200ff6e3c --- /dev/null +++ b/crates/compose/src/placed.rs @@ -0,0 +1,84 @@ +//! Where anchored text frames sit on their spread. +//! +//! An anchored frame is not a spread item: its host story's composition places it on a line. Its +//! own story is composed against that placement (page, page side, page rect, baseline grid), so +//! the host story is composed first. + +use std::sync::Arc; + +use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId}; +use designcraft_geom::Affine; + +use crate::{ComposedStory, FrameText}; + +/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in +/// each other in a cycle) are composed where they are stored. +pub(crate) const MAX_DEPTH: usize = 8; + +/// An anchored item's parent space on its spread, where its host story's composition placed it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub spread: SpreadRef, + /// The item's parent space → spread space. + pub xf: Affine, +} + +/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. +pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc) -> Vec<(ItemId, Placement)> { + let Some(st) = doc.story(sid) else { return Vec::new() }; + let mut anchored = None; + let mut out = Vec::new(); + for &fid in &st.frames { + if doc.find(fid).is_some() { + continue; + } + let items = anchored.get_or_insert_with(|| doc.anchored_items()); + // A frame anchored in its own story would need its own composition to place it. + let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue }; + if let Some(p) = locate(doc, &host(owner), fid) { + out.push((fid, p)); + } + } + out +} + +/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects, +/// footnotes and table cells. +fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option { + cs.frames.iter().find_map(|ft| { + let loc = doc.find(ft.frame)?; + let frame = doc.item_at(&loc)?; + let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?; + Some(Placement { spread: loc.spread, xf }) + }) +} + +/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by +/// `xf`. +fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0)); + objects + .or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1))) + .or_else(|| { + ft.tables + .iter() + .flat_map(|t| &t.cells) + .find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1)) + }) +} + +/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the +/// spread by `parent`. +fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option { + if it.id == fid { + return Some(parent); + } + if depth > crate::MAX_WRAP_DEPTH { + return None; + } + let xf = parent * it.xf; + it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1)) +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6e1784f5e..444e34ebb 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7658,3 +7658,91 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() { assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0])); assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); } + +/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt +/// above its anchor's baseline on a right-hand page. It composes against the document grid where +/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, +/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from +/// its own top edge and sat on no page. +#[test] +fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { + use designcraft_doc::anchored::AnchorRelative as R; + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + d.settings.baseline_grid.start = 36.0; + d.settings.baseline_grid.increment = 10.0; + let lid = d.default_layer(); + let page = d.spreads[1].pages[1].clone(); + assert_eq!(page.side, designcraft_doc::PageSide::Right); + // Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36. + let body = "One.\nTwo.\nThree.\nFour."; + let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0); + let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap(); + d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + let nine_ten = |f: &mut designcraft_doc::story::CharFormat| { + f.over.size = Some(9.0); + f.over.leading = Some(designcraft_doc::Leading::Points(10.0)); + }; + d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten); + // The note: its own story in a 60 × 40 frame anchored in the third line. + let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let text = "Note one\u{2028}note two"; + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() }; + let mut story = Story::with_text(nsid, text, para); + story.format_chars(0..text.len(), nine_ten); + story.frames.push(nid); + d.stories.insert(nsid, Arc::new(story)); + let mut note = + designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0))); + note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() }); + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: -72.0, + y_offset: -15.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + spine_relative: false, + }; + let at = body.find("Three").unwrap(); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position)); + + let cache = Cache::new(); + let on_grid = |y: f64| on_grid(y, (36.0, 10.0)); + // (anchor line baseline, note baselines), in spread space. + let baselines = |d: &Document, cs: &ComposedStory| { + let host = compose_story(d, sid, &ComposeOptions::default()); + let ft = host.frame(bf).unwrap(); + let o = &ft.objects[0]; + let fxf = d.item(bf).unwrap().xf; + let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y; + let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf; + (anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::>()) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 66.0), "anchor baseline {anchor}"); + assert_eq!(lines.len(), 2); + assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + // The note is on the host's right-hand page, with that page's rectangle. + let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default()))); + let spec = &frame_specs_placed(&d, nsid, &placed)[0]; + assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false)); + assert!(spec.page_rect.is_some()); + + // Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from + // 10 pt above it onto the grid line 5 pt above it. The cache composes the note again. + let xf = d.item(bf).unwrap().xf; + d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf; + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 71.0), "anchor baseline {anchor}"); + assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + + // The host's page turns into a left-hand page: the note is composed again for that side. + let before = cache.get(&d, nsid, None); + Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; + assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); +} diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 57657213f..e1cdc5fa7 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -182,7 +182,7 @@ fn settled(a: &[Vec], b: &[Vec]) -> bool { /// [`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); + let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed); if !others.is_empty() { let mut obs = Vec::new(); for cs in others {