From 7172cdf30393b7c515b82e3267fdc0e24f4340c6 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 20 Oct 2026 14:38:22 +0000 Subject: [PATCH] :bug: Keep indivisible CJK units together in narrow text boxes Retain legal line starts at the original wrap width before allowing an indivisible punctuation or annotation unit to overflow together. Share the layout plan and alignment offset across painting, editing and exports. Cover mixed styles, nonbreaking controls, graphemes, annotations, alignment, strokes and resize. All 666 renderer tests and both WASM builds pass. AI-assisted-by: GPT-6 --- render-wasm/docs/cjk-layout.md | 17 +- render-wasm/src/globals.rs | 39 ++- render-wasm/src/render/text.rs | 52 +++- render-wasm/src/render/text_editor.rs | 265 +++++++++++++++++- render-wasm/src/shapes/kinsoku.rs | 15 +- render-wasm/src/shapes/text.rs | 166 ++++++++--- render-wasm/src/shapes/text_horizontal/mod.rs | 27 +- .../src/shapes/text_horizontal/offsets.rs | 35 ++- .../src/shapes/text_horizontal/overflow.rs | 171 +++++++++++ .../src/shapes/text_horizontal/ruby.rs | 7 +- .../src/shapes/text_horizontal/tests.rs | 198 ++++++++++++- render-wasm/src/shapes/text_paths.rs | 28 +- 12 files changed, 921 insertions(+), 99 deletions(-) create mode 100644 render-wasm/src/shapes/text_horizontal/overflow.rs diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index 28a6a41a83..cd26831db4 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -53,10 +53,17 @@ Their offset maps therefore stay identical for drawing, hit testing, carets, selection, text paths and export. Width participates in the plan cache key; resizing a box restores whole-word joins when the word fits again. -A horizontal indivisible unit wider than the entire box remains subject to -SkParagraph's last-resort fallback. Very narrow boxes containing punctuation -or annotation units need further work; this implementation does not claim -complete CSS `keep-all` conformance. +If an indivisible Chinese or Korean punctuation/annotation unit exceeds the +box width, the plan lets it overflow together. It first measures surrounding +text at the original width, then records those line starts with layout-only +line separators before giving Skia enough physical width for the unit. +Ordinary text keeps its original wrap budget. Source text stays unchanged; +the plan's offset map accounts for every inserted separator. + +Painting, strokes, caret/selection geometry, hit testing, annotations, paths +and exports share the plan and its alignment offset. The width cache also +rebuilds these breaks on resize. Legacy Korean `spaces-only` keeps its earlier +behavior; the supported modes do not claim complete CSS `keep-all` conformance. ## Pinyin readings @@ -132,7 +139,7 @@ mode. Both WASM consumers use the same writer and byte values: 0 syllable, Real Pinyin, Zhuyin and Korean IME checks and native-reader review remain release checks. Font glyph coverage and localized forms still depend on the -chosen fonts. Dedicated Zhuyin layout remains a Stage 4 follow-up. +chosen fonts. Dedicated Zhuyin layout is described below. References checked on 2026-10-19: diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index b9045f1d89..228284c385 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -56,17 +56,30 @@ pub(crate) fn has_render_state() -> bool { unsafe { !RENDER_STATE.is_null() } } +/// The render resources in use: in tests, the ones this thread installed with +/// [`TestRenderResourcesGuard`], else the global ones. +#[inline(always)] +fn render_resources() -> *mut RenderResources { + #[cfg(test)] + { + let installed = TEST_RENDER_RESOURCES.with(std::cell::Cell::get); + if !installed.is_null() { + return installed; + } + } + unsafe { RENDER_RESOURCES } +} + #[inline(always)] pub(crate) fn has_render_resources() -> bool { - unsafe { !RENDER_RESOURCES.is_null() } + !render_resources().is_null() } #[inline(always)] pub(crate) fn get_resources() -> &'static mut RenderResources { - unsafe { - debug_assert!(!RENDER_RESOURCES.is_null(), "Render Resources is null"); - &mut *RENDER_RESOURCES - } + let resources = render_resources(); + debug_assert!(!resources.is_null(), "Render Resources is null"); + unsafe { &mut *resources } } #[inline(always)] @@ -120,7 +133,16 @@ macro_rules! with_current_shape { }; } -/// Scoped override of the global render resources pointer for unit tests. +#[cfg(test)] +thread_local! { + /// Render resources a test installed for its own thread. Tests run on + /// parallel threads, and Skia font collections are not thread-safe, so + /// another test must never reach them. + static TEST_RENDER_RESOURCES: std::cell::Cell<*mut RenderResources> = + const { std::cell::Cell::new(std::ptr::null_mut()) }; +} + +/// Scoped override of the render resources for the current test thread. #[cfg(test)] pub(crate) struct TestRenderResourcesGuard { prev: *mut RenderResources, @@ -136,8 +158,7 @@ impl TestRenderResourcesGuard { let lock = TEST_RENDER_RESOURCES_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let prev = unsafe { RENDER_RESOURCES }; - unsafe { RENDER_RESOURCES = resources as *mut _ }; + let prev = TEST_RENDER_RESOURCES.replace(resources as *mut _); Self { prev, _lock: lock } } } @@ -145,7 +166,7 @@ impl TestRenderResourcesGuard { #[cfg(test)] impl Drop for TestRenderResourcesGuard { fn drop(&mut self) { - unsafe { RENDER_RESOURCES = self.prev }; + TEST_RENDER_RESOURCES.set(self.prev); } } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 16f6954b73..375de96b5d 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -16,6 +16,7 @@ use skia_safe::{ textlayout::{ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration}, Canvas, ImageFilter, Paint, }; +use std::borrow::Cow; use text_vertical::GlyphOutput; /// Vertical text: shadows, fill, strokes and the debug grid, all painted from @@ -169,17 +170,50 @@ pub fn stroke_paragraph_builder_group_from_text( let mut group_layer_opacity: Option = None; let plans = text_content.horizontal_plans(); - for (paragraph, plan) in text_content.paragraphs().iter().zip(plans.iter()) { + let adjustments = plans + .iter() + .any(|plan| plan.has_atomic_overflow()) + .then(|| text_content.horizontal_line_adjustments()) + .flatten(); + for (index, (paragraph, plan)) in text_content + .paragraphs() + .iter() + .zip(plans.iter()) + .enumerate() + { + let adjustment = plan + .has_atomic_overflow() + .then(|| { + adjustments + .as_ref() + .and_then(|adjustments| adjustments[index].as_ref()) + }) + .flatten(); let mut stroke_paragraphs_map: std::collections::HashMap = std::collections::HashMap::new(); - for (((span, text), sheds), extra) in paragraph + for (span_index, (((span, text), sheds), extra)) in paragraph .children() .iter() .zip(&plan.texts) .zip(&plan.sheds) .zip(&plan.ruby_spacing.adjustments) + .enumerate() { + let sheds: Cow<'_, [(usize, f32)]> = adjustment + .map_or(Cow::Borrowed(sheds.as_slice()), |a| { + Cow::Owned(sheds.iter().chain(&a.sheds[span_index]).copied().collect()) + }); + let extra: Cow<'_, [(usize, f32)]> = + adjustment.map_or(Cow::Borrowed(extra.as_slice()), |a| { + Cow::Owned( + extra + .iter() + .chain(&a.spacing[span_index]) + .copied() + .collect(), + ) + }); let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints(stroke, bounds, remove_stroke_alpha); @@ -189,7 +223,13 @@ pub fn stroke_paragraph_builder_group_from_text( for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| { - let paragraph_style = paragraph.paragraph_to_style(); + let mut paragraph_style = paragraph.paragraph_to_style(); + if plan.has_atomic_overflow() { + paragraph_style.set_apply_rounding_hack(false); + if paragraph.text_align() == skia::textlayout::TextAlign::Justify { + paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); + } + } ParagraphBuilder::new(¶graph_style, fonts) }); let stroke_paint = stroke_paint.clone(); @@ -201,7 +241,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph.line_height(), ); builder.push_style(&stroke_style); - add_horizontal_span(builder, span, text, sheds, extra, &stroke_style, fonts); + add_horizontal_span(builder, span, text, &sheds, &extra, &stroke_style, fonts); } } @@ -571,6 +611,10 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC let Some(paragraph) = group.first() else { continue; }; + let x = x + plans + .get(index) + .zip(text_content.paragraphs().get(index)) + .map_or(0.0, |(plan, model)| plan.x_offset(model, selrect.width())); paragraph.paint(canvas, (x, y_accum)); if let (Some(text_paragraph), Some(plan)) = (text_content.paragraphs().get(index), plans.get(index)) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index af78d6f375..373b55de67 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -243,18 +243,37 @@ pub(crate) fn cursor_rect( .iter() .map(|paragraph| paragraph.height()) .sum::(); - let rect = horizontal_caret_rect(¶graphs[cursor.paragraph], laid_out_para, cursor.offset); - Some(rect.with_offset((0.0, y_offset))) + let plans = text_content.horizontal_plans(); + let plan = plans.get(cursor.paragraph)?; + let model = ¶graphs[cursor.paragraph]; + let rect = + horizontal_caret_rect_with_offsets(model, laid_out_para, cursor.offset, &plan.offsets); + Some(rect.with_offset((plan.x_offset(model, shape.selrect.width()), y_offset))) } /// Caret rect at the source character offset `char_pos` of a laid-out /// horizontal paragraph, in the paragraph's coordinates. It covers the /// character at the offset (overtype carets use its width), or follows the /// last character at the paragraph end. +#[cfg(test)] fn horizontal_caret_rect( para: &crate::shapes::Paragraph, laid_out_para: &skia_safe::textlayout::Paragraph, char_pos: usize, +) -> Rect { + horizontal_caret_rect_with_offsets( + para, + laid_out_para, + char_pos, + &crate::shapes::HorizontalOffsets::new(para), + ) +} + +fn horizontal_caret_rect_with_offsets( + para: &crate::shapes::Paragraph, + laid_out_para: &skia_safe::textlayout::Paragraph, + char_pos: usize, + offsets: &crate::shapes::HorizontalOffsets, ) -> Rect { if let Some(rect) = crate::shapes::horizontal_warichu_caret_rect(para, laid_out_para, char_pos) { @@ -272,7 +291,6 @@ fn horizontal_caret_rect( // Cursor offsets count source characters; the laid-out paragraph indexes // the transformed, kinsoku-shifted builder text. - let offsets = crate::shapes::HorizontalOffsets::new(para); let first_rect = |start: usize, end: usize| { laid_out_para .get_rects_for_range(start..end, RectHeightStyle::Max, RectWidthStyle::Tight) @@ -351,6 +369,7 @@ fn range_rects( let mut rects = Vec::new(); let (start_paragraph, start_offset) = start; let (end_paragraph, end_offset) = end; + let plans = text_content.horizontal_plans(); let paragraphs = text_content.paragraphs(); let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); @@ -368,6 +387,8 @@ fn range_rects( // Calculate character range for this paragraph let para = ¶graphs[para_idx]; + let plan = &plans[para_idx]; + let x_offset = plan.x_offset(para, shape.selrect.width()); let para_char_count: usize = para .children() .iter() @@ -397,14 +418,15 @@ fn range_rects( ); for r in warichu_rects { rects.push(Rect::from_xywh( - r.left(), + r.left() + x_offset, y_offset + r.top(), r.width(), r.height(), )); } use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - for builder_range in crate::shapes::HorizontalOffsets::new(para) + for builder_range in plan + .offsets .normal_selection_ranges(para, range_start, range_end) { for text_box in laid_out_para.get_rects_for_range( @@ -414,7 +436,7 @@ fn range_rects( ) { let r = text_box.rect; rects.push(Rect::from_xywh( - r.left(), + r.left() + x_offset, y_offset + r.top(), r.width(), r.height(), @@ -587,4 +609,235 @@ mod tests { let caret = horizontal_caret_rect(¶graph(""), &laid_out, 0); assert_eq!(caret, Rect::from_xywh(0.0, 0.0, 1.0, laid_out.height())); } + #[test] + fn narrow_cjk_layout_shares_alignment_with_carets_selection_hit_testing_and_exports() { + use crate::shapes::calculate_text_layout_data; + use crate::shapes::text_paths::TextPaths; + use crate::shapes::text_vertical::test_support::provider; + use crate::shapes::{GrowType, TextPositionWithAffinity, VerticalAlign}; + crate::globals::design_init(); + 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 align in [ + TextAlign::Left, + TextAlign::Right, + TextAlign::Center, + TextAlign::Justify, + ] { + for grow in [GrowType::Fixed, GrowType::AutoHeight] { + let spans = ["文文", "文", "。", "文文"] + .iter() + .map(|text| TextSpan { + text: text.to_string(), + font_size: 20.0, + line_height: 1.0, + ..TextSpan::default() + }) + .collect(); + let mut model = + Paragraph::new(align, TextDirection::LTR, None, None, 1.0, 0.0, spans); + model.set_language("zh-Hant-TW".to_owned()); + let mut content = + TextContent::new(crate::math::Rect::from_xywh(0.0, 0.0, 20.0, 300.0), grow); + content.add_paragraph(model.clone()); + content.add_paragraph(model); + let mut shape = Shape::new(crate::Uuid::nil()); + shape.set_selrect(0.0, 0.0, 20.0, 300.0); + content.update_layout(shape.selrect); + let plans = content.horizontal_plans(); + let mut editor = TextEditorState::new(); + let data = calculate_text_layout_data( + &shape, + &content, + &mut content.paragraph_builder_group_from_text(None), + false, + ); + let mut paragraph_y = 0.0; + for paragraph_index in 0..2 { + let laid_out = &content.layout.paragraphs[paragraph_index][0]; + let plan = &plans[paragraph_index]; + let x_offset = plan.x_offset(&content.paragraphs()[paragraph_index], 20.0); + let lines = laid_out.get_line_metrics(); + assert_eq!(lines.len(), 5, "{align:?} {grow:?}"); + let atomic_left = lines[2].left as f32 + x_offset; + let expected_left = match align { + TextAlign::Right => -20.0, + TextAlign::Center => -10.0, + _ => 0.0, + }; + assert!( + (atomic_left - expected_left).abs() < 0.02, + "{align:?}: {atomic_left}" + ); + assert!( + (lines[0].left as f32 + x_offset).abs() < 0.02, + "ordinary text stays within its box" + ); + for (offset, line_index) in [0, 1, 2, 2, 3, 4].into_iter().enumerate() { + let position = TextPositionWithAffinity::new_downstream_affinity( + paragraph_index, + offset, + ); + editor.selection.set_caret(position); + let caret = cursor_rect(&editor, &content, &shape).expect("caret"); + let line_top = paragraph_y + + lines[..line_index] + .iter() + .map(|line| line.height as f32) + .sum::(); + assert!( + (caret.top() - line_top).abs() < 0.1, + "caret {offset} in {align:?}: {caret:?}" + ); + let hit = content + .get_caret_position_from_shape_coords( + &crate::math::Point::new( + caret.left() + caret.width() * 0.1, + caret.center_y(), + ), + &shape.selrect, + VerticalAlign::Top, + ) + .expect("hit"); + assert_eq!( + (hit.paragraph, hit.offset), + (paragraph_index, offset), + "{align:?}, caret={caret:?}" + ); + } + editor.selection.anchor = + TextPositionWithAffinity::new_downstream_affinity(paragraph_index, 2); + editor.selection.focus = + TextPositionWithAffinity::new_downstream_affinity(paragraph_index, 4); + let selected = calculate_selection_rects(&editor.selection, &content, &shape); + assert!(!selected.is_empty()); + assert!(selected.iter().all(|rect| (rect.top() + - paragraph_y + - lines[..2] + .iter() + .map(|line| line.height as f32) + .sum::()) + .abs() + < 0.1)); + assert!((selected.iter().map(|r| r.width()).sum::() - 40.0).abs() < 0.1); + let exported = &data.paragraphs[paragraph_index]; + assert!((exported.x - x_offset).abs() < 0.01); + assert!((exported.y - paragraph_y).abs() < 0.01); + assert_eq!(exported.paragraph.get_line_metrics().len(), 5); + paragraph_y += laid_out.height(); + } + let paths = TextPaths::new(content.clone()).get_paths(VerticalAlign::Top); + assert!(!paths.is_empty()); + let mut bounds = skia_safe::Rect::new_empty(); + for path in paths { + bounds.join(path.bounds()); + } + assert!( + bounds.height() > paragraph_y - 30.0, + "paths include both paragraphs" + ); + assert!( + bounds.width() > 20.0, + "paths include the overflowing punctuation unit: {align:?} {bounds:?}" + ); + assert!( + content.get_height(160.0) < paragraph_y / 2.0, + "prospective width uses a fresh plan" + ); + let before: Vec<_> = plans.iter().map(|plan| plan.texts.clone()).collect(); + content.update_layout(crate::math::Rect::from_xywh(0.0, 0.0, 160.0, 300.0)); + assert!(content + .layout + .paragraphs + .iter() + .all(|group| group[0].get_line_metrics().len() == 1)); + assert!(content.horizontal_plans().iter().all(|plan| !plan + .texts + .concat() + .contains(crate::shapes::kinsoku::FORCED_LINE_BREAK))); + content.update_layout(shape.selrect); + assert_eq!( + content + .horizontal_plans() + .iter() + .map(|plan| plan.texts.clone()) + .collect::>(), + before, + "narrow/wide/narrow restores the plan" + ); + assert_eq!( + content.paragraphs()[0] + .children() + .iter() + .map(|s| s.text.as_str()) + .collect::(), + "文文文。文文" + ); + } + } + } + #[test] + fn narrow_cjk_fractional_strokes_share_the_fill_breaks_and_advances() { + use crate::shapes::text_vertical::test_support::provider; + use crate::shapes::{GrowType, Stroke, StrokeStyle}; + crate::globals::design_init(); + 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); + let spans = ["文文", "文", "。", "文文"] + .iter() + .map(|text| TextSpan { + text: text.to_string(), + font_size: 20.125, + line_height: 1.0, + ..TextSpan::default() + }) + .collect(); + let mut model = Paragraph::new( + TextAlign::Justify, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + spans, + ); + model.set_language("zh-Hant-TW".to_owned()); + let mut content = TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 32.25, 300.0), + GrowType::Fixed, + ); + content.add_paragraph(model); + content.update_layout(content.bounds()); + let stroke = Stroke::new_center_stroke(1.0, StrokeStyle::Solid, None, None, None, None); + let (mut builders, _) = crate::render::text::stroke_paragraph_builder_group_from_text( + &content, + &stroke, + &content.bounds(), + None, + ); + assert!(!builders[0].is_empty()); + let mut stroked = builders[0][0].build(); + stroked.layout(content.horizontal_plans()[0].layout_width(32.25)); + let fill_lines = content.layout.paragraphs[0][0].get_line_metrics(); + let stroke_lines = stroked.get_line_metrics(); + assert_eq!(fill_lines.len(), stroke_lines.len()); + for (fill, stroke) in fill_lines.iter().zip(&stroke_lines) { + assert_eq!( + (fill.start_index, fill.end_index), + (stroke.start_index, stroke.end_index) + ); + assert!((fill.width - stroke.width).abs() < 0.01); + assert!((fill.left - stroke.left).abs() < 0.01); + } + } } diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/kinsoku.rs index 18251bfdb4..6dc34d74eb 100644 --- a/render-wasm/src/shapes/kinsoku.rs +++ b/render-wasm/src/shapes/kinsoku.rs @@ -18,11 +18,14 @@ pub const WORD_JOINER: char = '\u{2060}'; /// ZERO WIDTH SPACE: opens a break UAX #14 forbids but JLREQ allows, before /// an inseparable mark such as … (JLREQ only keeps two of them together). pub const BREAK_OPPORTUNITY: char = '\u{200B}'; +/// Layout-only line separator used to retain the original wrap budget when +/// an indivisible CJK unit needs more width than its container. +pub const FORCED_LINE_BREAK: char = '\u{2028}'; /// True for the zero-width characters this pass inserts to steer breaks; /// they take no letter-spacing and count as no character. pub fn is_inserted_zero_width(ch: char) -> bool { - ch == WORD_JOINER || ch == BREAK_OPPORTUNITY + ch == WORD_JOINER || ch == BREAK_OPPORTUNITY || ch == FORCED_LINE_BREAK } /// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap. pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}'; @@ -104,19 +107,21 @@ fn apply_to_span_texts_with_ruby_breaks( apply_policy_to_span_texts(span_texts, ruby_breaks, TextLayoutPolicy::Japanese) } +#[cfg(test)] pub fn apply_policy_to_span_texts( span_texts: &[String], ruby_breaks: &[Option>], policy: TextLayoutPolicy, ) -> Option<(Vec, OffsetMap)> { - apply_policy_with_emergency_breaks(span_texts, ruby_breaks, policy, &[]) + apply_policy_with_breaks(span_texts, ruby_breaks, policy, &[], &[]) } -pub fn apply_policy_with_emergency_breaks( +pub fn apply_policy_with_breaks( span_texts: &[String], ruby_breaks: &[Option>], policy: TextLayoutPolicy, emergency_breaks: &[usize], + forced_breaks: &[usize], ) -> Option<(Vec, OffsetMap)> { let source = span_texts.concat(); let boundaries: Vec = source.grapheme_indices(true).map(|(at, _)| at).collect(); @@ -176,7 +181,9 @@ pub fn apply_policy_with_emergency_breaks( insert(JAPANESE_WESTERN_SPACE); } // After an inserted space, the joiner keeps the break blocked. - if forbid_break { + if forced_breaks.binary_search(&source_at).is_ok() { + insert(FORCED_LINE_BREAK); + } else if forbid_break { insert(if emergency_breaks.binary_search(&source_at).is_ok() { BREAK_OPPORTUNITY } else { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 4c66b0f6b1..ea3f62f502 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -31,7 +31,9 @@ use super::text_japanese::*; use super::{FontFamily, FontStyle}; use crate::math::Point; use crate::render::FallbackFonts; -use crate::shapes::{self, kinsoku, merge_fills, Shape, Type, VerticalAlign}; +#[cfg(test)] +use crate::shapes::kinsoku; +use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; @@ -287,8 +289,8 @@ struct HorizontalPlansCache { /// justifies them. #[derive(Debug, Clone, Default)] pub(crate) struct HorizontalAdjustment { - sheds: Vec>, - spacing: Vec>, + pub(crate) sheds: Vec>, + pub(crate) spacing: Vec>, } /// Per paragraph; `None` for paragraphs laid out by Skia alone. @@ -428,34 +430,40 @@ fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> b } fn paragraph_intersects<'a>( - paragraphs: impl Iterator, + paragraphs: impl Iterator, x_pos: f32, y_pos: f32, ) -> bool { paragraphs - .scan(0.0_f32, |height, p| { + .scan(0.0_f32, |height, (p, x_offset)| { let prev_height = *height; *height += p.height(); - Some((prev_height, p)) + Some((prev_height, p, x_offset)) }) - .any(|(height, p)| intersects(p, x_pos, y_pos - height)) + .any(|(height, p, x_offset)| intersects(p, x_pos - x_offset, y_pos - height)) } // Performs a text auto layout without width limits. // This should be the same as text_auto_layout. -pub fn build_paragraphs_from_paragraph_builders( +pub(crate) fn build_paragraphs_from_paragraph_builders( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, + plans: &[HorizontalParagraphPlan], ) -> Vec> { let paragraphs = paragraph_builders .iter_mut() - .map(|builders| { + .enumerate() + .map(|(index, builders)| { builders .iter_mut() .map(|builder| { let mut paragraph = builder.build(); // For auto-width, always layout with infinite width first to get intrinsic width - paragraph.layout(width); + paragraph.layout( + plans + .get(index) + .map_or(width, |plan| plan.layout_width(width)), + ); paragraph }) .collect() @@ -465,15 +473,20 @@ pub fn build_paragraphs_from_paragraph_builders( } /// Calculate the normalized line height from paragraph builders -pub fn calculate_normalized_line_height( +pub(crate) fn calculate_normalized_line_height( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, + plans: &[HorizontalParagraphPlan], ) -> f32 { let mut normalized_line_height = 0.0; - for paragraph_builder_group in paragraph_builders.iter_mut() { + for (index, paragraph_builder_group) in paragraph_builders.iter_mut().enumerate() { for paragraph_builder in paragraph_builder_group.iter_mut() { let mut paragraph = paragraph_builder.build(); - paragraph.layout(width); + paragraph.layout( + plans + .get(index) + .map_or(width, |plan| plan.layout_width(width)), + ); let baseline = paragraph.ideographic_baseline(); if baseline > normalized_line_height { normalized_line_height = baseline; @@ -683,7 +696,7 @@ impl TextContent { /// Squeeze sheds and justify spacing of each horizontal paragraph at /// the layout width, cached until the content or the width /// changes. `None` when no paragraph needs them. - fn horizontal_line_adjustments(&self) -> Option { + pub(crate) fn horizontal_line_adjustments(&self) -> Option { if self.is_vertical() || self.grow_type() == GrowType::AutoWidth { return None; } @@ -731,7 +744,7 @@ impl TextContent { let mut laid_out = self .horizontal_measure_builder(paragraph, plan, extra_sheds) .build(); - laid_out.layout(width); + laid_out.layout(plan.layout_width(width)); laid_out }; let (sheds, laid_out) = if paragraph.layout_policy() @@ -807,7 +820,11 @@ impl TextContent { /// sheds, ruby spacing), cached until content or width changes; clones /// share them. A font load bumps the content version for shaped spacing. pub(crate) fn horizontal_plans(&self) -> Rc> { - let width = self.get_width(self.bounds().width()); + let width = if self.grow_type() == GrowType::AutoWidth { + f32::MAX + } else { + self.bounds().width() + }; if let Some(entry) = self.layout.horizontal.borrow().as_ref() { if entry.version == self.content_version && entry.width == width { return Rc::clone(&entry.plans); @@ -826,6 +843,14 @@ impl TextContent { measured.layout(width); measured }); + plan.retain_atomic_overflow(paragraph, width, |plan, width| { + let sheds = vec![Vec::new(); plan.texts.len()]; + let mut measured = self + .horizontal_measure_builder(paragraph, plan, &sheds) + .build(); + measured.layout(width); + measured + }); plan }) .collect(), @@ -897,8 +922,10 @@ impl TextContent { let mut has_lines = false; let mut y_accum = base_y + vertical_offset; - for group in paragraphs.iter() { + let plans = self.horizontal_plans(); + for (index, group) in paragraphs.iter().enumerate() { if let Some(paragraph) = group.first() { + let x = x + plans[index].x_offset(&self.paragraphs[index], selrect.width()); let line_metrics = paragraph.get_line_metrics(); for line in &line_metrics { let line_baseline = y_accum + line.baseline as f32; @@ -1136,7 +1163,12 @@ impl TextContent { // Skia's get_glyph_position_at_coordinate expects coordinates relative to // the paragraph's top-left. For multi-paragraph or wrapped text, each // paragraph has its own origin; subtract start_y so we pass paragraph-local coords. - let para_pt = Point::new(point.x, point.y - start_y); + let plans = self.horizontal_plans(); + let x_offset = plans + .get(paragraph_index) + .zip(self.paragraphs().get(paragraph_index)) + .map_or(0.0, |(plan, model)| plan.x_offset(model, selrect.width())); + let para_pt = Point::new(point.x - x_offset, point.y - start_y); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { if let Some(original_position) = horizontal_warichu_hit_test(paragraph, layout_paragraph, para_pt) @@ -1294,7 +1326,7 @@ impl TextContent { .and_then(|adjustments| adjustments.get(paragraph_index)) .and_then(Option::as_ref); let mut paragraph_style = paragraph.paragraph_to_style(); - if self.adjusts_lines(paragraph, plan) { + if self.adjusts_lines(paragraph, plan) || plan.has_atomic_overflow() { // Line adjustment gives characters runs of their own; rounding // each run up would wrap the adjusted lines. The measuring // pass shares this style so both break alike. @@ -1420,11 +1452,17 @@ impl TextContent { None, ); - let normalized_line_height = - calculate_normalized_line_height(&mut measure_builders, f32::MAX); + let normalized_line_height = calculate_normalized_line_height( + &mut measure_builders, + f32::MAX, + &self.horizontal_plans(), + ); - let measure_paragraphs = - build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); + let measure_paragraphs = build_paragraphs_from_paragraph_builders( + &mut measure_builders, + f32::MAX, + &self.horizontal_plans(), + ); let content_width = measure_paragraphs .iter() @@ -1436,8 +1474,11 @@ impl TextContent { // Re-layout at the intrinsic width (without the HTML margin slack). let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = - build_paragraphs_from_paragraph_builders(&mut paragraph_builders, content_width); + let paragraphs = build_paragraphs_from_paragraph_builders( + &mut paragraph_builders, + content_width, + &self.horizontal_plans(), + ); let height = paragraphs .iter() .flatten() @@ -1461,10 +1502,17 @@ impl TextContent { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let normalized_line_height = - calculate_normalized_line_height(&mut paragraph_builders, width); + let normalized_line_height = calculate_normalized_line_height( + &mut paragraph_builders, + width, + &self.horizontal_plans(), + ); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders( + &mut paragraph_builders, + width, + &self.horizontal_plans(), + ); let height = paragraphs .iter() .flatten() @@ -1485,10 +1533,17 @@ impl TextContent { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let normalized_line_height = - calculate_normalized_line_height(&mut paragraph_builders, width); + let normalized_line_height = calculate_normalized_line_height( + &mut paragraph_builders, + width, + &self.horizontal_plans(), + ); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders( + &mut paragraph_builders, + width, + &self.horizontal_plans(), + ); let paragraph_height = paragraphs .iter() .flatten() @@ -1514,8 +1569,21 @@ impl TextContent { } pub fn get_height(&self, width: f32) -> f32 { - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + // A prospective width needs its own plan: frozen overflow breaks + // from the current bounds must not determine a resized box's height. + let mut content = self.clone(); + content.set_xywh( + self.bounds.x(), + self.bounds.y(), + width, + self.bounds.height(), + ); + let mut paragraph_builders = content.paragraph_builder_group_from_text(None); + let paragraphs = build_paragraphs_from_paragraph_builders( + &mut paragraph_builders, + width, + &content.horizontal_plans(), + ); let paragraph_height = paragraphs .iter() .flatten() @@ -1832,13 +1900,22 @@ impl TextContent { let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); + let plans = self.horizontal_plans(); if !self.layout.paragraphs.is_empty() { // Reuse stored laid-out paragraphs paragraph_intersects( self.layout .paragraphs .iter() - .flat_map(|group| group.first()), + .enumerate() + .filter_map(|(index, group)| { + group.first().map(|paragraph| { + ( + paragraph, + plans[index].x_offset(&self.paragraphs[index], rect.width()), + ) + }) + }), x_pos, y_pos, ) @@ -1846,9 +1923,20 @@ impl TextContent { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let paragraphs = - build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width, &plans); - paragraph_intersects(paragraphs.iter().flatten(), x_pos, y_pos) + paragraph_intersects( + paragraphs.iter().enumerate().filter_map(|(index, group)| { + group.first().map(|paragraph| { + ( + paragraph, + plans[index].x_offset(&self.paragraphs[index], width), + ) + }) + }), + x_pos, + y_pos, + ) } } } @@ -2124,6 +2212,7 @@ impl Paragraph { /// plus the map from original to builder-text UTF-16 offsets. Consumers /// of laid-out offsets must translate through the map. Only paragraphs /// with an explicit typography profile are transformed. + #[cfg(test)] pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) { layout_span_texts(self) } @@ -2710,13 +2799,13 @@ pub fn calculate_text_layout_data( let mut paragraph_heights: Vec = Vec::new(); let mut built_groups: Vec> = Vec::with_capacity(paragraph_builder_groups.len()); - for paragraph_builder_group in paragraph_builder_groups.iter_mut() { + for (index, paragraph_builder_group) in paragraph_builder_groups.iter_mut().enumerate() { let group_len = paragraph_builder_group.len(); let mut paragraph_offset_y = previous_line_height; let mut group_paragraphs: Vec = Vec::with_capacity(group_len); for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { let mut skia_paragraph = paragraph_builder.build(); - skia_paragraph.layout(text_width); + skia_paragraph.layout(plans[index].layout_width(text_width)); if builder_index == group_len - 1 { if skia_paragraph.get_line_metrics().is_empty() { paragraph_offset_y = skia_paragraph.ideographic_baseline(); @@ -2742,6 +2831,7 @@ pub fn calculate_text_layout_data( for (i, group_paragraphs) in built_groups.into_iter().enumerate() { // For each paragraph in the group (e.g., fill, stroke, etc.) for skia_paragraph in group_paragraphs.into_iter() { + let x = x + plans[i].x_offset(&text_paragraphs[i], text_width); let decorations = text_paragraphs .get(i) .zip(plans.get(i)) diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 5a23cb647e..057a342d50 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -5,6 +5,7 @@ mod emphasis; mod justify; mod offsets; +mod overflow; mod ruby; mod warichu; @@ -49,13 +50,15 @@ pub(crate) fn uses_japanese_layout(paragraph: &Paragraph, texts: &[String]) -> b .any(|text| text.chars().any(is_japanese_text_char))) } +#[cfg(test)] pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku::OffsetMap) { - layout_span_texts_with_emergency(paragraph, &[]) + layout_span_texts_with_breaks(paragraph, &[], &[]) } -fn layout_span_texts_with_emergency( +fn layout_span_texts_with_breaks( paragraph: &Paragraph, emergency: &[usize], + forced: &[usize], ) -> (Vec, kinsoku::OffsetMap) { let texts: Vec = paragraph .children() @@ -70,11 +73,8 @@ fn layout_span_texts_with_emergency( .iter() .map(|span| span.has_ruby().then(Vec::new)) .collect(); - let shifted = if emergency.is_empty() { - kinsoku::apply_policy_to_span_texts(&texts, &ruby_breaks, policy) - } else { - kinsoku::apply_policy_with_emergency_breaks(&texts, &ruby_breaks, policy, emergency) - }; + let shifted = + kinsoku::apply_policy_with_breaks(&texts, &ruby_breaks, policy, emergency, forced); if let Some((shifted, map)) = shifted { return (shifted, map); } @@ -172,6 +172,9 @@ pub(crate) fn add_horizontal_span( fonts: &skia::textlayout::FontCollection, ) { if span.is_warichu() { + if builder_text.starts_with(kinsoku::FORCED_LINE_BREAK) { + builder.add_text(kinsoku::FORCED_LINE_BREAK.to_string()); + } let text = span.apply_text_transform(); let (first, second) = warichu_text_lines(&text); let mut mini_style = text_style.clone(); @@ -263,6 +266,8 @@ pub(crate) struct HorizontalParagraphPlan { pub(crate) offsets: HorizontalOffsets, pub(crate) sheds: Vec>, pub(crate) ruby_spacing: HorizontalRubySpacing, + emergency_breaks: Vec, + overflow_width: Option, } impl HorizontalParagraphPlan { @@ -377,7 +382,11 @@ impl HorizontalParagraphPlan { } fn with_emergency_breaks(paragraph: &Paragraph, emergency: &[usize]) -> Self { - let (texts, offset_map) = layout_span_texts_with_emergency(paragraph, emergency); + Self::with_breaks(paragraph, emergency, &[]) + } + + fn with_breaks(paragraph: &Paragraph, emergency: &[usize], forced: &[usize]) -> Self { + let (texts, offset_map) = layout_span_texts_with_breaks(paragraph, emergency, forced); let sheds = horizontal_aki_sheds(paragraph, &texts); let ruby_spacing = horizontal_ruby_spacing(paragraph, &texts, &offset_map); let offsets = HorizontalOffsets::from_layout_texts(paragraph, &texts, offset_map); @@ -386,6 +395,8 @@ impl HorizontalParagraphPlan { offsets, sheds, ruby_spacing, + emergency_breaks: emergency.to_vec(), + overflow_width: None, } } } diff --git a/render-wasm/src/shapes/text_horizontal/offsets.rs b/render-wasm/src/shapes/text_horizontal/offsets.rs index 6b06581c31..bd67b7f94e 100644 --- a/render-wasm/src/shapes/text_horizontal/offsets.rs +++ b/render-wasm/src/shapes/text_horizontal/offsets.rs @@ -24,6 +24,7 @@ pub(crate) struct HorizontalOffsets { } impl HorizontalOffsets { + #[cfg(test)] pub(crate) fn new(paragraph: &Paragraph) -> Self { let (span_texts, offset_map) = paragraph.layout_span_texts(); Self::from_layout_texts(paragraph, &span_texts, offset_map) @@ -81,8 +82,15 @@ impl HorizontalOffsets { /// Paragraph source character offset of a builder-text UTF-16 offset. pub(crate) fn builder_to_source(&self, builder_offset: usize) -> usize { - let source_utf16 = self - .ranges + let source_utf16 = self.builder_to_transformed(builder_offset); + let boundaries = &self.boundaries; + boundaries + .partition_point(|boundary| *boundary < source_utf16) + .min(boundaries.len().saturating_sub(1)) + } + + pub(super) fn builder_to_transformed(&self, builder_offset: usize) -> usize { + self.ranges .iter() .find(|range| { builder_offset >= range.builder_start && builder_offset <= range.builder_end @@ -106,11 +114,7 @@ impl HorizontalOffsets { .last() .map(|range| range.source_end) .unwrap_or(0) - }); - let boundaries = &self.boundaries; - boundaries - .partition_point(|boundary| *boundary < source_utf16) - .min(boundaries.len().saturating_sub(1)) + }) } /// Builder-text ranges of the selected source characters @@ -172,11 +176,28 @@ fn span_ranges( let source_start = offset_map.to_original(shifted_start); let source_end = offset_map.to_original(shifted_end); let warichu = span.is_warichu(); + let leading_break = + usize::from(warichu && text.starts_with(kinsoku::FORCED_LINE_BREAK)); + 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() } 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::() }; builder_cursor += if warichu { HORIZONTAL_WARICHU_BUILDER_LEN diff --git a/render-wasm/src/shapes/text_horizontal/overflow.rs b/render-wasm/src/shapes/text_horizontal/overflow.rs new file mode 100644 index 0000000000..5116689067 --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/overflow.rs @@ -0,0 +1,171 @@ +//! Repair SkParagraph's emergency splits without changing the wrap budget. + +use super::*; + +impl HorizontalParagraphPlan { + /// Keep an indivisible unit together even when Skia's last-resort wrapping + /// ignores its joiners. Isolate overflowing units, measure the surrounding + /// text at the original width, and freeze those legal line starts before + /// increasing Skia's physical width. All consumers use this same plan. + pub(crate) fn retain_atomic_overflow( + &mut self, + paragraph: &Paragraph, + width: f32, + mut measure: impl FnMut(&Self, f32) -> skia::textlayout::Paragraph, + ) { + use unicode_segmentation::UnicodeSegmentation; + let policy = paragraph.layout_policy(); + if !(policy.is_chinese() + || matches!( + policy, + TextLayoutPolicy::KoreanWords | TextLayoutPolicy::KoreanSyllable + )) + || !width.is_finite() + || width <= 0.0 + || width == f32::MAX + { + return; + } + let source: String = paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform) + .collect(); + let mut length = 0; + let graphemes: Vec<_> = source + .graphemes(true) + .map(|g| { + let start = length; + length += g.encode_utf16().count(); + (start, g.chars().next().unwrap()) + }) + .collect(); + let mut offset = 0; + let protected: Vec<_> = paragraph + .children() + .iter() + .filter_map(|span| { + let start = offset; + offset += span.apply_text_transform().encode_utf16().count(); + (span.has_ruby() || span.is_warichu()).then_some(start..offset) + }) + .collect(); + let hard_break = |c| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}'); + let mut units = Vec::new(); + let mut start = 0; + for at in 1..graphemes.len() { + let (position, after) = graphemes[at]; + let before = graphemes[at - 1].1; + let annotation = protected + .get(protected.partition_point(|r| r.end <= position)) + .is_some_and(|r| r.start < position); + if hard_break(before) + || hard_break(after) + || (!annotation && policy.emergency_break_allowed(before, after)) + { + if at - start > 1 { + units.push(graphemes[start].0..position); + } + start = at; + } + } + if graphemes.len() - start > 1 { + units.push(graphemes[start].0..length); + } + if units.is_empty() { + return; + } + let bounded = measure(self, width); + let starts: Vec<_> = bounded + .get_line_metrics() + .iter() + .skip(1) + .map(|line| self.offsets.builder_to_transformed(line.start_index)) + .collect(); + units.retain(|unit| { + starts + .get(starts.partition_point(|at| *at <= unit.start)) + .is_some_and(|at| *at < unit.end) + }); + if units.is_empty() { + return; + } + let natural = measure(self, f32::MAX); + let mut physical_width = width; + units.retain(|unit| { + let from = self.offsets.transformed_to_builder(unit.start); + let to = self.offsets.transformed_to_builder(unit.end); + let extent: f32 = natural + .get_rects_for_range(from..to, RectHeightStyle::Tight, RectWidthStyle::Tight) + .iter() + .map(|box_| box_.rect.width()) + .sum(); + if extent <= width + 0.01 { + return false; + } + physical_width = physical_width.max(extent); + true + }); + if units.is_empty() { + return; + } + let can_insert = |at: usize| { + at > 0 + && at < length + && graphemes + .binary_search_by_key(&at, |g| g.0) + .ok() + .is_some_and(|i| !hard_break(graphemes[i].1) && !hard_break(graphemes[i - 1].1)) + }; + let mut forced: Vec<_> = units + .iter() + .flat_map(|unit| [unit.start, unit.end]) + .filter(|at| can_insert(*at)) + .collect(); + forced.sort_unstable(); + forced.dedup(); + let isolated = Self::with_breaks(paragraph, &self.emergency_breaks, &forced); + let bounded = measure(&isolated, width); + physical_width = physical_width.max(bounded.longest_line()); + forced.extend( + bounded + .get_line_metrics() + .iter() + .skip(1) + .map(|line| isolated.offsets.builder_to_transformed(line.start_index)) + .filter(|at| { + can_insert(*at) + && units + .get(units.partition_point(|unit| unit.end <= *at)) + .is_none_or(|unit| *at <= unit.start) + }), + ); + forced.sort_unstable(); + forced.dedup(); + *self = Self::with_breaks(paragraph, &self.emergency_breaks, &forced); + // Avoid a floating-point edge reintroducing the very fallback being + // repaired. The epsilon changes physical width, not the wrap budget. + self.overflow_width = Some(physical_width + 0.01); + } + + pub(crate) fn layout_width(&self, width: f32) -> f32 { + self.overflow_width.unwrap_or(width) + } + + /// Skia aligns against the physical width; translate it back to the box. + pub(crate) fn x_offset(&self, paragraph: &Paragraph, width: f32) -> f32 { + use skia::textlayout::{TextAlign, TextDirection}; + let slack = width - self.layout_width(width); + match paragraph.text_align() { + TextAlign::Center => slack * 0.5, + TextAlign::Right => slack, + TextAlign::Start if paragraph.text_direction() == TextDirection::RTL => slack, + TextAlign::End if paragraph.text_direction() == TextDirection::LTR => slack, + _ => 0.0, + } + } + + pub(crate) fn has_atomic_overflow(&self) -> bool { + self.overflow_width.is_some() + } +} diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index 8dce91944f..847db2b7f9 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -295,7 +295,12 @@ pub(super) fn horizontal_ruby_targets( .iter() .zip(&offsets.ranges) .filter(|(span, range)| span.has_ruby() && range.builder_start < range.builder_end) - .map(|(_, range)| (range.span, range.builder_start..range.builder_end)) + .map(|(_, range)| { + ( + range.span, + offsets.transformed_to_builder(range.source_start)..range.builder_end, + ) + }) .collect() } diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 3d7f77ae85..3124987665 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -33,11 +33,20 @@ fn cjk_horizontal_layout( let mut paragraph = make_paragraph(spans, 0.0); paragraph.set_language(language.to_owned()); paragraph.set_korean_wrap(wrap); - let mut plan = HorizontalParagraphPlan::new(¶graph); + let (plan, layout) = cjk_layout_paragraph(¶graph, width, &fonts); + (paragraph, plan, layout) +} + +fn cjk_layout_paragraph( + paragraph: &Paragraph, + width: f32, + fonts: &skia::textlayout::FontCollection, +) -> (HorizontalParagraphPlan, skia::textlayout::Paragraph) { + let mut plan = HorizontalParagraphPlan::new(paragraph); let lay_out = |plan: &HorizontalParagraphPlan, width: f32| { let mut paragraph_style = paragraph.paragraph_to_style(); paragraph_style.set_apply_rounding_hack(false); - let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); + let mut builder = ParagraphBuilder::new(¶graph_style, fonts); for (index, span) in paragraph.children().iter().enumerate() { let style = span.to_style( &crate::math::Rect::from_xywh(0.0, 0.0, width, 200.0), @@ -51,9 +60,9 @@ fn cjk_horizontal_layout( span, &plan.texts[index], &plan.sheds[index], - &[], + &plan.ruby_spacing.adjustments[index], &style, - &fonts, + fonts, ); builder.pop(); } @@ -61,9 +70,10 @@ fn cjk_horizontal_layout( layout.layout(width); layout }; - plan.relax_overlong_words(¶graph, width, |plan| lay_out(plan, width)); - let layout = lay_out(&plan, width); - (paragraph, plan, layout) + plan.relax_overlong_words(paragraph, width, |plan| lay_out(plan, width)); + plan.retain_atomic_overflow(paragraph, width, lay_out); + let layout = lay_out(&plan, plan.layout_width(width)); + (plan, layout) } #[test] @@ -222,10 +232,9 @@ fn cjk_emergency_wrapping_keeps_safe_boundaries_across_box_widths() { #[test] fn narrower_than_atomic_unit_layout_keeps_source_offsets_and_finite_geometry() { use crate::shapes::text_policy::KoreanWrap; - // These widths cannot fit the punctuation unit. This checks data safety; - // it does not claim SkParagraph retains the policy's forbidden boundaries. + // Overflow preserves both the punctuation rule and editable positions. for language in ["zh-Hant-TW", "ko"] { - for width in [1.0, 10.0, 19.0, 20.0, 30.0, 39.0] { + for width in [1.0, 10.0, 19.0, 20.0, 30.0, 39.0, 39.999] { let (paragraph, plan, layout) = cjk_horizontal_layout(&["文", "。"], language, KoreanWrap::KeepWords, width); let original = HorizontalParagraphPlan::new(¶graph); @@ -234,6 +243,7 @@ fn narrower_than_atomic_unit_layout_keeps_source_offsets_and_finite_geometry() { assert_eq!(plan.offsets.builder_to_source(builder), at); assert_eq!(builder, original.offsets.source_to_builder(at)); } + assert_eq!(layout.get_line_metrics().len(), 1); assert!(layout.height().is_finite() && layout.height() > 0.0); for line in layout.get_line_metrics() { assert!(line.width.is_finite() && line.width >= 0.0); @@ -1603,3 +1613,171 @@ fn horizontal_zhuyin_reserves_its_right_strip_without_changing_line_leading() { assert_eq!(origin.y, 0.0); } } + +#[test] +fn horizontal_indivisible_punctuation_unit_overflows_a_narrow_box() { + use crate::shapes::text_policy::KoreanWrap; + for language in ["zh-Hant-TW", "zh-Hans-CN", "zh-Hant-HK", "ko"] { + let (_, plan, layout) = + cjk_horizontal_layout(&["文", "。"], language, KoreanWrap::KeepWords, 20.0); + let starts: Vec<_> = layout + .get_line_metrics() + .iter() + .map(|line| plan.offsets.builder_to_source(line.start_index)) + .collect(); + assert_eq!( + starts, + vec![0], + "{language}: a closing mark must stay with its base" + ); + assert!( + layout.longest_line() > 20.0, + "the unit overflows rather than splitting" + ); + } +} + +#[test] +fn narrow_atomic_overflow_retains_normal_wrap_budget_and_explicit_newlines() { + use crate::shapes::text_policy::KoreanWrap; + for language in ["zh-Hant-TW", "zh-Hans-CN", "zh-Hant-HK", "ko"] { + for (texts, expected) in [ + (vec!["文文文", "。", "文文文"], vec![0, 1, 2, 4, 5, 6]), + (vec!["文\n", "文", "。", "\n文"], vec![0, 2, 5]), + (vec!["文(", "文)", "文文"], vec![0, 1, 4, 5]), + ] { + let (paragraph, plan, layout) = + cjk_horizontal_layout(&texts, language, KoreanWrap::KeepWords, 20.0); + let starts: Vec<_> = layout + .get_line_metrics() + .iter() + .map(|line| plan.offsets.builder_to_source(line.start_index)) + .collect(); + assert_eq!(starts, expected, "{language}: {texts:?}"); + for at in 0..=paragraph + .children() + .iter() + .map(|s| s.text.chars().count()) + .sum() + { + assert_eq!( + plan.offsets + .builder_to_source(plan.offsets.source_to_builder(at)), + at + ); + } + assert_eq!( + paragraph + .children() + .iter() + .map(|s| s.text.as_str()) + .collect::(), + texts.concat() + ); + } + } +} + +#[test] +fn narrow_atomic_units_preserve_controls_numeric_expressions_and_graphemes() { + use crate::shapes::text_policy::KoreanWrap; + for language in ["zh-Hant-TW", "zh-Hans-CN", "zh-Hant-HK", "ko"] { + for unit in [ + "文。", + "(文)", + "$1.5%", + "1℃", + "文\u{a0}文", + "文\u{2060}文", + "e\u{301}。", + "😀。", + "👩‍👩‍👧‍👦。", + ] { + for width in [1.0, 10.0, 20.0, 29.0, 39.0] { + let source = format!("文文{unit}文文"); + let (_, plan, layout) = + cjk_horizontal_layout(&[&source], language, KoreanWrap::KeepWords, width); + let end = 2 + unit.chars().count(); + for line in layout.get_line_metrics().iter().skip(1) { + let at = plan.offsets.builder_to_source(line.start_index); + assert!( + !(2 < at && at < end), + "{language} {width}px breaks {unit} at {at}" + ); + } + assert!( + layout + .get_line_metrics() + .iter() + .all(|line| line.width > 0.0), + "no synthetic empty lines in {source}" + ); + for at in 0..=source.chars().count() { + assert_eq!( + plan.offsets + .builder_to_source(plan.offsets.source_to_builder(at)), + at, + "{source} at {at}" + ); + } + } + } + } +} + +#[test] +fn narrow_atomic_overflow_keeps_ruby_and_warichu_with_their_closing_marks() { + use crate::shapes::text_vertical::test_support::provider; + init_state(); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_asset_font_manager(Some( + provider(include_bytes!("../../fonts/cjk-locl-test.ttf")).into(), + )); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + for warichu in [false, true] { + let mut annotation = make_span("文文文文", 0.0); + annotation.font_size = 20.0; + annotation.warichu = warichu; + if !warichu { + annotation.ruby = "wén wén".to_string(); + } + let mut paragraph = make_paragraph( + vec![make_span("文文", 0.0), annotation, make_span("。文文", 0.0)], + 0.0, + ); + paragraph.set_language("zh-Hant-TW".to_string()); + let (plan, layout) = cjk_layout_paragraph(¶graph, 20.0, &fonts); + let starts: Vec<_> = layout + .get_line_metrics() + .iter() + .map(|line| plan.offsets.builder_to_source(line.start_index)) + .collect(); + assert_eq!(starts, vec![0, 1, 2, 7, 8], "warichu={warichu}"); + for at in [0, 1, 2, 6, 7, 8, 9] { + assert_eq!( + plan.offsets + .builder_to_source(plan.offsets.source_to_builder(at)), + at, + "warichu={warichu}, {at}" + ); + } + assert!( + horizontal_span_style(&layout, &plan.offsets.ranges[1]).is_some(), + "warichu={warichu}" + ); + if !warichu { + let targets = horizontal_ruby_targets(¶graph, &plan.offsets); + assert_eq!(targets.len(), 1); + let rects = layout.get_rects_for_range( + targets[0].1.clone(), + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ); + assert!(rects + .iter() + .all(|r| (r.rect.top() - rects[0].rect.top()).abs() < 0.01)); + } + } +} diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 2f4f24921f..985446a831 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -28,13 +28,15 @@ impl TextPaths { } let layout_width = self.0.get_width(self.bounds.width()); + let plans = self.0.horizontal_plans(); let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None); let mut paragraphs: Vec = paragraph_builders .iter_mut() .filter_map(|group| group.first_mut()) - .map(|paragraph_builder| { + .enumerate() + .map(|(index, paragraph_builder)| { let mut paragraph = paragraph_builder.build(); - paragraph.layout(layout_width); + paragraph.layout(plans[index].layout_width(layout_width)); paragraph }) .collect(); @@ -44,9 +46,21 @@ impl TextPaths { + vertical_align_offset(self.bounds.height(), total_height, vertical_align); let mut paths = Vec::new(); - for (paragraph, text_paragraph) in paragraphs.iter_mut().zip(self.0.paragraphs()) { - let origin = Point::new(self.bounds.x(), offset_y); - Self::collect_paragraph_paths(paragraph, text_paragraph, origin, &mut paths); + for (index, (paragraph, text_paragraph)) in + paragraphs.iter_mut().zip(self.0.paragraphs()).enumerate() + { + let plan = &plans[index]; + let origin = Point::new( + self.bounds.x() + plan.x_offset(text_paragraph, layout_width), + offset_y, + ); + Self::collect_paragraph_paths( + paragraph, + text_paragraph, + &plan.offsets, + origin, + &mut paths, + ); offset_y += paragraph.height(); } @@ -56,11 +70,11 @@ impl TextPaths { fn collect_paragraph_paths( paragraph: &mut SkiaParagraph, text_paragraph: &Paragraph, + offsets: &crate::shapes::HorizontalOffsets, origin: Point, paths: &mut Vec, ) { - let offsets = crate::shapes::HorizontalOffsets::new(text_paragraph); - for deco in decoration_segments(paragraph, text_paragraph, &offsets, origin.x, origin.y) { + for deco in decoration_segments(paragraph, text_paragraph, offsets, origin.x, origin.y) { let mut builder = skia::PathBuilder::new(); builder.add_rect(deco.rect(), None, None); paths.push(builder.detach());