From 54305b11eb93e913addf96b0d1bdcd6bd4557d17 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Sat, 10 Oct 2026 18:45:56 +0000 Subject: [PATCH] Drop caps: scale with the cap's point size, as InDesign does A drop cap came out the same size whatever its point size: the fit (drop + body cap height) / (the cap's own cap height) cancels the cap's size, since its own cap height grows with it. InDesign fits the cap as if it were set at the body text's size and scales that by the cap's size over the body's, keeping its baseline: a larger cap rises above the first line, a smaller one falls short of it. Measured against an InDesign capture (Palatino, 14 pt body, 18 pt italic T, 2 lines): cap height 2.04x the line gap there, 2.03x predicted. The cap's box (selection highlight, caret) now reaches the top of a cap that rises above its line; selected glyphs are drawn inverted, so the part outside the box would vanish. A one-line cap still keeps its own size when that is smaller. Test: drop_cap_scales_with_its_point_size (a 15 pt cap over 10 pt text is 1.5x the fitted height on the same baseline, 7.5 pt is 0.75x, the body lines don't move, the box covers the cap). Before the fix the 15 pt cap was 30.7 pt, the same as at 10 pt; with it, 46.05 pt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 23 +++++++++++++++++------ crates/compose/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f..bfd29b39 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1133,7 +1133,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C drop_box = Some(DropCapBox { end: dc.end, baseline: baseline + dc.drop, - rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc), + // From its line's top, or its own top when it rises above that line. + rect: Rect::new(at, (baseline - asc).min(baseline + dc.drop - dc.cap), at + dc.width, baseline + dc.drop + desc), }); } ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); @@ -2923,11 +2924,15 @@ struct DropCap { /// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps /// on a baseline grid), wherever line `lines` is set. drop: f64, + /// Its cap height as set (how far its top sits above its baseline). + cap: f64, } /// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale /// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, -/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own +/// measured in the leading and cap height of the text after it: the size of a cap set at that text's +/// size. As in InDesign, a cap set larger (or smaller) is scaled by its size over that text's, +/// keeping its baseline, so it rises above the first line (or falls short of it). A one-line drop cap keeps its own /// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid /// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside /// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs @@ -2955,14 +2960,19 @@ fn split_drop_cap( let mut after: Vec = glyphs.drain(..spaces).collect(); let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end)); let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0); - let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap)); + let (lead, cap, body_size) = body.map_or((base_leading, base_cap, None), |g| (g.leading, g.cap, Some(g.size))); let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) { Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch, _ => lead, }; let drop = (lines - 1) as f64 * lead; - let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max); - let k = if own > 0.0 { (drop + cap) / own } else { 1.0 }; + // The cap's tallest glyph sets its height; its point size over the body's scales the fit. + let (own, own_size) = dc.iter().fold((0.0, 0.0), |(c, s), g| if g.cap > c { (g.cap, g.size) } else { (c, s) }); + let ratio = match body_size { + Some(b) if b > 0.0 && own_size > 0.0 && (own_size / b).is_finite() => own_size / b, + _ => 1.0, + }; + let k = if own > 0.0 { (drop + cap) / own * ratio } else { 1.0 }; let k = if lines == 1 { k.min(1.0) } else { k }; let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; for g in dc.iter_mut().chain(after.iter_mut()) { @@ -3003,7 +3013,8 @@ fn split_drop_cap( } let width = dc.iter().map(|g| g.adv).sum(); dc.append(&mut after); - Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) + let cap = dc.iter().map(|g| g.cap).fold(0.0, f64::max); + Some(DropCap { glyphs: dc, end, lines, width, drop, lsb, cap }) } impl DropCap { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b..7f8918a1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2493,6 +2493,37 @@ fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() { assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}"); } +/// The cap's point size scales it, as in InDesign: at the body's size (10 pt) it fills its lines +/// exactly; set larger or smaller it is scaled by its size over the body's, keeping its baseline, +/// so it rises above the first line (or falls short of it). The body's lines don't move, and the +/// cap's box (highlight, caret) reaches the top of a cap that rises above its line. +#[test] +fn drop_cap_scales_with_its_point_size() { + let set = |size: f64| { + let (mut d, sid, _) = drop_doc(&[LOREM; 2].join(" "), drop_cap(3, 1)); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(size)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).into_iter().cloned().collect::>() + }; + let base = set(10.0); + let (b0, b2) = (base[0].baseline, base[2].baseline); + let body = base[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + let fitted = cap_of(&base[0].glyphs[0]); + assert!((b2 - fitted - (b0 - cap_of(body))).abs() < 0.01, "at the body's size the cap fills its lines"); + for (size, scale) in [(15.0, 1.5), (7.5, 0.75)] { + let l = set(size); + let dc = &l[0].glyphs[0]; + for (a, b) in l.iter().zip(&base) { + assert!((a.baseline - b.baseline).abs() < 1e-6, "{size} pt: the lines stay where they were"); + } + assert!((l[0].baseline + dc.y - b2).abs() < 1e-6, "{size} pt: the cap sits on line 3's baseline"); + assert!((cap_of(dc) - fitted * scale).abs() < 0.01, "{size} pt: cap height {} vs {}", cap_of(dc), fitted * scale); + let top = (l[0].baseline - l[0].ascent).min(b2 - cap_of(dc)); + assert!((l[0].drop_cap.unwrap().rect.y0 - top).abs() < 1e-6, "{size} pt: the box starts at the line's or the cap's top"); + } + assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test]