mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 21:38:48 +00:00
polishing
This commit is contained in:
parent
6cdbfe4b51
commit
75aeea32fb
@ -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"
|
||||
|
||||
@ -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))
|
||||
|
||||
56
common/src/app/common/types/text/japanese_layout.cljc
Normal file
56
common/src/app/common/types/text/japanese_layout.cljc
Normal 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)))
|
||||
@ -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)]
|
||||
|
||||
@ -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))]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"))}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}]]]]))
|
||||
|
||||
@ -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]])])]))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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])])])))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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?)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
|
||||
@ -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"))))
|
||||
|
||||
@ -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"))))
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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": [
|
||||
"1–2 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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 {
|
||||
|
||||
718
render-wasm/src/shapes/text_japanese.rs
Normal file
718
render-wasm/src/shapes/text_japanese.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
213
render-wasm/src/wasm/text/japanese_layout.rs
Normal file
213
render-wasm/src/wasm/text/japanese_layout.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user