fixes horizontal furigana

This commit is contained in:
alonso.torres 2026-07-16 10:11:53 +02:00
parent 6b26950bdc
commit 9544cb3908
5 changed files with 168 additions and 6 deletions

View File

@ -1727,6 +1727,11 @@ impl RenderState {
}
}
}
if !text_content.is_vertical() && self.options.is_text_grid_visible() {
let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id);
text::paint_horizontal_grid(canvas, &shape, text_content);
}
}
_ => {
self.surfaces.apply_mut(surface_ids, |s| {

View File

@ -11,7 +11,9 @@ use crate::{
use skia_safe::{
self as skia,
canvas::SaveLayerRec,
textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle},
textlayout::{
ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration, TextStyle,
},
Canvas, ImageFilter, Paint,
};
@ -100,6 +102,62 @@ pub fn render_vertical_text(
Ok(())
}
/// Paint a viewport-only grid over SkParagraph horizontal text. Blue outlines
/// show line boxes, green boxes show the per-scalar tight rectangles returned
/// by SkParagraph, and amber rules show baselines. This mirrors the vertical
/// text grid while making horizontal annotation anchors inspectable.
pub fn paint_horizontal_grid(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
let mut builders = text_content.paragraph_builder_group_from_text(None);
let layout = calculate_text_layout_data(shape, text_content, &mut builders, true);
let mut line_paint = Paint::default();
line_paint.set_anti_alias(true);
line_paint.set_style(skia::PaintStyle::Stroke);
line_paint.set_stroke_width(1.0);
line_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED));
let mut glyph_paint = Paint::default();
glyph_paint.set_anti_alias(true);
glyph_paint.set_style(skia::PaintStyle::Stroke);
glyph_paint.set_stroke_width(1.0);
glyph_paint.set_color(skia::Color::from_argb(0x99, 0x27, 0xAE, 0x60));
let mut baseline_paint = Paint::default();
baseline_paint.set_anti_alias(true);
baseline_paint.set_stroke_width(1.0);
baseline_paint.set_color(skia::Color::from_argb(0xCC, 0xEB, 0x57, 0x57));
for paragraph in &layout.paragraphs {
for line in paragraph.paragraph.get_line_metrics() {
let baseline = line.baseline as f32;
let top = paragraph.y + baseline - line.ascent as f32;
let left = paragraph.x + line.left as f32;
let width = line.width as f32;
canvas.draw_rect(
Rect::from_xywh(left, top, width, line.height as f32),
&line_paint,
);
canvas.draw_line(
(left, paragraph.y + baseline),
(left + width, paragraph.y + baseline),
&baseline_paint,
);
for offset in line.start_index..line.end_index {
for textbox in paragraph.paragraph.get_rects_for_range(
offset..offset + 1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
) {
let mut rect = textbox.rect;
rect.offset((paragraph.x, paragraph.y));
canvas.draw_rect(rect, &glyph_paint);
}
}
}
}
}
pub fn stroke_paragraph_builder_group_from_text(
text_content: &TextContent,
stroke: &Stroke,

View File

@ -2190,6 +2190,16 @@ mod tests {
);
}
#[test]
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
assert_eq!(
crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0),
88.0
);
}
#[test]
fn horizontal_warichu_collapses_to_one_builder_position() {
init_state();

View File

@ -522,6 +522,18 @@ pub(crate) fn horizontal_span_style(
})
}
/// Top of the horizontal base em inside SkParagraph's typographic rectangle.
/// Its tight rect can include substantial ascender-side padding; anchoring an
/// over annotation to that rect's top therefore leaves a visible gap above CJK
/// ink. The em is bottom-aligned to the rect, matching the baseline model used
/// by the horizontal painter. The ruby showcase needs a 12 px clearance at
/// 56 px: the original 6 px adjustment still left it 6 px too close to the
/// kanji. Keep the 3/14-em value proportional at other text sizes.
pub(crate) fn horizontal_annotation_over_top(rect: skia::Rect, font_size: f32) -> f32 {
let font_size = font_size.max(0.0);
rect.top.max(rect.bottom - font_size) - font_size * (3.0 / 14.0)
}
/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs.
/// The base paragraph retains its normal metrics; interlinear collision and
/// automatic line-gap expansion remain a separate layout policy.
@ -549,8 +561,26 @@ pub(crate) fn paint_horizontal_emphasis(
style.set_height_override(true);
style.set_letter_spacing(0.0);
let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX);
let mark_width = mark_paragraph.longest_line();
let mark_height = mark_paragraph.height();
let mark_ink = mark_paragraph
.get_rects_for_range(
0..mark.len_utf16(),
RectHeightStyle::Tight,
RectWidthStyle::Tight,
)
.into_iter()
.map(|textbox| textbox.rect)
.reduce(|mut rect, next| {
rect.join(next);
rect
});
let mark_width = mark_ink
.as_ref()
.map(|rect| rect.width())
.unwrap_or_else(|| mark_paragraph.longest_line());
let mark_ink_bottom = mark_ink
.as_ref()
.map(|rect| rect.bottom())
.unwrap_or_else(|| mark_paragraph.height());
for placement in placements
.iter()
.filter(|placement| placement.span == range.span && placement.mark == mark)
@ -564,7 +594,9 @@ pub(crate) fn paint_horizontal_emphasis(
} else {
0.0
};
let mark_y = y + placement.rect.top() - mark_height - ruby_offset;
let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size)
- mark_ink_bottom
- ruby_offset;
mark_paragraph.paint(canvas, (mark_x, mark_y));
}
}

