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 0000000000..26a34601e7 Binary files /dev/null and b/render-wasm/src/fonts/pinyin-test.ttf differ diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index d39bb3c9c6..565e2c9379 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -1207,6 +1207,69 @@ fn horizontal_ruby_glyphs_share_one_baseline() { ); } +#[test] +fn horizontal_pinyin_tones_keep_canonical_shaping_and_group_alignment() { + use crate::shapes::text_vertical::test_support::{provider, EM, PINYIN_TEST_FONT}; + use crate::shapes::{FontFamily, FontStyle}; + + let font_provider = provider(PINYIN_TEST_FONT); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + let mut shapes = Vec::new(); + for reading in ["nǐ hǎo", "ni\u{30c} ha\u{30c}o"] { + let shaped = shape_segment_with_fallbacks( + reading, + EM / 2.0, + std::slice::from_ref(&family), + &font_provider, + false, + FontFeatures::None, + &FontMgr::from(font_provider.clone()), + "zh-Hans-CN", + ); + assert_eq!( + shaped + .iter() + .map(|run| run.text.as_str()) + .collect::(), + 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");