diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index 82519cd40b..e04182daff 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -355,10 +355,14 @@ impl<'a> SpanCells<'a> { let (ink_top, ink_bottom) = ink.unwrap_or((0.0, extent - letter_spacing)); let (mut ink_top, mut ink_bottom) = (ink_top + vpal_flow_shift, ink_bottom + vpal_flow_shift); - // Centre cl-05 ink in the em body, unless vpal already centres it. + // Centre cl-05 and forced-upright Western ink in the em body, + // unless vpal already centres it. Western glyphs hung from a + // shared baseline leave uneven gaps: a descender meets the next + // cell's ascender (`gf`) while x-height letters float apart. let glyph_flow_shift = if !synthetic_rotation && vpal_delta.is_none() - && ch.is_some_and(is_centered_punctuation) + && ink.is_some() + && ch.is_some_and(|c| is_centered_punctuation(c) || !is_upright_char(c)) { let shift = centered_flow_shift(ink_top, ink_bottom, extent - letter_spacing); ink_top += shift; @@ -490,6 +494,26 @@ mod tests { ); } + #[test] + fn upright_latin_ink_is_centred_in_its_cell() { + // A descender followed by an ascender (`gf`) must not touch: each + // letter's ink sits in the middle of its em cell. + let mut content = make_content(&["gf"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&provider(TEST_FONT), &content); + for cell in &layout.cells { + let ink_middle = (cell.ink_top + cell.ink_bottom) / 2.0; + assert!( + (ink_middle - cell.extent / 2.0).abs() < 0.01, + "ink middle {ink_middle} in a {} cell", + cell.extent + ); + } + let (g, f) = (&layout.cells[0], &layout.cells[1]); + let gap = f.top + f.ink_top - (g.top + g.ink_bottom); + assert!(gap > 0.2 * EM, "g/f ink gap {gap}"); + } + #[test] fn warichu_span_composes_two_half_size_sub_lines() { let content = warichu_content("ABCD", 400.0); diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index 302a8931a6..ef1819fc09 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -200,33 +200,33 @@ pub(super) fn align_offset_along_column(align: TextAlign, budget: f32, used: f32 } } -/// Japanese inter-script spacing at an upright CJK <-> rotated alphanumeric -/// boundary; punctuation and whitespace get none. Scales by the smaller -/// adjacent font size so a large neighbour cannot widen the gap. -fn inter_script_spacing( - previous: FlowScript, - previous_font_size: f32, - next: FlowScript, - next_font_size: f32, -) -> f32 { - let boundary = matches!( - (previous, next), +/// Japanese inter-script spacing (JLREQ §3.2.6) between kana or kanji +/// (cl-15, cl-16, cl-19) and a rotated alphanumeric run. Punctuation keeps +/// its own aki and Latin sets solid against it; whitespace gets none. Scales +/// by the smaller adjacent font size so a large neighbour cannot widen the +/// gap. +fn inter_script_spacing(previous: &FlowCell, next: &FlowCell) -> f32 { + let is_japanese_letter = + |flow: &FlowCell| flow.ch.is_some_and(|ch| classify(ch).is_japanese_letter()); + let boundary = match (previous.script, next.script) { ( FlowScript::Upright, FlowScript::Rotated { starts_alphanumeric: true, .. - } - ) | ( + }, + ) => is_japanese_letter(previous), + ( FlowScript::Rotated { ends_alphanumeric: true, .. }, - FlowScript::Upright - ) - ); + FlowScript::Upright, + ) => is_japanese_letter(next), + _ => false, + }; if boundary { - previous_font_size.min(next_font_size) * INTER_SCRIPT_SPACING_EM + previous.cell.font_size.min(next.cell.font_size) * INTER_SCRIPT_SPACING_EM } else { 0.0 } @@ -238,12 +238,7 @@ fn inter_script_spacing( /// keeps its trailing letter-spacing. pub(super) fn apply_inter_script_spacing(cells: &mut [FlowCell]) { for i in 1..cells.len() { - let target_gap = inter_script_spacing( - cells[i - 1].script, - cells[i - 1].cell.font_size, - cells[i].script, - cells[i].cell.font_size, - ); + let target_gap = inter_script_spacing(&cells[i - 1], &cells[i]); if target_gap <= 0.0 { continue; } @@ -1237,6 +1232,35 @@ mod tests { } } + #[test] + fn latin_sets_against_japanese_punctuation_without_script_spacing() { + // JLREQ §3.2.6 (Figure 103): after 、。」 Latin keeps only the + // punctuation's half-em aki, and Latin sets solid after 「 and + // before 、」. + let provider = provider_with_fallback(VPAL_TEST_FONT, TEST_FONT); + let fallback = ["fallback".to_string()]; + let layout = |text: &str| { + layout_with_fallback(&provider, &make_content(&[text], 1000.0), 1000.0, &fallback) + }; + let natural = layout("editor").cells[0].extent; + for text in ["あ、editor", "あ。editor", "あ」editor", "あ「editor"] { + let cells = layout(text).cells; + assert!( + (cells[2].top - cells[1].top - EM).abs() < 0.01, + "{text}: punctuation frame is {} em", + (cells[2].top - cells[1].top) / EM + ); + } + for text in ["editor、", "editor」"] { + let cells = layout(text).cells; + assert!( + (cells[1].top - natural).abs() < 0.01, + "{text}: Latin must set solid, gap {}", + cells[1].top - natural + ); + } + } + #[test] fn oikomi_reduces_script_gap_before_oidashi() { let natural = layout_content(&make_content(&["あa"], 1000.0), 1000.0); diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index 679dd833f2..93d1b9a6a7 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -99,7 +99,7 @@ fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32)) CellKind::Rotated { run } => { let run = &layout.runs[run]; // After rotation +x runs down the column and +y runs across it; - // the shift centres the run's actual ink band on the column axis. + // the shift puts the font's central baseline on the column axis. run.blob() .map(|blob| { GlyphDraw::transformed( diff --git a/render-wasm/src/shapes/text_vertical/shaping.rs b/render-wasm/src/shapes/text_vertical/shaping.rs index 20c7484c31..a20b85c208 100644 --- a/render-wasm/src/shapes/text_vertical/shaping.rs +++ b/render-wasm/src/shapes/text_vertical/shaping.rs @@ -40,7 +40,8 @@ pub struct ShapedRun { pub utf8_range: Range, /// Source text of `utf8_range`, attached to the run's text blobs. pub text: String, - /// Local-y offset that centres the run's ink after 90° rotation. + /// Local-y offset that puts the font's central baseline on the column + /// axis after 90° rotation. pub rotated_baseline_shift: f32, } @@ -262,11 +263,7 @@ impl RunHandler for RunCollector { .map(|(i, position)| positions.get(i + 1).map_or(end, |next| next.x) - position.x) .collect(); self.runs.push(ShapedRun { - rotated_baseline_shift: rotated_run_baseline_shift( - info.font, - &self.scratch_glyphs, - &positions, - ), + rotated_baseline_shift: central_baseline_shift(info.font), font: info.font.clone(), glyphs: self.scratch_glyphs.clone(), positions, @@ -453,8 +450,22 @@ pub(super) fn rotated_baseline_shift(top: f32, bottom: f32) -> f32 { -(top + bottom) / 2.0 } +/// Shift that puts the font's central baseline on the column axis: the +/// middle of its em box, derived from ascent/descent scaled to 1em (CSS +/// `central` for fonts without a BASE table). It depends only on the font, +/// so every sideways run of a face shares one baseline whatever its ink. +fn central_baseline_shift(font: &Font) -> f32 { + let (_, metrics) = font.metrics(); + let band = metrics.descent - metrics.ascent; + if band <= 0.0 { + return 0.0; + } + let em_over = -metrics.ascent * font.size() / band; + em_over - font.size() / 2.0 +} + /// Centres the glyphs' actual ink; ascent/descent metrics are only the -/// fallback for runs without visible ink. +/// fallback for glyphs without visible ink. fn rotated_run_baseline_shift(font: &Font, glyphs: &[GlyphId], positions: &[SkPoint]) -> f32 { if let Some(ink) = glyph_run_ink_bounds(font, glyphs, positions) { rotated_baseline_shift(ink.top, ink.bottom) @@ -477,33 +488,39 @@ mod tests { assert_eq!(families[2], "fallback"); } + fn rotated_shift(text: &str) -> f32 { + let layout = layout_content(&make_content(&[text], 1000.0), 1000.0); + let CellKind::Rotated { run } = layout.cells[0].kind else { + panic!("expected a rotated run"); + }; + layout.runs[run].rotated_baseline_shift + } + #[test] - fn rotated_run_centres_actual_lowercase_ink() { + fn rotated_runs_share_a_baseline_whatever_their_ink() { + // Ascenders and descenders must not move the baseline across the + // column. + let plain = rotated_shift("aaa"); + for text in ["aaaf", "aaag", "aaagf", "AAA"] { + assert!( + (rotated_shift(text) - plain).abs() < 0.01, + "{text} shifted from {plain} to {}", + rotated_shift(text) + ); + } + } + + #[test] + fn rotated_lowercase_sits_on_the_column_axis() { + // Source Sans: the em-box middle falls within 2% of an em of the + // x-height middle, so lowercase looks centred in the column. let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0); - let cell = &layout.cells[0]; - let CellKind::Rotated { run } = cell.kind else { + let CellKind::Rotated { run } = layout.cells[0].kind else { panic!("expected a rotated run"); }; let run = &layout.runs[run]; - let mut bounds = vec![skia::Rect::default(); run.glyphs.len()]; - run.font.get_bounds(&run.glyphs, &mut bounds, None); - let top = bounds - .iter() - .zip(&run.positions) - .map(|(bound, position)| bound.top + position.y) - .fold(f32::MAX, f32::min); - let bottom = bounds - .iter() - .zip(&run.positions) - .map(|(bound, position)| bound.bottom + position.y) - .fold(f32::MIN, f32::max); - let shift = run.rotated_baseline_shift; - assert!(((top + bottom) / 2.0 + shift).abs() < 0.01); - - // Lowercase ink does not fill the face's ascent/descent band, so ink - // centring differs from font-wide metric centring. - let (_, metrics) = run.font.metrics(); - let metrics_shift = rotated_baseline_shift(metrics.ascent, metrics.descent); - assert!((shift - metrics_shift).abs() > 0.1); + let ink = run.ink_bounds().expect("visible ink"); + let ink_middle = (ink.top + ink.bottom) / 2.0 + run.rotated_baseline_shift; + assert!(ink_middle.abs() < 0.02 * EM, "ink middle at {ink_middle}"); } }