From dffb8a9240f50d7ef9bfbcecd51e0732324ad037 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 3 Nov 2026 17:39:41 +0100 Subject: [PATCH] :bug: Include horizontal readings in the text export bounds Readings paint above the line box, outside the glyph metrics. The text bounds joined the emphasis marks only, so an auto-width text with ruby or Zhuyin and no emphasis exported with the top of its reading cut off. Measure the ink of the readings with the code that paints them and join it to the content and auto-width bounds. AI-assisted-by: claude-opus-5-5 --- render-wasm/src/shapes/text.rs | 63 ++++++++++++- .../src/shapes/text_horizontal/ruby.rs | 91 ++++++++++++++++--- .../src/shapes/text_horizontal/tests.rs | 69 ++++++++++++++ 3 files changed, 204 insertions(+), 19 deletions(-) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 7ae31a418f..3054f1a929 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -948,6 +948,61 @@ impl TextContent { (!bounds.is_empty()).then_some(bounds) } + /// Readings paint outside the base glyph metrics, like emphasis marks. + /// Their ink joins the culling and export bounds. + fn horizontal_ruby_bounds(&self, selrect: &Rect, valign: VerticalAlign) -> Option { + if !self + .paragraphs + .iter() + .flat_map(Paragraph::children) + .any(TextSpan::has_ruby) + { + return None; + } + let total_height: f32 = self + .layout + .paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|paragraph| paragraph.height()) + .sum(); + let mut y = selrect.y() + vertical_align_offset(selrect.height(), total_height, valign); + let plans = self.horizontal_plans(); + let mut bounds = Rect::new_empty(); + for (index, ((group, model), plan)) in self + .layout + .paragraphs + .iter() + .zip(&self.paragraphs) + .zip(plans.iter()) + .enumerate() + { + let Some(paragraph) = group.first() else { + continue; + }; + let x = selrect.x() + plan.x_offset(model, selrect.width()); + if let Some(ink) = horizontal_ruby_ink_bounds(self, index, paragraph, x, y) { + bounds.join(ink); + } + y += paragraph.height(); + } + (!bounds.is_empty()).then_some(bounds) + } + + /// Bounds of the marks and readings painted outside the glyph metrics. + fn horizontal_annotation_bounds(&self, selrect: &Rect, valign: VerticalAlign) -> Option { + match ( + self.horizontal_emphasis_bounds(selrect, valign), + self.horizontal_ruby_bounds(selrect, valign), + ) { + (Some(mut emphasis), Some(ruby)) => { + emphasis.join(ruby); + Some(emphasis) + } + (emphasis, ruby) => emphasis.or(ruby), + } + } + /// Compute a tight text rect from laid-out Skia paragraphs using glyph /// metrics (fm.top for overshoot, line descent for bottom, line left/width /// for horizontal extent). @@ -1005,8 +1060,8 @@ impl TextContent { if has_lines { let mut bounds = Rect::from_ltrb(min_x, min_y, max_x, max_y); - if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) { - bounds.join(emphasis); + if let Some(annotations) = self.horizontal_annotation_bounds(selrect, valign) { + bounds.join(annotations); } Some(bounds) } else { @@ -1030,8 +1085,8 @@ impl TextContent { } if self.grow_type() == GrowType::AutoWidth { let mut bounds = self.content_rect(selrect, valign); - if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) { - bounds.join(emphasis); + if let Some(annotations) = self.horizontal_annotation_bounds(selrect, valign) { + bounds.join(annotations); } return bounds; } diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index b6205208ac..edf09fbf60 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -441,6 +441,65 @@ pub(crate) fn paint_horizontal_ruby( laid_out: &LayoutParagraph, x: f32, y: f32, +) { + visit_horizontal_ruby_glyphs( + text_content, + paragraph_index, + laid_out, + x, + y, + |font, glyph, origin, paint| { + if let Some(blob) = single_glyph_blob(font, glyph) { + canvas.draw_text_blob(&blob, origin, paint); + } + }, + ); +} + +/// Ink bounds of the horizontal readings (ruby and Zhuyin) of a paragraph +/// laid out at (`x`, `y`), or None without readings. +pub(crate) fn horizontal_ruby_ink_bounds( + text_content: &TextContent, + paragraph_index: usize, + laid_out: &LayoutParagraph, + x: f32, + y: f32, +) -> Option { + let mut bounds: Option = None; + visit_horizontal_ruby_glyphs( + text_content, + paragraph_index, + laid_out, + x, + y, + |font, glyph, origin, _| { + let mut ink = [skia::Rect::default()]; + font.get_bounds(&[glyph], &mut ink, None); + let ink = ink[0].with_offset(origin); + if !ink.is_empty() { + bounds = Some(match bounds { + Some(mut joined) => { + joined.join(ink); + joined + } + None => ink, + }); + } + }, + ); + bounds +} + +/// Each reading glyph of a horizontal paragraph laid out at (`x`, `y`): its +/// font, glyph id, baseline origin and paint. Painting and bounds share it, +/// so exports keep exactly the ink the canvas draws. +fn visit_horizontal_ruby_glyphs( + text_content: &TextContent, + paragraph_index: usize, + laid_out: &LayoutParagraph, + x: f32, + y: f32, + mut visit: impl FnMut(&skia::Font, skia::GlyphId, (f32, f32), &skia::Paint), ) { let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else { return; @@ -483,15 +542,12 @@ pub(crate) fn paint_horizontal_ruby( font_provider, fallback_families, ) { - if let Some(blob) = - single_glyph_blob(&glyph.run.font, glyph.run.glyphs[glyph.glyph]) - { - canvas.draw_text_blob( - &blob, - (x + origin.x + glyph.origin.x, y + origin.y + glyph.origin.y), - &paint, - ); - } + visit( + &glyph.run.font, + glyph.run.glyphs[glyph.glyph], + (x + origin.x + glyph.origin.x, y + origin.y + glyph.origin.y), + &paint, + ); } } continue; @@ -539,7 +595,9 @@ pub(crate) fn paint_horizontal_ruby( leading_gap, room, }; - ruby_line.paint(canvas, &paint, x, y); + ruby_line.visit_glyphs(x, y, |font, glyph, origin| { + visit(font, glyph, origin, &paint) + }); } } } @@ -561,14 +619,17 @@ pub(super) struct RubyLine<'a> { impl RubyLine<'_> { /// Glyphs set in pieces (`horizontal_ruby_pieces`), distributed over the - /// base and the spacing before it. - fn paint(&self, canvas: &Canvas, paint: &skia::Paint, x: f32, y: f32) { + /// base and the spacing before it, with their baseline origins. + fn visit_glyphs( + &self, + x: f32, + y: f32, + mut visit: impl FnMut(&skia::Font, skia::GlyphId, (f32, f32)), + ) { let baseline = self.baseline(y); for ((run_index, glyph, _, _), left) in self.glyphs.iter().zip(self.glyph_lefts()) { let run = &self.shaped[*run_index]; - if let Some(blob) = single_glyph_blob(&run.font, run.glyphs[*glyph]) { - canvas.draw_text_blob(&blob, (x + left, baseline), paint); - } + visit(&run.font, run.glyphs[*glyph], (x + left, baseline)); } } diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 9f8f5efff8..ae63b6cd1c 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -2263,6 +2263,75 @@ fn horizontal_emphasis_export_bounds_include_every_adjacent_ruby_mark() { } } +#[test] +fn horizontal_ruby_export_bounds_keep_every_reading_whole() { + // Readings sit above the line box with the Japanese default clearance. + // Auto-width text built its bounds from the content rect and the + // emphasis marks only, so an export cut the top of the reading. + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let grows = [ + crate::shapes::GrowType::Fixed, + crate::shapes::GrowType::AutoHeight, + crate::shapes::GrowType::AutoWidth, + ]; + let emphases = [TextEmphasis::None, TextEmphasis::FilledCircle]; + let texts = [("AB", "ABC", "CD"), ("漢字", "かんじ", "の圏点")]; + for (grow, emphasis, (base, ruby, rest)) in grows + .into_iter() + .flat_map(|grow| emphases.map(|emphasis| (grow, emphasis))) + .flat_map(|(grow, emphasis)| texts.map(|text| (grow, emphasis, text))) + { + let spans = vec![ + TextSpan { + font_size: 48.0, + fills: vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor( + skia::Color::BLACK, + ))], + ruby: ruby.to_string(), + text_emphasis: emphasis, + ..make_span(base, 0.0) + }, + make_span(rest, 0.0), + ]; + let mut paragraph = make_paragraph(spans, 0.0); + paragraph.set_language("ja".to_string()); + let bounds = crate::math::Rect::from_xywh(0.0, 0.0, 360.0, 60.0); + let mut content = TextContent::new(bounds, grow); + content.add_paragraph(paragraph); + content.update_layout(bounds); + let mut shape = crate::shapes::Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 360.0, 60.0); + let export = content.calculate_bounds(&shape, false).to_rect(); + + let pad = 100.0; + let mut surface = skia::surfaces::raster_n32_premul((560, 260)).unwrap(); + surface.canvas().clear(skia::Color::TRANSPARENT); + let x = pad + content.horizontal_plans()[0].x_offset(&content.paragraphs()[0], 360.0); + paint_horizontal_ruby( + surface.canvas(), + &content, + 0, + &content.layout.paragraphs[0][0], + x, + pad, + ); + let image = surface.image_snapshot(); + let pixels = image.peek_pixels().expect("raster pixels"); + let ink_top = (0..260) + .find(|y| (0..560).any(|x| pixels.get_color((x, *y)).a() > 0)) + .expect("the reading is painted") as f32; + assert!( + ink_top - pad >= export.top() - 1.0, + "{grow:?} {emphasis:?} {ruby}: export top {} crops the reading inked from {}", + export.top(), + ink_top - pad + ); + } +} + /// Fixed-width `TextContent` of one paragraph, laid out like the renderer. fn aligned_content(text: &str, tracking: f32, align: TextAlign, width: f32) -> TextContent { let mut paragraph = Paragraph::new(