polishing

This commit is contained in:
alonso.torres 2026-07-15 17:12:49 +02:00
parent 6cdbfe4b51
commit 75aeea32fb
33 changed files with 2049 additions and 2124 deletions

View File

@ -41,8 +41,6 @@
(def ^:dynamic *wrap-with-objects-map-fn* identity)
(def ^:dynamic *wrap-with-pointer-map-fn* identity)
;; CHANGEME: The name of text-vertical/v1 is not clear for the feature, change for japanese-layout/v1
;; A set of supported features
(def supported-features
#{"fdata/objects-map"
@ -58,7 +56,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"text-vertical/v1"
"japanese-layout/v1"
"render-wasm/v1"
"variants/v1"})
@ -84,7 +82,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"text-vertical/v1"
"japanese-layout/v1"
"tokens/numeric-input"
"render-wasm/v1"})
@ -135,7 +133,7 @@
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-text-vertical "text-vertical/v1"
:feature-japanese-layout "japanese-layout/v1"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@ -11,6 +11,7 @@
[app.common.flags :as flags]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.text.japanese-layout :as jl]
[clojure.set :as set]
[clojure.walk :as walk]
[cuerdas.core :as str]))
@ -42,56 +43,6 @@
(def text-direction-attrs
[:text-direction])
;; CHANGEME: All these new attributes can be moved to a new namespace in app.common.types.text.japanese-layout
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
;; and "mixed", so plain horizontal text never stores these attrs.
(def text-writing-mode-attrs
[:writing-mode])
(def text-orientation-attrs
[:text-orientation])
(def text-combine-upright-attrs
[:text-combine-upright])
;; Emphasis mark (圏点 / bouten) applied per span; absent means no emphasis.
(def text-emphasis-attrs
[:text-emphasis])
;; Ruby (furigana) annotation text and customization carried per span.
(def text-ruby-attrs
[:ruby
:ruby-size
:ruby-align
:ruby-overhang
:ruby-side])
;; Warichu (割注): the span renders as two half-size lines stacked inline
;; within one column position. Values "warichu" / "none"; absent means off.
(def text-warichu-attrs
[:warichu])
(def text-font-features-attrs
[:font-features])
;; Annotation collision policy. "none" preserves the explicit line height;
;; "auto" reserves an additional half-em layer for ruby and emphasis.
(def text-annotation-clearance-attrs
[:annotation-clearance])
(defn content-writing-mode
"Writing mode of a text content. Stored per paragraph but treated as a
whole-shape property: the first paragraph decides the flow (mirrors
TextContent::is_vertical in wasm)."
[content]
(dm/get-in content [:children 0 :children 0 :writing-mode]))
(defn vertical-text-content?
"True when the text content flows vertically (vertical-rl)."
[content]
(= "vertical-rl" (content-writing-mode content)))
(def text-spacing-attrs
[:line-height
:letter-spacing])
@ -118,20 +69,20 @@
(d/concat-vec
text-align-attrs
text-direction-attrs
text-writing-mode-attrs
text-orientation-attrs))
jl/text-writing-mode-attrs
jl/text-orientation-attrs))
(def text-node-attrs
(d/concat-vec
text-typography-attrs
text-font-attrs
text-spacing-attrs
text-combine-upright-attrs
text-emphasis-attrs
text-ruby-attrs
text-warichu-attrs
text-font-features-attrs
text-annotation-clearance-attrs
jl/text-combine-upright-attrs
jl/text-emphasis-attrs
jl/text-ruby-attrs
jl/text-warichu-attrs
jl/text-font-features-attrs
jl/text-annotation-clearance-attrs
text-decoration-attrs
text-transform-attrs
text-fills))

View File

@ -0,0 +1,56 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.text.japanese-layout
(:require
[app.common.data.macros :as dm]))
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
;; and "mixed", so plain horizontal text never stores these attrs.
(def text-writing-mode-attrs
[:writing-mode])
(def text-orientation-attrs
[:text-orientation])
(def text-combine-upright-attrs
[:text-combine-upright])
;; Emphasis mark (圏点 / bouten) applied per span; absent means no emphasis.
(def text-emphasis-attrs
[:text-emphasis])
;; Ruby (furigana) annotation text and customization carried per span.
(def text-ruby-attrs
[:ruby
:ruby-size
:ruby-align
:ruby-overhang
:ruby-side])
;; Warichu (割注): the span renders as two half-size lines stacked inline
;; within one column position. Values "warichu" / "none"; absent means off.
(def text-warichu-attrs
[:warichu])
(def text-font-features-attrs
[:font-features])
;; Annotation collision policy. "none" preserves the explicit line height;
;; "auto" reserves an additional half-em layer for ruby and emphasis.
(def text-annotation-clearance-attrs
[:annotation-clearance])
(defn content-writing-mode
"Writing mode of a text content. Stored per paragraph but treated as a
whole-shape property: the first paragraph decides the flow."
[content]
(dm/get-in content [:children 0 :children 0 :writing-mode]))
(defn vertical-text-content?
"True when the text content flows vertically (vertical-rl)."
[content]
(= "vertical-rl" (content-writing-mode content)))

View File

@ -23,7 +23,7 @@
[app.common.types.shape-tree :as ctst]
[app.common.types.shape.attrs :refer [editable-attrs]]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.common.uuid :as uuid]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.comments :as-alias dwcm]
@ -1013,7 +1013,7 @@
(defn vertical-text-shape?
[shape]
(txt/vertical-text-content? (get shape :content)))
(jl/vertical-text-content? (get shape :content)))
;; Pure function to determine next grow-type for text layers
(defn next-grow-type
@ -1021,9 +1021,7 @@
(next-grow-type current-grow-type scalev false))
([current-grow-type scalev vertical?]
;; CHANGEME: Simplify this comment
;; Grow and wrap axes swap under vertical writing. Normalizing the scale
;; vector lets the existing horizontal state machine remain authoritative.
;; Reuse the horizontal state machine with swapped vertical axes.
(let [scalev (if vertical?
(gpt/point (:y scalev) (:x scalev))
scalev)]

View File

@ -20,6 +20,7 @@
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
@ -300,7 +301,6 @@
(defn current-paragraph-values
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
(cond
;; CHANGEME: is this correct? the v3-current-text-values is not here
(some? editor-styles) (merge (shape-current-values shape txt/is-paragraph-node? attrs)
(select-keys editor-styles attrs))
(some? editor-instance) (v2-current-text-values options)
@ -451,7 +451,6 @@
(ptk/reify ::update-paragraph-attrs
ptk/UpdateEvent
(update [_ state]
;; CHANGEME: check if the without nils is necesary here
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs))
ptk/WatchEvent
@ -959,8 +958,8 @@
;; apply them to every paragraph so they cannot diverge
;; from the first paragraph (which decides the flow).
whole-shape-attrs
(select-keys attrs (d/concat-vec txt/text-writing-mode-attrs
txt/text-orientation-attrs))
(select-keys attrs (d/concat-vec jl/text-writing-mode-attrs
jl/text-orientation-attrs))
selection-attrs
(apply dissoc attrs (keys whole-shape-attrs))]

View File

@ -15,7 +15,7 @@
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.shapes :as dwsh]
@ -32,7 +32,7 @@
auto-height keeps the physical height as its wrap budget and grows width."
[selrect grow-type content dimension]
(when (or (= :fixed grow-type) (some? dimension))
(let [vertical? (txt/vertical-text-content? content)]
(let [vertical? (jl/vertical-text-content? content)]
{:width (cond
(= :fixed grow-type)
(:width selrect)

View File

@ -10,7 +10,7 @@
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as cc]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.ui.shapes.text.styles :as sts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@ -170,7 +170,7 @@
;; Vertical writing anchors columns to the box edges, so the oversized
;; auto-grow box used to avoid horizontal wrapping/clipping would push
;; the content off-position. Use the real selrect size instead.
vertical? (txt/vertical-text-content? content)
vertical? (jl/vertical-text-content? content)
[colors _color-mapping color-mapping-inverse] (retrieve-colors shape)]

View File

@ -9,7 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.text :as legacy.txt]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.ui.shapes.text.styles :as sts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@ -86,7 +86,7 @@
;; Vertical writing anchors columns to the box edges; the oversized
;; auto-grow box (used to avoid horizontal wrapping) would push content
;; off-position, so use the real selrect size instead.
vertical? (txt/vertical-text-content? content)
vertical? (jl/vertical-text-content? content)
style
(when-not is-code

View File

@ -10,6 +10,7 @@
[app.common.transit :as transit]
[app.common.types.color :as cc]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.fonts :as fonts]
[app.main.ui.formats :as fmt]
[app.util.color :as uc]
@ -23,7 +24,7 @@
(let [valign (:vertical-align node "top")
;; Mirroring the shape's writing mode on the root makes paragraph
;; blocks stack right-to-left.
writing-mode (txt/content-writing-mode node)
writing-mode (jl/content-writing-mode node)
base #js {:height (when-not code? (fmt/format-pixels height))
:width (when-not code? (fmt/format-pixels width))
:display "flex"

View File

@ -14,7 +14,7 @@
[app.common.geom.shapes.text :as gst]
[app.common.math :as mth]
[app.common.text :as legacy.txt]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.config :as cf]
[app.main.data.workspace :as dw]
[app.main.data.workspace.texts :as dwt]
@ -236,7 +236,7 @@
:opacity (when @blurred 0)}
:on-pointer-down on-pointer-down
:class (dom/classnames
(cur/get-text (:rotation shape) (txt/vertical-text-content? content)) true
(cur/get-text (:rotation shape) (jl/vertical-text-content? content)) true
:align-top (= (:vertical-align content "top") "top")
:align-center (= (:vertical-align content) "center")
:align-bottom (= (:vertical-align content) "bottom"))}

View File

@ -16,6 +16,7 @@
[app.common.math :as mth]
[app.common.types.color :as color]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.config :as cf]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
@ -296,7 +297,7 @@
(dom/set-style! container-node "--text-editor-caret-color" text-color)))
[:div
{:class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? content))
{:class (dm/str (cur/get-text (:rotation shape) (jl/vertical-text-content? content))
" "
(stl/css :text-editor-container))
:ref container-ref

View File

@ -10,6 +10,7 @@
(:require
[app.common.data.macros :as dm]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.texts :as dwt]
[app.main.refs :as refs]
@ -405,7 +406,7 @@
:on-focus on-focus
:on-blur on-blur
:id "text-editor-wasm-input"
:class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? (:content shape)))
:class (dm/str (cur/get-text (:rotation shape) (jl/vertical-text-content? (:content shape)))
" "
(stl/css :text-editor-container))
:data-testid "text-editor-container"}]]]]))

View File

