diff --git a/ROADMAP.md b/ROADMAP.md index 2204ca58d..001523158 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-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. - **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. 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 a3fac1eab..248e4d99c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -709,16 +709,11 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let left_page = side == Some(designcraft_doc::PageSide::Left); let right_page = side == Some(designcraft_doc::PageSide::Right); 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(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(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 f9e8ba0fd..d41cf5976 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7266,6 +7266,103 @@ 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); +} + +/// 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; + 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 b72ba1c86..d00c76e74 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) |