✨ Add Chinese emphasis and underline positions

Carry optional span positions through the schema, WASM header, editor,
Plugin API and CSS output. Expose Chinese controls with language defaults
and independent overrides for horizontal and vertical text.

Reserve automatic clearance per side and stack underlines, ruby and
emphasis. Preserve reading groups during decoration edits and retain
canonical position values in DOM editor style data.

Add protocol, geometry, editing and API tests, including the prior Japanese
mixed-font gutter behavior. Verify both WASM targets and live exports.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-20 11:29:04 +02:00
parent eb4ee38f0b
commit 84a4cc564b
34 changed files with 937 additions and 130 deletions

View File

@ -40,6 +40,8 @@
["text-orientation" "RawTextOrientation"]
["text-combine-upright" "RawTextCombineUpright"]
["text-emphasis" "RawTextEmphasis"]
["text-emphasis-position" "RawTextEmphasisPosition"]
["text-underline-position" "RawTextUnderlinePosition"]
["warichu" "RawWarichu"]
["font-features" "RawFontFeatures"]
["annotation-clearance" "RawAnnotationClearance"]

View File

@ -176,7 +176,9 @@
ruby-size (sr/translate-text-layout-enum :ruby-size (get span :ruby-size))
ruby-align (sr/translate-text-layout-enum :ruby-align (get span :ruby-align))
ruby-overhang (sr/translate-text-layout-enum :ruby-overhang (get span :ruby-overhang))
ruby-side (sr/translate-text-layout-enum :ruby-side (get span :ruby-side))]
ruby-side (sr/translate-text-layout-enum :ruby-side (get span :ruby-side))
emphasis-position (sr/translate-text-layout-enum :text-emphasis-position (get span :text-emphasis-position))
underline-position (sr/translate-text-layout-enum :text-underline-position (get span :text-underline-position))]
(-> offset
(mem/write-u8 dview font-style)
(mem/write-u8 dview text-decoration)
@ -192,9 +194,9 @@
(mem/write-u8 dview ruby-align)
(mem/write-u8 dview ruby-overhang)
(mem/write-u8 dview ruby-side)
;; Alignment padding; must match RawTextSpan in Rust.
(mem/write-u8 dview 0)
(mem/write-u8 dview 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)
(mem/write-f32 dview font-size)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)

View File

@ -70,6 +70,8 @@
[:direction {:optional true} ::sm/text]
[:text-combine-upright {:optional true} (schema:jl-enum :text-combine-upright)]
[:text-emphasis {:optional true} (schema:jl-enum :text-emphasis)]
[:text-emphasis-position {:optional true} (schema:jl-enum :text-emphasis-position)]
[:text-underline-position {:optional true} (schema:jl-enum :text-underline-position)]
[:ruby {:optional true} :string]
[:ruby-hidden {:optional true} :boolean]
[:ruby-size {:optional true} (schema:jl-enum :ruby-size)]

View File

