Merge remote-tracking branch 'origin/main' into paragraph-shading-indesign

# Conflicts:
#	ROADMAP.md
This commit is contained in:
moppeeer
2026-10-24 15:22:34 +02:00
7 changed files with 237 additions and 12 deletions
+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-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. 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.
+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};
@@ -629,6 +631,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.
@@ -661,21 +666,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);
@@ -785,10 +803,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 {