diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 02d18f7cf..9c423cec0 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); @@ -3192,6 +3198,9 @@ fn compose_run( // 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]; // Composing again after an edit starts at the paragraph above it ([`reuse`]). The entries // for the paragraphs before that one are never read: going back over a paragraph abandons the // pass. @@ -3366,6 +3375,7 @@ fn compose_run( // 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; } @@ -3595,6 +3605,7 @@ fn compose_run( boxes: &boxes, span_paras: &span_paras, split_paras: &split_paras, + grid_paras: &grid_paras, styles_tab, }); (out, trace) @@ -3611,11 +3622,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() { @@ -3634,7 +3646,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); let anchors: Vec = if ft.objects.is_empty() { Vec::new() } else { story.text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect() }; - vertical_justify(ft, f, spanned, &doc.footnote_options, &anchors); + vertical_justify(ft, f, spanned, &doc.footnote_options, grid_paras, &anchors); 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); @@ -5962,7 +5974,15 @@ fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tin /// `spanned`: a paragraph spans the frame's columns, so the lines move together. `anchors`: the /// story byte of each anchored object's mark, by object index. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions, anchors: &[usize]) { +/// `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], + anchors: &[usize], +) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -6078,24 +6098,29 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de shift[k] <= 0.0 || ((k == n || l.baseline + l.descent + shift[k] <= limit + 1e-9) && in_outline(l, k == n, shift[k])) }) && clear(shift) }; - // Every row moves no less as `space` grows, so in a rectangular frame the most room that - // fits (and keeps every row clear of the wraps under it) is found by halving. A shaped - // frame can narrow and widen again further down: there the rooms are scanned from the most down to none, every point (at most 512 steps, each - // usually stopped by the lowest line), and the step between the first that fits and the - // one above it is then halved. - let mut shift = shifts(space); - if !fits(&shift) { - let steps = if f.outline.is_some() { space.ceil().clamp(64.0, 512.0) as usize } else { 1 }; - let (mut lo, mut hi) = (0.0, space); - for j in 1..steps { - let s = space * (steps - j) as f64 / steps as f64; - if fits(&shifts(s)) { - lo = s; - break; - } - hi = s; + // In a rectangular frame fit decreases monotonically as the shift grows. A shaped + // frame can narrow and widen again, so scan from the largest shift before refining the + // first fitting interval. This also bounds the room used for baseline-grid quantization + // and preserves clearance from fixed and anchor-following wraps. + let most_room = |top: f64| -> f64 { + if fits(&shifts(top)) { + return top; } - for _ in 0..50 { + let (mut lo, mut hi) = if f.outline.is_some() { + let steps = top.ceil().clamp(64.0, 512.0) as usize; + let mut found = (0.0, top); + for j in 1..=steps { + let candidate = top * (steps - j) as f64 / steps as f64; + if fits(&shifts(candidate)) { + found = (candidate, top * (steps - j + 1) as f64 / steps as f64); + break; + } + } + found + } else { + (0.0, top) + }; + for _ in 0..60 { let mid = (lo + hi) / 2.0; if fits(&shifts(mid)) { lo = mid; @@ -6103,8 +6128,60 @@ 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)), + // 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 }; + 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 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 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 }) + } + }; 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 ec85be8cd..59777d657 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; @@ -2753,6 +2753,176 @@ 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:?}"); + + // 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:?}"); +} + +/// 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 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, 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::{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 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:?}"); + let (_, b, band_top) = compose_with(Bottom, 40.0); + assert!(on_grid(b, grid), "Bottom: baseline {b} on the grid {grid:?}"); + 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 /// 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 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(); diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 7868f7aff..f3b9504b9 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) |