@ -25,14 +25,11 @@
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
[app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]]
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
@ -55,8 +52,6 @@
Evaluated once at module load time; cf/flags is immutable after startup."
(contains? cf/flags :token-typography-row))
;; CHANGEME: I want all the new options for japanese text to be in their own namespaced and then imported here
(defn- radio-selected
([value]
(radio-selected value ""))
@ -69,112 +64,6 @@
(string? value) value
:else (str value))))
(def ^:private mixed-span-values
#{:mixed :multiple "mixed" "multiple"})
(defn- mixed-span-value?
[value]
(contains? mixed-span-values value))
(defn- span-input-value
[value]
(if (mixed-span-value? value)
"mixed"
(radio-selected value)))
(defn- span-select-value
[value default]
(if (mixed-span-value? value)
"mixed"
(radio-selected value default)))
(defn- with-mixed-span-option
[options value]
(cond-> options
(mixed-span-value? value)
(conj {:id "mixed"
:label (tr "labels.mixed-values")
:disabled true
:dimmed true})))
(defn japanese-layout-enabled?
"Japanese layout is opt-in. An explicit supported writing mode is the
persisted marker; absent, mixed, or reset values remain ordinary text."
[values]
(let [writing-mode (:writing-mode values)]
(cond
(= writing-mode :multiple) nil
(#{"horizontal-tb" "vertical-rl"} writing-mode) true
:else false)))
(defn japanese-layout-toggle-attrs
"Attrs emitted by the Japanese layout switch. A nil writing mode removes the
persisted paragraph attribute, restoring the normal horizontal default."
[enabled?]
{:writing-mode (when enabled? "horizontal-tb")})
(defn reconcile-japanese-layout-state
"Keep the current opt-in state when a same-selection style snapshot omits
writing-mode. A new selection resets from its persisted paragraph value."
[current values selection-changed?]
(cond
selection-changed? (japanese-layout-enabled? values)
(#{"horizontal-tb" "vertical-rl"} (:writing-mode values)) true
:else current))
(defn vertical-japanese-layout?
"True when the Japanese layout controls are editing vertical text."
[values]
(= "vertical-rl" (:writing-mode values)))
(defn proportional-metrics-feature
"Return the proportional metric feature relevant to the writing mode."
[writing-mode]
(if (= writing-mode "vertical-rl") "vpal" "palt"))
(defn text-combine-upright-options
[text-selection-active translate]
(cond-> [{:value "none"
:id "none-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-none")
:icon i/text-combine-upright-none}]
text-selection-active
(conj {:value "all"
:id "all-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-all")
:icon i/text-combine-upright-all})
true
(conj {:value "digits"
:id "digits-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-digits")
:icon i/text-combine-upright-digits})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Sub-components
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc japanese-layout-toggle*
[{:keys [values enabled on-change on-toggle on-blur]}]
(let [handle-change
(mf/use-fn
(mf/deps on-change on-toggle on-blur)
(fn [checked?]
(on-toggle checked?)
(on-change (japanese-layout-toggle-attrs checked?))
(when (some? on-blur) (on-blur))))]
;; Repair the invalid reset sentinel emitted by the previous implementation
;; so an already-open document can recover without a reload or manual edit.
(mf/with-effect [(:writing-mode values) on-change]
(when (= "" (:writing-mode values))
(on-change {:writing-mode nil})))
[:div {:class (stl/css :japanese-layout-toggle)}
[:> switch* {:default-checked enabled
:label (tr "workspace.options.text-options.japanese-layout")
:on-change handle-change}]]))
(mf/defc text-align-options*
[{:keys [values on-change on-blur]}]
(let [options
@ -236,397 +125,6 @@
:name "text-direction-options"
:options options}]]))
(mf/defc writing-mode-options*
[{:keys [values on-change on-blur]}]
(let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb")
options
(mf/with-memo []
[{:value "horizontal-tb"
:id "horizontal-tb-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-horizontal")
:icon i/writing-mode-horizontal}
{:value "vertical-rl"
:id "vertical-rl-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-vertical")
:icon i/writing-mode-vertical}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:writing-mode value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :writing-mode-options)}
[:> radio-buttons* {:selected writing-mode
:on-change handle-change
:name "writing-mode-options"
:options options}]]))
(mf/defc text-orientation-options*
[{:keys [values on-change on-blur]}]
;; The v2 editor can read the paragraph orientation back as an empty
;; string when it is unset; treat that (and nil) as the "mixed" default.
(let [text-orientation (radio-selected (:text-orientation values) "mixed")
options
(mf/with-memo []
[{:value "mixed"
:id "mixed-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-mixed")
:icon i/text-orientation-mixed}
{:value "upright"
:id "upright-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-orientation value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-orientation-options)}
[:> radio-buttons* {:selected text-orientation
:on-change handle-change
:name "text-orientation-options"
:options options}]]))
(mf/defc text-combine-upright-options*
;; Digit TCY can be applied across a shape because it discovers eligible
;; runs automatically. The unrestricted `all` value is only offered for an
;; explicit text range.
[{:keys [values on-change on-blur text-selection-active]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
("digits" "digits2" "digits3") true
false)
selected (if digits? "digits" text-combine-upright)
options
(mf/with-memo [text-selection-active]
(text-combine-upright-options text-selection-active tr))
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-combine-upright value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-combine-upright-options)}
[:> radio-buttons* {:selected selected
:on-change handle-change
:name "text-combine-upright-options"
:options options}]]))
(mf/defc text-combine-upright-count-options*
[{:keys [values on-change on-blur]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
("digits" "digits2" "digits3") true
false)
options
(mf/with-memo []
;; select* matches and reports options by :id, so the id is the
;; persisted attr value.
[{:id "digits2"
:label (tr "workspace.options.text-options.text-combine-upright-digits-2")}
{:id "digits3"
:label (tr "workspace.options.text-options.text-combine-upright-digits-3")}
{:id "digits"
:label (tr "workspace.options.text-options.text-combine-upright-digits-4")}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-combine-upright value})
(when (some? on-blur) (on-blur))))]
(when digits?
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-combine-upright-digits-count")]
[:> select* {:default-selected text-combine-upright
:aria-label (tr "workspace.options.text-options.text-combine-upright-digits-count")
:options options
:on-change handle-change}]])))
(mf/defc ruby-options*
[{:keys [values on-change on-blur advanced-open on-toggle-advanced]}]
(let [ruby (span-input-value (:ruby values))
ruby* (mf/use-state ruby)
dirty* (mf/use-state false)
value (deref ruby*)
dirty? (deref dirty*)
commit!
(mf/use-fn
(mf/deps dirty? value on-change on-blur)
(fn []
(when dirty?
(on-change {:ruby value})
(reset! dirty* false))
(when (some? on-blur) (on-blur))))
handle-change
(mf/use-fn
(fn [event]
(reset! dirty* true)
(reset! ruby* (dom/get-target-val event))))
handle-key-down
(mf/use-fn
(mf/deps commit!)
(fn [event]
(when (= "Enter" (.-key event))
(dom/blur! (dom/get-target event)))))]
(mf/with-effect [ruby]
(reset! ruby* ruby)
(reset! dirty* false))
[:div {:class (stl/css :ruby-options)}
[:> input* {:class (stl/css :ruby-input)
:label (tr "workspace.options.text-options.ruby")
:placeholder (tr "workspace.options.text-options.ruby-placeholder")
:value value
:on-change handle-change
:on-blur commit!
:on-key-down handle-key-down}]
[:> icon-button* {:variant "ghost"
:selected advanced-open
:aria-label (tr "workspace.options.text-options.ruby-advanced-options")
:aria-expanded advanced-open
:data-testid "ruby-advanced-options-toggle"
:on-click on-toggle-advanced
:icon i/menu}]]))
(mf/defc ruby-select-option*
[{:keys [values on-change on-blur attr default-value label options]}]
(let [value (get values attr)
selected (span-select-value value default-value)
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps attr on-change on-blur)
(fn [value]
(on-change {attr value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)} label]
[:> select* {:default-selected selected
:aria-label label
:options options
:on-change handle-change}]]))
(defn- ruby-common-props
[values on-change on-blur]
(mf/props
{:values values
:on-change on-change
:on-blur on-blur}))
(mf/defc ruby-customization-options*
[{:keys [values on-change on-blur]}]
(let [common-props (ruby-common-props values on-change on-blur)
size-options [{:id "half" :label (tr "workspace.options.text-options.ruby-size-half")}
{:id "third" :label (tr "workspace.options.text-options.ruby-size-third")}
{:id "quarter" :label (tr "workspace.options.text-options.ruby-size-quarter")}]
align-options [{:id "space-around" :label (tr "workspace.options.text-options.ruby-align-space-around")}
{:id "center" :label (tr "workspace.options.text-options.ruby-align-center")}
{:id "start" :label (tr "workspace.options.text-options.ruby-align-start")}
{:id "space-between" :label (tr "workspace.options.text-options.ruby-align-space-between")}]
overhang-options [{:id "auto" :label (tr "workspace.options.text-options.ruby-overhang-auto")}
{:id "none" :label (tr "workspace.options.text-options.ruby-overhang-none")}]
side-options [{:id "over" :label (tr "workspace.options.text-options.ruby-side-over")}
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
[:div {:class (stl/css :japanese-layout-controls)}
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-size
:default-value "half"
:label (tr "workspace.options.text-options.ruby-size")
:options size-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-align
:default-value "space-around"
:label (tr "workspace.options.text-options.ruby-align")
:options align-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-overhang
:default-value "auto"
:label (tr "workspace.options.text-options.ruby-overhang")
:options overhang-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-side
:default-value "over"
:label (tr "workspace.options.text-options.ruby-side")
:options side-options})]]))
(mf/defc ruby-advanced-options*
[{:keys [values on-change on-blur]}]
(let [open* (mf/use-state false)
open? (deref open*)
toggle-open (mf/use-fn #(swap! open* not))
common-props (ruby-common-props values on-change on-blur)]
[:div {:class (stl/css :ruby-advanced-options)}
[:> ruby-options* (mf/spread-props common-props
{:advanced-open open?
:on-toggle-advanced toggle-open})]
[:> advanced-options* {:class (stl/css :ruby-advanced-content)
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc ruby-presentation-options*
[{:keys [values on-change on-blur]}]
(let [open* (mf/use-state false)
open? (deref open*)
toggle-open (mf/use-fn #(swap! open* not))
common-props (ruby-common-props values on-change on-blur)]
[:div {:class (stl/css :ruby-advanced-options)}
[:> title-bar* {:collapsable true
:collapsed (not open?)
:title (tr "workspace.options.text-options.ruby-advanced-options")
:on-collapsed toggle-open
:aria-expanded open?
:data-testid "ruby-presentation-options-toggle"}]
[:> advanced-options* {:class (stl/css :ruby-advanced-content)
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc warichu-options*
;; Warichu (割注): a span-scoped toggle that renders the selection as two
;; half-size lines within one inline position (top/bottom in horizontal
;; flow, right/left in vertical flow). Applies to the current selection
;; through the shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [warichu (radio-selected (:warichu values) "none")
options
(mf/with-memo []
[{:value "none"
:id "none-warichu"
:label (tr "workspace.options.text-options.warichu-none")
:icon i/warichu-none}
{:value "warichu"
:id "warichu-warichu"
:label (tr "workspace.options.text-options.warichu")
:icon i/warichu}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:warichu value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :warichu-options)}
[:> radio-buttons* {:selected warichu
:on-change handle-change
:name "warichu-options"
:options options}]]))
(mf/defc font-features-options*
;; Japanese proportional metric alternates. `palt` is typically used for
;; horizontal composition and `vpal` for vertical composition; the value is
;; span-scoped and passed through browser, export, and render-wasm paths.
[{:keys [values on-change on-blur]}]
(let [writing-mode (:writing-mode values)
feature (proportional-metrics-feature writing-mode)
value (:font-features values)
enabled? (cond
(mixed-span-value? value) nil
(= feature value) true
:else false)
handle-change
(mf/use-fn
(mf/deps feature on-change on-blur)
(fn [checked?]
(on-change {:font-features (if checked? feature "none")})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :font-features-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.font-features")]
[:> switch* {:default-checked enabled?
:aria-label (tr "workspace.options.text-options.font-features")
:on-change handle-change}]]))
(defn text-emphasis-options
([]
(text-emphasis-options tr))
([translate]
[{:id "none"
:label (translate "workspace.options.text-options.text-emphasis-none")}
{:id "filled-dot"
:label (translate "workspace.options.text-options.text-emphasis-filled-dot")}
{:id "open-dot"
:label (translate "workspace.options.text-options.text-emphasis-open-dot")}
{:id "filled-circle"
:label (translate "workspace.options.text-options.text-emphasis-filled-circle")}
{:id "open-circle"
:label (translate "workspace.options.text-options.text-emphasis-open-circle")}
{:id "filled-sesame"
:label (translate "workspace.options.text-options.text-emphasis-filled-sesame")}
{:id "open-sesame"
:label (translate "workspace.options.text-options.text-emphasis-open-sesame")}]))
(mf/defc text-emphasis-options*
;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base
;; character in vertical writing. Applies to the current selection through the
;; shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [value (:text-emphasis values)
text-emphasis (span-select-value value "none")
options
(mf/with-memo []
(text-emphasis-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-emphasis value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-emphasis-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-emphasis")]
[:> select* {:default-selected text-emphasis
:aria-label (tr "workspace.options.text-options.text-emphasis")
:options options
:on-change handle-change}]]))
(defn annotation-clearance-options
([]
(annotation-clearance-options tr))
([translate]
[{:id "none"
:label (translate "workspace.options.text-options.annotation-clearance-none")}
{:id "auto"
:label (translate "workspace.options.text-options.annotation-clearance-auto")}]))
(mf/defc annotation-clearance-options*
[{:keys [values on-change on-blur]}]
(let [value (:annotation-clearance values)
annotation-clearance (span-select-value value "none")
options (mf/with-memo [] (annotation-clearance-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:annotation-clearance value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :annotation-clearance-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.annotation-clearance")]
[:> select* {:default-selected annotation-clearance
:aria-label (tr "workspace.options.text-options.annotation-clearance")
:options options
:on-change handle-change}]]))
(mf/defc vertical-align*
[{:keys [values on-change on-blur]}]
(let [vertical-align (radio-selected (:vertical-align values) "top")
@ -896,17 +394,6 @@
;; --- Helpers
multiple? (->> values vals (d/seek #(= % :multiple)))
vertical-text-enabled?
(features/use-feature "text-vertical/v1")
selection-key (hash ids)
previous-selection-key-ref (mf/use-ref selection-key)
japanese-layout-active* (mf/use-state #(japanese-layout-enabled? values))
japanese-layout-active?
(deref japanese-layout-active*)
vertical-mode?
(vertical-japanese-layout? values)
apply-token!
(mf/use-fn
(mf/deps ids typography-tokens)
@ -948,10 +435,6 @@
(mf/use-fn
#(swap! token-dropdown-open* not))
toggle-japanese-layout
(mf/use-fn
#(reset! japanese-layout-active* %))
;; --- Event handlers
on-option-click
(mf/use-fn
@ -1046,16 +529,15 @@
:show-recent true
:on-blur on-text-blur})
ruby-presentation-props
japanese-layout-props
(mf/props
{:values ruby-values
:on-change on-ruby-presentation-change
:on-blur on-text-blur})
japanese-toggle-props
(mf/spread-props common-props
{:enabled japanese-layout-active?
:on-toggle toggle-japanese-layout})]
{:ids ids
:values values
:ruby-values ruby-values
:text-selection-active text-selection-active
:on-change on-change
:on-ruby-presentation-change on-ruby-presentation-change
:on-blur on-text-blur})]
(hooks/use-stream
expand-stream
@ -1073,19 +555,6 @@
(when token-dropdown-open?
(ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!))))
;; Selection style snapshots may temporarily omit paragraph attrs when a
;; span-level Japanese option changes. A new selection must instead trust
;; its persisted value. Handle both changes in one effect so a writing-mode
;; update cannot restore the previous selection's opt-in state.
(mf/with-effect [selection-key (:writing-mode values)]
(let [selection-changed?
(not= selection-key (mf/ref-val previous-selection-key-ref))]
(reset! japanese-layout-active*
(reconcile-japanese-layout-state japanese-layout-active?
values
selection-changed?))
(mf/set-ref-val! previous-selection-key-ref selection-key)))
[:section {:class (stl/css :element-set)
:aria-label (tr "workspace.options.text-options.text-section")}
[:div {:class (stl/css :element-title)}
@ -1176,27 +645,4 @@
[:> vertical-align* common-props]
[:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})]
[:> text-direction-options* common-props]]
(when ^boolean vertical-text-enabled?
[:div {:class (stl/css :japanese-layout-options)}
[:> japanese-layout-toggle* japanese-toggle-props]
(when ^boolean japanese-layout-active?
[:div {:class (stl/css :japanese-layout-controls)}
[:div {:class (stl/css :japanese-icon-options)}
[:> writing-mode-options* common-props]
(when ^boolean vertical-mode?
[:*
[:> text-orientation-options* common-props]
[:> text-combine-upright-options*
(mf/spread-props common-props
{:text-selection-active text-selection-active})]])
(when ^boolean text-selection-active
[:> warichu-options* common-props])]
(when ^boolean vertical-mode?
[:> text-combine-upright-count-options* common-props])
[:> font-features-options* common-props]
(when ^boolean text-selection-active
[:> text-emphasis-options* common-props])
[:> annotation-clearance-options* common-props]
(if text-selection-active
[:> ruby-advanced-options* common-props]
[:> ruby-presentation-options* ruby-presentation-props])])])])])]))
[:> tjl/japanese-layout-options* japanese-layout-props]])])]))

View File

@ -57,93 +57,13 @@
.align-options,
.text-direction-options,
.writing-mode-options,
.text-orientation-options,
.text-combine-upright-options,
.warichu-options,
.vertical-align-options,
.grow-options,
.text-decoration-options {
block-size: $sz-32;
}
.ruby-advanced-options {
display: flex;
flex-direction: column;
overflow: visible;
}
.ruby-options {
display: flex;
align-items: flex-end;
gap: var(--sp-xs);
}
.ruby-input {
flex: 1;
}
.ruby-advanced-content {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
}
.text-emphasis-options {
inline-size: 100%;
}
.font-features-options {
inline-size: 100%;
}
.annotation-clearance-options {
inline-size: 100%;
}
.text-decoration-options {
display: flex;
gap: var(--sp-xs);
}
.japanese-layout-options,
.japanese-layout-controls {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.japanese-layout-options {
padding-block-start: var(--sp-xs);
border-block-start: $b-1 solid var(--color-background-quaternary);
}
.japanese-layout-toggle {
display: flex;
align-items: center;
min-block-size: $sz-32;
}
.japanese-icon-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-xs);
}
.japanese-select-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
align-items: center;
gap: var(--sp-s);
inline-size: 100%;
}
.japanese-option-label {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}

View File

@ -0,0 +1,591 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.main.features :as features]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(defn- radio-selected
([value]
(radio-selected value ""))
([value default]
(cond
(= value :multiple) ""
(or (nil? value)
(and (string? value) (empty? value))) default
(keyword? value) (d/name value)
(string? value) value
:else (str value))))
(def ^:private mixed-span-values
#{:mixed :multiple "mixed" "multiple"})
(defn- mixed-span-value?
[value]
(contains? mixed-span-values value))
(defn- span-input-value
[value]
(if (mixed-span-value? value)
"mixed"
(radio-selected value)))
(defn- span-select-value
[value default]
(if (mixed-span-value? value)
"mixed"
(radio-selected value default)))
(defn- with-mixed-span-option
[options value]
(cond-> options
(mixed-span-value? value)
(conj {:id "mixed"
:label (tr "labels.mixed-values")
:disabled true
:dimmed true})))
(defn japanese-layout-enabled?
"Japanese layout is opt-in. An explicit supported writing mode is the
persisted marker; absent, mixed, or reset values remain ordinary text."
[values]
(let [writing-mode (:writing-mode values)]
(cond
(= writing-mode :multiple) nil
(#{"horizontal-tb" "vertical-rl"} writing-mode) true
:else false)))
(defn japanese-layout-toggle-attrs
"Attrs emitted by the Japanese layout switch. A nil writing mode removes the
persisted paragraph attribute, restoring the normal horizontal default."
[enabled?]
{:writing-mode (when enabled? "horizontal-tb")})
(defn reconcile-japanese-layout-state
"Keep the current opt-in state when a same-selection style snapshot omits
writing-mode. A new selection resets from its persisted paragraph value."
[current values selection-changed?]
(cond
selection-changed? (japanese-layout-enabled? values)
(#{"horizontal-tb" "vertical-rl"} (:writing-mode values)) true
:else current))
(defn vertical-japanese-layout?
"True when the Japanese layout controls are editing vertical text."
[values]
(= "vertical-rl" (:writing-mode values)))
(defn proportional-metrics-feature
"Return the proportional metric feature relevant to the writing mode."
[writing-mode]
(if (= writing-mode "vertical-rl") "vpal" "palt"))
(defn text-combine-upright-options
[text-selection-active translate]
(cond-> [{:value "none"
:id "none-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-none")
:icon i/text-combine-upright-none}]
text-selection-active
(conj {:value "all"
:id "all-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-all")
:icon i/text-combine-upright-all})
true
(conj {:value "digits"
:id "digits-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-digits")
:icon i/text-combine-upright-digits})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Sub-components
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc japanese-layout-toggle*
[{:keys [values enabled on-change on-toggle on-blur]}]
(let [handle-change
(mf/use-fn
(mf/deps on-change on-toggle on-blur)
(fn [checked?]
(on-toggle checked?)
(on-change (japanese-layout-toggle-attrs checked?))
(when (some? on-blur) (on-blur))))]
;; Repair the invalid reset sentinel emitted by the previous implementation
;; so an already-open document can recover without a reload or manual edit.
(mf/with-effect [(:writing-mode values) on-change]
(when (= "" (:writing-mode values))
(on-change {:writing-mode nil})))
[:div {:class (stl/css :japanese-layout-toggle)}
[:> switch* {:default-checked enabled
:label (tr "workspace.options.text-options.japanese-layout")
:on-change handle-change}]]))
(mf/defc writing-mode-options*
[{:keys [values on-change on-blur]}]
(let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb")
options
(mf/with-memo []
[{:value "horizontal-tb"
:id "horizontal-tb-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-horizontal")
:icon i/writing-mode-horizontal}
{:value "vertical-rl"
:id "vertical-rl-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-vertical")
:icon i/writing-mode-vertical}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:writing-mode value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :writing-mode-options)}
[:> radio-buttons* {:selected writing-mode
:on-change handle-change
:name "writing-mode-options"
:options options}]]))
(mf/defc text-orientation-options*
[{:keys [values on-change on-blur]}]
;; The v2 editor can read the paragraph orientation back as an empty
;; string when it is unset; treat that (and nil) as the "mixed" default.
(let [text-orientation (radio-selected (:text-orientation values) "mixed")
options
(mf/with-memo []
[{:value "mixed"
:id "mixed-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-mixed")
:icon i/text-orientation-mixed}
{:value "upright"
:id "upright-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-orientation value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-orientation-options)}
[:> radio-buttons* {:selected text-orientation
:on-change handle-change
:name "text-orientation-options"
:options options}]]))
(mf/defc text-combine-upright-options*
;; Digit TCY can be applied across a shape because it discovers eligible
;; runs automatically. The unrestricted `all` value is only offered for an
;; explicit text range.
[{:keys [values on-change on-blur text-selection-active]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
("digits" "digits2" "digits3") true
false)
selected (if digits? "digits" text-combine-upright)
options
(mf/with-memo [text-selection-active]
(text-combine-upright-options text-selection-active tr))
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-combine-upright value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-combine-upright-options)}
[:> radio-buttons* {:selected selected
:on-change handle-change
:name "text-combine-upright-options"
:options options}]]))
(mf/defc text-combine-upright-count-options*
[{:keys [values on-change on-blur]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
("digits" "digits2" "digits3") true
false)
options
(mf/with-memo []
;; select* matches and reports options by :id, so the id is the
;; persisted attr value.
[{:id "digits2"
:label (tr "workspace.options.text-options.text-combine-upright-digits-2")}
{:id "digits3"
:label (tr "workspace.options.text-options.text-combine-upright-digits-3")}
{:id "digits"
:label (tr "workspace.options.text-options.text-combine-upright-digits-4")}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-combine-upright value})
(when (some? on-blur) (on-blur))))]
(when digits?
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-combine-upright-digits-count")]
[:> select* {:default-selected text-combine-upright
:aria-label (tr "workspace.options.text-options.text-combine-upright-digits-count")
:options options
:on-change handle-change}]])))
(mf/defc ruby-options*
[{:keys [values on-change on-blur advanced-open on-toggle-advanced]}]
(let [ruby (span-input-value (:ruby values))
ruby* (mf/use-state ruby)
dirty* (mf/use-state false)
value (deref ruby*)
dirty? (deref dirty*)
commit!
(mf/use-fn
(mf/deps dirty? value on-change on-blur)
(fn []
(when dirty?
(on-change {:ruby value})
(reset! dirty* false))
(when (some? on-blur) (on-blur))))
handle-change
(mf/use-fn
(fn [event]
(reset! dirty* true)
(reset! ruby* (dom/get-target-val event))))
handle-key-down
(mf/use-fn
(mf/deps commit!)
(fn [event]
(when (= "Enter" (.-key event))
(dom/blur! (dom/get-target event)))))]
(mf/with-effect [ruby]
(reset! ruby* ruby)
(reset! dirty* false))
[:div {:class (stl/css :ruby-options)}
[:> input* {:class (stl/css :ruby-input)
:label (tr "workspace.options.text-options.ruby")
:placeholder (tr "workspace.options.text-options.ruby-placeholder")
:value value
:on-change handle-change
:on-blur commit!
:on-key-down handle-key-down}]
[:> icon-button* {:variant "ghost"
:selected advanced-open
:aria-label (tr "workspace.options.text-options.ruby-advanced-options")
:aria-expanded advanced-open
:data-testid "ruby-advanced-options-toggle"
:on-click on-toggle-advanced
:icon i/menu}]]))
(mf/defc ruby-select-option*
[{:keys [values on-change on-blur attr default-value label options]}]
(let [value (get values attr)
selected (span-select-value value default-value)
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps attr on-change on-blur)
(fn [value]
(on-change {attr value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)} label]
[:> select* {:default-selected selected
:aria-label label
:options options
:on-change handle-change}]]))
(defn- ruby-common-props
[values on-change on-blur]
(mf/props
{:values values
:on-change on-change
:on-blur on-blur}))
(mf/defc ruby-customization-options*
[{:keys [values on-change on-blur]}]
(let [common-props (ruby-common-props values on-change on-blur)
size-options [{:id "half" :label (tr "workspace.options.text-options.ruby-size-half")}
{:id "third" :label (tr "workspace.options.text-options.ruby-size-third")}
{:id "quarter" :label (tr "workspace.options.text-options.ruby-size-quarter")}]
align-options [{:id "space-around" :label (tr "workspace.options.text-options.ruby-align-space-around")}
{:id "center" :label (tr "workspace.options.text-options.ruby-align-center")}
{:id "start" :label (tr "workspace.options.text-options.ruby-align-start")}
{:id "space-between" :label (tr "workspace.options.text-options.ruby-align-space-between")}]
overhang-options [{:id "auto" :label (tr "workspace.options.text-options.ruby-overhang-auto")}
{:id "none" :label (tr "workspace.options.text-options.ruby-overhang-none")}]
side-options [{:id "over" :label (tr "workspace.options.text-options.ruby-side-over")}
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
[:div {:class (stl/css :japanese-layout-controls)}
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-size
:default-value "half"
:label (tr "workspace.options.text-options.ruby-size")
:options size-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-align
:default-value "space-around"
:label (tr "workspace.options.text-options.ruby-align")
:options align-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-overhang
:default-value "auto"
:label (tr "workspace.options.text-options.ruby-overhang")
:options overhang-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-side
:default-value "over"
:label (tr "workspace.options.text-options.ruby-side")
:options side-options})]]))
(mf/defc ruby-advanced-options*
[{:keys [values on-change on-blur]}]
(let [open* (mf/use-state false)
open? (deref open*)
toggle-open (mf/use-fn #(swap! open* not))
common-props (ruby-common-props values on-change on-blur)]
[:div {:class (stl/css :ruby-advanced-options)}
[:> ruby-options* (mf/spread-props common-props
{:advanced-open open?
:on-toggle-advanced toggle-open})]
[:> advanced-options* {:class (stl/css :ruby-advanced-content)
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc ruby-presentation-options*
[{:keys [values on-change on-blur]}]
(let [open* (mf/use-state false)
open? (deref open*)
toggle-open (mf/use-fn #(swap! open* not))
common-props (ruby-common-props values on-change on-blur)]
[:div {:class (stl/css :ruby-advanced-options)}
[:> title-bar* {:collapsable true
:collapsed (not open?)
:title (tr "workspace.options.text-options.ruby-advanced-options")
:on-collapsed toggle-open
:aria-expanded open?
:data-testid "ruby-presentation-options-toggle"}]
[:> advanced-options* {:class (stl/css :ruby-advanced-content)
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc warichu-options*
;; Warichu (割注): a span-scoped toggle that renders the selection as two
;; half-size lines within one inline position (top/bottom in horizontal
;; flow, right/left in vertical flow). Applies to the current selection
;; through the shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [warichu (radio-selected (:warichu values) "none")
options
(mf/with-memo []
[{:value "none"
:id "none-warichu"
:label (tr "workspace.options.text-options.warichu-none")
:icon i/warichu-none}
{:value "warichu"
:id "warichu-warichu"
:label (tr "workspace.options.text-options.warichu")
:icon i/warichu}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:warichu value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :warichu-options)}
[:> radio-buttons* {:selected warichu
:on-change handle-change
:name "warichu-options"
:options options}]]))
(mf/defc font-features-options*
;; Japanese proportional metric alternates. `palt` is typically used for
;; horizontal composition and `vpal` for vertical composition; the value is
;; span-scoped and passed through browser, export, and render-wasm paths.
[{:keys [values on-change on-blur]}]
(let [writing-mode (:writing-mode values)
feature (proportional-metrics-feature writing-mode)
value (:font-features values)
enabled? (cond
(mixed-span-value? value) nil
(= feature value) true
:else false)
handle-change
(mf/use-fn
(mf/deps feature on-change on-blur)
(fn [checked?]
(on-change {:font-features (if checked? feature "none")})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :font-features-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.font-features")]
[:> switch* {:default-checked enabled?
:aria-label (tr "workspace.options.text-options.font-features")
:on-change handle-change}]]))
(defn text-emphasis-options
([]
(text-emphasis-options tr))
([translate]
[{:id "none"
:label (translate "workspace.options.text-options.text-emphasis-none")}
{:id "filled-dot"
:label (translate "workspace.options.text-options.text-emphasis-filled-dot")}
{:id "open-dot"
:label (translate "workspace.options.text-options.text-emphasis-open-dot")}
{:id "filled-circle"
:label (translate "workspace.options.text-options.text-emphasis-filled-circle")}
{:id "open-circle"
:label (translate "workspace.options.text-options.text-emphasis-open-circle")}
{:id "filled-sesame"
:label (translate "workspace.options.text-options.text-emphasis-filled-sesame")}
{:id "open-sesame"
:label (translate "workspace.options.text-options.text-emphasis-open-sesame")}]))
(mf/defc text-emphasis-options*
;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base
;; character in vertical writing. Applies to the current selection through the
;; shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [value (:text-emphasis values)
text-emphasis (span-select-value value "none")
options
(mf/with-memo []
(text-emphasis-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-emphasis value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-emphasis-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-emphasis")]
[:> select* {:default-selected text-emphasis
:aria-label (tr "workspace.options.text-options.text-emphasis")
:options options
:on-change handle-change}]]))
(defn annotation-clearance-options
([]
(annotation-clearance-options tr))
([translate]
[{:id "none"
:label (translate "workspace.options.text-options.annotation-clearance-none")}
{:id "auto"
:label (translate "workspace.options.text-options.annotation-clearance-auto")}]))
(mf/defc annotation-clearance-options*
[{:keys [values on-change on-blur]}]
(let [value (:annotation-clearance values)
annotation-clearance (span-select-value value "none")
options (mf/with-memo [] (annotation-clearance-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:annotation-clearance value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :annotation-clearance-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.annotation-clearance")]
[:> select* {:default-selected annotation-clearance
:aria-label (tr "workspace.options.text-options.annotation-clearance")
:options options
:on-change handle-change}]]))
(mf/defc japanese-layout-options*
[{:keys [ids values ruby-values text-selection-active
on-change on-ruby-presentation-change on-blur]}]
(let [enabled? (features/use-feature "japanese-layout/v1")
selection-key (hash ids)
previous-selection-key-ref (mf/use-ref selection-key)
active* (mf/use-state #(japanese-layout-enabled? values))
active? (deref active*)
vertical? (vertical-japanese-layout? values)
toggle (mf/use-fn #(reset! active* %))
common-props (mf/props
{:values values
:on-change on-change
:on-blur on-blur})
ruby-presentation-props (mf/props
{:values ruby-values
:on-change on-ruby-presentation-change
:on-blur on-blur})
toggle-props (mf/spread-props
common-props
{:enabled active?
:on-toggle toggle})]
(mf/with-effect [selection-key (:writing-mode values)]
(let [selection-changed?
(not= selection-key (mf/ref-val previous-selection-key-ref))]
(reset! active*
(reconcile-japanese-layout-state active?
values
selection-changed?))
(mf/set-ref-val! previous-selection-key-ref selection-key)))
(when ^boolean enabled?
[:div {:class (stl/css :japanese-layout-options)}
[:> japanese-layout-toggle* toggle-props]
(when ^boolean active?
[:div {:class (stl/css :japanese-layout-controls)}
[:div {:class (stl/css :japanese-icon-options)}
[:> writing-mode-options* common-props]
(when ^boolean vertical?
[:*
[:> text-orientation-options* common-props]
[:> text-combine-upright-options*
(mf/spread-props common-props
{:text-selection-active text-selection-active})]])
(when ^boolean text-selection-active
[:> warichu-options* common-props])]
(when ^boolean vertical?
[:> text-combine-upright-count-options* common-props])
[:> font-features-options* common-props]
(when ^boolean text-selection-active
[:> text-emphasis-options* common-props])
[:> annotation-clearance-options* common-props]
(if text-selection-active
[:> ruby-advanced-options* common-props]
[:> ruby-presentation-options* ruby-presentation-props])])])))

View File

@ -0,0 +1,87 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/spacing.scss" as *;
.writing-mode-options,
.text-orientation-options,
.text-combine-upright-options,
.warichu-options {
block-size: $sz-32;
}
.ruby-advanced-options {
display: flex;
flex-direction: column;
overflow: visible;
}
.ruby-options {
display: flex;
align-items: flex-end;
gap: var(--sp-xs);
}
.ruby-input {
flex: 1;
}
.ruby-advanced-content {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
}
.text-emphasis-options,
.font-features-options,
.annotation-clearance-options {
inline-size: 100%;
}
.japanese-layout-options,
.japanese-layout-controls {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.japanese-layout-options {
padding-block-start: var(--sp-xs);
border-block-start: $b-1 solid var(--color-background-quaternary);
}
.japanese-layout-toggle {
display: flex;
align-items: center;
min-block-size: $sz-32;
}
.japanese-icon-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-xs);
}
.japanese-select-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
align-items: center;
gap: var(--sp-s);
inline-size: 100%;
}
.japanese-option-label {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}

View File

@ -15,7 +15,7 @@
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
@ -501,7 +501,7 @@
(keyword))
horizontal-resize? (contains? #{:right :left} position)
vertical-resize? (contains? #{:top :bottom} position)
vertical-text? (txt/vertical-text-content? (get shape :content))
vertical-text? (jl/vertical-text-content? (get shape :content))
wrap-axis-resize? (if vertical-text?
vertical-resize?
horizontal-resize?)

View File

@ -398,7 +398,6 @@
:else
(st/emit! (dwt/update-text-range id start end {:text-decoration value}))))}
;; CHANGEME: All these new method need to be added to the plugin-api-test-suite
:fontFeatures
{:this true
:get

View File

@ -8,6 +8,7 @@
(:require
[app.common.data :as d]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.util.dom :as dom]
[app.util.text.content.styles :as styles]))
@ -91,11 +92,8 @@
(defn get-root-styles
[root]
(let [styles (get-styles-from-attrs root txt/root-attrs txt/default-text-attrs)
;; CHANGEME: simplify this comment
;; Mirroring the shape's writing mode on the root makes paragraph
;; blocks stack right-to-left in the DOM editor. It is not read
;; back from the root (root-attrs does not include it).
writing-mode (txt/content-writing-mode root)]
;; Stack vertical paragraphs right-to-left in the DOM editor.
writing-mode (jl/content-writing-mode root)]
(cond-> styles
(some? writing-mode) (assoc :writing-mode writing-mode))))

View File

@ -275,8 +275,7 @@
(t/is (= #{:japanese :cyrillic}
(texts/resolve-ambiguous-cjk #{:han :cyrillic} "ja"))))
(t/deftest resolve-han-unification-fixture
;; Acceptance case from render-wasm/fixtures/japanese-typography.json:
;; a kanji-only Japanese address resolves to Japanese under a ja locale.
(t/deftest resolve-han-unification
;; A kanji-only Japanese address resolves to Japanese under a ja locale.
(t/is (= #{:japanese}
(texts/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja"))))

View File

@ -10,7 +10,7 @@
[app.main.data.workspace.texts :as dwt]
[app.main.ui.ds.controls.select :as select]
[app.main.ui.shapes.text.styles :as text-styles]
[app.main.ui.workspace.sidebar.options.menus.text :as text-menu]
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
[app.util.text.content.styles :as content-styles]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
@ -28,29 +28,29 @@
:font-features
:annotation-clearance])
(def ^:private radio-selected @#'text-menu/radio-selected)
(def ^:private span-input-value @#'text-menu/span-input-value)
(def ^:private span-select-value @#'text-menu/span-select-value)
(def ^:private with-mixed-span-option @#'text-menu/with-mixed-span-option)
(def ^:private ruby-common-props @#'text-menu/ruby-common-props)
(def ^:private radio-selected @#'tjl/radio-selected)
(def ^:private span-input-value @#'tjl/span-input-value)
(def ^:private span-select-value @#'tjl/span-select-value)
(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option)
(def ^:private ruby-common-props @#'tjl/ruby-common-props)
(t/deftest text-emphasis-select-reports-and-restores-canonical-values
(let [options (text-menu/text-emphasis-options identity)
(let [options (tjl/text-emphasis-options identity)
reported-id (:id (select/get-option options "filled-dot"))]
(t/is (= "filled-dot" reported-id))
(t/is (= "filled-dot"
(:id (select/get-option options reported-id))))))
(t/deftest annotation-clearance-select-reports-canonical-values
(let [options (text-menu/annotation-clearance-options identity)]
(let [options (tjl/annotation-clearance-options identity)]
(t/is (= ["none" "auto"] (mapv :id options)))
(t/is (= "auto" (:id (select/get-option options "auto"))))))
(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection
(t/is (= ["none" "digits"]
(mapv :value (text-menu/text-combine-upright-options false identity))))
(mapv :value (tjl/text-combine-upright-options false identity))))
(t/is (= ["none" "all" "digits"]
(mapv :value (text-menu/text-combine-upright-options true identity)))))
(mapv :value (tjl/text-combine-upright-options true identity)))))
(t/deftest ruby-container-styles-preserve-customization
(let [style (text-styles/generate-ruby-container-styles
@ -63,7 +63,7 @@
(t/deftest advanced-furigana-options-are-collapsed-by-default
(let [markup (rds/renderToStaticMarkup
(mf/element text-menu/ruby-presentation-options*
(mf/element tjl/ruby-presentation-options*
#js {:values {}
:on-change identity
:on-blur identity}))]
@ -102,7 +102,7 @@
(t/deftest mixed-japanese-span-values-have-distinct-control-states
(let [options (with-mixed-span-option
(text-menu/text-emphasis-options identity)
(tjl/text-emphasis-options identity)
:multiple)]
(t/is (= "mixed" (span-select-value :multiple "none")))
(t/is (= "mixed" (span-input-value :multiple)))
@ -177,31 +177,31 @@
{"text-combine-upright" "digits 3"}))))
(t/deftest japanese-layout-is-explicitly-enabled-by-writing-mode
(t/is (false? (text-menu/japanese-layout-enabled? {})))
(t/is (false? (text-menu/japanese-layout-enabled? {:writing-mode nil})))
(t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "horizontal-tb"})))
(t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "vertical-rl"})))
(t/is (nil? (text-menu/japanese-layout-enabled? {:writing-mode :multiple}))))
(t/is (false? (tjl/japanese-layout-enabled? {})))
(t/is (false? (tjl/japanese-layout-enabled? {:writing-mode nil})))
(t/is (true? (tjl/japanese-layout-enabled? {:writing-mode "horizontal-tb"})))
(t/is (true? (tjl/japanese-layout-enabled? {:writing-mode "vertical-rl"})))
(t/is (nil? (tjl/japanese-layout-enabled? {:writing-mode :multiple}))))
(t/deftest japanese-layout-toggle-emits-a-persisted-writing-mode
(t/is (= {:writing-mode "horizontal-tb"}
(text-menu/japanese-layout-toggle-attrs true)))
(tjl/japanese-layout-toggle-attrs true)))
(t/is (= {:writing-mode nil}
(text-menu/japanese-layout-toggle-attrs false))))
(tjl/japanese-layout-toggle-attrs false))))
(t/deftest japanese-layout-state-survives-transient-selection-styles
(t/is (true? (text-menu/reconcile-japanese-layout-state true {} false)))
(t/is (true? (text-menu/reconcile-japanese-layout-state false
{:writing-mode "vertical-rl"}
false)))
(t/is (false? (text-menu/reconcile-japanese-layout-state true {} true))))
(t/is (true? (tjl/reconcile-japanese-layout-state true {} false)))
(t/is (true? (tjl/reconcile-japanese-layout-state false
{:writing-mode "vertical-rl"}
false)))
(t/is (false? (tjl/reconcile-japanese-layout-state true {} true))))
(t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes
(t/is (false? (text-menu/vertical-japanese-layout?
(t/is (false? (tjl/vertical-japanese-layout?
{:writing-mode "horizontal-tb"})))
(t/is (true? (text-menu/vertical-japanese-layout?
(t/is (true? (tjl/vertical-japanese-layout?
{:writing-mode "vertical-rl"})))
(t/is (= "palt"
(text-menu/proportional-metrics-feature "horizontal-tb")))
(tjl/proportional-metrics-feature "horizontal-tb")))
(t/is (= "vpal"
(text-menu/proportional-metrics-feature "vertical-rl"))))
(tjl/proportional-metrics-feature "vertical-rl"))))

View File

@ -81,6 +81,38 @@ describe('Text', () => {
expect(t.direction).toBe('rtl');
});
test('Japanese paragraph layout properties round-trip', (ctx) => {
const t = text(ctx, '日本語');
t.writingMode = 'vertical-rl';
t.textOrientation = 'upright';
expect(t.writingMode).toBe('vertical-rl');
expect(t.textOrientation).toBe('upright');
});
test('Japanese span layout properties round-trip', (ctx) => {
const t = text(ctx, '日本語');
t.textCombineUpright = 'digits2';
t.textEmphasis = 'filled-dot';
t.warichu = 'warichu';
t.fontFeatures = 'vpal';
t.annotationClearance = 'auto';
t.ruby = 'にほんご';
t.rubySize = 'third';
t.rubyAlign = 'center';
t.rubyOverhang = 'none';
t.rubySide = 'under';
expect(t.textCombineUpright).toBe('digits2');
expect(t.textEmphasis).toBe('filled-dot');
expect(t.warichu).toBe('warichu');
expect(t.fontFeatures).toBe('vpal');
expect(t.annotationClearance).toBe('auto');
expect(t.ruby).toBe('にほんご');
expect(t.rubySize).toBe('third');
expect(t.rubyAlign).toBe('center');
expect(t.rubyOverhang).toBe('none');
expect(t.rubySide).toBe('under');
});
test('font identity and variant setters accept a real font/variant', (ctx) => {
const t = text(ctx);
const font = ctx.penpot.fonts.all[0];
@ -172,6 +204,31 @@ describe('Text', () => {
expect(range.textDecoration).toBe('line-through');
});
test('range Japanese layout properties round-trip', (ctx) => {
const t = text(ctx, '日本語');
const range = t.getRange(0, 3);
range.textCombineUpright = 'all';
range.textEmphasis = 'open-circle';
range.warichu = 'warichu';
range.fontFeatures = 'palt';
range.annotationClearance = 'auto';
range.ruby = 'にほんご';
range.rubySize = 'quarter';
range.rubyAlign = 'space-between';
range.rubyOverhang = 'none';
range.rubySide = 'under';
expect(range.textCombineUpright).toBe('all');
expect(range.textEmphasis).toBe('open-circle');
expect(range.warichu).toBe('warichu');
expect(range.fontFeatures).toBe('palt');
expect(range.annotationClearance).toBe('auto');
expect(range.ruby).toBe('にほんご');
expect(range.rubySize).toBe('quarter');
expect(range.rubyAlign).toBe('space-between');
expect(range.rubyOverhang).toBe('none');
expect(range.rubySide).toBe('under');
});
test('range fills round-trip', (ctx) => {
const t = text(ctx, 'Hello Penpot');
const range = t.getRange(0, 5);

View File

@ -1,235 +0,0 @@
{
"description": "Japanese typography fixture corpus (Phase 0). Each fixture is a paragraph-level test text plus the behaviors it exercises. Used to compare rendering across editor, WASM viewport, PNG, SVG and PDF exports, and later as regression fixtures for kinsoku/vertical/ruby/TCY work.",
"fixtures": [
{
"id": "horizontal-basic",
"title": "Basic horizontal Japanese",
"text": "吾輩は猫である。名前はまだ無い。",
"exercises": ["kanji+kana rendering", "CJK punctuation 。", "font fallback"]
},
{
"id": "horizontal-mixed",
"title": "Mixed kanji/kana/Latin/numbers/symbols",
"text": "Penpot UIは2024年にWASM rendererを導入した。価格は¥1,500税込です",
"exercises": [
"quarter-em CJK↔Latin boundaries",
"one-third-em Western word spaces",
"full-width ",
"half-width digits",
"currency sign"
]
},
{
"id": "kinsoku-line-start",
"title": "Forbidden line-start characters",
"text": "これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
"exercises": ["kinsoku: 、。」not at line start", "small kana not at line start"]
},
{
"id": "kinsoku-line-end",
"title": "Forbidden line-end characters",
"text": "開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
"exercises": ["kinsoku: 「『([【 not at line end"]
},
{
"id": "prolonged-sound",
"title": "Prolonged sound mark and iteration marks",
"text": "コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
"exercises": ["ー not at line start", "iteration mark 々", "katakana runs"]
},
{
"id": "vertical-basic",
"title": "Basic vertical Japanese",
"text": "春はあけぼの。やうやう白くなりゆく山際、少し明かりて。",
"writing_mode": "vertical-rl",
"exercises": ["column flow top→bottom right→left", "vertical forms of 、。"]
},
{
"id": "vertical-punctuation",
"title": "Vertical punctuation and brackets",
"text": "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)[注釈]【重要】!?",
"writing_mode": "vertical-rl",
"exercises": ["vert/vrt2 forms of 「」『』()[]【】", "full-width upright"]
},
{
"id": "vertical-prolonged",
"title": "Prolonged sound mark in vertical",
"text": "コンピューターゲームのニュース。データベースサーバー。",
"writing_mode": "vertical-rl",
"exercises": ["ー must rotate to vertical form", "katakana column flow"]
},
{
"id": "vertical-mixed-latin",
"title": "Latin and digits inside vertical text",
"text": "私はPenpot UIを使って2024年からWASM rendererを設計しています。",
"writing_mode": "vertical-rl",
"exercises": [
"rotated Latin runs",
"quarter-em mixed orientation boundaries",
"one-third-em Western word spaces",
"digit runs (TCY candidates)"
]
},
{
"id": "tcy-candidates",
"title": "Tate-chu-yoko candidate runs",
"text": "第2章では、平成31年4月1日と12月25日。甲」20「乙、20。甲「20」乙。",
"writing_mode": "vertical-rl",
"exercises": [
"12 digit runs for TCY",
"cl-30 half-em after closing punctuation and before opening brackets",
"cl-30 solid after opening and before closing punctuation",
"explicit all and digit TCY modes"
]
},
{
"id": "ruby-simple",
"title": "Simple ruby (annotation) examples",
"text": "漢字にふりがなを振る。振り仮名は難しい。",
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "振り仮名", "ruby": "ふりがな" }
],
"exercises": ["whole-span base/annotation alignment", "line-height inflation"]
},
{
"id": "horizontal-annotation-clearance-none",
"title": "Horizontal ruby and emphasis with line-height-only clearance",
"text": "漢字\n熟語",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "none",
"exercises": ["legacy horizontal annotation collision", "explicit line-height compatibility"]
},
{
"id": "horizontal-annotation-clearance-auto",
"title": "Horizontal ruby and emphasis with automatic clearance",
"text": "漢字\n熟語",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "auto",
"exercises": ["automatic horizontal line-gap reservation", "ruby nearest and emphasis outside"]
},
{
"id": "vertical-annotation-clearance-none",
"title": "Vertical ruby and emphasis with line-height-only clearance",
"text": "漢字\n熟語",
"writing_mode": "vertical-rl",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "none",
"exercises": ["legacy vertical annotation collision", "explicit column-gap compatibility"]
},
{
"id": "vertical-annotation-clearance-auto",
"title": "Vertical ruby and emphasis with automatic clearance",
"text": "漢字\n熟語",
"writing_mode": "vertical-rl",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "auto",
"exercises": ["automatic vertical column-gap reservation", "ruby nearest and emphasis outside"]
},
{
"id": "horizontal-ruby-customization",
"title": "Horizontal ruby size, alignment, overhang, and side",
"text": "標準 三分 中央 下側",
"ruby": [
{ "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" },
{ "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" },
{ "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" },
{ "base": "下側", "ruby": "したがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" }
],
"exercises": [
"half, third, and quarter ruby sizes",
"space-around, start, center, and space-between alignment",
"automatic and prohibited overhang",
"over and under annotation sides"
]
},
{
"id": "vertical-ruby-customization",
"title": "Vertical ruby size, alignment, overhang, and side",
"text": "標準 三分 中央 左側",
"writing_mode": "vertical-rl",
"ruby": [
{ "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" },
{ "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" },
{ "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" },
{ "base": "左側", "ruby": "ひだりがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" }
],
"exercises": [
"configured ruby gutter widths",
"vertical flow-axis annotation distribution",
"over/right and under/left annotation sides",
"viewport and export parity"
]
},
{
"id": "small-kana-sokuon",
"title": "Small kana and sokuon clusters",
"text": "ちょっと待ってください。キャッシュとクッキーをチェックする。",
"exercises": ["small kana ょっゃュッ", "kinsoku: small kana not at line start"]
},
{
"id": "cjk-punct-spacing",
"title": "CJK punctuation spacing and full-width space",
"text": "これは、テスト。『「括弧」』。」「次」・「項」!? 全角スペースの後。",
"writing_mode": "vertical-rl",
"exercises": [
"preferred half-em punctuation aki",
"solid consecutive opening and closing punctuation",
"half-em closing-to-opening spacing",
"quarter-em middle-dot adjacency",
"question/exclamation and full-width space"
]
},
{
"id": "jlreq-character-classes",
"title": "Representative JLREQ character classes",
"text": "「始」〜・。、…々ーっ¥100 あア漢A㎏",
"exercises": [
"cl-01 through cl-19 scalar classification",
"cl-24 grouped numerals",
"cl-25 unit symbols",
"cl-26 Western word space",
"cl-27 Western characters"
]
},
{
"id": "han-unification",
"title": "Kanji-only text (Han unification trap)",
"text": "東京都渋谷区神南一丁目",
"exercises": ["kanji-only classifies as :chinese today → falls back to Noto Sans SC not JP", "JP-vs-CN disambiguation policy"]
},
{
"id": "long-paragraph-wrap",
"title": "Long paragraph exercising many line breaks",
"text": "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
"writing_mode": "vertical-rl",
"text_align": "justify",
"exercises": [
"many break opportunities",
"ordered oikomi before oidashi",
"priority-based justified expansion",
"kinsoku and burasage integration"
]
}
]
}

View File

@ -1453,101 +1453,16 @@ impl RenderState {
)
})
.unzip();
// CHANGEME: Extract all this code to its own function
if text_content.is_vertical() {
// Vertical writing paints through the custom vertical pass:
// drop shadows and strokes derive from the same laid-out cells
// as the fills (decorations are painted with the fill pass).
use crate::shapes::text_vertical;
let blur_filter = (!skip_effects).then(|| shape.image_filter(1.)).flatten();
// The shadow/stroke passes share one layout, computed only
// when at least one of them has something to paint (the
// fill pass lays out on its own inside text::render).
let v_bounds = text_content.bounds();
let mut drop_shadows = if skip_effects {
Vec::new()
} else {
shape.drop_shadow_paints()
};
if !skip_effects {
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
}
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
let v_layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let v_max_height =
text_vertical::wrap_height(text_content, v_bounds.height());
text_vertical::layout_from_content(text_content, v_max_height)
});
// 1. Drop shadows (silhouettes behind the fills, on the same
// fills surface so they composite under the glyphs).
if let Some(v_layout) = v_layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id);
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
shadow,
);
}
}
// 2. Fills + decorations.
text::render(
Some(self),
None,
text::render_vertical_text(
self,
&shape,
text_content,
&mut paragraph_builders,
Some(fills_surface_id),
None,
blur_filter.as_ref(),
None,
None,
fills_surface_id,
strokes_surface_id,
skip_effects,
)?;
// 3. Strokes masked to the vertical cells.
if let Some(v_layout) = v_layout.as_ref().filter(|_| !strokes.is_empty()) {
let selrect = shape.selrect();
let canvas = self.surfaces.canvas_and_mark_dirty(strokes_surface_id);
for stroke in &strokes {
text_vertical::paint_stroke(
canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter.as_ref(),
);
}
}
// 4. Developer overlay: jlreq-style character-frame grid.
if self.options.is_text_grid_visible() {
let owned_layout;
let grid_layout = match v_layout.as_ref() {
Some(layout) => layout,
None => {
let v_max_height =
text_vertical::wrap_height(text_content, v_bounds.height());
owned_layout =
text_vertical::layout_from_content(text_content, v_max_height);
&owned_layout
}
};
let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id);
text_vertical::paint_grid(
canvas,
grid_layout,
&v_bounds,
shape.vertical_align(),
);
}
} else if skip_effects {
// Fast path: render fills and strokes only (skip shadows/blur).
text::render(

View File

@ -3,8 +3,8 @@ use crate::{
error::Result,
math::Rect,
shapes::{
add_horizontal_span, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
ParagraphLayout, Stroke, StrokeKind, TextContent,
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
},
utils::{get_fallback_fonts, get_font_collection},
};
@ -15,6 +15,91 @@ use skia_safe::{
Canvas, ImageFilter, Paint,
};
pub fn render_vertical_text(
state: &mut RenderState,
shape: &Shape,
text_content: &TextContent,
paragraph_builders: &mut [ParagraphBuilderGroup],
fills_surface_id: SurfaceId,
strokes_surface_id: SurfaceId,
skip_effects: bool,
) -> Result<()> {
let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten();
let bounds = text_content.bounds();
let mut drop_shadows = if skip_effects {
Vec::new()
} else {
shape.drop_shadow_paints()
};
if !skip_effects {
if let Some(inherited_shadows) = state.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
}
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
text_vertical::layout_from_content(text_content, max_height)
});
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
canvas,
layout,
&bounds,
shape.vertical_align(),
shadow,
);
}
}
render(
Some(state),
None,
shape,
paragraph_builders,
Some(fills_surface_id),
None,
blur_filter.as_ref(),
None,
None,
)?;
if let Some(layout) = layout.as_ref().filter(|_| !strokes.is_empty()) {
let selrect = shape.selrect();
let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id);
for stroke in &strokes {
text_vertical::paint_stroke(
canvas,
layout,
&bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter.as_ref(),
);
}
}
if state.options.is_text_grid_visible() {
let owned_layout;
let grid_layout = match layout.as_ref() {
Some(layout) => layout,
None => {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
owned_layout = text_vertical::layout_from_content(text_content, max_height);
&owned_layout
}
};
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
text_vertical::paint_grid(canvas, grid_layout, &bounds, shape.vertical_align());
}
Ok(())
}
pub fn stroke_paragraph_builder_group_from_text(
text_content: &TextContent,
stroke: &Stroke,

View File

@ -133,6 +133,78 @@ fn vertical_align_offset(
}
}
fn vertical_layout_for_shape(
text_content: &TextContent,
shape: &Shape,
) -> (text_vertical::VerticalLayout, f32) {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
(layout, origin_x)
}
fn calculate_vertical_cursor_rect(
text_content: &TextContent,
shape: &Shape,
paragraph: usize,
offset: usize,
) -> Option<Rect> {
let (layout, origin_x) = vertical_layout_for_shape(text_content, shape);
let rect = text_vertical::caret_rect(&layout, paragraph, offset)?;
Some(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
))
}
fn calculate_vertical_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
let (layout, origin_x) = vertical_layout_for_shape(text_content, shape);
let mut rects = Vec::new();
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
.take(end.paragraph + 1)
.skip(start.paragraph)
{
let para_char_count: usize = paragraph
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
} else {
para_char_count
};
for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) {
rects.push(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
));
}
}
rects
}
fn calculate_cursor_rect(
editor_state: &TextEditorState,
text_content: &TextContent,
@ -144,24 +216,13 @@ fn calculate_cursor_rect(
return None;
}
// Vertical writing: selrect-local caret bar from the vertical cells.
// CHANGEME: extract to function
if text_content.is_vertical() {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
let rect = text_vertical::caret_rect(&layout, cursor.paragraph, cursor.offset)?;
// The rect height is the character extent (zero after the last
// character); the renderer picks it for overtype carets and draws a
// thin bar otherwise.
return Some(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
));
return calculate_vertical_cursor_rect(
text_content,
shape,
cursor.paragraph,
cursor.offset,
);
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
@ -268,54 +329,17 @@ fn calculate_selection_rects(
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
// Vertical writing: selrect-local selection strips from the cells.
// CHANGEME: extract to function
if text_content.is_vertical() {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
.take(end.paragraph + 1)
.skip(start.paragraph)
{
let para_char_count: usize = paragraph
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
} else {
para_char_count
};
for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) {
rects.push(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
));
}
}
return rects;
return calculate_vertical_selection_rects(selection, text_content, shape);
}
let mut rects = Vec::new();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);