@ -19,6 +19,8 @@
;; Emphasis mark (圏点 / bouten) applied per span.
:text-emphasis ["none" "filled-dot" "open-dot" "filled-circle"
"open-circle" "filled-sesame" "open-sesame"]
:text-emphasis-position ["auto" "over-right" "over-left" "under-right" "under-left"]
:text-underline-position ["auto" "under" "left" "right" "under-left" "under-right"]
;; Warichu (割注): two half-size lines in one line position.
:warichu ["none" "warichu"]
:font-features ["none" "palt" "vpal"]
@ -57,7 +59,7 @@
;; Span attrs; `:ruby` holds the reading (furigana) of the span.
(def span-attrs
(into [:text-combine-upright :text-emphasis :ruby]
(into [:text-combine-upright :text-emphasis :text-emphasis-position :text-underline-position :ruby]
(concat ruby-presentation-attrs
[:warichu :font-features :annotation-clearance])))

View File

@ -499,3 +499,15 @@
(doseq [language ["zh-Hans-CN" "zh-Hant-CN" "zh-Hant-TW" "zh-Hant-HK" "ko-KR"]]
(t/is (ctst/valid-content?
(with-paragraph-attrs {:text-language language :korean-wrap "keep-words"})))))
(t/deftest annotation-position-enums-are-independent-span-styles
(doseq [[attr values] [[:text-emphasis-position ["auto" "over-right" "over-left" "under-right" "under-left"]]
[:text-underline-position ["auto" "under" "left" "right" "under-left" "under-right"]]]]
(t/is (= "auto" (cjk/enum-default attr)))
(t/is (some #{attr} cjk/span-attrs))
(doseq [value values]
(t/is (cjk/valid-enum-value? attr value))
(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")))))

View File

@ -62,6 +62,8 @@
line-height
(:line-height txt/default-typography))
chinese (and (string? (:text-language data))
(= "zh" (first (str/split (str/lower (:text-language data)) #"-"))))
text-align (:text-align data "start")
writing-mode (:writing-mode data)
text-orientation (:text-orientation data)
@ -71,6 +73,8 @@
:margin 0}]
(cond-> base
chinese (obj/set! "textEmphasisPosition" "under right")
chinese (obj/set! "textUnderlinePosition" "under left")
(some? line-height) (obj/set! "lineHeight" line-height)
(some? line-height) (obj/set! "--paragraph-line-height" (str line-height))
(some? text-align) (obj/set! "textAlign" text-align)
@ -135,6 +139,8 @@
[style data]
(let [text-combine-upright (:text-combine-upright data)
text-emphasis (:text-emphasis data)
emphasis-position (:text-emphasis-position data)
underline-position (:text-underline-position data)
font-features (font-feature-settings (:font-features data))
line-height (annotation-line-height data)
font-size (:font-size data)]
@ -147,6 +153,12 @@
(set-value? text-emphasis)
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
(some? emphasis-position)
(obj/set! "textEmphasisPosition" (if (= "auto" emphasis-position) "inherit" (str/replace emphasis-position "-" " ")))
(some? underline-position)
(obj/set! "textUnderlinePosition" (if (= "auto" underline-position) "inherit" (str/replace underline-position "-" " ")))
(some? font-features)
(obj/set! "fontFeatureSettings" font-features)
@ -277,6 +289,8 @@
;; Base-span styles that belong to the base glyphs, not the reading.
(obj/unset! "textCombineUpright")
(obj/unset! "textEmphasis")
(obj/unset! "textEmphasisPosition")
(obj/unset! "textUnderlinePosition")
(obj/unset! "fontFeatureSettings")
(obj/unset! "display")
(obj/unset! "inlineSize")))

View File

@ -336,6 +336,10 @@
(get n-values :annotation-clearance))
(identical? (get o-values :text-emphasis)
(get n-values :text-emphasis))
(identical? (get o-values :text-emphasis-position)
(get n-values :text-emphasis-position))
(identical? (get o-values :text-underline-position)
(get n-values :text-underline-position))
(identical? (get o-values :ruby)
(get n-values :ruby))
(identical? (get o-values :ruby-hidden)

View File

@ -45,6 +45,44 @@
[value]
{:text-language (when (seq value) value)})
(defn emphasis-position-options
[translate]
[{:id "auto" :label (translate "workspace.options.text-options.position-auto")}
{:id "over-right" :label (translate "workspace.options.text-options.position-over-right")}
{:id "over-left" :label (translate "workspace.options.text-options.position-over-left")}
{:id "under-right" :label (translate "workspace.options.text-options.position-under-right")}
{:id "under-left" :label (translate "workspace.options.text-options.position-under-left")}])
(defn underline-position-options
[translate]
[{:id "auto" :label (translate "workspace.options.text-options.position-auto")}
{:id "under" :label (translate "workspace.options.text-options.position-under")}
{:id "left" :label (translate "workspace.options.text-options.position-left")}
{:id "right" :label (translate "workspace.options.text-options.position-right")}
{:id "under-left" :label (translate "workspace.options.text-options.position-under-left")}
{:id "under-right" :label (translate "workspace.options.text-options.position-under-right")}])
(mf/defc chinese-decoration-options*
[{:keys [values on-change on-blur]}]
(let [props (mf/props {:values values :on-change on-change :on-blur on-blur})]
[:div {:class (stl/css :decoration-options)}
[:> tjl/span-select-option*
(mf/spread-props props {:attr :text-emphasis :default-value "none"
:label (tr "workspace.options.text-options.text-emphasis")
:options (tjl/text-emphasis-options tr)})]
[:> tjl/span-select-option*
(mf/spread-props props {:attr :text-emphasis-position :default-value "auto"
:label (tr "workspace.options.text-options.emphasis-position")
:options (emphasis-position-options tr)})]
[:> tjl/span-select-option*
(mf/spread-props props {:attr :text-underline-position :default-value "auto"
:label (tr "workspace.options.text-options.underline-position")
:options (underline-position-options tr)})]
[:> 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 pinyin-options*
[{:keys [values ruby-values text-selection-active reading-selection-active
on-change on-ruby-presentation-change on-blur]}]
@ -67,13 +105,7 @@
(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)})])]))
:on-change on-ruby-presentation-change})])])]))
(mf/defc language-layout-options*
[{:keys [language values ruby-values text-selection-active reading-selection-active
@ -100,6 +132,8 @@
:text-selection-active text-selection-active
:on-ruby-presentation-change on-ruby-presentation-change})]
[:> tjl/writing-mode-controls* props])
(when (and (= :chinese language) matches?)
[:> chinese-decoration-options* props])
(when (and (= :chinese language) matches?)
[:> pinyin-options*
(mf/spread-props props {:ruby-values ruby-values

View File

@ -26,7 +26,8 @@
color: var(--color-foreground-secondary);
}
.pinyin-options {
.pinyin-options,
.decoration-options {
display: flex;
flex-direction: column;
gap: var(--sp-xs);

View File

@ -477,6 +477,16 @@
:get (fn [self] (range-japanese-value self start end :text-emphasis))
:set (range-attr-setter plugin-id page-id id start end :textEmphasis :text-emphasis (enum-value? :text-emphasis))}
:textEmphasisPosition
{:this true
:get (fn [self] (range-japanese-value self start end :text-emphasis-position))
:set (range-attr-setter plugin-id page-id id start end :textEmphasisPosition :text-emphasis-position (enum-value? :text-emphasis-position))}
:textUnderlinePosition
{:this true
:get (fn [self] (range-japanese-value self start end :text-underline-position))
:set (range-attr-setter plugin-id page-id id start end :textUnderlinePosition :text-underline-position (enum-value? :text-underline-position))}
:warichu
{:this true
:get (fn [self] (range-japanese-value self start end :warichu))
@ -946,6 +956,14 @@
:get #(shape-japanese-value % :text-emphasis)
:set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? :text-emphasis))}
{:name "textEmphasisPosition"
:get #(shape-japanese-value % :text-emphasis-position)
:set (shape-attr-setter plugin-id page-id :textEmphasisPosition :text-emphasis-position (enum-value? :text-emphasis-position))}
{:name "textUnderlinePosition"
:get #(shape-japanese-value % :text-underline-position)
:set (shape-attr-setter plugin-id page-id :textUnderlinePosition :text-underline-position (enum-value? :text-underline-position))}
{:name "warichu"
:get #(shape-japanese-value % :warichu)
:set (shape-annotation-setter plugin-id page-id :warichu :warichu (enum-value? :warichu))}

View File

@ -854,40 +854,58 @@
(merge (selection-japanese-styles content selection)
(selection-paragraph-styles content selection)))))
(defn- annotation-style-range
[paragraph start end styles]
(if (and (< start end)
(map? styles)
(some #(contains? styles %) [:text-emphasis :text-emphasis-position
:text-decoration :text-underline-position :annotation-clearance]))
(let [[start end]
(reduce (fn [[from to offset] span]
(let [span-end (+ offset (count (:text span)))]
(if (and (< offset end) (< start span-end) (cjk/annotated-span? span))
[(min from offset) (max to span-end) span-end]
[from to span-end])))
[start end 0]
(:children paragraph))]
[start end])
[start end]))
(defn apply-attrs-to-paragraph
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."
[para sel-start sel-end styles]
(let [spans (:children para)
(let [[sel-start sel-end] (annotation-style-range para sel-start sel-end styles)
spans (:children para)
result (loop [spans spans
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
;; An empty span has no range to overlap, but an empty
;; line inside the selection still has to be restyled.
has-overlap? (or (< ol-start ol-end)
(and (zero? span-len)
(<= sel-start pos sel-end)))]
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (-> (if (fn? styles)
(styles span)
(merge span styles))
(assoc :text (subs text (- ol-start pos) (- ol-end pos))))
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
(-> acc
(into (keep identity [before selected after])))))))))]
result (loop [spans spans
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
;; An empty span has no range to overlap, but an empty
;; line inside the selection still has to be restyled.
has-overlap? (or (< ol-start ol-end)
(and (zero? span-len)
(<= sel-start pos sel-end)))]
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (-> (if (fn? styles)
(styles span)
(merge span styles))
(assoc :text (subs text (- ol-start pos) (- ol-end pos))))
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
(-> acc
(into (keep identity [before selected after])))))))))]
(assoc para :children result)))
(defn selection-fills

View File

@ -26,7 +26,10 @@
:typography-ref-file [encode decode]
:font-id [identity identity]
:font-variant-id [identity identity]
:vertical-align [identity identity]})
:vertical-align [identity identity]
;; Keep canonical enum values intact in the DOM editor's style data.
:text-emphasis-position [identity identity]
:text-underline-position [identity identity]})
(defn normalize-style-value
"This function adds units to style values"

View File

@ -378,3 +378,15 @@
(is (re-find #"line-height:\s*calc\(var\(--paragraph-line-height" css))
(is (not (str/includes? css "--fills")))
(is (not (str/includes? css "--font-id"))))))
(deftest generated-css-keeps-independent-decoration-positions
(let [content (-> tcy-digits2-text-content
(assoc-in [:children 0 :children 0 :text-language] "zh-Hans")
(assoc-in [:children 0 :children 0 :children 0 :text-emphasis] "filled-dot")
(assoc-in [:children 0 :children 0 :children 0 :text-emphasis-position] "over-left")
(assoc-in [:children 0 :children 0 :children 0 :text-underline-position] "under-right"))
css (css/generate-text-css (text-shape content))]
(is (str/includes? css "text-emphasis-position: over left"))
(is (str/includes? css "text-underline-position: under right"))
(is (str/includes? css "text-emphasis-position: under right"))))

