From f99d7d22b86b73dfc0dc63b94f1dd08deff59690 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 22 Oct 2026 12:15:35 +0200 Subject: [PATCH] :sparkles: Add distributed text alignment and preserve fractional line spacing Add distributed alignment to text styles, sidebar controls, CSS export, and the Plugin API. Spread characters across horizontal and vertical lines, including the final line, while preserving graphemes and annotations. Keep fractional line heights consistent across painting, hit testing, selection, and text paths to prevent baseline drift between lines. Add tests for distribution, nonbreaking text, and fractional line geometry. AI-assisted-by: gpt-6 --- common/src/app/common/types/shape.cljc | 2 +- common/test/common_tests/types/text_test.cljc | 7 + .../src/app/main/ui/shapes/text/styles.cljs | 3 + .../workspace/sidebar/options/menus/text.cljs | 6 +- frontend/src/app/plugins/text.cljs | 2 +- .../data/workspace_texts_test.cljs | 8 + .../frontend_tests/plugins/text_test.cljs | 5 + frontend/test/frontend_tests/runner.cljs | 2 +- .../frontend_tests/ui/text_options_test.cljs | 11 +- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + plugins/CHANGELOG.md | 3 + plugins/libs/plugin-types/index.d.ts | 6 +- render-wasm/src/render/text.rs | 32 +- render-wasm/src/render/text_editor.rs | 19 +- render-wasm/src/shapes.rs | 1 + render-wasm/src/shapes/text.rs | 90 +++-- .../src/shapes/text_horizontal/emphasis.rs | 5 +- .../src/shapes/text_horizontal/justify.rs | 48 ++- render-wasm/src/shapes/text_horizontal/mod.rs | 9 +- .../src/shapes/text_horizontal/overflow.rs | 5 +- .../src/shapes/text_horizontal/ruby.rs | 7 +- .../src/shapes/text_horizontal/tests.rs | 158 ++++++++- .../src/shapes/text_horizontal/warichu.rs | 13 +- render-wasm/src/shapes/text_paragraph.rs | 326 ++++++++++++++++++ render-wasm/src/shapes/text_paths.rs | 16 +- render-wasm/src/shapes/text_vertical/flow.rs | 25 +- .../src/shapes/text_vertical/layout.rs | 26 +- render-wasm/src/wasm/text.rs | 2 + render-wasm/src/wasm/text_editor.rs | 1 + 30 files changed, 723 insertions(+), 123 deletions(-) create mode 100644 render-wasm/src/shapes/text_paragraph.rs diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 5279707c7b..6a98d662c6 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -103,7 +103,7 @@ #{:top :bottom :topbottom :center :scale}) (def text-align-types - #{"left" "right" "center" "justify"}) + #{"left" "right" "center" "justify" "distribute"}) (def bool-types #{:union diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index c8f5560242..fb562fc36a 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -18,6 +18,13 @@ (get :content) (cttx/change-text "hello world"))) +(t/deftest distributed-alignment-is-a-persisted-paragraph-style + (t/is (contains? cts/text-align-types "distribute")) + (let [content (assoc-in content-base [:children 0 :children 0 :text-align] "distribute")] + (t/is (ctst/valid-content? content)) + (t/is (= "distribute" + (get-in (cttx/change-text content "ํ•œ๊ธ€") [:children 0 :children 0 :text-align]))))) + ;; Normal happy-path changes (def content-changed-text diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 8cd85738ed..7393644cbc 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -78,6 +78,9 @@ (some? line-height) (obj/set! "lineHeight" line-height) (some? line-height) (obj/set! "--paragraph-line-height" (str line-height)) (some? text-align) (obj/set! "textAlign" text-align) + (= text-align "distribute") (obj/set! "textAlign" "justify") + (= text-align "distribute") (obj/set! "textAlignLast" "justify") + (= text-align "distribute") (obj/set! "textJustify" "inter-character") (some? writing-mode) (obj/set! "writingMode" writing-mode) (some? writing-mode) (obj/set! "textSpacingTrim" "normal") (= writing-mode "vertical-rl") (obj/set! "textAutospace" "normal") diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 7e1398899a..2a2f4aeec4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -84,7 +84,11 @@ {:value "justify" :id "text-align-justify" :label (tr "workspace.options.text-options.text-align-justify") - :icon (if vertical? i/text-justify-vertical i/text-justify)}]) + :icon (if vertical? i/text-justify-vertical i/text-justify)} + {:value "distribute" + :id "text-align-distribute" + :label (tr "workspace.options.text-options.text-align-distribute") + :icon (if vertical? i/distribute-vertical-spacing i/distribute-horizontally)}]) handle-change (mf/use-fn diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 6327dd6a79..593cb862b5 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -31,7 +31,7 @@ (def ^:private text-decoration-re #"underline|line-through|none") (def ^:private text-direction-re #"ltr|rtl") -(def ^:private text-align-re #"left|center|right|justify") +(def ^:private text-align-re #"left|center|right|justify|distribute") (def ^:private vertical-align-re #"top|center|bottom") (defn- font-data diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 8167f4aa48..c1106663eb 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -138,6 +138,14 @@ (t/is (= :auto-height (dwm/next-grow-type :auto-height (gpt/point 1 2) true)))) +(t/deftest distributed-alignment-exports-valid-css + (let [style (text.styles/generate-paragraph-styles nil {:text-align "distribute"}) + normal (text.styles/generate-paragraph-styles nil {:text-align "justify"})] + (t/is (= "justify" (aget style "textAlign"))) + (t/is (= "justify" (aget style "textAlignLast"))) + (t/is (= "inter-character" (aget style "textJustify"))) + (t/is (nil? (aget normal "textAlignLast"))))) + (t/deftest vertical-export-styles-enable-inter-script-spacing (let [vertical (text.styles/generate-paragraph-styles nil diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index 7055429a53..c5f472aa51 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -48,6 +48,11 @@ [value] (apply-range-property "letterSpacing" value)) +(t/deftest text-range-alignment-accepts-distribution + (t/is (= {:text-align "distribute"} (apply-range-property "align" "distribute"))) + (t/is (= {:text-align "justify"} (apply-range-property "align" "justify"))) + (t/is (nil? (apply-range-property "align" "distributed")))) + (t/deftest letter-spacing-accepts-negative-values (t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56"))) (t/is (= {:letter-spacing "-12"} (apply-letter-spacing "-12"))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 2600f87571..a194e2b938 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -119,8 +119,8 @@ [frontend-tests.ui.comment-input-ime-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] - [frontend-tests.ui.dashboard-import-linking-test] [frontend-tests.ui.css-cursors-test] + [frontend-tests.ui.dashboard-import-linking-test] [frontend-tests.ui.drawarea-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.error-boundary-test] diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs index 0baf4d44f2..a4822b9876 100644 --- a/frontend/test/frontend_tests/ui/text_options_test.cljs +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -406,14 +406,15 @@ (t/deftest vertical-paragraph-alignment-shows-top-middle-bottom-and-vertical-justify (let [markup (alignment-markup text-menu/text-align-options* "vertical-rl" true)] - (t/is (= ["text-align-top" "text-align-middle" "text-align-bottom" "text-justify-vertical"] + (t/is (= ["text-align-top" "text-align-middle" "text-align-bottom" "text-justify-vertical" "distribute-vertical-spacing"] (alignment-icons markup))) (t/is (= ["workspace.options.text-options.align-top" "workspace.options.text-options.align-middle" "workspace.options.text-options.align-bottom" - "workspace.options.text-options.text-align-justify"] + "workspace.options.text-options.text-align-justify" + "workspace.options.text-options.text-align-distribute"] (alignment-labels markup))) - (t/is (= ["left" "center" "right" "justify"] + (t/is (= ["left" "center" "right" "justify" "distribute"] (mapv second (re-seq #"value=\"([^\"]+)\"" markup)))))) (t/deftest vertical-column-block-alignment-shows-right-center-and-left @@ -431,14 +432,14 @@ (doseq [writing-mode [nil "horizontal-tb" :multiple]] (let [paragraph (alignment-markup text-menu/text-align-options* writing-mode true) block (alignment-markup text-menu/vertical-align* writing-mode true)] - (t/is (= ["text-align-left" "text-align-center" "text-align-right" "text-justify"] + (t/is (= ["text-align-left" "text-align-center" "text-align-right" "text-justify" "distribute-horizontally"] (alignment-icons paragraph))) (t/is (= ["text-top" "text-middle" "text-bottom"] (alignment-icons block)))))) (t/deftest svg-renderer-keeps-horizontal-alignment-for-stored-vertical-text (let [paragraph (alignment-markup text-menu/text-align-options* "vertical-rl" false) block (alignment-markup text-menu/vertical-align* "vertical-rl" false)] - (t/is (= ["text-align-left" "text-align-center" "text-align-right" "text-justify"] + (t/is (= ["text-align-left" "text-align-center" "text-align-right" "text-justify" "distribute-horizontally"] (alignment-icons paragraph))) (t/is (= ["text-top" "text-middle" "text-bottom"] (alignment-icons block))) (t/is (= "workspace.options.text-options.text-align-left" (first (alignment-labels paragraph)))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index eed458876a..5f5ce84851 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8312,6 +8312,10 @@ msgid "workspace.options.text-options.text-align-center" msgstr "Align center" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:77 +msgid "workspace.options.text-options.text-align-distribute" +msgstr "Distribute characters on every line" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.text-align-justify" msgstr "Justify" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index c5d3cb8f87..b27c910161 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8260,6 +8260,10 @@ msgid "workspace.options.text-options.text-align-center" msgstr "Alinear al centro" #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:77 +msgid "workspace.options.text-options.text-align-distribute" +msgstr "Distribuir caracteres en todas las lรญneas" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs msgid "workspace.options.text-options.text-align-justify" msgstr "Justificar" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index d2a975a323..3315f9edd6 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,9 @@ ### ๐Ÿš€ Features +- **plugin-types:** Text and text range `align` accept `distribute` to spread + characters across every line, including the last line. + - **plugin-types:** Add `rubyZhuyin` to text shapes and ranges for typed Mandarin annotations. - **plugin-types:** Added independent `textEmphasisPosition` and `textUnderlinePosition` properties to text shapes and ranges. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 44c43f4963..95ac3b99df 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4691,7 +4691,8 @@ export interface Text extends ShapeBase { /** * The horizontal alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used. */ - align: 'left' | 'center' | 'right' | 'justify' | 'mixed' | null; + align: + 'left' | 'center' | 'right' | 'justify' | 'distribute' | 'mixed' | null; /** * The vertical alignment of the text shape. @@ -5018,7 +5019,8 @@ export interface TextRange { /** * The horizontal alignment of the text range. It can be a specific alignment or 'mixed' if multiple alignments are used. */ - align: 'left' | 'center' | 'right' | 'justify' | 'mixed' | null; + align: + 'left' | 'center' | 'right' | 'justify' | 'distribute' | 'mixed' | null; /** * The vertical alignment of the text range. It can be a specific alignment or 'mixed' if multiple alignments are used. diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 375de96b5d..9aa4dd84bb 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -1,4 +1,5 @@ use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT}; +use crate::shapes::text_paragraph::LayoutParagraph; use crate::{ error::Result, math::Rect, @@ -170,25 +171,16 @@ pub fn stroke_paragraph_builder_group_from_text( let mut group_layer_opacity: Option = None; let plans = text_content.horizontal_plans(); - let adjustments = plans - .iter() - .any(|plan| plan.has_atomic_overflow()) - .then(|| text_content.horizontal_line_adjustments()) - .flatten(); + let adjustments = text_content.horizontal_line_adjustments(); 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 adjustment = adjustments + .as_ref() + .and_then(|adjustments| adjustments[index].as_ref()); let mut stroke_paragraphs_map: std::collections::HashMap = std::collections::HashMap::new(); @@ -224,9 +216,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 mut paragraph_style = paragraph.paragraph_to_style(); - if plan.has_atomic_overflow() { + if plan.has_atomic_overflow() || adjustment.is_some() { paragraph_style.set_apply_rounding_hack(false); - if paragraph.text_align() == skia::textlayout::TextAlign::Justify { + if matches!( + paragraph.text_align(), + crate::shapes::TextAlign::Justify + | crate::shapes::TextAlign::Distribute + ) { paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); } } @@ -842,7 +838,7 @@ fn paint_text_with_emoji_overlay( } } -fn paragraph_has_emoji(paragraph: &mut skia::textlayout::Paragraph) -> bool { +fn paragraph_has_emoji(paragraph: &mut LayoutParagraph) -> bool { let line_bounds: Vec<(usize, usize)> = paragraph .get_line_metrics() .iter() @@ -854,7 +850,7 @@ fn paragraph_has_emoji(paragraph: &mut skia::textlayout::Paragraph) -> bool { } fn line_emoji_ranges( - paragraph: &mut skia::textlayout::Paragraph, + paragraph: &mut LayoutParagraph, line_start: usize, line_end: usize, ) -> Vec<(usize, usize)> { @@ -1424,7 +1420,7 @@ fn style_metric_at<'a>( /// how Skia grouped the line into style runs; the runs only supply the paint /// and font metrics covering each segment. pub fn decoration_segments( - skia_paragraph: &skia::textlayout::Paragraph, + skia_paragraph: &LayoutParagraph, text_paragraph: &TextParagraph, offsets: &HorizontalOffsets, x: f32, diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 373b55de67..af6d7ca9d7 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,4 +1,5 @@ use crate::render::options::RenderOptions; +use crate::shapes::text_paragraph::LayoutParagraph; use crate::shapes::text_vertical; use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::state::{TextEditorState, TextSelection}; @@ -129,10 +130,7 @@ fn render_selection( canvas.restore(); } -fn paragraphs_vertical_offset( - shape: &Shape, - layout_paragraphs: &[&skia_safe::textlayout::Paragraph], -) -> f32 { +fn paragraphs_vertical_offset(shape: &Shape, layout_paragraphs: &[&LayoutParagraph]) -> f32 { let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); vertical_align_offset( shape.selrect().height(), @@ -258,7 +256,7 @@ pub(crate) fn cursor_rect( #[cfg(test)] fn horizontal_caret_rect( para: &crate::shapes::Paragraph, - laid_out_para: &skia_safe::textlayout::Paragraph, + laid_out_para: &LayoutParagraph, char_pos: usize, ) -> Rect { horizontal_caret_rect_with_offsets( @@ -271,7 +269,7 @@ fn horizontal_caret_rect( fn horizontal_caret_rect_with_offsets( para: &crate::shapes::Paragraph, - laid_out_para: &skia_safe::textlayout::Paragraph, + laid_out_para: &LayoutParagraph, char_pos: usize, offsets: &crate::shapes::HorizontalOffsets, ) -> Rect { @@ -461,8 +459,7 @@ mod tests { }; use crate::uuid::Uuid; use skia_safe::textlayout::{ - FontCollection, Paragraph as SkiaParagraph, ParagraphBuilder, ParagraphStyle, TextStyle, - TypefaceFontProvider, + FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider, }; use skia_safe::FontMgr; @@ -561,7 +558,7 @@ mod tests { ) } - fn laid_out(text: &str) -> SkiaParagraph { + fn laid_out(text: &str) -> LayoutParagraph { let mut provider = TypefaceFontProvider::new(); let typeface = FontMgr::new() .new_from_data( @@ -578,12 +575,12 @@ mod tests { let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); builder.push_style(&style); builder.add_text(text); - let mut paragraph = builder.build(); + let mut paragraph = LayoutParagraph::build(&mut builder); paragraph.layout(1000.0); paragraph } - fn glyph_rect(laid_out: &SkiaParagraph, range: std::ops::Range) -> Rect { + fn glyph_rect(laid_out: &LayoutParagraph, range: std::ops::Range) -> Rect { laid_out.get_rects_for_range(range, RectHeightStyle::Max, RectWidthStyle::Tight)[0].rect } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 197bff610e..d493270e9b 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -32,6 +32,7 @@ mod svgraw; mod text; mod text_horizontal; mod text_japanese; +pub mod text_paragraph; pub mod text_paths; pub mod text_policy; pub mod text_vertical; diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 7627d26cd0..6639c2011b 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,4 +1,5 @@ use crate::render::text::decoration_segments; +use crate::shapes::text_paragraph::LayoutParagraph; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, @@ -241,7 +242,7 @@ impl TextPositionWithAffinity { #[derive(Debug)] pub struct TextContentLayoutResult( Vec, - Vec>, + Vec>, TextContentSize, ); @@ -316,7 +317,7 @@ pub struct TextContentLayout { pub paragraph_builders: Vec, /// Shared across shape clones (e.g. modifier transforms) so rotation/pan /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. - pub paragraphs: Rc>>, + pub paragraphs: Rc>>, cached_extrect: Cell>, /// Last vertical layout, shared across clones like `paragraphs`. vertical: RefCell>, @@ -366,7 +367,7 @@ impl TextContentLayout { pub fn set( &mut self, paragraph_builders: Vec, - paragraphs: Vec>, + paragraphs: Vec>, ) { self.paragraph_builders = paragraph_builders; self.paragraphs = Rc::new(paragraphs); @@ -415,7 +416,7 @@ pub fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalA } } -fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> bool { +fn intersects(paragraph: &LayoutParagraph, x: f32, y: f32) -> bool { if y < 0.0 || y > paragraph.height() { return false; } @@ -430,7 +431,7 @@ 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 { @@ -449,7 +450,7 @@ pub(crate) fn build_paragraphs_from_paragraph_builders( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, plans: &[HorizontalParagraphPlan], -) -> Vec> { +) -> Vec> { let paragraphs = paragraph_builders .iter_mut() .enumerate() @@ -457,7 +458,7 @@ pub(crate) fn build_paragraphs_from_paragraph_builders( builders .iter_mut() .map(|builder| { - let mut paragraph = builder.build(); + let mut paragraph = LayoutParagraph::build(builder); // For auto-width, always layout with infinite width first to get intrinsic width paragraph.layout( plans @@ -481,7 +482,7 @@ pub(crate) fn calculate_normalized_line_height( let mut normalized_line_height = 0.0; 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(); + let mut paragraph = LayoutParagraph::build(paragraph_builder); paragraph.layout( plans .get(index) @@ -680,17 +681,20 @@ impl TextContent { /// True when a horizontal paragraph gets its lines adjusted here rather /// than by Skia: Japanese squeezing or policy-based CJK justification. fn adjusts_lines(&self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> bool { + if paragraph.text_align() == TextAlign::Distribute { + return true; + } let policy = paragraph.layout_policy(); (uses_japanese_layout(paragraph, &plan.texts) && (self.line_adjustment == LineAdjustment::PushInFirst - || paragraph.text_align() == skia::textlayout::TextAlign::Justify)) + || paragraph.text_align() == TextAlign::Justify)) || ((policy.is_chinese() || matches!( policy, super::text_policy::TextLayoutPolicy::KoreanSyllable | super::text_policy::TextLayoutPolicy::KoreanWords )) - && paragraph.text_align() == skia::textlayout::TextAlign::Justify) + && paragraph.text_align() == TextAlign::Justify) } /// Squeeze sheds and justify spacing of each horizontal paragraph at @@ -741,9 +745,11 @@ impl TextContent { width: f32, ) -> HorizontalAdjustment { let lay_out = |extra_sheds: &[Vec<(usize, f32)>]| { - let mut laid_out = self - .horizontal_measure_builder(paragraph, plan, extra_sheds) - .build(); + let mut laid_out = LayoutParagraph::build(&mut self.horizontal_measure_builder( + paragraph, + plan, + extra_sheds, + )); laid_out.layout(plan.layout_width(width)); laid_out }; @@ -757,7 +763,9 @@ impl TextContent { let laid_out = lay_out(&sheds); (sheds, laid_out) }; - let spacing = if paragraph.text_align() == skia::textlayout::TextAlign::Justify { + let spacing = if paragraph.text_align() == TextAlign::Distribute { + horizontal_policy_justify_spacing(paragraph, &laid_out, plan, width) + } else if paragraph.text_align() == TextAlign::Justify { if paragraph.layout_policy() == super::text_policy::TextLayoutPolicy::Japanese { horizontal_justify_spacing(&laid_out, plan, width) } else { @@ -837,17 +845,17 @@ impl TextContent { let mut plan = HorizontalParagraphPlan::new(paragraph); plan.relax_overlong_words(paragraph, width, |plan| { let sheds = vec![Vec::new(); plan.texts.len()]; - let mut measured = self - .horizontal_measure_builder(paragraph, plan, &sheds) - .build(); + let mut measured = LayoutParagraph::build( + &mut self.horizontal_measure_builder(paragraph, plan, &sheds), + ); 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(); + let mut measured = LayoutParagraph::build( + &mut self.horizontal_measure_builder(paragraph, plan, &sheds), + ); measured.layout(width); measured }); @@ -1384,7 +1392,10 @@ impl TextContent { if let Some(align) = align_override { paragraph_style.set_text_align(align); } else if adjustment.is_some() - && paragraph.text_align() == skia::textlayout::TextAlign::Justify + && matches!( + paragraph.text_align(), + TextAlign::Justify | TextAlign::Distribute + ) { // The justify spacing already fills the lines. paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); @@ -1883,7 +1894,7 @@ impl TextContent { builder.push_style(&text_style); builder.add_text("0"); - let mut paragraph_layout = builder.build(); + let mut paragraph_layout = LayoutParagraph::build(&mut builder); paragraph_layout.layout(f32::MAX); let width = paragraph_layout.max_intrinsic_width(); @@ -2009,7 +2020,30 @@ impl Default for TextContent { } } -pub type TextAlign = skia::textlayout::TextAlign; +#[derive(Debug, Default, PartialEq, Clone, Copy)] +pub enum TextAlign { + #[default] + Left, + Center, + Right, + Justify, + Distribute, + Start, + End, +} + +impl From for skia::textlayout::TextAlign { + fn from(value: TextAlign) -> Self { + match value { + TextAlign::Left | TextAlign::Distribute => Self::Left, + TextAlign::Center => Self::Center, + TextAlign::Right => Self::Right, + TextAlign::Justify => Self::Justify, + TextAlign::Start => Self::Start, + TextAlign::End => Self::End, + } + } +} pub type TextDirection = skia::textlayout::TextDirection; pub type TextDecoration = skia::textlayout::TextDecoration; @@ -2270,7 +2304,7 @@ impl Paragraph { let mut style = ParagraphStyle::default(); style.set_height(self.line_height); - style.set_text_align(self.text_align); + style.set_text_align(self.text_align.into()); style.set_text_direction(self.text_direction); style.set_replace_tab_characters(false); style.set_apply_rounding_hack(true); @@ -2808,7 +2842,7 @@ pub struct PositionData { #[derive(Debug)] pub struct ParagraphLayout { - pub paragraph: skia::textlayout::Paragraph, + pub paragraph: LayoutParagraph, pub source_paragraph: usize, pub x: f32, pub y: f32, @@ -2846,14 +2880,14 @@ pub fn calculate_text_layout_data( // 1. Build + layout each paragraph once, recording heights as we go. let mut paragraph_heights: Vec = Vec::new(); - let mut built_groups: Vec> = + let mut built_groups: Vec> = Vec::with_capacity(paragraph_builder_groups.len()); 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); + 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(); + let mut skia_paragraph = LayoutParagraph::build(paragraph_builder); skia_paragraph.layout(plans[index].layout_width(text_width)); if builder_index == group_len - 1 { if skia_paragraph.get_line_metrics().is_empty() { diff --git a/render-wasm/src/shapes/text_horizontal/emphasis.rs b/render-wasm/src/shapes/text_horizontal/emphasis.rs index 46a0c8b862..23840a5f60 100644 --- a/render-wasm/src/shapes/text_horizontal/emphasis.rs +++ b/render-wasm/src/shapes/text_horizontal/emphasis.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shapes::text_paragraph::LayoutParagraph; #[derive(Debug, Clone)] pub(crate) struct HorizontalEmphasisPlacement { @@ -15,7 +16,7 @@ pub(crate) struct HorizontalEmphasisPlacement { pub(crate) fn horizontal_emphasis_placements( paragraph: &Paragraph, offsets: &HorizontalOffsets, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, ) -> Vec { let offset_map = &offsets.offset_map; let mut placements = Vec::new(); @@ -88,7 +89,7 @@ pub(crate) fn paint_horizontal_emphasis( canvas: &skia::Canvas, paragraph: &Paragraph, plan: &HorizontalParagraphPlan, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, x: f32, y: f32, ) { diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs index 95906155a6..a79ec2ab03 100644 --- a/render-wasm/src/shapes/text_horizontal/justify.rs +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -1,3 +1,4 @@ +use crate::shapes::text_paragraph::LayoutParagraph; // Justification of horizontal Japanese text (JLREQ ยง3.8). SkParagraph only // expands around ideographs, which bunches the space next to kanji; this pass // spreads each line's slack evenly between its characters instead. @@ -19,7 +20,7 @@ fn is_word_char(ch: char) -> bool { /// between its characters, skipping gaps inside a Western word and the /// zero-width word joiners; warichu notes take none. pub(crate) fn horizontal_justify_spacing( - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, plan: &HorizontalParagraphPlan, width: f32, ) -> Vec> { @@ -72,18 +73,24 @@ pub(crate) fn horizontal_justify_spacing( spacing } -/// Policy-based, bounded expansion for Chinese and Korean. The final line, -/// annotation interiors, nonbreaking pairs and grapheme interiors stay solid. +/// Policy-based, bounded justification; explicit distribution fills every +/// line without a per-gap cap. Annotation and grapheme interiors stay solid. pub(crate) fn horizontal_policy_justify_spacing( paragraph: &Paragraph, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, plan: &HorizontalParagraphPlan, width: f32, ) -> Vec> { use unicode_segmentation::UnicodeSegmentation; + let distribute = paragraph.text_align() == crate::shapes::TextAlign::Distribute; let policy = paragraph.layout_policy(); let chars = line_chars(paragraph, plan); let source: String = chars.iter().map(|c| c.ch).collect(); + let original: Vec<_> = paragraph + .children() + .iter() + .flat_map(|span| span.text.chars()) + .collect(); let starts: Vec<_> = source .graphemes(true) .scan(0usize, |at, g| { @@ -100,25 +107,47 @@ pub(crate) fn horizontal_policy_justify_spacing( .filter(|range| range.warichu) .collect(); let lines = laid_out.get_line_metrics(); - for line in lines.iter().take(lines.len().saturating_sub(1)) { + let count = if distribute { + lines.len() + } else { + lines.len().saturating_sub(1) + }; + for line in lines.iter().take(count) { let mut remaining = (width - line.width as f32 - JUSTIFY_EPSILON).max(0.0); let mut opportunities = Vec::new(); let start = chars.partition_point(|c| c.utf16 < line.start_index); let end = chars.partition_point(|c| c.utf16 < line.end_excluding_whitespaces); for index in start..end.saturating_sub(1) { let (before, after) = (&chars[index], &chars[index + 1]); + let source_before = plan.offsets.builder_to_source(before.utf16); + let source_after = plan.offsets.builder_to_source(after.utf16); + let nonbreaking = distribute + && original + .get(source_before..=source_after) + .is_some_and(|pair| { + pair.iter() + .any(|ch| matches!(ch, '\u{00a0}' | '\u{202f}' | '\u{2060}')) + }); let note = notes.get(notes.partition_point(|r| r.builder_end <= before.utf16)); if before.utf16 < line.start_index || after.utf16 >= line.end_excluding_whitespaces - || after.joined + || (!distribute && after.joined) + || nonbreaking || starts.binary_search(&(index + 1)).is_err() + || (distribute + && before.span == after.span + && paragraph.children()[before.span].has_ruby()) || note.is_some_and(|r| { r.builder_start > before.utf16 && r.builder_start < after.utf16 }) { continue; } - let (priority, capacity) = policy.expansion_em(before.ch, after.ch); + let (priority, capacity) = if distribute { + (1, width / before.font_size.min(after.font_size).max(0.01)) + } else { + policy.expansion_em(before.ch, after.ch) + }; if priority > 0 { opportunities.push(( index, @@ -144,8 +173,7 @@ pub(crate) fn horizontal_policy_justify_spacing( } /// Lays a paragraph out with extra per-span sheds (char index, amount). -pub(crate) type LayOutWithSheds<'a> = - dyn Fn(&[Vec<(usize, f32)>]) -> skia::textlayout::Paragraph + 'a; +pub(crate) type LayOutWithSheds<'a> = dyn Fn(&[Vec<(usize, f32)>]) -> LayoutParagraph + 'a; /// One real character of a horizontal paragraph in builder order. struct LineChar { @@ -239,7 +267,7 @@ pub(crate) fn horizontal_squeeze_sheds( plan: &HorizontalParagraphPlan, width: f32, lay_out: &LayOutWithSheds, -) -> (Vec>, skia::textlayout::Paragraph) { +) -> (Vec>, LayoutParagraph) { let chars = line_chars(paragraph, plan); let mut sheds: Vec> = vec![Vec::new(); plan.texts.len()]; let mut laid_out = lay_out(&sheds); diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index e74c227e7e..2e993b10d0 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -1,3 +1,4 @@ +use crate::shapes::text_paragraph::LayoutParagraph; // Horizontal Japanese layout on top of SkParagraph: the kinsoku layout // texts, punctuation aki, offset maps between source, layout and builder // text, and the ruby, emphasis and warichu painters. @@ -284,7 +285,7 @@ impl HorizontalParagraphPlan { &mut self, paragraph: &Paragraph, width: f32, - measure: impl FnOnce(&Self) -> skia::textlayout::Paragraph, + measure: impl FnOnce(&Self) -> LayoutParagraph, ) { use unicode_segmentation::UnicodeSegmentation; let policy = paragraph.layout_policy(); @@ -407,17 +408,17 @@ fn mini_paragraph( text: &str, style: &skia::textlayout::TextStyle, fonts: &skia::textlayout::FontCollection, -) -> skia::textlayout::Paragraph { +) -> LayoutParagraph { let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), fonts); builder.push_style(style); builder.add_text(text); - let mut paragraph = builder.build(); + let mut paragraph = LayoutParagraph::build(&mut builder); paragraph.layout(f32::MAX); paragraph } pub(crate) fn horizontal_span_style( - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, range: &HorizontalSpanRange, ) -> Option { // Lines use UTF-16, while their style entries use UTF-8 run starts. diff --git a/render-wasm/src/shapes/text_horizontal/overflow.rs b/render-wasm/src/shapes/text_horizontal/overflow.rs index 5116689067..3f99b7a11a 100644 --- a/render-wasm/src/shapes/text_horizontal/overflow.rs +++ b/render-wasm/src/shapes/text_horizontal/overflow.rs @@ -1,6 +1,7 @@ //! Repair SkParagraph's emergency splits without changing the wrap budget. use super::*; +use crate::shapes::text_paragraph::LayoutParagraph; impl HorizontalParagraphPlan { /// Keep an indivisible unit together even when Skia's last-resort wrapping @@ -11,7 +12,7 @@ impl HorizontalParagraphPlan { &mut self, paragraph: &Paragraph, width: f32, - mut measure: impl FnMut(&Self, f32) -> skia::textlayout::Paragraph, + mut measure: impl FnMut(&Self, f32) -> LayoutParagraph, ) { use unicode_segmentation::UnicodeSegmentation; let policy = paragraph.layout_policy(); @@ -154,7 +155,7 @@ impl HorizontalParagraphPlan { /// 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}; + use crate::shapes::{TextAlign, TextDirection}; let slack = width - self.layout_width(width); match paragraph.text_align() { TextAlign::Center => slack * 0.5, diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index 847db2b7f9..4869c9f3a0 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -1,4 +1,5 @@ use crate::render::FallbackFonts; +use crate::shapes::text_paragraph::LayoutParagraph; use std::collections::HashMap; use super::*; @@ -13,7 +14,7 @@ pub(crate) fn horizontal_zhuyin_origin(span: &TextSpan, rect: skia::Rect) -> ski pub(crate) fn horizontal_zhuyin_entries( paragraph: &Paragraph, offsets: &HorizontalOffsets, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, ) -> Vec { if !paragraph.children().iter().any(TextSpan::is_zhuyin) { return Vec::new(); @@ -307,7 +308,7 @@ pub(super) fn horizontal_ruby_targets( /// Paint of the pass that laid out the base: fill, stroke, shadow or mask. /// `fallback` covers a base Skia kept no style metrics for. pub(super) fn horizontal_ruby_paint( - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, range: &HorizontalSpanRange, fallback: skia::Paint, ) -> skia::Paint { @@ -416,7 +417,7 @@ pub(crate) fn paint_horizontal_ruby( canvas: &Canvas, text_content: &TextContent, paragraph_index: usize, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, x: f32, y: f32, ) { diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 5b51b16438..b5824e8541 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -4,6 +4,7 @@ use super::ruby::{ split_counts_by_extent, RubyGlyphRef, RubyLine, }; use super::*; +use crate::shapes::text_paragraph::LayoutParagraph; use crate::shapes::{TextAlign, TextDirection, TextTransform}; use crate::Uuid; @@ -12,11 +13,7 @@ fn cjk_horizontal_layout( language: &str, wrap: crate::shapes::text_policy::KoreanWrap, width: f32, -) -> ( - Paragraph, - HorizontalParagraphPlan, - skia::textlayout::Paragraph, -) { +) -> (Paragraph, HorizontalParagraphPlan, LayoutParagraph) { use crate::shapes::text_vertical::test_support::{provider, EM}; init_state(); let provider = provider(include_bytes!("../../fonts/cjk-locl-test.ttf")); @@ -41,7 +38,7 @@ fn cjk_layout_paragraph( paragraph: &Paragraph, width: f32, fonts: &skia::textlayout::FontCollection, -) -> (HorizontalParagraphPlan, skia::textlayout::Paragraph) { +) -> (HorizontalParagraphPlan, LayoutParagraph) { let mut plan = HorizontalParagraphPlan::new(paragraph); let lay_out = |plan: &HorizontalParagraphPlan, width: f32| { let mut paragraph_style = paragraph.paragraph_to_style(); @@ -66,7 +63,7 @@ fn cjk_layout_paragraph( ); builder.pop(); } - let mut layout = builder.build(); + let mut layout = LayoutParagraph::build(&mut builder); layout.layout(width); layout }; @@ -281,6 +278,90 @@ fn horizontal_cjk_justification_is_bounded_and_word_mode_only_expands_spaces() { ); } +#[test] +fn distribution_fills_the_last_line_between_hangul_graphemes_across_styles() { + use crate::shapes::text_policy::KoreanWrap; + let (paragraph, plan, layout) = + cjk_horizontal_layout(&["๊ธฐํš", "ํŽธ์ง‘"], "ko", KoreanWrap::KeepWords, 310.0); + let mut paragraph = Paragraph::new( + TextAlign::Distribute, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + paragraph.children().to_vec(), + ); + paragraph.set_language("ko".to_owned()); + paragraph.set_korean_wrap(KoreanWrap::KeepWords); + let extras = + super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 310.0); + let total: f32 = extras.iter().flatten().map(|(_, amount)| amount).sum(); + assert!((layout.longest_line() + total - 310.0).abs() < 0.1); + let gaps: Vec<_> = extras.iter().flatten().map(|(_, amount)| *amount).collect(); + assert_eq!(gaps.len(), 3); + assert!(gaps.windows(2).all(|p| (p[0] - p[1]).abs() < 0.001)); +} + +#[test] +fn distribution_keeps_graphemes_nonbreaking_pairs_and_ruby_bases_solid() { + use crate::shapes::text_policy::KoreanWrap; + init_state(); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_asset_font_manager(Some( + crate::shapes::text_vertical::test_support::provider(include_bytes!( + "../../fonts/cjk-locl-test.ttf" + )) + .into(), + )); + for (texts, expected_gaps) in [ + (vec!["แ„’", "แ…กแ†ซ๊ตญ"], 1), + (vec!["ํ•œ\u{2060}", "๊ธ€"], 0), + (vec!["ํ•œ\u{00a0}๊ธ€"], 0), + (vec!["ํ•œ"], 0), + (vec![""], 0), + ] { + let spans = texts.iter().map(|text| make_span(text, 0.0)).collect(); + let mut paragraph = Paragraph::new( + TextAlign::Distribute, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + spans, + ); + paragraph.set_language("ko".to_owned()); + paragraph.set_korean_wrap(KoreanWrap::KeepWords); + let (plan, layout) = cjk_layout_paragraph(¶graph, 310.0, &fonts); + let extras = + super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 310.0); + assert_eq!( + extras.iter().map(Vec::len).sum::(), + expected_gaps, + "{texts:?}" + ); + } + let mut ruby = make_span("ํ•œ๊ธ€", 0.0); + crate::globals::init_headless(1, 1); + ruby.ruby = "hangeul".to_owned(); + let paragraph = Paragraph::new( + TextAlign::Distribute, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![ruby], + ); + let (plan, layout) = cjk_layout_paragraph(¶graph, 310.0, &fonts); + assert!( + super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 310.0) + .iter() + .all(Vec::is_empty) + ); +} + #[test] fn chinese_proportional_punctuation_is_not_compressed_twice() { init_state(); @@ -387,7 +468,7 @@ fn same_paragraph_wraps_differently_by_profile_in_both_orientations() { &style, &fonts, ); - let mut horizontal = builder.build(); + let mut horizontal = LayoutParagraph::build(&mut builder); horizontal.layout(EM * 5.45); let (_, offset_map) = paragraph.layout_span_texts(); horizontal_ends.push(offset_map.to_original(horizontal.get_line_metrics()[0].end_index)); @@ -465,6 +546,49 @@ fn make_paragraph(spans: Vec, letter_spacing: f32) -> Paragraph { paragraph } +#[test] +fn proportional_line_spacing_preserves_fractional_baselines() { + 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(), + )); + for size in [18.0, 36.0] { + let mut span = make_span("ํ•œ๊ธ€\nํ•œ๊ธ€\nํ•œ๊ธ€", 0.0); + span.font_size = size; + let paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.6, + 0.0, + vec![span], + ); + let mut builder = ParagraphBuilder::new(¶graph.paragraph_to_style(), &fonts); + let style = paragraph.children()[0].to_style( + &crate::math::Rect::from_xywh(0.0, 0.0, 300.0, 300.0), + &crate::render::FallbackFonts::default(), + false, + paragraph.line_height(), + ); + builder.push_style(&style); + builder.add_text(¶graph.children()[0].text); + let mut layout = LayoutParagraph::build(&mut builder); + layout.layout(300.0); + let lines = layout.get_line_metrics(); + assert_eq!(lines.len(), 3); + for pair in lines.windows(2) { + let step = (pair[1].baseline - pair[0].baseline) as f32; + assert!( + (step - size * 1.6).abs() < 0.001, + "{size}px: {step}, lines: {lines:?}" + ); + } + } +} + #[test] fn layout_span_texts_applies_kinsoku() { init_state(); @@ -507,7 +631,7 @@ fn inserted_joiners_take_no_letter_spacing() { let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); builder.push_style(&style); add_horizontal_span(&mut builder, &span, text, &[], &[], &style, &fonts); - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(f32::MAX); laid_out.longest_line() }; @@ -813,7 +937,7 @@ fn horizontal_warichu_builder_emits_one_styled_placeholder() { let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); builder.push_style(&style); add_horizontal_span(&mut builder, &span, &span.text, &[], &[], &style, &fonts); - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(200.0); let placeholders = laid_out.get_rects_for_placeholders(); @@ -846,7 +970,7 @@ fn horizontal_warichu_allows_wrapping_after_the_atomic_box() { builder.push_style(&style); builder.add_text(&following.text); - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(16.1); assert_eq!(laid_out.get_rects_for_placeholders().len(), 1); @@ -992,7 +1116,7 @@ fn horizontal_ruby_paint_follows_the_pass_paint() { let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); builder.push_style(&style); add_horizontal_span(&mut builder, &base, &base.text, &[], &[], &style, &fonts); - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(400.0); let offsets = HorizontalOffsets::new(¶graph); @@ -1110,7 +1234,7 @@ fn horizontal_emphasis_tracks_eligible_unicode_characters() { builder.push_style(&style); add_horizontal_span(&mut builder, span, &text, &[], &[], &style, &fonts); } - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(200.0); let placements = @@ -1140,7 +1264,7 @@ fn horizontal_emphasis_recovers_each_non_ascii_span_style() { builder.push_style(&style); add_horizontal_span(&mut builder, span, &text, &[], &[], &style, &fonts); } - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(200.0); let ranges = horizontal_span_ranges(¶graph); @@ -1485,7 +1609,7 @@ fn horizontal_justify_spreads_every_line_evenly() { &style, &fonts, ); - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(width); laid_out }; @@ -1539,7 +1663,7 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() { let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); builder.push_style(&style); builder.add_text("ใ‚"); - let mut probe = builder.build(); + let mut probe = LayoutParagraph::build(&mut builder); probe.layout(1000.0); probe.max_intrinsic_width() }; @@ -1562,7 +1686,7 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() { &style, &fonts, ); - let mut laid_out = builder.build(); + let mut laid_out = LayoutParagraph::build(&mut builder); laid_out.layout(width); laid_out }; diff --git a/render-wasm/src/shapes/text_horizontal/warichu.rs b/render-wasm/src/shapes/text_horizontal/warichu.rs index 7a4b764e4f..94caded011 100644 --- a/render-wasm/src/shapes/text_horizontal/warichu.rs +++ b/render-wasm/src/shapes/text_horizontal/warichu.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shapes::text_paragraph::LayoutParagraph; pub(super) const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3; pub(super) const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}'; @@ -8,7 +9,7 @@ pub(super) const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}'; /// Tabs in normal spans are placeholders too, so they are skipped. pub(crate) fn horizontal_warichu_placeholders( paragraph: &Paragraph, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, ) -> Vec<(usize, skia::Rect)> { let mut placeholders = laid_out.get_rects_for_placeholders().into_iter(); let mut result = Vec::new(); @@ -28,7 +29,7 @@ pub(crate) fn horizontal_warichu_placeholders( fn horizontal_warichu_boxes<'a>( paragraph: &'a Paragraph, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, ) -> Vec<(&'a TextSpan, usize, usize, skia::Rect)> { let mut source_start = 0usize; let mut placeholders = horizontal_warichu_placeholders(paragraph, laid_out) @@ -47,7 +48,7 @@ fn horizontal_warichu_boxes<'a>( pub(crate) fn horizontal_warichu_hit_test( paragraph: &Paragraph, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, point: Point, ) -> Option { for (span, source_start, _, rect) in horizontal_warichu_boxes(paragraph, laid_out) { @@ -71,7 +72,7 @@ pub(crate) fn horizontal_warichu_hit_test( pub(crate) fn horizontal_warichu_caret_rect( paragraph: &Paragraph, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, source_offset: usize, ) -> Option { for (span, source_start, source_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) { @@ -100,7 +101,7 @@ pub(crate) fn horizontal_warichu_caret_rect( pub(crate) fn horizontal_warichu_range_rects( paragraph: &Paragraph, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, source_start: usize, source_end: usize, ) -> Vec { @@ -155,7 +156,7 @@ pub(crate) fn paint_horizontal_warichu( canvas: &skia::Canvas, paragraph: &Paragraph, plan: &HorizontalParagraphPlan, - laid_out: &skia::textlayout::Paragraph, + laid_out: &LayoutParagraph, x: f32, y: f32, ) { diff --git a/render-wasm/src/shapes/text_paragraph.rs b/render-wasm/src/shapes/text_paragraph.rs new file mode 100644 index 0000000000..256f8c7d5b --- /dev/null +++ b/render-wasm/src/shapes/text_paragraph.rs @@ -0,0 +1,326 @@ +//! SkParagraph rounds every line height and baseline, even with its rounding +//! hack disabled. Keep its shaping and horizontal layout, but use the original +//! ascent/descent for vertical geometry and painting. + +use skia_safe::{ + textlayout::{ + LineMetrics, Paragraph, ParagraphBuilder, PositionWithAffinity, RectHeightStyle, + RectWidthStyle, TextBox, + }, + Canvas, Paint, Point, TextBlob, TextBlobBuilder, +}; +use std::ops::{Deref, DerefMut, Range}; + +#[derive(Debug)] +pub struct LayoutParagraph { + inner: Paragraph, + text: String, + shifts: Vec, + height: f32, + runs: Vec<(TextBlob, Paint, Point)>, +} + +impl LayoutParagraph { + pub fn build(builder: &mut ParagraphBuilder) -> Self { + let text = builder.get_text().to_owned(); + Self { + inner: builder.build(), + text, + shifts: Vec::new(), + height: 0.0, + runs: Vec::new(), + } + } + + pub fn layout(&mut self, width: f32) { + self.inner.layout(width); + let lines = self.inner.get_line_metrics(); + self.shifts.clear(); + self.height = 0.0; + // Keep the first baseline anchored as before. Only remove drift + // between lines; this also preserves existing one-line layouts. + let anchor = lines + .first() + .map_or(0.0, |line| (line.baseline - line.ascent) as f32); + for line in &lines { + self.shifts + .push(self.height + line.ascent as f32 + anchor - line.baseline as f32); + self.height += (line.ascent + line.descent) as f32; + } + if lines.is_empty() { + self.height = self.inner.height(); + } + self.runs.clear(); + if self.shifts.iter().all(|shift| shift.abs() < 0.00001) { + return; + } + // Copy the paints before visiting: Skia exposes style metrics as + // references into the paragraph that visit mutably borrows. + let paints: Vec> = lines + .iter() + .map(|line| { + line.get_style_metrics(0..self.text.len().saturating_add(1)) + .into_iter() + .map(|(at, metric)| (at, metric.text_style.foreground())) + .collect() + }) + .collect(); + drop(lines); + // Retain UTF-8 clusters in the blobs so PDF text stays selectable. + let mut boundaries = vec![self.text.len()]; + self.inner.visit(|_, info| { + if let Some(info) = info { + boundaries.extend(info.utf8_starts().iter().map(|at| *at as usize)); + } + }); + boundaries.sort_unstable(); + boundaries.dedup(); + let text = &self.text; + let shifts = &self.shifts; + let runs = &mut self.runs; + self.inner.visit(|line, info| { + let Some(info) = info.filter(|info| info.count() > 0) else { + return; + }; + let starts = info.utf8_starts(); + let start = *starts.iter().min().unwrap() as usize; + let last = *starts.iter().max().unwrap() as usize; + let end = boundaries + .get(boundaries.partition_point(|at| *at <= last)) + .copied() + .unwrap_or(text.len()); + let paint = paints + .get(line) + .and_then(|paints| paints.iter().rev().find(|(at, _)| *at <= start)) + .map(|(_, paint)| paint.clone()) + .unwrap_or_default(); + let mut builder = TextBlobBuilder::new(); + if let Some(source) = text + .as_bytes() + .get(start..end) + .filter(|source| !source.is_empty()) + { + let (glyphs, points, utf8, clusters) = + builder.alloc_run_text_pos(info.font(), info.count(), source.len(), None); + glyphs.copy_from_slice(info.glyphs()); + points.copy_from_slice(info.positions()); + utf8.copy_from_slice(source); + for (cluster, at) in clusters.iter_mut().zip(starts) { + *cluster = *at - start as u32; + } + } else { + let (glyphs, points) = builder.alloc_run_pos(info.font(), info.count(), None); + glyphs.copy_from_slice(info.glyphs()); + points.copy_from_slice(info.positions()); + } + if let Some(blob) = builder.make() { + let mut origin = info.origin(); + origin.y += shifts.get(line).copied().unwrap_or(0.0); + runs.push((blob, paint, origin)); + } + }); + } + + pub fn line_shift(&self, line: usize) -> f32 { + self.shifts.get(line).copied().unwrap_or(0.0) + } + + pub fn height(&self) -> f32 { + self.height + } + + pub fn ideographic_baseline(&self) -> f32 { + self.inner.ideographic_baseline() + self.line_shift(0) + } + + pub fn get_line_metrics(&self) -> Vec> { + let mut lines = self.inner.get_line_metrics(); + for (index, line) in lines.iter_mut().enumerate() { + line.baseline += self.line_shift(index) as f64; + line.height = line.ascent + line.descent; + } + lines + } + + pub fn get_rects_for_range( + &self, + range: Range, + height: RectHeightStyle, + width: RectWidthStyle, + ) -> Vec { + let mut result = Vec::new(); + for (index, line) in self.inner.get_line_metrics().iter().enumerate() { + let start = range.start.max(line.start_index); + let end = range.end.min(line.end_including_newline); + if start >= end { + continue; + } + let mut boxes = self.inner.get_rects_for_range(start..end, height, width); + for textbox in &mut boxes { + textbox.rect.offset((0.0, self.line_shift(index))); + } + result.extend(boxes); + } + result + } + + pub fn get_rects_for_placeholders(&self) -> Vec { + let lines = self.inner.get_line_metrics(); + let mut boxes = self.inner.get_rects_for_placeholders(); + for textbox in &mut boxes { + let center = textbox.rect.center_y(); + let index = lines + .iter() + .position(|line| center < (line.baseline + line.descent) as f32) + .unwrap_or(lines.len().saturating_sub(1)); + textbox.rect.offset((0.0, self.line_shift(index))); + } + boxes + } + + pub fn get_glyph_position_at_coordinate( + &self, + point: impl Into, + ) -> PositionWithAffinity { + let mut point = point.into(); + let lines = self.get_line_metrics(); + let index = lines + .iter() + .position(|line| point.y < (line.baseline + line.descent) as f32) + .unwrap_or(lines.len().saturating_sub(1)); + point.y -= self.line_shift(index); + self.inner.get_glyph_position_at_coordinate(point) + } + + pub fn paint(&self, canvas: &Canvas, point: impl Into) { + let point = point.into(); + if self.runs.is_empty() { + self.inner.paint(canvas, point); + } else { + for (blob, paint, origin) in &self.runs { + canvas.draw_text_blob(blob, point + *origin, paint); + } + } + } +} + +impl Deref for LayoutParagraph { + type Target = Paragraph; + fn deref(&self) -> &Paragraph { + &self.inner + } +} + +impl DerefMut for LayoutParagraph { + fn deref_mut(&mut self) -> &mut Paragraph { + &mut self.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::text_vertical::test_support::provider; + use skia_safe::textlayout::{FontCollection, ParagraphStyle, TextStyle}; + + fn builder() -> ParagraphBuilder { + crate::globals::design_init(); + let mut fonts = FontCollection::new(); + fonts.set_asset_font_manager(Some( + provider(include_bytes!("../fonts/cjk-locl-test.ttf")).into(), + )); + ParagraphBuilder::new(&ParagraphStyle::default(), fonts) + } + + fn style(color: skia_safe::Color, stroke: bool) -> TextStyle { + let mut paint = Paint::default(); + paint.set_color(color); + if stroke { + paint.set_style(skia_safe::PaintStyle::Stroke); + paint.set_stroke_width(0.5); + } + let mut style = TextStyle::default(); + let family = format!( + "{}", + crate::shapes::FontFamily::new( + crate::Uuid::nil(), + 400, + crate::shapes::FontStyle::Normal + ) + ); + style.set_font_families(&[family]); + style.set_font_size(18.0); + style.set_height(1.6); + style.set_height_override(true); + style.set_half_leading(true); + style.set_foreground_paint(&paint); + style + } + + #[test] + fn fractional_wrapped_paint_matches_individual_lines_with_mixed_fills_and_strokes() { + for stroke in [false, true] { + let colors = [ + skia_safe::Color::RED, + skia_safe::Color::GREEN, + skia_safe::Color::BLUE, + ]; + let mut builder = builder(); + for (index, color) in colors.into_iter().enumerate() { + builder.push_style(&style(color, stroke)); + builder.add_text(if index < 2 { "ํ•œ๊ธ€\n" } else { "ํ•œ๊ธ€" }); + builder.pop(); + } + let mut paragraph = LayoutParagraph::build(&mut builder); + paragraph.layout(100.0); + let mut actual = skia_safe::surfaces::raster_n32_premul((100, 100)).unwrap(); + paragraph.paint(actual.canvas(), (0.0, 0.0)); + let mut expected = skia_safe::surfaces::raster_n32_premul((100, 100)).unwrap(); + for (index, color) in colors.into_iter().enumerate() { + let mut line_builder = self::builder(); + line_builder.push_style(&style(color, stroke)); + line_builder.add_text("ํ•œ๊ธ€"); + let mut line = line_builder.build(); + line.layout(100.0); + line.paint(expected.canvas(), (0.0, index as f32 * 28.8)); + } + let pixels = actual.peek_pixels().unwrap(); + assert!( + pixels.bytes().unwrap().iter().any(|byte| *byte > 0), + "the font paints visible ink" + ); + assert_eq!(pixels.bytes(), expected.peek_pixels().unwrap().bytes()); + } + } + + #[test] + fn fractional_geometry_hit_testing_and_relayout_share_the_same_lines() { + let mut builder = builder(); + builder.push_style(&style(skia_safe::Color::BLACK, false)); + builder.add_text("ํ•œ๊ธ€\nํ•œ๊ธ€\nํ•œ๊ธ€"); + let mut paragraph = LayoutParagraph::build(&mut builder); + for width in [100.0, 50.0, 100.0] { + paragraph.layout(width); + assert!((paragraph.height() - 86.4).abs() < 0.001); + let boxes: Vec<_> = [0, 3, 6] + .into_iter() + .map(|at| { + paragraph.get_rects_for_range( + at..at + 1, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + )[0] + .rect + }) + .collect(); + for pair in boxes.windows(2) { + assert!((pair[1].top - pair[0].top - 28.8).abs() < 0.001); + } + for (index, rect) in boxes.iter().enumerate() { + let hit = + paragraph.get_glyph_position_at_coordinate((rect.left + 0.01, rect.center_y())); + assert_eq!(hit.position as usize, index * 3); + } + } + } +} diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 985446a831..8613c9c651 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -1,11 +1,8 @@ use crate::render::text::decoration_segments; use crate::shapes::text::{vertical_align_offset, Paragraph, TextContent}; +use crate::shapes::text_paragraph::LayoutParagraph as SkiaParagraph; use crate::shapes::VerticalAlign; -use skia_safe::{ - self as skia, - textlayout::{paragraph::VisitorInfo, Paragraph as SkiaParagraph}, - Point, -}; +use skia_safe::{self as skia, textlayout::paragraph::VisitorInfo, Point}; use std::ops::Deref; pub struct TextPaths(TextContent); @@ -35,7 +32,7 @@ impl TextPaths { .filter_map(|group| group.first_mut()) .enumerate() .map(|(index, paragraph_builder)| { - let mut paragraph = paragraph_builder.build(); + let mut paragraph = SkiaParagraph::build(paragraph_builder); paragraph.layout(plans[index].layout_width(layout_width)); paragraph }) @@ -80,13 +77,16 @@ impl TextPaths { paths.push(builder.detach()); } - paragraph.visit(|_: usize, info: Option<&VisitorInfo>| { + let shifts: Vec<_> = (0..paragraph.get_line_metrics().len()) + .map(|line| paragraph.line_shift(line)) + .collect(); + paragraph.visit(|line: usize, info: Option<&VisitorInfo>| { let Some(info) = info else { return; }; let font = info.font(); - let run_origin = origin + info.origin(); + let run_origin = origin + info.origin() + Point::new(0.0, shifts[line]); let mut builder = skia::PathBuilder::new(); let mut has_glyphs = false; diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index ea1a0b2234..db3abf82ec 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -454,7 +454,9 @@ pub(super) fn policy_aligned_tops( for top in &mut tops[start..end] { *top += shift; } - if align == TextAlign::Justify && column < last_column && is_bounded(budget) { + if (align == TextAlign::Distribute || (align == TextAlign::Justify && column < last_column)) + && is_bounded(budget) + { let mut remaining = (budget - used).max(0.0); let mut extras = vec![0.0; end - start]; let mut opportunities = Vec::new(); @@ -468,7 +470,26 @@ pub(super) fn policy_aligned_tops( let (Some(before), Some(after)) = (edges[i].1, edges[i + 1].0) else { continue; }; - let (priority, cap) = policy.expansion_em(before, after); + if align == TextAlign::Distribute + && matches!(before, '\u{00a0}' | '\u{202f}' | '\u{2060}') + || align == TextAlign::Distribute + && matches!(after, '\u{00a0}' | '\u{202f}' | '\u{2060}') + { + continue; + } + let (priority, cap) = if align == TextAlign::Distribute { + ( + 1, + budget + / cells[i] + .cell + .font_size + .min(cells[i + 1].cell.font_size) + .max(0.01), + ) + } else { + policy.expansion_em(before, after) + }; let em = cells[i].cell.font_size.min(cells[i + 1].cell.font_size); if priority > 0 { opportunities.push((i, priority, cap * em)); diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 3409ed7e3e..26cb87274e 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -647,7 +647,7 @@ impl PlannedParagraph { } else { align }; - let tops = if self.policy == TextLayoutPolicy::Japanese { + let tops = if self.policy == TextLayoutPolicy::Japanese && align != TextAlign::Distribute { aligned_tops( &self.flow, &self.classes, @@ -852,6 +852,30 @@ mod tests { ) } + #[test] + fn distributed_hangul_fills_the_final_column_across_styles() { + use crate::shapes::text_policy::KoreanWrap; + let layout = cjk_layout( + &["๊ธฐํš", "ํŽธ์ง‘"], + "ko", + KoreanWrap::KeepWords, + TextAlign::Distribute, + 310.0, + ); + assert_eq!(layout.cells.len(), 4); + assert!(layout.cells.iter().all(|cell| cell.column == 0)); + let last = layout.cells.last().unwrap(); + assert!((last.top + last.extent - 310.0).abs() < 0.01); + let steps: Vec<_> = layout + .cells + .windows(2) + .map(|pair| pair[1].top - pair[0].top) + .collect(); + assert!(steps + .windows(2) + .all(|pair| (pair[1] - pair[0]).abs() < 0.01)); + } + #[test] fn boxes_shorter_than_atomic_units_keep_closing_punctuation_with_its_base() { use crate::shapes::text_policy::KoreanWrap; diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 982819a17e..9a90c3866a 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -28,6 +28,7 @@ pub enum RawTextAlign { Center = 1, Right = 2, Justify = 3, + Distribute = 4, } impl From for TextAlign { @@ -37,6 +38,7 @@ impl From for TextAlign { RawTextAlign::Center => TextAlign::Center, RawTextAlign::Right => TextAlign::Right, RawTextAlign::Justify => TextAlign::Justify, + RawTextAlign::Distribute => TextAlign::Distribute, } } } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index d4c4345a2d..4bac093051 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -784,6 +784,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { TextAlign::Right => RawTextAlign::Right as u32, TextAlign::End => RawTextAlign::Right as u32, TextAlign::Justify => RawTextAlign::Justify as u32, + TextAlign::Distribute => RawTextAlign::Distribute as u32, }) .unwrap_or(0);