mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ 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
This commit is contained in:
parent
3d359c06c0
commit
eb4ee38f0b
@ -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})]
|
||||
|
||||
@ -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"}]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
BIN
render-wasm/src/fonts/pinyin-test.ttf
Normal file
BIN
render-wasm/src/fonts/pinyin-test.ttf
Normal file
Binary file not shown.
@ -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::<String>(),
|
||||
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::<Vec<_>>(),
|
||||
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
|
||||
|
||||
@ -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<usize> = 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::<Vec<_>>(),
|
||||
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<_>>(),
|
||||
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
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user