diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 763a8cebed..f2e50e1892 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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| { diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 0addce190d..0e86efb5f3 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -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, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 38c7e0745b..0cc19bf16d 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -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(); diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs index a0e19a0657..33a5fb8932 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_japanese.rs @@ -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)); } } diff --git a/render-wasm/src/shapes/text_vertical.rs b/render-wasm/src/shapes/text_vertical.rs index 6275c6f72d..57fd8fc071 100644 --- a/render-wasm/src/shapes/text_vertical.rs +++ b/render-wasm/src/shapes/text_vertical.rs @@ -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;