diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index 8d3c48abd4..71aa15f8e5 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -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, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index d183a55418..67c94ca6c3 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -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, )); } diff --git a/render-wasm/src/shapes/text_horizontal/emphasis.rs b/render-wasm/src/shapes/text_horizontal/emphasis.rs index 7da947e3aa..cb431eace7 100644 --- a/render-wasm/src/shapes/text_horizontal/emphasis.rs +++ b/render-wasm/src/shapes/text_horizontal/emphasis.rs @@ -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 { + 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); + }, + ); } diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 5a9f0a83bf..6228b1bc9a 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -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(¶graph.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(¶graph, 400.0, &fonts); + let marks = horizontal_emphasis_mark_boxes(¶graph, &plan.offsets, &layout); + assert_eq!(marks.len(), 1); + let (placement, mark_box) = &marks[0]; + let span = ¶graph.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(), ¶graph, &plan, &layout, x, y); + let image = surface.image_snapshot(); + let pixels = image.peek_pixels().expect("raster pixels"); + let mut ink: Option = 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:?}" + ); + } +}