🐛 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
This commit is contained in:
alonso.torres 2026-11-03 17:39:41 +01:00
parent b8a922b4f0
commit dffb8a9240
3 changed files with 204 additions and 19 deletions

View File

@ -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<Rect> {
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<Rect> {
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;
}

View File

@ -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<skia::Rect> {
let mut bounds: Option<skia::Rect> = 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));
}
}

View File

@ -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(