diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs
index 96784895a1..b36fe245e2 100644
--- a/common/src/app/common/render_wasm/text_content.cljs
+++ b/common/src/app/common/render_wasm/text_content.cljs
@@ -193,7 +193,7 @@
(mem/write-u8 dview ruby-size)
(mem/write-u8 dview ruby-align)
(mem/write-u8 dview ruby-overhang)
- (mem/write-u8 dview ruby-side)
+ (mem/write-u8 dview (bit-or ruby-side (if (true? (:ruby-zhuyin span)) 2 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)
diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc
index 5ef26b0305..50bbeaa6d7 100644
--- a/common/src/app/common/types/shape/text.cljc
+++ b/common/src/app/common/types/shape/text.cljc
@@ -74,6 +74,7 @@
[:text-underline-position {:optional true} (schema:jl-enum :text-underline-position)]
[:ruby {:optional true} :string]
[:ruby-hidden {:optional true} :boolean]
+ [:ruby-zhuyin {:optional true} :boolean]
[:ruby-size {:optional true} (schema:jl-enum :ruby-size)]
[:ruby-align {:optional true} (schema:jl-enum :ruby-align)]
[:ruby-overhang {:optional true} (schema:jl-enum :ruby-overhang)]
@@ -107,6 +108,7 @@
[:text-orientation {:optional true} ::sm/text]
[:text-combine-upright {:optional true} ::sm/text]
[:emphasis-mark {:optional true} :boolean]
+ [:zhuyin-glyph {:optional true} :boolean]
[:ruby {:optional true} :string]
[:ruby-size {:optional true} ::sm/text]
[:ruby-align {:optional true} ::sm/text]
diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc
index f0305fba26..8f9aa6dc60 100644
--- a/common/src/app/common/types/text/cjk_layout.cljc
+++ b/common/src/app/common/types/text/cjk_layout.cljc
@@ -55,7 +55,7 @@
;; Ruby attrs that change how a reading looks, not the reading itself.
(def ruby-presentation-attrs
- [:ruby-hidden :ruby-size :ruby-align :ruby-overhang :ruby-side])
+ [:ruby-hidden :ruby-zhuyin :ruby-size :ruby-align :ruby-overhang :ruby-side])
;; Span attrs; `:ruby` holds the reading (furigana) of the span.
(def span-attrs
@@ -70,7 +70,7 @@
;; Values of the span attrs above when a span does not store them.
(def span-attr-defaults
- (into {:ruby "" :ruby-hidden false}
+ (into {:ruby "" :ruby-hidden false :ruby-zhuyin false}
(comp (filter #(contains? enum-values %))
(map (juxt identity enum-default)))
span-attrs))
@@ -126,6 +126,37 @@
(not (str/blank? ruby))
(not (re-find #"[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]" ruby))))
+(def ^:private zhuyin-base-pattern
+ #?(:clj (re-pattern "[\\u3400-\\u9FFF\\uF900-\\uFAFF\\x{20000}-\\x{323AF}][\\uFE00-\\uFE0F\\x{E0100}-\\x{E01EF}]?")
+ :cljs (js/RegExp. "[\\u3400-\\u9FFF\\uF900-\\uFAFF\\u{20000}-\\u{323AF}][\\uFE00-\\uFE0F\\u{E0100}-\\u{E01EF}]?" "u")))
+
+(defn zhuyin-base?
+ "One Han character, optionally followed by a variation selector."
+ [text]
+ (boolean (and (string? text) (re-matches zhuyin-base-pattern text))))
+
+(defn zhuyin-reading?
+ "One Mandarin syllable: one to three symbols and an optional tone."
+ [reading]
+ (boolean (and (string? reading)
+ (re-matches #"(?:[\u3105-\u3129]{1,3}[ˉˊˇˋ˙]?|˙[\u3105-\u3129]{1,3})" (str/trim reading)))))
+
+(defn zhuyin-slots
+ "Upright symbol ink centres in base em for HTML handoff. Input is one valid
+ Mandarin syllable. The canvas positions font ink around the same centres."
+ [reading]
+ (let [chars (vec (str/trim reading))
+ symbols (vec (keep-indexed (fn [idx ch] (when (re-matches #"[\u3105-\u3129]" (str ch)) idx)) chars))
+ neutral? (some #(= "˙" (str %)) chars)
+ lead (if neutral? (/ 1 15) 0)
+ top (/ (- 1 (* (count symbols) 0.3) lead) 2)]
+ (mapv (fn [idx ch]
+ (if-let [symbol (first (keep-indexed #(when (= idx %2) %1) symbols))]
+ {:text (str ch) :x 0.15 :y (+ top lead (* (+ symbol 0.5) 0.3))}
+ {:text (str ch) :x (if neutral? 0.15 0.45)
+ :y (+ top (if neutral? (/ lead 2) (* (dec (count symbols)) 0.3)))}))
+ (range (count chars)) chars)))
+
(defn ruby-span?
"True when a text node carries a ruby reading, hidden or not."
[node]
diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc
index ec0048ac34..c8f5560242 100644
--- a/common/test/common_tests/types/text_test.cljc
+++ b/common/test/common_tests/types/text_test.cljc
@@ -398,6 +398,7 @@
:text-emphasis "open-sesame"
:ruby "かんじ"
:ruby-hidden true
+ :ruby-zhuyin true
:ruby-size "quarter"
:ruby-align "space-between"
:ruby-overhang "none"
@@ -413,6 +414,7 @@
(doseq [attrs [{:text-combine-upright "digits5"}
{:text-emphasis "dot"}
{:ruby-hidden "yes"}
+ {:ruby-zhuyin "yes"}
{:ruby-size "full"}
{:ruby-align "end"}
{:ruby-overhang "always"}
@@ -511,3 +513,23 @@
(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")))))
+
+(t/deftest zhuyin-validates-one-han-base-and-one-mandarin-syllable
+ (doseq [base ["你" "中" "𠀀" "中︀"]]
+ (t/is (cjk/zhuyin-base? base)))
+ (doseq [base [nil "" "你好" "a" "한" "ㄋ"]]
+ (t/is (not (cjk/zhuyin-base? base))))
+ (doseq [reading ["ㄓ" "ㄋㄧˇ" "ㄓㄨㄤˋ" "˙ㄉㄜ" "ㄉㄜ˙" "ㄇㄚˉ"]]
+ (t/is (cjk/zhuyin-reading? reading)))
+ (doseq [reading [nil "" "nǐ" "ˇ" "ㄋˇㄧ" "ㄋㄧˇˋ" "ㄋ ㄧ" "ㄅㄆㄇㄈ"]]
+ (t/is (not (cjk/zhuyin-reading? reading))))
+ (t/is (false? (:ruby-zhuyin cjk/span-attr-defaults))))
+
+(t/deftest zhuyin-neutral-tone-is-above-and-other-tones-are-outside-the-last-symbol
+ (let [[neutral first-symbol second-symbol] (cjk/zhuyin-slots "˙ㄉㄜ")
+ [first-normal last-normal tone] (cjk/zhuyin-slots "ㄋㄧˇ")]
+ (t/is (< (:y neutral) (:y first-symbol) (:y second-symbol)))
+ (t/is (= (:x neutral) (:x first-symbol) (:x second-symbol)))
+ (t/is (< (:y first-normal) (:y last-normal)))
+ (t/is (> (:x tone) (:x last-normal)))
+ (t/is (< (:y tone) (:y last-normal)))))
diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs
index f9663085c9..27ad945b6f 100644
--- a/frontend/src/app/main/ui/shapes/text/html_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs
@@ -22,13 +22,30 @@
(sts/generate-text-styles shape node))
class (when is-code (:$id node))
ruby (cjk/visible-ruby node)]
- (if (some? ruby)
+ (cond
+ (and (true? (:ruby-zhuyin node)) (cjk/zhuyin-base? text) (cjk/zhuyin-reading? ruby))
+ [:span {:style #js {:display "inline-block" :position "relative"
+ :width "1.6em" :height "1em" :verticalAlign "middle"
+ :writingMode "horizontal-tb" :direction "ltr"
+ :fontSize (.-fontSize style) :fontFamily (.-fontFamily style)
+ :fontWeight (.-fontWeight style) :fontStyle (.-fontStyle style)}
+ :class (when is-code (dm/str class "-ruby"))}
+ [:span.text-node {:style style :class class} (sts/text-children text node)]
+ (for [[idx slot] (d/enumerate (cjk/zhuyin-slots ruby))]
+ [:span {:key idx :style #js {:position "absolute" :writingMode "horizontal-tb"
+ :left (dm/str (+ 1 (:x slot)) "em")
+ :top (dm/str (:y slot) "em")
+ :transform "translate(-50%, -50%)" :lineHeight 1}}
+ [:span {:style #js {:fontSize "0.3em"}} (:text slot)]])]
+
+ (some? ruby)
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)
:class (when is-code (dm/str class "-ruby"))}
[:span.text-node {:style style :class class} (sts/text-children text node)]
[:rt {:style (sts/generate-ruby-styles shape node)
:class (when is-code (dm/str class "-rt"))}
ruby]]
+ :else
[:span.text-node {:style style :class class}
(if (= text "") "\u00A0" (sts/text-children text node))])))
diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
index b33c93fba1..e3a4988a86 100644
--- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
@@ -269,9 +269,15 @@
[:defs
[:& fills/fills {:shape shape :render-id render-id}]]
- (if (:emphasis-mark data)
+ (cond
+ (:zhuyin-glyph data)
+ [:> :text #js {:x (:x data) :y (:y data)
+ :style (annotation-style data font-size false nil annotation-fill)} (:text data)]
+
+ (:emphasis-mark data)
;; Emphasis marks (圏点) are not stroked.
[:> :text (emphasis-mark-props data annotation-fill) (:text data)]
+ :else
[:*
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
(if (= "warichu" (:warichu data))
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 52255eb545..7e1398899a 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
@@ -292,6 +292,8 @@
(identical? (unchecked-get n-props "readingSelectionActive")
(unchecked-get o-props "readingSelectionActive"))
(= n-ruby-values o-ruby-values)
+ (= (unchecked-get o-props "zhuyinSelectionActive")
+ (unchecked-get n-props "zhuyinSelectionActive"))
(identical? (get o-values :fills)
(get n-values :fills))
(identical? (get o-values :font-family)
@@ -342,6 +344,8 @@
(get n-values :text-underline-position))
(identical? (get o-values :ruby)
(get n-values :ruby))
+ (identical? (get o-values :ruby-zhuyin)
+ (get n-values :ruby-zhuyin))
(identical? (get o-values :ruby-hidden)
(get n-values :ruby-hidden))
(identical? (get o-values :ruby-size)
@@ -389,7 +393,7 @@
(mf/defc text-menu*
{::mf/wrap [#(mf/memo' % check-props)]}
- [{:keys [ids type values ruby-values text-selection-active reading-selection-active
+ [{:keys [ids type values ruby-values text-selection-active reading-selection-active zhuyin-selection-active
applied-tokens libraries file-id typographies]}]
(let [;; --- UI state
@@ -594,6 +598,7 @@
:ruby-values ruby-values
:text-selection-active text-selection-active
:reading-selection-active reading-selection-active
+ :zhuyin-selection-active zhuyin-selection-active
:on-change on-change
:on-ruby-presentation-change on-ruby-presentation-change
:on-blur on-text-blur})]
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
index 87df055df5..596ba58f39 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
@@ -241,11 +241,13 @@
value (deref ruby*)
dirty? (deref dirty*)
+ valid? (or (empty? value) (nil? (:validate labels)) ((:validate labels) value))
+
commit!
(mf/use-fn
- (mf/deps dirty? value on-change on-blur)
+ (mf/deps dirty? valid? value on-change on-blur)
(fn []
- (when dirty?
+ (when (and dirty? valid?)
(on-change {:ruby value})
(reset! dirty* false))
(when (some? on-blur) (on-blur))))
@@ -275,13 +277,16 @@
:on-change handle-change
:on-blur commit!
:on-key-down handle-key-down}]
- [:> icon-button* {:variant "ghost"
- :selected advanced-open
- :aria-label (or (:advanced labels) (tr "workspace.options.text-options.ruby-advanced-options"))
- :aria-expanded advanced-open
- :data-testid "ruby-advanced-options-toggle"
- :on-click on-toggle-advanced
- :icon i/menu}]]))
+ (when-not valid?
+ [:p {:role "alert"} (:invalid labels)])
+ (when on-toggle-advanced
+ [:> icon-button* {:variant "ghost"
+ :selected advanced-open
+ :aria-label (or (:advanced labels) (tr "workspace.options.text-options.ruby-advanced-options"))
+ :aria-expanded advanced-open
+ :data-testid "ruby-advanced-options-toggle"
+ :on-click on-toggle-advanced
+ :icon i/menu}])]))
(mf/defc span-select-option*
"Select bound to one span attribute, with a disabled entry for mixed values."
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 6084807673..580d1977b8 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
@@ -7,6 +7,7 @@
(ns app.main.ui.workspace.sidebar.options.menus.text-language-layout
(:require-macros [app.main.style :as stl])
(:require
+ [app.common.types.text.cjk-layout :as cjk]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
[app.util.i18n :refer [tr]]
@@ -83,32 +84,54 @@
: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
+(mf/defc chinese-reading-options*
+ [{:keys [values ruby-values text-selection-active reading-selection-active zhuyin-selection-active
on-change on-ruby-presentation-change on-blur]}]
- (let [labels {:input (tr "workspace.options.text-options.pinyin")
- :placeholder (tr "workspace.options.text-options.pinyin-placeholder")
+ (let [zhuyin? (true? (:ruby-zhuyin values))
+ labels {:input (if zhuyin? (tr "workspace.options.text-options.zhuyin") (tr "workspace.options.text-options.pinyin"))
+ :placeholder (if zhuyin? (tr "workspace.options.text-options.zhuyin-placeholder") (tr "workspace.options.text-options.pinyin-placeholder"))
:advanced (tr "workspace.options.text-options.pinyin-advanced-options")
- :hidden (tr "workspace.options.text-options.pinyin-hidden")
+ :hidden (if zhuyin? (tr "workspace.options.text-options.zhuyin-hidden") (tr "workspace.options.text-options.pinyin-hidden"))
:size (tr "workspace.options.text-options.pinyin-size")
:align (tr "workspace.options.text-options.pinyin-align")
:overhang (tr "workspace.options.text-options.pinyin-overhang")
:side (tr "workspace.options.text-options.pinyin-side")}
+ handle-format-change (mf/use-fn
+ (mf/deps on-change)
+ (fn [value] (on-change {:ruby-zhuyin (= "zhuyin" value)})))
props (mf/props {:values values :on-change on-change
- :on-blur on-blur :labels labels})]
+ :on-blur on-blur :labels (cond-> labels zhuyin?
+ (assoc :validate cjk/zhuyin-reading?
+ :invalid (tr "workspace.options.text-options.zhuyin-invalid")))})]
[:div {:class (stl/css :pinyin-options)}
- (if reading-selection-active
- [:> tjl/ruby-advanced-options* props]
+ (when reading-selection-active
+ [:div {:class (stl/css :profile-option)}
+ [:span {:class (stl/css :option-label)} (tr "workspace.options.text-options.chinese-reading-format")]
+ [:> select* {:default-selected (if zhuyin? "zhuyin" "pinyin")
+ :aria-label (tr "workspace.options.text-options.chinese-reading-format")
+ :options [{:id "pinyin" :label (tr "workspace.options.text-options.pinyin")}
+ {:id "zhuyin" :label (tr "workspace.options.text-options.zhuyin")
+ :disabled (not zhuyin-selection-active)}]
+ :on-change handle-format-change}]])
+ (if (and reading-selection-active (or (not zhuyin?) zhuyin-selection-active))
+ (if zhuyin?
+ [:* [:p {:class (stl/css :selection-hint)} (tr "workspace.options.text-options.zhuyin-selection-hint")]
+ [:> tjl/ruby-options* props]
+ [:> tjl/ruby-hidden-option* props]]
+ [:> tjl/ruby-advanced-options* props])
[:*
[:p {:class (stl/css :selection-hint)}
- (tr "workspace.options.text-options.pinyin-selection-hint")]
+ (if zhuyin? (tr "workspace.options.text-options.zhuyin-selection-hint") (tr "workspace.options.text-options.pinyin-selection-hint"))]
(when-not text-selection-active
- [:> tjl/ruby-presentation-options*
- (mf/spread-props props {:values ruby-values
- :on-change on-ruby-presentation-change})])])]))
+ (if zhuyin?
+ [:> tjl/ruby-hidden-option*
+ (mf/spread-props props {:values ruby-values :on-change on-ruby-presentation-change})]
+ [:> tjl/ruby-presentation-options*
+ (mf/spread-props props {:values ruby-values
+ :on-change on-ruby-presentation-change})]))])]))
(mf/defc language-layout-options*
- [{:keys [language values ruby-values text-selection-active reading-selection-active
+ [{:keys [language values ruby-values text-selection-active reading-selection-active zhuyin-selection-active
on-change on-ruby-presentation-change on-blur]}]
(let [profile (:text-language values)
selected (if (= :multiple profile) "multiple" (or profile ""))
@@ -135,10 +158,11 @@
(when (and (= :chinese language) matches?)
[:> chinese-decoration-options* props])
(when (and (= :chinese language) matches?)
- [:> pinyin-options*
+ [:> chinese-reading-options*
(mf/spread-props props {:ruby-values ruby-values
:text-selection-active text-selection-active
:reading-selection-active reading-selection-active
+ :zhuyin-selection-active zhuyin-selection-active
:on-ruby-presentation-change on-ruby-presentation-change})])
(when (and (= :korean language) matches?)
[:> tjl/span-select-option*
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
index 54d1435251..6ee15f2d10 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
@@ -172,6 +172,12 @@
(wasm.text-editor/get-cached-content id)
(wasm.text-editor/text-editor-get-selection)))
+ zhuyin-selection-active
+ (and reading-selection-active
+ (wasm.text-editor/zhuyin-selection?
+ (wasm.text-editor/get-cached-content id)
+ (wasm.text-editor/text-editor-get-selection)))
+
fill-values
(dwt/current-text-values
{:editor-styles editor-styles
@@ -256,6 +262,7 @@
:ruby-values ruby-values
:text-selection-active text-selection-active
:reading-selection-active reading-selection-active
+ :zhuyin-selection-active zhuyin-selection-active
:libraries libraries
:file-id file-id
:typographies typographies}]
diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs
index ecf3a137d3..6327dd6a79 100644
--- a/frontend/src/app/plugins/text.cljs
+++ b/frontend/src/app/plugins/text.cljs
@@ -507,6 +507,11 @@
:get (fn [self] (range-japanese-value self start end :ruby-hidden))
:set (range-attr-setter plugin-id page-id id start end :rubyHidden :ruby-hidden boolean?)}
+ :rubyZhuyin
+ {:this true
+ :get (fn [self] (range-japanese-value self start end :ruby-zhuyin))
+ :set (range-attr-setter plugin-id page-id id start end :rubyZhuyin :ruby-zhuyin boolean?)}
+
:rubySize
{:this true
:get (fn [self] (range-japanese-value self start end :ruby-size))
@@ -984,6 +989,10 @@
:get #(shape-japanese-value % :ruby-hidden)
:set (shape-attr-setter plugin-id page-id :rubyHidden :ruby-hidden boolean?)}
+ {:name "rubyZhuyin"
+ :get #(shape-japanese-value % :ruby-zhuyin)
+ :set (shape-attr-setter plugin-id page-id :rubyZhuyin :ruby-zhuyin boolean?)}
+
{:name "rubySize"
:get #(shape-japanese-value % :ruby-size)
:set (shape-attr-setter plugin-id page-id :rubySize :ruby-size (enum-value? :ruby-size))}
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index e4cea6b556..7c0406a6ba 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -2887,6 +2887,7 @@
(def ^:private POSITION-DATA-VERTICAL 2)
(def ^:private POSITION-DATA-RUBY 3)
(def ^:private POSITION-DATA-EMPHASIS-MARK 4)
+(def ^:private POSITION-DATA-ZHUYIN 5)
(defn- emphasis-mark-entry
"Position-data entry for one emphasis mark (圏点): the geometry is the
@@ -2944,6 +2945,20 @@
:fills (get element :fills)
:text text}))))))
+(defn- zhuyin-glyph-entry
+ "One upright Zhuyin glyph at the renderer's drawing baseline."
+ [element {:keys [start-pos end-pos x y width height]}]
+ (when-let [reading (cjk/visible-ruby element)]
+ (d/patch-object
+ (txt/get-default-text-attrs)
+ (d/without-nils
+ {:x x :y (+ y height) :width width :height height
+ :direction "ltr" :zhuyin-glyph true
+ :font-id (:font-id element) :font-family (:font-family element)
+ :font-size (dm/str width "px") :font-weight (:font-weight element)
+ :font-style (:font-style element) :fills (:fills element)
+ :text (subs reading start-pos end-pos)}))))
+
(defn- text-strip-entry
"Position-data entry for a strip of base text. Be aware that for RTL texts
`start-pos` can be greater than `end-pos`."
@@ -2978,7 +2993,10 @@
;; Each warichu sub-line has its own entry, drawn at half size.
:warichu (when (= "warichu" (get element :warichu)) "warichu")
;; Horizontal data has no ruby strip; the base entry carries ruby for SVG export.
- :ruby (when (and (not vertical?) (seq element-text)) ruby)
+ :ruby (when (and (not vertical?) (seq element-text)
+ (not (and (true? (:ruby-zhuyin element))
+ (cjk/zhuyin-base? element-text)
+ (cjk/zhuyin-reading? ruby)))) ruby)
:ruby-size (get element :ruby-size)
:ruby-align (get element :ruby-align)
:ruby-overhang (get element :ruby-overhang)
@@ -3022,6 +3040,7 @@
element (-> paragraph-node :children (get span))]
(condp = direction
POSITION-DATA-RUBY (ruby-strip-entry element entry)
+ POSITION-DATA-ZHUYIN (zhuyin-glyph-entry element entry)
POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry)
(text-strip-entry paragraph-node element entry)))))
result))))
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index 4410dd88b0..d2e44f5562 100644
--- a/frontend/src/app/render_wasm/text_editor.cljs
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -800,6 +800,15 @@
(or (not (cjk/annotated-span? span))
(= (- end-offset start-offset) (count (:text span)))))))))
+(defn zhuyin-selection?
+ "True when the reading selection is exactly one Han base."
+ [content selection]
+ (and (reading-selection? content selection)
+ (let [{:keys [start-para start-offset end-offset]} (normalize-selection selection)
+ paragraph (get-in content [:children 0 :children start-para])
+ text (apply str (map :text (:children paragraph)))]
+ (cjk/zhuyin-base? (subs text start-offset end-offset)))))
+
(defn selection-japanese-styles
"Japanese span styles of a WASM selection, read from the cached content.
A range with differing values reports :multiple; a caret reports its
diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs
index f3722b47eb..2c8d58792c 100644
--- a/frontend/src/app/util/text/content/styles.cljs
+++ b/frontend/src/app/util/text/content/styles.cljs
@@ -22,6 +22,7 @@
(def mapping
{:fills [encode decode]
+ :ruby-zhuyin [encode decode]
:typography-ref-id [encode decode]
:typography-ref-file [encode decode]
:font-id [identity identity]
diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs
index 7c27005467..7055429a53 100644
--- a/frontend/test/frontend_tests/plugins/text_test.cljs
+++ b/frontend/test/frontend_tests/plugins/text_test.cljs
@@ -351,3 +351,13 @@
[^js text shape] (text-proxy-over content)]
(with-redefs [u/proxy->shape (constantly shape)]
(t/is (= "none" (.-textEmphasis text))))))
+
+(t/deftest zhuyin-mode-is-a-boolean-on-shapes-and-ranges
+ (let [range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 1)
+ result (capture-japanese-update range "rubyZhuyin" true two-span-content)
+ bad (capture-japanese-update range "rubyZhuyin" "yes" two-span-content)
+ [text] (text-proxy-over two-span-content)
+ shape (capture-japanese-update text "rubyZhuyin" false two-span-content)]
+ (t/is (= {:ruby-zhuyin true} (:attrs result)))
+ (t/is (nil? (:attrs bad)))
+ (t/is (= {:ruby-zhuyin false} (:attrs shape)))))
diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs
index 9d68d9817f..e411446109 100644
--- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs
+++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs
@@ -73,6 +73,7 @@
:text-underline-position "auto"
:ruby "にち"
:ruby-hidden false
+ :ruby-zhuyin false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
@@ -88,6 +89,7 @@
:text-underline-position "auto"
:ruby ""
:ruby-hidden false
+ :ruby-zhuyin false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
@@ -130,6 +132,7 @@
:annotation-clearance "auto"}
{:text "年"
:ruby-hidden false
+ :ruby-zhuyin false
:warichu "warichu"
:font-features "vpal"}])]
(t/is (= {:text-combine-upright :multiple
@@ -138,6 +141,7 @@
:text-underline-position :multiple
:ruby :multiple
:ruby-hidden :multiple
+ :ruby-zhuyin false
:ruby-size :multiple
:ruby-align :multiple
:ruby-overhang :multiple
@@ -610,3 +614,32 @@
(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"})))))
+
+(t/deftest zhuyin-mode-round-trips-in-existing-span-flags
+ (doseq [[attrs expected] [[{} 0] [{:ruby-side "under"} 1]
+ [{:ruby-zhuyin true} 2]
+ [{:ruby-zhuyin true :ruby-side "under"} 3]]]
+ (t/is (= expected (span-byte (serialized-span attrs) :ruby-side)))))
+
+(t/deftest zhuyin-selection-requires-a-whole-single-han-base
+ (let [content (editor-content [{:text "你好"} {:text "𠀀"}] [{:text "中" :ruby "ㄓㄨㄥ" :ruby-zhuyin true}])]
+ (t/is (true? (text-editor/zhuyin-selection? content (selection 0 0 0 1))))
+ (t/is (true? (text-editor/zhuyin-selection? content (selection 0 2 0 4))))
+ (t/is (true? (text-editor/zhuyin-selection? content (selection 1 0 1 1))))
+ (t/is (false? (text-editor/zhuyin-selection? content (selection 0 0 0 2))))
+ (t/is (false? (text-editor/zhuyin-selection? content (selection 0 2 0 3))))))
+
+(t/deftest dom-style-data-keeps-zhuyin-as-a-boolean
+ (let [attrs {:ruby-zhuyin true :ruby "ㄋㄧˇ"}
+ encoded (content-styles/attrs->styles attrs)
+ decoded (content-styles/styles->attrs (js->clj encoded))]
+ (t/is (true? (:ruby-zhuyin decoded)))
+ (t/is (= "ㄋㄧˇ" (:ruby decoded)))))
+
+(t/deftest zhuyin-export-retains-default-fonts-when-optional-span-fonts-are-absent
+ (let [entry (@#'api/zhuyin-glyph-entry
+ {:text "中" :ruby "ㄓ" :ruby-zhuyin true}
+ {:start-pos 0 :end-pos 1 :x 10 :y 20 :width 6 :height 6})]
+ (t/is (= "sourcesanspro" (:font-family entry)))
+ (t/is (= "ㄓ" (:text entry)))
+ (t/is (= 26 (:y entry)))))
diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs
index fa1c50bdf5..0baf4d44f2 100644
--- a/frontend/test/frontend_tests/ui/text_options_test.cljs
+++ b/frontend/test/frontend_tests/ui/text_options_test.cljs
@@ -503,3 +503,15 @@
(let [before (mf/props {:values {attr "auto"}})
after (mf/props {:values {attr "under-left"}})]
(t/is (false? (@#'text-menu/check-props before after))))))
+
+(t/deftest zhuyin-handoff-sets-symbols-upright-in-a-right-side-strip
+ (let [html (rds/renderToStaticMarkup
+ (mf/element html-text/render-text*
+ {:node {:text "中" :ruby "ㄋㄧˇ" :ruby-zhuyin true :font-size "40"}
+ :shape {} :parent {}}))]
+ (t/is (str/includes? html "width:1.6em"))
+ (t/is (str/includes? html "writing-mode:horizontal-tb"))
+ (t/is (str/includes? html "ㄋ"))
+ (t/is (str/includes? html "ㄧ"))
+ (t/is (str/includes? html "ˇ"))
+ (t/is (not (str/includes? html ". The strip is wider than the reference's
+half-em minimum to contain a full-width tone fallback glyph.
+
+Horizontal layout reserves the strip in the inline advance. Vertical Auto
+clearance reserves it in the right gutter, outside a same-side underline
+and inside same-side emphasis. Fixed clearance may allow overlaps, as with
+ordinary ruby. Zhuyin ignores ruby size, alignment, overhang and side;
+hiding a reading removes its room without clearing saved text or mode.
+
+The mode uses bit 1 of span header byte 13; bit 0 keeps over/under for normal
+ruby. Header size stays unchanged. Unknown flag bits fall back to ordinary
+over ruby. Position-data direction 5 carries a single upright annotation
+glyph with offsets into the original ruby string and its drawing baseline.
+Both layout paths use it for SVG; canvas, PNG, PDF and vertical outlines use
+the same shaped glyph positions. HTML handoff uses positioned symbol boxes;
+font metrics can make it differ from the renderer's ink-centred placement.
+
+This slice supports Mandarin, one base per span. Group-to-syllable mapping,
+dialectal symbols/tones, automatic pronunciation, native IME acceptance and
+native-reader approval remain outside this slice. Changing a base into a
+group preserves the reading and uses normal ruby until a single base is
+restored. Existing Pinyin and Japanese readings keep their saved behavior.
diff --git a/render-wasm/scripts/build-cjk-test-font.py b/render-wasm/scripts/build-cjk-test-font.py
index b9e8281b9e..9b6620f8c2 100644
--- a/render-wasm/scripts/build-cjk-test-font.py
+++ b/render-wasm/scripts/build-cjk-test-font.py
@@ -18,6 +18,7 @@ def main():
names = [".notdef", "base", "japanese", "chinese", "korean", "square", "space", "thirdEm"]
builder.setupGlyphOrder(names)
cmap = {ord(char): "square" for char in "文あっ한국어문장한글한「」,。"}
+ cmap.update({ord(char): "square" for char in "ㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄧㄨㄩㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦˉˊˇˋ˙"})
cmap.update({0x4E2D: "base", 0x20: "space", 0x2004: "thirdEm"})
builder.setupCharacterMap(cmap)
glyphs = {}
diff --git a/render-wasm/src/fonts/cjk-locl-test.ttf b/render-wasm/src/fonts/cjk-locl-test.ttf
index 90a910c2fc..c749061291 100644
Binary files a/render-wasm/src/fonts/cjk-locl-test.ttf and b/render-wasm/src/fonts/cjk-locl-test.ttf differ
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 6b3d5bb3df..197bff610e 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -35,6 +35,7 @@ mod text_japanese;
pub mod text_paths;
pub mod text_policy;
pub mod text_vertical;
+mod text_zhuyin;
mod transform;
pub use blend::*;
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 99ffd11a01..4c66b0f6b1 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -2280,6 +2280,7 @@ pub struct TextSpan {
pub text_underline_position: TextUnderlinePosition,
/// Ruby (furigana) annotation for this span; empty means no ruby.
pub ruby: String,
+ pub ruby_zhuyin: bool,
pub ruby_size: RubySize,
pub ruby_align: RubyAlign,
pub ruby_overhang: RubyOverhang,
@@ -2313,6 +2314,7 @@ impl Default for TextSpan {
text_emphasis_position: TextEmphasisPosition::default(),
text_underline_position: TextUnderlinePosition::default(),
ruby: String::default(),
+ ruby_zhuyin: false,
ruby_size: RubySize::default(),
ruby_align: RubyAlign::default(),
ruby_overhang: RubyOverhang::default(),
@@ -2388,7 +2390,32 @@ impl TextSpan {
}
pub fn ruby_font_size(&self) -> f32 {
- self.font_size * self.ruby_size.scale()
+ self.font_size
+ * if self.is_zhuyin() {
+ super::text_zhuyin::FONT_SCALE
+ } else {
+ self.ruby_size.scale()
+ }
+ }
+
+ pub fn is_zhuyin(&self) -> bool {
+ super::text_zhuyin::syllable(self).is_some()
+ }
+
+ pub fn effective_ruby_side(&self) -> RubySide {
+ if self.is_zhuyin() {
+ RubySide::Over
+ } else {
+ self.ruby_side
+ }
+ }
+
+ pub fn ruby_cross_room_em(&self) -> f32 {
+ if self.is_zhuyin() {
+ super::text_zhuyin::STRIP_WIDTH
+ } else {
+ self.ruby_size.scale()
+ }
}
/// Warichu needs at least two characters to fill its two sub-lines.
@@ -2421,7 +2448,8 @@ impl TextSpan {
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)
+ && (!self.is_zhuyin() || vertical)
+ && self.effective_ruby_side() == self.emphasis_side(vertical)
}
pub fn decoration_room_em(&self, side: RubySide, vertical: bool) -> f32 {
@@ -2446,12 +2474,12 @@ impl TextSpan {
}
pub fn ruby_decoration_offset(&self, vertical: bool) -> f32 {
- self.font_size * self.decoration_room_em(self.ruby_side, vertical)
+ self.font_size * self.decoration_room_em(self.effective_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()
+ self.font_size * self.ruby_cross_room_em()
} else {
0.0
};
@@ -2462,8 +2490,11 @@ impl TextSpan {
if !self.annotation_clearance.is_auto() {
return 0.0;
}
- let ruby = if self.has_ruby() && self.ruby_side == side {
- self.ruby_size.scale()
+ let ruby = if self.has_ruby()
+ && (!self.is_zhuyin() || vertical)
+ && self.effective_ruby_side() == side
+ {
+ self.ruby_cross_room_em()
} else {
0.0
};
@@ -2787,6 +2818,14 @@ impl HorizontalPositionEntries<'_> {
entries.extend(self.span_entries(range));
}
}
+ for mut entry in
+ horizontal_zhuyin_entries(self.paragraph, self.offsets, &self.layout.paragraph)
+ {
+ entry.paragraph = self.paragraph_index as u32;
+ entry.x += self.layout.x;
+ entry.y += self.layout.y;
+ entries.push(entry);
+ }
for placement in
horizontal_emphasis_placements(self.paragraph, self.offsets, &self.layout.paragraph)
{
diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs
index 63c0623bd8..8dce91944f 100644
--- a/render-wasm/src/shapes/text_horizontal/ruby.rs
+++ b/render-wasm/src/shapes/text_horizontal/ruby.rs
@@ -3,6 +3,53 @@ use std::collections::HashMap;
use super::*;
+pub(crate) fn horizontal_zhuyin_origin(span: &TextSpan, rect: skia::Rect) -> skia::Point {
+ skia::Point::new(
+ rect.right() - span.font_size * super::super::text_zhuyin::STRIP_WIDTH,
+ rect.top.max(rect.bottom - span.font_size),
+ )
+}
+
+pub(crate) fn horizontal_zhuyin_entries(
+ paragraph: &Paragraph,
+ offsets: &HorizontalOffsets,
+ laid_out: &skia::textlayout::Paragraph,
+) -> Vec {
+ if !paragraph.children().iter().any(TextSpan::is_zhuyin) {
+ return Vec::new();
+ }
+ let provider = get_resources().fonts.font_provider();
+ let fallbacks = get_fallback_fonts();
+ let mut entries = Vec::new();
+ for (index, range) in horizontal_ruby_targets(paragraph, offsets) {
+ let span = ¶graph.children()[index];
+ let Some(syllable) = super::super::text_zhuyin::syllable(span) else {
+ continue;
+ };
+ let rects =
+ laid_out.get_rects_for_range(range, RectHeightStyle::Tight, RectWidthStyle::Tight);
+ let Some(rect) = rects.first() else {
+ continue;
+ };
+ let origin = horizontal_zhuyin_origin(span, rect.rect);
+ let size = span.ruby_font_size();
+ for glyph in super::super::text_zhuyin::shape(span, &syllable, provider, fallbacks) {
+ entries.push(crate::shapes::PositionData {
+ paragraph: 0,
+ span: index as u32,
+ start_pos: glyph.range.start as u32,
+ end_pos: glyph.range.end as u32,
+ x: origin.x + glyph.origin.x,
+ y: origin.y + glyph.origin.y - size,
+ width: size,
+ height: size,
+ direction: super::super::text_zhuyin::DIRECTION_ZHUYIN,
+ });
+ }
+ }
+ entries
+}
+
/// Extra spacing for long horizontal ruby, per span of a paragraph.
#[derive(Debug, Clone, Default)]
pub(crate) struct HorizontalRubySpacing {
@@ -138,6 +185,17 @@ pub(super) fn horizontal_ruby_spacing_with(
let Some(&last) = base.last() else {
continue;
};
+ if span.is_zhuyin() {
+ // Inter-character annotations consume inline space, not ruby's
+ // above/below leading. Reserve the strip even at a line end.
+ for char_index in &base {
+ spacing.adjustments[index].push((
+ *char_index,
+ span.font_size * super::super::text_zhuyin::STRIP_WIDTH,
+ ));
+ }
+ continue;
+ }
let ruby_font_size = span.ruby_font_size();
let base_width = advances(span, &span.apply_text_transform(), span.font_size)
.iter()
@@ -386,6 +444,28 @@ pub(crate) fn paint_horizontal_ruby(
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
+ if let Some(syllable) = super::super::text_zhuyin::syllable(span) {
+ if let Some(rect) = rects.first() {
+ for glyph in super::super::text_zhuyin::shape(
+ span,
+ &syllable,
+ font_provider,
+ fallback_families,
+ ) {
+ if let Some(blob) =
+ single_glyph_blob(&glyph.run.font, glyph.run.glyphs[glyph.glyph])
+ {
+ let origin = horizontal_zhuyin_origin(span, rect.rect);
+ canvas.draw_text_blob(
+ &blob,
+ (x + origin.x + glyph.origin.x, y + origin.y + glyph.origin.y),
+ &paint,
+ );
+ }
+ }
+ }
+ continue;
+ }
let shaped = shape_segment_with_fallbacks(
span.ruby_text(),
span.ruby_font_size(),
diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs
index 263cb95bfb..3d7f77ae85 100644
--- a/render-wasm/src/shapes/text_horizontal/tests.rs
+++ b/render-wasm/src/shapes/text_horizontal/tests.rs
@@ -1582,3 +1582,24 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() {
assert_eq!(squeezed_chars, commas, "both commas give up aki");
assert!((sheds[0][0].1 - sheds[0][1].1).abs() < 0.01, "equally");
}
+
+#[test]
+fn horizontal_zhuyin_reserves_its_right_strip_without_changing_line_leading() {
+ init_state();
+ for reading in ["ㄓ", "ㄋㄧˇ", "˙ㄉㄜ", "ㄓㄨㄤˋ"] {
+ let span = TextSpan {
+ ruby: reading.into(),
+ ruby_zhuyin: true,
+ annotation_clearance: AnnotationClearance::Auto,
+ ..make_span("中", 0.0)
+ };
+ let paragraph = make_paragraph(vec![span.clone(), make_span("文", 0.0)], 0.0);
+ let spacing = test_ruby_spacing(¶graph);
+ assert_eq!(spacing.adjustments[0], vec![(0, span.font_size * 0.6)]);
+ assert_eq!(span.annotation_room_em(), 0.0);
+ let rect = skia::Rect::from_xywh(0.0, 0.0, span.font_size * 1.6, span.font_size);
+ let origin = horizontal_zhuyin_origin(&span, rect);
+ assert!((origin.x - span.font_size).abs() < 0.001);
+ assert_eq!(origin.y, 0.0);
+ }
+}
diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs
index da2b2432c2..dde5cbc2fb 100644
--- a/render-wasm/src/shapes/text_vertical/annotations.rs
+++ b/render-wasm/src/shapes/text_vertical/annotations.rs
@@ -48,6 +48,7 @@ pub struct RubyCell {
pub column: usize,
/// Flow-axis top of each glyph.
pub glyph_tops: Vec,
+ pub zhuyin_origins: Option>,
pub font_size: f32,
pub base_font_size: f32,
pub outside_offset: f32,
@@ -142,16 +143,20 @@ pub(super) fn ruby_base_units(
span: span_index,
start: span_starts[span_index],
end: span_starts[span_index] + transform.text.encode_utf16().count(),
- ruby_length: ruby_glyphs(
- span.ruby_text(),
- 0,
- &shape_ruby(span, fonts),
- 0,
- span.ruby_font_size(),
- )
- .iter()
- .map(|glyph| glyph.extent)
- .sum(),
+ ruby_length: if span.is_zhuyin() {
+ span.font_size
+ } else {
+ ruby_glyphs(
+ span.ruby_text(),
+ 0,
+ &shape_ruby(span, fonts),
+ 0,
+ span.ruby_font_size(),
+ )
+ .iter()
+ .map(|glyph| glyph.extent)
+ .sum()
+ },
ruby_font_size: span.ruby_font_size(),
overhang: span.ruby_overhang,
room: (0.0, 0.0),
@@ -468,6 +473,43 @@ pub(super) fn layout_ruby(
) else {
continue;
};
+ if let Some(syllable) = super::super::text_zhuyin::syllable(span) {
+ let shaped = super::super::text_zhuyin::shape(
+ span,
+ &syllable,
+ fonts.provider,
+ fonts.fallback_families,
+ );
+ let top = first.top + (first.extent - span.font_size) / 2.0;
+ let mut glyphs = Vec::new();
+ let mut origins = Vec::new();
+ for glyph in shaped {
+ glyphs.push(RubyGlyph {
+ run: ruby_runs.len(),
+ glyph: glyph.glyph,
+ utf16_start: glyph.range.start,
+ utf16_end: glyph.range.end,
+ sideways: false,
+ extent: span.ruby_font_size(),
+ });
+ origins.push(skia::Point::new(glyph.origin.x, top + glyph.origin.y));
+ ruby_runs.push(glyph.run);
+ }
+ ruby_cells.push(RubyCell {
+ glyph_tops: origins.iter().map(|p| p.y).collect(),
+ zhuyin_origins: Some(origins),
+ glyphs,
+ paragraph: paragraph_index,
+ span: span_index,
+ column: first.column,
+ font_size: span.ruby_font_size(),
+ base_font_size: span.font_size,
+ outside_offset: span.ruby_decoration_offset(true),
+ side: RubySide::Over,
+ paint: first.paint,
+ });
+ continue;
+ }
let ruby_text = span.ruby_text();
let ruby_font_size = span.ruby_font_size();
let shaped = shape_ruby(span, fonts);
@@ -517,6 +559,7 @@ pub(super) fn layout_ruby(
span: span_index,
column: first.column,
glyph_tops,
+ zhuyin_origins: None,
font_size: ruby_font_size,
base_font_size: span.font_size,
outside_offset: span.ruby_decoration_offset(true),
@@ -726,6 +769,58 @@ mod tests {
)
}
+ #[test]
+ fn zhuyin_is_upright_per_base_and_tones_have_exact_source_ranges() {
+ use crate::shapes::text_zhuyin;
+ let content = spans_content(
+ vec![
+ TextSpan {
+ ruby: "˙ㄉㄜ".into(),
+ ruby_zhuyin: true,
+ annotation_clearance: crate::shapes::AnnotationClearance::Auto,
+ ..make_span("中")
+ },
+ TextSpan {
+ ruby: "ㄋㄧˇ".into(),
+ ruby_zhuyin: true,
+ annotation_clearance: crate::shapes::AnnotationClearance::Auto,
+ ..make_span("文")
+ },
+ ],
+ 400.0,
+ );
+ let layout = layout_with(
+ &provider(include_bytes!("../../fonts/cjk-locl-test.ttf")),
+ &content,
+ );
+ assert_eq!(layout.ruby_cells.len(), 2);
+ for ruby in &layout.ruby_cells {
+ assert_eq!(ruby.glyphs.len(), 3);
+ assert!(ruby.glyphs.iter().all(|glyph| !glyph.sideways));
+ let origins = ruby.zhuyin_origins.as_ref().unwrap();
+ assert_eq!(origins.len(), 3);
+ assert!((ruby.font_size - EM * 0.3).abs() < 0.01);
+ }
+ let neutral = layout.ruby_cells[0].zhuyin_origins.as_ref().unwrap();
+ assert!(neutral[0].y < neutral[1].y);
+ let tone = layout.ruby_cells[1].zhuyin_origins.as_ref().unwrap();
+ assert!(tone[2].x > tone[1].x);
+ assert!(tone[2].y < tone[1].y);
+ let entries = super::super::positions::position_data(
+ &layout,
+ &content.bounds(),
+ crate::shapes::VerticalAlign::Top,
+ );
+ let readings: Vec<_> = entries
+ .iter()
+ .filter(|p| p.direction == text_zhuyin::DIRECTION_ZHUYIN)
+ .collect();
+ assert_eq!(readings.len(), 6);
+ assert!(readings
+ .iter()
+ .all(|p| p.end_pos == p.start_pos + 1 && p.width == EM * 0.3));
+ }
+
#[test]
fn utf16_slices_keep_the_characters_starting_in_range() {
let linear = |text: &str, start: usize, end: usize| {
diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs
index 750a878131..2d67c83175 100644
--- a/render-wasm/src/shapes/text_vertical/paint.rs
+++ b/render-wasm/src/shapes/text_vertical/paint.rs
@@ -247,6 +247,23 @@ fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec Vec {
let column = &layout.columns[ruby.column];
+ if let Some(origins) = &ruby.zhuyin_origins {
+ return ruby
+ .glyphs
+ .iter()
+ .zip(origins)
+ .filter_map(|(glyph, offset)| {
+ let run = &layout.ruby_runs[glyph.run];
+ Some(GlyphDraw::at(
+ run.glyph_blob(glyph.glyph)?,
+ (
+ origin.0 + ruby.strip_x(column) + offset.x,
+ origin.1 + offset.y,
+ ),
+ ))
+ })
+ .collect();
+ }
let gutter_center = origin.0 + ruby.strip_x(column) + ruby.font_size / 2.0;
ruby.glyphs
.iter()
diff --git a/render-wasm/src/shapes/text_vertical/positions.rs b/render-wasm/src/shapes/text_vertical/positions.rs
index 957b2857cf..e1dda69925 100644
--- a/render-wasm/src/shapes/text_vertical/positions.rs
+++ b/render-wasm/src/shapes/text_vertical/positions.rs
@@ -198,27 +198,50 @@ fn ruby_entries(layout: &VerticalLayout, (origin_x, origin_y): (f32, f32)) -> Ve
layout
.ruby_cells
.iter()
- .filter_map(|ruby| {
- let (first, last) = (ruby.glyphs.first()?, ruby.glyphs.last()?);
- let column = &layout.columns[ruby.column];
- let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min);
- let bottom = ruby
- .glyph_tops
- .iter()
- .zip(&ruby.glyphs)
- .map(|(top, glyph)| top + glyph.extent)
- .fold(f32::MIN, f32::max);
- Some(PositionData {
- paragraph: ruby.paragraph as u32,
- span: ruby.span as u32,
- start_pos: first.utf16_start as u32,
- end_pos: last.utf16_end as u32,
- x: origin_x + ruby.strip_x(column),
- y: origin_y + top,
- width: ruby.font_size,
- height: bottom - top,
- direction: DIRECTION_VERTICAL_RUBY,
- })
+ .flat_map(|ruby| {
+ if let Some(origins) = &ruby.zhuyin_origins {
+ let column = &layout.columns[ruby.column];
+ return ruby
+ .glyphs
+ .iter()
+ .zip(origins)
+ .map(|(glyph, origin)| PositionData {
+ paragraph: ruby.paragraph as u32,
+ span: ruby.span as u32,
+ start_pos: glyph.utf16_start as u32,
+ end_pos: glyph.utf16_end as u32,
+ x: origin_x + ruby.strip_x(column) + origin.x,
+ y: origin_y + origin.y - ruby.font_size,
+ width: ruby.font_size,
+ height: ruby.font_size,
+ direction: super::super::text_zhuyin::DIRECTION_ZHUYIN,
+ })
+ .collect::>();
+ }
+ (|| {
+ let (first, last) = (ruby.glyphs.first()?, ruby.glyphs.last()?);
+ let column = &layout.columns[ruby.column];
+ let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min);
+ let bottom = ruby
+ .glyph_tops
+ .iter()
+ .zip(&ruby.glyphs)
+ .map(|(top, glyph)| top + glyph.extent)
+ .fold(f32::MIN, f32::max);
+ Some(PositionData {
+ paragraph: ruby.paragraph as u32,
+ span: ruby.span as u32,
+ start_pos: first.utf16_start as u32,
+ end_pos: last.utf16_end as u32,
+ x: origin_x + ruby.strip_x(column),
+ y: origin_y + top,
+ width: ruby.font_size,
+ height: bottom - top,
+ direction: DIRECTION_VERTICAL_RUBY,
+ })
+ })()
+ .into_iter()
+ .collect::>()
})
.collect()
}
diff --git a/render-wasm/src/shapes/text_vertical/shaping.rs b/render-wasm/src/shapes/text_vertical/shaping.rs
index 68b27d6083..6cbe091e23 100644
--- a/render-wasm/src/shapes/text_vertical/shaping.rs
+++ b/render-wasm/src/shapes/text_vertical/shaping.rs
@@ -33,6 +33,7 @@ pub(crate) fn span_font_families(span: &TextSpan, fallback_fonts: &FallbackFonts
/// One shaped run: glyphs from a single font, pen-relative positions,
/// per-glyph horizontal advances and per-glyph UTF-8 cluster starts
/// (relative to the shaped segment text).
+#[derive(Clone)]
pub struct ShapedRun {
pub font: Font,
pub glyphs: Vec,
diff --git a/render-wasm/src/shapes/text_zhuyin.rs b/render-wasm/src/shapes/text_zhuyin.rs
new file mode 100644
index 0000000000..72a13384f3
--- /dev/null
+++ b/render-wasm/src/shapes/text_zhuyin.rs
@@ -0,0 +1,196 @@
+// Mandarin Zhuyin annotations: one Han base and one syllable per span.
+
+use std::ops::Range;
+
+use skia_safe::{self as skia, textlayout::TypefaceFontProvider, FontMgr};
+
+use super::text_vertical::{shape_segment_with_fallbacks, span_font_families, ShapedRun};
+use super::TextSpan;
+use crate::render::FallbackFonts;
+
+pub(crate) const FONT_SCALE: f32 = 0.3;
+pub(crate) const STRIP_WIDTH: f32 = 0.6;
+pub(crate) const DIRECTION_ZHUYIN: u32 = 5;
+
+pub(crate) struct Slot {
+ pub ch: char,
+ pub range: Range,
+ /// Centre of the glyph's ink, in base em, relative to the strip top-left.
+ pub x: f32,
+ pub y: f32,
+}
+
+pub(crate) struct Syllable {
+ chars: Vec,
+ symbols: Vec,
+ tone: Option,
+}
+
+impl Syllable {
+ pub fn parse(reading: &str) -> Option {
+ let chars: Vec = reading.chars().take(5).collect();
+ if chars.len() > 4 {
+ return None;
+ }
+ let mut symbols = Vec::new();
+ let mut tone = None;
+ for (index, ch) in chars.iter().enumerate() {
+ match ch {
+ '\u{3105}'..='\u{3129}' => symbols.push(index),
+ 'ˉ' | 'ˊ' | 'ˇ' | 'ˋ' | '˙' if tone.is_none() => tone = Some(index),
+ _ => return None,
+ }
+ }
+ if !(1..=3).contains(&symbols.len())
+ || tone.is_some_and(|i| i + 1 != chars.len() && !(i == 0 && chars[i] == '˙'))
+ {
+ return None;
+ }
+ Some(Self {
+ chars,
+ symbols,
+ tone,
+ })
+ }
+
+ pub fn slots(&self) -> Vec {
+ let neutral = self.tone.is_some_and(|i| self.chars[i] == '˙');
+ let lead = if neutral { 1.0 / 15.0 } else { 0.0 };
+ let top = (1.0 - self.symbols.len() as f32 * FONT_SCALE - lead) / 2.0;
+ self.chars
+ .iter()
+ .enumerate()
+ .map(|(i, ch)| {
+ let (x, y) = if let Some(symbol) = self.symbols.iter().position(|s| *s == i) {
+ (
+ FONT_SCALE / 2.0,
+ top + lead + (symbol as f32 + 0.5) * FONT_SCALE,
+ )
+ } else if neutral {
+ (FONT_SCALE / 2.0, top + lead / 2.0)
+ } else {
+ (0.45, top + (self.symbols.len() as f32 - 1.0) * FONT_SCALE)
+ };
+ Slot {
+ ch: *ch,
+ range: i..i + 1,
+ x,
+ y,
+ }
+ })
+ .collect()
+ }
+}
+
+/// A Han grapheme, including an optional variation selector. Do not attach
+/// a syllable to a group, Latin text, or an incomplete surrogate sequence.
+pub(crate) fn single_han(text: &str) -> bool {
+ let mut chars = text.chars();
+ let Some(first) = chars.next() else {
+ return false;
+ };
+ matches!(first as u32, 0x3400..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af)
+ && chars.next().is_none_or(|ch| {
+ matches!(ch as u32, 0xfe00..=0xfe0f | 0xe0100..=0xe01ef) && chars.next().is_none()
+ })
+}
+
+pub(crate) fn syllable(span: &TextSpan) -> Option {
+ (span.ruby_zhuyin && span.has_ruby() && single_han(&span.text))
+ .then(|| Syllable::parse(span.ruby_text()))
+ .flatten()
+}
+
+pub(crate) struct Glyph {
+ pub run: ShapedRun,
+ pub glyph: usize,
+ pub range: Range,
+ /// Drawing baseline relative to the base em's top-right corner.
+ pub origin: skia::Point,
+}
+
+/// Shape each symbol upright, retaining each fallback font and source range.
+/// Ink-centred placement also works for fonts with narrow spacing tone marks.
+pub(crate) fn shape(
+ span: &TextSpan,
+ syllable: &Syllable,
+ provider: &TypefaceFontProvider,
+ fallbacks: &FallbackFonts,
+) -> Vec {
+ let manager = FontMgr::from(provider.clone());
+ let leading = span.ruby.encode_utf16().count() - span.ruby.trim_start().encode_utf16().count();
+ syllable
+ .slots()
+ .into_iter()
+ .flat_map(|slot| {
+ shape_segment_with_fallbacks(
+ &slot.ch.to_string(),
+ span.font_size * FONT_SCALE,
+ &span_font_families(span, fallbacks),
+ provider,
+ false,
+ span.font_features,
+ &manager,
+ &span.language,
+ )
+ .into_iter()
+ .flat_map(move |run| {
+ (0..run.glyphs.len()).map(move |glyph| {
+ let mut bounds = [skia::Rect::default()];
+ run.font.get_bounds(&[run.glyphs[glyph]], &mut bounds, None);
+ Glyph {
+ origin: skia::Point::new(
+ slot.x * span.font_size - bounds[0].center_x(),
+ slot.y * span.font_size - bounds[0].center_y(),
+ ),
+ run: run.clone(),
+ glyph,
+ range: leading + slot.range.start..leading + slot.range.end,
+ }
+ })
+ })
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn zhuyin_keeps_neutral_tone_above_and_other_tones_beside_the_last_symbol() {
+ for reading in ["ㄓ", "ㄋㄧˇ", "ㄓㄨㄤˋ", "˙ㄉㄜ", "ㄉㄜ˙", "ㄏㄠˊ", "ㄇㄚˉ"]
+ {
+ let syllable = Syllable::parse(reading).expect(reading);
+ let slots = syllable.slots();
+ assert_eq!(slots.len(), reading.chars().count());
+ let symbols: Vec<_> = slots.iter().filter(|s| s.ch >= '\u{3105}').collect();
+ assert!(symbols.windows(2).all(|s| s[0].y < s[1].y));
+ for tone in slots.iter().filter(|s| s.ch < '\u{3105}') {
+ if tone.ch == '˙' {
+ assert!(tone.y < symbols[0].y);
+ assert_eq!(tone.x, symbols[0].x);
+ } else {
+ assert!(tone.x > symbols.last().unwrap().x);
+ assert!(tone.y < symbols.last().unwrap().y);
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn zhuyin_rejects_malformed_or_multiple_syllables_without_guessing() {
+ for reading in [
+ "",
+ "nǐ",
+ "ˇ",
+ "ㄋˇㄧ",
+ "ㄋㄧˇˋ",
+ "ㄋ ㄧ",
+ "ㄅㄆㄇㄈ",
+ "ㄋ\nㄧ",
+ ] {
+ assert!(Syllable::parse(reading).is_none(), "{reading:?}");
+ }
+ }
+}
diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs
index 4ce34b3b4c..982819a17e 100644
--- a/render-wasm/src/wasm/text.rs
+++ b/render-wasm/src/wasm/text.rs
@@ -225,7 +225,15 @@ impl From for shapes::TextSpan {
span.ruby_size = RawRubySize::from(value.ruby_size).into();
span.ruby_align = RawRubyAlign::from(value.ruby_align).into();
span.ruby_overhang = RawRubyOverhang::from(value.ruby_overhang).into();
- span.ruby_side = RawRubySide::from(value.ruby_side).into();
+ // Bit 1 selects Zhuyin; bit 0 retains the existing over/under side.
+ // Reject unknown flags instead of treating corrupt bytes as Zhuyin.
+ let ruby_flags = if value.ruby_side <= 3 {
+ value.ruby_side
+ } else {
+ 0
+ };
+ span.ruby_side = RawRubySide::from(ruby_flags & 1).into();
+ span.ruby_zhuyin = ruby_flags & 2 != 0;
span
}
}
@@ -523,6 +531,23 @@ mod tests {
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)
/// attr bytes before the fills block. These sizes must match the struct
/// layouts.
+ #[test]
+ fn zhuyin_flags_keep_legacy_side_and_header_size() {
+ for (flags, zhuyin, side) in [
+ (0, false, RubySide::Over),
+ (1, false, RubySide::Under),
+ (2, true, RubySide::Over),
+ (3, true, RubySide::Under),
+ (255, false, RubySide::Over),
+ ] {
+ let mut bytes = [0u8; std::mem::size_of::()];
+ bytes[13] = flags;
+ let span: shapes::TextSpan = RawTextSpan::from(bytes).into();
+ assert_eq!(span.ruby_zhuyin, zhuyin);
+ assert_eq!(span.ruby_side, side);
+ }
+ }
+
#[test]
fn raw_struct_sizes_match_cljs_writer() {
const PARAGRAPH_ATTR_U8_SIZE: usize = 20;