From 946e3ea4c19e18693c4fb144ef7b3a5ed02496c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:50:29 -0800 Subject: [PATCH 1/2] 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 2/2] 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