From 5c6d45908d447f6e96599505c9225d31b612862f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:25:22 -0800 Subject: [PATCH 1/7] Compose: vertical justification keeps grid-aligned lines on the baseline grid With Align to Baseline Grid, Center and Bottom moved the lines by any amount, so they left the grid. They now move by whole grid steps: Bottom puts the last baseline on the lowest grid line above the bottom inset, and Center on the grid line nearest the centered position. With Justify the first line stays in place and the last baseline goes to the lowest grid line above the bottom inset; the lines between are spread evenly. --- crates/compose/src/lib.rs | 61 +++++++++++++++++++++++++++++++------ crates/compose/src/tests.rs | 53 +++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 72c0f69cb..f43caad84 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2737,6 +2737,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // and in split columns. let mut span_paras = vec![false; np]; let mut split_paras = vec![false; np]; + // Each paragraph's Align to Baseline Grid (vertical justification moves grid-aligned lines by + // whole grid steps). + let mut grid_paras = vec![GridAlign::None; np]; 'paras: while pi <= np { cur.pi = pi; cur.limits = Rc::clone(&limits); @@ -2896,6 +2899,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // it, a paragraph moves on to the next sub-column. let split_cfg = SplitCfg::of(&pp); split_paras[pi] = split_cfg.is_some(); + grid_paras[pi] = pp.grid_align; if cur.split.is_some_and(|s| Some(s.cfg) != split_cfg) { cur.split = None; } @@ -3124,6 +3128,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C boxes: &boxes, span_paras: &span_paras, split_paras: &split_paras, + grid_paras: &grid_paras, styles_tab, }) } @@ -3138,11 +3143,12 @@ struct FinishInput<'a> { boxes: &'a [Option], span_paras: &'a [bool], split_paras: &'a [bool], + grid_paras: &'a [GridAlign], styles_tab: Vec, } fn finish_composition(input: FinishInput<'_>) -> ComposedStory { - let FinishInput { doc, story, frames, cols, notes, out, boxes, span_paras, split_paras, styles_tab } = input; + let FinishInput { doc, story, frames, cols, notes, out, boxes, span_paras, split_paras, grid_paras, styles_tab } = input; notes.finish(doc, story, frames, cols, out); // Ranges, content heights and vertical justification. for (fi, ft) in out.frames.iter_mut().enumerate() { @@ -3159,7 +3165,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - vertical_justify(ft, f, spanned, &doc.footnote_options); + vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -5484,8 +5490,9 @@ fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tin out } -/// `spanned`: a paragraph spans the frame's columns, so the lines move together. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { +/// `spanned`: a paragraph spans the frame's columns, so the lines move together. `grid_aligns`: +/// each paragraph's Align to Baseline Grid setting. +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions, grid_aligns: &[GridAlign]) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5563,10 +5570,13 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de let fits = |shift: &[f64]| { idx.iter().zip(&rows).all(|(&i, &k)| k == n || shift[k] <= 0.0 || ft.lines[i].baseline + ft.lines[i].descent + shift[k] <= limit + 1e-9) }; - // Every row moves no less as `space` grows, so the most room that fits is found by halving. - let mut shift = shifts(space); - if !fits(&shift) { - let (mut lo, mut hi) = (0.0, space); + // Every row moves no less as the room grows, so the most room that fits, up to `top`, is + // found by halving. + let most_room = |top: f64| -> f64 { + if fits(&shifts(top)) { + return top; + } + let (mut lo, mut hi) = (0.0, top); for _ in 0..60 { let mid = (lo + hi) / 2.0; if fits(&shifts(mid)) { @@ -5575,8 +5585,39 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de hi = mid; } } - shift = shifts(lo); - } + lo + }; + // Lines aligned to the baseline grid move by whole grid steps, so they stay on the grid. + // With Justify only the last row has to land on the grid (the first row doesn't move). + let on_grid = |i: usize| { + let line = &ft.lines[i]; + match grid_aligns.get(line.para) { + Some(GridAlign::AllLines) => true, + Some(GridAlign::FirstLineOnly) => line.first_in_para, + _ => false, + } + }; + let center = f.opts.vertical_justification == VerticalJustification::Center; + let step = f.grid.map(|(_, inc)| inc).filter(|inc| *inc > 0.0 && inc.is_finite()).filter(|_| match f.opts.vertical_justification { + VerticalJustification::Justify => idx.iter().zip(&rows).any(|(&i, &k)| k == n && on_grid(i)), + _ => idx.iter().any(|&i| on_grid(i)), + }); + let shift = match step { + None => shifts(most_room(space)), + Some(inc) => { + // The room one grid step takes: Center moves the lines by half the room. + let unit = if center { 2.0 * inc } else { inc }; + let room = most_room(if center { 2.0 * space } else { space }); + // The last baseline stays above the bottom of the text, and the rows above it fit. + let most = (space / inc + 1e-9).floor().min((room / unit + 1e-9).floor()).max(0.0); + // Bottom and Justify take the lowest grid line, Center the one nearest the middle. + let mut steps = if center { (space / 2.0 / inc).round().min(most) } else { most }; + if steps > 0.0 && !fits(&shifts(steps * unit)) { + steps -= 1.0; + } + shifts(steps * unit) + } + }; let before: Vec = idx.iter().map(|&i| ft.lines[i].baseline).collect(); for (&i, &k) in idx.iter().zip(&rows) { ft.lines[i].baseline += shift[k]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b94d94000..0f7b54da2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2291,7 +2291,7 @@ fn vertical_justification_counts_table_fragment_bottoms() { let mut spec = frame_specs(&d, sid).remove(0); spec.opts.vertical_justification = mode; - vertical_justify(&mut ft, &spec, false, &d.footnote_options); + vertical_justify(&mut ft, &spec, false, &d.footnote_options, &[]); let moved = ft.lines[anchor].baseline - baseline; let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; @@ -2384,6 +2384,57 @@ fn vertical_justification_keeps_earlier_descenders_inside() { assert!((last - area.y1).abs() < 1e-6, "Justify: last baseline at {last}, text area at {}", area.y1); } +/// With Align to Baseline Grid, Center and Bottom move the lines by whole grid steps, so every +/// line stays on the grid: Bottom puts the last baseline on the lowest grid line above the bottom +/// inset, and Center on the grid line nearest the centered place. Justify keeps the first and last +/// lines on the grid and spreads the lines between them. +#[test] +fn vertical_justification_keeps_grid_aligned_lines_on_the_grid() { + use designcraft_doc::GridAlign; + use designcraft_doc::VerticalJustification::{self, Bottom, Center, Justify, Top}; + let compose_with = |mode: VerticalJustification, align: GridAlign| { + let mut area = Rect::ZERO; + let (lines, grid) = grid_doc("One\nTwo\nThree", ParaAttrs { grid_align: Some(align), ..Default::default() }, |d, sid| { + let spec = frame_specs(d, sid).remove(0); + area = spec.area; + d.item_mut(spec.id).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode; + }); + (area, lines.iter().map(|l| l.baseline).collect::>(), grid) + }; + let (area, top, grid) = compose_with(Top, GridAlign::AllLines); + let inc = grid.1; + assert_eq!(top.len(), 3); + assert!(top.iter().all(|&b| on_grid(b, grid)), "Top: {top:?}"); + assert!(!on_grid(area.y1, grid), "the bottom inset is between grid lines: {area:?}"); + + let (_, bottom, _) = compose_with(Bottom, GridAlign::AllLines); + assert!(bottom.iter().all(|&b| on_grid(b, grid)), "Bottom keeps every line on the grid: {bottom:?}"); + let last = bottom[2]; + assert!(last <= area.y1 + 1e-6 && last > area.y1 - inc, "Bottom: last baseline {last} on the lowest grid line above {}", area.y1); + assert!((bottom[1] - bottom[0] - (top[1] - top[0])).abs() < 1e-6, "Bottom keeps the leading"); + + let (_, center, _) = compose_with(Center, GridAlign::AllLines); + assert!(center.iter().all(|&b| on_grid(b, grid)), "Center keeps every line on the grid: {center:?}"); + let shift = center[0] - top[0]; + let ideal = (area.y1 - top[2]) / 2.0; + assert!(shift > 1.0 && (shift - ideal).abs() <= inc / 2.0 + 1e-6, "Center: moved {shift}, centered at {ideal}"); + assert!(center[2] <= area.y1 + 1e-6); + + let (_, justify, _) = compose_with(Justify, GridAlign::AllLines); + assert!((justify[0] - top[0]).abs() < 1e-6, "Justify keeps the first baseline: {justify:?}"); + assert!(on_grid(justify[2], grid), "Justify keeps the last line on the grid: {justify:?}"); + assert!(justify[2] <= area.y1 + 1e-6 && justify[2] > area.y1 - inc, "Justify: last baseline {} near {}", justify[2], area.y1); + assert!((justify[1] - justify[0] - (justify[2] - justify[1])).abs() < 1e-6, "Justify spreads the lines evenly: {justify:?}"); + + // First lines only: each paragraph here is one line, so Bottom keeps every line on the grid. + let (_, first_only, _) = compose_with(Bottom, GridAlign::FirstLineOnly); + assert!(first_only.iter().all(|&b| on_grid(b, grid)), "Bottom, first lines only: {first_only:?}"); + + // Without grid alignment the last baseline goes down to the bottom inset. + let (_, free, _) = compose_with(Bottom, GridAlign::None); + assert!((free[2] - area.y1).abs() < 1e-6, "Bottom without the grid: {free:?}"); +} + /// A corner radius leaves a Cap Height first line where it sits in a rectangular frame, with Top /// and with Center: a line's band above the text area isn't held to the frame's outline (#1999). /// Center puts the last baseline halfway between where Top sets it and the bottom inset, also in From 5d9fbb4d4eb892a51f3563574c11c20530badcfd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:02:54 -0800 Subject: [PATCH 2/7] Docs: vertical justification moves grid-aligned lines by whole grid steps --- docs/typography-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8e17cd2a9..7987b9632 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -47,7 +47,7 @@ Fidelity: **unmeasured** unless stated. | 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 grid relative to the top of the page or each page's top margin; frame grids relative to the top of the page, top margin, top of frame or top inset | ✓ | ✓ | | -| Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | | +| Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | with Center and Bottom, lines aligned to the baseline grid move by whole grid steps; with Justify only the first and last lines stay on the grid | | 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) | | Column rules | ✓ | ✓ (screen, PDF, IDML) | ~ | no stroke types or overprint (#274) | From 5e60c4d8fe56a8265123bac2eef45aec161b9c0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:54:29 -0800 Subject: [PATCH 3/7] Compose: vertical justification stays finite with an extreme grid increment A grid increment so small that the room holds more steps than an f64 can count made the grid step count infinite, and Bottom, Center and Justify then moved the grid-aligned lines to an infinite baseline. Such an increment now leaves the lines placed as they are without a grid. A Center step so large that doubling it overflows no longer turns a zero step count into a NaN shift; the lines stay where they are. --- crates/compose/src/lib.rs | 6 +++++- crates/compose/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f43caad84..84da4a148 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -5604,6 +5604,9 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de }); let shift = match step { None => shifts(most_room(space)), + // A step so small that the room holds more steps than an f64 can count: the lines take + // the room as they do without a grid, and stay within one step of a grid line. + Some(inc) if !(space / inc).is_finite() => shifts(most_room(space)), Some(inc) => { // The room one grid step takes: Center moves the lines by half the room. let unit = if center { 2.0 * inc } else { inc }; @@ -5615,7 +5618,8 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de if steps > 0.0 && !fits(&shifts(steps * unit)) { steps -= 1.0; } - shifts(steps * unit) + // With no step taken the lines stay put, even when Center's doubled step overflows. + shifts(if steps > 0.0 { steps * unit } else { 0.0 }) } }; let before: Vec = idx.iter().map(|&i| ft.lines[i].baseline).collect(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0f7b54da2..aca4ab07c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2435,6 +2435,43 @@ fn vertical_justification_keeps_grid_aligned_lines_on_the_grid() { assert!((free[2] - area.y1).abs() < 1e-6, "Bottom without the grid: {free:?}"); } +/// A grid increment too small for the room to be counted in steps, or too large to double, leaves +/// a grid-aligned line at a finite place inside the text area with Bottom, Center and Justify. +#[test] +fn vertical_justification_with_an_extreme_grid_increment_stays_finite() { + use designcraft_doc::GridAlign; + use designcraft_doc::VerticalJustification::{self, Bottom, Center, Justify, Top}; + // The grid starts at the line's natural baseline, so the line sits on it before it moves. + let (natural, grid) = grid_doc("One", ParaAttrs { grid_align: Some(GridAlign::None), ..Default::default() }, |_, _| {}); + let natural = natural[0].baseline; + let start = natural - grid.0; + let compose_with = |mode: VerticalJustification, inc: f64| { + let mut area = Rect::ZERO; + let (lines, _) = grid_doc("One", ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, |d, sid| { + let spec = frame_specs(d, sid).remove(0); + area = spec.area; + let opts = &mut d.item_mut(spec.id).unwrap().text_frame_mut().unwrap().options; + opts.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(start, inc)); + opts.vertical_justification = mode; + }); + (area, lines[0].baseline) + }; + for inc in [1e-310, 1e308] { + let (_, top) = compose_with(Top, inc); + assert!((top - natural).abs() < 1e-6, "Top with increment {inc:e}: baseline {top}, natural {natural}"); + for mode in [Bottom, Center, Justify] { + let (area, b) = compose_with(mode, inc); + assert!(b.is_finite() && b >= top - 1e-6 && b <= area.y1 + 1e-6, "{mode:?} with increment {inc:e}: baseline {b}, text area {area:?}"); + } + } + // A step far smaller than the room: Bottom takes the room as it does without a grid. + let (area, b) = compose_with(Bottom, 1e-310); + assert!((b - area.y1).abs() < 1e-6, "Bottom with a tiny increment: baseline {b}, text area {area:?}"); + // A step far larger than the room: the line does not move. + let (_, b) = compose_with(Center, 1e308); + assert!((b - natural).abs() < 1e-6, "Center with a huge increment: baseline {b}, natural {natural}"); +} + /// A corner radius leaves a Cap Height first line where it sits in a rectangular frame, with Top /// and with Center: a line's band above the text area isn't held to the frame's outline (#1999). /// Center puts the last baseline halfway between where Top sets it and the bottom inset, also in From fc5221060c38df2573f4f6a5c150c9bf9f9a5d1f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 15:59:29 -0500 Subject: [PATCH 4/7] Fix vertical justification test arguments --- crates/compose/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d2f92f2ee..41a734f4c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2660,7 +2660,7 @@ fn vertical_justification_counts_table_fragment_bottoms() { let mut spec = frame_specs(&d, sid).remove(0); spec.opts.vertical_justification = mode; - vertical_justify(&mut ft, &spec, false, &d.footnote_options, &[]); + vertical_justify(&mut ft, &spec, false, &d.footnote_options, &[], &[]); let moved = ft.lines[anchor].baseline - baseline; let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; From 946e3ea4c19e18693c4fb144ef7b3a5ed02496c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:50:29 -0800 Subject: [PATCH 5/7] Compose: grid-aligned vertical justification takes only grid steps that fit a shaped frame In a frame that narrows and widens again, Bottom, Center and Justify now try lower grid steps until the lines fit the outline, and leave the lines in place when none does, instead of taking the next lower step unchecked. A grid whose step is too small to count across the column is now treated as no grid when a line is placed, so the line keeps a finite baseline. Tests cover a waist-shaped frame on a 40 pt grid, a tiny-step grid that starts away from the line, and Justify with a wrapped paragraph under All Lines and First Line Only. --- crates/compose/src/lib.rs | 20 ++++++++--- crates/compose/src/tests.rs | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b83f479fe..9a11229c4 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1858,8 +1858,14 @@ fn position_line(input: PositionLineInput<'_, '_>) -> LinePosition { if cur.last_baseline.is_some() { baseline += cur.last_reference - reference; } - let grid = f.grid.filter(|&(_, inc)| { - inc > 0.0 && inc.is_finite() && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) + // A step so small that the column holds more steps than an f64 can count is no grid: the + // line is placed as it is without one. + let countable = |g_start: f64, inc: f64| [baseline, col.y0, col.y1].iter().all(|y| ((y - g_start) / inc).is_finite()); + let grid = f.grid.filter(|&(g_start, inc)| { + inc > 0.0 + && inc.is_finite() + && countable(g_start, inc) + && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) }); if let Some((g_start, inc)) = grid { let n = pp.grid_gyoudori.min(grid::MAX_GYOUDORI); @@ -6152,8 +6158,14 @@ fn vertical_justify( let most = (space / inc + 1e-9).floor().min((room / unit + 1e-9).floor()).max(0.0); // Bottom and Justify take the lowest grid line, Center the one nearest the middle. let mut steps = if center { (space / 2.0 / inc).round().min(most) } else { most }; - if steps > 0.0 && !fits(&shifts(steps * unit)) { - steps -= 1.0; + // A shaped frame can narrow and widen again, so a lower step may not fit either: take + // the nearest step that fits, and leave the lines in place when none of the next + // `MAX_GRID_TRIES` does. + const MAX_GRID_TRIES: usize = 512; + let mut tries = 0; + while steps > 0.0 && !fits(&shifts(steps * unit)) { + tries += 1; + steps = if tries < MAX_GRID_TRIES { steps - 1.0 } else { 0.0 }; } // With no step taken the lines stay put, even when Center's doubled step overflows. shifts(if steps > 0.0 { steps * unit } else { 0.0 }) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 41a734f4c..ab8f56837 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2799,6 +2799,25 @@ fn vertical_justification_keeps_grid_aligned_lines_on_the_grid() { let (_, first_only, _) = compose_with(Bottom, GridAlign::FirstLineOnly); assert!(first_only.iter().all(|&b| on_grid(b, grid)), "Bottom, first lines only: {first_only:?}"); + // Justify with a paragraph that wraps: with all lines aligned the last line lands on the grid; + // with first lines only it is not a first line, so it goes down to the bottom inset. + let wrapped = "The first line of this paragraph sits on the baseline grid, and the lines after it keep the leading they have."; + let justify_wrapped = |align: GridAlign| { + let (lines, _) = grid_doc(wrapped, ParaAttrs { grid_align: Some(align), ..Default::default() }, |d, sid| { + let spec = frame_specs(d, sid).remove(0); + d.item_mut(spec.id).unwrap().text_frame_mut().unwrap().options.vertical_justification = Justify; + }); + lines.iter().map(|l| l.baseline).collect::>() + }; + let all = justify_wrapped(GridAlign::AllLines); + assert!(all.len() >= 2, "the paragraph wraps: {all:?}"); + let last = all[all.len() - 1]; + assert!(on_grid(last, grid) && last <= area.y1 + 1e-6 && last > area.y1 - inc, "Justify, all lines: last baseline {last}"); + let first_lines = justify_wrapped(GridAlign::FirstLineOnly); + assert!(first_lines.len() >= 2, "the paragraph wraps: {first_lines:?}"); + let last = first_lines[first_lines.len() - 1]; + assert!((last - area.y1).abs() < 1e-6 && !on_grid(last, grid), "Justify, first lines only: last baseline {last}, inset at {}", area.y1); + // Without grid alignment the last baseline goes down to the bottom inset. let (_, free, _) = compose_with(Bottom, GridAlign::None); assert!((free[2] - area.y1).abs() < 1e-6, "Bottom without the grid: {free:?}"); @@ -2839,6 +2858,57 @@ fn vertical_justification_with_an_extreme_grid_increment_stays_finite() { // A step far larger than the room: the line does not move. let (_, b) = compose_with(Center, 1e308); assert!((b - natural).abs() < 1e-6, "Center with a huge increment: baseline {b}, natural {natural}"); + + // A grid that starts away from the line, with a step too small to count the distance: the line + // is placed as it is without a grid. + for mode in [Top, Bottom, Center, Justify] { + let mut area = Rect::ZERO; + let (lines, _) = grid_doc("One", ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, |d, sid| { + let spec = frame_specs(d, sid).remove(0); + area = spec.area; + let opts = &mut d.item_mut(spec.id).unwrap().text_frame_mut().unwrap().options; + opts.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(start - 5.0, 1e-310)); + opts.vertical_justification = mode; + }); + let b = lines[0].baseline; + assert!(b.is_finite() && b >= natural - 1e-6 && b <= area.y1 + 1e-6, "{mode:?}, grid away from the line: baseline {b}, text area {area:?}"); + if mode == Top { + assert!((b - natural).abs() < 1e-6, "Top, grid away from the line: baseline {b}, natural {natural}"); + } + } +} + +/// In a shaped frame that narrows and widens again, Bottom with Align to Baseline Grid takes the +/// lowest grid step at which the line's slot fits the outline, even when the grid steps next to +/// the lowest room all fall in the narrow part. +#[test] +fn vertical_justification_on_the_grid_skips_steps_that_leave_a_shaped_frame() { + use designcraft_doc::GridAlign; + use designcraft_doc::VerticalJustification::Bottom; + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + // Full width down to 100 pt, a waist at 150 pt, full width again from 200 to 230 pt, then a + // point at 300 pt. + let pts = [(0.0, 0.0), (300.0, 0.0), (300.0, 100.0), (200.0, 150.0), (300.0, 200.0), (300.0, 230.0), (150.0, 300.0)]; + let pts: Vec<(f64, f64)> = pts.iter().copied().chain([(0.0, 230.0), (0.0, 200.0), (100.0, 150.0), (0.0, 100.0)]).collect(); + let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Typography", Rect::new(0.0, 0.0, 300.0, 300.0), para); + let it = d.item_mut(fid).unwrap(); + it.path = PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }); + let o = &mut it.text_frame_mut().unwrap().options; + o.inset = [0.0; 4]; + o.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 40.0)); + let grid = frame_specs(&d, sid)[0].grid.unwrap(); + let top = all_lines(&compose_story(&d, sid, &ComposeOptions::default()))[0].baseline; + assert!(on_grid(top, grid), "Top: baseline {top} on the grid {grid:?}"); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = Bottom; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + let (b, band_top) = (l.baseline, l.baseline - l.ascent); + assert!(on_grid(b, grid), "Bottom: baseline {b} on the grid {grid:?}"); + let upper = b <= 100.0 + 1e-6; + let lower = band_top >= 200.0 - 1e-6 && b <= 230.0 + 1e-6; + assert!(upper || lower, "Bottom: the line's band {band_top}..{b} reaches into the narrow part"); + assert!(b > top, "Bottom moves the line down a grid step that fits: {b}, Top at {top}"); } /// A corner radius leaves a Cap Height first line where it sits in a rectangular frame, with Top From fb77a73d93bcd7cdabd1ac78f306b8e9ae21f257 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 13:04:14 -0800 Subject: [PATCH 6/7] Compose: Center on the grid takes the fitting step nearest the middle in a shaped frame When the grid step nearest the centered place puts the lines outside a frame that narrows and widens again, Center now tries the steps on both sides of it, nearest first, instead of only the steps above it. --- crates/compose/src/lib.rs | 27 +++++++++++++------ crates/compose/src/tests.rs | 52 +++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9a11229c4..9c423cec0 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -6157,15 +6157,26 @@ fn vertical_justify( // The last baseline stays above the bottom of the text, and the rows above it fit. let most = (space / inc + 1e-9).floor().min((room / unit + 1e-9).floor()).max(0.0); // Bottom and Justify take the lowest grid line, Center the one nearest the middle. - let mut steps = if center { (space / 2.0 / inc).round().min(most) } else { most }; - // A shaped frame can narrow and widen again, so a lower step may not fit either: take - // the nearest step that fits, and leave the lines in place when none of the next - // `MAX_GRID_TRIES` does. + let target = if center { (space / 2.0 / inc).round().min(most) } else { most }; + // A shaped frame can narrow and widen again, so the target step may not fit: take + // the fitting step nearest it, up to `most` (the lower one on a tie, and no step + // when that is as near), and leave the lines in place when none within + // `MAX_GRID_TRIES` steps of it fits. const MAX_GRID_TRIES: usize = 512; - let mut tries = 0; - while steps > 0.0 && !fits(&shifts(steps * unit)) { - tries += 1; - steps = if tries < MAX_GRID_TRIES { steps - 1.0 } else { 0.0 }; + let mut steps = 0.0; + for d in 0..MAX_GRID_TRIES { + let (below, above) = (target - d as f64, target + d as f64); + if below <= 0.0 { + break; + } + if fits(&shifts(below * unit)) { + steps = below; + break; + } + if d > 0 && above <= most && fits(&shifts(above * unit)) { + steps = above; + break; + } } // With no step taken the lines stay put, even when Center's doubled step overflows. shifts(if steps > 0.0 { steps * unit } else { 0.0 }) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ab8f56837..59777d657 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2878,37 +2878,49 @@ fn vertical_justification_with_an_extreme_grid_increment_stays_finite() { } } -/// In a shaped frame that narrows and widens again, Bottom with Align to Baseline Grid takes the -/// lowest grid step at which the line's slot fits the outline, even when the grid steps next to -/// the lowest room all fall in the narrow part. +/// In a shaped frame that narrows and widens again, Align to Baseline Grid takes the grid step +/// nearest the place Bottom or Center would choose at which the line's slot fits the outline: +/// Bottom goes up past steps in the narrow part, and Center goes down a step when the step nearest +/// the middle is in the narrow part and the next one fits. #[test] fn vertical_justification_on_the_grid_skips_steps_that_leave_a_shaped_frame() { use designcraft_doc::GridAlign; - use designcraft_doc::VerticalJustification::Bottom; + use designcraft_doc::VerticalJustification::{self, Bottom, Center, Top}; use designcraft_geom::{Anchor, PathData, Point, SubPath}; // Full width down to 100 pt, a waist at 150 pt, full width again from 200 to 230 pt, then a // point at 300 pt. let pts = [(0.0, 0.0), (300.0, 0.0), (300.0, 100.0), (200.0, 150.0), (300.0, 200.0), (300.0, 230.0), (150.0, 300.0)]; let pts: Vec<(f64, f64)> = pts.iter().copied().chain([(0.0, 230.0), (0.0, 200.0), (100.0, 150.0), (0.0, 100.0)]).collect(); - let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }; - let (mut d, sid, fid) = doc_with("Typography", Rect::new(0.0, 0.0, 300.0, 300.0), para); - let it = d.item_mut(fid).unwrap(); - it.path = PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }); - let o = &mut it.text_frame_mut().unwrap().options; - o.inset = [0.0; 4]; - o.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 40.0)); - let grid = frame_specs(&d, sid)[0].grid.unwrap(); - let top = all_lines(&compose_story(&d, sid, &ComposeOptions::default()))[0].baseline; + let compose_with = |mode: VerticalJustification, inc: f64| { + let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Typography", Rect::new(0.0, 0.0, 300.0, 300.0), para); + let it = d.item_mut(fid).unwrap(); + it.path = PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }); + let o = &mut it.text_frame_mut().unwrap().options; + o.inset = [0.0; 4]; + o.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, inc)); + o.vertical_justification = mode; + let grid = frame_specs(&d, sid)[0].grid.unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + (grid, l.baseline, l.baseline - l.ascent) + }; + let lower = |b: f64, band_top: f64| band_top >= 200.0 - 1e-6 && b <= 230.0 + 1e-6; + + // A 40 pt grid: the lowest room ends between 160 and 200 pt, and the steps above it down to + // 120 pt put the line in the waist. + let (grid, top, _) = compose_with(Top, 40.0); assert!(on_grid(top, grid), "Top: baseline {top} on the grid {grid:?}"); - d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = Bottom; - let cs = compose_story(&d, sid, &ComposeOptions::default()); - let l = all_lines(&cs)[0]; - let (b, band_top) = (l.baseline, l.baseline - l.ascent); + let (_, b, band_top) = compose_with(Bottom, 40.0); assert!(on_grid(b, grid), "Bottom: baseline {b} on the grid {grid:?}"); - let upper = b <= 100.0 + 1e-6; - let lower = band_top >= 200.0 - 1e-6 && b <= 230.0 + 1e-6; - assert!(upper || lower, "Bottom: the line's band {band_top}..{b} reaches into the narrow part"); + assert!(b <= 100.0 + 1e-6 || lower(b, band_top), "Bottom: the line's band {band_top}..{b} reaches into the narrow part"); assert!(b > top, "Bottom moves the line down a grid step that fits: {b}, Top at {top}"); + + // A 45 pt grid: the step nearest the middle puts the baseline at 180 pt, in the waist; the + // step below it fits in the lower full-width part. + let (grid, b, band_top) = compose_with(Center, 45.0); + assert!(on_grid(b, grid), "Center: baseline {b} on the grid {grid:?}"); + assert!(lower(b, band_top), "Center: the line's band {band_top}..{b} is not in the lower full-width part"); } /// A corner radius leaves a Cap Height first line where it sits in a rectangular frame, with Top From 9847f972da194e4c7ab05ad0ec42e6577e9a17dc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 16:27:52 -0500 Subject: [PATCH 7/7] Update recovery test for explicit write conflict --- crates/engine/src/recovery/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/recovery/tests.rs b/crates/engine/src/recovery/tests.rs index 8d28dc8a9..ab967f4ff 100644 --- a/crates/engine/src/recovery/tests.rs +++ b/crates/engine/src/recovery/tests.rs @@ -394,8 +394,8 @@ fn no_other_sessions_write_of_the_uid_is_queued_during_a_write_now() { other.recovery_dir = Some(dir.path().to_path_buf()); other.docs[0].uid = uid; let now = worker::write_now(dir.path(), uid, writing.recovery_owner.0).expect("nothing else is in flight"); - let queued = other.execute("file.recovery.save", &json!({"background": true})).unwrap(); - assert_eq!(queued["queued"], 0, "another session's write was queued during the write now: {queued}"); + let err = other.execute("file.recovery.save", &json!({"background": true})).unwrap_err(); + assert!(err.to_string().contains("another session"), "{err}"); assert!(worker::write_now(dir.path(), uid, other.recovery_owner.0).is_none(), "a second write now of the uid started"); drop(now); let queued = other.execute("file.recovery.save", &json!({"background": true})).unwrap();