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());