View File

@ -2,7 +2,8 @@ use skia_safe::{self as skia, Canvas, Paint, RRect};
use crate::error::Result;
use crate::shapes::{
merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type,
merge_fills, radius_to_sigma, BlurType, Fill, Frame, ParagraphBuilderGroup, Rect, Shape,
Stroke, StrokeKind, TextContent, Type,
};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
@ -48,6 +49,62 @@ impl<'a> VectorRenderer<'a> {
_target: target,
}
}
fn draw_vertical_text(
&mut self,
shape: &Shape,
text_content: &TextContent,
paragraph_builders: &mut [ParagraphBuilderGroup],
blur_filter: Option<&skia::ImageFilter>,
) -> Result<()> {
use crate::shapes::text_vertical;
let bounds = text_content.bounds();
let drop_shadows = shape.drop_shadow_paints();
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
text_vertical::layout_from_content(text_content, max_height)
});
if let Some(layout) = &layout {
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
self.canvas,
layout,
&bounds,
shape.vertical_align(),
shadow,
);
}
}
text::render_overlay_emoji(
self.canvas,
shape,
paragraph_builders,
None,
blur_filter,
None,
None,
)?;
if let Some(layout) = &layout {
let selrect = shape.selrect();
for stroke in strokes {
text_vertical::paint_stroke(
self.canvas,
layout,
&bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter,
);
}
}
Ok(())
}
}
impl ShapeRenderer for VectorRenderer<'_> {
@ -154,60 +211,13 @@ impl ShapeRenderer for VectorRenderer<'_> {
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
// Vertical writing paints through the custom vertical pass: drop
// shadows, fills+decorations and strokes all derive from the same
// laid-out cells, drawn back-to-front onto the single vector canvas.
// CHANGEME: extract to function
if text_content.is_vertical() {
use crate::shapes::text_vertical;
let v_bounds = text_content.bounds();
// The shadow/stroke passes share one layout, computed only when
// at least one of them has something to paint (the fill pass
// lays out on its own inside render_overlay_emoji).
let drop_shadows = shape.drop_shadow_paints();
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
let v_layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let v_max_height = text_vertical::wrap_height(&text_content, v_bounds.height());
text_vertical::layout_from_content(&text_content, v_max_height)
});
if let Some(v_layout) = &v_layout {
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
self.canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
shadow,
);
}
}
text::render_overlay_emoji(
self.canvas,
return self.draw_vertical_text(
shape,
&text_content,
&mut paragraph_builders,
None,
blur_filter.as_ref(),
None,
None,
)?;
if let Some(v_layout) = &v_layout {
let selrect = shape.selrect();
for stroke in strokes {
text_vertical::paint_stroke(
self.canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter.as_ref(),
);
}
}
return Ok(());
);
}
// Text drop shadows: one filter layer per shadow over fill + stroke