View File

@ -3103,6 +3103,24 @@ fn next_horizontal_ruby_range(
offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor)
}
/// Baseline adjustment that attaches a glyph's visible ink edge to its base
/// strip. Font-wide ascender/descender metrics include leading which makes
/// horizontal ruby visibly detached for many Japanese faces.
fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 {
let mut bounds = [skia::Rect::default()];
font.get_bounds(&[glyph], &mut bounds, None);
let bound = bounds[0];
if bound.right > bound.left && bound.bottom > bound.top {
if over {
bound.bottom
} else {
bound.top
}
} else {
fallback
}
}
/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only:
/// base rects come from the already laid-out skparagraph
/// (`get_rects_for_range`), the annotation is shaped at half the span size
@ -3220,8 +3238,26 @@ pub fn paint_horizontal_ruby(
let run = &shaped[*run_index];
let (_, metrics) = run.font.metrics();
let baseline = match span.ruby_side {
RubySide::Over => y + rect_box.rect.top() - metrics.descent,
RubySide::Under => y + rect_box.rect.bottom() - metrics.ascent,
RubySide::Over => {
y + super::text_japanese::horizontal_annotation_over_top(
rect_box.rect,
span.font_size,
) - horizontal_ruby_ink_edge(
&run.font,
run.glyphs[*glyph],
metrics.descent,
true,
)
}
RubySide::Under => {
y + rect_box.rect.bottom()
- horizontal_ruby_ink_edge(
&run.font,
run.glyphs[*glyph],
metrics.ascent,
false,
)
}
};
let mut builder = TextBlobBuilder::new();
let (out_glyphs, points) = builder.alloc_run_pos(&run.font, 1, None);
@ -6551,6 +6587,27 @@ mod tests {
assert!((shift - metrics_shift).abs() > 0.1);
}
#[test]
fn horizontal_ruby_attaches_to_visible_glyph_ink() {
let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0);
let CellKind::Rotated { run } = layout.cells[0].kind else {
panic!("expected a rotated run");
};
let run = &layout.runs[run];
let mut bounds = [skia::Rect::default()];
run.font.get_bounds(&[run.glyphs[0]], &mut bounds, None);
assert!(
(horizontal_ruby_ink_edge(&run.font, run.glyphs[0], 0.0, true) - bounds[0].bottom)
.abs()
< f32::EPSILON
);
assert!(
(horizontal_ruby_ink_edge(&run.font, run.glyphs[0], 0.0, false) - bounds[0].top).abs()
< f32::EPSILON
);
}
#[test]
fn justify_fills_non_last_columns() {
use std::collections::BTreeMap;