View File

@ -65,7 +65,9 @@
(t/deftest japanese-range-properties-accept-only-supported-values
(doseq [[property attr accepted rejected]
[["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]]
[["textEmphasisPosition" :text-emphasis-position ["auto" "over-right" "over-left" "under-right" "under-left"] ["over" "right"]]
["textUnderlinePosition" :text-underline-position ["auto" "under" "left" "right" "under-left" "under-right"] ["above"]]
["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]]
["annotationClearance" :annotation-clearance ["none" "auto"] ["always"]]
["rubySize" :ruby-size ["half" "third" "quarter"] ["full"]]
["rubyAlign" :ruby-align ["space-around" "center" "start" "space-between"] ["end"]]

View File

@ -21,6 +21,7 @@
[app.render-wasm.api.texts :as texts]
[app.render-wasm.text-editor :as text-editor]
[app.util.i18n :as i18n]
[app.util.text.content.styles :as content-styles]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
@ -68,6 +69,8 @@
{:text "本"}])]
(t/is (= {:text-combine-upright "none"
:text-emphasis "filled-dot"
:text-emphasis-position "auto"
:text-underline-position "auto"
:ruby "にち"
:ruby-hidden false
:ruby-size "half"
@ -81,6 +84,8 @@
content (selection 0 0 0 1))))
(t/is (= {:text-combine-upright "none"
:text-emphasis "none"
:text-emphasis-position "auto"
:text-underline-position "auto"
:ruby ""
:ruby-hidden false
:ruby-size "half"
@ -114,6 +119,8 @@
[{:text "平成"
:text-combine-upright "digits2"
:text-emphasis "filled-dot"
:text-emphasis-position "under-left"
:text-underline-position "right"
:ruby "へいせい"
:ruby-hidden true
:ruby-size "third"
@ -127,6 +134,8 @@
:font-features "vpal"}])]
(t/is (= {:text-combine-upright :multiple
:text-emphasis :multiple
:text-emphasis-position :multiple
:text-underline-position :multiple
:ruby :multiple
:ruby-hidden :multiple
:ruby-size :multiple
@ -395,6 +404,8 @@
:ruby-align 11
:ruby-overhang 12
:ruby-side 13
:text-emphasis-position 14
:text-underline-position 15
:padding [14 15]
:ruby-length 72})
@ -568,3 +579,34 @@
(t/is (= 0 (sr/translate-text-layout-enum :ruby-side "left")))
(t/is (= 1 (sr/translate-text-layout-enum :ruby-side "under")))
(t/is (= 1 (sr/translate-text-layout-enum :ruby-side :under))))
(t/deftest decoration-positions-use-the-former-padding-bytes
(let [view (serialized-span {:text "中文" :text-emphasis-position "under-left"
:text-underline-position "under-right"})]
(t/is (= 4 (span-byte view :text-emphasis-position)))
(t/is (= 5 (span-byte view :text-underline-position)))
(t/is (= 16 (.getFloat32 view 16 true)))))
(t/deftest decoration-edits-preserve-an-existing-reading-as-one-group
(let [paragraph {:type "paragraph" :children [{:text "你好" :ruby "nǐ hǎo"}
{:text "世界" :font-weight "700"}]}
result (text-editor/apply-attrs-to-paragraph paragraph 1 3 {:text-emphasis-position "under-left"})]
(t/is (= [{:text "你好" :ruby "nǐ hǎo" :text-emphasis-position "under-left"}
{:text "世" :font-weight "700" :text-emphasis-position "under-left"}
{:text "界" :font-weight "700"}]
(:children result)))))
(t/deftest dom-editor-style-data-keeps-decoration-positions
(let [attrs {:text-emphasis-position "under-left" :text-underline-position "under-right"}
styles (content-styles/attrs->styles attrs)]
(t/is (= "under-left" (aget styles "--text-emphasis-position")))
(t/is (= "under-right" (aget styles "--text-underline-position")))
(t/is (= attrs (content-styles/styles->attrs (js->clj styles))))))
(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"})))))

View File

@ -102,6 +102,21 @@
(t/is (empty? @changes) "Showing controls must not assign a profile")))))
(t/deftest chinese-decoration-controls-require-a-matching-profile
(with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))]
(doseq [profile [nil :multiple "ja" "ko" "zh-Hans-CN" "zh-Hant-TW"]]
(let [changes (atom [])
markup (rds/renderToStaticMarkup
(mf/element tll/language-layout-options*
(mf/props {:language :chinese
:values {:text-language profile}
:on-change #(swap! changes conj %)})))]
(doseq [label ["workspace.options.text-options.text-emphasis"
"workspace.options.text-options.emphasis-position"
"workspace.options.text-options.underline-position"]]
(t/is (= (tl/profile-matches? :chinese profile) (str/includes? markup label))))
(t/is (empty? @changes))))))
(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"]
@ -481,3 +496,10 @@
(t/is (str/includes? (re-find #"<input[^>]*id=\"rtl-text-direction\"[^>]*>"
(render "horizontal-tb"))
"checked=\"\""))))
(t/deftest decoration-position-changes-refresh-the-text-options
(doseq [attr [:text-emphasis-position :text-underline-position]]
(let [before (mf/props {:values {attr "auto"}})
after (mf/props {:values {attr "under-left"}})]
(t/is (false? (@#'text-menu/check-props before after))))))

View File

@ -10077,3 +10077,43 @@ 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."
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.emphasis-position"
msgstr "Emphasis position"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.underline-position"
msgstr "Underline position"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-auto"
msgstr "Automatic"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-over-right"
msgstr "Above / right"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-over-left"
msgstr "Above / left"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-under-right"
msgstr "Below / right"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-under-left"
msgstr "Below / left"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-under"
msgstr "Below text"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-left"
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"

View File

@ -9953,3 +9953,43 @@ 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."
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.emphasis-position"
msgstr "Posición del énfasis"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.underline-position"
msgstr "Posición del subrayado"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-auto"
msgstr "Automática"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-over-right"
msgstr "Arriba / derecha"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-over-left"
msgstr "Arriba / izquierda"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-under-right"
msgstr "Abajo / derecha"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-under-left"
msgstr "Abajo / izquierda"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-under"
msgstr "Debajo del texto"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-left"
msgstr "Izquierda en vertical"
#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs
msgid "workspace.options.text-options.position-right"
msgstr "Derecha en vertical"

View File

@ -2,6 +2,8 @@
### 🚀 Features
- **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.
- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events. Global scope adds the `allow:global` permission, which users must accept when installing the plugin.
- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission.

View File

@ -4756,6 +4756,25 @@ export interface Text extends ShapeBase {
*/
warichu: 'none' | 'warichu' | 'mixed';
/** Emphasis position in horizontal and vertical text; auto uses the language default. */
textEmphasisPosition:
| 'auto'
| 'over-right'
| 'over-left'
| 'under-right'
| 'under-left'
| 'mixed';
/** Underline depth and vertical side; independent of emphasis and readings. */
textUnderlinePosition:
| 'auto'
| 'under'
| 'left'
| 'right'
| 'under-left'
| 'under-right'
| 'mixed';
/**
* OpenType proportional metrics for Japanese text: `palt` for horizontal
* writing, `vpal` for vertical. `none` (the default) turns them off.
@ -4921,6 +4940,27 @@ export interface TextRange {
/** Warichu applied to the range, or 'mixed' for different span values. */
warichu: 'none' | 'warichu' | 'mixed' | null;
/** Emphasis position in horizontal and vertical text; auto uses the language default. */
textEmphasisPosition:
| 'auto'
| 'over-right'
| 'over-left'
| 'under-right'
| 'under-left'
| 'mixed'
| null;
/** Underline depth and vertical side; independent of emphasis and readings. */
textUnderlinePosition:
| 'auto'
| 'under'
| 'left'
| 'right'
| 'under-left'
| 'under-right'
| 'mixed'
| null;
/**
* OpenType proportional metrics for the range: `palt` (horizontal), `vpal`
* (vertical) or `none`. Returns 'mixed' for different span values.

View File

@ -81,6 +81,47 @@ 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.
## Emphasis and underline positions
Chinese profiles expose emphasis shape, emphasis position, underline position
and annotation clearance independently of Pinyin. Position controls do not
turn on underlining; use the existing underline control for that.
Optional span `text-emphasis-position` stores `auto`, `over-right`,
`over-left`, `under-right` or `under-left`. The first part selects the
horizontal side; the second selects the vertical side. Auto uses below/right
for Chinese and preserves above/right for Japanese, Korean and plain text.
Optional `text-underline-position` stores `auto`, `under`, `left`, `right`,
`under-left` or `under-right`. Under places horizontal underlines below the
base rect; left/right select the vertical side. Chinese auto uses below/left.
Other auto values preserve the earlier baseline offset and vertical left
side. Explicit values can select other styles without changing the language.
These are supported POC choices, not a claim of complete decoration support.
Automatic clearance reserves each occupied side. Stacked annotations retain
the largest reading and mark sizes across mixed-font spans, preserving the
prior Japanese column widths. Underlines remain next to
the base; readings move outside an underline on their side, and emphasis
moves outside a reading on its side. The underline band reserves 0.18 em,
a POC bound around the existing 1/9-em gap and stroke width. Disabling
clearance retains fixed line-height behavior and can allow overlap/clipping.
Decoration edits that intersect a reading or note apply to its whole base,
so changing a side cannot duplicate its reading on split spans. Other text
styles remain unchanged. Selection snapshots, Plugin API properties,
CSS handoff and saved content retain the independent positions. CSS auto
positions inherit the Chinese paragraph defaults; explicit positions emit
the corresponding CSS keyword pairs. The binary writer uses former span
header bytes 14/15; header size and prior offsets stay unchanged. Unknown
bytes decode to auto. Both WASM consumers share that writer.
Defaults and positions were checked on 2026-10-20 against
[CSS Text Decoration, emphasis](https://www.w3.org/TR/css-text-decor-3/#text-emphasis-position-property)
and [underline positions](https://www.w3.org/TR/css-text-decor-3/#text-underline-position-property).
Japanese/Korean vertical auto underlines retain this branch's existing left
side rather than adopting the spec's suggested right-side style.
## Evidence and release checks
Renderer tests cover regional geometry, horizontal/vertical wrapping,
@ -91,8 +132,7 @@ 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. Dedicated Zhuyin layout and decoration positions remain
Stage 4 follow-ups.
chosen fonts. Dedicated Zhuyin layout remains a Stage 4 follow-up.
References checked on 2026-10-19:

View File

@ -1428,11 +1428,31 @@ pub fn decoration_segments(
}
None => (0.0, 0.0),
};
let (y, thickness) = if kind == TextDecoration::LINE_THROUGH {
let (mut y, thickness) = if kind == TextDecoration::LINE_THROUGH {
(strike_y, max_strike_thickness)
} else {
(underline_y, max_underline_thickness)
};
if kind == TextDecoration::UNDERLINE {
let index = offsets
.ranges
.partition_point(|range| range.builder_start <= seg_start)
.saturating_sub(1);
if let Some(span) = offsets
.ranges
.get(index)
.and_then(|range| text_paragraph.children().get(range.span))
.filter(|span| span.underline_below_ink())
{
let bottom = rects
.iter()
.map(|r| r.rect.bottom())
.fold(f32::NEG_INFINITY, f32::max);
if bottom.is_finite() {
y = Some(y_accum + bottom + span.font_size / 9.0 + thickness / 2.0);
}
}
}
segments.push(TextDecorationSegment {
kind,
text_style: (*metrics.text_style).clone(),

View File

@ -2276,6 +2276,8 @@ pub struct TextSpan {
pub text_combine_upright: TextCombineUpright,
/// Emphasis mark (圏点 / bouten) applied to each base character.
pub text_emphasis: TextEmphasis,
pub text_emphasis_position: TextEmphasisPosition,
pub text_underline_position: TextUnderlinePosition,
/// Ruby (furigana) annotation for this span; empty means no ruby.
pub ruby: String,
pub ruby_size: RubySize,
@ -2308,6 +2310,8 @@ impl Default for TextSpan {
text_orientation: TextOrientation::default(),
text_combine_upright: TextCombineUpright::default(),
text_emphasis: TextEmphasis::default(),
text_emphasis_position: TextEmphasisPosition::default(),
text_underline_position: TextUnderlinePosition::default(),
ruby: String::default(),
ruby_size: RubySize::default(),
ruby_align: RubyAlign::default(),
@ -2396,34 +2400,79 @@ impl TextSpan {
/// the line height: the ruby at its size plus the emphasis marks. `None`
/// keeps the set line height, so annotations sit in the line gap.
pub fn annotation_room_em(&self) -> f32 {
self.annotation_side_room_em(RubySide::Over, false)
+ self.annotation_side_room_em(RubySide::Under, false)
}
pub fn emphasis_side(&self, vertical: bool) -> RubySide {
self.text_emphasis_position.side(
vertical,
super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(),
)
}
pub fn underline_below_ink(&self) -> bool {
self.text_underline_position.below_ink(
super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(),
)
}
/// Only readings on the same side stack with emphasis marks.
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)
}
pub fn decoration_room_em(&self, side: RubySide, vertical: bool) -> f32 {
let underline = self
.text_decoration
.is_some_and(|kind| kind.contains(TextDecoration::UNDERLINE));
let underline_side = if vertical {
self.text_underline_position.side()
} else {
RubySide::Under
};
let positioned = self.text_underline_position != TextUnderlinePosition::Auto
|| self.text_emphasis_position != TextEmphasisPosition::Auto
|| super::text_policy::TextLayoutPolicy::for_language(&self.language, false)
.is_chinese();
if self.annotation_clearance.is_auto() && underline && positioned && side == underline_side
{
0.18
} else {
0.0
}
}
pub fn ruby_decoration_offset(&self, vertical: bool) -> f32 {
self.font_size * self.decoration_room_em(self.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()
} else {
0.0
};
ruby + self.font_size * self.decoration_room_em(self.emphasis_side(vertical), vertical)
}
pub fn annotation_side_room_em(&self, side: RubySide, vertical: bool) -> f32 {
if !self.annotation_clearance.is_auto() {
return 0.0;
}
let ruby = if self.has_ruby() {
let ruby = if self.has_ruby() && self.ruby_side == side {
self.ruby_size.scale()
} else {
0.0
};
let emphasis = if self.text_emphasis.is_none() {
0.0
} else {
let emphasis = if !self.text_emphasis.is_none() && self.emphasis_side(vertical) == side {
super::text_japanese::EMPHASIS_FONT_SCALE
};
ruby + emphasis
}
/// Automatic clearance stacks emphasis marks outside an over-side ruby.
pub fn stacks_emphasis_outside_ruby(&self) -> bool {
self.annotation_clearance.is_auto() && self.has_ruby() && self.ruby_side == RubySide::Over
}
/// Cross-axis offset of the emphasis marks past a stacked ruby layer.
pub fn emphasis_ruby_offset(&self) -> f32 {
if self.stacks_emphasis_outside_ruby() {
self.ruby_font_size()
} else {
0.0
}
};
ruby + emphasis + self.decoration_room_em(side, vertical)
}
pub fn to_style(

View File

@ -10,7 +10,7 @@ pub(crate) struct HorizontalEmphasisPlacement {
pub(crate) rect: skia::Rect,
}
/// Place one emphasis mark above each eligible character, from the laid-out
/// Place one emphasis mark beside each eligible character, from the laid-out
/// rect of each character so marks follow SkParagraph's wrapping and bidi.
pub(crate) fn horizontal_emphasis_placements(
paragraph: &Paragraph,
@ -66,23 +66,23 @@ pub(crate) fn horizontal_emphasis_placements(
}
/// Em box of a horizontal emphasis mark in the laid-out paragraph: centred
/// over its base character, outside any stacked ruby layer.
/// beside its base character on the selected side, outside stacked annotations.
pub(crate) fn horizontal_emphasis_mark_box(
span: &TextSpan,
placement: &HorizontalEmphasisPlacement,
) -> skia::Rect {
let size = span.font_size * EMPHASIS_FONT_SCALE;
let bottom = horizontal_annotation_over_top(placement.rect, span.font_size)
- span.emphasis_ruby_offset();
skia::Rect::from_xywh(
placement.rect.center_x() - size / 2.0,
bottom - size,
size,
size,
)
let top = if span.emphasis_side(false) == RubySide::Over {
horizontal_annotation_over_top(placement.rect, span.font_size)
- span.emphasis_outside_offset(false)
- size
} else {
placement.rect.bottom() + span.emphasis_outside_offset(false)
};
skia::Rect::from_xywh(placement.rect.center_x() - size / 2.0, top, size, size)
}
/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs.
/// Paint horizontal emphasis marks on their configured side of the base.
/// Draw-only: the base paragraph keeps its own metrics.
pub(crate) fn paint_horizontal_emphasis(
canvas: &skia::Canvas,
@ -125,6 +125,7 @@ pub(crate) fn paint_horizontal_emphasis(
.as_ref()
.map(|rect| rect.width())
.unwrap_or_else(|| mark_paragraph.longest_line());
let mark_ink_top = mark_ink.as_ref().map_or(0.0, |rect| rect.top());
let mark_ink_bottom = mark_ink
.as_ref()
.map(|rect| rect.bottom())
@ -134,9 +135,13 @@ pub(crate) fn paint_horizontal_emphasis(
.filter(|placement| placement.span == range.span && placement.mark == mark)
{
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size)
- mark_ink_bottom
- span.emphasis_ruby_offset();
let mark_y = y + if span.emphasis_side(false) == RubySide::Over {
horizontal_annotation_over_top(placement.rect, span.font_size)
- mark_ink_bottom
- span.emphasis_outside_offset(false)
} else {
placement.rect.bottom() - mark_ink_top + span.emphasis_outside_offset(false)
};
mark_paragraph.paint(canvas, (mark_x, mark_y));
}
}

View File

@ -484,9 +484,11 @@ impl RubyLine<'_> {
let over = self.span.ruby_side == RubySide::Over;
let edge = ruby_line_ink_edge(self.shaped, self.glyphs, over);
if over {
y + horizontal_annotation_over_top(self.base, self.span.font_size) - edge
y + horizontal_annotation_over_top(self.base, self.span.font_size)
- edge
- self.span.ruby_decoration_offset(false)
} else {
y + self.base.bottom() - edge
y + self.base.bottom() - edge + self.span.ruby_decoration_offset(false)
}
}
}

View File

@ -1207,6 +1207,107 @@ fn horizontal_ruby_glyphs_share_one_baseline() {
);
}
#[test]
fn emphasis_position_overrides_the_language_and_stacks_on_its_own_side() {
let placement = super::emphasis::HorizontalEmphasisPlacement {
span: 0,
range: 0..1,
mark: '•',
rect: skia::Rect::from_xywh(0.0, 20.0, 20.0, 20.0),
};
for (language, position, under) in [
("ja", TextEmphasisPosition::Auto, false),
("ko", TextEmphasisPosition::Auto, false),
("", TextEmphasisPosition::Auto, false),
("zh-Hant-TW", TextEmphasisPosition::Auto, true),
("zh-Hans", TextEmphasisPosition::OverLeft, false),
("ja", TextEmphasisPosition::UnderRight, true),
] {
let mut span = make_span("中文", 0.0);
span.language = language.to_string();
span.text_emphasis = TextEmphasis::FilledDot;
span.text_emphasis_position = position;
span.annotation_clearance = AnnotationClearance::Auto;
let plain = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
assert_eq!(plain.top() >= placement.rect.bottom(), under);
span.ruby = "zhōng wén".to_string();
for side in [RubySide::Over, RubySide::Under] {
span.ruby_side = side;
let stacked = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
let expected = if (side == RubySide::Under) == under {
span.ruby_font_size()
} else {
0.0
};
assert!(((stacked.top() - plain.top()).abs() - expected).abs() < 0.01);
}
}
}
#[test]
fn chinese_underline_stays_below_the_base_ink() {
for position in [
TextUnderlinePosition::Auto,
TextUnderlinePosition::Under,
TextUnderlinePosition::UnderRight,
] {
let (mut paragraph, plan, layout) = cjk_horizontal_layout(
&["文"],
"zh-Hans-CN",
crate::shapes::text_policy::KoreanWrap::Syllable,
200.0,
);
let span = &mut paragraph.children_mut()[0];
span.text_decoration = Some(skia::textlayout::TextDecoration::UNDERLINE);
span.text_underline_position = position;
let segments =
crate::render::text::decoration_segments(&layout, &paragraph, &plan.offsets, 0.0, 0.0);
assert_eq!(segments.len(), 1);
let bottom =
layout.get_rects_for_range(0..1, RectHeightStyle::Tight, RectWidthStyle::Tight)[0]
.rect
.bottom();
assert!(segments[0].y - segments[0].thickness / 2.0 >= bottom);
}
}
#[test]
fn below_emphasis_clears_an_underline_on_the_same_side() {
let span = TextSpan {
language: "zh-Hans".to_string(),
text_emphasis: TextEmphasis::FilledDot,
text_decoration: Some(skia::textlayout::TextDecoration::UNDERLINE),
text_underline_position: TextUnderlinePosition::Under,
annotation_clearance: AnnotationClearance::Auto,
..make_span("文", 0.0)
};
let placement = super::emphasis::HorizontalEmphasisPlacement {
span: 0,
range: 0..1,
mark: '•',
rect: skia::Rect::from_xywh(0.0, 20.0, 16.0, 18.0),
};
let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
assert!(mark.top() > placement.rect.bottom() + span.font_size / 9.0);
}
#[test]
fn chinese_horizontal_emphasis_defaults_below_the_base() {
let span = TextSpan {
language: "zh-Hans-CN".to_string(),
text_emphasis: TextEmphasis::FilledDot,
..make_span("中文", 0.0)
};
let placement = super::emphasis::HorizontalEmphasisPlacement {
span: 0,
range: 0..1,
mark: '•',
rect: skia::Rect::from_xywh(0.0, 20.0, 16.0, 18.0),
};
let mark = super::emphasis::horizontal_emphasis_mark_box(&span, &placement);
assert!(mark.top() >= placement.rect.bottom());
}
#[test]
fn horizontal_pinyin_tones_keep_canonical_shaping_and_group_alignment() {
use crate::shapes::text_vertical::test_support::{provider, EM, PINYIN_TEST_FONT};

View File

@ -283,9 +283,61 @@ impl TextCombineUpright {
}
}
/// Independent emphasis positions in the two writing directions.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextEmphasisPosition {
#[default]
Auto,
OverRight,
OverLeft,
UnderRight,
UnderLeft,
}
impl TextEmphasisPosition {
pub fn side(self, vertical: bool, chinese: bool) -> RubySide {
let under = if vertical {
matches!(self, Self::OverLeft | Self::UnderLeft)
} else {
matches!(self, Self::UnderRight | Self::UnderLeft) || (self == Self::Auto && chinese)
};
if under {
RubySide::Under
} else {
RubySide::Over
}
}
}
/// CSS underline position: horizontal depth and vertical side are independent.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextUnderlinePosition {
#[default]
Auto,
Under,
Left,
Right,
UnderLeft,
UnderRight,
}
impl TextUnderlinePosition {
pub fn side(self) -> RubySide {
if matches!(self, Self::Right | Self::UnderRight) {
RubySide::Over
} else {
RubySide::Under
}
}
pub fn below_ink(self, chinese: bool) -> bool {
matches!(self, Self::Under | Self::UnderLeft | Self::UnderRight)
|| (self == Self::Auto && chinese)
}
}
/// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS
/// `text-emphasis-style`. The mark is drawn above each eligible horizontal
/// base character or to the right of its vertical column.
/// `text-emphasis-style`. Position is a separate style with a language default.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextEmphasis {
#[default]

View File

@ -50,13 +50,26 @@ pub struct RubyCell {
pub glyph_tops: Vec<f32>,
pub font_size: f32,
pub base_font_size: f32,
pub outside_offset: f32,
pub side: RubySide,
pub paint: usize,
}
impl RubyCell {
pub(super) fn strip_x(&self, column: &VerticalColumn) -> f32 {
let sign = if self.side == RubySide::Over {
1.0
} else {
-1.0
};
ruby_strip_x(column, self.font_size, self.base_font_size, self.side)
+ sign * self.outside_offset
}
}
/// One emphasis mark (圏点 / bouten) beside a base character: the
/// single-glyph `run`, centred on the character along the flow, in the
/// column's right-side gutter.
/// column's selected-side gutter.
pub struct EmphasisMark {
pub run: usize,
/// Index of the annotated base cell in `VerticalLayout::cells`.
@ -66,6 +79,7 @@ pub struct EmphasisMark {
pub font_size: f32,
/// Cross-axis offset past a stacked ruby layer.
pub outside_offset: f32,
pub side: RubySide,
}
/// Cross-axis start of a vertical ruby strip. Ruby attaches to the edge of the
@ -505,6 +519,7 @@ pub(super) fn layout_ruby(
glyph_tops,
font_size: ruby_font_size,
base_font_size: span.font_size,
outside_offset: span.ruby_decoration_offset(true),
side: span.ruby_side,
paint: first.paint,
});
@ -652,7 +667,8 @@ pub(super) fn layout_emphasis(
cell: cell_index,
flow_center,
font_size: mark_font_size,
outside_offset: span.emphasis_ruby_offset(),
outside_offset: span.emphasis_outside_offset(true),
side: span.emphasis_side(true),
});
}
}
@ -666,10 +682,13 @@ pub(super) fn layout_emphasis(
pub(super) fn emphasis_mark_center(layout: &VerticalLayout, mark: &EmphasisMark) -> (f32, f32) {
let cell = &layout.cells[mark.cell];
let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE;
let sign = if mark.side == RubySide::Over {
1.0
} else {
-1.0
};
let x = column_base_center(&layout.columns[cell.column])
+ base_font_size / 2.0
+ mark.outside_offset
+ mark.font_size / 2.0;
+ sign * (base_font_size / 2.0 + mark.outside_offset + mark.font_size / 2.0);
(x, cell.top + mark.flow_center)
}
@ -920,6 +939,95 @@ mod tests {
);
}
#[test]
fn vertical_emphasis_reserves_its_side_and_only_stacks_with_same_side_ruby() {
for position in [
crate::shapes::TextEmphasisPosition::OverLeft,
crate::shapes::TextEmphasisPosition::UnderRight,
] {
for side in [RubySide::Over, RubySide::Under] {
let mut content = ruby_content("你好", "nǐ hǎo", 400.0);
content.paragraphs_mut()[0].set_language("zh-Hans".to_string());
let span = &mut content.paragraphs_mut()[0].children_mut()[0];
span.text_emphasis = TextEmphasis::FilledDot;
span.text_emphasis_position = position;
span.annotation_clearance = AnnotationClearance::Auto;
span.ruby_side = side;
let same_side = span.emphasis_side(true) == side;
let ruby_size = span.ruby_font_size();
let layout = layout_content(&content, 400.0);
assert_eq!(layout.emphasis_marks.len(), 2);
for mark in &layout.emphasis_marks {
let column = &layout.columns[layout.cells[mark.cell].column];
let (x, _) = emphasis_mark_center(&layout, mark);
assert_eq!(x < column_base_center(column), mark.side == RubySide::Under);
assert!(
(mark.outside_offset - if same_side { ruby_size } else { 0.0 }).abs()
< 0.01
);
assert!(x - mark.font_size / 2.0 >= column.x - 0.01);
assert!(x + mark.font_size / 2.0 <= column.x + column.width + 0.01);
}
}
}
}
#[test]
fn default_japanese_clearance_keeps_mixed_font_annotation_gutters() {
let content = spans_content(
vec![
TextSpan {
font_size: 40.0,
ruby: "a".to_string(),
annotation_clearance: AnnotationClearance::Auto,
..make_span("日")
},
TextSpan {
font_size: 30.0,
text_emphasis: TextEmphasis::FilledDot,
annotation_clearance: AnnotationClearance::Auto,
..make_span("本")
},
TextSpan {
font_size: 20.0,
ruby: "b".to_string(),
ruby_size: RubySize::Quarter,
text_emphasis: TextEmphasis::FilledDot,
annotation_clearance: AnnotationClearance::Auto,
..make_span("語")
},
],
400.0,
);
let layout = layout_content(&content, 400.0);
// The original gutter stacks the largest ruby (20px) and emphasis
// (15px) when any span combines them, beside the 40px base band.
assert_eq!(layout.columns.len(), 1);
assert!((layout.columns[0].width - 75.0).abs() < 0.01);
}
#[test]
fn underline_ruby_and_emphasis_fit_in_one_automatic_gutter() {
let mut content = ruby_content("你好", "nǐ hǎo", 400.0);
let span = &mut content.paragraphs_mut()[0].children_mut()[0];
span.ruby_side = RubySide::Under;
span.text_emphasis = TextEmphasis::FilledDot;
span.text_emphasis_position = crate::shapes::TextEmphasisPosition::UnderLeft;
span.text_decoration = Some(crate::shapes::TextDecoration::UNDERLINE);
span.text_underline_position = crate::shapes::TextUnderlinePosition::UnderLeft;
span.annotation_clearance = AnnotationClearance::Auto;
let layout = layout_content(&content, 400.0);
let column = &layout.columns[0];
let ruby = &layout.ruby_cells[0];
let base_left = column_base_center(column) - EM / 2.0;
assert!(ruby.strip_x(column) + ruby.font_size < base_left - EM / 9.0);
for mark in &layout.emphasis_marks {
let (x, _) = emphasis_mark_center(&layout, mark);
assert!(x + mark.font_size / 2.0 <= ruby.strip_x(column) + 0.01);
assert!(x - mark.font_size / 2.0 >= column.x - 0.01);
}
}
#[test]
fn vertical_pinyin_keeps_tone_clusters_and_base_groups() {
let mut results = Vec::new();

View File

@ -205,6 +205,7 @@ impl<'a> SpanCells<'a> {
paint: self.paint,
font_size: self.span.font_size,
decoration: self.span.text_decoration,
underline_side: self.span.text_underline_position.side(),
glyph_flow_shift: 0.0,
}
}

View File

@ -10,7 +10,6 @@ use crate::globals::get_resources;
use crate::math::Rect;
use crate::render::FallbackFonts;
use crate::shapes::japanese::JapaneseClass;
use crate::shapes::text_japanese::EMPHASIS_FONT_SCALE;
use crate::shapes::text_policy::TextLayoutPolicy;
use crate::shapes::{
merge_fills, AppliedTextTransform, FontFeatures, GrowType, LineAdjustment, Paragraph, RubySide,
@ -101,6 +100,7 @@ pub struct VerticalCell {
pub font_size: f32,
/// Span text decoration, painted as vertical bars along the column.
pub decoration: Option<TextDecoration>,
pub underline_side: RubySide,
/// Draw-only flow shift for half-width opening punctuation (jlreq aki).
pub glyph_flow_shift: f32,
}
@ -257,37 +257,45 @@ impl ColumnGeometry {
// on the logical annotation side. In vertical-rl, `over` is right and
// `under` is left. `None` keeps the column advance, as horizontal
// keeps the line height.
let reserves = |span: &&TextSpan| span.annotation_clearance.is_auto();
let ruby_gutter = |side| {
spans
let gutter = |side| {
let ruby = spans
.iter()
.filter(reserves)
.filter(|span| span.has_ruby() && span.ruby_side == side)
.filter(|span| {
span.annotation_clearance.is_auto() && span.has_ruby() && span.ruby_side == side
})
.map(TextSpan::ruby_font_size)
.fold(0.0, f32::max)
.fold(0.0, f32::max);
let emphasis = spans
.iter()
.filter(|span| {
span.annotation_clearance.is_auto()
&& !span.text_emphasis.is_none()
&& span.emphasis_side(true) == side
})
.map(|span| span.font_size * crate::shapes::text_japanese::EMPHASIS_FONT_SCALE)
.fold(0.0, f32::max);
let decoration = spans
.iter()
.map(|span| span.font_size * span.decoration_room_em(side, true))
.fold(0.0, f32::max);
// Keep the original mixed-font gutter: when any span stacks the
// two annotations, reserve their independent maximum sizes.
let stacked = spans.iter().any(|span| {
!span.text_emphasis.is_none()
&& span.ruby_side == side
&& span.stacks_emphasis_outside_ruby(true)
});
decoration
+ if stacked {
ruby + emphasis
} else {
ruby.max(emphasis)
}
};
let ruby_over_gutter = ruby_gutter(RubySide::Over);
// Emphasis takes the over (right) side. Auto-clearance spans with both
// annotations stack there; under-side ruby stays separate.
let emphasis_gutter = spans
.iter()
.filter(reserves)
.filter(|span| !span.text_emphasis.is_none())
.map(|span| span.font_size * EMPHASIS_FONT_SCALE)
.fold(0.0, f32::max);
let stacked = spans
.iter()
.any(|s| s.stacks_emphasis_outside_ruby() && !s.text_emphasis.is_none());
let over_gutter = if stacked {
ruby_over_gutter + emphasis_gutter
} else {
ruby_over_gutter.max(emphasis_gutter)
};
Self {
base_width: max_font_size * line_height,
under_gutter: ruby_gutter(RubySide::Under),
over_gutter,
under_gutter: gutter(RubySide::Under),
over_gutter: gutter(RubySide::Over),
}
}

View File

@ -7,7 +7,7 @@ use skia_safe::{
use crate::math::Rect;
use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign};
use super::annotations::{emphasis_mark_center, ruby_strip_x, EmphasisMark, RubyCell};
use super::annotations::{emphasis_mark_center, EmphasisMark, RubyCell};
use super::font_tables::upright_baseline;
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout};
use super::shaping::{single_glyph_blob, ShapedRun};
@ -247,9 +247,7 @@ 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];
let gutter_center = origin.0
+ ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side)
+ ruby.font_size / 2.0;
let gutter_center = origin.0 + ruby.strip_x(column) + ruby.font_size / 2.0;
ruby.glyphs
.iter()
.zip(&ruby.glyph_tops)
@ -689,9 +687,8 @@ fn push_text_path(
}
/// Decoration bar of a cell in absolute coordinates. Underline runs along
/// the *left* side of the column (the under side in `vertical-rl`);
/// line-through runs down the centre. Both span the cell's extent, so
/// adjacent decorated cells form one bar.
/// the selected side of the column; line-through runs down the centre.
/// Both span the cell's extent, so adjacent decorated cells form one bar.
fn decoration_bar(
layout: &VerticalLayout,
cell: &VerticalCell,
@ -706,7 +703,12 @@ fn decoration_bar(
let bar_x = if line_through {
x_center
} else {
x_center - cell.font_size / 2.0 - cell.font_size / 9.0
let sign = if cell.underline_side == crate::shapes::RubySide::Over {
1.0
} else {
-1.0
};
x_center + sign * (cell.font_size / 2.0 + cell.font_size / 9.0)
};
skia::Rect::from_ltrb(
bar_x - thickness / 2.0,
@ -803,6 +805,25 @@ mod tests {
assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01);
}
#[test]
fn underline_side_changes_the_bar_without_moving_the_base_or_strike() {
let mut content = decorated_content("中文", TextDecoration::UNDERLINE);
let left = layout_content(&content, 400.0);
content.paragraphs_mut()[0].children_mut()[0].text_underline_position =
crate::shapes::TextUnderlinePosition::Right;
let right = layout_content(&content, 400.0);
let left_bar = decoration_bar(&left, &left.cells[0], 0.0, 0.0, false);
let right_bar = decoration_bar(&right, &right.cells[0], 0.0, 0.0, false);
let center = column_base_center(&left.columns[0]);
assert!(left_bar.center_x() < center);
assert!(right_bar.center_x() > center);
assert_eq!(left.cells[0].top, right.cells[0].top);
assert_eq!(
decoration_bar(&left, &left.cells[0], 0.0, 0.0, true),
decoration_bar(&right, &right.cells[0], 0.0, 0.0, true)
);
}
#[test]
fn decoration_bar_underline_left_of_center_line_through_centered() {
let content = decorated_content("あい", TextDecoration::UNDERLINE);
@ -910,8 +931,7 @@ mod tests {
let ruby = &layout.ruby_cells[0];
let column = &layout.columns[ruby.column];
let (origin_x, origin_y) = layout.origin(&bounds, VerticalAlign::Top);
let strip_x =
origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side);
let strip_x = origin_x + ruby.strip_x(column);
let strip = skia::Rect::from_xywh(strip_x, origin_y, ruby.font_size, 400.0);
let mut surface = skia::surfaces::raster_n32_premul((256, 512)).unwrap();

View File

@ -5,7 +5,7 @@ use skia_safe::{Contains, Point as SkPoint};
use crate::math::Rect;
use crate::shapes::{PositionData, VerticalAlign};
use super::annotations::{emphasis_mark_center, ruby_strip_x};
use super::annotations::emphasis_mark_center;
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout};
/// Position-data `direction` of a vertical-rl strip; 0/1 are rtl/ltr.
@ -213,7 +213,7 @@ fn ruby_entries(layout: &VerticalLayout, (origin_x, origin_y): (f32, f32)) -> Ve
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, ruby.font_size, ruby.base_font_size, ruby.side),
x: origin_x + ruby.strip_x(column),
y: origin_y + top,
width: ruby.font_size,
height: bottom - top,

View File

@ -150,8 +150,8 @@ pub struct RawTextSpan {
ruby_align: u8,
ruby_overhang: u8,
ruby_side: u8,
// Padding for the CLJS writer's 4-byte-aligned layout; always zero.
_padding: [u8; 2],
text_emphasis_position: u8,
text_underline_position: u8,
font_size: f32,
line_height: f32,
letter_spacing: f32,
@ -215,6 +215,10 @@ impl From<RawTextSpan> for shapes::TextSpan {
span.text_orientation = RawTextOrientation::from(value.text_orientation).into();
span.text_combine_upright = RawTextCombineUpright::from(value.text_combine_upright).into();
span.text_emphasis = RawTextEmphasis::from(value.text_emphasis).into();
span.text_emphasis_position =
RawTextEmphasisPosition::from(value.text_emphasis_position).into();
span.text_underline_position =
RawTextUnderlinePosition::from(value.text_underline_position).into();
span.warichu = RawWarichu::from(value.warichu).into();
span.font_features = RawFontFeatures::from(value.font_features).into();
span.annotation_clearance = RawAnnotationClearance::from(value.annotation_clearance).into();
@ -591,6 +595,33 @@ mod tests {
assert_eq!(span.text_combine_upright.digits_max(), Some(4));
}
#[test]
fn annotation_positions_decode_from_former_padding_and_unknown_defaults() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[14] = 4;
bytes[15] = 5;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(
span.text_emphasis_position,
shapes::TextEmphasisPosition::UnderLeft
);
assert_eq!(
span.text_underline_position,
shapes::TextUnderlinePosition::UnderRight
);
bytes[14] = 255;
bytes[15] = 255;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(
span.text_emphasis_position,
shapes::TextEmphasisPosition::Auto
);
assert_eq!(
span.text_underline_position,
shapes::TextUnderlinePosition::Auto
);
}
#[test]
fn raw_font_features_deserializes_from_reserved_span_byte() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];

View File

@ -124,6 +124,64 @@ impl From<RawTextEmphasis> for TextEmphasis {
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextEmphasisPosition {
Auto = 0,
OverRight = 1,
OverLeft = 2,
UnderRight = 3,
UnderLeft = 4,
}
raw_enum_from_byte!(
RawTextEmphasisPosition,
Auto,
[Auto, OverRight, OverLeft, UnderRight, UnderLeft]
);
impl From<RawTextEmphasisPosition> for shapes::TextEmphasisPosition {
fn from(value: RawTextEmphasisPosition) -> Self {
match value {
RawTextEmphasisPosition::Auto => Self::Auto,
RawTextEmphasisPosition::OverRight => Self::OverRight,
RawTextEmphasisPosition::OverLeft => Self::OverLeft,
RawTextEmphasisPosition::UnderRight => Self::UnderRight,
RawTextEmphasisPosition::UnderLeft => Self::UnderLeft,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextUnderlinePosition {
Auto = 0,
Under = 1,
Left = 2,
Right = 3,
UnderLeft = 4,
UnderRight = 5,
}
raw_enum_from_byte!(
RawTextUnderlinePosition,
Auto,
[Auto, Under, Left, Right, UnderLeft, UnderRight]
);
impl From<RawTextUnderlinePosition> for shapes::TextUnderlinePosition {
fn from(value: RawTextUnderlinePosition) -> Self {
match value {
RawTextUnderlinePosition::Auto => Self::Auto,
RawTextUnderlinePosition::Under => Self::Under,
RawTextUnderlinePosition::Left => Self::Left,
RawTextUnderlinePosition::Right => Self::Right,
RawTextUnderlinePosition::UnderLeft => Self::UnderLeft,
RawTextUnderlinePosition::UnderRight => Self::UnderRight,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawWarichu {