🐛 Make horizontal emphasis mark bounds match where the mark paints

The mark box used the mark's em box (half the base size, square), while
painting placed the shaped mark by its tight rect: advance wide, ascent
plus descent tall. Text bounds, selection and export therefore disagreed
with the canvas; over marks could paint above the bounds.

Shape each span's mark once in HorizontalEmphasisMark and place that
measured box for paint, text bounds and position data alike. Position
data keeps an em box, now centred on the painted mark, because SVG
export draws the mark at a font size equal to the box width.

Tests that built synthetic placements now pass the mark ink explicitly;
their assertions are unchanged. Vertical marks had no mismatch: paint
and position data centre on the same point.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-11-05 16:48:41 +01:00
parent 2ac10ce6d7
commit 31115ab29a
4 changed files with 216 additions and 74 deletions

View File

@ -117,6 +117,13 @@ moves outside a reading on its side. The underline band reserves 0.18 em,
a POC bound around the existing 1/9-em gap and stroke width. Disabling
clearance retains fixed line-height behavior and can allow overlap/clipping.
Horizontal marks are shaped once per span and placed by their measured
box (advance by ascent and descent at half the base size). Paint, text
bounds and selection use that one box, so bounds cover exactly what paints.
Position data carries the mark's em box centred on it, because SVG export
draws the mark at a font size of that box's width. Vertical marks already
centre the glyph and their position-data box on the same point.
Decoration edits that intersect a reading or note apply to its whole base,
so changing a side cannot duplicate its reading on split spans. Other text
styles remain unchanged. Selection snapshots, Plugin API properties,

View File

