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 {