mirror of
https://github.com/penpot/penpot.git
synced 2026-08-20 03:38:52 +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-objects-map-fn* identity)
|
||||||
(def ^:dynamic *wrap-with-pointer-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
|
;; A set of supported features
|
||||||
(def supported-features
|
(def supported-features
|
||||||
#{"fdata/objects-map"
|
#{"fdata/objects-map"
|
||||||
@ -58,7 +56,7 @@
|
|||||||
"text-editor/v2-html-paste"
|
"text-editor/v2-html-paste"
|
||||||
"text-editor/v2"
|
"text-editor/v2"
|
||||||
"text-editor-wasm/v1"
|
"text-editor-wasm/v1"
|
||||||
"text-vertical/v1"
|
"japanese-layout/v1"
|
||||||
"render-wasm/v1"
|
"render-wasm/v1"
|
||||||
"variants/v1"})
|
"variants/v1"})
|
||||||
|
|
||||||
@ -84,7 +82,7 @@
|
|||||||
"text-editor/v2-html-paste"
|
"text-editor/v2-html-paste"
|
||||||
"text-editor/v2"
|
"text-editor/v2"
|
||||||
"text-editor-wasm/v1"
|
"text-editor-wasm/v1"
|
||||||
"text-vertical/v1"
|
"japanese-layout/v1"
|
||||||
"tokens/numeric-input"
|
"tokens/numeric-input"
|
||||||
"render-wasm/v1"})
|
"render-wasm/v1"})
|
||||||
|
|
||||||
@ -135,7 +133,7 @@
|
|||||||
:feature-text-editor-v2 "text-editor/v2"
|
:feature-text-editor-v2 "text-editor/v2"
|
||||||
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
|
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
|
||||||
:feature-text-editor-wasm "text-editor-wasm/v1"
|
: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-render-wasm "render-wasm/v1"
|
||||||
:feature-variants "variants/v1"
|
:feature-variants "variants/v1"
|
||||||
:feature-token-input "tokens/numeric-input"
|
:feature-token-input "tokens/numeric-input"
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
[app.common.flags :as flags]
|
[app.common.flags :as flags]
|
||||||
[app.common.types.color :as clr]
|
[app.common.types.color :as clr]
|
||||||
[app.common.types.fills :as types.fills]
|
[app.common.types.fills :as types.fills]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[clojure.set :as set]
|
[clojure.set :as set]
|
||||||
[clojure.walk :as walk]
|
[clojure.walk :as walk]
|
||||||
[cuerdas.core :as str]))
|
[cuerdas.core :as str]))
|
||||||
@ -42,56 +43,6 @@
|
|||||||
(def text-direction-attrs
|
(def text-direction-attrs
|
||||||
[:text-direction])
|
[: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
|
(def text-spacing-attrs
|
||||||
[:line-height
|
[:line-height
|
||||||
:letter-spacing])
|
:letter-spacing])
|
||||||
@ -118,20 +69,20 @@
|
|||||||
(d/concat-vec
|
(d/concat-vec
|
||||||
text-align-attrs
|
text-align-attrs
|
||||||
text-direction-attrs
|
text-direction-attrs
|
||||||
text-writing-mode-attrs
|
jl/text-writing-mode-attrs
|
||||||
text-orientation-attrs))
|
jl/text-orientation-attrs))
|
||||||
|
|
||||||
(def text-node-attrs
|
(def text-node-attrs
|
||||||
(d/concat-vec
|
(d/concat-vec
|
||||||
text-typography-attrs
|
text-typography-attrs
|
||||||
text-font-attrs
|
text-font-attrs
|
||||||
text-spacing-attrs
|
text-spacing-attrs
|
||||||
text-combine-upright-attrs
|
jl/text-combine-upright-attrs
|
||||||
text-emphasis-attrs
|
jl/text-emphasis-attrs
|
||||||
text-ruby-attrs
|
jl/text-ruby-attrs
|
||||||
text-warichu-attrs
|
jl/text-warichu-attrs
|
||||||
text-font-features-attrs
|
jl/text-font-features-attrs
|
||||||
text-annotation-clearance-attrs
|
jl/text-annotation-clearance-attrs
|
||||||
text-decoration-attrs
|
text-decoration-attrs
|
||||||
text-transform-attrs
|
text-transform-attrs
|
||||||
text-fills))
|
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-tree :as ctst]
|
||||||
[app.common.types.shape.attrs :refer [editable-attrs]]
|
[app.common.types.shape.attrs :refer [editable-attrs]]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[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.common.uuid :as uuid]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace.comments :as-alias dwcm]
|
[app.main.data.workspace.comments :as-alias dwcm]
|
||||||
@ -1013,7 +1013,7 @@
|
|||||||
|
|
||||||
(defn vertical-text-shape?
|
(defn vertical-text-shape?
|
||||||
[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
|
;; Pure function to determine next grow-type for text layers
|
||||||
(defn next-grow-type
|
(defn next-grow-type
|
||||||
@ -1021,9 +1021,7 @@
|
|||||||
(next-grow-type current-grow-type scalev false))
|
(next-grow-type current-grow-type scalev false))
|
||||||
|
|
||||||
([current-grow-type scalev vertical?]
|
([current-grow-type scalev vertical?]
|
||||||
;; CHANGEME: Simplify this comment
|
;; Reuse the horizontal state machine with swapped vertical axes.
|
||||||
;; Grow and wrap axes swap under vertical writing. Normalizing the scale
|
|
||||||
;; vector lets the existing horizontal state machine remain authoritative.
|
|
||||||
(let [scalev (if vertical?
|
(let [scalev (if vertical?
|
||||||
(gpt/point (:y scalev) (:x scalev))
|
(gpt/point (:y scalev) (:x scalev))
|
||||||
scalev)]
|
scalev)]
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
[app.common.types.modifiers :as ctm]
|
[app.common.types.modifiers :as ctm]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.main.data.changes :as dch]
|
[app.main.data.changes :as dch]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
@ -300,7 +301,6 @@
|
|||||||
(defn current-paragraph-values
|
(defn current-paragraph-values
|
||||||
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
|
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
|
||||||
(cond
|
(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)
|
(some? editor-styles) (merge (shape-current-values shape txt/is-paragraph-node? attrs)
|
||||||
(select-keys editor-styles attrs))
|
(select-keys editor-styles attrs))
|
||||||
(some? editor-instance) (v2-current-text-values options)
|
(some? editor-instance) (v2-current-text-values options)
|
||||||
@ -451,7 +451,6 @@
|
|||||||
(ptk/reify ::update-paragraph-attrs
|
(ptk/reify ::update-paragraph-attrs
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(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))
|
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
@ -959,8 +958,8 @@
|
|||||||
;; apply them to every paragraph so they cannot diverge
|
;; apply them to every paragraph so they cannot diverge
|
||||||
;; from the first paragraph (which decides the flow).
|
;; from the first paragraph (which decides the flow).
|
||||||
whole-shape-attrs
|
whole-shape-attrs
|
||||||
(select-keys attrs (d/concat-vec txt/text-writing-mode-attrs
|
(select-keys attrs (d/concat-vec jl/text-writing-mode-attrs
|
||||||
txt/text-orientation-attrs))
|
jl/text-orientation-attrs))
|
||||||
|
|
||||||
selection-attrs
|
selection-attrs
|
||||||
(apply dissoc attrs (keys whole-shape-attrs))]
|
(apply dissoc attrs (keys whole-shape-attrs))]
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
[app.common.geom.matrix :as gmt]
|
[app.common.geom.matrix :as gmt]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.types.modifiers :as ctm]
|
[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.helpers :as dsh]
|
||||||
[app.main.data.workspace.modifiers :as dwm]
|
[app.main.data.workspace.modifiers :as dwm]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
@ -32,7 +32,7 @@
|
|||||||
auto-height keeps the physical height as its wrap budget and grows width."
|
auto-height keeps the physical height as its wrap budget and grows width."
|
||||||
[selrect grow-type content dimension]
|
[selrect grow-type content dimension]
|
||||||
(when (or (= :fixed grow-type) (some? dimension))
|
(when (or (= :fixed grow-type) (some? dimension))
|
||||||
(let [vertical? (txt/vertical-text-content? content)]
|
(let [vertical? (jl/vertical-text-content? content)]
|
||||||
{:width (cond
|
{:width (cond
|
||||||
(= :fixed grow-type)
|
(= :fixed grow-type)
|
||||||
(:width selrect)
|
(:width selrect)
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.types.color :as cc]
|
[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]
|
[app.main.ui.shapes.text.styles :as sts]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
@ -170,7 +170,7 @@
|
|||||||
;; Vertical writing anchors columns to the box edges, so the oversized
|
;; Vertical writing anchors columns to the box edges, so the oversized
|
||||||
;; auto-grow box used to avoid horizontal wrapping/clipping would push
|
;; auto-grow box used to avoid horizontal wrapping/clipping would push
|
||||||
;; the content off-position. Use the real selrect size instead.
|
;; 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)]
|
[colors _color-mapping color-mapping-inverse] (retrieve-colors shape)]
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.text :as legacy.txt]
|
[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]
|
[app.main.ui.shapes.text.styles :as sts]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
@ -86,7 +86,7 @@
|
|||||||
;; Vertical writing anchors columns to the box edges; the oversized
|
;; Vertical writing anchors columns to the box edges; the oversized
|
||||||
;; auto-grow box (used to avoid horizontal wrapping) would push content
|
;; auto-grow box (used to avoid horizontal wrapping) would push content
|
||||||
;; off-position, so use the real selrect size instead.
|
;; off-position, so use the real selrect size instead.
|
||||||
vertical? (txt/vertical-text-content? content)
|
vertical? (jl/vertical-text-content? content)
|
||||||
|
|
||||||
style
|
style
|
||||||
(when-not is-code
|
(when-not is-code
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
[app.common.transit :as transit]
|
[app.common.transit :as transit]
|
||||||
[app.common.types.color :as cc]
|
[app.common.types.color :as cc]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.main.fonts :as fonts]
|
[app.main.fonts :as fonts]
|
||||||
[app.main.ui.formats :as fmt]
|
[app.main.ui.formats :as fmt]
|
||||||
[app.util.color :as uc]
|
[app.util.color :as uc]
|
||||||
@ -23,7 +24,7 @@
|
|||||||
(let [valign (:vertical-align node "top")
|
(let [valign (:vertical-align node "top")
|
||||||
;; Mirroring the shape's writing mode on the root makes paragraph
|
;; Mirroring the shape's writing mode on the root makes paragraph
|
||||||
;; blocks stack right-to-left.
|
;; 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))
|
base #js {:height (when-not code? (fmt/format-pixels height))
|
||||||
:width (when-not code? (fmt/format-pixels width))
|
:width (when-not code? (fmt/format-pixels width))
|
||||||
:display "flex"
|
:display "flex"
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
[app.common.geom.shapes.text :as gst]
|
[app.common.geom.shapes.text :as gst]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
[app.common.text :as legacy.txt]
|
[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.config :as cf]
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
[app.main.data.workspace.texts :as dwt]
|
[app.main.data.workspace.texts :as dwt]
|
||||||
@ -236,7 +236,7 @@
|
|||||||
:opacity (when @blurred 0)}
|
:opacity (when @blurred 0)}
|
||||||
:on-pointer-down on-pointer-down
|
:on-pointer-down on-pointer-down
|
||||||
:class (dom/classnames
|
: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-top (= (:vertical-align content "top") "top")
|
||||||
:align-center (= (:vertical-align content) "center")
|
:align-center (= (:vertical-align content) "center")
|
||||||
:align-bottom (= (:vertical-align content) "bottom"))}
|
:align-bottom (= (:vertical-align content) "bottom"))}
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
[app.common.types.color :as color]
|
[app.common.types.color :as color]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
@ -296,7 +297,7 @@
|
|||||||
(dom/set-style! container-node "--text-editor-caret-color" text-color)))
|
(dom/set-style! container-node "--text-editor-caret-color" text-color)))
|
||||||
|
|
||||||
[:div
|
[: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))
|
(stl/css :text-editor-container))
|
||||||
:ref container-ref
|
:ref container-ref
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace.texts :as dwt]
|
[app.main.data.workspace.texts :as dwt]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
@ -405,7 +406,7 @@
|
|||||||
:on-focus on-focus
|
:on-focus on-focus
|
||||||
:on-blur on-blur
|
:on-blur on-blur
|
||||||
:id "text-editor-wasm-input"
|
: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))
|
(stl/css :text-editor-container))
|
||||||
:data-testid "text-editor-container"}]]]]))
|
:data-testid "text-editor-container"}]]]]))
|
||||||
|
|||||||
@ -25,14 +25,11 @@
|
|||||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||||
[app.main.ui.context :as ctx]
|
[app.main.ui.context :as ctx]
|
||||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
[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.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.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.ds.foundations.assets.icon :as i]
|
||||||
[app.main.ui.hooks :as hooks]
|
[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.token-typography-row :refer [token-typography-row*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
|
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
|
||||||
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
|
[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."
|
Evaluated once at module load time; cf/flags is immutable after startup."
|
||||||
(contains? cf/flags :token-typography-row))
|
(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
|
(defn- radio-selected
|
||||||
([value]
|
([value]
|
||||||
(radio-selected value ""))
|
(radio-selected value ""))
|
||||||
@ -69,112 +64,6 @@
|
|||||||
(string? value) value
|
(string? value) value
|
||||||
:else (str 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*
|
(mf/defc text-align-options*
|
||||||
[{:keys [values on-change on-blur]}]
|
[{:keys [values on-change on-blur]}]
|
||||||
(let [options
|
(let [options
|
||||||
@ -236,397 +125,6 @@
|
|||||||
:name "text-direction-options"
|
:name "text-direction-options"
|
||||||
:options 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*
|
(mf/defc vertical-align*
|
||||||
[{:keys [values on-change on-blur]}]
|
[{:keys [values on-change on-blur]}]
|
||||||
(let [vertical-align (radio-selected (:vertical-align values) "top")
|
(let [vertical-align (radio-selected (:vertical-align values) "top")
|
||||||
@ -896,17 +394,6 @@
|
|||||||
;; --- Helpers
|
;; --- Helpers
|
||||||
multiple? (->> values vals (d/seek #(= % :multiple)))
|
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!
|
apply-token!
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps ids typography-tokens)
|
(mf/deps ids typography-tokens)
|
||||||
@ -948,10 +435,6 @@
|
|||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
#(swap! token-dropdown-open* not))
|
#(swap! token-dropdown-open* not))
|
||||||
|
|
||||||
toggle-japanese-layout
|
|
||||||
(mf/use-fn
|
|
||||||
#(reset! japanese-layout-active* %))
|
|
||||||
|
|
||||||
;; --- Event handlers
|
;; --- Event handlers
|
||||||
on-option-click
|
on-option-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -1046,16 +529,15 @@
|
|||||||
:show-recent true
|
:show-recent true
|
||||||
:on-blur on-text-blur})
|
:on-blur on-text-blur})
|
||||||
|
|
||||||
ruby-presentation-props
|
japanese-layout-props
|
||||||
(mf/props
|
(mf/props
|
||||||
{:values ruby-values
|
{:ids ids
|
||||||
:on-change on-ruby-presentation-change
|
:values values
|
||||||
:on-blur on-text-blur})
|
:ruby-values ruby-values
|
||||||
|
:text-selection-active text-selection-active
|
||||||
japanese-toggle-props
|
:on-change on-change
|
||||||
(mf/spread-props common-props
|
:on-ruby-presentation-change on-ruby-presentation-change
|
||||||
{:enabled japanese-layout-active?
|
:on-blur on-text-blur})]
|
||||||
:on-toggle toggle-japanese-layout})]
|
|
||||||
|
|
||||||
(hooks/use-stream
|
(hooks/use-stream
|
||||||
expand-stream
|
expand-stream
|
||||||
@ -1073,19 +555,6 @@
|
|||||||
(when token-dropdown-open?
|
(when token-dropdown-open?
|
||||||
(ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!))))
|
(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)
|
[:section {:class (stl/css :element-set)
|
||||||
:aria-label (tr "workspace.options.text-options.text-section")}
|
:aria-label (tr "workspace.options.text-options.text-section")}
|
||||||
[:div {:class (stl/css :element-title)}
|
[:div {:class (stl/css :element-title)}
|
||||||
@ -1176,27 +645,4 @@
|
|||||||
[:> vertical-align* common-props]
|
[:> vertical-align* common-props]
|
||||||
[:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})]
|
[:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})]
|
||||||
[:> text-direction-options* common-props]]
|
[:> text-direction-options* common-props]]
|
||||||
(when ^boolean vertical-text-enabled?
|
[:> tjl/japanese-layout-options* japanese-layout-props]])])]))
|
||||||
[: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])])])])])]))
|
|
||||||
|
|||||||
@ -57,93 +57,13 @@
|
|||||||
|
|
||||||
.align-options,
|
.align-options,
|
||||||
.text-direction-options,
|
.text-direction-options,
|
||||||
.writing-mode-options,
|
|
||||||
.text-orientation-options,
|
|
||||||
.text-combine-upright-options,
|
|
||||||
.warichu-options,
|
|
||||||
.vertical-align-options,
|
.vertical-align-options,
|
||||||
.grow-options,
|
.grow-options,
|
||||||
.text-decoration-options {
|
.text-decoration-options {
|
||||||
block-size: $sz-32;
|
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 {
|
.text-decoration-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--sp-xs);
|
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.component :as ctk]
|
||||||
[app.common.types.container :as ctn]
|
[app.common.types.container :as ctn]
|
||||||
[app.common.types.shape :as cts]
|
[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.helpers :as dsh]
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
@ -501,7 +501,7 @@
|
|||||||
(keyword))
|
(keyword))
|
||||||
horizontal-resize? (contains? #{:right :left} position)
|
horizontal-resize? (contains? #{:right :left} position)
|
||||||
vertical-resize? (contains? #{:top :bottom} 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?
|
wrap-axis-resize? (if vertical-text?
|
||||||
vertical-resize?
|
vertical-resize?
|
||||||
horizontal-resize?)
|
horizontal-resize?)
|
||||||
|
|||||||
@ -398,7 +398,6 @@
|
|||||||
:else
|
:else
|
||||||
(st/emit! (dwt/update-text-range id start end {:text-decoration value}))))}
|
(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
|
:fontFeatures
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.text.content.styles :as styles]))
|
[app.util.text.content.styles :as styles]))
|
||||||
|
|
||||||
@ -91,11 +92,8 @@
|
|||||||
(defn get-root-styles
|
(defn get-root-styles
|
||||||
[root]
|
[root]
|
||||||
(let [styles (get-styles-from-attrs root txt/root-attrs txt/default-text-attrs)
|
(let [styles (get-styles-from-attrs root txt/root-attrs txt/default-text-attrs)
|
||||||
;; CHANGEME: simplify this comment
|
;; Stack vertical paragraphs right-to-left in the DOM editor.
|
||||||
;; Mirroring the shape's writing mode on the root makes paragraph
|
writing-mode (jl/content-writing-mode root)]
|
||||||
;; 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)]
|
|
||||||
(cond-> styles
|
(cond-> styles
|
||||||
(some? writing-mode) (assoc :writing-mode writing-mode))))
|
(some? writing-mode) (assoc :writing-mode writing-mode))))
|
||||||
|
|
||||||
|
|||||||
@ -275,8 +275,7 @@
|
|||||||
(t/is (= #{:japanese :cyrillic}
|
(t/is (= #{:japanese :cyrillic}
|
||||||
(texts/resolve-ambiguous-cjk #{:han :cyrillic} "ja"))))
|
(texts/resolve-ambiguous-cjk #{:han :cyrillic} "ja"))))
|
||||||
|
|
||||||
(t/deftest resolve-han-unification-fixture
|
(t/deftest resolve-han-unification
|
||||||
;; Acceptance case from render-wasm/fixtures/japanese-typography.json:
|
;; A kanji-only Japanese address resolves to Japanese under a ja locale.
|
||||||
;; a kanji-only Japanese address resolves to Japanese under a ja locale.
|
|
||||||
(t/is (= #{:japanese}
|
(t/is (= #{:japanese}
|
||||||
(texts/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja"))))
|
(texts/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja"))))
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
[app.main.data.workspace.texts :as dwt]
|
[app.main.data.workspace.texts :as dwt]
|
||||||
[app.main.ui.ds.controls.select :as select]
|
[app.main.ui.ds.controls.select :as select]
|
||||||
[app.main.ui.shapes.text.styles :as text-styles]
|
[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]
|
[app.util.text.content.styles :as content-styles]
|
||||||
[cljs.test :as t :include-macros true]
|
[cljs.test :as t :include-macros true]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
@ -28,29 +28,29 @@
|
|||||||
:font-features
|
:font-features
|
||||||
:annotation-clearance])
|
:annotation-clearance])
|
||||||
|
|
||||||
(def ^:private radio-selected @#'text-menu/radio-selected)
|
(def ^:private radio-selected @#'tjl/radio-selected)
|
||||||
(def ^:private span-input-value @#'text-menu/span-input-value)
|
(def ^:private span-input-value @#'tjl/span-input-value)
|
||||||
(def ^:private span-select-value @#'text-menu/span-select-value)
|
(def ^:private span-select-value @#'tjl/span-select-value)
|
||||||
(def ^:private with-mixed-span-option @#'text-menu/with-mixed-span-option)
|
(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option)
|
||||||
(def ^:private ruby-common-props @#'text-menu/ruby-common-props)
|
(def ^:private ruby-common-props @#'tjl/ruby-common-props)
|
||||||
|
|
||||||
(t/deftest text-emphasis-select-reports-and-restores-canonical-values
|
(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"))]
|
reported-id (:id (select/get-option options "filled-dot"))]
|
||||||
(t/is (= "filled-dot" reported-id))
|
(t/is (= "filled-dot" reported-id))
|
||||||
(t/is (= "filled-dot"
|
(t/is (= "filled-dot"
|
||||||
(:id (select/get-option options reported-id))))))
|
(:id (select/get-option options reported-id))))))
|
||||||
|
|
||||||
(t/deftest annotation-clearance-select-reports-canonical-values
|
(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 (= ["none" "auto"] (mapv :id options)))
|
||||||
(t/is (= "auto" (:id (select/get-option options "auto"))))))
|
(t/is (= "auto" (:id (select/get-option options "auto"))))))
|
||||||
|
|
||||||
(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection
|
(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection
|
||||||
(t/is (= ["none" "digits"]
|
(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"]
|
(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
|
(t/deftest ruby-container-styles-preserve-customization
|
||||||
(let [style (text-styles/generate-ruby-container-styles
|
(let [style (text-styles/generate-ruby-container-styles
|
||||||
@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
(t/deftest advanced-furigana-options-are-collapsed-by-default
|
(t/deftest advanced-furigana-options-are-collapsed-by-default
|
||||||
(let [markup (rds/renderToStaticMarkup
|
(let [markup (rds/renderToStaticMarkup
|
||||||
(mf/element text-menu/ruby-presentation-options*
|
(mf/element tjl/ruby-presentation-options*
|
||||||
#js {:values {}
|
#js {:values {}
|
||||||
:on-change identity
|
:on-change identity
|
||||||
:on-blur identity}))]
|
:on-blur identity}))]
|
||||||
@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
(t/deftest mixed-japanese-span-values-have-distinct-control-states
|
(t/deftest mixed-japanese-span-values-have-distinct-control-states
|
||||||
(let [options (with-mixed-span-option
|
(let [options (with-mixed-span-option
|
||||||
(text-menu/text-emphasis-options identity)
|
(tjl/text-emphasis-options identity)
|
||||||
:multiple)]
|
:multiple)]
|
||||||
(t/is (= "mixed" (span-select-value :multiple "none")))
|
(t/is (= "mixed" (span-select-value :multiple "none")))
|
||||||
(t/is (= "mixed" (span-input-value :multiple)))
|
(t/is (= "mixed" (span-input-value :multiple)))
|
||||||
@ -177,31 +177,31 @@
|
|||||||
{"text-combine-upright" "digits 3"}))))
|
{"text-combine-upright" "digits 3"}))))
|
||||||
|
|
||||||
(t/deftest japanese-layout-is-explicitly-enabled-by-writing-mode
|
(t/deftest japanese-layout-is-explicitly-enabled-by-writing-mode
|
||||||
(t/is (false? (text-menu/japanese-layout-enabled? {})))
|
(t/is (false? (tjl/japanese-layout-enabled? {})))
|
||||||
(t/is (false? (text-menu/japanese-layout-enabled? {:writing-mode nil})))
|
(t/is (false? (tjl/japanese-layout-enabled? {:writing-mode nil})))
|
||||||
(t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "horizontal-tb"})))
|
(t/is (true? (tjl/japanese-layout-enabled? {:writing-mode "horizontal-tb"})))
|
||||||
(t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "vertical-rl"})))
|
(t/is (true? (tjl/japanese-layout-enabled? {:writing-mode "vertical-rl"})))
|
||||||
(t/is (nil? (text-menu/japanese-layout-enabled? {:writing-mode :multiple}))))
|
(t/is (nil? (tjl/japanese-layout-enabled? {:writing-mode :multiple}))))
|
||||||
|
|
||||||
(t/deftest japanese-layout-toggle-emits-a-persisted-writing-mode
|
(t/deftest japanese-layout-toggle-emits-a-persisted-writing-mode
|
||||||
(t/is (= {:writing-mode "horizontal-tb"}
|
(t/is (= {:writing-mode "horizontal-tb"}
|
||||||
(text-menu/japanese-layout-toggle-attrs true)))
|
(tjl/japanese-layout-toggle-attrs true)))
|
||||||
(t/is (= {:writing-mode nil}
|
(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/deftest japanese-layout-state-survives-transient-selection-styles
|
||||||
(t/is (true? (text-menu/reconcile-japanese-layout-state true {} false)))
|
(t/is (true? (tjl/reconcile-japanese-layout-state true {} false)))
|
||||||
(t/is (true? (text-menu/reconcile-japanese-layout-state false
|
(t/is (true? (tjl/reconcile-japanese-layout-state false
|
||||||
{:writing-mode "vertical-rl"}
|
{:writing-mode "vertical-rl"}
|
||||||
false)))
|
false)))
|
||||||
(t/is (false? (text-menu/reconcile-japanese-layout-state true {} true))))
|
(t/is (false? (tjl/reconcile-japanese-layout-state true {} true))))
|
||||||
|
|
||||||
(t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes
|
(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"})))
|
{:writing-mode "horizontal-tb"})))
|
||||||
(t/is (true? (text-menu/vertical-japanese-layout?
|
(t/is (true? (tjl/vertical-japanese-layout?
|
||||||
{:writing-mode "vertical-rl"})))
|
{:writing-mode "vertical-rl"})))
|
||||||
(t/is (= "palt"
|
(t/is (= "palt"
|
||||||
(text-menu/proportional-metrics-feature "horizontal-tb")))
|
(tjl/proportional-metrics-feature "horizontal-tb")))
|
||||||
(t/is (= "vpal"
|
(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');
|
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) => {
|
test('font identity and variant setters accept a real font/variant', (ctx) => {
|
||||||
const t = text(ctx);
|
const t = text(ctx);
|
||||||
const font = ctx.penpot.fonts.all[0];
|
const font = ctx.penpot.fonts.all[0];
|
||||||
@ -172,6 +204,31 @@ describe('Text', () => {
|
|||||||
expect(range.textDecoration).toBe('line-through');
|
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) => {
|
test('range fills round-trip', (ctx) => {
|
||||||
const t = text(ctx, 'Hello Penpot');
|
const t = text(ctx, 'Hello Penpot');
|
||||||
const range = t.getRange(0, 5);
|
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();
|
.unzip();
|
||||||
// CHANGEME: Extract all this code to its own function
|
|
||||||
if text_content.is_vertical() {
|
if text_content.is_vertical() {
|
||||||
// Vertical writing paints through the custom vertical pass:
|
text::render_vertical_text(
|
||||||
// drop shadows and strokes derive from the same laid-out cells
|
self,
|
||||||
// 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,
|
|
||||||
&shape,
|
&shape,
|
||||||
|
text_content,
|
||||||
&mut paragraph_builders,
|
&mut paragraph_builders,
|
||||||
Some(fills_surface_id),
|
fills_surface_id,
|
||||||
None,
|
strokes_surface_id,
|
||||||
blur_filter.as_ref(),
|
skip_effects,
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 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 {
|
} else if skip_effects {
|
||||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||||
text::render(
|
text::render(
|
||||||
|
|||||||
@ -3,8 +3,8 @@ use crate::{
|
|||||||
error::Result,
|
error::Result,
|
||||||
math::Rect,
|
math::Rect,
|
||||||
shapes::{
|
shapes::{
|
||||||
add_horizontal_span, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
|
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
|
||||||
ParagraphLayout, Stroke, StrokeKind, TextContent,
|
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
|
||||||
},
|
},
|
||||||
utils::{get_fallback_fonts, get_font_collection},
|
utils::{get_fallback_fonts, get_font_collection},
|
||||||
};
|
};
|
||||||
@ -15,6 +15,91 @@ use skia_safe::{
|
|||||||
Canvas, ImageFilter, Paint,
|
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(
|
pub fn stroke_paragraph_builder_group_from_text(
|
||||||
text_content: &TextContent,
|
text_content: &TextContent,
|
||||||
stroke: &Stroke,
|
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(
|
fn calculate_cursor_rect(
|
||||||
editor_state: &TextEditorState,
|
editor_state: &TextEditorState,
|
||||||
text_content: &TextContent,
|
text_content: &TextContent,
|
||||||
@ -144,24 +216,13 @@ fn calculate_cursor_rect(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical writing: selrect-local caret bar from the vertical cells.
|
|
||||||
// CHANGEME: extract to function
|
|
||||||
if text_content.is_vertical() {
|
if text_content.is_vertical() {
|
||||||
let selrect = shape.selrect();
|
return calculate_vertical_cursor_rect(
|
||||||
let max_height = text_vertical::wrap_height(text_content, selrect.height());
|
text_content,
|
||||||
let layout = text_vertical::layout_from_content(text_content, max_height);
|
shape,
|
||||||
let origin_x =
|
cursor.paragraph,
|
||||||
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
|
cursor.offset,
|
||||||
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(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||||
@ -268,54 +329,17 @@ fn calculate_selection_rects(
|
|||||||
text_content: &TextContent,
|
text_content: &TextContent,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
) -> Vec<Rect> {
|
) -> Vec<Rect> {
|
||||||
let mut rects = Vec::new();
|
|
||||||
|
|
||||||
let start = selection.start();
|
let start = selection.start();
|
||||||
let end = selection.end();
|
let end = selection.end();
|
||||||
|
|
||||||
let paragraphs = text_content.paragraphs();
|
let paragraphs = text_content.paragraphs();
|
||||||
|
|
||||||
// Vertical writing: selrect-local selection strips from the cells.
|
|
||||||
// CHANGEME: extract to function
|
|
||||||
if text_content.is_vertical() {
|
if text_content.is_vertical() {
|
||||||
let selrect = shape.selrect();
|
return calculate_vertical_selection_rects(selection, text_content, shape);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut rects = Vec::new();
|
||||||
|
|
||||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||||
|
|
||||||
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
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::error::Result;
|
||||||
use crate::shapes::{
|
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::state::ShapesPoolRef;
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
@ -48,6 +49,62 @@ impl<'a> VectorRenderer<'a> {
|
|||||||
_target: target,
|
_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<'_> {
|
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 mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||||
let blur_filter = shape.image_filter(1.);
|
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() {
|
if text_content.is_vertical() {
|
||||||
use crate::shapes::text_vertical;
|
return self.draw_vertical_text(
|
||||||
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,
|
|
||||||
shape,
|
shape,
|
||||||
|
&text_content,
|
||||||
&mut paragraph_builders,
|
&mut paragraph_builders,
|
||||||
None,
|
|
||||||
blur_filter.as_ref(),
|
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
|
// Text drop shadows: one filter layer per shadow over fill + stroke
|
||||||
|
|||||||
@ -30,6 +30,7 @@ mod strokes;
|
|||||||
mod svg_attrs;
|
mod svg_attrs;
|
||||||
mod svgraw;
|
mod svgraw;
|
||||||
mod text;
|
mod text;
|
||||||
|
mod text_japanese;
|
||||||
pub mod text_paths;
|
pub mod text_paths;
|
||||||
pub mod text_vertical;
|
pub mod text_vertical;
|
||||||
mod transform;
|
mod transform;
|
||||||
@ -53,6 +54,7 @@ pub use strokes::*;
|
|||||||
pub use svg_attrs::*;
|
pub use svg_attrs::*;
|
||||||
pub use svgraw::*;
|
pub use svgraw::*;
|
||||||
pub use text::*;
|
pub use text::*;
|
||||||
|
pub use text_japanese::*;
|
||||||
pub use transform::*;
|
pub use transform::*;
|
||||||
|
|
||||||
use crate::math::{self, Bounds, Matrix, Point};
|
use crate::math::{self, Bounds, Matrix, Point};
|
||||||
|
|||||||
@ -7,9 +7,7 @@ use crate::{
|
|||||||
|
|
||||||
use core::f32;
|
use core::f32;
|
||||||
use macros::ToJs;
|
use macros::ToJs;
|
||||||
use skia_safe::textlayout::{
|
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||||
PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline,
|
|
||||||
};
|
|
||||||
use skia_safe::{
|
use skia_safe::{
|
||||||
self as skia,
|
self as skia,
|
||||||
paint::{self, Paint},
|
paint::{self, Paint},
|
||||||
@ -20,11 +18,10 @@ use skia_safe::{
|
|||||||
Contains,
|
Contains,
|
||||||
};
|
};
|
||||||
|
|
||||||
// CHANGEME: move all the custom japanese layout code to its own module
|
|
||||||
|
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use super::text_japanese::*;
|
||||||
use super::FontFamily;
|
use super::FontFamily;
|
||||||
use crate::math::Point;
|
use crate::math::Point;
|
||||||
use crate::shapes::{self, kinsoku, merge_fills, Shape, VerticalAlign};
|
use crate::shapes::{self, kinsoku, merge_fills, Shape, VerticalAlign};
|
||||||
@ -34,539 +31,6 @@ use crate::Uuid;
|
|||||||
// TODO: maybe move this to the wasm module?
|
// TODO: maybe move this to the wasm module?
|
||||||
pub type ParagraphBuilderGroup = Vec<ParagraphBuilder>;
|
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)]
|
#[repr(u8)]
|
||||||
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
||||||
pub enum GrowType {
|
pub enum GrowType {
|
||||||
@ -1720,153 +1184,6 @@ pub type TextAlign = skia::textlayout::TextAlign;
|
|||||||
pub type TextDirection = skia::textlayout::TextDirection;
|
pub type TextDirection = skia::textlayout::TextDirection;
|
||||||
pub type TextDecoration = skia::textlayout::TextDecoration;
|
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)]
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||||
pub enum TextTransform {
|
pub enum TextTransform {
|
||||||
Lowercase,
|
Lowercase,
|
||||||
@ -1984,27 +1301,7 @@ impl Paragraph {
|
|||||||
/// transform is skipped under letter-spacing, where skparagraph would add
|
/// transform is skipped under letter-spacing, where skparagraph would add
|
||||||
/// letter spacing to synthetic layout characters.
|
/// letter spacing to synthetic layout characters.
|
||||||
pub fn layout_span_texts(&self) -> (Vec<String>, kinsoku::OffsetMap) {
|
pub fn layout_span_texts(&self) -> (Vec<String>, kinsoku::OffsetMap) {
|
||||||
let texts: Vec<String> = self
|
layout_span_texts(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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paragraph_to_style(&self) -> ParagraphStyle {
|
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.
|
/// 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
|
/// Warichu (割注) sub-line font size relative to the base em; two half-size
|
||||||
/// sub-columns side by side fill the base em width.
|
/// 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
|
/// Char index where a warichu run splits into its two sub-lines: the
|
||||||
/// balanced midpoint (first line longer), nudged so the second sub-line
|
/// 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;
|
break;
|
||||||
}
|
}
|
||||||
let len = c.len_utf16();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
offset += len;
|
offset += len;
|
||||||
|
|||||||
@ -4,9 +4,7 @@ use super::{fills::RawFillData, fonts::RawFontStyle};
|
|||||||
|
|
||||||
use crate::mem::{self, SerializableResult};
|
use crate::mem::{self, SerializableResult};
|
||||||
use crate::shapes::{
|
use crate::shapes::{
|
||||||
self, AnnotationClearance, FontFeatures, GrowType, RubyAlign, RubyOverhang, RubySide, RubySize,
|
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
|
||||||
Shape, TextAlign, TextCombineUpright, TextDecoration, TextDirection, TextEmphasis,
|
|
||||||
TextTransform, Type,
|
|
||||||
};
|
};
|
||||||
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
|
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
|
||||||
use crate::{with_current_shape, with_current_shape_mut, with_state};
|
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;
|
use crate::error::Error;
|
||||||
|
|
||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
|
mod japanese_layout;
|
||||||
|
|
||||||
|
pub use japanese_layout::*;
|
||||||
|
|
||||||
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
|
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
|
||||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||||
|
|
||||||
const MAX_TEXT_FILLS: usize = 8;
|
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)]
|
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum RawTextAlign {
|
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)]
|
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum RawTextDecoration {
|
pub enum RawTextDecoration {
|
||||||
@ -703,6 +494,9 @@ pub extern "C" fn calculate_position_data() -> *mut u8 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::shapes::{
|
||||||
|
AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize,
|
||||||
|
};
|
||||||
|
|
||||||
/// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16)
|
/// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16)
|
||||||
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)
|
/// 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