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(