mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ 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
This commit is contained in:
parent
84a4cc564b
commit
d5da7b2a75
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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))])))
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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})]
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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*
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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))}
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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 "<rt")))))
|
||||
|
||||
@ -10117,3 +10117,21 @@ msgstr "Left in vertical"
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
|
||||
msgid "workspace.options.text-options.position-right"
|
||||
msgstr "Right in vertical"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin"
|
||||
msgstr "Zhuyin"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-placeholder"
|
||||
msgstr "e.g. ㄋㄧˇ"
|
||||
|
||||
msgid "workspace.options.text-options.chinese-reading-format"
|
||||
msgstr "Reading format"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-selection-hint"
|
||||
msgstr "Select one Han character. Enter 1–3 Zhuyin symbols and an optional tone. Symbols stay upright on the right; the neutral tone sits above. Size and position are fixed for this mode."
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-invalid"
|
||||
msgstr "Enter 1–3 Zhuyin symbols with at most one tone. Place a neutral tone before or after the symbols; place other tones at the end."
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-hidden"
|
||||
msgstr "Hide Zhuyin"
|
||||
|
||||
@ -8220,6 +8220,9 @@ msgstr "Alinear al centro"
|
||||
msgid "workspace.options.text-options.align-top"
|
||||
msgstr "Alinear arriba"
|
||||
|
||||
msgid "workspace.options.text-options.chinese-reading-format"
|
||||
msgstr "Formato de lectura"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:100
|
||||
msgid "workspace.options.text-options.direction-ltr"
|
||||
msgstr "LTR"
|
||||
@ -8288,6 +8291,26 @@ msgstr "Texto de selección"
|
||||
msgid "workspace.options.text-options.underline"
|
||||
msgstr "Subrayado (%s)"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin"
|
||||
msgstr "Zhuyin"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-hidden"
|
||||
msgstr "Ocultar zhuyin"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-invalid"
|
||||
msgstr ""
|
||||
"Escribe entre 1 y 3 símbolos zhuyin con un solo tono como máximo. Coloca el "
|
||||
"tono neutro antes o después de los símbolos, y los demás tonos al final."
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-placeholder"
|
||||
msgstr "p. ej. ㄋㄧˇ"
|
||||
|
||||
msgid "workspace.options.text-options.zhuyin-selection-hint"
|
||||
msgstr ""
|
||||
"Selecciona un carácter han. Escribe entre 1 y 3 símbolos zhuyin y un tono "
|
||||
"opcional. Los símbolos se colocan a la derecha; el tono neutro se sitúa "
|
||||
"encima. Este modo tiene tamaño y posición fijos."
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788
|
||||
msgid "workspace.options.use-play-button"
|
||||
msgstr "Usa el botón de play de la cabecera para arrancar la vista de prototipo."
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **plugin-types:** Add `rubyZhuyin` to text shapes and ranges for typed Mandarin annotations.
|
||||
|
||||
- **plugin-types:** Added independent `textEmphasisPosition` and `textUnderlinePosition` properties to text shapes and ranges.
|
||||
|
||||
- **plugin-types, plugins-runtime:** `penpotMgmt` returns `Team`, `Project` and `ProjectFile` objects. Assigning `name` renames teams, projects and files, `project.pinned` pins a project and `file.shared` shares a file as a team library; `createProject`, `createFile`, `moveTo`, `duplicate`, `remove` and `restore` live on the objects. Added `penpotMgmt.listTeams`, `penpotMgmt.createTeam`, `penpotMgmt.getFile`, `listDeletedProjects` and `listDeletedFiles`, and the `manage:projects`, `manage:teams` and `manage:delete` permissions. Restoration uses the dashboard's Trash operation; projects need recoverable files and empty projects cannot be restored; the API exposes no permanent deletion method. Team deletion is not exposed.
|
||||
|
||||
6
plugins/libs/plugin-types/index.d.ts
vendored
6
plugins/libs/plugin-types/index.d.ts
vendored
@ -4812,6 +4812,9 @@ export interface Text extends ShapeBase {
|
||||
/** Whether ruby may extend past its base text: `auto` (default) or `none`. */
|
||||
rubyOverhang: 'auto' | 'none' | 'mixed';
|
||||
|
||||
/** Set one Han base and one typed Mandarin syllable as upright right-side Zhuyin. */
|
||||
rubyZhuyin: boolean | 'mixed';
|
||||
|
||||
/** Ruby side: `over` (above or right, the default) or `under` (below or left). */
|
||||
rubySide: 'over' | 'under' | 'mixed';
|
||||
|
||||
@ -4996,6 +4999,9 @@ export interface TextRange {
|
||||
/** Whether ruby may extend past its base text: `auto` (default) or `none`. */
|
||||
rubyOverhang: 'auto' | 'none' | 'mixed' | null;
|
||||
|
||||
/** Set one Han base and one typed Mandarin syllable as upright right-side Zhuyin. */
|
||||
rubyZhuyin: boolean | 'mixed' | null;
|
||||
|
||||
/** Ruby side: `over` (above or right, the default) or `under` (below or left). */
|
||||
rubySide: 'over' | 'under' | 'mixed' | null;
|
||||
|
||||
|
||||
@ -142,3 +142,43 @@ References checked on 2026-10-19:
|
||||
sections 7.1 and 7.2: syllable/word wrapping and justification restrictions.
|
||||
- [CSS Text 3](https://www.w3.org/TR/css-text-3/#word-break-property):
|
||||
letter-breaking policy is distinct from punctuation and overflow handling.
|
||||
|
||||
### Mandarin Zhuyin annotations
|
||||
|
||||
An optional span flag, `ruby-zhuyin`, selects dedicated right-side Zhuyin.
|
||||
Absent or false retains normal ruby. Each span maps one Han character
|
||||
(optionally with one variation selector) to one typed Mandarin syllable in
|
||||
`ruby`. Enter 1–3 U+3105–U+3129 symbols and at most one tone: U+02C9, U+02CA,
|
||||
U+02C7, U+02CB or U+02D9. Neutral tone may precede or follow the symbols;
|
||||
other tones must follow them. The editor validates this syntax; it does not
|
||||
check pronunciation, infer mappings or generate readings. Unsupported API
|
||||
content keeps its text and falls back to ordinary ruby.
|
||||
|
||||
Symbols remain upright to the base's right in horizontal and vertical text.
|
||||
Their size is 0.3 em. A 0.6 em strip holds symbols and tones, including an
|
||||
empty tone lane for unmarked first tone. Non-neutral tones sit outside the
|
||||
upper right of the last symbol; neutral tone sits above the symbol stack.
|
||||
The stack centres vertically on the base. These rules follow CLREQ
|
||||
sections 5.5.3.2–5.5.3.3, retrieved 2026-10-20:
|
||||
<https://www.w3.org/TR/clreq/>. 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.
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
Binary file not shown.
@ -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::*;
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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<crate::shapes::PositionData> {
|
||||
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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ pub struct RubyCell {
|
||||
pub column: usize,
|
||||
/// Flow-axis top of each glyph.
|
||||
pub glyph_tops: Vec<f32>,
|
||||
pub zhuyin_origins: Option<Vec<skia::Point>>,
|
||||
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| {
|
||||
|
||||
@ -247,6 +247,23 @@ fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec<Glyp
|
||||
/// own fallback-font run.
|
||||
fn ruby_draws(layout: &VerticalLayout, ruby: &RubyCell, origin: (f32, f32)) -> Vec<GlyphDraw> {
|
||||
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()
|
||||
|
||||
@ -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::<Vec<_>>();
|
||||
}
|
||||
(|| {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@ -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<GlyphId>,
|
||||
|
||||
196
render-wasm/src/shapes/text_zhuyin.rs
Normal file
196
render-wasm/src/shapes/text_zhuyin.rs
Normal file
@ -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<usize>,
|
||||
/// 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<char>,
|
||||
symbols: Vec<usize>,
|
||||
tone: Option<usize>,
|
||||
}
|
||||
|
||||
impl Syllable {
|
||||
pub fn parse(reading: &str) -> Option<Self> {
|
||||
let chars: Vec<char> = 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<Slot> {
|
||||
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<Syllable> {
|
||||
(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<usize>,
|
||||
/// 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<Glyph> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -225,7 +225,15 @@ impl From<RawTextSpan> 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::<RawTextSpan>()];
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user