From d5da7b2a75d8c27e8018ae5fbc59131517714d4b Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 20 Oct 2026 13:28:30 +0000 Subject: [PATCH] :sparkles: Add Mandarin Zhuyin reading layout Add typed one-character Zhuyin readings with upright right-side symbols and separate neutral and non-neutral tones in both writing modes. Persist the mode through the editor, WASM bridge and Plugin API. Preserve annotation clearance and canvas, PNG, PDF and SVG geometry. Validate syntax and selection boundaries, and cover schema, bridge, rendering, UI and API behavior. Native IME and reader review remain open. AI-assisted-by: gpt-6 --- .../app/common/render_wasm/text_content.cljs | 2 +- common/src/app/common/types/shape/text.cljc | 2 + .../src/app/common/types/text/cjk_layout.cljc | 35 +++- common/test/common_tests/types/text_test.cljc | 22 ++ .../app/main/ui/shapes/text/html_text.cljs | 19 +- .../src/app/main/ui/shapes/text/svg_text.cljs | 8 +- .../workspace/sidebar/options/menus/text.cljs | 7 +- .../options/menus/text_japanese_layout.cljs | 23 +- .../options/menus/text_language_layout.cljs | 52 +++-- .../sidebar/options/shapes/text.cljs | 7 + frontend/src/app/plugins/text.cljs | 9 + frontend/src/app/render_wasm/api.cljs | 21 +- frontend/src/app/render_wasm/text_editor.cljs | 9 + .../src/app/util/text/content/styles.cljs | 1 + .../frontend_tests/plugins/text_test.cljs | 10 + .../render_wasm/texts_test.cljs | 33 +++ .../frontend_tests/ui/text_options_test.cljs | 12 ++ frontend/translations/en.po | 18 ++ frontend/translations/es.po | 23 ++ plugins/CHANGELOG.md | 2 + plugins/libs/plugin-types/index.d.ts | 6 + render-wasm/docs/cjk-layout.md | 40 ++++ render-wasm/scripts/build-cjk-test-font.py | 1 + render-wasm/src/fonts/cjk-locl-test.ttf | Bin 1480 -> 1592 bytes render-wasm/src/shapes.rs | 1 + render-wasm/src/shapes/text.rs | 51 ++++- .../src/shapes/text_horizontal/ruby.rs | 80 +++++++ .../src/shapes/text_horizontal/tests.rs | 21 ++ .../src/shapes/text_vertical/annotations.rs | 115 +++++++++- render-wasm/src/shapes/text_vertical/paint.rs | 17 ++ .../src/shapes/text_vertical/positions.rs | 65 ++++-- .../src/shapes/text_vertical/shaping.rs | 1 + render-wasm/src/shapes/text_zhuyin.rs | 196 ++++++++++++++++++ render-wasm/src/wasm/text.rs | 27 ++- 34 files changed, 868 insertions(+), 68 deletions(-) create mode 100644 render-wasm/src/shapes/text_zhuyin.rs 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 90a910c2fc8fadfa8c865659114176bfea795ece..c74906129174e699782d6aa90eb73c8b0a9aadb7 100644 GIT binary patch delta 369 zcmX@Xy@O|hxZw;228Mb5!TLt7hIa%Q7#J;p{DkD(#DbqDFO~rLJAl|AJ*P6Qf2zI> z0|V2ai5~WhFD7=#E1m($WEbQYmu#N-eHQ}*D@bP?10#?G;w>t>|4lqGmsydMdvXS2 zni``FP+X5efq{jAk3oUyIMZpSn}R}uiGr&YSPYm9cnzEkk_|N{zhRW92TGnqk>mkO zvij+!wy(`y$FSSt=$7M4ug3i6`Tzg_v%hu>41aareSWv(UDN~S3(O1*=MSG>#K5re z%B;Ge89}TrXRQJ)60}w_`~*9Vfk6xC5Cl*HGZhd543qD$aQU+`P!(=2VG3ae0JuR@ AvH$=8 delta 262 zcmdnNbAo$Jphbe#N`{{d{2*r` PfYfA1R*B6|m_nEV(+E@n 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;