fix alignment

This commit is contained in:
alonso.torres 2026-10-02 15:38:21 +02:00
parent c512ec189a
commit a4a0be38d6
4 changed files with 121 additions and 56 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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(

View File

@ -40,7 +40,8 @@ pub struct ShapedRun {
pub utf8_range: Range<usize>,
/// 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}");
}
}