Anchored text frames compose against the grid and page where they are placed (#1710)

* Anchored text frames compose against the grid and page where they are placed

An anchored text frame is not a spread item, so frame_specs composed it at
its stored transform: no page, no page side, no page rect, and a baseline
grid phase taken from that transform. A note set to Only Align First Line
to Grid snapped to the wrong line, and page-number markers and
spine-relative alignment inside it saw no page.

The composer now looks up the host story's composition first, finds where
it placed the frame (in its frames' anchored objects, footnotes or table
cells) and composes the frame's story against that placement
(ComposeOptions::placed). The cache does the same and adds the placement
to the entry's signature, so a note recomposes when its host moves it.
Host lookups stop after eight levels, which bounds stories anchored in
each other.

* Anchored text frames: the cache keys on the placed spread's pages
This commit is contained in:
aiongg authored and GitHub committed 2026-10-24 08:30:03 -04:00
1 parent d4c1322d97
commit 4be45a713a
7 files changed
+237 -12

No files matched your search

+1
View File
@@ -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.
+28 -8
View File
@@ -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<ComposedStory> {
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<ComposedStory> {
/// [`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<ComposedStory> {
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<Arc<ComposedStory>> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect();
let others: Vec<Arc<ComposedStory>> = 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::<Vec<_>>(),
));
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<ComposedStory> {
fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc<ComposedStory> {
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)
}
}
+34 -3
View File
@@ -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<String>,
/// Anchor pages for cross-references (needed when the story contains cross-references).
pub xrefs: Option<Arc<xref::XrefIndex>>,
/// 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<FrameSpec> {
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<FrameSpec> {
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<FrameSpec> {
/// 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<ComposedStory> =
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::<Vec<_>>())
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;
+1
View File
@@ -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();
+84
View File
@@ -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<ComposedStory>) -> 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<Placement> {
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<Affine> {
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<Affine> {
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))
}
+88
View File
@@ -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::<Vec<_>>())
};
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)));
}
+1 -1
View File
@@ -182,7 +182,7 @@ fn settled(a: &[Vec<Exclusion>], b: &[Vec<Exclusion>]) -> 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 {