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