diff --git a/common/src/app/common/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs index 69d75338ac..7b90cc0de0 100644 --- a/common/src/app/common/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -302,78 +302,58 @@ [text-direction] (untranslate "text-direction" text-direction "ltr")) +(defn- translate-enum + "Wasm discriminant of the `id` enum variant named `value`, falling back to + the `default` variant. 0 when the enum is not exported." + [id value default] + (if-let [values (unchecked-get wasm/serializers id)] + (d/nilv (unchecked-get values (d/name value)) + (unchecked-get values default)) + 0)) + (defn translate-writing-mode - [writing-mode] - (let [values (unchecked-get wasm/serializers "writing-mode") - default (unchecked-get values "horizontal-tb")] - (d/nilv (unchecked-get values (d/name writing-mode)) default))) + [value] + (translate-enum "writing-mode" value "horizontal-tb")) (defn translate-text-orientation - [text-orientation] - (let [values (unchecked-get wasm/serializers "text-orientation") - default (unchecked-get values "mixed")] - (d/nilv (unchecked-get values (d/name text-orientation)) default))) + [value] + (translate-enum "text-orientation" value "mixed")) (defn translate-text-combine-upright - [text-combine-upright] - (let [values (unchecked-get wasm/serializers "text-combine-upright") - default (unchecked-get values "none")] - (d/nilv (unchecked-get values (d/name text-combine-upright)) default))) + [value] + (translate-enum "text-combine-upright" value "none")) (defn translate-text-emphasis - [text-emphasis] - (let [values (unchecked-get wasm/serializers "text-emphasis") - default (unchecked-get values "none")] - (d/nilv (unchecked-get values (d/name text-emphasis)) default))) + [value] + (translate-enum "text-emphasis" value "none")) (defn translate-warichu - [warichu] - (let [values (unchecked-get wasm/serializers "warichu") - default (unchecked-get values "none")] - (d/nilv (unchecked-get values (d/name warichu)) default))) + [value] + (translate-enum "warichu" value "none")) (defn translate-font-features - [font-features] - (if-let [values (unchecked-get wasm/serializers "font-features")] - (let [default (unchecked-get values "none")] - (d/nilv (unchecked-get values (d/name font-features)) default)) - 0)) + [value] + (translate-enum "font-features" value "none")) (defn translate-annotation-clearance - [annotation-clearance] - (if-let [values (unchecked-get wasm/serializers "annotation-clearance")] - (let [default (unchecked-get values "none")] - (d/nilv (unchecked-get values (d/name annotation-clearance)) default)) - 0)) + [value] + (translate-enum "annotation-clearance" value "none")) (defn translate-ruby-size [value] - (if-let [values (unchecked-get wasm/serializers "ruby-size")] - (let [default (unchecked-get values "half")] - (d/nilv (unchecked-get values (d/name value)) default)) - 0)) + (translate-enum "ruby-size" value "half")) (defn translate-ruby-align [value] - (if-let [values (unchecked-get wasm/serializers "ruby-align")] - (let [default (unchecked-get values "space-around")] - (d/nilv (unchecked-get values (d/name value)) default)) - 0)) + (translate-enum "ruby-align" value "space-around")) (defn translate-ruby-overhang [value] - (if-let [values (unchecked-get wasm/serializers "ruby-overhang")] - (let [default (unchecked-get values "auto")] - (d/nilv (unchecked-get values (d/name value)) default)) - 0)) + (translate-enum "ruby-overhang" value "auto")) (defn translate-ruby-side [value] - (if-let [values (unchecked-get wasm/serializers "ruby-side")] - (let [default (unchecked-get values "over")] - (d/nilv (unchecked-get values (d/name value)) default)) - 0)) - + (translate-enum "ruby-side" value "over")) (defn translate-font-style [font-style] diff --git a/common/src/app/common/types/text/japanese_layout.cljc b/common/src/app/common/types/text/japanese_layout.cljc index b660037a00..5945ed6c48 100644 --- a/common/src/app/common/types/text/japanese_layout.cljc +++ b/common/src/app/common/types/text/japanese_layout.cljc @@ -6,7 +6,8 @@ (ns app.common.types.text.japanese-layout (:require - [app.common.data.macros :as dm])) + [app.common.data.macros :as dm] + [cuerdas.core :as str])) ;; Vertical writing (tategaki). Absent values behave as "horizontal-tb" ;; and "mixed", so plain horizontal text never stores these attrs. @@ -50,6 +51,50 @@ (def text-annotation-clearance-attrs [:annotation-clearance]) +;; Values of the span attrs above when a span does not store them. +(def span-attr-defaults + {:text-combine-upright "none" + :text-emphasis "none" + :ruby "" + :ruby-hidden false + :ruby-size "half" + :ruby-align "space-around" + :ruby-overhang "auto" + :ruby-side "over" + :warichu "none" + :font-features "none" + :annotation-clearance "none"}) + +;; Annotation font sizes relative to the base font size. +(def emphasis-font-scale 0.5) +(def warichu-font-scale 0.5) + +(defn ruby-font-scale + [ruby-size] + (case ruby-size + "third" (/ 1 3) + "quarter" 0.25 + 0.5)) + +(defn visible-ruby + "Ruby annotation text of a text node, or nil when it has none or the + annotation is hidden." + [node] + (let [ruby (:ruby node)] + (when (and (string? ruby) + (not (str/blank? ruby)) + (not (true? (:ruby-hidden node)))) + ruby))) + +(defn warichu-text? + "True when a text node renders as warichu: it needs two characters to fill + its two sub-lines." + [node] + (let [text (:text node)] + (and (= "warichu" (:warichu node)) + (string? text) + (>= (count text) 2)))) + (defn content-writing-mode "Writing mode of a text content. Stored per paragraph but treated as a whole-shape property: the first paragraph decides the flow." diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 06363f00e7..56afc0d711 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] [app.common.types.color :as cc] + [app.common.types.text.japanese-layout :as jl] [app.main.ui.shapes.text.styles :as sts] [app.util.text.writing-mode :as wm] [cuerdas.core :as str] @@ -21,11 +22,8 @@ style (if (= text "") (sts/generate-text-styles shape parent) (sts/generate-text-styles shape node)) - ruby (:ruby node) - ruby? (and (not (true? (:ruby-hidden node))) - (string? ruby) - (not (str/blank? ruby)))] - (if ruby? + ruby (jl/visible-ruby node)] + (if (some? ruby) [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)} [:span.text-node {:style style} text] [:rt {:style (sts/generate-ruby-styles shape node)} ruby]] diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index 29d394f121..254afec61c 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -9,9 +9,9 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.text :as legacy.txt] + [app.common.types.text.japanese-layout :as jl] [app.main.ui.shapes.text.styles :as sts] [app.util.text.writing-mode :as wm] - [cuerdas.core :as str] [rumext.v2 :as mf])) (mf/defc render-text* @@ -21,11 +21,8 @@ (sts/generate-text-styles shape parent) (sts/generate-text-styles shape node)) class (when is-code (:$id node)) - ruby (:ruby node) - ruby? (and (not (true? (:ruby-hidden node))) - (string? ruby) - (not (str/blank? ruby)))] - (if ruby? + ruby (jl/visible-ruby node)] + (if (some? ruby) [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)} [:span.text-node {:style style :class class} text] [:rt {:style (sts/generate-ruby-styles shape node)} ruby]] diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 2aa7103f88..97a010fa00 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -10,6 +10,7 @@ [app.common.transit :as transit] [app.common.types.color :as cc] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.main.fonts :as fonts] [app.main.ui.formats :as fmt] [app.util.color :as uc] @@ -87,31 +88,74 @@ "digits3" "digits 3" value)) +(defn- set-value? + "True for a stored style value other than empty or \"none\"." + [value] + (and (string? value) (pos? (alength value)) (not= "none" value))) + +(defn font-feature-settings + "CSS `font-feature-settings` for a persisted font-features value, or nil." + [font-features] + (when (set-value? font-features) + (str/format "\"%s\"" font-features))) + +(defn- annotation-line-height + "Line height that reserves a half-em per ruby/emphasis layer under + automatic annotation clearance, or nil." + [data] + (when (= "auto" (:annotation-clearance data)) + (let [layers (+ (if (jl/visible-ruby data) 1 0) + (if (set-value? (:text-emphasis data)) 1 0)) + line-height (js/parseFloat (or (:line-height data) + (:line-height txt/default-typography)))] + (when (and (pos? layers) (not (js/isNaN line-height))) + (+ line-height (* layers 0.5)))))) + +(defn- add-japanese-text-styles! + [style data] + (let [text-combine-upright (:text-combine-upright data) + text-emphasis (:text-emphasis data) + font-features (font-feature-settings (:font-features data)) + annotation-clearance (:annotation-clearance data) + line-height (annotation-line-height data) + font-size (:font-size data)] + (cond-> style + (and (string? text-combine-upright) (pos? (alength text-combine-upright))) + (obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright)) + + ;; Emphasis marks map to CSS text-emphasis-style: our kebab values + ;; ("filled-dot") become the CSS " " pair ("filled dot"). + (set-value? text-emphasis) + (obj/set! "textEmphasis" (str/replace text-emphasis "-" " ")) + + (some? font-features) + (obj/set! "fontFeatureSettings" font-features) + + (and (string? annotation-clearance) (pos? (alength annotation-clearance))) + (obj/set! "--annotation-clearance" annotation-clearance) + + (some? line-height) + (obj/set! "lineHeight" line-height) + + ;; Warichu (割注) CSS emulation: an inline-block at half size whose + ;; inline-size fits half the characters, so the browser wraps it into + ;; two half-size sub-lines within one inline position in either writing + ;; mode. + (jl/warichu-text? data) + (-> (obj/set! "display" "inline-block") + (obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size))) + (str (* (js/parseFloat font-size) jl/warichu-font-scale) "px") + "50%")) + (obj/set! "lineHeight" "1") + (obj/set! "inlineSize" + (str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em")))))) + (defn generate-text-styles ([shape data] (generate-text-styles shape data nil)) ([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}] (let [letter-spacing (:letter-spacing data 0) - text-combine-upright (:text-combine-upright data) - text-emphasis (:text-emphasis data) - font-features (:font-features data) - annotation-clearance (:annotation-clearance data) - annotation-layers (if (= "auto" annotation-clearance) - (+ (if (and (not (true? (:ruby-hidden data))) - (string? (:ruby data)) - (seq (:ruby data))) 1 0) - (if (and (string? text-emphasis) - (not= "none" text-emphasis)) 1 0)) - 0) - line-height-num (js/parseFloat (or (:line-height data) - (:line-height txt/default-typography))) - auto-line-height (when (and (pos? annotation-layers) - (not (js/isNaN line-height-num))) - (+ line-height-num (* annotation-layers 0.5))) - warichu? (and (= "warichu" (:warichu data)) - (string? (:text data)) - (>= (count (:text data)) 2)) text-decoration (:text-decoration data) text-transform (:text-transform data) @@ -186,26 +230,6 @@ (and (string? letter-spacing) (pos? (alength letter-spacing))) (obj/set! "letterSpacing" (str letter-spacing "px")) - (and (string? text-combine-upright) (pos? (alength text-combine-upright))) - (obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright)) - - ;; Emphasis marks map to CSS text-emphasis-style: our kebab values - ;; ("filled-dot") become the CSS " " pair ("filled dot"). - (and (string? text-emphasis) (pos? (alength text-emphasis)) - (not= "none" text-emphasis)) - (obj/set! "textEmphasis" (str/replace text-emphasis "-" " ")) - - (and (string? font-features) (pos? (alength font-features)) - (not= "none" font-features)) - (obj/set! "fontFeatureSettings" (str/format "\"%s\"" font-features)) - - (and (string? annotation-clearance) - (pos? (alength annotation-clearance))) - (obj/set! "--annotation-clearance" annotation-clearance) - - (some? auto-line-height) - (obj/set! "lineHeight" auto-line-height) - (and (string? font-size) (pos? (alength font-size))) (obj/set! "fontSize" (str font-size "px")) @@ -217,18 +241,8 @@ (= grow-type :auto-width) (obj/set! "whiteSpace" "pre") - ;; Warichu (割注) CSS emulation: an inline-block at half size whose - ;; inline-size fits half the characters, so the browser wraps it into - ;; two half-size sub-lines within one inline position in either writing - ;; mode. - warichu? - (-> (obj/set! "display" "inline-block") - (obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size))) - (str (/ (js/parseFloat font-size) 2) "px") - "50%")) - (obj/set! "lineHeight" "1") - (obj/set! "inlineSize" - (str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em"))))))) + :always + (add-japanese-text-styles! data))))) (defn generate-ruby-styles [shape data] 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 70f87820f2..f0907d85f1 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] + [app.common.types.text.japanese-layout :as jl] [app.config :as cf] [app.main.ui.context :as muc] [app.main.ui.shapes.attrs :as attrs] @@ -21,17 +22,6 @@ (def fill-attrs [:fill-color :fill-color-gradient :fill-opacity]) -(defn- ruby-font-scale - [ruby-size] - (case ruby-size - "third" (/ 1 3) - "quarter" 0.25 - 0.5)) - -(def ^:private emphasis-font-scale 0.5) - -(def ^:private warichu-font-scale 0.5) - ;; 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 @@ -97,13 +87,168 @@ (if (emphasis-character? character) mark " "))) (apply str))) -(defn- add-font-features! - [style font-features] - (cond-> style - (and (string? font-features) - (not= "none" font-features) - (pos? (alength font-features))) - (obj/set! "fontFeatureSettings" (dm/str "\"" font-features "\"")))) +(defn- px + "CSS pixel size, or nil for a non-positive size." + [size] + (when (pos? size) (dm/str size "px"))) + +(defn- with-font-features + [style data] + (let [font-features (sts/font-feature-settings (:font-features data))] + (cond-> style + (some? font-features) (obj/set! "fontFeatureSettings" font-features)))) + +(defn- annotation-style + "Style of a ruby, warichu or emphasis line drawn at `font-size` along a + position-data strip." + [data font-size vertical? text-orientation fill] + (with-font-features + #js {:fontFamily (:font-family data) + :fontSize (px font-size) + :fontWeight (:font-weight data) + :fontStyle (:font-style data) + :direction (:direction data) + :writingMode (if vertical? "vertical-rl" "horizontal-tb") + :textSpacingTrim "normal" + :textOrientation text-orientation + :textAutospace "normal" + :whiteSpace "pre" + :fill fill} + data)) + +(defn- strip-text-props + "Props of the `text` element drawing a position-data strip's base text." + [shape data index fill browser-props] + (let [style (with-font-features + #js {:fontFamily (:font-family data) + :fontSize (:font-size data) + :fontWeight (:font-weight data) + :textTransform (:text-transform data) + :textDecoration (:text-decoration data) + :textCombineUpright (sts/css-text-combine-upright + (:text-combine-upright data)) + :letterSpacing (:letter-spacing data) + :fontStyle (:font-style data) + :direction (:direction data) + :textSpacingTrim "normal" + :whiteSpace "pre"} + data) + key (dm/str "text-" (:id shape) "-" index)] + (if (= "vertical-rl" (:writing-mode data)) + ;; Vertical strip: glyphs run down the column; x is the column's + ;; central axis and y the strip top (stored y is the strip bottom). + #js {:key key + :x (+ (:x data) (/ (:width data) 2)) + :y (- (:y data) (:height data)) + :textLength (:height data) + :lengthAdjust "spacingAndGlyphs" + :style (-> style + (obj/set! "writingMode" "vertical-rl") + (obj/set! "textOrientation" (or (:text-orientation data) "mixed")) + (obj/set! "textAutospace" "normal") + (obj/set! "fill" fill))} + (cond-> #js {:key key + :x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data)) + :y (:y data) + :dominantBaseline "ideographic" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style (obj/set! style "fill" fill)} + (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- ruby-text-props + "Props of the `text` element drawing a strip's ruby annotation beside it + (vertical) or above/below it (horizontal), per its side and alignment." + [shape data index ruby-font-size style] + (let [key (dm/str "ruby-" (:id shape) "-" index) + ruby-side (:ruby-side data "over")] + (if (= "vertical-rl" (:writing-mode data)) + #js {:key key + :x (if (= "under" ruby-side) + (- (:x data) (/ ruby-font-size 2)) + (+ (:x data) (:width data) (/ ruby-font-size 2))) + :y (- (:y data) (:height data)) + :textLength (:height data) + :lengthAdjust "spacingAndGlyphs" + :style style} + (let [rtl? (= "rtl" (:direction data)) + ruby-align (:ruby-align data "space-around") + fit? (or (= "none" (:ruby-overhang data "auto")) + (= "space-around" ruby-align))] + (cond-> #js {:key key + :x (if (= "center" ruby-align) + (+ (:x data) (/ (:width data) 2)) + (if rtl? (+ (:x data) (:width data)) (:x data))) + :y (if (= "under" ruby-side) + (:y data) + (- (:y data) (:height data))) + :dominantBaseline (if (= "under" ruby-side) "hanging" "text-after-edge") + :style style} + (= "center" ruby-align) + (obj/set! "textAnchor" "middle") + + (= "start" ruby-align) + (obj/set! "textAnchor" (if rtl? "end" "start")) + + fit? + (-> (obj/set! "textLength" (:width data)) + (obj/set! "lengthAdjust" "spacingAndGlyphs")) + + (= "space-between" ruby-align) + (-> (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 set-white-fill [shape] @@ -156,125 +301,24 @@ [:> :g group-props (for [[index data] (d/enumerate position-data)] - (let [rtl? (= "rtl" (:direction data)) - vertical? (= "vertical-rl" (:writing-mode data)) - ruby (:ruby data) - ruby? (and (string? ruby) (seq ruby)) - ruby-side (:ruby-side data "over") - ruby-align (:ruby-align data "space-around") - ruby-overhang (:ruby-overhang data "auto") - auto-clearance? (= "auto" (:annotation-clearance data)) - has-ruby-layer? (and (not= "under" ruby-side) - (or ruby? (:annotation-has-ruby data))) - emphasis-mark (get emphasis-mark-chars (:text-emphasis data)) - emphasis? (some? emphasis-mark) - warichu? (and (= "warichu" (:warichu data)) - (string? (:text data)) - (>= (count (:text data)) 2)) - font-features (:font-features 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))) + ;; Annotations take the fills of this strip's own render id. + annotation-fill (str "url(#fill-" index "-" render-id "-" index ")") browser-props - (cond - (and (not vertical?) (cf/check-browser? :safari)) + (when (and (not vertical?) (cf/check-browser? :safari)) #js {:dominantBaseline "hanging" :dy "0.2em" :y (- (:y data) (:height data))}) - base-style (add-font-features! - #js {:fontFamily (:font-family data) - :fontSize (:font-size data) - :fontWeight (:font-weight data) - :textTransform (:text-transform data) - :textDecoration (:text-decoration data) - :textCombineUpright (sts/css-text-combine-upright - (:text-combine-upright data)) - :letterSpacing (:letter-spacing data) - :fontStyle (:font-style data) - :direction (:direction data) - :textSpacingTrim "normal" - :whiteSpace "pre"} - font-features) - - font-size (js/parseFloat (:font-size data)) - ruby-font-size-num (if (js/isNaN font-size) - 0 - (* font-size (ruby-font-scale (:ruby-size data)))) - ruby-font-size (when (pos? ruby-font-size-num) - (dm/str ruby-font-size-num "px")) - ruby-style (add-font-features! - #js {:fontFamily (:font-family data) - :fontSize ruby-font-size - :fontWeight (:font-weight data) - :fontStyle (:font-style data) - :direction (:direction data) - :writingMode (if vertical? "vertical-rl" "horizontal-tb") - :textSpacingTrim "normal" - :textOrientation "upright" - :textAutospace "normal" - :whiteSpace "pre" - :fill (str "url(#fill-" index "-" render-id "-" index ")")} - font-features) - - emphasis-font-size-num (if (js/isNaN font-size) - 0 - (* font-size emphasis-font-scale)) - - warichu-font-size-num (if (js/isNaN font-size) - 0 - (* font-size warichu-font-scale)) - warichu-style (add-font-features! - #js {:fontFamily (:font-family data) - :fontSize (when (pos? warichu-font-size-num) - (dm/str warichu-font-size-num "px")) - :fontWeight (:font-weight data) - :fontStyle (:font-style data) - :direction (:direction data) - :writingMode (if vertical? "vertical-rl" "horizontal-tb") - :textSpacingTrim "normal" - :textOrientation (when vertical? "upright") - :textAutospace "normal" - :whiteSpace "pre" - :fill (str "url(#fill-" index "-" render-id "-" index ")")} - font-features) - emphasis-style (add-font-features! - #js {:fontFamily (:font-family data) - :fontSize (when (pos? emphasis-font-size-num) - (dm/str emphasis-font-size-num "px")) - :fontWeight (:font-weight data) - :fontStyle (:font-style data) - :direction (:direction data) - :writingMode (if vertical? "vertical-rl" "horizontal-tb") - :textSpacingTrim "normal" - :textOrientation (when vertical? "upright") - :textAutospace "normal" - :whiteSpace "pre" - :fill (str "url(#fill-" index "-" render-id "-" index ")")} - font-features) - - props (if vertical? - ;; Vertical strip: glyphs run down the column; x is - ;; the column's central axis and y the strip top - ;; (stored y is the strip bottom). - #js {:key (dm/str "text-" (:id shape) "-" index) - :x (+ (:x data) (/ (:width data) 2)) - :y (- (:y data) (:height data)) - :textLength (:height data) - :lengthAdjust "spacingAndGlyphs" - :style (-> base-style - (obj/set! "writingMode" "vertical-rl") - (obj/set! "textSpacingTrim" "normal") - (obj/set! "textOrientation" (or (:text-orientation data) "mixed")) - (obj/set! "textAutospace" "normal") - (obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))} - (-> #js {:key (dm/str "text-" (:id shape) "-" index) - :x (if rtl? (+ (:x data) (:width data)) (:x data)) - :y (:y data) - :dominantBaseline "ideographic" - :textLength (:width data) - :lengthAdjust "spacingAndGlyphs" - :style (obj/set! base-style "fill" (str "url(#fill-" index "-" render-id ")"))} - (cond-> browser-props - (obj/merge! browser-props)))) + text-props (strip-text-props shape data index + (str "url(#fill-" index "-" render-id ")") + browser-props) shape (-> shape (assoc :fills (:fills data)) ;; The text elements have the shadow and blur already applied in the @@ -290,106 +334,25 @@ [:& fills/fills {:shape shape :render-id render-id}]] [:& shape-custom-strokes {:shape shape :position index :render-id render-id} - (if warichu? - ;; Warichu: two half-size sub-lines within one inline strip. - ;; Vertical reading order is right then left; horizontal is - ;; top then bottom. - (let [text (:text data) - split-index (warichu-split-index text) - centre (+ (:x data) (/ (:width data) 2)) - quarter (/ warichu-font-size-num 2) - top (- (:y data) (:height data))] - [:g {:key (dm/str "warichu-" (:id shape) "-" index)} - (if vertical? - [:* - [:> :text {:x (+ centre quarter) - :y top - :style warichu-style} - (subs text 0 split-index)] - [:> :text {:x (- centre quarter) - :y top - :style warichu-style} - (subs text split-index)]] - [:* - [:> :text {:x (:x data) - :y top - :dominantBaseline "hanging" - :textLength (:width data) - :lengthAdjust "spacingAndGlyphs" - :style warichu-style} - (subs text 0 split-index)] - [:> :text {:x (:x data) - :y (+ top (/ (:height data) 2)) - :dominantBaseline "hanging" - :textLength (:width data) - :lengthAdjust "spacingAndGlyphs" - :style warichu-style} - (subs text split-index)]])]) - [:> :text props (:text data)])] - (when ruby? - [:> :text (if vertical? - #js {:key (dm/str "ruby-" (:id shape) "-" index) - :x (if (= "under" ruby-side) - (- (:x data) (/ ruby-font-size-num 2)) - (+ (:x data) (:width data) (/ ruby-font-size-num 2))) - :y (- (:y data) (:height data)) - :textLength (:height data) - :lengthAdjust "spacingAndGlyphs" - :style ruby-style} - (let [start-x (if rtl? (+ (:x data) (:width data)) (:x data)) - center-x (+ (:x data) (/ (:width data) 2)) - constrain? (= "none" ruby-overhang)] - (cond-> #js {:key (dm/str "ruby-" (:id shape) "-" index) - :x (if (= "center" ruby-align) center-x start-x) - :y (if (= "under" ruby-side) - (:y data) - (- (:y data) (:height data))) - :dominantBaseline (if (= "under" ruby-side) - "hanging" - "text-after-edge") - :style ruby-style} - (= "center" ruby-align) - (obj/set! "textAnchor" "middle") - - (= "start" ruby-align) - (obj/set! "textAnchor" (if rtl? "end" "start")) - - (or constrain? (= "space-around" ruby-align)) - (obj/set! "textLength" (:width data)) - - (or constrain? (= "space-around" ruby-align)) - (obj/set! "lengthAdjust" "spacingAndGlyphs") - - (= "space-between" ruby-align) - (obj/set! "textLength" (:width data)) - - (= "space-between" ruby-align) - (obj/set! "lengthAdjust" "spacing")))) + (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, right of vertical text or above horizontal text. - (when emphasis? - [:> :text (if vertical? - #js {:key (dm/str "emphasis-" (:id shape) "-" index) - :x (+ (:x data) - (:width data) - (/ emphasis-font-size-num 2) - (if (and auto-clearance? has-ruby-layer?) - ruby-font-size-num - 0)) - :y (- (:y data) (:height data)) - :textLength (:height data) - :lengthAdjust "spacing" - :style emphasis-style} - #js {:key (dm/str "emphasis-" (:id shape) "-" index) - :x (if rtl? (+ (:x data) (:width data)) (:x data)) - :y (- (:y data) - (:height data) - (if (and auto-clearance? has-ruby-layer?) - ruby-font-size-num - 0)) - :dominantBaseline "text-after-edge" - :textLength (:width data) - :lengthAdjust "spacing" - :style emphasis-style}) - (emphasis-marks-text (:text data) emphasis-mark)])]))]])) + ;; 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)]))]))]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index ad8bcc2a9c..10b430c7a3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.workspace.sidebar.options.common (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.main.data.workspace.tokens.application :as dwta] [app.main.store :as st] [app.util.dom :as dom] @@ -26,6 +27,20 @@ :ref ref} children]))) +(defn radio-selected + "Value to select in a radio group for an attribute value: its name, or + `default` when unset. Mixed (`:multiple`) values select nothing." + ([value] + (radio-selected value "")) + ([value default] + (cond + (= value :multiple) "" + (or (nil? value) + (and (string? value) (empty? value))) default + (keyword? value) (d/name value) + (string? value) value + :else (str value)))) + (defn emit-value-or-token [value emit-value-fn ids attrs] (if (or (string? value) (number? value) 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 d6008cd85e..067f7a5f0d 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 @@ -30,6 +30,7 @@ [app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.sidebar.options.common :refer [radio-selected]] [app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl] [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]] @@ -53,18 +54,6 @@ Evaluated once at module load time; cf/flags is immutable after startup." (contains? cf/flags :token-typography-row)) -(defn- radio-selected - ([value] - (radio-selected value "")) - ([value default] - (cond - (= value :multiple) "" - (or (nil? value) - (and (string? value) (empty? value))) default - (keyword? value) (d/name value) - (string? value) value - :else (str value)))) - (mf/defc text-align-options* [{:keys [values on-change on-blur]}] (let [options diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs index 64ec971ee5..51ca4e3393 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout (:require-macros [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.types.file :as ctf] [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] @@ -16,24 +15,12 @@ [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.controls.switch :refer [switch*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]] + [app.main.ui.workspace.sidebar.options.common :refer [advanced-options* radio-selected]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.text.writing-mode :as wm] [rumext.v2 :as mf])) -(defn- radio-selected - ([value] - (radio-selected value "")) - ([value default] - (cond - (= value :multiple) "" - (or (nil? value) - (and (string? value) (empty? value))) default - (keyword? value) (d/name value) - (string? value) value - :else (str value)))) - (def ^:private mixed-span-values #{:mixed :multiple "mixed" "multiple"}) @@ -47,13 +34,13 @@ "mixed" (radio-selected value))) -(defn- span-select-value +(defn span-select-value [value default] (if (mixed-span-value? value) "mixed" (radio-selected value default))) -(defn- with-mixed-span-option +(defn with-mixed-span-option [options value] (cond-> options (mixed-span-value? value) @@ -102,61 +89,57 @@ ;; Sub-components ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(mf/defc writing-mode-options* - [{:keys [values on-change on-blur]}] - (let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb") - options - (mf/with-memo [] - [{:value "horizontal-tb" - :id "horizontal-tb-writing-mode" - :label (tr "workspace.options.text-options.writing-mode-horizontal") - :icon i/writing-mode-horizontal} - {:value "vertical-rl" - :id "vertical-rl-writing-mode" - :label (tr "workspace.options.text-options.writing-mode-vertical") - :icon i/writing-mode-vertical}]) - +(mf/defc attr-radio-options* + "Radio group bound to one text attribute." + [{:keys [values on-change on-blur attr default-value name class options]}] + (let [selected (radio-selected (get values attr) default-value) handle-change (mf/use-fn - (mf/deps on-change on-blur) + (mf/deps attr on-change on-blur) (fn [value] - (on-change {:writing-mode value}) + (on-change {attr value}) (when (some? on-blur) (on-blur))))] - - [:div {:class (stl/css :writing-mode-options)} - [:> radio-buttons* {:selected writing-mode + [:div {:class class} + [:> radio-buttons* {:selected selected :on-change handle-change - :name "writing-mode-options" + :name name :options options}]])) -(mf/defc text-orientation-options* - [{:keys [values on-change on-blur]}] - ;; The v2 editor can read the paragraph orientation back as an empty - ;; string when it is unset; treat that (and nil) as the "mixed" default. - (let [text-orientation (radio-selected (:text-orientation values) "mixed") - options - (mf/with-memo [] - [{:value "mixed" - :id "mixed-text-orientation" - :label (tr "workspace.options.text-options.text-orientation-mixed") - :icon i/text-orientation-mixed} - {:value "upright" - :id "upright-text-orientation" - :label (tr "workspace.options.text-options.text-orientation-upright") - :icon i/text-orientation-upright}]) +(defn- writing-mode-options + [] + [{:value "horizontal-tb" + :id "horizontal-tb-writing-mode" + :label (tr "workspace.options.text-options.writing-mode-horizontal") + :icon i/writing-mode-horizontal} + {:value "vertical-rl" + :id "vertical-rl-writing-mode" + :label (tr "workspace.options.text-options.writing-mode-vertical") + :icon i/writing-mode-vertical}]) - handle-change - (mf/use-fn - (mf/deps on-change on-blur) - (fn [value] - (on-change {:text-orientation value}) - (when (some? on-blur) (on-blur))))] +(defn- text-orientation-options + [] + [{:value "mixed" + :id "mixed-text-orientation" + :label (tr "workspace.options.text-options.text-orientation-mixed") + :icon i/text-orientation-mixed} + {:value "upright" + :id "upright-text-orientation" + :label (tr "workspace.options.text-options.text-orientation-upright") + :icon i/text-orientation-upright}]) - [:div {:class (stl/css :text-orientation-options)} - [:> radio-buttons* {:selected text-orientation - :on-change handle-change - :name "text-orientation-options" - :options options}]])) +;; Warichu (割注): a span-scoped toggle that renders the selection as two +;; half-size lines within one inline position (top/bottom in horizontal +;; flow, right/left in vertical flow). +(defn- warichu-options + [] + [{:value "none" + :id "none-warichu" + :label (tr "workspace.options.text-options.warichu-none") + :icon i/warichu-none} + {:value "warichu" + :id "warichu-warichu" + :label (tr "workspace.options.text-options.warichu") + :icon i/warichu}]) (mf/defc text-combine-upright-options* ;; Digit TCY can be applied across a shape because it discovers eligible @@ -268,7 +251,8 @@ :on-click on-toggle-advanced :icon i/menu}]])) -(mf/defc ruby-select-option* +(mf/defc span-select-option* + "Select bound to one span attribute, with a disabled entry for mixed values." [{:keys [values on-change on-blur attr default-value label options]}] (let [value (get values attr) selected (span-select-value value default-value) @@ -329,22 +313,22 @@ {:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]] [:div {:class (stl/css :japanese-layout-controls)} [:> ruby-hidden-option* common-props] - [:> ruby-select-option* (mf/spread-props common-props + [:> span-select-option* (mf/spread-props common-props {:attr :ruby-size :default-value "half" :label (tr "workspace.options.text-options.ruby-size") :options size-options})] - [:> ruby-select-option* (mf/spread-props common-props + [:> span-select-option* (mf/spread-props common-props {:attr :ruby-align :default-value "space-around" :label (tr "workspace.options.text-options.ruby-align") :options align-options})] - [:> ruby-select-option* (mf/spread-props common-props + [:> span-select-option* (mf/spread-props common-props {:attr :ruby-overhang :default-value "auto" :label (tr "workspace.options.text-options.ruby-overhang") :options overhang-options})] - [:> ruby-select-option* (mf/spread-props common-props + [:> span-select-option* (mf/spread-props common-props {:attr :ruby-side :default-value "over" :label (tr "workspace.options.text-options.ruby-side") @@ -381,37 +365,6 @@ :is-visible open?} [:> ruby-customization-options* common-props]]])) -(mf/defc warichu-options* - ;; Warichu (割注): a span-scoped toggle that renders the selection as two - ;; half-size lines within one inline position (top/bottom in horizontal - ;; flow, right/left in vertical flow). Applies to the current selection - ;; through the shared node-attr on-change; "none" is the default. - [{:keys [values on-change on-blur]}] - (let [warichu (radio-selected (:warichu values) "none") - options - (mf/with-memo [] - [{:value "none" - :id "none-warichu" - :label (tr "workspace.options.text-options.warichu-none") - :icon i/warichu-none} - {:value "warichu" - :id "warichu-warichu" - :label (tr "workspace.options.text-options.warichu") - :icon i/warichu}]) - - handle-change - (mf/use-fn - (mf/deps on-change on-blur) - (fn [value] - (on-change {:warichu value}) - (when (some? on-blur) (on-blur))))] - - [:div {:class (stl/css :warichu-options)} - [:> radio-buttons* {:selected warichu - :on-change handle-change - :name "warichu-options" - :options options}]])) - (mf/defc font-features-options* ;; Japanese proportional metric alternates. `palt` is typically used for ;; horizontal composition and `vpal` for vertical composition; the value is @@ -432,7 +385,7 @@ (on-change {:font-features (if checked? feature "none")}) (when (some? on-blur) (on-blur))))] - [:div {:class (stl/css :font-features-options :japanese-select-option)} + [:div {:class (stl/css :japanese-select-option)} [:span {:class (stl/css :japanese-option-label)} (tr "workspace.options.text-options.font-features")] [:> switch* {:default-checked enabled? @@ -458,33 +411,6 @@ {:id "open-sesame" :label (translate "workspace.options.text-options.text-emphasis-open-sesame")}])) -(mf/defc text-emphasis-options* - ;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base - ;; character in vertical writing. Applies to the current selection through the - ;; shared node-attr on-change; "none" is the default. - [{:keys [values on-change on-blur]}] - (let [value (:text-emphasis values) - text-emphasis (span-select-value value "none") - options - (mf/with-memo [] - (text-emphasis-options)) - options (with-mixed-span-option options value) - - handle-change - (mf/use-fn - (mf/deps on-change on-blur) - (fn [value] - (on-change {:text-emphasis value}) - (when (some? on-blur) (on-blur))))] - - [:div {:class (stl/css :text-emphasis-options :japanese-select-option)} - [:span {:class (stl/css :japanese-option-label)} - (tr "workspace.options.text-options.text-emphasis")] - [:> select* {:default-selected text-emphasis - :aria-label (tr "workspace.options.text-options.text-emphasis") - :options options - :on-change handle-change}]])) - (defn annotation-clearance-options ([] (annotation-clearance-options tr)) @@ -494,26 +420,6 @@ {:id "auto" :label (translate "workspace.options.text-options.annotation-clearance-auto")}])) -(mf/defc annotation-clearance-options* - [{:keys [values on-change on-blur]}] - (let [value (:annotation-clearance values) - annotation-clearance (span-select-value value "none") - options (mf/with-memo [] (annotation-clearance-options)) - options (with-mixed-span-option options value) - handle-change - (mf/use-fn - (mf/deps on-change on-blur) - (fn [value] - (on-change {:annotation-clearance value}) - (when (some? on-blur) (on-blur))))] - [:div {:class (stl/css :annotation-clearance-options :japanese-select-option)} - [:span {:class (stl/css :japanese-option-label)} - (tr "workspace.options.text-options.annotation-clearance")] - [:> select* {:default-selected annotation-clearance - :aria-label (tr "workspace.options.text-options.annotation-clearance") - :options options - :on-change handle-change}]])) - (mf/defc japanese-layout-options* [{:keys [values ruby-values text-selection-active on-change on-ruby-presentation-change on-blur]}] @@ -525,26 +431,58 @@ ruby-presentation-props (mf/props {:values ruby-values :on-change on-ruby-presentation-change - :on-blur on-blur})] + :on-blur on-blur}) + options (mf/with-memo [] + {:writing-mode (writing-mode-options) + :text-orientation (text-orientation-options) + :warichu (warichu-options) + :text-emphasis (text-emphasis-options) + :annotation-clearance (annotation-clearance-options)})] [:div {:class (stl/css :japanese-layout-options)} [:div {:class (stl/css :japanese-layout-controls)} [:div {:class (stl/css :japanese-icon-options)} - [:> writing-mode-options* common-props] + [:> attr-radio-options* (mf/spread-props common-props + {:attr :writing-mode + :default-value "horizontal-tb" + :name "writing-mode-options" + :class (stl/css :writing-mode-options) + :options (:writing-mode options)})] (when ^boolean vertical? [:* - [:> text-orientation-options* common-props] + ;; The v2 editor can read the paragraph orientation back as an empty + ;; string when it is unset; that (and nil) selects "mixed". + [:> attr-radio-options* (mf/spread-props common-props + {:attr :text-orientation + :default-value "mixed" + :name "text-orientation-options" + :class (stl/css :text-orientation-options) + :options (:text-orientation options)})] [:> text-combine-upright-options* (mf/spread-props common-props {:text-selection-active text-selection-active})]]) (when ^boolean text-selection-active - [:> warichu-options* common-props])] + [:> attr-radio-options* (mf/spread-props common-props + {:attr :warichu + :default-value "none" + :name "warichu-options" + :class (stl/css :warichu-options) + :options (:warichu options)})])] (when ^boolean vertical? [:> text-combine-upright-count-options* common-props]) [:> font-features-options* common-props] + ;; Emphasis marks (圏点 / bouten) apply to the selected characters. (when ^boolean text-selection-active - [:> text-emphasis-options* common-props]) - [:> annotation-clearance-options* common-props] + [:> span-select-option* (mf/spread-props common-props + {:attr :text-emphasis + :default-value "none" + :label (tr "workspace.options.text-options.text-emphasis") + :options (:text-emphasis options)})]) + [:> span-select-option* (mf/spread-props common-props + {:attr :annotation-clearance + :default-value "none" + :label (tr "workspace.options.text-options.annotation-clearance") + :options (:annotation-clearance options)})] (if text-selection-active [:> ruby-advanced-options* common-props] [:> ruby-presentation-options* ruby-presentation-props])]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss index e9ddb3c921..b84d9971a5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss @@ -41,12 +41,6 @@ border-radius: $br-8; } -.text-emphasis-options, -.font-features-options, -.annotation-clearance-options { - inline-size: 100%; -} - .japanese-layout-options, .japanese-layout-controls { display: flex; diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 0068c0cb76..ee0629e144 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -13,6 +13,7 @@ [app.common.schema :as sm] [app.common.types.fills :as types.fills] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.wasm-text :as dwwt] @@ -102,24 +103,57 @@ result)))) (def ^:private japanese-range-defaults - {:text-combine-upright "none" - :text-emphasis "none" - :ruby nil - :ruby-size "half" - :ruby-align "space-around" - :ruby-overhang "auto" - :ruby-side "over" - :warichu "none" - :font-features "none" - :annotation-clearance "none"}) + ;; A range without ruby reports null to plugins. + (assoc jl/span-attr-defaults :ruby nil)) (defn- range-japanese-value - [range-data attr] + "Value of a Japanese span `attr` over a text range proxy's characters." + [range-proxy start end attr] (let [default (get japanese-range-defaults attr)] - (->> range-data + (->> (-> range-proxy u/proxy->shape :content (content-range->text+styles start end)) (map #(get % attr default)) (u/mixed-value)))) +(defn- enum-value? + "Validator accepting the strings fully matched by `re`." + [re] + (fn [value] + (and (string? value) (some? (re-matches re value))))) + +(defn- optional-string? + [value] + (or (nil? value) (string? value))) + +(defn- attr-setter + "Property setter that checks `valid?` and the plugin's write access before + emitting `(update-event self attrs)` with `{attr value}`." + [plugin-id page-id prop attr valid? update-event] + (fn [self value] + (cond + (not (valid? value)) + (u/not-valid plugin-id prop value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id prop "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id prop "Cannot modify a page that is not currently active") + + :else + (st/emit! (update-event self {attr value}))))) + +(defn- range-attr-setter + "Setter of a span attribute over the characters [start, end) of shape `id`." + [plugin-id page-id id start end prop attr valid?] + (attr-setter plugin-id page-id prop attr valid? + (fn [_ attrs] (dwt/update-text-range id start end attrs)))) + +(defn- shape-attr-setter + "Setter of a text attribute over the whole text shape." + [plugin-id page-id prop attr valid?] + (attr-setter plugin-id page-id prop attr valid? + (fn [self attrs] (dwt/update-attrs (obj/get self "$id") attrs)))) + (defn text-range-proxy? [range] (obj/type-of? range "TextRange")) @@ -394,223 +428,53 @@ :fontFeatures {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :font-features))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches font-features-re value))) - (u/not-valid plugin-id :fontFeatures value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:font-features value}))))} + :get (fn [self] (range-japanese-value self start end :font-features)) + :set (range-attr-setter plugin-id page-id id start end :fontFeatures :font-features (enum-value? font-features-re))} :textCombineUpright {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :text-combine-upright))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches text-combine-upright-re value))) - (u/not-valid plugin-id :textCombineUpright value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:text-combine-upright value}))))} + :get (fn [self] (range-japanese-value self start end :text-combine-upright)) + :set (range-attr-setter plugin-id page-id id start end :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))} :textEmphasis {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :text-emphasis))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches text-emphasis-re value))) - (u/not-valid plugin-id :textEmphasis value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:text-emphasis value}))))} + :get (fn [self] (range-japanese-value self start end :text-emphasis)) + :set (range-attr-setter plugin-id page-id id start end :textEmphasis :text-emphasis (enum-value? text-emphasis-re))} :warichu {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :warichu))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches warichu-re value))) - (u/not-valid plugin-id :warichu value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:warichu value}))))} + :get (fn [self] (range-japanese-value self start end :warichu)) + :set (range-attr-setter plugin-id page-id id start end :warichu :warichu (enum-value? warichu-re))} :annotationClearance {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :annotation-clearance))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches annotation-clearance-re value))) - (u/not-valid plugin-id :annotationClearance value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:annotation-clearance value}))))} + :get (fn [self] (range-japanese-value self start end :annotation-clearance)) + :set (range-attr-setter plugin-id page-id id start end :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))} :ruby {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :ruby))) - :set - (fn [_ value] - (cond - (and (some? value) (not (string? value))) - (u/not-valid plugin-id :ruby value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:ruby value}))))} + :get (fn [self] (range-japanese-value self start end :ruby)) + :set (range-attr-setter plugin-id page-id id start end :ruby :ruby optional-string?)} :rubySize {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :ruby-size))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches ruby-size-re value))) - (u/not-valid plugin-id :rubySize value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:ruby-size value}))))} + :get (fn [self] (range-japanese-value self start end :ruby-size)) + :set (range-attr-setter plugin-id page-id id start end :rubySize :ruby-size (enum-value? ruby-size-re))} :rubyAlign {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :ruby-align))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches ruby-align-re value))) - (u/not-valid plugin-id :rubyAlign value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:ruby-align value}))))} + :get (fn [self] (range-japanese-value self start end :ruby-align)) + :set (range-attr-setter plugin-id page-id id start end :rubyAlign :ruby-align (enum-value? ruby-align-re))} :rubyOverhang {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :ruby-overhang))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches ruby-overhang-re value))) - (u/not-valid plugin-id :rubyOverhang value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:ruby-overhang value}))))} + :get (fn [self] (range-japanese-value self start end :ruby-overhang)) + :set (range-attr-setter plugin-id page-id id start end :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))} :rubySide {:this true - :get - (fn [self] - (let [range-data - (-> self u/proxy->shape :content (content-range->text+styles start end))] - (range-japanese-value range-data :ruby-side))) - :set - (fn [_ value] - (cond - (or (not (string? value)) (not (re-matches ruby-side-re value))) - (u/not-valid plugin-id :rubySide value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-text-range id start end {:ruby-side value}))))} + :get (fn [self] (range-japanese-value self start end :ruby-side)) + :set (range-attr-setter plugin-id page-id id start end :rubySide :ruby-side (enum-value? ruby-side-re))} :direction {:this true @@ -1008,207 +872,51 @@ {:name "writingMode" :get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches writing-mode-re value))) - (u/not-valid plugin-id :writingMode value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :writingMode "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :writingMode "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:writing-mode value})))))} + :set (shape-attr-setter plugin-id page-id :writingMode :writing-mode (enum-value? writing-mode-re))} {:name "textOrientation" :get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches text-orientation-re value))) - (u/not-valid plugin-id :textOrientation value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :textOrientation "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :textOrientation "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:text-orientation value})))))} + :set (shape-attr-setter plugin-id page-id :textOrientation :text-orientation (enum-value? text-orientation-re))} {:name "textCombineUpright" :get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches text-combine-upright-re value))) - (u/not-valid plugin-id :textCombineUpright value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:text-combine-upright value})))))} + :set (shape-attr-setter plugin-id page-id :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))} {:name "textEmphasis" :get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches text-emphasis-re value))) - (u/not-valid plugin-id :textEmphasis value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:text-emphasis value})))))} + :set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? text-emphasis-re))} {:name "warichu" :get #(-> % u/proxy->shape text-props :warichu format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches warichu-re value))) - (u/not-valid plugin-id :warichu value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:warichu value})))))} + :set (shape-attr-setter plugin-id page-id :warichu :warichu (enum-value? warichu-re))} {:name "fontFeatures" :get #(-> % u/proxy->shape text-props :font-features format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches font-features-re value))) - (u/not-valid plugin-id :fontFeatures value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:font-features value})))))} + :set (shape-attr-setter plugin-id page-id :fontFeatures :font-features (enum-value? font-features-re))} {:name "annotationClearance" :get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches annotation-clearance-re value))) - (u/not-valid plugin-id :annotationClearance value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:annotation-clearance value})))))} + :set (shape-attr-setter plugin-id page-id :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))} {:name "ruby" :get #(-> % u/proxy->shape text-props :ruby format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (and (some? value) (not (string? value))) - (u/not-valid plugin-id :ruby value) - - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission") - - (not (u/page-active? page-id)) - (u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active") - - :else - (st/emit! (dwt/update-attrs id {:ruby value})))))} + :set (shape-attr-setter plugin-id page-id :ruby :ruby optional-string?)} {:name "rubySize" :get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches ruby-size-re value))) - (u/not-valid plugin-id :rubySize value) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission") - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active") - :else - (st/emit! (dwt/update-attrs id {:ruby-size value})))))} + :set (shape-attr-setter plugin-id page-id :rubySize :ruby-size (enum-value? ruby-size-re))} {:name "rubyAlign" :get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches ruby-align-re value))) - (u/not-valid plugin-id :rubyAlign value) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission") - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active") - :else - (st/emit! (dwt/update-attrs id {:ruby-align value})))))} + :set (shape-attr-setter plugin-id page-id :rubyAlign :ruby-align (enum-value? ruby-align-re))} {:name "rubyOverhang" :get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches ruby-overhang-re value))) - (u/not-valid plugin-id :rubyOverhang value) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission") - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active") - :else - (st/emit! (dwt/update-attrs id {:ruby-overhang value})))))} + :set (shape-attr-setter plugin-id page-id :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))} {:name "rubySide" :get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed) - :set - (fn [self value] - (let [id (obj/get self "$id")] - (cond - (or (not (string? value)) (not (re-matches ruby-side-re value))) - (u/not-valid plugin-id :rubySide value) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission") - (not (u/page-active? page-id)) - (u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active") - :else - (st/emit! (dwt/update-attrs id {:ruby-side value})))))} + :set (shape-attr-setter plugin-id page-id :rubySide :ruby-side (enum-value? ruby-side-re))} {:name "textBounds" :get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)}))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0a4ce78ea3..cd231588b3 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -31,6 +31,7 @@ [app.common.types.path.impl :as path.impl] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.helpers :as dsh] @@ -2931,60 +2932,90 @@ (def POSITION-DATA-U8-SIZE 36) (def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) -(defn- ruby-font-scale - [ruby-size] - (case ruby-size - "third" (/ 1 3) - "quarter" 0.25 - 0.5)) - -(defn- horizontal-ruby-slice - "Returns the whole-span ruby annotation for a horizontal base strip." - [element _start-pos _end-pos] - (let [text (:text element) - ruby (:ruby element)] - (when (and (not (true? (:ruby-hidden element))) - (string? text) - (seq text) - (string? ruby) - (seq ruby)) - ruby))) +(def ^:private POSITION-DATA-VERTICAL 2) +(def ^:private POSITION-DATA-RUBY 3) (defn- ruby-strip-entry - "Position-data entry for a ruby annotation strip (direction 3): the - offsets index the span's ruby string and the geometry is the exact - gutter placement the canvas paints." + "Position-data entry for a ruby annotation strip: the offsets index the + span's ruby string and the geometry is the exact gutter placement the + canvas paints." [element {:keys [start-pos end-pos x y width height]}] - (let [ruby (get element :ruby)] - (when (and (not (true? (:ruby-hidden element))) - (string? ruby)) - (let [text (subs ruby - (min start-pos (count ruby)) - (min end-pos (count ruby))) - font-size (js/parseFloat (get element :font-size))] - (when (seq text) - (d/patch-object - (txt/get-default-text-attrs) - (d/without-nils - {:x x - :y (+ y height) - :width width - :height height - :direction "ltr" - :writing-mode "vertical-rl" - :text-orientation "upright" - :font-id (get element :font-id) - :font-family (get element :font-family) - :font-size (when-not (js/isNaN font-size) - (dm/str (* (ruby-font-scale (:ruby-size element)) font-size) "px")) - :font-weight (get element :font-weight) - :font-style (get element :font-style) - :ruby-size (get element :ruby-size) - :ruby-align (get element :ruby-align) - :ruby-overhang (get element :ruby-overhang) - :ruby-side (get element :ruby-side) - :fills (get element :fills) - :text text}))))))) + (when-let [ruby (jl/visible-ruby element)] + (let [text (subs ruby + (min start-pos (count ruby)) + (min end-pos (count ruby))) + font-size (js/parseFloat (get element :font-size))] + (when (seq text) + (d/patch-object + (txt/get-default-text-attrs) + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction "ltr" + :writing-mode "vertical-rl" + :text-orientation "upright" + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (when-not (js/isNaN font-size) + (dm/str (* (jl/ruby-font-scale (:ruby-size element)) font-size) "px")) + :font-weight (get element :font-weight) + :font-style (get element :font-style) + :ruby-size (get element :ruby-size) + :ruby-align (get element :ruby-align) + :ruby-overhang (get element :ruby-overhang) + :ruby-side (get element :ruby-side) + :fills (get element :fills) + :text text})))))) + +(defn- text-strip-entry + "Position-data entry for a strip of base text. Be aware that for RTL texts + `start-pos` can be greater than `end-pos`." + [paragraph element {:keys [start-pos end-pos direction x y width height]}] + (when-let [element-text (:text element)] + (let [vertical? (= direction POSITION-DATA-VERTICAL) + ruby (jl/visible-ruby element)] + (d/patch-object + (txt/get-default-text-attrs) + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + ;; The SVG renderer draws vertical strips with CSS writing-mode. + :writing-mode (when vertical? "vertical-rl") + ;; Glyph orientation is stored on the span or its paragraph. + :text-orientation (when vertical? + (not-empty (or (get element :text-orientation) + (get paragraph :text-orientation)))) + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (dm/str (get element :font-size) "px") + :font-weight (get element :font-weight) + :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)) + :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) + :ruby-size (get element :ruby-size) + :ruby-align (get element :ruby-align) + :ruby-overhang (get element :ruby-overhang) + :ruby-side (get element :ruby-side) + :letter-spacing (dm/str (get element :letter-spacing) "px") + :font-style (get element :font-style) + :fills (get element :fills) + :text (subs element-text start-pos end-pos)}))))) (defn calculate-position-data [shape] @@ -3013,82 +3044,14 @@ (into [] (keep - (fn [{:keys [paragraph span start-pos end-pos direction x y width height] :as entry}] + (fn [{:keys [paragraph span direction] :as entry}] (let [paragraph-node (-> content :children (get 0) :children ;; paragraph-set (get paragraph)) - element (-> paragraph-node :children ;; paragraph - (get span)) - element-text (:text element)] - (if (= direction 3) + element (-> paragraph-node :children (get span))] + (if (= direction POSITION-DATA-RUBY) (ruby-strip-entry element entry) - ;; Glyph orientation of a vertical strip; stored on the - ;; span or its paragraph. Empty reads normalize to nil - ;; (the SVG renderer then defaults to "mixed"). - (let [text-orientation - (when (= direction 2) - (let [orientation (or (get element :text-orientation) - (get paragraph-node :text-orientation))] - (when (seq orientation) orientation)))] - - ;; Add comprehensive nil-safety checks - ;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos` - (when (and element element-text) - (let [text (subs element-text start-pos end-pos)] - (d/patch-object - (txt/get-default-text-attrs) - (d/without-nils - {:x x - :y (+ y height) - :width width - :height height - :direction (dr/translate-direction direction) - ;; Direction 2 marks a vertical-rl column strip; - ;; the SVG renderer draws it with CSS writing-mode. - :writing-mode (when (= direction 2) "vertical-rl") - :text-orientation text-orientation - :font-id (get element :font-id) - :font-family (get element :font-family) - :font-size (dm/str (get element :font-size) "px") - :font-weight (get element :font-weight) - :text-transform (get element :text-transform) - :text-decoration (get element :text-decoration) - :text-combine-upright (get element :text-combine-upright) - ;; Emphasis marks (圏点) are drawn by the static SVG - ;; renderer; "none" carries no information. - :text-emphasis (let [emphasis (get element :text-emphasis)] - (when (and (string? emphasis) - (seq emphasis) - (not= "none" emphasis)) - emphasis)) - :annotation-clearance - (let [clearance (get element :annotation-clearance)] - (when (= "auto" clearance) clearance)) - :annotation-has-ruby - (let [ruby (get element :ruby)] - (when (and (not (true? (:ruby-hidden element))) - (string? ruby) - (seq ruby)) - true)) - ;; Horizontal position data has no separate ruby - ;; strip, so carry the annotation on the base entry - ;; for static SVG export. Vertical ruby has its own - ;; direction-3 position entry. - :ruby (when (not= direction 2) - (horizontal-ruby-slice - element start-pos end-pos)) - :ruby-size (get element :ruby-size) - :ruby-align (get element :ruby-align) - :ruby-overhang (get element :ruby-overhang) - :ruby-side (get element :ruby-side) - ;; Warichu spans render as two half-size sub-columns - ;; in the static SVG; "none" carries no information. - :warichu (let [warichu (get element :warichu)] - (when (= "warichu" warichu) warichu)) - :letter-spacing (dm/str (get element :letter-spacing) "px") - :font-style (get element :font-style) - :fills (get element :fills) - :text text}))))))))) + (text-strip-entry paragraph-node element entry))))) result)))) (defn apply-canvas-blur diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index b2071bea38..1ec2c0f503 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -697,29 +697,16 @@ [para] (apply + (map (fn [span] (count (:text span))) (:children para)))) -(def ^:private japanese-span-style-defaults - "Explicit UI defaults for Japanese span attributes. These values must be - present in every selection snapshot so moving onto an unstyled span clears - the previous span's controls instead of leaving stale values behind." - {:text-combine-upright "none" - :text-emphasis "none" - :ruby "" - :ruby-hidden false - :ruby-size "half" - :ruby-align "space-around" - :ruby-overhang "auto" - :ruby-side "over" - :warichu "none" - :font-features "none" - :annotation-clearance "none"}) - (defn- span-japanese-styles + "Japanese span attributes with their defaults filled in. Every selection + snapshot carries all of them, so moving onto an unstyled span clears the + previous span's controls instead of leaving stale values behind." [span] (reduce-kv (fn [styles attr default] (assoc styles attr (or (get span attr) default))) {} - japanese-span-style-defaults)) + jl/span-attr-defaults)) (defn- merge-selection-styles [result styles] diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs index fd6c7900c3..4f8dd85099 100644 --- a/frontend/test/frontend_tests/copy_as_svg_test.cljs +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -38,223 +38,44 @@ [re s] (count (re-seq re s))) -(defn- setup-vertical-text - [] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 40 - :height 100}) - (assoc :name "Vertical text" - :position-data - [{:x 20 - :y 100 - :width 24 - :height 80 - :fills [{:fill-color "#112233" - :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "vertical-rl" - :text-orientation "upright" - :font-features "vpal" - :text "うA"}])) +(def ^:private strip-defaults + {:fills [{:fill-color "#112233" :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400"}) + +(defn- setup-text + "A text shape whose single position-data strip is `strip` (over + `strip-defaults`), with its objects and selection." + [{:keys [width height]} strip] + (let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height}) + (assoc :name "Text" + :position-data [(merge strip-defaults strip)])) file (cths/add-sample-shape (cthf/sample-file :file1 :page-label :page1) - :vertical-text + :text shape) - page (cthf/current-page file) - objects (:objects page)] + objects (:objects (cthf/current-page file))] {:objects objects - :shapes [(get objects (cthi/id :vertical-text))]})) + :shapes [(get objects (cthi/id :text))]})) -(defn- setup-vertical-ruby-text - [] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 60 - :height 100}) - (assoc :name "Vertical ruby text" - :position-data - [{:x 20 - :y 100 - :width 24 - :height 80 - :fills [{:fill-color "#112233" - :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "vertical-rl" - :text-orientation "upright" - :text "漢字" - :ruby "かんじ"}])) - file (cths/add-sample-shape - (cthf/sample-file :file1 :page-label :page1) - :vertical-ruby-text - shape) - page (cthf/current-page file) - objects (:objects page)] - {:objects objects - :shapes [(get objects (cthi/id :vertical-ruby-text))]})) +(defn- vertical-strip + [attrs] + (merge {:x 20 :y 100 :width 24 :height 80 + :writing-mode "vertical-rl" + :text-orientation "upright"} + attrs)) -(defn- setup-vertical-emphasis-text - [] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 60 - :height 100}) - (assoc :name "Vertical emphasis text" - :position-data - [{:x 20 - :y 100 - :width 24 - :height 80 - :fills [{:fill-color "#112233" - :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "vertical-rl" - :text-orientation "upright" - :text "強調 あ" - :text-emphasis "filled-dot"}])) - file (cths/add-sample-shape - (cthf/sample-file :file1 :page-label :page1) - :vertical-emphasis-text - shape) - page (cthf/current-page file) - objects (:objects page)] - {:objects objects - :shapes [(get objects (cthi/id :vertical-emphasis-text))]})) - -(defn- setup-horizontal-emphasis-text - [] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 120 - :height 40}) - (assoc :name "Horizontal emphasis text" - :position-data - [{:x 20 - :y 60 - :width 100 - :height 20 - :fills [{:fill-color "#112233" - :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "horizontal-tb" - :text "強調、 あ" - :text-emphasis "filled-dot"}])) - file (cths/add-sample-shape - (cthf/sample-file :file1 :page-label :page1) - :horizontal-emphasis-text - shape) - page (cthf/current-page file) - objects (:objects page)] - {:objects objects - :shapes [(get objects (cthi/id :horizontal-emphasis-text))]})) - -(defn- setup-horizontal-stacked-annotations - [] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 120 - :height 40}) - (assoc :name "Horizontal stacked annotations" - :position-data - [{:x 20 - :y 60 - :width 100 - :height 20 - :fills [{:fill-color "#112233" :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "horizontal-tb" - :text "漢字" - :ruby "かんじ" - :text-emphasis "filled-dot" - :annotation-clearance "auto" - :annotation-has-ruby true}])) - file (cths/add-sample-shape - (cthf/sample-file :file1 :page-label :page1) - :horizontal-stacked-annotations - shape) - page (cthf/current-page file) - objects (:objects page)] - {:objects objects - :shapes [(get objects (cthi/id :horizontal-stacked-annotations))]})) +(defn- horizontal-strip + [attrs] + (merge {:x 20 :y 60 :width 100 :height 20 + :writing-mode "horizontal-tb"} + attrs)) (defn- setup-vertical-warichu-text - ([] (setup-vertical-warichu-text "割注入り")) - ([text] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 60 - :height 100}) - (assoc :name "Vertical warichu text" - :position-data - [{:x 20 - :y 100 - :width 24 - :height 40 - :fills [{:fill-color "#112233" - :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "vertical-rl" - :text-orientation "upright" - :text text - :warichu "warichu"}])) - file (cths/add-sample-shape - (cthf/sample-file :file1 :page-label :page1) - :vertical-warichu-text - shape) - page (cthf/current-page file) - objects (:objects page)] - {:objects objects - :shapes [(get objects (cthi/id :vertical-warichu-text))]}))) - -(defn- setup-horizontal-warichu-text - ([] (setup-horizontal-warichu-text "割注入り")) - ([text] - (let [shape (-> (cts/setup-shape {:type :text - :x 10 - :y 20 - :width 120 - :height 40}) - (assoc :name "Horizontal warichu text" - :position-data - [{:x 20 - :y 60 - :width 40 - :height 20 - :fills [{:fill-color "#112233" - :fill-opacity 1}] - :font-family "Noto Sans CJK JP" - :font-size "20" - :font-weight "400" - :writing-mode "horizontal-tb" - :text text - :warichu "warichu"}])) - file (cths/add-sample-shape - (cthf/sample-file :file1 :page-label :page1) - :horizontal-warichu-text - shape) - page (cthf/current-page file) - objects (:objects page)] - {:objects objects - :shapes [(get objects (cthi/id :horizontal-warichu-text))]}))) + [text] + (setup-text {:width 60 :height 100} + (vertical-strip {:height 40 :text text :warichu "warichu"}))) (deftest empty-selection-yields-empty-string (is (= "" (svg/generate-markup {} [])))) @@ -281,7 +102,8 @@ (deftest vertical-text-svg-preserves-browser-layout-properties (testing "Static SVG carries the vertical writing properties used by browser exports" - (let [{:keys [objects shapes]} (setup-vertical-text) + (let [{:keys [objects shapes]} (setup-text {:width 40 :height 100} + (vertical-strip {:font-features "vpal" :text "うA"})) markup (svg/generate-markup objects shapes)] (is (re-find #"writing-mode:vertical-rl" markup)) (is (re-find #"text-orientation:upright" markup)) @@ -291,7 +113,8 @@ (deftest vertical-emphasis-svg-emits-static-marks (testing "Static SVG draws emphasis marks and skips whitespace characters" - (let [{:keys [objects shapes]} (setup-vertical-emphasis-text) + (let [{:keys [objects shapes]} (setup-text {:width 60 :height 100} + (vertical-strip {:text "強調 あ" :text-emphasis "filled-dot"})) markup (svg/generate-markup objects shapes)] (is (re-find #"強調 あ" markup)) ;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark. @@ -301,7 +124,8 @@ (deftest horizontal-emphasis-svg-emits-static-marks-above-the-text (testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace" - (let [{:keys [objects shapes]} (setup-horizontal-emphasis-text) + (let [{:keys [objects shapes]} (setup-text {:width 120 :height 40} + (horizontal-strip {:text "強調、 あ" :text-emphasis "filled-dot"})) markup (svg/generate-markup objects shapes)] (is (re-find #"強調、 あ" markup)) ;; Five Unicode base characters: two marks, punctuation and whitespace @@ -317,7 +141,12 @@ (deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby (testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em" - (let [{:keys [objects shapes]} (setup-horizontal-stacked-annotations) + (let [{:keys [objects shapes]} (setup-text {:width 120 :height 40} + (horizontal-strip {:text "漢字" + :ruby "かんじ" + :text-emphasis "filled-dot" + :annotation-clearance "auto" + :annotation-has-ruby true})) markup (svg/generate-markup objects shapes)] (is (re-find #"かんじ" markup)) (is (re-find #"••" markup)) @@ -326,7 +155,7 @@ (deftest vertical-warichu-svg-emits-two-sub-columns (testing "Static SVG splits a warichu strip into two half-size sub-columns" - (let [{:keys [objects shapes]} (setup-vertical-warichu-text) + (let [{:keys [objects shapes]} (setup-vertical-warichu-text "割注入り") markup (svg/generate-markup objects shapes)] (is (re-find #"割注" markup)) (is (re-find #"入り" markup)) @@ -354,7 +183,8 @@ (deftest horizontal-warichu-svg-emits-two-stacked-sub-lines (testing "Static SVG splits horizontal warichu into top and bottom half-size lines" - (let [{:keys [objects shapes]} (setup-horizontal-warichu-text) + (let [{:keys [objects shapes]} (setup-text {:width 120 :height 40} + (horizontal-strip {:width 40 :text "割注入り" :warichu "warichu"})) markup (svg/generate-markup objects shapes)] (is (re-find #"割注" markup)) (is (re-find #"入り" markup)) @@ -365,7 +195,8 @@ (deftest vertical-ruby-svg-emits-static-annotation (testing "Static SVG keeps ruby visible without falling back to foreignObject" - (let [{:keys [objects shapes]} (setup-vertical-ruby-text) + (let [{:keys [objects shapes]} (setup-text {:width 60 :height 100} + (vertical-strip {:text "漢字" :ruby "かんじ"})) markup (svg/generate-markup objects shapes)] (is (re-find #"漢字" markup)) (is (re-find #"かんじ" markup)) diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index 28834ebe16..5626c00414 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -26,10 +26,10 @@ ;; so negative values were refused. Pin the accept/reject contract of the ;; plugin setter here. -(defn- apply-letter-spacing - "Sets `letterSpacing` on a text range proxy and returns the attributes sent - to the update event, or nil when the plugin rejected the value." - [value] +(defn- apply-range-property + "Sets `property` on a text range proxy and returns the attributes sent to + the update event, or nil when the plugin rejected the value." + [property value] (let [captured (atom nil) range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 4)] @@ -41,19 +41,12 @@ (reset! captured attrs) :update-text-range) st/emit! mock/noop] - (set! (.-letterSpacing range) value) + (unchecked-set range property value) @captured))) -(def ^:private font-features-re @#'plugins.text/font-features-re) -(def ^:private annotation-clearance-re @#'plugins.text/annotation-clearance-re) -(def ^:private ruby-size-re @#'plugins.text/ruby-size-re) -(def ^:private ruby-align-re @#'plugins.text/ruby-align-re) -(def ^:private ruby-overhang-re @#'plugins.text/ruby-overhang-re) -(def ^:private ruby-side-re @#'plugins.text/ruby-side-re) - -(defn- valid-font-features? [s] (boolean (re-matches font-features-re s))) -(defn- valid-annotation-clearance? [s] - (boolean (re-matches annotation-clearance-re s))) +(defn- apply-letter-spacing + [value] + (apply-range-property "letterSpacing" value)) (t/deftest letter-spacing-accepts-negative-values (t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56"))) @@ -70,28 +63,21 @@ (t/is (nil? (apply-letter-spacing "1-2"))) (t/is (nil? (apply-letter-spacing "--1")))) -(t/deftest font-features-re-accepts-supported-japanese-proportional-features - (t/is (valid-font-features? "none")) - (t/is (valid-font-features? "palt")) - (t/is (valid-font-features? "vpal")) - (t/is (not (valid-font-features? "liga"))) - (t/is (not (valid-font-features? "palt,vpal")))) - -(t/deftest annotation-clearance-re-accepts-supported-policies - (t/is (valid-annotation-clearance? "none")) - (t/is (valid-annotation-clearance? "auto")) - (t/is (not (valid-annotation-clearance? "always")))) - -(t/deftest ruby-customization-validates-supported-values - (t/is (every? #(re-matches ruby-size-re %) ["half" "third" "quarter"])) - (t/is (not (re-matches ruby-size-re "full"))) - (t/is (every? #(re-matches ruby-align-re %) - ["space-around" "center" "start" "space-between"])) - (t/is (not (re-matches ruby-align-re "end"))) - (t/is (every? #(re-matches ruby-overhang-re %) ["auto" "none"])) - (t/is (not (re-matches ruby-overhang-re "always"))) - (t/is (every? #(re-matches ruby-side-re %) ["over" "under"])) - (t/is (not (re-matches ruby-side-re "right")))) +(t/deftest japanese-range-properties-accept-only-supported-values + (doseq [[property attr accepted rejected] + [["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]] + ["annotationClearance" :annotation-clearance ["none" "auto"] ["always"]] + ["rubySize" :ruby-size ["half" "third" "quarter"] ["full"]] + ["rubyAlign" :ruby-align ["space-around" "center" "start" "space-between"] ["end"]] + ["rubyOverhang" :ruby-overhang ["auto" "none"] ["always"]] + ["rubySide" :ruby-side ["over" "under"] ["right"]] + ["ruby" :ruby ["かんじ" nil] [42]]]] + (doseq [value accepted] + (t/is (= {attr value} (apply-range-property property value)) + (str property " accepts " (pr-str value)))) + (doseq [value rejected] + (t/is (nil? (apply-range-property property value)) + (str property " rejects " (pr-str value)))))) (t/deftest text-range-japanese-properties-read-span-values (let [file-id (random-uuid) diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index 467b5b5a99..6098480d40 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -5,12 +5,12 @@ ;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns frontend-tests.render-wasm.texts-test - "Unit tests for CJK script classification and the Han-unification - resolution policy used to pick Noto fallback fonts." + "Japanese text support in the WASM bridge: span serialization, editor + selection styles, IME composition caret placement, and the CJK script + classification used to pick Noto fallback fonts." (:require [app.common.fonts :as cfnt] [app.common.render-wasm.text-content :as tc] - [app.common.render-wasm.wasm :as wasm] [app.main.ui.workspace.shapes.text.v3-editor :as v3-editor] [app.render-wasm.api :as api] [app.render-wasm.api.texts :as texts] @@ -148,119 +148,66 @@ (t/is (not-any? #(contains? % :text-orientation) paragraphs)) (t/is (= [nil "center" nil] (mapv :text-align paragraphs))))) -(t/deftest write-spans-serializes-font-features-in-reserved-byte - (let [sentinel (js-obj) - previous (if (.hasOwnProperty wasm/serializers "font-features") - (unchecked-get wasm/serializers "font-features") - sentinel)] - (try - (aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2}) - (let [buffer (js/ArrayBuffer. 256) - dview (js/DataView. buffer) - span {:text "日本語" - :font-size "16" - :font-weight "400" - :font-features "vpal"} - paragraph {:font-size "16" - :font-weight "400" - :line-height "1"}] - (write-spans 0 dview [span] paragraph) - (t/is (= 2 (.getUint8 dview 8))) - (t/is (= 0 (.getUint8 dview 9))) - (t/is (= 0 (.getUint8 dview 14))) - (t/is (= 0 (.getUint8 dview 15)))) - (finally - (if (identical? sentinel previous) - (js-delete wasm/serializers "font-features") - (aset wasm/serializers "font-features" previous)))))) +;; Byte offsets inside one span's attribute block (see `write-spans` and +;; RawTextSpan in render-wasm). +(def ^:private span-attr-offset + {:text-combine-upright 5 + :font-features 8 + :annotation-clearance 9 + :ruby-size 10 + :ruby-align 11 + :ruby-overhang 12 + :ruby-side 13 + :padding [14 15] + :ruby-length 72}) -(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte - (let [sentinel (js-obj) - previous (if (.hasOwnProperty wasm/serializers "annotation-clearance") - (unchecked-get wasm/serializers "annotation-clearance") - sentinel)] - (try - (aset wasm/serializers "annotation-clearance" #js {"none" 0 "auto" 1}) - (let [buffer (js/ArrayBuffer. 256) - dview (js/DataView. buffer) - span {:text "漢字" - :font-size "16" - :font-weight "400" - :annotation-clearance "auto"} - paragraph {:font-size "16" - :font-weight "400" - :line-height "1"}] - (write-spans 0 dview [span] paragraph) - (t/is (= 1 (.getUint8 dview 9))) - (t/is (= 0 (.getUint8 dview 14))) - (t/is (= 0 (.getUint8 dview 15)))) - (finally - (if (identical? sentinel previous) - (js-delete wasm/serializers "annotation-clearance") - (aset wasm/serializers "annotation-clearance" previous)))))) +(def ^:private span-paragraph + {:font-size "16" :font-weight "400" :line-height "1"}) -(t/deftest write-spans-serializes-ruby-customization-bytes - (let [buffer (js/ArrayBuffer. 256) - dview (js/DataView. buffer) - span {:text "漢字" - :font-size "16" - :font-weight "400" - :ruby-size "quarter" - :ruby-align "space-between" - :ruby-overhang "none" - :ruby-side "under"} - paragraph {:font-size "16" - :font-weight "400" - :line-height "1"}] - (write-spans 0 dview [span] paragraph) - (t/is (= [2 3 1 1 0 0] - (mapv #(.getUint8 dview %) (range 10 16)))))) +(defn- serialized-span + "DataView over the attribute block `write-spans` produces for `span`." + [span] + (let [dview (js/DataView. (js/ArrayBuffer. 256))] + (write-spans 0 dview [(merge {:font-size "16" :font-weight "400"} span)] span-paragraph) + dview)) -(t/deftest write-spans-omits-hidden-ruby-bytes - (let [buffer (js/ArrayBuffer. 256) - dview (js/DataView. buffer) - paragraph {:font-size "16" - :font-weight "400" - :line-height "1"}] - (write-spans 0 dview [{:text "日" - :ruby "にち" - :ruby-hidden true - :font-size "16" - :font-weight "400"}] - paragraph) - (t/is (= 0 (.getInt32 dview 72 true))) - (write-spans 0 dview [{:text "日" - :ruby "にち" - :ruby-hidden false - :font-size "16" - :font-weight "400"}] - paragraph) - (t/is (pos? (.getInt32 dview 72 true))))) +(defn- span-byte + [dview attr] + (.getUint8 dview (get span-attr-offset attr))) + +(defn- padding-bytes + [dview] + (mapv #(.getUint8 dview %) (:padding span-attr-offset))) + +(t/deftest write-spans-serializes-font-features + (let [dview (serialized-span {:text "日本語" :font-features "vpal"})] + (t/is (= 2 (span-byte dview :font-features))) + (t/is (= 0 (span-byte dview :annotation-clearance))) + (t/is (= [0 0] (padding-bytes dview))))) + +(t/deftest write-spans-serializes-annotation-clearance + (let [dview (serialized-span {:text "漢字" :annotation-clearance "auto"})] + (t/is (= 1 (span-byte dview :annotation-clearance))) + (t/is (= [0 0] (padding-bytes dview))))) + +(t/deftest write-spans-serializes-ruby-customization + (let [dview (serialized-span {:text "漢字" + :ruby-size "quarter" + :ruby-align "space-between" + :ruby-overhang "none" + :ruby-side "under"})] + (t/is (= [2 3 1 1] + (mapv #(span-byte dview %) [:ruby-size :ruby-align :ruby-overhang :ruby-side]))) + (t/is (= [0 0] (padding-bytes dview))))) + +(t/deftest write-spans-omits-hidden-ruby + (let [ruby-length #(.getInt32 % (:ruby-length span-attr-offset) true)] + (t/is (= 0 (ruby-length (serialized-span {:text "日" :ruby "にち" :ruby-hidden true})))) + (t/is (pos? (ruby-length (serialized-span {:text "日" :ruby "にち" :ruby-hidden false})))))) (t/deftest write-spans-serializes-counted-digits-tcy - (let [sentinel (js-obj) - previous (if (.hasOwnProperty wasm/serializers "text-combine-upright") - (unchecked-get wasm/serializers "text-combine-upright") - sentinel)] - (try - (aset wasm/serializers "text-combine-upright" - #js {"none" 0 "all" 1 "digits" 2 "digits2" 3 "digits3" 4}) - (let [buffer (js/ArrayBuffer. 256) - dview (js/DataView. buffer) - span {:text "平成31年" - :font-size "16" - :font-weight "400" - :text-combine-upright "digits2"} - paragraph {:font-size "16" - :font-weight "400" - :line-height "1"}] - (write-spans 0 dview [span] paragraph) - ;; Byte 5 of the span attr block carries text-combine-upright. - (t/is (= 3 (.getUint8 dview 5)))) - (finally - (if (identical? sentinel previous) - (js-delete wasm/serializers "text-combine-upright") - (aset wasm/serializers "text-combine-upright" previous)))))) + (let [dview (serialized-span {:text "平成31年" :text-combine-upright "digits2"})] + (t/is (= 3 (span-byte dview :text-combine-upright))))) (t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery (let [content {:children diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs index abf932c49d..bfe124e4f0 100644 --- a/frontend/test/frontend_tests/ui/text_options_test.cljs +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -11,6 +11,7 @@ [app.main.ui.ds.controls.select :as select] [app.main.ui.shapes.text.html-text :as html-text] [app.main.ui.shapes.text.styles :as text-styles] + [app.main.ui.workspace.sidebar.options.common :refer [radio-selected]] [app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl] [cljs.test :as t :include-macros true] [cuerdas.core :as str] @@ -29,11 +30,6 @@ :font-features :annotation-clearance]) -(def ^:private radio-selected @#'tjl/radio-selected) -(def ^:private span-input-value @#'tjl/span-input-value) -(def ^:private span-select-value @#'tjl/span-select-value) -(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option) -(def ^:private ruby-common-props @#'tjl/ruby-common-props) (t/deftest japanese-layout-controls-follow-file-or-profile-configuration (t/is (false? (tjl/japanese-layout-config-enabled? {} {}))) @@ -105,13 +101,6 @@ (t/is (str/includes? markup ">日")) (t/is (not (str/includes? markup " = shape.visible_strokes().rev().cloned().collect(); - let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| { - let max_height = text_vertical::wrap_height(text_content, bounds.height()); - text_vertical::layout_from_content(text_content, max_height) - }); + 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); @@ -90,8 +88,7 @@ pub fn render_vertical_text( let grid_layout = match layout.as_ref() { Some(layout) => layout, None => { - let max_height = text_vertical::wrap_height(text_content, bounds.height()); - owned_layout = text_vertical::layout_from_content(text_content, max_height); + owned_layout = text_vertical::layout_for_box(text_content, bounds.height()); &owned_layout } }; @@ -713,7 +710,7 @@ fn paint_text_with_emoji_overlay( } if ruby_per_paragraph { - crate::shapes::text_vertical::paint_horizontal_ruby( + crate::shapes::paint_horizontal_ruby( canvas, text_content, para.source_paragraph, diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 8afea390f6..2f5ac9784f 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -146,8 +146,7 @@ fn vertical_layout_for_shape( shape: &Shape, ) -> (text_vertical::VerticalLayout, f32) { let selrect = shape.selrect(); - let max_height = text_vertical::wrap_height(text_content, selrect.height()); - let layout = text_vertical::layout_from_content(text_content, max_height); + let layout = text_vertical::layout_for_box(text_content, selrect.height()); let origin_x = text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align()); (layout, origin_x) diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index e129927b74..6f98a605dc 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -70,10 +70,8 @@ impl<'a> VectorRenderer<'a> { 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(|| { - let max_height = text_vertical::wrap_height(text_content, bounds.height()); - text_vertical::layout_from_content(text_content, max_height) - }); + let layout = (!drop_shadows.is_empty() || !strokes.is_empty()) + .then(|| text_vertical::layout_for_box(text_content, bounds.height())); if let Some(layout) = &layout { for shadow in &drop_shadows { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 803b331485..270523bd4d 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -575,9 +575,7 @@ impl TextContent { .iter() .flat_map(|p| p.children()) .any(|span| { - span.warichu - || !span.ruby.trim().is_empty() - || span.text_emphasis.mark_char().is_some() + span.warichu || span.has_ruby() || span.text_emphasis.mark_char().is_some() }) } @@ -798,8 +796,7 @@ impl TextContent { // arrives selrect-local; the content block is right-anchored. if self.is_vertical() { let bounds = self.bounds(); - let max_height = super::text_vertical::wrap_height(self, bounds.height()); - let layout = super::text_vertical::layout_from_content(self, max_height); + let layout = super::text_vertical::layout_for_box(self, bounds.height()); let cx = point.x - super::text_vertical::block_axis_offset( bounds.width(), @@ -1284,15 +1281,14 @@ impl TextContent { if self.is_vertical() { match self.grow_type() { GrowType::AutoWidth => { - let max_height = super::text_vertical::wrap_height(self, selrect.height()); - let (width, height) = super::text_vertical::measure_content(self, max_height); + let (width, height) = + super::text_vertical::measure_content(self, selrect.height()); self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.max_width = self.size.width; } GrowType::AutoHeight => { - let max_height = super::text_vertical::wrap_height(self, selrect.height()); - let (width, _) = super::text_vertical::measure_content(self, max_height); + let (width, _) = super::text_vertical::measure_content(self, selrect.height()); self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.height = selrect.height(); self.size.max_width = self.size.width; @@ -1414,8 +1410,7 @@ impl TextContent { // Vertical writing: hit-test against the laid-out cells directly // (absolute coordinates, right-anchored to the selrect). if self.is_vertical() { - let max_height = super::text_vertical::wrap_height(self, shape.selrect.height()); - let layout = super::text_vertical::layout_from_content(self, max_height); + let layout = super::text_vertical::layout_for_box(self, shape.selrect.height()); return super::text_vertical::intersects( &layout, &shape.selrect, @@ -1635,29 +1630,6 @@ impl Paragraph { } } -/// Capitalize the first letter of each word, preserving all original whitespace. -/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after -/// any non-letter character (whitespace, punctuation, digits, symbols). -#[cfg(test)] -fn capitalize_words(text: &str) -> String { - let mut result = String::with_capacity(text.len()); - let mut capitalize_next = true; - for c in text.chars() { - if c.is_alphabetic() { - if capitalize_next { - result.extend(c.to_uppercase()); - } else { - result.push(c); - } - capitalize_next = false; - } else { - result.push(c); - capitalize_next = true; - } - } - result -} - /// Add `text`, pushing every '\t' as a one em wide placeholder. pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { let tab = PlaceholderStyle::new( @@ -1676,28 +1648,6 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: } } -/// Filter control characters below U+0020, preserving tabs and line breaks. -/// Browser-dependent: Firefox drops them, others replace with space. -#[cfg(test)] -fn process_ignored_chars(text: &str, browser: u8) -> String { - text.chars() - .filter_map(|c| { - if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { - return Some(c); - } - if c < '\u{0020}' { - if browser == Browser::Firefox as u8 { - None - } else { - Some(' ') - } - } else { - Some(c) - } - }) - .collect() -} - /// Text after browser filtering and CSS text transformation, plus the source /// UTF-16 range that produced each transformed Unicode scalar. A single source /// scalar can produce several output scalars (for example `ß` uppercases to @@ -1742,10 +1692,9 @@ fn apply_text_transform_with_source_ranges( let source_start = source_utf16; source_utf16 += source_char.len_utf16(); - let processed = if source_char == '\n' - || source_char == '\r' - || source_char == '\u{2028}' - || source_char == '\u{2029}' + // Control characters below U+0020 are filtered (Firefox drops them, + // other browsers show a space); tabs and line breaks are kept. + let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}') || source_char >= '\u{0020}' { Some(source_char) @@ -1923,6 +1872,38 @@ impl TextSpan { self.annotation_clearance = clearance; } + /// Ruby annotation text, without surrounding whitespace. + pub fn ruby_text(&self) -> &str { + self.ruby.trim() + } + + pub fn has_ruby(&self) -> bool { + !self.ruby_text().is_empty() + } + + pub fn ruby_font_size(&self) -> f32 { + self.font_size * self.ruby_size.scale() + } + + /// Warichu needs at least two characters to fill its two sub-lines. + pub fn is_warichu(&self) -> bool { + self.warichu && self.text.chars().count() >= 2 + } + + /// Automatic clearance stacks emphasis marks outside an over-side ruby. + pub fn stacks_emphasis_outside_ruby(&self) -> bool { + self.annotation_clearance.is_auto() && self.has_ruby() && self.ruby_side == RubySide::Over + } + + /// Cross-axis offset of the emphasis marks past a stacked ruby layer. + pub fn emphasis_ruby_offset(&self) -> f32 { + if self.stacks_emphasis_outside_ruby() { + self.ruby_font_size() + } else { + 0.0 + } + } + pub fn to_style( &self, content_bounds: &Rect, @@ -1967,7 +1948,7 @@ impl TextSpan { }; let annotation_layers = if self.annotation_clearance.is_auto() { - usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none()) + usize::from(self.has_ruby()) + usize::from(!self.text_emphasis.is_none()) } else { 0 }; @@ -2281,8 +2262,7 @@ pub fn calculate_position_data( if skip_position_data { return Vec::new(); } - let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height()); - let layout = super::text_vertical::layout_from_content(&text_content, max_height); + let layout = super::text_vertical::layout_for_box(&text_content, shape.selrect.height()); return super::text_vertical::position_data( &layout, &shape.selrect, @@ -2338,100 +2318,107 @@ mod tests { ); } + fn capitalize(text: &str) -> String { + apply_text_transform_with_source_ranges( + text, + Browser::Chrome as u8, + Some(TextTransform::Capitalize), + ) + .text + } + + fn filter_ignored_chars(text: &str, browser: u8) -> String { + apply_text_transform_with_source_ranges(text, browser, None).text + } + #[test] fn capitalize_basic_words() { - assert_eq!(capitalize_words("hello world"), "Hello World"); + assert_eq!(capitalize("hello world"), "Hello World"); } #[test] fn capitalize_preserves_leading_whitespace() { - assert_eq!(capitalize_words(" hello"), " Hello"); + assert_eq!(capitalize(" hello"), " Hello"); } #[test] fn capitalize_preserves_trailing_whitespace() { - assert_eq!(capitalize_words("hello "), "Hello "); + assert_eq!(capitalize("hello "), "Hello "); } #[test] fn capitalize_preserves_multiple_spaces() { - assert_eq!(capitalize_words("hello world"), "Hello World"); + assert_eq!(capitalize("hello world"), "Hello World"); } #[test] fn capitalize_whitespace_only() { - assert_eq!(capitalize_words(" "), " "); - assert_eq!(capitalize_words(" "), " "); + assert_eq!(capitalize(" "), " "); + assert_eq!(capitalize(" "), " "); } #[test] fn capitalize_empty_string() { - assert_eq!(capitalize_words(""), ""); + assert_eq!(capitalize(""), ""); } #[test] fn capitalize_single_char() { - assert_eq!(capitalize_words("a"), "A"); + assert_eq!(capitalize("a"), "A"); } #[test] fn capitalize_already_uppercase() { - assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD"); + assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD"); } #[test] fn capitalize_preserves_tabs_and_newlines() { - assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld"); - assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld"); + assert_eq!(capitalize("hello\tworld"), "Hello\tWorld"); + assert_eq!(capitalize("hello\nworld"), "Hello\nWorld"); } #[test] fn capitalize_after_punctuation() { - assert_eq!(capitalize_words("(readonly)"), "(Readonly)"); - assert_eq!(capitalize_words("hello-world"), "Hello-World"); - assert_eq!(capitalize_words("one/two/three"), "One/Two/Three"); + assert_eq!(capitalize("(readonly)"), "(Readonly)"); + assert_eq!(capitalize("hello-world"), "Hello-World"); + assert_eq!(capitalize("one/two/three"), "One/Two/Three"); } #[test] fn capitalize_after_digits() { - assert_eq!(capitalize_words("item1name"), "Item1Name"); + assert_eq!(capitalize("item1name"), "Item1Name"); } #[test] - fn process_ignored_chars_preserves_spaces() { - assert_eq!(process_ignored_chars("hello world", 0), "hello world"); + fn ignored_chars_preserves_spaces() { + assert_eq!(filter_ignored_chars("hello world", 0), "hello world"); } #[test] - fn process_ignored_chars_preserves_line_breaks() { - assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld"); - assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); + fn ignored_chars_preserves_line_breaks() { + assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld"); + assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld"); } #[test] - fn process_ignored_chars_preserves_tabs() { - assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); + fn ignored_chars_preserves_tabs() { + assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld"); assert_eq!( - process_ignored_chars("hello\tworld", Browser::Firefox as u8), + filter_ignored_chars("hello\tworld", Browser::Firefox as u8), "hello\tworld" ); } #[test] - fn process_ignored_chars_replaces_control_chars_chrome() { + fn ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox - assert_eq!( - process_ignored_chars("a\x01b", Browser::Chrome as u8), - "a b" - ); + assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b"); } #[test] - fn process_ignored_chars_removes_control_chars_firefox() { - assert_eq!( - process_ignored_chars("a\x01b", Browser::Firefox as u8), - "ab" - ); + fn ignored_chars_removes_control_chars_firefox() { + assert_eq!(filter_ignored_chars("a\x01b", Browser::Firefox as u8), "ab"); } fn test_paragraph(texts: &[&str]) -> Paragraph { @@ -2743,290 +2730,4 @@ mod tests { assert_eq!(transformed.source_utf16_range(1..3), 1..2); assert_eq!(transformed.source_utf16_range(3..4), 2..3); } - - // apply_text_transform reads the browser from the design state. - fn init_state() { - crate::globals::design_init(); - } - - fn make_span(text: &str, letter_spacing: f32) -> TextSpan { - TextSpan { - text: text.to_string(), - font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal), - font_size: 16.0, - line_height: 1.0, - letter_spacing, - font_weight: 400, - font_variant_id: Uuid::nil(), - text_decoration: None, - text_transform: None, - text_direction: TextDirection::LTR, - text_orientation: TextOrientation::default(), - text_combine_upright: TextCombineUpright::default(), - text_emphasis: TextEmphasis::default(), - ruby: String::default(), - warichu: false, - font_features: FontFeatures::default(), - annotation_clearance: AnnotationClearance::default(), - ruby_size: RubySize::default(), - ruby_align: RubyAlign::default(), - ruby_overhang: RubyOverhang::default(), - ruby_side: RubySide::default(), - paragraph_position: u32::MAX, - span_position: u32::MAX, - fills: vec![], - } - } - - fn make_paragraph(spans: Vec, letter_spacing: f32) -> Paragraph { - Paragraph::new( - TextAlign::default(), - TextDirection::LTR, - None, - None, - 1.0, - letter_spacing, - spans, - ) - } - - #[test] - fn layout_span_texts_applies_kinsoku() { - init_state(); - let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0); - let (texts, map) = paragraph.layout_span_texts(); - assert_eq!( - texts, - vec!["雪国".to_string(), "\u{2060}。です".to_string()] - ); - assert!(!map.is_empty()); - assert_eq!(map.to_original(3), 2); - } - - #[test] - fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() { - init_state(); - let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0); - let (texts, map) = paragraph.layout_span_texts(); - assert_eq!(texts, vec!["雪国。".to_string()]); - assert!(map.is_empty()); - } - - #[test] - fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() { - init_state(); - let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0); - let (texts, map) = paragraph.layout_span_texts(); - assert_eq!(texts, vec!["雪国。".to_string()]); - assert!(map.is_empty()); - } - - #[test] - fn layout_span_texts_respects_text_transform() { - init_state(); - let mut span = make_span("hello。", 0.0); - span.text_transform = Some(TextTransform::Uppercase); - let paragraph = make_paragraph(vec![span], 0.0); - let (texts, _) = paragraph.layout_span_texts(); - assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]); - } - - #[test] - fn layout_span_texts_identity_map_for_plain_text() { - init_state(); - let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0); - let (texts, map) = paragraph.layout_span_texts(); - assert_eq!(texts, vec!["helloworld".to_string()]); - assert!(map.is_empty()); - assert_eq!(map.to_original(5), 5); - assert_eq!(map.to_shifted(5), 5); - } - - #[test] - fn horizontal_ruby_is_atomic() { - init_state(); - let mut group = make_span("日本", 0.0); - group.ruby = "にほん".to_string(); - let paragraph = make_paragraph(vec![group], 0.0); - assert_eq!( - paragraph.layout_span_texts().0, - vec!["日\u{2060}本".to_string()] - ); - } - - #[test] - fn horizontal_annotation_uses_the_base_em_not_typographic_leading() { - let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0); - - assert_eq!( - crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0), - 88.0 - ); - } - - #[test] - fn horizontal_warichu_collapses_to_one_builder_position() { - init_state(); - let mut warichu = make_span("割注入り", 0.0); - warichu.warichu = true; - let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0); - - 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!(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), - vec![3..4] - ); - } - - #[test] - fn horizontal_builder_mapping_preserves_non_bmp_boundaries() { - 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!(horizontal_builder_to_source(¶graph, 2), 1); - assert_eq!(horizontal_source_to_builder(¶graph, 2), 3); - assert_eq!(horizontal_builder_to_source(¶graph, 3), 2); - } - - #[test] - fn horizontal_warichu_builder_emits_one_styled_placeholder() { - init_state(); - let mut span = make_span("割注入り", 0.0); - span.warichu = true; - let mut style = skia::textlayout::TextStyle::default(); - style.set_font_size(span.font_size); - let mut fonts = skia::textlayout::FontCollection::new(); - fonts.set_default_font_manager(skia::FontMgr::new(), None); - 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(); - laid_out.layout(200.0); - - let placeholders = laid_out.get_rects_for_placeholders(); - assert_eq!(placeholders.len(), 1); - assert!(placeholders[0].rect.width() > 0.0); - assert!(placeholders[0].rect.height() > 0.0); - let has_style = laid_out - .get_line_metrics() - .iter() - .any(|line| !line.get_style_metrics(3..5).is_empty()); - assert!( - has_style, - "the paint pass must recover the placeholder style" - ); - } - - #[test] - fn horizontal_warichu_allows_wrapping_after_the_atomic_box() { - init_state(); - let mut span = make_span("割注入り", 0.0); - span.warichu = true; - let following = make_span("A", 0.0); - let mut style = skia::textlayout::TextStyle::default(); - style.set_font_size(span.font_size); - let mut fonts = skia::textlayout::FontCollection::new(); - fonts.set_default_font_manager(skia::FontMgr::new(), None); - let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); - builder.push_style(&style); - add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts); - builder.push_style(&style); - builder.add_text(&following.text); - - let mut laid_out = builder.build(); - laid_out.layout(16.1); - - assert_eq!(laid_out.get_rects_for_placeholders().len(), 1); - assert_eq!(laid_out.get_line_metrics().len(), 2); - } - - #[test] - fn emphasis_excludes_whitespace_and_japanese_punctuation() { - for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars() - { - assert!( - !emphasis_char_allowed(character), - "emphasis must skip {character:?}" - ); - } - for character in "漢あA1・!?".chars() { - assert!( - emphasis_char_allowed(character), - "emphasis should mark {character:?}" - ); - } - } - - #[test] - fn horizontal_emphasis_tracks_eligible_unicode_characters() { - init_state(); - let mut span = make_span("A😀。 B", 0.0); - span.text_emphasis = TextEmphasis::FilledDot; - let paragraph = make_paragraph(vec![span], 0.0); - let mut style = skia::textlayout::TextStyle::default(); - style.set_font_size(16.0); - let mut fonts = skia::textlayout::FontCollection::new(); - fonts.set_default_font_manager(skia::FontMgr::new(), None); - let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); - let (texts, _) = paragraph.layout_span_texts(); - for (span, text) in paragraph.children().iter().zip(texts) { - builder.push_style(&style); - add_horizontal_span(&mut builder, span, &text, &style, &fonts); - } - let mut laid_out = builder.build(); - laid_out.layout(200.0); - - let placements = horizontal_emphasis_placements(¶graph, &laid_out); - assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each"); - assert!(placements - .iter() - .all(|placement| placement.rect.width() > 0.0)); - assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some()); - } - - #[test] - fn horizontal_emphasis_recovers_each_non_ascii_span_style() { - init_state(); - let mut first = make_span("漢", 0.0); - first.text_emphasis = TextEmphasis::FilledDot; - let mut second = make_span("字", 0.0); - second.text_emphasis = TextEmphasis::OpenCircle; - let paragraph = make_paragraph(vec![first, second], 0.0); - let mut fonts = skia::textlayout::FontCollection::new(); - fonts.set_default_font_manager(skia::FontMgr::new(), None); - let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); - let (texts, _) = paragraph.layout_span_texts(); - for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() { - let mut style = skia::textlayout::TextStyle::default(); - style.set_font_size(if index == 0 { 16.0 } else { 24.0 }); - builder.push_style(&style); - add_horizontal_span(&mut builder, span, &text, &style, &fonts); - } - let mut laid_out = builder.build(); - laid_out.layout(200.0); - - let ranges = horizontal_span_ranges(¶graph); - assert_eq!( - horizontal_span_style(&laid_out, &ranges[0]) - .unwrap() - .font_size(), - 16.0 - ); - assert_eq!( - horizontal_span_style(&laid_out, &ranges[1]) - .unwrap() - .font_size(), - 24.0 - ); - } } diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs index e3406cf58f..9d481ec950 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_japanese.rs @@ -1,14 +1,18 @@ -use super::text::{add_text_with_tabs, Paragraph, TextSpan}; +use super::text::{add_text_with_tabs, Paragraph, TextContent, TextSpan}; +use super::text_vertical::{ + distribute_ruby_tops, shape_segment_with_fallbacks, single_glyph_blob, span_font_families, +}; +use crate::globals::get_resources; use crate::math::Point; -use crate::shapes::kinsoku; -use crate::utils::get_font_collection; +use crate::shapes::{kinsoku, merge_fills}; +use crate::utils::{get_fallback_fonts, get_font_collection}; use skia_safe::{ self as skia, textlayout::{ ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline, }, - Contains, + Canvas, Contains, Font, FontMgr, GlyphId, }; pub const WARICHU_FONT_SCALE: f32 = 0.5; @@ -32,7 +36,7 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku: let ruby_breaks: Vec>> = paragraph .children() .iter() - .map(|span| (!span.ruby.trim().is_empty()).then(Vec::new)) + .map(|span| span.has_ruby().then(Vec::new)) .collect(); if let Some((shifted, map)) = kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks) @@ -53,15 +57,9 @@ pub(crate) fn add_horizontal_span( text_style: &skia::textlayout::TextStyle, fonts: &skia::textlayout::FontCollection, ) { - if span.warichu && span.text.chars().count() >= 2 { + if span.is_warichu() { let text = span.apply_text_transform(); - let split = super::text_vertical::warichu_split_chars(&text); - let split_byte = text - .char_indices() - .nth(split) - .map(|(index, _)| index) - .unwrap_or(text.len()); - let (first, second) = text.split_at(split_byte); + let (first, second) = warichu_text_lines(&text); let mut mini_style = text_style.clone(); mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE); mini_style.set_height(1.0); @@ -133,7 +131,7 @@ pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec= 2; + let warichu = span.is_warichu(); let builder_start = builder_cursor; let style_anchor_start = if warichu { builder_byte_cursor + '\u{FFFC}'.len_utf8() @@ -254,7 +252,7 @@ fn horizontal_warichu_placeholders( let mut placeholders = laid_out.get_rects_for_placeholders().into_iter(); let mut result = Vec::new(); for (index, span) in paragraph.children().iter().enumerate() { - if span.warichu && span.text.chars().count() >= 2 { + if span.is_warichu() { if let Some(textbox) = placeholders.next() { result.push((index, textbox.rect)); } @@ -295,7 +293,7 @@ pub(crate) fn horizontal_warichu_hit_test( if !rect.contains(&point) { continue; } - let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); + let split = warichu_split_chars(&span.apply_text_transform()); let total = span.text.chars().count(); let second = point.y >= rect.top() + rect.height() / 2.0; let (line_start, line_len) = if second { @@ -319,7 +317,7 @@ pub(crate) fn horizontal_warichu_caret_rect( if source_offset < source_start || source_offset > source_end { continue; } - let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); + let split = warichu_split_chars(&span.apply_text_transform()); let local = source_offset - source_start; let total = source_end - source_start; let (line_start, line_len, top) = if local >= split { @@ -352,7 +350,7 @@ pub(crate) fn horizontal_warichu_range_rects( if selected_start >= selected_end { continue; } - let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); + let split = warichu_split_chars(&span.apply_text_transform()); for (line_start, line_end, top) in [ (span_start, span_start + split, rect.top()), ( @@ -392,7 +390,7 @@ pub(crate) fn horizontal_normal_selection_ranges( 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.warichu && span.text.chars().count() >= 2; + let warichu = span.is_warichu(); span_start = span_end; if warichu || selected_start >= selected_end { return None; @@ -405,8 +403,39 @@ pub(crate) fn horizontal_normal_selection_ranges( .collect() } -fn warichu_text_lines(text: &str) -> (&str, &str) { - let split = super::text_vertical::warichu_split_chars(text); +/// 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 +/// does not end with a line-end-prohibited one. Nudging forward pulls the +/// offending mark up into the first sub-line (jlreq); backward is the +/// fallback, and the midpoint stands when no split satisfies kinsoku. +pub(crate) fn warichu_split_chars(text: &str) -> usize { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mid = n.div_ceil(2); + let valid = |split: usize| { + split >= 1 + && split < n + && !kinsoku::forbidden_at_line_start(chars[split]) + && !kinsoku::forbidden_at_line_end(chars[split - 1]) + }; + if valid(mid) { + return mid; + } + for distance in 1..n { + if valid(mid + distance) { + return mid + distance; + } + if mid > distance && valid(mid - distance) { + return mid - distance; + } + } + mid +} + +/// The two warichu sub-lines of `text`, split at `warichu_split_chars`. +pub(crate) fn warichu_text_lines(text: &str) -> (&str, &str) { + let split = warichu_split_chars(text); let split_byte = text .char_indices() .nth(split) @@ -415,7 +444,7 @@ fn warichu_text_lines(text: &str) -> (&str, &str) { text.split_at(split_byte) } -fn warichu_mini_paragraph( +fn mini_paragraph( text: &str, style: &skia::textlayout::TextStyle, width: f32, @@ -448,17 +477,7 @@ pub(crate) fn paint_horizontal_warichu( let Some(span) = paragraph.children().get(range.span) else { continue; }; - // Indexed style metrics expose builder UTF-8 byte positions even - // though glyph/line ranges use UTF-16 offsets. - let style_anchor = range.style_anchor_start - ..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8(); - let style = laid_out.get_line_metrics().iter().find_map(|line| { - line.get_style_metrics(style_anchor.clone()) - .into_iter() - .next() - .map(|(_, metric)| metric.text_style.clone()) - }); - let Some(mut style) = style else { + let Some(mut style) = horizontal_span_style(laid_out, range) else { continue; }; style.set_font_size(span.font_size * WARICHU_FONT_SCALE); @@ -467,8 +486,8 @@ pub(crate) fn paint_horizontal_warichu( style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); let transformed = span.apply_text_transform(); let (first, second) = warichu_text_lines(&transformed); - let first_para = warichu_mini_paragraph(first, &style, rect.width()); - let second_para = warichu_mini_paragraph(second, &style, rect.width()); + let first_para = mini_paragraph(first, &style, rect.width()); + let second_para = mini_paragraph(second, &style, rect.width()); let half_height = rect.height() / 2.0; first_para.paint(canvas, (x + rect.left(), y + rect.top())); second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height)); @@ -546,8 +565,8 @@ pub(crate) fn horizontal_span_style( laid_out: &skia::textlayout::Paragraph, range: &HorizontalSpanRange, ) -> Option { - // Indexed style metrics use builder UTF-8 byte positions (the same - // convention used by the warichu style anchor above). + // Indexed style metrics use builder UTF-8 byte positions, unlike the + // UTF-16 offsets of glyph and line ranges. let anchor = range.style_anchor_start..range.style_anchor_start + 1; laid_out.get_line_metrics().iter().find_map(|line| { line.get_style_metrics(anchor.clone()) @@ -595,7 +614,7 @@ pub(crate) fn paint_horizontal_emphasis( style.set_height(1.0); style.set_height_override(true); style.set_letter_spacing(0.0); - let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX); + let mark_paragraph = mini_paragraph(&mark.to_string(), &style, f32::MAX); let mark_ink = mark_paragraph .get_rects_for_range( 0..mark.len_utf16(), @@ -621,22 +640,204 @@ pub(crate) fn paint_horizontal_emphasis( .filter(|placement| placement.span == range.span && placement.mark == mark) { let mark_x = x + placement.rect.center_x() - mark_width / 2.0; - let ruby_offset = if span.annotation_clearance.is_auto() - && !span.ruby.trim().is_empty() - && span.ruby_side == RubySide::Over - { - span.font_size * span.ruby_size.scale() - } else { - 0.0 - }; let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size) - mark_ink_bottom - - ruby_offset; + - span.emphasis_ruby_offset(); mark_paragraph.paint(canvas, (mark_x, mark_y)); } } } +/// Split `total` glyphs across segments proportionally to their extents +/// (rounded per segment, remainder to the last) so no glyph is dropped. +fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec { + let mut counts = vec![0usize; extents.len()]; + if extents.is_empty() || total == 0 { + return counts; + } + let sum: f32 = extents.iter().sum(); + if sum <= 0.0 { + counts[extents.len() - 1] = total; + return counts; + } + let mut assigned = 0usize; + let last = extents.len() - 1; + for (index, extent) in extents.iter().enumerate() { + let count = if index == last { + total - assigned + } else { + (((extent / sum) * total as f32).round() as usize).min(total - assigned) + }; + counts[index] = count; + assigned += count; + } + counts +} + +fn next_horizontal_ruby_range( + offset_map: &kinsoku::OffsetMap, + utf16_cursor: &mut usize, + text: &str, +) -> std::ops::Range { + let start = *utf16_cursor; + *utf16_cursor += text.encode_utf16().count(); + offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor) +} + +/// Baseline adjustment that attaches a glyph's visible ink edge to its base +/// strip. Font-wide ascender/descender metrics include leading which makes +/// horizontal ruby visibly detached for many Japanese faces. +fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 { + let mut bounds = [skia::Rect::default()]; + font.get_bounds(&[glyph], &mut bounds, None); + let bound = bounds[0]; + if bound.right > bound.left && bound.bottom > bound.top { + if over { + bound.bottom + } else { + bound.top + } + } else { + fallback + } +} + +/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only: +/// base rects come from the already laid-out skparagraph +/// (`get_rects_for_range`), the annotation is shaped at half the span size +/// and distributed over each line's base rect with the same jlreq +/// distribution the vertical path uses (`distribute_ruby_tops` along the +/// horizontal flow). Lines are not reflowed to reserve an annotation band; +/// the ruby draws in the natural leading above the base line. +pub(crate) fn paint_horizontal_ruby( + canvas: &Canvas, + text_content: &TextContent, + paragraph_index: usize, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else { + return; + }; + if !paragraph.children().iter().any(TextSpan::has_ruby) { + return; + } + let font_provider = get_resources().fonts.font_provider(); + let fallback_mgr = FontMgr::from(font_provider.clone()); + let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); + let bounds = text_content.bounds(); + let (_, offset_map) = paragraph.layout_span_texts(); + + let mut utf16_cursor = 0usize; + for span in paragraph.children() { + let span_text = span.apply_text_transform(); + let span_range = next_horizontal_ruby_range(&offset_map, &mut utf16_cursor, &span_text); + let ruby_text = span.ruby_text(); + if ruby_text.is_empty() || span_range.is_empty() { + continue; + } + let ruby_font_size = span.ruby_font_size(); + let families = span_font_families(span, &fallback_families); + let paint = merge_fills(&span.fills, bounds); + let rects = laid_out.get_rects_for_range( + span_range, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let shaped = shape_segment_with_fallbacks( + ruby_text, + ruby_font_size, + &families, + font_provider, + false, + span.font_features, + &fallback_mgr, + ); + let glyphs: Vec<(usize, usize, f32)> = shaped + .iter() + .enumerate() + .flat_map(|(run_index, run)| { + (0..run.glyphs.len()).map(move |glyph| { + ( + run_index, + glyph, + run.advances.get(glyph).copied().unwrap_or(ruby_font_size), + ) + }) + }) + .collect(); + let extents: Vec = rects.iter().map(|rect| rect.rect.width()).collect(); + let counts = split_counts_by_extent(&extents, glyphs.len()); + let mut assigned = 0usize; + for (rect_box, count) in rects.iter().zip(counts) { + if count == 0 { + continue; + } + let slice = &glyphs[assigned..assigned + count]; + assigned += count; + let advance = slice + .iter() + .map(|(_, _, advance)| *advance) + .fold(0.0f32, f32::max) + .max(1.0); + // Horizontal SkParagraph has already fixed the base geometry. + // When overhang is prohibited, fit the annotation strip to + // that geometry instead of allowing either end to escape it. + let glyph_scale = if span.ruby_overhang == RubyOverhang::None { + (rect_box.rect.width() / (advance * count as f32)).min(1.0) + } else { + 1.0 + }; + let layout_advance = advance * glyph_scale; + let lefts = distribute_ruby_tops( + rect_box.rect.left(), + rect_box.rect.width(), + count, + layout_advance, + span.ruby_align, + span.ruby_overhang, + ); + for ((run_index, glyph, glyph_advance), left) in slice.iter().zip(lefts) { + let run = &shaped[*run_index]; + let (_, metrics) = run.font.metrics(); + let baseline = match span.ruby_side { + RubySide::Over => { + y + horizontal_annotation_over_top(rect_box.rect, span.font_size) + - horizontal_ruby_ink_edge( + &run.font, + run.glyphs[*glyph], + metrics.descent, + true, + ) + } + RubySide::Under => { + y + rect_box.rect.bottom() + - horizontal_ruby_ink_edge( + &run.font, + run.glyphs[*glyph], + metrics.ascent, + false, + ) + } + }; + if let Some(blob) = single_glyph_blob(&run.font, run.glyphs[*glyph]) { + let gx = x + left + (layout_advance - glyph_advance * glyph_scale) / 2.0; + if glyph_scale < 1.0 { + canvas.save(); + canvas.translate((gx, baseline)); + canvas.scale((glyph_scale, 1.0)); + canvas.draw_text_blob(&blob, (0.0, 0.0), &paint); + canvas.restore(); + } else { + canvas.draw_text_blob(&blob, (gx, baseline), &paint); + } + } + } + } + } +} + /// Block flow direction of a paragraph. Horizontal is the skparagraph /// path; vertical-rl lays out columns top->bottom advancing right->left /// through the custom vertical pass. @@ -783,3 +984,337 @@ impl AnnotationClearance { matches!(self, AnnotationClearance::Auto) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextTransform}; + use crate::Uuid; + + #[test] + fn warichu_split_balances_and_respects_kinsoku() { + // Balanced midpoint when nothing forbids it, first line longer. + assert_eq!(warichu_split_chars("あいうえおか"), 3); + assert_eq!(warichu_split_chars("あいうえお"), 3); + // A comma at the midpoint may end the first sub-line... + assert_eq!(warichu_split_chars("あい、うえ"), 3); + // ...but must not start the second one: the split moves forward. + assert_eq!(warichu_split_chars("あいう、えお"), 4); + // An opening bracket must not end the first sub-line. + assert_eq!(warichu_split_chars("あい「うえお"), 4); + // Pathological all-forbidden text keeps the midpoint. + assert_eq!(warichu_split_chars("、、、、"), 2); + } + + #[test] + fn horizontal_ruby_ranges_use_utf16_across_spans() { + let offset_map = crate::shapes::kinsoku::OffsetMap::default(); + let mut cursor = 0; + + assert_eq!( + next_horizontal_ruby_range(&offset_map, &mut cursor, "𠀀"), + 0..2 + ); + assert_eq!( + next_horizontal_ruby_range(&offset_map, &mut cursor, "漢"), + 2..3 + ); + } + + #[test] + fn split_counts_by_extent_drops_no_glyph() { + assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]); + assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]); + assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]); + assert_eq!( + split_counts_by_extent(&[1.0, 1.0, 1.0], 2) + .iter() + .sum::(), + 2 + ); + assert!(split_counts_by_extent(&[], 3).is_empty()); + } + + // apply_text_transform reads the browser from the design state. + fn init_state() { + crate::globals::design_init(); + } + + fn make_span(text: &str, letter_spacing: f32) -> TextSpan { + TextSpan { + text: text.to_string(), + font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size: 16.0, + line_height: 1.0, + letter_spacing, + font_weight: 400, + font_variant_id: Uuid::nil(), + text_decoration: None, + text_transform: None, + text_direction: TextDirection::LTR, + text_orientation: TextOrientation::default(), + text_combine_upright: TextCombineUpright::default(), + text_emphasis: TextEmphasis::default(), + ruby: String::default(), + warichu: false, + font_features: FontFeatures::default(), + annotation_clearance: AnnotationClearance::default(), + ruby_size: RubySize::default(), + ruby_align: RubyAlign::default(), + ruby_overhang: RubyOverhang::default(), + ruby_side: RubySide::default(), + paragraph_position: u32::MAX, + span_position: u32::MAX, + fills: vec![], + } + } + + fn make_paragraph(spans: Vec, letter_spacing: f32) -> Paragraph { + Paragraph::new( + TextAlign::default(), + TextDirection::LTR, + None, + None, + 1.0, + letter_spacing, + spans, + ) + } + + #[test] + fn layout_span_texts_applies_kinsoku() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!( + texts, + vec!["雪国".to_string(), "\u{2060}。です".to_string()] + ); + assert!(!map.is_empty()); + assert_eq!(map.to_original(3), 2); + } + + #[test] + fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["雪国。".to_string()]); + assert!(map.is_empty()); + } + + #[test] + fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["雪国。".to_string()]); + assert!(map.is_empty()); + } + + #[test] + fn layout_span_texts_respects_text_transform() { + init_state(); + let mut span = make_span("hello。", 0.0); + span.text_transform = Some(TextTransform::Uppercase); + let paragraph = make_paragraph(vec![span], 0.0); + let (texts, _) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]); + } + + #[test] + fn layout_span_texts_identity_map_for_plain_text() { + init_state(); + let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["helloworld".to_string()]); + assert!(map.is_empty()); + assert_eq!(map.to_original(5), 5); + assert_eq!(map.to_shifted(5), 5); + } + + #[test] + fn horizontal_ruby_is_atomic() { + init_state(); + let mut group = make_span("日本", 0.0); + group.ruby = "にほん".to_string(); + let paragraph = make_paragraph(vec![group], 0.0); + assert_eq!( + paragraph.layout_span_texts().0, + vec!["日\u{2060}本".to_string()] + ); + } + + #[test] + fn horizontal_annotation_uses_the_base_em_not_typographic_leading() { + let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0); + + assert_eq!(horizontal_annotation_over_top(rect, 56.0), 88.0); + } + + #[test] + fn horizontal_warichu_collapses_to_one_builder_position() { + init_state(); + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0); + + 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!(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), + vec![3..4] + ); + } + + #[test] + fn horizontal_builder_mapping_preserves_non_bmp_boundaries() { + 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!(horizontal_builder_to_source(¶graph, 2), 1); + assert_eq!(horizontal_source_to_builder(¶graph, 2), 3); + assert_eq!(horizontal_builder_to_source(¶graph, 3), 2); + } + + #[test] + fn horizontal_warichu_builder_emits_one_styled_placeholder() { + init_state(); + let mut span = make_span("割注入り", 0.0); + span.warichu = true; + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + 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(); + laid_out.layout(200.0); + + let placeholders = laid_out.get_rects_for_placeholders(); + assert_eq!(placeholders.len(), 1); + assert!(placeholders[0].rect.width() > 0.0); + assert!(placeholders[0].rect.height() > 0.0); + let has_style = laid_out + .get_line_metrics() + .iter() + .any(|line| !line.get_style_metrics(3..5).is_empty()); + assert!( + has_style, + "the paint pass must recover the placeholder style" + ); + } + + #[test] + fn horizontal_warichu_allows_wrapping_after_the_atomic_box() { + init_state(); + let mut span = make_span("割注入り", 0.0); + span.warichu = true; + let following = make_span("A", 0.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts); + builder.push_style(&style); + builder.add_text(&following.text); + + let mut laid_out = builder.build(); + laid_out.layout(16.1); + + assert_eq!(laid_out.get_rects_for_placeholders().len(), 1); + assert_eq!(laid_out.get_line_metrics().len(), 2); + } + + #[test] + fn emphasis_excludes_whitespace_and_japanese_punctuation() { + for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars() + { + assert!( + !emphasis_char_allowed(character), + "emphasis must skip {character:?}" + ); + } + for character in "漢あA1・!?".chars() { + assert!( + emphasis_char_allowed(character), + "emphasis should mark {character:?}" + ); + } + } + + #[test] + fn horizontal_emphasis_tracks_eligible_unicode_characters() { + init_state(); + let mut span = make_span("A😀。 B", 0.0); + span.text_emphasis = TextEmphasis::FilledDot; + let paragraph = make_paragraph(vec![span], 0.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(16.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + let (texts, _) = paragraph.layout_span_texts(); + for (span, text) in paragraph.children().iter().zip(texts) { + builder.push_style(&style); + add_horizontal_span(&mut builder, span, &text, &style, &fonts); + } + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let placements = horizontal_emphasis_placements(¶graph, &laid_out); + assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each"); + assert!(placements + .iter() + .all(|placement| placement.rect.width() > 0.0)); + assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some()); + } + + #[test] + fn horizontal_emphasis_recovers_each_non_ascii_span_style() { + init_state(); + let mut first = make_span("漢", 0.0); + first.text_emphasis = TextEmphasis::FilledDot; + let mut second = make_span("字", 0.0); + second.text_emphasis = TextEmphasis::OpenCircle; + let paragraph = make_paragraph(vec![first, second], 0.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + let (texts, _) = paragraph.layout_span_texts(); + for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() { + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(if index == 0 { 16.0 } else { 24.0 }); + builder.push_style(&style); + add_horizontal_span(&mut builder, span, &text, &style, &fonts); + } + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let ranges = horizontal_span_ranges(¶graph); + assert_eq!( + horizontal_span_style(&laid_out, &ranges[0]) + .unwrap() + .font_size(), + 16.0 + ); + assert_eq!( + horizontal_span_style(&laid_out, &ranges[1]) + .unwrap() + .font_size(), + 24.0 + ); + } +} diff --git a/render-wasm/src/shapes/text_vertical.rs b/render-wasm/src/shapes/text_vertical.rs deleted file mode 100644 index ee7e48329f..0000000000 --- a/render-wasm/src/shapes/text_vertical.rs +++ /dev/null @@ -1,7089 +0,0 @@ -// Vertical (tategaki) text layout: columns flow top->bottom and advance -// right->left. Skia's skparagraph has no vertical writing mode, so this -// module owns the whole vertical pipeline: -// -// segment spans by glyph orientation -> shape each segment with SkShaper -// (OpenType `vert`/`vrt2` for upright runs) -> plan kinsoku-aware column -// breaks -> derive cells (one per upright glyph cluster / one per rotated -// run) -> paint / position-data / hit-testing from the cells. -// -// Offset discipline: cells use UTF-16 offsets in transformed layout text. -// Position data maps those ranges back to the original span text before it -// crosses the WASM boundary; the WORD JOINER OffsetMap remains exclusive to -// skparagraph-driven horizontal breaks. -// -// Layouts are computed on demand (like the horizontal path, which rebuilds -// its skparagraph objects per paint); nothing is cached. -// -// Strokes (center/inner/outer, masked to the glyph silhouettes), drop -// shadows and decorations (underline / line-through as bars along the -// column) are painted from the same cells as the fills. -// -// Text-align aligns each column's glyphs along the vertical (inline) axis: -// Left/Start->top, Center->middle, Right/End->bottom of the wrap budget. -// Justify stretches every column but the last to fill the wrap budget, -// distributing the leftover space evenly between the column's cells. -// -// Letter-spacing adds inter-glyph advance along the column — once per -// upright cluster and once per glyph inside a rotated run — mirroring the -// horizontal `letter-spacing` that Skia applies to each glyph advance. -// Upright CJK <-> rotated alphanumeric boundaries additionally receive the -// conventional quarter-em inter-script gap. -// -// Deferred to a later phase (documented in the phase handoff): PDF/vector -// emoji overlays, inner shadows and block-axis vertical-align. - -use skia_safe::{ - self as skia, - canvas::SaveLayerRec, - shaper::{ - run_handler::{Buffer, RunInfo}, - Feature, RunHandler, - }, - textlayout::{Paragraph as SkiaParagraph, TextDecoration, TypefaceFontProvider}, - BlendMode, Canvas, Contains, Font, FontMgr, GlyphId, ImageFilter, Paint, Point as SkPoint, - TextBlob, TextBlobBuilder, -}; - -use crate::globals::get_resources; -use crate::math::Rect; -use crate::render::DEFAULT_EMOJI_FONT; -use crate::shapes::japanese::{classify, pair_rule, JapaneseClass}; -use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; -use crate::shapes::{ - merge_fills, AppliedTextTransform, FontFeatures, GrowType, PositionData, RubyAlign, - RubyOverhang, RubySide, Stroke, StrokeKind, TextAlign, TextCombineUpright, TextContent, - TextOrientation, TextSpan, VerticalAlign, -}; -use crate::utils::get_fallback_fonts; - -/// Emoji ranges recognized by the frontend font-loader. Emoji use their -/// intrinsic upright presentation in vertical flow instead of rotating like -/// Latin text under `text-orientation: mixed`. -fn is_emoji_char(c: char) -> bool { - matches!(u32::from(c), - 0x2300..=0x23FF - | 0x2600..=0x27BF - | 0x2B00..=0x2BFF - | 0x1F000..=0x1FAFF - ) -} - -fn span_font_families(span: &TextSpan, fallback_families: &[String]) -> Vec { - let mut families = vec![ - format!("{}", span.font_family), - DEFAULT_EMOJI_FONT.to_string(), - ]; - families.extend(fallback_families.iter().cloned()); - families -} - -/// Characters that stay upright in vertical flow: kana, kanji, CJK -/// punctuation, full-width forms and emoji. Everything else rotates sideways -/// under `text-orientation: mixed`. -pub fn is_upright_char(c: char) -> bool { - is_emoji_char(c) - || matches!(u32::from(c), - 0x2E80..=0x2FDF // CJK radicals, Kangxi radicals - | 0x3000..=0x303F // CJK symbols and punctuation - | 0x3040..=0x30FF // hiragana, katakana - | 0x31C0..=0x31EF // CJK strokes - | 0x31F0..=0x31FF // katakana phonetic extensions - | 0x3200..=0x33FF // enclosed CJK, CJK compatibility - | 0x3400..=0x4DBF // CJK unified ideographs extension A - | 0x4E00..=0x9FFF // CJK unified ideographs - | 0xAC00..=0xD7AF // hangul syllables - | 0xF900..=0xFAFF // CJK compatibility ideographs - | 0xFE30..=0xFE4F // CJK compatibility forms - | 0xFF00..=0xFF60 // full-width forms - | 0xFFE0..=0xFFE6 // full-width signs - | 0x20000..=0x2FA1F // CJK extensions B..F - ) -} - -/// Characters whose horizontal glyph needs a vertical alternate. `vert` / -/// `vrt2` normally supplies that alternate; when the selected face has no -/// such substitution, rotate the horizontal glyph clockwise as a legible -/// fallback. The set covers UAX #50 `Tr`, plus comma/full-stop punctuation -/// whose untransformed glyph otherwise occupies the wrong half of the -/// vertical em box. -fn uses_rotated_vertical_fallback(c: char) -> bool { - matches!(u32::from(c), - 0x2018..=0x2019 // single quotation marks - | 0x201C..=0x201D // double quotation marks - | 0x2329..=0x232A // angle brackets - | 0x3001..=0x3002 // ideographic comma / full stop - | 0x3008..=0x301F // CJK brackets, wave dash and quotation marks - | 0x3030 // wavy dash - | 0x30A0 // katakana-hiragana double hyphen - | 0x30FC // prolonged sound mark - | 0xFE50..=0xFE52 // small comma / ideographic comma / full stop - | 0xFE59..=0xFE5E // small brackets - | 0xFF08..=0xFF09 // full-width parentheses - | 0xFF0C // full-width comma - | 0xFF0D // full-width hyphen-minus - | 0xFF0E // full-width full stop - | 0xFF1A..=0xFF1B // full-width colon / semicolon - | 0xFF1C..=0xFF1E // full-width comparison signs - | 0xFF3B // full-width left square bracket - | 0xFF3D // full-width right square bracket - | 0xFF3F // full-width low line - | 0xFF5B..=0xFF60 // full-width braces, bars, tilde and white parentheses - | 0xFFE3 // full-width macron - ) -} - -fn cluster_needs_rotated_vertical_fallback( - run: &ShapedRun, - glyph: usize, - count: usize, - ch: Option, -) -> bool { - let Some(ch) = ch else { - return false; - }; - count == 1 - && uses_rotated_vertical_fallback(ch) - && run.glyphs[glyph] == run.font.unichar_to_glyph(ch as i32) -} - -#[derive(Debug, PartialEq)] -pub struct Segment { - pub text: String, - /// UTF-16 offset of the segment start within its span text. - pub utf16_start: usize, - pub upright: bool, -} - -/// Split text into maximal runs of same orientation. Under -/// `TextOrientation::Upright` every character is upright. -pub fn segment_by_orientation(text: &str, orientation: TextOrientation) -> Vec { - let mut segments: Vec = Vec::new(); - let mut utf16_offset = 0; - for c in text.chars() { - let upright = orientation == TextOrientation::Upright || is_upright_char(c); - let emoji = is_emoji_char(c); - // CJK and emoji are both upright, but keep a shaping boundary between - // them so the segment probe can explicitly select the emoji family. - // The provider's generic fallback iterator does not reliably switch - // from a registered CJK face to a registered color-emoji face. - match segments.last_mut() { - Some(last) - if last.upright == upright - && last.text.chars().next_back().is_some_and(is_emoji_char) == emoji => - { - last.text.push(c) - } - _ => segments.push(Segment { - text: c.to_string(), - utf16_start: utf16_offset, - upright, - }), - } - utf16_offset += c.len_utf16(); - } - segments -} - -/// One shaped run: glyphs from a single font, pen-relative positions, -/// per-glyph horizontal advances and per-glyph UTF-8 cluster starts -/// (relative to the shaped segment text). -pub struct ShapedRun { - pub font: Font, - pub glyphs: Vec, - pub positions: Vec, - pub advances: Vec, - pub clusters: Vec, - pub advance: f32, - pub utf8_range: std::ops::Range, - /// Local-y offset that centres the run's ink after 90° rotation. - pub rotated_baseline_shift: f32, -} - -#[derive(Default)] -struct RunCollector { - runs: Vec, - scratch_glyphs: Vec, - scratch_positions: Vec, - scratch_clusters: Vec, -} - -impl RunHandler for RunCollector { - fn begin_line(&mut self) {} - - fn run_info(&mut self, _info: &RunInfo) {} - - fn commit_run_info(&mut self) {} - - fn run_buffer(&mut self, info: &RunInfo) -> Buffer<'_> { - self.scratch_glyphs.resize(info.glyph_count, 0); - self.scratch_positions - .resize(info.glyph_count, SkPoint::default()); - self.scratch_clusters.resize(info.glyph_count, 0); - Buffer { - glyphs: &mut self.scratch_glyphs, - positions: &mut self.scratch_positions, - offsets: None, - clusters: Some(&mut self.scratch_clusters), - point: SkPoint::default(), - } - } - - fn commit_run_buffer(&mut self, info: &RunInfo) { - if info.glyph_count == 0 { - return; - } - let origin = self.scratch_positions[0]; - let positions: Vec = self - .scratch_positions - .iter() - .map(|p| SkPoint::new(p.x - origin.x, p.y)) - .collect(); - let end = positions[0].x + info.advance.x; - let mut advances: Vec = Vec::with_capacity(info.glyph_count); - for i in 0..positions.len() { - let next = if i + 1 < positions.len() { - positions[i + 1].x - } else { - end - }; - advances.push(next - positions[i].x); - } - self.runs.push(ShapedRun { - rotated_baseline_shift: rotated_run_baseline_shift( - info.font, - &self.scratch_glyphs, - &positions, - ), - font: info.font.clone(), - glyphs: self.scratch_glyphs.clone(), - positions, - advances, - clusters: self.scratch_clusters.clone(), - advance: info.advance.x, - utf8_range: info.utf8_range.clone(), - }); - } - - fn commit_line(&mut self) {} -} - -fn feature(tag: &[u8; 4]) -> Feature { - Feature { - tag: u32::from_be_bytes(*tag), - value: 1, - start: 0, - end: usize::MAX, - } -} - -/// `vpal` is deliberately absent: SkShaper shapes on a horizontal line, -/// where HarfBuzz would apply the feature's y-placement deltas as glyph -/// offsets without its advance deltas. Vertical layout applies the parsed -/// GPOS `vpal` metrics to upright cells itself. -fn font_feature(font_features: FontFeatures) -> Option { - match font_features { - FontFeatures::None => None, - FontFeatures::Palt => Some(feature(b"palt")), - FontFeatures::Vpal => None, - } -} - -/// Shape one orientation segment on a single unbounded line. Upright -/// segments get the OpenType vertical substitution features so CJK -/// punctuation and brackets take their vertical forms. -fn shape_segment( - text: &str, - font: &Font, - upright: bool, - font_features: FontFeatures, - fallback: FontMgr, -) -> Vec { - let shaper = skia::Shaper::new(fallback.clone()); - let mut font_iter = skia::Shaper::new_font_mgr_run_iterator(text, font, Some(fallback)); - let mut bidi_iter = skia::shapers::primitive::trivial_bidi_run_iterator(0, text.len()); - let mut script_iter = skia::Shaper::new_hb_icu_script_run_iterator(text); - let mut lang_iter = skia::Shaper::new_trivial_language_run_iterator("ja", text.len()); - - let mut features = if upright { - vec![feature(b"vert"), feature(b"vrt2")] - } else { - vec![] - }; - if let Some(font_feature) = font_feature(font_features) { - features.push(font_feature); - } - - let mut collector = RunCollector::default(); - shaper.shape_with_iterators_and_features( - text, - &mut font_iter, - &mut bidi_iter, - &mut script_iter, - &mut lang_iter, - &features, - f32::MAX, - &mut collector, - ); - collector.runs -} - -/// Shape a segment with explicit per-character font fallback. Penpot's -/// `TypefaceFontProvider` can resolve named families, but its character -/// fallback hook is not available consistently in every Skia build. Resolve -/// coverage here so missing glyphs never depend on that hook. -fn shape_segment_with_fallbacks( - text: &str, - font_size: f32, - families: &[String], - font_provider: &TypefaceFontProvider, - upright: bool, - font_features: FontFeatures, - fallback_mgr: &FontMgr, -) -> Vec { - let typefaces: Vec<_> = families - .iter() - .filter_map(|family| font_provider.match_family_style(family, skia::FontStyle::default())) - .collect(); - let Some(primary) = typefaces.first() else { - return Vec::new(); - }; - - // Combining marks, variation selectors and ZWJ belong to the preceding - // grapheme. Keep them in that font run even when they have no standalone - // cmap entry; HarfBuzz then gets the complete shaping sequence. - let extends_previous = |ch: char| { - matches!(u32::from(ch), - 0x0300..=0x036F - | 0x1AB0..=0x1AFF - | 0x1DC0..=0x1DFF - | 0x200D - | 0x20D0..=0x20FF - | 0x3099..=0x309A - | 0xFE00..=0xFE0F - | 0xFE20..=0xFE2F - | 0xE0100..=0xE01EF - ) - }; - - let mut chunks: Vec<(std::ops::Range, usize)> = Vec::new(); - let mut current_typeface = 0usize; - let mut chunk_start = 0usize; - let mut has_character = false; - for (byte, ch) in text.char_indices() { - let selected = if has_character && extends_previous(ch) { - current_typeface - } else { - typefaces - .iter() - .position(|typeface| typeface.unichar_to_glyph(ch as i32) != 0) - .unwrap_or(0) - }; - if has_character && selected != current_typeface { - chunks.push((chunk_start..byte, current_typeface)); - chunk_start = byte; - } - current_typeface = selected; - has_character = true; - } - if has_character { - chunks.push((chunk_start..text.len(), current_typeface)); - } - - let mut result = Vec::new(); - for (range, typeface_index) in chunks { - let typeface = typefaces - .get(typeface_index) - .cloned() - .unwrap_or_else(|| primary.clone()); - let font = Font::new(typeface, font_size); - let mut shaped = shape_segment( - &text[range.clone()], - &font, - upright, - font_features, - fallback_mgr.clone(), - ); - for run in &mut shaped { - run.utf8_range = - (run.utf8_range.start + range.start)..(run.utf8_range.end + range.start); - for cluster in &mut run.clusters { - *cluster += range.start as u32; - } - } - result.extend(shaped); - } - result -} - -/// Vertical advances from a font's `vhea`/`vmtx` tables. Upright cells -/// advance down the column by the glyph's true vertical advance rather -/// than its shaped horizontal advance: identical for full-width CJK, but -/// correct for vertical alternates and proportional glyphs whose `vmtx` -/// differs from `hmtx` (e.g. the vertical kana repeat marks). -struct VerticalMetrics { - units_per_em: f32, - /// Advance heights of the first `advances.len()` glyph ids. - advances: Vec, - /// Advance for glyph ids at or beyond the long-metric count. - last: u16, -} - -impl VerticalMetrics { - /// Parse `vhea`/`vmtx` off the font's typeface. Returns `None` when the - /// font carries no vertical metrics (the caller then keeps horizontal - /// advances). - fn from_font(font: &Font) -> Option { - let typeface = font.typeface(); - let units_per_em = typeface.units_per_em()? as f32; - if units_per_em <= 0.0 { - return None; - } - let vhea = typeface.copy_table_data(u32::from_be_bytes(*b"vhea"))?; - let vmtx = typeface.copy_table_data(u32::from_be_bytes(*b"vmtx"))?; - let vhea = vhea.as_bytes(); - let vmtx = vmtx.as_bytes(); - // `numberOfLongVerMetrics` is the trailing u16 of the 36-byte header. - let num_long = u16::from_be_bytes([*vhea.get(34)?, *vhea.get(35)?]) as usize; - if num_long == 0 { - return None; - } - // Each long metric is { advanceHeight: u16, topSideBearing: i16 }. - let mut advances = Vec::with_capacity(num_long); - for i in 0..num_long { - let o = i * 4; - let (Some(&hi), Some(&lo)) = (vmtx.get(o), vmtx.get(o + 1)) else { - break; - }; - advances.push(u16::from_be_bytes([hi, lo])); - } - let last = *advances.last()?; - Some(Self { - units_per_em, - advances, - last, - }) - } - - /// Vertical advance of `glyph` at `font_size`, in pixels at the shaped - /// size (same units as the horizontal advances). - fn advance(&self, glyph: GlyphId, font_size: f32) -> f32 { - let raw = self - .advances - .get(glyph as usize) - .copied() - .unwrap_or(self.last); - raw as f32 * font_size / self.units_per_em - } -} - -thread_local! { - /// Parsed `vhea`/`vmtx` per typeface. Layouts are recomputed on every - /// paint but a face's vertical metrics never change, so the table - /// parse (which copies the whole table) is done once per typeface. - // Keyed by the typeface's unique id (`SkTypefaceID`, a `u32`). - static VERTICAL_METRICS_CACHE: std::cell::RefCell< - std::collections::HashMap>>, - > = std::cell::RefCell::new(std::collections::HashMap::new()); -} - -fn vertical_metrics(font: &Font) -> Option> { - let id = font.typeface().unique_id(); - VERTICAL_METRICS_CACHE.with(|cache| { - cache - .borrow_mut() - .entry(id) - .or_insert_with(|| VerticalMetrics::from_font(font).map(std::rc::Rc::new)) - .clone() - }) -} - -/// GPOS `vpal` deltas for one typeface, in font units. -struct VpalTable { - units_per_em: f32, - deltas: std::collections::HashMap, -} - -impl VpalTable { - fn from_font(font: &Font) -> Option { - let typeface = font.typeface(); - let units_per_em = typeface.units_per_em()? as f32; - if units_per_em <= 0.0 { - return None; - } - let gpos = typeface.copy_table_data(u32::from_be_bytes(*b"GPOS"))?; - let deltas = super::gpos_vpal::parse_vpal(gpos.as_bytes())?; - Some(Self { - units_per_em, - deltas, - }) - } - - /// Pixel deltas for a cluster at `font_size`: summed advance delta - /// (negative when the cell tightens) and the flow-axis shift of the - /// drawn ink (positive down the column). GPOS `yPlacement` is y-up, - /// so its sign flips into flow space. `None` when no glyph of the - /// cluster is covered. - fn cluster_delta(&self, glyphs: &[GlyphId], font_size: f32) -> Option<(f32, f32)> { - let scale = font_size / self.units_per_em; - let mut advance = 0.0f32; - let mut flow_shift = None; - for glyph in glyphs { - if let Some(delta) = self.deltas.get(glyph) { - advance += delta.y_advance as f32 * scale; - flow_shift.get_or_insert(-(delta.y_placement as f32) * scale); - } - } - flow_shift.map(|shift| (advance, shift)) - } -} - -thread_local! { - /// Parsed GPOS `vpal` deltas per typeface, keyed by typeface unique id - /// (same lifetime argument as `VERTICAL_METRICS_CACHE`). - static VPAL_TABLE_CACHE: std::cell::RefCell< - std::collections::HashMap>>, - > = std::cell::RefCell::new(std::collections::HashMap::new()); -} - -fn vpal_table(font: &Font) -> Option> { - let id = font.typeface().unique_id(); - VPAL_TABLE_CACHE.with(|cache| { - cache - .borrow_mut() - .entry(id) - .or_insert_with(|| VpalTable::from_font(font).map(std::rc::Rc::new)) - .clone() - }) -} - -/// OS/2 typographic ascender/descender, normalised to the em (design units / -/// unitsPerEm). These bound the ideographic em box and are what the browser -/// uses for the vertical central baseline; `hhea`/`Font::metrics` are oversized -/// for CJK faces (their ascent exceeds the em) and would push an upright glyph -/// off the ideographic centre of its cell. -#[derive(Clone, Copy)] -struct TypoMetrics { - /// sTypoAscender / unitsPerEm (positive, above the baseline). - ascender: f32, - /// sTypoDescender / unitsPerEm (negative, below the baseline). - descender: f32, -} - -impl TypoMetrics { - fn from_font(font: &Font) -> Option { - let typeface = font.typeface(); - let units_per_em = typeface.units_per_em()? as f32; - if units_per_em <= 0.0 { - return None; - } - let os2 = typeface.copy_table_data(u32::from_be_bytes(*b"OS/2"))?; - let os2 = os2.as_bytes(); - // sTypoAscender @ 68 (i16), sTypoDescender @ 70 (i16); present in every - // OS/2 table version. - let ascender = i16::from_be_bytes([*os2.get(68)?, *os2.get(69)?]) as f32; - let descender = i16::from_be_bytes([*os2.get(70)?, *os2.get(71)?]) as f32; - Some(Self { - ascender: ascender / units_per_em, - descender: descender / units_per_em, - }) - } -} - -thread_local! { - /// Parsed OS/2 typographic metrics per typeface, cached like the vertical - /// metrics because layouts recompute every paint but a face's metrics are - /// constant. Keyed by the typeface's unique id. - static TYPO_METRICS_CACHE: std::cell::RefCell< - std::collections::HashMap>, - > = std::cell::RefCell::new(std::collections::HashMap::new()); -} - -/// Ascent/descent for centring an upright cell, in Skia's sign convention -/// (ascent negative, descent positive) at the font's current size. Prefers the -/// OS/2 typographic metrics (the ideographic em box, matching the browser's -/// vertical central baseline used by the SVG/foreignObject export); falls back -/// to `Font::metrics` when the face carries no OS/2 table. -fn upright_centre_metrics(font: &Font) -> (f32, f32) { - let id = font.typeface().unique_id(); - let typo = TYPO_METRICS_CACHE.with(|cache| { - *cache - .borrow_mut() - .entry(id) - .or_insert_with(|| TypoMetrics::from_font(font)) - }); - if let Some(typo) = typo { - let size = font.size(); - return (-typo.ascender * size, -typo.descender * size); - } - let (_, metrics) = font.metrics(); - (metrics.ascent, metrics.descent) -} - -/// A placeable item in the vertical flow: its extent along the column and, -/// for single-character cells, the character (used for kinsoku decisions -/// at column breaks). Rotated runs carry no character and are unsplittable. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct FlowItem { - pub extent: f32, - pub ch: Option, - /// This item and its predecessor came from the same source character - /// after a length-expanding text transform, so a column break must not - /// split them. - pub keep_with_previous: bool, -} - -/// Assign items to columns: returns (column-index, offset-from-top) per -/// item. An item that overflows the column height starts a new column; -/// items taller than the column occupy one on their own. Kinsoku: a break -/// may not leave a forbidden-at-line-end character at the column bottom -/// nor put a forbidden-at-line-start character at the next column top; -/// offending predecessors are pushed to the new column (oidashi), bounded -/// so a pathological run cannot empty its column. -/// Punctuation allowed to hang past the column bottom (ぶら下げ / burasage): the -/// ideographic and full-width comma and period. When such a mark is the item -/// that would overflow the column, it protrudes into the margin instead of -/// wrapping (itself and its predecessor) to the next column. -fn can_hang(c: char) -> bool { - matches!(c, '、' | '。' | ',' | '.') -} - -pub fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> { - const MAX_KINSOKU_SHIFT: usize = 4; - - let mut placements: Vec<(usize, f32)> = Vec::with_capacity(items.len()); - let mut column = 0usize; - let mut cursor = 0.0f32; - let mut column_start = 0usize; - - for (i, item) in items.iter().enumerate() { - // Burasage: let a comma/period hang past the column bottom rather than - // break. The next non-hangable item still sees the overflowed cursor - // and wraps normally; oidashi never pulls the hung marks because they - // are forbidden-at-line-start, not forbidden-at-line-end. - if cursor > 0.0 && cursor + item.extent > max_height && !item.ch.is_some_and(can_hang) { - let mut break_at = i; - while break_at > column_start && items[break_at].keep_with_previous { - break_at -= 1; - } - // The current item closes an atomic composite that began at the - // column head (group ruby or an expanded source scalar). Keep the - // complete unit together even when it is taller than the nominal - // column; there is no legal internal break to choose instead. - if break_at == column_start && items[i].keep_with_previous { - placements.push((column, cursor)); - cursor += item.extent; - continue; - } - let mut shifted = 0; - while break_at > column_start - && shifted < MAX_KINSOKU_SHIFT - && (items[break_at].ch.is_some_and(forbidden_at_line_start) - || items[break_at - 1].ch.is_some_and(forbidden_at_line_end)) - { - break_at -= 1; - while break_at > column_start && items[break_at].keep_with_previous { - break_at -= 1; - } - shifted += 1; - } - // Give up on the shift when the moved items plus the current one - // would overflow the new column too: overflowing the wrap budget - // is worse than the kinsoku violation. - let shifted_extent: f32 = items[break_at..i].iter().map(|it| it.extent).sum(); - if break_at < i && shifted_extent + item.extent > max_height { - break_at = i; - } - column += 1; - let mut new_cursor = 0.0; - for k in break_at..i { - placements[k] = (column, new_cursor); - new_cursor += items[k].extent; - } - cursor = new_cursor; - column_start = break_at; - } - placements.push((column, cursor)); - cursor += item.extent; - } - placements -} - -/// Offset of a column's content along the column (vertical/inline) axis for -/// a given `text-align`. In `vertical-rl` the inline axis runs top->bottom, so -/// Left/Start anchor to the top, Right/End to the bottom and Center to the -/// middle of the wrap budget. `budget` is the column wrap height (the box -/// height); an unbounded budget (auto-width, columns are snug) yields no shift. -/// Justify yields no uniform shift here — its space is distributed between -/// cells per column by `justify_extra` in the placement loop. -pub fn align_offset_along_column(align: TextAlign, budget: f32, used: f32) -> f32 { - if !budget.is_finite() || budget >= f32::MAX || budget <= 0.0 { - return 0.0; - } - let slack = (budget - used).max(0.0); - match align { - TextAlign::Center => slack / 2.0, - TextAlign::Right | TextAlign::End => slack, - _ => 0.0, - } -} - -const INTER_SCRIPT_SPACING_EM: f32 = 0.25; -const WESTERN_WORD_SPACING_EM: f32 = 1.0 / 3.0; - -#[derive(Debug, Clone, Copy)] -enum FlowScript { - Upright, - Rotated { - starts_alphanumeric: bool, - ends_alphanumeric: bool, - }, -} - -/// Full-width-font punctuation whose glyph body occupies the leading (top) -/// half of its em. Its normal trailing half-em aki remains in ordinary text -/// and is shed only at punctuation-sequence boundaries. -fn sheds_trailing_aki(c: char) -> bool { - classify(c).is_trailing_aki_punctuation() -} - -/// Opening punctuation has a half-width glyph body and normally keeps a -/// leading half-em aki. That aki is shed after another opening bracket or a -/// middle dot, and at a wrapped line head (JIS X 4051 tentsuki policy). -fn sheds_leading_aki(c: char) -> bool { - classify(c) == JapaneseClass::OpeningBracket -} - -fn embedded_leading_aki(class: JapaneseClass) -> f32 { - match class { - JapaneseClass::OpeningBracket => 0.5, - JapaneseClass::MiddleDot => 0.25, - _ => 0.0, - } -} - -fn embedded_trailing_aki(class: JapaneseClass) -> f32 { - match class { - JapaneseClass::ClosingBracket | JapaneseClass::FullStop | JapaneseClass::Comma => 0.5, - JapaneseClass::MiddleDot => 0.25, - _ => 0.0, - } -} - -fn flow_class(cell: &VerticalCell, item: &FlowItem) -> Option { - match cell.kind { - CellKind::TateChuYoko { .. } => Some(JapaneseClass::TateChuYoko), - CellKind::Rotated { .. } => Some(JapaneseClass::Western), - _ => item.ch.map(classify), - } -} - -/// Smallest legal flow extent after oikomi. Full-width Japanese punctuation -/// normally carries removable aki inside its one-em advance, but `vpal` may -/// have removed that aki already. Clamp the floor to the actual shaped extent -/// so a proportional half-em glyph frame can never be compressed again. -fn minimum_oikomi_extent( - ch: Option, - extent: f32, - font_size: f32, - letter_spacing: f32, -) -> f32 { - let half_em_frame = ch.is_some_and(|ch| { - matches!( - classify(ch), - JapaneseClass::OpeningBracket - | JapaneseClass::ClosingBracket - | JapaneseClass::FullStop - | JapaneseClass::Comma - | JapaneseClass::MiddleDot - ) - }); - if half_em_frame { - extent.min(0.5 * font_size + letter_spacing) - } else { - extent - } -} - -/// Divide `amount` equally across capped opportunities, redistributing the -/// remainder whenever one opportunity reaches its cap. -fn capped_equal_allocations(capacities: &[(usize, f32)], amount: f32) -> Vec<(usize, f32)> { - let mut allocations: Vec<(usize, f32)> = capacities.iter().map(|(i, _)| (*i, 0.0)).collect(); - let mut remaining = amount.max(0.0); - while remaining > 0.0001 { - let active: Vec = capacities - .iter() - .enumerate() - .filter_map(|(slot, (_, cap))| (allocations[slot].1 + 0.0001 < *cap).then_some(slot)) - .collect(); - if active.is_empty() { - break; - } - let share = remaining / active.len() as f32; - let mut used = 0.0; - for slot in active { - let cap = capacities[slot].1; - let delta = share.min(cap - allocations[slot].1); - allocations[slot].1 += delta; - used += delta; - } - if used <= 0.0001 { - break; - } - remaining -= used; - } - allocations -} - -fn explicit_pair_spacing_em(before: JapaneseClass, after: JapaneseClass) -> f32 { - if matches!(before, JapaneseClass::DividingPunctuation) { - pair_rule(before, after).preferred_em - } else { - 0.0 - } -} - -fn materialize_explicit_pair_spacing( - cells: &mut [VerticalCell], - items: &mut [FlowItem], - classes: &[Option], -) { - for boundary in 0..cells.len().saturating_sub(1) { - let (Some(before), Some(after)) = (classes[boundary], classes[boundary + 1]) else { - continue; - }; - let spacing_em = explicit_pair_spacing_em(before, after); - if spacing_em <= 0.0 { - continue; - } - let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); - let spacing = spacing_em * em; - cells[boundary].extent += spacing; - items[boundary].extent += spacing; - } -} - -fn spacing_owner(before: JapaneseClass, after: JapaneseClass, boundary: usize) -> (usize, bool) { - if explicit_pair_spacing_em(before, after) > 0.0 { - (boundary, false) - } else if matches!( - after, - JapaneseClass::OpeningBracket | JapaneseClass::MiddleDot - ) { - // Sequence layout removes any redundant preceding trailing half; the - // remaining aki is the next glyph's embedded leading space. - (boundary + 1, true) - } else { - (boundary, false) - } -} - -fn discard_explicit_spacing_at_column_edges( - cells: &mut [VerticalCell], - items: &mut [FlowItem], - classes: &[Option], - pair_spacing_em: &mut [f32], - placements: &[(usize, f32)], -) -> bool { - let mut changed = false; - for boundary in 0..cells.len().saturating_sub(1) { - if placements[boundary].0 == placements[boundary + 1].0 { - continue; - } - let (Some(before), Some(after)) = (classes[boundary], classes[boundary + 1]) else { - continue; - }; - if explicit_pair_spacing_em(before, after) <= 0.0 { - continue; - } - let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); - let spacing = pair_spacing_em[boundary] * em; - if spacing <= 0.0 { - continue; - } - reduce_cell_spacing(cells, items, boundary, spacing, false); - pair_spacing_em[boundary] = 0.0; - changed = true; - } - changed -} - -fn reduce_cell_spacing( - cells: &mut [VerticalCell], - items: &mut [FlowItem], - index: usize, - amount: f32, - leading: bool, -) -> f32 { - let removable = (cells[index].extent - cells[index].minimum_oikomi_extent).max(0.0); - let amount = amount.min(items[index].extent.max(0.0)).min(removable); - items[index].extent -= amount; - cells[index].extent -= amount; - if leading { - cells[index].glyph_flow_shift -= amount; - } - amount -} - -/// JLREQ oikomi: before wrapping a non-hanging item, try to keep it in the -/// current column by reducing only legal aki, in table priority order. If the -/// complete deficit cannot be recovered, leave the line untouched for the -/// subsequent oidashi/kinsoku planner. -fn apply_ordered_oikomi( - cells: &mut [VerticalCell], - items: &mut [FlowItem], - classes: &[Option], - pair_spacing_em: &mut [f32], - max_height: f32, -) { - if !max_height.is_finite() || max_height <= 0.0 || max_height >= f32::MAX { - return; - } - let mut column_start = 0usize; - let mut cursor = 0.0f32; - for i in 0..items.len() { - if cursor > 0.0 - && cursor + items[i].extent > max_height - && !items[i].ch.is_some_and(can_hang) - { - let deficit = cursor + items[i].extent - max_height; - let mut opportunities: Vec<(usize, u8, f32, f32)> = Vec::new(); - for boundary in column_start..i { - let (Some(before), Some(after)) = (classes[boundary], classes[boundary + 1]) else { - continue; - }; - let rule = pair_rule(before, after); - if rule.shrink_priority == 0 || pair_spacing_em[boundary] <= rule.minimum_em { - continue; - } - let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); - let (owner, _) = spacing_owner(before, after, boundary); - let rule_capacity = (pair_spacing_em[boundary] - rule.minimum_em) * em; - let physical_capacity = - (cells[owner].extent - cells[owner].minimum_oikomi_extent).max(0.0); - let capacity = rule_capacity.min(physical_capacity); - if capacity <= 0.0001 { - continue; - } - opportunities.push((boundary, rule.shrink_priority, capacity, em)); - } - let total_capacity: f32 = opportunities.iter().map(|(_, _, cap, _)| *cap).sum(); - if total_capacity + 0.0001 >= deficit { - let mut remaining = deficit; - for priority in 1..=5 { - if remaining <= 0.0001 { - break; - } - let caps: Vec<(usize, f32)> = opportunities - .iter() - .filter(|(_, p, _, _)| *p == priority) - .map(|(boundary, _, cap, _)| (*boundary, *cap)) - .collect(); - if caps.is_empty() { - continue; - } - let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); - for (boundary, reduction) in - capped_equal_allocations(&caps, remaining.min(available)) - { - let (Some(before), Some(after)) = - (classes[boundary], classes[boundary + 1]) - else { - continue; - }; - let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); - let (owner, leading) = spacing_owner(before, after, boundary); - let reduction = - reduce_cell_spacing(cells, items, owner, reduction, leading); - pair_spacing_em[boundary] -= reduction / em; - if owner < i { - cursor -= reduction; - } - remaining -= reduction; - } - } - } - if cursor + items[i].extent > max_height + 0.0001 { - column_start = i; - cursor = 0.0; - } - } - cursor += items[i].extent; - } -} - -/// Ordered oidashi expansion for justified columns. Stages 1–3 respect the -/// table caps; if slack remains, stage 4 distributes it equally across every -/// otherwise-expandable boundary, as required by JLREQ §3.8.4. -fn ordered_expansion_offsets( - cells: &[VerticalCell], - classes: &[Option], - pair_spacing_em: &[f32], - placements: &[(usize, f32)], - column_used: &[f32], - max_height: f32, - last_column: usize, -) -> Vec { - let mut boundary_expansion = vec![0.0f32; cells.len().saturating_sub(1)]; - for (column, used) in column_used.iter().enumerate().take(last_column) { - let mut boundaries: Vec<(usize, u8, f32)> = Vec::new(); - for boundary in 0..cells.len().saturating_sub(1) { - if placements[boundary].0 != column || placements[boundary + 1].0 != column { - continue; - } - let (Some(before), Some(after)) = (classes[boundary], classes[boundary + 1]) else { - continue; - }; - let rule = pair_rule(before, after); - if rule.expand_priority == 0 { - continue; - } - let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); - let cap = (rule.maximum_em - pair_spacing_em[boundary]).max(0.0) * em; - boundaries.push((boundary, rule.expand_priority, cap)); - } - let mut remaining = (max_height - used).max(0.0); - for priority in 1..=3 { - let caps: Vec<(usize, f32)> = boundaries - .iter() - .filter(|(_, p, _)| *p == priority) - .map(|(boundary, _, cap)| (*boundary, *cap)) - .collect(); - let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); - for (boundary, expansion) in capped_equal_allocations(&caps, remaining.min(available)) { - boundary_expansion[boundary] += expansion; - remaining -= expansion; - } - } - if remaining > 0.0001 && !boundaries.is_empty() { - let extra = remaining / boundaries.len() as f32; - for (boundary, _, _) in &boundaries { - boundary_expansion[*boundary] += extra; - } - } - } - - let mut offsets = vec![0.0f32; cells.len()]; - for i in 1..cells.len() { - if placements[i].0 == placements[i - 1].0 { - offsets[i] = offsets[i - 1] + boundary_expansion[i - 1]; - } - } - offsets -} - -/// Centered punctuation (jlreq class cl-05 中点類): the middle dot, the -/// full-width colon and the full-width semicolon are placed at the centre of -/// the em box in vertical writing rather than on the horizontal baseline. -fn is_centered_punctuation(c: char) -> bool { - classify(c) == JapaneseClass::MiddleDot -} - -/// Flow-axis shift that centres a glyph's ink band within its em body: moves -/// the ink midpoint (`(ink_top + ink_bottom) / 2`) to the body midpoint. -fn centered_flow_shift(ink_top: f32, ink_bottom: f32, em_body: f32) -> f32 { - em_body / 2.0 - (ink_top + ink_bottom) / 2.0 -} - -/// Japanese inter-script spacing at an upright CJK <-> rotated alphabetic or -/// numeric boundary. Punctuation and explicit whitespace do not create an -/// automatic gap. Use the smaller adjacent font size so a large neighboring -/// run cannot create a disproportionate gap. -fn inter_script_spacing( - previous: FlowScript, - previous_font_size: f32, - next: FlowScript, - next_font_size: f32, -) -> f32 { - let boundary = matches!( - (previous, next), - ( - FlowScript::Upright, - FlowScript::Rotated { - starts_alphanumeric: true, - .. - } - ) | ( - FlowScript::Rotated { - ends_alphanumeric: true, - .. - }, - FlowScript::Upright - ) - ); - if boundary { - previous_font_size.min(next_font_size) * INTER_SCRIPT_SPACING_EM - } else { - 0.0 - } -} - -/// Offset that centres a local-y band on the column axis after the run is -/// rotated 90°. The draw-time path supplies the glyphs' actual ink bounds; -/// ascent/descent metrics are only the fallback for runs without visible ink. -pub fn rotated_baseline_shift(top: f32, bottom: f32) -> f32 { - -(top + bottom) / 2.0 -} - -fn glyph_run_ink_bounds( - font: &Font, - glyphs: &[GlyphId], - positions: &[SkPoint], -) -> Option { - let mut bounds = vec![skia::Rect::default(); glyphs.len()]; - font.get_bounds(glyphs, &mut bounds, None); - - let mut ink = skia::Rect::new(f32::MAX, f32::MAX, f32::MIN, f32::MIN); - for (bound, position) in bounds.iter().zip(positions) { - if bound.right > bound.left && bound.bottom > bound.top { - ink.left = ink.left.min(bound.left + position.x); - ink.top = ink.top.min(bound.top + position.y); - ink.right = ink.right.max(bound.right + position.x); - ink.bottom = ink.bottom.max(bound.bottom + position.y); - } - } - - (ink.right > ink.left && ink.bottom > ink.top).then_some(ink) -} - -fn rotated_run_baseline_shift(font: &Font, glyphs: &[GlyphId], positions: &[SkPoint]) -> f32 { - if let Some(ink) = glyph_run_ink_bounds(font, glyphs, positions) { - rotated_baseline_shift(ink.top, ink.bottom) - } else { - let (_, metrics) = font.metrics(); - rotated_baseline_shift(metrics.ascent, metrics.descent) - } -} - -/// Normalize ASCII word spaces inside a sideways Western run to JLREQ's -/// preferred one-third em. Shaping retains the source scalar and break -/// opportunity; only its advance and following glyph positions change. -fn normalize_rotated_word_spaces(segment_text: &str, run: &mut ShapedRun, font_size: f32) { - let mut glyph = 0usize; - let mut accumulated_shift = 0.0f32; - while glyph < run.glyphs.len() { - let cluster = run.clusters[glyph]; - let mut count = 1usize; - while glyph + count < run.glyphs.len() && run.clusters[glyph + count] == cluster { - count += 1; - } - for position in &mut run.positions[glyph..glyph + count] { - position.x += accumulated_shift; - } - let is_word_space = segment_text - .get(cluster as usize..) - .and_then(|text| text.chars().next()) - .is_some_and(|character| character == ' '); - if is_word_space { - let natural: f32 = run.advances[glyph..glyph + count].iter().sum(); - let delta = font_size * WESTERN_WORD_SPACING_EM - natural; - run.advances[glyph + count - 1] += delta; - accumulated_shift += delta; - } - glyph += count; - } - run.advance += accumulated_shift; -} - -/// Vertical offset from a cell's top edge to the glyph baseline for an upright -/// cell. Centres the glyph's line box within the em cell (as the Tate-chu-yoko -/// path does) instead of hanging it from the horizontal ascent: CJK faces have -/// an ascent larger than the em, so hanging from it pushes every glyph below -/// its cell and the whole column overflows its bounds. -fn upright_baseline_offset(ascent: f32, descent: f32, font_size: f32) -> f32 { - font_size / 2.0 - (ascent + descent) / 2.0 -} - -fn upright_flow_ink_bounds(run: &ShapedRun, glyph: usize, count: usize) -> Option<(f32, f32)> { - let base_x = run.positions[glyph].x; - let positions: Vec = run.positions[glyph..glyph + count] - .iter() - .map(|position| SkPoint::new(position.x - base_x, position.y)) - .collect(); - let ink = glyph_run_ink_bounds(&run.font, &run.glyphs[glyph..glyph + count], &positions)?; - let (ascent, descent) = upright_centre_metrics(&run.font); - let offset = upright_baseline_offset(ascent, descent, run.font.size()); - Some((ink.top + offset, ink.bottom + offset)) -} - -fn rotated_cluster_flow_ink_bounds( - run: &ShapedRun, - glyph: usize, - count: usize, -) -> Option<(f32, f32)> { - let base_x = run.positions[glyph].x; - let positions: Vec = run.positions[glyph..glyph + count] - .iter() - .map(|position| SkPoint::new(position.x - base_x, position.y)) - .collect(); - let ink = glyph_run_ink_bounds(&run.font, &run.glyphs[glyph..glyph + count], &positions)?; - Some((ink.left, ink.right)) -} - -fn cluster_text_blob(run: &ShapedRun, glyph: usize, count: usize) -> Option { - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - let base_x = run.positions[glyph].x; - for i in 0..count { - glyphs[i] = run.glyphs[glyph + i]; - points[i] = SkPoint::new( - run.positions[glyph + i].x - base_x, - run.positions[glyph + i].y, - ); - } - builder.make() -} - -fn rotated_cluster_baseline_shift(run: &ShapedRun, glyph: usize, count: usize) -> f32 { - let base_x = run.positions[glyph].x; - let positions: Vec = run.positions[glyph..glyph + count] - .iter() - .map(|position| SkPoint::new(position.x - base_x, position.y)) - .collect(); - rotated_run_baseline_shift(&run.font, &run.glyphs[glyph..glyph + count], &positions) -} - -fn rotated_flow_ink_bounds(run: &ShapedRun) -> Option<(f32, f32)> { - let ink = glyph_run_ink_bounds(&run.font, &run.glyphs, &run.positions)?; - Some((ink.left, ink.right)) -} - -#[derive(Debug, Clone, Copy)] -pub enum CellKind { - Upright { - run: usize, - glyph: usize, - count: usize, - }, - /// A character that participates in upright Japanese flow and kinsoku, - /// but whose font has no `vert`/`vrt2` alternate. It is rotated per cell - /// instead of becoming a sideways run so wrapping and editor offsets stay - /// character-granular. - SyntheticRotated { - run: usize, - glyph: usize, - count: usize, - }, - Rotated { - run: usize, - }, - TateChuYoko { - /// Composite of one or more shaped runs (fallback fonts each add a - /// run) laid side by side; `[run_start, run_start + run_count)`. - run_start: usize, - run_count: usize, - scale: f32, - }, - Warichu { - /// Two half-size sub-lines stacked side by side within the column: - /// runs `[run_start, run_start + first_count)` are the first (right) - /// sub-line, the rest up to `run_start + run_count` the second (left). - run_start: usize, - run_count: usize, - first_count: usize, - /// UTF-16 length of the first sub-line's text (the split point, - /// relative to the cell's `start`). - first_chars: usize, - }, -} - -/// One placed piece of the vertical flow. Offsets are UTF-16, -/// paragraph-relative (all spans concatenated), in original text space. -pub struct VerticalCell { - pub kind: CellKind, - pub paragraph: usize, - pub span: usize, - pub start: usize, - pub end: usize, - pub column: usize, - pub top: f32, - /// Advance along the column (vertical/flow axis) — from `vmtx` when - /// available, else the shaped horizontal advance. - pub extent: f32, - /// Lower bound for oikomi reductions. This is the glyph frame after font - /// features such as `vpal`, before any removable pair spacing is added. - pub minimum_oikomi_extent: f32, - /// Shaped horizontal glyph advance, used to centre the glyph on the - /// column axis (independent of the vertical flow `extent`). - pub h_advance: f32, - /// Visible glyph-ink edges along the flow axis, relative to `top`. - pub ink_top: f32, - pub ink_bottom: f32, - pub paint: usize, - /// Span font size, for decoration bar geometry. - pub font_size: f32, - /// Span text decoration, painted as vertical bars along the column. - pub decoration: Option, - /// Extra flow-axis (vertical) shift applied to the drawn glyph only, used to - /// pull half-width opening punctuation up into its compressed cell so its - /// ink hugs the preceding character (jlreq leading aki removal). Zero for - /// every other cell; never affects extent, caret, or position-data. - pub glyph_flow_shift: f32, -} - -/// A laid-out column, x measured from the *content* left edge (the content -/// block is anchored to the shape's right edge by consumers). -#[derive(Debug, Clone, Copy)] -pub struct VerticalColumn { - pub x: f32, - pub width: f32, - /// Reserved annotation gutter before the base band (left / `under`). - pub base_offset: f32, - /// Line-height-controlled column advance. Base glyphs centre on this band. - /// Ruby reserves additional column width but attaches to the centred base - /// em, so extra leading does not become base-to-ruby spacing. - pub base_width: f32, -} - -fn column_base_center(column: &VerticalColumn) -> f32 { - column.x + column.base_offset + column.base_width / 2.0 -} - -/// One shaped ruby glyph, retaining its fallback-font run and source range. -#[derive(Debug, Clone, Copy)] -pub struct RubyGlyph { - pub run: usize, - pub glyph: usize, - pub utf16_start: usize, - pub utf16_end: usize, -} - -/// A ruby annotation placed alongside one column of base characters. -/// Painted from `ruby_runs`; kept out of `cells` so base metrics and caret -/// geometry are unaffected. -#[derive(Debug, Clone)] -pub struct RubyCell { - /// Ordered glyphs for this base column. Each retains the shaped run that - /// supplied its font so fallback boundaries do not drop ruby content. - pub glyphs: Vec, - pub paragraph: usize, - pub span: usize, - pub column: usize, - /// Flow-axis (top, extent) of each base character this ruby annotates, - /// in flow order, restricted to the annotated column. Group ruby spreads - /// the annotation over the union; mono ruby maps ruby glyphs onto the - /// individual segments. - pub base_segments: Vec<(f32, f32)>, - /// Final flow-axis positions computed from the explicit ruby mapping. - pub glyph_tops: Vec, - pub font_size: f32, - pub base_font_size: f32, - pub side: RubySide, - pub paint: usize, -} - -struct RubyColumnSegments { - column: usize, - paint: usize, - segments: Vec, -} - -#[derive(Debug, Clone, Copy)] -struct RubyBaseSegment { - top: f32, - extent: f32, -} - -/// 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. -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, - pub font_size: f32, - pub outside_offset: f32, -} - -pub struct VerticalLayout { - pub runs: Vec, - pub paints: Vec, - pub cells: Vec, - pub columns: Vec, - /// Shaped ruby annotation runs, indexed by `RubyCell::run`. - pub ruby_runs: Vec, - pub ruby_cells: Vec, - /// Shaped emphasis-mark runs (single glyph each), indexed by - /// `EmphasisMark::run`. - pub emphasis_runs: Vec, - pub emphasis_marks: Vec, - /// Per paragraph: [start, end) range into `columns`. - pub paragraph_columns: Vec<(usize, usize)>, - /// Per paragraph: UTF-16 start offset of each span (paragraph-relative). - pub span_utf16_starts: Vec>, - /// Per paragraph: source UTF-16 start offset of each span. - pub span_source_utf16_starts: Vec>, - /// Per paragraph and span: transformed scalar ownership in source text. - pub span_transforms: Vec>, - /// Per paragraph: UTF-16 offset of every Unicode scalar boundary. Editor - /// positions use indices into this table, while cells and position data - /// keep their browser-facing UTF-16 offsets. - pub paragraph_utf16_boundaries: Vec>, - pub width: f32, - pub height: f32, -} - -impl VerticalLayout { - /// Content origin (top-left of the laid-out block) in the same - /// coordinate space as `bounds`. In vertical-rl, block-start is the - /// right edge, block-center is the horizontal center and block-end is - /// the left edge. - pub fn origin(&self, bounds: &Rect, align: VerticalAlign) -> (f32, f32) { - ( - bounds.left + block_axis_offset(bounds.width(), self.width, align), - bounds.top, - ) - } -} - -/// Horizontal offset of vertical content within its shape. The existing -/// top/center/bottom values describe block-start/center/end; for vertical-rl -/// those positions map to right/center/left respectively. -pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 { - let slack = (container_width - content_width).max(0.0); - match align { - VerticalAlign::Top => slack, - VerticalAlign::Center => slack / 2.0, - VerticalAlign::Bottom => 0.0, - } -} - -/// The column-wrap limit for a vertical text content: auto-width shapes -/// grow to fit (columns never wrap), everything else wraps at the shape -/// height. This is the phase's explicit auto-size decision: auto-height -/// behaves like fixed under vertical writing for now. -pub fn wrap_height(text_content: &TextContent, height: f32) -> f32 { - match text_content.grow_type() { - GrowType::AutoWidth => f32::MAX, - _ => f32::max(height, 1.0), - } -} - -/// Return the proportional item range belonging to a contiguous slice of the -/// base text. This keeps a ruby reading monotonic when its base wraps across -/// columns while ensuring the final column receives any rounding remainder. -fn proportional_range( - item_count: usize, - base_start: usize, - base_count: usize, - total_base_count: usize, -) -> std::ops::Range { - if item_count == 0 || total_base_count == 0 { - return 0..0; - } - let start = base_start.saturating_mul(item_count) / total_base_count; - let end_base = base_start.saturating_add(base_count).min(total_base_count); - let end = if end_base == total_base_count { - item_count - } else { - end_base.saturating_mul(item_count) / total_base_count - }; - start.min(item_count)..end.min(item_count) -} - -/// Expand gaps between already-placed base cells for long ruby annotations. -/// -/// This is deliberately post-placement and bounded: it only shifts later base -/// cells in the same span/column, and only into slack before the next cell (or -/// the column bottom). It avoids re-wrapping columns while making the base span -/// long enough for common long compound-word readings when there is room. -#[derive(Debug, Clone, Copy)] -struct RubyBaseUnit { - span: usize, - start: usize, - end: usize, - ruby_len: usize, - ruby_font_size: f32, - overhang: RubyOverhang, -} - -fn spread_ruby_base_cells( - cells: &mut [VerticalCell], - ruby_units: &[RubyBaseUnit], - max_height: f32, -) { - for unit in ruby_units { - let mut columns: Vec = cells - .iter() - .filter(|cell| cell.span == unit.span && cell.start < unit.end && cell.end > unit.start) - .map(|cell| cell.column) - .collect(); - columns.sort_unstable(); - columns.dedup(); - - let total_base_count = cells - .iter() - .filter(|cell| cell.span == unit.span && cell.start < unit.end && cell.end > unit.start) - .count(); - let mut base_start = 0usize; - - for column in columns { - let mut group: Vec = cells - .iter() - .enumerate() - .filter(|(_, cell)| { - cell.span == unit.span - && cell.column == column - && cell.start < unit.end - && cell.end > unit.start - }) - .map(|(index, _)| index) - .collect(); - if group.len() < 2 { - base_start += group.len(); - continue; - } - group.sort_by(|a, b| { - cells[*a] - .top - .partial_cmp(&cells[*b].top) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - let ruby_range = - proportional_range(unit.ruby_len, base_start, group.len(), total_base_count); - base_start += group.len(); - let ruby_line = unit.ruby_font_size * ruby_range.len() as f32; - let first = group[0]; - let last = *group.last().unwrap(); - let base_top = cells[first].top; - let base_bottom = cells[last].top + cells[last].extent; - let base_extent = base_bottom - base_top; - if ruby_line <= base_extent { - continue; - } - - let next_top = cells - .iter() - .enumerate() - .filter(|(index, cell)| { - cell.column == column && !group.contains(index) && cell.top >= base_bottom - }) - .map(|(_, cell)| cell.top) - .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let column_limit = if max_height.is_finite() && max_height < f32::MAX { - max_height - } else { - f32::MAX - }; - let limit = next_top.unwrap_or(column_limit); - let spread = (ruby_line - base_extent).min((limit - base_bottom).max(0.0)); - if spread <= 0.0 { - continue; - } - - let gap = spread / (group.len() - 1) as f32; - for (position, index) in group.iter().enumerate().skip(1) { - cells[*index].top += gap * position as f32; - } - } - } -} - -/// Split a TCY `digits` span into pieces: maximal ASCII or full-width digit runs of -/// 2..=max characters become upright composites, everything else keeps the -/// normal vertical layout. Returns (piece text, span-relative UTF-16 start, -/// tcy). -fn is_tcy_digit(c: char) -> bool { - c.is_ascii_digit() || ('0'..='9').contains(&c) -} - -fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> { - let mut pieces: Vec<(String, usize, bool)> = Vec::new(); - let mut utf16 = 0usize; - let mut current = String::new(); - let mut current_start = 0usize; - let mut current_digit = false; - for c in text.chars() { - let digit = is_tcy_digit(c); - if current.is_empty() { - current_start = utf16; - current_digit = digit; - } else if digit != current_digit { - pieces.push((std::mem::take(&mut current), current_start, current_digit)); - current_start = utf16; - current_digit = digit; - } - current.push(c); - utf16 += c.len_utf16(); - } - if !current.is_empty() { - pieces.push((current, current_start, current_digit)); - } - for piece in pieces.iter_mut() { - if piece.2 { - let len = piece.0.chars().count(); - piece.2 = (2..=max).contains(&len); - } - } - pieces -} - -/// Compose `piece` (a whole TCY span or a digit run inside one) into one -/// upright composite cell, shaping with the first family that covers the -/// piece and composing any fallback runs side by side. Returns false when -/// the composite would compress below `min_scale`; the caller then falls back -/// to the normal vertical segmentation. -#[allow(clippy::too_many_arguments)] -fn try_push_tcy_composite( - piece: &str, - piece_utf16_start: usize, - min_scale: f32, - span: &TextSpan, - span_utf16_offset: usize, - paragraph_index: usize, - span_index: usize, - paint_index: usize, - families: &[String], - font_provider: &TypefaceFontProvider, - fallback_mgr: &FontMgr, - runs: &mut Vec, - para_cells: &mut Vec, - items: &mut Vec, - scripts: &mut Vec, - trailing_spacings: &mut Vec, -) -> bool { - // Ruby and other span-level formatting can split an otherwise continuous - // vertical run into a one-character span while preserving an inherited - // `text-combine-upright: all`. A single CJK character is already upright; - // routing it through the horizontal TCY path can scale it down to fit the - // font's line metrics and make it smaller than adjacent base characters. - // Keep naturally upright single characters on the normal vertical path. - let mut chars = piece.chars(); - if matches!( - (chars.next(), chars.next()), - (Some(ch), None) if is_upright_char(ch) - ) { - return false; - } - - let letter_spacing = span.letter_spacing; - let mut shaped = shape_segment_with_fallbacks( - piece, - span.font_size, - families, - font_provider, - false, - span.font_features, - fallback_mgr, - ); - if shaped.is_empty() { - return false; - } - let em = span.font_size.max(1.0); - let mut combined_advance = 0.0f32; - let mut run_height = 1.0f32; - for run in &mut shaped { - if letter_spacing != 0.0 { - for (i, position) in run.positions.iter_mut().enumerate() { - position.x += letter_spacing * i as f32; - } - run.advance += letter_spacing * run.glyphs.len() as f32; - } - let (_, metrics) = run.font.metrics(); - run_height = run_height.max(metrics.descent - metrics.ascent); - combined_advance += run.advance; - } - // Letter-spacing also separates adjacent fallback runs. - if letter_spacing != 0.0 && shaped.len() > 1 { - combined_advance += letter_spacing * (shaped.len() - 1) as f32; - } - let scale = (em / combined_advance.max(1.0)) - .min(em / run_height.max(1.0)) - .min(1.0); - // Width limit: reject runs wider than the active TCY mode permits. The - // generic `all` mode keeps the conservative half-scale limit, while the - // counted digit modes allow the 1/run-length scale needed by full-width - // digits. - if scale < min_scale { - return false; - } - let extent = em + letter_spacing; - let run_start = runs.len(); - let run_count = shaped.len(); - for run in shaped { - runs.push(run); - } - let start = span_utf16_offset + piece_utf16_start; - let end = start + piece.encode_utf16().count(); - para_cells.push(VerticalCell { - kind: CellKind::TateChuYoko { - run_start, - run_count, - scale, - }, - paragraph: paragraph_index, - span: span_index, - start, - end, - column: 0, - top: 0.0, - extent, - minimum_oikomi_extent: extent, - h_advance: combined_advance * scale, - ink_top: 0.0, - ink_bottom: em, - paint: paint_index, - font_size: span.font_size, - decoration: span.text_decoration, - glyph_flow_shift: 0.0, - }); - items.push(FlowItem { - extent, - ch: None, - keep_with_previous: false, - }); - scripts.push(FlowScript::Upright); - trailing_spacings.push(letter_spacing); - true -} - -/// Lay out the whole content vertically. Pure of global state: fonts come -/// through the provider/fallback arguments so native tests can supply -/// their own. -pub fn layout_vertical( - text_content: &TextContent, - max_height: f32, - font_provider: &TypefaceFontProvider, - fallback_mgr: FontMgr, - fallback_families: &[String], - bounds: Rect, -) -> VerticalLayout { - let mut runs: Vec = Vec::new(); - let mut paints: Vec = Vec::new(); - let mut cells: Vec = Vec::new(); - let mut columns: Vec = Vec::new(); - let mut paragraph_columns: Vec<(usize, usize)> = Vec::new(); - let mut span_utf16_starts: Vec> = Vec::new(); - let span_transforms: Vec> = text_content - .paragraphs() - .iter() - .map(|paragraph| { - paragraph - .children() - .iter() - .map(TextSpan::apply_text_transform_with_source_ranges) - .collect() - }) - .collect(); - let paragraph_utf16_boundaries: Vec> = span_transforms - .iter() - .map(|paragraph| { - let mut boundaries = vec![0usize]; - for span in paragraph { - for character in span.text.chars() { - let next = boundaries.last().copied().unwrap_or(0) + character.len_utf16(); - boundaries.push(next); - } - } - boundaries - }) - .collect(); - let span_source_utf16_starts: Vec> = text_content - .paragraphs() - .iter() - .map(|paragraph| { - let mut offset = 0usize; - paragraph - .children() - .iter() - .map(|span| { - let start = offset; - offset += span.text.encode_utf16().count(); - start - }) - .collect() - }) - .collect(); - - for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { - let line_height = if paragraph.line_height() > 0.0 { - paragraph.line_height() - } else { - 1.2 - }; - let max_font_size = paragraph - .children() - .iter() - .map(|s| s.font_size) - .fold(12.0, f32::max); - let column_advance = max_font_size * line_height; - - // Ruby reserves its configured-size gutter on the logical annotation - // side. In vertical-rl, `over` is right and `under` is left. - let ruby_over_gutter = paragraph - .children() - .iter() - .filter(|span| !span.ruby.trim().is_empty() && span.ruby_side == RubySide::Over) - .map(|span| span.font_size * span.ruby_size.scale()) - .fold(0.0, f32::max); - let ruby_under_gutter = paragraph - .children() - .iter() - .filter(|span| !span.ruby.trim().is_empty() && span.ruby_side == RubySide::Under) - .map(|span| span.font_size * span.ruby_size.scale()) - .fold(0.0, f32::max); - // Emphasis occupies the over/right side. Auto-clearance spans carrying - // both annotation types stack there; opposite-side ruby stays separate. - let paragraph_has_emphasis = paragraph - .children() - .iter() - .any(|s| !s.text_emphasis.is_none()); - let emphasis_gutter = if paragraph_has_emphasis { - max_font_size * EMPHASIS_FONT_SCALE - } else { - 0.0 - }; - let has_stacked_auto_annotations = paragraph.children().iter().any(|s| { - s.annotation_clearance.is_auto() - && !s.ruby.trim().is_empty() - && s.ruby_side == RubySide::Over - && !s.text_emphasis.is_none() - }); - let over_gutter = if has_stacked_auto_annotations { - ruby_over_gutter + emphasis_gutter - } else { - ruby_over_gutter.max(emphasis_gutter) - }; - let column_width = ruby_under_gutter + column_advance + over_gutter; - - // Cells of this paragraph, parallel to `items`, placed after - // column planning. - let mut para_cells: Vec = Vec::new(); - let mut items: Vec = Vec::new(); - let mut scripts: Vec = Vec::new(); - let mut trailing_spacings: Vec = Vec::new(); - let mut span_starts: Vec = Vec::new(); - let mut span_utf16_offset = 0usize; - let mut ruby_units: Vec = Vec::new(); - - for (span_index, span) in paragraph.children().iter().enumerate() { - span_starts.push(span_utf16_offset); - let text = span_transforms[paragraph_index][span_index].text.clone(); - if text.is_empty() { - continue; - } - if !span.ruby.trim().is_empty() { - ruby_units.push(RubyBaseUnit { - span: span_index, - start: span_utf16_offset, - end: span_utf16_offset + text.encode_utf16().count(), - ruby_len: span.ruby.trim().chars().count(), - ruby_font_size: span.font_size * span.ruby_size.scale(), - overhang: span.ruby_overhang, - }); - } - paints.push(merge_fills(&span.fills, bounds)); - let paint_index = paints.len() - 1; - - // Candidate families: the span's own font first, then emoji and - // registered fallback fonts (Noto etc.). Shaping resolves glyph - // coverage explicitly and splits runs at typeface boundaries. - let families = span_font_families(span, fallback_families); - - let orientation = span.text_orientation; - let letter_spacing = span.letter_spacing; - if span.text_combine_upright == TextCombineUpright::All - && try_push_tcy_composite( - &text, - 0, - MIN_TCY_SCALE, - span, - span_utf16_offset, - paragraph_index, - span_index, - paint_index, - &families, - font_provider, - &fallback_mgr, - &mut runs, - &mut para_cells, - &mut items, - &mut scripts, - &mut trailing_spacings, - ) - { - span_utf16_offset += text.encode_utf16().count(); - continue; - } - // Warichu (割注): the span becomes one composite cell holding two - // half-size sub-lines laid side by side within the column (the - // first sub-line on the right, jlreq reading order). The split is - // balanced by character count with the first line the longer one, - // nudged so the sub-lines respect kinsoku. - if span.warichu && text.chars().count() >= 2 { - let half_size = span.font_size * WARICHU_FONT_SCALE; - let split_chars = warichu_split_chars(&text); - let split_utf8 = text - .char_indices() - .nth(split_chars) - .map(|(i, _)| i) - .unwrap_or(text.len()); - let (first_text, second_text) = text.split_at(split_utf8); - let first_chars = first_text.encode_utf16().count(); - let mut first_runs = shape_segment_with_fallbacks( - first_text, - half_size, - &families, - font_provider, - true, - span.font_features, - &fallback_mgr, - ); - let mut second_runs = shape_segment_with_fallbacks( - second_text, - half_size, - &families, - font_provider, - true, - span.font_features, - &fallback_mgr, - ); - if !first_runs.is_empty() && !second_runs.is_empty() { - let first_extent: f32 = first_runs.iter().map(|r| r.advance).sum(); - let second_extent: f32 = second_runs.iter().map(|r| r.advance).sum(); - let extent = first_extent.max(second_extent) + letter_spacing; - let run_start = runs.len(); - let first_count = first_runs.len(); - let run_count = first_count + second_runs.len(); - runs.append(&mut first_runs); - runs.append(&mut second_runs); - let end = span_utf16_offset + text.encode_utf16().count(); - para_cells.push(VerticalCell { - kind: CellKind::Warichu { - run_start, - run_count, - first_count, - first_chars, - }, - paragraph: paragraph_index, - span: span_index, - start: span_utf16_offset, - end, - column: 0, - top: 0.0, - extent, - minimum_oikomi_extent: extent, - // Two half-em sub-columns side by side fill the em. - h_advance: span.font_size, - ink_top: 0.0, - ink_bottom: extent - letter_spacing, - paint: paint_index, - font_size: span.font_size, - decoration: span.text_decoration, - glyph_flow_shift: 0.0, - }); - items.push(FlowItem { - extent, - ch: None, - keep_with_previous: false, - }); - scripts.push(FlowScript::Upright); - trailing_spacings.push(letter_spacing); - span_utf16_offset += text.encode_utf16().count(); - continue; - } - } - // `digits` combines runs of 2..=max consecutive ASCII or full-width digits - // (max 4, or 2/3 for the counted variants) into one upright - // composite; the rest of the span (and every other span) flows - // through the normal orientation segmentation. - let pieces: Vec<(String, usize, bool)> = - if let Some(max) = span.text_combine_upright.digits_max() { - split_digit_runs(&text, max) - } else { - vec![(text.clone(), 0, false)] - }; - for (piece_text, piece_utf16_start, piece_tcy) in &pieces { - let piece_base = span_utf16_offset + piece_utf16_start; - let min_tcy_scale = 1.0 / piece_text.chars().count().max(1) as f32; - if *piece_tcy - && try_push_tcy_composite( - piece_text, - *piece_utf16_start, - min_tcy_scale, - span, - span_utf16_offset, - paragraph_index, - span_index, - paint_index, - &families, - font_provider, - &fallback_mgr, - &mut runs, - &mut para_cells, - &mut items, - &mut scripts, - &mut trailing_spacings, - ) - { - continue; - } - for segment in segment_by_orientation(piece_text, orientation) { - let shaped = shape_segment_with_fallbacks( - &segment.text, - span.font_size, - &families, - font_provider, - segment.upright, - span.font_features, - &fallback_mgr, - ); - - // Map UTF-8 offsets in the segment text to UTF-16 offsets - // in the span text. - let utf8_to_utf16 = |utf8: usize| -> usize { - segment.utf16_start - + segment.text[..utf8.min(segment.text.len())] - .chars() - .map(char::len_utf16) - .sum::() - }; - - for mut run in shaped { - let run_index = runs.len(); - let seg_utf16_end = utf8_to_utf16(run.utf8_range.end); - if segment.upright { - let vmetrics = vertical_metrics(&run.font); - // vpal deltas apply here in layout, not in the - // shaper: SkShaper shapes on a horizontal line, - // where vertical GPOS positioning never fires. - let vpal = (span.font_features == FontFeatures::Vpal) - .then(|| vpal_table(&run.font)) - .flatten(); - // Group glyphs by cluster so combining sequences - // stay in one cell. - let mut glyph = 0usize; - while glyph < run.glyphs.len() { - let cluster = run.clusters[glyph]; - let mut count = 1; - while glyph + count < run.glyphs.len() - && run.clusters[glyph + count] == cluster - { - count += 1; - } - let next_cluster_utf8 = run - .clusters - .get(glyph + count) - .map(|c| *c as usize) - .unwrap_or(run.utf8_range.end); - let h_advance: f32 = - run.advances[glyph..glyph + count].iter().sum(); - let h_advance = if h_advance > 0.0 { - h_advance - } else { - span.font_size - }; - // The character in this cluster; used to size the - // cell and, later, to classify it. - let ch = segment.text[(cluster as usize)..].chars().next(); - // Flow down the column by the true vertical advance - // when the font has `vmtx`. Without it, fall back to - // the horizontal advance, which is exact for - // full-width CJK. A character that is upright only - // because `text-orientation: upright` forced it - // (e.g. a Latin letter) is far narrower than an em - // and would pack together and collide with the - // following character, so floor it to a full em, - // matching the browser's upright behavior. - // Letter-spacing widens the gap after each cluster. - let horizontal_fallback = if ch.is_some_and(|c| !is_upright_char(c)) - { - h_advance.max(span.font_size) - } else { - h_advance - }; - let extent = vmetrics - .as_ref() - .map(|vm| { - run.glyphs[glyph..glyph + count] - .iter() - .map(|g| vm.advance(*g, span.font_size)) - .sum::() - }) - .filter(|v| *v > 0.0) - .unwrap_or(horizontal_fallback); - // vpal: the font's advance delta tightens the - // cell and its placement delta lifts the drawn - // ink to keep it inside. The tightened extent - // flows into caret, position-data and the aki - // sheds (which skip already-half-width cells). - let vpal_delta = vpal.as_ref().and_then(|table| { - table.cluster_delta( - &run.glyphs[glyph..glyph + count], - span.font_size, - ) - }); - let extent = extent - + vpal_delta.map(|(advance, _)| advance).unwrap_or(0.0) - + letter_spacing; - let vpal_flow_shift = - vpal_delta.map(|(_, shift)| shift).unwrap_or(0.0); - let start = piece_base + utf8_to_utf16(cluster as usize); - let end = piece_base + utf8_to_utf16(next_cluster_utf8); - let minimum_oikomi_extent = minimum_oikomi_extent( - ch, - extent, - span.font_size, - letter_spacing, - ); - let synthetic_rotation = - cluster_needs_rotated_vertical_fallback(&run, glyph, count, ch); - let (mut ink_top, mut ink_bottom) = if synthetic_rotation { - rotated_cluster_flow_ink_bounds(&run, glyph, count) - } else { - upright_flow_ink_bounds(&run, glyph, count) - } - .unwrap_or((0.0, extent - letter_spacing)); - ink_top += vpal_flow_shift; - ink_bottom += vpal_flow_shift; - // Centre middle-dot / colon / semicolon ink in the - // em body; the shift moves the drawn glyph only. - // A vpal-covered glyph keeps the font's own - // centring instead. - let glyph_flow_shift = if !synthetic_rotation - && vpal_delta.is_none() - && ch.is_some_and(is_centered_punctuation) - { - let shift = centered_flow_shift( - ink_top, - ink_bottom, - extent - letter_spacing, - ); - ink_top += shift; - ink_bottom += shift; - shift - } else { - vpal_flow_shift - }; - para_cells.push(VerticalCell { - kind: if synthetic_rotation { - CellKind::SyntheticRotated { - run: run_index, - glyph, - count, - } - } else { - CellKind::Upright { - run: run_index, - glyph, - count, - } - }, - paragraph: paragraph_index, - span: span_index, - start, - end, - column: 0, - top: 0.0, - extent, - minimum_oikomi_extent, - h_advance, - ink_top, - ink_bottom, - paint: paint_index, - font_size: span.font_size, - decoration: span.text_decoration, - glyph_flow_shift, - }); - items.push(FlowItem { - extent, - ch, - keep_with_previous: false, - }); - scripts.push(FlowScript::Upright); - trailing_spacings.push(letter_spacing); - glyph += count; - } - } else { - normalize_rotated_word_spaces(&segment.text, &mut run, span.font_size); - // Spread the sideways glyphs down the column by - // letter-spacing (post-rotation +x runs down the - // column), matching the per-glyph horizontal spacing. - let extent = if letter_spacing != 0.0 { - for (i, position) in run.positions.iter_mut().enumerate() { - position.x += letter_spacing * i as f32; - } - run.advance + letter_spacing * run.glyphs.len() as f32 - } else { - run.advance - }; - let run_text = &segment.text[run.utf8_range.clone()]; - let starts_alphanumeric = - run_text.chars().next().is_some_and(char::is_alphanumeric); - let ends_alphanumeric = run_text - .chars() - .next_back() - .is_some_and(char::is_alphanumeric); - let start = piece_base + utf8_to_utf16(run.utf8_range.start); - let (ink_top, ink_bottom) = rotated_flow_ink_bounds(&run) - .unwrap_or((0.0, extent - letter_spacing)); - para_cells.push(VerticalCell { - kind: CellKind::Rotated { run: run_index }, - paragraph: paragraph_index, - span: span_index, - start, - end: piece_base + seg_utf16_end, - column: 0, - top: 0.0, - extent, - minimum_oikomi_extent: extent, - // Rotated runs draw from `run.positions`; the - // centring path doesn't read `h_advance`. - h_advance: run.advance, - ink_top, - ink_bottom, - paint: paint_index, - font_size: span.font_size, - decoration: span.text_decoration, - glyph_flow_shift: 0.0, - }); - items.push(FlowItem { - extent, - ch: None, - keep_with_previous: false, - }); - scripts.push(FlowScript::Rotated { - starts_alphanumeric, - ends_alphanumeric, - }); - trailing_spacings.push(letter_spacing); - } - runs.push(run); - } - } - } - span_utf16_offset += text.encode_utf16().count(); - } - - // A CSS transform may expand one source character into several shaped - // cells. Keep those cells in one column so a source slice is rendered - // exactly once by the SVG fallback (for example `ß` -> `SS`). - for index in 1..para_cells.len() { - let previous = ¶_cells[index - 1]; - let current = ¶_cells[index]; - if previous.span != current.span { - continue; - } - let transform = &span_transforms[paragraph_index][current.span]; - let transformed_span_start = span_starts[current.span]; - let previous_source = transform.source_utf16_range( - previous.start - transformed_span_start..previous.end - transformed_span_start, - ); - let current_source = transform.source_utf16_range( - current.start - transformed_span_start..current.end - transformed_span_start, - ); - items[index].keep_with_previous = previous_source == current_source; - } - - let ruby_classes: Vec> = paragraph - .children() - .iter() - .map(|span| (!span.ruby.trim().is_empty()).then_some(JapaneseClass::SimpleRuby)) - .collect(); - // Adjust the preceding cell's flow advance so the *visible ink* edges - // have the target gap. Logical advances alone are asymmetric around - // mixed fonts/glyphs (e.g. `うpenあ`) because their side bearings differ. - // Preserve the preceding cell's explicit trailing letter-spacing. - for i in 1..para_cells.len() { - let target_gap = inter_script_spacing( - scripts[i - 1], - para_cells[i - 1].font_size, - scripts[i], - para_cells[i].font_size, - ); - if target_gap > 0.0 { - let previous = ¶_cells[i - 1]; - let natural_gap_without_letter_spacing = previous.extent - trailing_spacings[i - 1] - + para_cells[i].ink_top - - previous.ink_bottom; - let correction = target_gap - natural_gap_without_letter_spacing; - para_cells[i - 1].extent = (previous.extent + correction).max(0.0); - items[i - 1].extent = para_cells[i - 1].extent; - } - } - - // JLREQ punctuation and cl-30 adjacency. Full-width fonts bake the half-em aki - // into punctuation advances. Keep that preferred aki in ordinary text, - // but remove one half-em at the internal boundaries defined by §3.1.4: - // closing punctuation sequences set solid, closing→opening retains a - // single half-em, opening sequences set solid after the first bracket, - // and middle-dot adjacency retains its own quarter-em side spacing. - let flow_classes: Vec> = para_cells - .iter() - .zip(&items) - .map(|(cell, item)| ruby_classes[cell.span].or_else(|| flow_class(cell, item))) - .collect(); - for i in 0..para_cells.len() { - let Some(ch) = items[i].ch else { - continue; - }; - let closing = sheds_trailing_aki(ch); - let opening = sheds_leading_aki(ch); - if !closing && !opening { - continue; - } - let previous_class = i.checked_sub(1).and_then(|index| flow_classes[index]); - let next_class = flow_classes.get(i + 1).copied().flatten(); - let shed = if closing { - next_class.is_some_and(|class| { - let natural = 0.5 + embedded_leading_aki(class); - let preferred = pair_rule(classify(ch), class).preferred_em; - natural > preferred + f32::EPSILON - }) - } else { - previous_class.is_some_and(|class| { - // A preceding trailing-aki mark owns the reduction for - // closing→opening, so never remove both halves. - !class.is_trailing_aki_punctuation() - && embedded_trailing_aki(class) + 0.5 - > pair_rule(class, classify(ch)).preferred_em + f32::EPSILON - }) - }; - if !shed { - continue; - } - let target = 0.5 * para_cells[i].font_size + trailing_spacings[i]; - if target >= para_cells[i].extent { - continue; - } - // Opening punctuation keeps its ink in the trailing half of the em, - // so pulling it up by the shed amount lands the glyph inside the - // compressed cell and hugs the preceding character. Closing - // punctuation already sits in the leading half; no shift is needed. - if opening { - para_cells[i].glyph_flow_shift += -(para_cells[i].extent - target); - } - para_cells[i].extent = target; - items[i].extent = target; - } - - // cl-04 question/exclamation marks carry an explicit one-em space - // after their character frame. Unlike bracket/comma/full-stop aki, - // this space is not embedded in the font's full-width advance, so it - // must exist in the flow extent before oikomi is allowed to reduce it. - materialize_explicit_pair_spacing(&mut para_cells, &mut items, &flow_classes); - - // Long ruby with no slack: grow the base span's flow extent *before* - // column planning so the wrap itself makes room (forced spreading). - // The growth becomes inter-character gaps, so only the cells before - // the last one grow. Single-character bases keep the capped-overhang - // behaviour of the post-placement pass. - for unit in &ruby_units { - let indices: Vec = para_cells - .iter() - .enumerate() - .filter(|(_, cell)| { - cell.span == unit.span && cell.start < unit.end && cell.end > unit.start - }) - .map(|(index, _)| index) - .collect(); - if indices.is_empty() { - continue; - } - let ruby_line = unit.ruby_font_size * unit.ruby_len as f32; - let base_total: f32 = indices.iter().map(|index| para_cells[*index].extent).sum(); - let deficit = ruby_line - base_total; - if deficit <= 0.0 { - continue; - } - if indices.len() == 1 { - if unit.overhang == RubyOverhang::None { - para_cells[indices[0]].extent += deficit; - items[indices[0]].extent += deficit; - } - continue; - } - let gap = deficit / (indices.len() - 1) as f32; - for index in &indices[..indices.len() - 1] { - para_cells[*index].extent += gap; - items[*index].extent += gap; - } - } - - let mut pair_spacing_em: Vec = flow_classes - .windows(2) - .map(|pair| match (pair[0], pair[1]) { - (Some(before), Some(after)) => pair_rule(before, after).preferred_em, - _ => 0.0, - }) - .collect(); - apply_ordered_oikomi( - &mut para_cells, - &mut items, - &flow_classes, - &mut pair_spacing_em, - max_height, - ); - - // Wrapped opening brackets use the JIS X 4051 tentsuki policy: discard - // their leading half-em at a column head. Re-plan after each newly - // trimmed bracket because the shorter line may pull one more cell into - // the preceding column. The loop is bounded and each iteration can - // only shrink a previously untrimmed item. - let mut placements = plan_columns(&items, max_height); - for _ in 0..para_cells.len() { - if !discard_explicit_spacing_at_column_edges( - &mut para_cells, - &mut items, - &flow_classes, - &mut pair_spacing_em, - &placements, - ) { - break; - } - placements = plan_columns(&items, max_height); - } - for _ in 0..para_cells.len() { - let mut changed = false; - for i in 0..para_cells.len() { - if placements[i].1 != 0.0 || !items[i].ch.is_some_and(sheds_leading_aki) { - continue; - } - let target = 0.5 * para_cells[i].font_size + trailing_spacings[i]; - if target >= para_cells[i].extent { - continue; - } - para_cells[i].glyph_flow_shift += -(para_cells[i].extent - target); - para_cells[i].extent = target; - items[i].extent = target; - changed = true; - } - if !changed { - break; - } - placements = plan_columns(&items, max_height); - } - let columns_used = placements.last().map(|(c, _)| c + 1).unwrap_or(1); - - let column_base = columns.len(); - for _ in 0..columns_used { - columns.push(VerticalColumn { - x: 0.0, - width: column_width, - base_offset: ruby_under_gutter, - base_width: column_advance, - }); - } - paragraph_columns.push((column_base, column_base + columns_used)); - - // Align each column's cells along the vertical (inline) axis per the - // paragraph's text-align. The wrap height `max_height` is the budget; - // a column's used length is its farthest cell bottom. - let text_align = paragraph.text_align(); - let mut column_used = vec![0.0f32; columns_used]; - for (item, (column, top)) in items.iter().zip(placements.iter()) { - column_used[*column] = column_used[*column].max(top + item.extent); - } - let column_offset: Vec = column_used - .iter() - .map(|used| align_offset_along_column(text_align, max_height, *used)) - .collect(); - - // Justify stretches every column but the last (the last "line") to - // fill the wrap budget; a snug auto-width budget has no slack. - let justify = matches!(text_align, TextAlign::Justify) - && max_height.is_finite() - && max_height < f32::MAX - && max_height > 0.0; - let last_column = columns_used.saturating_sub(1); - let expansion_offsets = if justify { - ordered_expansion_offsets( - ¶_cells, - &flow_classes, - &pair_spacing_em, - &placements, - &column_used, - max_height, - last_column, - ) - } else { - vec![0.0; para_cells.len()] - }; - let paragraph_cell_start = cells.len(); - - for (index, (mut cell, (column, top))) in para_cells.into_iter().zip(placements).enumerate() - { - cell.column = column_base + column; - cell.top = top + column_offset[column] + expansion_offsets[index]; - cells.push(cell); - } - spread_ruby_base_cells(&mut cells[paragraph_cell_start..], &ruby_units, max_height); - - span_utf16_starts.push(span_starts); - } - - // Columns advance right->left: column 0 is the rightmost. - let width: f32 = columns.iter().map(|c| c.width).sum(); - let mut right = width; - for column in columns.iter_mut() { - right -= column.width; - column.x = right; - } - - // Ruby (furigana) post-pass. Runs after column placement because a ruby - // annotation's strip is positioned from its base characters' final column - // and flow extent. - let mut ruby_runs: Vec = Vec::new(); - let mut ruby_cells: Vec = Vec::new(); - for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { - for (span_index, span) in paragraph.children().iter().enumerate() { - let ruby_text = span.ruby.trim(); - if ruby_text.is_empty() { - continue; - } - // Base characters of this span grouped by column, in flow (column - // index) order; segments within a column sorted along the flow. - let mut span_columns: Vec = Vec::new(); - for cell in &cells { - if cell.paragraph != paragraph_index || cell.span != span_index { - continue; - } - match span_columns - .iter_mut() - .find(|data| data.column == cell.column) - { - Some(data) => data.segments.push(RubyBaseSegment { - top: cell.top, - extent: cell.extent, - }), - None => span_columns.push(RubyColumnSegments { - column: cell.column, - paint: cell.paint, - segments: vec![RubyBaseSegment { - top: cell.top, - extent: cell.extent, - }], - }), - } - } - if span_columns.is_empty() { - continue; - } - span_columns.sort_by_key(|data| data.column); - for data in span_columns.iter_mut() { - data.segments.sort_by(|a, b| { - a.top - .partial_cmp(&b.top) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } - - let ruby_font_size = span.font_size * span.ruby_size.scale(); - let families = span_font_families(span, fallback_families); - let shaped = shape_segment_with_fallbacks( - ruby_text, - ruby_font_size, - &families, - font_provider, - true, - span.font_features, - &fallback_mgr, - ); - if shaped.is_empty() { - continue; - } - let trim_utf16 = span.ruby[..span.ruby.len() - span.ruby.trim_start().len()] - .encode_utf16() - .count(); - let run_start = ruby_runs.len(); - let mut glyphs: Vec = shaped - .iter() - .enumerate() - .flat_map(|(run_index, run)| { - run.clusters - .iter() - .enumerate() - .map(move |(glyph, cluster)| { - let utf8 = (*cluster as usize).min(ruby_text.len()); - RubyGlyph { - run: run_start + run_index, - glyph, - utf16_start: trim_utf16 + ruby_text[..utf8].encode_utf16().count(), - utf16_end: 0, - } - }) - }) - .collect(); - if glyphs.is_empty() { - continue; - } - let ruby_utf16_end = trim_utf16 + ruby_text.encode_utf16().count(); - for index in 0..glyphs.len() { - glyphs[index].utf16_end = glyphs - .get(index + 1) - .map(|next| next.utf16_start) - .unwrap_or(ruby_utf16_end); - } - ruby_runs.extend(shaped); - - let total_base_count: usize = span_columns.iter().map(|data| data.segments.len()).sum(); - let mut base_start = 0usize; - for data in &span_columns { - let base_segments: Vec<(f32, f32)> = data - .segments - .iter() - .map(|segment| (segment.top, segment.extent)) - .collect(); - if base_segments.is_empty() { - continue; - } - let glyph_range = proportional_range( - glyphs.len(), - base_start, - base_segments.len(), - total_base_count, - ); - base_start += base_segments.len(); - let column_glyphs = glyphs[glyph_range].to_vec(); - if column_glyphs.is_empty() { - continue; - } - let top = base_segments - .first() - .map(|segment| segment.0) - .unwrap_or(0.0); - let bottom = base_segments - .last() - .map(|segment| segment.0 + segment.1) - .unwrap_or(top); - let glyph_tops = distribute_ruby_tops( - top, - (bottom - top).max(0.0), - column_glyphs.len(), - ruby_font_size, - span.ruby_align, - span.ruby_overhang, - ); - ruby_cells.push(RubyCell { - glyphs: column_glyphs, - paragraph: paragraph_index, - span: span_index, - column: data.column, - base_segments, - glyph_tops, - font_size: ruby_font_size, - base_font_size: span.font_size, - side: span.ruby_side, - paint: data.paint, - }); - } - } - } - - // Emphasis marks (圏点 / bouten): one mark glyph per base cell of every - // span that carries `text_emphasis`. The mark is shaped once per span and - // drawn beside each Upright base cell in the right-side gutter. Like ruby, - // marks stay out of `cells` so base metrics and the editor are untouched. - // Whitespace cells get no mark (CSS `text-emphasis` behaviour). - let mut emphasis_runs: Vec = Vec::new(); - let mut emphasis_marks: Vec = Vec::new(); - for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { - for (span_index, span) in paragraph.children().iter().enumerate() { - let Some(mark) = span.text_emphasis.mark_char() else { - continue; - }; - let mark_font_size = span.font_size * EMPHASIS_FONT_SCALE; - let families = span_font_families(span, fallback_families); - let typeface = families - .iter() - .filter_map(|family| { - font_provider.match_family_style(family, skia::FontStyle::default()) - }) - .find(|tf| tf.unichar_to_glyph(mark as i32) != 0) - .or_else(|| { - font_provider - .match_family_style(families[0].as_str(), skia::FontStyle::default()) - }); - let Some(typeface) = typeface else { - continue; - }; - let font = Font::new(typeface, mark_font_size); - let mut shaped = shape_segment( - &mark.to_string(), - &font, - true, - FontFeatures::None, - fallback_mgr.clone(), - ); - if shaped.is_empty() || shaped[0].glyphs.is_empty() { - continue; - } - 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 { - if cell.paragraph != paragraph_index - || cell.span != span_index - || !matches!( - cell.kind, - CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } - ) - { - continue; - } - if !utf16_range_allows_emphasis( - &span.text, - cell.start - span_start, - cell.end - span_start, - ) { - continue; - } - emphasis_marks.push(EmphasisMark { - run: run_index, - column: cell.column, - top: cell.top, - extent: cell.extent, - paint: cell.paint, - font_size: mark_font_size, - outside_offset: if span.annotation_clearance.is_auto() - && !span.ruby.trim().is_empty() - && span.ruby_side == RubySide::Over - { - span.font_size * span.ruby_size.scale() - } else { - 0.0 - }, - }); - } - } - } - - let height = cells - .iter() - .map(|c| c.top + c.extent) - .fold(0.0f32, f32::max); - - VerticalLayout { - runs, - paints, - cells, - columns, - ruby_runs, - ruby_cells, - emphasis_runs, - emphasis_marks, - paragraph_columns, - span_utf16_starts, - span_source_utf16_starts, - span_transforms, - paragraph_utf16_boundaries, - width, - height, - } -} - -/// Emphasis-mark (圏点) font size relative to the base em. -const EMPHASIS_FONT_SCALE: f32 = super::text_japanese::EMPHASIS_FONT_SCALE; - -/// Warichu (割注) sub-line font size relative to the base em; two half-size -/// sub-columns side by side fill the base em width. -const WARICHU_FONT_SCALE: f32 = super::text_japanese::WARICHU_FONT_SCALE; - -/// 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 -/// does not end with a line-end-prohibited one. Nudging forward pulls the -/// offending mark up into the first sub-line (jlreq); backward is the -/// fallback, and the midpoint stands when no split satisfies kinsoku. -pub(crate) fn warichu_split_chars(text: &str) -> usize { - let chars: Vec = text.chars().collect(); - let n = chars.len(); - let mid = n.div_ceil(2); - let valid = |split: usize| { - split >= 1 - && split < n - && !forbidden_at_line_start(chars[split]) - && !forbidden_at_line_end(chars[split - 1]) - }; - if valid(mid) { - return mid; - } - for distance in 1..n { - if valid(mid + distance) { - return mid + distance; - } - if mid > distance && valid(mid - distance) { - return mid - distance; - } - } - mid -} - -/// True when the UTF-16 range contains an emphasis-eligible character. -fn utf16_range_allows_emphasis(text: &str, start: usize, end: usize) -> bool { - let mut offset = 0; - for c in text.chars() { - if offset >= end { - break; - } - let len = c.len_utf16(); - if offset + len > start && super::text_japanese::emphasis_char_allowed(c) { - return true; - } - offset += len; - } - false -} - -/// Minimum scale for unconstrained `all` tate-chu-yoko. Counted digit modes -/// derive their limit from the eligible run length instead. -const MIN_TCY_SCALE: f32 = 0.5; - -/// Distribute `count` ruby glyphs of the given `advance` along a single base -/// segment `[seg_top, seg_top + seg_extent)`, returning each glyph's flow-axis -/// top. Two jlreq regimes: -/// -/// - Ruby no longer than the base (`Lr <= seg_extent`): even distribution -/// (均等割り付け). Each glyph gets an equal slot `seg_extent / count` and is -/// centred in its slot, which yields equal inter-glyph gaps and half gaps at -/// both ends. -/// - Ruby longer than the base (`Lr > seg_extent`): the glyphs are packed at -/// their own advance and the block is centred on the base, overhanging both -/// ends symmetrically (オーバーハング). The per-end overhang is capped at one -/// ruby em so a long annotation cannot swallow its neighbours' cells. -fn distribute_ruby_tops( - seg_top: f32, - seg_extent: f32, - count: usize, - advance: f32, - align: RubyAlign, - overhang_policy: RubyOverhang, -) -> Vec { - if count == 0 { - return Vec::new(); - } - let line = advance * count as f32; - if line <= seg_extent { - match align { - RubyAlign::SpaceAround => { - let slot = seg_extent / count as f32; - (0..count) - .map(|i| seg_top + slot * (i as f32 + 0.5) - advance / 2.0) - .collect() - } - RubyAlign::Center => { - let start = seg_top + (seg_extent - line) / 2.0; - (0..count).map(|i| start + advance * i as f32).collect() - } - RubyAlign::Start => (0..count).map(|i| seg_top + advance * i as f32).collect(), - RubyAlign::SpaceBetween if count > 1 => { - let gap = (seg_extent - line) / (count - 1) as f32; - (0..count) - .map(|i| seg_top + (advance + gap) * i as f32) - .collect() - } - RubyAlign::SpaceBetween => vec![seg_top + (seg_extent - advance) / 2.0], - } - } else { - let overflow = line - seg_extent; - let overhang = if overhang_policy == RubyOverhang::Auto { - (overflow / 2.0).min(advance) - } else { - 0.0 - }; - let start = seg_top - overhang; - (0..count).map(|i| start + advance * i as f32).collect() - } -} - -/// Production entry point: lay out with the render state's font store. -pub fn layout_from_content(text_content: &TextContent, max_height: f32) -> VerticalLayout { - let font_provider = get_resources().fonts.font_provider(); - let fallback_mgr = FontMgr::from(font_provider.clone()); - let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); - layout_vertical( - text_content, - max_height, - font_provider, - fallback_mgr, - &fallback_families, - text_content.bounds(), - ) -} - -/// Content size (width, height) of a vertical layout for auto-sizing. -pub fn measure_content(text_content: &TextContent, max_height: f32) -> (f32, f32) { - let layout = layout_from_content(text_content, max_height); - (layout.width, layout.height) -} - -/// Paint the text content vertically inside its bounds. Returns false -/// when the content is not vertical, so the caller falls back to the -/// horizontal path. -pub fn paint_text_vertical( - canvas: &Canvas, - text_content: &TextContent, - vertical_align: VerticalAlign, -) -> bool { - if !text_content.is_vertical() { - return false; - } - - let bounds = text_content.bounds(); - let max_height = wrap_height(text_content, bounds.height()); - let layout = layout_from_content(text_content, max_height); - paint_layout(canvas, &layout, &bounds, vertical_align); - true -} - -fn text_blob_path(mut blob: TextBlob, offset: impl Into) -> skia::Path { - // SkParagraph normalizes extracted glyph outlines against the blob's ink - // bounds. Restore that origin before applying the draw offset so the path - // occupies the same document coordinates as Canvas::draw_text_blob. - let bounds = *blob.bounds(); - let offset = offset.into(); - SkiaParagraph::get_path(&mut blob).with_offset((offset.x + bounds.left, offset.y + bounds.top)) -} - -fn push_text_path( - paths: &mut Vec<(skia::Path, Paint)>, - path: skia::Path, - paint: &Paint, - antialias: bool, -) { - if path.is_empty() { - return; - } - let mut paint = paint.clone(); - paint.set_anti_alias(antialias); - paths.push((path, paint)); -} - -fn append_warichu_line_paths( - paths: &mut Vec<(skia::Path, Paint)>, - runs: &[ShapedRun], - x_center: f32, - y_top: f32, - paint: &Paint, - antialias: bool, -) { - let mut cursor = y_top; - for run in runs { - let font_size = run.font.size(); - let (ascent, descent) = upright_centre_metrics(&run.font); - let baseline_offset = upright_baseline_offset(ascent, descent, font_size); - let mut glyph = 0usize; - while glyph < run.glyphs.len() { - let cluster = run.clusters[glyph]; - let mut count = 1; - while glyph + count < run.glyphs.len() && run.clusters[glyph + count] == cluster { - count += 1; - } - let advance: f32 = run.advances[glyph..glyph + count].iter().sum(); - let advance = if advance > 0.0 { advance } else { font_size }; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - let base_x = run.positions[glyph].x; - for i in 0..count { - glyphs[i] = run.glyphs[glyph + i]; - points[i] = SkPoint::new( - run.positions[glyph + i].x - base_x, - run.positions[glyph + i].y, - ); - } - if let Some(blob) = builder.make() { - let path = - text_blob_path(blob, (x_center - advance / 2.0, cursor + baseline_offset)); - push_text_path(paths, path, paint, antialias); - } - cursor += advance; - glyph += count; - } - } -} - -fn append_cell_paths( - paths: &mut Vec<(skia::Path, Paint)>, - layout: &VerticalLayout, - cell: &VerticalCell, - origin_x: f32, - origin_y: f32, - antialias: bool, -) { - let column = &layout.columns[cell.column]; - let x_center = origin_x + column_base_center(column); - let y_top = origin_y + cell.top; - let paint = &layout.paints[cell.paint]; - match cell.kind { - CellKind::Upright { run, glyph, count } => { - let run = &layout.runs[run]; - let (ascent, descent) = upright_centre_metrics(&run.font); - let baseline = y_top - + cell.glyph_flow_shift - + upright_baseline_offset(ascent, descent, cell.font_size); - let x = x_center - cell.h_advance / 2.0; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - let base_x = run.positions[glyph].x; - for i in 0..count { - glyphs[i] = run.glyphs[glyph + i]; - points[i] = SkPoint::new( - run.positions[glyph + i].x - base_x, - run.positions[glyph + i].y, - ); - } - if let Some(blob) = builder.make() { - push_text_path(paths, text_blob_path(blob, (x, baseline)), paint, antialias); - } - } - CellKind::SyntheticRotated { run, glyph, count } => { - let run = &layout.runs[run]; - if let Some(blob) = cluster_text_blob(run, glyph, count) { - let path = text_blob_path( - blob, - (0.0, rotated_cluster_baseline_shift(run, glyph, count)), - ) - .make_transform(&skia::Matrix::rotate_deg(90.0)) - .with_offset((x_center, y_top + cell.glyph_flow_shift)); - push_text_path(paths, path, paint, antialias); - } - } - CellKind::Rotated { run } => { - let run = &layout.runs[run]; - let mut builder = TextBlobBuilder::new(); - let count = run.glyphs.len(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - glyphs.copy_from_slice(&run.glyphs); - points.copy_from_slice(&run.positions); - if let Some(blob) = builder.make() { - let path = text_blob_path(blob, (0.0, run.rotated_baseline_shift)) - .make_transform(&skia::Matrix::rotate_deg(90.0)) - .with_offset((x_center, y_top)); - push_text_path(paths, path, paint, antialias); - } - } - CellKind::TateChuYoko { - run_start, - run_count, - scale, - } => { - let composite = &layout.runs[run_start..run_start + run_count]; - let combined_advance: f32 = composite.iter().map(|run| run.advance).sum(); - let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| { - let (_, metrics) = run.font.metrics(); - (a.min(metrics.ascent), d.max(metrics.descent)) - }); - let x0 = x_center - (combined_advance * scale) / 2.0; - let baseline = y_top + cell.font_size / 2.0 - ((ascent + descent) * scale) / 2.0; - let mut cursor = 0.0f32; - for run in composite { - let mut builder = TextBlobBuilder::new(); - let count = run.glyphs.len(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - glyphs.copy_from_slice(&run.glyphs); - points.copy_from_slice(&run.positions); - if let Some(blob) = builder.make() { - let path = text_blob_path(blob, (cursor, 0.0)) - .make_transform(&skia::Matrix::scale((scale, scale))) - .with_offset((x0, baseline)); - push_text_path(paths, path, paint, antialias); - } - cursor += run.advance; - } - } - CellKind::Warichu { - run_start, - run_count, - first_count, - .. - } => { - let all = &layout.runs[run_start..run_start + run_count]; - let (first, second) = all.split_at(first_count); - let quarter = cell.font_size / 4.0; - append_warichu_line_paths(paths, first, x_center + quarter, y_top, paint, antialias); - append_warichu_line_paths(paths, second, x_center - quarter, y_top, paint, antialias); - } - } - - if let Some(decoration) = cell.decoration { - if decoration.contains(TextDecoration::UNDERLINE) { - push_text_path( - paths, - skia::Path::rect( - decoration_bar(layout, cell, origin_x, origin_y, false), - None, - ), - paint, - antialias, - ); - } - if decoration.contains(TextDecoration::LINE_THROUGH) { - push_text_path( - paths, - skia::Path::rect(decoration_bar(layout, cell, origin_x, origin_y, true), None), - paint, - antialias, - ); - } - } -} - -fn append_ruby_paths( - paths: &mut Vec<(skia::Path, Paint)>, - layout: &VerticalLayout, - ruby: &RubyCell, - origin_x: f32, - origin_y: f32, - antialias: bool, -) { - let column = &layout.columns[ruby.column]; - let paint = &layout.paints[ruby.paint]; - let gutter_center = origin_x - + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) - + ruby.font_size / 2.0; - for (ruby_glyph, top) in ruby.glyphs.iter().zip(&ruby.glyph_tops) { - let run = &layout.ruby_runs[ruby_glyph.run]; - let Some(glyph) = run.glyphs.get(ruby_glyph.glyph) else { - continue; - }; - let (_, metrics) = run.font.metrics(); - let baseline = origin_y + *top - metrics.ascent; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); - glyphs[0] = *glyph; - points[0] = SkPoint::new(0.0, 0.0); - if let Some(blob) = builder.make() { - let advance = run - .advances - .get(ruby_glyph.glyph) - .copied() - .unwrap_or(ruby.font_size); - push_text_path( - paths, - text_blob_path(blob, (gutter_center - advance / 2.0, baseline)), - paint, - antialias, - ); - } - } -} - -fn append_emphasis_path( - paths: &mut Vec<(skia::Path, Paint)>, - layout: &VerticalLayout, - mark: &EmphasisMark, - origin_x: f32, - origin_y: f32, - antialias: bool, -) { - let column = &layout.columns[mark.column]; - let run = &layout.emphasis_runs[mark.run]; - let Some(glyph) = run.glyphs.first() else { - return; - }; - let paint = &layout.paints[mark.paint]; - let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; - let gutter_center = origin_x - + column_base_center(column) - + base_font_size / 2.0 - + mark.outside_offset - + mark.font_size / 2.0; - let (_, metrics) = run.font.metrics(); - let cell_center = origin_y + mark.top + mark.extent / 2.0; - let baseline = cell_center - (metrics.ascent + metrics.descent) / 2.0; - let advance = run.advances.first().copied().unwrap_or(0.0); - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); - glyphs[0] = *glyph; - points[0] = SkPoint::new(0.0, 0.0); - if let Some(blob) = builder.make() { - push_text_path( - paths, - text_blob_path(blob, (gutter_center - advance / 2.0, baseline)), - paint, - antialias, - ); - } -} - -fn paths_from_layout( - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, - antialias: bool, -) -> Vec<(skia::Path, Paint)> { - let mut paths = Vec::new(); - let (origin_x, origin_y) = layout.origin(bounds, vertical_align); - for cell in &layout.cells { - append_cell_paths(&mut paths, layout, cell, origin_x, origin_y, antialias); - } - for ruby in &layout.ruby_cells { - append_ruby_paths(&mut paths, layout, ruby, origin_x, origin_y, antialias); - } - for mark in &layout.emphasis_marks { - append_emphasis_path(&mut paths, layout, mark, origin_x, origin_y, antialias); - } - paths -} - -/// Convert the custom vertical layout to glyph-outline paths using the same -/// cells, composite transforms, annotations and alignment as the canvas pass. -pub fn vertical_text_paths( - text_content: &TextContent, - vertical_align: VerticalAlign, - antialias: bool, -) -> Vec<(skia::Path, Paint)> { - if !text_content.is_vertical() { - return Vec::new(); - } - let bounds = text_content.bounds(); - let max_height = wrap_height(text_content, bounds.height()); - let layout = layout_from_content(text_content, max_height); - paths_from_layout(&layout, &bounds, vertical_align, antialias) -} - -/// Split `total` glyphs across segments proportionally to their extents -/// (rounded per segment, remainder to the last) so no glyph is dropped. -fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec { - let mut counts = vec![0usize; extents.len()]; - if extents.is_empty() || total == 0 { - return counts; - } - let sum: f32 = extents.iter().sum(); - if sum <= 0.0 { - counts[extents.len() - 1] = total; - return counts; - } - let mut assigned = 0usize; - let last = extents.len() - 1; - for (index, extent) in extents.iter().enumerate() { - let count = if index == last { - total - assigned - } else { - (((extent / sum) * total as f32).round() as usize).min(total - assigned) - }; - counts[index] = count; - assigned += count; - } - counts -} - -fn next_horizontal_ruby_range( - offset_map: &super::kinsoku::OffsetMap, - utf16_cursor: &mut usize, - text: &str, -) -> std::ops::Range { - let start = *utf16_cursor; - *utf16_cursor += text.encode_utf16().count(); - offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor) -} - -/// Baseline adjustment that attaches a glyph's visible ink edge to its base -/// strip. Font-wide ascender/descender metrics include leading which makes -/// horizontal ruby visibly detached for many Japanese faces. -fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 { - let mut bounds = [skia::Rect::default()]; - font.get_bounds(&[glyph], &mut bounds, None); - let bound = bounds[0]; - if bound.right > bound.left && bound.bottom > bound.top { - if over { - bound.bottom - } else { - bound.top - } - } else { - fallback - } -} - -/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only: -/// base rects come from the already laid-out skparagraph -/// (`get_rects_for_range`), the annotation is shaped at half the span size -/// and distributed over each line's base rect with the same jlreq -/// distribution the vertical path uses (`distribute_ruby_tops` along the -/// horizontal flow). Lines are not reflowed to reserve an annotation band; -/// the ruby draws in the natural leading above the base line. -pub fn paint_horizontal_ruby( - canvas: &Canvas, - text_content: &TextContent, - paragraph_index: usize, - laid_out: &skia::textlayout::Paragraph, - x: f32, - y: f32, -) { - let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else { - return; - }; - if paragraph - .children() - .iter() - .all(|span| span.ruby.trim().is_empty()) - { - return; - } - let font_provider = get_resources().fonts.font_provider(); - let fallback_mgr = FontMgr::from(font_provider.clone()); - let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); - let bounds = text_content.bounds(); - let (_, offset_map) = paragraph.layout_span_texts(); - - let mut utf16_cursor = 0usize; - for span in paragraph.children() { - let span_text = span.apply_text_transform(); - let span_range = next_horizontal_ruby_range(&offset_map, &mut utf16_cursor, &span_text); - let ruby_text = span.ruby.trim().to_string(); - if ruby_text.is_empty() || span_range.is_empty() { - continue; - } - let ruby_font_size = span.font_size * span.ruby_size.scale(); - let families = span_font_families(span, &fallback_families); - let paint = merge_fills(&span.fills, bounds); - let shifted_range = span_range; - let rects = laid_out.get_rects_for_range( - shifted_range, - skia::textlayout::RectHeightStyle::Tight, - skia::textlayout::RectWidthStyle::Tight, - ); - let shaped = shape_segment_with_fallbacks( - &ruby_text, - ruby_font_size, - &families, - font_provider, - false, - span.font_features, - &fallback_mgr, - ); - let glyphs: Vec<(usize, usize, f32)> = shaped - .iter() - .enumerate() - .flat_map(|(run_index, run)| { - (0..run.glyphs.len()).map(move |glyph| { - ( - run_index, - glyph, - run.advances.get(glyph).copied().unwrap_or(ruby_font_size), - ) - }) - }) - .collect(); - let extents: Vec = rects.iter().map(|rect| rect.rect.width()).collect(); - let counts = split_counts_by_extent(&extents, glyphs.len()); - let mut assigned = 0usize; - for (rect_box, count) in rects.iter().zip(counts) { - if count == 0 { - continue; - } - let slice = &glyphs[assigned..assigned + count]; - assigned += count; - let advance = slice - .iter() - .map(|(_, _, advance)| *advance) - .fold(0.0f32, f32::max) - .max(1.0); - // Horizontal SkParagraph has already fixed the base geometry. - // When overhang is prohibited, fit the annotation strip to - // that geometry instead of allowing either end to escape it. - let glyph_scale = if span.ruby_overhang == RubyOverhang::None { - (rect_box.rect.width() / (advance * count as f32)).min(1.0) - } else { - 1.0 - }; - let layout_advance = advance * glyph_scale; - let lefts = distribute_ruby_tops( - rect_box.rect.left(), - rect_box.rect.width(), - count, - layout_advance, - span.ruby_align, - span.ruby_overhang, - ); - for ((run_index, glyph, glyph_advance), left) in slice.iter().zip(lefts) { - let run = &shaped[*run_index]; - let (_, metrics) = run.font.metrics(); - let baseline = match span.ruby_side { - RubySide::Over => { - y + super::text_japanese::horizontal_annotation_over_top( - rect_box.rect, - span.font_size, - ) - horizontal_ruby_ink_edge( - &run.font, - run.glyphs[*glyph], - metrics.descent, - true, - ) - } - RubySide::Under => { - y + rect_box.rect.bottom() - - horizontal_ruby_ink_edge( - &run.font, - run.glyphs[*glyph], - metrics.ascent, - false, - ) - } - }; - let mut builder = TextBlobBuilder::new(); - let (out_glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); - out_glyphs[0] = run.glyphs[*glyph]; - points[0] = SkPoint::new(0.0, 0.0); - if let Some(blob) = builder.make() { - let gx = x + left + (layout_advance - glyph_advance * glyph_scale) / 2.0; - if glyph_scale < 1.0 { - canvas.save(); - canvas.translate((gx, baseline)); - canvas.scale((glyph_scale, 1.0)); - canvas.draw_text_blob(&blob, (0.0, 0.0), &paint); - canvas.restore(); - } else { - canvas.draw_text_blob(&blob, (gx, baseline), &paint); - } - } - } - } - } -} - -/// Draw one cell's glyphs with an explicit paint (used both for the fill -/// pass — cell's own fill — and for the stroke / shadow silhouette passes, -/// which override the paint for every cell). -fn draw_cell_glyph( - canvas: &Canvas, - layout: &VerticalLayout, - cell: &VerticalCell, - origin_x: f32, - origin_y: f32, - paint: &Paint, -) { - let column = &layout.columns[cell.column]; - let x_center = origin_x + column_base_center(column); - let y_top = origin_y + cell.top; - match cell.kind { - CellKind::Upright { run, glyph, count } => { - let run = &layout.runs[run]; - let (ascent, descent) = upright_centre_metrics(&run.font); - let baseline = y_top - + cell.glyph_flow_shift - + upright_baseline_offset(ascent, descent, cell.font_size); - let x = x_center - cell.h_advance / 2.0; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - let base_x = run.positions[glyph].x; - for i in 0..count { - glyphs[i] = run.glyphs[glyph + i]; - points[i] = SkPoint::new( - run.positions[glyph + i].x - base_x, - run.positions[glyph + i].y, - ); - } - if let Some(blob) = builder.make() { - canvas.draw_text_blob(&blob, (x, baseline), paint); - } - } - CellKind::SyntheticRotated { run, glyph, count } => { - let run = &layout.runs[run]; - if let Some(blob) = cluster_text_blob(run, glyph, count) { - canvas.save(); - canvas.translate((x_center, y_top + cell.glyph_flow_shift)); - canvas.rotate(90.0, None); - canvas.draw_text_blob( - &blob, - (0.0, rotated_cluster_baseline_shift(run, glyph, count)), - paint, - ); - canvas.restore(); - } - } - CellKind::Rotated { run } => { - let run = &layout.runs[run]; - let mut builder = TextBlobBuilder::new(); - let count = run.glyphs.len(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - glyphs.copy_from_slice(&run.glyphs); - points.copy_from_slice(&run.positions); - if let Some(blob) = builder.make() { - canvas.save(); - canvas.translate((x_center, y_top)); - canvas.rotate(90.0, None); - // After rotation +x runs down the column and +y runs across it; - // centre the run's actual ink band on the column axis. - canvas.draw_text_blob(&blob, (0.0, run.rotated_baseline_shift), paint); - canvas.restore(); - } - } - CellKind::TateChuYoko { - run_start, - run_count, - scale, - } => { - let composite = &layout.runs[run_start..run_start + run_count]; - let combined_advance: f32 = composite.iter().map(|r| r.advance).sum(); - // Vertical centring uses the tallest run's ascent/descent. - let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), r| { - let (_, metrics) = r.font.metrics(); - (a.min(metrics.ascent), d.max(metrics.descent)) - }); - let x0 = x_center - (combined_advance * scale) / 2.0; - let baseline = y_top + cell.font_size / 2.0 - ((ascent + descent) * scale) / 2.0; - canvas.save(); - canvas.translate((x0, baseline)); - canvas.scale((scale, scale)); - let mut cursor = 0.0f32; - for run in composite { - let mut builder = TextBlobBuilder::new(); - let count = run.glyphs.len(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - glyphs.copy_from_slice(&run.glyphs); - points.copy_from_slice(&run.positions); - if let Some(blob) = builder.make() { - canvas.draw_text_blob(&blob, (cursor, 0.0), paint); - } - cursor += run.advance; - } - canvas.restore(); - } - CellKind::Warichu { - run_start, - run_count, - first_count, - .. - } => { - let all = &layout.runs[run_start..run_start + run_count]; - let (first, second) = all.split_at(first_count); - let quarter = cell.font_size / 4.0; - // vertical-rl: the first sub-line reads first, on the right half. - draw_warichu_line(canvas, first, x_center + quarter, y_top, paint); - draw_warichu_line(canvas, second, x_center - quarter, y_top, paint); - } - } -} - -/// Draw one warichu sub-line: upright half-size glyphs stacked down the -/// sub-column centred on `x_center`, clusters kept together like the normal -/// upright path. -fn draw_warichu_line( - canvas: &Canvas, - runs: &[ShapedRun], - x_center: f32, - y_top: f32, - paint: &Paint, -) { - let mut cursor = y_top; - for run in runs { - let font_size = run.font.size(); - let (ascent, descent) = upright_centre_metrics(&run.font); - let baseline_offset = upright_baseline_offset(ascent, descent, font_size); - let mut glyph = 0usize; - while glyph < run.glyphs.len() { - let cluster = run.clusters[glyph]; - let mut count = 1; - while glyph + count < run.glyphs.len() && run.clusters[glyph + count] == cluster { - count += 1; - } - let advance: f32 = run.advances[glyph..glyph + count].iter().sum(); - let advance = if advance > 0.0 { advance } else { font_size }; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - let base_x = run.positions[glyph].x; - for i in 0..count { - glyphs[i] = run.glyphs[glyph + i]; - points[i] = SkPoint::new( - run.positions[glyph + i].x - base_x, - run.positions[glyph + i].y, - ); - } - if let Some(blob) = builder.make() { - canvas.draw_text_blob( - &blob, - (x_center - advance / 2.0, cursor + baseline_offset), - paint, - ); - } - cursor += advance; - glyph += count; - } - } -} - -/// Decoration bar geometry for a cell in absolute coordinates. Underline -/// runs along the *left* side of the column (the under side in -/// `vertical-rl`); line-through runs down the column centre. Both span the -/// cell's vertical extent so consecutive decorated cells tile a continuous -/// bar. Returns (rect, thickness-adjusted rect) as a skia rect. -fn decoration_bar( - layout: &VerticalLayout, - cell: &VerticalCell, - origin_x: f32, - origin_y: f32, - line_through: bool, -) -> skia::Rect { - let column = &layout.columns[cell.column]; - let x_center = origin_x + column_base_center(column); - let y_top = origin_y + cell.top; - let thickness = (cell.font_size * 0.06).max(1.0); - let bar_x = if line_through { - x_center - } else { - x_center - cell.font_size / 2.0 - cell.font_size / 9.0 - }; - skia::Rect::from_ltrb( - bar_x - thickness / 2.0, - y_top, - bar_x + thickness / 2.0, - y_top + cell.extent, - ) -} - -fn draw_cell_decorations( - canvas: &Canvas, - layout: &VerticalLayout, - cell: &VerticalCell, - origin_x: f32, - origin_y: f32, -) { - let Some(decoration) = cell.decoration else { - return; - }; - let mut paint = layout.paints[cell.paint].clone(); - paint.set_style(skia::PaintStyle::Fill); - paint.set_anti_alias(true); - if decoration.contains(TextDecoration::UNDERLINE) { - canvas.draw_rect( - decoration_bar(layout, cell, origin_x, origin_y, false), - &paint, - ); - } - if decoration.contains(TextDecoration::LINE_THROUGH) { - canvas.draw_rect( - decoration_bar(layout, cell, origin_x, origin_y, true), - &paint, - ); - } -} - -/// Paint every cell's glyphs with a single overriding paint (stroke / -/// shadow silhouette passes). -fn paint_glyphs( - canvas: &Canvas, - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, - paint: &Paint, -) { - let (origin_x, origin_y) = layout.origin(bounds, vertical_align); - for cell in &layout.cells { - draw_cell_glyph(canvas, layout, cell, origin_x, origin_y, paint); - } -} - -/// Fill pass: each cell drawn with its own fill paint, then its decorations. -pub fn paint_layout( - canvas: &Canvas, - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, -) { - let (origin_x, origin_y) = layout.origin(bounds, vertical_align); - for cell in &layout.cells { - let paint = &layout.paints[cell.paint]; - draw_cell_glyph(canvas, layout, cell, origin_x, origin_y, paint); - draw_cell_decorations(canvas, layout, cell, origin_x, origin_y); - } - for ruby in &layout.ruby_cells { - draw_ruby_cell(canvas, layout, ruby, origin_x, origin_y); - } - for mark in &layout.emphasis_marks { - draw_emphasis_mark(canvas, layout, mark, origin_x, origin_y); - } -} - -/// Draw one emphasis mark (圏点) centred on its base cell's flow extent, in the -/// column's right-side gutter. The mark reuses the base cell's fill paint. -fn draw_emphasis_mark( - canvas: &Canvas, - layout: &VerticalLayout, - mark: &EmphasisMark, - origin_x: f32, - origin_y: f32, -) { - let column = &layout.columns[mark.column]; - let run = &layout.emphasis_runs[mark.run]; - if run.glyphs.is_empty() { - return; - } - let paint = &layout.paints[mark.paint]; - let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; - let gutter_center = origin_x - + column_base_center(column) - + base_font_size / 2.0 - + mark.outside_offset - + mark.font_size / 2.0; - let (_, metrics) = run.font.metrics(); - let cell_center = origin_y + mark.top + mark.extent / 2.0; - let baseline = cell_center - (metrics.ascent + metrics.descent) / 2.0; - let advance = run.advances.first().copied().unwrap_or(0.0); - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); - glyphs[0] = run.glyphs[0]; - points[0] = SkPoint::new(0.0, 0.0); - if let Some(blob) = builder.make() { - let x = gutter_center - advance / 2.0; - canvas.draw_text_blob(&blob, (x, baseline), paint); - } -} - -/// Draw one column's slice of a ruby annotation stacked upright in its -/// column's ruby gutter. Each glyph's flow-axis position comes from -/// the whole base span; each glyph is centred across the gutter using its own -/// fallback-font run. -fn draw_ruby_cell( - canvas: &Canvas, - layout: &VerticalLayout, - ruby: &RubyCell, - origin_x: f32, - origin_y: f32, -) { - let column = &layout.columns[ruby.column]; - let paint = &layout.paints[ruby.paint]; - if ruby.glyphs.is_empty() { - return; - } - let gutter_center = origin_x - + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) - + ruby.font_size / 2.0; - for (ruby_glyph, top) in ruby.glyphs.iter().zip(&ruby.glyph_tops) { - let run = &layout.ruby_runs[ruby_glyph.run]; - let Some(glyph) = run.glyphs.get(ruby_glyph.glyph) else { - continue; - }; - let (_, metrics) = run.font.metrics(); - let glyph_top = origin_y + *top; - let baseline = glyph_top - metrics.ascent; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); - glyphs[0] = *glyph; - points[0] = SkPoint::new(0.0, 0.0); - if let Some(blob) = builder.make() { - let h_advance = run - .advances - .get(ruby_glyph.glyph) - .copied() - .unwrap_or(ruby.font_size); - let x = gutter_center - h_advance / 2.0; - canvas.draw_text_blob(&blob, (x, baseline), paint); - } - } -} - -/// Cross-axis start of a vertical ruby strip. Line height controls the column -/// advance (`base_width`), but must not become spacing between an annotation -/// and its base glyph. Centre the base em in that advance and attach ruby to -/// the em edge; any extra leading remains outside the base+ruby group. -fn ruby_strip_x( - column: &VerticalColumn, - ruby_font_size: f32, - base_font_size: f32, - side: RubySide, -) -> f32 { - let base_center = column_base_center(column); - match side { - RubySide::Over => base_center + base_font_size / 2.0, - RubySide::Under => base_center - base_font_size / 2.0 - ruby_font_size, - } -} - -/// Developer overlay: draw a jlreq-style character-frame grid over the -/// laid-out vertical cells. For every column it outlines the column band; -/// for every cell it draws the advance box (the real layout cell), the -/// virtual body / em square centred on the column axis, and the glyph-ink -/// band. This exposes solid-setting, aki, letter-spacing and column -/// planning visually, mirroring the grids in the jlreq figures. It is never -/// emitted to exported or persisted output — only the on-screen fills pass -/// calls it, gated by the `TEXT_GRID_VISIBLE` render flag. -pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align: VerticalAlign) { - let (origin_x, origin_y) = layout.origin(bounds, align); - - let mut column_paint = Paint::default(); - column_paint.set_anti_alias(true); - column_paint.set_style(skia::PaintStyle::Stroke); - column_paint.set_stroke_width(1.0); - column_paint.set_color(skia::Color::from_argb(0x55, 0x88, 0x88, 0x88)); - - let mut advance_paint = Paint::default(); - advance_paint.set_anti_alias(true); - advance_paint.set_style(skia::PaintStyle::Stroke); - advance_paint.set_stroke_width(1.0); - advance_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED)); - - let mut em_paint = Paint::default(); - em_paint.set_anti_alias(true); - em_paint.set_style(skia::PaintStyle::Stroke); - em_paint.set_stroke_width(1.0); - em_paint.set_color(skia::Color::from_argb(0x99, 0xEB, 0x57, 0x57)); - - let mut ink_paint = Paint::default(); - ink_paint.set_anti_alias(true); - ink_paint.set_style(skia::PaintStyle::Stroke); - ink_paint.set_stroke_width(1.0); - ink_paint.set_color(skia::Color::from_argb(0x77, 0x27, 0xAE, 0x60)); - - // Column bands over the full used height. - for column in &layout.columns { - let x = origin_x + column.x; - canvas.draw_rect( - Rect::from_xywh(x, origin_y, column.width, layout.height), - &column_paint, - ); - } - - for cell in &layout.cells { - let column = &layout.columns[cell.column]; - let x_left = origin_x + column.x; - let y_top = origin_y + cell.top; - let x_center = x_left + column.base_offset + column.base_width / 2.0; - - // Advance box: the real layout cell along the column axis. - canvas.draw_rect( - Rect::from_xywh(x_left, y_top, column.base_width, cell.extent), - &advance_paint, - ); - - // Virtual body / em square, centred on the column axis and on the - // cell's advance so aki and letter-spacing are visible. - let em = cell.font_size.max(1.0); - canvas.draw_rect( - Rect::from_xywh( - x_center - em / 2.0, - y_top + (cell.extent - em) / 2.0, - em, - em, - ), - &em_paint, - ); - - // Glyph-ink band along the flow axis. - if cell.ink_bottom > cell.ink_top { - canvas.draw_line( - (x_left, origin_y + cell.top + cell.ink_top), - (x_left + column.width, origin_y + cell.top + cell.ink_top), - &ink_paint, - ); - canvas.draw_line( - (x_left, origin_y + cell.top + cell.ink_bottom), - (x_left + column.width, origin_y + cell.top + cell.ink_bottom), - &ink_paint, - ); - } - } -} - -/// Paint the vertical glyph shadows. The shadow paint carries the -/// blur/offset image filter; drawing the glyphs directly through it renders -/// each glyph's shadow without allocating a full-content offscreen layer -/// (a `save_layer` with the filter can exceed GPU limits on tall columns). -pub fn paint_drop_shadow( - canvas: &Canvas, - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, - shadow_paint: &Paint, -) { - let mut paint = shadow_paint.clone(); - paint.set_color(skia::Color::BLACK); - paint.set_anti_alias(true); - paint_glyphs(canvas, layout, bounds, vertical_align, &paint); -} - -/// Paint a stroke masked to the vertical glyph silhouettes. Center strokes -/// draw the stroked outline directly; inner/outer strokes are masked with -/// `SrcIn` / `SrcOut` against the glyph silhouette (mirrors the horizontal -/// masked-stroke path). -pub fn paint_stroke( - canvas: &Canvas, - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, - stroke: &Stroke, - selrect: &Rect, - blur: Option<&ImageFilter>, -) { - let (stroke_paints, layer_opacity) = - crate::render::text::get_text_stroke_paints(stroke, selrect, false); - - 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().paint(&blur_paint)); - } - if let Some(opacity) = layer_opacity { - let mut opacity_paint = Paint::default(); - opacity_paint.set_alpha_f(opacity); - canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); - } - - for stroke_paint in &stroke_paints { - match stroke.kind { - StrokeKind::Center => { - paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint) - } - StrokeKind::Inner => paint_masked_stroke( - canvas, - layout, - bounds, - vertical_align, - stroke_paint, - BlendMode::SrcIn, - ), - StrokeKind::Outer => paint_masked_stroke( - canvas, - layout, - bounds, - vertical_align, - stroke_paint, - BlendMode::SrcOut, - ), - } - } - - if layer_opacity.is_some() { - canvas.restore(); - } - if blur.is_some() { - canvas.restore(); - } -} - -fn paint_masked_stroke( - canvas: &Canvas, - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, - stroke_paint: &Paint, - blend: BlendMode, -) { - let mut mask = Paint::default(); - mask.set_color(skia::Color::BLACK); - mask.set_anti_alias(true); - - canvas.save_layer(&SaveLayerRec::default()); - paint_glyphs(canvas, layout, bounds, vertical_align, &mask); - - let mut blend_paint = Paint::default(); - blend_paint.set_blend_mode(blend); - canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); - paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint); - canvas.restore(); - - canvas.restore(); -} - -/// Position-data `direction` value marking a vertical (vertical-rl) -/// strip; 0/1 are the horizontal rtl/ltr values. The CLJS deserializer -/// turns it into `:writing-mode "vertical-rl"` on the entry so the -/// legacy SVG renderer can draw the strip vertically. -pub const DIRECTION_VERTICAL_RL: u32 = 2; - -/// Position-data `direction` value marking a ruby annotation strip: the -/// entry's offsets index the span's *ruby* string and the geometry is the -/// exact gutter placement the canvas paints. -pub const DIRECTION_VERTICAL_RUBY: u32 = 3; - -fn cell_source_utf16_range(layout: &VerticalLayout, cell: &VerticalCell) -> std::ops::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 -} - -/// Position-data entries for the v2 editor / exports: consecutive cells of -/// the same span in the same column merge into one vertical strip. -pub fn position_data( - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, -) -> Vec { - let (origin_x, origin_y) = layout.origin(bounds, vertical_align); - let mut result: Vec = Vec::new(); - - let mut i = 0; - while i < layout.cells.len() { - let first = &layout.cells[i]; - let mut source_range = cell_source_utf16_range(layout, first); - let mut bottom = first.top + first.extent; - let mut j = i + 1; - while j < layout.cells.len() { - let next = &layout.cells[j]; - if next.paragraph == first.paragraph - && next.span == first.span - && next.column == first.column - { - let next_source = cell_source_utf16_range(layout, next); - source_range.start = source_range.start.min(next_source.start); - source_range.end = source_range.end.max(next_source.end); - bottom = next.top + next.extent; - j += 1; - } else { - break; - } - } - 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, - }); - i = j; - } - - // Ruby annotation strips, with the exact flow-axis placement the canvas - // paints. `start_pos`/`end_pos` are UTF-16 offsets - // into the span's ruby string, taken from the shaped clusters so - // surrogate-pair readings slice correctly. The consumer renders them as - // their own half-size vertical strips in the column's right-side gutter. - for ruby in &layout.ruby_cells { - let (Some(first), Some(last)) = (ruby.glyphs.first(), ruby.glyphs.last()) else { - continue; - }; - if ruby.base_segments.is_empty() { - continue; - } - let column = &layout.columns[ruby.column]; - let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min); - let bottom = ruby.glyph_tops.iter().copied().fold(f32::MIN, f32::max) + ruby.font_size; - result.push(PositionData { - paragraph: ruby.paragraph as u32, - span: ruby.span as u32, - start_pos: first.utf16_start as u32, - end_pos: last.utf16_end as u32, - x: origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side), - y: origin_y + top, - width: ruby.font_size, - height: bottom - top, - direction: DIRECTION_VERTICAL_RUBY, - }); - } - result -} - -/// True when the point (in the same space as `bounds`) hits laid-out text. -pub fn intersects( - layout: &VerticalLayout, - bounds: &Rect, - vertical_align: VerticalAlign, - x: f32, - y: f32, -) -> bool { - let (origin_x, origin_y) = layout.origin(bounds, vertical_align); - layout.cells.iter().any(|cell| { - let column = &layout.columns[cell.column]; - let rect = Rect::from_xywh( - origin_x + column.x, - origin_y + cell.top, - column.width, - cell.extent, - ); - rect.contains(&SkPoint::new(x, y)) - }) -} - -fn scalar_offset_from_utf16( - layout: &VerticalLayout, - paragraph: usize, - utf16_offset: usize, -) -> Option { - let boundaries = layout.paragraph_utf16_boundaries.get(paragraph)?; - Some(match boundaries.binary_search(&utf16_offset) { - Ok(index) => index, - Err(index) => index.saturating_sub(1), - }) -} - -fn cell_scalar_range(layout: &VerticalLayout, cell: &VerticalCell) -> Option<(usize, usize)> { - Some(( - scalar_offset_from_utf16(layout, cell.paragraph, cell.start)?, - scalar_offset_from_utf16(layout, cell.paragraph, cell.end)?, - )) -} - -/// Caret position (paragraph index, paragraph-relative Unicode scalar offset) for -/// a point given relative to the content block's top-left origin. -pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usize, usize)> { - if layout.columns.is_empty() { - return None; - } - - // Columns are ordered right->left, i.e. descending x. - let column_index = layout - .columns - .iter() - .position(|c| x >= c.x && x < c.x + c.width) - .unwrap_or(if x >= layout.width { - 0 - } else { - layout.columns.len() - 1 - }); - - let paragraph = layout - .paragraph_columns - .iter() - .position(|(start, end)| column_index >= *start && column_index < *end)?; - - let column_cells: Vec<&VerticalCell> = layout - .cells - .iter() - .filter(|c| c.column == column_index) - .collect(); - - if column_cells.is_empty() { - return Some((paragraph, 0)); - } - - for cell in &column_cells { - if y < cell.top + cell.extent { - let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; - let chars = (cell_end - cell_start).max(1); - let offset = match cell.kind { - CellKind::Rotated { .. } => { - // Proportional position along the rotated run. - let frac = ((y - cell.top) / cell.extent).clamp(0.0, 1.0); - cell_start + ((frac * chars as f32).round() as usize).min(chars) - } - CellKind::TateChuYoko { .. } => { - // The digits run left->right inside the composite, so the - // horizontal position picks the offset within it. - let column = &layout.columns[cell.column]; - let left = column_base_center(column) - cell.h_advance / 2.0; - let frac = ((x - left) / cell.h_advance.max(1.0)).clamp(0.0, 1.0); - cell_start + ((frac * chars as f32).round() as usize).min(chars) - } - CellKind::Warichu { first_chars, .. } => { - // Right sub-column holds the first sub-line's characters, - // left sub-column the second; the vertical position picks - // the offset within the chosen sub-line. - let column = &layout.columns[cell.column]; - let centre = column_base_center(column); - let first = - scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)? - .saturating_sub(cell_start) - .min(chars); - let (lo, hi) = if x >= centre { - (0, first) - } else { - (first, chars) - }; - let line_chars = (hi - lo).max(1); - let frac = ((y - cell.top) / cell.extent.max(1.0)).clamp(0.0, 1.0); - cell_start + lo + ((frac * line_chars as f32).round() as usize).min(hi - lo) - } - CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } => { - if y < cell.top + cell.extent / 2.0 { - cell_start - } else { - cell_end - } - } - }; - return Some((paragraph, offset)); - } - } - - Some(( - paragraph, - scalar_offset_from_utf16(layout, paragraph, column_cells.last().unwrap().end)?, - )) -} - -/// Caret rectangle for a paragraph-relative Unicode scalar offset, in -/// content-local coordinates. The rect spans the column width; its height -/// is the extent of the character at the offset (used by overtype-mode -/// carets to cover the glyph) or zero when the caret sits after the last -/// character, where a thin bar is drawn instead. -pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> Option { - let (col_start, _) = *layout.paragraph_columns.get(paragraph)?; - - let mut result: Option = None; - for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { - let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; - if cell_start > offset { - continue; - } - let column = &layout.columns[cell.column]; - let chars = (cell_end - cell_start).max(1); - let rect = if offset >= cell_end { - Rect::from_xywh(column.x, cell.top + cell.extent, column.base_width, 0.0) - } else { - match cell.kind { - CellKind::TateChuYoko { .. } => { - // Digits run left->right inside the composite: the offset - // moves the caret along the horizontal axis while the - // rect keeps the composite's flow extent. - let left = column_base_center(column) - cell.h_advance / 2.0; - let width = cell.h_advance / chars as f32; - let x = left + (offset - cell_start) as f32 * width; - Rect::from_xywh(x, cell.top, width, cell.extent) - } - CellKind::Warichu { first_chars, .. } => { - // Right sub-column holds the first sub-line's - // characters, left sub-column the second (jlreq reading - // order); the caret tracks the offset down the chosen - // half-width sub-line. - let first = - scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)? - .saturating_sub(cell_start) - .min(chars); - let within = offset - cell_start; - let centre = column_base_center(column); - let half = cell.font_size / 2.0; - let (band_x, line_start, line_chars) = if within < first { - (centre, 0, first) - } else { - (centre - half, first, chars - first) - }; - let line_chars = line_chars.max(1); - let frac = (within - line_start) as f32 / line_chars as f32; - Rect::from_xywh( - band_x, - cell.top + frac * cell.extent, - half, - cell.extent / line_chars as f32, - ) - } - _ => { - let frac = (offset - cell_start) as f32 / chars as f32; - Rect::from_xywh( - column.x, - cell.top + frac * cell.extent, - column.base_width, - cell.extent / chars as f32, - ) - } - } - }; - result = Some(rect); - if offset < cell_end { - break; - } - } - - result.or_else(|| { - // Empty paragraph: caret at the top of its (empty) first column. - layout - .columns - .get(col_start) - .map(|column| Rect::from_xywh(column.x, 0.0, column.base_width, 0.0)) - }) -} - -/// Selection rectangles for a paragraph-relative Unicode scalar offset range, in -/// content-local coordinates. -pub fn range_rects( - layout: &VerticalLayout, - paragraph: usize, - start: usize, - end: usize, -) -> Vec { - let mut rects: Vec = Vec::new(); - for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { - let Some((cell_start, cell_end)) = cell_scalar_range(layout, cell) else { - continue; - }; - if cell_end <= start || cell_start >= end { - continue; - } - let column = &layout.columns[cell.column]; - let chars = (cell_end - cell_start).max(1); - let sel_top = if start > cell_start { - cell.top + ((start - cell_start) as f32 / chars as f32) * cell.extent - } else { - cell.top - }; - let sel_bottom = if end < cell_end { - cell.top + ((end - cell_start) as f32 / chars as f32) * cell.extent - } else { - cell.top + cell.extent - }; - if sel_bottom <= sel_top { - continue; - } - // Merge with the previous rect when contiguous in the same column. - if let Some(last) = rects.last_mut() { - if (last.left - column.x).abs() < f32::EPSILON && (last.bottom - sel_top).abs() < 0.01 { - last.bottom = sel_bottom; - continue; - } - } - rects.push(Rect::from_ltrb( - column.x, - sel_top, - column.x + column.base_width, - sel_bottom, - )); - } - rects -} - -#[cfg(test)] -mod tests { - use super::*; - - fn item(extent: f32, ch: char) -> FlowItem { - FlowItem { - extent, - ch: Some(ch), - keep_with_previous: false, - } - } - - #[test] - fn upright_classification() { - assert!(is_upright_char('あ')); - assert!(is_upright_char('ア')); - assert!(is_upright_char('漢')); - assert!(is_upright_char('。')); - assert!(is_upright_char('「')); - assert!(is_upright_char('ー')); - assert!(is_upright_char('!')); - assert!(!is_upright_char('A')); - assert!(!is_upright_char('1')); - assert!(!is_upright_char(' ')); - assert!(!is_upright_char('.')); - } - - #[test] - fn emoji_stay_upright_in_vertical_text() { - for emoji in ['😆', '💦', '🙇'] { - assert!(is_upright_char(emoji)); - } - - let segments = segment_by_orientation("久😆元💦🙇", TextOrientation::Mixed); - assert_eq!(segments.len(), 4); - assert!(segments.iter().all(|segment| segment.upright)); - assert_eq!(segments[1].text, "😆"); - assert_eq!(segments[3].text, "💦🙇"); - - let families = span_font_families(&make_span("😆"), &[]); - assert_eq!(families[1], DEFAULT_EMOJI_FONT); - } - - #[test] - fn transformed_japanese_punctuation_has_a_rotated_font_fallback() { - for character in [ - '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '、', '。', 'ー', '〜', - ':', ';', - ] { - assert!( - uses_rotated_vertical_fallback(character), - "{character} needs a transformed vertical glyph" - ); - } - assert!(!uses_rotated_vertical_fallback('あ')); - assert!(!uses_rotated_vertical_fallback('漢')); - assert!(!uses_rotated_vertical_fallback('!')); - } - - #[test] - fn missing_vertical_alternate_uses_a_character_granular_rotated_cell() { - let mut content = make_content(&["“"], 1000.0); - content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; - let layout = layout_with(&test_provider(), &content); - assert_eq!(layout.cells.len(), 1); - assert!(matches!( - layout.cells[0].kind, - CellKind::SyntheticRotated { .. } - )); - } - - #[test] - fn upright_narrow_latin_reserves_a_full_em_without_vmtx() { - // Under text-orientation: upright, Latin letters are set upright. The - // Latin test face carries no `vmtx`, so the flow advance must fall back - // to a synthesized em instead of the (much narrower) horizontal advance; - // otherwise the letters pack together and collide with the following - // character. - let em = 20.0; - let mut content = make_content(&["ab"], 1000.0); - content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; - let layout = layout_with(&test_provider(), &content); - assert_eq!(layout.cells.len(), 2, "one upright cell per Latin letter"); - for (index, cell) in layout.cells.iter().enumerate() { - assert!( - matches!(cell.kind, CellKind::Upright { .. }), - "cell {index} should be an upright Latin letter" - ); - assert!( - (cell.extent - em).abs() < 0.5, - "upright Latin cell {index} reserves a full em, got {}", - cell.extent - ); - } - // The letters advance a full em down the column, so they never overlap. - assert!( - layout.cells[1].top - layout.cells[0].top >= em - 0.5, - "successive upright letters are one em apart, got {}", - layout.cells[1].top - layout.cells[0].top - ); - } - - #[test] - fn segments_mixed_text() { - let segments = segment_by_orientation("縦書きABC123です", TextOrientation::Mixed); - assert_eq!(segments.len(), 3); - assert_eq!(segments[0].text, "縦書き"); - assert!(segments[0].upright); - assert_eq!(segments[0].utf16_start, 0); - assert_eq!(segments[1].text, "ABC123"); - assert!(!segments[1].upright); - assert_eq!(segments[1].utf16_start, 3); - assert_eq!(segments[2].text, "です"); - assert!(segments[2].upright); - assert_eq!(segments[2].utf16_start, 9); - } - - #[test] - fn segments_upright_orientation_keeps_latin_upright() { - let segments = segment_by_orientation("縦ABC", TextOrientation::Upright); - assert_eq!(segments.len(), 1); - assert!(segments[0].upright); - } - - #[test] - fn segments_empty_text() { - assert!(segment_by_orientation("", TextOrientation::Mixed).is_empty()); - } - - #[test] - fn plan_columns_breaks_on_overflow() { - let items: Vec = "あいうえお".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 25.0); - assert_eq!( - placements, - vec![(0, 0.0), (0, 10.0), (1, 0.0), (1, 10.0), (2, 0.0)] - ); - } - - #[test] - fn plan_columns_oversized_item_gets_own_column() { - let items = vec![item(10.0, 'あ'), item(100.0, 'い'), item(10.0, 'う')]; - let placements = plan_columns(&items, 25.0); - assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (2, 0.0)]); - } - - #[test] - fn plan_columns_burasage_hangs_comma_period() { - // 。 overflows the two-cell column; instead of oidashi (pushing い down - // with it), burasage lets it hang past the column bottom. The invariant - // "no forbidden-at-line-start char at a column top" still holds — 。 - // never reaches the next column's top. - let items = vec![ - item(10.0, 'あ'), - item(10.0, 'い'), - item(10.0, '。'), - item(10.0, 'う'), - ]; - let placements = plan_columns(&items, 25.0); - assert_eq!(placements, vec![(0, 0.0), (0, 10.0), (0, 20.0), (1, 0.0)]); - assert_eq!(placements[2].0, 0, "。 hangs in the current column"); - } - - #[test] - fn plan_columns_kinsoku_no_forbidden_end_at_column_bottom() { - // 「 would be left at the bottom of column 0; it moves down. - let items = vec![ - item(10.0, 'あ'), - item(10.0, '「'), - item(10.0, 'い'), - item(10.0, 'う'), - ]; - let placements = plan_columns(&items, 25.0); - assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 10.0), (2, 0.0)]); - } - - #[test] - fn plan_columns_kinsoku_shift_is_bounded() { - // A column full of open brackets cannot be emptied: the shift - // stops after MAX_KINSOKU_SHIFT characters. - let items: Vec = "「「「「「「あ".chars().map(|c| item(10.0, c)).collect(); - let placements = plan_columns(&items, 60.0); - let first_column_count = placements.iter().filter(|(c, _)| *c == 0).count(); - assert!(first_column_count >= 2); - } - - #[test] - fn plan_columns_kinsoku_shift_never_overflows_budget() { - // A two-item budget: moving the trailing 「「 down with the - // overflowing い would put three items (30.0) in a 20.0 column; - // the shift is dropped instead, keeping every column within the - // wrap budget. - let items = vec![ - item(10.0, 'あ'), - item(10.0, '「'), - item(10.0, '「'), - item(10.0, 'い'), - ]; - let placements = plan_columns(&items, 20.0); - let mut column_used = std::collections::BTreeMap::new(); - for (it, (column, top)) in items.iter().zip(&placements) { - let used: &mut f32 = column_used.entry(*column).or_default(); - *used = used.max(top + it.extent); - } - for (column, used) in column_used { - assert!(used <= 20.0, "column {column} overflows: {used}"); - } - } - - // ----------------------------------------------------------------- - // Full layout pipeline (real shaping with the bundled test font; - // glyph coverage does not matter for offsets/columns, tofu still - // shapes with real advances) - // ----------------------------------------------------------------- - - use crate::math::Rect as MathRect; - use crate::shapes::{ - AnnotationClearance, Fill, FontFamily, FontStyle, GrowType, Paragraph, RubyAlign, - RubyOverhang, RubySide, RubySize, SolidColor, TextAlign, TextCombineUpright, TextContent, - TextDirection, TextEmphasis, TextOrientation, TextPositionWithAffinity, TextSpan, - TextTransform, WritingMode, - }; - use crate::wasm::text::helpers as text_helpers; - use crate::Uuid; - - const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); - const RUBY_FALLBACK_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vmtx-test.ttf"); - - fn test_provider() -> TypefaceFontProvider { - let font_mgr = FontMgr::new(); - let typeface = font_mgr - .new_from_data(TEST_FONT, None) - .expect("failed to load test font"); - let mut provider = TypefaceFontProvider::new(); - // The span font family serializes as "uuid-weight-style"; register - // the test face under the nil-uuid regular name so it matches. - let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); - provider.register_typeface(typeface, Some(family.as_str())); - provider - } - - fn make_span(text: &str) -> TextSpan { - TextSpan { - text: text.to_string(), - font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), - font_size: 20.0, - line_height: 1.0, - letter_spacing: 0.0, - font_weight: 400, - font_variant_id: Uuid::nil(), - text_decoration: None, - text_transform: None, - text_direction: TextDirection::LTR, - text_orientation: TextOrientation::Mixed, - text_combine_upright: TextCombineUpright::None, - text_emphasis: TextEmphasis::None, - ruby: String::default(), - warichu: false, - font_features: FontFeatures::None, - annotation_clearance: AnnotationClearance::None, - ruby_size: RubySize::Half, - ruby_align: RubyAlign::SpaceAround, - ruby_overhang: RubyOverhang::Auto, - ruby_side: RubySide::Over, - paragraph_position: u32::MAX, - span_position: u32::MAX, - fills: vec![], - } - } - - fn make_content(texts: &[&str], height: f32) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, height), - GrowType::Fixed, - ); - for text in texts { - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![make_span(text)], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - } - content - } - - fn make_content_with_spans(texts: &[&str], height: f32) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, height), - GrowType::Fixed, - ); - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - texts.iter().map(|text| make_span(text)).collect(), - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - fn make_content_aligned(text: &str, height: f32, align: TextAlign) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, height), - GrowType::Fixed, - ); - let mut paragraph = Paragraph::new( - align, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![make_span(text)], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - fn make_ruby_content(base: &str, ruby: &str, height: f32) -> TextContent { - make_ruby_content_with_line_height(base, ruby, height, 1.0) - } - - fn make_ruby_content_with_line_height( - base: &str, - ruby: &str, - height: f32, - line_height: f32, - ) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, height), - GrowType::Fixed, - ); - let mut span = make_span(base); - span.ruby = ruby.to_string(); - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - line_height, - 0.0, - vec![span], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - fn make_emphasis_content(base: &str, emphasis: TextEmphasis, height: f32) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, height), - GrowType::Fixed, - ); - let mut span = make_span(base); - span.text_emphasis = emphasis; - span.text_orientation = TextOrientation::Upright; - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![span], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - #[test] - fn emphasis_span_reserves_gutter_and_emits_one_mark_per_upright_cell() { - let content = make_emphasis_content("AB", TextEmphasis::FilledDot, 400.0); - let layout = layout_content(&content, 400.0); - let upright = layout - .cells - .iter() - .filter(|c| matches!(c.kind, CellKind::Upright { .. })) - .count(); - assert_eq!(upright, 2, "AB upright yields two base cells"); - assert_eq!( - layout.emphasis_marks.len(), - upright, - "one emphasis mark per upright base cell" - ); - for column in &layout.columns { - assert!( - column.width > column.base_width, - "an emphasis paragraph reserves a gutter beside the base band" - ); - } - let mark = &layout.emphasis_marks[0]; - assert!( - !layout.emphasis_runs[mark.run].glyphs.is_empty(), - "the emphasis mark must be shaped" - ); - } - - #[test] - fn emphasis_skips_whitespace_cells() { - let content = make_emphasis_content("A B", TextEmphasis::FilledDot, 400.0); - let layout = layout_content(&content, 400.0); - let upright = layout - .cells - .iter() - .filter(|c| matches!(c.kind, CellKind::Upright { .. })) - .count(); - assert_eq!(upright, 3, "'A B' upright yields three base cells"); - assert_eq!( - layout.emphasis_marks.len(), - 2, - "the whitespace cell gets no emphasis mark" - ); - } - - #[test] - fn emphasis_skips_japanese_commas_stops_and_brackets() { - let content = make_emphasis_content("A、。(B)」", TextEmphasis::FilledDot, 400.0); - let layout = layout_content(&content, 400.0); - - assert_eq!( - layout.emphasis_marks.len(), - 2, - "only A and B receive emphasis marks" - ); - } - - fn make_warichu_content(base: &str, height: f32) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, height), - GrowType::Fixed, - ); - let mut span = make_span(base); - span.warichu = true; - span.text_orientation = TextOrientation::Upright; - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![span], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - #[test] - fn warichu_span_composes_two_half_size_sub_lines() { - let content = make_warichu_content("ABCD", 400.0); - let layout = layout_content(&content, 400.0); - assert_eq!(layout.cells.len(), 1, "the whole span is one composite"); - let cell = &layout.cells[0]; - let CellKind::Warichu { - run_count, - first_count, - .. - } = cell.kind - else { - panic!("expected a warichu cell"); - }; - assert!(first_count >= 1 && run_count > first_count); - // Two chars per sub-line at half size: the block's flow extent is - // roughly one em, far below the four em of normal layout. - assert!( - cell.extent < 2.0 * 20.0, - "two half-size sub-lines take about one em, got {}", - cell.extent - ); - assert!( - (cell.h_advance - 20.0).abs() < 0.001, - "the composite fills the full em width" - ); - assert_eq!(cell.start, 0); - assert_eq!(cell.end, 4); - } - - #[test] - fn warichu_single_char_keeps_normal_layout() { - let content = make_warichu_content("A", 400.0); - let layout = layout_content(&content, 400.0); - assert!(layout - .cells - .iter() - .all(|c| !matches!(c.kind, CellKind::Warichu { .. }))); - } - - #[test] - fn no_emphasis_emits_no_marks() { - let content = make_content(&["AB"], 400.0); - let layout = layout_content(&content, 400.0); - assert!(layout.emphasis_marks.is_empty()); - assert!(layout.emphasis_runs.is_empty()); - } - - fn layout_content(content: &TextContent, max_height: f32) -> VerticalLayout { - let provider = test_provider(); - let fallback_mgr = FontMgr::from(provider.clone()); - layout_vertical( - content, - max_height, - &provider, - fallback_mgr, - &[], - content.bounds(), - ) - } - - #[test] - fn ruby_span_reserves_gutter_and_emits_ruby_cells() { - let content = make_ruby_content("AB", "ab", 400.0); - let layout = layout_content(&content, 400.0); - assert!( - !layout.ruby_cells.is_empty(), - "a span with ruby must emit ruby cells" - ); - for column in &layout.columns { - assert!( - column.width > column.base_width, - "a ruby paragraph reserves a gutter beside the base band" - ); - } - let ruby = &layout.ruby_cells[0]; - assert!( - ruby.glyphs.iter().all(|glyph| layout - .ruby_runs - .get(glyph.run) - .and_then(|run| run.glyphs.get(glyph.glyph)) - .is_some()), - "every ruby glyph must retain its shaped run" - ); - } - - #[test] - fn ruby_size_and_side_control_gutter_geometry() { - let mut content = make_ruby_content("AB", "ab", 400.0); - let span = &mut content.paragraphs_mut()[0].children_mut()[0]; - span.ruby_size = RubySize::Quarter; - span.ruby_side = RubySide::Under; - - let layout = layout_content(&content, 400.0); - let column = &layout.columns[0]; - let ruby = &layout.ruby_cells[0]; - - assert!((ruby.font_size - 5.0).abs() < 0.001); - assert!((column.base_offset - 5.0).abs() < 0.001); - assert!((column.width - column.base_width - 5.0).abs() < 0.001); - assert_eq!(ruby.side, RubySide::Under); - } - - #[test] - fn auto_clearance_stacks_ruby_and_emphasis_gutters() { - let mut legacy_content = make_ruby_content("漢字", "かんじ", 400.0); - legacy_content.paragraphs_mut()[0].children_mut()[0].text_emphasis = - TextEmphasis::FilledDot; - let legacy = layout_content(&legacy_content, 400.0); - - let mut auto_content = legacy_content.clone(); - auto_content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = - AnnotationClearance::Auto; - let auto = layout_content(&auto_content, 400.0); - - let legacy_gutter = legacy.columns[0].width - legacy.columns[0].base_width; - let auto_gutter = auto.columns[0].width - auto.columns[0].base_width; - assert!((legacy_gutter - 10.0).abs() < 0.001); - assert!((auto_gutter - 20.0).abs() < 0.001); - assert!(auto.emphasis_marks[0].outside_offset > 0.0); - } - - #[test] - fn ruby_attachment_distance_is_independent_of_line_height() { - let compact_content = make_ruby_content_with_line_height("AB", "ab", 400.0, 1.0); - let loose_content = make_ruby_content_with_line_height("AB", "ab", 400.0, 2.0); - let compact = layout_content(&compact_content, 400.0); - let loose = layout_content(&loose_content, 400.0); - - let attachment_gap = |layout: &VerticalLayout| { - let ruby = &layout.ruby_cells[0]; - let column = &layout.columns[ruby.column]; - let base_right = column_base_center(column) + ruby.base_font_size / 2.0; - ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) - base_right - }; - - assert!( - loose.columns[0].base_width > compact.columns[0].base_width, - "line height must still increase column progression" - ); - assert!(attachment_gap(&compact).abs() < 0.001); - assert!(attachment_gap(&loose).abs() < 0.001); - - let loose_positions = position_data(&loose, &loose_content.bounds(), VerticalAlign::Top); - let ruby_position = loose_positions - .iter() - .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) - .expect("ruby position data"); - assert!( - (ruby_position.width - loose.ruby_cells[0].font_size).abs() < 0.001, - "export geometry must use the attached ruby strip, not the line-height band" - ); - } - - #[test] - fn vertical_ruby_preserves_every_fallback_run() { - let content = make_ruby_content("AB", "aあ", 400.0); - let font_mgr = FontMgr::new(); - let source = font_mgr.new_from_data(TEST_FONT, None).unwrap(); - let fallback = font_mgr.new_from_data(RUBY_FALLBACK_FONT, None).unwrap(); - let mut provider = TypefaceFontProvider::new(); - let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); - provider.register_typeface(source, Some(family.as_str())); - provider.register_typeface(fallback, Some("ruby-fallback")); - let fallback_mgr = FontMgr::from(provider.clone()); - let layout = layout_vertical( - &content, - 400.0, - &provider, - fallback_mgr, - &["ruby-fallback".to_string()], - content.bounds(), - ); - - let referenced_runs: std::collections::BTreeSet = layout - .ruby_cells - .iter() - .flat_map(|cell| cell.glyphs.iter().map(|glyph| glyph.run)) - .collect(); - assert_eq!(referenced_runs.len(), 2, "ruby must retain both typefaces"); - assert_eq!( - layout - .ruby_cells - .iter() - .map(|cell| cell.glyphs.len()) - .sum::(), - layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), - "every fallback glyph must be assigned to a ruby cell" - ); - - let position = position_data(&layout, &content.bounds(), VerticalAlign::Top); - let ruby_position = position - .iter() - .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) - .expect("ruby position data"); - assert_eq!(ruby_position.start_pos, 0); - assert_eq!(ruby_position.end_pos, 2); - - let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); - paint_layout( - surface.canvas(), - &layout, - &content.bounds(), - VerticalAlign::Top, - ); - } - - #[test] - fn vertical_base_text_switches_font_inside_an_upright_segment() { - let content = make_content(&["あ、く"], 400.0); - let font_mgr = FontMgr::new(); - let primary = font_mgr.new_from_data(VMTX_TEST_FONT, None).unwrap(); - let fallback = font_mgr.new_from_data(VPAL_TEST_FONT, None).unwrap(); - let mut provider = TypefaceFontProvider::new(); - let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); - provider.register_typeface(primary, Some(family.as_str())); - provider.register_typeface(fallback, Some("base-fallback")); - let fallback_mgr = FontMgr::from(provider.clone()); - let layout = layout_vertical( - &content, - 400.0, - &provider, - fallback_mgr, - &["base-fallback".to_string()], - content.bounds(), - ); - - assert_eq!(layout.cells.len(), 3); - let run_ids: Vec = layout - .cells - .iter() - .map(|cell| match cell.kind { - CellKind::Upright { run, .. } => layout.runs[run].font.typeface().unique_id(), - _ => panic!("expected upright fallback cells"), - }) - .collect(); - assert_eq!(run_ids[0], run_ids[2]); - assert_ne!(run_ids[0], run_ids[1]); - let CellKind::Upright { run, glyph, .. } = layout.cells[1].kind else { - unreachable!(); - }; - assert_ne!(layout.runs[run].glyphs[glyph], 0); - } - - #[test] - fn ruby_selection_geometry_excludes_gutter() { - let content = make_ruby_content("AB", "ab", 400.0); - let layout = layout_content(&content, 400.0); - let base_width = layout.columns[0].base_width; - assert!(base_width < layout.columns[0].width); - - let pd = position_data(&layout, &content.bounds(), VerticalAlign::Top); - assert!(!pd.is_empty()); - for entry in pd.iter().filter(|e| e.direction == DIRECTION_VERTICAL_RL) { - assert!( - (entry.width - base_width).abs() < 0.01, - "position-data strip must be the base band, not the full column" - ); - } - - let rects = range_rects(&layout, 0, 0, 10); - assert!(!rects.is_empty()); - for rect in &rects { - assert!( - (rect.width() - base_width).abs() < 0.01, - "selection rect must be the base band, not the full column" - ); - } - } - - #[test] - fn no_ruby_keeps_base_width_equal_to_width() { - let content = make_content(&["AB"], 400.0); - let layout = layout_content(&content, 400.0); - assert!(layout.ruby_cells.is_empty()); - for column in &layout.columns { - assert_eq!( - column.width, column.base_width, - "columns without ruby must not reserve a gutter" - ); - } - } - - #[test] - fn ruby_shorter_than_base_distributes_evenly() { - // One base char (extent 100) with 2 ruby glyphs at advance 50: the - // combined ruby line (100) equals the base, so glyphs sit in equal - // slots of 50, each centred (slot/2 - advance/2 = 0 offset). - let tops = distribute_ruby_tops( - 0.0, - 100.0, - 2, - 50.0, - RubyAlign::SpaceAround, - RubyOverhang::Auto, - ); - assert_eq!(tops.len(), 2); - assert!( - (tops[0] - 0.0).abs() < 0.001, - "first ruby glyph at slot start" - ); - assert!( - (tops[1] - 50.0).abs() < 0.001, - "second ruby glyph one slot down" - ); - - // A wide base (extent 200) with 2 glyphs of advance 50 must spread out - // (slot 100) rather than pack tight at advance 50. - let spread = distribute_ruby_tops( - 0.0, - 200.0, - 2, - 50.0, - RubyAlign::SpaceAround, - RubyOverhang::Auto, - ); - assert!( - (spread[0] - 25.0).abs() < 0.001, - "ruby glyph centred in its slot" - ); - assert!( - (spread[1] - spread[0] - 100.0).abs() < 0.001, - "even distribution keeps a full slot between glyphs, not the advance" - ); - } - - #[test] - fn ruby_longer_than_base_overhangs_symmetrically() { - // Base extent 40, four ruby glyphs of advance 20 (line 80 > 40): the - // block centres on the base and overhangs both ends. Overflow is 40, - // per-end overhang 20 (capped at one ruby em = 20), so it starts 20 - // above the base top. - let tops = distribute_ruby_tops( - 0.0, - 40.0, - 4, - 20.0, - RubyAlign::SpaceAround, - RubyOverhang::Auto, - ); - assert_eq!(tops.len(), 4); - assert!(tops[0] < 0.0, "long ruby overhangs above the base top"); - let block_center = (tops[0] + tops[3] + 20.0) / 2.0; - assert!( - (block_center - 20.0).abs() < 0.001, - "the ruby block stays centred on the base" - ); - } - - #[test] - fn ruby_alignment_modes_control_short_annotation_distribution() { - assert_eq!( - distribute_ruby_tops( - 0.0, - 20.0, - 2, - 4.0, - RubyAlign::SpaceAround, - RubyOverhang::Auto, - ), - vec![3.0, 13.0] - ); - assert_eq!( - distribute_ruby_tops(0.0, 20.0, 2, 4.0, RubyAlign::Center, RubyOverhang::Auto,), - vec![6.0, 10.0] - ); - assert_eq!( - distribute_ruby_tops(0.0, 20.0, 2, 4.0, RubyAlign::Start, RubyOverhang::Auto,), - vec![0.0, 4.0] - ); - assert_eq!( - distribute_ruby_tops( - 0.0, - 20.0, - 2, - 4.0, - RubyAlign::SpaceBetween, - RubyOverhang::Auto, - ), - vec![0.0, 16.0] - ); - } - - #[test] - fn ruby_overhang_none_keeps_long_annotation_at_base_start() { - let automatic = - distribute_ruby_tops(0.0, 10.0, 4, 4.0, RubyAlign::Center, RubyOverhang::Auto); - let constrained = - distribute_ruby_tops(0.0, 10.0, 4, 4.0, RubyAlign::Center, RubyOverhang::None); - - assert_eq!(automatic, vec![-3.0, 1.0, 5.0, 9.0]); - assert_eq!(constrained, vec![0.0, 4.0, 8.0, 12.0]); - } - - #[test] - fn ruby_side_attaches_to_opposite_base_edges() { - let column = VerticalColumn { - x: 10.0, - width: 40.0, - base_offset: 8.0, - base_width: 24.0, - }; - - assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Over), 40.0); - assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Under), 12.0); - } - - #[test] - fn ruby_base_spreading_expands_gap_for_long_reading() { - let content = make_ruby_content("日本", "にほんご", 120.0); - let layout = layout_content(&content, 120.0); - let ruby_cells: Vec<&VerticalCell> = layout - .cells - .iter() - .filter(|cell| cell.span == 0 && cell.column == 0) - .collect(); - assert_eq!(ruby_cells.len(), 2); - let spacing = ruby_cells[1].top - ruby_cells[0].top; - assert!( - spacing > 20.0, - "long ruby should spread the base characters apart, got {}", - spacing - ); - let base_extent = ruby_cells[1].top + ruby_cells[1].extent - ruby_cells[0].top; - assert!( - base_extent >= 40.0, - "base span should be at least as long as the 4-glyph half-em ruby line" - ); - } - - #[test] - fn ruby_base_spreading_does_not_overlap_following_text() { - let content = make_content_with_spans(&["日本", "語"], 60.0); - let mut content = content; - content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんご".to_string(); - let layout = layout_content(&content, 60.0); - let mut same_column: Vec<&VerticalCell> = layout - .cells - .iter() - .filter(|cell| cell.column == 0) - .collect(); - same_column.sort_by(|a, b| { - a.top - .partial_cmp(&b.top) - .unwrap_or(std::cmp::Ordering::Equal) - }); - for pair in same_column.windows(2) { - assert!( - pair[0].top + pair[0].extent <= pair[1].top + 0.001, - "base spreading must not overlap the following cell" - ); - } - } - - #[test] - fn ruby_base_spreading_forces_room_when_no_slack() { - // Base 日本 (2 em = 40) with a 6-glyph half-em reading (60) followed - // by 語 in a 60 budget: there is no post-placement slack, so the base - // extents must grow before planning and push the follower to the next - // column instead of falling back to overhang. - let mut content = make_content_with_spans(&["日本", "語"], 60.0); - content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんごです".to_string(); - let layout = layout_content(&content, 60.0); - let base: Vec<&VerticalCell> = layout.cells.iter().filter(|c| c.span == 0).collect(); - assert_eq!(base.len(), 2); - let base_extent = - base.last().unwrap().top + base.last().unwrap().extent - base.first().unwrap().top; - assert!( - base_extent >= 60.0 - 0.001, - "the base span must grow to the ruby line length, got {}", - base_extent - ); - let follower = layout - .cells - .iter() - .find(|c| c.span == 1) - .expect("follower cell"); - assert_ne!( - follower.column, base[0].column, - "the follower must wrap to the next column instead of overlapping" - ); - } - - #[test] - fn ruby_base_and_reading_wrap_across_columns() { - let content = make_ruby_content("日本語文", "にほんごぶん", 40.0); - let layout = layout_content(&content, 40.0); - - let base_columns: std::collections::BTreeSet = - layout.cells.iter().map(|c| c.column).collect(); - assert_eq!(base_columns.len(), 2, "the ruby base must wrap normally"); - - let ruby_columns: std::collections::BTreeSet = - layout.ruby_cells.iter().map(|r| r.column).collect(); - assert_eq!(ruby_columns, base_columns); - assert!(!layout.ruby_cells.is_empty()); - let placed: usize = layout.ruby_cells.iter().map(|r| r.glyphs.len()).sum(); - assert_eq!( - placed, - layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), - "the reading is partitioned across columns without duplication or loss" - ); - assert!( - layout - .ruby_cells - .windows(2) - .all(|pair| pair[0].glyphs.last().unwrap().utf16_end - <= pair[1].glyphs.first().unwrap().utf16_start), - "ruby source ranges remain monotonic across the column break" - ); - } - - #[test] - fn tate_chu_yoko_span_becomes_one_upright_cell() { - let mut content = make_content_with_spans(&["20", "年"], 200.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::All); - - let layout = layout_content(&content, 200.0); - - assert_eq!(layout.cells.len(), 2); - assert!(matches!(layout.cells[0].kind, CellKind::TateChuYoko { .. })); - assert_eq!(layout.cells[0].start, 0); - assert_eq!(layout.cells[0].end, 2); - assert_eq!(layout.cells[0].extent, 20.0); - assert_eq!(layout.cells[1].start, 2); - } - - #[test] - fn tate_chu_yoko_all_keeps_single_upright_ruby_base_at_full_size() { - let mut content = make_content_with_spans(&["く", "くくく"], 400.0); - let spans = content.paragraphs_mut()[0].children_mut(); - spans[0].ruby = "あ".to_string(); - for span in spans { - span.set_text_combine_upright(TextCombineUpright::All); - } - - let layout = layout_with(&vmtx_provider(), &content); - let ruby_base = layout - .cells - .iter() - .find(|cell| cell.span == 0) - .expect("ruby base cell"); - let following_base = layout - .cells - .iter() - .find(|cell| cell.span == 1) - .expect("following base cell"); - - assert!(matches!(ruby_base.kind, CellKind::Upright { .. })); - assert!(!layout.ruby_cells.is_empty(), "ruby annotation is present"); - assert_eq!(ruby_base.font_size, following_base.font_size); - assert!(layout - .cells - .iter() - .all(|cell| !matches!(cell.kind, CellKind::TateChuYoko { .. }))); - } - - #[test] - fn tate_chu_yoko_composes_covered_run_to_single_cell() { - // A CJK-covering face keeps the whole marked span in one upright - // composite cell (run_count >= 1); the next span is a separate cell. - let mut content = make_content_with_spans(&["くく", "あ"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::All); - let layout = layout_with(&vmtx_provider(), &content); - let CellKind::TateChuYoko { run_count, .. } = layout.cells[0].kind else { - panic!("expected a Tate-chu-yoko cell"); - }; - assert!(run_count >= 1, "composite references at least one run"); - assert_eq!(layout.cells[0].start, 0); - assert_eq!(layout.cells[0].end, 2); - assert_eq!(layout.cells[1].start, 2); - } - - #[test] - fn tate_chu_yoko_wide_run_falls_back_to_normal_layout() { - // A run far wider than the em would compress below MIN_TCY_SCALE; it is - // not combined into a squished composite but laid out normally. - let mut content = make_content_with_spans(&["123456789"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::All); - let layout = layout_content(&content, 400.0); - assert!( - !layout - .cells - .iter() - .any(|c| matches!(c.kind, CellKind::TateChuYoko { .. })), - "an over-wide run must not become a tate-chu-yoko composite" - ); - } - - #[test] - fn split_digit_runs_honours_the_max_parameter() { - // max 2: only exactly-two-digit runs combine. - let pieces = split_digit_runs("平成31年123日", 2); - let flags: Vec<(&str, bool)> = pieces - .iter() - .map(|(text, _, tcy)| (text.as_str(), *tcy)) - .collect(); - assert_eq!( - flags, - vec![ - ("平成", false), - ("31", true), - ("年", false), - ("123", false), - ("日", false), - ] - ); - // max 3 admits the three-digit run. - let pieces = split_digit_runs("平成31年123日", 3); - assert!(pieces.iter().any(|(text, _, tcy)| text == "123" && *tcy)); - } - - #[test] - fn split_digit_runs_marks_two_to_four_digit_runs() { - let pieces = split_digit_runs("平成31年12345日5", 4); - let flags: Vec<(&str, usize, bool)> = pieces - .iter() - .map(|(text, start, tcy)| (text.as_str(), *start, *tcy)) - .collect(); - assert_eq!( - flags, - vec![ - ("平成", 0, false), - ("31", 2, true), - ("年", 4, false), - ("12345", 5, false), - ("日", 10, false), - ("5", 11, false), - ] - ); - } - - #[test] - fn split_digit_runs_recognizes_full_width_digits() { - let pieces = split_digit_runs("2026夏号", 4); - let flags: Vec<(&str, bool)> = pieces - .iter() - .map(|(text, _, tcy)| (text.as_str(), *tcy)) - .collect(); - assert_eq!(flags, vec![("2026", true), ("夏号", false)]); - } - - #[test] - fn tate_chu_yoko_digits_combines_only_digit_runs() { - let mut content = make_content_with_spans(&["あ31く"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::Digits); - let layout = layout_with(&vmtx_provider(), &content); - let tcy: Vec<&VerticalCell> = layout - .cells - .iter() - .filter(|c| matches!(c.kind, CellKind::TateChuYoko { .. })) - .collect(); - assert_eq!(tcy.len(), 1, "exactly the digit run combines"); - assert_eq!(tcy[0].start, 1); - assert_eq!(tcy[0].end, 3); - // The surrounding characters keep their own cells in text order. - let starts: Vec = layout.cells.iter().map(|c| c.start).collect(); - let mut sorted = starts.clone(); - sorted.sort_unstable(); - assert_eq!(starts, sorted, "cells stay in text order"); - assert_eq!(layout.cells.len(), 3); - } - - #[test] - fn tate_chu_yoko_digits_combines_four_full_width_digits() { - let mut content = make_content_with_spans(&["2025年"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::Digits); - let layout = layout_with(&vmtx_provider(), &content); - let tcy = layout - .cells - .iter() - .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) - .expect("four-digit run combines"); - assert_eq!(tcy.start, 0); - assert_eq!(tcy.end, 4); - } - - #[test] - fn tate_chu_yoko_digits_combines_full_width_unicode_digits() { - let mut content = make_content_with_spans(&["2026年"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::Digits); - let layout = layout_with(&vmtx_provider(), &content); - let tcy = layout - .cells - .iter() - .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) - .expect("full-width digit run combines"); - assert_eq!(tcy.start, 0); - assert_eq!(tcy.end, 4); - } - - #[test] - fn tate_chu_yoko_all_uses_asymmetric_punctuation_aki() { - // 」→TCY keeps the closing mark's trailing half-em, and TCY→「 keeps - // the opening mark's leading half-em. TCY on the opposite side of - // either bracket sets solid. - let mut content = make_content_with_spans(&["く", "」", "20", "「", "く"], 400.0); - content.paragraphs_mut()[0].children_mut()[2] - .set_text_combine_upright(TextCombineUpright::All); - let layout = layout_with(&vmtx_provider(), &content); - assert!(matches!(layout.cells[2].kind, CellKind::TateChuYoko { .. })); - for (index, label) in [(1, "」 before TCY"), (2, "TCY"), (3, "「 after TCY")] { - assert!( - (layout.cells[index].extent - 20.0).abs() < 0.01, - "{label} should occupy its preferred one-em frame, got {}", - layout.cells[index].extent - ); - } - - let mut reverse = make_content_with_spans(&["く", "「", "20", "」", "く"], 400.0); - reverse.paragraphs_mut()[0].children_mut()[2] - .set_text_combine_upright(TextCombineUpright::All); - let reverse = layout_with(&vmtx_provider(), &reverse); - assert!(matches!( - reverse.cells[2].kind, - CellKind::TateChuYoko { .. } - )); - assert!((reverse.cells[1].extent - 20.0).abs() < 0.01); - assert!((reverse.cells[3].extent - 20.0).abs() < 0.01); - } - - #[test] - fn tate_chu_yoko_digits_uses_the_same_cl30_adjacency() { - let mut content = make_content_with_spans(&["く」31「く"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::Digits); - let layout = layout_with(&vmtx_provider(), &content); - let tcy_index = layout - .cells - .iter() - .position(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) - .expect("digit TCY cell"); - assert!((layout.cells[tcy_index - 1].extent - 20.0).abs() < 0.01); - assert!((layout.cells[tcy_index].extent - 20.0).abs() < 0.01); - assert!((layout.cells[tcy_index + 1].extent - 20.0).abs() < 0.01); - } - - #[test] - fn caret_from_point_lands_inside_tcy_composite() { - let mut content = make_content_with_spans(&["1234", "あ"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::All); - let layout = layout_content(&content, 400.0); - let cell = &layout.cells[0]; - assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); - let column = &layout.columns[cell.column]; - let y_mid = cell.top + cell.extent / 2.0; - let (_, at_left) = caret_from_point(&layout, column.x + 0.5, y_mid).unwrap(); - let (_, at_right) = - caret_from_point(&layout, column.x + column.base_width - 0.5, y_mid).unwrap(); - assert!(at_left <= 1, "left edge maps near the start, got {at_left}"); - assert!( - at_right >= 3, - "right edge maps near the end, got {at_right}" - ); - } - - #[test] - fn caret_rect_tracks_horizontal_axis_inside_tcy_composite() { - let mut content = make_content_with_spans(&["1234", "あ"], 400.0); - content.paragraphs_mut()[0].children_mut()[0] - .set_text_combine_upright(TextCombineUpright::All); - let layout = layout_content(&content, 400.0); - let cell = &layout.cells[0]; - assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); - let left = column_base_center(&layout.columns[cell.column]) - cell.h_advance / 2.0; - let digit = cell.h_advance / 4.0; - for i in 0..4 { - let rect = caret_rect(&layout, 0, cell.start + i).expect("caret rect"); - assert!( - (rect.left - (left + i as f32 * digit)).abs() < 0.01, - "digit {i} caret sits at its horizontal slot, got {}", - rect.left - ); - assert!( - (rect.width() - digit).abs() < 0.01, - "digit {i} caret is one digit wide, got {}", - rect.width() - ); - assert!( - (rect.top - cell.top).abs() < 0.01 && (rect.height() - cell.extent).abs() < 0.01, - "digit {i} caret spans the composite's flow extent" - ); - } - // After the composite the caret falls back to the flow axis. - let rect = caret_rect(&layout, 0, cell.end).expect("caret rect"); - assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01); - } - - #[test] - fn warichu_split_balances_and_respects_kinsoku() { - // Balanced midpoint when nothing forbids it, first line longer. - assert_eq!(warichu_split_chars("あいうえおか"), 3); - assert_eq!(warichu_split_chars("あいうえお"), 3); - // A comma at the midpoint may end the first sub-line... - assert_eq!(warichu_split_chars("あい、うえ"), 3); - // ...but must not start the second one: the split moves forward. - assert_eq!(warichu_split_chars("あいう、えお"), 4); - // An opening bracket must not end the first sub-line. - assert_eq!(warichu_split_chars("あい「うえお"), 4); - // Pathological all-forbidden text keeps the midpoint. - assert_eq!(warichu_split_chars("、、、、"), 2); - } - - #[test] - fn warichu_cell_carries_kinsoku_split() { - let content = make_warichu_content("あいう、えお", 400.0); - let layout = layout_content(&content, 400.0); - let cell = layout - .cells - .iter() - .find(|c| matches!(c.kind, CellKind::Warichu { .. })) - .expect("a warichu cell"); - let CellKind::Warichu { first_chars, .. } = cell.kind else { - unreachable!(); - }; - assert_eq!( - first_chars, 4, - "the comma is pulled up into the first sub-line" - ); - // The caret for the second sub-line's first character restarts at - // the composite top, left of the axis. - let column = &layout.columns[cell.column]; - let centre = column_base_center(column); - let rect = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); - assert!((rect.top - cell.top).abs() < 0.01); - assert!(rect.left < centre); - } - - #[test] - fn caret_rect_tracks_sub_lines_inside_warichu() { - let content = make_warichu_content("割注二行説明", 400.0); - let layout = layout_content(&content, 400.0); - let cell = layout - .cells - .iter() - .find(|c| matches!(c.kind, CellKind::Warichu { .. })) - .expect("a warichu cell"); - let column = &layout.columns[cell.column]; - let centre = column_base_center(column); - let half = cell.font_size / 2.0; - // First sub-line (offsets 0..3) sits right of the column axis. - let first = caret_rect(&layout, 0, cell.start).expect("caret rect"); - assert!( - (first.left - centre).abs() < 0.01, - "first sub-line caret starts at the axis, got {}", - first.left - ); - assert!( - (first.width() - half).abs() < 0.01, - "sub-line caret is half-size wide, got {}", - first.width() - ); - assert!((first.top - cell.top).abs() < 0.01); - // Second sub-line (offsets 3..6) sits left of the axis, restarting - // at the composite top. - let second = caret_rect(&layout, 0, cell.start + 3).expect("caret rect"); - assert!( - (second.left - (centre - half)).abs() < 0.01, - "second sub-line caret sits left of the axis, got {}", - second.left - ); - assert!( - (second.top - cell.top).abs() < 0.01, - "second sub-line restarts at the composite top, got {}", - second.top - ); - // Offsets advance down the sub-line. - let deeper = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); - assert!( - deeper.top > second.top, - "later offsets move down the sub-line" - ); - } - - fn visible_flow_gap(previous: &VerticalCell, next: &VerticalCell) -> f32 { - next.top + next.ink_top - (previous.top + previous.ink_bottom) - } - - fn spaced_content(text: &str, letter_spacing: f32) -> TextContent { - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, 1000.0), - GrowType::Fixed, - ); - let mut span = make_span(text); - span.letter_spacing = letter_spacing; - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![span], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - #[test] - fn letter_spacing_extends_upright_cells() { - // Each upright cluster gains `letter_spacing` of flow advance, so - // cells stack further apart down the column; the centring width - // (`h_advance`) is unaffected. - let plain = layout_content(&spaced_content("あい", 0.0), 1000.0); - let spaced = layout_content(&spaced_content("あい", 5.0), 1000.0); - assert_eq!(plain.cells.len(), spaced.cells.len()); - for i in 0..plain.cells.len() { - assert!( - (spaced.cells[i].extent - plain.cells[i].extent - 5.0).abs() < 0.01, - "cell {i} extent grows by letter-spacing" - ); - assert!( - (spaced.cells[i].h_advance - plain.cells[i].h_advance).abs() < 0.01, - "cell {i} centring width is unchanged" - ); - } - assert!( - spaced.cells[1].top - plain.cells[1].top - 5.0 > -0.01, - "the second cell is pushed down by the spacing" - ); - } - - #[test] - fn letter_spacing_spreads_rotated_run() { - // A sideways Latin run grows by `letter_spacing` per glyph and its - // glyphs shift apart along the (post-rotation) column axis. - let plain = layout_content(&spaced_content("AB", 0.0), 1000.0); - let spaced = layout_content(&spaced_content("AB", 5.0), 1000.0); - let plain_cell = plain - .cells - .iter() - .find(|c| matches!(c.kind, CellKind::Rotated { .. })) - .expect("a rotated cell"); - let spaced_cell = spaced - .cells - .iter() - .find(|c| matches!(c.kind, CellKind::Rotated { .. })) - .expect("a rotated cell"); - let (CellKind::Rotated { run: plain_run }, CellKind::Rotated { run: spaced_run }) = - (plain_cell.kind, spaced_cell.kind) - else { - unreachable!(); - }; - let glyphs = spaced.runs[spaced_run].glyphs.len(); - assert!(glyphs >= 2, "AB shapes to at least two glyphs"); - assert!( - (spaced_cell.extent - plain_cell.extent - 5.0 * glyphs as f32).abs() < 0.01, - "rotated extent grows by letter_spacing * glyph count" - ); - let plain_gap = plain.runs[plain_run].positions[1].x - plain.runs[plain_run].positions[0].x; - let spaced_gap = - spaced.runs[spaced_run].positions[1].x - spaced.runs[spaced_run].positions[0].x; - assert!( - (spaced_gap - plain_gap - 5.0).abs() < 0.01, - "adjacent glyph gap grows by letter-spacing" - ); - } - - // A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the - // vertical kana repeat mark) has a 2em vertical advance vs a 1em - // horizontal advance; U+3042/U+304F are symmetric controls. - const VMTX_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vmtx-test.ttf"); - - fn vmtx_provider() -> TypefaceFontProvider { - let font_mgr = FontMgr::new(); - let typeface = font_mgr - .new_from_data(VMTX_TEST_FONT, None) - .expect("failed to load vmtx test font"); - let mut provider = TypefaceFontProvider::new(); - let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); - provider.register_typeface(typeface, Some(family.as_str())); - provider - } - - fn layout_with(provider: &TypefaceFontProvider, content: &TextContent) -> VerticalLayout { - layout_with_height(provider, content, 1000.0) - } - - fn layout_with_height( - provider: &TypefaceFontProvider, - content: &TextContent, - max_height: f32, - ) -> VerticalLayout { - let fallback_mgr = FontMgr::from(provider.clone()); - layout_vertical( - content, - max_height, - provider, - fallback_mgr, - &[], - content.bounds(), - ) - } - - #[test] - fn vertical_advance_uses_vmtx() { - // 〱 flows down the column by its 2em vertical advance (vmtx), not - // its 1em horizontal width; the glyph still centres on the 1em width. - let content = make_content(&["〱"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - let cell = &layout.cells[0]; - let CellKind::Upright { run, glyph, count } = cell.kind else { - panic!("expected an upright cell"); - }; - let horizontal: f32 = layout.runs[run].advances[glyph..glyph + count].iter().sum(); - assert!( - (horizontal - 20.0).abs() < 0.5, - "horizontal advance ~1em, got {horizontal}" - ); - assert!( - (cell.extent - 40.0).abs() < 0.5, - "vertical extent ~2em from vmtx, got {}", - cell.extent - ); - assert!( - (cell.h_advance - 20.0).abs() < 0.5, - "h_advance stays ~1em, got {}", - cell.h_advance - ); - } - - #[test] - fn shape_to_path_places_vertical_glyphs_down_the_column() { - let content = make_content(&["あく"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - - let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true); - - assert_eq!(paths.len(), 2, "one outline path per upright glyph"); - let first = paths[0].0.bounds(); - let second = paths[1].0.bounds(); - assert!( - second.top > first.top, - "the second glyph outline follows the first down the vertical flow axis" - ); - let horizontal_shift = (second.center_x() - first.center_x()).abs(); - let vertical_shift = second.center_y() - first.center_y(); - assert!( - vertical_shift > horizontal_shift, - "vertical flow dominates the glyphs' optical side-bearing difference" - ); - } - - #[test] - fn shape_to_path_preserves_the_text_blob_draw_origin() { - let content = make_content(&["あ"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - let cell = &layout.cells[0]; - let CellKind::Upright { run, glyph, count } = cell.kind else { - panic!("expected an upright cell"); - }; - let run = &layout.runs[run]; - let mut builder = TextBlobBuilder::new(); - let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); - let base_x = run.positions[glyph].x; - for i in 0..count { - glyphs[i] = run.glyphs[glyph + i]; - points[i] = SkPoint::new( - run.positions[glyph + i].x - base_x, - run.positions[glyph + i].y, - ); - } - let blob = builder.make().expect("a glyph text blob"); - let blob_bounds = *blob.bounds(); - let draw_origin = SkPoint::new(120.0, 340.0); - let mut normalized_blob = blob.clone(); - let normalized_path = SkiaParagraph::get_path(&mut normalized_blob); - let normalized_bounds = normalized_path.bounds(); - - let path = text_blob_path(blob, draw_origin); - let path_bounds = path.bounds(); - - assert!( - (path_bounds.left - (draw_origin.x + blob_bounds.left + normalized_bounds.left)).abs() - < 0.01 - ); - assert!( - (path_bounds.top - (draw_origin.y + blob_bounds.top + normalized_bounds.top)).abs() - < 0.01 - ); - assert!((path_bounds.width() - normalized_bounds.width()).abs() < 0.01); - assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01); - } - - #[test] - fn vertical_advance_symmetric_glyph_unchanged() { - // A glyph whose vmtx equals its hmtx keeps extent == h_advance. - let content = make_content(&["く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - let cell = &layout.cells[0]; - assert!( - (cell.extent - 20.0).abs() < 0.5, - "extent ~1em, got {}", - cell.extent - ); - assert!( - (cell.h_advance - cell.extent).abs() < 0.01, - "symmetric glyph: extent == h_advance" - ); - } - - // Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the - // vertical alternates of 、。「」 halve their vertical advances (「 also - // lifts its ink by 481 units) and the あ/く alternates tighten by - // 58/60 units with small placement lifts. - const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf"); - - fn vpal_provider() -> TypefaceFontProvider { - let font_mgr = FontMgr::new(); - let typeface = font_mgr - .new_from_data(VPAL_TEST_FONT, None) - .expect("failed to load vpal test font"); - let mut provider = TypefaceFontProvider::new(); - let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); - provider.register_typeface(typeface, Some(family.as_str())); - provider - } - - fn vpal_content(text: &str, font_features: FontFeatures) -> TextContent { - let mut content = make_content(&[text], 1000.0); - content.paragraphs_mut()[0].children_mut()[0].font_features = font_features; - content - } - - #[test] - fn native_vertical_alternates_are_not_synthetically_rotated() { - let layout = layout_with( - &vpal_provider(), - &vpal_content("「」、。", FontFeatures::None), - ); - assert_eq!(layout.cells.len(), 4); - assert!(layout - .cells - .iter() - .all(|cell| matches!(cell.kind, CellKind::Upright { .. }))); - } - - #[test] - fn vpal_tightens_upright_kana() { - let provider = vpal_provider(); - let plain = layout_with(&provider, &vpal_content("あ", FontFeatures::None)); - let tight = layout_with(&provider, &vpal_content("あ", FontFeatures::Vpal)); - assert!( - (plain.cells[0].extent - 20.0).abs() < 0.01, - "without vpal あ keeps its full em, got {}", - plain.cells[0].extent - ); - // あ's vertical alternate carries YAdvance -58, YPlacement 39. - let expected = 20.0 * (1000.0 - 58.0) / 1000.0; - assert!( - (tight.cells[0].extent - expected).abs() < 0.01, - "vpal advance delta tightens the extent, got {}", - tight.cells[0].extent - ); - let expected_shift = -20.0 * 39.0 / 1000.0; - assert!( - (tight.cells[0].glyph_flow_shift - expected_shift).abs() < 0.01, - "vpal placement lifts the drawn ink, got {}", - tight.cells[0].glyph_flow_shift - ); - } - - #[test] - fn vpal_punctuation_is_not_double_compressed() { - // 、's vertical alternate already halves its advance under vpal; - // the aki shed must not compress the half-width cell again. - let provider = vpal_provider(); - let layout = layout_with(&provider, &vpal_content("あ、あ", FontFeatures::Vpal)); - assert!( - (layout.cells[1].extent - 10.0).abs() < 0.01, - "、 is exactly half-width under vpal, got {}", - layout.cells[1].extent - ); - } - - #[test] - fn vpal_oikomi_preserves_half_em_punctuation_frame() { - // vpal has already removed the opening bracket's leading aki. A tight - // fixed-height column must wrap instead of offering that same half-em - // to oikomi and collapsing the glyph frame a second time. - let provider = vpal_provider(); - let content = vpal_content("あ「あ", FontFeatures::Vpal); - let natural = layout_with(&provider, &content); - let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); - let bracket_extent = natural.cells[1].extent; - let constrained = layout_with_height(&provider, &content, natural_total - 5.0); - - assert!( - (bracket_extent - 10.0).abs() < 0.01, - "vpal opening bracket starts at half-em, got {bracket_extent}" - ); - assert!( - (constrained.cells[1].extent - bracket_extent).abs() < 0.01, - "oikomi collapsed the vpal bracket frame from {bracket_extent} to {}", - constrained.cells[1].extent - ); - assert_ne!( - constrained.cells[0].column, constrained.cells[2].column, - "insufficient removable aki should wrap the protected bracket and following glyph" - ); - } - - #[test] - fn vpal_opening_bracket_uses_font_placement() { - // In the middle of a line a normal opening bracket keeps its leading - // aki. Under vpal its placement still comes from the font's own - // YPlacement (481 units), not a synthetic sequence shed. - let provider = vpal_provider(); - let shed = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::None)); - let vpal = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::Vpal)); - assert_eq!( - shed.cells[1].glyph_flow_shift, 0.0, - "without vpal the preferred aki needs no synthetic shift, got {}", - shed.cells[1].glyph_flow_shift - ); - let expected = -20.0 * 481.0 / 1000.0; - assert!( - (vpal.cells[1].glyph_flow_shift - expected).abs() < 0.01, - "with vpal 「 lifts by the font's placement delta, got {}", - vpal.cells[1].glyph_flow_shift - ); - assert!( - (vpal.cells[1].extent - 10.0).abs() < 0.01, - "「 is half-width under vpal, got {}", - vpal.cells[1].extent - ); - } - - #[test] - fn ordinary_closing_punctuation_keeps_preferred_aki() { - // In ordinary text, the comma and closing bracket keep their normal - // half-em glyph body plus half-em trailing aki: one em in total. - let content = make_content(&["く、く」く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - assert_eq!(layout.cells.len(), 5, "one cell per character"); - let em = 20.0; - assert!( - (layout.cells[0].extent - em).abs() < 1.0, - "leading ideograph keeps full advance, got {}", - layout.cells[0].extent - ); - assert!( - (layout.cells[1].extent - em).abs() < 1.0, - "、 before an ideograph keeps its aki, got {}", - layout.cells[1].extent - ); - assert!( - (layout.cells[3].extent - em).abs() < 1.0, - "」 before an ideograph keeps its aki, got {}", - layout.cells[3].extent - ); - assert!( - (layout.cells[4].extent - em).abs() < 1.0, - "trailing ideograph keeps full advance, got {}", - layout.cells[4].extent - ); - } - - #[test] - fn closing_then_opening_keeps_half_em_aki() { - // く」「く: the closing bracket sheds its trailing half, but the - // opening bracket after it keeps its full em (leading half blank), so - // the pair keeps the half-em aki JIS X 4051 asks for instead of - // setting solid. - let content = make_content(&["く」「く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - assert_eq!(layout.cells.len(), 4, "one cell per character"); - let em = 20.0; - assert!( - layout.cells[1].extent <= 0.5 * em + 0.01, - "」 sheds its trailing aki, got {}", - layout.cells[1].extent - ); - assert!( - (layout.cells[2].extent - em).abs() < 1.0, - "「 after a closing mark keeps its full em, got {}", - layout.cells[2].extent - ); - assert_eq!( - layout.cells[2].glyph_flow_shift, 0.0, - "the unshed opening bracket is not shifted" - ); - } - - #[test] - fn dividing_punctuation_materializes_trailing_aki() { - let provider = vmtx_provider(); - let terminal = layout_with(&provider, &make_content(&["!"], 1000.0)); - let before_ideograph = layout_with(&provider, &make_content(&["!く"], 1000.0)); - let before_dividing = layout_with(&provider, &make_content(&["!?"], 1000.0)); - let before_closing = layout_with(&provider, &make_content(&["!」"], 1000.0)); - let em = 20.0; - let natural_extent = terminal.cells[0].extent; - - assert!( - (before_ideograph.cells[0].extent - natural_extent - em).abs() < 0.01, - "! before ordinary text adds one em, got {} over natural {natural_extent}", - before_ideograph.cells[0].extent - ); - assert!( - (before_dividing.cells[0].extent - natural_extent - em).abs() < 0.01, - "! before ? adds one em, got {} over natural {natural_extent}", - before_dividing.cells[0].extent - ); - assert!( - (before_closing.cells[0].extent - natural_extent).abs() < 0.01, - "! before a closing bracket adds no aki, got {} over natural {natural_extent}", - before_closing.cells[0].extent - ); - } - - #[test] - fn dividing_punctuation_oikomi_never_compresses_glyph_frames() { - let provider = vmtx_provider(); - let terminal_exclamation = layout_with(&provider, &make_content(&["!"], 1000.0)); - let terminal_question = layout_with(&provider, &make_content(&["?"], 1000.0)); - let layout = layout_with_height(&provider, &make_content(&["く!?く"], 60.0), 60.0); - - assert!( - layout.cells[1].extent + 0.01 >= terminal_exclamation.cells[0].extent, - "oikomi compressed the ! glyph frame from {} to {}", - terminal_exclamation.cells[0].extent, - layout.cells[1].extent - ); - assert!( - layout.cells[2].extent + 0.01 >= terminal_question.cells[0].extent, - "oikomi compressed the ? glyph frame from {} to {}", - terminal_question.cells[0].extent, - layout.cells[2].extent - ); - } - - #[test] - fn dividing_punctuation_discards_trailing_aki_at_column_edge() { - let provider = vmtx_provider(); - let terminal = layout_with(&provider, &make_content(&["!"], 1000.0)); - let layout = layout_with_height(&provider, &make_content(&["!く"], 20.0), 20.0); - - assert_ne!(layout.cells[0].column, layout.cells[1].column); - assert!( - (layout.cells[0].extent - terminal.cells[0].extent).abs() < 0.01, - "column-end ! should discard its trailing aki, got {} over natural {}", - layout.cells[0].extent, - terminal.cells[0].extent - ); - } - - #[test] - fn ordinary_opening_punctuation_keeps_preferred_aki() { - // く「く: the opening bracket keeps its leading half-em aki in the - // middle of a line, so its total advance remains one em. - let content = make_content(&["く「く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - assert_eq!(layout.cells.len(), 3, "one cell per character"); - let em = 20.0; - assert!( - (layout.cells[0].extent - em).abs() < 1.0, - "leading ideograph keeps full advance, got {}", - layout.cells[0].extent - ); - assert_eq!( - layout.cells[0].glyph_flow_shift, 0.0, - "ideograph is not shifted" - ); - assert!( - (layout.cells[1].extent - em).abs() < 1.0, - "「 keeps its leading aki, got {}", - layout.cells[1].extent - ); - assert_eq!( - layout.cells[1].glyph_flow_shift, 0.0, - "an uncompressed opening bracket is not shifted, got {}", - layout.cells[1].glyph_flow_shift - ); - assert!( - (layout.cells[2].extent - em).abs() < 1.0, - "trailing ideograph keeps full advance, got {}", - layout.cells[2].extent - ); - } - - #[test] - fn consecutive_closing_punctuation_sets_solid_internally() { - let content = make_content(&["く。」く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - let em = 20.0; - assert!( - layout.cells[1].extent <= 0.5 * em + 0.01, - "。 sheds its internal trailing aki, got {}", - layout.cells[1].extent - ); - assert!( - (layout.cells[2].extent - em).abs() < 1.0, - "the final 」 keeps the sequence's trailing aki, got {}", - layout.cells[2].extent - ); - } - - #[test] - fn consecutive_opening_punctuation_sets_solid_after_first() { - let content = make_content(&["く「『く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - let em = 20.0; - assert!( - (layout.cells[1].extent - em).abs() < 1.0, - "the first opening bracket keeps the sequence's leading aki, got {}", - layout.cells[1].extent - ); - assert!( - layout.cells[2].extent <= 0.5 * em + 0.01, - "the second opening bracket sets solid, got {}", - layout.cells[2].extent - ); - assert!(layout.cells[2].glyph_flow_shift < -0.01); - } - - #[test] - fn opening_bracket_at_column_head_is_tentsuki() { - let content = make_content(&["「く"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - let em = 20.0; - assert_eq!(layout.cells[0].top, 0.0); - assert!( - layout.cells[0].extent <= 0.5 * em + 0.01, - "line-head 「 sheds its leading aki, got {}", - layout.cells[0].extent - ); - assert!(layout.cells[0].glyph_flow_shift < -0.01); - } - - #[test] - fn line_end_punctuation_keeps_preferred_half_em_aki() { - let content = make_content(&["く、"], 40.0); - let layout = layout_with(&vmtx_provider(), &content); - assert_eq!(layout.cells[0].column, layout.cells[1].column); - assert!( - (layout.cells[1].extent - 20.0).abs() < 1.0, - "line-end 、 keeps a half-em after its glyph, got {}", - layout.cells[1].extent - ); - } - - #[test] - fn centered_punctuation_shift_centres_ink_in_em_body() { - assert!(is_centered_punctuation('・')); - assert!(is_centered_punctuation(':')); - assert!(is_centered_punctuation(';')); - assert!(!is_centered_punctuation('あ')); - // Ink hugging the bottom of the body (14..18 in a 20 body) shifts up so - // its midpoint (16) lands on the body midpoint (10): shift == -6. - let shift = centered_flow_shift(14.0, 18.0, 20.0); - assert!((shift + 6.0).abs() < 1e-4, "expected -6, got {shift}"); - // Already-centred ink needs no shift. - assert!(centered_flow_shift(8.0, 12.0, 20.0).abs() < 1e-4); - } - - #[test] - fn plain_ideographs_keep_full_advance() { - let content = make_content(&["くくく"], 1000.0); - let layout = layout_with(&vmtx_provider(), &content); - for cell in &layout.cells { - assert!( - (cell.extent - 20.0).abs() < 1.0, - "no punctuation: full advance kept, got {}", - cell.extent - ); - } - } - - #[test] - fn vertical_metrics_parse_vmtx() { - let font_mgr = FontMgr::new(); - let typeface = font_mgr.new_from_data(VMTX_TEST_FONT, None).unwrap(); - let font = Font::new(typeface, 20.0); - let vm = VerticalMetrics::from_font(&font).expect("vmtx present"); - // gid 1 = 〱 (2000 units), gid 3+ fall back to the last long metric. - assert!((vm.advance(1, 20.0) - 40.0).abs() < 0.01); - assert!((vm.advance(3, 20.0) - 20.0).abs() < 0.01); - assert!((vm.advance(99, 20.0) - 20.0).abs() < 0.01); - } - - #[test] - fn vertical_metrics_absent_without_vmtx() { - // The bundled Source Sans face carries no `vmtx`. - let font_mgr = FontMgr::new(); - let typeface = font_mgr.new_from_data(TEST_FONT, None).unwrap(); - let font = Font::new(typeface, 20.0); - assert!(VerticalMetrics::from_font(&font).is_none()); - } - - #[test] - fn layout_cells_tile_the_text() { - let text = "縦書きのAB12テスト。"; - let content = make_content(&[text], 1000.0); - let layout = layout_content(&content, 1000.0); - - let mut expected = 0usize; - for cell in &layout.cells { - assert_eq!(cell.paragraph, 0); - assert_eq!(cell.start, expected, "cells must tile without gaps"); - assert!(cell.end > cell.start); - expected = cell.end; - } - assert_eq!(expected, text.encode_utf16().count()); - } - - #[test] - fn layout_columns_respect_wrap_height() { - let content = make_content(&["あいうえおかきくけこ"], 100.0); - let layout = layout_content(&content, 60.0); - - assert!(layout.columns.len() > 1, "content must wrap into columns"); - for column_index in 0..layout.columns.len() { - let bottom = layout - .cells - .iter() - .filter(|c| c.column == column_index) - .map(|c| c.top + c.extent) - .fold(0.0f32, f32::max); - assert!(bottom <= 60.0 + 0.01, "column overflows the wrap height"); - } - // Columns advance leftward: column 0 is the rightmost. - assert!(layout.columns[0].x > layout.columns[1].x); - let total: f32 = layout.columns.iter().map(|c| c.width).sum(); - assert!((layout.width - total).abs() < 0.01); - } - - #[test] - fn layout_kinsoku_no_period_at_column_top() { - // Wrap height fits exactly 2 characters per column; the 。 after - // the second character would start column 2 without kinsoku. - let content = make_content(&["あい。うえお"], 100.0); - let cell_extent = { - let layout = layout_content(&content, 1000.0); - layout.cells[0].extent - }; - let layout = layout_content(&content, cell_extent * 2.0 + 0.1); - - for column_index in 0..layout.columns.len() { - let first = layout - .cells - .iter() - .filter(|c| c.column == column_index) - .min_by(|a, b| a.top.partial_cmp(&b.top).unwrap()); - if let Some(first) = first { - if first.top == 0.0 && first.end - first.start == 1 { - let text: Vec = "あい。うえお".chars().collect(); - let c = text[first.start]; - assert!( - !forbidden_at_line_start(c), - "column starts with forbidden char {c}" - ); - } - } - } - } - - #[test] - fn layout_each_paragraph_starts_a_new_column() { - let content = make_content(&["あい", "うえ"], 1000.0); - let layout = layout_content(&content, 1000.0); - - assert_eq!(layout.paragraph_columns.len(), 2); - let (p0_start, p0_end) = layout.paragraph_columns[0]; - let (p1_start, _) = layout.paragraph_columns[1]; - assert_eq!(p0_start, 0); - assert_eq!(p0_end, p1_start); - assert!(layout - .cells - .iter() - .all(|c| (c.paragraph == 0) == (c.column < p0_end))); - } - - #[test] - fn layout_empty_paragraph_still_takes_a_column() { - let content = make_content(&["あ", "", "い"], 1000.0); - let layout = layout_content(&content, 1000.0); - assert_eq!(layout.columns.len(), 3); - assert_eq!(layout.paragraph_columns[1], (1, 2)); - } - - #[test] - fn caret_round_trip() { - let text = "あいうえお"; - let content = make_content(&[text], 1000.0); - let layout = layout_content(&content, 1000.0); - - for cell in &layout.cells { - let column = &layout.columns[cell.column]; - // A point in the upper half of the cell resolves to its start. - let (paragraph, offset) = caret_from_point( - &layout, - column.x + column.width / 2.0, - cell.top + cell.extent * 0.25, - ) - .expect("caret"); - assert_eq!(paragraph, 0); - assert_eq!(offset, cell.start); - - // caret_rect for that offset lands inside the same column and - // carries the character extent (for overtype carets). - let rect = caret_rect(&layout, paragraph, offset).expect("caret rect"); - assert!((rect.x() - column.x).abs() < 0.01); - let chars = (cell.end - cell.start).max(1); - assert!((rect.height() - cell.extent / chars as f32).abs() < 0.01); - } - - // After the last character there is no glyph to cover: zero height. - let end_offset = layout.cells.last().unwrap().end; - let rect = caret_rect(&layout, 0, end_offset).expect("caret rect"); - assert_eq!(rect.height(), 0.0); - } - - #[test] - fn non_bmp_caret_offsets_round_trip_through_editor_operations() { - let content = make_content(&["𠀀あ"], 1000.0); - let layout = layout_content(&content, 1000.0); - let first = &layout.cells[0]; - let second = &layout.cells[1]; - let column = &layout.columns[first.column]; - let x = column_base_center(column); - - let before = caret_from_point(&layout, x, first.top + first.extent * 0.25).unwrap(); - let after = caret_from_point(&layout, x, first.top + first.extent * 0.75).unwrap(); - assert_eq!(before, (0, 0)); - assert_eq!(after, (0, 1)); - - let rect = caret_rect(&layout, after.0, after.1).expect("caret rect"); - assert!((rect.top - second.top).abs() < 0.01); - assert!((rect.height() - second.extent).abs() < 0.01); - - let rects = range_rects(&layout, 0, 0, 1); - assert_eq!(rects.len(), 1); - assert!((rects[0].height() - first.extent).abs() < 0.01); - - let start = TextPositionWithAffinity::new_downstream_affinity(0, 0); - let end = TextPositionWithAffinity::new_downstream_affinity(after.0, after.1); - assert_eq!( - text_helpers::move_cursor_forward(&start, content.paragraphs(), false), - end - ); - assert_eq!( - text_helpers::move_cursor_backward(&end, content.paragraphs(), false), - start - ); - - let mut inserted = make_content(&["𠀀あ"], 1000.0); - assert_eq!( - text_helpers::insert_text_at_cursor(&mut inserted, &end, "X"), - Some(2) - ); - assert_eq!(inserted.paragraphs()[0].children()[0].text, "𠀀Xあ"); - - let mut deleted = make_content(&["𠀀あ"], 1000.0); - assert_eq!( - text_helpers::delete_char_before(&mut deleted, &end), - Some(start) - ); - assert_eq!(deleted.paragraphs()[0].children()[0].text, "あ"); - } - - #[test] - fn selection_rects_cover_the_range() { - let text = "あいうえお"; - let content = make_content(&[text], 1000.0); - let layout = layout_content(&content, 1000.0); - - let rects = range_rects(&layout, 0, 1, 3); - assert!(!rects.is_empty()); - let covered: f32 = rects.iter().map(|r| r.height()).sum(); - let expected: f32 = layout - .cells - .iter() - .filter(|c| c.start >= 1 && c.end <= 3) - .map(|c| c.extent) - .sum(); - assert!((covered - expected).abs() < 0.01); - } - - #[test] - fn position_data_merges_by_column_and_maps_span_offsets() { - let text = "あいうえお"; - let content = make_content(&[text], 1000.0); - let layout = layout_content(&content, 1000.0); - let bounds = content.bounds(); - - let data = position_data(&layout, &bounds, VerticalAlign::Top); - assert_eq!(data.len(), 1, "one column, one span => one entry"); - assert_eq!(data[0].start_pos, 0); - assert_eq!(data[0].end_pos, text.encode_utf16().count() as u32); - // Right-anchored: the strip ends at the bounds right edge. - assert!((data[0].x + data[0].width - bounds.right).abs() < 0.01); - } - - #[test] - fn position_data_keeps_expanded_transform_in_one_source_safe_strip() { - let mut content = make_content(&["AßB"], 1000.0); - let span = &mut content.paragraphs_mut()[0].children_mut()[0]; - span.text_transform = Some(TextTransform::Uppercase); - span.text_orientation = TextOrientation::Upright; - let unwrapped = layout_content(&content, 1000.0); - let expanded_extent: f32 = unwrapped - .cells - .iter() - .filter(|cell| cell_source_utf16_range(&unwrapped, cell) == (1..2)) - .map(|cell| cell.extent) - .sum(); - let layout = layout_content(&content, expanded_extent + 0.01); - - let expanded: Vec<&VerticalCell> = layout - .cells - .iter() - .filter(|cell| cell_source_utf16_range(&layout, cell) == (1..2)) - .collect(); - assert_eq!(expanded.len(), 2, "ß must shape as the two cells in SS"); - assert_eq!( - expanded[0].column, expanded[1].column, - "glyphs from one source character must not split across columns" - ); - - let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); - let ranges: Vec<(u32, u32)> = data - .iter() - .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) - .map(|entry| (entry.start_pos, entry.end_pos)) - .collect(); - assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]); - } - - #[test] - fn position_data_emits_ruby_annotation_strips() { - let content = make_ruby_content("漢字", "かんじ", 400.0); - let layout = layout_content(&content, 400.0); - let bounds = content.bounds(); - let data = position_data(&layout, &bounds, VerticalAlign::Top); - - let base: Vec<&PositionData> = data - .iter() - .filter(|d| d.direction == DIRECTION_VERTICAL_RL) - .collect(); - let ruby: Vec<&PositionData> = data - .iter() - .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) - .collect(); - assert_eq!(base.len(), 1); - assert_eq!(ruby.len(), 1, "one annotated column => one ruby strip"); - assert_eq!(ruby[0].start_pos, 0); - assert_eq!(ruby[0].end_pos, 3, "offsets index the ruby string"); - // The strip sits in the gutter, to the right of the base band. - assert!(ruby[0].x >= base[0].x + base[0].width - 0.001); - assert!(ruby[0].width > 0.0); - assert!(ruby[0].height > 0.0); - } - - #[test] - fn position_data_ruby_offsets_are_utf16_for_non_bmp_readings() { - // Two surrogate-pair reading characters: 2 glyphs but 4 UTF-16 - // units. The strip offsets must slice the ruby string by UTF-16. - let content = make_ruby_content("\u{6f22}\u{5b57}", "\u{1d4aa}\u{1d4ab}", 400.0); - let layout = layout_content(&content, 400.0); - let bounds = content.bounds(); - let data = position_data(&layout, &bounds, VerticalAlign::Top); - let ruby: Vec<&PositionData> = data - .iter() - .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) - .collect(); - assert_eq!(ruby.len(), 1); - assert_eq!(ruby[0].start_pos, 0); - assert_eq!( - ruby[0].end_pos, 4, - "surrogate pairs count two UTF-16 units each" - ); - } - - #[test] - fn horizontal_ruby_ranges_use_utf16_across_spans() { - let offset_map = crate::shapes::kinsoku::OffsetMap::default(); - let mut cursor = 0; - - assert_eq!( - next_horizontal_ruby_range(&offset_map, &mut cursor, "𠀀"), - 0..2 - ); - assert_eq!( - next_horizontal_ruby_range(&offset_map, &mut cursor, "漢"), - 2..3 - ); - } - - #[test] - fn split_counts_by_extent_drops_no_glyph() { - assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]); - assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]); - assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]); - assert_eq!( - split_counts_by_extent(&[1.0, 1.0, 1.0], 2) - .iter() - .sum::(), - 2 - ); - assert!(split_counts_by_extent(&[], 3).is_empty()); - } - - #[test] - fn block_axis_alignment_maps_start_center_end_to_right_center_left() { - assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Top), 160.0); - assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Center), 80.0); - assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Bottom), 0.0); - assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), 0.0); - } - - #[test] - fn position_data_follows_block_axis_alignment() { - let content = make_content(&["あいう"], 1000.0); - let layout = layout_content(&content, 1000.0); - let bounds = content.bounds(); - let top = position_data(&layout, &bounds, VerticalAlign::Top); - let center = position_data(&layout, &bounds, VerticalAlign::Center); - let bottom = position_data(&layout, &bounds, VerticalAlign::Bottom); - - assert!(top[0].x > center[0].x); - assert!(center[0].x > bottom[0].x); - assert!((bottom[0].x - bounds.left).abs() < 0.01); - } - - #[test] - fn wrapped_vertical_content_grows_across_columns() { - let content = make_content(&["あいうえおかきくけこ"], 60.0); - let layout = layout_content(&content, 60.0); - assert!(layout.columns.len() > 1); - assert!(layout.width > layout.columns[0].width); - assert!(layout.height <= 60.0 + 0.01); - } - - #[test] - fn layout_mixed_text_has_rotated_run() { - let content = make_content(&["あAB1い"], 1000.0); - let layout = layout_content(&content, 1000.0); - assert!(layout - .cells - .iter() - .any(|c| matches!(c.kind, CellKind::Rotated { .. }))); - // The rotated run covers the Latin range 1..4 (UTF-16). - let rotated = layout - .cells - .iter() - .find(|c| matches!(c.kind, CellKind::Rotated { .. })) - .unwrap(); - assert_eq!(rotated.start, 1); - assert_eq!(rotated.end, 4); - } - - #[test] - fn mixed_spans_preserve_fill_color_and_opacity() { - let mut content = make_content_with_spans(&["あ", "A", "い"], 1000.0); - let colors = [ - skia::Color::from_argb(255, 255, 0, 0), - skia::Color::from_argb(128, 0, 255, 0), - skia::Color::from_argb(64, 0, 0, 255), - ]; - for (span, color) in content.paragraphs_mut()[0] - .children_mut() - .iter_mut() - .zip(colors) - { - span.fills = vec![Fill::Solid(SolidColor(color))]; - } - - let layout = layout_content(&content, 1000.0); - assert_eq!(layout.paints.len(), colors.len()); - for (paint, color) in layout.paints.iter().zip(colors) { - assert_eq!(paint.color(), color); - } - assert!(layout - .cells - .iter() - .any(|cell| matches!(cell.kind, CellKind::Rotated { .. }))); - } - - #[test] - fn inter_script_spacing_separates_cjk_and_latin() { - let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; - - let cjk_latin = layout_content(&make_content(&["あa"], 1000.0), 1000.0); - assert_eq!(cjk_latin.cells.len(), 2); - assert!( - (visible_flow_gap(&cjk_latin.cells[0], &cjk_latin.cells[1]) - expected_gap).abs() - < 0.01 - ); - - let latin_cjk = layout_content(&make_content(&["aあ"], 1000.0), 1000.0); - assert_eq!(latin_cjk.cells.len(), 2); - assert!( - (visible_flow_gap(&latin_cjk.cells[0], &latin_cjk.cells[1]) - expected_gap).abs() - < 0.01 - ); - } - - #[test] - fn inter_script_spacing_is_visually_symmetric_around_latin_run() { - let layout = layout_content(&make_content(&["うpenあ"], 1000.0), 1000.0); - assert_eq!(layout.cells.len(), 3); - let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; - let before = visible_flow_gap(&layout.cells[0], &layout.cells[1]); - let after = visible_flow_gap(&layout.cells[1], &layout.cells[2]); - assert!((before - expected_gap).abs() < 0.01); - assert!((after - expected_gap).abs() < 0.01); - assert!((before - after).abs() < 0.01); - } - - #[test] - fn inter_script_spacing_preserves_explicit_letter_spacing() { - let letter_spacing = 3.0; - let layout = layout_content(&spaced_content("うpenあ", letter_spacing), 1000.0); - assert_eq!(layout.cells.len(), 3); - let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM + letter_spacing; - assert!((visible_flow_gap(&layout.cells[0], &layout.cells[1]) - expected_gap).abs() < 0.01); - assert!((visible_flow_gap(&layout.cells[1], &layout.cells[2]) - expected_gap).abs() < 0.01); - } - - #[test] - fn inter_script_spacing_crosses_span_boundaries() { - let joined = layout_content(&make_content(&["あa"], 1000.0), 1000.0); - let split = layout_content(&make_content_with_spans(&["あ", "a"], 1000.0), 1000.0); - assert_eq!(joined.cells.len(), split.cells.len()); - for (joined, split) in joined.cells.iter().zip(&split.cells) { - assert!((joined.top - split.top).abs() < 0.01); - assert!((joined.extent - split.extent).abs() < 0.01); - } - } - - #[test] - fn inter_script_spacing_excludes_punctuation_and_whitespace() { - let cjk_extent = layout_content(&make_content(&["あ"], 1000.0), 1000.0).cells[0].extent; - for text in ["あ.", "あ a"] { - let layout = layout_content(&make_content(&[text], 1000.0), 1000.0); - assert!( - (layout.cells[0].extent - cjk_extent).abs() < 0.01, - "{text:?} must not add spacing immediately after the CJK cell" - ); - } - } - - #[test] - fn oikomi_reduces_script_gap_before_oidashi() { - let natural = layout_content(&make_content(&["あa"], 1000.0), 1000.0); - assert_eq!(natural.cells.len(), 2); - let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); - let budget = natural_total - 2.0; - let adjusted = layout_content(&make_content(&["あa"], budget), budget); - assert_eq!(adjusted.cells[0].column, adjusted.cells[1].column); - let gap = visible_flow_gap(&adjusted.cells[0], &adjusted.cells[1]); - assert!( - (gap - (20.0 * INTER_SCRIPT_SPACING_EM - 2.0)).abs() < 0.01, - "oikomi should recover the two-pixel deficit from the script gap, got {gap}" - ); - } - - #[test] - fn oikomi_preserves_sentence_final_full_stop_aki() { - let natural = layout_content(&make_content(&["あ。あ"], 1000.0), 1000.0); - let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); - let budget = natural_total - 2.0; - let adjusted = layout_content(&make_content(&["あ。あ"], budget), budget); - assert_ne!( - adjusted.cells[1].column, adjusted.cells[2].column, - "fixed full-stop aki must not be compressed to retain the next ideograph" - ); - assert!((adjusted.cells[1].extent - natural.cells[1].extent).abs() < 0.01); - } - - #[test] - fn capped_adjustment_redistributes_after_a_boundary_saturates() { - let allocations = capped_equal_allocations(&[(0, 1.0), (1, 3.0)], 3.0); - assert_eq!(allocations, vec![(0, 1.0), (1, 2.0)]); - } - - #[test] - fn western_word_space_uses_one_third_em() { - let without_space = layout_content(&make_content(&["ab"], 1000.0), 1000.0); - let with_space = layout_content(&make_content(&["a b"], 1000.0), 1000.0); - assert_eq!(without_space.cells.len(), 1); - assert_eq!(with_space.cells.len(), 1); - let added = with_space.cells[0].extent - without_space.cells[0].extent; - assert!( - (added - 20.0 * WESTERN_WORD_SPACING_EM).abs() < 0.01, - "word space should add one third em, got {added}" - ); - } - - #[test] - fn align_offset_helper_maps_edges() { - // Unbounded budget (auto-width): no shift. - assert_eq!( - align_offset_along_column(TextAlign::Center, f32::MAX, 40.0), - 0.0 - ); - // Left/Start anchors to the top. - assert_eq!(align_offset_along_column(TextAlign::Left, 100.0, 40.0), 0.0); - // Center splits the slack. - assert_eq!( - align_offset_along_column(TextAlign::Center, 100.0, 40.0), - 30.0 - ); - // Right/End pushes to the bottom. - assert_eq!( - align_offset_along_column(TextAlign::Right, 100.0, 40.0), - 60.0 - ); - assert_eq!(align_offset_along_column(TextAlign::End, 100.0, 40.0), 60.0); - // Overfull column: no negative shift. - assert_eq!( - align_offset_along_column(TextAlign::Right, 40.0, 100.0), - 0.0 - ); - } - - #[test] - fn text_align_shifts_column_cells() { - let text = "あいう"; - // Baseline (top-aligned) used length of the single column. - let used = { - let content = make_content_aligned(text, 1000.0, TextAlign::Left); - let layout = layout_content(&content, 1000.0); - layout - .cells - .iter() - .map(|c| c.top + c.extent) - .fold(0.0f32, f32::max) - }; - - let top_of = |align: TextAlign| { - let content = make_content_aligned(text, 1000.0, align); - let layout = layout_content(&content, 1000.0); - layout.cells[0].top - }; - - assert!(top_of(TextAlign::Left).abs() < 0.01); - assert!((top_of(TextAlign::Center) - (1000.0 - used) / 2.0).abs() < 0.5); - assert!((top_of(TextAlign::Right) - (1000.0 - used)).abs() < 0.5); - // Cells stay in reading order and keep tiling under the shift. - let content = make_content_aligned(text, 1000.0, TextAlign::Right); - let layout = layout_content(&content, 1000.0); - for pair in layout.cells.windows(2) { - assert!(pair[1].top >= pair[0].top); - } - } - - #[test] - fn text_align_auto_width_stays_top() { - // Auto-width columns are snug: alignment must not shift them. - crate::globals::design_init(); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, 60.0), - GrowType::AutoWidth, - ); - let mut paragraph = Paragraph::new( - TextAlign::Right, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![make_span("あいうえお")], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - - let layout = layout_content(&content, wrap_height(&content, 60.0)); - assert!(layout.cells[0].top.abs() < 0.01); - } - - #[test] - fn rotated_baseline_shift_centres_band() { - let top = -30.0; - let bottom = 10.0; - let shift = rotated_baseline_shift(top, bottom); - assert!(((top + bottom) / 2.0 + shift).abs() < f32::EPSILON); - // Symmetric metrics need no shift. - assert_eq!(rotated_baseline_shift(-20.0, 20.0), 0.0); - } - - #[test] - fn rotated_run_centres_actual_lowercase_ink() { - let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0); - let cell = &layout.cells[0]; - let CellKind::Rotated { run } = cell.kind else { - panic!("expected a rotated run"); - }; - let run = &layout.runs[run]; - let mut bounds = vec![skia::Rect::default(); run.glyphs.len()]; - run.font.get_bounds(&run.glyphs, &mut bounds, None); - let top = bounds - .iter() - .zip(&run.positions) - .map(|(bound, position)| bound.top + position.y) - .fold(f32::MAX, f32::min); - let bottom = bounds - .iter() - .zip(&run.positions) - .map(|(bound, position)| bound.bottom + position.y) - .fold(f32::MIN, f32::max); - let shift = run.rotated_baseline_shift; - assert!(((top + bottom) / 2.0 + shift).abs() < 0.01); - - // Lowercase ink does not occupy the face's full ascent/descent band; - // this guards against regressing to font-wide metric centring. - let (_, metrics) = run.font.metrics(); - let metrics_shift = rotated_baseline_shift(metrics.ascent, metrics.descent); - assert!((shift - metrics_shift).abs() > 0.1); - } - - #[test] - fn horizontal_ruby_attaches_to_visible_glyph_ink() { - let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0); - let CellKind::Rotated { run } = layout.cells[0].kind else { - panic!("expected a rotated run"); - }; - let run = &layout.runs[run]; - let mut bounds = [skia::Rect::default()]; - run.font.get_bounds(&[run.glyphs[0]], &mut bounds, None); - - assert!( - (horizontal_ruby_ink_edge(&run.font, run.glyphs[0], 0.0, true) - bounds[0].bottom) - .abs() - < f32::EPSILON - ); - assert!( - (horizontal_ruby_ink_edge(&run.font, run.glyphs[0], 0.0, false) - bounds[0].top).abs() - < f32::EPSILON - ); - } - - #[test] - fn justify_fills_non_last_columns() { - use std::collections::BTreeMap; - let text = "あいうえお"; - // Uniform per-cell extent for the test font. - let e = { - let content = make_content_aligned(text, 1000.0, TextAlign::Left); - layout_content(&content, 1000.0).cells[0].extent - }; - // A 2.5-cell budget wraps into three columns: {0,1}, {2,3}, {4}. The - // first two break with 0.5e of slack; the last column is one cell. - let budget = e * 2.5; - let content = make_content_aligned(text, budget, TextAlign::Justify); - let layout = layout_content(&content, budget); - - let mut by_col: BTreeMap> = BTreeMap::new(); - for c in &layout.cells { - by_col.entry(c.column).or_default().push(c); - } - assert!(by_col.len() >= 2, "text must wrap into multiple columns"); - let last = *by_col.keys().max().unwrap(); - - for (&col, col_cells) in &by_col { - if col != last && col_cells.len() > 1 { - assert!(col_cells[0].top.abs() < 0.01, "col {col} top cell at top"); - let bottom = col_cells.last().unwrap().top + col_cells.last().unwrap().extent; - assert!( - (bottom - budget).abs() < 0.5, - "justified col {col} fills the budget, bottom {bottom} vs {budget}" - ); - } - } - // The last column keeps its natural top (start-aligned, not stretched). - assert!( - by_col[&last][0].top.abs() < 0.01, - "last column is not justified" - ); - } - - // ----------------------------------------------------------------- - // Strokes / shadows / decorations - // ----------------------------------------------------------------- - - use crate::shapes::{Stroke, StrokeStyle}; - - fn make_decorated_content(text: &str, decoration: TextDecoration) -> TextContent { - crate::globals::design_init(); - let mut span = make_span(text); - span.text_decoration = Some(decoration); - let mut content = TextContent::new( - MathRect::from_xywh(0.0, 0.0, 200.0, 1000.0), - GrowType::Fixed, - ); - let mut paragraph = Paragraph::new( - TextAlign::Left, - TextDirection::LTR, - None, - None, - 1.0, - 0.0, - vec![span], - ); - paragraph.set_writing_mode(WritingMode::VerticalRl); - content.add_paragraph(paragraph); - content - } - - #[test] - fn cells_carry_font_size_and_decoration() { - let content = make_decorated_content("あい", TextDecoration::UNDERLINE); - let layout = layout_content(&content, 1000.0); - assert!(!layout.cells.is_empty()); - for cell in &layout.cells { - assert_eq!(cell.font_size, 20.0); - assert_eq!(cell.decoration, Some(TextDecoration::UNDERLINE)); - } - } - - #[test] - fn decoration_bar_underline_left_of_center_line_through_centered() { - let content = make_decorated_content("あい", TextDecoration::UNDERLINE); - let layout = layout_content(&content, 1000.0); - let (ox, oy) = layout.origin(&content.bounds(), VerticalAlign::Top); - let cell = &layout.cells[0]; - let column = &layout.columns[cell.column]; - let x_center = ox + column.x + column.width / 2.0; - - let underline = decoration_bar(&layout, cell, ox, oy, false); - let strike = decoration_bar(&layout, cell, ox, oy, true); - - // Underline sits left of the column axis; line-through is centered. - assert!(underline.center_x() < x_center); - assert!((strike.center_x() - x_center).abs() < 0.01); - // Both bars span the cell's vertical extent. - assert!((underline.height() - cell.extent).abs() < 0.01); - // Bar thickness is at least 1px. - assert!(underline.width() >= 1.0 - 0.01); - } - - #[test] - fn paint_passes_do_not_panic() { - let content = make_decorated_content("あいAB。", TextDecoration::LINE_THROUGH); - let layout = layout_content(&content, 1000.0); - let bounds = content.bounds(); - let selrect = content.bounds(); - - let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); - let canvas = surface.canvas(); - - // Shape transforms are applied on the caller's canvas. Exercise a - // non-trivial transform with mixed upright/rotated cells so every - // vertical paint pass remains transform-safe. - canvas.translate((12.0, 8.0)); - canvas.rotate(7.0, Some((64.0, 64.0).into())); - - paint_layout(canvas, &layout, &bounds, VerticalAlign::Top); - paint_drop_shadow( - canvas, - &layout, - &bounds, - VerticalAlign::Top, - &Paint::default(), - ); - - // With a real drop-shadow image filter (as `drop_shadow_paints` builds). - let mut shadow_paint = Paint::default(); - shadow_paint.set_image_filter(skia::image_filters::drop_shadow( - (12.0, 12.0), - (6.0, 6.0), - skia::Color::from_argb(230, 0, 0, 255), - None, - None, - None, - )); - paint_drop_shadow(canvas, &layout, &bounds, VerticalAlign::Top, &shadow_paint); - - for kind in [ - Stroke::new_center_stroke(3.0, StrokeStyle::Solid, None, None, None, None), - Stroke::new_inner_stroke(3.0, StrokeStyle::Solid, None, None, None, None), - Stroke::new_outer_stroke(3.0, StrokeStyle::Solid, None, None, None, None), - ] { - paint_stroke( - canvas, - &layout, - &bounds, - VerticalAlign::Top, - &kind, - &selrect, - None, - ); - } - } -} diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs new file mode 100644 index 0000000000..8395b091a1 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -0,0 +1,1026 @@ +// Ruby (furigana) and emphasis marks (圏点) in vertical flow. Annotations +// stay out of `cells`, so base metrics, caret geometry and position data of +// the base text never see them; they are placed from the base cells' final +// columns and flow extents. + +use skia_safe::{self as skia, Font}; + +use crate::shapes::text_japanese::{emphasis_char_allowed, EMPHASIS_FONT_SCALE}; +use crate::shapes::{ + AppliedTextTransform, FontFeatures, Paragraph, RubyAlign, RubyOverhang, RubySide, TextContent, +}; + +use super::cells::Fonts; +use super::flow::FlowCell; +use super::layout::{column_base_center, CellKind, VerticalCell, VerticalColumn}; +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. +#[derive(Debug, Clone, Copy)] +pub struct RubyGlyph { + pub run: usize, + pub glyph: usize, + pub utf16_start: usize, + pub utf16_end: usize, +} + +/// A ruby annotation placed alongside one column of base characters. +/// Painted from `ruby_runs`; kept out of `cells` so base metrics and caret +/// geometry are unaffected. +#[derive(Debug, Clone)] +pub struct RubyCell { + /// Ordered glyphs for this base column. Each retains the shaped run that + /// supplied its font so fallback boundaries do not drop ruby content. + pub glyphs: Vec, + pub paragraph: usize, + pub span: usize, + pub column: usize, + /// Flow-axis (top, extent) of each base character this ruby annotates, + /// in flow order, restricted to the annotated column. Group ruby spreads + /// the annotation over the union; mono ruby maps ruby glyphs onto the + /// individual segments. + pub base_segments: Vec<(f32, f32)>, + /// Final flow-axis positions computed from the explicit ruby mapping. + pub glyph_tops: Vec, + pub font_size: f32, + pub base_font_size: f32, + pub side: RubySide, + pub paint: usize, +} + +/// 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. +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, + pub font_size: f32, + pub outside_offset: f32, +} + +/// Return the proportional item range belonging to a contiguous slice of the +/// base text. This keeps a ruby reading monotonic when its base wraps across +/// columns while ensuring the final column receives any rounding remainder. +fn proportional_range( + item_count: usize, + base_start: usize, + base_count: usize, + total_base_count: usize, +) -> std::ops::Range { + if item_count == 0 || total_base_count == 0 { + return 0..0; + } + let start = base_start.saturating_mul(item_count) / total_base_count; + let end_base = base_start.saturating_add(base_count).min(total_base_count); + let end = if end_base == total_base_count { + item_count + } else { + end_base.saturating_mul(item_count) / total_base_count + }; + start.min(item_count)..end.min(item_count) +} + +/// Distribute `count` ruby glyphs of the given `advance` along a single base +/// segment `[seg_top, seg_top + seg_extent)`, returning each glyph's flow-axis +/// top. Two jlreq regimes: +/// +/// - Ruby no longer than the base (`Lr <= seg_extent`): even distribution +/// (均等割り付け). Each glyph gets an equal slot `seg_extent / count` and is +/// centred in its slot, which yields equal inter-glyph gaps and half gaps at +/// both ends. +/// - Ruby longer than the base (`Lr > seg_extent`): the glyphs are packed at +/// their own advance and the block is centred on the base, overhanging both +/// ends symmetrically (オーバーハング). The per-end overhang is capped at one +/// ruby em so a long annotation cannot swallow its neighbours' cells. +pub(crate) fn distribute_ruby_tops( + seg_top: f32, + seg_extent: f32, + count: usize, + advance: f32, + align: RubyAlign, + overhang_policy: RubyOverhang, +) -> Vec { + if count == 0 { + return Vec::new(); + } + let line = advance * count as f32; + if line <= seg_extent { + match align { + RubyAlign::SpaceAround => { + let slot = seg_extent / count as f32; + (0..count) + .map(|i| seg_top + slot * (i as f32 + 0.5) - advance / 2.0) + .collect() + } + RubyAlign::Center => { + let start = seg_top + (seg_extent - line) / 2.0; + (0..count).map(|i| start + advance * i as f32).collect() + } + RubyAlign::Start => (0..count).map(|i| seg_top + advance * i as f32).collect(), + RubyAlign::SpaceBetween if count > 1 => { + let gap = (seg_extent - line) / (count - 1) as f32; + (0..count) + .map(|i| seg_top + (advance + gap) * i as f32) + .collect() + } + RubyAlign::SpaceBetween => vec![seg_top + (seg_extent - advance) / 2.0], + } + } else { + let overflow = line - seg_extent; + let overhang = if overhang_policy == RubyOverhang::Auto { + (overflow / 2.0).min(advance) + } else { + 0.0 + }; + let start = seg_top - overhang; + (0..count).map(|i| start + advance * i as f32).collect() + } +} + +/// Cross-axis start of a vertical ruby strip. Line height controls the column +/// advance (`base_width`), but must not become spacing between an annotation +/// and its base glyph. Centre the base em in that advance and attach ruby to +/// the em edge; any extra leading remains outside the base+ruby group. +pub(super) fn ruby_strip_x( + column: &VerticalColumn, + ruby_font_size: f32, + base_font_size: f32, + side: RubySide, +) -> f32 { + let base_center = column_base_center(column); + match side { + RubySide::Over => base_center + base_font_size / 2.0, + RubySide::Under => base_center - base_font_size / 2.0 - ruby_font_size, + } +} + +/// True when the UTF-16 range contains an emphasis-eligible character. +fn utf16_range_allows_emphasis(text: &str, start: usize, end: usize) -> bool { + let mut offset = 0; + for c in text.chars() { + if offset >= end { + break; + } + let len = c.len_utf16(); + if offset + len > start && emphasis_char_allowed(c) { + return true; + } + offset += len; + } + false +} + +/// The base text of one ruby span: the transformed UTF-16 range it covers in +/// its paragraph and the length of its reading. +#[derive(Debug, Clone, Copy)] +pub(super) struct RubyBaseUnit { + span: usize, + start: usize, + end: usize, + ruby_len: usize, + ruby_font_size: f32, + overhang: RubyOverhang, +} + +impl RubyBaseUnit { + fn annotates(&self, cell: &VerticalCell) -> bool { + cell.span == self.span && cell.start < self.end && cell.end > self.start + } + + /// Flow length of the reading set solid at the ruby font size. + fn ruby_line(&self, ruby_len: usize) -> f32 { + self.ruby_font_size * ruby_len as f32 + } +} + +/// Ruby base units of a paragraph, from its spans' layout text. +pub(super) fn ruby_base_units( + paragraph: &Paragraph, + transforms: &[AppliedTextTransform], + span_starts: &[usize], +) -> Vec { + paragraph + .children() + .iter() + .zip(transforms) + .enumerate() + .filter(|(_, (span, transform))| span.has_ruby() && !transform.text.is_empty()) + .map(|(span_index, (span, transform))| RubyBaseUnit { + span: span_index, + start: span_starts[span_index], + end: span_starts[span_index] + transform.text.encode_utf16().count(), + ruby_len: span.ruby_text().chars().count(), + ruby_font_size: span.ruby_font_size(), + overhang: span.ruby_overhang, + }) + .collect() +} + +/// Long ruby with no slack: grow the base span's flow extent *before* +/// column planning so the wrap itself makes room (forced spreading). The +/// growth becomes inter-character gaps, so only the cells before the last +/// one grow. Single-character bases keep the capped-overhang behaviour of +/// `spread_ruby_base_cells`, unless overhang is prohibited. +pub(super) fn grow_ruby_bases(flow: &mut [FlowCell], ruby_units: &[RubyBaseUnit]) { + for unit in ruby_units { + let indices: Vec = (0..flow.len()) + .filter(|index| unit.annotates(&flow[*index].cell)) + .collect(); + let Some((last, growing)) = indices.split_last() else { + continue; + }; + let base_total: f32 = indices.iter().map(|index| flow[*index].cell.extent).sum(); + let deficit = unit.ruby_line(unit.ruby_len) - base_total; + if deficit <= 0.0 { + continue; + } + if growing.is_empty() { + if unit.overhang == RubyOverhang::None { + flow[*last].cell.extent += deficit; + } + continue; + } + let gap = deficit / growing.len() as f32; + for index in growing { + flow[*index].cell.extent += gap; + } + } +} + +/// Expand gaps between already-placed base cells for long ruby annotations. +/// +/// This is deliberately post-placement and bounded: it only shifts later base +/// cells in the same span/column, and only into slack before the next cell (or +/// the column bottom). It avoids re-wrapping columns while making the base span +/// long enough for common long compound-word readings when there is room. +pub(super) fn spread_ruby_base_cells( + cells: &mut [VerticalCell], + ruby_units: &[RubyBaseUnit], + max_height: f32, +) { + let column_limit = if max_height.is_finite() && max_height < f32::MAX { + max_height + } else { + f32::MAX + }; + for unit in ruby_units { + let mut columns: Vec = cells + .iter() + .filter(|cell| unit.annotates(cell)) + .map(|cell| cell.column) + .collect(); + let total_base_count = columns.len(); + columns.sort_unstable(); + columns.dedup(); + + let mut base_start = 0usize; + for column in columns { + let mut group: Vec = (0..cells.len()) + .filter(|index| cells[*index].column == column && unit.annotates(&cells[*index])) + .collect(); + let group_start = base_start; + base_start += group.len(); + if group.len() < 2 { + continue; + } + group.sort_by(|a, b| cells[*a].top.total_cmp(&cells[*b].top)); + + let ruby_range = + proportional_range(unit.ruby_len, group_start, group.len(), total_base_count); + let ruby_line = unit.ruby_line(ruby_range.len()); + let first = group[0]; + let last = group[group.len() - 1]; + let base_top = cells[first].top; + let base_bottom = cells[last].top + cells[last].extent; + let base_extent = base_bottom - base_top; + if ruby_line <= base_extent { + continue; + } + + let next_top = cells + .iter() + .enumerate() + .filter(|(index, cell)| { + cell.column == column && !group.contains(index) && cell.top >= base_bottom + }) + .map(|(_, cell)| cell.top) + .min_by(f32::total_cmp); + let limit = next_top.unwrap_or(column_limit); + let spread = (ruby_line - base_extent).min((limit - base_bottom).max(0.0)); + if spread <= 0.0 { + continue; + } + + let gap = spread / (group.len() - 1) as f32; + for (position, index) in group.iter().enumerate().skip(1) { + cells[*index].top += gap * position as f32; + } + } + } +} + +/// The base cells of one ruby span inside one column. +struct RubyBaseColumn { + column: usize, + /// Paint of the column's first base cell. + paint: usize, + /// Flow-axis (top, extent) of each base cell, sorted along the column. + segments: Vec<(f32, f32)>, +} + +/// Base cells of one span grouped by column, in flow (column index) order. +fn ruby_base_columns(cells: &[VerticalCell], paragraph: usize, span: usize) -> Vec { + let mut columns: Vec = Vec::new(); + for cell in cells + .iter() + .filter(|cell| cell.paragraph == paragraph && cell.span == span) + { + let segment = (cell.top, cell.extent); + match columns.iter_mut().find(|base| base.column == cell.column) { + Some(base) => base.segments.push(segment), + None => columns.push(RubyBaseColumn { + column: cell.column, + paint: cell.paint, + segments: vec![segment], + }), + } + } + columns.sort_by_key(|base| base.column); + for base in columns.iter_mut() { + base.segments.sort_by(|a, b| a.0.total_cmp(&b.0)); + } + columns +} + +/// One `RubyGlyph` per shaped glyph of `ruby_text`, with UTF-16 offsets into +/// the span's untrimmed ruby string (`leading_utf16` is the trimmed prefix). +/// `run_start` is the index the first shaped run will take in `ruby_runs`. +fn ruby_glyphs( + ruby_text: &str, + leading_utf16: usize, + shaped: &[ShapedRun], + run_start: usize, +) -> Vec { + let mut glyphs: Vec = shaped + .iter() + .enumerate() + .flat_map(|(run_index, run)| { + run.clusters + .iter() + .enumerate() + .map(move |(glyph, cluster)| { + let utf8 = (*cluster as usize).min(ruby_text.len()); + RubyGlyph { + run: run_start + run_index, + glyph, + utf16_start: leading_utf16 + ruby_text[..utf8].encode_utf16().count(), + utf16_end: 0, + } + }) + }) + .collect(); + let ruby_end = leading_utf16 + ruby_text.encode_utf16().count(); + for index in 0..glyphs.len() { + glyphs[index].utf16_end = glyphs + .get(index + 1) + .map_or(ruby_end, |next| next.utf16_start); + } + glyphs +} + +/// Ruby (furigana) placement. Runs after column placement because a ruby +/// annotation's strip is positioned from its base characters' final column +/// and flow extent. A reading whose base wraps is partitioned across the +/// columns in proportion to their base characters. +pub(super) fn layout_ruby( + text_content: &TextContent, + cells: &[VerticalCell], + fonts: &Fonts, +) -> (Vec, Vec) { + let mut ruby_runs: Vec = Vec::new(); + let mut ruby_cells: Vec = Vec::new(); + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + for (span_index, span) in paragraph.children().iter().enumerate() { + let ruby_text = span.ruby_text(); + if ruby_text.is_empty() { + continue; + } + let base_columns = ruby_base_columns(cells, paragraph_index, span_index); + if base_columns.is_empty() { + continue; + } + let ruby_font_size = span.ruby_font_size(); + let shaped = shape_segment_with_fallbacks( + ruby_text, + ruby_font_size, + &span_font_families(span, fonts.fallback_families), + fonts.provider, + true, + span.font_features, + fonts.fallback_mgr, + ); + let leading_utf16 = span.ruby[..span.ruby.len() - span.ruby.trim_start().len()] + .encode_utf16() + .count(); + let glyphs = ruby_glyphs(ruby_text, leading_utf16, &shaped, ruby_runs.len()); + if glyphs.is_empty() { + continue; + } + ruby_runs.extend(shaped); + + let total_base_count: usize = base_columns.iter().map(|base| base.segments.len()).sum(); + let mut base_start = 0usize; + for RubyBaseColumn { + column, + paint, + segments: base_segments, + } in base_columns + { + let glyph_range = proportional_range( + glyphs.len(), + base_start, + base_segments.len(), + total_base_count, + ); + base_start += base_segments.len(); + let column_glyphs = glyphs[glyph_range].to_vec(); + if column_glyphs.is_empty() { + continue; + } + let top = base_segments[0].0; + let (last_top, last_extent) = base_segments[base_segments.len() - 1]; + let glyph_tops = distribute_ruby_tops( + top, + (last_top + last_extent - top).max(0.0), + column_glyphs.len(), + ruby_font_size, + span.ruby_align, + span.ruby_overhang, + ); + ruby_cells.push(RubyCell { + glyphs: column_glyphs, + paragraph: paragraph_index, + span: span_index, + column, + base_segments, + glyph_tops, + font_size: ruby_font_size, + base_font_size: span.font_size, + side: span.ruby_side, + paint, + }); + } + } + } + (ruby_runs, ruby_cells) +} + +/// Emphasis marks (圏点 / bouten): one mark glyph per upright base cell of +/// every span that carries `text_emphasis`, drawn beside the cell in the +/// right-side gutter. The mark is shaped once per span. Whitespace and +/// Japanese punctuation cells get no mark (CSS `text-emphasis` behaviour). +pub(super) fn layout_emphasis( + text_content: &TextContent, + cells: &[VerticalCell], + span_utf16_starts: &[Vec], + fonts: &Fonts, +) -> (Vec, Vec) { + let mut emphasis_runs: Vec = Vec::new(); + let mut emphasis_marks: Vec = Vec::new(); + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + for (span_index, span) in paragraph.children().iter().enumerate() { + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let mark_font_size = span.font_size * EMPHASIS_FONT_SCALE; + let families = span_font_families(span, fonts.fallback_families); + let match_family = |family: &str| { + fonts + .provider + .match_family_style(family, skia::FontStyle::default()) + }; + let typeface = families + .iter() + .filter_map(|family| match_family(family)) + .find(|typeface| typeface.unichar_to_glyph(mark as i32) != 0) + .or_else(|| match_family(&families[0])); + let Some(typeface) = typeface else { + continue; + }; + let mut shaped = shape_segment( + &mark.to_string(), + &Font::new(typeface, mark_font_size), + true, + FontFeatures::None, + fonts.fallback_mgr.clone(), + ); + if shaped.first().is_none_or(|run| run.glyphs.is_empty()) { + continue; + } + 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 { + if cell.paragraph != paragraph_index + || cell.span != span_index + || !matches!( + cell.kind, + CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } + ) + || !utf16_range_allows_emphasis( + &span.text, + cell.start - span_start, + cell.end - span_start, + ) + { + continue; + } + emphasis_marks.push(EmphasisMark { + run: run_index, + column: cell.column, + top: cell.top, + extent: cell.extent, + paint: cell.paint, + font_size: mark_font_size, + outside_offset: span.emphasis_ruby_offset(), + }); + } + } + } + (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 { + 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 +} + +#[cfg(test)] +mod tests { + use super::super::layout::VerticalLayout; + use super::super::positions::{position_data, DIRECTION_VERTICAL_RUBY}; + use super::super::test_support::*; + use super::*; + use crate::shapes::{AnnotationClearance, GrowType, RubySize}; + use crate::shapes::{TextAlign, TextEmphasis, TextOrientation, TextSpan, VerticalAlign}; + + fn emphasis_content(base: &str, emphasis: TextEmphasis) -> TextContent { + spans_content( + vec![TextSpan { + text_emphasis: emphasis, + text_orientation: TextOrientation::Upright, + ..make_span(base) + }], + 400.0, + ) + } + + fn ruby_content_with_line_height(line_height: f32) -> TextContent { + let span = TextSpan { + ruby: "ab".to_string(), + ..make_span("AB") + }; + content_of( + vec![vertical_paragraph(vec![span], TextAlign::Left, line_height)], + 400.0, + GrowType::Fixed, + ) + } + + #[test] + fn emphasis_span_reserves_gutter_and_emits_one_mark_per_upright_cell() { + let content = emphasis_content("AB", TextEmphasis::FilledDot); + let layout = layout_content(&content, 400.0); + let upright = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::Upright { .. })) + .count(); + assert_eq!(upright, 2, "AB upright yields two base cells"); + assert_eq!( + layout.emphasis_marks.len(), + upright, + "one emphasis mark per upright base cell" + ); + for column in &layout.columns { + assert!( + column.width > column.base_width, + "an emphasis paragraph reserves a gutter beside the base band" + ); + } + let mark = &layout.emphasis_marks[0]; + assert!( + !layout.emphasis_runs[mark.run].glyphs.is_empty(), + "the emphasis mark must be shaped" + ); + } + + #[test] + fn emphasis_skips_whitespace_cells() { + let content = emphasis_content("A B", TextEmphasis::FilledDot); + let layout = layout_content(&content, 400.0); + let upright = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::Upright { .. })) + .count(); + assert_eq!(upright, 3, "'A B' upright yields three base cells"); + assert_eq!( + layout.emphasis_marks.len(), + 2, + "the whitespace cell gets no emphasis mark" + ); + } + + #[test] + fn emphasis_skips_japanese_commas_stops_and_brackets() { + let content = emphasis_content("A、。(B)」", TextEmphasis::FilledDot); + let layout = layout_content(&content, 400.0); + + assert_eq!( + layout.emphasis_marks.len(), + 2, + "only A and B receive emphasis marks" + ); + } + + #[test] + fn no_emphasis_emits_no_marks() { + let content = make_content(&["AB"], 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout.emphasis_marks.is_empty()); + assert!(layout.emphasis_runs.is_empty()); + } + + #[test] + fn ruby_span_reserves_gutter_and_emits_ruby_cells() { + let content = ruby_content("AB", "ab", 400.0); + let layout = layout_content(&content, 400.0); + assert!( + !layout.ruby_cells.is_empty(), + "a span with ruby must emit ruby cells" + ); + for column in &layout.columns { + assert!( + column.width > column.base_width, + "a ruby paragraph reserves a gutter beside the base band" + ); + } + let ruby = &layout.ruby_cells[0]; + assert!( + ruby.glyphs.iter().all(|glyph| layout + .ruby_runs + .get(glyph.run) + .and_then(|run| run.glyphs.get(glyph.glyph)) + .is_some()), + "every ruby glyph must retain its shaped run" + ); + } + + #[test] + fn ruby_size_and_side_control_gutter_geometry() { + let mut content = ruby_content("AB", "ab", 400.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.ruby_size = RubySize::Quarter; + span.ruby_side = RubySide::Under; + + let layout = layout_content(&content, 400.0); + let column = &layout.columns[0]; + let ruby = &layout.ruby_cells[0]; + + assert!((ruby.font_size - 5.0).abs() < 0.001); + assert!((column.base_offset - 5.0).abs() < 0.001); + assert!((column.width - column.base_width - 5.0).abs() < 0.001); + assert_eq!(ruby.side, RubySide::Under); + } + + #[test] + fn auto_clearance_stacks_ruby_and_emphasis_gutters() { + let mut legacy_content = ruby_content("漢字", "かんじ", 400.0); + legacy_content.paragraphs_mut()[0].children_mut()[0].text_emphasis = + TextEmphasis::FilledDot; + let legacy = layout_content(&legacy_content, 400.0); + + let mut auto_content = legacy_content.clone(); + auto_content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = + AnnotationClearance::Auto; + let auto = layout_content(&auto_content, 400.0); + + let legacy_gutter = legacy.columns[0].width - legacy.columns[0].base_width; + let auto_gutter = auto.columns[0].width - auto.columns[0].base_width; + assert!((legacy_gutter - 10.0).abs() < 0.001); + assert!((auto_gutter - 20.0).abs() < 0.001); + assert!(auto.emphasis_marks[0].outside_offset > 0.0); + } + + #[test] + fn ruby_attachment_distance_is_independent_of_line_height() { + let compact_content = ruby_content_with_line_height(1.0); + let loose_content = ruby_content_with_line_height(2.0); + let compact = layout_content(&compact_content, 400.0); + let loose = layout_content(&loose_content, 400.0); + + let attachment_gap = |layout: &VerticalLayout| { + let ruby = &layout.ruby_cells[0]; + let column = &layout.columns[ruby.column]; + let base_right = column_base_center(column) + ruby.base_font_size / 2.0; + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) - base_right + }; + + assert!( + loose.columns[0].base_width > compact.columns[0].base_width, + "line height must still increase column progression" + ); + assert!(attachment_gap(&compact).abs() < 0.001); + assert!(attachment_gap(&loose).abs() < 0.001); + + let loose_positions = position_data(&loose, &loose_content.bounds(), VerticalAlign::Top); + let ruby_position = loose_positions + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) + .expect("ruby position data"); + assert!( + (ruby_position.width - loose.ruby_cells[0].font_size).abs() < 0.001, + "export geometry must use the attached ruby strip, not the line-height band" + ); + } + + #[test] + fn vertical_ruby_preserves_every_fallback_run() { + let content = ruby_content("AB", "aあ", 400.0); + let provider = provider_with_fallback(TEST_FONT, VMTX_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]); + + let referenced_runs: std::collections::BTreeSet = layout + .ruby_cells + .iter() + .flat_map(|cell| cell.glyphs.iter().map(|glyph| glyph.run)) + .collect(); + assert_eq!(referenced_runs.len(), 2, "ruby must retain both typefaces"); + assert_eq!( + layout + .ruby_cells + .iter() + .map(|cell| cell.glyphs.len()) + .sum::(), + layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), + "every fallback glyph must be assigned to a ruby cell" + ); + + let position = position_data(&layout, &content.bounds(), VerticalAlign::Top); + let ruby_position = position + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) + .expect("ruby position data"); + assert_eq!(ruby_position.start_pos, 0); + assert_eq!(ruby_position.end_pos, 2); + } + + #[test] + fn no_ruby_keeps_base_width_equal_to_width() { + let content = make_content(&["AB"], 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout.ruby_cells.is_empty()); + for column in &layout.columns { + assert_eq!( + column.width, column.base_width, + "columns without ruby must not reserve a gutter" + ); + } + } + + #[test] + fn ruby_shorter_than_base_distributes_evenly() { + // One base char (extent 100) with 2 ruby glyphs at advance 50: the + // combined ruby line (100) equals the base, so glyphs sit in equal + // slots of 50, each centred (slot/2 - advance/2 = 0 offset). + let tops = distribute_ruby_tops( + 0.0, + 100.0, + 2, + 50.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ); + assert_eq!(tops.len(), 2); + assert!( + (tops[0] - 0.0).abs() < 0.001, + "first ruby glyph at slot start" + ); + assert!( + (tops[1] - 50.0).abs() < 0.001, + "second ruby glyph one slot down" + ); + + // A wide base (extent 200) with 2 glyphs of advance 50 must spread out + // (slot 100) rather than pack tight at advance 50. + let spread = distribute_ruby_tops( + 0.0, + 200.0, + 2, + 50.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ); + assert!( + (spread[0] - 25.0).abs() < 0.001, + "ruby glyph centred in its slot" + ); + assert!( + (spread[1] - spread[0] - 100.0).abs() < 0.001, + "even distribution keeps a full slot between glyphs, not the advance" + ); + } + + #[test] + fn ruby_longer_than_base_overhangs_symmetrically() { + // Base extent 40, four ruby glyphs of advance 20 (line 80 > 40): the + // block centres on the base and overhangs both ends. Overflow is 40, + // per-end overhang 20 (capped at one ruby em = 20), so it starts 20 + // above the base top. + let tops = distribute_ruby_tops( + 0.0, + 40.0, + 4, + 20.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ); + assert_eq!(tops.len(), 4); + assert!(tops[0] < 0.0, "long ruby overhangs above the base top"); + let block_center = (tops[0] + tops[3] + 20.0) / 2.0; + assert!( + (block_center - 20.0).abs() < 0.001, + "the ruby block stays centred on the base" + ); + } + + #[test] + fn ruby_alignment_modes_control_short_annotation_distribution() { + assert_eq!( + distribute_ruby_tops( + 0.0, + 20.0, + 2, + 4.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ), + vec![3.0, 13.0] + ); + assert_eq!( + distribute_ruby_tops(0.0, 20.0, 2, 4.0, RubyAlign::Center, RubyOverhang::Auto,), + vec![6.0, 10.0] + ); + assert_eq!( + distribute_ruby_tops(0.0, 20.0, 2, 4.0, RubyAlign::Start, RubyOverhang::Auto,), + vec![0.0, 4.0] + ); + assert_eq!( + distribute_ruby_tops( + 0.0, + 20.0, + 2, + 4.0, + RubyAlign::SpaceBetween, + RubyOverhang::Auto, + ), + vec![0.0, 16.0] + ); + } + + #[test] + fn ruby_overhang_none_keeps_long_annotation_at_base_start() { + let automatic = + distribute_ruby_tops(0.0, 10.0, 4, 4.0, RubyAlign::Center, RubyOverhang::Auto); + let constrained = + distribute_ruby_tops(0.0, 10.0, 4, 4.0, RubyAlign::Center, RubyOverhang::None); + + assert_eq!(automatic, vec![-3.0, 1.0, 5.0, 9.0]); + assert_eq!(constrained, vec![0.0, 4.0, 8.0, 12.0]); + } + + #[test] + fn ruby_side_attaches_to_opposite_base_edges() { + let column = VerticalColumn { + x: 10.0, + width: 40.0, + base_offset: 8.0, + base_width: 24.0, + }; + + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Over), 40.0); + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Under), 12.0); + } + + #[test] + fn ruby_base_spreading_expands_gap_for_long_reading() { + let content = ruby_content("日本", "にほんご", 120.0); + let layout = layout_content(&content, 120.0); + let ruby_cells: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell.span == 0 && cell.column == 0) + .collect(); + assert_eq!(ruby_cells.len(), 2); + let spacing = ruby_cells[1].top - ruby_cells[0].top; + assert!( + spacing > 20.0, + "long ruby should spread the base characters apart, got {}", + spacing + ); + let base_extent = ruby_cells[1].top + ruby_cells[1].extent - ruby_cells[0].top; + assert!( + base_extent >= 40.0, + "base span should be at least as long as the 4-glyph half-em ruby line" + ); + } + + #[test] + fn ruby_base_spreading_does_not_overlap_following_text() { + let content = make_content_with_spans(&["日本", "語"], 60.0); + let mut content = content; + content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんご".to_string(); + let layout = layout_content(&content, 60.0); + let mut same_column: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell.column == 0) + .collect(); + same_column.sort_by(|a, b| { + a.top + .partial_cmp(&b.top) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for pair in same_column.windows(2) { + assert!( + pair[0].top + pair[0].extent <= pair[1].top + 0.001, + "base spreading must not overlap the following cell" + ); + } + } + + #[test] + fn ruby_base_spreading_forces_room_when_no_slack() { + // Base 日本 (2 em = 40) with a 6-glyph half-em reading (60) followed + // by 語 in a 60 budget: there is no post-placement slack, so the base + // extents must grow before planning and push the follower to the next + // column instead of falling back to overhang. + let mut content = make_content_with_spans(&["日本", "語"], 60.0); + content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんごです".to_string(); + let layout = layout_content(&content, 60.0); + let base: Vec<&VerticalCell> = layout.cells.iter().filter(|c| c.span == 0).collect(); + assert_eq!(base.len(), 2); + let base_extent = + base.last().unwrap().top + base.last().unwrap().extent - base.first().unwrap().top; + assert!( + base_extent >= 60.0 - 0.001, + "the base span must grow to the ruby line length, got {}", + base_extent + ); + let follower = layout + .cells + .iter() + .find(|c| c.span == 1) + .expect("follower cell"); + assert_ne!( + follower.column, base[0].column, + "the follower must wrap to the next column instead of overlapping" + ); + } + + #[test] + fn ruby_base_and_reading_wrap_across_columns() { + let content = ruby_content("日本語文", "にほんごぶん", 40.0); + let layout = layout_content(&content, 40.0); + + let base_columns: std::collections::BTreeSet = + layout.cells.iter().map(|c| c.column).collect(); + assert_eq!(base_columns.len(), 2, "the ruby base must wrap normally"); + + let ruby_columns: std::collections::BTreeSet = + layout.ruby_cells.iter().map(|r| r.column).collect(); + assert_eq!(ruby_columns, base_columns); + assert!(!layout.ruby_cells.is_empty()); + let placed: usize = layout.ruby_cells.iter().map(|r| r.glyphs.len()).sum(); + assert_eq!( + placed, + layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), + "the reading is partitioned across columns without duplication or loss" + ); + assert!( + layout + .ruby_cells + .windows(2) + .all(|pair| pair[0].glyphs.last().unwrap().utf16_end + <= pair[1].glyphs.first().unwrap().utf16_start), + "ruby source ranges remain monotonic across the column break" + ); + } +} diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs new file mode 100644 index 0000000000..8ce1f2c318 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -0,0 +1,1032 @@ +// Builders that turn one span's text into flow cells: one per upright glyph +// cluster, one per rotated (sideways) run, and one per tate-chu-yoko or +// warichu composite. + +use skia_safe::{textlayout::TypefaceFontProvider, FontMgr}; + +use crate::shapes::japanese::{classify, JapaneseClass}; +use crate::shapes::text_japanese::{warichu_text_lines, WARICHU_FONT_SCALE}; +use crate::shapes::{FontFeatures, TextCombineUpright, TextSpan}; + +use super::flow::{minimum_oikomi_extent, FlowCell, FlowScript}; +use super::font_tables::{upright_baseline, vertical_metrics, vpal_table}; +use super::layout::{CellKind, VerticalCell}; +use super::orientation::{ + is_upright_char, segment_by_orientation, uses_rotated_vertical_fallback, Segment, +}; +use super::shaping::{shape_segment_with_fallbacks, span_font_families, ShapedRun}; + +/// Minimum scale for unconstrained `all` tate-chu-yoko. Counted digit modes +/// derive their limit from the eligible run length instead. +const MIN_TCY_SCALE: f32 = 0.5; + +/// Fonts available to the layout: the provider's registered faces and the +/// fallback families tried after each span's own font. +pub(super) struct Fonts<'a> { + pub provider: &'a TypefaceFontProvider, + pub fallback_mgr: &'a FontMgr, + pub fallback_families: &'a [String], +} + +/// Centered punctuation (jlreq class cl-05 中点類): the middle dot, the +/// full-width colon and the full-width semicolon are placed at the centre of +/// the em box in vertical writing rather than on the horizontal baseline. +fn is_centered_punctuation(c: char) -> bool { + classify(c) == JapaneseClass::MiddleDot +} + +/// Flow-axis shift that centres a glyph's ink band within its em body: moves +/// the ink midpoint (`(ink_top + ink_bottom) / 2`) to the body midpoint. +fn centered_flow_shift(ink_top: f32, ink_bottom: f32, em_body: f32) -> f32 { + em_body / 2.0 - (ink_top + ink_bottom) / 2.0 +} + +/// A single-glyph cluster whose font has no vertical alternate for it: the +/// shaped glyph is still the horizontal one. +fn needs_rotated_vertical_fallback(run: &ShapedRun, glyph: usize, count: usize, ch: char) -> bool { + count == 1 + && uses_rotated_vertical_fallback(ch) + && run.glyphs[glyph] == run.font.unichar_to_glyph(ch as i32) +} + +fn is_tcy_digit(c: char) -> bool { + c.is_ascii_digit() || ('0'..='9').contains(&c) +} + +/// Split a TCY `digits` span into pieces: maximal ASCII or full-width digit +/// runs of 2..=max characters become upright composites, everything else +/// keeps the normal vertical layout. Returns (piece text, span-relative +/// UTF-16 start, tcy). +fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> { + let mut pieces: Vec<(String, usize, bool)> = Vec::new(); + let mut utf16 = 0usize; + for c in text.chars() { + let digit = is_tcy_digit(c); + match pieces.last_mut() { + Some((piece, _, piece_digit)) if *piece_digit == digit => piece.push(c), + _ => pieces.push((c.to_string(), utf16, digit)), + } + utf16 += c.len_utf16(); + } + for (piece, _, tcy) in pieces.iter_mut() { + *tcy = *tcy && (2..=max).contains(&piece.chars().count()); + } + pieces +} + +/// UTF-16 offset, within the shaped piece, of a UTF-8 offset in `segment`. +fn segment_utf16(segment: &Segment, utf8: usize) -> usize { + segment.utf16_start + + segment.text[..utf8.min(segment.text.len())] + .chars() + .map(char::len_utf16) + .sum::() +} + +/// Cell builder for one span of a paragraph. +pub(super) struct SpanCells<'a> { + fonts: &'a Fonts<'a>, + span: &'a TextSpan, + paragraph: usize, + span_index: usize, + paint: usize, + /// UTF-16 offset of the span in its paragraph's layout text. + start: usize, + /// The span's own font first, then emoji and the registered fallback + /// fonts. Shaping resolves glyph coverage explicitly and splits runs at + /// typeface boundaries. + families: Vec, +} + +impl<'a> SpanCells<'a> { + pub(super) fn new( + fonts: &'a Fonts<'a>, + span: &'a TextSpan, + paragraph: usize, + span_index: usize, + paint: usize, + start: usize, + ) -> Self { + Self { + fonts, + span, + paragraph, + span_index, + paint, + start, + families: span_font_families(span, fonts.fallback_families), + } + } + + fn shape(&self, text: &str, font_size: f32, upright: bool) -> Vec { + shape_segment_with_fallbacks( + text, + font_size, + &self.families, + self.fonts.provider, + upright, + self.span.font_features, + self.fonts.fallback_mgr, + ) + } + + /// A cell of this span covering `start..end` (paragraph UTF-16), whose + /// ink fills its extent. + fn cell(&self, kind: CellKind, start: usize, end: usize, extent: f32) -> VerticalCell { + VerticalCell { + kind, + paragraph: self.paragraph, + span: self.span_index, + start, + end, + column: 0, + top: 0.0, + extent, + minimum_oikomi_extent: extent, + h_advance: self.span.font_size, + ink_top: 0.0, + ink_bottom: extent - self.span.letter_spacing, + paint: self.paint, + font_size: self.span.font_size, + decoration: self.span.text_decoration, + glyph_flow_shift: 0.0, + } + } + + /// Push the cells of the span's layout `text`. Runs are appended to + /// `runs`, which the cells index. + pub(super) fn push(&self, text: &str, runs: &mut Vec, cells: &mut Vec) { + let combine = self.span.text_combine_upright; + if combine == TextCombineUpright::All + && self.push_tate_chu_yoko(text, 0, MIN_TCY_SCALE, runs, cells) + { + return; + } + if self.span.is_warichu() && self.push_warichu(text, runs, cells) { + return; + } + // `digits` combines runs of 2..=max consecutive ASCII or full-width + // digits (max 4, or 2/3 for the counted variants) into one upright + // composite; the rest of the span flows through the normal + // orientation segmentation. + let pieces = match combine.digits_max() { + Some(max) => split_digit_runs(text, max), + None => vec![(text.to_string(), 0, false)], + }; + for (piece, piece_start, tcy) in &pieces { + let min_scale = 1.0 / piece.chars().count().max(1) as f32; + if *tcy && self.push_tate_chu_yoko(piece, *piece_start, min_scale, runs, cells) { + continue; + } + let piece_base = self.start + piece_start; + for segment in segment_by_orientation(piece, self.span.text_orientation) { + for mut run in self.shape(&segment.text, self.span.font_size, segment.upright) { + let run_index = runs.len(); + if segment.upright { + self.push_upright_clusters(&segment, piece_base, &run, run_index, cells); + } else { + cells.push(self.rotated_cell(&segment, piece_base, &mut run, run_index)); + } + runs.push(run); + } + } + } + } + + /// Compose `piece` (a whole TCY span or a digit run inside one) into one + /// upright composite cell, shaping with the first family that covers the + /// piece and composing any fallback runs side by side. Returns false when + /// the composite would compress below `min_scale`; the caller then falls + /// back to the normal vertical segmentation. + fn push_tate_chu_yoko( + &self, + piece: &str, + piece_start: usize, + min_scale: f32, + runs: &mut Vec, + cells: &mut Vec, + ) -> bool { + // Ruby and other span-level formatting can split an otherwise + // continuous vertical run into a one-character span while preserving + // an inherited `text-combine-upright: all`. A single CJK character is + // already upright; routing it through the horizontal TCY path can + // scale it down to fit the font's line metrics and make it smaller + // than adjacent base characters. + let mut chars = piece.chars(); + if matches!((chars.next(), chars.next()), (Some(ch), None) if is_upright_char(ch)) { + return false; + } + + let letter_spacing = self.span.letter_spacing; + let mut shaped = self.shape(piece, self.span.font_size, false); + if shaped.is_empty() { + return false; + } + let em = self.span.font_size.max(1.0); + let mut combined_advance = 0.0f32; + let mut run_height = 1.0f32; + for run in &mut shaped { + run.advance = run.spread_glyphs(letter_spacing); + let (_, metrics) = run.font.metrics(); + run_height = run_height.max(metrics.descent - metrics.ascent); + combined_advance += run.advance; + } + // Letter-spacing also separates adjacent fallback runs. + combined_advance += letter_spacing * (shaped.len() - 1) as f32; + let scale = (em / combined_advance.max(1.0)) + .min(em / run_height.max(1.0)) + .min(1.0); + if scale < min_scale { + return false; + } + + let extent = em + letter_spacing; + let kind = CellKind::TateChuYoko { + run_start: runs.len(), + run_count: shaped.len(), + scale, + }; + runs.extend(shaped); + let start = self.start + piece_start; + let end = start + piece.encode_utf16().count(); + let cell = VerticalCell { + h_advance: combined_advance * scale, + ink_bottom: em, + ..self.cell(kind, start, end, extent) + }; + cells.push(FlowCell::new( + cell, + None, + FlowScript::Upright, + letter_spacing, + )); + true + } + + /// Warichu (割注): the span becomes one composite cell holding two + /// half-size sub-lines laid side by side within the column (the first + /// sub-line on the right, jlreq reading order), split by + /// `warichu_split_chars`. Returns false when a sub-line shapes empty. + fn push_warichu( + &self, + text: &str, + runs: &mut Vec, + cells: &mut Vec, + ) -> bool { + let half_size = self.span.font_size * WARICHU_FONT_SCALE; + let (first_text, second_text) = warichu_text_lines(text); + let first_runs = self.shape(first_text, half_size, true); + let second_runs = self.shape(second_text, half_size, true); + if first_runs.is_empty() || second_runs.is_empty() { + return false; + } + let line_extent = |runs: &[ShapedRun]| runs.iter().map(|r| r.advance).sum::(); + let extent = + line_extent(&first_runs).max(line_extent(&second_runs)) + self.span.letter_spacing; + let kind = CellKind::Warichu { + run_start: runs.len(), + run_count: first_runs.len() + second_runs.len(), + first_count: first_runs.len(), + first_chars: first_text.encode_utf16().count(), + }; + runs.extend(first_runs); + runs.extend(second_runs); + // Two half-em sub-columns side by side fill the em, the default + // `h_advance`. + let end = self.start + text.encode_utf16().count(); + let cell = self.cell(kind, self.start, end, extent); + cells.push(FlowCell::new( + cell, + None, + FlowScript::Upright, + self.span.letter_spacing, + )); + true + } + + /// One upright cell per glyph cluster of `run`, so combining sequences + /// stay in one cell. + fn push_upright_clusters( + &self, + segment: &Segment, + piece_base: usize, + run: &ShapedRun, + run_index: usize, + cells: &mut Vec, + ) { + let font_size = self.span.font_size; + let letter_spacing = self.span.letter_spacing; + let vmetrics = vertical_metrics(&run.font); + // vpal deltas apply here in layout, not in the shaper: SkShaper + // shapes on a horizontal line, where vertical GPOS positioning never + // fires. + let vpal = (self.span.font_features == FontFeatures::Vpal) + .then(|| vpal_table(&run.font)) + .flatten(); + + for (glyph, count) in run.cluster_spans() { + let glyphs = &run.glyphs[glyph..glyph + count]; + let cluster_utf8 = run.clusters[glyph] as usize; + let next_cluster_utf8 = run + .clusters + .get(glyph + count) + .map_or(run.utf8_range.end, |c| *c as usize); + let ch = segment.text[cluster_utf8..].chars().next(); + let h_advance = match run.cluster_advance(glyph, count) { + advance if advance > 0.0 => advance, + _ => font_size, + }; + // Flow down the column by the true vertical advance when the font + // has `vmtx`. Without it, fall back to the horizontal advance, + // which is exact for full-width CJK. A character that is upright + // only because `text-orientation: upright` forced it (e.g. a Latin + // letter) is far narrower than an em and would collide with the + // following character, so floor it to a full em, matching the + // browser's upright behavior. + let horizontal_fallback = if ch.is_some_and(|c| !is_upright_char(c)) { + h_advance.max(font_size) + } else { + h_advance + }; + let vertical_advance = vmetrics + .as_ref() + .map(|vm| { + glyphs + .iter() + .map(|g| vm.advance(*g, font_size)) + .sum::() + }) + .filter(|advance| *advance > 0.0) + .unwrap_or(horizontal_fallback); + // vpal: the font's advance delta tightens the cell and its + // placement delta lifts the drawn ink to keep it inside. The + // tightened extent flows into caret, position-data and the aki + // sheds (which skip already-half-width cells). + let vpal_delta = vpal + .as_ref() + .and_then(|table| table.cluster_delta(glyphs, font_size)); + let (vpal_advance, vpal_flow_shift) = vpal_delta.unwrap_or((0.0, 0.0)); + let extent = vertical_advance + vpal_advance + letter_spacing; + + let synthetic_rotation = + ch.is_some_and(|ch| needs_rotated_vertical_fallback(run, glyph, count, ch)); + let ink = run.cluster_ink_bounds(glyph, count).map(|ink| { + if synthetic_rotation { + (ink.left, ink.right) + } else { + let offset = upright_baseline(&run.font, run.font.size()); + (ink.top + offset, ink.bottom + offset) + } + }); + let (ink_top, ink_bottom) = ink.unwrap_or((0.0, extent - letter_spacing)); + let (mut ink_top, mut ink_bottom) = + (ink_top + vpal_flow_shift, ink_bottom + vpal_flow_shift); + // Centre middle-dot / colon / semicolon ink in the em body; the + // shift moves the drawn glyph only. A vpal-covered glyph keeps the + // font's own centring instead. + let glyph_flow_shift = if !synthetic_rotation + && vpal_delta.is_none() + && ch.is_some_and(is_centered_punctuation) + { + let shift = centered_flow_shift(ink_top, ink_bottom, extent - letter_spacing); + ink_top += shift; + ink_bottom += shift; + shift + } else { + vpal_flow_shift + }; + + let kind = if synthetic_rotation { + CellKind::SyntheticRotated { + run: run_index, + glyph, + count, + } + } else { + CellKind::Upright { + run: run_index, + glyph, + count, + } + }; + let start = piece_base + segment_utf16(segment, cluster_utf8); + let end = piece_base + segment_utf16(segment, next_cluster_utf8); + let cell = VerticalCell { + minimum_oikomi_extent: minimum_oikomi_extent(ch, extent, font_size, letter_spacing), + h_advance, + ink_top, + ink_bottom, + glyph_flow_shift, + ..self.cell(kind, start, end, extent) + }; + cells.push(FlowCell::new(cell, ch, FlowScript::Upright, letter_spacing)); + } + } + + /// One cell for a whole sideways Western run. Its glyphs spread down the + /// column by letter-spacing (post-rotation +x runs down the column). + fn rotated_cell( + &self, + segment: &Segment, + piece_base: usize, + run: &mut ShapedRun, + run_index: usize, + ) -> FlowCell { + let letter_spacing = self.span.letter_spacing; + run.normalize_word_spaces(&segment.text, self.span.font_size); + let extent = run.spread_glyphs(letter_spacing); + let run_text = &segment.text[run.utf8_range.clone()]; + let script = FlowScript::Rotated { + starts_alphanumeric: run_text.chars().next().is_some_and(char::is_alphanumeric), + ends_alphanumeric: run_text + .chars() + .next_back() + .is_some_and(char::is_alphanumeric), + }; + let start = piece_base + segment_utf16(segment, run.utf8_range.start); + let end = piece_base + segment_utf16(segment, run.utf8_range.end); + let (ink_top, ink_bottom) = run + .ink_bounds() + .map_or((0.0, extent - letter_spacing), |ink| (ink.left, ink.right)); + let cell = VerticalCell { + // Rotated runs draw from `run.positions`; centring doesn't read + // `h_advance`. + h_advance: run.advance, + ink_top, + ink_bottom, + ..self.cell(CellKind::Rotated { run: run_index }, start, end, extent) + }; + FlowCell::new(cell, None, script, letter_spacing) + } +} + +#[cfg(test)] +mod tests { + use super::super::layout::VerticalCell; + use super::super::shaping::WESTERN_WORD_SPACING_EM; + use super::super::test_support::*; + use super::*; + use crate::shapes::{Fill, SolidColor, TextDecoration, TextOrientation}; + use skia_safe as skia; + + fn vpal_content(text: &str, font_features: FontFeatures) -> crate::shapes::TextContent { + spans_content( + vec![TextSpan { + font_features, + ..make_span(text) + }], + 1000.0, + ) + } + + #[test] + fn missing_vertical_alternate_uses_a_character_granular_rotated_cell() { + let mut content = make_content(&["“"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&provider(TEST_FONT), &content); + assert_eq!(layout.cells.len(), 1); + assert!(matches!( + layout.cells[0].kind, + CellKind::SyntheticRotated { .. } + )); + } + + #[test] + fn upright_narrow_latin_reserves_a_full_em_without_vmtx() { + // Under text-orientation: upright, Latin letters are set upright. The + // Latin test face carries no `vmtx`, so the flow advance must fall back + // to a synthesized em instead of the (much narrower) horizontal advance; + // otherwise the letters pack together and collide with the following + // character. + let em = 20.0; + let mut content = make_content(&["ab"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&provider(TEST_FONT), &content); + assert_eq!(layout.cells.len(), 2, "one upright cell per Latin letter"); + for (index, cell) in layout.cells.iter().enumerate() { + assert!( + matches!(cell.kind, CellKind::Upright { .. }), + "cell {index} should be an upright Latin letter" + ); + assert!( + (cell.extent - em).abs() < 0.5, + "upright Latin cell {index} reserves a full em, got {}", + cell.extent + ); + } + // The letters advance a full em down the column, so they never overlap. + assert!( + layout.cells[1].top - layout.cells[0].top >= em - 0.5, + "successive upright letters are one em apart, got {}", + layout.cells[1].top - layout.cells[0].top + ); + } + + #[test] + fn warichu_span_composes_two_half_size_sub_lines() { + let content = warichu_content("ABCD", 400.0); + let layout = layout_content(&content, 400.0); + assert_eq!(layout.cells.len(), 1, "the whole span is one composite"); + let cell = &layout.cells[0]; + let CellKind::Warichu { + run_count, + first_count, + .. + } = cell.kind + else { + panic!("expected a warichu cell"); + }; + assert!(first_count >= 1 && run_count > first_count); + // Two chars per sub-line at half size: the block's flow extent is + // roughly one em, far below the four em of normal layout. + assert!( + cell.extent < 2.0 * 20.0, + "two half-size sub-lines take about one em, got {}", + cell.extent + ); + assert!( + (cell.h_advance - 20.0).abs() < 0.001, + "the composite fills the full em width" + ); + assert_eq!(cell.start, 0); + assert_eq!(cell.end, 4); + } + + #[test] + fn warichu_single_char_keeps_normal_layout() { + let content = warichu_content("A", 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout + .cells + .iter() + .all(|c| !matches!(c.kind, CellKind::Warichu { .. }))); + } + + #[test] + fn tate_chu_yoko_span_becomes_one_upright_cell() { + let mut content = make_content_with_spans(&["20", "年"], 200.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + + let layout = layout_content(&content, 200.0); + + assert_eq!(layout.cells.len(), 2); + assert!(matches!(layout.cells[0].kind, CellKind::TateChuYoko { .. })); + assert_eq!(layout.cells[0].start, 0); + assert_eq!(layout.cells[0].end, 2); + assert_eq!(layout.cells[0].extent, 20.0); + assert_eq!(layout.cells[1].start, 2); + } + + #[test] + fn tate_chu_yoko_all_keeps_single_upright_ruby_base_at_full_size() { + let mut content = make_content_with_spans(&["く", "くくく"], 400.0); + let spans = content.paragraphs_mut()[0].children_mut(); + spans[0].ruby = "あ".to_string(); + for span in spans { + span.set_text_combine_upright(TextCombineUpright::All); + } + + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let ruby_base = layout + .cells + .iter() + .find(|cell| cell.span == 0) + .expect("ruby base cell"); + let following_base = layout + .cells + .iter() + .find(|cell| cell.span == 1) + .expect("following base cell"); + + assert!(matches!(ruby_base.kind, CellKind::Upright { .. })); + assert!(!layout.ruby_cells.is_empty(), "ruby annotation is present"); + assert_eq!(ruby_base.font_size, following_base.font_size); + assert!(layout + .cells + .iter() + .all(|cell| !matches!(cell.kind, CellKind::TateChuYoko { .. }))); + } + + #[test] + fn tate_chu_yoko_composes_covered_run_to_single_cell() { + // A CJK-covering face keeps the whole marked span in one upright + // composite cell (run_count >= 1); the next span is a separate cell. + let mut content = make_content_with_spans(&["くく", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let CellKind::TateChuYoko { run_count, .. } = layout.cells[0].kind else { + panic!("expected a Tate-chu-yoko cell"); + }; + assert!(run_count >= 1, "composite references at least one run"); + assert_eq!(layout.cells[0].start, 0); + assert_eq!(layout.cells[0].end, 2); + assert_eq!(layout.cells[1].start, 2); + } + + #[test] + fn tate_chu_yoko_wide_run_falls_back_to_normal_layout() { + // A run far wider than the em would compress below MIN_TCY_SCALE; it is + // not combined into a squished composite but laid out normally. + let mut content = make_content_with_spans(&["123456789"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_content(&content, 400.0); + assert!( + !layout + .cells + .iter() + .any(|c| matches!(c.kind, CellKind::TateChuYoko { .. })), + "an over-wide run must not become a tate-chu-yoko composite" + ); + } + + #[test] + fn split_digit_runs_honours_the_max_parameter() { + // max 2: only exactly-two-digit runs combine. + let pieces = split_digit_runs("平成31年123日", 2); + let flags: Vec<(&str, bool)> = pieces + .iter() + .map(|(text, _, tcy)| (text.as_str(), *tcy)) + .collect(); + assert_eq!( + flags, + vec![ + ("平成", false), + ("31", true), + ("年", false), + ("123", false), + ("日", false), + ] + ); + // max 3 admits the three-digit run. + let pieces = split_digit_runs("平成31年123日", 3); + assert!(pieces.iter().any(|(text, _, tcy)| text == "123" && *tcy)); + } + + #[test] + fn split_digit_runs_marks_two_to_four_digit_runs() { + let pieces = split_digit_runs("平成31年12345日5", 4); + let flags: Vec<(&str, usize, bool)> = pieces + .iter() + .map(|(text, start, tcy)| (text.as_str(), *start, *tcy)) + .collect(); + assert_eq!( + flags, + vec![ + ("平成", 0, false), + ("31", 2, true), + ("年", 4, false), + ("12345", 5, false), + ("日", 10, false), + ("5", 11, false), + ] + ); + } + + #[test] + fn split_digit_runs_recognizes_full_width_digits() { + let pieces = split_digit_runs("2026夏号", 4); + let flags: Vec<(&str, bool)> = pieces + .iter() + .map(|(text, _, tcy)| (text.as_str(), *tcy)) + .collect(); + assert_eq!(flags, vec![("2026", true), ("夏号", false)]); + } + + #[test] + fn tate_chu_yoko_digits_combines_only_digit_runs() { + let mut content = make_content_with_spans(&["あ31く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::TateChuYoko { .. })) + .collect(); + assert_eq!(tcy.len(), 1, "exactly the digit run combines"); + assert_eq!(tcy[0].start, 1); + assert_eq!(tcy[0].end, 3); + // The surrounding characters keep their own cells in text order. + let starts: Vec = layout.cells.iter().map(|c| c.start).collect(); + let mut sorted = starts.clone(); + sorted.sort_unstable(); + assert_eq!(starts, sorted, "cells stay in text order"); + assert_eq!(layout.cells.len(), 3); + } + + #[test] + fn tate_chu_yoko_digits_combines_four_ascii_digits() { + let mut content = make_content_with_spans(&["2025年"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("four-digit run combines"); + assert_eq!(tcy.start, 0); + assert_eq!(tcy.end, 4); + } + + #[test] + fn tate_chu_yoko_digits_combines_full_width_unicode_digits() { + let mut content = make_content_with_spans(&["2026年"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("full-width digit run combines"); + assert_eq!(tcy.start, 0); + assert_eq!(tcy.end, 4); + } + + #[test] + fn letter_spacing_extends_upright_cells() { + // Each upright cluster gains `letter_spacing` of flow advance, so + // cells stack further apart down the column; the centring width + // (`h_advance`) is unaffected. + let plain = layout_content(&spaced_content("あい", 0.0), 1000.0); + let spaced = layout_content(&spaced_content("あい", 5.0), 1000.0); + assert_eq!(plain.cells.len(), spaced.cells.len()); + for i in 0..plain.cells.len() { + assert!( + (spaced.cells[i].extent - plain.cells[i].extent - 5.0).abs() < 0.01, + "cell {i} extent grows by letter-spacing" + ); + assert!( + (spaced.cells[i].h_advance - plain.cells[i].h_advance).abs() < 0.01, + "cell {i} centring width is unchanged" + ); + } + assert!( + spaced.cells[1].top - plain.cells[1].top - 5.0 > -0.01, + "the second cell is pushed down by the spacing" + ); + } + + #[test] + fn letter_spacing_spreads_rotated_run() { + // A sideways Latin run grows by `letter_spacing` per glyph and its + // glyphs shift apart along the (post-rotation) column axis. + let plain = layout_content(&spaced_content("AB", 0.0), 1000.0); + let spaced = layout_content(&spaced_content("AB", 5.0), 1000.0); + let plain_cell = plain + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .expect("a rotated cell"); + let spaced_cell = spaced + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .expect("a rotated cell"); + let (CellKind::Rotated { run: plain_run }, CellKind::Rotated { run: spaced_run }) = + (plain_cell.kind, spaced_cell.kind) + else { + unreachable!(); + }; + let glyphs = spaced.runs[spaced_run].glyphs.len(); + assert!(glyphs >= 2, "AB shapes to at least two glyphs"); + assert!( + (spaced_cell.extent - plain_cell.extent - 5.0 * glyphs as f32).abs() < 0.01, + "rotated extent grows by letter_spacing * glyph count" + ); + let plain_gap = plain.runs[plain_run].positions[1].x - plain.runs[plain_run].positions[0].x; + let spaced_gap = + spaced.runs[spaced_run].positions[1].x - spaced.runs[spaced_run].positions[0].x; + assert!( + (spaced_gap - plain_gap - 5.0).abs() < 0.01, + "adjacent glyph gap grows by letter-spacing" + ); + } + + // A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the + // vertical kana repeat mark) has a 2em vertical advance vs a 1em + // horizontal advance; U+3042/U+304F are symmetric controls. + + #[test] + fn vertical_advance_uses_vmtx() { + // 〱 flows down the column by its 2em vertical advance (vmtx), not + // its 1em horizontal width; the glyph still centres on the 1em width. + let content = make_content(&["〱"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let cell = &layout.cells[0]; + let CellKind::Upright { run, glyph, count } = cell.kind else { + panic!("expected an upright cell"); + }; + let horizontal: f32 = layout.runs[run].advances[glyph..glyph + count].iter().sum(); + assert!( + (horizontal - 20.0).abs() < 0.5, + "horizontal advance ~1em, got {horizontal}" + ); + assert!( + (cell.extent - 40.0).abs() < 0.5, + "vertical extent ~2em from vmtx, got {}", + cell.extent + ); + assert!( + (cell.h_advance - 20.0).abs() < 0.5, + "h_advance stays ~1em, got {}", + cell.h_advance + ); + } + + #[test] + fn vertical_advance_symmetric_glyph_unchanged() { + // A glyph whose vmtx equals its hmtx keeps extent == h_advance. + let content = make_content(&["く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let cell = &layout.cells[0]; + assert!( + (cell.extent - 20.0).abs() < 0.5, + "extent ~1em, got {}", + cell.extent + ); + assert!( + (cell.h_advance - cell.extent).abs() < 0.01, + "symmetric glyph: extent == h_advance" + ); + } + + // Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the + // vertical alternates of 、。「」 halve their vertical advances (「 also + // lifts its ink by 481 units) and the あ/く alternates tighten by + // 58/60 units with small placement lifts. + + #[test] + fn native_vertical_alternates_are_not_synthetically_rotated() { + let layout = layout_with( + &provider(VPAL_TEST_FONT), + &vpal_content("「」、。", FontFeatures::None), + ); + assert_eq!(layout.cells.len(), 4); + assert!(layout + .cells + .iter() + .all(|cell| matches!(cell.kind, CellKind::Upright { .. }))); + } + + #[test] + fn vpal_tightens_upright_kana() { + let provider = provider(VPAL_TEST_FONT); + let plain = layout_with(&provider, &vpal_content("あ", FontFeatures::None)); + let tight = layout_with(&provider, &vpal_content("あ", FontFeatures::Vpal)); + assert!( + (plain.cells[0].extent - 20.0).abs() < 0.01, + "without vpal あ keeps its full em, got {}", + plain.cells[0].extent + ); + // あ's vertical alternate carries YAdvance -58, YPlacement 39. + let expected = 20.0 * (1000.0 - 58.0) / 1000.0; + assert!( + (tight.cells[0].extent - expected).abs() < 0.01, + "vpal advance delta tightens the extent, got {}", + tight.cells[0].extent + ); + let expected_shift = -20.0 * 39.0 / 1000.0; + assert!( + (tight.cells[0].glyph_flow_shift - expected_shift).abs() < 0.01, + "vpal placement lifts the drawn ink, got {}", + tight.cells[0].glyph_flow_shift + ); + } + + #[test] + fn vpal_opening_bracket_uses_font_placement() { + // In the middle of a line a normal opening bracket keeps its leading + // aki. Under vpal its placement still comes from the font's own + // YPlacement (481 units), not a synthetic sequence shed. + let provider = provider(VPAL_TEST_FONT); + let shed = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::None)); + let vpal = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::Vpal)); + assert_eq!( + shed.cells[1].glyph_flow_shift, 0.0, + "without vpal the preferred aki needs no synthetic shift, got {}", + shed.cells[1].glyph_flow_shift + ); + let expected = -20.0 * 481.0 / 1000.0; + assert!( + (vpal.cells[1].glyph_flow_shift - expected).abs() < 0.01, + "with vpal 「 lifts by the font's placement delta, got {}", + vpal.cells[1].glyph_flow_shift + ); + assert!( + (vpal.cells[1].extent - 10.0).abs() < 0.01, + "「 is half-width under vpal, got {}", + vpal.cells[1].extent + ); + } + + #[test] + fn centered_punctuation_shift_centres_ink_in_em_body() { + assert!(is_centered_punctuation('・')); + assert!(is_centered_punctuation(':')); + assert!(is_centered_punctuation(';')); + assert!(!is_centered_punctuation('あ')); + // Ink hugging the bottom of the body (14..18 in a 20 body) shifts up so + // its midpoint (16) lands on the body midpoint (10): shift == -6. + let shift = centered_flow_shift(14.0, 18.0, 20.0); + assert!((shift + 6.0).abs() < 1e-4, "expected -6, got {shift}"); + // Already-centred ink needs no shift. + assert!(centered_flow_shift(8.0, 12.0, 20.0).abs() < 1e-4); + } + + #[test] + fn vertical_base_text_switches_font_inside_an_upright_segment() { + let content = make_content(&["あ、く"], 400.0); + let provider = provider_with_fallback(VMTX_TEST_FONT, VPAL_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]); + + assert_eq!(layout.cells.len(), 3); + let run_ids: Vec = layout + .cells + .iter() + .map(|cell| match cell.kind { + CellKind::Upright { run, .. } => layout.runs[run].font.typeface().unique_id(), + _ => panic!("expected upright fallback cells"), + }) + .collect(); + assert_eq!(run_ids[0], run_ids[2]); + assert_ne!(run_ids[0], run_ids[1]); + let CellKind::Upright { run, glyph, .. } = layout.cells[1].kind else { + unreachable!(); + }; + assert_ne!(layout.runs[run].glyphs[glyph], 0); + } + + #[test] + fn layout_mixed_text_has_rotated_run() { + let content = make_content(&["あAB1い"], 1000.0); + let layout = layout_content(&content, 1000.0); + assert!(layout + .cells + .iter() + .any(|c| matches!(c.kind, CellKind::Rotated { .. }))); + // The rotated run covers the Latin range 1..4 (UTF-16). + let rotated = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .unwrap(); + assert_eq!(rotated.start, 1); + assert_eq!(rotated.end, 4); + } + + #[test] + fn western_word_space_uses_one_third_em() { + let without_space = layout_content(&make_content(&["ab"], 1000.0), 1000.0); + let with_space = layout_content(&make_content(&["a b"], 1000.0), 1000.0); + assert_eq!(without_space.cells.len(), 1); + assert_eq!(with_space.cells.len(), 1); + let added = with_space.cells[0].extent - without_space.cells[0].extent; + assert!( + (added - 20.0 * WESTERN_WORD_SPACING_EM).abs() < 0.01, + "word space should add one third em, got {added}" + ); + } + + #[test] + fn cells_carry_font_size_and_decoration() { + let content = decorated_content("あい", TextDecoration::UNDERLINE); + let layout = layout_content(&content, 1000.0); + assert!(!layout.cells.is_empty()); + for cell in &layout.cells { + assert_eq!(cell.font_size, 20.0); + assert_eq!(cell.decoration, Some(TextDecoration::UNDERLINE)); + } + } + + #[test] + fn mixed_spans_preserve_fill_color_and_opacity() { + let mut content = make_content_with_spans(&["あ", "A", "い"], 1000.0); + let colors = [ + skia::Color::from_argb(255, 255, 0, 0), + skia::Color::from_argb(128, 0, 255, 0), + skia::Color::from_argb(64, 0, 0, 255), + ]; + for (span, color) in content.paragraphs_mut()[0] + .children_mut() + .iter_mut() + .zip(colors) + { + span.fills = vec![Fill::Solid(SolidColor(color))]; + } + + let layout = layout_content(&content, 1000.0); + assert_eq!(layout.paints.len(), colors.len()); + for (paint, color) in layout.paints.iter().zip(colors) { + assert_eq!(paint.color(), color); + } + assert!(layout + .cells + .iter() + .any(|cell| matches!(cell.kind, CellKind::Rotated { .. }))); + } +} diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs new file mode 100644 index 0000000000..446de4fbb7 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -0,0 +1,1374 @@ +// Column planning and JLREQ spacing along the vertical flow. The passes run +// on a paragraph's `FlowCell`s before they are placed: they adjust cell +// extents (aki, oikomi, inter-script spacing) and pick the column breaks +// (kinsoku, burasage, oidashi). + +use crate::shapes::japanese::{classify, pair_rule, JapaneseClass}; +use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; +use crate::shapes::TextAlign; + +use super::layout::{CellKind, VerticalCell}; + +const INTER_SCRIPT_SPACING_EM: f32 = 0.25; + +/// Amounts below this are treated as zero by the spacing passes. +const EPSILON: f32 = 0.0001; + +/// A placeable item in the vertical flow: its extent along the column and, +/// for single-character cells, the character (used for kinsoku decisions +/// at column breaks). Rotated runs carry no character and are unsplittable. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct FlowItem { + pub extent: f32, + pub ch: Option, + /// This item and its predecessor came from the same source character + /// after a length-expanding text transform, so a column break must not + /// split them. + pub keep_with_previous: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum FlowScript { + Upright, + Rotated { + starts_alphanumeric: bool, + ends_alphanumeric: bool, + }, +} + +/// A cell of one paragraph awaiting column placement, with the facts the +/// spacing passes and the column planner need. +pub(super) struct FlowCell { + pub cell: VerticalCell, + /// The character of single-character cells; classifies the cell for aki + /// and kinsoku. + pub ch: Option, + pub keep_with_previous: bool, + pub script: FlowScript, + /// Letter-spacing included at the end of the cell's extent. + pub trailing_spacing: f32, +} + +impl FlowCell { + pub(super) fn new( + cell: VerticalCell, + ch: Option, + script: FlowScript, + trailing_spacing: f32, + ) -> Self { + Self { + cell, + ch, + keep_with_previous: false, + script, + trailing_spacing, + } + } + + fn item(&self) -> FlowItem { + FlowItem { + extent: self.cell.extent, + ch: self.ch, + keep_with_previous: self.keep_with_previous, + } + } + + /// Reduce the cell to a half-em frame (plus its letter-spacing). Opening + /// punctuation keeps its ink in the trailing half of the em, so + /// `pull_glyph` lifts it into the compressed cell, against the preceding + /// character. Returns false when the cell is already that narrow. + fn shed_to_half_em(&mut self, pull_glyph: bool) -> bool { + let target = 0.5 * self.cell.font_size + self.trailing_spacing; + if target >= self.cell.extent { + return false; + } + if pull_glyph { + self.cell.glyph_flow_shift -= self.cell.extent - target; + } + self.cell.extent = target; + true + } +} + +pub(super) fn flow_items(cells: &[FlowCell]) -> Vec { + cells.iter().map(FlowCell::item).collect() +} + +/// Punctuation allowed to hang past the column bottom (ぶら下げ / burasage): the +/// ideographic and full-width comma and period. When such a mark is the item +/// that would overflow the column, it protrudes into the margin instead of +/// wrapping (itself and its predecessor) to the next column. +fn can_hang(c: char) -> bool { + matches!(c, '、' | '。' | ',' | '.') +} + +/// True when `item` would overflow a column already filled to `cursor`. +fn overflows(cursor: f32, item: &FlowItem, max_height: f32) -> bool { + cursor > 0.0 && cursor + item.extent > max_height && !item.ch.is_some_and(can_hang) +} + +/// First item of the next column when `items[i]` overflows the column that +/// starts at `column_start`. Kinsoku: a break may not leave a +/// forbidden-at-line-end character at the column bottom nor put a +/// forbidden-at-line-start character at the next column top; offending +/// predecessors move to the new column (oidashi), bounded so a pathological +/// run cannot empty its column. `None` keeps `items[i]` in the column: it +/// closes an atomic composite (group ruby or an expanded source scalar) that +/// began at the column head, which has no legal internal break. +fn column_break( + items: &[FlowItem], + column_start: usize, + i: usize, + max_height: f32, +) -> Option { + const MAX_KINSOKU_SHIFT: usize = 4; + + let skip_kept = |mut index: usize| { + while index > column_start && items[index].keep_with_previous { + index -= 1; + } + index + }; + + let mut break_at = skip_kept(i); + if break_at == column_start && items[i].keep_with_previous { + return None; + } + let mut shifted = 0; + while break_at > column_start + && shifted < MAX_KINSOKU_SHIFT + && (items[break_at].ch.is_some_and(forbidden_at_line_start) + || items[break_at - 1].ch.is_some_and(forbidden_at_line_end)) + { + break_at = skip_kept(break_at - 1); + shifted += 1; + } + // Give up on the shift when the moved items plus the current one would + // overflow the new column too: overflowing the wrap budget is worse than + // the kinsoku violation. + let shifted_extent: f32 = items[break_at..i].iter().map(|it| it.extent).sum(); + if break_at < i && shifted_extent + items[i].extent > max_height { + break_at = i; + } + Some(break_at) +} + +/// Assign items to columns: returns (column-index, offset-from-top) per +/// item. An item that overflows the column height starts a new column (see +/// `column_break`); items taller than the column occupy one on their own. +/// Burasage marks never overflow: the next non-hanging item still sees the +/// overflowed cursor and wraps normally, and oidashi never pulls the hung +/// marks because they are forbidden-at-line-start, not -end. +pub(super) fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> { + let mut placements: Vec<(usize, f32)> = Vec::with_capacity(items.len()); + let mut column = 0usize; + let mut cursor = 0.0f32; + let mut column_start = 0usize; + + for (i, item) in items.iter().enumerate() { + if overflows(cursor, item, max_height) { + if let Some(break_at) = column_break(items, column_start, i, max_height) { + column += 1; + cursor = 0.0; + for k in break_at..i { + placements[k] = (column, cursor); + cursor += items[k].extent; + } + column_start = break_at; + } + } + placements.push((column, cursor)); + cursor += item.extent; + } + placements +} + +/// True when `max_height` is a real wrap budget (auto-width columns are +/// unbounded). +pub(super) fn is_bounded(max_height: f32) -> bool { + max_height.is_finite() && max_height > 0.0 && max_height < f32::MAX +} + +/// Offset of a column's content along the column (vertical/inline) axis for +/// a given `text-align`. In `vertical-rl` the inline axis runs top->bottom, so +/// Left/Start anchor to the top, Right/End to the bottom and Center to the +/// middle of the wrap budget. `budget` is the column wrap height (the box +/// height); an unbounded budget (auto-width, columns are snug) yields no shift. +/// Justify yields no uniform shift here — its space is distributed between +/// cells by `ordered_expansion_offsets`. +pub(super) fn align_offset_along_column(align: TextAlign, budget: f32, used: f32) -> f32 { + if !is_bounded(budget) { + return 0.0; + } + let slack = (budget - used).max(0.0); + match align { + TextAlign::Center => slack / 2.0, + TextAlign::Right | TextAlign::End => slack, + _ => 0.0, + } +} + +/// Japanese inter-script spacing at an upright CJK <-> rotated alphabetic or +/// numeric boundary. Punctuation and explicit whitespace do not create an +/// automatic gap. Use the smaller adjacent font size so a large neighboring +/// run cannot create a disproportionate gap. +fn inter_script_spacing( + previous: FlowScript, + previous_font_size: f32, + next: FlowScript, + next_font_size: f32, +) -> f32 { + let boundary = matches!( + (previous, next), + ( + FlowScript::Upright, + FlowScript::Rotated { + starts_alphanumeric: true, + .. + } + ) | ( + FlowScript::Rotated { + ends_alphanumeric: true, + .. + }, + FlowScript::Upright + ) + ); + if boundary { + previous_font_size.min(next_font_size) * INTER_SCRIPT_SPACING_EM + } else { + 0.0 + } +} + +/// Give every upright <-> Western boundary its inter-script gap. The +/// preceding cell's flow advance is adjusted so the *visible ink* edges have +/// the target gap: logical advances alone are asymmetric around mixed +/// fonts/glyphs (e.g. `うpenあ`) because their side bearings differ. The +/// preceding cell's explicit trailing letter-spacing is preserved. +pub(super) fn apply_inter_script_spacing(cells: &mut [FlowCell]) { + for i in 1..cells.len() { + let target_gap = inter_script_spacing( + cells[i - 1].script, + cells[i - 1].cell.font_size, + cells[i].script, + cells[i].cell.font_size, + ); + if target_gap <= 0.0 { + continue; + } + let previous = &cells[i - 1]; + let natural_gap = previous.cell.extent - previous.trailing_spacing + cells[i].cell.ink_top + - previous.cell.ink_bottom; + let corrected = previous.cell.extent + target_gap - natural_gap; + cells[i - 1].cell.extent = corrected.max(0.0); + } +} + +/// JLREQ character class of each cell. Ruby bases count as simple ruby, so +/// `ruby_spans[span]` overrides the cell's own class. +pub(super) fn flow_classes(cells: &[FlowCell], ruby_spans: &[bool]) -> Vec> { + cells + .iter() + .map(|flow| { + if ruby_spans[flow.cell.span] { + return Some(JapaneseClass::SimpleRuby); + } + match flow.cell.kind { + CellKind::TateChuYoko { .. } => Some(JapaneseClass::TateChuYoko), + CellKind::Rotated { .. } => Some(JapaneseClass::Western), + _ => flow.ch.map(classify), + } + }) + .collect() +} + +fn embedded_leading_aki(class: JapaneseClass) -> f32 { + match class { + JapaneseClass::OpeningBracket => 0.5, + JapaneseClass::MiddleDot => 0.25, + _ => 0.0, + } +} + +fn embedded_trailing_aki(class: JapaneseClass) -> f32 { + match class { + JapaneseClass::ClosingBracket | JapaneseClass::FullStop | JapaneseClass::Comma => 0.5, + JapaneseClass::MiddleDot => 0.25, + _ => 0.0, + } +} + +/// JLREQ punctuation and cl-30 adjacency. Full-width fonts bake the half-em +/// aki into punctuation advances. That preferred aki stays in ordinary text, +/// but one half-em goes at the internal boundaries defined by §3.1.4: +/// closing punctuation sequences set solid, closing→opening retains a single +/// half-em, opening sequences set solid after the first bracket, and +/// middle-dot adjacency retains its own quarter-em side spacing. +pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], classes: &[Option]) { + for (i, flow) in cells.iter_mut().enumerate() { + let Some(ch) = flow.ch else { + continue; + }; + let class = classify(ch); + let closing = class.is_trailing_aki_punctuation(); + let opening = class == JapaneseClass::OpeningBracket; + let shed = if closing { + classes.get(i + 1).copied().flatten().is_some_and(|next| { + 0.5 + embedded_leading_aki(next) + > pair_rule(class, next).preferred_em + f32::EPSILON + }) + } else if opening { + i.checked_sub(1) + .and_then(|previous| classes[previous]) + .is_some_and(|previous| { + // A preceding trailing-aki mark owns the reduction for + // closing→opening, so never remove both halves. + !previous.is_trailing_aki_punctuation() + && embedded_trailing_aki(previous) + 0.5 + > pair_rule(previous, class).preferred_em + f32::EPSILON + }) + } else { + false + }; + if shed { + flow.shed_to_half_em(opening); + } + } +} + +/// Smallest legal flow extent after oikomi. Full-width Japanese punctuation +/// normally carries removable aki inside its one-em advance, but `vpal` may +/// have removed that aki already. Clamp the floor to the actual shaped extent +/// so a proportional half-em glyph frame can never be compressed again. +pub(super) fn minimum_oikomi_extent( + ch: Option, + extent: f32, + font_size: f32, + letter_spacing: f32, +) -> f32 { + let half_em_frame = ch.is_some_and(|ch| { + matches!( + classify(ch), + JapaneseClass::OpeningBracket + | JapaneseClass::ClosingBracket + | JapaneseClass::FullStop + | JapaneseClass::Comma + | JapaneseClass::MiddleDot + ) + }); + if half_em_frame { + extent.min(0.5 * font_size + letter_spacing) + } else { + extent + } +} + +/// Divide `amount` equally across capped opportunities, redistributing the +/// remainder whenever one opportunity reaches its cap. +fn capped_equal_allocations(capacities: &[(usize, f32)], amount: f32) -> Vec<(usize, f32)> { + let mut allocations: Vec<(usize, f32)> = capacities.iter().map(|(i, _)| (*i, 0.0)).collect(); + let mut remaining = amount.max(0.0); + while remaining > EPSILON { + let active: Vec = capacities + .iter() + .enumerate() + .filter_map(|(slot, (_, cap))| (allocations[slot].1 + EPSILON < *cap).then_some(slot)) + .collect(); + if active.is_empty() { + break; + } + let share = remaining / active.len() as f32; + let mut used = 0.0; + for slot in active { + let cap = capacities[slot].1; + let delta = share.min(cap - allocations[slot].1); + allocations[slot].1 += delta; + used += delta; + } + if used <= EPSILON { + break; + } + remaining -= used; + } + allocations +} + +/// The em that scales the spacing between `cells[boundary]` and the next +/// cell: the smaller adjacent font size. +fn boundary_em(cells: &[FlowCell], boundary: usize) -> f32 { + cells[boundary] + .cell + .font_size + .min(cells[boundary + 1].cell.font_size) +} + +fn explicit_pair_spacing_em(before: JapaneseClass, after: JapaneseClass) -> f32 { + if matches!(before, JapaneseClass::DividingPunctuation) { + pair_rule(before, after).preferred_em + } else { + 0.0 + } +} + +/// Adjacent classes at `boundary`, when both cells have one. +fn boundary_classes( + classes: &[Option], + boundary: usize, +) -> Option<(JapaneseClass, JapaneseClass)> { + Some((classes[boundary]?, classes[boundary + 1]?)) +} + +/// Preferred pair spacing (in em) at every boundary, per the JLREQ table. +pub(super) fn preferred_pair_spacing(classes: &[Option]) -> Vec { + (0..classes.len().saturating_sub(1)) + .map(|boundary| { + boundary_classes(classes, boundary) + .map_or(0.0, |(before, after)| pair_rule(before, after).preferred_em) + }) + .collect() +} + +/// cl-04 question/exclamation marks carry an explicit one-em space after +/// their character frame. Unlike bracket/comma/full-stop aki, this space is +/// not embedded in the font's full-width advance, so it must exist in the +/// flow extent before oikomi is allowed to reduce it. +pub(super) fn materialize_explicit_pair_spacing( + cells: &mut [FlowCell], + classes: &[Option], +) { + for boundary in 0..cells.len().saturating_sub(1) { + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let spacing_em = explicit_pair_spacing_em(before, after); + if spacing_em > 0.0 { + cells[boundary].cell.extent += spacing_em * boundary_em(cells, boundary); + } + } +} + +/// Cell holding the removable spacing of a boundary, and whether that space +/// leads the cell (its glyph then moves up with the reduction). +fn spacing_owner(before: JapaneseClass, after: JapaneseClass, boundary: usize) -> (usize, bool) { + if explicit_pair_spacing_em(before, after) > 0.0 { + (boundary, false) + } else if matches!( + after, + JapaneseClass::OpeningBracket | JapaneseClass::MiddleDot + ) { + // Sequence layout removes any redundant preceding trailing half; the + // remaining aki is the next glyph's embedded leading space. + (boundary + 1, true) + } else { + (boundary, false) + } +} + +/// Remove up to `amount` of removable spacing from `cells[index]`, never +/// below its oikomi floor. Returns the amount removed. +fn reduce_cell_spacing(cells: &mut [FlowCell], index: usize, amount: f32, leading: bool) -> f32 { + let cell = &mut cells[index].cell; + let removable = (cell.extent - cell.minimum_oikomi_extent).max(0.0); + let amount = amount.min(cell.extent.max(0.0)).min(removable); + cell.extent -= amount; + if leading { + cell.glyph_flow_shift -= amount; + } + amount +} + +/// Explicit spacing at a column edge is discarded (no trailing `!` space at +/// the column bottom). Returns true when any spacing was removed. +fn discard_explicit_spacing_at_column_edges( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + placements: &[(usize, f32)], +) -> bool { + let mut changed = false; + for boundary in 0..cells.len().saturating_sub(1) { + if placements[boundary].0 == placements[boundary + 1].0 { + continue; + } + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + if explicit_pair_spacing_em(before, after) <= 0.0 { + continue; + } + let spacing = pair_spacing_em[boundary] * boundary_em(cells, boundary); + if spacing <= 0.0 { + continue; + } + reduce_cell_spacing(cells, boundary, spacing, false); + pair_spacing_em[boundary] = 0.0; + changed = true; + } + changed +} + +/// JLREQ oikomi: before wrapping a non-hanging item, try to keep it in the +/// current column by reducing only legal aki, in table priority order. If the +/// complete deficit cannot be recovered, leave the line untouched for the +/// subsequent oidashi/kinsoku planner. +pub(super) fn apply_ordered_oikomi( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + max_height: f32, +) { + if !is_bounded(max_height) { + return; + } + let mut column_start = 0usize; + let mut cursor = 0.0f32; + for i in 0..cells.len() { + if overflows(cursor, &cells[i].item(), max_height) { + let deficit = cursor + cells[i].cell.extent - max_height; + cursor -= compress_line(cells, classes, pair_spacing_em, column_start..i, deficit); + if cursor + cells[i].cell.extent > max_height + EPSILON { + column_start = i; + cursor = 0.0; + } + } + cursor += cells[i].cell.extent; + } +} + +/// Recover `deficit` from the boundaries in `line` (the cells before the +/// overflowing one), in shrink-priority order, when the whole deficit fits. +/// Returns the extent removed from the line. +fn compress_line( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + line: std::ops::Range, + deficit: f32, +) -> f32 { + let overflowing = line.end; + // (boundary, priority, capacity) + let mut opportunities: Vec<(usize, u8, f32)> = Vec::new(); + for boundary in line { + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let rule = pair_rule(before, after); + if rule.shrink_priority == 0 || pair_spacing_em[boundary] <= rule.minimum_em { + continue; + } + let em = boundary_em(cells, boundary); + let (owner, _) = spacing_owner(before, after, boundary); + let owner = &cells[owner].cell; + let rule_capacity = (pair_spacing_em[boundary] - rule.minimum_em) * em; + let physical_capacity = (owner.extent - owner.minimum_oikomi_extent).max(0.0); + let capacity = rule_capacity.min(physical_capacity); + if capacity > EPSILON { + opportunities.push((boundary, rule.shrink_priority, capacity)); + } + } + let total_capacity: f32 = opportunities.iter().map(|(_, _, cap)| *cap).sum(); + if total_capacity + EPSILON < deficit { + return 0.0; + } + + let mut removed_from_line = 0.0; + let mut remaining = deficit; + for priority in 1..=5 { + if remaining <= EPSILON { + break; + } + let caps: Vec<(usize, f32)> = opportunities + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(boundary, _, cap)| (*boundary, *cap)) + .collect(); + if caps.is_empty() { + continue; + } + let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); + for (boundary, reduction) in capped_equal_allocations(&caps, remaining.min(available)) { + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let em = boundary_em(cells, boundary); + let (owner, leading) = spacing_owner(before, after, boundary); + let reduction = reduce_cell_spacing(cells, owner, reduction, leading); + pair_spacing_em[boundary] -= reduction / em; + if owner < overflowing { + removed_from_line += reduction; + } + remaining -= reduction; + } + } + removed_from_line +} + +/// Plan the columns, then trim spacing that a break left at a column edge +/// and re-plan, since a shorter line may pull one more cell into the +/// preceding column. Each round only shrinks a previously untrimmed cell, so +/// the loops are bounded by the cell count. +/// +/// Wrapped opening brackets use the JIS X 4051 tentsuki policy: their leading +/// half-em is discarded at a column head. +pub(super) fn plan_with_edge_trimming( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + max_height: f32, +) -> Vec<(usize, f32)> { + let mut placements = plan_columns(&flow_items(cells), max_height); + for _ in 0..cells.len() { + if !discard_explicit_spacing_at_column_edges(cells, classes, pair_spacing_em, &placements) { + break; + } + placements = plan_columns(&flow_items(cells), max_height); + } + for _ in 0..cells.len() { + let mut changed = false; + for (flow, (_, top)) in cells.iter_mut().zip(&placements) { + let opening = flow + .ch + .is_some_and(|ch| classify(ch) == JapaneseClass::OpeningBracket); + if *top == 0.0 && opening { + changed |= flow.shed_to_half_em(true); + } + } + if !changed { + break; + } + placements = plan_columns(&flow_items(cells), max_height); + } + placements +} + +/// Ordered oidashi expansion for justified columns. Stages 1–3 respect the +/// table caps; if slack remains, stage 4 distributes it equally across every +/// otherwise-expandable boundary, as required by JLREQ §3.8.4. Returns the +/// extra flow offset of every cell. The last column is not justified. +pub(super) fn ordered_expansion_offsets( + cells: &[FlowCell], + classes: &[Option], + pair_spacing_em: &[f32], + placements: &[(usize, f32)], + column_used: &[f32], + max_height: f32, +) -> Vec { + let last_column = column_used.len().saturating_sub(1); + let mut boundary_expansion = vec![0.0f32; cells.len().saturating_sub(1)]; + for (column, used) in column_used.iter().enumerate().take(last_column) { + // (boundary, priority, cap) + let mut boundaries: Vec<(usize, u8, f32)> = Vec::new(); + for boundary in 0..cells.len().saturating_sub(1) { + if placements[boundary].0 != column || placements[boundary + 1].0 != column { + continue; + } + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let rule = pair_rule(before, after); + if rule.expand_priority == 0 { + continue; + } + let cap = (rule.maximum_em - pair_spacing_em[boundary]).max(0.0) + * boundary_em(cells, boundary); + boundaries.push((boundary, rule.expand_priority, cap)); + } + let mut remaining = (max_height - used).max(0.0); + for priority in 1..=3 { + let caps: Vec<(usize, f32)> = boundaries + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(boundary, _, cap)| (*boundary, *cap)) + .collect(); + let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); + for (boundary, expansion) in capped_equal_allocations(&caps, remaining.min(available)) { + boundary_expansion[boundary] += expansion; + remaining -= expansion; + } + } + if remaining > EPSILON && !boundaries.is_empty() { + let extra = remaining / boundaries.len() as f32; + for (boundary, _, _) in &boundaries { + boundary_expansion[*boundary] += extra; + } + } + } + + let mut offsets = vec![0.0f32; cells.len()]; + for i in 1..cells.len() { + if placements[i].0 == placements[i - 1].0 { + offsets[i] = offsets[i - 1] + boundary_expansion[i - 1]; + } + } + offsets +} + +#[cfg(test)] +mod tests { + use super::super::layout::{wrap_height, CellKind, VerticalCell}; + use super::super::test_support::*; + use super::*; + use crate::shapes::{FontFeatures, GrowType, TextCombineUpright, TextSpan}; + + fn item(extent: f32, ch: char) -> FlowItem { + FlowItem { + extent, + ch: Some(ch), + keep_with_previous: false, + } + } + + fn aligned_content(text: &str, height: f32, align: TextAlign) -> crate::shapes::TextContent { + content_of( + vec![vertical_paragraph(vec![make_span(text)], align, 1.0)], + height, + GrowType::Fixed, + ) + } + + fn vpal_content(text: &str) -> crate::shapes::TextContent { + spans_content( + vec![TextSpan { + font_features: FontFeatures::Vpal, + ..make_span(text) + }], + 1000.0, + ) + } + + fn visible_flow_gap(previous: &VerticalCell, next: &VerticalCell) -> f32 { + next.top + next.ink_top - (previous.top + previous.ink_bottom) + } + + #[test] + fn plan_columns_breaks_on_overflow() { + let items: Vec = "あいうえお".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 25.0); + assert_eq!( + placements, + vec![(0, 0.0), (0, 10.0), (1, 0.0), (1, 10.0), (2, 0.0)] + ); + } + + #[test] + fn plan_columns_oversized_item_gets_own_column() { + let items = vec![item(10.0, 'あ'), item(100.0, 'い'), item(10.0, 'う')]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (2, 0.0)]); + } + + #[test] + fn plan_columns_burasage_hangs_comma_period() { + // 。 overflows the two-cell column; instead of oidashi (pushing い down + // with it), burasage lets it hang past the column bottom. The invariant + // "no forbidden-at-line-start char at a column top" still holds — 。 + // never reaches the next column's top. + let items = vec![ + item(10.0, 'あ'), + item(10.0, 'い'), + item(10.0, '。'), + item(10.0, 'う'), + ]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (0, 10.0), (0, 20.0), (1, 0.0)]); + assert_eq!(placements[2].0, 0, "。 hangs in the current column"); + } + + #[test] + fn plan_columns_kinsoku_no_forbidden_end_at_column_bottom() { + // 「 would be left at the bottom of column 0; it moves down. + let items = vec![ + item(10.0, 'あ'), + item(10.0, '「'), + item(10.0, 'い'), + item(10.0, 'う'), + ]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 10.0), (2, 0.0)]); + } + + #[test] + fn plan_columns_kinsoku_shift_is_bounded() { + // A column full of open brackets cannot be emptied: oidashi moves at + // most four of them along with the overflowing あ. + let items: Vec = "「「「「「「あ".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 60.0); + let first_column_count = placements.iter().filter(|(c, _)| *c == 0).count(); + assert_eq!(first_column_count, 2); + } + + #[test] + fn plan_columns_kinsoku_shift_never_overflows_budget() { + // A two-item budget: moving the trailing 「「 down with the + // overflowing い would put three items (30.0) in a 20.0 column; + // the shift is dropped instead, keeping every column within the + // wrap budget. + let items = vec![ + item(10.0, 'あ'), + item(10.0, '「'), + item(10.0, '「'), + item(10.0, 'い'), + ]; + let placements = plan_columns(&items, 20.0); + let mut column_used = std::collections::BTreeMap::new(); + for (it, (column, top)) in items.iter().zip(&placements) { + let used: &mut f32 = column_used.entry(*column).or_default(); + *used = used.max(top + it.extent); + } + for (column, used) in column_used { + assert!(used <= 20.0, "column {column} overflows: {used}"); + } + } + + #[test] + fn capped_adjustment_redistributes_after_a_boundary_saturates() { + let allocations = capped_equal_allocations(&[(0, 1.0), (1, 3.0)], 3.0); + assert_eq!(allocations, vec![(0, 1.0), (1, 2.0)]); + } + + #[test] + fn align_offset_helper_maps_edges() { + // Unbounded budget (auto-width): no shift. + assert_eq!( + align_offset_along_column(TextAlign::Center, f32::MAX, 40.0), + 0.0 + ); + // Left/Start anchors to the top. + assert_eq!(align_offset_along_column(TextAlign::Left, 100.0, 40.0), 0.0); + // Center splits the slack. + assert_eq!( + align_offset_along_column(TextAlign::Center, 100.0, 40.0), + 30.0 + ); + // Right/End pushes to the bottom. + assert_eq!( + align_offset_along_column(TextAlign::Right, 100.0, 40.0), + 60.0 + ); + assert_eq!(align_offset_along_column(TextAlign::End, 100.0, 40.0), 60.0); + // Overfull column: no negative shift. + assert_eq!( + align_offset_along_column(TextAlign::Right, 40.0, 100.0), + 0.0 + ); + } + + #[test] + fn layout_kinsoku_no_period_at_column_top() { + // Wrap height fits exactly 2 characters per column; the 。 after + // the second character would start column 2 without kinsoku. + let text = "あい。うえお"; + let content = make_content(&[text], 100.0); + let cell_extent = layout_content(&content, 1000.0).cells[0].extent; + let layout = layout_content(&content, cell_extent * 2.0 + 0.1); + + assert!(layout.columns.len() > 1, "the text must wrap"); + let chars: Vec = text.chars().collect(); + for column_index in 0..layout.columns.len() { + let head = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .min_by(|a, b| a.top.total_cmp(&b.top)) + .expect("every column holds a cell"); + let c = chars[head.start]; + assert!( + !forbidden_at_line_start(c), + "column {column_index} starts with forbidden char {c}" + ); + } + } + + #[test] + fn ordinary_closing_punctuation_keeps_preferred_aki() { + // In ordinary text, the comma and closing bracket keep their normal + // half-em glyph body plus half-em trailing aki: one em in total. + let content = make_content(&["く、く」く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells.len(), 5, "one cell per character"); + let em = 20.0; + assert!( + (layout.cells[0].extent - em).abs() < 1.0, + "leading ideograph keeps full advance, got {}", + layout.cells[0].extent + ); + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "、 before an ideograph keeps its aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[3].extent - em).abs() < 1.0, + "」 before an ideograph keeps its aki, got {}", + layout.cells[3].extent + ); + assert!( + (layout.cells[4].extent - em).abs() < 1.0, + "trailing ideograph keeps full advance, got {}", + layout.cells[4].extent + ); + } + + #[test] + fn closing_then_opening_keeps_half_em_aki() { + // く」「く: the closing bracket sheds its trailing half, but the + // opening bracket after it keeps its full em (leading half blank), so + // the pair keeps the half-em aki JIS X 4051 asks for instead of + // setting solid. + let content = make_content(&["く」「く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells.len(), 4, "one cell per character"); + let em = 20.0; + assert!( + layout.cells[1].extent <= 0.5 * em + 0.01, + "」 sheds its trailing aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "「 after a closing mark keeps its full em, got {}", + layout.cells[2].extent + ); + assert_eq!( + layout.cells[2].glyph_flow_shift, 0.0, + "the unshed opening bracket is not shifted" + ); + } + + #[test] + fn dividing_punctuation_materializes_trailing_aki() { + let provider = provider(VMTX_TEST_FONT); + let terminal = layout_with(&provider, &make_content(&["!"], 1000.0)); + let before_ideograph = layout_with(&provider, &make_content(&["!く"], 1000.0)); + let before_dividing = layout_with(&provider, &make_content(&["!?"], 1000.0)); + let before_closing = layout_with(&provider, &make_content(&["!」"], 1000.0)); + let em = 20.0; + let natural_extent = terminal.cells[0].extent; + + assert!( + (before_ideograph.cells[0].extent - natural_extent - em).abs() < 0.01, + "! before ordinary text adds one em, got {} over natural {natural_extent}", + before_ideograph.cells[0].extent + ); + assert!( + (before_dividing.cells[0].extent - natural_extent - em).abs() < 0.01, + "! before ? adds one em, got {} over natural {natural_extent}", + before_dividing.cells[0].extent + ); + assert!( + (before_closing.cells[0].extent - natural_extent).abs() < 0.01, + "! before a closing bracket adds no aki, got {} over natural {natural_extent}", + before_closing.cells[0].extent + ); + } + + #[test] + fn dividing_punctuation_oikomi_never_compresses_glyph_frames() { + let provider = provider(VMTX_TEST_FONT); + let terminal_exclamation = layout_with(&provider, &make_content(&["!"], 1000.0)); + let terminal_question = layout_with(&provider, &make_content(&["?"], 1000.0)); + let layout = layout_with_height(&provider, &make_content(&["く!?く"], 60.0), 60.0); + + assert!( + layout.cells[1].extent + 0.01 >= terminal_exclamation.cells[0].extent, + "oikomi compressed the ! glyph frame from {} to {}", + terminal_exclamation.cells[0].extent, + layout.cells[1].extent + ); + assert!( + layout.cells[2].extent + 0.01 >= terminal_question.cells[0].extent, + "oikomi compressed the ? glyph frame from {} to {}", + terminal_question.cells[0].extent, + layout.cells[2].extent + ); + } + + #[test] + fn dividing_punctuation_discards_trailing_aki_at_column_edge() { + let provider = provider(VMTX_TEST_FONT); + let terminal = layout_with(&provider, &make_content(&["!"], 1000.0)); + let layout = layout_with_height(&provider, &make_content(&["!く"], 20.0), 20.0); + + assert_ne!(layout.cells[0].column, layout.cells[1].column); + assert!( + (layout.cells[0].extent - terminal.cells[0].extent).abs() < 0.01, + "column-end ! should discard its trailing aki, got {} over natural {}", + layout.cells[0].extent, + terminal.cells[0].extent + ); + } + + #[test] + fn ordinary_opening_punctuation_keeps_preferred_aki() { + // く「く: the opening bracket keeps its leading half-em aki in the + // middle of a line, so its total advance remains one em. + let content = make_content(&["く「く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells.len(), 3, "one cell per character"); + let em = 20.0; + assert!( + (layout.cells[0].extent - em).abs() < 1.0, + "leading ideograph keeps full advance, got {}", + layout.cells[0].extent + ); + assert_eq!( + layout.cells[0].glyph_flow_shift, 0.0, + "ideograph is not shifted" + ); + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "「 keeps its leading aki, got {}", + layout.cells[1].extent + ); + assert_eq!( + layout.cells[1].glyph_flow_shift, 0.0, + "an uncompressed opening bracket is not shifted, got {}", + layout.cells[1].glyph_flow_shift + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "trailing ideograph keeps full advance, got {}", + layout.cells[2].extent + ); + } + + #[test] + fn consecutive_closing_punctuation_sets_solid_internally() { + let content = make_content(&["く。」く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let em = 20.0; + assert!( + layout.cells[1].extent <= 0.5 * em + 0.01, + "。 sheds its internal trailing aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "the final 」 keeps the sequence's trailing aki, got {}", + layout.cells[2].extent + ); + } + + #[test] + fn consecutive_opening_punctuation_sets_solid_after_first() { + let content = make_content(&["く「『く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let em = 20.0; + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "the first opening bracket keeps the sequence's leading aki, got {}", + layout.cells[1].extent + ); + assert!( + layout.cells[2].extent <= 0.5 * em + 0.01, + "the second opening bracket sets solid, got {}", + layout.cells[2].extent + ); + assert!(layout.cells[2].glyph_flow_shift < -0.01); + } + + #[test] + fn opening_bracket_at_column_head_is_tentsuki() { + let content = make_content(&["「く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let em = 20.0; + assert_eq!(layout.cells[0].top, 0.0); + assert!( + layout.cells[0].extent <= 0.5 * em + 0.01, + "line-head 「 sheds its leading aki, got {}", + layout.cells[0].extent + ); + assert!(layout.cells[0].glyph_flow_shift < -0.01); + } + + #[test] + fn line_end_punctuation_keeps_preferred_half_em_aki() { + let content = make_content(&["く、"], 40.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells[0].column, layout.cells[1].column); + assert!( + (layout.cells[1].extent - 20.0).abs() < 1.0, + "line-end 、 keeps a half-em after its glyph, got {}", + layout.cells[1].extent + ); + } + + #[test] + fn plain_ideographs_keep_full_advance() { + let content = make_content(&["くくく"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + for cell in &layout.cells { + assert!( + (cell.extent - 20.0).abs() < 1.0, + "no punctuation: full advance kept, got {}", + cell.extent + ); + } + } + + #[test] + fn vpal_punctuation_is_not_double_compressed() { + // 、's vertical alternate already halves its advance under vpal; + // the aki shed must not compress the half-width cell again. + let provider = provider(VPAL_TEST_FONT); + let layout = layout_with(&provider, &vpal_content("あ、あ")); + assert!( + (layout.cells[1].extent - 10.0).abs() < 0.01, + "、 is exactly half-width under vpal, got {}", + layout.cells[1].extent + ); + } + + #[test] + fn vpal_oikomi_preserves_half_em_punctuation_frame() { + // vpal has already removed the opening bracket's leading aki. A tight + // fixed-height column must wrap instead of offering that same half-em + // to oikomi and collapsing the glyph frame a second time. + let provider = provider(VPAL_TEST_FONT); + let content = vpal_content("あ「あ"); + let natural = layout_with(&provider, &content); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let bracket_extent = natural.cells[1].extent; + let constrained = layout_with_height(&provider, &content, natural_total - 5.0); + + assert!( + (bracket_extent - 10.0).abs() < 0.01, + "vpal opening bracket starts at half-em, got {bracket_extent}" + ); + assert!( + (constrained.cells[1].extent - bracket_extent).abs() < 0.01, + "oikomi collapsed the vpal bracket frame from {bracket_extent} to {}", + constrained.cells[1].extent + ); + assert_ne!( + constrained.cells[0].column, constrained.cells[2].column, + "insufficient removable aki should wrap the protected bracket and following glyph" + ); + } + + #[test] + fn tate_chu_yoko_all_uses_asymmetric_punctuation_aki() { + // 」→TCY keeps the closing mark's trailing half-em, and TCY→「 keeps + // the opening mark's leading half-em. TCY on the opposite side of + // either bracket sets solid. + let mut content = make_content_with_spans(&["く", "」", "20", "「", "く"], 400.0); + content.paragraphs_mut()[0].children_mut()[2] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert!(matches!(layout.cells[2].kind, CellKind::TateChuYoko { .. })); + for (index, label) in [(1, "」 before TCY"), (2, "TCY"), (3, "「 after TCY")] { + assert!( + (layout.cells[index].extent - 20.0).abs() < 0.01, + "{label} should occupy its preferred one-em frame, got {}", + layout.cells[index].extent + ); + } + + let mut reverse = make_content_with_spans(&["く", "「", "20", "」", "く"], 400.0); + reverse.paragraphs_mut()[0].children_mut()[2] + .set_text_combine_upright(TextCombineUpright::All); + let reverse = layout_with(&provider(VMTX_TEST_FONT), &reverse); + assert!(matches!( + reverse.cells[2].kind, + CellKind::TateChuYoko { .. } + )); + assert!((reverse.cells[1].extent - 20.0).abs() < 0.01); + assert!((reverse.cells[3].extent - 20.0).abs() < 0.01); + } + + #[test] + fn tate_chu_yoko_digits_uses_the_same_cl30_adjacency() { + let mut content = make_content_with_spans(&["く」31「く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy_index = layout + .cells + .iter() + .position(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("digit TCY cell"); + assert!((layout.cells[tcy_index - 1].extent - 20.0).abs() < 0.01); + assert!((layout.cells[tcy_index].extent - 20.0).abs() < 0.01); + assert!((layout.cells[tcy_index + 1].extent - 20.0).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_separates_cjk_and_latin() { + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; + + let cjk_latin = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + assert_eq!(cjk_latin.cells.len(), 2); + assert!( + (visible_flow_gap(&cjk_latin.cells[0], &cjk_latin.cells[1]) - expected_gap).abs() + < 0.01 + ); + + let latin_cjk = layout_content(&make_content(&["aあ"], 1000.0), 1000.0); + assert_eq!(latin_cjk.cells.len(), 2); + assert!( + (visible_flow_gap(&latin_cjk.cells[0], &latin_cjk.cells[1]) - expected_gap).abs() + < 0.01 + ); + } + + #[test] + fn inter_script_spacing_is_visually_symmetric_around_latin_run() { + let layout = layout_content(&make_content(&["うpenあ"], 1000.0), 1000.0); + assert_eq!(layout.cells.len(), 3); + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; + let before = visible_flow_gap(&layout.cells[0], &layout.cells[1]); + let after = visible_flow_gap(&layout.cells[1], &layout.cells[2]); + assert!((before - expected_gap).abs() < 0.01); + assert!((after - expected_gap).abs() < 0.01); + assert!((before - after).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_preserves_explicit_letter_spacing() { + let letter_spacing = 3.0; + let layout = layout_content(&spaced_content("うpenあ", letter_spacing), 1000.0); + assert_eq!(layout.cells.len(), 3); + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM + letter_spacing; + assert!((visible_flow_gap(&layout.cells[0], &layout.cells[1]) - expected_gap).abs() < 0.01); + assert!((visible_flow_gap(&layout.cells[1], &layout.cells[2]) - expected_gap).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_crosses_span_boundaries() { + let joined = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + let split = layout_content(&make_content_with_spans(&["あ", "a"], 1000.0), 1000.0); + assert_eq!(joined.cells.len(), split.cells.len()); + for (joined, split) in joined.cells.iter().zip(&split.cells) { + assert!((joined.top - split.top).abs() < 0.01); + assert!((joined.extent - split.extent).abs() < 0.01); + } + } + + #[test] + fn inter_script_spacing_excludes_punctuation_and_whitespace() { + let cjk_extent = layout_content(&make_content(&["あ"], 1000.0), 1000.0).cells[0].extent; + for text in ["あ.", "あ a"] { + let layout = layout_content(&make_content(&[text], 1000.0), 1000.0); + assert!( + (layout.cells[0].extent - cjk_extent).abs() < 0.01, + "{text:?} must not add spacing immediately after the CJK cell" + ); + } + } + + #[test] + fn oikomi_reduces_script_gap_before_oidashi() { + let natural = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + assert_eq!(natural.cells.len(), 2); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = natural_total - 2.0; + let adjusted = layout_content(&make_content(&["あa"], budget), budget); + assert_eq!(adjusted.cells[0].column, adjusted.cells[1].column); + let gap = visible_flow_gap(&adjusted.cells[0], &adjusted.cells[1]); + assert!( + (gap - (20.0 * INTER_SCRIPT_SPACING_EM - 2.0)).abs() < 0.01, + "oikomi should recover the two-pixel deficit from the script gap, got {gap}" + ); + } + + #[test] + fn oikomi_preserves_sentence_final_full_stop_aki() { + let natural = layout_content(&make_content(&["あ。あ"], 1000.0), 1000.0); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = natural_total - 2.0; + let adjusted = layout_content(&make_content(&["あ。あ"], budget), budget); + assert_ne!( + adjusted.cells[1].column, adjusted.cells[2].column, + "fixed full-stop aki must not be compressed to retain the next ideograph" + ); + assert!((adjusted.cells[1].extent - natural.cells[1].extent).abs() < 0.01); + } + + #[test] + fn text_align_shifts_column_cells() { + let text = "あいう"; + // Baseline (top-aligned) used length of the single column. + let used = { + let content = aligned_content(text, 1000.0, TextAlign::Left); + let layout = layout_content(&content, 1000.0); + layout + .cells + .iter() + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max) + }; + + let top_of = |align: TextAlign| { + let content = aligned_content(text, 1000.0, align); + let layout = layout_content(&content, 1000.0); + layout.cells[0].top + }; + + assert!(top_of(TextAlign::Left).abs() < 0.01); + assert!((top_of(TextAlign::Center) - (1000.0 - used) / 2.0).abs() < 0.5); + assert!((top_of(TextAlign::Right) - (1000.0 - used)).abs() < 0.5); + // Cells stay in reading order and keep tiling under the shift. + let content = aligned_content(text, 1000.0, TextAlign::Right); + let layout = layout_content(&content, 1000.0); + for pair in layout.cells.windows(2) { + assert!(pair[1].top >= pair[0].top); + } + } + + #[test] + fn text_align_auto_width_stays_top() { + // Auto-width columns are snug: alignment must not shift them. + let content = content_of( + vec![vertical_paragraph( + vec![make_span("あいうえお")], + TextAlign::Right, + 1.0, + )], + 60.0, + GrowType::AutoWidth, + ); + + let layout = layout_content(&content, wrap_height(&content, 60.0)); + assert!(layout.cells[0].top.abs() < 0.01); + } + + #[test] + fn justify_fills_non_last_columns() { + use std::collections::BTreeMap; + let text = "あいうえお"; + // Uniform per-cell extent for the test font. + let e = { + let content = aligned_content(text, 1000.0, TextAlign::Left); + layout_content(&content, 1000.0).cells[0].extent + }; + // A 2.5-cell budget wraps into three columns: {0,1}, {2,3}, {4}. The + // first two break with 0.5e of slack; the last column is one cell. + let budget = e * 2.5; + let content = aligned_content(text, budget, TextAlign::Justify); + let layout = layout_content(&content, budget); + + let mut by_col: BTreeMap> = BTreeMap::new(); + for c in &layout.cells { + by_col.entry(c.column).or_default().push(c); + } + assert!(by_col.len() >= 2, "text must wrap into multiple columns"); + let last = *by_col.keys().max().unwrap(); + + for (&col, col_cells) in &by_col { + if col != last && col_cells.len() > 1 { + assert!(col_cells[0].top.abs() < 0.01, "col {col} top cell at top"); + let bottom = col_cells.last().unwrap().top + col_cells.last().unwrap().extent; + assert!( + (bottom - budget).abs() < 0.5, + "justified col {col} fills the budget, bottom {bottom} vs {budget}" + ); + } + } + // The last column keeps its natural top (start-aligned, not stretched). + assert!( + by_col[&last][0].top.abs() < 0.01, + "last column is not justified" + ); + } +} diff --git a/render-wasm/src/shapes/text_vertical/font_tables.rs b/render-wasm/src/shapes/text_vertical/font_tables.rs new file mode 100644 index 0000000000..e31fd2e594 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/font_tables.rs @@ -0,0 +1,230 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::thread::LocalKey; + +use skia_safe::{Font, GlyphId}; + +use crate::shapes::gpos_vpal::{parse_vpal, VpalDelta}; + +/// Parsed font tables keyed by `SkTypefaceID`. `None` is cached too: a face +/// without the table never gains it. +type TableCache = RefCell>>>; + +thread_local! { + // Layouts are recomputed on every paint but a face's tables never + // change, so each table (whose parse copies it) is read once per face. + static VERTICAL_METRICS: TableCache = RefCell::default(); + static VPAL_TABLES: TableCache = RefCell::default(); + static TYPO_METRICS: TableCache = RefCell::default(); +} + +fn cached_table( + cache: &'static LocalKey>, + font: &Font, + parse: impl FnOnce(&Font) -> Option, +) -> Option> { + let id = font.typeface().unique_id(); + cache.with(|cache| { + cache + .borrow_mut() + .entry(id) + .or_insert_with(|| parse(font).map(Rc::new)) + .clone() + }) +} + +fn units_per_em(font: &Font) -> Option { + let units_per_em = font.typeface().units_per_em()? as f32; + (units_per_em > 0.0).then_some(units_per_em) +} + +fn table_data(font: &Font, tag: &[u8; 4]) -> Option { + font.typeface().copy_table_data(u32::from_be_bytes(*tag)) +} + +/// Vertical advances from a font's `vhea`/`vmtx` tables. Upright cells +/// advance down the column by the glyph's true vertical advance rather +/// than its shaped horizontal advance: identical for full-width CJK, but +/// correct for vertical alternates and proportional glyphs whose `vmtx` +/// differs from `hmtx` (e.g. the vertical kana repeat marks). +pub(super) struct VerticalMetrics { + units_per_em: f32, + /// Advance heights of the first `advances.len()` glyph ids. + advances: Vec, + /// Advance for glyph ids at or beyond the long-metric count. + last: u16, +} + +impl VerticalMetrics { + /// Parse `vhea`/`vmtx` off the font's typeface. Returns `None` when the + /// font carries no vertical metrics (the caller then keeps horizontal + /// advances). + pub(super) fn from_font(font: &Font) -> Option { + let units_per_em = units_per_em(font)?; + let vhea = table_data(font, b"vhea")?; + let vmtx = table_data(font, b"vmtx")?; + let vhea = vhea.as_bytes(); + let vmtx = vmtx.as_bytes(); + // `numberOfLongVerMetrics` is the trailing u16 of the 36-byte header. + let num_long = u16::from_be_bytes([*vhea.get(34)?, *vhea.get(35)?]) as usize; + if num_long == 0 { + return None; + } + // Each long metric is { advanceHeight: u16, topSideBearing: i16 }. + let mut advances = Vec::with_capacity(num_long); + for i in 0..num_long { + let o = i * 4; + let (Some(&hi), Some(&lo)) = (vmtx.get(o), vmtx.get(o + 1)) else { + break; + }; + advances.push(u16::from_be_bytes([hi, lo])); + } + let last = *advances.last()?; + Some(Self { + units_per_em, + advances, + last, + }) + } + + /// Vertical advance of `glyph` at `font_size`, in pixels at the shaped + /// size (same units as the horizontal advances). + pub(super) fn advance(&self, glyph: GlyphId, font_size: f32) -> f32 { + let raw = self + .advances + .get(glyph as usize) + .copied() + .unwrap_or(self.last); + raw as f32 * font_size / self.units_per_em + } +} + +pub(super) fn vertical_metrics(font: &Font) -> Option> { + cached_table(&VERTICAL_METRICS, font, VerticalMetrics::from_font) +} + +/// GPOS `vpal` deltas for one typeface, in font units. +pub(super) struct VpalTable { + units_per_em: f32, + deltas: HashMap, +} + +impl VpalTable { + fn from_font(font: &Font) -> Option { + let units_per_em = units_per_em(font)?; + let gpos = table_data(font, b"GPOS")?; + let deltas = parse_vpal(gpos.as_bytes())?; + Some(Self { + units_per_em, + deltas, + }) + } + + /// Pixel deltas for a cluster at `font_size`: summed advance delta + /// (negative when the cell tightens) and the flow-axis shift of the + /// drawn ink (positive down the column). GPOS `yPlacement` is y-up, + /// so its sign flips into flow space. `None` when no glyph of the + /// cluster is covered. + pub(super) fn cluster_delta(&self, glyphs: &[GlyphId], font_size: f32) -> Option<(f32, f32)> { + let scale = font_size / self.units_per_em; + let mut advance = 0.0f32; + let mut flow_shift = None; + for glyph in glyphs { + if let Some(delta) = self.deltas.get(glyph) { + advance += delta.y_advance as f32 * scale; + flow_shift.get_or_insert(-(delta.y_placement as f32) * scale); + } + } + flow_shift.map(|shift| (advance, shift)) + } +} + +pub(super) fn vpal_table(font: &Font) -> Option> { + cached_table(&VPAL_TABLES, font, VpalTable::from_font) +} + +/// OS/2 typographic ascender/descender, normalised to the em (design units / +/// unitsPerEm). These bound the ideographic em box and are what the browser +/// uses for the vertical central baseline; `hhea`/`Font::metrics` are oversized +/// for CJK faces (their ascent exceeds the em) and would push an upright glyph +/// off the ideographic centre of its cell. +struct TypoMetrics { + /// sTypoAscender / unitsPerEm (positive, above the baseline). + ascender: f32, + /// sTypoDescender / unitsPerEm (negative, below the baseline). + descender: f32, +} + +impl TypoMetrics { + fn from_font(font: &Font) -> Option { + let units_per_em = units_per_em(font)?; + let os2 = table_data(font, b"OS/2")?; + let os2 = os2.as_bytes(); + // sTypoAscender @ 68 (i16), sTypoDescender @ 70 (i16); present in every + // OS/2 table version. + let ascender = i16::from_be_bytes([*os2.get(68)?, *os2.get(69)?]) as f32; + let descender = i16::from_be_bytes([*os2.get(70)?, *os2.get(71)?]) as f32; + Some(Self { + ascender: ascender / units_per_em, + descender: descender / units_per_em, + }) + } +} + +/// Ascent/descent for centring an upright cell, in Skia's sign convention +/// (ascent negative, descent positive) at the font's current size. Prefers the +/// OS/2 typographic metrics (the ideographic em box, matching the browser's +/// vertical central baseline used by the SVG/foreignObject export); falls back +/// to `Font::metrics` when the face carries no OS/2 table. +pub(super) fn upright_centre_metrics(font: &Font) -> (f32, f32) { + if let Some(typo) = cached_table(&TYPO_METRICS, font, TypoMetrics::from_font) { + let size = font.size(); + return (-typo.ascender * size, -typo.descender * size); + } + let (_, metrics) = font.metrics(); + (metrics.ascent, metrics.descent) +} + +/// Vertical offset from a cell's top edge to the glyph baseline for an upright +/// cell. Centres the glyph's line box within the em cell (as the Tate-chu-yoko +/// path does) instead of hanging it from the horizontal ascent: CJK faces have +/// an ascent larger than the em, so hanging from it pushes every glyph below +/// its cell and the whole column overflows its bounds. +pub(super) fn upright_baseline_offset(ascent: f32, descent: f32, font_size: f32) -> f32 { + font_size / 2.0 - (ascent + descent) / 2.0 +} + +/// Baseline offset for an upright glyph of `font` in an em cell of `font_size`. +pub(super) fn upright_baseline(font: &Font, font_size: f32) -> f32 { + let (ascent, descent) = upright_centre_metrics(font); + upright_baseline_offset(ascent, descent, font_size) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::{TEST_FONT, VMTX_TEST_FONT}; + use super::*; + use skia_safe::FontMgr; + + #[test] + fn vertical_metrics_parse_vmtx() { + let font_mgr = FontMgr::new(); + let typeface = font_mgr.new_from_data(VMTX_TEST_FONT, None).unwrap(); + let font = Font::new(typeface, 20.0); + let vm = VerticalMetrics::from_font(&font).expect("vmtx present"); + // gid 1 = 〱 (2000 units), gid 3+ fall back to the last long metric. + assert!((vm.advance(1, 20.0) - 40.0).abs() < 0.01); + assert!((vm.advance(3, 20.0) - 20.0).abs() < 0.01); + assert!((vm.advance(99, 20.0) - 20.0).abs() < 0.01); + } + + #[test] + fn vertical_metrics_absent_without_vmtx() { + // The bundled Source Sans face carries no `vmtx`. + let font_mgr = FontMgr::new(); + let typeface = font_mgr.new_from_data(TEST_FONT, None).unwrap(); + let font = Font::new(typeface, 20.0); + assert!(VerticalMetrics::from_font(&font).is_none()); + } +} diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs new file mode 100644 index 0000000000..7a0d552e89 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -0,0 +1,602 @@ +use skia_safe::{ + self as skia, + textlayout::{TextDecoration, TypefaceFontProvider}, + FontMgr, +}; + +use crate::globals::get_resources; +use crate::math::Rect; +use crate::shapes::japanese::JapaneseClass; +use crate::shapes::text_japanese::EMPHASIS_FONT_SCALE; +use crate::shapes::{ + merge_fills, AppliedTextTransform, GrowType, Paragraph, RubySide, TextAlign, TextContent, + TextSpan, VerticalAlign, +}; +use crate::utils::get_fallback_fonts; + +use super::annotations::{ + grow_ruby_bases, layout_emphasis, layout_ruby, ruby_base_units, spread_ruby_base_cells, + EmphasisMark, RubyCell, +}; +use super::cells::{Fonts, SpanCells}; +use super::flow::{ + align_offset_along_column, apply_inter_script_spacing, apply_ordered_oikomi, flow_classes, + is_bounded, materialize_explicit_pair_spacing, ordered_expansion_offsets, + plan_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, FlowCell, +}; +use super::shaping::ShapedRun; + +#[derive(Debug, Clone, Copy)] +pub enum CellKind { + Upright { + run: usize, + glyph: usize, + count: usize, + }, + /// A character that participates in upright Japanese flow and kinsoku, + /// but whose font has no `vert`/`vrt2` alternate. It is rotated per cell + /// instead of becoming a sideways run so wrapping and editor offsets stay + /// character-granular. + SyntheticRotated { + run: usize, + glyph: usize, + count: usize, + }, + Rotated { + run: usize, + }, + TateChuYoko { + /// Composite of one or more shaped runs (fallback fonts each add a + /// run) laid side by side; `[run_start, run_start + run_count)`. + run_start: usize, + run_count: usize, + scale: f32, + }, + Warichu { + /// Two half-size sub-lines stacked side by side within the column: + /// runs `[run_start, run_start + first_count)` are the first (right) + /// sub-line, the rest up to `run_start + run_count` the second (left). + run_start: usize, + run_count: usize, + first_count: usize, + /// UTF-16 length of the first sub-line's text (the split point, + /// relative to the cell's `start`). + first_chars: usize, + }, +} + +/// One placed piece of the vertical flow. Offsets are UTF-16, +/// paragraph-relative (all spans concatenated), in original text space. +pub struct VerticalCell { + pub kind: CellKind, + pub paragraph: usize, + pub span: usize, + pub start: usize, + pub end: usize, + pub column: usize, + pub top: f32, + /// Advance along the column (vertical/flow axis) — from `vmtx` when + /// available, else the shaped horizontal advance. + pub extent: f32, + /// Lower bound for oikomi reductions. This is the glyph frame after font + /// features such as `vpal`, before any removable pair spacing is added. + pub minimum_oikomi_extent: f32, + /// Shaped horizontal glyph advance, used to centre the glyph on the + /// column axis (independent of the vertical flow `extent`). + pub h_advance: f32, + /// Visible glyph-ink edges along the flow axis, relative to `top`. + pub ink_top: f32, + pub ink_bottom: f32, + pub paint: usize, + /// Span font size, for decoration bar geometry. + pub font_size: f32, + /// Span text decoration, painted as vertical bars along the column. + pub decoration: Option, + /// Extra flow-axis (vertical) shift applied to the drawn glyph only, used to + /// pull half-width opening punctuation up into its compressed cell so its + /// ink hugs the preceding character (jlreq leading aki removal). Zero for + /// every other cell; never affects extent, caret, or position-data. + pub glyph_flow_shift: f32, +} + +/// A laid-out column, x measured from the *content* left edge (the content +/// block is anchored to the shape's right edge by consumers). +#[derive(Debug, Clone, Copy)] +pub struct VerticalColumn { + pub x: f32, + pub width: f32, + /// Reserved annotation gutter before the base band (left / `under`). + pub base_offset: f32, + /// Line-height-controlled column advance. Base glyphs centre on this band. + /// Ruby reserves additional column width but attaches to the centred base + /// em, so extra leading does not become base-to-ruby spacing. + pub base_width: f32, +} + +pub(super) fn column_base_center(column: &VerticalColumn) -> f32 { + column.x + column.base_offset + column.base_width / 2.0 +} + +pub struct VerticalLayout { + pub runs: Vec, + pub paints: Vec, + pub cells: Vec, + pub columns: Vec, + /// Shaped ruby annotation runs, indexed by `RubyCell::run`. + pub ruby_runs: Vec, + pub ruby_cells: Vec, + /// Shaped emphasis-mark runs (single glyph each), indexed by + /// `EmphasisMark::run`. + pub emphasis_runs: Vec, + pub emphasis_marks: Vec, + /// Per paragraph: [start, end) range into `columns`. + pub paragraph_columns: Vec<(usize, usize)>, + /// Per paragraph: UTF-16 start offset of each span (paragraph-relative). + pub span_utf16_starts: Vec>, + /// Per paragraph: source UTF-16 start offset of each span. + pub span_source_utf16_starts: Vec>, + /// Per paragraph and span: transformed scalar ownership in source text. + pub span_transforms: Vec>, + /// Per paragraph: UTF-16 offset of every Unicode scalar boundary. Editor + /// positions use indices into this table, while cells and position data + /// keep their browser-facing UTF-16 offsets. + pub paragraph_utf16_boundaries: Vec>, + pub width: f32, + pub height: f32, +} + +impl VerticalLayout { + /// Content origin (top-left of the laid-out block) in the same + /// coordinate space as `bounds`. In vertical-rl, block-start is the + /// right edge, block-center is the horizontal center and block-end is + /// the left edge. + pub fn origin(&self, bounds: &Rect, align: VerticalAlign) -> (f32, f32) { + ( + bounds.left + block_axis_offset(bounds.width(), self.width, align), + bounds.top, + ) + } +} + +/// Horizontal offset of vertical content within its shape. The existing +/// top/center/bottom values describe block-start/center/end; for vertical-rl +/// those positions map to right/center/left respectively. +pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 { + let slack = (container_width - content_width).max(0.0); + match align { + VerticalAlign::Top => slack, + VerticalAlign::Center => slack / 2.0, + VerticalAlign::Bottom => 0.0, + } +} + +/// The column-wrap limit for a vertical text content: auto-width shapes +/// grow to fit (columns never wrap), everything else wraps at the shape +/// height. This is the phase's explicit auto-size decision: auto-height +/// behaves like fixed under vertical writing for now. +pub fn wrap_height(text_content: &TextContent, height: f32) -> f32 { + match text_content.grow_type() { + GrowType::AutoWidth => f32::MAX, + _ => f32::max(height, 1.0), + } +} + +/// Cross-axis geometry shared by every column of a paragraph. +struct ColumnGeometry { + /// Line-height-controlled column advance the base glyphs centre on. + base_width: f32, + /// Annotation gutter on the left (`under`) side of the base band. + under_gutter: f32, + /// Annotation gutter on the right (`over`) side of the base band. + over_gutter: f32, +} + +impl ColumnGeometry { + fn new(paragraph: &Paragraph) -> Self { + let spans = paragraph.children(); + let line_height = if paragraph.line_height() > 0.0 { + paragraph.line_height() + } else { + 1.2 + }; + let max_font_size = spans.iter().map(|s| s.font_size).fold(12.0, f32::max); + + // Ruby reserves its configured-size gutter on the logical annotation + // side. In vertical-rl, `over` is right and `under` is left. + let ruby_gutter = |side| { + spans + .iter() + .filter(|span| span.has_ruby() && span.ruby_side == side) + .map(TextSpan::ruby_font_size) + .fold(0.0, f32::max) + }; + let ruby_over_gutter = ruby_gutter(RubySide::Over); + // Emphasis occupies the over/right side. Auto-clearance spans carrying + // both annotation types stack there; opposite-side ruby stays separate. + let emphasis_gutter = if spans.iter().any(|s| !s.text_emphasis.is_none()) { + max_font_size * EMPHASIS_FONT_SCALE + } else { + 0.0 + }; + let stacked = spans + .iter() + .any(|s| s.stacks_emphasis_outside_ruby() && !s.text_emphasis.is_none()); + let over_gutter = if stacked { + ruby_over_gutter + emphasis_gutter + } else { + ruby_over_gutter.max(emphasis_gutter) + }; + + Self { + base_width: max_font_size * line_height, + under_gutter: ruby_gutter(RubySide::Under), + over_gutter, + } + } + + fn column(&self) -> VerticalColumn { + VerticalColumn { + x: 0.0, + width: self.under_gutter + self.base_width + self.over_gutter, + base_offset: self.under_gutter, + base_width: self.base_width, + } + } +} + +/// The paragraph's cells in flow order, before spacing and placement, plus +/// the UTF-16 start of every span in the paragraph's layout text. +fn build_paragraph_flow( + fonts: &Fonts, + paragraph_index: usize, + paragraph: &Paragraph, + transforms: &[AppliedTextTransform], + bounds: Rect, + runs: &mut Vec, + paints: &mut Vec, +) -> (Vec, Vec) { + let mut flow = Vec::new(); + let mut span_starts = Vec::with_capacity(transforms.len()); + let mut offset = 0usize; + for (span_index, (span, transform)) in paragraph.children().iter().zip(transforms).enumerate() { + span_starts.push(offset); + if transform.text.is_empty() { + continue; + } + paints.push(merge_fills(&span.fills, bounds)); + let span_cells = SpanCells::new( + fonts, + span, + paragraph_index, + span_index, + paints.len() - 1, + offset, + ); + span_cells.push(&transform.text, runs, &mut flow); + offset += transform.text.encode_utf16().count(); + } + keep_transform_expansions_together(&mut flow, transforms, &span_starts); + (flow, span_starts) +} + +/// A CSS transform may expand one source character into several shaped +/// cells. Keep those cells in one column so a source slice is rendered +/// exactly once by the SVG fallback (for example `ß` -> `SS`). +fn keep_transform_expansions_together( + flow: &mut [FlowCell], + transforms: &[AppliedTextTransform], + span_starts: &[usize], +) { + let source_range = |cell: &VerticalCell| { + let start = span_starts[cell.span]; + transforms[cell.span].source_utf16_range(cell.start - start..cell.end - start) + }; + for index in 1..flow.len() { + let (previous, current) = (&flow[index - 1].cell, &flow[index].cell); + flow[index].keep_with_previous = + previous.span == current.span && source_range(previous) == source_range(current); + } +} + +/// Final flow-axis top of every cell: its planned offset, shifted by the +/// paragraph's text-align. Justify stretches every column but the last (the +/// last "line") to fill the wrap budget; a snug auto-width budget has no +/// slack. +fn aligned_tops( + flow: &[FlowCell], + classes: &[Option], + pair_spacing_em: &[f32], + placements: &[(usize, f32)], + align: TextAlign, + max_height: f32, +) -> Vec { + let columns_used = placements.last().map_or(1, |(column, _)| column + 1); + // A column's used length is its farthest cell bottom. + let mut column_used = vec![0.0f32; columns_used]; + for (flow, (column, top)) in flow.iter().zip(placements) { + column_used[*column] = column_used[*column].max(top + flow.cell.extent); + } + let expansion = if matches!(align, TextAlign::Justify) && is_bounded(max_height) { + ordered_expansion_offsets( + flow, + classes, + pair_spacing_em, + placements, + &column_used, + max_height, + ) + } else { + vec![0.0; flow.len()] + }; + placements + .iter() + .zip(expansion) + .map(|((column, top), extra)| { + top + align_offset_along_column(align, max_height, column_used[*column]) + extra + }) + .collect() +} + +/// Lay out the whole content vertically. Pure of global state: fonts come +/// through the provider/fallback arguments so native tests can supply +/// their own. +pub fn layout_vertical( + text_content: &TextContent, + max_height: f32, + font_provider: &TypefaceFontProvider, + fallback_mgr: FontMgr, + fallback_families: &[String], + bounds: Rect, +) -> VerticalLayout { + let fonts = Fonts { + provider: font_provider, + fallback_mgr: &fallback_mgr, + fallback_families, + }; + let paragraphs = text_content.paragraphs(); + let span_transforms: Vec> = paragraphs + .iter() + .map(|paragraph| { + paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform_with_source_ranges) + .collect() + }) + .collect(); + let paragraph_utf16_boundaries: Vec> = span_transforms + .iter() + .map(|paragraph| { + let mut boundaries = vec![0usize]; + for character in paragraph.iter().flat_map(|span| span.text.chars()) { + let next = boundaries.last().copied().unwrap_or(0) + character.len_utf16(); + boundaries.push(next); + } + boundaries + }) + .collect(); + let span_source_utf16_starts: Vec> = paragraphs + .iter() + .map(|paragraph| { + let mut offset = 0usize; + paragraph + .children() + .iter() + .map(|span| { + let start = offset; + offset += span.text.encode_utf16().count(); + start + }) + .collect() + }) + .collect(); + + let mut runs: Vec = Vec::new(); + let mut paints: Vec = Vec::new(); + let mut cells: Vec = Vec::new(); + let mut columns: Vec = Vec::new(); + let mut paragraph_columns: Vec<(usize, usize)> = Vec::new(); + let mut span_utf16_starts: Vec> = Vec::new(); + + for (paragraph_index, paragraph) in paragraphs.iter().enumerate() { + let transforms = &span_transforms[paragraph_index]; + let (mut flow, span_starts) = build_paragraph_flow( + &fonts, + paragraph_index, + paragraph, + transforms, + bounds, + &mut runs, + &mut paints, + ); + let ruby_units = ruby_base_units(paragraph, transforms, &span_starts); + let ruby_spans: Vec = paragraph + .children() + .iter() + .map(TextSpan::has_ruby) + .collect(); + + apply_inter_script_spacing(&mut flow); + let classes = flow_classes(&flow, &ruby_spans); + shed_punctuation_aki(&mut flow, &classes); + materialize_explicit_pair_spacing(&mut flow, &classes); + grow_ruby_bases(&mut flow, &ruby_units); + let mut pair_spacing_em = preferred_pair_spacing(&classes); + apply_ordered_oikomi(&mut flow, &classes, &mut pair_spacing_em, max_height); + let placements = + plan_with_edge_trimming(&mut flow, &classes, &mut pair_spacing_em, max_height); + let tops = aligned_tops( + &flow, + &classes, + &pair_spacing_em, + &placements, + paragraph.text_align(), + max_height, + ); + + let columns_used = placements.last().map_or(1, |(column, _)| column + 1); + let column_base = columns.len(); + columns.extend(std::iter::repeat_n( + ColumnGeometry::new(paragraph).column(), + columns_used, + )); + paragraph_columns.push((column_base, column_base + columns_used)); + + let paragraph_cell_start = cells.len(); + for ((flow, (column, _)), top) in flow.into_iter().zip(placements).zip(tops) { + cells.push(VerticalCell { + column: column_base + column, + top, + ..flow.cell + }); + } + spread_ruby_base_cells(&mut cells[paragraph_cell_start..], &ruby_units, max_height); + span_utf16_starts.push(span_starts); + } + + // Columns advance right->left: column 0 is the rightmost. + let width: f32 = columns.iter().map(|c| c.width).sum(); + let mut right = width; + for column in columns.iter_mut() { + right -= column.width; + column.x = right; + } + + 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 height = cells + .iter() + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max); + + VerticalLayout { + runs, + paints, + cells, + columns, + ruby_runs, + ruby_cells, + emphasis_runs, + emphasis_marks, + paragraph_columns, + span_utf16_starts, + span_source_utf16_starts, + span_transforms, + paragraph_utf16_boundaries, + width, + height, + } +} + +/// Lay out with the render state's font store. +fn layout_from_content(text_content: &TextContent, max_height: f32) -> VerticalLayout { + let font_provider = get_resources().fonts.font_provider(); + let fallback_mgr = FontMgr::from(font_provider.clone()); + let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); + layout_vertical( + text_content, + max_height, + font_provider, + fallback_mgr, + &fallback_families, + text_content.bounds(), + ) +} + +/// Production entry point: lay out the content for a box `height` tall. +pub fn layout_for_box(text_content: &TextContent, height: f32) -> VerticalLayout { + layout_from_content(text_content, wrap_height(text_content, height)) +} + +/// Content size (width, height) of the vertical layout for a box `height` +/// tall, for auto-sizing. +pub fn measure_content(text_content: &TextContent, height: f32) -> (f32, f32) { + let layout = layout_for_box(text_content, height); + (layout.width, layout.height) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use super::*; + + #[test] + fn layout_cells_tile_the_text() { + let text = "縦書きのAB12テスト。"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + let mut expected = 0usize; + for cell in &layout.cells { + assert_eq!(cell.paragraph, 0); + assert_eq!(cell.start, expected, "cells must tile without gaps"); + assert!(cell.end > cell.start); + expected = cell.end; + } + assert_eq!(expected, text.encode_utf16().count()); + } + + #[test] + fn layout_columns_respect_wrap_height() { + let content = make_content(&["あいうえおかきくけこ"], 100.0); + let layout = layout_content(&content, 60.0); + + assert!(layout.columns.len() > 1, "content must wrap into columns"); + for column_index in 0..layout.columns.len() { + let bottom = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max); + assert!(bottom <= 60.0 + 0.01, "column overflows the wrap height"); + } + // Columns advance leftward: column 0 is the rightmost. + assert!(layout.columns[0].x > layout.columns[1].x); + let total: f32 = layout.columns.iter().map(|c| c.width).sum(); + assert!((layout.width - total).abs() < 0.01); + } + + #[test] + fn layout_each_paragraph_starts_a_new_column() { + let content = make_content(&["あい", "うえ"], 1000.0); + let layout = layout_content(&content, 1000.0); + + assert_eq!(layout.paragraph_columns.len(), 2); + let (p0_start, p0_end) = layout.paragraph_columns[0]; + let (p1_start, _) = layout.paragraph_columns[1]; + assert_eq!(p0_start, 0); + assert_eq!(p0_end, p1_start); + assert!(layout + .cells + .iter() + .all(|c| (c.paragraph == 0) == (c.column < p0_end))); + } + + #[test] + fn layout_empty_paragraph_still_takes_a_column() { + let content = make_content(&["あ", "", "い"], 1000.0); + let layout = layout_content(&content, 1000.0); + assert_eq!(layout.columns.len(), 3); + assert_eq!(layout.paragraph_columns[1], (1, 2)); + } + + #[test] + fn block_axis_alignment_maps_start_center_end_to_right_center_left() { + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Top), 160.0); + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Center), 80.0); + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Bottom), 0.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), 0.0); + } + + #[test] + fn wrapped_vertical_content_grows_across_columns() { + let content = make_content(&["あいうえおかきくけこ"], 60.0); + let layout = layout_content(&content, 60.0); + assert!(layout.columns.len() > 1); + assert!(layout.width > layout.columns[0].width); + assert!(layout.height <= 60.0 + 0.01); + } +} diff --git a/render-wasm/src/shapes/text_vertical/mod.rs b/render-wasm/src/shapes/text_vertical/mod.rs new file mode 100644 index 0000000000..7397d5954f --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/mod.rs @@ -0,0 +1,52 @@ +// Vertical (tategaki) text layout: columns flow top->bottom and advance +// right->left. Skia's skparagraph has no vertical writing mode, so this +// module owns the whole vertical pipeline: +// +// segment spans by glyph orientation (`orientation`) -> shape each segment +// with SkShaper, OpenType `vert`/`vrt2` for upright runs (`shaping`) -> +// derive cells: one per upright glyph cluster, one per rotated run, one per +// composite (`cells`) -> JLREQ spacing and kinsoku-aware column breaks +// (`flow`) -> place columns and annotations (`layout`, `annotations`) -> +// paint / outline export (`paint`) and position data / hit-testing +// (`positions`) from the cells. +// +// Offset discipline: cells use UTF-16 offsets in transformed layout text. +// Position data maps those ranges back to the original span text before it +// crosses the WASM boundary; the WORD JOINER OffsetMap remains exclusive to +// skparagraph-driven horizontal breaks. +// +// Layouts are computed on demand (like the horizontal path, which rebuilds +// its skparagraph objects per paint); only per-typeface font tables are +// cached. +// +// Text-align aligns each column's glyphs along the vertical (inline) axis: +// Left/Start->top, Center->middle, Right/End->bottom of the wrap budget. +// Justify stretches every column but the last to fill the wrap budget. +// +// Letter-spacing adds inter-glyph advance along the column — once per +// upright cluster and once per glyph inside a rotated run — mirroring the +// horizontal `letter-spacing` that Skia applies to each glyph advance. +// +// Deferred to a later phase: PDF/vector emoji overlays, inner shadows and +// block-axis vertical-align. + +mod annotations; +mod cells; +mod flow; +mod font_tables; +mod layout; +mod orientation; +mod paint; +mod positions; +mod shaping; + +#[cfg(test)] +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, +}; +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/orientation.rs b/render-wasm/src/shapes/text_vertical/orientation.rs new file mode 100644 index 0000000000..2500faed00 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/orientation.rs @@ -0,0 +1,182 @@ +use crate::shapes::TextOrientation; + +/// Emoji ranges recognized by the frontend font-loader. Emoji use their +/// intrinsic upright presentation in vertical flow instead of rotating like +/// Latin text under `text-orientation: mixed`. +pub(super) fn is_emoji_char(c: char) -> bool { + matches!(u32::from(c), + 0x2300..=0x23FF + | 0x2600..=0x27BF + | 0x2B00..=0x2BFF + | 0x1F000..=0x1FAFF + ) +} + +/// Characters that stay upright in vertical flow: kana, kanji, CJK +/// punctuation, full-width forms and emoji. Everything else rotates sideways +/// under `text-orientation: mixed`. +pub(super) fn is_upright_char(c: char) -> bool { + is_emoji_char(c) + || matches!(u32::from(c), + 0x2E80..=0x2FDF // CJK radicals, Kangxi radicals + | 0x3000..=0x303F // CJK symbols and punctuation + | 0x3040..=0x30FF // hiragana, katakana + | 0x31C0..=0x31EF // CJK strokes + | 0x31F0..=0x31FF // katakana phonetic extensions + | 0x3200..=0x33FF // enclosed CJK, CJK compatibility + | 0x3400..=0x4DBF // CJK unified ideographs extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xAC00..=0xD7AF // hangul syllables + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF00..=0xFF60 // full-width forms + | 0xFFE0..=0xFFE6 // full-width signs + | 0x20000..=0x2FA1F // CJK extensions B..F + ) +} + +/// Characters whose horizontal glyph needs a vertical alternate. `vert` / +/// `vrt2` normally supplies that alternate; when the selected face has no +/// such substitution, rotate the horizontal glyph clockwise as a legible +/// fallback. The set covers UAX #50 `Tr`, plus comma/full-stop punctuation +/// whose untransformed glyph otherwise occupies the wrong half of the +/// vertical em box. +pub(super) fn uses_rotated_vertical_fallback(c: char) -> bool { + matches!(u32::from(c), + 0x2018..=0x2019 // single quotation marks + | 0x201C..=0x201D // double quotation marks + | 0x2329..=0x232A // angle brackets + | 0x3001..=0x3002 // ideographic comma / full stop + | 0x3008..=0x301F // CJK brackets, wave dash and quotation marks + | 0x3030 // wavy dash + | 0x30A0 // katakana-hiragana double hyphen + | 0x30FC // prolonged sound mark + | 0xFE50..=0xFE52 // small comma / ideographic comma / full stop + | 0xFE59..=0xFE5E // small brackets + | 0xFF08..=0xFF09 // full-width parentheses + | 0xFF0C // full-width comma + | 0xFF0D // full-width hyphen-minus + | 0xFF0E // full-width full stop + | 0xFF1A..=0xFF1B // full-width colon / semicolon + | 0xFF1C..=0xFF1E // full-width comparison signs + | 0xFF3B // full-width left square bracket + | 0xFF3D // full-width right square bracket + | 0xFF3F // full-width low line + | 0xFF5B..=0xFF60 // full-width braces, bars, tilde and white parentheses + | 0xFFE3 // full-width macron + ) +} + +#[derive(Debug, PartialEq)] +pub(super) struct Segment { + pub text: String, + /// UTF-16 offset of the segment start within its span text. + pub utf16_start: usize, + pub upright: bool, +} + +/// Split text into maximal runs of same orientation. Under +/// `TextOrientation::Upright` every character is upright. +pub(super) fn segment_by_orientation(text: &str, orientation: TextOrientation) -> Vec { + let mut segments: Vec = Vec::new(); + let mut utf16_offset = 0; + for c in text.chars() { + let upright = orientation == TextOrientation::Upright || is_upright_char(c); + let emoji = is_emoji_char(c); + // CJK and emoji are both upright, but keep a shaping boundary between + // them so the segment probe can explicitly select the emoji family. + // The provider's generic fallback iterator does not reliably switch + // from a registered CJK face to a registered color-emoji face. + match segments.last_mut() { + Some(last) + if last.upright == upright + && last.text.chars().next_back().is_some_and(is_emoji_char) == emoji => + { + last.text.push(c) + } + _ => segments.push(Segment { + text: c.to_string(), + utf16_start: utf16_offset, + upright, + }), + } + utf16_offset += c.len_utf16(); + } + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upright_classification() { + assert!(is_upright_char('あ')); + assert!(is_upright_char('ア')); + assert!(is_upright_char('漢')); + assert!(is_upright_char('。')); + assert!(is_upright_char('「')); + assert!(is_upright_char('ー')); + assert!(is_upright_char('!')); + assert!(!is_upright_char('A')); + assert!(!is_upright_char('1')); + assert!(!is_upright_char(' ')); + assert!(!is_upright_char('.')); + } + + #[test] + fn emoji_stay_upright_in_vertical_text() { + for emoji in ['😆', '💦', '🙇'] { + assert!(is_upright_char(emoji)); + } + + let segments = segment_by_orientation("久😆元💦🙇", TextOrientation::Mixed); + assert_eq!(segments.len(), 4); + assert!(segments.iter().all(|segment| segment.upright)); + assert_eq!(segments[1].text, "😆"); + assert_eq!(segments[3].text, "💦🙇"); + } + + #[test] + fn transformed_japanese_punctuation_has_a_rotated_font_fallback() { + for character in [ + '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '、', '。', 'ー', '〜', + ':', ';', + ] { + assert!( + uses_rotated_vertical_fallback(character), + "{character} needs a transformed vertical glyph" + ); + } + assert!(!uses_rotated_vertical_fallback('あ')); + assert!(!uses_rotated_vertical_fallback('漢')); + assert!(!uses_rotated_vertical_fallback('!')); + } + + #[test] + fn segments_mixed_text() { + let segments = segment_by_orientation("縦書きABC123です", TextOrientation::Mixed); + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].text, "縦書き"); + assert!(segments[0].upright); + assert_eq!(segments[0].utf16_start, 0); + assert_eq!(segments[1].text, "ABC123"); + assert!(!segments[1].upright); + assert_eq!(segments[1].utf16_start, 3); + assert_eq!(segments[2].text, "です"); + assert!(segments[2].upright); + assert_eq!(segments[2].utf16_start, 9); + } + + #[test] + fn segments_upright_orientation_keeps_latin_upright() { + let segments = segment_by_orientation("縦ABC", TextOrientation::Upright); + assert_eq!(segments.len(), 1); + assert!(segments[0].upright); + } + + #[test] + fn segments_empty_text() { + assert!(segment_by_orientation("", TextOrientation::Mixed).is_empty()); + } +} diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs new file mode 100644 index 0000000000..bdc4d5154c --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -0,0 +1,779 @@ +use skia_safe::{ + self as skia, canvas::SaveLayerRec, textlayout::Paragraph as SkiaParagraph, + textlayout::TextDecoration, BlendMode, Canvas, ImageFilter, Matrix, Paint, Point as SkPoint, + TextBlob, +}; + +use crate::math::Rect; +use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign}; + +use super::annotations::{emphasis_gutter_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}; + +/// One text blob placed in document space: drawn at `offset` in the local +/// space that `transform` maps to the document (identity when `None`). +/// Canvas painting and outline export consume the same draws, so both stay +/// in sync for every cell kind. +struct GlyphDraw { + blob: TextBlob, + offset: SkPoint, + transform: Option, +} + +impl GlyphDraw { + fn at(blob: TextBlob, offset: impl Into) -> Self { + Self { + blob, + offset: offset.into(), + transform: None, + } + } + + fn transformed(blob: TextBlob, offset: impl Into, transform: Matrix) -> Self { + Self { + blob, + offset: offset.into(), + transform: Some(transform), + } + } + + fn draw(&self, canvas: &Canvas, paint: &Paint) { + match &self.transform { + None => { + canvas.draw_text_blob(&self.blob, self.offset, paint); + } + Some(transform) => { + canvas.save(); + canvas.concat(transform); + canvas.draw_text_blob(&self.blob, self.offset, paint); + canvas.restore(); + } + } + } + + fn into_path(self) -> skia::Path { + let path = text_blob_path(self.blob, self.offset); + match self.transform { + None => path, + Some(transform) => path.make_transform(&transform), + } + } +} + +/// Glyphs on their side: +x of the blob runs down the column from `origin`. +fn rotated_at(origin: (f32, f32)) -> Matrix { + let mut matrix = Matrix::translate(origin); + matrix.pre_rotate(90.0, None); + matrix +} + +/// Glyph draws of one cell, for a layout whose content origin is `origin`. +fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32)) -> Vec { + let column = &layout.columns[cell.column]; + let x_center = origin.0 + column_base_center(column); + let y_top = origin.1 + cell.top; + match cell.kind { + CellKind::Upright { run, glyph, count } => { + let run = &layout.runs[run]; + let baseline = + y_top + cell.glyph_flow_shift + upright_baseline(&run.font, cell.font_size); + run.cluster_blob(glyph, count) + .map(|blob| GlyphDraw::at(blob, (x_center - cell.h_advance / 2.0, baseline))) + .into_iter() + .collect() + } + CellKind::SyntheticRotated { run, glyph, count } => { + let run = &layout.runs[run]; + run.cluster_blob(glyph, count) + .map(|blob| { + GlyphDraw::transformed( + blob, + (0.0, run.cluster_rotated_baseline_shift(glyph, count)), + rotated_at((x_center, y_top + cell.glyph_flow_shift)), + ) + }) + .into_iter() + .collect() + } + CellKind::Rotated { run } => { + let run = &layout.runs[run]; + // After rotation +x runs down the column and +y runs across it; + // the shift centres the run's actual ink band on the column axis. + run.blob() + .map(|blob| { + GlyphDraw::transformed( + blob, + (0.0, run.rotated_baseline_shift), + rotated_at((x_center, y_top)), + ) + }) + .into_iter() + .collect() + } + CellKind::TateChuYoko { + run_start, + run_count, + scale, + } => { + let composite = &layout.runs[run_start..run_start + run_count]; + let combined_advance: f32 = composite.iter().map(|run| run.advance).sum(); + // Vertical centring uses the tallest run's ascent/descent. + let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| { + let (_, metrics) = run.font.metrics(); + (a.min(metrics.ascent), d.max(metrics.descent)) + }); + let x0 = x_center - (combined_advance * scale) / 2.0; + let baseline = y_top + cell.font_size / 2.0 - ((ascent + descent) * scale) / 2.0; + let mut transform = Matrix::translate((x0, baseline)); + transform.pre_scale((scale, scale), None); + let mut cursor = 0.0f32; + let mut draws = Vec::with_capacity(composite.len()); + for run in composite { + if let Some(blob) = run.blob() { + draws.push(GlyphDraw::transformed(blob, (cursor, 0.0), transform)); + } + cursor += run.advance; + } + draws + } + CellKind::Warichu { + run_start, + run_count, + first_count, + .. + } => { + let (first, second) = + layout.runs[run_start..run_start + run_count].split_at(first_count); + let quarter = cell.font_size / 4.0; + // vertical-rl: the first sub-line reads first, on the right half. + let mut draws = warichu_line_draws(first, x_center + quarter, y_top); + draws.extend(warichu_line_draws(second, x_center - quarter, y_top)); + draws + } + } +} + +/// One warichu sub-line: upright half-size glyphs stacked down the +/// sub-column centred on `x_center`, clusters kept together like the normal +/// upright path. +fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec { + let mut draws = Vec::new(); + let mut cursor = y_top; + for run in runs { + let font_size = run.font.size(); + let baseline_offset = upright_baseline(&run.font, font_size); + for (glyph, count) in run.cluster_spans() { + let advance = match run.cluster_advance(glyph, count) { + advance if advance > 0.0 => advance, + _ => font_size, + }; + if let Some(blob) = run.cluster_blob(glyph, count) { + draws.push(GlyphDraw::at( + blob, + (x_center - advance / 2.0, cursor + baseline_offset), + )); + } + cursor += advance; + } + } + draws +} + +/// One column's slice of a ruby annotation stacked upright in its column's +/// ruby gutter. Each glyph's flow-axis position comes from the whole base +/// span; each glyph is centred across the gutter using its own +/// fallback-font run. +fn ruby_draws(layout: &VerticalLayout, ruby: &RubyCell, origin: (f32, f32)) -> Vec { + let column = &layout.columns[ruby.column]; + let gutter_center = origin.0 + + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) + + ruby.font_size / 2.0; + ruby.glyphs + .iter() + .zip(&ruby.glyph_tops) + .filter_map(|(ruby_glyph, top)| { + let run = &layout.ruby_runs[ruby_glyph.run]; + let blob = single_glyph_blob(&run.font, *run.glyphs.get(ruby_glyph.glyph)?)?; + let (_, metrics) = run.font.metrics(); + let advance = run + .advances + .get(ruby_glyph.glyph) + .copied() + .unwrap_or(ruby.font_size); + Some(GlyphDraw::at( + blob, + ( + gutter_center - advance / 2.0, + origin.1 + top - metrics.ascent, + ), + )) + }) + .collect() +} + +/// One emphasis mark (圏点) centred on its base cell's flow extent, in the +/// column's right-side gutter. +fn emphasis_draw( + layout: &VerticalLayout, + mark: &EmphasisMark, + origin: (f32, f32), +) -> 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 (_, 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 advance = run.advances.first().copied().unwrap_or(0.0); + Some(GlyphDraw::at( + blob, + (gutter_center - advance / 2.0, baseline), + )) +} + +/// Underline and line-through bars of a cell. +fn decoration_rects( + layout: &VerticalLayout, + cell: &VerticalCell, + origin: (f32, f32), +) -> Vec { + let Some(decoration) = cell.decoration else { + return Vec::new(); + }; + let mut rects = Vec::new(); + if decoration.contains(TextDecoration::UNDERLINE) { + rects.push(decoration_bar(layout, cell, origin.0, origin.1, false)); + } + if decoration.contains(TextDecoration::LINE_THROUGH) { + rects.push(decoration_bar(layout, cell, origin.0, origin.1, true)); + } + 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( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, +) { + let origin = layout.origin(bounds, vertical_align); + for cell in &layout.cells { + let paint = &layout.paints[cell.paint]; + for draw in cell_draws(layout, cell, origin) { + draw.draw(canvas, paint); + } + let mut decoration_paint = paint.clone(); + decoration_paint.set_style(skia::PaintStyle::Fill); + decoration_paint.set_anti_alias(true); + for rect in decoration_rects(layout, cell, origin) { + canvas.draw_rect(rect, &decoration_paint); + } + } + for ruby in &layout.ruby_cells { + for draw in ruby_draws(layout, ruby, origin) { + draw.draw(canvas, &layout.paints[ruby.paint]); + } + } + for mark in &layout.emphasis_marks { + if let Some(draw) = emphasis_draw(layout, mark, origin) { + draw.draw(canvas, &layout.paints[mark.paint]); + } + } +} + +/// Paint every cell's glyphs with a single overriding paint (stroke / +/// shadow silhouette passes). +fn paint_glyphs( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + paint: &Paint, +) { + let origin = layout.origin(bounds, vertical_align); + for cell in &layout.cells { + for draw in cell_draws(layout, cell, origin) { + draw.draw(canvas, paint); + } + } +} + +/// Paint the text content vertically inside its bounds. Returns false +/// when the content is not vertical, so the caller falls back to the +/// horizontal path. +pub fn paint_text_vertical( + canvas: &Canvas, + text_content: &TextContent, + vertical_align: VerticalAlign, +) -> bool { + if !text_content.is_vertical() { + return false; + } + let bounds = text_content.bounds(); + let layout = layout_for_box(text_content, bounds.height()); + paint_layout(canvas, &layout, &bounds, vertical_align); + true +} + +/// Glyph-outline paths of the layout, in the same order and with the same +/// paints as the canvas fill pass. +fn paths_from_layout( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + antialias: bool, +) -> Vec<(skia::Path, Paint)> { + let mut paths = Vec::new(); + let origin = layout.origin(bounds, vertical_align); + for cell in &layout.cells { + let paint = &layout.paints[cell.paint]; + for draw in cell_draws(layout, cell, origin) { + push_text_path(&mut paths, draw.into_path(), paint, antialias); + } + for rect in decoration_rects(layout, cell, origin) { + push_text_path(&mut paths, skia::Path::rect(rect, None), paint, antialias); + } + } + for ruby in &layout.ruby_cells { + for draw in ruby_draws(layout, ruby, origin) { + push_text_path( + &mut paths, + draw.into_path(), + &layout.paints[ruby.paint], + antialias, + ); + } + } + for mark in &layout.emphasis_marks { + if let Some(draw) = emphasis_draw(layout, mark, origin) { + push_text_path( + &mut paths, + draw.into_path(), + &layout.paints[mark.paint], + antialias, + ); + } + } + paths +} + +/// Convert the custom vertical layout to glyph-outline paths using the same +/// cells, composite transforms, annotations and alignment as the canvas pass. +pub fn vertical_text_paths( + text_content: &TextContent, + vertical_align: VerticalAlign, + antialias: bool, +) -> Vec<(skia::Path, Paint)> { + if !text_content.is_vertical() { + return Vec::new(); + } + let bounds = text_content.bounds(); + let layout = layout_for_box(text_content, bounds.height()); + paths_from_layout(&layout, &bounds, vertical_align, antialias) +} + +/// Developer overlay: draw a jlreq-style character-frame grid over the +/// laid-out vertical cells. For every column it outlines the column band; +/// for every cell it draws the advance box (the real layout cell), the +/// virtual body / em square centred on the column axis, and the glyph-ink +/// band. This exposes solid-setting, aki, letter-spacing and column +/// planning visually, mirroring the grids in the jlreq figures. It is never +/// emitted to exported or persisted output — only the on-screen fills pass +/// calls it, gated by the `TEXT_GRID_VISIBLE` render flag. +pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align: VerticalAlign) { + let (origin_x, origin_y) = layout.origin(bounds, align); + + let mut column_paint = Paint::default(); + column_paint.set_anti_alias(true); + column_paint.set_style(skia::PaintStyle::Stroke); + column_paint.set_stroke_width(1.0); + column_paint.set_color(skia::Color::from_argb(0x55, 0x88, 0x88, 0x88)); + + let mut advance_paint = Paint::default(); + advance_paint.set_anti_alias(true); + advance_paint.set_style(skia::PaintStyle::Stroke); + advance_paint.set_stroke_width(1.0); + advance_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED)); + + let mut em_paint = Paint::default(); + em_paint.set_anti_alias(true); + em_paint.set_style(skia::PaintStyle::Stroke); + em_paint.set_stroke_width(1.0); + em_paint.set_color(skia::Color::from_argb(0x99, 0xEB, 0x57, 0x57)); + + let mut ink_paint = Paint::default(); + ink_paint.set_anti_alias(true); + ink_paint.set_style(skia::PaintStyle::Stroke); + ink_paint.set_stroke_width(1.0); + ink_paint.set_color(skia::Color::from_argb(0x77, 0x27, 0xAE, 0x60)); + + // Column bands over the full used height. + for column in &layout.columns { + let x = origin_x + column.x; + canvas.draw_rect( + Rect::from_xywh(x, origin_y, column.width, layout.height), + &column_paint, + ); + } + + for cell in &layout.cells { + let column = &layout.columns[cell.column]; + let x_left = origin_x + column.x; + let y_top = origin_y + cell.top; + let x_center = origin_x + column_base_center(column); + + // Advance box: the real layout cell along the column axis. + canvas.draw_rect( + Rect::from_xywh(x_left, y_top, column.base_width, cell.extent), + &advance_paint, + ); + + // Virtual body / em square, centred on the column axis and on the + // cell's advance so aki and letter-spacing are visible. + let em = cell.font_size.max(1.0); + canvas.draw_rect( + Rect::from_xywh( + x_center - em / 2.0, + y_top + (cell.extent - em) / 2.0, + em, + em, + ), + &em_paint, + ); + + // Glyph-ink band along the flow axis. + if cell.ink_bottom > cell.ink_top { + canvas.draw_line( + (x_left, origin_y + cell.top + cell.ink_top), + (x_left + column.width, origin_y + cell.top + cell.ink_top), + &ink_paint, + ); + canvas.draw_line( + (x_left, origin_y + cell.top + cell.ink_bottom), + (x_left + column.width, origin_y + cell.top + cell.ink_bottom), + &ink_paint, + ); + } + } +} + +/// Paint the vertical glyph shadows. The shadow paint carries the +/// blur/offset image filter; drawing the glyphs directly through it renders +/// each glyph's shadow without allocating a full-content offscreen layer +/// (a `save_layer` with the filter can exceed GPU limits on tall columns). +pub fn paint_drop_shadow( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + shadow_paint: &Paint, +) { + let mut paint = shadow_paint.clone(); + paint.set_color(skia::Color::BLACK); + paint.set_anti_alias(true); + paint_glyphs(canvas, layout, bounds, vertical_align, &paint); +} + +/// Paint a stroke masked to the vertical glyph silhouettes. Center strokes +/// draw the stroked outline directly; inner/outer strokes are masked with +/// `SrcIn` / `SrcOut` against the glyph silhouette (mirrors the horizontal +/// masked-stroke path). +pub fn paint_stroke( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + stroke: &Stroke, + selrect: &Rect, + blur: Option<&ImageFilter>, +) { + let (stroke_paints, layer_opacity) = + crate::render::text::get_text_stroke_paints(stroke, selrect, false); + + 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().paint(&blur_paint)); + } + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + } + + for stroke_paint in &stroke_paints { + match stroke.kind { + StrokeKind::Center => { + paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint) + } + StrokeKind::Inner => paint_masked_stroke( + canvas, + layout, + bounds, + vertical_align, + stroke_paint, + BlendMode::SrcIn, + ), + StrokeKind::Outer => paint_masked_stroke( + canvas, + layout, + bounds, + vertical_align, + stroke_paint, + BlendMode::SrcOut, + ), + } + } + + if layer_opacity.is_some() { + canvas.restore(); + } + if blur.is_some() { + canvas.restore(); + } +} + +fn paint_masked_stroke( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + stroke_paint: &Paint, + blend: BlendMode, +) { + let mut mask = Paint::default(); + mask.set_color(skia::Color::BLACK); + mask.set_anti_alias(true); + + canvas.save_layer(&SaveLayerRec::default()); + paint_glyphs(canvas, layout, bounds, vertical_align, &mask); + + let mut blend_paint = Paint::default(); + blend_paint.set_blend_mode(blend); + canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint); + canvas.restore(); + + canvas.restore(); +} + +fn text_blob_path(mut blob: TextBlob, offset: impl Into) -> skia::Path { + // SkParagraph normalizes extracted glyph outlines against the blob's ink + // bounds. Restore that origin before applying the draw offset so the path + // occupies the same document coordinates as Canvas::draw_text_blob. + let bounds = *blob.bounds(); + let offset = offset.into(); + SkiaParagraph::get_path(&mut blob).with_offset((offset.x + bounds.left, offset.y + bounds.top)) +} + +fn push_text_path( + paths: &mut Vec<(skia::Path, Paint)>, + path: skia::Path, + paint: &Paint, + antialias: bool, +) { + if path.is_empty() { + return; + } + let mut paint = paint.clone(); + paint.set_anti_alias(antialias); + paths.push((path, paint)); +} + +/// Decoration bar geometry for a cell in absolute coordinates. Underline +/// runs along the *left* side of the column (the under side in +/// `vertical-rl`); line-through runs down the column centre. Both span the +/// cell's vertical extent so consecutive decorated cells tile a continuous +/// bar. +fn decoration_bar( + layout: &VerticalLayout, + cell: &VerticalCell, + origin_x: f32, + origin_y: f32, + line_through: bool, +) -> skia::Rect { + let column = &layout.columns[cell.column]; + let x_center = origin_x + column_base_center(column); + let y_top = origin_y + cell.top; + let thickness = (cell.font_size * 0.06).max(1.0); + let bar_x = if line_through { + x_center + } else { + x_center - cell.font_size / 2.0 - cell.font_size / 9.0 + }; + skia::Rect::from_ltrb( + bar_x - thickness / 2.0, + y_top, + bar_x + thickness / 2.0, + y_top + cell.extent, + ) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use super::*; + use crate::shapes::{StrokeStyle, TextEmphasis, TextOrientation, TextSpan}; + + #[test] + fn shape_to_path_places_vertical_glyphs_down_the_column() { + let content = make_content(&["あく"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + + let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true); + + assert_eq!(paths.len(), 2, "one outline path per upright glyph"); + let first = paths[0].0.bounds(); + let second = paths[1].0.bounds(); + assert!( + second.top > first.top, + "the second glyph outline follows the first down the vertical flow axis" + ); + let horizontal_shift = (second.center_x() - first.center_x()).abs(); + let vertical_shift = second.center_y() - first.center_y(); + assert!( + vertical_shift > horizontal_shift, + "vertical flow dominates the glyphs' optical side-bearing difference" + ); + } + + #[test] + fn shape_to_path_preserves_the_text_blob_draw_origin() { + let content = make_content(&["あ"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let CellKind::Upright { run, glyph, count } = layout.cells[0].kind else { + panic!("expected an upright cell"); + }; + let blob = layout.runs[run] + .cluster_blob(glyph, count) + .expect("a glyph text blob"); + let blob_bounds = *blob.bounds(); + let draw_origin = SkPoint::new(120.0, 340.0); + let mut normalized_blob = blob.clone(); + let normalized_bounds = *SkiaParagraph::get_path(&mut normalized_blob).bounds(); + + let path_bounds = *text_blob_path(blob, draw_origin).bounds(); + + assert!( + (path_bounds.left - (draw_origin.x + blob_bounds.left + normalized_bounds.left)).abs() + < 0.01 + ); + assert!( + (path_bounds.top - (draw_origin.y + blob_bounds.top + normalized_bounds.top)).abs() + < 0.01 + ); + assert!((path_bounds.width() - normalized_bounds.width()).abs() < 0.01); + assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01); + } + + #[test] + fn decoration_bar_underline_left_of_center_line_through_centered() { + let content = decorated_content("あい", TextDecoration::UNDERLINE); + let layout = layout_content(&content, 1000.0); + let (ox, oy) = layout.origin(&content.bounds(), VerticalAlign::Top); + let cell = &layout.cells[0]; + let column = &layout.columns[cell.column]; + let x_center = ox + column.x + column.width / 2.0; + + let underline = decoration_bar(&layout, cell, ox, oy, false); + let strike = decoration_bar(&layout, cell, ox, oy, true); + + // Underline sits left of the column axis; line-through is centered. + assert!(underline.center_x() < x_center); + assert!((strike.center_x() - x_center).abs() < 0.01); + // Both bars span the cell's vertical extent. + assert!((underline.height() - cell.extent).abs() < 0.01); + // Bar thickness is at least 1px. + assert!(underline.width() >= 1.0 - 0.01); + } + + #[test] + fn outline_export_includes_every_annotation_glyph() { + let content = spans_content( + vec![TextSpan { + ruby: "aあ".to_string(), + text_emphasis: TextEmphasis::FilledDot, + text_orientation: TextOrientation::Upright, + ..make_span("AB") + }], + 400.0, + ); + let provider = provider_with_fallback(TEST_FONT, VMTX_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]); + let ruby_glyphs: usize = layout.ruby_cells.iter().map(|ruby| ruby.glyphs.len()).sum(); + assert_eq!(ruby_glyphs, 2, "both fallback runs carry a ruby glyph"); + + let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true); + + assert_eq!( + paths.len(), + layout.cells.len() + ruby_glyphs + layout.emphasis_marks.len(), + "one outline per base cell, ruby glyph and emphasis mark" + ); + let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); + paint_layout( + surface.canvas(), + &layout, + &content.bounds(), + VerticalAlign::Top, + ); + } + + #[test] + fn paint_passes_do_not_panic() { + let content = decorated_content("あいAB。", TextDecoration::LINE_THROUGH); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + let selrect = content.bounds(); + + let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); + let canvas = surface.canvas(); + + // Shape transforms are applied on the caller's canvas. Exercise a + // non-trivial transform with mixed upright/rotated cells so every + // vertical paint pass remains transform-safe. + canvas.translate((12.0, 8.0)); + canvas.rotate(7.0, Some((64.0, 64.0).into())); + + paint_layout(canvas, &layout, &bounds, VerticalAlign::Top); + paint_drop_shadow( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &Paint::default(), + ); + + // With a real drop-shadow image filter (as `drop_shadow_paints` builds). + let mut shadow_paint = Paint::default(); + shadow_paint.set_image_filter(skia::image_filters::drop_shadow( + (12.0, 12.0), + (6.0, 6.0), + skia::Color::from_argb(230, 0, 0, 255), + None, + None, + None, + )); + paint_drop_shadow(canvas, &layout, &bounds, VerticalAlign::Top, &shadow_paint); + + for kind in [ + Stroke::new_center_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + Stroke::new_inner_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + Stroke::new_outer_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + ] { + paint_stroke( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &kind, + &selrect, + None, + ); + } + } +} diff --git a/render-wasm/src/shapes/text_vertical/positions.rs b/render-wasm/src/shapes/text_vertical/positions.rs new file mode 100644 index 0000000000..0039a50695 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/positions.rs @@ -0,0 +1,742 @@ +use std::ops::Range; + +use skia_safe::{Contains, Point as SkPoint}; + +use crate::math::Rect; +use crate::shapes::{PositionData, VerticalAlign}; + +use super::annotations::ruby_strip_x; +use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; + +/// Position-data `direction` value marking a vertical (vertical-rl) +/// strip; 0/1 are the horizontal rtl/ltr values. The CLJS deserializer +/// turns it into `:writing-mode "vertical-rl"` on the entry so the +/// legacy SVG renderer can draw the strip vertically. +pub const DIRECTION_VERTICAL_RL: u32 = 2; + +/// Position-data `direction` value marking a ruby annotation strip: the +/// entry's offsets index the span's *ruby* string and the geometry is the +/// exact gutter placement the canvas paints. +pub const DIRECTION_VERTICAL_RUBY: u32 = 3; + +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 +} + +/// Position-data entries for the v2 editor / exports: consecutive cells of +/// the same span in the same column merge into one vertical strip. +pub fn position_data( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, +) -> Vec { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + let mut result: Vec = Vec::new(); + + let mut i = 0; + while i < layout.cells.len() { + let first = &layout.cells[i]; + let mut source_range = cell_source_utf16_range(layout, first); + let mut bottom = first.top + first.extent; + let mut j = i + 1; + while j < layout.cells.len() { + let next = &layout.cells[j]; + if next.paragraph == first.paragraph + && next.span == first.span + && next.column == first.column + { + let next_source = cell_source_utf16_range(layout, next); + source_range.start = source_range.start.min(next_source.start); + source_range.end = source_range.end.max(next_source.end); + bottom = next.top + next.extent; + j += 1; + } else { + break; + } + } + 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, + }); + i = j; + } + + // Ruby annotation strips, with the exact flow-axis placement the canvas + // paints. `start_pos`/`end_pos` are UTF-16 offsets + // into the span's ruby string, taken from the shaped clusters so + // surrogate-pair readings slice correctly. The consumer renders them as + // their own half-size vertical strips in the column's right-side gutter. + for ruby in &layout.ruby_cells { + let (Some(first), Some(last)) = (ruby.glyphs.first(), ruby.glyphs.last()) else { + continue; + }; + if ruby.base_segments.is_empty() { + continue; + } + let column = &layout.columns[ruby.column]; + let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min); + let bottom = ruby.glyph_tops.iter().copied().fold(f32::MIN, f32::max) + ruby.font_size; + result.push(PositionData { + paragraph: ruby.paragraph as u32, + span: ruby.span as u32, + start_pos: first.utf16_start as u32, + end_pos: last.utf16_end as u32, + x: origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side), + y: origin_y + top, + width: ruby.font_size, + height: bottom - top, + direction: DIRECTION_VERTICAL_RUBY, + }); + } + result +} + +/// True when the point (in the same space as `bounds`) hits laid-out text. +pub fn intersects( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + x: f32, + y: f32, +) -> bool { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + layout.cells.iter().any(|cell| { + let column = &layout.columns[cell.column]; + let rect = Rect::from_xywh( + origin_x + column.x, + origin_y + cell.top, + column.width, + cell.extent, + ); + rect.contains(&SkPoint::new(x, y)) + }) +} + +fn scalar_offset_from_utf16( + layout: &VerticalLayout, + paragraph: usize, + utf16_offset: usize, +) -> Option { + let boundaries = layout.paragraph_utf16_boundaries.get(paragraph)?; + Some(match boundaries.binary_search(&utf16_offset) { + Ok(index) => index, + Err(index) => index.saturating_sub(1), + }) +} + +fn cell_scalar_range(layout: &VerticalLayout, cell: &VerticalCell) -> Option<(usize, usize)> { + Some(( + scalar_offset_from_utf16(layout, cell.paragraph, cell.start)?, + scalar_offset_from_utf16(layout, cell.paragraph, cell.end)?, + )) +} + +/// Scalar length of a warichu cell's first sub-line. `first_chars` is its +/// UTF-16 length; the cell holds `chars` scalars from `cell_start`. +fn warichu_first_line_len( + layout: &VerticalLayout, + cell: &VerticalCell, + first_chars: usize, + cell_start: usize, + chars: usize, +) -> Option { + let first_end = scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)?; + Some(first_end.saturating_sub(cell_start).min(chars)) +} + +/// Caret position (paragraph index, paragraph-relative Unicode scalar offset) for +/// a point given relative to the content block's top-left origin. +pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usize, usize)> { + if layout.columns.is_empty() { + return None; + } + + // Columns are ordered right->left, i.e. descending x. + let column_index = layout + .columns + .iter() + .position(|c| x >= c.x && x < c.x + c.width) + .unwrap_or(if x >= layout.width { + 0 + } else { + layout.columns.len() - 1 + }); + + let paragraph = layout + .paragraph_columns + .iter() + .position(|(start, end)| column_index >= *start && column_index < *end)?; + + let column_cells: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .collect(); + + if column_cells.is_empty() { + return Some((paragraph, 0)); + } + + for cell in &column_cells { + if y < cell.top + cell.extent { + let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; + let chars = (cell_end - cell_start).max(1); + let offset = match cell.kind { + CellKind::Rotated { .. } => { + // Proportional position along the rotated run. + let frac = ((y - cell.top) / cell.extent).clamp(0.0, 1.0); + cell_start + ((frac * chars as f32).round() as usize).min(chars) + } + CellKind::TateChuYoko { .. } => { + // The digits run left->right inside the composite, so the + // horizontal position picks the offset within it. + let column = &layout.columns[cell.column]; + let left = column_base_center(column) - cell.h_advance / 2.0; + let frac = ((x - left) / cell.h_advance.max(1.0)).clamp(0.0, 1.0); + cell_start + ((frac * chars as f32).round() as usize).min(chars) + } + CellKind::Warichu { first_chars, .. } => { + // Right sub-column holds the first sub-line's characters, + // left sub-column the second; the vertical position picks + // the offset within the chosen sub-line. + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let first = + warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?; + let (lo, hi) = if x >= centre { + (0, first) + } else { + (first, chars) + }; + let line_chars = (hi - lo).max(1); + let frac = ((y - cell.top) / cell.extent.max(1.0)).clamp(0.0, 1.0); + cell_start + lo + ((frac * line_chars as f32).round() as usize).min(hi - lo) + } + CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } => { + if y < cell.top + cell.extent / 2.0 { + cell_start + } else { + cell_end + } + } + }; + return Some((paragraph, offset)); + } + } + + Some(( + paragraph, + scalar_offset_from_utf16(layout, paragraph, column_cells.last().unwrap().end)?, + )) +} + +/// Caret rectangle for a paragraph-relative Unicode scalar offset, in +/// content-local coordinates. The rect spans the column width; its height +/// is the extent of the character at the offset (used by overtype-mode +/// carets to cover the glyph) or zero when the caret sits after the last +/// character, where a thin bar is drawn instead. +pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> Option { + let (col_start, _) = *layout.paragraph_columns.get(paragraph)?; + + let mut result: Option = None; + for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { + let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; + if cell_start > offset { + continue; + } + let column = &layout.columns[cell.column]; + let chars = (cell_end - cell_start).max(1); + let rect = if offset >= cell_end { + Rect::from_xywh(column.x, cell.top + cell.extent, column.base_width, 0.0) + } else { + match cell.kind { + CellKind::TateChuYoko { .. } => { + // Digits run left->right inside the composite: the offset + // moves the caret along the horizontal axis while the + // rect keeps the composite's flow extent. + let left = column_base_center(column) - cell.h_advance / 2.0; + let width = cell.h_advance / chars as f32; + let x = left + (offset - cell_start) as f32 * width; + Rect::from_xywh(x, cell.top, width, cell.extent) + } + CellKind::Warichu { first_chars, .. } => { + // Right sub-column holds the first sub-line's + // characters, left sub-column the second (jlreq reading + // order); the caret tracks the offset down the chosen + // half-width sub-line. + let first = + warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?; + let within = offset - cell_start; + let centre = column_base_center(column); + let half = cell.font_size / 2.0; + let (band_x, line_start, line_chars) = if within < first { + (centre, 0, first) + } else { + (centre - half, first, chars - first) + }; + let line_chars = line_chars.max(1); + let frac = (within - line_start) as f32 / line_chars as f32; + Rect::from_xywh( + band_x, + cell.top + frac * cell.extent, + half, + cell.extent / line_chars as f32, + ) + } + _ => { + let frac = (offset - cell_start) as f32 / chars as f32; + Rect::from_xywh( + column.x, + cell.top + frac * cell.extent, + column.base_width, + cell.extent / chars as f32, + ) + } + } + }; + result = Some(rect); + if offset < cell_end { + break; + } + } + + result.or_else(|| { + // Empty paragraph: caret at the top of its (empty) first column. + layout + .columns + .get(col_start) + .map(|column| Rect::from_xywh(column.x, 0.0, column.base_width, 0.0)) + }) +} + +/// Selection rectangles for a paragraph-relative Unicode scalar offset range, in +/// content-local coordinates. +pub fn range_rects( + layout: &VerticalLayout, + paragraph: usize, + start: usize, + end: usize, +) -> Vec { + let mut rects: Vec = Vec::new(); + for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { + let Some((cell_start, cell_end)) = cell_scalar_range(layout, cell) else { + continue; + }; + if cell_end <= start || cell_start >= end { + continue; + } + let column = &layout.columns[cell.column]; + let chars = (cell_end - cell_start).max(1); + let sel_top = if start > cell_start { + cell.top + ((start - cell_start) as f32 / chars as f32) * cell.extent + } else { + cell.top + }; + let sel_bottom = if end < cell_end { + cell.top + ((end - cell_start) as f32 / chars as f32) * cell.extent + } else { + cell.top + cell.extent + }; + if sel_bottom <= sel_top { + continue; + } + // Merge with the previous rect when contiguous in the same column. + if let Some(last) = rects.last_mut() { + if (last.left - column.x).abs() < f32::EPSILON && (last.bottom - sel_top).abs() < 0.01 { + last.bottom = sel_bottom; + continue; + } + } + rects.push(Rect::from_ltrb( + column.x, + sel_top, + column.x + column.base_width, + sel_bottom, + )); + } + rects +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use super::*; + use crate::shapes::{ + TextCombineUpright, TextOrientation, TextPositionWithAffinity, TextTransform, + }; + use crate::wasm::text::helpers as text_helpers; + + #[test] + fn caret_from_point_lands_inside_tcy_composite() { + let mut content = make_content_with_spans(&["1234", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_content(&content, 400.0); + let cell = &layout.cells[0]; + assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); + let column = &layout.columns[cell.column]; + let y_mid = cell.top + cell.extent / 2.0; + let (_, at_left) = caret_from_point(&layout, column.x + 0.5, y_mid).unwrap(); + let (_, at_right) = + caret_from_point(&layout, column.x + column.base_width - 0.5, y_mid).unwrap(); + assert!(at_left <= 1, "left edge maps near the start, got {at_left}"); + assert!( + at_right >= 3, + "right edge maps near the end, got {at_right}" + ); + } + + #[test] + fn caret_rect_tracks_horizontal_axis_inside_tcy_composite() { + let mut content = make_content_with_spans(&["1234", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_content(&content, 400.0); + let cell = &layout.cells[0]; + assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); + let left = column_base_center(&layout.columns[cell.column]) - cell.h_advance / 2.0; + let digit = cell.h_advance / 4.0; + for i in 0..4 { + let rect = caret_rect(&layout, 0, cell.start + i).expect("caret rect"); + assert!( + (rect.left - (left + i as f32 * digit)).abs() < 0.01, + "digit {i} caret sits at its horizontal slot, got {}", + rect.left + ); + assert!( + (rect.width() - digit).abs() < 0.01, + "digit {i} caret is one digit wide, got {}", + rect.width() + ); + assert!( + (rect.top - cell.top).abs() < 0.01 && (rect.height() - cell.extent).abs() < 0.01, + "digit {i} caret spans the composite's flow extent" + ); + } + // After the composite the caret falls back to the flow axis. + let rect = caret_rect(&layout, 0, cell.end).expect("caret rect"); + assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01); + } + + #[test] + fn warichu_cell_carries_kinsoku_split() { + let content = warichu_content("あいう、えお", 400.0); + let layout = layout_content(&content, 400.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Warichu { .. })) + .expect("a warichu cell"); + let CellKind::Warichu { first_chars, .. } = cell.kind else { + unreachable!(); + }; + assert_eq!( + first_chars, 4, + "the comma is pulled up into the first sub-line" + ); + // The caret for the second sub-line's first character restarts at + // the composite top, left of the axis. + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let rect = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); + assert!((rect.top - cell.top).abs() < 0.01); + assert!(rect.left < centre); + } + + #[test] + fn caret_rect_tracks_sub_lines_inside_warichu() { + let content = warichu_content("割注二行説明", 400.0); + let layout = layout_content(&content, 400.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Warichu { .. })) + .expect("a warichu cell"); + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let half = cell.font_size / 2.0; + // First sub-line (offsets 0..3) sits right of the column axis. + let first = caret_rect(&layout, 0, cell.start).expect("caret rect"); + assert!( + (first.left - centre).abs() < 0.01, + "first sub-line caret starts at the axis, got {}", + first.left + ); + assert!( + (first.width() - half).abs() < 0.01, + "sub-line caret is half-size wide, got {}", + first.width() + ); + assert!((first.top - cell.top).abs() < 0.01); + // Second sub-line (offsets 3..6) sits left of the axis, restarting + // at the composite top. + let second = caret_rect(&layout, 0, cell.start + 3).expect("caret rect"); + assert!( + (second.left - (centre - half)).abs() < 0.01, + "second sub-line caret sits left of the axis, got {}", + second.left + ); + assert!( + (second.top - cell.top).abs() < 0.01, + "second sub-line restarts at the composite top, got {}", + second.top + ); + // Offsets advance down the sub-line. + let deeper = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); + assert!( + deeper.top > second.top, + "later offsets move down the sub-line" + ); + } + + #[test] + fn caret_round_trip() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + for cell in &layout.cells { + let column = &layout.columns[cell.column]; + // A point in the upper half of the cell resolves to its start. + let (paragraph, offset) = caret_from_point( + &layout, + column.x + column.width / 2.0, + cell.top + cell.extent * 0.25, + ) + .expect("caret"); + assert_eq!(paragraph, 0); + assert_eq!(offset, cell.start); + + // caret_rect for that offset lands inside the same column and + // carries the character extent (for overtype carets). + let rect = caret_rect(&layout, paragraph, offset).expect("caret rect"); + assert!((rect.x() - column.x).abs() < 0.01); + let chars = (cell.end - cell.start).max(1); + assert!((rect.height() - cell.extent / chars as f32).abs() < 0.01); + } + + // After the last character there is no glyph to cover: zero height. + let end_offset = layout.cells.last().unwrap().end; + let rect = caret_rect(&layout, 0, end_offset).expect("caret rect"); + assert_eq!(rect.height(), 0.0); + } + + #[test] + fn non_bmp_caret_offsets_round_trip_through_editor_operations() { + let content = make_content(&["𠀀あ"], 1000.0); + let layout = layout_content(&content, 1000.0); + let first = &layout.cells[0]; + let second = &layout.cells[1]; + let column = &layout.columns[first.column]; + let x = column_base_center(column); + + let before = caret_from_point(&layout, x, first.top + first.extent * 0.25).unwrap(); + let after = caret_from_point(&layout, x, first.top + first.extent * 0.75).unwrap(); + assert_eq!(before, (0, 0)); + assert_eq!(after, (0, 1)); + + let rect = caret_rect(&layout, after.0, after.1).expect("caret rect"); + assert!((rect.top - second.top).abs() < 0.01); + assert!((rect.height() - second.extent).abs() < 0.01); + + let rects = range_rects(&layout, 0, 0, 1); + assert_eq!(rects.len(), 1); + assert!((rects[0].height() - first.extent).abs() < 0.01); + + let start = TextPositionWithAffinity::new_downstream_affinity(0, 0); + let end = TextPositionWithAffinity::new_downstream_affinity(after.0, after.1); + assert_eq!( + text_helpers::move_cursor_forward(&start, content.paragraphs(), false), + end + ); + assert_eq!( + text_helpers::move_cursor_backward(&end, content.paragraphs(), false), + start + ); + + let mut inserted = make_content(&["𠀀あ"], 1000.0); + assert_eq!( + text_helpers::insert_text_at_cursor(&mut inserted, &end, "X"), + Some(2) + ); + assert_eq!(inserted.paragraphs()[0].children()[0].text, "𠀀Xあ"); + + let mut deleted = make_content(&["𠀀あ"], 1000.0); + assert_eq!( + text_helpers::delete_char_before(&mut deleted, &end), + Some(start) + ); + assert_eq!(deleted.paragraphs()[0].children()[0].text, "あ"); + } + + #[test] + fn selection_rects_cover_the_range() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + let rects = range_rects(&layout, 0, 1, 3); + assert!(!rects.is_empty()); + let covered: f32 = rects.iter().map(|r| r.height()).sum(); + let expected: f32 = layout + .cells + .iter() + .filter(|c| c.start >= 1 && c.end <= 3) + .map(|c| c.extent) + .sum(); + assert!((covered - expected).abs() < 0.01); + } + + #[test] + fn position_data_merges_by_column_and_maps_span_offsets() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + + let data = position_data(&layout, &bounds, VerticalAlign::Top); + assert_eq!(data.len(), 1, "one column, one span => one entry"); + assert_eq!(data[0].start_pos, 0); + assert_eq!(data[0].end_pos, text.encode_utf16().count() as u32); + // Right-anchored: the strip ends at the bounds right edge. + assert!((data[0].x + data[0].width - bounds.right).abs() < 0.01); + } + + #[test] + fn position_data_keeps_expanded_transform_in_one_source_safe_strip() { + let mut content = make_content(&["AßB"], 1000.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.text_transform = Some(TextTransform::Uppercase); + span.text_orientation = TextOrientation::Upright; + let unwrapped = layout_content(&content, 1000.0); + let expanded_extent: f32 = unwrapped + .cells + .iter() + .filter(|cell| cell_source_utf16_range(&unwrapped, cell) == (1..2)) + .map(|cell| cell.extent) + .sum(); + let layout = layout_content(&content, expanded_extent + 0.01); + + let expanded: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell_source_utf16_range(&layout, cell) == (1..2)) + .collect(); + assert_eq!(expanded.len(), 2, "ß must shape as the two cells in SS"); + assert_eq!( + expanded[0].column, expanded[1].column, + "glyphs from one source character must not split across columns" + ); + + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + let ranges: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]); + } + + #[test] + fn position_data_emits_ruby_annotation_strips() { + let content = ruby_content("漢字", "かんじ", 400.0); + let layout = layout_content(&content, 400.0); + let bounds = content.bounds(); + let data = position_data(&layout, &bounds, VerticalAlign::Top); + + let base: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RL) + .collect(); + let ruby: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) + .collect(); + assert_eq!(base.len(), 1); + assert_eq!(ruby.len(), 1, "one annotated column => one ruby strip"); + assert_eq!(ruby[0].start_pos, 0); + assert_eq!(ruby[0].end_pos, 3, "offsets index the ruby string"); + // The strip sits in the gutter, to the right of the base band. + assert!(ruby[0].x >= base[0].x + base[0].width - 0.001); + assert!(ruby[0].width > 0.0); + assert!(ruby[0].height > 0.0); + } + + #[test] + fn position_data_ruby_offsets_are_utf16_for_non_bmp_readings() { + // Two surrogate-pair reading characters: 2 glyphs but 4 UTF-16 + // units. The strip offsets must slice the ruby string by UTF-16. + let content = ruby_content("\u{6f22}\u{5b57}", "\u{1d4aa}\u{1d4ab}", 400.0); + let layout = layout_content(&content, 400.0); + let bounds = content.bounds(); + let data = position_data(&layout, &bounds, VerticalAlign::Top); + let ruby: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) + .collect(); + assert_eq!(ruby.len(), 1); + assert_eq!(ruby[0].start_pos, 0); + assert_eq!( + ruby[0].end_pos, 4, + "surrogate pairs count two UTF-16 units each" + ); + } + + #[test] + fn position_data_follows_block_axis_alignment() { + let content = make_content(&["あいう"], 1000.0); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + let top = position_data(&layout, &bounds, VerticalAlign::Top); + let center = position_data(&layout, &bounds, VerticalAlign::Center); + let bottom = position_data(&layout, &bounds, VerticalAlign::Bottom); + + assert!(top[0].x > center[0].x); + assert!(center[0].x > bottom[0].x); + assert!((bottom[0].x - bounds.left).abs() < 0.01); + } + + #[test] + fn ruby_selection_geometry_excludes_gutter() { + let content = ruby_content("AB", "ab", 400.0); + let layout = layout_content(&content, 400.0); + let base_width = layout.columns[0].base_width; + assert!(base_width < layout.columns[0].width); + + let pd = position_data(&layout, &content.bounds(), VerticalAlign::Top); + assert!(!pd.is_empty()); + for entry in pd.iter().filter(|e| e.direction == DIRECTION_VERTICAL_RL) { + assert!( + (entry.width - base_width).abs() < 0.01, + "position-data strip must be the base band, not the full column" + ); + } + + let rects = range_rects(&layout, 0, 0, 10); + assert!(!rects.is_empty()); + for rect in &rects { + assert!( + (rect.width() - base_width).abs() < 0.01, + "selection rect must be the base band, not the full column" + ); + } + } +} diff --git a/render-wasm/src/shapes/text_vertical/shaping.rs b/render-wasm/src/shapes/text_vertical/shaping.rs new file mode 100644 index 0000000000..65994d7ddc --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/shaping.rs @@ -0,0 +1,447 @@ +use std::ops::Range; + +use skia_safe::{ + self as skia, + shaper::{ + run_handler::{Buffer, RunInfo}, + Feature, RunHandler, + }, + textlayout::TypefaceFontProvider, + Font, FontMgr, GlyphId, Point as SkPoint, TextBlob, TextBlobBuilder, Typeface, +}; + +use crate::render::DEFAULT_EMOJI_FONT; +use crate::shapes::{FontFeatures, TextSpan}; + +/// JLREQ preferred width of a word space inside sideways Western text. +pub(super) const WESTERN_WORD_SPACING_EM: f32 = 1.0 / 3.0; + +/// Candidate families for a span: its own font first, then emoji and the +/// registered fallback fonts (Noto etc.). +pub(crate) fn span_font_families(span: &TextSpan, fallback_families: &[String]) -> Vec { + let mut families = vec![ + format!("{}", span.font_family), + DEFAULT_EMOJI_FONT.to_string(), + ]; + families.extend(fallback_families.iter().cloned()); + families +} + +/// One shaped run: glyphs from a single font, pen-relative positions, +/// per-glyph horizontal advances and per-glyph UTF-8 cluster starts +/// (relative to the shaped segment text). +pub struct ShapedRun { + pub font: Font, + pub glyphs: Vec, + pub positions: Vec, + pub advances: Vec, + pub clusters: Vec, + pub advance: f32, + pub utf8_range: Range, + /// Local-y offset that centres the run's ink after 90° rotation. + pub rotated_baseline_shift: f32, +} + +impl ShapedRun { + /// Glyph clusters as (first glyph, glyph count). A combining sequence + /// shapes to one cluster and must stay in one cell. + pub(super) fn cluster_spans(&self) -> impl Iterator + '_ { + let mut glyph = 0usize; + std::iter::from_fn(move || { + let cluster = *self.clusters.get(glyph)?; + let count = self.clusters[glyph..] + .iter() + .take_while(|other| **other == cluster) + .count(); + let span = (glyph, count); + glyph += count; + Some(span) + }) + } + + /// Summed horizontal advance of a cluster. + pub(super) fn cluster_advance(&self, glyph: usize, count: usize) -> f32 { + self.advances[glyph..glyph + count].iter().sum() + } + + /// Positions of a cluster's glyphs relative to its first glyph's pen x. + fn cluster_positions(&self, glyph: usize, count: usize) -> Vec { + let base_x = self.positions[glyph].x; + self.positions[glyph..glyph + count] + .iter() + .map(|position| SkPoint::new(position.x - base_x, position.y)) + .collect() + } + + /// Ink bounds of a cluster, relative to its first glyph's pen position. + pub(super) fn cluster_ink_bounds(&self, glyph: usize, count: usize) -> Option { + glyph_run_ink_bounds( + &self.font, + &self.glyphs[glyph..glyph + count], + &self.cluster_positions(glyph, count), + ) + } + + /// Ink bounds of the whole run at its shaped positions. + pub(super) fn ink_bounds(&self) -> Option { + glyph_run_ink_bounds(&self.font, &self.glyphs, &self.positions) + } + + /// Text blob of one cluster, positioned from its first glyph's pen x. + pub(super) fn cluster_blob(&self, glyph: usize, count: usize) -> Option { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&self.font, count, None); + glyphs.copy_from_slice(&self.glyphs[glyph..glyph + count]); + points.copy_from_slice(&self.cluster_positions(glyph, count)); + builder.make() + } + + /// Text blob of the whole run at its shaped positions. + pub(super) fn blob(&self) -> Option { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&self.font, self.glyphs.len(), None); + glyphs.copy_from_slice(&self.glyphs); + points.copy_from_slice(&self.positions); + builder.make() + } + + /// Local-y shift that centres a cluster's ink after a 90° rotation. + pub(super) fn cluster_rotated_baseline_shift(&self, glyph: usize, count: usize) -> f32 { + rotated_run_baseline_shift( + &self.font, + &self.glyphs[glyph..glyph + count], + &self.cluster_positions(glyph, count), + ) + } + + /// Spread the glyphs apart by `letter_spacing` per glyph, mirroring the + /// horizontal per-glyph spacing. Returns the spaced run advance. + pub(super) fn spread_glyphs(&mut self, letter_spacing: f32) -> f32 { + if letter_spacing != 0.0 { + for (i, position) in self.positions.iter_mut().enumerate() { + position.x += letter_spacing * i as f32; + } + } + self.advance + letter_spacing * self.glyphs.len() as f32 + } + + /// Normalize ASCII word spaces inside a sideways Western run to JLREQ's + /// preferred one-third em. Shaping retains the source scalar and break + /// opportunity; only its advance and following glyph positions change. + pub(super) fn normalize_word_spaces(&mut self, segment_text: &str, font_size: f32) { + let clusters: Vec<(usize, usize)> = self.cluster_spans().collect(); + let mut accumulated_shift = 0.0f32; + for (glyph, count) in clusters { + for position in &mut self.positions[glyph..glyph + count] { + position.x += accumulated_shift; + } + let is_word_space = segment_text + .get(self.clusters[glyph] as usize..) + .and_then(|text| text.chars().next()) + == Some(' '); + if is_word_space { + let delta = + font_size * WESTERN_WORD_SPACING_EM - self.cluster_advance(glyph, count); + self.advances[glyph + count - 1] += delta; + accumulated_shift += delta; + } + } + self.advance += accumulated_shift; + } +} + +/// Text blob holding a single glyph drawn at its pen origin. +pub(crate) fn single_glyph_blob(font: &Font, glyph: GlyphId) -> Option { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(font, 1, None); + glyphs[0] = glyph; + points[0] = SkPoint::default(); + builder.make() +} + +#[derive(Default)] +struct RunCollector { + runs: Vec, + scratch_glyphs: Vec, + scratch_positions: Vec, + scratch_clusters: Vec, +} + +impl RunHandler for RunCollector { + fn begin_line(&mut self) {} + + fn run_info(&mut self, _info: &RunInfo) {} + + fn commit_run_info(&mut self) {} + + fn run_buffer(&mut self, info: &RunInfo) -> Buffer<'_> { + self.scratch_glyphs.resize(info.glyph_count, 0); + self.scratch_positions + .resize(info.glyph_count, SkPoint::default()); + self.scratch_clusters.resize(info.glyph_count, 0); + Buffer { + glyphs: &mut self.scratch_glyphs, + positions: &mut self.scratch_positions, + offsets: None, + clusters: Some(&mut self.scratch_clusters), + point: SkPoint::default(), + } + } + + fn commit_run_buffer(&mut self, info: &RunInfo) { + if info.glyph_count == 0 { + return; + } + let origin = self.scratch_positions[0]; + let positions: Vec = self + .scratch_positions + .iter() + .map(|p| SkPoint::new(p.x - origin.x, p.y)) + .collect(); + let end = positions[0].x + info.advance.x; + let advances: Vec = positions + .iter() + .enumerate() + .map(|(i, position)| positions.get(i + 1).map_or(end, |next| next.x) - position.x) + .collect(); + self.runs.push(ShapedRun { + rotated_baseline_shift: rotated_run_baseline_shift( + info.font, + &self.scratch_glyphs, + &positions, + ), + font: info.font.clone(), + glyphs: self.scratch_glyphs.clone(), + positions, + advances, + clusters: self.scratch_clusters.clone(), + advance: info.advance.x, + utf8_range: info.utf8_range.clone(), + }); + } + + fn commit_line(&mut self) {} +} + +fn feature(tag: &[u8; 4]) -> Feature { + Feature { + tag: u32::from_be_bytes(*tag), + value: 1, + start: 0, + end: usize::MAX, + } +} + +/// `vpal` is deliberately absent: SkShaper shapes on a horizontal line, +/// where HarfBuzz would apply the feature's y-placement deltas as glyph +/// offsets without its advance deltas. Vertical layout applies the parsed +/// GPOS `vpal` metrics to upright cells itself. +fn font_feature(font_features: FontFeatures) -> Option { + match font_features { + FontFeatures::None => None, + FontFeatures::Palt => Some(feature(b"palt")), + FontFeatures::Vpal => None, + } +} + +/// Shape one orientation segment on a single unbounded line. Upright +/// segments get the OpenType vertical substitution features so CJK +/// punctuation and brackets take their vertical forms. +pub(super) fn shape_segment( + text: &str, + font: &Font, + upright: bool, + font_features: FontFeatures, + fallback: FontMgr, +) -> Vec { + let shaper = skia::Shaper::new(fallback.clone()); + let mut font_iter = skia::Shaper::new_font_mgr_run_iterator(text, font, Some(fallback)); + let mut bidi_iter = skia::shapers::primitive::trivial_bidi_run_iterator(0, text.len()); + let mut script_iter = skia::Shaper::new_hb_icu_script_run_iterator(text); + let mut lang_iter = skia::Shaper::new_trivial_language_run_iterator("ja", text.len()); + + let mut features = if upright { + vec![feature(b"vert"), feature(b"vrt2")] + } else { + vec![] + }; + if let Some(font_feature) = font_feature(font_features) { + features.push(font_feature); + } + + let mut collector = RunCollector::default(); + shaper.shape_with_iterators_and_features( + text, + &mut font_iter, + &mut bidi_iter, + &mut script_iter, + &mut lang_iter, + &features, + f32::MAX, + &mut collector, + ); + collector.runs +} + +/// Combining marks, variation selectors and ZWJ belong to the preceding +/// grapheme. They stay in that font run even without a standalone cmap +/// entry, so HarfBuzz gets the complete shaping sequence. +fn extends_previous_grapheme(ch: char) -> bool { + matches!(u32::from(ch), + 0x0300..=0x036F + | 0x1AB0..=0x1AFF + | 0x1DC0..=0x1DFF + | 0x200D + | 0x20D0..=0x20FF + | 0x3099..=0x309A + | 0xFE00..=0xFE0F + | 0xFE20..=0xFE2F + | 0xE0100..=0xE01EF + ) +} + +/// Split `text` into byte ranges covered by one typeface each: the first of +/// `typefaces` with a glyph for the character, or the primary face. +fn typeface_chunks(text: &str, typefaces: &[Typeface]) -> Vec<(Range, usize)> { + let mut chunks: Vec<(Range, usize)> = Vec::new(); + for (byte, ch) in text.char_indices() { + let current = chunks.last().map(|(_, typeface)| *typeface); + let selected = match current { + Some(typeface) if extends_previous_grapheme(ch) => typeface, + _ => typefaces + .iter() + .position(|typeface| typeface.unichar_to_glyph(ch as i32) != 0) + .unwrap_or(0), + }; + match chunks.last_mut() { + Some((range, typeface)) if *typeface == selected => range.end = byte + ch.len_utf8(), + _ => chunks.push((byte..byte + ch.len_utf8(), selected)), + } + } + chunks +} + +/// Shape a segment with explicit per-character font fallback. Penpot's +/// `TypefaceFontProvider` can resolve named families, but its character +/// fallback hook is not available consistently in every Skia build. Resolve +/// coverage here so missing glyphs never depend on that hook. +pub(crate) fn shape_segment_with_fallbacks( + text: &str, + font_size: f32, + families: &[String], + font_provider: &TypefaceFontProvider, + upright: bool, + font_features: FontFeatures, + fallback_mgr: &FontMgr, +) -> Vec { + let typefaces: Vec = families + .iter() + .filter_map(|family| font_provider.match_family_style(family, skia::FontStyle::default())) + .collect(); + if typefaces.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + for (range, typeface_index) in typeface_chunks(text, &typefaces) { + let font = Font::new(typefaces[typeface_index].clone(), font_size); + let mut shaped = shape_segment( + &text[range.clone()], + &font, + upright, + font_features, + fallback_mgr.clone(), + ); + for run in &mut shaped { + run.utf8_range = + (run.utf8_range.start + range.start)..(run.utf8_range.end + range.start); + for cluster in &mut run.clusters { + *cluster += range.start as u32; + } + } + result.extend(shaped); + } + result +} + +fn glyph_run_ink_bounds( + font: &Font, + glyphs: &[GlyphId], + positions: &[SkPoint], +) -> Option { + let mut bounds = vec![skia::Rect::default(); glyphs.len()]; + font.get_bounds(glyphs, &mut bounds, None); + + let mut ink = skia::Rect::new(f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for (bound, position) in bounds.iter().zip(positions) { + if bound.right > bound.left && bound.bottom > bound.top { + ink.left = ink.left.min(bound.left + position.x); + ink.top = ink.top.min(bound.top + position.y); + ink.right = ink.right.max(bound.right + position.x); + ink.bottom = ink.bottom.max(bound.bottom + position.y); + } + } + + (ink.right > ink.left && ink.bottom > ink.top).then_some(ink) +} + +/// Offset that centres a local-y band on the column axis after the run is +/// rotated 90°. +pub(super) fn rotated_baseline_shift(top: f32, bottom: f32) -> f32 { + -(top + bottom) / 2.0 +} + +/// Centres the glyphs' actual ink; ascent/descent metrics are only the +/// fallback for runs without visible ink. +fn rotated_run_baseline_shift(font: &Font, glyphs: &[GlyphId], positions: &[SkPoint]) -> f32 { + if let Some(ink) = glyph_run_ink_bounds(font, glyphs, positions) { + rotated_baseline_shift(ink.top, ink.bottom) + } else { + let (_, metrics) = font.metrics(); + rotated_baseline_shift(metrics.ascent, metrics.descent) + } +} + +#[cfg(test)] +mod tests { + use super::super::layout::CellKind; + use super::super::test_support::*; + use super::*; + + #[test] + fn span_families_try_emoji_after_the_span_font() { + let families = span_font_families(&make_span("😆"), &["fallback".to_string()]); + assert_eq!(families[1], DEFAULT_EMOJI_FONT); + assert_eq!(families[2], "fallback"); + } + + #[test] + fn rotated_run_centres_actual_lowercase_ink() { + let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0); + let cell = &layout.cells[0]; + let CellKind::Rotated { run } = cell.kind else { + panic!("expected a rotated run"); + }; + let run = &layout.runs[run]; + let mut bounds = vec![skia::Rect::default(); run.glyphs.len()]; + run.font.get_bounds(&run.glyphs, &mut bounds, None); + let top = bounds + .iter() + .zip(&run.positions) + .map(|(bound, position)| bound.top + position.y) + .fold(f32::MAX, f32::min); + let bottom = bounds + .iter() + .zip(&run.positions) + .map(|(bound, position)| bound.bottom + position.y) + .fold(f32::MIN, f32::max); + let shift = run.rotated_baseline_shift; + assert!(((top + bottom) / 2.0 + shift).abs() < 0.01); + + // Lowercase ink does not occupy the face's full ascent/descent band; + // this guards against regressing to font-wide metric centring. + let (_, metrics) = run.font.metrics(); + let metrics_shift = rotated_baseline_shift(metrics.ascent, metrics.descent); + assert!((shift - metrics_shift).abs() > 0.1); + } +} diff --git a/render-wasm/src/shapes/text_vertical/test_support.rs b/render-wasm/src/shapes/text_vertical/test_support.rs new file mode 100644 index 0000000000..0fce41a7a9 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/test_support.rs @@ -0,0 +1,215 @@ +// Fixtures for the vertical layout tests: real shaping with bundled test +// fonts. Glyph coverage does not matter for offsets/columns; tofu still +// shapes with real advances. + +use skia_safe::{textlayout::TypefaceFontProvider, FontMgr}; + +use crate::math::Rect; +use crate::shapes::{ + AnnotationClearance, FontFamily, FontFeatures, FontStyle, GrowType, Paragraph, RubyAlign, + RubyOverhang, RubySide, RubySize, TextAlign, TextCombineUpright, TextContent, TextDecoration, + TextDirection, TextEmphasis, TextOrientation, TextSpan, WritingMode, +}; +use crate::Uuid; + +use super::layout::{layout_vertical, VerticalLayout}; + +pub(super) const TEST_FONT: &[u8] = include_bytes!("../../fonts/sourcesanspro-regular.ttf"); + +/// A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the +/// vertical kana repeat mark) has a 2em vertical advance vs a 1em +/// horizontal advance; U+3042/U+304F are symmetric controls. +pub(super) const VMTX_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vmtx-test.ttf"); + +/// Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the +/// vertical alternates of 、。「」 halve their vertical advances (「 also +/// lifts its ink by 481 units) and the あ/く alternates tighten by +/// 58/60 units with small placement lifts. +pub(super) const VPAL_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vpal-test.ttf"); + +/// Font size of every test span. +pub(super) const EM: f32 = 20.0; + +/// Provider with `font` registered under the family every test span uses +/// (spans serialize their family as "uuid-weight-style"). +pub(super) fn provider(font: &[u8]) -> TypefaceFontProvider { + let typeface = FontMgr::new() + .new_from_data(font, None) + .expect("failed to load test font"); + let mut provider = TypefaceFontProvider::new(); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(typeface, Some(family.as_str())); + provider +} + +/// `provider(font)` plus `fallback` registered as the "fallback" family. +pub(super) fn provider_with_fallback(font: &[u8], fallback: &[u8]) -> TypefaceFontProvider { + let mut provider = provider(font); + let typeface = FontMgr::new() + .new_from_data(fallback, None) + .expect("failed to load fallback font"); + provider.register_typeface(typeface, Some("fallback")); + provider +} + +pub(super) fn make_span(text: &str) -> TextSpan { + TextSpan { + text: text.to_string(), + font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size: EM, + line_height: 1.0, + letter_spacing: 0.0, + font_weight: 400, + font_variant_id: Uuid::nil(), + text_decoration: None, + text_transform: None, + text_direction: TextDirection::LTR, + text_orientation: TextOrientation::Mixed, + text_combine_upright: TextCombineUpright::None, + text_emphasis: TextEmphasis::None, + ruby: String::default(), + warichu: false, + font_features: FontFeatures::None, + annotation_clearance: AnnotationClearance::None, + ruby_size: RubySize::Half, + ruby_align: RubyAlign::SpaceAround, + ruby_overhang: RubyOverhang::Auto, + ruby_side: RubySide::Over, + paragraph_position: u32::MAX, + span_position: u32::MAX, + fills: vec![], + } +} + +pub(super) fn vertical_paragraph( + spans: Vec, + align: TextAlign, + line_height: f32, +) -> Paragraph { + let mut paragraph = Paragraph::new( + align, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + spans, + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + paragraph +} + +/// A 200px wide box `height` tall holding `paragraphs`. +pub(super) fn content_of( + paragraphs: Vec, + height: f32, + grow_type: GrowType, +) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, height), grow_type); + for paragraph in paragraphs { + content.add_paragraph(paragraph); + } + content +} + +/// One left-aligned paragraph holding `spans`. +pub(super) fn spans_content(spans: Vec, height: f32) -> TextContent { + content_of( + vec![vertical_paragraph(spans, TextAlign::Left, 1.0)], + height, + GrowType::Fixed, + ) +} + +/// One paragraph per text, each a single span. +pub(super) fn make_content(texts: &[&str], height: f32) -> TextContent { + let paragraphs = texts + .iter() + .map(|text| vertical_paragraph(vec![make_span(text)], TextAlign::Left, 1.0)) + .collect(); + content_of(paragraphs, height, GrowType::Fixed) +} + +/// One paragraph with a span per text. +pub(super) fn make_content_with_spans(texts: &[&str], height: f32) -> TextContent { + spans_content(texts.iter().map(|text| make_span(text)).collect(), height) +} + +pub(super) fn ruby_content(base: &str, ruby: &str, height: f32) -> TextContent { + spans_content( + vec![TextSpan { + ruby: ruby.to_string(), + ..make_span(base) + }], + height, + ) +} + +pub(super) fn warichu_content(base: &str, height: f32) -> TextContent { + spans_content( + vec![TextSpan { + warichu: true, + text_orientation: TextOrientation::Upright, + ..make_span(base) + }], + height, + ) +} + +pub(super) fn spaced_content(text: &str, letter_spacing: f32) -> TextContent { + spans_content( + vec![TextSpan { + letter_spacing, + ..make_span(text) + }], + 1000.0, + ) +} + +pub(super) fn decorated_content(text: &str, decoration: TextDecoration) -> TextContent { + spans_content( + vec![TextSpan { + text_decoration: Some(decoration), + ..make_span(text) + }], + 1000.0, + ) +} + +pub(super) fn layout_with_fallback( + provider: &TypefaceFontProvider, + content: &TextContent, + max_height: f32, + fallback_families: &[String], +) -> VerticalLayout { + layout_vertical( + content, + max_height, + provider, + FontMgr::from(provider.clone()), + fallback_families, + content.bounds(), + ) +} + +pub(super) fn layout_with_height( + provider: &TypefaceFontProvider, + content: &TextContent, + max_height: f32, +) -> VerticalLayout { + layout_with_fallback(provider, content, max_height, &[]) +} + +/// Layout with `provider` and a 1000px wrap budget. +pub(super) fn layout_with( + provider: &TypefaceFontProvider, + content: &TextContent, +) -> VerticalLayout { + layout_with_height(provider, content, 1000.0) +} + +/// Layout with the Latin test face. +pub(super) fn layout_content(content: &TextContent, max_height: f32) -> VerticalLayout { + layout_with_height(&provider(TEST_FONT), content, max_height) +}