From eb4ee38f0b0321b59e613aa221a8678761214fbe Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 20 Oct 2026 10:48:30 +0200 Subject: [PATCH] :sparkles: Add Chinese Pinyin reading controls Support typed character and group readings through the shared ruby model. Expose Chinese labels, presentation controls and selection guards without changing Japanese controls or generating pronunciation. Preserve complete source ranges for glyphs sharing a combining cluster. Add pinned horizontal and vertical tone-mark fixtures and editor tests. AI-assisted-by: GPT-6 --- .../workspace/sidebar/options/menus/text.cljs | 5 +- .../options/menus/text_japanese_layout.cljs | 39 +++++----- .../options/menus/text_language_layout.cljs | 38 ++++++++- .../options/menus/text_language_layout.scss | 13 ++++ .../sidebar/options/shapes/text.cljs | 9 +++ frontend/src/app/render_wasm/text_editor.cljs | 15 ++++ .../render_wasm/texts_test.cljs | 11 +++ .../frontend_tests/ui/text_options_test.cljs | 49 ++++++++++++ frontend/translations/en.po | 36 +++++++++ frontend/translations/es.po | 36 +++++++++ render-wasm/docs/cjk-layout.md | 26 ++++++- render-wasm/src/fonts/pinyin-test.ttf | Bin 0 -> 8368 bytes .../src/shapes/text_horizontal/tests.rs | 63 +++++++++++++++ .../src/shapes/text_vertical/annotations.rs | 72 +++++++++++++++++- .../src/shapes/text_vertical/test_support.rs | 3 + 15 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 render-wasm/src/fonts/pinyin-test.ttf 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 513efbc884..e6e83e4777 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 @@ -289,6 +289,8 @@ (unchecked-get o-props "typographies")) (identical? (unchecked-get n-props "textSelectionActive") (unchecked-get o-props "textSelectionActive")) + (identical? (unchecked-get n-props "readingSelectionActive") + (unchecked-get o-props "readingSelectionActive")) (= n-ruby-values o-ruby-values) (identical? (get o-values :fills) (get n-values :fills)) @@ -383,7 +385,7 @@ (mf/defc text-menu* {::mf/wrap [#(mf/memo' % check-props)]} - [{:keys [ids type values ruby-values text-selection-active + [{:keys [ids type values ruby-values text-selection-active reading-selection-active applied-tokens libraries file-id typographies]}] (let [;; --- UI state @@ -587,6 +589,7 @@ :values values :ruby-values ruby-values :text-selection-active text-selection-active + :reading-selection-active reading-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 dbfdb71f41..87df055df5 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 @@ -234,7 +234,7 @@ :on-change handle-change}]]))) (mf/defc ruby-options* - [{:keys [values on-change on-blur advanced-open on-toggle-advanced]}] + [{:keys [values on-change on-blur advanced-open on-toggle-advanced labels]}] (let [ruby (span-input-value (:ruby values)) ruby* (mf/use-state ruby) dirty* (mf/use-state false) @@ -269,15 +269,15 @@ [:div {:class (stl/css :ruby-options)} [:> input* {:class (stl/css :ruby-input) - :label (tr "workspace.options.text-options.ruby") - :placeholder (tr "workspace.options.text-options.ruby-placeholder") + :label (or (:input labels) (tr "workspace.options.text-options.ruby")) + :placeholder (or (:placeholder labels) (tr "workspace.options.text-options.ruby-placeholder")) :value value :on-change handle-change :on-blur commit! :on-key-down handle-key-down}] [:> icon-button* {:variant "ghost" :selected advanced-open - :aria-label (tr "workspace.options.text-options.ruby-advanced-options") + :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 @@ -303,20 +303,21 @@ :on-change handle-change}]])) (defn- ruby-common-props - [values on-change on-blur] + [values on-change on-blur labels] (mf/props {:values values :on-change on-change - :on-blur on-blur})) + :on-blur on-blur + :labels labels})) (mf/defc ruby-hidden-option* - [{:keys [values on-change on-blur]}] + [{:keys [values on-change on-blur labels]}] (let [value (:ruby-hidden values) enabled? (cond (mixed-span-value? value) nil (true? value) true :else false) - label (tr "workspace.options.text-options.ruby-hidden") + label (or (:hidden labels) (tr "workspace.options.text-options.ruby-hidden")) handle-change (mf/use-fn (mf/deps on-change on-blur) @@ -330,8 +331,8 @@ :on-change handle-change}]])) (mf/defc ruby-customization-options* - [{:keys [values on-change on-blur]}] - (let [common-props (ruby-common-props values on-change on-blur) + [{:keys [values on-change on-blur labels]}] + (let [common-props (ruby-common-props values on-change on-blur labels) options (mf/with-memo [] {:ruby-size (ruby-size-options tr) :ruby-align (ruby-align-options tr) @@ -342,30 +343,30 @@ [:> span-select-option* (mf/spread-props common-props {:attr :ruby-size :default-value (cjk/enum-default :ruby-size) - :label (tr "workspace.options.text-options.ruby-size") + :label (or (:size labels) (tr "workspace.options.text-options.ruby-size")) :options (:ruby-size options)})] [:> span-select-option* (mf/spread-props common-props {:attr :ruby-align :default-value (cjk/enum-default :ruby-align) - :label (tr "workspace.options.text-options.ruby-align") + :label (or (:align labels) (tr "workspace.options.text-options.ruby-align")) :options (:ruby-align options)})] [:> span-select-option* (mf/spread-props common-props {:attr :ruby-overhang :default-value (cjk/enum-default :ruby-overhang) - :label (tr "workspace.options.text-options.ruby-overhang") + :label (or (:overhang labels) (tr "workspace.options.text-options.ruby-overhang")) :options (:ruby-overhang options)})] [:> span-select-option* (mf/spread-props common-props {:attr :ruby-side :default-value (cjk/enum-default :ruby-side) - :label (tr "workspace.options.text-options.ruby-side") + :label (or (:side labels) (tr "workspace.options.text-options.ruby-side")) :options (:ruby-side options)})]])) (mf/defc ruby-advanced-options* - [{:keys [values on-change on-blur]}] + [{:keys [values on-change on-blur labels]}] (let [open* (mf/use-state false) open? (deref open*) toggle-open (mf/use-fn #(swap! open* not)) - common-props (ruby-common-props values on-change on-blur)] + common-props (ruby-common-props values on-change on-blur labels)] [:div {:class (stl/css :ruby-advanced-options)} [:> ruby-options* (mf/spread-props common-props {:advanced-open open? @@ -375,15 +376,15 @@ [:> ruby-customization-options* common-props]]])) (mf/defc ruby-presentation-options* - [{:keys [values on-change on-blur]}] + [{:keys [values on-change on-blur labels]}] (let [open* (mf/use-state false) open? (deref open*) toggle-open (mf/use-fn #(swap! open* not)) - common-props (ruby-common-props values on-change on-blur)] + common-props (ruby-common-props values on-change on-blur labels)] [:div {:class (stl/css :ruby-advanced-options)} [:> title-bar* {:collapsable true :collapsed (not open?) - :title (tr "workspace.options.text-options.ruby-advanced-options") + :title (or (:advanced labels) (tr "workspace.options.text-options.ruby-advanced-options")) :on-collapsed toggle-open :aria-expanded open? :data-testid "ruby-presentation-options-toggle"}] 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 5618c7f879..9f12c1219d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs @@ -45,8 +45,38 @@ [value] {:text-language (when (seq value) value)}) +(mf/defc pinyin-options* + [{:keys [values ruby-values text-selection-active reading-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") + :advanced (tr "workspace.options.text-options.pinyin-advanced-options") + :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")} + props (mf/props {:values values :on-change on-change + :on-blur on-blur :labels labels})] + [:div {:class (stl/css :pinyin-options)} + (if reading-selection-active + [:> tjl/ruby-advanced-options* props] + [:* + [:p {:class (stl/css :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})])]) + (when (or reading-selection-active (not text-selection-active)) + [:> tjl/span-select-option* + (mf/spread-props props {:attr :annotation-clearance + :default-value "none" + :label (tr "workspace.options.text-options.annotation-clearance") + :options (tjl/annotation-clearance-options)})])])) + (mf/defc language-layout-options* - [{:keys [language values ruby-values text-selection-active + [{:keys [language values ruby-values text-selection-active reading-selection-active on-change on-ruby-presentation-change on-blur]}] (let [profile (:text-language values) selected (if (= :multiple profile) "multiple" (or profile "")) @@ -70,6 +100,12 @@ :text-selection-active text-selection-active :on-ruby-presentation-change on-ruby-presentation-change})] [:> tjl/writing-mode-controls* props]) + (when (and (= :chinese language) matches?) + [:> pinyin-options* + (mf/spread-props props {:ruby-values ruby-values + :text-selection-active text-selection-active + :reading-selection-active reading-selection-active + :on-ruby-presentation-change on-ruby-presentation-change})]) (when (and (= :korean language) matches?) [:> tjl/span-select-option* (mf/spread-props props diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss index 3a7b62bfdc..647074c279 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.scss @@ -25,3 +25,16 @@ color: var(--color-foreground-secondary); } + +.pinyin-options { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.selection-hint { + @include t.use-typography("body-small"); + + margin: 0; + color: var(--color-foreground-secondary); +} 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 fe0fa3050d..54d1435251 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 @@ -29,6 +29,7 @@ [app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]] [app.main.ui.workspace.sidebar.options.menus.text :refer [text-menu*]] [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as wasm.text-editor] [rumext.v2 :as mf])) (defn- dom-text-range-selected? @@ -164,6 +165,13 @@ :else (draft-text-range-selected? editor-state))) + reading-selection-active + (and text-selection-active + text-editor-wasm? + (wasm.text-editor/reading-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 @@ -247,6 +255,7 @@ :values text-values :ruby-values ruby-values :text-selection-active text-selection-active + :reading-selection-active reading-selection-active :libraries libraries :file-id file-id :typographies typographies}] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index be7813554f..ec19412cdf 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -785,6 +785,21 @@ (if (= idx end-para) end-offset (para-char-count para))))) (range start-para (inc end-para)))) +(defn reading-selection? + "True for characters in one span, including the whole base of an existing reading." + [content selection] + (boolean + (when (and content selection) + (let [{:keys [start-para start-offset end-para end-offset] :as range} + (normalize-selection selection) + paragraphs (-> content :children first :children) + spans (selected-spans paragraphs range) + span (first spans)] + (and (= start-para end-para) + (= 1 (count spans)) + (or (not (cjk/annotated-span? span)) + (= (- end-offset start-offset) (count (:text span))))))))) + (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/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index de5a100682..38fc9630ed 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -51,6 +51,17 @@ :focus-para focus-para :focus-offset focus-offset}) +(t/deftest reading-selection-stays-with-one-base-group + (let [content (editor-content [{:text "你好"} {:text "世界" :font-weight "700"}] + [{:text "中国" :ruby "zhōng guó"}])] + (doseq [range [(selection 0 0 0 1) (selection 0 2 0 0) + (selection 0 2 0 4) (selection 1 0 1 2)]] + (t/is (true? (text-editor/reading-selection? content range)))) + (doseq [range [(selection 0 1 0 3) (selection 0 0 1 2) + (selection 0 1 0 1) (selection 1 0 1 1)]] + (t/is (false? (text-editor/reading-selection? content range)))) + (t/is (false? (text-editor/reading-selection? nil nil))))) + (t/deftest japanese-styles-follow-the-selected-span (let [content (editor-content [{:text "日" :ruby "にち" :text-emphasis "filled-dot"} diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs index 1062d2f145..2f85738b62 100644 --- a/frontend/test/frontend_tests/ui/text_options_test.cljs +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -101,6 +101,55 @@ (t/is (str/includes? markup "horizontal-tb-writing-mode")) (t/is (empty? @changes) "Showing controls must not assign a profile"))))) + +(t/deftest pinyin-reading-editor-requires-a-chinese-profile-and-text-selection + (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] + (doseq [profile [nil :multiple "ja" "ko" "zh-Hans" "zh-Hant-TW"] + selected [false true]] + (let [changes (atom []) + markup (rds/renderToStaticMarkup + (mf/element tll/language-layout-options* + (mf/props {:language :chinese + :values {:text-language profile :ruby "nǐ hǎo"} + :ruby-values {:ruby-hidden true} + :text-selection-active selected + :reading-selection-active selected + :on-change #(swap! changes conj %) + :on-ruby-presentation-change #(swap! changes conj %)}))) + chinese (tl/profile-matches? :chinese profile)] + (t/is (= (and chinese selected) + (str/includes? markup "workspace.options.text-options.pinyin-placeholder"))) + (t/is (= chinese + (str/includes? markup "workspace.options.text-options.pinyin-advanced-options"))) + (t/is (not (str/includes? markup "workspace.options.text-options.ruby-placeholder"))) + (t/is (empty? @changes) "Showing Pinyin controls must not edit text"))))) + +(t/deftest pinyin-reading-editor-rejects-a-selection-across-text-styles + (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] + (let [markup (rds/renderToStaticMarkup + (mf/element tll/language-layout-options* + (mf/props {:language :chinese + :values {:text-language "zh-Hans"} + :text-selection-active true + :reading-selection-active false + :on-change identity})))] + (t/is (str/includes? markup "workspace.options.text-options.pinyin-selection-hint")) + (t/is (not (str/includes? markup "workspace.options.text-options.pinyin-placeholder")))))) + +(t/deftest pinyin-presentation-offers-guidance-without-a-text-selection + (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] + (let [markup (rds/renderToStaticMarkup + (mf/element tll/language-layout-options* + (mf/props {:language :chinese + :values {:text-language "zh-Hant-HK"} + :ruby-values {} + :text-selection-active false + :on-change identity + :on-ruby-presentation-change identity})))] + (t/is (str/includes? markup "workspace.options.text-options.pinyin-selection-hint")) + (t/is (str/includes? markup "ruby-presentation-options-toggle")) + (t/is (not (str/includes? markup "ruby-advanced-options-toggle")))))) + (t/deftest shared-writing-mode-controls-have-independent-radio-groups (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] (let [markup (rds/renderToStaticMarkup diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 4f3c47e9d4..f6f055841a 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10041,3 +10041,39 @@ msgstr "Hong Kong" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.korean-wrap-keep-words" msgstr "Keep words together" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin" +msgstr "Pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-placeholder" +msgstr "nǐ hǎo" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-advanced-options" +msgstr "Pinyin options" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-hidden" +msgstr "Hide Pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-size" +msgstr "Pinyin size" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-align" +msgstr "Pinyin alignment" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-overhang" +msgstr "Pinyin overhang" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-side" +msgstr "Pinyin side" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-selection-hint" +msgstr "Select characters within one text style to add a reading. Select the whole base to edit an existing reading." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 7777334e4c..e508fe17b3 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9917,3 +9917,39 @@ msgstr "Hong Kong" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.korean-wrap-keep-words" msgstr "Mantener las palabras juntas" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin" +msgstr "Pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-placeholder" +msgstr "nǐ hǎo" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-advanced-options" +msgstr "Opciones de pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-hidden" +msgstr "Ocultar pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-size" +msgstr "Tamaño del pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-align" +msgstr "Alineación del pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-overhang" +msgstr "Saliente del pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-side" +msgstr "Lado del pinyin" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.pinyin-selection-hint" +msgstr "Selecciona caracteres con un mismo estilo para añadir una lectura. Selecciona toda la base para editar su lectura." diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md index f634e493a2..0f9f5fdcee 100644 --- a/render-wasm/docs/cjk-layout.md +++ b/render-wasm/docs/cjk-layout.md @@ -58,6 +58,29 @@ SkParagraph's last-resort fallback. Very narrow boxes containing punctuation or annotation units need further work; this implementation does not claim complete CSS `keep-all` conformance. +## Pinyin readings + +The Chinese layout panel offers typed Pinyin readings when the selected +paragraph has an explicit Chinese profile. In the WASM text editor, select +one character or a group within one text style, then enter its reading. +To edit an existing reading, select its whole base. Selections across styles +or paragraphs do not offer a reading field, which prevents repeated readings. +For character-by-character readings, annotate each character separately. + +Readings use the saved `ruby` attributes and the shared annotation renderer. +They support size, alignment, overhang, side, hiding and annotation clearance. +Without a text selection, presentation changes affect existing readings. +Horizontal readings form one Latin group above or below the base; vertical +readings form one sideways Latin group beside it. Pronunciation is entered +by the author; the editor does not generate or distribute syllables. + +Composed and combining tone marks retain their source text and shaping. +Glyphs sharing a cluster retain the same complete source range. Tests use +`src/fonts/pinyin-test.ttf`, an 8 KB subset of the repository's Work Sans +Regular font, with letters, umlauts and combining tone marks. The subset +retains its copyright and SIL Open Font License metadata; its family name +is Pinyin Test. FontTools generated it from the Pinyin test strings. + ## Evidence and release checks Renderer tests cover regional geometry, horizontal/vertical wrapping, @@ -68,7 +91,8 @@ mode. Both WASM consumers use the same writer and byte values: 0 syllable, Real Pinyin, Zhuyin and Korean IME checks and native-reader review remain release checks. Font glyph coverage and localized forms still depend on the -chosen fonts. Pinyin/Zhuyin annotation tools belong to Stage 4. +chosen fonts. Dedicated Zhuyin layout and decoration positions remain +Stage 4 follow-ups. References checked on 2026-10-19: diff --git a/render-wasm/src/fonts/pinyin-test.ttf b/render-wasm/src/fonts/pinyin-test.ttf new file mode 100644 index 0000000000000000000000000000000000000000..26a34601e7f3485ded5313225c0702e20609a3f4 GIT binary patch literal 8368 zcmb_hYj9h~bv}D9aKQ)o00EGqph%azAVm=u6vdZliK0P(ASIKMz=tS6@dMyXQbbA& zCE0Qwjh#fc%S_s48c#BAr|z_wrauyQLB*}oUrfjC_(xl3CUvJ{ccwG>(WcXxxZ`ov z{)mZwXD%Uej6cG7W#QQy=ApT| z4~X_WiSgUZYZq7N4|`rDs>~AAdN1a#uK`~V{W0_v7gwHNzO<135|P+PWNcho$}Kkk zw0@Jw!}>DlPd_{oJ(%eH#*uE37(5Z}=@?A5O&tF)?0&o|+;*^S|B2|}shaQ` z2M&ggM*8}q<->HgGsU-;x_cxI&abZq-SxiC&a)sM8l zrfRBVt1gt6vNer7>H5rRQue%FLEbuVU2ToWP1T~>@HR&Lt(+4iD;O}EVE1;Yr6q*7 zCZ)|P0E6tdSA66|M&AddQ**3Q6GA{S(G+nYI_W;$+Z6Fb$M;j>BPCKOlswZ6aKI@i zJDA@fO}^kS|o}p*m5=xf5Js zfpTFDG_@K*Ra*Uh?ZO^k9UfjCAGmJ2bta?BeR)c8hKG&a$54DflDWoY=6`mRqC%6A*@-gd)?ETmi_rR#?`?=r!#w)=v}l=Z(ct)Pd&GvC~jS z0a#8*IftZe+}gg8r9VbVs}cb$$@iw4;cB{Acl&)|Rrs2jzzCb%#lV44=zZ2bS zSJPLYNTt})l`C)?%83H>4f3EU-Pk9(DNA2xbgdU>H}7Ty%J0zM($DDM#be?N;%)Js zb{x;=wQp;A?eDdFdYvBDli*`rn)aL5CO;T`rf%pzs80VGi@$$r@rzEs^md_R`%m1u zi_Z!J*3<{J*`p263GE6!C93EYUe)thOVcs!GNska^m8myST4L=c&CsnJXv_P@NNNH z3)|NJpn(0-eXP*&vF%1xE9Li_8ggmp#BU)=(l(%%Xu|2m)6^sE`-}=o>>Qs@;^^5T zQ};SHTvOsa=*oilNaJrQrpgsZ#5oI~WoiI9WJ?YBHlib56S=%h*XS3z&|SJ0I}c0d z=n`E8jvQR!-wMB9c&+g5Le0lDAA3LkWF!!hWSf%CrIQ)coVZK1QxnpdxsaB9A=#11 zE}I+Y(o$>7{jdTX+QNc0kF!ovMpD8W-Gpa}Y&Vzor2ZkvmD^gxi} z3F%Lm*=#(o1rqUmo1TzbV)m9Py%vB(Zc(~Yw>ELQLyc@%+SkI^&Dx-d?=t~f@lC(* zgJwxerI#|BjeI4*sOthtdjr4MO~$_9I4Hl24Zj%LELX34r0m416-Dm^jdAVV#{`xQQ5=cyra zE}d^^NJx>3OJ9VS97-#muj8g3jTDWT3%xCs&ht7%y5bv{VVJG&Y_T9#f>94lXA!Jq zre+YyIQ$<6cK2jGVhZy_ehW!XNE+V6ai!`ZXj7iFB$3)h|P^Dgc~cHYhX zK09x4|0z4~;eNlJui*ZGov-9RVj~+){-u#crY*Beil=zBM5Voh*jOfh)*(iB5ZlYd zb%$u$B4eI0?u@Sm@tN?e74dOzAwKTIh>!a=#K(O*;^Y1x;^V#p@o|3$ z@p0dY__*(~%^|gr4%=o{?#Y_i^hB0xB8Ogfam{wy@^C~R#(F%0r7(_)edvO;a)TCc z?7x%)gOL1A9h%9Y%S*AI( z0iDNpvzc9vK51C-61OR{yr;yb(sx{@+YH@twc`gyoOjwPY}&9753I4QG%(w{mn&A5 zh&ZQ`E0JBaq#K7SHak}$7XqBkV1IZ}CI{EB|660Z!H@+DV~7)-vI2b%F#<<;OE$2H zU^3h&IGi94cmj<%KCRdZ9c^(Zy>AP}GE~Zi2@l-u#aviJNYjZjNmgMqG0ib+oQ?7< z4wq;u7$?(`X475f5Ds+qUUQI8TAm7Nv|)4#r1EzcXA(bId0DGCTiE%d^6`>2mHpy$ zW%&!vgCUouIEMYBi?cW;n-b|%2xqD}l2ZvZD6H8$~R1Cm+Xd%Nfk1eal4cG9KN8%ozS_QEv&RItUM>6 zF*{#{vxci*RsR32q{kmtV82QgbG~^W8?pvNEj#L^B~$cs92@YlNGU5PF*+V;;W^{H z6r(Ap;1p62be287onVFgyW{{?`4f-8Pe7dr_~lXXOxp4>bf-CsNu=8x!y#PC))|`% zTAoIl&)T>t90QO7AQ+gkH-*AX15lV5<{1ZRmU$RB$2<(oF%JXh?K{{D6M)iyaJB== z*mv+VfuRe49NedwPcZID=2Os9%%`BInNLB_u+1dcvTT!q9NT1Io^3L)z&s}bEHV!R zOU%Q-GV?HSQTa6r=#uh_q07oIhCZYGV(5zUi=h?e7elMcFNUrvzZklP91oQza7~S5 z41~`*;0VBVPCB#6I7ZiT)&gXn8xF`kHx&;PnCDptpD1g6PK}hrEeB+Y=N*vQKL1R~(QTz6##Q%ldAs zk<#~)12X%|4#@1UK=N@%->*9$Gi*2@Gkne7tXAJ_r4ib6X?hYrnsAn7;t^S~B=vCW zR_Q>G5`F-_J&muE;x};CIY0T<^5lufDk(!FL~S1GRO9-mU1uuBT*r(aSEp;g;i)R3 z=e0TG2`%Ot(7Lp+OQCDyBb7~C{w?2D?Ur||YRj{QD}rk92SK3Nu7?Rf@SG+*N6?vRRs4DKeb(se*7V6Nd`oF!UNQX_N|{Uyc8 z^bV!%2DD$NV{{nLMc81Dn>0^dkh(^zz&-~Jmy0daSU-DGf)1ChN6Xd^!TM$JJ+E5! za|u>%&@}pskX(V5b(+Iy9eBQ9xeAYZV3i~5rsEjhz_ScK_WBy^u|AHz19y){z&VJw zmv&n!+2OogR#_NRx>;@p&@wbUr)+c99F7(A3-E-qwnU4--vqpmZZI79Pj+0W-NW7`x!14d_(V&;(<{hI-#V^onu!KuPJT6fA!@WZ>V4WY%4&!x5vbPm@*>Aoxu2;N_ z_2S`OC#LLhR`KU1-u(}}VT_#@TCu~S)xHVNddTiWRyy!>DeOP1_AS-^K(()Ot6x;@ z0$ML*1hr#r-=DV!H+Nz0hXj9V8o7HHhy{0p;Uu?XO_zb`kyy;BAIQkM`SW zhXHdAxL&@BeROBNG(a06F89cVq)B#Sde)@DiIEE?jn0k$PR&kFn1p|!QBj(sWk+q@ zuQOCz8ISCjVcbQRVS_vi3#)5nFI<1_67}I<=M_4p+L&s`R6D8KY1m?Wj?F4%xir5V z3-qEjREvMJkeO+$;P2sitNi?fs8<|Z&+VuWmUsg%*E7S;tkeVM8v2}i8PBiN@><{( z<-(Ow18RmVOUIh5#kytNta)Ei^AM bN;W!n*x(), + reading + ); + let glyphs = ruby_glyph_refs(reading, &shaped, EM / 2.0); + assert!(glyphs.iter().all(|(_, _, _, western)| *western)); + assert!(shaped + .iter() + .flat_map(|run| &run.glyphs) + .all(|glyph| *glyph != 0)); + let span = TextSpan { + ruby: reading.to_string(), + ..make_span("你好", 0.0) + }; + let base = skia::Rect::from_xywh(0.0, 0.0, EM * 2.0, EM); + let lefts = RubyLine { + span: &span, + shaped: &shaped, + glyphs: &glyphs, + base, + leading_gap: 0.0, + room: (0.0, 0.0), + } + .glyph_lefts(); + for (index, pair) in lefts.windows(2).enumerate() { + assert!((pair[1] - pair[0] - glyphs[index].2).abs() < 0.01); + } + let width: f32 = glyphs.iter().map(|(_, _, advance, _)| advance).sum(); + assert!((lefts[0] - (base.width() - width) / 2.0).abs() < 0.01); + shapes.push(( + shaped + .iter() + .flat_map(|run| run.glyphs.clone()) + .collect::>(), + width, + )); + } + assert_eq!(shapes[0].0, shapes[1].0); + assert!((shapes[0].1 - shapes[1].1).abs() < 0.01); +} + #[test] fn horizontal_western_ruby_keeps_its_word_whole() { // JLREQ Fig 112: "editor" over 編集者 is centred as one word, never diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs index 4516a2f2c8..654860c76a 100644 --- a/render-wasm/src/shapes/text_vertical/annotations.rs +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -389,10 +389,16 @@ fn ruby_glyphs( }) .collect(); let ruby_end = leading_utf16 + ruby_text.encode_utf16().count(); - for index in 0..glyphs.len() { - glyphs[index].utf16_end = glyphs - .get(index + 1) - .map_or(ruby_end, |next| next.utf16_start); + // A base letter and its combining marks can share a cluster. Every + // glyph in that cluster covers the same source range, even when the + // shaper emits more than one glyph or orders clusters right to left. + let mut boundaries: Vec = glyphs.iter().map(|glyph| glyph.utf16_start).collect(); + boundaries.push(ruby_end); + boundaries.sort_unstable(); + boundaries.dedup(); + for glyph in &mut glyphs { + let next = boundaries.partition_point(|offset| *offset <= glyph.utf16_start); + glyph.utf16_end = boundaries.get(next).copied().unwrap_or(ruby_end); } glyphs } @@ -914,6 +920,64 @@ mod tests { ); } + #[test] + fn vertical_pinyin_keeps_tone_clusters_and_base_groups() { + let mut results = Vec::new(); + for reading in ["nǐ hǎo", "ni\u{30c} ha\u{30c}o"] { + let mut content = ruby_content("你好", reading, 400.0); + content.paragraphs_mut()[0].set_language("zh-Hans-CN".to_string()); + let layout = layout_with_height(&provider(PINYIN_TEST_FONT), &content, 400.0); + assert_eq!(layout.ruby_cells.len(), 1); + let ruby = &layout.ruby_cells[0]; + assert_eq!((ruby.paragraph, ruby.span), (0, 0)); + assert!(ruby.glyphs.iter().all(|glyph| glyph.sideways)); + for glyph in &ruby.glyphs { + let run = &layout.ruby_runs[glyph.run]; + assert_ne!( + run.glyphs[glyph.glyph], 0, + "the pinned font covers tone marks" + ); + assert!(glyph.utf16_start < glyph.utf16_end); + } + for (index, pair) in ruby.glyph_tops.windows(2).enumerate() { + assert!((pair[1] - pair[0] - ruby.glyphs[index].extent).abs() < 0.01); + } + let width: f32 = ruby.glyphs.iter().map(|glyph| glyph.extent).sum(); + results.push(( + ruby.glyphs + .iter() + .map(|glyph| layout.ruby_runs[glyph.run].glyphs[glyph.glyph]) + .collect::>(), + width, + )); + } + assert_eq!(results[0].0, results[1].0); + assert!((results[0].1 - results[1].1).abs() < 0.01); + + let content = spans_content( + vec![ + TextSpan { + ruby: "nǐ".to_string(), + ..make_span("你") + }, + TextSpan { + ruby: "hǎo".to_string(), + ..make_span("好") + }, + ], + 400.0, + ); + let layout = layout_with_height(&provider(PINYIN_TEST_FONT), &content, 400.0); + assert_eq!( + layout + .ruby_cells + .iter() + .map(|ruby| ruby.span) + .collect::>(), + vec![0, 1] + ); + } + #[test] fn western_ruby_is_one_sideways_word_centred_on_its_base() { // JLREQ Fig 112: "editor" beside 編集者 turns on its side like base diff --git a/render-wasm/src/shapes/text_vertical/test_support.rs b/render-wasm/src/shapes/text_vertical/test_support.rs index 69614e16e1..9204a66861 100644 --- a/render-wasm/src/shapes/text_vertical/test_support.rs +++ b/render-wasm/src/shapes/text_vertical/test_support.rs @@ -16,6 +16,9 @@ use super::layout::{layout_vertical, VerticalLayout}; pub(crate) const TEST_FONT: &[u8] = include_bytes!("../../fonts/sourcesanspro-regular.ttf"); +/// Work Sans subset with Latin letters and combining Pinyin tone marks. +pub(crate) const PINYIN_TEST_FONT: &[u8] = include_bytes!("../../fonts/pinyin-test.ttf"); + /// Noto Sans JP subset with `vmtx`/`vhea`: 〱 advances 2em vertically, あ/く 1em. pub(super) const VMTX_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vmtx-test.ttf");