diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj index e5354a1e59..b076d92cc7 100644 --- a/common/src/app/common/render_wasm/enums.clj +++ b/common/src/app/common/render_wasm/enums.clj @@ -40,6 +40,8 @@ ["text-orientation" "RawTextOrientation"] ["text-combine-upright" "RawTextCombineUpright"] ["text-emphasis" "RawTextEmphasis"] + ["text-emphasis-position" "RawTextEmphasisPosition"] + ["text-underline-position" "RawTextUnderlinePosition"] ["warichu" "RawWarichu"] ["font-features" "RawFontFeatures"] ["annotation-clearance" "RawAnnotationClearance"] diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs index 94f546084c..96784895a1 100644 --- a/common/src/app/common/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -176,7 +176,9 @@ ruby-size (sr/translate-text-layout-enum :ruby-size (get span :ruby-size)) ruby-align (sr/translate-text-layout-enum :ruby-align (get span :ruby-align)) ruby-overhang (sr/translate-text-layout-enum :ruby-overhang (get span :ruby-overhang)) - ruby-side (sr/translate-text-layout-enum :ruby-side (get span :ruby-side))] + ruby-side (sr/translate-text-layout-enum :ruby-side (get span :ruby-side)) + emphasis-position (sr/translate-text-layout-enum :text-emphasis-position (get span :text-emphasis-position)) + underline-position (sr/translate-text-layout-enum :text-underline-position (get span :text-underline-position))] (-> offset (mem/write-u8 dview font-style) (mem/write-u8 dview text-decoration) @@ -192,9 +194,9 @@ (mem/write-u8 dview ruby-align) (mem/write-u8 dview ruby-overhang) (mem/write-u8 dview ruby-side) - ;; Alignment padding; must match RawTextSpan in Rust. - (mem/write-u8 dview 0) - (mem/write-u8 dview 0) + ;; Use the former padding bytes; the span header stays the same size. + (mem/write-u8 dview emphasis-position) + (mem/write-u8 dview underline-position) (mem/write-f32 dview font-size) (mem/write-f32 dview line-height) (mem/write-f32 dview letter-spacing) diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index e7189728dd..5ef26b0305 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -70,6 +70,8 @@ [:direction {:optional true} ::sm/text] [:text-combine-upright {:optional true} (schema:jl-enum :text-combine-upright)] [:text-emphasis {:optional true} (schema:jl-enum :text-emphasis)] + [:text-emphasis-position {:optional true} (schema:jl-enum :text-emphasis-position)] + [:text-underline-position {:optional true} (schema:jl-enum :text-underline-position)] [:ruby {:optional true} :string] [:ruby-hidden {:optional true} :boolean] [:ruby-size {:optional true} (schema:jl-enum :ruby-size)] diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc index afa6b40576..f0305fba26 100644 --- a/common/src/app/common/types/text/cjk_layout.cljc +++ b/common/src/app/common/types/text/cjk_layout.cljc @@ -19,6 +19,8 @@ ;; Emphasis mark (圏点 / bouten) applied per span. :text-emphasis ["none" "filled-dot" "open-dot" "filled-circle" "open-circle" "filled-sesame" "open-sesame"] + :text-emphasis-position ["auto" "over-right" "over-left" "under-right" "under-left"] + :text-underline-position ["auto" "under" "left" "right" "under-left" "under-right"] ;; Warichu (割注): two half-size lines in one line position. :warichu ["none" "warichu"] :font-features ["none" "palt" "vpal"] @@ -57,7 +59,7 @@ ;; Span attrs; `:ruby` holds the reading (furigana) of the span. (def span-attrs - (into [:text-combine-upright :text-emphasis :ruby] + (into [:text-combine-upright :text-emphasis :text-emphasis-position :text-underline-position :ruby] (concat ruby-presentation-attrs [:warichu :font-features :annotation-clearance]))) diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index 400d8d0b76..ec0048ac34 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -499,3 +499,15 @@ (doseq [language ["zh-Hans-CN" "zh-Hant-CN" "zh-Hant-TW" "zh-Hant-HK" "ko-KR"]] (t/is (ctst/valid-content? (with-paragraph-attrs {:text-language language :korean-wrap "keep-words"}))))) + + +(t/deftest annotation-position-enums-are-independent-span-styles + (doseq [[attr values] [[:text-emphasis-position ["auto" "over-right" "over-left" "under-right" "under-left"]] + [:text-underline-position ["auto" "under" "left" "right" "under-left" "under-right"]]]] + (t/is (= "auto" (cjk/enum-default attr))) + (t/is (some #{attr} cjk/span-attrs)) + (doseq [value values] + (t/is (cjk/valid-enum-value? attr value)) + (t/is (ctst/valid-content? (with-span-attrs {attr value})))) + (t/is (not (ctst/valid-content? (with-span-attrs {attr "invalid"})))) + (t/is (not (cjk/valid-enum-value? attr "invalid"))))) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 19932dca3d..8cd85738ed 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -62,6 +62,8 @@ line-height (:line-height txt/default-typography)) + chinese (and (string? (:text-language data)) + (= "zh" (first (str/split (str/lower (:text-language data)) #"-")))) text-align (:text-align data "start") writing-mode (:writing-mode data) text-orientation (:text-orientation data) @@ -71,6 +73,8 @@ :margin 0}] (cond-> base + chinese (obj/set! "textEmphasisPosition" "under right") + chinese (obj/set! "textUnderlinePosition" "under left") (some? line-height) (obj/set! "lineHeight" line-height) (some? line-height) (obj/set! "--paragraph-line-height" (str line-height)) (some? text-align) (obj/set! "textAlign" text-align) @@ -135,6 +139,8 @@ [style data] (let [text-combine-upright (:text-combine-upright data) text-emphasis (:text-emphasis data) + emphasis-position (:text-emphasis-position data) + underline-position (:text-underline-position data) font-features (font-feature-settings (:font-features data)) line-height (annotation-line-height data) font-size (:font-size data)] @@ -147,6 +153,12 @@ (set-value? text-emphasis) (obj/set! "textEmphasis" (str/replace text-emphasis "-" " ")) + (some? emphasis-position) + (obj/set! "textEmphasisPosition" (if (= "auto" emphasis-position) "inherit" (str/replace emphasis-position "-" " "))) + + (some? underline-position) + (obj/set! "textUnderlinePosition" (if (= "auto" underline-position) "inherit" (str/replace underline-position "-" " "))) + (some? font-features) (obj/set! "fontFeatureSettings" font-features) @@ -277,6 +289,8 @@ ;; Base-span styles that belong to the base glyphs, not the reading. (obj/unset! "textCombineUpright") (obj/unset! "textEmphasis") + (obj/unset! "textEmphasisPosition") + (obj/unset! "textUnderlinePosition") (obj/unset! "fontFeatureSettings") (obj/unset! "display") (obj/unset! "inlineSize"))) 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 e6e83e4777..52255eb545 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 @@ -336,6 +336,10 @@ (get n-values :annotation-clearance)) (identical? (get o-values :text-emphasis) (get n-values :text-emphasis)) + (identical? (get o-values :text-emphasis-position) + (get n-values :text-emphasis-position)) + (identical? (get o-values :text-underline-position) + (get n-values :text-underline-position)) (identical? (get o-values :ruby) (get n-values :ruby)) (identical? (get o-values :ruby-hidden) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs index 9f12c1219d..6084807673 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs @@ -45,6 +45,44 @@ [value] {:text-language (when (seq value) value)}) +(defn emphasis-position-options + [translate] + [{:id "auto" :label (translate "workspace.options.text-options.position-auto")} + {:id "over-right" :label (translate "workspace.options.text-options.position-over-right")} + {:id "over-left" :label (translate "workspace.options.text-options.position-over-left")} + {:id "under-right" :label (translate "workspace.options.text-options.position-under-right")} + {:id "under-left" :label (translate "workspace.options.text-options.position-under-left")}]) + +(defn underline-position-options + [translate] + [{:id "auto" :label (translate "workspace.options.text-options.position-auto")} + {:id "under" :label (translate "workspace.options.text-options.position-under")} + {:id "left" :label (translate "workspace.options.text-options.position-left")} + {:id "right" :label (translate "workspace.options.text-options.position-right")} + {:id "under-left" :label (translate "workspace.options.text-options.position-under-left")} + {:id "under-right" :label (translate "workspace.options.text-options.position-under-right")}]) + +(mf/defc chinese-decoration-options* + [{:keys [values on-change on-blur]}] + (let [props (mf/props {:values values :on-change on-change :on-blur on-blur})] + [:div {:class (stl/css :decoration-options)} + [:> tjl/span-select-option* + (mf/spread-props props {:attr :text-emphasis :default-value "none" + :label (tr "workspace.options.text-options.text-emphasis") + :options (tjl/text-emphasis-options tr)})] + [:> tjl/span-select-option* + (mf/spread-props props {:attr :text-emphasis-position :default-value "auto" + :label (tr "workspace.options.text-options.emphasis-position") + :options (emphasis-position-options tr)})] + [:> tjl/span-select-option* + (mf/spread-props props {:attr :text-underline-position :default-value "auto" + :label (tr "workspace.options.text-options.underline-position") + :options (underline-position-options tr)})] + [:> tjl/span-select-option* + (mf/spread-props props {:attr :annotation-clearance :default-value "none" + :label (tr "workspace.options.text-options.annotation-clearance") + :options (tjl/annotation-clearance-options)})]])) + (mf/defc pinyin-options* [{:keys [values ruby-values text-selection-active reading-selection-active on-change on-ruby-presentation-change on-blur]}] @@ -67,13 +105,7 @@ (when-not text-selection-active [:> tjl/ruby-presentation-options* (mf/spread-props props {:values ruby-values - :on-change on-ruby-presentation-change})])]) - (when (or reading-selection-active (not text-selection-active)) - [:> tjl/span-select-option* - (mf/spread-props props {:attr :annotation-clearance - :default-value "none" - :label (tr "workspace.options.text-options.annotation-clearance") - :options (tjl/annotation-clearance-options)})])])) + :on-change on-ruby-presentation-change})])])])) (mf/defc language-layout-options* [{:keys [language values ruby-values text-selection-active reading-selection-active @@ -100,6 +132,8 @@ :text-selection-active text-selection-active :on-ruby-presentation-change on-ruby-presentation-change})] [:> tjl/writing-mode-controls* props]) + (when (and (= :chinese language) matches?) + [:> chinese-decoration-options* props]) (when (and (= :chinese language) matches?) [:> pinyin-options* (mf/spread-props props {:ruby-values ruby-values diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss index 647074c279..c67f55971c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss @@ -26,7 +26,8 @@ color: var(--color-foreground-secondary); } -.pinyin-options { +.pinyin-options, +.decoration-options { display: flex; flex-direction: column; gap: var(--sp-xs); diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 0cc33d028a..ecf3a137d3 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -477,6 +477,16 @@ :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))} + :textEmphasisPosition + {:this true + :get (fn [self] (range-japanese-value self start end :text-emphasis-position)) + :set (range-attr-setter plugin-id page-id id start end :textEmphasisPosition :text-emphasis-position (enum-value? :text-emphasis-position))} + + :textUnderlinePosition + {:this true + :get (fn [self] (range-japanese-value self start end :text-underline-position)) + :set (range-attr-setter plugin-id page-id id start end :textUnderlinePosition :text-underline-position (enum-value? :text-underline-position))} + :warichu {:this true :get (fn [self] (range-japanese-value self start end :warichu)) @@ -946,6 +956,14 @@ :get #(shape-japanese-value % :text-emphasis) :set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? :text-emphasis))} + {:name "textEmphasisPosition" + :get #(shape-japanese-value % :text-emphasis-position) + :set (shape-attr-setter plugin-id page-id :textEmphasisPosition :text-emphasis-position (enum-value? :text-emphasis-position))} + + {:name "textUnderlinePosition" + :get #(shape-japanese-value % :text-underline-position) + :set (shape-attr-setter plugin-id page-id :textUnderlinePosition :text-underline-position (enum-value? :text-underline-position))} + {:name "warichu" :get #(shape-japanese-value % :warichu) :set (shape-annotation-setter plugin-id page-id :warichu :warichu (enum-value? :warichu))} diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index ec19412cdf..4410dd88b0 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -854,40 +854,58 @@ (merge (selection-japanese-styles content selection) (selection-paragraph-styles content selection))))) +(defn- annotation-style-range + [paragraph start end styles] + (if (and (< start end) + (map? styles) + (some #(contains? styles %) [:text-emphasis :text-emphasis-position + :text-decoration :text-underline-position :annotation-clearance])) + (let [[start end] + (reduce (fn [[from to offset] span] + (let [span-end (+ offset (count (:text span)))] + (if (and (< offset end) (< start span-end) (cjk/annotated-span? span)) + [(min from offset) (max to span-end) span-end] + [from to span-end]))) + [start end 0] + (:children paragraph))] + [start end]) + [start end])) + (defn apply-attrs-to-paragraph "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans." [para sel-start sel-end styles] - (let [spans (:children para) + (let [[sel-start sel-end] (annotation-style-range para sel-start sel-end styles) + spans (:children para) - result (loop [spans spans - pos 0 - acc []] - (if (empty? spans) - acc - (let [span (first spans) - text (:text span) - span-len (count text) - span-end (+ pos span-len) - ol-start (max pos sel-start) - ol-end (min span-end sel-end) - ;; An empty span has no range to overlap, but an empty - ;; line inside the selection still has to be restyled. - has-overlap? (or (< ol-start ol-end) - (and (zero? span-len) - (<= sel-start pos sel-end)))] - (if (not has-overlap?) - (recur (rest spans) span-end (conj acc span)) - (let [before (when (> ol-start pos) - (assoc span :text (subs text 0 (- ol-start pos)))) - selected (-> (if (fn? styles) - (styles span) - (merge span styles)) - (assoc :text (subs text (- ol-start pos) (- ol-end pos)))) - after (when (< ol-end span-end) - (assoc span :text (subs text (- ol-end pos))))] - (recur (rest spans) span-end - (-> acc - (into (keep identity [before selected after])))))))))] + result (loop [spans spans + pos 0 + acc []] + (if (empty? spans) + acc + (let [span (first spans) + text (:text span) + span-len (count text) + span-end (+ pos span-len) + ol-start (max pos sel-start) + ol-end (min span-end sel-end) + ;; An empty span has no range to overlap, but an empty + ;; line inside the selection still has to be restyled. + has-overlap? (or (< ol-start ol-end) + (and (zero? span-len) + (<= sel-start pos sel-end)))] + (if (not has-overlap?) + (recur (rest spans) span-end (conj acc span)) + (let [before (when (> ol-start pos) + (assoc span :text (subs text 0 (- ol-start pos)))) + selected (-> (if (fn? styles) + (styles span) + (merge span styles)) + (assoc :text (subs text (- ol-start pos) (- ol-end pos)))) + after (when (< ol-end span-end) + (assoc span :text (subs text (- ol-end pos))))] + (recur (rest spans) span-end + (-> acc + (into (keep identity [before selected after])))))))))] (assoc para :children result))) (defn selection-fills diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index e4c50ae22a..f3722b47eb 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -26,7 +26,10 @@ :typography-ref-file [encode decode] :font-id [identity identity] :font-variant-id [identity identity] - :vertical-align [identity identity]}) + :vertical-align [identity identity] + ;; Keep canonical enum values intact in the DOM editor's style data. + :text-emphasis-position [identity identity] + :text-underline-position [identity identity]}) (defn normalize-style-value "This function adds units to style values" diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index acdf7bb323..19345f68c8 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -378,3 +378,15 @@ (is (re-find #"line-height:\s*calc\(var\(--paragraph-line-height" css)) (is (not (str/includes? css "--fills"))) (is (not (str/includes? css "--font-id")))))) + + +(deftest generated-css-keeps-independent-decoration-positions + (let [content (-> tcy-digits2-text-content + (assoc-in [:children 0 :children 0 :text-language] "zh-Hans") + (assoc-in [:children 0 :children 0 :children 0 :text-emphasis] "filled-dot") + (assoc-in [:children 0 :children 0 :children 0 :text-emphasis-position] "over-left") + (assoc-in [:children 0 :children 0 :children 0 :text-underline-position] "under-right")) + css (css/generate-text-css (text-shape content))] + (is (str/includes? css "text-emphasis-position: over left")) + (is (str/includes? css "text-underline-position: under right")) + (is (str/includes? css "text-emphasis-position: under right")))) diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index c4a6864f84..7c27005467 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -65,7 +65,9 @@ (t/deftest japanese-range-properties-accept-only-supported-values (doseq [[property attr accepted rejected] - [["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]] + [["textEmphasisPosition" :text-emphasis-position ["auto" "over-right" "over-left" "under-right" "under-left"] ["over" "right"]] + ["textUnderlinePosition" :text-underline-position ["auto" "under" "left" "right" "under-left" "under-right"] ["above"]] + ["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"]] diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index 38fc9630ed..9d68d9817f 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -21,6 +21,7 @@ [app.render-wasm.api.texts :as texts] [app.render-wasm.text-editor :as text-editor] [app.util.i18n :as i18n] + [app.util.text.content.styles :as content-styles] [cljs.test :as t :include-macros true] [frontend-tests.helpers.mock :as mock])) @@ -68,6 +69,8 @@ {:text "本"}])] (t/is (= {:text-combine-upright "none" :text-emphasis "filled-dot" + :text-emphasis-position "auto" + :text-underline-position "auto" :ruby "にち" :ruby-hidden false :ruby-size "half" @@ -81,6 +84,8 @@ content (selection 0 0 0 1)))) (t/is (= {:text-combine-upright "none" :text-emphasis "none" + :text-emphasis-position "auto" + :text-underline-position "auto" :ruby "" :ruby-hidden false :ruby-size "half" @@ -114,6 +119,8 @@ [{:text "平成" :text-combine-upright "digits2" :text-emphasis "filled-dot" + :text-emphasis-position "under-left" + :text-underline-position "right" :ruby "へいせい" :ruby-hidden true :ruby-size "third" @@ -127,6 +134,8 @@ :font-features "vpal"}])] (t/is (= {:text-combine-upright :multiple :text-emphasis :multiple + :text-emphasis-position :multiple + :text-underline-position :multiple :ruby :multiple :ruby-hidden :multiple :ruby-size :multiple @@ -395,6 +404,8 @@ :ruby-align 11 :ruby-overhang 12 :ruby-side 13 + :text-emphasis-position 14 + :text-underline-position 15 :padding [14 15] :ruby-length 72}) @@ -568,3 +579,34 @@ (t/is (= 0 (sr/translate-text-layout-enum :ruby-side "left"))) (t/is (= 1 (sr/translate-text-layout-enum :ruby-side "under"))) (t/is (= 1 (sr/translate-text-layout-enum :ruby-side :under)))) + + +(t/deftest decoration-positions-use-the-former-padding-bytes + (let [view (serialized-span {:text "中文" :text-emphasis-position "under-left" + :text-underline-position "under-right"})] + (t/is (= 4 (span-byte view :text-emphasis-position))) + (t/is (= 5 (span-byte view :text-underline-position))) + (t/is (= 16 (.getFloat32 view 16 true))))) + + +(t/deftest decoration-edits-preserve-an-existing-reading-as-one-group + (let [paragraph {:type "paragraph" :children [{:text "你好" :ruby "nǐ hǎo"} + {:text "世界" :font-weight "700"}]} + result (text-editor/apply-attrs-to-paragraph paragraph 1 3 {:text-emphasis-position "under-left"})] + (t/is (= [{:text "你好" :ruby "nǐ hǎo" :text-emphasis-position "under-left"} + {:text "世" :font-weight "700" :text-emphasis-position "under-left"} + {:text "界" :font-weight "700"}] + (:children result))))) + + +(t/deftest dom-editor-style-data-keeps-decoration-positions + (let [attrs {:text-emphasis-position "under-left" :text-underline-position "under-right"} + styles (content-styles/attrs->styles attrs)] + (t/is (= "under-left" (aget styles "--text-emphasis-position"))) + (t/is (= "under-right" (aget styles "--text-underline-position"))) + (t/is (= attrs (content-styles/styles->attrs (js->clj styles)))))) + + +(t/deftest a-collapsed-decoration-range-does-not-restyle-a-reading + (let [paragraph {:type "paragraph" :children [{:text "你好" :ruby "nǐ hǎo"}]}] + (t/is (= paragraph (text-editor/apply-attrs-to-paragraph paragraph 1 1 {:text-emphasis-position "under-left"}))))) diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs index 2f85738b62..fa1c50bdf5 100644 --- a/frontend/test/frontend_tests/ui/text_options_test.cljs +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -102,6 +102,21 @@ (t/is (empty? @changes) "Showing controls must not assign a profile"))))) +(t/deftest chinese-decoration-controls-require-a-matching-profile + (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] + (doseq [profile [nil :multiple "ja" "ko" "zh-Hans-CN" "zh-Hant-TW"]] + (let [changes (atom []) + markup (rds/renderToStaticMarkup + (mf/element tll/language-layout-options* + (mf/props {:language :chinese + :values {:text-language profile} + :on-change #(swap! changes conj %)})))] + (doseq [label ["workspace.options.text-options.text-emphasis" + "workspace.options.text-options.emphasis-position" + "workspace.options.text-options.underline-position"]] + (t/is (= (tl/profile-matches? :chinese profile) (str/includes? markup label)))) + (t/is (empty? @changes)))))) + (t/deftest pinyin-reading-editor-requires-a-chinese-profile-and-text-selection (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] (doseq [profile [nil :multiple "ja" "ko" "zh-Hans" "zh-Hant-TW"] @@ -481,3 +496,10 @@ (t/is (str/includes? (re-find #"]*id=\"rtl-text-direction\"[^>]*>" (render "horizontal-tb")) "checked=\"\"")))) + + +(t/deftest decoration-position-changes-refresh-the-text-options + (doseq [attr [:text-emphasis-position :text-underline-position]] + (let [before (mf/props {:values {attr "auto"}}) + after (mf/props {:values {attr "under-left"}})] + (t/is (false? (@#'text-menu/check-props before after)))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f6f055841a..e7b435383e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10077,3 +10077,43 @@ msgstr "Pinyin side" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.pinyin-selection-hint" msgstr "Select characters within one text style to add a reading. Select the whole base to edit an existing reading." + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.emphasis-position" +msgstr "Emphasis position" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.underline-position" +msgstr "Underline position" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-auto" +msgstr "Automatic" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-over-right" +msgstr "Above / right" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-over-left" +msgstr "Above / left" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-under-right" +msgstr "Below / right" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-under-left" +msgstr "Below / left" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-under" +msgstr "Below text" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-left" +msgstr "Left in vertical" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-right" +msgstr "Right in vertical" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index e508fe17b3..e6e664d37a 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9953,3 +9953,43 @@ msgstr "Lado del pinyin" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.pinyin-selection-hint" msgstr "Selecciona caracteres con un mismo estilo para añadir una lectura. Selecciona toda la base para editar su lectura." + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.emphasis-position" +msgstr "Posición del énfasis" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.underline-position" +msgstr "Posición del subrayado" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-auto" +msgstr "Automática" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-over-right" +msgstr "Arriba / derecha" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-over-left" +msgstr "Arriba / izquierda" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-under-right" +msgstr "Abajo / derecha" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-under-left" +msgstr "Abajo / izquierda" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-under" +msgstr "Debajo del texto" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-left" +msgstr "Izquierda en vertical" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.position-right" +msgstr "Derecha en vertical" diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 99cf9a8f72..7eef5d163c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,8 @@ ### 🚀 Features +- **plugin-types:** Added independent `textEmphasisPosition` and `textUnderlinePosition` properties to text shapes and ranges. + - **plugin-types, plugins-runtime:** `penpotMgmt` returns `Team`, `Project` and `ProjectFile` objects. Assigning `name` renames teams, projects and files, `project.pinned` pins a project and `file.shared` shares a file as a team library; `createProject`, `createFile`, `moveTo`, `duplicate`, `remove` and `restore` live on the objects. Added `penpotMgmt.listTeams`, `penpotMgmt.createTeam`, `penpotMgmt.getFile`, `listDeletedProjects` and `listDeletedFiles`, and the `manage:projects`, `manage:teams` and `manage:delete` permissions. Restoration uses the dashboard's Trash operation; projects need recoverable files and empty projects cannot be restored; the API exposes no permanent deletion method. Team deletion is not exposed. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin. - **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 71cb74687d..7290bd0ebc 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4756,6 +4756,25 @@ export interface Text extends ShapeBase { */ warichu: 'none' | 'warichu' | 'mixed'; + /** Emphasis position in horizontal and vertical text; auto uses the language default. */ + textEmphasisPosition: + | 'auto' + | 'over-right' + | 'over-left' + | 'under-right' + | 'under-left' + | 'mixed'; + + /** Underline depth and vertical side; independent of emphasis and readings. */ + textUnderlinePosition: + | 'auto' + | 'under' + | 'left' + | 'right' + | 'under-left' + | 'under-right' + | 'mixed'; + /** * OpenType proportional metrics for Japanese text: `palt` for horizontal * writing, `vpal` for vertical. `none` (the default) turns them off. @@ -4921,6 +4940,27 @@ export interface TextRange { /** Warichu applied to the range, or 'mixed' for different span values. */ warichu: 'none' | 'warichu' | 'mixed' | null; + /** Emphasis position in horizontal and vertical text; auto uses the language default. */ + textEmphasisPosition: + | 'auto' + | 'over-right' + | 'over-left' + | 'under-right' + | 'under-left' + | 'mixed' + | null; + + /** Underline depth and vertical side; independent of emphasis and readings. */ + textUnderlinePosition: + | 'auto' + | 'under' + | 'left' + | 'right' + | 'under-left' + | 'under-right' + | 'mixed' + | null; + /** * OpenType proportional metrics for the range: `palt` (horizontal), `vpal` * (vertical) or `none`. Returns 'mixed' for different span values. diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index 0f9f5fdcee..8765aae778 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -81,6 +81,47 @@ Regular font, with letters, umlauts and combining tone marks. The subset retains its copyright and SIL Open Font License metadata; its family name is Pinyin Test. FontTools generated it from the Pinyin test strings. +## Emphasis and underline positions + +Chinese profiles expose emphasis shape, emphasis position, underline position +and annotation clearance independently of Pinyin. Position controls do not +turn on underlining; use the existing underline control for that. + +Optional span `text-emphasis-position` stores `auto`, `over-right`, +`over-left`, `under-right` or `under-left`. The first part selects the +horizontal side; the second selects the vertical side. Auto uses below/right +for Chinese and preserves above/right for Japanese, Korean and plain text. + +Optional `text-underline-position` stores `auto`, `under`, `left`, `right`, +`under-left` or `under-right`. Under places horizontal underlines below the +base rect; left/right select the vertical side. Chinese auto uses below/left. +Other auto values preserve the earlier baseline offset and vertical left +side. Explicit values can select other styles without changing the language. +These are supported POC choices, not a claim of complete decoration support. + +Automatic clearance reserves each occupied side. Stacked annotations retain +the largest reading and mark sizes across mixed-font spans, preserving the +prior Japanese column widths. Underlines remain next to +the base; readings move outside an underline on their side, and emphasis +moves outside a reading on its side. The underline band reserves 0.18 em, +a POC bound around the existing 1/9-em gap and stroke width. Disabling +clearance retains fixed line-height behavior and can allow overlap/clipping. + +Decoration edits that intersect a reading or note apply to its whole base, +so changing a side cannot duplicate its reading on split spans. Other text +styles remain unchanged. Selection snapshots, Plugin API properties, +CSS handoff and saved content retain the independent positions. CSS auto +positions inherit the Chinese paragraph defaults; explicit positions emit +the corresponding CSS keyword pairs. The binary writer uses former span +header bytes 14/15; header size and prior offsets stay unchanged. Unknown +bytes decode to auto. Both WASM consumers share that writer. + +Defaults and positions were checked on 2026-10-20 against +[CSS Text Decoration, emphasis](https://www.w3.org/TR/css-text-decor-3/#text-emphasis-position-property) +and [underline positions](https://www.w3.org/TR/css-text-decor-3/#text-underline-position-property). +Japanese/Korean vertical auto underlines retain this branch's existing left +side rather than adopting the spec's suggested right-side style. + ## Evidence and release checks Renderer tests cover regional geometry, horizontal/vertical wrapping, @@ -91,8 +132,7 @@ mode. Both WASM consumers use the same writer and byte values: 0 syllable, Real Pinyin, Zhuyin and Korean IME checks and native-reader review remain release checks. Font glyph coverage and localized forms still depend on the -chosen fonts. Dedicated Zhuyin layout and decoration positions remain -Stage 4 follow-ups. +chosen fonts. Dedicated Zhuyin layout remains a Stage 4 follow-up. References checked on 2026-10-19: diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index f549759ca4..16f6954b73 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -1428,11 +1428,31 @@ pub fn decoration_segments( } None => (0.0, 0.0), }; - let (y, thickness) = if kind == TextDecoration::LINE_THROUGH { + let (mut y, thickness) = if kind == TextDecoration::LINE_THROUGH { (strike_y, max_strike_thickness) } else { (underline_y, max_underline_thickness) }; + if kind == TextDecoration::UNDERLINE { + let index = offsets + .ranges + .partition_point(|range| range.builder_start <= seg_start) + .saturating_sub(1); + if let Some(span) = offsets + .ranges + .get(index) + .and_then(|range| text_paragraph.children().get(range.span)) + .filter(|span| span.underline_below_ink()) + { + let bottom = rects + .iter() + .map(|r| r.rect.bottom()) + .fold(f32::NEG_INFINITY, f32::max); + if bottom.is_finite() { + y = Some(y_accum + bottom + span.font_size / 9.0 + thickness / 2.0); + } + } + } segments.push(TextDecorationSegment { kind, text_style: (*metrics.text_style).clone(), diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 420424c0f6..99ffd11a01 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -2276,6 +2276,8 @@ pub struct TextSpan { pub text_combine_upright: TextCombineUpright, /// Emphasis mark (圏点 / bouten) applied to each base character. pub text_emphasis: TextEmphasis, + pub text_emphasis_position: TextEmphasisPosition, + pub text_underline_position: TextUnderlinePosition, /// Ruby (furigana) annotation for this span; empty means no ruby. pub ruby: String, pub ruby_size: RubySize, @@ -2308,6 +2310,8 @@ impl Default for TextSpan { text_orientation: TextOrientation::default(), text_combine_upright: TextCombineUpright::default(), text_emphasis: TextEmphasis::default(), + text_emphasis_position: TextEmphasisPosition::default(), + text_underline_position: TextUnderlinePosition::default(), ruby: String::default(), ruby_size: RubySize::default(), ruby_align: RubyAlign::default(), @@ -2396,34 +2400,79 @@ impl TextSpan { /// the line height: the ruby at its size plus the emphasis marks. `None` /// keeps the set line height, so annotations sit in the line gap. pub fn annotation_room_em(&self) -> f32 { + self.annotation_side_room_em(RubySide::Over, false) + + self.annotation_side_room_em(RubySide::Under, false) + } + + pub fn emphasis_side(&self, vertical: bool) -> RubySide { + self.text_emphasis_position.side( + vertical, + super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(), + ) + } + + pub fn underline_below_ink(&self) -> bool { + self.text_underline_position.below_ink( + super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(), + ) + } + + /// Only readings on the same side stack with emphasis marks. + pub fn stacks_emphasis_outside_ruby(&self, vertical: bool) -> bool { + self.annotation_clearance.is_auto() + && self.has_ruby() + && self.ruby_side == self.emphasis_side(vertical) + } + + pub fn decoration_room_em(&self, side: RubySide, vertical: bool) -> f32 { + let underline = self + .text_decoration + .is_some_and(|kind| kind.contains(TextDecoration::UNDERLINE)); + let underline_side = if vertical { + self.text_underline_position.side() + } else { + RubySide::Under + }; + let positioned = self.text_underline_position != TextUnderlinePosition::Auto + || self.text_emphasis_position != TextEmphasisPosition::Auto + || super::text_policy::TextLayoutPolicy::for_language(&self.language, false) + .is_chinese(); + if self.annotation_clearance.is_auto() && underline && positioned && side == underline_side + { + 0.18 + } else { + 0.0 + } + } + + pub fn ruby_decoration_offset(&self, vertical: bool) -> f32 { + self.font_size * self.decoration_room_em(self.ruby_side, vertical) + } + + pub fn emphasis_outside_offset(&self, vertical: bool) -> f32 { + let ruby = if self.stacks_emphasis_outside_ruby(vertical) { + self.ruby_font_size() + } else { + 0.0 + }; + ruby + self.font_size * self.decoration_room_em(self.emphasis_side(vertical), vertical) + } + + pub fn annotation_side_room_em(&self, side: RubySide, vertical: bool) -> f32 { if !self.annotation_clearance.is_auto() { return 0.0; } - let ruby = if self.has_ruby() { + let ruby = if self.has_ruby() && self.ruby_side == side { self.ruby_size.scale() } else { 0.0 }; - let emphasis = if self.text_emphasis.is_none() { - 0.0 - } else { + let emphasis = if !self.text_emphasis.is_none() && self.emphasis_side(vertical) == side { super::text_japanese::EMPHASIS_FONT_SCALE - }; - ruby + emphasis - } - - /// 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 - } + }; + ruby + emphasis + self.decoration_room_em(side, vertical) } pub fn to_style( diff --git a/render-wasm/src/shapes/text_horizontal/emphasis.rs b/render-wasm/src/shapes/text_horizontal/emphasis.rs index 90f2eea7bb..46a0c8b862 100644 --- a/render-wasm/src/shapes/text_horizontal/emphasis.rs +++ b/render-wasm/src/shapes/text_horizontal/emphasis.rs @@ -10,7 +10,7 @@ pub(crate) struct HorizontalEmphasisPlacement { pub(crate) rect: skia::Rect, } -/// Place one emphasis mark above each eligible character, from the laid-out +/// Place one emphasis mark beside each eligible character, from the laid-out /// rect of each character so marks follow SkParagraph's wrapping and bidi. pub(crate) fn horizontal_emphasis_placements( paragraph: &Paragraph, @@ -66,23 +66,23 @@ pub(crate) fn horizontal_emphasis_placements( } /// Em box of a horizontal emphasis mark in the laid-out paragraph: centred -/// over its base character, outside any stacked ruby layer. +/// beside its base character on the selected side, outside stacked annotations. pub(crate) fn horizontal_emphasis_mark_box( span: &TextSpan, placement: &HorizontalEmphasisPlacement, ) -> skia::Rect { let size = span.font_size * EMPHASIS_FONT_SCALE; - let bottom = horizontal_annotation_over_top(placement.rect, span.font_size) - - span.emphasis_ruby_offset(); - skia::Rect::from_xywh( - placement.rect.center_x() - size / 2.0, - bottom - size, - size, - size, - ) + let top = if span.emphasis_side(false) == RubySide::Over { + horizontal_annotation_over_top(placement.rect, span.font_size) + - span.emphasis_outside_offset(false) + - size + } else { + placement.rect.bottom() + span.emphasis_outside_offset(false) + }; + skia::Rect::from_xywh(placement.rect.center_x() - size / 2.0, top, size, size) } -/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs. +/// Paint horizontal emphasis marks on their configured side of the base. /// Draw-only: the base paragraph keeps its own metrics. pub(crate) fn paint_horizontal_emphasis( canvas: &skia::Canvas, @@ -125,6 +125,7 @@ pub(crate) fn paint_horizontal_emphasis( .as_ref() .map(|rect| rect.width()) .unwrap_or_else(|| mark_paragraph.longest_line()); + let mark_ink_top = mark_ink.as_ref().map_or(0.0, |rect| rect.top()); let mark_ink_bottom = mark_ink .as_ref() .map(|rect| rect.bottom()) @@ -134,9 +135,13 @@ 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 mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size) - - mark_ink_bottom - - span.emphasis_ruby_offset(); + let mark_y = y + if span.emphasis_side(false) == RubySide::Over { + horizontal_annotation_over_top(placement.rect, span.font_size) + - mark_ink_bottom + - span.emphasis_outside_offset(false) + } else { + placement.rect.bottom() - mark_ink_top + span.emphasis_outside_offset(false) + }; mark_paragraph.paint(canvas, (mark_x, mark_y)); } } diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index 3e21f6b55b..63c0623bd8 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -484,9 +484,11 @@ impl RubyLine<'_> { let over = self.span.ruby_side == RubySide::Over; let edge = ruby_line_ink_edge(self.shaped, self.glyphs, over); if over { - y + horizontal_annotation_over_top(self.base, self.span.font_size) - edge + y + horizontal_annotation_over_top(self.base, self.span.font_size) + - edge + - self.span.ruby_decoration_offset(false) } else { - y + self.base.bottom() - edge + y + self.base.bottom() - edge + self.span.ruby_decoration_offset(false) } } } diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index 565e2c9379..263cb95bfb 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -1207,6 +1207,107 @@ fn horizontal_ruby_glyphs_share_one_baseline() { ); } +#[test] +fn emphasis_position_overrides_the_language_and_stacks_on_its_own_side() { + let placement = super::emphasis::HorizontalEmphasisPlacement { + span: 0, + range: 0..1, + mark: '•', + rect: skia::Rect::from_xywh(0.0, 20.0, 20.0, 20.0), + }; + for (language, position, under) in [ + ("ja", TextEmphasisPosition::Auto, false), + ("ko", TextEmphasisPosition::Auto, false), + ("", TextEmphasisPosition::Auto, false), + ("zh-Hant-TW", TextEmphasisPosition::Auto, true), + ("zh-Hans", TextEmphasisPosition::OverLeft, false), + ("ja", TextEmphasisPosition::UnderRight, true), + ] { + let mut span = make_span("中文", 0.0); + span.language = language.to_string(); + span.text_emphasis = TextEmphasis::FilledDot; + span.text_emphasis_position = position; + span.annotation_clearance = AnnotationClearance::Auto; + let plain = super::emphasis::horizontal_emphasis_mark_box(&span, &placement); + assert_eq!(plain.top() >= placement.rect.bottom(), under); + span.ruby = "zhōng wén".to_string(); + for side in [RubySide::Over, RubySide::Under] { + span.ruby_side = side; + let stacked = super::emphasis::horizontal_emphasis_mark_box(&span, &placement); + let expected = if (side == RubySide::Under) == under { + span.ruby_font_size() + } else { + 0.0 + }; + assert!(((stacked.top() - plain.top()).abs() - expected).abs() < 0.01); + } + } +} + +#[test] +fn chinese_underline_stays_below_the_base_ink() { + for position in [ + TextUnderlinePosition::Auto, + TextUnderlinePosition::Under, + TextUnderlinePosition::UnderRight, + ] { + let (mut paragraph, plan, layout) = cjk_horizontal_layout( + &["文"], + "zh-Hans-CN", + crate::shapes::text_policy::KoreanWrap::Syllable, + 200.0, + ); + let span = &mut paragraph.children_mut()[0]; + span.text_decoration = Some(skia::textlayout::TextDecoration::UNDERLINE); + span.text_underline_position = position; + let segments = + crate::render::text::decoration_segments(&layout, ¶graph, &plan.offsets, 0.0, 0.0); + assert_eq!(segments.len(), 1); + let bottom = + layout.get_rects_for_range(0..1, RectHeightStyle::Tight, RectWidthStyle::Tight)[0] + .rect + .bottom(); + assert!(segments[0].y - segments[0].thickness / 2.0 >= bottom); + } +} + +#[test] +fn below_emphasis_clears_an_underline_on_the_same_side() { + let span = TextSpan { + language: "zh-Hans".to_string(), + text_emphasis: TextEmphasis::FilledDot, + text_decoration: Some(skia::textlayout::TextDecoration::UNDERLINE), + text_underline_position: TextUnderlinePosition::Under, + annotation_clearance: AnnotationClearance::Auto, + ..make_span("文", 0.0) + }; + let placement = super::emphasis::HorizontalEmphasisPlacement { + span: 0, + range: 0..1, + mark: '•', + rect: skia::Rect::from_xywh(0.0, 20.0, 16.0, 18.0), + }; + let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement); + assert!(mark.top() > placement.rect.bottom() + span.font_size / 9.0); +} + +#[test] +fn chinese_horizontal_emphasis_defaults_below_the_base() { + let span = TextSpan { + language: "zh-Hans-CN".to_string(), + text_emphasis: TextEmphasis::FilledDot, + ..make_span("中文", 0.0) + }; + let placement = super::emphasis::HorizontalEmphasisPlacement { + span: 0, + range: 0..1, + mark: '•', + rect: skia::Rect::from_xywh(0.0, 20.0, 16.0, 18.0), + }; + let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement); + assert!(mark.top() >= placement.rect.bottom()); +} + #[test] fn horizontal_pinyin_tones_keep_canonical_shaping_and_group_alignment() { use crate::shapes::text_vertical::test_support::{provider, EM, PINYIN_TEST_FONT}; diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs index 61d1be23bb..e9e1d3186e 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_japanese.rs @@ -283,9 +283,61 @@ impl TextCombineUpright { } } +/// Independent emphasis positions in the two writing directions. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextEmphasisPosition { + #[default] + Auto, + OverRight, + OverLeft, + UnderRight, + UnderLeft, +} + +impl TextEmphasisPosition { + pub fn side(self, vertical: bool, chinese: bool) -> RubySide { + let under = if vertical { + matches!(self, Self::OverLeft | Self::UnderLeft) + } else { + matches!(self, Self::UnderRight | Self::UnderLeft) || (self == Self::Auto && chinese) + }; + if under { + RubySide::Under + } else { + RubySide::Over + } + } +} + +/// CSS underline position: horizontal depth and vertical side are independent. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextUnderlinePosition { + #[default] + Auto, + Under, + Left, + Right, + UnderLeft, + UnderRight, +} + +impl TextUnderlinePosition { + pub fn side(self) -> RubySide { + if matches!(self, Self::Right | Self::UnderRight) { + RubySide::Over + } else { + RubySide::Under + } + } + + pub fn below_ink(self, chinese: bool) -> bool { + matches!(self, Self::Under | Self::UnderLeft | Self::UnderRight) + || (self == Self::Auto && chinese) + } +} + /// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS -/// `text-emphasis-style`. The mark is drawn above each eligible horizontal -/// base character or to the right of its vertical column. +/// `text-emphasis-style`. Position is a separate style with a language default. #[derive(Debug, PartialEq, Clone, Copy, Default)] pub enum TextEmphasis { #[default] diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs index 654860c76a..da2b2432c2 100644 --- a/render-wasm/src/shapes/text_vertical/annotations.rs +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -50,13 +50,26 @@ pub struct RubyCell { pub glyph_tops: Vec, pub font_size: f32, pub base_font_size: f32, + pub outside_offset: f32, pub side: RubySide, pub paint: usize, } +impl RubyCell { + pub(super) fn strip_x(&self, column: &VerticalColumn) -> f32 { + let sign = if self.side == RubySide::Over { + 1.0 + } else { + -1.0 + }; + ruby_strip_x(column, self.font_size, self.base_font_size, self.side) + + sign * self.outside_offset + } +} + /// One emphasis mark (圏点 / bouten) beside a base character: the /// single-glyph `run`, centred on the character along the flow, in the -/// column's right-side gutter. +/// column's selected-side gutter. pub struct EmphasisMark { pub run: usize, /// Index of the annotated base cell in `VerticalLayout::cells`. @@ -66,6 +79,7 @@ pub struct EmphasisMark { pub font_size: f32, /// Cross-axis offset past a stacked ruby layer. pub outside_offset: f32, + pub side: RubySide, } /// Cross-axis start of a vertical ruby strip. Ruby attaches to the edge of the @@ -505,6 +519,7 @@ pub(super) fn layout_ruby( glyph_tops, font_size: ruby_font_size, base_font_size: span.font_size, + outside_offset: span.ruby_decoration_offset(true), side: span.ruby_side, paint: first.paint, }); @@ -652,7 +667,8 @@ pub(super) fn layout_emphasis( cell: cell_index, flow_center, font_size: mark_font_size, - outside_offset: span.emphasis_ruby_offset(), + outside_offset: span.emphasis_outside_offset(true), + side: span.emphasis_side(true), }); } } @@ -666,10 +682,13 @@ pub(super) fn layout_emphasis( pub(super) fn emphasis_mark_center(layout: &VerticalLayout, mark: &EmphasisMark) -> (f32, f32) { let cell = &layout.cells[mark.cell]; let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; + let sign = if mark.side == RubySide::Over { + 1.0 + } else { + -1.0 + }; let x = column_base_center(&layout.columns[cell.column]) - + base_font_size / 2.0 - + mark.outside_offset - + mark.font_size / 2.0; + + sign * (base_font_size / 2.0 + mark.outside_offset + mark.font_size / 2.0); (x, cell.top + mark.flow_center) } @@ -920,6 +939,95 @@ mod tests { ); } + #[test] + fn vertical_emphasis_reserves_its_side_and_only_stacks_with_same_side_ruby() { + for position in [ + crate::shapes::TextEmphasisPosition::OverLeft, + crate::shapes::TextEmphasisPosition::UnderRight, + ] { + for side in [RubySide::Over, RubySide::Under] { + let mut content = ruby_content("你好", "nǐ hǎo", 400.0); + content.paragraphs_mut()[0].set_language("zh-Hans".to_string()); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.text_emphasis = TextEmphasis::FilledDot; + span.text_emphasis_position = position; + span.annotation_clearance = AnnotationClearance::Auto; + span.ruby_side = side; + let same_side = span.emphasis_side(true) == side; + let ruby_size = span.ruby_font_size(); + let layout = layout_content(&content, 400.0); + assert_eq!(layout.emphasis_marks.len(), 2); + for mark in &layout.emphasis_marks { + let column = &layout.columns[layout.cells[mark.cell].column]; + let (x, _) = emphasis_mark_center(&layout, mark); + assert_eq!(x < column_base_center(column), mark.side == RubySide::Under); + assert!( + (mark.outside_offset - if same_side { ruby_size } else { 0.0 }).abs() + < 0.01 + ); + assert!(x - mark.font_size / 2.0 >= column.x - 0.01); + assert!(x + mark.font_size / 2.0 <= column.x + column.width + 0.01); + } + } + } + } + + #[test] + fn default_japanese_clearance_keeps_mixed_font_annotation_gutters() { + let content = spans_content( + vec![ + TextSpan { + font_size: 40.0, + ruby: "a".to_string(), + annotation_clearance: AnnotationClearance::Auto, + ..make_span("日") + }, + TextSpan { + font_size: 30.0, + text_emphasis: TextEmphasis::FilledDot, + annotation_clearance: AnnotationClearance::Auto, + ..make_span("本") + }, + TextSpan { + font_size: 20.0, + ruby: "b".to_string(), + ruby_size: RubySize::Quarter, + text_emphasis: TextEmphasis::FilledDot, + annotation_clearance: AnnotationClearance::Auto, + ..make_span("語") + }, + ], + 400.0, + ); + let layout = layout_content(&content, 400.0); + // The original gutter stacks the largest ruby (20px) and emphasis + // (15px) when any span combines them, beside the 40px base band. + assert_eq!(layout.columns.len(), 1); + assert!((layout.columns[0].width - 75.0).abs() < 0.01); + } + + #[test] + fn underline_ruby_and_emphasis_fit_in_one_automatic_gutter() { + let mut content = ruby_content("你好", "nǐ hǎo", 400.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.ruby_side = RubySide::Under; + span.text_emphasis = TextEmphasis::FilledDot; + span.text_emphasis_position = crate::shapes::TextEmphasisPosition::UnderLeft; + span.text_decoration = Some(crate::shapes::TextDecoration::UNDERLINE); + span.text_underline_position = crate::shapes::TextUnderlinePosition::UnderLeft; + span.annotation_clearance = AnnotationClearance::Auto; + let layout = layout_content(&content, 400.0); + let column = &layout.columns[0]; + let ruby = &layout.ruby_cells[0]; + let base_left = column_base_center(column) - EM / 2.0; + assert!(ruby.strip_x(column) + ruby.font_size < base_left - EM / 9.0); + for mark in &layout.emphasis_marks { + let (x, _) = emphasis_mark_center(&layout, mark); + assert!(x + mark.font_size / 2.0 <= ruby.strip_x(column) + 0.01); + assert!(x - mark.font_size / 2.0 >= column.x - 0.01); + } + } + #[test] fn vertical_pinyin_keeps_tone_clusters_and_base_groups() { let mut results = Vec::new(); diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index b3bf1f02f5..38f4d8dc30 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -205,6 +205,7 @@ impl<'a> SpanCells<'a> { paint: self.paint, font_size: self.span.font_size, decoration: self.span.text_decoration, + underline_side: self.span.text_underline_position.side(), glyph_flow_shift: 0.0, } } diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 164ca9b39a..3409ed7e3e 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -10,7 +10,6 @@ use crate::globals::get_resources; use crate::math::Rect; use crate::render::FallbackFonts; use crate::shapes::japanese::JapaneseClass; -use crate::shapes::text_japanese::EMPHASIS_FONT_SCALE; use crate::shapes::text_policy::TextLayoutPolicy; use crate::shapes::{ merge_fills, AppliedTextTransform, FontFeatures, GrowType, LineAdjustment, Paragraph, RubySide, @@ -101,6 +100,7 @@ pub struct VerticalCell { pub font_size: f32, /// Span text decoration, painted as vertical bars along the column. pub decoration: Option, + pub underline_side: RubySide, /// Draw-only flow shift for half-width opening punctuation (jlreq aki). pub glyph_flow_shift: f32, } @@ -257,37 +257,45 @@ impl ColumnGeometry { // on the logical annotation side. In vertical-rl, `over` is right and // `under` is left. `None` keeps the column advance, as horizontal // keeps the line height. - let reserves = |span: &&TextSpan| span.annotation_clearance.is_auto(); - let ruby_gutter = |side| { - spans + let gutter = |side| { + let ruby = spans .iter() - .filter(reserves) - .filter(|span| span.has_ruby() && span.ruby_side == side) + .filter(|span| { + span.annotation_clearance.is_auto() && span.has_ruby() && span.ruby_side == side + }) .map(TextSpan::ruby_font_size) - .fold(0.0, f32::max) + .fold(0.0, f32::max); + let emphasis = spans + .iter() + .filter(|span| { + span.annotation_clearance.is_auto() + && !span.text_emphasis.is_none() + && span.emphasis_side(true) == side + }) + .map(|span| span.font_size * crate::shapes::text_japanese::EMPHASIS_FONT_SCALE) + .fold(0.0, f32::max); + let decoration = spans + .iter() + .map(|span| span.font_size * span.decoration_room_em(side, true)) + .fold(0.0, f32::max); + // Keep the original mixed-font gutter: when any span stacks the + // two annotations, reserve their independent maximum sizes. + let stacked = spans.iter().any(|span| { + !span.text_emphasis.is_none() + && span.ruby_side == side + && span.stacks_emphasis_outside_ruby(true) + }); + decoration + + if stacked { + ruby + emphasis + } else { + ruby.max(emphasis) + } }; - let ruby_over_gutter = ruby_gutter(RubySide::Over); - // Emphasis takes the over (right) side. Auto-clearance spans with both - // annotations stack there; under-side ruby stays separate. - let emphasis_gutter = spans - .iter() - .filter(reserves) - .filter(|span| !span.text_emphasis.is_none()) - .map(|span| span.font_size * EMPHASIS_FONT_SCALE) - .fold(0.0, f32::max); - 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, + under_gutter: gutter(RubySide::Under), + over_gutter: gutter(RubySide::Over), } } diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index 3f89863c76..750a878131 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -7,7 +7,7 @@ use skia_safe::{ use crate::math::Rect; use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign}; -use super::annotations::{emphasis_mark_center, ruby_strip_x, EmphasisMark, RubyCell}; +use super::annotations::{emphasis_mark_center, EmphasisMark, RubyCell}; use super::font_tables::upright_baseline; use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; use super::shaping::{single_glyph_blob, ShapedRun}; @@ -247,9 +247,7 @@ fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec 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; + let gutter_center = origin.0 + ruby.strip_x(column) + ruby.font_size / 2.0; ruby.glyphs .iter() .zip(&ruby.glyph_tops) @@ -689,9 +687,8 @@ fn push_text_path( } /// Decoration bar of 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 centre. Both span the cell's extent, so -/// adjacent decorated cells form one bar. +/// the selected side of the column; line-through runs down the centre. +/// Both span the cell's extent, so adjacent decorated cells form one bar. fn decoration_bar( layout: &VerticalLayout, cell: &VerticalCell, @@ -706,7 +703,12 @@ fn decoration_bar( let bar_x = if line_through { x_center } else { - x_center - cell.font_size / 2.0 - cell.font_size / 9.0 + let sign = if cell.underline_side == crate::shapes::RubySide::Over { + 1.0 + } else { + -1.0 + }; + x_center + sign * (cell.font_size / 2.0 + cell.font_size / 9.0) }; skia::Rect::from_ltrb( bar_x - thickness / 2.0, @@ -803,6 +805,25 @@ mod tests { assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01); } + #[test] + fn underline_side_changes_the_bar_without_moving_the_base_or_strike() { + let mut content = decorated_content("中文", TextDecoration::UNDERLINE); + let left = layout_content(&content, 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_underline_position = + crate::shapes::TextUnderlinePosition::Right; + let right = layout_content(&content, 400.0); + let left_bar = decoration_bar(&left, &left.cells[0], 0.0, 0.0, false); + let right_bar = decoration_bar(&right, &right.cells[0], 0.0, 0.0, false); + let center = column_base_center(&left.columns[0]); + assert!(left_bar.center_x() < center); + assert!(right_bar.center_x() > center); + assert_eq!(left.cells[0].top, right.cells[0].top); + assert_eq!( + decoration_bar(&left, &left.cells[0], 0.0, 0.0, true), + decoration_bar(&right, &right.cells[0], 0.0, 0.0, true) + ); + } + #[test] fn decoration_bar_underline_left_of_center_line_through_centered() { let content = decorated_content("あい", TextDecoration::UNDERLINE); @@ -910,8 +931,7 @@ mod tests { let ruby = &layout.ruby_cells[0]; let column = &layout.columns[ruby.column]; let (origin_x, origin_y) = layout.origin(&bounds, VerticalAlign::Top); - let strip_x = - origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side); + let strip_x = origin_x + ruby.strip_x(column); let strip = skia::Rect::from_xywh(strip_x, origin_y, ruby.font_size, 400.0); let mut surface = skia::surfaces::raster_n32_premul((256, 512)).unwrap(); diff --git a/render-wasm/src/shapes/text_vertical/positions.rs b/render-wasm/src/shapes/text_vertical/positions.rs index d48e8c284d..957b2857cf 100644 --- a/render-wasm/src/shapes/text_vertical/positions.rs +++ b/render-wasm/src/shapes/text_vertical/positions.rs @@ -5,7 +5,7 @@ use skia_safe::{Contains, Point as SkPoint}; use crate::math::Rect; use crate::shapes::{PositionData, VerticalAlign}; -use super::annotations::{emphasis_mark_center, ruby_strip_x}; +use super::annotations::emphasis_mark_center; use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; /// Position-data `direction` of a vertical-rl strip; 0/1 are rtl/ltr. @@ -213,7 +213,7 @@ fn ruby_entries(layout: &VerticalLayout, (origin_x, origin_y): (f32, f32)) -> Ve 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), + x: origin_x + ruby.strip_x(column), y: origin_y + top, width: ruby.font_size, height: bottom - top, diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index fa5703b122..4ce34b3b4c 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -150,8 +150,8 @@ pub struct RawTextSpan { ruby_align: u8, ruby_overhang: u8, ruby_side: u8, - // Padding for the CLJS writer's 4-byte-aligned layout; always zero. - _padding: [u8; 2], + text_emphasis_position: u8, + text_underline_position: u8, font_size: f32, line_height: f32, letter_spacing: f32, @@ -215,6 +215,10 @@ impl From for shapes::TextSpan { span.text_orientation = RawTextOrientation::from(value.text_orientation).into(); span.text_combine_upright = RawTextCombineUpright::from(value.text_combine_upright).into(); span.text_emphasis = RawTextEmphasis::from(value.text_emphasis).into(); + span.text_emphasis_position = + RawTextEmphasisPosition::from(value.text_emphasis_position).into(); + span.text_underline_position = + RawTextUnderlinePosition::from(value.text_underline_position).into(); span.warichu = RawWarichu::from(value.warichu).into(); span.font_features = RawFontFeatures::from(value.font_features).into(); span.annotation_clearance = RawAnnotationClearance::from(value.annotation_clearance).into(); @@ -591,6 +595,33 @@ mod tests { assert_eq!(span.text_combine_upright.digits_max(), Some(4)); } + #[test] + fn annotation_positions_decode_from_former_padding_and_unknown_defaults() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[14] = 4; + bytes[15] = 5; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!( + span.text_emphasis_position, + shapes::TextEmphasisPosition::UnderLeft + ); + assert_eq!( + span.text_underline_position, + shapes::TextUnderlinePosition::UnderRight + ); + bytes[14] = 255; + bytes[15] = 255; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!( + span.text_emphasis_position, + shapes::TextEmphasisPosition::Auto + ); + assert_eq!( + span.text_underline_position, + shapes::TextUnderlinePosition::Auto + ); + } + #[test] fn raw_font_features_deserializes_from_reserved_span_byte() { let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; diff --git a/render-wasm/src/wasm/text/japanese_layout.rs b/render-wasm/src/wasm/text/japanese_layout.rs index efdc9ee58f..f8f467c1b8 100644 --- a/render-wasm/src/wasm/text/japanese_layout.rs +++ b/render-wasm/src/wasm/text/japanese_layout.rs @@ -124,6 +124,64 @@ impl From for TextEmphasis { } } +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawTextEmphasisPosition { + Auto = 0, + OverRight = 1, + OverLeft = 2, + UnderRight = 3, + UnderLeft = 4, +} + +raw_enum_from_byte!( + RawTextEmphasisPosition, + Auto, + [Auto, OverRight, OverLeft, UnderRight, UnderLeft] +); + +impl From for shapes::TextEmphasisPosition { + fn from(value: RawTextEmphasisPosition) -> Self { + match value { + RawTextEmphasisPosition::Auto => Self::Auto, + RawTextEmphasisPosition::OverRight => Self::OverRight, + RawTextEmphasisPosition::OverLeft => Self::OverLeft, + RawTextEmphasisPosition::UnderRight => Self::UnderRight, + RawTextEmphasisPosition::UnderLeft => Self::UnderLeft, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawTextUnderlinePosition { + Auto = 0, + Under = 1, + Left = 2, + Right = 3, + UnderLeft = 4, + UnderRight = 5, +} + +raw_enum_from_byte!( + RawTextUnderlinePosition, + Auto, + [Auto, Under, Left, Right, UnderLeft, UnderRight] +); + +impl From for shapes::TextUnderlinePosition { + fn from(value: RawTextUnderlinePosition) -> Self { + match value { + RawTextUnderlinePosition::Auto => Self::Auto, + RawTextUnderlinePosition::Under => Self::Under, + RawTextUnderlinePosition::Left => Self::Left, + RawTextUnderlinePosition::Right => Self::Right, + RawTextUnderlinePosition::UnderLeft => Self::UnderLeft, + RawTextUnderlinePosition::UnderRight => Self::UnderRight, + } + } +} + #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] pub enum RawWarichu {