diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index cd26831db4..20ede9f7fb 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -76,6 +76,9 @@ For character-by-character readings, annotate each character separately. Readings use the saved `ruby` attributes and the shared annotation renderer. They support size, alignment, overhang, side, hiding and annotation clearance. +Adjacent reading spans recover the style covering each base even when Skia +merges their styles. Emphasis marks retain their per-base placement across +wraps; text bounds include the marks so individual exports keep them whole. Without a text selection, presentation changes affect existing readings. Horizontal readings form one Latin group above or below the base; vertical readings form one sideways Latin group beside it. Pronunciation is entered diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index ea3f62f502..7627d26cd0 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -899,6 +899,47 @@ impl TextContent { } } + /// Emphasis paints outside the base glyph metrics. Use its placement + /// geometry for culling and export bounds as well as position data. + fn horizontal_emphasis_bounds(&self, selrect: &Rect, valign: VerticalAlign) -> Option { + if !self + .paragraphs + .iter() + .flat_map(Paragraph::children) + .any(|span| !span.text_emphasis.is_none()) + { + 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 ((group, model), plan) in self + .layout + .paragraphs + .iter() + .zip(&self.paragraphs) + .zip(plans.iter()) + { + let Some(paragraph) = group.first() else { + 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))); + } + y += paragraph.height(); + } + (!bounds.is_empty()).then_some(bounds) + } + /// 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). @@ -955,7 +996,11 @@ impl TextContent { } if has_lines { - Some(Rect::from_ltrb(min_x, min_y, max_x, max_y)) + 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); + } + Some(bounds) } else { None } @@ -976,7 +1021,11 @@ impl TextContent { return self.vertical_extrect(selrect, valign); } if self.grow_type() == GrowType::AutoWidth { - return self.content_rect(selrect, valign); + let mut bounds = self.content_rect(selrect, valign); + if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) { + bounds.join(emphasis); + } + return bounds; } let layout_matches_container = self diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 057a342d50..e74c227e7e 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -420,15 +420,18 @@ pub(crate) fn horizontal_span_style( laid_out: &skia::textlayout::Paragraph, range: &HorizontalSpanRange, ) -> Option { - // Indexed style metrics use builder UTF-8 byte positions, unlike the - // UTF-16 offsets of glyph and line ranges. - let anchor = range.style_anchor_start..range.style_anchor_start + 1; - laid_out.get_line_metrics().iter().find_map(|line| { - line.get_style_metrics(anchor.clone()) - .into_iter() - .next() - .map(|(_, metric)| metric.text_style.clone()) - }) + // Lines use UTF-16, while their style entries use UTF-8 run starts. + // Skia can merge neighboring spans into one run, so an exact lookup at + // the later span's anchor may be empty. Recover the preceding style on + // the line containing the base, without carrying a style across lines. + let lines = laid_out.get_line_metrics(); + let line = lines.iter().find(|line| { + line.start_index <= range.style_anchor_utf16 + && range.style_anchor_utf16 < line.end_including_newline + })?; + line.get_style_metrics(0..range.style_anchor_start.saturating_add(1)) + .last() + .map(|(_, metric)| metric.text_style.clone()) } /// Top of the horizontal base em inside SkParagraph's typographic rect, less diff --git a/render-wasm/src/shapes/text_horizontal/offsets.rs b/render-wasm/src/shapes/text_horizontal/offsets.rs index bd67b7f94e..8350dcf59d 100644 --- a/render-wasm/src/shapes/text_horizontal/offsets.rs +++ b/render-wasm/src/shapes/text_horizontal/offsets.rs @@ -9,7 +9,10 @@ pub(crate) struct HorizontalSpanRange { pub source_start: usize, pub source_end: usize, pub warichu: bool, + /// UTF-8 byte anchor for indexed Skia style metrics. pub style_anchor_start: usize, + /// UTF-16 anchor for choosing the line containing those metrics. + pub style_anchor_utf16: usize, } /// Offset maps of one horizontal paragraph between source characters, the @@ -181,23 +184,27 @@ fn span_ranges( builder_cursor += leading_break; builder_byte_cursor += leading_break * kinsoku::FORCED_LINE_BREAK.len_utf8(); let builder_start = builder_cursor; - let style_anchor_start = if warichu { - builder_byte_cursor + '\u{FFFC}'.len_utf8() + let (style_anchor_start, style_anchor_utf16) = if warichu { + ( + builder_byte_cursor + '\u{FFFC}'.len_utf8(), + builder_start + 1, + ) } else { // A forced separator belongs to the preceding line and has // no style metrics. Anchor annotations to the first source // character rather than a leading layout-only control. let mut shifted = shifted_start; - builder_byte_cursor - + text - .chars() - .take_while(|ch| { - let inserted = offset_map.is_inserted(shifted); - shifted += ch.len_utf16(); - inserted - }) - .map(char::len_utf8) - .sum::() + let (bytes, utf16) = text + .chars() + .take_while(|ch| { + let inserted = offset_map.is_inserted(shifted); + shifted += ch.len_utf16(); + inserted + }) + .fold((0, 0), |(bytes, utf16), ch| { + (bytes + ch.len_utf8(), utf16 + ch.len_utf16()) + }); + (builder_byte_cursor + bytes, builder_start + utf16) }; builder_cursor += if warichu { HORIZONTAL_WARICHU_BUILDER_LEN @@ -221,6 +228,7 @@ fn span_ranges( source_end, warichu, style_anchor_start, + style_anchor_utf16, } }) .collect() diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 3124987665..5b51b16438 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -1781,3 +1781,176 @@ fn narrow_atomic_overflow_keeps_ruby_and_warichu_with_their_closing_marks() { } } } + +#[test] +fn adjacent_horizontal_ruby_bases_retain_each_annotation_style() { + use crate::shapes::text_vertical::test_support::provider; + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let mut fonts = resources.fonts.font_collection().clone(); + fonts.set_asset_font_manager(Some( + provider(include_bytes!("../../fonts/cjk-locl-test.ttf")).into(), + )); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + for width in [240.0, 48.0] { + let spans = ["文", "文", "文", "文"] + .iter() + .map(|text| TextSpan { + text: text.to_string(), + font_size: 24.0, + ruby: "wén".to_string(), + text_emphasis: TextEmphasis::FilledDot, + annotation_clearance: AnnotationClearance::Auto, + ..make_span(text, 0.0) + }) + .collect(); + let mut paragraph = make_paragraph(spans, 0.0); + paragraph.set_language("zh-Hant-TW".to_string()); + let (plan, layout) = cjk_layout_paragraph(¶graph, width, &fonts); + assert_eq!( + horizontal_emphasis_placements(¶graph, &plan.offsets, &layout).len(), + 4 + ); + for range in &plan.offsets.ranges { + let style = horizontal_span_style(&layout, range); + assert!( + style.is_some(), + "missing style for adjacent ruby base {} at {width}px", + range.span + ); + assert_eq!(style.unwrap().font_size(), 24.0); + } + } +} + +#[test] +fn adjacent_ruby_emphasis_paints_a_mark_for_every_base_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 spans = ["A", "B", "C", "D"] + .iter() + .map(|text| TextSpan { + font_size: 48.0, + fills: vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor( + skia::Color::BLACK, + ))], + ruby: "a".to_string(), + text_emphasis: TextEmphasis::FilledDot, + text_emphasis_position: position, + annotation_clearance: AnnotationClearance::Auto, + ..make_span(text, 0.0) + }) + .collect(); + let mut paragraph = make_paragraph(spans, 0.0); + paragraph.set_language("zh-Hant-TW".to_string()); + let (plan, layout) = cjk_layout_paragraph(¶graph, 400.0, &fonts); + let placements = horizontal_emphasis_placements(¶graph, &plan.offsets, &layout); + assert_eq!(placements.len(), 4); + let mut surface = skia::surfaces::raster_n32_premul((440, 200)).unwrap(); + surface.canvas().clear(skia::Color::TRANSPARENT); + paint_horizontal_emphasis(surface.canvas(), ¶graph, &plan, &layout, 20.0, 40.0); + let image = surface.image_snapshot(); + let pixels = image.peek_pixels().expect("raster pixels"); + for placement in placements { + let left = (20.0 + placement.rect.left()).ceil() as i32; + let right = (20.0 + placement.rect.right()).floor() as i32; + let ink = (0..200).any(|y| (left..right).any(|x| pixels.get_color((x, y)).a() > 0)); + assert!( + ink, + "missing {position:?} emphasis on adjacent ruby base {}", + placement.span + ); + } + } +} + +#[test] +fn annotation_style_keeps_utf16_lines_separate_from_utf8_style_keys() { + use crate::shapes::text_vertical::test_support::{provider, TEST_FONT}; + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let mut fonts = resources.fonts.font_collection().clone(); + fonts.set_asset_font_manager(Some(provider(TEST_FONT).into())); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let prefix = make_span("😀\n", 0.0); + let spans = vec![ + prefix, + TextSpan { + font_size: 24.0, + ruby: "a".to_string(), + ..make_span("A", 0.0) + }, + TextSpan { + font_size: 24.0, + ruby: "b".to_string(), + ..make_span("B", 0.0) + }, + ]; + let mut paragraph = make_paragraph(spans, 0.0); + paragraph.set_language("zh-Hant-TW".to_string()); + let (plan, layout) = cjk_layout_paragraph(¶graph, 240.0, &fonts); + for (range, expected) in plan.offsets.ranges.iter().zip([16.0, 24.0, 24.0]) { + let style = horizontal_span_style(&layout, range).expect("style on the base's line"); + assert_eq!(style.font_size(), expected); + } +} + +#[test] +fn horizontal_emphasis_export_bounds_include_every_adjacent_ruby_mark() { + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + for grow in [ + crate::shapes::GrowType::Fixed, + crate::shapes::GrowType::AutoHeight, + crate::shapes::GrowType::AutoWidth, + ] { + let spans = ["A", "B", "C", "D"] + .iter() + .map(|text| TextSpan { + font_size: 48.0, + language: "zh-Hant-TW".to_string(), + ruby: "a".to_string(), + text_emphasis: TextEmphasis::FilledDot, + text_decoration: Some(skia::textlayout::TextDecoration::UNDERLINE), + annotation_clearance: AnnotationClearance::Auto, + ..make_span(text, 0.0) + }) + .collect(); + let mut paragraph = make_paragraph(spans, 0.0); + paragraph.set_language("zh-Hant-TW".to_string()); + let bounds = crate::math::Rect::from_xywh(0.0, 0.0, 360.0, 160.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, 160.0); + let export = content.calculate_bounds(&shape, false).to_rect(); + let plans = content.horizontal_plans(); + let paragraph = &content.paragraphs()[0]; + let placements = horizontal_emphasis_placements( + 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!( + export.bottom() >= mark.bottom(), + "{grow:?}: export {export:?} crops mark {mark:?}" + ); + } + } +}