🐛 Keep Pinyin readings on one baseline over horizontal text

Each ruby reading attached its lowest ink to the base, which suits kana
but not Latin letters: the descender of "y" lifted "yǒu" above "liáo"
over 友聊, and under ruby moved with ascenders and tone marks.

Western letters in a reading now take the font's descent (over) or cap
height (under) instead of their ink, so Pinyin and romaji readings share
one baseline and descenders stay clear of the base. Upright glyphs keep
the ink edge.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-27 16:59:16 +00:00
parent 8f5c66c2d5
commit 01db64abaf
2 changed files with 77 additions and 2 deletions

View File

@ -384,11 +384,22 @@ pub(super) fn horizontal_ruby_pieces(
/// Ink edge shared by the glyphs of one ruby line: the lowest ink bottom for
/// over ruby, the highest ink top for under ruby. One edge for the whole line
/// keeps marks whose ink sits mid-height, such as ー and ・, on the line's
/// baseline instead of pulling each of them onto the base.
/// baseline instead of pulling each of them onto the base. Western letters
/// (Pinyin, romaji) take the font's descent and cap height instead of their
/// ink, so every reading keeps one baseline whatever its descenders, ascenders
/// or tone marks.
pub(super) fn ruby_line_ink_edge(shaped: &[ShapedRun], glyphs: &[RubyGlyphRef], over: bool) -> f32 {
let edges = glyphs.iter().map(|(run_index, glyph, _, _)| {
let edges = glyphs.iter().map(|(run_index, glyph, _, western)| {
let run = &shaped[*run_index];
let (_, metrics) = run.font.metrics();
if *western {
if over {
return metrics.descent;
}
if metrics.cap_height > 0.0 {
return -metrics.cap_height;
}
}
let fallback = if over {
metrics.descent
} else {

View File

@ -1341,6 +1341,70 @@ fn horizontal_ruby_glyphs_share_one_baseline() {
);
}
#[test]
fn latin_readings_share_one_baseline_whatever_their_letters() {
// Pinyin over 友聊: the descender of "yu" lifted it above "lia" when each
// reading attached its own ink bottom to the base.
use crate::shapes::text_vertical::test_support::{provider, EM};
use crate::shapes::{FontFamily, FontStyle};
use skia_safe::FontMgr;
let font_provider = provider(include_bytes!("../../fonts/sourcesanspro-regular.ttf"));
let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal));
let size = EM / 2.0;
let shape = |text: &str| {
shape_segment_with_fallbacks(
text,
size,
&[family.clone()],
&font_provider,
false,
FontFeatures::None,
&FontMgr::from(font_provider.clone()),
"zh-Hans",
)
};
let base = skia::Rect::from_xywh(0.0, 0.0, EM, EM);
for side in [RubySide::Over, RubySide::Under] {
let baseline = |text: &str| {
let span = TextSpan {
ruby: text.to_string(),
ruby_side: side,
font_size: EM,
..make_span("友", 0.0)
};
let shaped = shape(text);
let glyphs = ruby_glyph_refs(text, &shaped, size);
assert!(glyphs.iter().all(|(_, _, _, western)| *western));
let line = RubyLine {
span: &span,
shaped: &shaped,
glyphs: &glyphs,
base,
leading_gap: 0.0,
room: (0.0, 0.0),
};
(line.baseline(0.0), shaped)
};
let (descender, shaped) = baseline("yu");
let (plain, _) = baseline("lia");
assert!(
(descender - plain).abs() < 0.01,
"{side:?}: yu at {descender}, lia at {plain}"
);
if side == RubySide::Over {
let mut bounds = [skia::Rect::default()];
shaped[0]
.font
.get_bounds(&shaped[0].glyphs[..1], &mut bounds, None);
assert!(
descender + bounds[0].bottom <= horizontal_annotation_over_top(base, EM) + 0.01,
"the descender of y reaches into the base"
);
}
}
}
#[test]
fn emphasis_position_overrides_the_language_and_stacks_on_its_own_side() {
let placement = super::emphasis::HorizontalEmphasisPlacement {