View File

@ -30,6 +30,7 @@ mod strokes;
mod svg_attrs;
mod svgraw;
mod text;
mod text_japanese;
pub mod text_paths;
pub mod text_vertical;
mod transform;
@ -53,6 +54,7 @@ pub use strokes::*;
pub use svg_attrs::*;
pub use svgraw::*;
pub use text::*;
pub use text_japanese::*;
pub use transform::*;
use crate::math::{self, Bounds, Matrix, Point};

View File

@ -7,9 +7,7 @@ use crate::{
use core::f32;
use macros::ToJs;
use skia_safe::textlayout::{
PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline,
};
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{
self as skia,
paint::{self, Paint},
@ -20,11 +18,10 @@ use skia_safe::{
Contains,
};
// CHANGEME: move all the custom japanese layout code to its own module
use std::cell::Cell;
use std::collections::HashSet;
use super::text_japanese::*;
use super::FontFamily;
use crate::math::Point;
use crate::shapes::{self, kinsoku, merge_fills, Shape, VerticalAlign};
@ -34,539 +31,6 @@ use crate::Uuid;
// TODO: maybe move this to the wasm module?
pub type ParagraphBuilderGroup = Vec<ParagraphBuilder>;
pub const WARICHU_FONT_SCALE: f32 = 0.5;
pub const EMPHASIS_FONT_SCALE: f32 = 0.5;
const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3;
const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}';
const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}';
/// Add a span to a horizontal paragraph builder. Warichu is represented by a
/// single inline placeholder so the two annotation lines wrap as one unit.
/// The actual glyphs are painted after SkParagraph has positioned the box.
pub(crate) fn add_horizontal_span(
builder: &mut ParagraphBuilder,
span: &TextSpan,
builder_text: &str,
text_style: &skia::textlayout::TextStyle,
fonts: &skia::textlayout::FontCollection,
) {
if span.warichu && span.text.chars().count() >= 2 {
let text = span.apply_text_transform();
let split = super::text_vertical::warichu_split_chars(&text);
let split_byte = text
.char_indices()
.nth(split)
.map(|(index, _)| index)
.unwrap_or(text.len());
let (first, second) = text.split_at(split_byte);
let mut mini_style = text_style.clone();
mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
mini_style.set_height(1.0);
mini_style.set_height_override(true);
mini_style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
let measure = |line: &str| {
let mut mini = ParagraphBuilder::new(&ParagraphStyle::default(), fonts);
mini.push_style(&mini_style);
mini.add_text(line);
let mut paragraph = mini.build();
paragraph.layout(f32::MAX);
paragraph.longest_line()
};
let width = measure(first).max(measure(second)).max(0.01);
let height = span.font_size.max(0.01);
builder.add_placeholder(&PlaceholderStyle::new(
width,
height,
PlaceholderAlignment::Middle,
TextBaseline::Alphabetic,
height,
));
// SkParagraph does not expose a placeholder's TextStyle through line
// metrics. A near-zero, inkless NBSP preserves the exact
// fill/stroke/shadow style for the custom paint pass; the following
// zero-width space restores a legal wrapping boundary after the
// atomic placeholder.
let mut anchor_style = text_style.clone();
anchor_style.set_font_size(0.01);
anchor_style.set_height(0.01);
anchor_style.set_height_override(true);
anchor_style.set_letter_spacing(0.0);
builder.push_style(&anchor_style);
builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string());
builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string());
} else {
builder.add_text(builder_text);
}
}
#[derive(Debug, Clone)]
pub(crate) struct HorizontalSpanRange {
pub span: usize,
pub builder_start: usize,
pub builder_end: usize,
pub shifted_start: usize,
pub source_start: usize,
pub source_end: usize,
pub warichu: bool,
pub style_anchor_start: usize,
}
/// Ranges shared by layout, position-data and editor mapping. `shifted_*`
/// addresses the normal kinsoku-adjusted paragraph text, while `builder_*`
/// addresses the paragraph where a whole warichu span occupies one U+FFFC.
pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpanRange> {
let (span_texts, offset_map) = paragraph.layout_span_texts();
let mut builder_cursor = 0usize;
let mut builder_byte_cursor = 0usize;
let mut shifted_cursor = 0usize;
paragraph
.children()
.iter()
.zip(span_texts)
.enumerate()
.map(|(span_index, (span, text))| {
let shifted_start = shifted_cursor;
shifted_cursor += text.encode_utf16().count();
let shifted_end = shifted_cursor;
let source_start = offset_map.to_original(shifted_start);
let source_end = offset_map.to_original(shifted_end);
let warichu = span.warichu && span.text.chars().count() >= 2;
let builder_start = builder_cursor;
let style_anchor_start = if warichu {
builder_byte_cursor + '\u{FFFC}'.len_utf8()
} else {
builder_byte_cursor
};
builder_cursor += if warichu {
HORIZONTAL_WARICHU_BUILDER_LEN
} else {
shifted_end - shifted_start
};
builder_byte_cursor += if warichu {
'\u{FFFC}'.len_utf8()
+ HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8()
+ HORIZONTAL_WARICHU_BREAK_ANCHOR.len_utf8()
} else {
text.len()
};
HorizontalSpanRange {
span: span_index,
builder_start,
builder_end: builder_cursor,
shifted_start,
source_start,
source_end,
warichu,
style_anchor_start,
}
})
.collect()
}
fn source_char_boundaries(paragraph: &Paragraph) -> Vec<usize> {
let mut boundaries = vec![0usize];
for span in paragraph.children() {
for character in span.text.chars() {
boundaries.push(boundaries.last().copied().unwrap_or(0) + character.len_utf16());
}
}
boundaries
}
pub(crate) fn horizontal_source_to_builder(
paragraph: &Paragraph,
source_char_offset: usize,
) -> usize {
let boundaries = source_char_boundaries(paragraph);
let source_utf16 = boundaries
.get(source_char_offset)
.copied()
.unwrap_or_else(|| boundaries.last().copied().unwrap_or(0));
let (_, offset_map) = paragraph.layout_span_texts();
let ranges = horizontal_span_ranges(paragraph);
let Some(range) = ranges
.iter()
.find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end)
else {
return ranges.last().map(|range| range.builder_end).unwrap_or(0);
};
if range.warichu {
return if source_utf16 >= range.source_end {
range.builder_end
} else {
range.builder_start
};
}
let shifted = offset_map.to_shifted(source_utf16);
range.builder_start + shifted.saturating_sub(range.shifted_start)
}
pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize {
let (_, offset_map) = paragraph.layout_span_texts();
let ranges = horizontal_span_ranges(paragraph);
let source_utf16 = ranges
.iter()
.find(|range| builder_offset >= range.builder_start && builder_offset <= range.builder_end)
.map(|range| {
if range.warichu {
if builder_offset > range.builder_start {
range.source_end
} else {
range.source_start
}
} else {
let within = builder_offset
.saturating_sub(range.builder_start)
.min(range.builder_end - range.builder_start);
offset_map.to_original(range.shifted_start + within)
}
})
.unwrap_or_else(|| ranges.last().map(|range| range.source_end).unwrap_or(0));
let boundaries = source_char_boundaries(paragraph);
boundaries
.partition_point(|boundary| *boundary < source_utf16)
.min(boundaries.len().saturating_sub(1))
}
fn horizontal_warichu_boxes<'a>(
paragraph: &'a Paragraph,
laid_out: &skia::textlayout::Paragraph,
) -> Vec<(&'a TextSpan, usize, usize, skia::Rect)> {
let mut source_start = 0usize;
let mut placeholders = laid_out.get_rects_for_placeholders().into_iter();
let mut boxes = Vec::new();
for span in paragraph.children() {
let length = span.text.chars().count();
if span.warichu && length >= 2 {
if let Some(textbox) = placeholders.next() {
boxes.push((span, source_start, source_start + length, textbox.rect));
}
}
source_start += length;
}
boxes
}
pub(crate) fn horizontal_warichu_hit_test(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
point: Point,
) -> Option<usize> {
for (span, source_start, _, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
if !rect.contains(&point) {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let total = span.text.chars().count();
let second = point.y >= rect.top() + rect.height() / 2.0;
let (line_start, line_len) = if second {
(split, total - split)
} else {
(0, split)
};
let fraction = ((point.x - rect.left()) / rect.width().max(0.01)).clamp(0.0, 1.0);
let within = (fraction * line_len as f32).round() as usize;
return Some(source_start + line_start + within.min(line_len));
}
None
}
pub(crate) fn horizontal_warichu_caret_rect(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
source_offset: usize,
) -> Option<skia::Rect> {
for (span, source_start, source_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
if source_offset < source_start || source_offset > source_end {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let local = source_offset - source_start;
let total = source_end - source_start;
let (line_start, line_len, top) = if local >= split {
(split, total - split, rect.top() + rect.height() / 2.0)
} else {
(0, split, rect.top())
};
let within = local.saturating_sub(line_start).min(line_len);
let char_width = rect.width() / line_len.max(1) as f32;
return Some(skia::Rect::from_xywh(
rect.left() + within as f32 * char_width,
top,
char_width,
rect.height() / 2.0,
));
}
None
}
pub(crate) fn horizontal_warichu_range_rects(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
source_start: usize,
source_end: usize,
) -> Vec<skia::Rect> {
let mut rects = Vec::new();
for (span, span_start, span_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
let selected_start = source_start.max(span_start);
let selected_end = source_end.min(span_end);
if selected_start >= selected_end {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
for (line_start, line_end, top) in [
(span_start, span_start + split, rect.top()),
(
span_start + split,
span_end,
rect.top() + rect.height() / 2.0,
),
] {
let start = selected_start.max(line_start);
let end = selected_end.min(line_end);
if start >= end {
continue;
}
let line_len = line_end - line_start;
let char_width = rect.width() / line_len.max(1) as f32;
rects.push(skia::Rect::from_xywh(
rect.left() + (start - line_start) as f32 * char_width,
top,
(end - start) as f32 * char_width,
rect.height() / 2.0,
));
}
}
rects
}
pub(crate) fn horizontal_normal_selection_ranges(
paragraph: &Paragraph,
source_start: usize,
source_end: usize,
) -> Vec<std::ops::Range<usize>> {
let mut span_start = 0usize;
paragraph
.children()
.iter()
.filter_map(|span| {
let span_end = span_start + span.text.chars().count();
let selected_start = source_start.max(span_start);
let selected_end = source_end.min(span_end);
let warichu = span.warichu && span.text.chars().count() >= 2;
span_start = span_end;
if warichu || selected_start >= selected_end {
return None;
}
Some(
horizontal_source_to_builder(paragraph, selected_start)
..horizontal_source_to_builder(paragraph, selected_end),
)
})
.collect()
}
fn warichu_text_lines(text: &str) -> (&str, &str) {
let split = super::text_vertical::warichu_split_chars(text);
let split_byte = text
.char_indices()
.nth(split)
.map(|(index, _)| index)
.unwrap_or(text.len());
text.split_at(split_byte)
}
fn warichu_mini_paragraph(
text: &str,
style: &skia::textlayout::TextStyle,
width: f32,
) -> skia::textlayout::Paragraph {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), get_font_collection());
builder.push_style(style);
builder.add_text(text);
let mut paragraph = builder.build();
paragraph.layout(width.max(0.01));
paragraph
}
/// Paint the two horizontal warichu sub-lines into SkParagraph's inline
/// placeholder boxes. Reading order is top line then bottom line.
pub(crate) fn paint_horizontal_warichu(
canvas: &skia::Canvas,
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
x: f32,
y: f32,
) {
let ranges = horizontal_span_ranges(paragraph);
let placeholders = laid_out.get_rects_for_placeholders();
let warichu_ranges: Vec<_> = ranges.iter().filter(|range| range.warichu).collect();
if placeholders.len() != warichu_ranges.len() {
return;
}
for (range, textbox) in warichu_ranges.into_iter().zip(placeholders) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
// Indexed style metrics expose builder UTF-8 byte positions even
// though glyph/line ranges use UTF-16 offsets.
let style_anchor = range.style_anchor_start
..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8();
let style = laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(style_anchor.clone())
.into_iter()
.next()
.map(|(_, metric)| metric.text_style.clone())
});
let Some(mut style) = style else {
continue;
};
style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
let transformed = span.apply_text_transform();
let (first, second) = warichu_text_lines(&transformed);
let rect = textbox.rect;
let first_para = warichu_mini_paragraph(first, &style, rect.width());
let second_para = warichu_mini_paragraph(second, &style, rect.width());
let half_height = rect.height() / 2.0;
first_para.paint(canvas, (x + rect.left(), y + rect.top()));
second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height));
}
}
pub(crate) fn emphasis_char_allowed(character: char) -> bool {
!character.is_whitespace()
&& !crate::shapes::japanese::classify(character).is_emphasis_prohibited()
}
#[derive(Debug, Clone, Copy)]
struct HorizontalEmphasisPlacement {
span: usize,
mark: char,
rect: skia::Rect,
}
/// Locate one horizontal emphasis mark above each eligible source character.
/// SkParagraph owns wrapping and bidi placement; querying each transformed
/// character range keeps the marks attached to the actual laid-out glyphs.
fn horizontal_emphasis_placements(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
) -> Vec<HorizontalEmphasisPlacement> {
let (_, offset_map) = paragraph.layout_span_texts();
let ranges = horizontal_span_ranges(paragraph);
let mut placements = Vec::new();
for range in ranges.iter().filter(|range| !range.warichu) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
let Some(mark) = span.text_emphasis.mark_char() else {
continue;
};
let transformed = span.apply_text_transform();
let mut local_utf16 = 0usize;
for character in transformed.chars() {
let next_utf16 = local_utf16 + character.len_utf16();
if emphasis_char_allowed(character) {
let shifted_start = offset_map.to_shifted(range.source_start + local_utf16);
let shifted_end = offset_map.to_shifted(range.source_start + next_utf16);
let builder_start =
range.builder_start + shifted_start.saturating_sub(range.shifted_start);
let builder_end =
range.builder_start + shifted_end.saturating_sub(range.shifted_start);
let scalar_rect = laid_out
.get_rects_for_range(
builder_start..builder_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
)
.into_iter()
.map(|textbox| textbox.rect)
.reduce(|mut rect, next| {
rect.join(next);
rect
});
if let Some(rect) = scalar_rect {
placements.push(HorizontalEmphasisPlacement {
span: range.span,
mark,
rect,
});
}
}
local_utf16 = next_utf16;
}
}
placements
}
fn horizontal_span_style(
laid_out: &skia::textlayout::Paragraph,
range: &HorizontalSpanRange,
) -> Option<skia::textlayout::TextStyle> {
// Indexed style metrics use builder UTF-8 byte positions (the same
// convention used by the warichu style anchor above).
let anchor = range.style_anchor_start..range.style_anchor_start + 1;
laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(anchor.clone())
.into_iter()
.next()
.map(|(_, metric)| metric.text_style.clone())
})
}
/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs.
/// The base paragraph retains its normal metrics; interlinear collision and
/// automatic line-gap expansion remain a separate layout policy.
pub(crate) fn paint_horizontal_emphasis(
canvas: &skia::Canvas,
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
x: f32,
y: f32,
) {
let ranges = horizontal_span_ranges(paragraph);
let placements = horizontal_emphasis_placements(paragraph, laid_out);
for range in ranges.iter().filter(|range| !range.warichu) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
let Some(mark) = span.text_emphasis.mark_char() else {
continue;
};
let Some(mut style) = horizontal_span_style(laid_out, range) else {
continue;
};
style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE);
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(0.0);
let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX);
let mark_width = mark_paragraph.longest_line();
let mark_height = mark_paragraph.height();
for placement in placements
.iter()
.filter(|placement| placement.span == range.span && placement.mark == mark)
{
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
let ruby_offset = if span.annotation_clearance.is_auto()
&& !span.ruby.trim().is_empty()
&& span.ruby_side == RubySide::Over
{
span.font_size * span.ruby_size.scale()
} else {
0.0
};
let mark_y = y + placement.rect.top() - mark_height - ruby_offset;
mark_paragraph.paint(canvas, (mark_x, mark_y));
}
}
}
#[repr(u8)]
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
pub enum GrowType {
@ -1720,153 +1184,6 @@ pub type TextAlign = skia::textlayout::TextAlign;
pub type TextDirection = skia::textlayout::TextDirection;
pub type TextDecoration = skia::textlayout::TextDecoration;
/// Block flow direction of a paragraph. Horizontal is the skparagraph
/// path; vertical-rl lays out columns top->bottom advancing right->left
/// through the custom vertical pass.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum WritingMode {
#[default]
HorizontalTb,
VerticalRl,
}
/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs
/// sideways, `Upright` keeps every character upright. Ignored in
/// horizontal writing.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextOrientation {
#[default]
Mixed,
Upright,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextCombineUpright {
#[default]
None,
All,
/// Combine runs of 2-4 consecutive ASCII or full-width digits into one upright
/// composite; other characters keep the normal vertical layout.
Digits,
/// Like `Digits` but only runs of exactly 2 digits combine
/// (CSS `text-combine-upright: digits 2`).
Digits2,
/// Like `Digits` but runs of 2-3 digits combine.
Digits3,
}
impl TextCombineUpright {
/// Longest digit run that combines, when digits mode is active.
pub fn digits_max(self) -> Option<usize> {
match self {
TextCombineUpright::Digits => Some(4),
TextCombineUpright::Digits2 => Some(2),
TextCombineUpright::Digits3 => Some(3),
_ => None,
}
}
}
/// 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.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextEmphasis {
#[default]
None,
FilledDot,
OpenDot,
FilledCircle,
OpenCircle,
FilledSesame,
OpenSesame,
}
impl TextEmphasis {
pub fn is_none(self) -> bool {
matches!(self, TextEmphasis::None)
}
/// The glyph drawn as the emphasis mark, following the CSS
/// `text-emphasis-style` character mapping.
pub fn mark_char(self) -> Option<char> {
match self {
TextEmphasis::None => None,
TextEmphasis::FilledDot => Some('•'),
TextEmphasis::OpenDot => Some('◦'),
TextEmphasis::FilledCircle => Some('●'),
TextEmphasis::OpenCircle => Some('○'),
TextEmphasis::FilledSesame => Some('﹅'),
TextEmphasis::OpenSesame => Some('﹆'),
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum FontFeatures {
#[default]
None,
Palt,
Vpal,
}
/// Controls whether annotation layers participate in line/column spacing.
/// The default preserves legacy documents; `Auto` reserves one half-em for
/// each active ruby or emphasis layer.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum AnnotationClearance {
#[default]
None,
Auto,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubySize {
#[default]
Half,
Third,
Quarter,
}
impl RubySize {
pub fn scale(self) -> f32 {
match self {
Self::Half => 0.5,
Self::Third => 1.0 / 3.0,
Self::Quarter => 0.25,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubyAlign {
#[default]
SpaceAround,
Center,
Start,
SpaceBetween,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubyOverhang {
#[default]
Auto,
None,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubySide {
#[default]
Over,
Under,
}
impl AnnotationClearance {
pub fn is_auto(self) -> bool {
matches!(self, AnnotationClearance::Auto)
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum TextTransform {
Lowercase,
@ -1984,27 +1301,7 @@ impl Paragraph {
/// transform is skipped under letter-spacing, where skparagraph would add
/// letter spacing to synthetic layout characters.
pub fn layout_span_texts(&self) -> (Vec<String>, kinsoku::OffsetMap) {
let texts: Vec<String> = self
.children
.iter()
.map(|s| s.apply_text_transform())
.collect();
let has_letter_spacing =
self.letter_spacing != 0.0 || self.children.iter().any(|s| s.letter_spacing != 0.0);
if !has_letter_spacing {
let ruby_breaks: Vec<Option<Vec<usize>>> = self
.children
.iter()
.zip(&texts)
.map(|(span, _text)| (!span.ruby.trim().is_empty()).then(Vec::new))
.collect();
if let Some((shifted, map)) =
kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks)
{
return (shifted, map);
}
}
(texts, kinsoku::OffsetMap::default())
layout_span_texts(self)
}
pub fn paragraph_to_style(&self) -> ParagraphStyle {

View File

@ -0,0 +1,718 @@
use super::text::{Paragraph, TextSpan};
use crate::math::Point;
use crate::shapes::kinsoku;
use crate::utils::get_font_collection;
use skia_safe::{
self as skia,
textlayout::{
ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle,
RectWidthStyle, TextBaseline,
},
Contains,
};
pub const WARICHU_FONT_SCALE: f32 = 0.5;
pub const EMPHASIS_FONT_SCALE: f32 = 0.5;
const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3;
const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}';
const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}';
pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec<String>, kinsoku::OffsetMap) {
let texts: Vec<String> = paragraph
.children()
.iter()
.map(TextSpan::apply_text_transform)
.collect();
let has_letter_spacing = paragraph.letter_spacing() != 0.0
|| paragraph
.children()
.iter()
.any(|span| span.letter_spacing != 0.0);
if !has_letter_spacing {
let ruby_breaks: Vec<Option<Vec<usize>>> = paragraph
.children()
.iter()
.map(|span| (!span.ruby.trim().is_empty()).then(Vec::new))
.collect();
if let Some((shifted, map)) =
kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks)
{
return (shifted, map);
}
}
(texts, kinsoku::OffsetMap::default())
}
/// Add a span to a horizontal paragraph builder. Warichu is represented by a
/// single inline placeholder so the two annotation lines wrap as one unit.
/// The actual glyphs are painted after SkParagraph has positioned the box.
pub(crate) fn add_horizontal_span(
builder: &mut ParagraphBuilder,
span: &TextSpan,
builder_text: &str,
text_style: &skia::textlayout::TextStyle,
fonts: &skia::textlayout::FontCollection,
) {
if span.warichu && span.text.chars().count() >= 2 {
let text = span.apply_text_transform();
let split = super::text_vertical::warichu_split_chars(&text);
let split_byte = text
.char_indices()
.nth(split)
.map(|(index, _)| index)
.unwrap_or(text.len());
let (first, second) = text.split_at(split_byte);
let mut mini_style = text_style.clone();
mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
mini_style.set_height(1.0);
mini_style.set_height_override(true);
mini_style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
let measure = |line: &str| {
let mut mini = ParagraphBuilder::new(&ParagraphStyle::default(), fonts);
mini.push_style(&mini_style);
mini.add_text(line);
let mut paragraph = mini.build();
paragraph.layout(f32::MAX);
paragraph.longest_line()
};
let width = measure(first).max(measure(second)).max(0.01);
let height = span.font_size.max(0.01);
builder.add_placeholder(&PlaceholderStyle::new(
width,
height,
PlaceholderAlignment::Middle,
TextBaseline::Alphabetic,
height,
));
// SkParagraph does not expose a placeholder's TextStyle through line
// metrics. A near-zero, inkless NBSP preserves the exact
// fill/stroke/shadow style for the custom paint pass; the following
// zero-width space restores a legal wrapping boundary after the
// atomic placeholder.
let mut anchor_style = text_style.clone();
anchor_style.set_font_size(0.01);
anchor_style.set_height(0.01);
anchor_style.set_height_override(true);
anchor_style.set_letter_spacing(0.0);
builder.push_style(&anchor_style);
builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string());
builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string());
} else {
builder.add_text(builder_text);
}
}
#[derive(Debug, Clone)]
pub(crate) struct HorizontalSpanRange {
pub span: usize,
pub builder_start: usize,
pub builder_end: usize,
pub shifted_start: usize,
pub source_start: usize,
pub source_end: usize,
pub warichu: bool,
pub style_anchor_start: usize,
}
/// Ranges shared by layout, position-data and editor mapping. `shifted_*`
/// addresses the normal kinsoku-adjusted paragraph text, while `builder_*`
/// addresses the paragraph where a whole warichu span occupies one U+FFFC.
pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpanRange> {
let (span_texts, offset_map) = paragraph.layout_span_texts();
let mut builder_cursor = 0usize;
let mut builder_byte_cursor = 0usize;
let mut shifted_cursor = 0usize;
paragraph
.children()
.iter()
.zip(span_texts)
.enumerate()
.map(|(span_index, (span, text))| {
let shifted_start = shifted_cursor;
shifted_cursor += text.encode_utf16().count();
let shifted_end = shifted_cursor;
let source_start = offset_map.to_original(shifted_start);
let source_end = offset_map.to_original(shifted_end);
let warichu = span.warichu && span.text.chars().count() >= 2;
let builder_start = builder_cursor;
let style_anchor_start = if warichu {
builder_byte_cursor + '\u{FFFC}'.len_utf8()
} else {
builder_byte_cursor
};
builder_cursor += if warichu {
HORIZONTAL_WARICHU_BUILDER_LEN
} else {
shifted_end - shifted_start
};
builder_byte_cursor += if warichu {
'\u{FFFC}'.len_utf8()
+ HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8()
+ HORIZONTAL_WARICHU_BREAK_ANCHOR.len_utf8()
} else {
text.len()
};
HorizontalSpanRange {
span: span_index,
builder_start,
builder_end: builder_cursor,
shifted_start,
source_start,
source_end,
warichu,
style_anchor_start,
}
})
.collect()
}
fn source_char_boundaries(paragraph: &Paragraph) -> Vec<usize> {
let mut boundaries = vec![0usize];
for span in paragraph.children() {
for character in span.text.chars() {
boundaries.push(boundaries.last().copied().unwrap_or(0) + character.len_utf16());
}
}
boundaries
}
pub(crate) fn horizontal_source_to_builder(
paragraph: &Paragraph,
source_char_offset: usize,
) -> usize {
let boundaries = source_char_boundaries(paragraph);
let source_utf16 = boundaries
.get(source_char_offset)
.copied()
.unwrap_or_else(|| boundaries.last().copied().unwrap_or(0));
let (_, offset_map) = paragraph.layout_span_texts();
let ranges = horizontal_span_ranges(paragraph);
let Some(range) = ranges
.iter()
.find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end)
else {
return ranges.last().map(|range| range.builder_end).unwrap_or(0);
};
if range.warichu {
return if source_utf16 >= range.source_end {
range.builder_end
} else {
range.builder_start
};
}
let shifted = offset_map.to_shifted(source_utf16);
range.builder_start + shifted.saturating_sub(range.shifted_start)
}
pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize {
let (_, offset_map) = paragraph.layout_span_texts();
let ranges = horizontal_span_ranges(paragraph);
let source_utf16 = ranges
.iter()
.find(|range| builder_offset >= range.builder_start && builder_offset <= range.builder_end)
.map(|range| {
if range.warichu {
if builder_offset > range.builder_start {
range.source_end
} else {
range.source_start
}
} else {
let within = builder_offset
.saturating_sub(range.builder_start)
.min(range.builder_end - range.builder_start);
offset_map.to_original(range.shifted_start + within)
}
})
.unwrap_or_else(|| ranges.last().map(|range| range.source_end).unwrap_or(0));
let boundaries = source_char_boundaries(paragraph);
boundaries
.partition_point(|boundary| *boundary < source_utf16)
.min(boundaries.len().saturating_sub(1))
}
fn horizontal_warichu_boxes<'a>(
paragraph: &'a Paragraph,
laid_out: &skia::textlayout::Paragraph,
) -> Vec<(&'a TextSpan, usize, usize, skia::Rect)> {
let mut source_start = 0usize;
let mut placeholders = laid_out.get_rects_for_placeholders().into_iter();
let mut boxes = Vec::new();
for span in paragraph.children() {
let length = span.text.chars().count();
if span.warichu && length >= 2 {
if let Some(textbox) = placeholders.next() {
boxes.push((span, source_start, source_start + length, textbox.rect));
}
}
source_start += length;
}
boxes
}
pub(crate) fn horizontal_warichu_hit_test(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
point: Point,
) -> Option<usize> {
for (span, source_start, _, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
if !rect.contains(&point) {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let total = span.text.chars().count();
let second = point.y >= rect.top() + rect.height() / 2.0;
let (line_start, line_len) = if second {
(split, total - split)
} else {
(0, split)
};
let fraction = ((point.x - rect.left()) / rect.width().max(0.01)).clamp(0.0, 1.0);
let within = (fraction * line_len as f32).round() as usize;
return Some(source_start + line_start + within.min(line_len));
}
None
}
pub(crate) fn horizontal_warichu_caret_rect(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
source_offset: usize,
) -> Option<skia::Rect> {
for (span, source_start, source_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
if source_offset < source_start || source_offset > source_end {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let local = source_offset - source_start;
let total = source_end - source_start;
let (line_start, line_len, top) = if local >= split {
(split, total - split, rect.top() + rect.height() / 2.0)
} else {
(0, split, rect.top())
};
let within = local.saturating_sub(line_start).min(line_len);
let char_width = rect.width() / line_len.max(1) as f32;
return Some(skia::Rect::from_xywh(
rect.left() + within as f32 * char_width,
top,
char_width,
rect.height() / 2.0,
));
}
None
}
pub(crate) fn horizontal_warichu_range_rects(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
source_start: usize,
source_end: usize,
) -> Vec<skia::Rect> {
let mut rects = Vec::new();
for (span, span_start, span_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
let selected_start = source_start.max(span_start);
let selected_end = source_end.min(span_end);
if selected_start >= selected_end {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
for (line_start, line_end, top) in [
(span_start, span_start + split, rect.top()),
(
span_start + split,
span_end,
rect.top() + rect.height() / 2.0,
),
] {
let start = selected_start.max(line_start);
let end = selected_end.min(line_end);
if start >= end {
continue;
}
let line_len = line_end - line_start;
let char_width = rect.width() / line_len.max(1) as f32;
rects.push(skia::Rect::from_xywh(
rect.left() + (start - line_start) as f32 * char_width,
top,
(end - start) as f32 * char_width,
rect.height() / 2.0,
));
}
}
rects
}
pub(crate) fn horizontal_normal_selection_ranges(
paragraph: &Paragraph,
source_start: usize,
source_end: usize,
) -> Vec<std::ops::Range<usize>> {
let mut span_start = 0usize;
paragraph
.children()
.iter()
.filter_map(|span| {
let span_end = span_start + span.text.chars().count();
let selected_start = source_start.max(span_start);
let selected_end = source_end.min(span_end);
let warichu = span.warichu && span.text.chars().count() >= 2;
span_start = span_end;
if warichu || selected_start >= selected_end {
return None;
}
Some(
horizontal_source_to_builder(paragraph, selected_start)
..horizontal_source_to_builder(paragraph, selected_end),
)
})
.collect()
}
fn warichu_text_lines(text: &str) -> (&str, &str) {
let split = super::text_vertical::warichu_split_chars(text);
let split_byte = text
.char_indices()
.nth(split)
.map(|(index, _)| index)
.unwrap_or(text.len());
text.split_at(split_byte)
}
fn warichu_mini_paragraph(
text: &str,
style: &skia::textlayout::TextStyle,
width: f32,
) -> skia::textlayout::Paragraph {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), get_font_collection());
builder.push_style(style);
builder.add_text(text);
let mut paragraph = builder.build();
paragraph.layout(width.max(0.01));
paragraph
}
/// Paint the two horizontal warichu sub-lines into SkParagraph's inline
/// placeholder boxes. Reading order is top line then bottom line.
pub(crate) fn paint_horizontal_warichu(
canvas: &skia::Canvas,
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
x: f32,
y: f32,
) {
let ranges = horizontal_span_ranges(paragraph);
let placeholders = laid_out.get_rects_for_placeholders();
let warichu_ranges: Vec<_> = ranges.iter().filter(|range| range.warichu).collect();
if placeholders.len() != warichu_ranges.len() {
return;
}
for (range, textbox) in warichu_ranges.into_iter().zip(placeholders) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
// Indexed style metrics expose builder UTF-8 byte positions even
// though glyph/line ranges use UTF-16 offsets.
let style_anchor = range.style_anchor_start
..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8();
let style = laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(style_anchor.clone())
.into_iter()
.next()
.map(|(_, metric)| metric.text_style.clone())
});
let Some(mut style) = style else {
continue;
};
style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
let transformed = span.apply_text_transform();
let (first, second) = warichu_text_lines(&transformed);
let rect = textbox.rect;
let first_para = warichu_mini_paragraph(first, &style, rect.width());
let second_para = warichu_mini_paragraph(second, &style, rect.width());
let half_height = rect.height() / 2.0;
first_para.paint(canvas, (x + rect.left(), y + rect.top()));
second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height));
}
}
pub(crate) fn emphasis_char_allowed(character: char) -> bool {
!character.is_whitespace()
&& !crate::shapes::japanese::classify(character).is_emphasis_prohibited()
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HorizontalEmphasisPlacement {
pub(crate) span: usize,
pub(crate) mark: char,
pub(crate) rect: skia::Rect,
}
/// Locate one horizontal emphasis mark above each eligible source character.
/// SkParagraph owns wrapping and bidi placement; querying each transformed
/// character range keeps the marks attached to the actual laid-out glyphs.
pub(crate) fn horizontal_emphasis_placements(
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
) -> Vec<HorizontalEmphasisPlacement> {
let (_, offset_map) = paragraph.layout_span_texts();
let ranges = horizontal_span_ranges(paragraph);
let mut placements = Vec::new();
for range in ranges.iter().filter(|range| !range.warichu) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
let Some(mark) = span.text_emphasis.mark_char() else {
continue;
};
let transformed = span.apply_text_transform();
let mut local_utf16 = 0usize;
for character in transformed.chars() {
let next_utf16 = local_utf16 + character.len_utf16();
if emphasis_char_allowed(character) {
let shifted_start = offset_map.to_shifted(range.source_start + local_utf16);
let shifted_end = offset_map.to_shifted(range.source_start + next_utf16);
let builder_start =
range.builder_start + shifted_start.saturating_sub(range.shifted_start);
let builder_end =
range.builder_start + shifted_end.saturating_sub(range.shifted_start);
let scalar_rect = laid_out
.get_rects_for_range(
builder_start..builder_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
)
.into_iter()
.map(|textbox| textbox.rect)
.reduce(|mut rect, next| {
rect.join(next);
rect
});
if let Some(rect) = scalar_rect {
placements.push(HorizontalEmphasisPlacement {
span: range.span,
mark,
rect,
});
}
}
local_utf16 = next_utf16;
}
}
placements
}
pub(crate) fn horizontal_span_style(
laid_out: &skia::textlayout::Paragraph,
range: &HorizontalSpanRange,
) -> Option<skia::textlayout::TextStyle> {
// Indexed style metrics use builder UTF-8 byte positions (the same
// convention used by the warichu style anchor above).
let anchor = range.style_anchor_start..range.style_anchor_start + 1;
laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(anchor.clone())
.into_iter()
.next()
.map(|(_, metric)| metric.text_style.clone())
})
}
/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs.
/// The base paragraph retains its normal metrics; interlinear collision and
/// automatic line-gap expansion remain a separate layout policy.
pub(crate) fn paint_horizontal_emphasis(
canvas: &skia::Canvas,
paragraph: &Paragraph,
laid_out: &skia::textlayout::Paragraph,
x: f32,
y: f32,
) {
let ranges = horizontal_span_ranges(paragraph);
let placements = horizontal_emphasis_placements(paragraph, laid_out);
for range in ranges.iter().filter(|range| !range.warichu) {
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
let Some(mark) = span.text_emphasis.mark_char() else {
continue;
};
let Some(mut style) = horizontal_span_style(laid_out, range) else {
continue;
};
style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE);
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(0.0);
let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX);
let mark_width = mark_paragraph.longest_line();
let mark_height = mark_paragraph.height();
for placement in placements
.iter()
.filter(|placement| placement.span == range.span && placement.mark == mark)
{
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
let ruby_offset = if span.annotation_clearance.is_auto()
&& !span.ruby.trim().is_empty()
&& span.ruby_side == RubySide::Over
{
span.font_size * span.ruby_size.scale()
} else {
0.0
};
let mark_y = y + placement.rect.top() - mark_height - ruby_offset;
mark_paragraph.paint(canvas, (mark_x, mark_y));
}
}
}
/// Block flow direction of a paragraph. Horizontal is the skparagraph
/// path; vertical-rl lays out columns top->bottom advancing right->left
/// through the custom vertical pass.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum WritingMode {
#[default]
HorizontalTb,
VerticalRl,
}
/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs
/// sideways, `Upright` keeps every character upright. Ignored in
/// horizontal writing.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextOrientation {
#[default]
Mixed,
Upright,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextCombineUpright {
#[default]
None,
All,
/// Combine runs of 2-4 consecutive ASCII or full-width digits into one upright
/// composite; other characters keep the normal vertical layout.
Digits,
/// Like `Digits` but only runs of exactly 2 digits combine
/// (CSS `text-combine-upright: digits 2`).
Digits2,
/// Like `Digits` but runs of 2-3 digits combine.
Digits3,
}
impl TextCombineUpright {
/// Longest digit run that combines, when digits mode is active.
pub fn digits_max(self) -> Option<usize> {
match self {
TextCombineUpright::Digits => Some(4),
TextCombineUpright::Digits2 => Some(2),
TextCombineUpright::Digits3 => Some(3),
_ => None,
}
}
}
/// 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.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum TextEmphasis {
#[default]
None,
FilledDot,
OpenDot,
FilledCircle,
OpenCircle,
FilledSesame,
OpenSesame,
}
impl TextEmphasis {
pub fn is_none(self) -> bool {
matches!(self, TextEmphasis::None)
}
/// The glyph drawn as the emphasis mark, following the CSS
/// `text-emphasis-style` character mapping.
pub fn mark_char(self) -> Option<char> {
match self {
TextEmphasis::None => None,
TextEmphasis::FilledDot => Some('•'),
TextEmphasis::OpenDot => Some('◦'),
TextEmphasis::FilledCircle => Some('●'),
TextEmphasis::OpenCircle => Some('○'),
TextEmphasis::FilledSesame => Some('﹅'),
TextEmphasis::OpenSesame => Some('﹆'),
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum FontFeatures {
#[default]
None,
Palt,
Vpal,
}
/// Controls whether annotation layers participate in line/column spacing.
/// The default preserves legacy documents; `Auto` reserves one half-em for
/// each active ruby or emphasis layer.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum AnnotationClearance {
#[default]
None,
Auto,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubySize {
#[default]
Half,
Third,
Quarter,
}
impl RubySize {
pub fn scale(self) -> f32 {
match self {
Self::Half => 0.5,
Self::Third => 1.0 / 3.0,
Self::Quarter => 0.25,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubyAlign {
#[default]
SpaceAround,
Center,
Start,
SpaceBetween,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubyOverhang {
#[default]
Auto,
None,
}
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum RubySide {
#[default]
Over,
Under,
}
impl AnnotationClearance {
pub fn is_auto(self) -> bool {
matches!(self, AnnotationClearance::Auto)
}
}

View File

@ -2578,11 +2578,11 @@ pub fn layout_vertical(
}
/// Emphasis-mark (圏点) font size relative to the base em.
const EMPHASIS_FONT_SCALE: f32 = super::text::EMPHASIS_FONT_SCALE;
const EMPHASIS_FONT_SCALE: f32 = super::text_japanese::EMPHASIS_FONT_SCALE;
/// Warichu (割注) sub-line font size relative to the base em; two half-size
/// sub-columns side by side fill the base em width.
const WARICHU_FONT_SCALE: f32 = super::text::WARICHU_FONT_SCALE;
const WARICHU_FONT_SCALE: f32 = super::text_japanese::WARICHU_FONT_SCALE;
/// Char index where a warichu run splits into its two sub-lines: the
/// balanced midpoint (first line longer), nudged so the second sub-line
@ -2622,7 +2622,7 @@ fn utf16_range_allows_emphasis(text: &str, start: usize, end: usize) -> bool {
break;
}
let len = c.len_utf16();
if offset + len > start && super::text::emphasis_char_allowed(c) {
if offset + len > start && super::text_japanese::emphasis_char_allowed(c) {
return true;
}
offset += len;

View File

@ -4,9 +4,7 @@ use super::{fills::RawFillData, fonts::RawFontStyle};
use crate::mem::{self, SerializableResult};
use crate::shapes::{
self, AnnotationClearance, FontFeatures, GrowType, RubyAlign, RubyOverhang, RubySide, RubySize,
Shape, TextAlign, TextCombineUpright, TextDecoration, TextDirection, TextEmphasis,
TextTransform, Type,
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
};
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{with_current_shape, with_current_shape_mut, with_state};
@ -14,14 +12,15 @@ use crate::{with_current_shape, with_current_shape_mut, with_state};
use crate::error::Error;
pub mod helpers;
mod japanese_layout;
pub use japanese_layout::*;
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
const MAX_TEXT_FILLS: usize = 8;
// CHANGEME: Move all the types from japanes text layout to its own module
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextAlign {
@ -58,214 +57,6 @@ impl From<RawTextDirection> for TextDirection {
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawWritingMode {
HorizontalTb = 0,
VerticalRl = 1,
}
impl From<RawWritingMode> for shapes::WritingMode {
fn from(value: RawWritingMode) -> Self {
match value {
RawWritingMode::HorizontalTb => shapes::WritingMode::HorizontalTb,
RawWritingMode::VerticalRl => shapes::WritingMode::VerticalRl,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextOrientation {
Mixed = 0,
Upright = 1,
}
impl From<RawTextOrientation> for shapes::TextOrientation {
fn from(value: RawTextOrientation) -> Self {
match value {
RawTextOrientation::Mixed => shapes::TextOrientation::Mixed,
RawTextOrientation::Upright => shapes::TextOrientation::Upright,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextCombineUpright {
None = 0,
All = 1,
Digits = 2,
Digits2 = 3,
Digits3 = 4,
}
impl From<RawTextCombineUpright> for TextCombineUpright {
fn from(value: RawTextCombineUpright) -> Self {
match value {
RawTextCombineUpright::None => TextCombineUpright::None,
RawTextCombineUpright::All => TextCombineUpright::All,
RawTextCombineUpright::Digits => TextCombineUpright::Digits,
RawTextCombineUpright::Digits2 => TextCombineUpright::Digits2,
RawTextCombineUpright::Digits3 => TextCombineUpright::Digits3,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextEmphasis {
None = 0,
FilledDot = 1,
OpenDot = 2,
FilledCircle = 3,
OpenCircle = 4,
FilledSesame = 5,
OpenSesame = 6,
}
impl From<RawTextEmphasis> for TextEmphasis {
fn from(value: RawTextEmphasis) -> Self {
match value {
RawTextEmphasis::None => TextEmphasis::None,
RawTextEmphasis::FilledDot => TextEmphasis::FilledDot,
RawTextEmphasis::OpenDot => TextEmphasis::OpenDot,
RawTextEmphasis::FilledCircle => TextEmphasis::FilledCircle,
RawTextEmphasis::OpenCircle => TextEmphasis::OpenCircle,
RawTextEmphasis::FilledSesame => TextEmphasis::FilledSesame,
RawTextEmphasis::OpenSesame => TextEmphasis::OpenSesame,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawWarichu {
None = 0,
Warichu = 1,
}
impl From<RawWarichu> for bool {
fn from(value: RawWarichu) -> Self {
value == RawWarichu::Warichu
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawFontFeatures {
None = 0,
Palt = 1,
Vpal = 2,
}
impl From<RawFontFeatures> for FontFeatures {
fn from(value: RawFontFeatures) -> Self {
match value {
RawFontFeatures::None => FontFeatures::None,
RawFontFeatures::Palt => FontFeatures::Palt,
RawFontFeatures::Vpal => FontFeatures::Vpal,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawAnnotationClearance {
None = 0,
Auto = 1,
}
impl From<RawAnnotationClearance> for AnnotationClearance {
fn from(value: RawAnnotationClearance) -> Self {
match value {
RawAnnotationClearance::None => AnnotationClearance::None,
RawAnnotationClearance::Auto => AnnotationClearance::Auto,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubySize {
Half = 0,
Third = 1,
Quarter = 2,
}
impl From<RawRubySize> for RubySize {
fn from(value: RawRubySize) -> Self {
match value {
RawRubySize::Half => RubySize::Half,
RawRubySize::Third => RubySize::Third,
RawRubySize::Quarter => RubySize::Quarter,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubyAlign {
SpaceAround = 0,
Center = 1,
Start = 2,
SpaceBetween = 3,
}
impl From<RawRubyAlign> for RubyAlign {
fn from(value: RawRubyAlign) -> Self {
match value {
RawRubyAlign::SpaceAround => RubyAlign::SpaceAround,
RawRubyAlign::Center => RubyAlign::Center,
RawRubyAlign::Start => RubyAlign::Start,
RawRubyAlign::SpaceBetween => RubyAlign::SpaceBetween,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubyOverhang {
Auto = 0,
None = 1,
}
impl From<RawRubyOverhang> for RubyOverhang {
fn from(value: RawRubyOverhang) -> Self {
match value {
RawRubyOverhang::Auto => RubyOverhang::Auto,
RawRubyOverhang::None => RubyOverhang::None,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubySide {
Over = 0,
Under = 1,
}
impl From<RawRubySide> for RubySide {
fn from(value: RawRubySide) -> Self {
match value {
RawRubySide::Over => RubySide::Over,
RawRubySide::Under => RubySide::Under,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextDecoration {
@ -703,6 +494,9 @@ pub extern "C" fn calculate_position_data() -> *mut u8 {
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{
AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize,
};
/// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16)
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)

View File

@ -0,0 +1,213 @@
use crate::shapes::{
self, AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize,
TextCombineUpright, TextEmphasis,
};
use macros::ToJs;
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawWritingMode {
HorizontalTb = 0,
VerticalRl = 1,
}
impl From<RawWritingMode> for shapes::WritingMode {
fn from(value: RawWritingMode) -> Self {
match value {
RawWritingMode::HorizontalTb => shapes::WritingMode::HorizontalTb,
RawWritingMode::VerticalRl => shapes::WritingMode::VerticalRl,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextOrientation {
Mixed = 0,
Upright = 1,
}
impl From<RawTextOrientation> for shapes::TextOrientation {
fn from(value: RawTextOrientation) -> Self {
match value {
RawTextOrientation::Mixed => shapes::TextOrientation::Mixed,
RawTextOrientation::Upright => shapes::TextOrientation::Upright,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextCombineUpright {
None = 0,
All = 1,
Digits = 2,
Digits2 = 3,
Digits3 = 4,
}
impl From<RawTextCombineUpright> for TextCombineUpright {
fn from(value: RawTextCombineUpright) -> Self {
match value {
RawTextCombineUpright::None => TextCombineUpright::None,
RawTextCombineUpright::All => TextCombineUpright::All,
RawTextCombineUpright::Digits => TextCombineUpright::Digits,
RawTextCombineUpright::Digits2 => TextCombineUpright::Digits2,
RawTextCombineUpright::Digits3 => TextCombineUpright::Digits3,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextEmphasis {
None = 0,
FilledDot = 1,
OpenDot = 2,
FilledCircle = 3,
OpenCircle = 4,
FilledSesame = 5,
OpenSesame = 6,
}
impl From<RawTextEmphasis> for TextEmphasis {
fn from(value: RawTextEmphasis) -> Self {
match value {
RawTextEmphasis::None => TextEmphasis::None,
RawTextEmphasis::FilledDot => TextEmphasis::FilledDot,
RawTextEmphasis::OpenDot => TextEmphasis::OpenDot,
RawTextEmphasis::FilledCircle => TextEmphasis::FilledCircle,
RawTextEmphasis::OpenCircle => TextEmphasis::OpenCircle,
RawTextEmphasis::FilledSesame => TextEmphasis::FilledSesame,
RawTextEmphasis::OpenSesame => TextEmphasis::OpenSesame,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawWarichu {
None = 0,
Warichu = 1,
}
impl From<RawWarichu> for bool {
fn from(value: RawWarichu) -> Self {
value == RawWarichu::Warichu
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawFontFeatures {
None = 0,
Palt = 1,
Vpal = 2,
}
impl From<RawFontFeatures> for FontFeatures {
fn from(value: RawFontFeatures) -> Self {
match value {
RawFontFeatures::None => FontFeatures::None,
RawFontFeatures::Palt => FontFeatures::Palt,
RawFontFeatures::Vpal => FontFeatures::Vpal,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawAnnotationClearance {
None = 0,
Auto = 1,
}
impl From<RawAnnotationClearance> for AnnotationClearance {
fn from(value: RawAnnotationClearance) -> Self {
match value {
RawAnnotationClearance::None => AnnotationClearance::None,
RawAnnotationClearance::Auto => AnnotationClearance::Auto,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubySize {
Half = 0,
Third = 1,
Quarter = 2,
}
impl From<RawRubySize> for RubySize {
fn from(value: RawRubySize) -> Self {
match value {
RawRubySize::Half => RubySize::Half,
RawRubySize::Third => RubySize::Third,
RawRubySize::Quarter => RubySize::Quarter,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubyAlign {
SpaceAround = 0,
Center = 1,
Start = 2,
SpaceBetween = 3,
}
impl From<RawRubyAlign> for RubyAlign {
fn from(value: RawRubyAlign) -> Self {
match value {
RawRubyAlign::SpaceAround => RubyAlign::SpaceAround,
RawRubyAlign::Center => RubyAlign::Center,
RawRubyAlign::Start => RubyAlign::Start,
RawRubyAlign::SpaceBetween => RubyAlign::SpaceBetween,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubyOverhang {
Auto = 0,
None = 1,
}
impl From<RawRubyOverhang> for RubyOverhang {
fn from(value: RawRubyOverhang) -> Self {
match value {
RawRubyOverhang::Auto => RubyOverhang::Auto,
RawRubyOverhang::None => RubyOverhang::None,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubySide {
Over = 0,
Under = 1,
}
impl From<RawRubySide> for RubySide {
fn from(value: RawRubySide) -> Self {
match value {
RawRubySide::Over => RubySide::Over,
RawRubySide::Under => RubySide::Under,
}
}
}