diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 06070d836..24f26d215 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -132,6 +132,8 @@ pub struct ParaLayout { pub notes: Vec<(usize, u32)>, /// Proofing issues: (byte start, byte end, grammar?). pub issues: Vec<(usize, usize, bool)>, + /// Drop cap: (clusters at the start that form it, lines it drops, width with its gap). + pub drop_cap: Option<(usize, u8, f32)>, } /// Inputs that change a paragraph's layout beyond its own content. @@ -353,6 +355,13 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let mut has_page_fields = false; let mut notes = Vec::new(); let mut obj_index = 0usize; + // Drop cap: the first character, sized so its capital spans `lines` lines. + let drop_first = (rp.drop_cap > 0) + .then(|| p.text.chars().next()) + .flatten() + .filter(|c| !matches!(*c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r' | ' ')) + .filter(|c| p.text.len() > c.len_utf8()); + let mut drop_cap = None; for (range, props) in p.run_ranges() { let rc = resolve(props); let Some(text) = p.text.get(range.clone()) else { continue }; @@ -379,7 +388,29 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { continue; } let mut seg = 0; + if range.start == 0 + && let Some(c) = drop_first + && let Some(first) = text.get(..c.len_utf8()) + { + let line_h = (rc.size * 1.2).max(1.0); + let lines = rp.drop_cap as f32; + // Capital height is ~0.7 em: the letter's cap spans from line 1's caps to line N's baseline. + let mut big = (*rc).clone(); + big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).clamp(rc.size, 1000.0); + let before = b.clusters.len(); + b.shape(first, 0, &Arc::new(big), None); + let w: f32 = b.clusters.get(before..).map(|c| c.iter().map(|c| c.adv).sum()).unwrap_or(0.0); + drop_cap = Some((b.clusters.len() - before, rp.drop_cap, w + rc.size * 0.3)); + // The letter hangs beside the text; lines flow at the indent `break_lines` adds. + for c in b.clusters.iter_mut().skip(before) { + c.adv = 0.0; + } + seg = c.len_utf8(); + } for (i, c) in text.char_indices() { + if i < seg { + continue; + } let special = matches!(c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r'); if !special { continue; @@ -494,6 +525,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { has_page_fields, notes, issues: if env.proofing { proof_issues(p) } else { Vec::new() }, + drop_cap, }; break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l.suffix)); pl @@ -541,6 +573,11 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti let mut guard = 0; loop { let (mut lo, mut hi) = (if first { first_left } else { rp.indent_left }, base_right); + if let Some((_, dl, dw)) = pl.drop_cap + && lines.len() < dl as usize + { + lo = first_left.max(rp.indent_left) + dw; + } let mut push: Option = None; for e in env.exclusions { if e.bottom <= top || e.top >= top + est_h { @@ -747,8 +784,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti // Vertical metrics. let (mut asc, mut desc) = (0.0f32, 0.0f32); let mut any = false; - for c in pl.clusters.get(c0..c1).into_iter().flatten() { - if c.kind == ClKind::Marker { + let dropped = pl.drop_cap.map_or(0, |d| d.0); + for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() { + if c.kind == ClKind::Marker || c0 + k < dropped { continue; } if let Some(st) = pl.styles.get(c.style as usize) { @@ -880,6 +918,23 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti break; } } + // Hang the drop cap from the first line's caps down to line N's baseline, left of the text. + if let Some((nc, dl, dw)) = pl.drop_cap + && let (Some(l0), Some(ln)) = (lines.first(), lines.get((dl as usize).min(lines.len()).saturating_sub(1))) + { + let short = lines.len() < dl as usize; + let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline }; + let dx = dw.min(l0.xs.first().copied().unwrap_or(0.0) - first_left.max(rp.indent_left)); + if short { + top = top.max(l0.top + l0.height * dl as f32); + } + for c in pl.clusters.get(..nc).unwrap_or(&[]).to_vec() { + for g in pl.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() { + g.dy -= dy; + g.dx -= dx; + } + } + } pl.height = top; pl.lines = lines; } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2c035e46d..bf977101f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -375,3 +375,31 @@ fn tall_rows_split_across_pages() { let l2 = lay(&d2); assert!(l2.pages.len() >= 2); } + +#[test] +fn drop_cap_indents_its_lines() { + let mut p = + wordcraft_doc::Paragraph::with_text(&"Every line of this paragraph wraps around a large first letter. ".repeat(6), Default::default()); + p.props.drop_cap = Some(3); + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = lay(&d); + let Some(pl) = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }) else { + panic!("no lines") + }; + let (nc, lines, w) = pl.drop_cap.unwrap_or((0, 0, 0.0)); + assert_eq!((nc, lines), (1, 3)); + assert!(w > 20.0, "drop cap width {w}"); + assert!(pl.lines.len() > 4); + for (k, line) in pl.lines.iter().enumerate() { + let x = line.xs.first().copied().unwrap_or(0.0); + if k < 3 { + assert!(x >= w - 0.5, "line {k} at {x}"); + } else { + assert!(x < 1.0, "line {k} at {x}"); + } + } + // The letter's glyphs are lowered toward the third line's baseline. + let g = pl.glyphs.first().map(|g| g.dy).unwrap_or(0.0); + assert!(g < -20.0, "dy {g}"); +}