From 14da000a350e60bc93b4526820d67a5c4fbe529c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:28:34 +0000 Subject: [PATCH 1/2] Baseline grid: Relative To Top Margin starts the grid at each page's top margin The setting was imported and editable but never read: the composer, the canvas grid and baseline snapping measured the grid from the page top. BaselineGrid::origin gives the first line on a page (start, plus the page's top margin for Top Margin), and all three use it. The composition cache tracks the setting and the pages' top margins. Also adds a left-to-right facing-pages guard test for mirrored margins and the right parent page's guides and running head. --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 3 + crates/compose/src/lib.rs | 13 +--- crates/compose/src/tests.rs | 71 ++++++++++++++++++ crates/doc/src/lib.rs | 11 +++ crates/idml/src/tests.rs | 1 + crates/idml/src/tests/facing_ltr.rs | 111 ++++++++++++++++++++++++++++ crates/tools/src/snap.rs | 30 ++++++-- crates/ui-egui/src/canvas.rs | 5 +- docs/typography-parity.md | 2 +- 10 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 crates/idml/src/tests/facing_ltr.rs diff --git a/ROADMAP.md b/ROADMAP.md index e4d94421d..4ce5bd0f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -350,6 +350,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 77bb30f95..404b43685 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -350,6 +350,7 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) doc.font_scope as usize, g.start.to_bits() as usize, g.increment.to_bits() as usize, + usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin), ]; let mut keep = Vec::new(); let mut spreads = Vec::new(); @@ -373,6 +374,8 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) sig.push(loc.top()); if !spreads.contains(&loc.spread) { spreads.push(loc.spread); + // A grid relative to the top margin starts at each page's own margin. + sig.extend(sp.pages.iter().map(|p| p.margins.top.to_bits() as usize)); for it in &sp.items { let mut wraps = false; it.walk(&mut |i| wraps |= i.has_wrap()); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ebf1e377b..f6b7a605c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -703,16 +703,11 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { _ => (None, None, false), }; let section_marker = page.map(|abs| section_marker(doc, abs)); - // The page under the frame and its margins, in inner space (bounding boxes). - let page_rect = match (spread_ref, spread) { - (_, Some(sp)) => sp - .page_at_x(item.bounds().center().x) - .and_then(|pi| sp.pages.get(pi)) - .map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))), - _ => None, - }; + // The page under the frame, and its bounds and margins in inner space (bounding boxes). + let under = spread.and_then(|sp| sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi))); + let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))); let g = &doc.settings.baseline_grid; - let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start)); + let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, under.map_or(g.start, |pg| g.origin(pg)))); // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). let ty = xf.translation().y; let grid = (inc > 0.0).then_some((start - ty, inc)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b73806d27..e761639b7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7199,6 +7199,77 @@ fn cache_recomposes_when_the_baseline_grid_changes() { assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline); } +/// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned +/// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582). +fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) { + let mut d = Document::new(&NewDocument { + height: 618.0, + pages: 2, + facing_pages: false, + margins: designcraft_doc::Margins::uniform(36.0), + ..Default::default() + }); + Arc::make_mut(&mut d.spreads[1]).pages[0].margins.top = 48.0; + Arc::make_mut(&mut d.spreads[1]).pages[0].local_margins = true; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = relative_to; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let text = [LOREM; 12].join(" "); + let mut sids = Vec::new(); + for (si, top) in [(0, 36.0), (1, 48.0)] { + let (_, sid) = d.add_text_frame(SpreadRef::Doc(si), Rect::new(36.0, top, 336.0, 582.0), lid, &text, para.clone()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + sids.push(sid); + } + (d, [sids[0], sids[1]]) +} + +/// Page y of a frame's baselines (the frame's inner space is its page's: it has no transform). +fn page_baselines(d: &Document, sid: StoryId) -> Vec { + let cs = compose_story(d, sid, &ComposeOptions::default()); + assert!(cs.is_overset()); + cs.frames[0].lines.iter().map(|l| l.baseline).collect() +} + +#[test] +fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() { + use designcraft_doc::GridRelative; + let (d, [p1, p2]) = grid_margin_doc(GridRelative::TopMargin); + // Grid lines at 36 + 13n on page 1: 42 lines from 49 to 582. + let lines = page_baselines(&d, p1); + assert!((lines[0] - 49.0).abs() < 1e-6, "{lines:?}"); + assert!(lines.iter().enumerate().all(|(n, y)| (y - (49.0 + 13.0 * n as f64)).abs() < 1e-6), "{lines:?}"); + assert_eq!(lines.len(), 42); + // Page 2's own margin: lines at 48 + 13n. + let lines = page_baselines(&d, p2); + assert!((lines[0] - 61.0).abs() < 1e-6, "{lines:?}"); + // From the top of the page the lines are at 13n: the first at 52, and one line fewer. + let (d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage); + let lines = page_baselines(&d, p1); + assert!((lines[0] - 52.0).abs() < 1e-6, "{lines:?}"); + assert_eq!(lines.len(), 41); +} + +#[test] +fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() { + use designcraft_doc::GridRelative; + let (mut d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage); + let cache = Cache::new(); + let a = cache.get(&d, p1, None); + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let b = cache.get(&d, p1, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0); + Arc::make_mut(&mut d.spreads[0]).pages[0].margins.top = 40.0; + let c = cache.get(&d, p1, None); + assert!(!Arc::ptr_eq(&b, &c)); + assert!((b.frames[0].lines[0].baseline - c.frames[0].lines[0].baseline).abs() > 1.0); +} + #[test] fn cache_recomposes_when_a_wrap_layer_is_hidden() { let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b2d3b4f4c..a509fdd8b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -147,6 +147,17 @@ impl Default for BaselineGrid { } } +impl BaselineGrid { + /// The first grid line on `page`, measured down from the page top: `start` from the top of + /// the page or from its top margin. The other lines follow every `increment` below it. + pub fn origin(&self, page: &page::Page) -> f64 { + match self.relative_to { + GridRelative::TopOfPage => self.start, + GridRelative::TopMargin => self.start + page.margins.top, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocumentGrid { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 35374a5e0..a892062b1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -10,6 +10,7 @@ use super::*; mod assets; mod clipping; mod decorations; +mod facing_ltr; mod index_sort; mod liquid_guides; mod object_styles; diff --git a/crates/idml/src/tests/facing_ltr.rs b/crates/idml/src/tests/facing_ltr.rs new file mode 100644 index 000000000..b1c322ecc --- /dev/null +++ b/crates/idml/src/tests/facing_ltr.rs @@ -0,0 +1,111 @@ +use designcraft_doc::PageSide::{self, Left, Right}; + +use super::*; + +const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0""#; + +/// A running head: a text frame across `x0..x1` (IDML spread x), 20 pt below the page top. +fn head(id: &str, story: &str, x0: f64, x1: f64) -> String { + let pts: String = [(x0, -280.0), (x0, -260.0), (x1, -260.0), (x1, -280.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + format!( + r#"{pts}"# + ) +} + +fn story(id: &str, text: &str) -> String { + format!( + r#"{text}"# + ) +} + +/// Bound left to right with facing pages: the first page of a spread lies left of the spine +/// (except page 1, alone on the right). Every page has margins `Left=36 Right=120` (inside 36, +/// outside 120), the parent's left page a ruler guide at 108 and a "Left head" running head, its +/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure +/// from each page. +fn left_to_right_fixture() -> Vec { + let margins = r#""#; + let page = |id: &str, x: f64, applied: bool, guide: Option| { + let applied = if applied { r#" AppliedMaster="m1""# } else { "" }; + let guide = guide.map_or(String::new(), |at| format!(r#""#)); + format!(r#"{margins}{guide}"#) + }; + let master = format!( + r#"{}{}{}{}"#, + page("mp1", -400.0, false, Some(108.0)), + page("mp2", 0.0, false, Some(300.0)), + head("hl", "sl", -280.0, -36.0), + head("hr", "sr", 36.0, 280.0), + ); + let spread = |id: &str, pages: &[(&str, f64)]| { + let ps: String = pages.iter().map(|(p, x)| page(p, *x, true, None)).collect(); + format!(r#"{ps}"#, pages.len()) + }; + let map = format!( + r#""# + ); + let prefs = format!( + r#""# + ); + zip_files(&[ + ("designmap.xml", &map), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/m1.xml", &master), + ("Spreads/sp0.xml", &spread("sp0", &[("p1", 0.0)])), + ("Spreads/sp1.xml", &spread("sp1", &[("p2", -400.0), ("p3", 0.0)])), + ("Stories/sl.xml", &story("sl", "Left head")), + ("Stories/sr.xml", &story("sr", "Right head")), + ]) +} + +/// The text of the running heads a document page shows from its parent page. +fn shown_heads(d: &Document, abs: usize) -> Vec { + let (ppi, ppage) = d.parent_page_for(abs).unwrap(); + let parent = &d.parents[ppi]; + parent + .items + .iter() + .filter(|it| parent.page_at_x(it.bounds().center().x) == Some(ppage)) + .filter_map(|it| d.story(it.text_frame()?.story).map(|s| s.text.clone())) + .collect() +} + +/// Ruler guides of the parent page a document page shows, as x from that parent page's left edge. +fn parent_guides(d: &Document, abs: usize) -> Vec { + let (ppi, ppage) = d.parent_page_for(abs).unwrap(); + let pp = &d.parents[ppi].pages[ppage]; + pp.guides.iter().map(|g| g.position - pp.x).collect() +} + +fn assert_right_pages_mirror_left_pages(d: &Document) { + assert!(d.settings.facing_pages && !d.settings.right_to_left_binding); + let sides: Vec = d.parents[0].pages.iter().map(|p| p.side).collect(); + assert_eq!(sides, vec![Left, Right]); + // Right pages: inside (36) on the left; left pages: outside (120) on the left. + for (abs, side, live, guide, text) in [ + (0, Right, (36.0, 280.0), 300.0, "Right head"), + (1, Left, (120.0, 364.0), 108.0, "Left head"), + (2, Right, (36.0, 280.0), 300.0, "Right head"), + ] { + let n = abs + 1; + let page = d.page(abs).unwrap(); + assert_eq!(page.side, side, "page {n}"); + let m = page.margin_rect(); + assert!((m.x0 - page.x - live.0).abs() < 1e-6 && (m.x1 - page.x - live.1).abs() < 1e-6, "page {n}: margins {m:?}"); + assert_eq!(d.parents[0].pages[d.parent_page_for(abs).unwrap().1].side, side, "page {n} shows the parent's {side:?} page"); + let guides = parent_guides(d, abs); + assert!(guides.len() == 1 && (guides[0] - guide).abs() < 1e-6, "page {n}: guides {guides:?}"); + assert_eq!(shown_heads(d, abs), vec![text.to_string()], "page {n}"); + } +} + +#[test] +fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() { + let d = import_idml(&left_to_right_fixture()).unwrap(); + assert_right_pages_mirror_left_pages(&d); + let again = import_idml(&export_idml(&d)).unwrap(); + assert_right_pages_mirror_left_pages(&again); +} diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 11da46178..328e7d0d1 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -393,10 +393,11 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option= 0 while the line is inside the page -/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the -/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not -/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide +/// Lines are `page.y0 + origin + n * increment` for n >= 0 while the line is inside the page +/// (the bottom edge is not a line, matching the canvas), `origin` from +/// [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). An increment of 0 or less +/// skips the pass. `view_threshold` is not read, and hiding the baseline grid does not turn this +/// off. Only a page the rect crosses on x contributes. A dragged ruler guide /// ([`Gesture::Guide`]) doesn't snap to it. fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) { @@ -417,7 +418,10 @@ fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) if !ranges_overlap(moving_lo, moving_hi, lo, hi) { continue; } - let origin = bounds.y0 + baseline.start; + let origin = bounds.y0 + baseline.origin(page); + if !origin.is_finite() { + continue; + } offer_enabled(&mut best, req.rect, flags, axis, tol, |v| nearest_baseline(v, origin, increment, bounds.y0, bounds.y1)); } best.map(|b| b.hit) @@ -1790,6 +1794,22 @@ mod tests { assert_eq!(hit.delta.y, 0.0); } + #[test] + fn baseline_relative_to_the_top_margin_snaps_from_the_margin() { + let mut doc = Document::new(&NewDocument::default()); + std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].margins.top = 37.0; + doc.settings.baseline_grid.start = 0.0; + doc.settings.baseline_grid.increment = 10.0; + doc.settings.baseline_grid.relative_to = designcraft_doc::GridRelative::TopMargin; + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + // Lines at 37 + 10n: a top at 58 goes up to 57, not down to the page-relative 60. + let hit = snap(&cx, y_req(Rect::new(100.0, 58.0, 140.0, 80.0))); + assert!((hit.delta.y - -1.0).abs() < 1e-6, "{}", hit.delta.y); + } + #[test] fn nearer_of_margin_and_baseline_wins() { // Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2118c4929..b1c71c056 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -962,7 +962,8 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, const MAX_BASELINES: usize = 216 * 72; /// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom -/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +/// edge is not a line), `start` from [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). Lines +/// above the page are skipped without walking them, however far up the start is. fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator { let step = increment.max(1.0); let first = if start < 0.0 { start.rem_euclid(step) } else { start }; @@ -1022,7 +1023,7 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - for y in baseline_grid_lines(g.start, g.increment, p.height) { + for y in baseline_grid_lines(g.origin(p), g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 68d7a5d9b..1cdca69de 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -46,7 +46,7 @@ Fidelity: **unmeasured** unless stated. | Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md | | First baseline | ascent, cap height, x-height, leading, fixed, min | ✓ | ✓ | | | Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits | -| Baseline grid | document and frame grids | ✓ | ✓ | | +| Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids | ✓ | ✓ | | | Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | | | Text in shaped frames | lines follow oval, polygon and freeform frame outlines, one inset from the outline | ✓ | ~ | concave shapes set one slot per line (the widest); vertical justification and paragraph shading use the frame's rectangle (#646) | | Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) | From 05901d3ec1d9c6d5c5c339ec68c49d38ffb3e8d9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:39:34 +0000 Subject: [PATCH 2/2] Baseline grid: find the page under a grouped frame from its spread position A group's transform can move a frame onto another page; the page lookup used the frame's bounds without its parents' transforms, so the grid took the wrong page's top margin. --- crates/compose/src/lib.rs | 9 +++++---- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f6b7a605c..49f9428c1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -686,17 +686,18 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 }); } + // The frame's center on its spread: a group around it can move it to another page. + let center_x = loc.as_ref().map_or(item.bounds(), |l| doc.parent_xf(l).transform_rect_bbox(item.bounds())).center().x; let (page_name, page, left_page) = match (spread_ref, spread) { (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { - let c = item.bounds().center(); - let pi = sp.page_at_x(c.x).unwrap_or(0); + let pi = sp.page_at_x(center_x).unwrap_or(0); let abs = doc.first_page_of_spread(si) + pi; (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) } (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); // The side of the parent page under the frame, so a folio there aligns to the spine of its own page. - let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi)); + let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)); let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left); (Some(prefix), None, left) } @@ -704,7 +705,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { }; let section_marker = page.map(|abs| section_marker(doc, abs)); // The page under the frame, and its bounds and margins in inner space (bounding boxes). - let under = spread.and_then(|sp| sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi))); + let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi))); let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))); let g = &doc.settings.baseline_grid; let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, under.map_or(g.start, |pg| g.origin(pg)))); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e761639b7..e07e7707d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7254,6 +7254,32 @@ fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() { assert_eq!(lines.len(), 41); } +/// A group's transform moves a frame from the left page of a spread to the right one: the grid +/// starts at the right page's top margin. +#[test] +fn baseline_grid_follows_a_grouped_frame_to_its_page() { + use designcraft_doc::{Content, GridRelative, Item, ItemId, Shape}; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(36.0, 36.0, 336.0, 582.0), lid, LOREM, para).unwrap(); + let sp = Arc::make_mut(&mut d.spreads[1]); + let at = sp.items.iter().position(|it| it.id == fid).unwrap(); + let frame = sp.items.remove(at); + let right = sp.pages[1].x; + let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, designcraft_geom::PathData::default()); + g.content = Content::Group { items: vec![frame] }; + g.xf = designcraft_geom::Affine::translate((right, 0.0)); + Arc::make_mut(&mut d.spreads[1]).items.push(Arc::new(g)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = cs.frames[0].lines[0].baseline; + assert!((first - 61.0).abs() < 1e-6, "{first}"); +} + #[test] fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() { use designcraft_doc::GridRelative;