mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 17:10:39 +00:00
🐛 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:
parent
b8a922b4f0
commit
dffb8a9240
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user