From 84a4cc564bd648e1e159ea15c6f84595a14915b0 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 20 Oct 2026 11:29:04 +0200 Subject: [PATCH] :sparkles: Add Chinese emphasis and underline positions Carry optional span positions through the schema, WASM header, editor, Plugin API and CSS output. Expose Chinese controls with language defaults and independent overrides for horizontal and vertical text. Reserve automatic clearance per side and stack underlines, ruby and emphasis. Preserve reading groups during decoration edits and retain canonical position values in DOM editor style data. Add protocol, geometry, editing and API tests, including the prior Japanese mixed-font gutter behavior. Verify both WASM targets and live exports. AI-assisted-by: GPT-6 --- common/src/app/common/render_wasm/enums.clj | 2 + .../app/common/render_wasm/text_content.cljs | 10 +- common/src/app/common/types/shape/text.cljc | 2 + .../src/app/common/types/text/cjk_layout.cljc | 4 +- common/test/common_tests/types/text_test.cljc | 12 ++ .../src/app/main/ui/shapes/text/styles.cljs | 14 +++ .../workspace/sidebar/options/menus/text.cljs | 4 + .../options/menus/text_language_layout.cljs | 48 +++++-- .../options/menus/text_language_layout.scss | 3 +- frontend/src/app/plugins/text.cljs | 18 +++ frontend/src/app/render_wasm/text_editor.cljs | 78 +++++++----- .../src/app/util/text/content/styles.cljs | 5 +- .../frontend_tests/code_gen_style_test.cljs | 12 ++ .../frontend_tests/plugins/text_test.cljs | 4 +- .../render_wasm/texts_test.cljs | 42 +++++++ .../frontend_tests/ui/text_options_test.cljs | 22 ++++ frontend/translations/en.po | 40 ++++++ frontend/translations/es.po | 40 ++++++ plugins/CHANGELOG.md | 2 + plugins/libs/plugin-types/index.d.ts | 40 ++++++ render-wasm/docs/cjk-layout.md | 44 ++++++- render-wasm/src/render/text.rs | 22 +++- render-wasm/src/shapes/text.rs | 85 ++++++++++--- .../src/shapes/text_horizontal/emphasis.rs | 33 ++--- .../src/shapes/text_horizontal/ruby.rs | 6 +- .../src/shapes/text_horizontal/tests.rs | 101 +++++++++++++++ render-wasm/src/shapes/text_japanese.rs | 56 ++++++++- .../src/shapes/text_vertical/annotations.rs | 118 +++++++++++++++++- render-wasm/src/shapes/text_vertical/cells.rs | 1 + .../src/shapes/text_vertical/layout.rs | 62 +++++---- render-wasm/src/shapes/text_vertical/paint.rs | 40 ++++-- .../src/shapes/text_vertical/positions.rs | 4 +- render-wasm/src/wasm/text.rs | 35 +++++- render-wasm/src/wasm/text/japanese_layout.rs | 58 +++++++++ 34 files changed, 937 insertions(+), 130 deletions(-) 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 {