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;