@ -961,9 +961,8 @@ impl TextContent {
continue;
};
let x = selrect.x() + plan.x_offset(model, selrect.width());
for placement in horizontal_emphasis_placements(model, &plan.offsets, paragraph) {
let span = &model.children()[placement.span];
bounds.join(horizontal_emphasis_mark_box(span, &placement).with_offset((x, y)));
for (_, ink_box) in horizontal_emphasis_mark_boxes(model, &plan.offsets, paragraph) {
bounds.join(ink_box.with_offset((x, y)));
}
y += paragraph.height();
}
@ -3106,14 +3105,14 @@ impl HorizontalPositionEntries<'_> {
entry.y += self.layout.y;
entries.push(entry);
}
for placement in
horizontal_emphasis_placements(self.paragraph, self.offsets, &self.layout.paragraph)
for (placement, ink_box) in
horizontal_emphasis_mark_boxes(self.paragraph, self.offsets, &self.layout.paragraph)
{
if let Some(span) = self.paragraph.children().get(placement.span) {
entries.push(self.entry(
placement.span,
placement.range.clone(),
horizontal_emphasis_mark_box(span, &placement),
horizontal_emphasis_mark_em_box(span, &ink_box),
DIRECTION_EMPHASIS_MARK,
));
}

View File

@ -76,19 +76,121 @@ pub(crate) fn horizontal_emphasis_placements(
placements
}
/// Em box of a horizontal emphasis mark in the laid-out paragraph: centred
/// beside its base character on the selected side, outside stacked annotations.
/// One emphasis mark shaped at its span's style. Paint, bounds and position
/// data all place this measurement, so they agree on where the mark is.
pub(crate) struct HorizontalEmphasisMark {
paragraph: LayoutParagraph,
/// Tight rect of the mark in `paragraph`.
pub(crate) ink: skia::Rect,
}
impl HorizontalEmphasisMark {
/// Shape `mark` at the style of `range` in the laid-out paragraph.
pub(crate) fn measure(
laid_out: &LayoutParagraph,
range: &HorizontalSpanRange,
span: &TextSpan,
mark: char,
) -> Option<Self> {
let mut style = horizontal_span_style(laid_out, range)?;
style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE);
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(0.0);
let paragraph = mini_paragraph(&mark.to_string(), &style, get_font_collection());
let ink = 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
})
.unwrap_or_else(|| skia::Rect::from_wh(paragraph.longest_line(), paragraph.height()));
Some(Self { paragraph, ink })
}
}
/// Box of a horizontal emphasis mark whose ink is `ink`, in the laid-out
/// paragraph: centred beside its base character on the selected side,
/// outside stacked annotations. This is exactly where the mark paints.
pub(crate) fn horizontal_emphasis_mark_box(
span: &TextSpan,
placement: &HorizontalEmphasisPlacement,
ink: &skia::Rect,
) -> skia::Rect {
let size = span.font_size * EMPHASIS_FONT_SCALE;
let offset = span.emphasis_outside_offset(false);
let top = if span.emphasis_side(false) == AnnotationSide::Over {
placement.em_top - span.emphasis_outside_offset(false) - size
placement.em_top - offset - ink.height()
} else {
placement.rect.bottom() + span.emphasis_outside_offset(false)
placement.rect.bottom() + offset
};
skia::Rect::from_xywh(placement.rect.center_x() - size / 2.0, top, size, size)
skia::Rect::from_xywh(
placement.rect.center_x() - ink.width() / 2.0,
top,
ink.width(),
ink.height(),
)
}
/// Em box of a mark painted in `ink_box`, centred on it. Position data
/// carries it: SVG export draws the mark at a font size of its width.
pub(crate) fn horizontal_emphasis_mark_em_box(span: &TextSpan, ink_box: &skia::Rect) -> skia::Rect {
let size = span.font_size * EMPHASIS_FONT_SCALE;
skia::Rect::from_xywh(
ink_box.center_x() - size / 2.0,
ink_box.center_y() - size / 2.0,
size,
size,
)
}
/// Call `f` with each placed mark, its span, its shaped mark and the box
/// it paints in, shaping one mark per span range.
fn for_each_horizontal_emphasis_mark(
paragraph: &Paragraph,
offsets: &HorizontalOffsets,
laid_out: &LayoutParagraph,
mut f: impl FnMut(&TextSpan, HorizontalEmphasisPlacement, &HorizontalEmphasisMark, skia::Rect),
) {
let mut placements = horizontal_emphasis_placements(paragraph, offsets, laid_out);
for range in offsets.ranges.iter().filter(|range| !range.warichu) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
let Some(mark) = span.text_emphasis.mark_char() else {
continue;
};
let Some(shaped) = HorizontalEmphasisMark::measure(laid_out, range, span, mark) else {
continue;
};
let (own, rest) = placements
.into_iter()
.partition(|placement| placement.span == range.span && placement.mark == mark);
placements = rest;
for placement in own {
let ink_box = horizontal_emphasis_mark_box(span, &placement, &shaped.ink);
f(span, placement, &shaped, ink_box);
}
}
}
/// Every horizontal emphasis mark with the box it paints in, in the
/// laid-out paragraph. Text bounds join these boxes.
pub(crate) fn horizontal_emphasis_mark_boxes(
paragraph: &Paragraph,
offsets: &HorizontalOffsets,
laid_out: &LayoutParagraph,
) -> Vec<(HorizontalEmphasisPlacement, skia::Rect)> {
let mut boxes = Vec::new();
for_each_horizontal_emphasis_mark(paragraph, offsets, laid_out, |_, placement, _, ink_box| {
boxes.push((placement, ink_box));
});
boxes
}
/// Paint horizontal emphasis marks on their configured side of the base.
@ -101,55 +203,16 @@ pub(crate) fn paint_horizontal_emphasis(
x: f32,
y: f32,
) {
let offsets = &plan.offsets;
let placements = horizontal_emphasis_placements(paragraph, offsets, laid_out);
for range in offsets.ranges.iter().filter(|range| !range.warichu) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
let Some(mark) = span.text_emphasis.mark_char() else {
continue;
};
let Some(mut style) = horizontal_span_style(laid_out, range) else {
continue;
};
style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE);
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(0.0);
let mark_paragraph = mini_paragraph(&mark.to_string(), &style, get_font_collection());
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_top = mark_ink.as_ref().map_or(0.0, |rect| rect.top());
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)
{
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
let mark_y = y + if span.emphasis_side(false) == AnnotationSide::Over {
placement.em_top - mark_ink_bottom - span.emphasis_outside_offset(false)
} else {
placement.rect.bottom() - mark_ink_top + span.emphasis_outside_offset(false)
};
mark_paragraph.paint(canvas, (mark_x, mark_y));
}
}
for_each_horizontal_emphasis_mark(
paragraph,
&plan.offsets,
laid_out,
|_, _, shaped, ink_box| {
let origin = (
x + ink_box.left - shaped.ink.left,
y + ink_box.top - shaped.ink.top,
);
shaped.paragraph.paint(canvas, origin);
},
);
}

View File

