From c6ce618babcb21d8ffc504e6da37811ac22ac501 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 1 Oct 2026 13:45:05 +0200 Subject: [PATCH] Feature improvements --- common/src/app/common/types/shape/text.cljc | 4 +- .../common/types/text/japanese_layout.cljc | 17 +- .../src/app/main/ui/shapes/text/svg_text.cljs | 185 ++----- frontend/src/app/render_wasm/api.cljs | 39 +- .../test/frontend_tests/copy_as_svg_test.cljs | 109 ++--- render-wasm/src/render.rs | 455 +++++++++--------- render-wasm/src/render/text.rs | 193 ++++---- render-wasm/src/render/text_editor.rs | 18 +- render-wasm/src/render/vector.rs | 73 +-- render-wasm/src/shapes/text.rs | 99 ++-- render-wasm/src/shapes/text_japanese.rs | 320 ++++++++---- .../src/shapes/text_vertical/annotations.rs | 63 ++- .../src/shapes/text_vertical/layout.rs | 9 +- render-wasm/src/shapes/text_vertical/mod.rs | 7 +- render-wasm/src/shapes/text_vertical/paint.rs | 14 +- .../src/shapes/text_vertical/positions.rs | 192 +++++++- 16 files changed, 1012 insertions(+), 785 deletions(-) diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index 2244380c51..238fb1d7d3 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -89,7 +89,7 @@ [:writing-mode {:optional true} ::sm/text] [:text-orientation {:optional true} ::sm/text] [:text-combine-upright {:optional true} ::sm/text] - [:text-emphasis {:optional true} ::sm/text] + [:emphasis-mark {:optional true} :boolean] [:ruby {:optional true} :string] [:ruby-size {:optional true} ::sm/text] [:ruby-align {:optional true} ::sm/text] @@ -97,8 +97,6 @@ [:ruby-side {:optional true} ::sm/text] [:warichu {:optional true} ::sm/text] [:font-features {:optional true} ::sm/text] - [:annotation-clearance {:optional true} ::sm/text] - [:annotation-has-ruby {:optional true} :boolean] [:text {:optional true} :string] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text]]]) diff --git a/common/src/app/common/types/text/japanese_layout.cljc b/common/src/app/common/types/text/japanese_layout.cljc index 5945ed6c48..75877fd4cc 100644 --- a/common/src/app/common/types/text/japanese_layout.cljc +++ b/common/src/app/common/types/text/japanese_layout.cljc @@ -65,8 +65,21 @@ :font-features "none" :annotation-clearance "none"}) -;; Annotation font sizes relative to the base font size. -(def emphasis-font-scale 0.5) +;; Glyph of each emphasis style, per CSS `text-emphasis-style`. +(def ^:private emphasis-mark-chars + {"filled-dot" "•" + "open-dot" "◦" + "filled-circle" "●" + "open-circle" "○" + "filled-sesame" "﹅" + "open-sesame" "﹆"}) + +(defn emphasis-mark-char + "Mark glyph of a text-emphasis value, or nil for none." + [text-emphasis] + (get emphasis-mark-chars text-emphasis)) + +;; Warichu sub-line font size relative to the base font size. (def warichu-font-scale 0.5) (defn ruby-font-scale diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index f0907d85f1..bf37749088 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -22,71 +22,6 @@ (def fill-attrs [:fill-color :fill-color-gradient :fill-opacity]) -;; Kinsoku classes for the warichu sub-line split (same characters the -;; renderer's kinsoku module suppresses at line boundaries). -(def ^:private warichu-forbidden-at-start - (str "、。,.)」』]】〕〉》’”!?;:ー" - "ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ" - "々ゝゞヽヾ・")) - -(def ^:private warichu-forbidden-at-end "(「『[【〔〈《‘“") - -(defn- warichu-split-index - "Safe JavaScript string index where a warichu run splits into its two - sub-lines. The split is chosen in Unicode code-point space, then translated - back to a UTF-16 boundary for `subs`: the - balanced midpoint (first sub-line longer), nudged forward then backward - so the second sub-line does not start with a line-start-prohibited - character and the first does not end with a line-end-prohibited one. - Mirrors the renderer's `warichu_split_chars`." - [text] - (let [characters (vec (js/Array.from text)) - n (count characters) - mid (js/Math.ceil (/ n 2)) - valid? (fn [split] - (and (>= split 1) - (< split n) - (not (.includes warichu-forbidden-at-start (nth characters split))) - (not (.includes warichu-forbidden-at-end (nth characters (dec split)))))) - split (if (valid? mid) - mid - (or (->> (range 1 n) - (some (fn [distance] - (cond - (valid? (+ mid distance)) (+ mid distance) - (and (> mid distance) (valid? (- mid distance))) (- mid distance))))) - mid))] - (->> (take split characters) - (reduce (fn [index character] (+ index (.-length character))) 0)))) - -;; CSS `text-emphasis-style` character mapping (same glyphs the canvas -;; renderer shapes for each mark style). -(def ^:private emphasis-mark-chars - {"filled-dot" "•" - "open-dot" "◦" - "filled-circle" "●" - "open-circle" "○" - "filled-sesame" "﹅" - "open-sesame" "﹆"}) - -(def ^:private emphasis-prohibited-chars - "、。,.「」『』()[]【】〔〕〈〉《》‘’“”") - -(defn- emphasis-character? - [character] - (and (not (re-matches #"\s" character)) - (not (.includes emphasis-prohibited-chars character)))) - -(defn- emphasis-marks-text - "One mark per eligible Unicode base character. Spaces replace whitespace - and Japanese punctuation that does not normally carry emphasis so marks - stay aligned with the base slots." - [text mark] - (->> (js/Array.from text) - (map (fn [character] - (if (emphasis-character? character) mark " "))) - (apply str))) - (defn- px "CSS pixel size, or nil for a non-positive size." [size] @@ -157,36 +92,25 @@ (some? browser-props) (obj/merge! browser-props))))) -(defn- warichu-lines - "Warichu: two half-size sub-lines within one inline strip. Vertical - reading order is right then left; horizontal is top then bottom." - [shape data index fill] - (let [vertical? (= "vertical-rl" (:writing-mode data)) - font-size (* (js/parseFloat (:font-size data)) jl/warichu-font-scale) - font-size (if (js/isNaN font-size) 0 font-size) - style (annotation-style data font-size vertical? (when vertical? "upright") fill) - text (:text data) - split-index (warichu-split-index text) - lines [(subs text 0 split-index) (subs text split-index)] - top (- (:y data) (:height data)) - centre (+ (:x data) (/ (:width data) 2)) - quarter (/ font-size 2)] - (mf/html - [:g {:key (dm/str "warichu-" (:id shape) "-" index)} - (if vertical? - [:* - [:> :text {:x (+ centre quarter) :y top :style style} (first lines)] - [:> :text {:x (- centre quarter) :y top :style style} (second lines)]] - [:* - (for [[line-index line] (d/enumerate lines)] - [:> :text {:key line-index - :x (:x data) - :y (+ top (* line-index (/ (:height data) 2))) - :dominantBaseline "hanging" - :textLength (:width data) - :lengthAdjust "spacingAndGlyphs" - :style style} - line])])]))) +(defn- warichu-line-props + "Props of the `text` element drawing one warichu sub-line at half size in + its own strip: the renderer splits the span and places each sub-line." + [data fill] + (let [vertical? (= "vertical-rl" (:writing-mode data)) + font-size (* (js/parseFloat (:font-size data)) jl/warichu-font-scale) + font-size (if (js/isNaN font-size) 0 font-size) + style (annotation-style data font-size vertical? (when vertical? "upright") fill) + top (- (:y data) (:height data))] + (if vertical? + #js {:x (+ (:x data) (/ (:width data) 2)) + :y top + :style style} + #js {:x (:x data) + :y top + :dominantBaseline "hanging" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style style}))) (defn- ruby-text-props "Props of the `text` element drawing a strip's ruby annotation beside it @@ -230,25 +154,16 @@ (-> (obj/set! "textLength" (:width data)) (obj/set! "lengthAdjust" "spacing"))))))) -(defn- emphasis-text-props - "Props of the `text` element drawing a strip's emphasis marks: right of - vertical text or above horizontal text, outside a stacked ruby layer." - [shape data index font-size ruby-offset style] - (let [key (dm/str "emphasis-" (:id shape) "-" index)] - (if (= "vertical-rl" (:writing-mode data)) - #js {:key key - :x (+ (:x data) (:width data) (/ font-size 2) ruby-offset) - :y (- (:y data) (:height data)) - :textLength (:height data) - :lengthAdjust "spacing" - :style style} - #js {:key key - :x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data)) - :y (- (:y data) (:height data) ruby-offset) - :dominantBaseline "text-after-edge" - :textLength (:width data) - :lengthAdjust "spacing" - :style style}))) +(defn- emphasis-mark-props + "Props of the `text` element drawing one emphasis mark centred in its em + box, as placed by the renderer." + [data fill] + (let [size (:width data)] + #js {:x (+ (:x data) (/ size 2)) + :y (- (:y data) (/ (:height data) 2)) + :textAnchor "middle" + :dominantBaseline "central" + :style (annotation-style data size false nil fill)})) (defn set-white-fill [shape] @@ -303,10 +218,10 @@ (for [[index data] (d/enumerate position-data)] (let [vertical? (= "vertical-rl" (:writing-mode data)) ruby (jl/visible-ruby data) - emphasis-mark (get emphasis-mark-chars (:text-emphasis data)) font-size (js/parseFloat (:font-size data)) - scaled-size (fn [scale] (if (js/isNaN font-size) 0 (* font-size scale))) - ruby-font-size (scaled-size (jl/ruby-font-scale (:ruby-size data))) + ruby-font-size (if (js/isNaN font-size) + 0 + (* font-size (jl/ruby-font-scale (:ruby-size data)))) ;; Annotations take the fills of this strip's own render id. annotation-fill (str "url(#fill-" index "-" render-id "-" index ")") @@ -333,26 +248,16 @@ [:defs [:& fills/fills {:shape shape :render-id render-id}]] - [:& shape-custom-strokes {:shape shape :position index :render-id render-id} - (if (jl/warichu-text? data) - (warichu-lines shape data index annotation-fill) - [:> :text text-props (:text data)])] - (when (some? ruby) - [:> :text (ruby-text-props shape data index ruby-font-size - (annotation-style data ruby-font-size vertical? - "upright" annotation-fill)) - ruby]) - ;; Emphasis marks (圏点): one half-size mark per eligible base - ;; character. - (when (some? emphasis-mark) - (let [ruby-layer? (and (= "auto" (:annotation-clearance data)) - (not= "under" (:ruby-side data "over")) - (or (some? ruby) (:annotation-has-ruby data))) - mark-size (scaled-size jl/emphasis-font-scale) - style (annotation-style data mark-size vertical? - (when vertical? "upright") - annotation-fill)] - [:> :text (emphasis-text-props shape data index mark-size - (if ruby-layer? ruby-font-size 0) - style) - (emphasis-marks-text (:text data) emphasis-mark)]))]))]])) + (if (:emphasis-mark data) + ;; Emphasis marks (圏点) are not stroked. + [:> :text (emphasis-mark-props data annotation-fill) (:text data)] + [:* + [:& shape-custom-strokes {:shape shape :position index :render-id render-id} + (if (= "warichu" (:warichu data)) + [:> :text (warichu-line-props data annotation-fill) (:text data)] + [:> :text text-props (:text data)])] + (when (some? ruby) + [:> :text (ruby-text-props shape data index ruby-font-size + (annotation-style data ruby-font-size vertical? + "upright" annotation-fill)) + ruby])])]))]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cd231588b3..6f775661aa 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2934,6 +2934,29 @@ (def ^:private POSITION-DATA-VERTICAL 2) (def ^:private POSITION-DATA-RUBY 3) +(def ^:private POSITION-DATA-EMPHASIS-MARK 4) + +(defn- emphasis-mark-entry + "Position-data entry for one emphasis mark (圏点): the geometry is the + mark's em box, centred where the canvas paints it." + [element {:keys [x y width height]}] + (when-let [mark (jl/emphasis-mark-char (get element :text-emphasis))] + (d/patch-object + (txt/get-default-text-attrs) + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction "ltr" + :emphasis-mark true + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (dm/str width "px") + :font-weight (get element :font-weight) + :font-style (get element :font-style) + :fills (get element :fills) + :text mark})))) (defn- ruby-strip-entry "Position-data entry for a ruby annotation strip: the offsets index the @@ -2997,14 +3020,11 @@ :text-transform (get element :text-transform) :text-decoration (get element :text-decoration) :text-combine-upright (get element :text-combine-upright) - ;; Emphasis marks (圏点) and warichu are drawn by the static SVG - ;; renderer; "none" carries no information. - :text-emphasis (let [emphasis (get element :text-emphasis)] - (when (and (seq emphasis) (not= "none" emphasis)) - emphasis)) + :font-features (let [features (get element :font-features)] + (when (and (seq features) (not= "none" features)) + features)) + ;; Each warichu sub-line has its own entry, drawn at half size. :warichu (when (= "warichu" (get element :warichu)) "warichu") - :annotation-clearance (when (= "auto" (get element :annotation-clearance)) "auto") - :annotation-has-ruby (when (some? ruby) true) ;; Horizontal position data has no separate ruby strip, so the base ;; entry carries the annotation for static SVG export. :ruby (when (and (not vertical?) (seq element-text)) ruby) @@ -3049,8 +3069,9 @@ (get 0) :children ;; paragraph-set (get paragraph)) element (-> paragraph-node :children (get span))] - (if (= direction POSITION-DATA-RUBY) - (ruby-strip-entry element entry) + (condp = direction + POSITION-DATA-RUBY (ruby-strip-entry element entry) + POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry) (text-strip-entry paragraph-node element entry))))) result)))) diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs index 4f8dd85099..9d18eb74c7 100644 --- a/frontend/test/frontend_tests/copy_as_svg_test.cljs +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -45,12 +45,12 @@ :font-weight "400"}) (defn- setup-text - "A text shape whose single position-data strip is `strip` (over + "A text shape with the given position-data `strips` (over `strip-defaults`), with its objects and selection." - [{:keys [width height]} strip] + [{:keys [width height]} & strips] (let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height}) (assoc :name "Text" - :position-data [(merge strip-defaults strip)])) + :position-data (mapv #(merge strip-defaults %) strips))) file (cths/add-sample-shape (cthf/sample-file :file1 :page-label :page1) :text @@ -72,10 +72,10 @@ :writing-mode "horizontal-tb"} attrs)) -(defn- setup-vertical-warichu-text - [text] - (setup-text {:width 60 :height 100} - (vertical-strip {:height 40 :text text :warichu "warichu"}))) +(defn- emphasis-mark + "A mark entry as the renderer emits it: the mark glyph in its em box." + [x y] + {:x x :y y :width 10 :height 10 :font-size "10px" :emphasis-mark true :text "•"}) (deftest empty-selection-yields-empty-string (is (= "" (svg/generate-markup {} [])))) @@ -111,87 +111,48 @@ (is (re-find #"font-feature-settings:"vpal"" markup)) (is (not (re-find #"•<" markup)) "one text element per mark") + (is (re-find #"x=\"49\"" markup) "marks centre on their box") + (is (re-find #"text-anchor=\"middle\"" markup)) (is (re-find #"font-size:10px" markup)) + (is (re-find #"fill:url\(#fill-1-[^)]+-1\)" markup) + "a mark references its generated per-strip fill") (is (not (re-find #"]*>割注<" markup) "first sub-line on the right half") + (is (re-find #"x=\"25\"[^>]*>入り<" markup) "second sub-line on the left half") + (is (= 2 (count-matches #"font-size:10px" markup))) (is (not (re-find #" 0).then(|| 1.0 / self.get_scale()); - let text_stroke_blur_outset = - Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = - text_content.paragraph_builder_group_from_text(None); - let stroke_kinds: Vec = - shape.visible_strokes().rev().map(|s| s.kind).collect(); - let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - None, - ) - }) - .unzip(); if text_content.is_vertical() { text::render_vertical_text( self, &shape, text_content, - &mut paragraph_builders, fills_surface_id, strokes_surface_id, skip_effects, )?; - } else if skip_effects { - // Fast path: render fills and strokes only (skip shadows/blur). - text::render( - Some(self), - None, - &shape, - &mut paragraph_builders, - Some(fills_surface_id), - None, - None, - text_fill_inset, - None, - )?; - - for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list - .iter_mut() - .zip(stroke_opacities.iter()) - .enumerate() - { - if stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_inner_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - &mut fill_builders, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else if stroke_kinds[i] == StrokeKind::Outer { - text::render_outer_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; - } - } - - if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); - let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_emoji_overlay( - self, - &shape, - &mut emoji_builders, - &mut deco_builders, - strokes_surface_id, - None, - ); - } } else { - let shape_scale = self.get_scale(); - let mut drop_shadows = if skip_drop_shadows { - Vec::new() - } else { + let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = + (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); + let text_stroke_blur_outset = + Stroke::max_bounds_width(shape.visible_strokes(), false); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec = + shape.visible_strokes().rev().map(|s| s.kind).collect(); + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .drop_shadows_visible() - .filter(|s| s.is_perceptible_at_scale(shape_scale)) - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - paint.set_image_filter(shadow.get_drop_shadow_filter()); - paint - }) - .collect() - }; - - if !skip_drop_shadows { - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); - let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( - Vec<_>, - Vec<_>, - ) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - Some(true), - ) - }) - .unzip(); - - if let Some(parent_shadows) = parent_shadows { - if !skip_drop_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( text_content, - )?; - } - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), + stroke, + &shape.selrect(), None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } - - // 2. Text fills + ) + }) + .unzip(); + if skip_effects { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -1952,25 +1792,11 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, text_fill_inset, None, )?; - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - - // 4. Stroke fills for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) @@ -1986,7 +1812,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1997,7 +1823,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -2009,7 +1835,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -2028,37 +1854,216 @@ impl RenderState { &mut emoji_builders, &mut deco_builders, strokes_surface_id, - blur_filter.as_ref(), + None, ); } + } else { + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { - text::render( - Some(self), - None, + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + let mut paragraphs_with_shadows = + text_content.paragraph_builder_group_from_text(Some(true)); + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip(); + + if let Some(parent_shadows) = parent_shadows { + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + } + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + text_fill_inset, + None, + )?; + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 4. Stroke fills + for (i, (stroke_paragraphs, layer_opacity)) in + stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + .enumerate() + { + if stroke_kinds[i] == StrokeKind::Inner { + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else if stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } + } + + if shape.has_visible_strokes() && text_content.has_non_ascii() { + let mut emoji_builders = + text_content.paragraph_builder_group_opaque(); + let mut deco_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_emoji_overlay( + self, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + &mut emoji_builders, + &mut deco_builders, + strokes_surface_id, blur_filter.as_ref(), - None, - None, - )?; + ); + } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } } } } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 97c2f0ec4b..d93c5492f2 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -16,66 +16,57 @@ use skia_safe::{ Canvas, ImageFilter, Paint, }; +/// Vertical text: shadows, fill, strokes and the debug grid, all painted from +/// one layout. Like every vertical pass, the content is rebound to the +/// selrect: stored text bounds describe the measured content and can be +/// taller than a fixed shape, whose height is the column-wrap budget. pub fn render_vertical_text( state: &mut RenderState, shape: &Shape, text_content: &TextContent, - paragraph_builders: &mut [ParagraphBuilderGroup], fills_surface_id: SurfaceId, strokes_surface_id: SurfaceId, skip_effects: bool, ) -> Result<()> { let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten(); + let text_content = text_content.new_bounds(shape.selrect()); let bounds = text_content.bounds(); + let vertical_align = shape.vertical_align(); + let layout = text_vertical::layout_for_box(&text_content, bounds.height()); + let skip_shadows = skip_effects || state.should_skip_drop_shadows(); - let mut drop_shadows = if skip_shadows { - Vec::new() - } else { - shape.drop_shadow_paints() - }; if !skip_shadows { + let mut drop_shadows = shape.drop_shadow_paints(); if let Some(inherited_shadows) = state.get_inherited_drop_shadows() { drop_shadows.extend(inherited_shadows); } - } - let strokes: Vec = shape.visible_strokes().rev().cloned().collect(); - let layout = (!drop_shadows.is_empty() || !strokes.is_empty()) - .then(|| text_vertical::layout_for_box(text_content, bounds.height())); - - if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) { - let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); - for shadow in &drop_shadows { - text_vertical::paint_drop_shadow( - canvas, - layout, - &bounds, - shape.vertical_align(), - shadow, - ); + if !drop_shadows.is_empty() { + let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); + for shadow in &drop_shadows { + text_vertical::paint_drop_shadow(canvas, &layout, &bounds, vertical_align, shadow); + } } } - render( - Some(state), - None, + render_to_surface( + state, shape, - paragraph_builders, - Some(fills_surface_id), - None, + fills_surface_id, blur_filter.as_ref(), - None, - None, + 0.0, + |canvas| paint_vertical_fill(canvas, shape, &layout, &bounds, blur_filter.as_ref()), )?; - if let Some(layout) = layout.as_ref().filter(|_| !strokes.is_empty()) { + let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect(); + if !strokes.is_empty() { let selrect = shape.selrect(); let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id); - for stroke in &strokes { + for stroke in strokes { text_vertical::paint_stroke( canvas, - layout, + &layout, &bounds, - shape.vertical_align(), + vertical_align, stroke, &selrect, blur_filter.as_ref(), @@ -84,16 +75,8 @@ pub fn render_vertical_text( } if state.options.is_text_grid_visible() { - let owned_layout; - let grid_layout = match layout.as_ref() { - Some(layout) => layout, - None => { - owned_layout = text_vertical::layout_for_box(text_content, bounds.height()); - &owned_layout - } - }; let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); - text_vertical::paint_grid(canvas, grid_layout, &bounds, shape.vertical_align()); + text_vertical::paint_grid(canvas, &layout, &bounds, vertical_align); } Ok(()) @@ -327,6 +310,70 @@ pub fn render_with_bounds_outset_overlay_emoji( ) } +/// Run `paint` on `target_surface`. With a blur, it paints into a filter +/// surface sized to the blurred text bounds (grown by `stroke_bounds_outset`) +/// when those bounds are usable. +fn render_to_surface( + render_state: &mut RenderState, + shape: &Shape, + target_surface: SurfaceId, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + mut paint: impl FnMut(&Canvas), +) -> Result<()> { + if let Some(blur_filter) = blur { + let mut text_bounds = shape + .get_text_content() + .calculate_bounds(shape, false) + .to_rect(); + if stroke_bounds_outset > 0.0 { + text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); + } + let bounds = blur_filter.compute_fast_bounds(text_bounds); + if bounds.is_finite() + && bounds.width() > 0.0 + && bounds.height() > 0.0 + && filters::render_with_filter_surface( + render_state, + bounds, + target_surface, + |state, temp_surface| { + paint(state.surfaces.canvas(temp_surface)); + Ok(()) + }, + )? + { + return Ok(()); + } + } + paint(render_state.surfaces.canvas_and_mark_dirty(target_surface)); + Ok(()) +} + +/// Fill pass of vertical text: `layout` painted inside the shape's layer +/// blur. +pub fn paint_vertical_fill( + canvas: &Canvas, + shape: &Shape, + layout: &text_vertical::VerticalLayout, + bounds: &Rect, + blur: Option<&ImageFilter>, +) { + if let Some(blur_filter) = blur { + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&shape.layer_bounds()) + .paint(&blur_paint), + ); + } + text_vertical::paint_layout(canvas, layout, bounds, shape.vertical_align()); + if blur.is_some() { + canvas.restore(); + } +} + #[allow(clippy::too_many_arguments)] fn render_with_bounds_outset_inner( render_state: Option<&mut RenderState>, @@ -342,55 +389,25 @@ fn render_with_bounds_outset_inner( overlay_emoji: bool, ) -> Result<()> { if let Some(render_state) = render_state { - let target_surface = surface_id.unwrap_or(SurfaceId::Fills); - - if let Some(blur_filter) = blur { - let mut text_bounds = shape - .get_text_content() - .calculate_bounds(shape, false) - .to_rect(); - if stroke_bounds_outset > 0.0 { - text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); - } - let bounds = blur_filter.compute_fast_bounds(text_bounds); - if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 { - let blur_filter_clone = blur_filter.clone(); - if filters::render_with_filter_surface( - render_state, - bounds, - target_surface, - |state, temp_surface| { - let temp_canvas = state.surfaces.canvas(temp_surface); - render_text_on_canvas( - temp_canvas, - shape, - paragraph_builders, - shadow, - Some(&blur_filter_clone), - fill_inset, - layer_opacity, - false, - ); - Ok(()) - }, - )? { - return Ok(()); - } - } - } - - let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_text_on_canvas( - canvas, + return render_to_surface( + render_state, shape, - paragraph_builders, - shadow, + surface_id.unwrap_or(SurfaceId::Fills), blur, - fill_inset, - layer_opacity, - false, + stroke_bounds_outset, + |canvas| { + render_text_on_canvas( + canvas, + shape, + paragraph_builders, + shadow, + blur, + fill_inset, + layer_opacity, + false, + ) + }, ); - return Ok(()); } if let Some(canvas) = canvas { diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 2f5ac9784f..866c48fc45 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -274,12 +274,13 @@ fn calculate_cursor_rect( // Cursor offsets count source characters; the laid-out paragraph // indexes the transformed, kinsoku-shifted builder text. + let offsets = crate::shapes::HorizontalOffsets::new(para); let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height (0.0, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( - 0..crate::shapes::horizontal_source_to_builder(para, 1), + 0..offsets.source_to_builder(1), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -290,11 +291,8 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { - let last_start = crate::shapes::horizontal_source_to_builder( - para, - para_char_count.saturating_sub(1), - ); - let last_end = crate::shapes::horizontal_source_to_builder(para, para_char_count); + let last_start = offsets.source_to_builder(para_char_count.saturating_sub(1)); + let last_end = offsets.source_to_builder(para_char_count); let rects = laid_out_para.get_rects_for_range( last_start..last_end, RectHeightStyle::Max, @@ -314,8 +312,8 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } } else { - let start = crate::shapes::horizontal_source_to_builder(para, char_pos); - let end = crate::shapes::horizontal_source_to_builder(para, char_pos + 1); + let start = offsets.source_to_builder(char_pos); + let end = offsets.source_to_builder(char_pos + 1); let rects = laid_out_para.get_rects_for_range( start..end, RectHeightStyle::Max, @@ -411,8 +409,8 @@ fn calculate_selection_rects( )); } use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - for builder_range in - crate::shapes::horizontal_normal_selection_ranges(para, range_start, range_end) + for builder_range in crate::shapes::HorizontalOffsets::new(para) + .normal_selection_ranges(para, range_start, range_end) { for text_box in laid_out_para.get_rects_for_range( builder_range, diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 6f98a605dc..37241bec2b 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -3,8 +3,7 @@ use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path, - BlurType, Fill, Frame, ParagraphBuilderGroup, Path, Rect, Shape, Stroke, StrokeKind, - StrokeStyle, TextContent, Type, + BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, TextContent, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -58,58 +57,35 @@ impl<'a> VectorRenderer<'a> { shape.image_filter(1.) } + /// Vertical text: shadows, fill and strokes painted from one layout. fn draw_vertical_text( &mut self, shape: &Shape, text_content: &TextContent, - paragraph_builders: &mut [ParagraphBuilderGroup], blur_filter: Option<&skia::ImageFilter>, - ) -> Result<()> { + ) { use crate::shapes::text_vertical; let bounds = text_content.bounds(); - let drop_shadows = shape.drop_shadow_paints(); - let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect(); - let layout = (!drop_shadows.is_empty() || !strokes.is_empty()) - .then(|| text_vertical::layout_for_box(text_content, bounds.height())); + let vertical_align = shape.vertical_align(); + let layout = text_vertical::layout_for_box(text_content, bounds.height()); - if let Some(layout) = &layout { - for shadow in &drop_shadows { - text_vertical::paint_drop_shadow( - self.canvas, - layout, - &bounds, - shape.vertical_align(), - shadow, - ); - } + for shadow in &shape.drop_shadow_paints() { + text_vertical::paint_drop_shadow(self.canvas, &layout, &bounds, vertical_align, shadow); } - - text::render_overlay_emoji( - self.canvas, - shape, - paragraph_builders, - None, - blur_filter, - None, - None, - )?; - - if let Some(layout) = &layout { - let selrect = shape.selrect(); - for stroke in strokes { - text_vertical::paint_stroke( - self.canvas, - layout, - &bounds, - shape.vertical_align(), - stroke, - &selrect, - blur_filter, - ); - } + text::paint_vertical_fill(self.canvas, shape, &layout, &bounds, blur_filter); + let selrect = shape.selrect(); + for stroke in shape.visible_strokes().rev() { + text_vertical::paint_stroke( + self.canvas, + &layout, + &bounds, + vertical_align, + stroke, + &selrect, + blur_filter, + ); } - Ok(()) } } @@ -236,18 +212,15 @@ impl ShapeRenderer for VectorRenderer<'_> { }; let text_content = text_content.new_bounds(shape.selrect()); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let blur_filter = self.layer_blur_filter(shape); if text_content.is_vertical() { - return self.draw_vertical_text( - shape, - &text_content, - &mut paragraph_builders, - blur_filter.as_ref(), - ); + self.draw_vertical_text(shape, &text_content, blur_filter.as_ref()); + return Ok(()); } + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + // Text drop shadows: one filter layer per shadow over fill + stroke // silhouettes (mirrors GPU `render_text_shadows`). let drop_shadows = shape.drop_shadow_paints(); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 270523bd4d..1d7aa1dcbe 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -2168,26 +2168,44 @@ pub fn calculate_text_layout_data( // Ranges are in the builder-text (kinsoku-shifted) // space; exported positions are translated back to // original span-relative offsets through the map. - let (_, offset_map) = text_para.layout_span_texts(); - let span_ranges = horizontal_span_ranges(text_para); + let offsets = HorizontalOffsets::new(text_para); + let offset_map = &offsets.offset_map; + let entry = + |span: usize, range: std::ops::Range, mut rect: Rect, direction| { + rect.offset((x, current_y)); + PositionData { + paragraph: paragraph_index as u32, + span: span as u32, + start_pos: range.start as u32, + end_pos: range.end as u32, + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + direction, + } + }; let placeholder_rects = para_layout.paragraph.get_rects_for_placeholders(); let mut placeholder_index = 0usize; - for range in span_ranges { + for range in &offsets.ranges { if range.warichu { - if let Some(textbox) = placeholder_rects.get(placeholder_index) { - let mut rect = textbox.rect; - rect.offset((x, current_y)); - position_data.push(PositionData { - paragraph: paragraph_index as u32, - span: range.span as u32, - start_pos: 0, - end_pos: (range.source_end - range.source_start) as u32, - x: rect.x(), - y: rect.y(), - width: rect.width(), - height: rect.height(), - direction: direction_to_int(TextDirection::LTR), - }); + // One strip per sub-line: the top half holds the + // first line, the bottom half the second. + if let (Some(textbox), Some(span)) = ( + placeholder_rects.get(placeholder_index), + text_para.children().get(range.span), + ) { + let rect = textbox.rect; + let text = span.apply_text_transform(); + let split = warichu_text_lines(&text).0.encode_utf16().count(); + let end = range.source_end - range.source_start; + let half = rect.height() / 2.0; + let ltr = direction_to_int(TextDirection::LTR); + let top = Rect::from_xywh(rect.x(), rect.y(), rect.width(), half); + let bottom = + Rect::from_xywh(rect.x(), rect.y() + half, rect.width(), half); + position_data.push(entry(range.span, 0..split, top, ltr)); + position_data.push(entry(range.span, split..end, bottom, ltr)); } placeholder_index += 1; continue; @@ -2201,7 +2219,7 @@ pub fn calculate_text_layout_data( for textbox in rects { let direction = textbox.direct; - let mut rect = textbox.rect; + let rect = textbox.rect; let cy = rect.top + rect.height() / 2.0; // Get byte positions from Skia's transformed text layout @@ -2225,18 +2243,24 @@ pub fn calculate_text_layout_data( .position as usize, ) - orig_span_start; - rect.offset((x, current_y)); - position_data.push(PositionData { - paragraph: paragraph_index as u32, - span: range.span as u32, - start_pos: start_pos as u32, - end_pos: end_pos as u32, - x: rect.x(), - y: rect.y(), - width: rect.width(), - height: rect.height(), - direction: direction_to_int(direction), - }); + position_data.push(entry( + range.span, + start_pos..end_pos, + rect, + direction_to_int(direction), + )); + } + } + for placement in + horizontal_emphasis_placements(text_para, &offsets, ¶_layout.paragraph) + { + if let Some(span) = text_para.children().get(placement.span) { + position_data.push(entry( + placement.span, + placement.range.clone(), + horizontal_emphasis_mark_box(span, &placement), + super::text_vertical::DIRECTION_EMPHASIS_MARK, + )); } } } @@ -2457,7 +2481,10 @@ mod tests { let para = test_paragraph(&["Añadir"]); for offset in 0..=6 { assert_eq!(para.char_offset_to_utf16(offset), offset); - assert_eq!(horizontal_source_to_builder(¶, offset), offset); + assert_eq!( + HorizontalOffsets::new(¶).source_to_builder(offset), + offset + ); assert_eq!(horizontal_builder_to_source(¶, offset), offset); } } @@ -2491,9 +2518,9 @@ mod tests { assert_eq!(para.char_offset_to_utf16(4), 4); assert_eq!(para.char_offset_to_utf16(6), 7); - assert_eq!(horizontal_source_to_builder(¶, 4), 4); - assert_eq!(horizontal_source_to_builder(¶, 5), 6); - assert_eq!(horizontal_source_to_builder(¶, 6), 7); + assert_eq!(HorizontalOffsets::new(¶).source_to_builder(4), 4); + assert_eq!(HorizontalOffsets::new(¶).source_to_builder(5), 6); + assert_eq!(HorizontalOffsets::new(¶).source_to_builder(6), 7); assert_eq!(horizontal_builder_to_source(¶, 7), 6); } @@ -2501,8 +2528,8 @@ mod tests { fn builder_range_covers_the_whole_glyph() { let para = test_paragraph(&["a😀b"]); let len_at = |offset| { - horizontal_source_to_builder(¶, offset + 1) - - horizontal_source_to_builder(¶, offset) + HorizontalOffsets::new(¶).source_to_builder(offset + 1) + - HorizontalOffsets::new(¶).source_to_builder(offset) }; assert_eq!(len_at(0), 1); assert_eq!(len_at(1), 2); diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs index 9d481ec950..c6bb5dc94b 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_japanese.rs @@ -112,11 +112,134 @@ pub(crate) struct HorizontalSpanRange { pub style_anchor_start: usize, } +/// Offset mapping of one horizontally laid-out paragraph between source +/// characters, the kinsoku-adjusted layout text and the paragraph builder +/// text. Building it runs the kinsoku pass once; reuse it for every lookup +/// into the same paragraph. +pub(crate) struct HorizontalOffsets { + /// Map between the transformed text and the kinsoku-adjusted text. + pub(crate) offset_map: kinsoku::OffsetMap, + pub(crate) ranges: Vec, + /// Transformed-text UTF-16 offset of every source character boundary. + boundaries: Vec, +} + +impl HorizontalOffsets { + pub(crate) fn new(paragraph: &Paragraph) -> Self { + let (span_texts, offset_map) = paragraph.layout_span_texts(); + let ranges = span_ranges(paragraph, span_texts, &offset_map); + Self { + offset_map, + ranges, + boundaries: source_char_boundaries(paragraph), + } + } + + /// Builder-text UTF-16 offset of a paragraph source character offset. + pub(crate) fn source_to_builder(&self, source_char_offset: usize) -> usize { + let boundaries = &self.boundaries; + let source_utf16 = boundaries + .get(source_char_offset) + .copied() + .unwrap_or_else(|| boundaries.last().copied().unwrap_or(0)); + let Some(range) = self + .ranges + .iter() + .find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end) + else { + return self + .ranges + .last() + .map(|range| range.builder_end) + .unwrap_or(0); + }; + if range.warichu { + return if source_utf16 >= range.source_end { + range.builder_end + } else { + range.builder_start + }; + } + let shifted = self.offset_map.to_shifted(source_utf16); + range.builder_start + shifted.saturating_sub(range.shifted_start) + } + + /// Paragraph source character offset of a builder-text UTF-16 offset. + pub(crate) fn builder_to_source(&self, builder_offset: usize) -> usize { + let source_utf16 = self + .ranges + .iter() + .find(|range| { + builder_offset >= range.builder_start && builder_offset <= range.builder_end + }) + .map(|range| { + if range.warichu { + if builder_offset > range.builder_start { + range.source_end + } else { + range.source_start + } + } else { + let within = builder_offset + .saturating_sub(range.builder_start) + .min(range.builder_end - range.builder_start); + self.offset_map.to_original(range.shifted_start + within) + } + }) + .unwrap_or_else(|| { + self.ranges + .last() + .map(|range| range.source_end) + .unwrap_or(0) + }); + let boundaries = &self.boundaries; + boundaries + .partition_point(|boundary| *boundary < source_utf16) + .min(boundaries.len().saturating_sub(1)) + } + + /// Builder-text ranges of the selected source characters + /// `[source_start, source_end)` outside warichu spans. + pub(crate) fn normal_selection_ranges( + &self, + paragraph: &Paragraph, + source_start: usize, + source_end: usize, + ) -> Vec> { + let mut span_start = 0usize; + paragraph + .children() + .iter() + .filter_map(|span| { + let span_end = span_start + span.text.chars().count(); + let selected_start = source_start.max(span_start); + let selected_end = source_end.min(span_end); + span_start = span_end; + if span.is_warichu() || selected_start >= selected_end { + return None; + } + Some(self.source_to_builder(selected_start)..self.source_to_builder(selected_end)) + }) + .collect() + } +} + +pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec { + HorizontalOffsets::new(paragraph).ranges +} + +pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize { + HorizontalOffsets::new(paragraph).builder_to_source(builder_offset) +} + /// Ranges shared by layout, position-data and editor mapping. `shifted_*` /// addresses the normal kinsoku-adjusted paragraph text, while `builder_*` /// addresses the paragraph where a whole warichu span occupies one U+FFFC. -pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec { - let (span_texts, offset_map) = paragraph.layout_span_texts(); +fn span_ranges( + paragraph: &Paragraph, + span_texts: Vec, + offset_map: &kinsoku::OffsetMap, +) -> Vec { let mut builder_cursor = 0usize; let mut builder_byte_cursor = 0usize; let mut shifted_cursor = 0usize; @@ -188,61 +311,6 @@ fn source_char_boundaries(paragraph: &Paragraph) -> Vec { boundaries } -pub(crate) fn horizontal_source_to_builder( - paragraph: &Paragraph, - source_char_offset: usize, -) -> usize { - let boundaries = source_char_boundaries(paragraph); - let source_utf16 = boundaries - .get(source_char_offset) - .copied() - .unwrap_or_else(|| boundaries.last().copied().unwrap_or(0)); - let (_, offset_map) = paragraph.layout_span_texts(); - let ranges = horizontal_span_ranges(paragraph); - let Some(range) = ranges - .iter() - .find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end) - else { - return ranges.last().map(|range| range.builder_end).unwrap_or(0); - }; - if range.warichu { - return if source_utf16 >= range.source_end { - range.builder_end - } else { - range.builder_start - }; - } - let shifted = offset_map.to_shifted(source_utf16); - range.builder_start + shifted.saturating_sub(range.shifted_start) -} - -pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize { - let (_, offset_map) = paragraph.layout_span_texts(); - let ranges = horizontal_span_ranges(paragraph); - let source_utf16 = ranges - .iter() - .find(|range| builder_offset >= range.builder_start && builder_offset <= range.builder_end) - .map(|range| { - if range.warichu { - if builder_offset > range.builder_start { - range.source_end - } else { - range.source_start - } - } else { - let within = builder_offset - .saturating_sub(range.builder_start) - .min(range.builder_end - range.builder_start); - offset_map.to_original(range.shifted_start + within) - } - }) - .unwrap_or_else(|| ranges.last().map(|range| range.source_end).unwrap_or(0)); - let boundaries = source_char_boundaries(paragraph); - boundaries - .partition_point(|boundary| *boundary < source_utf16) - .min(boundaries.len().saturating_sub(1)) -} - /// Placeholder rect of each horizontal warichu span, keyed by span index. /// Tabs in normal spans are placeholders too, so they are skipped. fn horizontal_warichu_placeholders( @@ -377,32 +445,6 @@ pub(crate) fn horizontal_warichu_range_rects( rects } -pub(crate) fn horizontal_normal_selection_ranges( - paragraph: &Paragraph, - source_start: usize, - source_end: usize, -) -> Vec> { - let mut span_start = 0usize; - paragraph - .children() - .iter() - .filter_map(|span| { - let span_end = span_start + span.text.chars().count(); - let selected_start = source_start.max(span_start); - let selected_end = source_end.min(span_end); - let warichu = span.is_warichu(); - span_start = span_end; - if warichu || selected_start >= selected_end { - return None; - } - Some( - horizontal_source_to_builder(paragraph, selected_start) - ..horizontal_source_to_builder(paragraph, selected_end), - ) - }) - .collect() -} - /// Char index where a warichu run splits into its two sub-lines: the /// balanced midpoint (first line longer), nudged so the second sub-line /// does not start with a line-start-prohibited character and the first @@ -499,10 +541,13 @@ pub(crate) fn emphasis_char_allowed(character: char) -> bool { && !crate::shapes::japanese::classify(character).is_emphasis_prohibited() } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct HorizontalEmphasisPlacement { pub(crate) span: usize, + /// UTF-16 range of the base character in the span's transformed text. + pub(crate) range: std::ops::Range, pub(crate) mark: char, + /// Base character rect in the laid-out paragraph. pub(crate) rect: skia::Rect, } @@ -511,13 +556,13 @@ pub(crate) struct HorizontalEmphasisPlacement { /// character range keeps the marks attached to the actual laid-out glyphs. pub(crate) fn horizontal_emphasis_placements( paragraph: &Paragraph, + offsets: &HorizontalOffsets, laid_out: &skia::textlayout::Paragraph, ) -> Vec { - let (_, offset_map) = paragraph.layout_span_texts(); - let ranges = horizontal_span_ranges(paragraph); + let offset_map = &offsets.offset_map; let mut placements = Vec::new(); - for range in ranges.iter().filter(|range| !range.warichu) { + for range in offsets.ranges.iter().filter(|range| !range.warichu) { let Some(span) = paragraph.children().get(range.span) else { continue; }; @@ -550,6 +595,7 @@ pub(crate) fn horizontal_emphasis_placements( if let Some(rect) = scalar_rect { placements.push(HorizontalEmphasisPlacement { span: range.span, + range: local_utf16..next_utf16, mark, rect, }); @@ -588,6 +634,23 @@ pub(crate) fn horizontal_annotation_over_top(rect: skia::Rect, font_size: f32) - rect.top.max(rect.bottom - font_size) - font_size * (3.0 / 14.0) } +/// Em box of a horizontal emphasis mark in the laid-out paragraph: centred +/// over its base character, outside any stacked ruby layer. +pub(crate) fn horizontal_emphasis_mark_box( + span: &TextSpan, + placement: &HorizontalEmphasisPlacement, +) -> skia::Rect { + let size = span.font_size * EMPHASIS_FONT_SCALE; + let bottom = horizontal_annotation_over_top(placement.rect, span.font_size) + - span.emphasis_ruby_offset(); + skia::Rect::from_xywh( + placement.rect.center_x() - size / 2.0, + bottom - size, + size, + size, + ) +} + /// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs. /// The base paragraph retains its normal metrics; interlinear collision and /// automatic line-gap expansion remain a separate layout policy. @@ -598,9 +661,9 @@ pub(crate) fn paint_horizontal_emphasis( x: f32, y: f32, ) { - let ranges = horizontal_span_ranges(paragraph); - let placements = horizontal_emphasis_placements(paragraph, laid_out); - for range in ranges.iter().filter(|range| !range.warichu) { + let offsets = HorizontalOffsets::new(paragraph); + let placements = horizontal_emphasis_placements(paragraph, &offsets, laid_out); + for range in offsets.ranges.iter().filter(|range| !range.warichu) { let Some(span) = paragraph.children().get(range.span) else { continue; }; @@ -1162,15 +1225,15 @@ mod tests { let ranges = horizontal_span_ranges(¶graph); assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3); assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4); - assert_eq!(horizontal_source_to_builder(¶graph, 2), 0); - assert_eq!(horizontal_source_to_builder(¶graph, 4), 3); - assert_eq!(horizontal_source_to_builder(¶graph, 5), 4); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(2), 0); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(4), 3); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(5), 4); assert_eq!(horizontal_builder_to_source(¶graph, 1), 4); assert_eq!(horizontal_builder_to_source(¶graph, 2), 4); assert_eq!(horizontal_builder_to_source(¶graph, 3), 4); assert_eq!(horizontal_builder_to_source(¶graph, 4), 5); assert_eq!( - horizontal_normal_selection_ranges(¶graph, 1, 5), + HorizontalOffsets::new(¶graph).normal_selection_ranges(¶graph, 1, 5), vec![3..4] ); } @@ -1180,9 +1243,9 @@ mod tests { init_state(); let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0); - assert_eq!(horizontal_source_to_builder(¶graph, 1), 2); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(1), 2); assert_eq!(horizontal_builder_to_source(¶graph, 2), 1); - assert_eq!(horizontal_source_to_builder(¶graph, 2), 3); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(2), 3); assert_eq!(horizontal_builder_to_source(¶graph, 3), 2); } @@ -1255,6 +1318,65 @@ mod tests { } } + #[test] + fn horizontal_position_data_carries_warichu_lines_and_emphasis_marks() { + init_state(); + let mut emphasized = make_span("A、B", 0.0); + emphasized.text_emphasis = TextEmphasis::FilledDot; + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 400.0, 100.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![emphasized, warichu], 0.0)); + let mut shape = crate::shapes::Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 400.0, 100.0); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let data = super::super::text::calculate_position_data(&shape, &content, false); + + let warichu_lines: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.span == 1) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(warichu_lines, vec![(0, 2), (2, 4)]); + let marks: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.direction == super::super::text_vertical::DIRECTION_EMPHASIS_MARK) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(marks, vec![(0, 1), (2, 3)], "A and B are marked; 、 is not"); + } + + #[test] + fn horizontal_emphasis_mark_stacks_outside_auto_clearance_ruby() { + let placement = HorizontalEmphasisPlacement { + span: 0, + range: 0..1, + mark: '•', + rect: skia::Rect::from_xywh(10.0, 40.0, 16.0, 20.0), + }; + let plain = make_span("漢", 0.0); + let mut stacked = make_span("漢", 0.0); + stacked.ruby = "かん".to_string(); + stacked.annotation_clearance = AnnotationClearance::Auto; + + let plain_box = horizontal_emphasis_mark_box(&plain, &placement); + let stacked_box = horizontal_emphasis_mark_box(&stacked, &placement); + + assert_eq!(plain_box.width(), plain.font_size * EMPHASIS_FONT_SCALE); + assert_eq!(plain_box.center_x(), placement.rect.center_x()); + assert_eq!( + stacked_box.bottom, + plain_box.bottom - stacked.ruby_font_size(), + "the mark sits outside the ruby layer" + ); + } + #[test] fn horizontal_emphasis_tracks_eligible_unicode_characters() { init_state(); @@ -1274,7 +1396,11 @@ mod tests { let mut laid_out = builder.build(); laid_out.layout(200.0); - let placements = horizontal_emphasis_placements(¶graph, &laid_out); + let placements = horizontal_emphasis_placements( + ¶graph, + &HorizontalOffsets::new(¶graph), + &laid_out, + ); assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each"); assert!(placements .iter() diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs index 8395b091a1..3d2db5db16 100644 --- a/render-wasm/src/shapes/text_vertical/annotations.rs +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -12,7 +12,7 @@ use crate::shapes::{ use super::cells::Fonts; use super::flow::FlowCell; -use super::layout::{column_base_center, CellKind, VerticalCell, VerticalColumn}; +use super::layout::{column_base_center, CellKind, VerticalCell, VerticalColumn, VerticalLayout}; use super::shaping::{shape_segment, shape_segment_with_fallbacks, span_font_families, ShapedRun}; /// One shaped ruby glyph, retaining its fallback-font run and source range. @@ -49,17 +49,15 @@ pub struct RubyCell { } /// One emphasis mark (圏点 / bouten) drawn beside a base character. Kept out of -/// `cells` like ruby, so base metrics, caret and position-data never see it. -/// The mark glyph is the single-glyph `run`; it is centred on the base cell's -/// flow extent and drawn in the column's right-side gutter. +/// `cells` like ruby, so base metrics and caret geometry never see it. The +/// mark glyph is the single-glyph `run`; it is centred on its base cell's flow +/// extent and drawn in the column's right-side gutter with the cell's paint. pub struct EmphasisMark { pub run: usize, - pub column: usize, - /// Flow-axis top and extent of the annotated base cell. - pub top: f32, - pub extent: f32, - pub paint: usize, + /// Index of the annotated base cell in `VerticalLayout::cells`. + pub cell: usize, pub font_size: f32, + /// Cross-axis offset past a stacked ruby layer. pub outside_offset: f32, } @@ -488,6 +486,7 @@ pub(super) fn layout_emphasis( text_content: &TextContent, cells: &[VerticalCell], span_utf16_starts: &[Vec], + span_transforms: &[Vec], fonts: &Fonts, ) -> (Vec, Vec) { let mut emphasis_runs: Vec = Vec::new(); @@ -525,7 +524,9 @@ pub(super) fn layout_emphasis( let run_index = emphasis_runs.len(); emphasis_runs.push(shaped.remove(0)); let span_start = span_utf16_starts[paragraph_index][span_index]; - for cell in cells { + // Cell offsets index the span's transformed text. + let span_text = &span_transforms[paragraph_index][span_index].text; + for (cell_index, cell) in cells.iter().enumerate() { if cell.paragraph != paragraph_index || cell.span != span_index || !matches!( @@ -533,7 +534,7 @@ pub(super) fn layout_emphasis( CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } ) || !utf16_range_allows_emphasis( - &span.text, + span_text, cell.start - span_start, cell.end - span_start, ) @@ -542,10 +543,7 @@ pub(super) fn layout_emphasis( } emphasis_marks.push(EmphasisMark { run: run_index, - column: cell.column, - top: cell.top, - extent: cell.extent, - paint: cell.paint, + cell: cell_index, font_size: mark_font_size, outside_offset: span.emphasis_ruby_offset(), }); @@ -555,10 +553,16 @@ pub(super) fn layout_emphasis( (emphasis_runs, emphasis_marks) } -/// Cross-axis centre of an emphasis mark's gutter, past any stacked ruby. -pub(super) fn emphasis_gutter_center(column: &VerticalColumn, mark: &EmphasisMark) -> f32 { +/// Centre of an emphasis mark, relative to the layout's content origin: in +/// its column's gutter past any stacked ruby, at the middle of its base cell. +pub(super) fn emphasis_mark_center(layout: &VerticalLayout, mark: &EmphasisMark) -> (f32, f32) { + let cell = &layout.cells[mark.cell]; let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; - column_base_center(column) + base_font_size / 2.0 + mark.outside_offset + mark.font_size / 2.0 + let x = column_base_center(&layout.columns[cell.column]) + + base_font_size / 2.0 + + mark.outside_offset + + mark.font_size / 2.0; + (x, cell.top + cell.extent / 2.0) } #[cfg(test)] @@ -568,7 +572,9 @@ mod tests { use super::super::test_support::*; use super::*; use crate::shapes::{AnnotationClearance, GrowType, RubySize}; - use crate::shapes::{TextAlign, TextEmphasis, TextOrientation, TextSpan, VerticalAlign}; + use crate::shapes::{ + TextAlign, TextEmphasis, TextOrientation, TextSpan, TextTransform, VerticalAlign, + }; fn emphasis_content(base: &str, emphasis: TextEmphasis) -> TextContent { spans_content( @@ -650,6 +656,25 @@ mod tests { ); } + #[test] + fn emphasis_follows_the_transformed_text() { + // Uppercase expands ß into SS, so the transformed cells S S 、 A are + // offset from the source text ß、A. + let content = spans_content( + vec![TextSpan { + text_emphasis: TextEmphasis::FilledDot, + text_orientation: TextOrientation::Upright, + text_transform: Some(TextTransform::Uppercase), + ..make_span("ß、A") + }], + 400.0, + ); + let layout = layout_content(&content, 400.0); + assert_eq!(layout.cells.len(), 4); + let marked: Vec = layout.emphasis_marks.iter().map(|mark| mark.cell).collect(); + assert_eq!(marked, vec![0, 1, 3], "S, S and A are marked; 、 is not"); + } + #[test] fn no_emphasis_emits_no_marks() { let content = make_content(&["AB"], 400.0); diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 7a0d552e89..822f0c7bf3 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -463,8 +463,13 @@ pub fn layout_vertical( } let (ruby_runs, ruby_cells) = layout_ruby(text_content, &cells, &fonts); - let (emphasis_runs, emphasis_marks) = - layout_emphasis(text_content, &cells, &span_utf16_starts, &fonts); + let (emphasis_runs, emphasis_marks) = layout_emphasis( + text_content, + &cells, + &span_utf16_starts, + &span_transforms, + &fonts, + ); let height = cells .iter() diff --git a/render-wasm/src/shapes/text_vertical/mod.rs b/render-wasm/src/shapes/text_vertical/mod.rs index 7397d5954f..ee376e388a 100644 --- a/render-wasm/src/shapes/text_vertical/mod.rs +++ b/render-wasm/src/shapes/text_vertical/mod.rs @@ -46,7 +46,10 @@ mod test_support; pub(crate) use annotations::distribute_ruby_tops; pub use layout::{block_axis_offset, layout_for_box, measure_content, VerticalLayout}; pub use paint::{ - paint_drop_shadow, paint_grid, paint_stroke, paint_text_vertical, vertical_text_paths, + paint_drop_shadow, paint_grid, paint_layout, paint_stroke, paint_text_vertical, + vertical_text_paths, +}; +pub use positions::{ + caret_from_point, caret_rect, intersects, position_data, range_rects, DIRECTION_EMPHASIS_MARK, }; -pub use positions::{caret_from_point, caret_rect, intersects, position_data, range_rects}; pub(crate) use shaping::{shape_segment_with_fallbacks, single_glyph_blob, span_font_families}; diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index bdc4d5154c..13e301de0d 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -7,7 +7,7 @@ use skia_safe::{ use crate::math::Rect; use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign}; -use super::annotations::{emphasis_gutter_center, ruby_strip_x, EmphasisMark, RubyCell}; +use super::annotations::{emphasis_mark_center, ruby_strip_x, EmphasisMark, RubyCell}; use super::font_tables::upright_baseline; use super::layout::{column_base_center, layout_for_box, CellKind, VerticalCell, VerticalLayout}; use super::shaping::{single_glyph_blob, ShapedRun}; @@ -222,10 +222,10 @@ fn emphasis_draw( ) -> Option { let run = &layout.emphasis_runs[mark.run]; let blob = single_glyph_blob(&run.font, *run.glyphs.first()?)?; - let gutter_center = origin.0 + emphasis_gutter_center(&layout.columns[mark.column], mark); + let (center_x, center_y) = emphasis_mark_center(layout, mark); + let gutter_center = origin.0 + center_x; let (_, metrics) = run.font.metrics(); - let cell_center = origin.1 + mark.top + mark.extent / 2.0; - let baseline = cell_center - (metrics.ascent + metrics.descent) / 2.0; + let baseline = origin.1 + center_y - (metrics.ascent + metrics.descent) / 2.0; let advance = run.advances.first().copied().unwrap_or(0.0); Some(GlyphDraw::at( blob, @@ -254,7 +254,7 @@ fn decoration_rects( /// Fill pass: each cell drawn with its own fill paint and its decorations, /// then ruby and emphasis marks with their base cell's paint. -fn paint_layout( +pub fn paint_layout( canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, @@ -280,7 +280,7 @@ fn paint_layout( } for mark in &layout.emphasis_marks { if let Some(draw) = emphasis_draw(layout, mark, origin) { - draw.draw(canvas, &layout.paints[mark.paint]); + draw.draw(canvas, &layout.paints[layout.cells[mark.cell].paint]); } } } @@ -353,7 +353,7 @@ fn paths_from_layout( push_text_path( &mut paths, draw.into_path(), - &layout.paints[mark.paint], + &layout.paints[layout.cells[mark.cell].paint], antialias, ); } diff --git a/render-wasm/src/shapes/text_vertical/positions.rs b/render-wasm/src/shapes/text_vertical/positions.rs index 0039a50695..f1b2055070 100644 --- a/render-wasm/src/shapes/text_vertical/positions.rs +++ b/render-wasm/src/shapes/text_vertical/positions.rs @@ -5,7 +5,7 @@ use skia_safe::{Contains, Point as SkPoint}; use crate::math::Rect; use crate::shapes::{PositionData, VerticalAlign}; -use super::annotations::ruby_strip_x; +use super::annotations::{emphasis_mark_center, ruby_strip_x}; use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; /// Position-data `direction` value marking a vertical (vertical-rl) @@ -19,15 +19,76 @@ pub const DIRECTION_VERTICAL_RL: u32 = 2; /// exact gutter placement the canvas paints. pub const DIRECTION_VERTICAL_RUBY: u32 = 3; +/// Position-data `direction` value marking one emphasis mark (圏点): the +/// entry's offsets are its base character's and the geometry is the mark's +/// em box, centred where the canvas paints it. Horizontal text uses it too. +pub const DIRECTION_EMPHASIS_MARK: u32 = 4; + +/// Paragraph source UTF-16 range of a `transformed` span-text range. +fn source_utf16_range( + layout: &VerticalLayout, + paragraph: usize, + span: usize, + transformed: Range, +) -> Range { + let transformed_span_start = layout.span_utf16_starts[paragraph][span]; + let source_span_start = layout.span_source_utf16_starts[paragraph][span]; + let relative = layout.span_transforms[paragraph][span].source_utf16_range( + transformed.start - transformed_span_start..transformed.end - transformed_span_start, + ); + source_span_start + relative.start..source_span_start + relative.end +} + pub(super) fn cell_source_utf16_range( layout: &VerticalLayout, cell: &VerticalCell, ) -> Range { - let transformed_span_start = layout.span_utf16_starts[cell.paragraph][cell.span]; - let source_span_start = layout.span_source_utf16_starts[cell.paragraph][cell.span]; - let relative = layout.span_transforms[cell.paragraph][cell.span] - .source_utf16_range(cell.start - transformed_span_start..cell.end - transformed_span_start); - source_span_start + relative.start..source_span_start + relative.end + source_utf16_range(layout, cell.paragraph, cell.span, cell.start..cell.end) +} + +/// Entry of `cell`'s span covering the paragraph source range `source`. +fn span_entry( + layout: &VerticalLayout, + cell: &VerticalCell, + source: Range, + (x, y, width, height): (f32, f32, f32, f32), + direction: u32, +) -> PositionData { + let span_start = layout.span_source_utf16_starts[cell.paragraph][cell.span]; + PositionData { + paragraph: cell.paragraph as u32, + span: cell.span as u32, + start_pos: (source.start - span_start) as u32, + end_pos: (source.end - span_start) as u32, + x, + y, + width, + height, + direction, + } +} + +/// The two sub-line strips of a warichu cell: the first on the right half of +/// the base band, the second on the left, each holding its own characters. +fn warichu_entries( + layout: &VerticalLayout, + cell: &VerticalCell, + first_chars: usize, + origin: (f32, f32), +) -> [PositionData; 2] { + let column = &layout.columns[cell.column]; + let center = origin.0 + column_base_center(column); + let half = cell.font_size / 2.0; + let split = cell.start + first_chars; + let line = |range: Range, x: f32| { + let source = source_utf16_range(layout, cell.paragraph, cell.span, range); + let rect = (x, origin.1 + cell.top, half, cell.extent); + span_entry(layout, cell, source, rect, DIRECTION_VERTICAL_RL) + }; + [ + line(cell.start..split, center), + line(split..cell.end, center - half), + ] } /// Position-data entries for the v2 editor / exports: consecutive cells of @@ -43,6 +104,16 @@ pub fn position_data( let mut i = 0; while i < layout.cells.len() { let first = &layout.cells[i]; + if let CellKind::Warichu { first_chars, .. } = first.kind { + result.extend(warichu_entries( + layout, + first, + first_chars, + (origin_x, origin_y), + )); + i += 1; + continue; + } let mut source_range = cell_source_utf16_range(layout, first); let mut bottom = first.top + first.extent; let mut j = i + 1; @@ -51,6 +122,7 @@ pub fn position_data( if next.paragraph == first.paragraph && next.span == first.span && next.column == first.column + && !matches!(next.kind, CellKind::Warichu { .. }) { let next_source = cell_source_utf16_range(layout, next); source_range.start = source_range.start.min(next_source.start); @@ -62,20 +134,21 @@ pub fn position_data( } } let column = &layout.columns[first.column]; - let span_start = layout.span_source_utf16_starts[first.paragraph][first.span]; - result.push(PositionData { - paragraph: first.paragraph as u32, - span: first.span as u32, - start_pos: (source_range.start - span_start) as u32, - end_pos: (source_range.end - span_start) as u32, - x: origin_x + column.x, - y: origin_y + first.top, - // Base text occupies the base sub-band; any ruby gutter is - // excluded so the editor overlay and selection track the glyphs. - width: column.base_width, - height: bottom - first.top, - direction: DIRECTION_VERTICAL_RL, - }); + // Base text occupies the base sub-band; any ruby gutter is excluded + // so the editor overlay and selection track the glyphs. + let rect = ( + origin_x + column.x, + origin_y + first.top, + column.base_width, + bottom - first.top, + ); + result.push(span_entry( + layout, + first, + source_range, + rect, + DIRECTION_VERTICAL_RL, + )); i = j; } @@ -106,6 +179,26 @@ pub fn position_data( direction: DIRECTION_VERTICAL_RUBY, }); } + + for mark in &layout.emphasis_marks { + let cell = &layout.cells[mark.cell]; + let (center_x, center_y) = emphasis_mark_center(layout, mark); + let size = mark.font_size; + let rect = ( + origin_x + center_x - size / 2.0, + origin_y + center_y - size / 2.0, + size, + size, + ); + let source = cell_source_utf16_range(layout, cell); + result.push(span_entry( + layout, + cell, + source, + rect, + DIRECTION_EMPHASIS_MARK, + )); + } result } @@ -380,7 +473,8 @@ mod tests { use super::super::test_support::*; use super::*; use crate::shapes::{ - TextCombineUpright, TextOrientation, TextPositionWithAffinity, TextTransform, + TextCombineUpright, TextEmphasis, TextOrientation, TextPositionWithAffinity, TextSpan, + TextTransform, }; use crate::wasm::text::helpers as text_helpers; @@ -436,6 +530,62 @@ mod tests { assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01); } + #[test] + fn position_data_emits_one_strip_per_warichu_sub_line() { + let content = warichu_content("あいう、えお", 400.0); + let layout = layout_content(&content, 400.0); + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + + let strips: Vec<&PositionData> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .collect(); + assert_eq!(strips.len(), 2); + assert_eq!((strips[0].start_pos, strips[0].end_pos), (0, 4)); + assert_eq!((strips[1].start_pos, strips[1].end_pos), (4, 6)); + assert!( + strips[0].x > strips[1].x, + "the first sub-line reads first, on the right" + ); + assert!((strips[0].width - EM / 2.0).abs() < 0.01); + } + + #[test] + fn position_data_emits_each_emphasis_mark_box() { + let content = spans_content( + vec![TextSpan { + text_emphasis: TextEmphasis::FilledDot, + text_orientation: TextOrientation::Upright, + ..make_span("A、B") + }], + 400.0, + ); + let layout = layout_content(&content, 400.0); + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + + let marks: Vec<&PositionData> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_EMPHASIS_MARK) + .collect(); + let ranges: Vec<(u32, u32)> = marks.iter().map(|m| (m.start_pos, m.end_pos)).collect(); + assert_eq!( + ranges, + vec![(0, 1), (2, 3)], + "A and B are marked; 、 is not" + ); + let base = data + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .expect("base strip"); + for mark in marks { + assert!((mark.width - EM / 2.0).abs() < 0.01); + assert!( + mark.x >= base.x + base.width - 0.01, + "marks sit in the gutter right of the base band" + ); + } + } + #[test] fn warichu_cell_carries_kinsoku_split() { let content = warichu_content("あいう、えお", 400.0);