Merge #1703: Baseline grid: Relative To Top Margin starts the grid at each page's top margin

# Conflicts:
#	crates/compose/src/lib.rs
This commit is contained in:
aiongg
2026-10-24 05:51:57 +00:00
10 changed files with 257 additions and 17 deletions
+1
View File
@@ -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.
+3
View File
@@ -350,6 +350,7 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
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<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
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());
+4 -9
View File
@@ -709,16 +709,11 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
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));
+97
View File
@@ -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<f64> {
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());
+11
View File
@@ -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 {
+1
View File
@@ -10,6 +10,7 @@ use super::*;
mod assets;
mod clipping;
mod decorations;
mod facing_ltr;
mod index_sort;
mod liquid_guides;
mod object_styles;
+111
View File
@@ -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#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{id}" ParentStory="{story}" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
}
fn story(id: &str, text: &str) -> String {
format!(
r#"<idPkg:Story {NS}><Story Self="{id}"><ParagraphStyleRange><CharacterStyleRange><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
)
}
/// 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<u8> {
let margins = r#"<MarginPreference ColumnCount="1" ColumnGutter="12" Top="36" Bottom="36" Left="36" Right="120"/>"#;
let page = |id: &str, x: f64, applied: bool, guide: Option<f64>| {
let applied = if applied { r#" AppliedMaster="m1""# } else { "" };
let guide = guide.map_or(String::new(), |at| format!(r#"<Guide Self="{id}g" Orientation="Vertical" Location="{at}"/>"#));
format!(r#"<Page Self="{id}"{applied} GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 {x} -300">{margins}{guide}</Page>"#)
};
let master = format!(
r#"<idPkg:MasterSpread {NS}><MasterSpread Self="m1" Name="A-Parent" NamePrefix="A" BaseName="Parent" PageCount="2" ItemTransform="1 0 0 1 0 0">{}{}{}{}</MasterSpread></idPkg:MasterSpread>"#,
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#"<idPkg:Spread {NS}><Spread Self="{id}" PageCount="{}" ItemTransform="1 0 0 1 0 0">{ps}</Spread></idPkg:Spread>"#, pages.len())
};
let map = format!(
r#"<Document {NS} Self="d" StoryList="sl sr"><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1"/><idPkg:MasterSpread src="MasterSpreads/m1.xml"/><idPkg:Spread src="Spreads/sp0.xml"/><idPkg:Spread src="Spreads/sp1.xml"/><idPkg:Story src="Stories/sl.xml"/><idPkg:Story src="Stories/sr.xml"/></Document>"#
);
let prefs = format!(
r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="true" PageBinding="LeftToRight"/><ViewPreference RulerOrigin="PageOrigin"/></idPkg:Preferences>"#
);
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<String> {
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<f64> {
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<PageSide> = 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);
}
+25 -5
View File
@@ -393,10 +393,11 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option<f6
/// Baseline grid. No overlay.
///
/// Lines are `page.y0 + start + n * increment` for n >= 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<AxisHit> {
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.
+3 -2
View File
@@ -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<Item = f64> {
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)),
+1 -1
View File
@@ -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) |