@ -1201,8 +1201,10 @@ fn horizontal_emphasis_mark_stacks_outside_auto_clearance_ruby() {
stacked.ruby = "かん".to_string();
stacked.annotation_clearance = AnnotationClearance::Auto;
let plain_box = horizontal_emphasis_mark_box(&plain, &placement);
let stacked_box = horizontal_emphasis_mark_box(&stacked, &placement);
let size = plain.font_size * EMPHASIS_FONT_SCALE;
let ink = skia::Rect::from_wh(size, size);
let plain_box = horizontal_emphasis_mark_box(&plain, &placement, &ink);
let stacked_box = horizontal_emphasis_mark_box(&stacked, &placement, &ink);
assert_eq!(plain_box.width(), plain.font_size * EMPHASIS_FONT_SCALE);
assert_eq!(plain_box.center_x(), placement.rect.center_x());
@ -1427,12 +1429,13 @@ fn emphasis_position_overrides_the_language_and_stacks_on_its_own_side() {
span.text_emphasis = TextEmphasis::FilledDot;
span.text_emphasis_position = position;
span.annotation_clearance = AnnotationClearance::Auto;
let plain = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
let ink = skia::Rect::from_wh(8.0, 8.0);
let plain = super::emphasis::horizontal_emphasis_mark_box(&span, &placement, &ink);
assert_eq!(plain.top() >= placement.rect.bottom(), under);
span.ruby = "zhōng wén".to_string();
for side in [AnnotationSide::Over, AnnotationSide::Under] {
span.ruby_side = side;
let stacked = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
let stacked = super::emphasis::horizontal_emphasis_mark_box(&span, &placement, &ink);
let expected = if (side == AnnotationSide::Under) == under {
span.ruby_font_size()
} else {
@ -1527,7 +1530,8 @@ fn below_emphasis_clears_an_underline_on_the_same_side() {
rect: skia::Rect::from_xywh(0.0, 20.0, 16.0, 18.0),
em_top: 20.0,
};
let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
let ink = skia::Rect::from_wh(8.0, 8.0);
let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement, &ink);
assert!(mark.top() > placement.rect.bottom() + span.font_size / 9.0);
}
@ -1546,7 +1550,8 @@ fn chinese_horizontal_emphasis_defaults_below_the_base() {
rect: skia::Rect::from_xywh(0.0, 20.0, 16.0, 18.0),
em_top: 20.0,
};
let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
let ink = skia::Rect::from_wh(8.0, 8.0);
let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement, &ink);
assert!(mark.top() >= placement.rect.bottom());
}
@ -2256,15 +2261,13 @@ fn horizontal_emphasis_export_bounds_include_every_adjacent_ruby_mark() {
let export = content.calculate_bounds(&shape, false).to_rect();
let plans = content.horizontal_plans();
let paragraph = &content.paragraphs()[0];
let placements = horizontal_emphasis_placements(
let marks = horizontal_emphasis_mark_boxes(
paragraph,
&plans[0].offsets,
&content.layout.paragraphs[0][0],
);
assert_eq!(placements.len(), 4);
for placement in placements {
let mark =
horizontal_emphasis_mark_box(&paragraph.children()[placement.span], &placement);
assert_eq!(marks.len(), 4);
for (_, mark) in marks {
assert!(
export.bottom() >= mark.bottom(),
"{grow:?}: export {export:?} crops mark {mark:?}"
@ -2475,3 +2478,73 @@ fn japanese_distribution_reaches_the_box_edges() {
);
}
}
#[test]
fn horizontal_emphasis_mark_box_is_the_painted_mark_on_either_side() {
init_state();
let mut resources =
crate::render::RenderResources::try_new_headless().expect("headless resources");
let fonts = resources.fonts.font_collection().clone();
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
for position in [
TextEmphasisPosition::OverRight,
TextEmphasisPosition::UnderRight,
] {
let span = TextSpan {
font_size: 48.0,
fills: vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor(
skia::Color::BLACK,
))],
text_emphasis: TextEmphasis::FilledCircle,
text_emphasis_position: position,
..make_span("A", 0.0)
};
let paragraph = make_paragraph(vec![span], 0.0);
let (plan, layout) = cjk_layout_paragraph(&paragraph, 400.0, &fonts);
let marks = horizontal_emphasis_mark_boxes(&paragraph, &plan.offsets, &layout);
assert_eq!(marks.len(), 1);
let (placement, mark_box) = &marks[0];
let span = &paragraph.children()[0];
let shaped = super::emphasis::HorizontalEmphasisMark::measure(
&layout,
&plan.offsets.ranges[0],
span,
placement.mark,
)
.expect("shaped mark");
assert_eq!(mark_box.width(), shaped.ink.width());
assert_eq!(mark_box.height(), shaped.ink.height());
assert_eq!(mark_box.center_x(), placement.rect.center_x());
let offset = span.emphasis_outside_offset(false);
if position == TextEmphasisPosition::OverRight {
assert_eq!(mark_box.bottom(), placement.em_top - offset);
} else {
assert_eq!(mark_box.top(), placement.rect.bottom() + offset);
}
let (x, y) = (20.0, 60.0);
let mut surface = skia::surfaces::raster_n32_premul((440, 220)).unwrap();
surface.canvas().clear(skia::Color::TRANSPARENT);
paint_horizontal_emphasis(surface.canvas(), &paragraph, &plan, &layout, x, y);
let image = surface.image_snapshot();
let pixels = image.peek_pixels().expect("raster pixels");
let mut ink: Option<skia::IRect> = None;
for py in 0..220 {
for px in 0..440 {
if pixels.get_color((px, py)).a() > 0 {
let pixel = skia::IRect::from_xywh(px, py, 1, 1);
ink = Some(ink.map_or(pixel, |rect| skia::IRect::join(&rect, &pixel)));
}
}
}
let ink = ink.expect("the mark paints");
let painted = mark_box.with_offset((x, y));
assert!(
ink.left as f32 >= painted.left.floor()
&& ink.right as f32 <= painted.right.ceil()
&& ink.top as f32 >= painted.top.floor()
&& ink.bottom as f32 <= painted.bottom.ceil(),
"{position:?}: mark paints {ink:?} outside its box {painted:?}"
);
}
}