✨ Add japanese text layout support
@ -62,6 +62,7 @@
|
||||
[:map {:title "ProfileProps" :closed true}
|
||||
[:plugins {:optional true} ctp/schema:plugin-registry]
|
||||
[:renderer {:optional true} [::sm/one-of #{:svg :wasm}]]
|
||||
[:japanese-layout-all-files {:optional true} ::sm/boolean]
|
||||
[:mcp-enabled {:optional true} ::sm/boolean]
|
||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||
[:newsletter-news {:optional true} ::sm/boolean]
|
||||
|
||||
@ -164,6 +164,21 @@
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:photo-id (:result out))))))))
|
||||
|
||||
(t/deftest update-profile-japanese-layout-setting
|
||||
(let [profile (th/create-profile* 1)
|
||||
params {::th/type :update-profile-props
|
||||
::rpc/profile-id (:id profile)
|
||||
:props {:japanese-layout-all-files true}}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (true? (get-in out [:result :japanese-layout-all-files])))
|
||||
|
||||
(let [params {::th/type :get-profile
|
||||
::rpc/profile-id (:id profile)}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (true? (get-in out [:result :props :japanese-layout-all-files]))))))
|
||||
|
||||
(t/deftest profile-deletion-1
|
||||
(let [prof (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
|
||||
@ -438,7 +438,12 @@
|
||||
[:map {:title "SetTokensSource"}
|
||||
[:type [:= :set-tokens-source]]
|
||||
[:file-id ::sm/uuid]
|
||||
[:library-id [:maybe ::sm/uuid]]]]])
|
||||
[:library-id [:maybe ::sm/uuid]]]]
|
||||
|
||||
[:set-japanese-layout
|
||||
[:map {:title "SetJapaneseLayout"}
|
||||
[:type [:= :set-japanese-layout]]
|
||||
[:enabled ::sm/boolean]]]])
|
||||
|
||||
(def schema:changes
|
||||
[:sequential {:gen/max 5 :gen/min 1} schema:change])
|
||||
@ -1092,6 +1097,10 @@
|
||||
[data {:keys [library-id]}]
|
||||
(cfo/set-tokens-source data library-id))
|
||||
|
||||
(defmethod process-change :set-japanese-layout
|
||||
[data {:keys [enabled]}]
|
||||
(ctf/set-japanese-layout-enabled data enabled))
|
||||
|
||||
;; === Operations
|
||||
|
||||
(def decode-shape-attrs
|
||||
|
||||
@ -1150,6 +1150,18 @@
|
||||
:library-id prev-val})
|
||||
(apply-changes-local))))
|
||||
|
||||
(defn set-japanese-layout
|
||||
[changes enabled?]
|
||||
(assert-file-data! changes)
|
||||
(let [file-data (::file-data (meta changes))
|
||||
previous-enabled (ctf/japanese-layout-enabled? file-data)]
|
||||
(-> changes
|
||||
(update :redo-changes conj {:type :set-japanese-layout
|
||||
:enabled enabled?})
|
||||
(update :undo-changes conj {:type :set-japanese-layout
|
||||
:enabled previous-enabled})
|
||||
(apply-changes-local))))
|
||||
|
||||
;; Misc changes
|
||||
|
||||
(defn reorder-children
|
||||
|
||||
@ -163,7 +163,14 @@
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
;; Han ideographs are shared by Japanese/Chinese/Korean (Han
|
||||
;; unification) and cannot identify a language by themselves; they
|
||||
;; are resolved to a concrete language by `resolve-ambiguous-cjk`.
|
||||
:han #"[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]"
|
||||
;; CJK symbols/punctuation (U+3000-303F) and half/full-width forms
|
||||
;; (U+FF01-FF65, U+FFE0-FFEE) are likewise shared across
|
||||
;; Japanese/Chinese/Korean; resolved by `resolve-ambiguous-cjk`.
|
||||
:cjk-punctuation #"[\u3000-\u303F\uFF01-\uFF65\uFFE0-\uFFEE]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
@ -236,6 +243,33 @@
|
||||
used
|
||||
unicode-ranges))
|
||||
|
||||
(defn- locale->cjk-language
|
||||
"CJK language implied by an app locale string, or nil."
|
||||
[locale]
|
||||
(let [locale (str/lower (str locale))]
|
||||
(cond
|
||||
(str/starts-with? locale "ja") :japanese
|
||||
(str/starts-with? locale "ko") :korean
|
||||
(str/starts-with? locale "zh") :chinese
|
||||
:else nil)))
|
||||
|
||||
(defn resolve-ambiguous-cjk
|
||||
"Resolves the ambiguous CJK classes (:han, :cjk-punctuation) to a
|
||||
concrete language: an unambiguous script in the same content wins
|
||||
(kana implies Japanese, hangul implies Korean), then the `locale`
|
||||
(nil when the host has none); Chinese is the final default."
|
||||
[langs locale]
|
||||
(if (or (contains? langs :han)
|
||||
(contains? langs :cjk-punctuation))
|
||||
(let [resolved (cond
|
||||
(contains? langs :japanese) :japanese
|
||||
(contains? langs :korean) :korean
|
||||
:else (or (locale->cjk-language locale) :chinese))]
|
||||
(-> langs
|
||||
(disj :han :cjk-punctuation)
|
||||
(conj resolved)))
|
||||
langs))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
(conj fonts {:font-id "gfont-noto-color-emoji"
|
||||
|
||||
@ -36,6 +36,17 @@
|
||||
["fill-data" "RawFillData"]
|
||||
["text-align" "RawTextAlign"]
|
||||
["text-direction" "RawTextDirection"]
|
||||
["writing-mode" "RawWritingMode"]
|
||||
["text-orientation" "RawTextOrientation"]
|
||||
["text-combine-upright" "RawTextCombineUpright"]
|
||||
["text-emphasis" "RawTextEmphasis"]
|
||||
["warichu" "RawWarichu"]
|
||||
["font-features" "RawFontFeatures"]
|
||||
["annotation-clearance" "RawAnnotationClearance"]
|
||||
["ruby-size" "RawRubySize"]
|
||||
["ruby-align" "RawRubyAlign"]
|
||||
["ruby-overhang" "RawRubyOverhang"]
|
||||
["ruby-side" "RawRubySide"]
|
||||
["text-decoration" "RawTextDecoration"]
|
||||
["text-transform" "RawTextTransform"]
|
||||
["multiple-state" "MultipleState"]
|
||||
|
||||
@ -302,6 +302,78 @@
|
||||
[text-direction]
|
||||
(untranslate "text-direction" text-direction "ltr"))
|
||||
|
||||
(defn translate-writing-mode
|
||||
[writing-mode]
|
||||
(let [values (unchecked-get wasm/serializers "writing-mode")
|
||||
default (unchecked-get values "horizontal-tb")]
|
||||
(d/nilv (unchecked-get values (d/name writing-mode)) default)))
|
||||
|
||||
(defn translate-text-orientation
|
||||
[text-orientation]
|
||||
(let [values (unchecked-get wasm/serializers "text-orientation")
|
||||
default (unchecked-get values "mixed")]
|
||||
(d/nilv (unchecked-get values (d/name text-orientation)) default)))
|
||||
|
||||
(defn translate-text-combine-upright
|
||||
[text-combine-upright]
|
||||
(let [values (unchecked-get wasm/serializers "text-combine-upright")
|
||||
default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name text-combine-upright)) default)))
|
||||
|
||||
(defn translate-text-emphasis
|
||||
[text-emphasis]
|
||||
(let [values (unchecked-get wasm/serializers "text-emphasis")
|
||||
default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name text-emphasis)) default)))
|
||||
|
||||
(defn translate-warichu
|
||||
[warichu]
|
||||
(let [values (unchecked-get wasm/serializers "warichu")
|
||||
default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name warichu)) default)))
|
||||
|
||||
(defn translate-font-features
|
||||
[font-features]
|
||||
(if-let [values (unchecked-get wasm/serializers "font-features")]
|
||||
(let [default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name font-features)) default))
|
||||
0))
|
||||
|
||||
(defn translate-annotation-clearance
|
||||
[annotation-clearance]
|
||||
(if-let [values (unchecked-get wasm/serializers "annotation-clearance")]
|
||||
(let [default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name annotation-clearance)) default))
|
||||
0))
|
||||
|
||||
(defn translate-ruby-size
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-size")]
|
||||
(let [default (unchecked-get values "half")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
|
||||
(defn translate-ruby-align
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-align")]
|
||||
(let [default (unchecked-get values "space-around")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
|
||||
(defn translate-ruby-overhang
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-overhang")]
|
||||
(let [default (unchecked-get values "auto")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
|
||||
(defn translate-ruby-side
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-side")]
|
||||
(let [default (unchecked-get values "over")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
|
||||
|
||||
(defn translate-font-style
|
||||
[font-style]
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
(ns app.common.render-wasm.text-content
|
||||
"Single source of truth for writing a text shape's content into the WASM design
|
||||
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
|
||||
state. The binary layout ([num-spans][paragraph attrs][span attrs][text][ruby-text]) is
|
||||
identical for the workspace and the headless exporter, and so is the font-id
|
||||
-> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs —
|
||||
the workspace has a loaded fonts DB, the exporter does not — so that part is
|
||||
@ -24,8 +24,8 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 16)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 80)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(def ^:private default-font-size 14)
|
||||
@ -75,6 +75,13 @@
|
||||
|
||||
;; --- binary writers --------------------------------------------------------
|
||||
|
||||
(defn- span-ruby
|
||||
"Ruby annotation text sent to WASM; hidden ruby is omitted."
|
||||
[span]
|
||||
(if (true? (:ruby-hidden span))
|
||||
""
|
||||
(get span :ruby "")))
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance."
|
||||
[text]
|
||||
@ -104,17 +111,24 @@
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
writing-mode (sr/translate-writing-mode (get paragraph :writing-mode))
|
||||
text-orientation (sr/translate-text-orientation (get paragraph :text-orientation))
|
||||
line-height (serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview writing-mode)
|
||||
(mem/write-u8 dview text-orientation)
|
||||
;; Alignment padding; must match RawParagraphData in Rust.
|
||||
(mem/write-u8 dview 0)
|
||||
(mem/write-u8 dview 0)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
@ -135,6 +149,8 @@
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
ruby-buffer (encode-text (span-ruby span))
|
||||
ruby-length (mem/size ruby-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
font-variant-id (get span :font-variant-id)
|
||||
font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero)
|
||||
@ -146,12 +162,36 @@
|
||||
(sr/translate-text-transform "none"))
|
||||
text-direction (or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
(sr/translate-text-direction "ltr"))
|
||||
text-orientation (sr/translate-text-orientation
|
||||
(get span :text-orientation (get paragraph :text-orientation)))
|
||||
text-combine-upright (sr/translate-text-combine-upright (get span :text-combine-upright))
|
||||
text-emphasis (sr/translate-text-emphasis (get span :text-emphasis))
|
||||
warichu (sr/translate-warichu (get span :warichu))
|
||||
font-features (sr/translate-font-features (get span :font-features))
|
||||
annotation-clearance (sr/translate-annotation-clearance (get span :annotation-clearance))
|
||||
ruby-size (sr/translate-ruby-size (get span :ruby-size))
|
||||
ruby-align (sr/translate-ruby-align (get span :ruby-align))
|
||||
ruby-overhang (sr/translate-ruby-overhang (get span :ruby-overhang))
|
||||
ruby-side (sr/translate-ruby-side (get span :ruby-side))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-orientation)
|
||||
(mem/write-u8 dview text-combine-upright)
|
||||
(mem/write-u8 dview text-emphasis)
|
||||
(mem/write-u8 dview warichu)
|
||||
(mem/write-u8 dview font-features)
|
||||
(mem/write-u8 dview annotation-clearance)
|
||||
(mem/write-u8 dview ruby-size)
|
||||
(mem/write-u8 dview ruby-align)
|
||||
(mem/write-u8 dview ruby-overhang)
|
||||
(mem/write-u8 dview ruby-side)
|
||||
;; Alignment padding; must match RawTextSpan in Rust.
|
||||
(mem/write-u8 dview 0)
|
||||
(mem/write-u8 dview 0)
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
@ -160,6 +200,7 @@
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview ruby-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
(write-span-fills dview fills))))
|
||||
@ -168,7 +209,9 @@
|
||||
|
||||
(defn write-shape-text!
|
||||
"Writes one paragraph's spans + text into WASM and appends it to the current
|
||||
shape via `_set_shape_text_content`.
|
||||
shape via `_set_shape_text_content`. Ruby annotations are concatenated per
|
||||
span, in span order, after the base text; the reader splits them using the
|
||||
per-span byte lengths.
|
||||
|
||||
`opts` injects host-specific font handling:
|
||||
- `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
|
||||
@ -189,7 +232,9 @@
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
ruby-buffer (encode-text (apply str (map span-ruby spans)))
|
||||
ruby-size (mem/size ruby-buffer)
|
||||
total-size (+ 4 metadata-size text-size ruby-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
@ -197,5 +242,6 @@
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview paragraph)
|
||||
(write-spans dview spans paragraph normalize-font-id)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
(mem/write-buffer heapu8 text-buffer)
|
||||
(mem/write-buffer heapu8 ruby-buffer))
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
|
||||
@ -76,7 +76,8 @@
|
||||
(def schema:options
|
||||
[:map {:title "FileOptions"}
|
||||
[:components-v2 {:optional true} ::sm/boolean]
|
||||
[:base-font-size {:optional true} :string]])
|
||||
[:base-font-size {:optional true} :string]
|
||||
[:japanese-layout {:optional true} ::sm/boolean]])
|
||||
|
||||
(def schema:data
|
||||
[:map {:title "FileData"}
|
||||
@ -1252,6 +1253,19 @@
|
||||
(assoc-in file-data [:options :base-font-size] base-font-size))
|
||||
|
||||
|
||||
;; Japanese text layout
|
||||
|
||||
(defn japanese-layout-enabled?
|
||||
[file-data]
|
||||
(true? (get-in file-data [:options :japanese-layout])))
|
||||
|
||||
(defn set-japanese-layout-enabled
|
||||
[file-data enabled?]
|
||||
(if enabled?
|
||||
(assoc-in file-data [:options :japanese-layout] true)
|
||||
(d/update-in-when file-data [:options] dissoc :japanese-layout)))
|
||||
|
||||
|
||||
;; Ref Chains
|
||||
(defn get-ref-chain-until-target-ref
|
||||
"Returns a vector with the shape ref chain until target-ref, including itself"
|
||||
|
||||
@ -36,6 +36,8 @@
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:writing-mode {:optional true} ::sm/text]
|
||||
[:text-orientation {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
@ -52,6 +54,17 @@
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-combine-upright {:optional true} ::sm/text]
|
||||
[:text-emphasis {:optional true} ::sm/text]
|
||||
[:ruby {:optional true} :string]
|
||||
[:ruby-size {:optional true} ::sm/text]
|
||||
[:ruby-align {:optional true} ::sm/text]
|
||||
[:ruby-overhang {:optional true} ::sm/text]
|
||||
[:ruby-side {:optional true} ::sm/text]
|
||||
[:warichu {:optional true} ::sm/text]
|
||||
[:font-features {:optional true} ::sm/text]
|
||||
[:annotation-clearance {:optional true} ::sm/text]
|
||||
[:text-orientation {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
@ -73,6 +86,19 @@
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:rtl {:optional true} :boolean]
|
||||
[:writing-mode {:optional true} ::sm/text]
|
||||
[:text-orientation {:optional true} ::sm/text]
|
||||
[:text-combine-upright {:optional true} ::sm/text]
|
||||
[:text-emphasis {:optional true} ::sm/text]
|
||||
[:ruby {:optional true} :string]
|
||||
[:ruby-size {:optional true} ::sm/text]
|
||||
[:ruby-align {:optional true} ::sm/text]
|
||||
[:ruby-overhang {:optional true} ::sm/text]
|
||||
[:ruby-side {:optional true} ::sm/text]
|
||||
[:warichu {:optional true} ::sm/text]
|
||||
[:font-features {:optional true} ::sm/text]
|
||||
[:annotation-clearance {:optional true} ::sm/text]
|
||||
[:annotation-has-ruby {:optional true} :boolean]
|
||||
[:text {:optional true} :string]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]]])
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[clojure.set :as set]
|
||||
[clojure.walk :as walk]
|
||||
[cuerdas.core :as str]))
|
||||
@ -102,13 +103,21 @@
|
||||
(def paragraph-attrs
|
||||
(d/concat-vec
|
||||
text-align-attrs
|
||||
text-direction-attrs))
|
||||
text-direction-attrs
|
||||
jl/text-writing-mode-attrs
|
||||
jl/text-orientation-attrs))
|
||||
|
||||
(def text-node-attrs
|
||||
(d/concat-vec
|
||||
text-typography-attrs
|
||||
text-font-attrs
|
||||
text-spacing-attrs
|
||||
jl/text-combine-upright-attrs
|
||||
jl/text-emphasis-attrs
|
||||
jl/text-ruby-attrs
|
||||
jl/text-warichu-attrs
|
||||
jl/text-font-features-attrs
|
||||
jl/text-annotation-clearance-attrs
|
||||
text-decoration-attrs
|
||||
text-transform-attrs
|
||||
text-fills))
|
||||
|
||||
62
common/src/app/common/types/text/japanese_layout.cljc
Normal file
@ -0,0 +1,62 @@
|
||||
;; 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])
|
||||
|
||||
;; Paragraph attrs that act as whole-shape properties: every paragraph must
|
||||
;; carry the first paragraph's value, since that one decides the flow.
|
||||
(def whole-shape-paragraph-attrs
|
||||
(into text-writing-mode-attrs text-orientation-attrs))
|
||||
|
||||
(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-hidden
|
||||
: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)))
|
||||
@ -251,6 +251,26 @@
|
||||
"The parent must use the updated bounds of both children")
|
||||
(assert-idempotent data changes))))))
|
||||
|
||||
(t/deftest set-japanese-layout-file-option
|
||||
(let [file-id (uuid/custom 2 2)
|
||||
page-id (uuid/custom 1 1)
|
||||
data (make-file-data file-id page-id)
|
||||
enabled (ch/process-changes data [{:type :set-japanese-layout
|
||||
:enabled true}])
|
||||
disabled (ch/process-changes enabled [{:type :set-japanese-layout
|
||||
:enabled false}])
|
||||
changes (-> (pcb/empty-changes)
|
||||
(pcb/with-file-data data)
|
||||
(pcb/set-japanese-layout true))]
|
||||
(t/is (false? (ctf/japanese-layout-enabled? data)))
|
||||
(t/is (true? (ctf/japanese-layout-enabled? enabled)))
|
||||
(t/is (false? (ctf/japanese-layout-enabled? disabled)))
|
||||
(t/is (not (contains? (:options disabled) :japanese-layout)))
|
||||
(t/is (= [{:type :set-japanese-layout :enabled true}]
|
||||
(:redo-changes changes)))
|
||||
(t/is (= '({:type :set-japanese-layout :enabled false})
|
||||
(:undo-changes changes)))))
|
||||
|
||||
(t/deftest add-obj
|
||||
(let [file-id (uuid/custom 2 2)
|
||||
page-id (uuid/custom 1 1)
|
||||
|
||||
@ -301,11 +301,13 @@
|
||||
(let [texts (for [shape (vals scene)
|
||||
:when (= :text (:type shape))
|
||||
node (or (some->> (:content shape) (tree-seq :children :children)) [])
|
||||
:let [text (:text node)]
|
||||
text [(:text node) (:ruby node)]
|
||||
:when (string? text)]
|
||||
text)
|
||||
emoji? (boolean (some cfnt/contains-emoji? texts))
|
||||
langs (reduce cfnt/collect-used-languages #{} texts)]
|
||||
;; The exporter has no user locale; Han-only text falls back to Chinese.
|
||||
langs (-> (reduce cfnt/collect-used-languages #{} texts)
|
||||
(cfnt/resolve-ambiguous-cjk nil))]
|
||||
(distinct
|
||||
(cond-> (cfnt/add-noto-fonts [] langs)
|
||||
emoji? (cfnt/add-emoji-font)))))
|
||||
|
||||
@ -34,6 +34,10 @@ function mergeBlockData(block, newData) {
|
||||
let data = block.getData();
|
||||
|
||||
for (let key of Object.keys(newData)) {
|
||||
if (newData[key] == null) {
|
||||
data = data.delete(key);
|
||||
continue;
|
||||
}
|
||||
const oldVal = data.get(key);
|
||||
if (oldVal === newData[key]) {
|
||||
data = data.delete(key);
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 3.5v-2h11v2M2.5 12.5v2h11v-2M5 6.25h1.5v3.5M9 6.25h1.5v3.5H9v-1.75h1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 205 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 4V2h12v2M2 12v2h12v-2M3.5 6H5v4M7.25 6h1.5v4h-1.5V8h1.5M11 6h1.5v4H11m0-2h1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 210 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 2.5h6M5 5.5h6M5 8.5h6M5 11.5h6M8 1v13.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 172 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 1.5h6v5H5zM4 9.5l7 2.5-7 2.5M8 10.9v2.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 1.5h6v5H5zM5.5 14l2.5-6 2.5 6M6.5 11.5h3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 175 B |
3
frontend/resources/images/icons/warichu-none.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 2.5h6v4H5zM5 9.5h6v4H5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 156 B |
3
frontend/resources/images/icons/warichu.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 2v12M14 2v12M4.5 3.5h2v3h-2zM9.5 3.5h2v3h-2zM4.5 9.5h2v3h-2zM9.5 9.5h2v3h-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 209 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1.5 2.5h8m-8 3h8m-8 3h6M13 2v11m-2-2 2 2 2-2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.5 1.5v8m-3-8v8m-3-8v6M14 13H3m2-2-2 2 2 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@ -670,6 +670,17 @@
|
||||
(dm/export layout/toggle-layout-flag)
|
||||
(dm/export layout/remove-layout-flag)
|
||||
|
||||
(defn set-japanese-layout
|
||||
[enabled?]
|
||||
(ptk/reify ::set-japanese-layout
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [file-data (dsh/lookup-file-data state)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/set-japanese-layout enabled?))]
|
||||
(rx/of (dch/commit-changes changes))))))
|
||||
|
||||
;; --- Profile
|
||||
|
||||
(defn update-nudge
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.gesture :as wasm-gesture]
|
||||
[app.render-wasm.shape :as wasm.shape]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
@ -1126,21 +1127,32 @@
|
||||
(rx/of (dwu/commit-undo-transaction undo-id))
|
||||
(rx/empty))))))))
|
||||
|
||||
(defn vertical-text-shape?
|
||||
[shape]
|
||||
(wm/vertical-text-content? (get shape :content)))
|
||||
|
||||
;; Pure function to determine next grow-type for text layers
|
||||
(defn next-grow-type
|
||||
[current-grow-type scalev]
|
||||
(cond
|
||||
(= current-grow-type :fixed)
|
||||
:fixed
|
||||
([current-grow-type scalev]
|
||||
(next-grow-type current-grow-type scalev false))
|
||||
|
||||
(and (not (mth/close? (:y scalev) 1.0))
|
||||
(or (= current-grow-type :auto-width)
|
||||
(= current-grow-type :auto-height)))
|
||||
:fixed
|
||||
([current-grow-type scalev vertical?]
|
||||
;; Reuse the horizontal state machine with swapped vertical axes.
|
||||
(let [scalev (if vertical?
|
||||
(gpt/point (:y scalev) (:x scalev))
|
||||
scalev)]
|
||||
(cond
|
||||
(= current-grow-type :fixed)
|
||||
:fixed
|
||||
|
||||
(and (not (mth/close? (:x scalev) 1.0))
|
||||
(= current-grow-type :auto-width))
|
||||
:auto-height
|
||||
(and (not (mth/close? (:y scalev) 1.0))
|
||||
(or (= current-grow-type :auto-width)
|
||||
(= current-grow-type :auto-height)))
|
||||
:fixed
|
||||
|
||||
:else
|
||||
current-grow-type))
|
||||
(and (not (mth/close? (:x scalev) 1.0))
|
||||
(= current-grow-type :auto-width))
|
||||
:auto-height
|
||||
|
||||
:else
|
||||
current-grow-type))))
|
||||
|
||||
@ -184,6 +184,9 @@
|
||||
(rx/empty)))
|
||||
(wrf/with-pending :font ids))))
|
||||
|
||||
(def ruby-presentation-attrs
|
||||
[:ruby-hidden :ruby-size :ruby-align :ruby-overhang :ruby-side])
|
||||
|
||||
;; -- Content helpers
|
||||
|
||||
;; Style attrs typed as `::sm/text` in the content schema (see
|
||||
@ -435,6 +438,13 @@
|
||||
[{:keys [attrs shape]}]
|
||||
(shape-current-values shape txt/is-root-node? attrs))
|
||||
|
||||
(defn current-ruby-values
|
||||
[{:keys [attrs shape]}]
|
||||
(shape-current-values shape
|
||||
#(and (txt/is-text-node? %)
|
||||
(not (str/blank? (:ruby %))))
|
||||
attrs))
|
||||
|
||||
(defn v3-current-text-values
|
||||
[{:keys [editor-styles attrs]}]
|
||||
(let [result (-> editor-styles
|
||||
@ -461,7 +471,8 @@
|
||||
(defn current-paragraph-values
|
||||
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
|
||||
(cond
|
||||
(some? editor-styles) (v3-current-text-values options)
|
||||
(some? editor-styles) (merge (shape-current-values shape txt/is-paragraph-node? attrs)
|
||||
(select-keys editor-styles attrs))
|
||||
(some? editor-instance) (v2-current-text-values options)
|
||||
(some? editor-state) (v1-current-paragraph-values options)
|
||||
:else (shape-current-values shape txt/is-paragraph-node? attrs)))
|
||||
@ -499,7 +510,6 @@
|
||||
|
||||
(count (:text node)))))
|
||||
|
||||
|
||||
(defn decorate-range-info
|
||||
"Adds information about ranges inside the metadata of the text nodes"
|
||||
[content]
|
||||
@ -622,30 +632,32 @@
|
||||
|
||||
(defn update-paragraph-attrs
|
||||
[{:keys [id attrs]}]
|
||||
(let [attrs (d/without-nils attrs)]
|
||||
(ptk/reify ::update-paragraph-attrs
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs))
|
||||
(ptk/reify ::update-paragraph-attrs
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when-not (some? (get-in state [:workspace-editor-state id]))
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when-not (some? (get-in state [:workspace-editor-state id]))
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
|
||||
merge-fn (fn [node attrs]
|
||||
(reduce-kv
|
||||
(fn [node k v] (assoc node k v))
|
||||
node
|
||||
attrs))
|
||||
merge-fn (fn [node attrs]
|
||||
(reduce-kv
|
||||
(fn [node k v]
|
||||
(if (nil? v)
|
||||
(dissoc node k)
|
||||
(assoc node k v)))
|
||||
node
|
||||
attrs))
|
||||
|
||||
update-fn #(txt/update-text-content % txt/is-paragraph-node? merge-fn attrs)
|
||||
shape-ids (cond
|
||||
(cfh/text-shape? shape) [id]
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
|
||||
update-fn #(txt/update-text-content % txt/is-paragraph-node? merge-fn attrs)
|
||||
shape-ids (cond
|
||||
(cfh/text-shape? shape) [id]
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
|
||||
|
||||
(rx/of (dwsh/update-shapes shape-ids update-fn))))))))
|
||||
(rx/of (dwsh/update-shapes shape-ids update-fn)))))))
|
||||
|
||||
(defn update-text-attrs
|
||||
[{:keys [id attrs]}]
|
||||
@ -677,6 +689,51 @@
|
||||
updated-shape))]
|
||||
(rx/of (dwsh/update-shapes shape-ids merge-shape)))))))
|
||||
|
||||
(defn update-ruby-presentation-attrs
|
||||
[shape attrs]
|
||||
(txt/update-text-content
|
||||
shape
|
||||
#(and (txt/is-text-node? %)
|
||||
(not (str/blank? (:ruby %))))
|
||||
d/txt-merge
|
||||
attrs))
|
||||
|
||||
(defn update-ruby-presentation
|
||||
[id attrs]
|
||||
(ptk/reify ::update-ruby-presentation
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
wasm? (features/active-feature? state "render-wasm/v1")
|
||||
shape-ids (cond
|
||||
(cfh/text-shape? shape) [id]
|
||||
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
||||
update-fn (fn [shape]
|
||||
(let [updated-shape (update-ruby-presentation-attrs shape attrs)]
|
||||
(when (and wasm? (cfh/text-shape? updated-shape))
|
||||
(wasm.text-editor/cache-shape-text-content!
|
||||
(:id updated-shape)
|
||||
(:content updated-shape)))
|
||||
updated-shape))]
|
||||
(rx/concat
|
||||
(rx/of (dwsh/update-shapes shape-ids update-fn))
|
||||
(if wasm?
|
||||
(rx/of (dwwt/resize-wasm-text-all shape-ids))
|
||||
(rx/empty)))))))
|
||||
|
||||
(defn update-all-ruby-presentation
|
||||
[ids attrs]
|
||||
(ptk/reify ::update-all-ruby-presentation
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(let [undo-id (js/Symbol)]
|
||||
(rx/concat
|
||||
(rx/of (dwu/start-undo-transaction undo-id))
|
||||
(->> (rx/from ids)
|
||||
(rx/map #(update-ruby-presentation % attrs)))
|
||||
(rx/of (dwu/commit-undo-transaction undo-id)))))))
|
||||
|
||||
(defn migrate-node
|
||||
[node]
|
||||
(let [color-attrs (not-empty (select-keys node types.fills/fill-attrs))]
|
||||
|
||||
@ -233,7 +233,9 @@
|
||||
;; Calculate new grow-type for text layers
|
||||
new-grow-type
|
||||
(when (cfh/text-shape? shape)
|
||||
(dwm/next-grow-type (dm/get-prop shape :grow-type) scalev))
|
||||
(dwm/next-grow-type (dm/get-prop shape :grow-type)
|
||||
scalev
|
||||
(dwm/vertical-text-shape? shape)))
|
||||
|
||||
;; When the horizontal/vertical scale a flex children with auto/fill
|
||||
;; we change it too fixed
|
||||
@ -551,7 +553,9 @@
|
||||
new-height (if (= attr :height) value sr-height)
|
||||
scalev (gpt/point (/ new-width sr-width) (/ new-height sr-height))
|
||||
current-grow-type (dm/get-prop shape :grow-type)
|
||||
new-grow-type (dwm/next-grow-type current-grow-type scalev)]
|
||||
new-grow-type (dwm/next-grow-type current-grow-type
|
||||
scalev
|
||||
(dwm/vertical-text-shape? shape))]
|
||||
(cond-> modifiers
|
||||
(not= new-grow-type current-grow-type)
|
||||
(ctm/change-property :grow-type new-grow-type)))
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.render-wasm.api.select :as wselect]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace :as-alias dw]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
@ -29,6 +30,31 @@
|
||||
|
||||
(def debounce-resize-text-time 40)
|
||||
|
||||
(defn resolve-text-size
|
||||
"Selects the axes controlled by WASM for a text grow type. Vertical
|
||||
auto-height keeps the physical height as its wrap budget and grows width."
|
||||
[selrect grow-type content dimension]
|
||||
(when (or (= :fixed grow-type) (some? dimension))
|
||||
(let [vertical? (jl/vertical-text-content? content)]
|
||||
{:width (cond
|
||||
(= :fixed grow-type)
|
||||
(:width selrect)
|
||||
|
||||
(and (= :auto-height grow-type) (not vertical?))
|
||||
(:width selrect)
|
||||
|
||||
:else
|
||||
(:width dimension))
|
||||
:height (cond
|
||||
(= :fixed grow-type)
|
||||
(:height selrect)
|
||||
|
||||
(and (= :auto-height grow-type) vertical?)
|
||||
(:height selrect)
|
||||
|
||||
:else
|
||||
(:height dimension))})))
|
||||
|
||||
(defn get-wasm-text-new-size
|
||||
"Computes the new {width, height} for a text shape from WASM text layout.
|
||||
For :fixed grow-type, updates WASM content and returns current dimensions (no resize)."
|
||||
@ -51,14 +77,7 @@
|
||||
(wasm.api/set-shape-text-images id content))
|
||||
(let [dimension (when (not= :fixed grow-type)
|
||||
(wasm.api/get-text-dimensions))]
|
||||
;; nil dimension = shape not present in WASM state; skip the resize.
|
||||
(when (or (= :fixed grow-type) (some? dimension))
|
||||
{:width (if (#{:fixed :auto-height} grow-type)
|
||||
(:width selrect)
|
||||
(:width dimension))
|
||||
:height (if (= :fixed grow-type)
|
||||
(:height selrect)
|
||||
(:height dimension))})))))
|
||||
(resolve-text-size selrect grow-type content dimension)))))
|
||||
|
||||
(defn resize-wasm-text-modifiers
|
||||
([shape]
|
||||
|
||||
@ -17,6 +17,11 @@
|
||||
[name rotation]
|
||||
(dm/str "cursor-" name "-" (mod (* (.floor js/Math (/ rotation angle-step)) angle-step) 360)))
|
||||
|
||||
(defn get-text
|
||||
"Returns the text cursor class, rotating the I-beam with vertical text flow."
|
||||
[rotation vertical?]
|
||||
(get-dynamic "text" (+ (or rotation 0) (if vertical? 90 0))))
|
||||
|
||||
(defn init-static-cursor-style
|
||||
"Initializes a static cursor style"
|
||||
[style name value]
|
||||
|
||||
@ -275,6 +275,9 @@
|
||||
(def ^:icon-id text-auto-height "text-auto-height")
|
||||
(def ^:icon-id text-auto-width "text-auto-width")
|
||||
(def ^:icon-id text-bottom "text-bottom")
|
||||
(def ^:icon-id text-combine-upright-all "text-combine-upright-all")
|
||||
(def ^:icon-id text-combine-upright-digits "text-combine-upright-digits")
|
||||
(def ^:icon-id text-combine-upright-none "text-combine-upright-none")
|
||||
(def ^:icon-id text-fixed "text-fixed")
|
||||
(def ^:icon-id text-font-family "text-font-family")
|
||||
(def ^:icon-id text-font-size "text-font-size")
|
||||
@ -286,6 +289,8 @@
|
||||
(def ^:icon-id text-ltr "text-ltr")
|
||||
(def ^:icon-id text-middle "text-middle")
|
||||
(def ^:icon-id text-mixed "text-mixed")
|
||||
(def ^:icon-id text-orientation-mixed "text-orientation-mixed")
|
||||
(def ^:icon-id text-orientation-upright "text-orientation-upright")
|
||||
(def ^:icon-id text-palette "text-palette")
|
||||
(def ^:icon-id text-paragraph "text-paragraph")
|
||||
(def ^:icon-id text-rtl "text-rtl")
|
||||
@ -308,7 +313,11 @@
|
||||
(def ^:icon-id vertical-align-items-start "vertical-align-items-start")
|
||||
(def ^:icon-id view-as-icons "view-as-icons")
|
||||
(def ^:icon-id view-as-list "view-as-list")
|
||||
(def ^:icon-id warichu "warichu")
|
||||
(def ^:icon-id warichu-none "warichu-none")
|
||||
(def ^:icon-id wrap "wrap")
|
||||
(def ^:icon-id writing-mode-horizontal "writing-mode-horizontal")
|
||||
(def ^:icon-id writing-mode-vertical "writing-mode-vertical")
|
||||
|
||||
(def icon-list
|
||||
"A collection of all icons"
|
||||
|
||||
@ -246,6 +246,9 @@
|
||||
(def ^:icon text-auto-height (icon-xref :text-auto-height))
|
||||
(def ^:icon text-auto-width (icon-xref :text-auto-width))
|
||||
(def ^:icon text-bottom (icon-xref :text-bottom))
|
||||
(def ^:icon text-combine-upright-all (icon-xref :text-combine-upright-all))
|
||||
(def ^:icon text-combine-upright-digits (icon-xref :text-combine-upright-digits))
|
||||
(def ^:icon text-combine-upright-none (icon-xref :text-combine-upright-none))
|
||||
(def ^:icon text-fixed (icon-xref :text-fixed))
|
||||
(def ^:icon text-font-family (icon-xref :text-font-family))
|
||||
(def ^:icon text-font-size (icon-xref :text-font-size))
|
||||
@ -257,6 +260,8 @@
|
||||
(def ^:icon text-ltr (icon-xref :text-ltr))
|
||||
(def ^:icon text-middle (icon-xref :text-middle))
|
||||
(def ^:icon text-mixed (icon-xref :text-mixed))
|
||||
(def ^:icon text-orientation-mixed (icon-xref :text-orientation-mixed))
|
||||
(def ^:icon text-orientation-upright (icon-xref :text-orientation-upright))
|
||||
(def ^:icon text-palette (icon-xref :text-palette))
|
||||
(def ^:icon text-paragraph (icon-xref :text-paragraph))
|
||||
(def ^:icon text-rtl (icon-xref :text-rtl))
|
||||
@ -283,7 +288,11 @@
|
||||
(def ^:icon vertical-align-items-start (icon-xref :vertical-align-items-start))
|
||||
(def ^:icon view-as-icons (icon-xref :view-as-icons))
|
||||
(def ^:icon view-as-list (icon-xref :view-as-list))
|
||||
(def ^:icon warichu (icon-xref :warichu))
|
||||
(def ^:icon warichu-none (icon-xref :warichu-none))
|
||||
(def ^:icon wrap (icon-xref :wrap))
|
||||
(def ^:icon writing-mode-horizontal (icon-xref :writing-mode-horizontal))
|
||||
(def ^:icon writing-mode-vertical (icon-xref :writing-mode-vertical))
|
||||
|
||||
(def default
|
||||
"A collection of all icons"
|
||||
|
||||
@ -115,10 +115,37 @@
|
||||
:on-change handle-render-change}]]
|
||||
[:> text* {:typography t/body-medium :class (stl/css :feedback)} [:a {:href "#" :on-click go-settings-feedback :class (stl/css :link)} (tr "dashboard.webgl-switch.feedback") [:> icon* {:icon-id "arrow-up-right" :size "s"}]]]]))
|
||||
|
||||
(mf/defc japanese-layout-settings*
|
||||
[{:keys [enabled]}]
|
||||
(let [handle-change
|
||||
(mf/use-fn
|
||||
(fn [enabled?]
|
||||
(st/emit! (ev/event {::ev/name (if enabled?
|
||||
"enable-japanese-layout-all-files"
|
||||
"disable-japanese-layout-all-files")
|
||||
::ev/origin "settings"})
|
||||
(du/update-profile-props
|
||||
{:japanese-layout-all-files enabled?})
|
||||
(ntf/success (tr "notifications.profile-saved")))))]
|
||||
[:section {:class (stl/css :japanese-layout-container)}
|
||||
[:> heading* {:class (stl/css :title)
|
||||
:level 2
|
||||
:typography t/title-large}
|
||||
(tr "dashboard.japanese-layout.title")]
|
||||
[:> text* {:class (stl/css :description) :typography t/body-medium}
|
||||
(tr "dashboard.japanese-layout.description")]
|
||||
[:form {:class (stl/css :japanese-layout-form)}
|
||||
[:> switch* {:label (tr "dashboard.japanese-layout.enable-all-files")
|
||||
:default-checked enabled
|
||||
:on-change handle-change}]]]))
|
||||
|
||||
(mf/defc options-page*
|
||||
[]
|
||||
(let [profile (mf/deref refs/profile)
|
||||
renderer (or (-> profile :props :renderer) :svg)]
|
||||
renderer (or (-> profile :props :renderer) :svg)
|
||||
japanese-layout-enabled (true? (-> profile
|
||||
:props
|
||||
:japanese-layout-all-files))]
|
||||
(mf/use-effect
|
||||
#(dom/set-html-title (tr "title.settings.options")))
|
||||
|
||||
@ -132,5 +159,6 @@
|
||||
:id "options-section-title"}
|
||||
(tr "labels.settings")]
|
||||
[:> options-form*]]
|
||||
[:> japanese-layout-settings* {:enabled japanese-layout-enabled}]
|
||||
(when (contains? cf/flags :render-switch)
|
||||
[:> webgl-settings* {:renderer renderer}])]]))
|
||||
|
||||
@ -21,9 +21,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Copied from profile.scss .form-container, but without included nested
|
||||
rules, since we want custom styles for it */
|
||||
.webgl-container {
|
||||
/* Settings sections with standalone controls. */
|
||||
.webgl-container,
|
||||
.japanese-layout-container {
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
gap: var(--sp-s);
|
||||
@ -34,6 +34,10 @@
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.japanese-layout-form {
|
||||
padding-block-start: var(--sp-xs);
|
||||
}
|
||||
|
||||
.webgl-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.types.color :as cc]
|
||||
[app.main.ui.shapes.text.styles :as sts]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@ -19,9 +20,17 @@
|
||||
(let [text (:text node)
|
||||
style (if (= text "")
|
||||
(sts/generate-text-styles shape parent)
|
||||
(sts/generate-text-styles shape node))]
|
||||
[:span.text-node {:style style}
|
||||
(if (= text "") "\u00A0" text)]))
|
||||
(sts/generate-text-styles shape node))
|
||||
ruby (:ruby node)
|
||||
ruby? (and (not (true? (:ruby-hidden node)))
|
||||
(string? ruby)
|
||||
(not (str/blank? ruby)))]
|
||||
(if ruby?
|
||||
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
||||
[:span.text-node {:style style} text]
|
||||
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
||||
[:span.text-node {:style style}
|
||||
(if (= text "") "\u00A0" text)])))
|
||||
|
||||
(mf/defc render-root*
|
||||
[{:keys [node children shape]}]
|
||||
@ -166,6 +175,11 @@
|
||||
height (dm/get-prop shape :height)
|
||||
content (get shape :content)
|
||||
|
||||
;; Vertical writing anchors columns to the box edges, so the oversized
|
||||
;; auto-grow box used to avoid horizontal wrapping/clipping would push
|
||||
;; the content off-position. Use the real selrect size instead.
|
||||
vertical? (wm/vertical-text-content? content)
|
||||
|
||||
[colors _color-mapping color-mapping-inverse] (retrieve-colors shape)]
|
||||
|
||||
[:foreignObject
|
||||
@ -175,8 +189,8 @@
|
||||
:data-colors (str/join "," colors)
|
||||
:data-mapping (-> color-mapping-inverse clj->js js/JSON.stringify)
|
||||
:transform transform
|
||||
:width (if (#{:auto-width} grow-type) 100000 width)
|
||||
:height (if (#{:auto-height :auto-width} grow-type) 100000 height)
|
||||
:width (if (and (not vertical?) (#{:auto-width} grow-type)) 100000 width)
|
||||
:height (if (and (not vertical?) (#{:auto-height :auto-width} grow-type)) 100000 height)
|
||||
:ref ref}
|
||||
;; We use a class here because react has a bug that won't use the appropriate selector for
|
||||
;; `background-clip`
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.text :as legacy.txt]
|
||||
[app.main.ui.shapes.text.styles :as sts]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc render-text*
|
||||
@ -18,9 +20,17 @@
|
||||
style (if (= text "")
|
||||
(sts/generate-text-styles shape parent)
|
||||
(sts/generate-text-styles shape node))
|
||||
class (when is-code (:$id node))]
|
||||
[:span.text-node {:style style :class class}
|
||||
(if (= text "") "\u00A0" text)]))
|
||||
class (when is-code (:$id node))
|
||||
ruby (:ruby node)
|
||||
ruby? (and (not (true? (:ruby-hidden node)))
|
||||
(string? ruby)
|
||||
(not (str/blank? ruby)))]
|
||||
(if ruby?
|
||||
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
||||
[:span.text-node {:style style :class class} text]
|
||||
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
||||
[:span.text-node {:style style :class class}
|
||||
(if (= text "") "\u00A0" text)])))
|
||||
|
||||
(mf/defc render-root*
|
||||
[{:keys [node children shape is-code]}]
|
||||
@ -75,14 +85,19 @@
|
||||
|
||||
content (if is-code (legacy.txt/index-content content) content)
|
||||
|
||||
;; Vertical writing anchors columns to the box edges; the oversized
|
||||
;; auto-grow box (used to avoid horizontal wrapping) would push content
|
||||
;; off-position, so use the real selrect size instead.
|
||||
vertical? (wm/vertical-text-content? content)
|
||||
|
||||
style
|
||||
(when-not is-code
|
||||
#js {:position "fixed"
|
||||
:left 0
|
||||
:top 0
|
||||
:background "white"
|
||||
:width (if (#{:auto-width} grow-type) 100000 width)
|
||||
:height (if (#{:auto-height :auto-width} grow-type) 100000 height)})]
|
||||
:width (if (and (not vertical?) (#{:auto-width} grow-type)) 100000 width)
|
||||
:height (if (and (not vertical?) (#{:auto-height :auto-width} grow-type)) 100000 height)})]
|
||||
|
||||
[:div.text-node-html
|
||||
{:id (dm/str "html-text-node-" id)
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
[app.main.ui.formats :as fmt]
|
||||
[app.util.color :as uc]
|
||||
[app.util.object :as obj]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn generate-root-styles
|
||||
@ -21,6 +22,9 @@
|
||||
(generate-root-styles props node false))
|
||||
([{:keys [width height]} node code?]
|
||||
(let [valign (:vertical-align node "top")
|
||||
;; Mirroring the shape's writing mode on the root makes paragraph
|
||||
;; blocks stack right-to-left.
|
||||
writing-mode (wm/content-writing-mode node)
|
||||
base #js {:height (when-not code? (fmt/format-pixels height))
|
||||
:width (when-not code? (fmt/format-pixels width))
|
||||
:display "flex"
|
||||
@ -28,7 +32,8 @@
|
||||
(cond-> base
|
||||
(= valign "top") (obj/set! "alignItems" "flex-start")
|
||||
(= valign "center") (obj/set! "alignItems" "center")
|
||||
(= valign "bottom") (obj/set! "alignItems" "flex-end")))))
|
||||
(= valign "bottom") (obj/set! "alignItems" "flex-end")
|
||||
(some? writing-mode) (obj/set! "writingMode" writing-mode)))))
|
||||
|
||||
(defn generate-paragraph-set-styles
|
||||
[{:keys [grow-type] :as shape}]
|
||||
@ -56,6 +61,9 @@
|
||||
(:line-height txt/default-typography))
|
||||
|
||||
text-align (:text-align data "start")
|
||||
vertical-layout? (wm/vertical-layout-active?)
|
||||
writing-mode (when vertical-layout? (:writing-mode data))
|
||||
text-orientation (when vertical-layout? (:text-orientation data))
|
||||
base #js {;; Fix a problem when exporting HTML
|
||||
:fontSize 0
|
||||
:lineHeight line-height
|
||||
@ -63,7 +71,21 @@
|
||||
|
||||
(cond-> base
|
||||
(some? line-height) (obj/set! "lineHeight" line-height)
|
||||
(some? text-align) (obj/set! "textAlign" text-align))))
|
||||
(some? text-align) (obj/set! "textAlign" text-align)
|
||||
(some? writing-mode) (obj/set! "writingMode" writing-mode)
|
||||
(some? writing-mode) (obj/set! "textSpacingTrim" "normal")
|
||||
(= writing-mode "vertical-rl") (obj/set! "textAutospace" "normal")
|
||||
(some? text-orientation) (obj/set! "textOrientation" text-orientation))))
|
||||
|
||||
(defn css-text-combine-upright
|
||||
"CSS value for a persisted text-combine-upright: the digits variants
|
||||
serialize with their max run length per the CSS `digits <n>` syntax."
|
||||
[value]
|
||||
(case value
|
||||
"digits" "digits 4"
|
||||
"digits2" "digits 2"
|
||||
"digits3" "digits 3"
|
||||
value))
|
||||
|
||||
(defn generate-text-styles
|
||||
([shape data]
|
||||
@ -71,6 +93,25 @@
|
||||
|
||||
([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}]
|
||||
(let [letter-spacing (:letter-spacing data 0)
|
||||
text-combine-upright (:text-combine-upright data)
|
||||
text-emphasis (:text-emphasis data)
|
||||
font-features (:font-features data)
|
||||
annotation-clearance (:annotation-clearance data)
|
||||
annotation-layers (if (= "auto" annotation-clearance)
|
||||
(+ (if (and (not (true? (:ruby-hidden data)))
|
||||
(string? (:ruby data))
|
||||
(seq (:ruby data))) 1 0)
|
||||
(if (and (string? text-emphasis)
|
||||
(not= "none" text-emphasis)) 1 0))
|
||||
0)
|
||||
line-height-num (js/parseFloat (or (:line-height data)
|
||||
(:line-height txt/default-typography)))
|
||||
auto-line-height (when (and (pos? annotation-layers)
|
||||
(not (js/isNaN line-height-num)))
|
||||
(+ line-height-num (* annotation-layers 0.5)))
|
||||
warichu? (and (= "warichu" (:warichu data))
|
||||
(string? (:text data))
|
||||
(>= (count (:text data)) 2))
|
||||
text-decoration (:text-decoration data)
|
||||
text-transform (:text-transform data)
|
||||
|
||||
@ -145,6 +186,26 @@
|
||||
(and (string? letter-spacing) (pos? (alength letter-spacing)))
|
||||
(obj/set! "letterSpacing" (str letter-spacing "px"))
|
||||
|
||||
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
|
||||
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
|
||||
|
||||
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
|
||||
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
|
||||
(and (string? text-emphasis) (pos? (alength text-emphasis))
|
||||
(not= "none" text-emphasis))
|
||||
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
|
||||
|
||||
(and (string? font-features) (pos? (alength font-features))
|
||||
(not= "none" font-features))
|
||||
(obj/set! "fontFeatureSettings" (str/format "\"%s\"" font-features))
|
||||
|
||||
(and (string? annotation-clearance)
|
||||
(pos? (alength annotation-clearance)))
|
||||
(obj/set! "--annotation-clearance" annotation-clearance)
|
||||
|
||||
(some? auto-line-height)
|
||||
(obj/set! "lineHeight" auto-line-height)
|
||||
|
||||
(and (string? font-size) (pos? (alength font-size)))
|
||||
(obj/set! "fontSize" (str font-size "px"))
|
||||
|
||||
@ -154,4 +215,38 @@
|
||||
(obj/set! "fontWeight" font-weight))
|
||||
|
||||
(= grow-type :auto-width)
|
||||
(obj/set! "whiteSpace" "pre")))))
|
||||
(obj/set! "whiteSpace" "pre")
|
||||
|
||||
;; Warichu (割注) CSS emulation: an inline-block at half size whose
|
||||
;; inline-size fits half the characters, so the browser wraps it into
|
||||
;; two half-size sub-lines within one inline position in either writing
|
||||
;; mode.
|
||||
warichu?
|
||||
(-> (obj/set! "display" "inline-block")
|
||||
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))
|
||||
(str (/ (js/parseFloat font-size) 2) "px")
|
||||
"50%"))
|
||||
(obj/set! "lineHeight" "1")
|
||||
(obj/set! "inlineSize"
|
||||
(str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em")))))))
|
||||
|
||||
(defn generate-ruby-styles
|
||||
[shape data]
|
||||
(-> (generate-text-styles shape data)
|
||||
(obj/set! "fontSize" (case (:ruby-size data)
|
||||
"third" "33.333333%"
|
||||
"quarter" "25%"
|
||||
"50%"))
|
||||
(obj/set! "lineHeight" "1")
|
||||
(obj/set! "textDecoration" "none")
|
||||
(obj/unset! "textCombineUpright")))
|
||||
|
||||
(defn generate-ruby-container-styles
|
||||
[data]
|
||||
#js {:rubyPosition (if (= "under" (:ruby-side data)) "under" "over")
|
||||
:rubyAlign (case (:ruby-align data)
|
||||
"center" "center"
|
||||
"start" "start"
|
||||
"space-between" "space-between"
|
||||
"space-around")
|
||||
:rubyOverhang (if (= "none" (:ruby-overhang data)) "none" "auto")})
|
||||
|
||||
@ -15,11 +15,96 @@
|
||||
[app.main.ui.shapes.custom-stroke :refer [shape-custom-strokes]]
|
||||
[app.main.ui.shapes.fills :as fills]
|
||||
[app.main.ui.shapes.gradients :as grad]
|
||||
[app.main.ui.shapes.text.styles :as sts]
|
||||
[app.util.object :as obj]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def fill-attrs [:fill-color :fill-color-gradient :fill-opacity])
|
||||
|
||||
(defn- ruby-font-scale
|
||||
[ruby-size]
|
||||
(case ruby-size
|
||||
"third" (/ 1 3)
|
||||
"quarter" 0.25
|
||||
0.5))
|
||||
|
||||
(def ^:private emphasis-font-scale 0.5)
|
||||
|
||||
(def ^:private warichu-font-scale 0.5)
|
||||
|
||||
;; Kinsoku classes for the warichu sub-line split (same characters the
|
||||
;; renderer's kinsoku module suppresses at line boundaries).
|
||||
(def ^:private warichu-forbidden-at-start
|
||||
(str "、。,.)」』]】〕〉》’”!?;:ー"
|
||||
"ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ"
|
||||
"々ゝゞヽヾ・"))
|
||||
|
||||
(def ^:private warichu-forbidden-at-end "(「『[【〔〈《‘“")
|
||||
|
||||
(defn- warichu-split-index
|
||||
"Safe JavaScript string index where a warichu run splits into its two
|
||||
sub-lines. The split is chosen in Unicode code-point space, then translated
|
||||
back to a UTF-16 boundary for `subs`: the
|
||||
balanced midpoint (first sub-line longer), nudged forward then backward
|
||||
so the second sub-line does not start with a line-start-prohibited
|
||||
character and the first does not end with a line-end-prohibited one.
|
||||
Mirrors the renderer's `warichu_split_chars`."
|
||||
[text]
|
||||
(let [characters (vec (js/Array.from text))
|
||||
n (count characters)
|
||||
mid (js/Math.ceil (/ n 2))
|
||||
valid? (fn [split]
|
||||
(and (>= split 1)
|
||||
(< split n)
|
||||
(not (.includes warichu-forbidden-at-start (nth characters split)))
|
||||
(not (.includes warichu-forbidden-at-end (nth characters (dec split))))))
|
||||
split (if (valid? mid)
|
||||
mid
|
||||
(or (->> (range 1 n)
|
||||
(some (fn [distance]
|
||||
(cond
|
||||
(valid? (+ mid distance)) (+ mid distance)
|
||||
(and (> mid distance) (valid? (- mid distance))) (- mid distance)))))
|
||||
mid))]
|
||||
(->> (take split characters)
|
||||
(reduce (fn [index character] (+ index (.-length character))) 0))))
|
||||
|
||||
;; CSS `text-emphasis-style` character mapping (same glyphs the canvas
|
||||
;; renderer shapes for each mark style).
|
||||
(def ^:private emphasis-mark-chars
|
||||
{"filled-dot" "•"
|
||||
"open-dot" "◦"
|
||||
"filled-circle" "●"
|
||||
"open-circle" "○"
|
||||
"filled-sesame" "﹅"
|
||||
"open-sesame" "﹆"})
|
||||
|
||||
(def ^:private emphasis-prohibited-chars
|
||||
"、。,.「」『』()[]【】〔〕〈〉《》‘’“”")
|
||||
|
||||
(defn- emphasis-character?
|
||||
[character]
|
||||
(and (not (re-matches #"\s" character))
|
||||
(not (.includes emphasis-prohibited-chars character))))
|
||||
|
||||
(defn- emphasis-marks-text
|
||||
"One mark per eligible Unicode base character. Spaces replace whitespace
|
||||
and Japanese punctuation that does not normally carry emphasis so marks
|
||||
stay aligned with the base slots."
|
||||
[text mark]
|
||||
(->> (js/Array.from text)
|
||||
(map (fn [character]
|
||||
(if (emphasis-character? character) mark " ")))
|
||||
(apply str)))
|
||||
|
||||
(defn- add-font-features!
|
||||
[style font-features]
|
||||
(cond-> style
|
||||
(and (string? font-features)
|
||||
(not= "none" font-features)
|
||||
(pos? (alength font-features)))
|
||||
(obj/set! "fontFeatureSettings" (dm/str "\"" font-features "\""))))
|
||||
|
||||
(defn set-white-fill
|
||||
[shape]
|
||||
(let [update-color
|
||||
@ -72,32 +157,124 @@
|
||||
[:> :g group-props
|
||||
(for [[index data] (d/enumerate position-data)]
|
||||
(let [rtl? (= "rtl" (:direction data))
|
||||
vertical? (= "vertical-rl" (:writing-mode data))
|
||||
ruby (:ruby data)
|
||||
ruby? (and (string? ruby) (seq ruby))
|
||||
ruby-side (:ruby-side data "over")
|
||||
ruby-align (:ruby-align data "space-around")
|
||||
ruby-overhang (:ruby-overhang data "auto")
|
||||
auto-clearance? (= "auto" (:annotation-clearance data))
|
||||
has-ruby-layer? (and (not= "under" ruby-side)
|
||||
(or ruby? (:annotation-has-ruby data)))
|
||||
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
|
||||
emphasis? (some? emphasis-mark)
|
||||
warichu? (and (= "warichu" (:warichu data))
|
||||
(string? (:text data))
|
||||
(>= (count (:text data)) 2))
|
||||
font-features (:font-features data)
|
||||
|
||||
browser-props
|
||||
(cond
|
||||
(cf/check-browser? :safari)
|
||||
(and (not vertical?) (cf/check-browser? :safari))
|
||||
#js {:dominantBaseline "hanging"
|
||||
:dy "0.2em"
|
||||
:y (- (:y data) (:height data))})
|
||||
|
||||
props (-> #js {:key (dm/str "text-" (:id shape) "-" index)
|
||||
:x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
:y (:y data)
|
||||
:dominantBaseline "ideographic"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (-> #js {:fontFamily (:font-family data)
|
||||
:fontSize (:font-size data)
|
||||
:fontWeight (:font-weight data)
|
||||
:textTransform (:text-transform data)
|
||||
:textDecoration (:text-decoration data)
|
||||
:letterSpacing (:letter-spacing data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:whiteSpace "pre"}
|
||||
(obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
|
||||
(cond-> browser-props
|
||||
(obj/merge! browser-props)))
|
||||
base-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (:font-size data)
|
||||
:fontWeight (:font-weight data)
|
||||
:textTransform (:text-transform data)
|
||||
:textDecoration (:text-decoration data)
|
||||
:textCombineUpright (sts/css-text-combine-upright
|
||||
(:text-combine-upright data))
|
||||
:letterSpacing (:letter-spacing data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:textSpacingTrim "normal"
|
||||
:whiteSpace "pre"}
|
||||
font-features)
|
||||
|
||||
font-size (js/parseFloat (:font-size data))
|
||||
ruby-font-size-num (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size (ruby-font-scale (:ruby-size data))))
|
||||
ruby-font-size (when (pos? ruby-font-size-num)
|
||||
(dm/str ruby-font-size-num "px"))
|
||||
ruby-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize ruby-font-size
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation "upright"
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
|
||||
font-features)
|
||||
|
||||
emphasis-font-size-num (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size emphasis-font-scale))
|
||||
|
||||
warichu-font-size-num (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size warichu-font-scale))
|
||||
warichu-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (when (pos? warichu-font-size-num)
|
||||
(dm/str warichu-font-size-num "px"))
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation (when vertical? "upright")
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
|
||||
font-features)
|
||||
emphasis-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (when (pos? emphasis-font-size-num)
|
||||
(dm/str emphasis-font-size-num "px"))
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation (when vertical? "upright")
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
|
||||
font-features)
|
||||
|
||||
props (if vertical?
|
||||
;; Vertical strip: glyphs run down the column; x is
|
||||
;; the column's central axis and y the strip top
|
||||
;; (stored y is the strip bottom).
|
||||
#js {:key (dm/str "text-" (:id shape) "-" index)
|
||||
:x (+ (:x data) (/ (:width data) 2))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (-> base-style
|
||||
(obj/set! "writingMode" "vertical-rl")
|
||||
(obj/set! "textSpacingTrim" "normal")
|
||||
(obj/set! "textOrientation" (or (:text-orientation data) "mixed"))
|
||||
(obj/set! "textAutospace" "normal")
|
||||
(obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
|
||||
(-> #js {:key (dm/str "text-" (:id shape) "-" index)
|
||||
:x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
:y (:y data)
|
||||
:dominantBaseline "ideographic"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (obj/set! base-style "fill" (str "url(#fill-" index "-" render-id ")"))}
|
||||
(cond-> browser-props
|
||||
(obj/merge! browser-props))))
|
||||
shape (-> shape
|
||||
(assoc :fills (:fills data))
|
||||
;; The text elements have the shadow and blur already applied in the
|
||||
@ -113,4 +290,106 @@
|
||||
[:& fills/fills {:shape shape :render-id render-id}]]
|
||||
|
||||
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
|
||||
[:> :text props (:text data)]]]))]]))
|
||||
(if warichu?
|
||||
;; Warichu: two half-size sub-lines within one inline strip.
|
||||
;; Vertical reading order is right then left; horizontal is
|
||||
;; top then bottom.
|
||||
(let [text (:text data)
|
||||
split-index (warichu-split-index text)
|
||||
centre (+ (:x data) (/ (:width data) 2))
|
||||
quarter (/ warichu-font-size-num 2)
|
||||
top (- (:y data) (:height data))]
|
||||
[:g {:key (dm/str "warichu-" (:id shape) "-" index)}
|
||||
(if vertical?
|
||||
[:*
|
||||
[:> :text {:x (+ centre quarter)
|
||||
:y top
|
||||
:style warichu-style}
|
||||
(subs text 0 split-index)]
|
||||
[:> :text {:x (- centre quarter)
|
||||
:y top
|
||||
:style warichu-style}
|
||||
(subs text split-index)]]
|
||||
[:*
|
||||
[:> :text {:x (:x data)
|
||||
:y top
|
||||
:dominantBaseline "hanging"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style warichu-style}
|
||||
(subs text 0 split-index)]
|
||||
[:> :text {:x (:x data)
|
||||
:y (+ top (/ (:height data) 2))
|
||||
:dominantBaseline "hanging"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style warichu-style}
|
||||
(subs text split-index)]])])
|
||||
[:> :text props (:text data)])]
|
||||
(when ruby?
|
||||
[:> :text (if vertical?
|
||||
#js {:key (dm/str "ruby-" (:id shape) "-" index)
|
||||
:x (if (= "under" ruby-side)
|
||||
(- (:x data) (/ ruby-font-size-num 2))
|
||||
(+ (:x data) (:width data) (/ ruby-font-size-num 2)))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style ruby-style}
|
||||
(let [start-x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
center-x (+ (:x data) (/ (:width data) 2))
|
||||
constrain? (= "none" ruby-overhang)]
|
||||
(cond-> #js {:key (dm/str "ruby-" (:id shape) "-" index)
|
||||
:x (if (= "center" ruby-align) center-x start-x)
|
||||
:y (if (= "under" ruby-side)
|
||||
(:y data)
|
||||
(- (:y data) (:height data)))
|
||||
:dominantBaseline (if (= "under" ruby-side)
|
||||
"hanging"
|
||||
"text-after-edge")
|
||||
:style ruby-style}
|
||||
(= "center" ruby-align)
|
||||
(obj/set! "textAnchor" "middle")
|
||||
|
||||
(= "start" ruby-align)
|
||||
(obj/set! "textAnchor" (if rtl? "end" "start"))
|
||||
|
||||
(or constrain? (= "space-around" ruby-align))
|
||||
(obj/set! "textLength" (:width data))
|
||||
|
||||
(or constrain? (= "space-around" ruby-align))
|
||||
(obj/set! "lengthAdjust" "spacingAndGlyphs")
|
||||
|
||||
(= "space-between" ruby-align)
|
||||
(obj/set! "textLength" (:width data))
|
||||
|
||||
(= "space-between" ruby-align)
|
||||
(obj/set! "lengthAdjust" "spacing"))))
|
||||
ruby])
|
||||
;; Emphasis marks (圏点): one half-size mark per eligible base
|
||||
;; character, right of vertical text or above horizontal text.
|
||||
(when emphasis?
|
||||
[:> :text (if vertical?
|
||||
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
|
||||
:x (+ (:x data)
|
||||
(:width data)
|
||||
(/ emphasis-font-size-num 2)
|
||||
(if (and auto-clearance? has-ruby-layer?)
|
||||
ruby-font-size-num
|
||||
0))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacing"
|
||||
:style emphasis-style}
|
||||
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
|
||||
:x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
:y (- (:y data)
|
||||
(:height data)
|
||||
(if (and auto-clearance? has-ruby-layer?)
|
||||
ruby-font-size-num
|
||||
0))
|
||||
:dominantBaseline "text-after-edge"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacing"
|
||||
:style emphasis-style})
|
||||
(emphasis-marks-text (:text data) emphasis-mark)])]))]]))
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.common :as dcm]
|
||||
@ -44,6 +45,7 @@
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]
|
||||
[rumext.v2 :as mf]))
|
||||
@ -232,8 +234,13 @@
|
||||
(mf/defc preferences-menu*
|
||||
{::mf/private true
|
||||
::mf/wrap [mf/memo]}
|
||||
[{:keys [layout profile toggle-flag on-close toggle-theme toggle-render]}]
|
||||
(let [renderer (or (-> profile :props :renderer) :svg)
|
||||
[{:keys [japanese-layout-enabled layout profile toggle-flag on-close
|
||||
toggle-theme toggle-render toggle-japanese-layout]}]
|
||||
(let [renderer (or (-> profile :props :renderer) :svg)
|
||||
japanese-layout-all-files? (true? (-> profile
|
||||
:props
|
||||
:japanese-layout-all-files))
|
||||
read-only? (mf/use-ctx ctx/workspace-read-only?)
|
||||
|
||||
show-nudge-options
|
||||
(mf/use-fn
|
||||
@ -316,6 +323,21 @@
|
||||
:id "file-menu-nudge"}
|
||||
[:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]]
|
||||
|
||||
(when (and (not read-only?)
|
||||
(not japanese-layout-all-files?)
|
||||
(wm/vertical-layout-active?))
|
||||
[:> dropdown-menu-item* {:on-click toggle-japanese-layout
|
||||
:class (stl/css :base-menu-item :submenu-item)
|
||||
:on-key-down (fn [event]
|
||||
(when (kbd/enter? event)
|
||||
(toggle-japanese-layout event)))
|
||||
:data-testid "japanese-layout"
|
||||
:id "file-menu-japanese-layout"}
|
||||
[:span {:class (stl/css :item-name)}
|
||||
(if japanese-layout-enabled
|
||||
(tr "workspace.header.menu.disable-japanese-layout")
|
||||
(tr "workspace.header.menu.enable-japanese-layout"))]])
|
||||
|
||||
[:> dropdown-menu-item* {:on-click toggle-theme
|
||||
:class (stl/css :base-menu-item :submenu-item)
|
||||
:on-key-down (fn [event]
|
||||
@ -885,6 +907,9 @@
|
||||
(mf/defc menu*
|
||||
[{:keys [layout file]}]
|
||||
(let [profile (mf/deref refs/profile)
|
||||
file-data (mf/deref refs/workspace-data)
|
||||
japanese-layout-enabled
|
||||
(ctf/japanese-layout-enabled? file-data)
|
||||
|
||||
show-menu* (mf/use-state false)
|
||||
show-menu? (deref show-menu*)
|
||||
@ -946,6 +971,15 @@
|
||||
(dom/stop-propagation event)
|
||||
(st/emit! (du/toggle-theme))))
|
||||
|
||||
toggle-japanese-layout
|
||||
(mf/use-fn
|
||||
(mf/deps japanese-layout-enabled)
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(st/emit! (dw/set-japanese-layout (not japanese-layout-enabled)))
|
||||
(reset! show-menu* false)
|
||||
(reset! selected-sub-menu* nil)))
|
||||
|
||||
toggle-render
|
||||
(mf/use-fn
|
||||
(mf/deps profile)
|
||||
@ -1145,10 +1179,12 @@
|
||||
:preferences
|
||||
[:> preferences-menu* {:layout layout
|
||||
:profile profile
|
||||
:japanese-layout-enabled japanese-layout-enabled
|
||||
:toggle-flag toggle-flag
|
||||
:toggle-theme toggle-theme
|
||||
:toggle-render toggle-render
|
||||
:show-shortcuts show-shortcuts
|
||||
:toggle-japanese-layout toggle-japanese-layout
|
||||
:on-close close-sub-menu}]
|
||||
|
||||
:plugins
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
[app.util.keyboard :as kbd]
|
||||
[app.util.object :as obj]
|
||||
[app.util.text-editor :as ted]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@ -247,7 +248,7 @@
|
||||
:opacity (when @blurred 0)}
|
||||
:on-pointer-down on-pointer-down
|
||||
:class (dom/classnames
|
||||
(cur/get-dynamic "text" (:rotation shape)) true
|
||||
(cur/get-text (:rotation shape) (wm/vertical-text-content? content)) true
|
||||
:align-top (= (:vertical-align content "top") "top")
|
||||
:align-center (= (:vertical-align content) "center")
|
||||
:align-bottom (= (:vertical-align content) "bottom"))}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.config :as cf]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace :as dw]
|
||||
@ -225,12 +226,112 @@
|
||||
|
||||
Mousetrap's `stopCallback` drops every keystroke whose target is
|
||||
contentEditable, so without the `mousetrap` class (as in V1/V2) the text
|
||||
shortcuts (Ctrl+B, Ctrl+I, …) never reach the dispatcher."
|
||||
[rotation]
|
||||
(dm/str "mousetrap "
|
||||
(cur/get-dynamic "text" rotation)
|
||||
" "
|
||||
(stl/css :text-editor-container)))
|
||||
shortcuts (Ctrl+B, Ctrl+I, …) never reach the dispatcher. The hover cursor
|
||||
lives on the overlay: the surface is pointer-events:none."
|
||||
[]
|
||||
(dm/str "mousetrap " (stl/css :text-editor-container)))
|
||||
|
||||
(def ^:private ime-surface-collapse
|
||||
"Scale applied along the inline axis of the capture surface, so its text
|
||||
(retained input or an in-flight composition) never moves the DOM caret."
|
||||
0.001)
|
||||
|
||||
(defn- update-ime-caret!
|
||||
"Move the hidden contenteditable capture surface onto the WASM caret so the
|
||||
browser's IME candidate window opens next to the edited text. Returns the
|
||||
caret rectangle it anchored to, or nil when there is none yet.
|
||||
|
||||
Must only be called while the editor is idle, never during or immediately
|
||||
before an IME composition: real IMEs (ibus-mozc) abort and commit the
|
||||
pending text on any mutation of the composing element, including style
|
||||
writes on the keydown-229 that precedes compositionstart. The surface is
|
||||
therefore kept on the caret at all times, updated after every
|
||||
caret-affecting operation (click, arrows, typing, commit). During a
|
||||
composition the window follows the WASM caret by moving the surface's
|
||||
wrapper instead (see `follow-ime-caret!`).
|
||||
|
||||
The browser anchors the candidate window to the DOM caret inside the
|
||||
surface. The surface is collapsed along the inline axis, so neither the text
|
||||
it retains between keystrokes (see `keep-input-alive`) nor the composition
|
||||
moves that caret. The block axis keeps the caret cell size (font-size = line
|
||||
height horizontally, column width vertically).
|
||||
|
||||
The IME opens the window below the caret. Horizontally that clears the
|
||||
current line. Vertically the text itself flows down, so the surface (with
|
||||
`writing-mode: vertical-rl` and `line-height: 1`, making its column exactly
|
||||
one caret column wide) sits just right of the caret column: the window opens
|
||||
beside the composition instead of over it.
|
||||
|
||||
The caret rectangle is in the same page space as the overlay foreignObject,
|
||||
whose top-left and width are passed as `origin`."
|
||||
[^js node ^js wrapper origin]
|
||||
(when (and (some? node) (some? origin))
|
||||
(when-let [{:keys [x y width height] :as rect} (text-editor/text-editor-get-cursor-rect)]
|
||||
(let [style (.-style node)]
|
||||
(if (:vertical? origin)
|
||||
(do
|
||||
(set! (.-writingMode style) "vertical-rl")
|
||||
(set! (.-fontSize style) (dm/str width "px"))
|
||||
(set! (.-lineHeight style) "1")
|
||||
(set! (.-transformOrigin style) "100% 0")
|
||||
(set! (.-transform style) (dm/str "scaleY(" ime-surface-collapse ")"))
|
||||
;; Surface column starts past the caret column's right edge, plus a
|
||||
;; small gap so the window border stays clear of the glyphs.
|
||||
(set! (.-left style) (dm/str (- (+ x (* 2.15 width)) (:x origin) (:width origin)) "px")))
|
||||
(do
|
||||
(set! (.-writingMode style) "")
|
||||
(set! (.-fontSize style) (dm/str height "px"))
|
||||
(set! (.-lineHeight style) "")
|
||||
(set! (.-transformOrigin style) "0 0")
|
||||
(set! (.-transform style) (dm/str "scaleX(" ime-surface-collapse ")"))
|
||||
(set! (.-left style) (dm/str (- x (:x origin)) "px"))))
|
||||
(set! (.-top style) (dm/str (- y (:y origin)) "px"))
|
||||
(when (some? wrapper)
|
||||
(set! (.. wrapper -style -transform) ""))
|
||||
rect))))
|
||||
|
||||
(defn- text-before-selection
|
||||
"UTF-16 length of the surface text before the DOM selection focus, or nil
|
||||
when the selection is not inside `node`. Reading it mutates nothing."
|
||||
[^js node]
|
||||
(let [sel (.getSelection js/window)]
|
||||
(when (and (some? node) (some? sel) (pos? (.-rangeCount sel))
|
||||
(.contains node (.-focusNode sel)))
|
||||
(let [range (.createRange js/document)]
|
||||
(.setStart range node 0)
|
||||
(.setEnd range (.-focusNode sel) (.-focusOffset sel))
|
||||
(.-length (.toString range))))))
|
||||
|
||||
(defn changed-span-end
|
||||
"Code point index, in `new-text`, where the part that differs from
|
||||
`old-text` ends: a newly typed kana or a newly picked candidate."
|
||||
[old-text new-text]
|
||||
(let [old (js/Array.from old-text)
|
||||
new (js/Array.from new-text)
|
||||
n-old (.-length old)
|
||||
n-new (.-length new)
|
||||
prefix (loop [i 0]
|
||||
(if (and (< i n-old) (< i n-new) (= (aget old i) (aget new i)))
|
||||
(recur (inc i))
|
||||
i))
|
||||
suffix (loop [i 0]
|
||||
(if (and (< i (- n-old prefix))
|
||||
(< i (- n-new prefix))
|
||||
(= (aget old (- n-old 1 i)) (aget new (- n-new 1 i))))
|
||||
(recur (inc i))
|
||||
i))]
|
||||
(- n-new suffix)))
|
||||
|
||||
(defn- follow-ime-caret!
|
||||
"Translate the surface wrapper by how far the WASM caret moved from `anchor`,
|
||||
the caret rectangle the surface was placed on. Only the wrapper is written,
|
||||
never the composing element, so it is safe while a composition is in flight.
|
||||
Returns nil when the caret rectangle is not available yet."
|
||||
[^js wrapper anchor]
|
||||
(when (and (some? wrapper) (some? anchor))
|
||||
(when-let [{:keys [x y]} (text-editor/text-editor-get-cursor-rect)]
|
||||
(set! (.. wrapper -style -transform)
|
||||
(dm/str "translate(" (- x (:x anchor)) "px, " (- y (:y anchor)) "px)")))))
|
||||
|
||||
(mf/defc text-editor*
|
||||
"Contenteditable element positioned over the text shape to capture input events."
|
||||
@ -239,6 +340,8 @@
|
||||
|
||||
clip-id (dm/str "text-edition-clip" shape-id)
|
||||
|
||||
vertical? (jl/vertical-text-content? (:content shape))
|
||||
|
||||
contenteditable-ref (mf/use-ref nil)
|
||||
|
||||
;; Number of characters the browser is about to replace via marked text
|
||||
@ -253,11 +356,35 @@
|
||||
|
||||
deferred-press-ref (mf/use-ref nil)
|
||||
|
||||
;; Overlay foreignObject top-left and width in page space, used to
|
||||
;; translate the WASM caret rectangle into an offset inside it.
|
||||
origin-ref (mf/use-ref nil)
|
||||
|
||||
;; True between compositionstart and compositionend; guards the surface
|
||||
;; against any repositioning while the IME owns it.
|
||||
composing-ref (mf/use-ref false)
|
||||
|
||||
;; Positioned wrapper of the surface, moved during a composition so the
|
||||
;; IME window follows the WASM caret (see `follow-ime-caret!`).
|
||||
wrapper-ref (mf/use-ref nil)
|
||||
|
||||
;; Caret rectangle the surface was last placed on while idle.
|
||||
anchor-ref (mf/use-ref nil)
|
||||
|
||||
;; Surface text length (UTF-16) before the composition, and the current
|
||||
;; composition text: locate the IME cursor within the preview.
|
||||
composition-base-ref (mf/use-ref 0)
|
||||
composition-text-ref (mf/use-ref "")
|
||||
|
||||
;; IME cursor offset last read from the DOM selection, nil right after a
|
||||
;; text change until the next read.
|
||||
synced-offset-ref (mf/use-ref nil)
|
||||
|
||||
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
|
||||
fallback-families (map (fn [font]
|
||||
(font-family-from-font-id (:font-id font))) fallback-fonts)
|
||||
|
||||
[{:keys [x y width height]} transform]
|
||||
[{:keys [x y width height ime-width]} transform]
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
vbox (mf/deref refs/vbox)
|
||||
@ -281,35 +408,163 @@
|
||||
"bottom" (+ y (- selrect-height height))
|
||||
"center" (+ y (/ (- selrect-height height) 2))
|
||||
y)]
|
||||
[(assoc selrect :y y :width overlay-width :height max-height) transform])
|
||||
;; Vertical text anchors the IME window right of the caret column,
|
||||
;; which for the rightmost column lies past the overlay. The
|
||||
;; foreignObject is widened (not the clip) so the anchor stays inside
|
||||
;; it; otherwise the browser scrolls it to reveal the caret and
|
||||
;; cancels the offset.
|
||||
[(assoc selrect
|
||||
:y y :width overlay-width :height max-height
|
||||
:ime-width (cond-> overlay-width vertical? (+ viewport-width)))
|
||||
transform])
|
||||
|
||||
schedule-ime-caret!
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
;; Wait two frames so the pending WASM render rebuilds the text
|
||||
;; layout the caret rect reads, then retry for a while: right after
|
||||
;; mount the rect can stay unavailable until the first full layout.
|
||||
;; Skipped when a composition began in the meantime — the surface
|
||||
;; must not move while the IME owns it.
|
||||
(letfn [(attempt [tries]
|
||||
(when-not (mf/ref-val composing-ref)
|
||||
(if-let [rect (update-ime-caret!
|
||||
(mf/ref-val contenteditable-ref)
|
||||
(mf/ref-val wrapper-ref)
|
||||
(mf/ref-val origin-ref))]
|
||||
(mf/set-ref-val! anchor-ref rect)
|
||||
(when (pos? tries)
|
||||
(js/requestAnimationFrame #(attempt (dec tries)))))))]
|
||||
(js/requestAnimationFrame
|
||||
(fn []
|
||||
(js/requestAnimationFrame #(attempt 30)))))))
|
||||
|
||||
schedule-ime-follow!
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
;; Same frame wait and retries as `schedule-ime-caret!`: the preview
|
||||
;; update clears the text layout the caret rect reads until the
|
||||
;; pending render rebuilds it. Only applies mid-composition.
|
||||
(letfn [(attempt [tries]
|
||||
(when (and (mf/ref-val composing-ref)
|
||||
(nil? (follow-ime-caret!
|
||||
(mf/ref-val wrapper-ref)
|
||||
(mf/ref-val anchor-ref)))
|
||||
(pos? tries))
|
||||
(js/requestAnimationFrame #(attempt (dec tries)))))]
|
||||
(js/requestAnimationFrame
|
||||
(fn []
|
||||
(js/requestAnimationFrame #(attempt 30)))))))
|
||||
|
||||
;; Moves the WASM caret inside the preview and the IME window with it.
|
||||
;; Synchronous: the browser reports the caret bounds to the IME right
|
||||
;; after the composition/selection update that triggered this, so a
|
||||
;; deferred move would only reach the IME on its next update.
|
||||
place-composition-caret!
|
||||
(mf/use-fn
|
||||
(fn [offset]
|
||||
(text-editor/text-editor-set-composition-cursor offset)
|
||||
(when (nil? (follow-ime-caret! (mf/ref-val wrapper-ref) (mf/ref-val anchor-ref)))
|
||||
(schedule-ime-follow!))))
|
||||
|
||||
;; When only the IME cursor moves (switching clauses), the browser keeps
|
||||
;; the DOM selection on it: mirror it on the WASM caret. The first read
|
||||
;; after a text change is just the baseline; text changes place the
|
||||
;; caret themselves (see on-composition-update).
|
||||
sync-composition-cursor!
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(when (mf/ref-val composing-ref)
|
||||
(when-let [focus (text-before-selection (mf/ref-val contenteditable-ref))]
|
||||
(let [text (mf/ref-val composition-text-ref)
|
||||
units (-> (- focus (mf/ref-val composition-base-ref))
|
||||
(max 0)
|
||||
(min (count text)))
|
||||
cursor (.-length (js/Array.from (subs text 0 units)))
|
||||
previous (mf/ref-val synced-offset-ref)]
|
||||
(mf/set-ref-val! synced-offset-ref cursor)
|
||||
(when (and (some? previous) (not= cursor previous))
|
||||
(place-composition-caret! cursor)
|
||||
(wasm.api/render-text-editor-overlay!)))))))
|
||||
|
||||
;; Programmatic .focus() on the surface does not reliably fire
|
||||
;; focus/focusin in Chromium (contenteditable inside an SVG
|
||||
;; foreignObject): the node becomes document.activeElement but React's
|
||||
;; on-focus never dispatches, leaving the WASM editor unfocused and
|
||||
;; every caret-rect read null. So wherever we focus programmatically,
|
||||
;; the WASM focus is established explicitly instead of relying on the
|
||||
;; focus event.
|
||||
focus-editor!
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id)
|
||||
(fn []
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(when (not= (.-activeElement js/document) node)
|
||||
(.focus node)))
|
||||
;; Re-focusing resets transient WASM editor state (overtype mode,
|
||||
;; pending events), so only do it when this shape lacks the focus.
|
||||
(when-not (and (text-editor/text-editor-has-focus?)
|
||||
(= shape-id (text-editor/text-editor-get-active-shape-id)))
|
||||
(wasm.api/text-editor-focus shape-id))
|
||||
(schedule-ime-caret!)))
|
||||
|
||||
on-composition-start
|
||||
(mf/use-fn
|
||||
(fn [_event]
|
||||
(mf/set-ref-val! composing-ref true)
|
||||
(mf/set-ref-val! composition-text-ref "")
|
||||
(mf/set-ref-val! synced-offset-ref nil)
|
||||
(mf/set-ref-val! composition-base-ref
|
||||
(let [node (mf/ref-val contenteditable-ref)]
|
||||
(or (text-before-selection node)
|
||||
(count (.-textContent node)))))
|
||||
;; IME composition supplies its own text; drop any pending caret style.
|
||||
(text-editor/clear-pending-caret-styles!)
|
||||
(text-editor/text-editor-composition-start)))
|
||||
|
||||
on-composition-update
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id)
|
||||
(fn [event]
|
||||
;; IME cancel (e.g. Escape on Linux ibus-mozc) fires compositionupdate
|
||||
;; with an empty string; that must reach WASM to clear the preview text.
|
||||
;;
|
||||
;; While the composition is in flight the browser owns the capture
|
||||
;; surface, so this handler must not touch it: no clearing, no style
|
||||
;; writes, and no store dispatch. A store sync re-renders this
|
||||
;; component (shape content/name/dimensions, foreignObject
|
||||
;; attributes, CSS vars), and any such churn makes a real IME commit
|
||||
;; the pending kana and restart (typing "ni" commits ん then い
|
||||
;; instead of composing に). The WASM editor keeps the preview in its
|
||||
;; own state and paints it via the render request; the store is
|
||||
;; synced once on compositionend. Only the surface wrapper moves, so
|
||||
;; the IME window follows the WASM caret (wrapping, candidates).
|
||||
;;
|
||||
;; The caret goes to the end of the part that changed: a typed kana,
|
||||
;; a converted word, a picked candidate (even in a middle clause).
|
||||
(let [data (.-data event)]
|
||||
(when (some? data)
|
||||
(text-editor/text-editor-composition-update data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render-preserving-target "text-composition"))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref)))))
|
||||
(let [previous (mf/ref-val composition-text-ref)]
|
||||
(mf/set-ref-val! composition-text-ref data)
|
||||
(mf/set-ref-val! synced-offset-ref nil)
|
||||
(text-editor/text-editor-composition-update data)
|
||||
;; The preview never reaches the store, so request its
|
||||
;; fallback faces here or it shows missing glyphs until commit.
|
||||
(wasm.api/load-composition-fonts! shape-id data)
|
||||
(place-composition-caret! (changed-span-end previous data))
|
||||
(wasm.api/request-render-preserving-target "text-composition"))))))
|
||||
|
||||
on-composition-end
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(mf/set-ref-val! composing-ref false)
|
||||
(let [data (or (.-data event) "")]
|
||||
(text-editor/text-editor-composition-end data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render-preserving-target "text-composition"))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref))))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref))
|
||||
;; Move the surface onto the caret, now past the committed text.
|
||||
(schedule-ime-caret!)))
|
||||
|
||||
on-paste
|
||||
(mf/use-fn
|
||||
@ -322,7 +577,8 @@
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1-html-paste")))]
|
||||
(paste-fragment fragment)
|
||||
(wasm.api/request-render-preserving-target "text-paste"))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref))))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref))
|
||||
(schedule-ime-caret!)))
|
||||
|
||||
on-copy
|
||||
(mf/use-fn
|
||||
@ -353,11 +609,17 @@
|
||||
(text-editor/text-editor-delete-backward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render-preserving-target "text-cut"))))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref)))))
|
||||
(reset-input-node (mf/ref-val contenteditable-ref))
|
||||
(schedule-ime-caret!))))
|
||||
|
||||
on-key-down
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
;; IME keydowns (keyCode 229) must not touch the surface at all: by
|
||||
;; the time the 229 reaches the DOM the IME already owns the surface,
|
||||
;; and even a pre-compositionstart style write makes ibus abort and
|
||||
;; commit. The surface is already sitting on the caret from the last
|
||||
;; idle repositioning (see `update-ime-caret!`).
|
||||
(when (and (text-editor/text-editor-has-focus?)
|
||||
(not (composing-event? event)))
|
||||
(let [key (.-key event)
|
||||
@ -455,7 +717,11 @@
|
||||
(wasm.api/render-text-editor-overlay!))
|
||||
|
||||
;; Let contenteditable handle text input via on-input
|
||||
:else nil)))))
|
||||
:else nil)
|
||||
;; Any handled key may have moved the caret; keep the surface on
|
||||
;; it so the next IME sequence anchors correctly. Plain character
|
||||
;; keys reschedule again from on-input after the insert.
|
||||
(schedule-ime-caret!)))))
|
||||
|
||||
;; Native `beforeinput` listener (see the use-effect that registers it).
|
||||
;; We use the native event, not React's synthetic `onBeforeInput`, because
|
||||
@ -489,6 +755,9 @@
|
||||
(let [native-event (.-nativeEvent event)
|
||||
input-type (.-inputType native-event)
|
||||
data (.-data native-event)]
|
||||
;; The DOM now holds the updated composition and its IME cursor.
|
||||
(when (= input-type "insertCompositionText")
|
||||
(sync-composition-cursor!))
|
||||
;; Skip composition-related input events - composition-end handles those
|
||||
(when (and (not (composing-event? event))
|
||||
(not= input-type "insertCompositionText"))
|
||||
@ -511,11 +780,18 @@
|
||||
;; IMPORTANT: do NOT clear the surface here (see keep-input-alive):
|
||||
;; the browser must retain the just-typed character so the macOS
|
||||
;; accent menu can replace it on the next input.
|
||||
(keep-input-alive (mf/ref-val contenteditable-ref))))))
|
||||
(keep-input-alive (mf/ref-val contenteditable-ref))
|
||||
(schedule-ime-caret!)))))
|
||||
|
||||
on-pointer-down
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
;; The capture surface is pointer-events:none (it sits on the caret,
|
||||
;; not under the pointer), so it must be focused programmatically.
|
||||
;; preventDefault stops the browser from moving focus to the body on
|
||||
;; mousedown, which would blur the surface right back.
|
||||
(dom/prevent-default event)
|
||||
(focus-editor!)
|
||||
(when-not (secondary-button? event)
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
@ -560,7 +836,8 @@
|
||||
;; Without a drag there is no pointer selection to close; the
|
||||
;; caret is placed by `on-click`.
|
||||
(when dragging?
|
||||
(wasm.api/render-text-editor-overlay!))))))
|
||||
(wasm.api/render-text-editor-overlay!))
|
||||
(schedule-ime-caret!)))))
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
@ -583,7 +860,8 @@
|
||||
:else
|
||||
(do
|
||||
(wasm.api/text-editor-set-cursor-from-offset off-pt)
|
||||
(wasm.api/render-text-editor-overlay!)))))))
|
||||
(wasm.api/render-text-editor-overlay!)))
|
||||
(schedule-ime-caret!)))))
|
||||
|
||||
on-double-click
|
||||
(mf/use-fn
|
||||
@ -591,7 +869,8 @@
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-select-word-boundary off-pt)
|
||||
(wasm.api/render-text-editor-overlay!))))
|
||||
(wasm.api/render-text-editor-overlay!)
|
||||
(schedule-ime-caret!))))
|
||||
|
||||
on-context-menu
|
||||
(mf/use-fn
|
||||
@ -610,7 +889,8 @@
|
||||
;; on every other path that moves it.
|
||||
(text-editor/clear-pending-caret-styles!)
|
||||
(wasm.api/text-editor-set-cursor-from-offset off-pt)
|
||||
(wasm.api/render-text-editor-overlay!)))
|
||||
(wasm.api/render-text-editor-overlay!)
|
||||
(schedule-ime-caret!)))
|
||||
;; Deferred: the dropdown closes itself on a document `contextmenu`,
|
||||
;; which would close the menu this very event is opening.
|
||||
(ts/schedule
|
||||
@ -622,7 +902,8 @@
|
||||
on-focus
|
||||
(mf/use-fn
|
||||
(fn [^js _event]
|
||||
(wasm.api/text-editor-focus shape-id)))
|
||||
(wasm.api/text-editor-focus shape-id)
|
||||
(schedule-ime-caret!)))
|
||||
|
||||
on-blur
|
||||
(mf/use-fn
|
||||
@ -639,6 +920,9 @@
|
||||
"--editor-container-height" (dm/str height "px")
|
||||
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}]
|
||||
|
||||
;; Keep the caret-translation origin in sync with the current render.
|
||||
(mf/set-ref-val! origin-ref {:x x :y y :width ime-width :vertical? vertical?})
|
||||
|
||||
;; Exit on Escape via a document key-up listener (like v2). On key-down the trailing
|
||||
;; key-up is read as a non-editing Escape and deselects the shape.
|
||||
(mf/use-effect
|
||||
@ -655,6 +939,14 @@
|
||||
(.addEventListener js/document "keyup" on-key-up)
|
||||
#(.removeEventListener js/document "keyup" on-key-up))))
|
||||
|
||||
;; The IME cursor can move inside the composition without changing its
|
||||
;; text (e.g. switching clauses), which only shows as a selection change.
|
||||
(mf/use-effect
|
||||
(mf/deps sync-composition-cursor!)
|
||||
(fn []
|
||||
(.addEventListener js/document "selectionchange" sync-composition-cursor!)
|
||||
#(.removeEventListener js/document "selectionchange" sync-composition-cursor!)))
|
||||
|
||||
;; Register the native `beforeinput` listener. React's synthetic
|
||||
;; `onBeforeInput` does not expose `getTargetRanges()`, even with
|
||||
;; nativeEvent (it's fully synthetic, composed of other two events).
|
||||
@ -675,9 +967,10 @@
|
||||
;; Group the whole editing session (edits, reflow resizes, finalize) into a single
|
||||
;; undo entry. Nested transactions (e.g. style shortcuts) are ref-counted and fold in.
|
||||
(st/emit! (dwu/start-undo-transaction shape-id :timeout nil))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
;; Focus and select all text on mount (this will trigger on-focus)
|
||||
(.focus node)
|
||||
(when (some? (mf/ref-val contenteditable-ref))
|
||||
;; Focus and select all text on mount. `focus-editor!` establishes the
|
||||
;; WASM focus explicitly; select-all is a no-op without it.
|
||||
(focus-editor!)
|
||||
(text-editor/text-editor-select-all)
|
||||
(wasm.api/request-render-preserving-target "text-editor-select-all-on-mount"))
|
||||
;; On unmount, finalize the editor content and then dispose the WASM editor.
|
||||
@ -719,36 +1012,42 @@
|
||||
[:clipPath {:id clip-id}
|
||||
[:rect {:x x :y y :width width :height height}]]]
|
||||
|
||||
[:foreignObject {:x x :y y :width width :height height}
|
||||
[:foreignObject {:x x :y y :width ime-width :height height}
|
||||
[:div {:on-click on-click
|
||||
:on-double-click on-double-click
|
||||
:on-pointer-down on-pointer-down
|
||||
:on-pointer-move on-pointer-move
|
||||
:on-pointer-up on-pointer-up
|
||||
:on-context-menu on-context-menu
|
||||
:class (stl/css :text-editor)
|
||||
;; The hover cursor lives here: the capture surface below is
|
||||
;; pointer-events:none so it never receives hover itself.
|
||||
:class (dm/str (cur/get-text (:rotation shape) vertical?)
|
||||
" "
|
||||
(stl/css :text-editor))
|
||||
:style style}
|
||||
[:div
|
||||
{:ref contenteditable-ref
|
||||
:contentEditable true
|
||||
:suppressContentEditableWarning true
|
||||
;; The surface retains typed text between keystrokes (see
|
||||
;; keep-input-alive), so disable text assistance that would otherwise
|
||||
;; rewrite that retained text and desync the WASM editor.
|
||||
;; NOTE: this was already not working in v1/v2
|
||||
:spellCheck false
|
||||
:autoCorrect "off"
|
||||
:autoCapitalize "off"
|
||||
:on-composition-start on-composition-start
|
||||
:on-composition-update on-composition-update
|
||||
:on-composition-end on-composition-end
|
||||
:on-key-down on-key-down
|
||||
:on-input on-input
|
||||
:on-paste on-paste
|
||||
:on-copy on-copy
|
||||
:on-cut on-cut
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:id "text-editor-wasm-input"
|
||||
:class (input-surface-class (:rotation shape))
|
||||
:data-testid "text-editor-container"}]]]]))
|
||||
[:div {:ref wrapper-ref
|
||||
:class (stl/css :ime-anchor)}
|
||||
[:div
|
||||
{:ref contenteditable-ref
|
||||
:contentEditable true
|
||||
:suppressContentEditableWarning true
|
||||
;; The surface retains typed text between keystrokes (see
|
||||
;; keep-input-alive), so disable text assistance that would otherwise
|
||||
;; rewrite that retained text and desync the WASM editor.
|
||||
;; NOTE: this was already not working in v1/v2
|
||||
:spellCheck false
|
||||
:autoCorrect "off"
|
||||
:autoCapitalize "off"
|
||||
:on-composition-start on-composition-start
|
||||
:on-composition-update on-composition-update
|
||||
:on-composition-end on-composition-end
|
||||
:on-key-down on-key-down
|
||||
:on-input on-input
|
||||
:on-paste on-paste
|
||||
:on-copy on-copy
|
||||
:on-cut on-cut
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
:id "text-editor-wasm-input"
|
||||
:class (input-surface-class)
|
||||
:data-testid "text-editor-container"}]]]]]))
|
||||
|
||||
@ -9,4 +9,20 @@
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
|
||||
// The surface is positioned on the caret while idle, so clicks must always
|
||||
// hit the outer overlay (keeping offset coordinates stable); focus is given
|
||||
// to the surface programmatically from the overlay's pointer-down.
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Positioned wrapper of the capture surface, translated during an IME
|
||||
// composition so the candidate window follows the WASM caret.
|
||||
.ime-anchor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
[app.util.text-editor :as ted]
|
||||
[app.util.text-svg-position :as tsp]
|
||||
[app.util.text.content :as content]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[promesa.core :as p]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@ -98,11 +99,11 @@
|
||||
|
||||
(st/emit! (dwt/clean-text-modifier id))))
|
||||
;; Always clear the task and log measurement errors.
|
||||
(p/catch (fn [cause]
|
||||
(log/error :hint "Could not measure text shape"
|
||||
:shape-id id
|
||||
:cause cause)
|
||||
nil))))
|
||||
(p/merr (fn [cause]
|
||||
(log/error :hint "Could not measure text shape"
|
||||
:shape-id id
|
||||
:cause cause)
|
||||
(p/resolved nil)))))
|
||||
|
||||
(defn- update-text-modifier
|
||||
[{:keys [grow-type id] :as shape} node]
|
||||
@ -167,6 +168,16 @@
|
||||
pending-update* (mf/use-state {})
|
||||
pending-update (deref pending-update*)
|
||||
|
||||
;; Vertical texts already measured as horizontal (see `stale-layout?`).
|
||||
remeasured-ref (mf/use-ref #{})
|
||||
|
||||
;; A vertical text keeps the position data the WASM renderer computed
|
||||
;; for its columns; measure it once more as horizontal text.
|
||||
stale-layout?
|
||||
(fn [id]
|
||||
(and (not (contains? (mf/ref-val remeasured-ref) id))
|
||||
(wm/stale-vertical-layout? (:content (get text-shapes id)))))
|
||||
|
||||
text-change?
|
||||
(fn [id]
|
||||
(let [new-shape (get text-shapes id)
|
||||
@ -176,7 +187,8 @@
|
||||
(or (and (not remote?) ;; changes caused by a remote peer are not re-calculated
|
||||
(not (text-properties-equal? old-shape new-shape)))
|
||||
;; When the position data is nil we force to recalculate
|
||||
(nil? (:position-data new-shape)))))
|
||||
(nil? (:position-data new-shape))
|
||||
(stale-layout? id))))
|
||||
|
||||
changed-texts
|
||||
(mf/with-memo [text-shapes pending-update]
|
||||
@ -193,6 +205,7 @@
|
||||
;; Unique to indentify the pending state
|
||||
(let [uid (uuid/next)
|
||||
id (:id shape)]
|
||||
(mf/set-ref-val! remeasured-ref (conj (mf/ref-val remeasured-ref) id))
|
||||
(swap! pending-update* assoc uid id)
|
||||
;; Callback refs run at the DOM commit boundary. Track the exact
|
||||
;; measurement promise from that acknowledgement; any resize it
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
[app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
|
||||
[app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]]
|
||||
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.utils :as csu]
|
||||
@ -52,9 +53,17 @@
|
||||
Evaluated once at module load time; cf/flags is immutable after startup."
|
||||
(contains? cf/flags :token-typography-row))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Sub-components
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
(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))))
|
||||
|
||||
(mf/defc text-align-options*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
@ -85,14 +94,14 @@
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
|
||||
[:div {:class (stl/css :align-options)}
|
||||
[:> radio-buttons* {:selected (:text-align values)
|
||||
[:> radio-buttons* {:selected (radio-selected (:text-align values))
|
||||
:on-change handle-change
|
||||
:name "align-text-options"
|
||||
:options options}]]))
|
||||
|
||||
(mf/defc text-direction-options*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [direction (:text-direction values)
|
||||
(let [direction (radio-selected (:text-direction values))
|
||||
options
|
||||
(mf/with-memo []
|
||||
[{:value "ltr"
|
||||
@ -119,7 +128,7 @@
|
||||
|
||||
(mf/defc vertical-align*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [vertical-align (or (:vertical-align values) "top")
|
||||
(let [vertical-align (radio-selected (:vertical-align values) "top")
|
||||
options
|
||||
(mf/with-memo []
|
||||
[{:value "top"
|
||||
@ -151,6 +160,7 @@
|
||||
(mf/defc grow-options*
|
||||
[{:keys [ids values on-blur]}]
|
||||
(let [grow-type (:grow-type values)
|
||||
selected (radio-selected grow-type)
|
||||
editor-instance (mf/deref refs/workspace-editor)
|
||||
options
|
||||
(mf/with-memo []
|
||||
@ -191,14 +201,14 @@
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
|
||||
[:div {:class (stl/css :grow-options)}
|
||||
[:> radio-buttons* {:selected (d/name grow-type)
|
||||
[:> radio-buttons* {:selected selected
|
||||
:on-change handle-change
|
||||
:name "grow-text-options"
|
||||
:options options}]]))
|
||||
|
||||
(mf/defc text-decoration-options*
|
||||
[{:keys [values on-change on-blur token-applied]}]
|
||||
(let [text-decoration (some-> (:text-decoration values) d/name)
|
||||
(let [text-decoration (radio-selected (:text-decoration values))
|
||||
options
|
||||
(mf/with-memo [token-applied]
|
||||
[{:value "underline"
|
||||
@ -222,8 +232,8 @@
|
||||
|
||||
[:div {:class (stl/css :text-decoration-options)}
|
||||
[:> radio-buttons* {:selected (if (= text-decoration "none")
|
||||
nil
|
||||
text-decoration)
|
||||
""
|
||||
(radio-selected text-decoration))
|
||||
:on-change handle-change
|
||||
:name "text-decoration-options"
|
||||
:disabled (and token-typography-row-enabled? (some? token-applied))
|
||||
@ -246,8 +256,10 @@
|
||||
(d/seek #(= (:id %) (uuid/uuid id)))))
|
||||
|
||||
(defn- check-props [n-props o-props]
|
||||
(let [o-values (unchecked-get o-props "values")
|
||||
n-values (unchecked-get n-props "values")]
|
||||
(let [o-values (unchecked-get o-props "values")
|
||||
n-values (unchecked-get n-props "values")
|
||||
o-ruby-values (unchecked-get o-props "rubyValues")
|
||||
n-ruby-values (unchecked-get n-props "rubyValues")]
|
||||
(and (identical? (unchecked-get n-props "ids")
|
||||
(unchecked-get o-props "ids"))
|
||||
(identical? (unchecked-get n-props "type")
|
||||
@ -258,6 +270,9 @@
|
||||
(unchecked-get o-props "fileId"))
|
||||
(identical? (unchecked-get n-props "typographies")
|
||||
(unchecked-get o-props "typographies"))
|
||||
(identical? (unchecked-get n-props "textSelectionActive")
|
||||
(unchecked-get o-props "textSelectionActive"))
|
||||
(= n-ruby-values o-ruby-values)
|
||||
(identical? (get o-values :fills)
|
||||
(get n-values :fills))
|
||||
(identical? (get o-values :font-family)
|
||||
@ -284,6 +299,32 @@
|
||||
(get n-values :text-decoration))
|
||||
(identical? (get o-values :text-direction)
|
||||
(get n-values :text-direction))
|
||||
(identical? (get o-values :writing-mode)
|
||||
(get n-values :writing-mode))
|
||||
(identical? (get o-values :text-orientation)
|
||||
(get n-values :text-orientation))
|
||||
(identical? (get o-values :text-combine-upright)
|
||||
(get n-values :text-combine-upright))
|
||||
(identical? (get o-values :warichu)
|
||||
(get n-values :warichu))
|
||||
(identical? (get o-values :font-features)
|
||||
(get n-values :font-features))
|
||||
(identical? (get o-values :annotation-clearance)
|
||||
(get n-values :annotation-clearance))
|
||||
(identical? (get o-values :text-emphasis)
|
||||
(get n-values :text-emphasis))
|
||||
(identical? (get o-values :ruby)
|
||||
(get n-values :ruby))
|
||||
(identical? (get o-values :ruby-hidden)
|
||||
(get n-values :ruby-hidden))
|
||||
(identical? (get o-values :ruby-size)
|
||||
(get n-values :ruby-size))
|
||||
(identical? (get o-values :ruby-align)
|
||||
(get n-values :ruby-align))
|
||||
(identical? (get o-values :ruby-overhang)
|
||||
(get n-values :ruby-overhang))
|
||||
(identical? (get o-values :ruby-side)
|
||||
(get n-values :ruby-side))
|
||||
(identical? (get o-values :text-transform)
|
||||
(get n-values :text-transform))
|
||||
(identical? (get o-values :typography-ref-file)
|
||||
@ -299,14 +340,22 @@
|
||||
|
||||
(mf/defc text-menu*
|
||||
{::mf/wrap [#(mf/memo' % check-props)]}
|
||||
[{:keys [ids type values applied-tokens libraries file-id typographies]}]
|
||||
[{:keys [ids type values ruby-values text-selection-active
|
||||
applied-tokens libraries file-id typographies]}]
|
||||
|
||||
(let [;; --- UI state
|
||||
menu-state* (mf/use-state {:main-menu true
|
||||
:more-options false})
|
||||
:more-options false
|
||||
:japanese-layout true})
|
||||
menu-state (deref menu-state*)
|
||||
main-menu-open? (:main-menu menu-state)
|
||||
more-options-open? (:more-options menu-state)
|
||||
japanese-layout-open? (:japanese-layout menu-state)
|
||||
|
||||
profile (mf/deref refs/profile)
|
||||
file-data (mf/deref refs/workspace-data)
|
||||
japanese-layout-config-enabled?
|
||||
(tjl/japanese-layout-config-enabled? file-data profile)
|
||||
|
||||
font-id (or (:font-id values) (:font-id txt/default-typography))
|
||||
|
||||
@ -397,6 +446,10 @@
|
||||
(mf/use-fn
|
||||
#(swap! menu-state* update :more-options not))
|
||||
|
||||
toggle-japanese-layout
|
||||
(mf/use-fn
|
||||
#(swap! menu-state* update :japanese-layout not))
|
||||
|
||||
toggle-token-dropdown
|
||||
(mf/use-fn
|
||||
#(swap! token-dropdown-open* not))
|
||||
@ -427,6 +480,14 @@
|
||||
(fn [attrs]
|
||||
(emit-update! ids attrs)))
|
||||
|
||||
on-ruby-presentation-change
|
||||
(mf/use-fn
|
||||
(mf/deps ids on-change text-selection-active)
|
||||
(fn [attrs]
|
||||
(if text-selection-active
|
||||
(on-change attrs)
|
||||
(st/emit! (dwt/update-all-ruby-presentation ids attrs)))))
|
||||
|
||||
on-convert-to-typography
|
||||
(mf/use-fn
|
||||
(mf/deps values ids file-id emit-update!)
|
||||
@ -485,7 +546,17 @@
|
||||
:values values
|
||||
:on-change on-change
|
||||
:show-recent true
|
||||
:on-blur on-text-blur})]
|
||||
:on-blur on-text-blur})
|
||||
|
||||
japanese-layout-props
|
||||
(mf/props
|
||||
{:ids ids
|
||||
:values values
|
||||
:ruby-values ruby-values
|
||||
:text-selection-active text-selection-active
|
||||
:on-change on-change
|
||||
:on-ruby-presentation-change on-ruby-presentation-change
|
||||
:on-blur on-text-blur})]
|
||||
|
||||
(hooks/use-stream
|
||||
expand-stream
|
||||
@ -503,97 +574,111 @@
|
||||
(when token-dropdown-open?
|
||||
(ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!))))
|
||||
|
||||
[:section {:class (stl/css :element-set)
|
||||
;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`).
|
||||
:data-keep-editing-on-blur true
|
||||
:aria-label (tr "workspace.options.text-options.text-section")}
|
||||
[:div {:class (stl/css :element-title)}
|
||||
[:> title-bar* {:collapsable true
|
||||
:collapsed (not main-menu-open?)
|
||||
:on-collapsed toggle-main-menu
|
||||
:title label
|
||||
:class (stl/css :title-spacing-text)}
|
||||
[:*
|
||||
(when (and token-typography-row-enabled? (some? (resolve-delay typography-tokens)) (not typography))
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
|
||||
:on-click toggle-token-dropdown
|
||||
:tooltip-placement "top-left"
|
||||
:icon i/tokens}])
|
||||
(when (and (not typography) (not multiple?) (not applied-token-name))
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (if (not font)
|
||||
(tr "workspace.options.font-not-available" (:font-family values))
|
||||
(tr "workspace.options.convert-to-typography"))
|
||||
:on-click on-convert-to-typography
|
||||
:tooltip-placement "top-left"
|
||||
:disabled (not font)
|
||||
:icon i/add}])]]
|
||||
(when (and token-typography-row-enabled? token-dropdown-open?)
|
||||
[:> searchable-options-dropdown* {:on-click on-option-click
|
||||
:id listbox-id
|
||||
:options (resolve-delay dropdown-options)
|
||||
:selected selected-token-id
|
||||
:align "right"
|
||||
:placeholder (tr "workspace.tokens.search-by-token")
|
||||
:ref set-option-ref}])]
|
||||
|
||||
(when main-menu-open?
|
||||
[:div {:class (stl/css :element-content)}
|
||||
(cond
|
||||
(and token-typography-row-enabled? (= :multiple current-token-name) (= typography-id :multiple))
|
||||
[:div {:class (stl/css :multiple-typography)}
|
||||
[:span {:class (stl/css :multiple-text)}
|
||||
(tr "workspace.libraries.text.mixed-tokens-and-assets")]]
|
||||
|
||||
(and token-typography-row-enabled? (= :multiple current-token-name))
|
||||
[:div {:class (stl/css :multiple-typography)}
|
||||
[:span {:class (stl/css :multiple-text)}
|
||||
(tr "workspace.libraries.text.mixed-tokens")]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.libraries.text.multiple-token-tooltip")
|
||||
[:*
|
||||
[:section {:class (stl/css :element-set)
|
||||
;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`).
|
||||
:data-keep-editing-on-blur true
|
||||
:aria-label (tr "workspace.options.text-options.text-section")}
|
||||
[:div {:class (stl/css :element-title)}
|
||||
[:> title-bar* {:collapsable true
|
||||
:collapsed (not main-menu-open?)
|
||||
:on-collapsed toggle-main-menu
|
||||
:title label
|
||||
:class (stl/css :title-spacing-text)}
|
||||
[:*
|
||||
(when (and token-typography-row-enabled? (some? (resolve-delay typography-tokens)) (not typography))
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
|
||||
:on-click toggle-token-dropdown
|
||||
:tooltip-placement "top-left"
|
||||
:on-click handle-detach-all-tokens
|
||||
:icon i/detach}]]
|
||||
|
||||
(and token-typography-row-enabled? current-token-name)
|
||||
[:> token-typography-row* {:token-name current-token-name
|
||||
:detach-token detach-token
|
||||
:active-tokens (resolve-delay typography-tokens)}]
|
||||
|
||||
(= typography-id :multiple)
|
||||
[:div {:class (stl/css :multiple-typography)}
|
||||
[:span {:class (stl/css :multiple-text)}
|
||||
(tr "workspace.libraries.text.mixed-typography")]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.libraries.text.multiple-assets-tooltip")
|
||||
:on-click handle-detach-typography
|
||||
:icon i/tokens}])
|
||||
(when (and (not typography) (not multiple?) (not applied-token-name))
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (if (not font)
|
||||
(tr "workspace.options.font-not-available" (:font-family values))
|
||||
(tr "workspace.options.convert-to-typography"))
|
||||
:on-click on-convert-to-typography
|
||||
:tooltip-placement "top-left"
|
||||
:icon i/detach}]]
|
||||
:disabled (not font)
|
||||
:icon i/add}])]]
|
||||
(when (and token-typography-row-enabled? token-dropdown-open?)
|
||||
[:> searchable-options-dropdown* {:on-click on-option-click
|
||||
:id listbox-id
|
||||
:options (resolve-delay dropdown-options)
|
||||
:selected selected-token-id
|
||||
:align "right"
|
||||
:placeholder (tr "workspace.tokens.search-by-token")
|
||||
:ref set-option-ref}])]
|
||||
|
||||
typography
|
||||
[:> typography-entry* {:file-id typography-file-id
|
||||
:typography typography
|
||||
:is-local (= typography-file-id file-id)
|
||||
:on-detach handle-detach-typography
|
||||
:on-change handle-change-typography}]
|
||||
(when main-menu-open?
|
||||
[:div {:class (stl/css :element-content)}
|
||||
(cond
|
||||
(and token-typography-row-enabled? (= :multiple current-token-name) (= typography-id :multiple))
|
||||
[:div {:class (stl/css :multiple-typography)}
|
||||
[:span {:class (stl/css :multiple-text)}
|
||||
(tr "workspace.libraries.text.mixed-tokens-and-assets")]]
|
||||
|
||||
(and token-typography-row-enabled? (= :multiple current-token-name))
|
||||
[:div {:class (stl/css :multiple-typography)}
|
||||
[:span {:class (stl/css :multiple-text)}
|
||||
(tr "workspace.libraries.text.mixed-tokens")]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.libraries.text.multiple-token-tooltip")
|
||||
:tooltip-placement "top-left"
|
||||
:on-click handle-detach-all-tokens
|
||||
:icon i/detach}]]
|
||||
|
||||
(and token-typography-row-enabled? current-token-name)
|
||||
[:> token-typography-row* {:token-name current-token-name
|
||||
:detach-token detach-token
|
||||
:active-tokens (resolve-delay typography-tokens)}]
|
||||
|
||||
:else
|
||||
[:> text-options* common-props])
|
||||
(= typography-id :multiple)
|
||||
[:div {:class (stl/css :multiple-typography)}
|
||||
[:span {:class (stl/css :multiple-text)}
|
||||
(tr "workspace.libraries.text.mixed-typography")]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "workspace.libraries.text.multiple-assets-tooltip")
|
||||
:on-click handle-detach-typography
|
||||
:tooltip-placement "top-left"
|
||||
:icon i/detach}]]
|
||||
|
||||
[:div {:class (stl/css :text-align-options)}
|
||||
[:> text-align-options* common-props]
|
||||
[:> grow-options* common-props]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.options")
|
||||
:data-testid "text-align-options-button"
|
||||
:on-click toggle-more-options
|
||||
:icon i/menu}]]
|
||||
typography
|
||||
[:> typography-entry* {:file-id typography-file-id
|
||||
:typography typography
|
||||
:is-local (= typography-file-id file-id)
|
||||
:on-detach handle-detach-typography
|
||||
:on-change handle-change-typography}]
|
||||
|
||||
(when more-options-open?
|
||||
[:div {:class (stl/css :text-decoration-options)}
|
||||
[:> vertical-align* common-props]
|
||||
[:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})]
|
||||
[:> text-direction-options* common-props]])])]))
|
||||
:else
|
||||
[:> text-options* common-props])
|
||||
|
||||
[:div {:class (stl/css :text-align-options)}
|
||||
[:> text-align-options* common-props]
|
||||
[:> grow-options* common-props]
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.options")
|
||||
:data-testid "text-align-options-button"
|
||||
:on-click toggle-more-options
|
||||
:icon i/menu}]]
|
||||
|
||||
(when more-options-open?
|
||||
[:div {:class (stl/css :text-decoration-options)}
|
||||
[:> vertical-align* common-props]
|
||||
[:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})]
|
||||
[:> text-direction-options* common-props]])])]
|
||||
|
||||
(when japanese-layout-config-enabled?
|
||||
[:section {:class (stl/css :element-set)
|
||||
;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`).
|
||||
:data-keep-editing-on-blur true
|
||||
:aria-label (tr "workspace.options.text-options.japanese-layout")}
|
||||
[:div {:class (stl/css :element-title)}
|
||||
[:> title-bar* {:collapsable true
|
||||
:collapsed (not japanese-layout-open?)
|
||||
:on-collapsed toggle-japanese-layout
|
||||
:title (tr "workspace.options.text-options.japanese-layout")
|
||||
:class (stl/css :title-spacing-text)}]]
|
||||
(when japanese-layout-open?
|
||||
[:div {:class (stl/css :element-content)}
|
||||
[:> tjl/japanese-layout-options* japanese-layout-props]])])]))
|
||||
|
||||
@ -0,0 +1,550 @@
|
||||
;; 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.common.types.file :as ctf]
|
||||
[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]]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[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-config-enabled?
|
||||
"Japanese layout controls are available under the WASM renderer, when
|
||||
enabled for the current file or globally in the user's profile."
|
||||
[file-data profile]
|
||||
(and ^boolean (wm/vertical-layout-active?)
|
||||
(or (ctf/japanese-layout-enabled? file-data)
|
||||
(true? (get-in profile [:props :japanese-layout-all-files])))))
|
||||
|
||||
(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 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-hidden-option*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [value (:ruby-hidden values)
|
||||
enabled? (cond
|
||||
(mixed-span-value? value) nil
|
||||
(true? value) true
|
||||
:else false)
|
||||
label (tr "workspace.options.text-options.ruby-hidden")
|
||||
handle-change
|
||||
(mf/use-fn
|
||||
(mf/deps on-change on-blur)
|
||||
(fn [checked?]
|
||||
(on-change {:ruby-hidden checked?})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
[:div {:class (stl/css :ruby-hidden-option :japanese-select-option)}
|
||||
[:span {:class (stl/css :japanese-option-label)} label]
|
||||
[:> switch* {:default-checked enabled?
|
||||
:aria-label label
|
||||
:on-change handle-change}]]))
|
||||
|
||||
(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-hidden-option* common-props]
|
||||
[:> 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 [values ruby-values text-selection-active
|
||||
on-change on-ruby-presentation-change on-blur]}]
|
||||
(let [vertical? (vertical-japanese-layout? values)
|
||||
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})]
|
||||
|
||||
[:div {:class (stl/css :japanese-layout-options)}
|
||||
[: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,84 @@
|
||||
// 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: var(--sp-xs);
|
||||
}
|
||||
|
||||
.ruby-hidden-option {
|
||||
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);
|
||||
}
|
||||
@ -27,8 +27,30 @@
|
||||
[app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]]
|
||||
[app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]]
|
||||
[app.main.ui.workspace.sidebar.options.menus.text :refer [text-menu*]]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- dom-text-range-selected?
|
||||
[editor]
|
||||
(when editor
|
||||
(let [selection (.getSelection js/window)
|
||||
element (.-element editor)
|
||||
anchor (some-> selection .-anchorNode)
|
||||
focus (some-> selection .-focusNode)]
|
||||
(and selection
|
||||
(not (.-isCollapsed selection))
|
||||
element
|
||||
anchor
|
||||
focus
|
||||
(.contains element anchor)
|
||||
(.contains element focus)))))
|
||||
|
||||
(defn- draft-text-range-selected?
|
||||
[editor-state]
|
||||
(when editor-state
|
||||
(let [selection (.getSelection editor-state)]
|
||||
(and selection (not (.isCollapsed selection))))))
|
||||
|
||||
(mf/defc options*
|
||||
[{:keys [shape libraries file-id page-id]}]
|
||||
(let [id (dm/get-prop shape :id)
|
||||
@ -117,6 +139,19 @@
|
||||
(when text-editor-v2?
|
||||
editor)
|
||||
|
||||
text-selection-active
|
||||
(boolean
|
||||
(cond
|
||||
text-editor-wasm?
|
||||
(and (= id (wasm.api/text-editor-get-active-shape-id))
|
||||
(wasm.api/text-editor-has-selection?))
|
||||
|
||||
text-editor-v2?
|
||||
(dom-text-range-selected? editor-instance)
|
||||
|
||||
:else
|
||||
(draft-text-range-selected? editor-state)))
|
||||
|
||||
fill-values
|
||||
(dwt/current-text-values
|
||||
{:editor-styles editor-styles
|
||||
@ -143,7 +178,14 @@
|
||||
:editor-state editor-state
|
||||
:editor-instance editor-instance
|
||||
:shape shape
|
||||
:attrs txt/text-node-attrs}))]
|
||||
:attrs txt/text-node-attrs}))
|
||||
|
||||
ruby-values
|
||||
(if text-selection-active
|
||||
(select-keys text-values dwt/ruby-presentation-attrs)
|
||||
(dwt/current-ruby-values
|
||||
{:shape shape
|
||||
:attrs dwt/ruby-presentation-attrs}))]
|
||||
|
||||
[:*
|
||||
[:> layer-menu* {:ids ids
|
||||
@ -191,6 +233,8 @@
|
||||
:type type
|
||||
:applied-tokens applied-tokens
|
||||
:values text-values
|
||||
:ruby-values ruby-values
|
||||
:text-selection-active text-selection-active
|
||||
:libraries libraries
|
||||
:file-id file-id
|
||||
:typographies typographies}]
|
||||
@ -226,4 +270,3 @@
|
||||
:values (select-keys shape exports-attrs)
|
||||
:page-id page-id
|
||||
:file-id file-id}]]))
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.object :as obj]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[potok.v2.core :as ptk]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@ -563,23 +564,32 @@
|
||||
(dom/stop-propagation event)
|
||||
(let [target (dom/get-current-target event)
|
||||
position (-> (dom/get-data target "position")
|
||||
(keyword))]
|
||||
(keyword))
|
||||
horizontal-resize? (contains? #{:right :left} position)
|
||||
vertical-resize? (contains? #{:top :bottom} position)
|
||||
vertical-text? (wm/vertical-text-content? (get shape :content))
|
||||
wrap-axis-resize? (if vertical-text?
|
||||
vertical-resize?
|
||||
horizontal-resize?)
|
||||
resize-direction (if horizontal-resize? :horizontal :vertical)]
|
||||
(cond
|
||||
;; If text and in auto-width and the resize is horizontal, switch to auto-height and mark direction
|
||||
;; Resizing auto-width on the wrap axis switches to auto-height.
|
||||
;; The physical wrap axis is horizontal normally and vertical
|
||||
;; under vertical writing.
|
||||
(and (= shape-type :text)
|
||||
(= grow-type :auto-width)
|
||||
(or (= position :right) (= position :left)))
|
||||
(st/emit! (dwsh/update-shapes [shape-id] #(-> % (assoc :grow-type :auto-height) (assoc :last-resize-direction :horizontal))))
|
||||
;; If text and in auto-height and the resize is horizontal, mark direction but do not change grow-type
|
||||
wrap-axis-resize?)
|
||||
(st/emit! (dwsh/update-shapes [shape-id]
|
||||
#(assoc %
|
||||
:grow-type :auto-height
|
||||
:last-resize-direction resize-direction)))
|
||||
|
||||
(and (= shape-type :text)
|
||||
(= grow-type :auto-height)
|
||||
(or (= position :right) (= position :left)))
|
||||
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :last-resize-direction :horizontal)))
|
||||
;; If text and in auto-height and the resize is vertical, mark direction
|
||||
(and (= shape-type :text)
|
||||
(= grow-type :auto-height)
|
||||
(or (= position :top) (= position :bottom)))
|
||||
(st/emit! (dwsh/update-shapes [shape-id] #(assoc % :last-resize-direction :vertical)))
|
||||
(or horizontal-resize? vertical-resize?))
|
||||
(st/emit! (dwsh/update-shapes [shape-id]
|
||||
#(assoc % :last-resize-direction resize-direction)))
|
||||
|
||||
:else
|
||||
nil)
|
||||
(st/emit! (dw/start-resize position #{shape-id} shape))))))
|
||||
|
||||
@ -32,6 +32,17 @@
|
||||
(def ^:private text-direction-re #"ltr|rtl")
|
||||
(def ^:private text-align-re #"left|center|right|justify")
|
||||
(def ^:private vertical-align-re #"top|center|bottom")
|
||||
(def ^:private writing-mode-re #"horizontal-tb|vertical-rl")
|
||||
(def ^:private text-orientation-re #"mixed|upright")
|
||||
(def ^:private text-combine-upright-re #"none|all|digits2|digits3|digits")
|
||||
(def ^:private text-emphasis-re #"none|filled-dot|open-dot|filled-circle|open-circle|filled-sesame|open-sesame")
|
||||
(def ^:private warichu-re #"none|warichu")
|
||||
(def ^:private font-features-re #"none|palt|vpal")
|
||||
(def ^:private annotation-clearance-re #"none|auto")
|
||||
(def ^:private ruby-size-re #"half|third|quarter")
|
||||
(def ^:private ruby-align-re #"space-around|center|start|space-between")
|
||||
(def ^:private ruby-overhang-re #"auto|none")
|
||||
(def ^:private ruby-side-re #"over|under")
|
||||
|
||||
(defn- font-data
|
||||
[font variant]
|
||||
@ -90,6 +101,25 @@
|
||||
(recur (when continue? (rest styles)) taking? to result))
|
||||
result))))
|
||||
|
||||
(def ^:private japanese-range-defaults
|
||||
{:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby nil
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"})
|
||||
|
||||
(defn- range-japanese-value
|
||||
[range-data attr]
|
||||
(let [default (get japanese-range-defaults attr)]
|
||||
(->> range-data
|
||||
(map #(get % attr default))
|
||||
(u/mixed-value))))
|
||||
|
||||
(defn text-range-proxy?
|
||||
[range]
|
||||
(obj/type-of? range "TextRange"))
|
||||
@ -362,6 +392,226 @@
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:text-decoration value}))))}
|
||||
|
||||
:fontFeatures
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :font-features)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches font-features-re value)))
|
||||
(u/not-valid plugin-id :fontFeatures value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:font-features value}))))}
|
||||
|
||||
:textCombineUpright
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :text-combine-upright)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-combine-upright-re value)))
|
||||
(u/not-valid plugin-id :textCombineUpright value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:text-combine-upright value}))))}
|
||||
|
||||
:textEmphasis
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :text-emphasis)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-emphasis-re value)))
|
||||
(u/not-valid plugin-id :textEmphasis value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:text-emphasis value}))))}
|
||||
|
||||
:warichu
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :warichu)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches warichu-re value)))
|
||||
(u/not-valid plugin-id :warichu value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:warichu value}))))}
|
||||
|
||||
:annotationClearance
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :annotation-clearance)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches annotation-clearance-re value)))
|
||||
(u/not-valid plugin-id :annotationClearance value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:annotation-clearance value}))))}
|
||||
|
||||
:ruby
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(and (some? value) (not (string? value)))
|
||||
(u/not-valid plugin-id :ruby value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby value}))))}
|
||||
|
||||
:rubySize
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-size)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-size-re value)))
|
||||
(u/not-valid plugin-id :rubySize value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-size value}))))}
|
||||
|
||||
:rubyAlign
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-align)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-align-re value)))
|
||||
(u/not-valid plugin-id :rubyAlign value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-align value}))))}
|
||||
|
||||
:rubyOverhang
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-overhang)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-overhang-re value)))
|
||||
(u/not-valid plugin-id :rubyOverhang value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-overhang value}))))}
|
||||
|
||||
:rubySide
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-side)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-side-re value)))
|
||||
(u/not-valid plugin-id :rubySide value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-side value}))))}
|
||||
|
||||
:direction
|
||||
{:this true
|
||||
:get
|
||||
@ -756,5 +1006,209 @@
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:vertical-align value})))))}
|
||||
|
||||
{:name "writingMode"
|
||||
:get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches writing-mode-re value)))
|
||||
(u/not-valid plugin-id :writingMode value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :writingMode "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :writingMode "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:writing-mode value})))))}
|
||||
|
||||
{:name "textOrientation"
|
||||
:get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-orientation-re value)))
|
||||
(u/not-valid plugin-id :textOrientation value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textOrientation "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textOrientation "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:text-orientation value})))))}
|
||||
|
||||
{:name "textCombineUpright"
|
||||
:get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-combine-upright-re value)))
|
||||
(u/not-valid plugin-id :textCombineUpright value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:text-combine-upright value})))))}
|
||||
|
||||
{:name "textEmphasis"
|
||||
:get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-emphasis-re value)))
|
||||
(u/not-valid plugin-id :textEmphasis value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:text-emphasis value})))))}
|
||||
|
||||
{:name "warichu"
|
||||
:get #(-> % u/proxy->shape text-props :warichu format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches warichu-re value)))
|
||||
(u/not-valid plugin-id :warichu value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:warichu value})))))}
|
||||
|
||||
{:name "fontFeatures"
|
||||
:get #(-> % u/proxy->shape text-props :font-features format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches font-features-re value)))
|
||||
(u/not-valid plugin-id :fontFeatures value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:font-features value})))))}
|
||||
|
||||
{:name "annotationClearance"
|
||||
:get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches annotation-clearance-re value)))
|
||||
(u/not-valid plugin-id :annotationClearance value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:annotation-clearance value})))))}
|
||||
|
||||
{:name "ruby"
|
||||
:get #(-> % u/proxy->shape text-props :ruby format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(and (some? value) (not (string? value)))
|
||||
(u/not-valid plugin-id :ruby value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby value})))))}
|
||||
|
||||
{:name "rubySize"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-size-re value)))
|
||||
(u/not-valid plugin-id :rubySize value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-size value})))))}
|
||||
|
||||
{:name "rubyAlign"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-align-re value)))
|
||||
(u/not-valid plugin-id :rubyAlign value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-align value})))))}
|
||||
|
||||
{:name "rubyOverhang"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-overhang-re value)))
|
||||
(u/not-valid plugin-id :rubyOverhang value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-overhang value})))))}
|
||||
|
||||
{:name "rubySide"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-side-re value)))
|
||||
(u/not-valid plugin-id :rubySide value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-side value})))))}
|
||||
|
||||
{:name "textBounds"
|
||||
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})))
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
[app.util.dom :as dom]
|
||||
[app.util.functions :as fns]
|
||||
[app.util.globals :as ug]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.modules :as mod]
|
||||
[app.util.text.content :as tc]
|
||||
[app.util.timers :as timers]
|
||||
@ -110,7 +110,6 @@
|
||||
;; the next `tiles-complete` arrives.
|
||||
(defonce ^:private snapshot-capture-in-flight? (atom false))
|
||||
|
||||
|
||||
(defn initialized?
|
||||
"True when the WASM render context is safe for normal application calls.
|
||||
False while missing/lost or while `reload-renderer!` is reconstructing it.
|
||||
@ -123,7 +122,6 @@
|
||||
|
||||
(declare sync-workspace-local-viewport!)
|
||||
|
||||
|
||||
(defn set-transition-image-from-background!
|
||||
"Sets `transition-image*` to a data URL representing a solid background color."
|
||||
[background]
|
||||
@ -439,7 +437,9 @@
|
||||
needs-render?
|
||||
(do
|
||||
(when (= ev TEXT_EDITOR_EVENT_STYLES_CHANGED)
|
||||
(let [current-styles (text-editor/text-editor-get-current-styles)
|
||||
(let [current-styles (merge
|
||||
(text-editor/text-editor-get-current-styles)
|
||||
(text-editor/text-editor-get-current-japanese-styles))
|
||||
shape-id (text-editor/text-editor-get-active-shape-id)]
|
||||
;; Keep the caret color matching the text at the caret position.
|
||||
(text-editor/text-editor-apply-caret-color (:fills current-styles))
|
||||
@ -565,7 +565,6 @@
|
||||
(when (initialized?)
|
||||
(h/call wasm/internal-module "_render_preview")))
|
||||
|
||||
|
||||
(defonce pending-render (atom false))
|
||||
(defonce shapes-loading? (atom false))
|
||||
(defonce deferred-render? (atom false))
|
||||
@ -1367,6 +1366,13 @@
|
||||
[shadows]
|
||||
(props/set-shape-shadows shadows))
|
||||
|
||||
(defn- fallback-font-set
|
||||
"Emoji and Noto faces covering `emoji?` and the scripts in `langs`."
|
||||
[emoji? langs]
|
||||
(-> #{}
|
||||
(cond-> ^boolean emoji? (cfnt/add-emoji-font))
|
||||
(cfnt/add-noto-fonts (cfnt/resolve-ambiguous-cjk langs @i18n/locale))))
|
||||
|
||||
(defn fonts-from-text-content [content fallback-fonts-only?]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
paragraphs (get paragraph-set :children)
|
||||
@ -1383,9 +1389,10 @@
|
||||
emoji?
|
||||
langs)
|
||||
|
||||
(let [text (apply str (map :text spans))
|
||||
emoji? (if emoji? emoji? (cfnt/contains-emoji? text))
|
||||
langs (cfnt/collect-used-languages langs text)]
|
||||
(let [text (apply str (map :text spans))
|
||||
fallback-text (apply str (map #(str (:text %) (:ruby %)) spans))
|
||||
emoji? (if emoji? emoji? (cfnt/contains-emoji? fallback-text))
|
||||
langs (cfnt/collect-used-languages langs fallback-text)]
|
||||
|
||||
;; FIXME: this should probably be somewhere else
|
||||
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
|
||||
@ -1394,14 +1401,48 @@
|
||||
emoji?
|
||||
langs))))
|
||||
|
||||
(let [updated-fonts
|
||||
(-> #{}
|
||||
(cond-> ^boolean emoji? (cfnt/add-emoji-font))
|
||||
(cfnt/add-noto-fonts langs))
|
||||
(let [updated-fonts (fallback-font-set emoji? langs)
|
||||
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
|
||||
|
||||
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
|
||||
|
||||
;; Fallback faces a composition is already waiting on, so each preview update
|
||||
;; does not queue another relayout for them.
|
||||
(defonce ^:private composition-pending-faces (atom #{}))
|
||||
|
||||
(defn load-composition-fonts!
|
||||
"Fetch the fallback faces (emoji, Noto, ...) that `text` needs and relayout
|
||||
`shape-id` once they are stored. For text that only lives in WASM, such as an
|
||||
IME composition preview; committed content loads its faces through
|
||||
`set-shape-text-content`."
|
||||
[shape-id text]
|
||||
(let [emoji? (cfnt/contains-emoji? text)
|
||||
langs (cfnt/collect-used-languages #{} text)
|
||||
waiting @composition-pending-faces
|
||||
fonts (into []
|
||||
(comp (filter :is-fallback)
|
||||
(map (fn [font] [font (f/make-font-data font)]))
|
||||
(remove (fn [[_ font-data]]
|
||||
(or (f/font-ready? font-data)
|
||||
(contains? waiting (f/font-data-key font-data))))))
|
||||
(fallback-font-set emoji? langs))
|
||||
faces (into #{} (map (comp f/font-data-key second)) fonts)]
|
||||
(when (seq faces)
|
||||
(swap! composition-pending-faces into faces)
|
||||
(->> (rx/merge f/font-stored-stream f/font-storage-failed-stream)
|
||||
(rx/filter faces)
|
||||
(rx/scan disj faces)
|
||||
(rx/filter empty?)
|
||||
(rx/take 1)
|
||||
(rx/subs! (fn [_]
|
||||
(swap! composition-pending-faces #(reduce disj % faces))
|
||||
(f/force-update-text-layout shape-id)
|
||||
(request-render-preserving-target "composition-fonts"))))
|
||||
(->> (rx/from (f/store-fonts (map first fonts)))
|
||||
(rx/merge-map (fn [{:keys [callback]}]
|
||||
(if (fn? callback) (callback) (rx/empty))))
|
||||
(rx/subs! noop-fn noop-fn)))))
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(when (initialized?)
|
||||
@ -1912,7 +1953,6 @@
|
||||
(request-render "images-loaded")))))))))))]
|
||||
(process-next-chunk 0 [] [] empty-text-font-state))))))
|
||||
|
||||
|
||||
;; This is a version of process-pending that doesn't have sideffects
|
||||
;; with like request render or update layout.
|
||||
(defn- process-pending-no-sideffects
|
||||
@ -2119,7 +2159,6 @@
|
||||
heapu32 (mem/get-heap-u32)
|
||||
heapf32 (mem/get-heap-f32)]
|
||||
|
||||
|
||||
(reduce (fn [offset {:keys [type parent id index value]}]
|
||||
(-> offset
|
||||
(mem.h32/write-u32 heapu32 (sr/translate-structure-modifier-type type))
|
||||
@ -2369,7 +2408,9 @@
|
||||
(text-editor-wasm?)
|
||||
(bit-or 2r00000000000000000000000000000100)
|
||||
(contains? cf/flags :render-wasm-info)
|
||||
(bit-or 2r00000000000000000000000000001000)))
|
||||
(bit-or 2r00000000000000000000000000001000)
|
||||
(dbg/enabled? :wasm-text-grid)
|
||||
(bit-or 2r00000000000000000000000000010000)))
|
||||
|
||||
(defn set-render-options!
|
||||
"Updates WASM render options with a new DPR value."
|
||||
@ -2890,6 +2931,61 @@
|
||||
(def POSITION-DATA-U8-SIZE 36)
|
||||
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
|
||||
|
||||
(defn- ruby-font-scale
|
||||
[ruby-size]
|
||||
(case ruby-size
|
||||
"third" (/ 1 3)
|
||||
"quarter" 0.25
|
||||
0.5))
|
||||
|
||||
(defn- horizontal-ruby-slice
|
||||
"Returns the whole-span ruby annotation for a horizontal base strip."
|
||||
[element _start-pos _end-pos]
|
||||
(let [text (:text element)
|
||||
ruby (:ruby element)]
|
||||
(when (and (not (true? (:ruby-hidden element)))
|
||||
(string? text)
|
||||
(seq text)
|
||||
(string? ruby)
|
||||
(seq ruby))
|
||||
ruby)))
|
||||
|
||||
(defn- ruby-strip-entry
|
||||
"Position-data entry for a ruby annotation strip (direction 3): the
|
||||
offsets index the span's ruby string and the geometry is the exact
|
||||
gutter placement the canvas paints."
|
||||
[element {:keys [start-pos end-pos x y width height]}]
|
||||
(let [ruby (get element :ruby)]
|
||||
(when (and (not (true? (:ruby-hidden element)))
|
||||
(string? ruby))
|
||||
(let [text (subs ruby
|
||||
(min start-pos (count ruby))
|
||||
(min end-pos (count ruby)))
|
||||
font-size (js/parseFloat (get element :font-size))]
|
||||
(when (seq text)
|
||||
(d/patch-object
|
||||
(txt/get-default-text-attrs)
|
||||
(d/without-nils
|
||||
{:x x
|
||||
:y (+ y height)
|
||||
:width width
|
||||
:height height
|
||||
:direction "ltr"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:font-id (get element :font-id)
|
||||
:font-family (get element :font-family)
|
||||
:font-size (when-not (js/isNaN font-size)
|
||||
(dm/str (* (ruby-font-scale (:ruby-size element)) font-size) "px"))
|
||||
:font-weight (get element :font-weight)
|
||||
:font-style (get element :font-style)
|
||||
:ruby-size (get element :ruby-size)
|
||||
:ruby-align (get element :ruby-align)
|
||||
:ruby-overhang (get element :ruby-overhang)
|
||||
:ruby-side (get element :ruby-side)
|
||||
:fills (get element :fills)
|
||||
:text text})))))))
|
||||
|
||||
(defn calculate-position-data
|
||||
[shape]
|
||||
(when (initialized?)
|
||||
@ -2917,35 +3013,82 @@
|
||||
|
||||
(into []
|
||||
(keep
|
||||
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
|
||||
(let [element (-> content :children
|
||||
(get 0) :children ;; paragraph-set
|
||||
(get paragraph) :children ;; paragraph
|
||||
(fn [{:keys [paragraph span start-pos end-pos direction x y width height] :as entry}]
|
||||
(let [paragraph-node (-> content :children
|
||||
(get 0) :children ;; paragraph-set
|
||||
(get paragraph))
|
||||
element (-> paragraph-node :children ;; paragraph
|
||||
(get span))
|
||||
element-text (:text element)]
|
||||
(if (= direction 3)
|
||||
(ruby-strip-entry element entry)
|
||||
;; Glyph orientation of a vertical strip; stored on the
|
||||
;; span or its paragraph. Empty reads normalize to nil
|
||||
;; (the SVG renderer then defaults to "mixed").
|
||||
(let [text-orientation
|
||||
(when (= direction 2)
|
||||
(let [orientation (or (get element :text-orientation)
|
||||
(get paragraph-node :text-orientation))]
|
||||
(when (seq orientation) orientation)))]
|
||||
|
||||
;; Add comprehensive nil-safety checks
|
||||
;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos`
|
||||
(when (and element element-text)
|
||||
(let [text (subs element-text start-pos end-pos)]
|
||||
(d/patch-object
|
||||
(txt/get-default-text-attrs)
|
||||
(d/without-nils
|
||||
{:x x
|
||||
:y (+ y height)
|
||||
:width width
|
||||
:height height
|
||||
:direction (dr/translate-direction direction)
|
||||
:font-id (get element :font-id)
|
||||
:font-family (get element :font-family)
|
||||
:font-size (dm/str (get element :font-size) "px")
|
||||
:font-weight (get element :font-weight)
|
||||
:text-transform (get element :text-transform)
|
||||
:text-decoration (get element :text-decoration)
|
||||
:letter-spacing (dm/str (get element :letter-spacing) "px")
|
||||
:font-style (get element :font-style)
|
||||
:fills (get element :fills)
|
||||
:text text})))))))
|
||||
;; Add comprehensive nil-safety checks
|
||||
;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos`
|
||||
(when (and element element-text)
|
||||
(let [text (subs element-text start-pos end-pos)]
|
||||
(d/patch-object
|
||||
(txt/get-default-text-attrs)
|
||||
(d/without-nils
|
||||
{:x x
|
||||
:y (+ y height)
|
||||
:width width
|
||||
:height height
|
||||
:direction (dr/translate-direction direction)
|
||||
;; Direction 2 marks a vertical-rl column strip;
|
||||
;; the SVG renderer draws it with CSS writing-mode.
|
||||
:writing-mode (when (= direction 2) "vertical-rl")
|
||||
:text-orientation text-orientation
|
||||
:font-id (get element :font-id)
|
||||
:font-family (get element :font-family)
|
||||
:font-size (dm/str (get element :font-size) "px")
|
||||
:font-weight (get element :font-weight)
|
||||
:text-transform (get element :text-transform)
|
||||
:text-decoration (get element :text-decoration)
|
||||
:text-combine-upright (get element :text-combine-upright)
|
||||
;; Emphasis marks (圏点) are drawn by the static SVG
|
||||
;; renderer; "none" carries no information.
|
||||
:text-emphasis (let [emphasis (get element :text-emphasis)]
|
||||
(when (and (string? emphasis)
|
||||
(seq emphasis)
|
||||
(not= "none" emphasis))
|
||||
emphasis))
|
||||
:annotation-clearance
|
||||
(let [clearance (get element :annotation-clearance)]
|
||||
(when (= "auto" clearance) clearance))
|
||||
:annotation-has-ruby
|
||||
(let [ruby (get element :ruby)]
|
||||
(when (and (not (true? (:ruby-hidden element)))
|
||||
(string? ruby)
|
||||
(seq ruby))
|
||||
true))
|
||||
;; Horizontal position data has no separate ruby
|
||||
;; strip, so carry the annotation on the base entry
|
||||
;; for static SVG export. Vertical ruby has its own
|
||||
;; direction-3 position entry.
|
||||
:ruby (when (not= direction 2)
|
||||
(horizontal-ruby-slice
|
||||
element start-pos end-pos))
|
||||
:ruby-size (get element :ruby-size)
|
||||
:ruby-align (get element :ruby-align)
|
||||
:ruby-overhang (get element :ruby-overhang)
|
||||
:ruby-side (get element :ruby-side)
|
||||
;; Warichu spans render as two half-size sub-columns
|
||||
;; in the static SVG; "none" carries no information.
|
||||
:warichu (let [warichu (get element :warichu)]
|
||||
(when (= "warichu" warichu) warichu))
|
||||
:letter-spacing (dm/str (get element :letter-spacing) "px")
|
||||
:font-style (get element :font-style)
|
||||
:fills (get element :fills)
|
||||
:text text})))))))))
|
||||
result))))
|
||||
|
||||
(defn apply-canvas-blur
|
||||
@ -3075,5 +3218,3 @@
|
||||
viewport mount. Idempotent: the `delay` caches its in-flight promise."
|
||||
[]
|
||||
@module)
|
||||
|
||||
|
||||
|
||||
@ -282,6 +282,17 @@
|
||||
(or (contains? @failed-font-data-keys (font-data-key font-data))
|
||||
(font-stored? font-data (:emoji? font-data))))
|
||||
|
||||
(defn- mark-font-as-fallback!
|
||||
[font-data]
|
||||
(let [id-buffer (uuid/get-u32 (:wasm-id font-data))]
|
||||
(h/call wasm/internal-module "_mark_font_as_fallback"
|
||||
(aget id-buffer 0)
|
||||
(aget id-buffer 1)
|
||||
(aget id-buffer 2)
|
||||
(aget id-buffer 3)
|
||||
(:weight font-data)
|
||||
(:style font-data))))
|
||||
|
||||
(defn- store-font-id
|
||||
[font-data asset-id emoji? fallback?]
|
||||
(if asset-id
|
||||
@ -299,6 +310,8 @@
|
||||
(do
|
||||
(store-font-url font-data uri)
|
||||
(clear-font-storage-failure! font-data)
|
||||
(when fallback?
|
||||
(mark-font-as-fallback! font-data))
|
||||
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
|
||||
(fetch-font font-data uri emoji? fallback?)))
|
||||
;; Report missing font assets asynchronously.
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.fonts :as main-fonts]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
@ -250,7 +251,7 @@
|
||||
caret/selection overlay on top, submitting one atomic frame. Pixel identical
|
||||
to the last full render at any zoom, so the blink does not flash."
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(when (wasm/ready?)
|
||||
(h/call wasm/internal-module "_text_editor_render_caret")))
|
||||
|
||||
(defn text-editor-poll-event
|
||||
@ -369,6 +370,25 @@
|
||||
(mem/free)
|
||||
result)))))
|
||||
|
||||
(defn text-editor-get-cursor-rect
|
||||
"Read the caret rectangle from the WASM editor.
|
||||
|
||||
Coordinates are in the shape's local selrect space (the same space as the
|
||||
text overlay's foreignObject). Returns a map with :x :y :width :height, or
|
||||
nil when there is no active caret."
|
||||
[]
|
||||
(when (wasm/ready?)
|
||||
(let [ptr (h/call wasm/internal-module "_text_editor_get_cursor_rect")]
|
||||
(when (and ptr (not (zero? ptr)))
|
||||
(let [heap-f32 (mem/get-heap-f32)
|
||||
offset (mem/->offset-32 ptr)
|
||||
x (aget heap-f32 offset)
|
||||
y (aget heap-f32 (+ offset 1))
|
||||
width (aget heap-f32 (+ offset 2))
|
||||
height (aget heap-f32 (+ offset 3))]
|
||||
(mem/free)
|
||||
{:x x :y y :width width :height height})))))
|
||||
|
||||
(defn text-editor-encode-text-pre
|
||||
[text]
|
||||
(when (and (not (empty? text))
|
||||
@ -398,6 +418,12 @@
|
||||
(h/call wasm/internal-module "_text_editor_composition_update")
|
||||
(text-editor-encode-text-post text)))
|
||||
|
||||
(defn text-editor-set-composition-cursor
|
||||
"Place the WASM caret `offset` characters into the composition preview."
|
||||
[offset]
|
||||
(when (wasm/ready?)
|
||||
(h/call wasm/internal-module "_text_editor_set_composition_cursor" offset)))
|
||||
|
||||
(defn text-editor-composition-end
|
||||
[text]
|
||||
(when (wasm/ready?)
|
||||
@ -667,6 +693,115 @@
|
||||
{:start-para focus-para :start-offset focus-offset
|
||||
:end-para anchor-para :end-offset anchor-offset}))
|
||||
|
||||
(defn- para-char-count
|
||||
[para]
|
||||
(apply + (map (fn [span] (count (:text span))) (:children para))))
|
||||
|
||||
(def ^:private japanese-span-style-defaults
|
||||
"Explicit UI defaults for Japanese span attributes. These values must be
|
||||
present in every selection snapshot so moving onto an unstyled span clears
|
||||
the previous span's controls instead of leaving stale values behind."
|
||||
{:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby ""
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"})
|
||||
|
||||
(defn- span-japanese-styles
|
||||
[span]
|
||||
(reduce-kv
|
||||
(fn [styles attr default]
|
||||
(assoc styles attr (or (get span attr) default)))
|
||||
{}
|
||||
japanese-span-style-defaults))
|
||||
|
||||
(defn- merge-selection-styles
|
||||
[result styles]
|
||||
(reduce-kv
|
||||
(fn [result attr value]
|
||||
(update result attr #(if (or (nil? %) (= % value)) value :multiple)))
|
||||
result
|
||||
styles))
|
||||
|
||||
(defn- span-at-offset
|
||||
"Return the span used by the WASM editor for a collapsed caret. At a span
|
||||
boundary the preceding span wins, matching find_text_span_at_offset in
|
||||
render-wasm."
|
||||
[paragraph offset]
|
||||
(let [spans (:children paragraph)]
|
||||
(loop [remaining-spans spans
|
||||
accumulated 0]
|
||||
(if-let [span (first remaining-spans)]
|
||||
(let [span-end (+ accumulated (count (:text span)))]
|
||||
(if (<= offset span-end)
|
||||
span
|
||||
(recur (rest remaining-spans) span-end)))
|
||||
(last spans)))))
|
||||
|
||||
(defn- selected-spans-in-paragraph
|
||||
[paragraph selection-start selection-end]
|
||||
(loop [spans (:children paragraph)
|
||||
position 0
|
||||
selected []]
|
||||
(if-let [span (first spans)]
|
||||
(let [span-end (+ position (count (:text span)))]
|
||||
(recur (rest spans)
|
||||
span-end
|
||||
(cond-> selected
|
||||
(< (max position selection-start)
|
||||
(min span-end selection-end))
|
||||
(conj span))))
|
||||
selected)))
|
||||
|
||||
(defn selection-japanese-styles
|
||||
"Read Japanese span styles for a normalized WASM selection from Penpot's
|
||||
cached content tree. A range spanning different values reports :multiple;
|
||||
a caret reports the style of its current span."
|
||||
[content selection]
|
||||
(when (and content selection)
|
||||
(let [{:keys [start-para start-offset end-para end-offset]}
|
||||
(normalize-selection selection)
|
||||
paragraphs (-> content :children first :children)
|
||||
collapsed? (and (= start-para end-para)
|
||||
(= start-offset end-offset))
|
||||
selected-spans
|
||||
(if collapsed?
|
||||
(some-> (get paragraphs start-para)
|
||||
(span-at-offset start-offset)
|
||||
vector)
|
||||
(mapcat
|
||||
(fn [paragraph-index]
|
||||
(when-let [paragraph (get paragraphs paragraph-index)]
|
||||
(let [selection-start (if (= paragraph-index start-para)
|
||||
start-offset
|
||||
0)
|
||||
selection-end (if (= paragraph-index end-para)
|
||||
end-offset
|
||||
(para-char-count paragraph))]
|
||||
(selected-spans-in-paragraph paragraph
|
||||
selection-start
|
||||
selection-end))))
|
||||
(range start-para (inc end-para))))]
|
||||
(when (seq selected-spans)
|
||||
(reduce (fn [result span]
|
||||
(merge-selection-styles result (span-japanese-styles span)))
|
||||
{}
|
||||
selected-spans)))))
|
||||
|
||||
(defn text-editor-get-current-japanese-styles
|
||||
"Return Japanese span styles for the active WASM editor selection."
|
||||
[]
|
||||
(when (wasm/ready?)
|
||||
(let [shape-id (text-editor-get-active-shape-id)
|
||||
selection (text-editor-get-selection)]
|
||||
(selection-japanese-styles (get-cached-content shape-id) selection))))
|
||||
|
||||
(defn apply-attrs-to-paragraph
|
||||
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."
|
||||
[para sel-start sel-end styles]
|
||||
@ -703,10 +838,6 @@
|
||||
(into (keep identity [before selected after])))))))))]
|
||||
(assoc para :children result)))
|
||||
|
||||
(defn- para-char-count
|
||||
[para]
|
||||
(apply + (map (fn [span] (count (:text span))) (:children para))))
|
||||
|
||||
(defn- paragraph-selected-spans
|
||||
"Return the spans of `para` that overlap the [sel-start, sel-end) char range."
|
||||
[para sel-start sel-end]
|
||||
@ -823,26 +954,40 @@
|
||||
{:shape-id shape-id
|
||||
:content new-content})))))
|
||||
|
||||
(defn apply-paragraph-attrs-to-range
|
||||
"Apply paragraph level attrs to the paragraphs `selection` touches; a
|
||||
collapsed caret means just the one it sits in. Whole-shape attrs (writing
|
||||
mode, orientation) go to every paragraph instead, a nil value removing them."
|
||||
[content selection attrs]
|
||||
(let [{:keys [start-para end-para]} (normalize-selection selection)
|
||||
whole-attrs (select-keys attrs jl/whole-shape-paragraph-attrs)
|
||||
range-attrs (apply dissoc attrs jl/whole-shape-paragraph-attrs)
|
||||
apply-whole (fn [para]
|
||||
(reduce-kv (fn [para attr value]
|
||||
(if (some? value)
|
||||
(assoc para attr value)
|
||||
(dissoc para attr)))
|
||||
para
|
||||
whole-attrs))
|
||||
paragraph-set (first (:children content))
|
||||
new-paragraphs (into []
|
||||
(map-indexed (fn [idx para]
|
||||
(cond-> (apply-whole para)
|
||||
(<= start-para idx end-para)
|
||||
(merge range-attrs))))
|
||||
(:children paragraph-set))]
|
||||
(assoc content :children [(assoc paragraph-set :children new-paragraphs)])))
|
||||
|
||||
(defn apply-paragraph-attrs-to-selection
|
||||
"Apply paragraph level attrs (text-align, text-direction) to the whole
|
||||
paragraphs the editor selection touches; a collapsed caret means just the one
|
||||
it sits in."
|
||||
"Apply paragraph level attrs to the active editor selection (see
|
||||
`apply-paragraph-attrs-to-range`)."
|
||||
[attrs use-shape-fn set-shape-text-content-fn]
|
||||
(when (wasm/ready?)
|
||||
(let [shape-id (text-editor-get-active-shape-id)
|
||||
selection (text-editor-get-selection)]
|
||||
(when (and shape-id selection)
|
||||
(when-let [content (get-cached-content shape-id)]
|
||||
(let [{:keys [start-para end-para]} (normalize-selection selection)
|
||||
paragraph-set (first (:children content))
|
||||
new-paragraphs (into []
|
||||
(map-indexed (fn [idx para]
|
||||
(if (<= start-para idx end-para)
|
||||
(merge para attrs)
|
||||
para)))
|
||||
(:children paragraph-set))
|
||||
new-content (assoc content :children
|
||||
[(assoc paragraph-set :children new-paragraphs)])]
|
||||
(let [new-content (apply-paragraph-attrs-to-range content selection attrs)]
|
||||
(update-cached-content! shape-id new-content)
|
||||
(use-shape-fn shape-id)
|
||||
(set-shape-text-content-fn shape-id new-content)
|
||||
|
||||
@ -104,6 +104,9 @@
|
||||
;; Show viewbox.
|
||||
:wasm-viewbox
|
||||
|
||||
;; Draw the jlreq-style character-frame grid over Japanese vertical text.
|
||||
:wasm-text-grid
|
||||
|
||||
;; Makes the GL context to fail on initialization.
|
||||
:wasm-gl-context-init-error
|
||||
|
||||
|
||||
38
frontend/src/app/util/text/writing_mode.cljs
Normal file
@ -0,0 +1,38 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns app.util.text.writing-mode
|
||||
"Writing mode of text content as the active renderer lays it out. Only the
|
||||
WASM renderer supports vertical writing; under the SVG renderer every text
|
||||
flows horizontally, while the stored attrs are kept untouched."
|
||||
(:require
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.text.japanese-layout :as jl]))
|
||||
|
||||
(defn vertical-layout-active?
|
||||
"True when the active renderer lays out vertical writing (WASM)."
|
||||
[]
|
||||
cts/wasm-enabled?)
|
||||
|
||||
(defn content-writing-mode
|
||||
"Effective writing mode of a text content (see `jl/content-writing-mode`)."
|
||||
[content]
|
||||
(when ^boolean (vertical-layout-active?)
|
||||
(jl/content-writing-mode content)))
|
||||
|
||||
(defn vertical-text-content?
|
||||
"True when the text content flows vertically under the active renderer."
|
||||
[content]
|
||||
(and ^boolean (vertical-layout-active?)
|
||||
(jl/vertical-text-content? content)))
|
||||
|
||||
(defn stale-vertical-layout?
|
||||
"True when the content is stored as vertical but the active renderer lays
|
||||
it out horizontally, so a layout computed by the WASM renderer (such as
|
||||
`:position-data`) no longer matches."
|
||||
[content]
|
||||
(and (not ^boolean (vertical-layout-active?))
|
||||
(jl/vertical-text-content? content)))
|
||||
@ -10,14 +10,17 @@
|
||||
Each test guards against a concrete bug found in the CSS/HTML generation
|
||||
of layout children and text shapes."
|
||||
(:require
|
||||
["react-dom/server" :as rds]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.ui.shapes.text.fo-text :as fo-text]
|
||||
[app.util.code-gen.markup-html :as html]
|
||||
[app.util.code-gen.style-css :as css]
|
||||
[cljs.test :refer [deftest is testing] :include-macros true]
|
||||
[cuerdas.core :as str]))
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; --- Builders ------------------------------------------------------------
|
||||
|
||||
@ -180,6 +183,29 @@
|
||||
:children [{:text "Hello"
|
||||
:fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]})
|
||||
|
||||
(def ^:private ruby-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:children [{:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(defn- text-shape
|
||||
[content]
|
||||
(let [tid (uuid/next)]
|
||||
{:id tid :name "Text" :type :text
|
||||
:parent-id uuid/zero :frame-id uuid/zero
|
||||
:x 0 :y 0 :width 100 :height 40
|
||||
:selrect (grc/make-rect 0 0 100 40)
|
||||
:points (pts 0 0 100 40)
|
||||
:grow-type :fixed
|
||||
:content content}))
|
||||
|
||||
(deftest text-markup-emits-node-id-classes
|
||||
(testing "generated text markup carries the $id classes the CSS rules target"
|
||||
(let [tid (uuid/next)
|
||||
@ -194,3 +220,90 @@
|
||||
(is (str/includes? markup "root-0")
|
||||
"the text nodes must expose their $id as a class for the CSS to apply")
|
||||
(is (str/includes? markup "root-0-paragraph-set-0-paragraph-0")))))
|
||||
|
||||
(def ^:private warichu-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:children [{:text "割注入り"
|
||||
:warichu "warichu"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(def ^:private palt-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "かな"
|
||||
:font-features "palt"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(deftest foreign-object-text-emits-warichu-styles
|
||||
(testing "browser/foreignObject render folds a warichu span into two half-size lines"
|
||||
(let [text (text-shape warichu-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "割注入り"))
|
||||
(is (str/includes? markup "display:inline-block"))
|
||||
(is (str/includes? markup "font-size:10px"))
|
||||
(is (str/includes? markup "inline-size:2em")))))
|
||||
|
||||
(deftest foreign-object-warichu-sizes-by-unicode-code-points
|
||||
(testing "non-BMP characters count as one slot when sizing warichu lines"
|
||||
(let [content (assoc-in warichu-text-content
|
||||
[:children 0 :children 0 :children 0 :text]
|
||||
"割𠀀注😀")
|
||||
text (text-shape content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "割𠀀注😀"))
|
||||
(is (str/includes? markup "inline-size:2em")))))
|
||||
|
||||
(def ^:private tcy-digits2-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:children [{:text "平成31年"
|
||||
:text-combine-upright "digits2"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(deftest foreign-object-text-emits-counted-digits-css
|
||||
(testing "the counted digits variants serialize as CSS `digits <n>`"
|
||||
(let [text (text-shape tcy-digits2-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "text-combine-upright:digits 2")))))
|
||||
|
||||
(deftest foreign-object-text-emits-font-feature-settings
|
||||
(testing "browser/foreignObject render emits palt/vpal as OpenType features"
|
||||
(let [text (text-shape palt-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "font-feature-settings:"palt"")))))
|
||||
|
||||
(deftest text-markup-emits-ruby-annotations
|
||||
(testing "generated text markup keeps ruby annotations beside the base text"
|
||||
(let [text (text-shape ruby-text-content)
|
||||
markup (html/generate-markup (objects text) [text])]
|
||||
(is (str/includes? markup "<ruby"))
|
||||
(is (str/includes? markup "<rt"))
|
||||
(is (str/includes? markup "漢字"))
|
||||
(is (str/includes? markup "かんじ")))))
|
||||
|
||||
(deftest foreign-object-text-emits-ruby-annotations
|
||||
(testing "browser/foreignObject text render keeps ruby annotations"
|
||||
(let [text (text-shape ruby-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "<ruby"))
|
||||
(is (str/includes? markup "<rt"))
|
||||
(is (str/includes? markup "漢字"))
|
||||
(is (str/includes? markup "かんじ")))))
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.util.code-gen.markup-svg :as svg]
|
||||
[cljs.test :refer [deftest is testing] :include-macros true]))
|
||||
|
||||
@ -37,6 +38,224 @@
|
||||
[re s]
|
||||
(count (re-seq re s)))
|
||||
|
||||
(defn- setup-vertical-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 40
|
||||
:height 100})
|
||||
(assoc :name "Vertical text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:font-features "vpal"
|
||||
:text "うA"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-text))]}))
|
||||
|
||||
(defn- setup-vertical-ruby-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical ruby text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text "漢字"
|
||||
:ruby "かんじ"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-ruby-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-ruby-text))]}))
|
||||
|
||||
(defn- setup-vertical-emphasis-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical emphasis text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text "強調 あ"
|
||||
:text-emphasis "filled-dot"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-emphasis-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-emphasis-text))]}))
|
||||
|
||||
(defn- setup-horizontal-emphasis-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal emphasis text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 100
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text "強調、 あ"
|
||||
:text-emphasis "filled-dot"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-emphasis-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-emphasis-text))]}))
|
||||
|
||||
(defn- setup-horizontal-stacked-annotations
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal stacked annotations"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 100
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233" :fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:text-emphasis "filled-dot"
|
||||
:annotation-clearance "auto"
|
||||
:annotation-has-ruby true}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-stacked-annotations
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-stacked-annotations))]}))
|
||||
|
||||
(defn- setup-vertical-warichu-text
|
||||
([] (setup-vertical-warichu-text "割注入り"))
|
||||
([text]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical warichu text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 40
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text text
|
||||
:warichu "warichu"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-warichu-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-warichu-text))]})))
|
||||
|
||||
(defn- setup-horizontal-warichu-text
|
||||
([] (setup-horizontal-warichu-text "割注入り"))
|
||||
([text]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal warichu text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 40
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text text
|
||||
:warichu "warichu"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-warichu-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-warichu-text))]})))
|
||||
|
||||
(deftest empty-selection-yields-empty-string
|
||||
(is (= "" (svg/generate-markup {} []))))
|
||||
|
||||
@ -59,3 +278,97 @@
|
||||
"multi-select must NOT emit multiple <svg> roots")
|
||||
(is (= 1 (count-matches #"</svg>" markup))
|
||||
"multi-select must NOT emit multiple </svg> closing tags"))))
|
||||
|
||||
(deftest vertical-text-svg-preserves-browser-layout-properties
|
||||
(testing "Static SVG carries the vertical writing properties used by browser exports"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"writing-mode:vertical-rl" markup))
|
||||
(is (re-find #"text-orientation:upright" markup))
|
||||
(is (re-find #"text-autospace:normal" markup))
|
||||
(is (re-find #"font-feature-settings:"vpal"" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest vertical-emphasis-svg-emits-static-marks
|
||||
(testing "Static SVG draws emphasis marks and skips whitespace characters"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-emphasis-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調 あ" markup))
|
||||
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
|
||||
(is (re-find #"•• •" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
|
||||
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-emphasis-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調、 あ" markup))
|
||||
;; Five Unicode base characters: two marks, punctuation and whitespace
|
||||
;; slots, then one mark.
|
||||
(is (re-find #"•• •" markup))
|
||||
(is (re-find #"writing-mode:horizontal-tb" markup))
|
||||
(is (re-find #"dominant-baseline=\"text-after-edge\"" markup))
|
||||
(is (re-find #"lengthAdjust=\"spacing\"" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (re-find #"fill:url\(#fill-0-[^)]+-0\)" markup)
|
||||
"the emphasis mark references its generated per-strip fill")
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
|
||||
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-stacked-annotations)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"かんじ" markup))
|
||||
(is (re-find #"••" markup))
|
||||
(is (re-find #"y=\"40\"" markup) "ruby occupies the first half-em layer")
|
||||
(is (re-find #"y=\"30\"" markup) "emphasis occupies the outer half-em layer"))))
|
||||
|
||||
(deftest vertical-warichu-svg-emits-two-sub-columns
|
||||
(testing "Static SVG splits a warichu strip into two half-size sub-columns"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
(is (not (re-find #"割注入り" markup))
|
||||
"the base text must be split, not drawn as one strip")
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest vertical-warichu-svg-split-respects-kinsoku
|
||||
(testing "The second sub-line must not start with a line-start-prohibited mark"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割り注、と")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割り注、" markup)
|
||||
"the comma is pulled up into the first sub-line")
|
||||
(is (not (re-find #"割り注、と" markup))
|
||||
"the strip is still split into two sub-lines"))))
|
||||
|
||||
(deftest vertical-warichu-svg-preserves-non-bmp-characters
|
||||
(testing "Static SVG never splits a surrogate pair between warichu sub-lines"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割𠀀注😀")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割𠀀" markup))
|
||||
(is (re-find #"注😀" markup))
|
||||
(is (not (re-find #"<22>" markup))))))
|
||||
|
||||
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
|
||||
(testing "Static SVG splits horizontal warichu into top and bottom half-size lines"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-warichu-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
(is (not (re-find #"割注入り" markup)))
|
||||
(is (re-find #"writing-mode:horizontal-tb" markup))
|
||||
(is (= 2 (count-matches #"dominant-baseline=.?hanging" markup)))
|
||||
(is (re-find #"font-size:10px" markup)))))
|
||||
|
||||
(deftest vertical-ruby-svg-emits-static-annotation
|
||||
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-ruby-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"漢字" markup))
|
||||
(is (re-find #"かんじ" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (re-find #"text-orientation:upright" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
(ns frontend-tests.data.workspace-texts-test
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
@ -13,12 +14,80 @@
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.data.workspace.texts-events :as dwte]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
[app.main.ui.shapes.text.styles :as text.styles]
|
||||
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.state :as ths]))
|
||||
|
||||
(defn- text-content
|
||||
[writing-mode]
|
||||
{:children [{:children [{:writing-mode writing-mode}]}]})
|
||||
|
||||
(t/deftest vertical-auto-height-grows-width
|
||||
(let [selrect {:width 100 :height 200}
|
||||
dimension {:width 240 :height 360}
|
||||
content (text-content "vertical-rl")]
|
||||
(t/is (= {:width 240 :height 200}
|
||||
(dwwt/resolve-text-size selrect :auto-height content dimension)))))
|
||||
|
||||
(t/deftest horizontal-auto-height-grows-height
|
||||
(let [selrect {:width 100 :height 200}
|
||||
dimension {:width 240 :height 360}
|
||||
content (text-content "horizontal-tb")]
|
||||
(t/is (= {:width 100 :height 360}
|
||||
(dwwt/resolve-text-size selrect :auto-height content dimension)))))
|
||||
|
||||
(t/deftest vertical-grow-type-resize-axes-are-remapped
|
||||
(t/is (= :auto-height
|
||||
(dwm/next-grow-type :auto-width (gpt/point 1 2) true)))
|
||||
(t/is (= :fixed
|
||||
(dwm/next-grow-type :auto-height (gpt/point 2 1) true)))
|
||||
(t/is (= :auto-height
|
||||
(dwm/next-grow-type :auto-height (gpt/point 1 2) true))))
|
||||
|
||||
(t/deftest vertical-export-styles-enable-inter-script-spacing
|
||||
(let [vertical (text.styles/generate-paragraph-styles
|
||||
nil
|
||||
{:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"})
|
||||
horizontal (text.styles/generate-paragraph-styles
|
||||
nil
|
||||
{:writing-mode "horizontal-tb"})]
|
||||
(t/is (= "vertical-rl" (aget vertical "writingMode")))
|
||||
(t/is (= "upright" (aget vertical "textOrientation")))
|
||||
(t/is (= "normal" (aget vertical "textAutospace")))
|
||||
(t/is (nil? (aget horizontal "textAutospace")))))
|
||||
|
||||
(t/deftest text-export-styles-emit-font-features
|
||||
(let [palt (text.styles/generate-text-styles
|
||||
{:grow-type :fixed}
|
||||
{:font-features "palt"
|
||||
:font-size "20"
|
||||
:fills [{:fill-color "#000000" :fill-opacity 1}]})
|
||||
none (text.styles/generate-text-styles
|
||||
{:grow-type :fixed}
|
||||
{:font-features "none"
|
||||
:font-size "20"
|
||||
:fills [{:fill-color "#000000" :fill-opacity 1}]})]
|
||||
(t/is (= "\"palt\"" (aget palt "fontFeatureSettings")))
|
||||
(t/is (nil? (aget none "fontFeatureSettings")))))
|
||||
|
||||
(t/deftest text-export-styles-emit-annotation-clearance
|
||||
(let [style (text.styles/generate-text-styles
|
||||
{:grow-type :fixed}
|
||||
{:annotation-clearance "auto"
|
||||
:line-height "1.2"
|
||||
:ruby "かんじ"
|
||||
:text-emphasis "filled-dot"
|
||||
:font-size "20"
|
||||
:fills [{:fill-color "#000000" :fill-opacity 1}]})]
|
||||
(t/is (= "auto" (aget style "--annotation-clearance")))
|
||||
(t/is (= 2.2 (aget style "lineHeight")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@ -189,6 +189,11 @@
|
||||
["TextRangeProxy.fontSize" #(set! (.-fontSize range) "16")]
|
||||
["TextRangeProxy.lineHeight" #(set! (.-lineHeight range) "1.2")]
|
||||
["TextRangeProxy.letterSpacing" #(set! (.-letterSpacing range) "1")]
|
||||
["TextRangeProxy.ruby" #(set! (.-ruby range) "にち")]
|
||||
["TextRangeProxy.rubySize" #(set! (.-rubySize range) "third")]
|
||||
["TextRangeProxy.rubyAlign" #(set! (.-rubyAlign range) "center")]
|
||||
["TextRangeProxy.rubyOverhang" #(set! (.-rubyOverhang range) "none")]
|
||||
["TextRangeProxy.rubySide" #(set! (.-rubySide range) "under")]
|
||||
["TextRangeProxy.fills" #(set! (.-fills range) #js [#js {:fillColor "#fabada" :fillOpacity 1}])]
|
||||
|
||||
;; ---- RulerGuideProxy ----
|
||||
@ -315,4 +320,3 @@
|
||||
["borderRadiusBottomRight" #(set! (.-borderRadiusBottomRight rect) 2.5)]
|
||||
["borderRadiusBottomLeft" #(set! (.-borderRadiusBottomLeft rect) 2.5)]]]
|
||||
(t/is (not (throws? thunk)) (str label " must accept a fractional value")))))))
|
||||
|
||||
|
||||
@ -44,6 +44,17 @@
|
||||
(set! (.-letterSpacing range) value)
|
||||
@captured)))
|
||||
|
||||
(def ^:private font-features-re @#'plugins.text/font-features-re)
|
||||
(def ^:private annotation-clearance-re @#'plugins.text/annotation-clearance-re)
|
||||
(def ^:private ruby-size-re @#'plugins.text/ruby-size-re)
|
||||
(def ^:private ruby-align-re @#'plugins.text/ruby-align-re)
|
||||
(def ^:private ruby-overhang-re @#'plugins.text/ruby-overhang-re)
|
||||
(def ^:private ruby-side-re @#'plugins.text/ruby-side-re)
|
||||
|
||||
(defn- valid-font-features? [s] (boolean (re-matches font-features-re s)))
|
||||
(defn- valid-annotation-clearance? [s]
|
||||
(boolean (re-matches annotation-clearance-re s)))
|
||||
|
||||
(t/deftest letter-spacing-accepts-negative-values
|
||||
(t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56")))
|
||||
(t/is (= {:letter-spacing "-12"} (apply-letter-spacing "-12")))
|
||||
@ -59,6 +70,119 @@
|
||||
(t/is (nil? (apply-letter-spacing "1-2")))
|
||||
(t/is (nil? (apply-letter-spacing "--1"))))
|
||||
|
||||
(t/deftest font-features-re-accepts-supported-japanese-proportional-features
|
||||
(t/is (valid-font-features? "none"))
|
||||
(t/is (valid-font-features? "palt"))
|
||||
(t/is (valid-font-features? "vpal"))
|
||||
(t/is (not (valid-font-features? "liga")))
|
||||
(t/is (not (valid-font-features? "palt,vpal"))))
|
||||
|
||||
(t/deftest annotation-clearance-re-accepts-supported-policies
|
||||
(t/is (valid-annotation-clearance? "none"))
|
||||
(t/is (valid-annotation-clearance? "auto"))
|
||||
(t/is (not (valid-annotation-clearance? "always"))))
|
||||
|
||||
(t/deftest ruby-customization-validates-supported-values
|
||||
(t/is (every? #(re-matches ruby-size-re %) ["half" "third" "quarter"]))
|
||||
(t/is (not (re-matches ruby-size-re "full")))
|
||||
(t/is (every? #(re-matches ruby-align-re %)
|
||||
["space-around" "center" "start" "space-between"]))
|
||||
(t/is (not (re-matches ruby-align-re "end")))
|
||||
(t/is (every? #(re-matches ruby-overhang-re %) ["auto" "none"]))
|
||||
(t/is (not (re-matches ruby-overhang-re "always")))
|
||||
(t/is (every? #(re-matches ruby-side-re %) ["over" "under"]))
|
||||
(t/is (not (re-matches ruby-side-re "right"))))
|
||||
|
||||
(t/deftest text-range-japanese-properties-read-span-values
|
||||
(let [file-id (random-uuid)
|
||||
page-id (random-uuid)
|
||||
shape-id (random-uuid)
|
||||
content {:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "漢字"
|
||||
:text-combine-upright "digits2"
|
||||
:text-emphasis "filled-dot"
|
||||
:warichu "warichu"
|
||||
:font-features "vpal"
|
||||
:annotation-clearance "auto"
|
||||
:ruby "かんじ"
|
||||
:ruby-size "third"
|
||||
:ruby-align "center"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"}]}]}]}
|
||||
range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)]
|
||||
(with-redefs [u/proxy->shape (constantly {:content content})]
|
||||
(t/is (= "digits2" (.-textCombineUpright range)))
|
||||
(t/is (= "filled-dot" (.-textEmphasis range)))
|
||||
(t/is (= "warichu" (.-warichu range)))
|
||||
(t/is (= "vpal" (.-fontFeatures range)))
|
||||
(t/is (= "auto" (.-annotationClearance range)))
|
||||
(t/is (= "かんじ" (.-ruby range)))
|
||||
(t/is (= "third" (.-rubySize range)))
|
||||
(t/is (= "center" (.-rubyAlign range)))
|
||||
(t/is (= "none" (.-rubyOverhang range)))
|
||||
(t/is (= "under" (.-rubySide range))))))
|
||||
|
||||
(t/deftest text-range-japanese-properties-report-mixed-values
|
||||
(let [file-id (random-uuid)
|
||||
page-id (random-uuid)
|
||||
shape-id (random-uuid)
|
||||
content {:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "日"
|
||||
:text-emphasis "filled-dot"
|
||||
:ruby "にち"
|
||||
:ruby-size "third"}
|
||||
{:text "本"
|
||||
:text-emphasis "none"
|
||||
:ruby nil
|
||||
:ruby-size "half"}]}]}]}
|
||||
range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)]
|
||||
(with-redefs [u/proxy->shape (constantly {:content content})]
|
||||
(t/is (= "mixed" (.-textEmphasis range)))
|
||||
(t/is (= "mixed" (.-ruby range)))
|
||||
(t/is (= "mixed" (.-rubySize range))))))
|
||||
|
||||
(t/deftest text-range-japanese-properties-update-the-selected-range
|
||||
(let [file-id (random-uuid)
|
||||
page-id (random-uuid)
|
||||
shape-id (random-uuid)
|
||||
range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4)
|
||||
captured (atom [])]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/page-active? (constantly true)
|
||||
dwt/update-text-range
|
||||
(fn [id start end attrs]
|
||||
(swap! captured conj {:id id
|
||||
:start start
|
||||
:end end
|
||||
:attrs attrs})
|
||||
:update-text-range)
|
||||
st/emit! mock/noop]
|
||||
(set! (.-textCombineUpright range) "digits2")
|
||||
(set! (.-textEmphasis range) "filled-dot")
|
||||
(set! (.-warichu range) "warichu")
|
||||
(set! (.-fontFeatures range) "vpal")
|
||||
(set! (.-annotationClearance range) "auto")
|
||||
(set! (.-ruby range) "かんじ")
|
||||
(set! (.-rubySize range) "third")
|
||||
(set! (.-rubyAlign range) "center")
|
||||
(set! (.-rubyOverhang range) "none")
|
||||
(set! (.-rubySide range) "under")
|
||||
(t/is (= [{:id shape-id :start 1 :end 4 :attrs {:text-combine-upright "digits2"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:text-emphasis "filled-dot"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:warichu "warichu"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:font-features "vpal"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:annotation-clearance "auto"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:ruby "かんじ"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:ruby-size "third"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:ruby-align "center"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:ruby-overhang "none"}}
|
||||
{:id shape-id :start 1 :end 4 :attrs {:ruby-side "under"}}]
|
||||
@captured)))))
|
||||
|
||||
|
||||
(t/deftest font-apply-to-text-uses-font-id-not-shape-id
|
||||
(let [file-id (random-uuid)
|
||||
|
||||
337
frontend/test/frontend_tests/render_wasm/texts_test.cljs
Normal file
@ -0,0 +1,337 @@
|
||||
;; 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 frontend-tests.render-wasm.texts-test
|
||||
"Unit tests for CJK script classification and the Han-unification
|
||||
resolution policy used to pick Noto fallback fonts."
|
||||
(:require
|
||||
[app.common.fonts :as cfnt]
|
||||
[app.common.render-wasm.text-content :as tc]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.main.ui.workspace.shapes.text.v3-editor :as v3-editor]
|
||||
[app.render-wasm.api :as api]
|
||||
[app.render-wasm.api.texts :as texts]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph]
|
||||
(@#'tc/write-spans offset dview spans paragraph cfnt/font-id->uuid))
|
||||
|
||||
(defn- langs [text]
|
||||
(cfnt/collect-used-languages #{} text))
|
||||
|
||||
(defn- font-ids
|
||||
[fonts]
|
||||
(into #{} (map :font-id) fonts))
|
||||
|
||||
(defn- editor-content
|
||||
[& paragraphs]
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children (mapv (fn [spans]
|
||||
{:type "paragraph"
|
||||
:children spans})
|
||||
paragraphs)}]})
|
||||
|
||||
(defn- selection
|
||||
[anchor-para anchor-offset focus-para focus-offset]
|
||||
{:anchor-para anchor-para
|
||||
:anchor-offset anchor-offset
|
||||
:focus-para focus-para
|
||||
:focus-offset focus-offset})
|
||||
|
||||
(t/deftest japanese-styles-follow-the-selected-span
|
||||
(let [content (editor-content
|
||||
[{:text "日" :ruby "にち" :text-emphasis "filled-dot"}
|
||||
{:text "本"}])]
|
||||
(t/is (= {:text-combine-upright "none"
|
||||
:text-emphasis "filled-dot"
|
||||
:ruby "にち"
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"}
|
||||
(text-editor/selection-japanese-styles
|
||||
content (selection 0 0 0 1))))
|
||||
(t/is (= {:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby ""
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"}
|
||||
(text-editor/selection-japanese-styles
|
||||
content (selection 0 1 0 2))))))
|
||||
|
||||
(t/deftest japanese-styles-report-mixed-ranges-and-follow-the-caret
|
||||
(let [content (editor-content
|
||||
[{:text "平成"
|
||||
:text-combine-upright "digits2"
|
||||
:text-emphasis "filled-dot"
|
||||
:ruby "へいせい"
|
||||
:ruby-hidden true
|
||||
:ruby-size "third"
|
||||
:ruby-align "center"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"
|
||||
:annotation-clearance "auto"}
|
||||
{:text "年"
|
||||
:ruby-hidden false
|
||||
:warichu "warichu"
|
||||
:font-features "vpal"}])]
|
||||
(t/is (= {:text-combine-upright :multiple
|
||||
:text-emphasis :multiple
|
||||
:ruby :multiple
|
||||
:ruby-hidden :multiple
|
||||
:ruby-size :multiple
|
||||
:ruby-align :multiple
|
||||
:ruby-overhang :multiple
|
||||
:ruby-side :multiple
|
||||
:warichu :multiple
|
||||
:font-features :multiple
|
||||
:annotation-clearance :multiple}
|
||||
(text-editor/selection-japanese-styles
|
||||
content (selection 0 0 0 3))))
|
||||
;; A caret at the boundary belongs to the preceding span, as in WASM.
|
||||
(t/is (= "digits2"
|
||||
(:text-combine-upright
|
||||
(text-editor/selection-japanese-styles
|
||||
content (selection 0 2 0 2)))))
|
||||
(t/is (= "warichu"
|
||||
(:warichu
|
||||
(text-editor/selection-japanese-styles
|
||||
content (selection 0 3 0 3)))))))
|
||||
|
||||
(t/deftest japanese-selection-offsets-are-utf16-code-units
|
||||
(let [content (editor-content
|
||||
[{:text "😀"}
|
||||
{:text "日" :ruby "にち"}])]
|
||||
(t/is (= "にち"
|
||||
(:ruby
|
||||
(text-editor/selection-japanese-styles
|
||||
content (selection 0 2 0 3)))))))
|
||||
|
||||
(t/deftest composition-caret-goes-to-the-end-of-the-changed-part
|
||||
;; Typing appends, converting replaces everything, and picking a candidate
|
||||
;; for a middle clause changes only that clause.
|
||||
(t/is (= 1 (v3-editor/changed-span-end "" "に")))
|
||||
(t/is (= 2 (v3-editor/changed-span-end "に" "にほ")))
|
||||
(t/is (= 3 (v3-editor/changed-span-end "にほんご" "日本語")))
|
||||
(t/is (= 5 (v3-editor/changed-span-end "日本語を書く" "日本語を掻く")))
|
||||
(t/is (= 3 (v3-editor/changed-span-end "ああ" "あああ")))
|
||||
(t/is (= 0 (v3-editor/changed-span-end "にほ" "")))
|
||||
(t/is (= 2 (v3-editor/changed-span-end "😀" "😀😀"))))
|
||||
|
||||
(t/deftest whole-shape-paragraph-attrs-apply-to-every-paragraph
|
||||
(let [content (-> (editor-content [{:text "一"}] [{:text "二"}] [{:text "三"}])
|
||||
(assoc-in [:children 0 :children 2 :text-orientation] "upright"))
|
||||
result (text-editor/apply-paragraph-attrs-to-range
|
||||
content
|
||||
(selection 1 0 1 0)
|
||||
{:writing-mode "vertical-rl"
|
||||
:text-orientation nil
|
||||
:text-align "center"})
|
||||
paragraphs (-> result :children first :children)]
|
||||
(t/is (every? #(= "vertical-rl" (:writing-mode %)) paragraphs))
|
||||
(t/is (not-any? #(contains? % :text-orientation) paragraphs))
|
||||
(t/is (= [nil "center" nil] (mapv :text-align paragraphs)))))
|
||||
|
||||
(t/deftest write-spans-serializes-font-features-in-reserved-byte
|
||||
(let [sentinel (js-obj)
|
||||
previous (if (.hasOwnProperty wasm/serializers "font-features")
|
||||
(unchecked-get wasm/serializers "font-features")
|
||||
sentinel)]
|
||||
(try
|
||||
(aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2})
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "日本語"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:font-features "vpal"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
(t/is (= 2 (.getUint8 dview 8)))
|
||||
(t/is (= 0 (.getUint8 dview 9)))
|
||||
(t/is (= 0 (.getUint8 dview 14)))
|
||||
(t/is (= 0 (.getUint8 dview 15))))
|
||||
(finally
|
||||
(if (identical? sentinel previous)
|
||||
(js-delete wasm/serializers "font-features")
|
||||
(aset wasm/serializers "font-features" previous))))))
|
||||
|
||||
(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte
|
||||
(let [sentinel (js-obj)
|
||||
previous (if (.hasOwnProperty wasm/serializers "annotation-clearance")
|
||||
(unchecked-get wasm/serializers "annotation-clearance")
|
||||
sentinel)]
|
||||
(try
|
||||
(aset wasm/serializers "annotation-clearance" #js {"none" 0 "auto" 1})
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "漢字"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:annotation-clearance "auto"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
(t/is (= 1 (.getUint8 dview 9)))
|
||||
(t/is (= 0 (.getUint8 dview 14)))
|
||||
(t/is (= 0 (.getUint8 dview 15))))
|
||||
(finally
|
||||
(if (identical? sentinel previous)
|
||||
(js-delete wasm/serializers "annotation-clearance")
|
||||
(aset wasm/serializers "annotation-clearance" previous))))))
|
||||
|
||||
(t/deftest write-spans-serializes-ruby-customization-bytes
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "漢字"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:ruby-size "quarter"
|
||||
:ruby-align "space-between"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
(t/is (= [2 3 1 1 0 0]
|
||||
(mapv #(.getUint8 dview %) (range 10 16))))))
|
||||
|
||||
(t/deftest write-spans-omits-hidden-ruby-bytes
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [{:text "日"
|
||||
:ruby "にち"
|
||||
:ruby-hidden true
|
||||
:font-size "16"
|
||||
:font-weight "400"}]
|
||||
paragraph)
|
||||
(t/is (= 0 (.getInt32 dview 72 true)))
|
||||
(write-spans 0 dview [{:text "日"
|
||||
:ruby "にち"
|
||||
:ruby-hidden false
|
||||
:font-size "16"
|
||||
:font-weight "400"}]
|
||||
paragraph)
|
||||
(t/is (pos? (.getInt32 dview 72 true)))))
|
||||
|
||||
(t/deftest write-spans-serializes-counted-digits-tcy
|
||||
(let [sentinel (js-obj)
|
||||
previous (if (.hasOwnProperty wasm/serializers "text-combine-upright")
|
||||
(unchecked-get wasm/serializers "text-combine-upright")
|
||||
sentinel)]
|
||||
(try
|
||||
(aset wasm/serializers "text-combine-upright"
|
||||
#js {"none" 0 "all" 1 "digits" 2 "digits2" 3 "digits3" 4})
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "平成31年"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:text-combine-upright "digits2"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
;; Byte 5 of the span attr block carries text-combine-upright.
|
||||
(t/is (= 3 (.getUint8 dview 5))))
|
||||
(finally
|
||||
(if (identical? sentinel previous)
|
||||
(js-delete wasm/serializers "text-combine-upright")
|
||||
(aset wasm/serializers "text-combine-upright" previous))))))
|
||||
|
||||
(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery
|
||||
(let [content {:children
|
||||
[{:children
|
||||
[{:children
|
||||
[{:text "Penpot"
|
||||
:ruby "ぺんぽっと😀"}]}]}]}
|
||||
expected #{"gfont-noto-sans-jp" "gfont-noto-color-emoji"}]
|
||||
(with-redefs [texts/write-shape-text (fn [& _])]
|
||||
(t/is (every? (font-ids (api/fonts-from-text-content content true)) expected)))
|
||||
(t/is (every? (font-ids (api/fonts-from-text-content content false)) expected))))
|
||||
|
||||
(t/deftest classification-kana
|
||||
;; Hiragana/katakana are unambiguously Japanese.
|
||||
(t/is (= #{:japanese} (langs "ひらがなとカタカナ")))
|
||||
;; Half-width katakana too.
|
||||
(t/is (= #{:japanese} (langs "デザイン"))))
|
||||
|
||||
(t/deftest classification-han-is-ambiguous
|
||||
;; Kanji-only text (han-unification fixture) must NOT classify as a
|
||||
;; concrete language; it is ambiguous Han.
|
||||
(t/is (= #{:han} (langs "東京都渋谷区神南一丁目"))))
|
||||
|
||||
(t/deftest classification-cjk-punctuation
|
||||
;; CJK punctuation and full-width forms match the shared class
|
||||
;; (previously they matched no range at all).
|
||||
(t/is (= #{:cjk-punctuation} (langs "、。「」『』()")))
|
||||
(t/is (= #{:cjk-punctuation} (langs "!?:;123ABC"))))
|
||||
|
||||
(t/deftest classification-mixed-japanese
|
||||
(t/is (= #{:japanese :han :cjk-punctuation}
|
||||
(langs "「こんにちは」と彼は言った。"))))
|
||||
|
||||
(t/deftest classification-korean
|
||||
(t/is (= #{:korean} (langs "안녕하세요"))))
|
||||
|
||||
(t/deftest resolve-kana-wins-over-locale
|
||||
;; Kana in the same content implies Japanese regardless of locale.
|
||||
(t/is (= #{:japanese}
|
||||
(cfnt/resolve-ambiguous-cjk #{:japanese :han :cjk-punctuation} "zh")))
|
||||
(t/is (= #{:japanese}
|
||||
(cfnt/resolve-ambiguous-cjk #{:japanese :han} "en"))))
|
||||
|
||||
(t/deftest resolve-hangul-wins-over-locale
|
||||
(t/is (= #{:korean}
|
||||
(cfnt/resolve-ambiguous-cjk #{:korean :han} "ja"))))
|
||||
|
||||
(t/deftest resolve-han-only-uses-locale
|
||||
(t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja")))
|
||||
(t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja_paid")))
|
||||
(t/is (= #{:korean} (cfnt/resolve-ambiguous-cjk #{:han} "ko")))
|
||||
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "zh_cn"))))
|
||||
|
||||
(t/deftest resolve-han-only-defaults-to-chinese
|
||||
;; Without kana/hangul and without a CJK locale, keep the previous
|
||||
;; behavior (Noto Sans SC).
|
||||
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "en")))
|
||||
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil))))
|
||||
|
||||
(t/deftest resolve-punctuation-only
|
||||
(t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} "ja")))
|
||||
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} "en"))))
|
||||
|
||||
(t/deftest resolve-leaves-unambiguous-sets-alone
|
||||
(t/is (= #{:latin-ext} (cfnt/resolve-ambiguous-cjk #{:latin-ext} "ja")))
|
||||
(t/is (= #{} (cfnt/resolve-ambiguous-cjk #{} "ja")))
|
||||
;; Other detected languages survive resolution.
|
||||
(t/is (= #{:japanese :cyrillic}
|
||||
(cfnt/resolve-ambiguous-cjk #{:han :cyrillic} "ja"))))
|
||||
|
||||
(t/deftest resolve-han-unification
|
||||
;; A kanji-only Japanese address resolves to Japanese under a ja locale.
|
||||
(t/is (= #{:japanese}
|
||||
(cfnt/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja"))))
|
||||
@ -86,6 +86,7 @@
|
||||
[frontend-tests.render-wasm.text-editor-apply-styles-test]
|
||||
[frontend-tests.render-wasm.text-editor-caret-color-test]
|
||||
[frontend-tests.render-wasm.text-paste-test]
|
||||
[frontend-tests.render-wasm.texts-test]
|
||||
[frontend-tests.router-test]
|
||||
[frontend-tests.svg-fills-test]
|
||||
[frontend-tests.svg-filters-test]
|
||||
@ -106,6 +107,7 @@
|
||||
[frontend-tests.ui.comment-input-ime-test]
|
||||
[frontend-tests.ui.comments-clustering-test]
|
||||
[frontend-tests.ui.comments-position-modifier-test]
|
||||
[frontend-tests.ui.css-cursors-test]
|
||||
[frontend-tests.ui.ds-controls-numeric-input-test]
|
||||
[frontend-tests.ui.gradient-handlers-test]
|
||||
[frontend-tests.ui.history-test]
|
||||
@ -119,6 +121,7 @@
|
||||
[frontend-tests.ui.sidebar-scroll-test]
|
||||
[frontend-tests.ui.stroke-menu-test]
|
||||
[frontend-tests.ui.text-attrs-multiple-test]
|
||||
[frontend-tests.ui.text-options-test]
|
||||
[frontend-tests.util-clipboard-test]
|
||||
[frontend-tests.util-object-test]
|
||||
[frontend-tests.util-queue-test]
|
||||
@ -225,6 +228,7 @@
|
||||
'frontend-tests.render-wasm.text-editor-apply-styles-test
|
||||
'frontend-tests.render-wasm.text-editor-caret-color-test
|
||||
'frontend-tests.render-wasm.text-paste-test
|
||||
'frontend-tests.render-wasm.texts-test
|
||||
'frontend-tests.router-test
|
||||
'frontend-tests.svg-fills-test
|
||||
'frontend-tests.svg-filters-test
|
||||
@ -244,6 +248,7 @@
|
||||
'frontend-tests.ui.comment-input-ime-test
|
||||
'frontend-tests.ui.comments-clustering-test
|
||||
'frontend-tests.ui.comments-position-modifier-test
|
||||
'frontend-tests.ui.css-cursors-test
|
||||
'frontend-tests.ui.ds-controls-numeric-input-test
|
||||
'frontend-tests.ui.gradient-handlers-test
|
||||
'frontend-tests.ui.history-test
|
||||
@ -259,6 +264,7 @@
|
||||
'frontend-tests.ui.sidebar-scroll-test
|
||||
'frontend-tests.ui.stroke-menu-test
|
||||
'frontend-tests.ui.text-attrs-multiple-test
|
||||
'frontend-tests.ui.text-options-test
|
||||
'frontend-tests.util-clipboard-test
|
||||
'frontend-tests.util-object-test
|
||||
'frontend-tests.util-queue-test
|
||||
|
||||
9
frontend/test/frontend_tests/ui/css_cursors_test.cljs
Normal file
@ -0,0 +1,9 @@
|
||||
(ns frontend-tests.ui.css-cursors-test
|
||||
(:require
|
||||
[app.main.ui.css-cursors :as cursors]
|
||||
[cljs.test :as t]))
|
||||
|
||||
(t/deftest text-cursor-follows-writing-direction-and-shape-rotation
|
||||
(t/is (= "cursor-text-0" (cursors/get-text 0 false)))
|
||||
(t/is (= "cursor-text-90" (cursors/get-text 0 true)))
|
||||
(t/is (= "cursor-text-10" (cursors/get-text 280 true))))
|
||||
223
frontend/test/frontend_tests/ui/text_options_test.cljs
Normal file
@ -0,0 +1,223 @@
|
||||
;; 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 frontend-tests.ui.text-options-test
|
||||
(:require
|
||||
["react-dom/server" :as rds]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.ui.ds.controls.select :as select]
|
||||
[app.main.ui.shapes.text.html-text :as html-text]
|
||||
[app.main.ui.shapes.text.styles :as text-styles]
|
||||
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private japanese-span-attrs
|
||||
[:text-combine-upright
|
||||
:text-emphasis
|
||||
:ruby
|
||||
:ruby-hidden
|
||||
:ruby-size
|
||||
:ruby-align
|
||||
:ruby-overhang
|
||||
:ruby-side
|
||||
:warichu
|
||||
:font-features
|
||||
:annotation-clearance])
|
||||
|
||||
(def ^:private radio-selected @#'tjl/radio-selected)
|
||||
(def ^:private span-input-value @#'tjl/span-input-value)
|
||||
(def ^:private span-select-value @#'tjl/span-select-value)
|
||||
(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option)
|
||||
(def ^:private ruby-common-props @#'tjl/ruby-common-props)
|
||||
|
||||
(t/deftest japanese-layout-controls-follow-file-or-profile-configuration
|
||||
(t/is (false? (tjl/japanese-layout-config-enabled? {} {})))
|
||||
(t/is (true? (tjl/japanese-layout-config-enabled?
|
||||
{:options {:japanese-layout true}}
|
||||
{})))
|
||||
(t/is (true? (tjl/japanese-layout-config-enabled?
|
||||
{}
|
||||
{:props {:japanese-layout-all-files true}})))
|
||||
(t/is (true? (tjl/japanese-layout-config-enabled?
|
||||
{:options {:japanese-layout true}}
|
||||
{:props {:japanese-layout-all-files false}}))))
|
||||
|
||||
(t/deftest text-emphasis-select-reports-and-restores-canonical-values
|
||||
(let [options (tjl/text-emphasis-options identity)
|
||||
reported-id (:id (select/get-option options "filled-dot"))]
|
||||
(t/is (= "filled-dot" reported-id))
|
||||
(t/is (= "filled-dot"
|
||||
(:id (select/get-option options reported-id))))))
|
||||
|
||||
(t/deftest annotation-clearance-select-reports-canonical-values
|
||||
(let [options (tjl/annotation-clearance-options identity)]
|
||||
(t/is (= ["none" "auto"] (mapv :id options)))
|
||||
(t/is (= "auto" (:id (select/get-option options "auto"))))))
|
||||
|
||||
(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection
|
||||
(t/is (= ["none" "digits"]
|
||||
(mapv :value (tjl/text-combine-upright-options false identity))))
|
||||
(t/is (= ["none" "all" "digits"]
|
||||
(mapv :value (tjl/text-combine-upright-options true identity)))))
|
||||
|
||||
(t/deftest ruby-container-styles-preserve-customization
|
||||
(let [style (text-styles/generate-ruby-container-styles
|
||||
{:ruby-align "space-between"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"})]
|
||||
(t/is (= "space-between" (.-rubyAlign style)))
|
||||
(t/is (= "none" (.-rubyOverhang style)))
|
||||
(t/is (= "under" (.-rubyPosition style)))))
|
||||
|
||||
(t/deftest advanced-furigana-options-are-collapsed-by-default
|
||||
(let [markup (rds/renderToStaticMarkup
|
||||
(mf/element tjl/ruby-presentation-options*
|
||||
#js {:values {}
|
||||
:on-change identity
|
||||
:on-blur identity}))]
|
||||
(t/is (str/includes? markup "data-testid=\"ruby-presentation-options-toggle\""))
|
||||
(t/is (str/includes? markup "aria-expanded=\"false\""))
|
||||
(t/is (str/includes? markup "workspace.options.text-options.ruby-advanced-options"))))
|
||||
|
||||
(t/deftest advanced-furigana-options-include-hide-toggle
|
||||
(let [markup (rds/renderToStaticMarkup
|
||||
(mf/element tjl/ruby-hidden-option*
|
||||
#js {:values {:ruby-hidden true}
|
||||
:on-change identity
|
||||
:on-blur identity}))]
|
||||
(t/is (str/includes? markup "workspace.options.text-options.ruby-hidden"))
|
||||
(t/is (str/includes? markup "role=\"switch\""))
|
||||
(t/is (str/includes? markup "aria-checked=\"true\""))))
|
||||
|
||||
(t/deftest hidden-furigana-renders-base-text-without-ruby-layout
|
||||
(let [markup (rds/renderToStaticMarkup
|
||||
(mf/element html-text/render-text*
|
||||
#js {:node {:text "日"
|
||||
:ruby "にち"
|
||||
:ruby-hidden true}
|
||||
:parent {}
|
||||
:shape {}}))]
|
||||
(t/is (str/includes? markup ">日</span>"))
|
||||
(t/is (not (str/includes? markup "<ruby")))))
|
||||
|
||||
(t/deftest advanced-furigana-options-preserve-change-callbacks
|
||||
(let [on-change identity
|
||||
on-blur identity
|
||||
props (ruby-common-props {} on-change on-blur)]
|
||||
(t/is (identical? on-change (unchecked-get props "onChange")))
|
||||
(t/is (identical? on-blur (unchecked-get props "onBlur")))))
|
||||
|
||||
(t/deftest shape-level-furigana-presentation-only-updates-ruby-spans
|
||||
(let [shape {:content
|
||||
{:type "root"
|
||||
:children
|
||||
[{:type "paragraph-set"
|
||||
:children
|
||||
[{:type "paragraph"
|
||||
:children
|
||||
[{:text "日"
|
||||
:ruby "にち"
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"}
|
||||
{:text "本"
|
||||
:ruby ""
|
||||
:ruby-hidden false
|
||||
:ruby-size "quarter"}]}]}]}}
|
||||
values (dwt/current-ruby-values {:shape shape
|
||||
:attrs dwt/ruby-presentation-attrs})
|
||||
updated (dwt/update-ruby-presentation-attrs
|
||||
shape {:ruby-hidden true :ruby-size "third"})
|
||||
spans (get-in updated [:content :children 0 :children 0 :children])]
|
||||
(t/is (= "half" (:ruby-size values)))
|
||||
(t/is (false? (:ruby-hidden values)))
|
||||
(t/is (= "third" (:ruby-size (first spans))))
|
||||
(t/is (true? (:ruby-hidden (first spans))))
|
||||
(t/is (= "quarter" (:ruby-size (second spans))))
|
||||
(t/is (false? (:ruby-hidden (second spans))))))
|
||||
|
||||
(t/deftest mixed-japanese-span-values-have-distinct-control-states
|
||||
(let [options (with-mixed-span-option
|
||||
(tjl/text-emphasis-options identity)
|
||||
:multiple)]
|
||||
(t/is (= "mixed" (span-select-value :multiple "none")))
|
||||
(t/is (= "mixed" (span-input-value :multiple)))
|
||||
(t/is (= "mixed" (:id (last options))))
|
||||
(t/is (true? (:disabled (last options))))
|
||||
(t/is (= "" (radio-selected :multiple "none")))))
|
||||
|
||||
(t/deftest whole-text-selection-reports-every-differing-span-value-as-mixed
|
||||
(let [shape {:content
|
||||
{:type "root"
|
||||
:children
|
||||
[{:type "paragraph-set"
|
||||
:children
|
||||
[{:type "paragraph"
|
||||
:children
|
||||
[{:text "日"
|
||||
:text-combine-upright "digits2"
|
||||
:text-emphasis "filled-dot"
|
||||
:ruby "にち"
|
||||
:ruby-hidden true
|
||||
:ruby-size "third"
|
||||
:ruby-align "center"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"
|
||||
:warichu "warichu"
|
||||
:font-features "palt"
|
||||
:annotation-clearance "auto"}
|
||||
{:text "本"
|
||||
:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby ""
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"}]}]}]}}
|
||||
values (dwt/current-text-values {:shape shape
|
||||
:attrs japanese-span-attrs})]
|
||||
(t/is (= (zipmap japanese-span-attrs (repeat :multiple)) values))))
|
||||
|
||||
(t/deftest wasm-editor-span-styles-preserve-persisted-vertical-paragraph-values
|
||||
(let [shape {:content
|
||||
{:type "root"
|
||||
:children
|
||||
[{:type "paragraph-set"
|
||||
:children
|
||||
[{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:children [{:text "12" :text-combine-upright "none"}]}]}]}}
|
||||
editor-styles {:text-combine-upright "digits2"}
|
||||
paragraph-values (dwt/current-paragraph-values
|
||||
{:editor-styles editor-styles
|
||||
:shape shape
|
||||
:attrs [:writing-mode :text-orientation]})
|
||||
text-values (dwt/current-text-values
|
||||
{:editor-styles editor-styles
|
||||
:shape shape
|
||||
:attrs [:text-combine-upright]})]
|
||||
(t/is (= "vertical-rl" (:writing-mode paragraph-values)))
|
||||
(t/is (= "upright" (:text-orientation paragraph-values)))
|
||||
(t/is (= "digits2" (:text-combine-upright text-values)))))
|
||||
|
||||
(t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes
|
||||
(t/is (false? (tjl/vertical-japanese-layout? {})))
|
||||
(t/is (false? (tjl/vertical-japanese-layout?
|
||||
{:writing-mode "horizontal-tb"})))
|
||||
(t/is (true? (tjl/vertical-japanese-layout?
|
||||
{:writing-mode "vertical-rl"})))
|
||||
(t/is (= "palt"
|
||||
(tjl/proportional-metrics-feature "horizontal-tb")))
|
||||
(t/is (= "palt"
|
||||
(tjl/proportional-metrics-feature nil)))
|
||||
(t/is (= "vpal"
|
||||
(tjl/proportional-metrics-feature "vertical-rl"))))
|
||||
@ -811,6 +811,18 @@ msgstr "Invite people"
|
||||
msgid "dashboard.invite-profile-disabled"
|
||||
msgstr "You don't have permission to invite people to this team"
|
||||
|
||||
#: src/app/main/ui/settings/options.cljs
|
||||
msgid "dashboard.japanese-layout.description"
|
||||
msgstr "Makes Japanese text layout controls available in every file."
|
||||
|
||||
#: src/app/main/ui/settings/options.cljs
|
||||
msgid "dashboard.japanese-layout.enable-all-files"
|
||||
msgstr "Enable Japanese text layout for all files"
|
||||
|
||||
#: src/app/main/ui/settings/options.cljs
|
||||
msgid "dashboard.japanese-layout.title"
|
||||
msgstr "Japanese text layout"
|
||||
|
||||
#: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266
|
||||
msgid "dashboard.leave-organization"
|
||||
msgstr "Leave org"
|
||||
@ -6417,6 +6429,10 @@ msgstr "Radial gradient"
|
||||
msgid "workspace.header.menu.disable-dynamic-alignment"
|
||||
msgstr "Disable dynamic alignment"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs
|
||||
msgid "workspace.header.menu.disable-japanese-layout"
|
||||
msgstr "Disable Japanese text layout"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs:254
|
||||
msgid "workspace.header.menu.disable-scale-content"
|
||||
msgstr "Disable proportional scale"
|
||||
@ -6441,6 +6457,10 @@ msgstr "Disable WebGL rendering (beta)"
|
||||
msgid "workspace.header.menu.enable-dynamic-alignment"
|
||||
msgstr "Enable dynamic alignment"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs
|
||||
msgid "workspace.header.menu.enable-japanese-layout"
|
||||
msgstr "Enable Japanese text layout"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs:255
|
||||
msgid "workspace.header.menu.enable-scale-content"
|
||||
msgstr "Enable proportional scale"
|
||||
@ -7969,6 +7989,18 @@ msgstr "Align middle"
|
||||
msgid "workspace.options.text-options.align-top"
|
||||
msgstr "Align top"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.annotation-clearance"
|
||||
msgstr "Ann. clearance"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.annotation-clearance-auto"
|
||||
msgstr "Automatic"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.annotation-clearance-none"
|
||||
msgstr "Use line height"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:100
|
||||
msgid "workspace.options.text-options.direction-ltr"
|
||||
msgstr "LTR"
|
||||
@ -7977,6 +8009,22 @@ msgstr "LTR"
|
||||
msgid "workspace.options.text-options.direction-rtl"
|
||||
msgstr "RTL"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features"
|
||||
msgstr "Prop. metrics"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features-none"
|
||||
msgstr "None"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features-palt"
|
||||
msgstr "Horizontal (palt)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features-vpal"
|
||||
msgstr "Vertical (vpal)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:167
|
||||
msgid "workspace.options.text-options.grow-auto-height"
|
||||
msgstr "Auto height"
|
||||
@ -7989,6 +8037,10 @@ msgstr "Auto width"
|
||||
msgid "workspace.options.text-options.grow-fixed"
|
||||
msgstr "Fixed"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.japanese-layout"
|
||||
msgstr "Japanese text layout"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:688
|
||||
msgid "workspace.options.text-options.letter-spacing"
|
||||
msgstr "Letter Spacing"
|
||||
@ -7997,6 +8049,82 @@ msgstr "Letter Spacing"
|
||||
msgid "workspace.options.text-options.line-height"
|
||||
msgstr "Line height"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby"
|
||||
msgstr "Ruby"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-advanced-options"
|
||||
msgstr "Advanced furigana options"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align"
|
||||
msgstr "Ruby alignment"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-center"
|
||||
msgstr "Center"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-space-around"
|
||||
msgstr "Space around"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-space-between"
|
||||
msgstr "Space between"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-start"
|
||||
msgstr "Start"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
|
||||
msgid "workspace.options.text-options.ruby-hidden"
|
||||
msgstr "Hide furigana"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-overhang"
|
||||
msgstr "Ruby overhang"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-overhang-auto"
|
||||
msgstr "Auto"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-overhang-none"
|
||||
msgstr "None"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-placeholder"
|
||||
msgstr "Furigana"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-side"
|
||||
msgstr "Ruby side"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-side-over"
|
||||
msgstr "Over / right"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-side-under"
|
||||
msgstr "Under / left"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size"
|
||||
msgstr "Ruby size"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size-half"
|
||||
msgstr "Half"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size-quarter"
|
||||
msgstr "Quarter"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size-third"
|
||||
msgstr "Third"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:212
|
||||
msgid "workspace.options.text-options.strikethrough"
|
||||
msgstr "Strikethrough (%s)"
|
||||
@ -8017,6 +8145,74 @@ msgstr "Align left"
|
||||
msgid "workspace.options.text-options.text-align-right"
|
||||
msgstr "Align right"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-all"
|
||||
msgstr "Tate-chu-yoko"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits"
|
||||
msgstr "Digits"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-2"
|
||||
msgstr "Up to 2 digits"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-3"
|
||||
msgstr "Up to 3 digits"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-4"
|
||||
msgstr "Up to 4 digits"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-count"
|
||||
msgstr "Digit count"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-none"
|
||||
msgstr "Normal"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis"
|
||||
msgstr "Emphasis marks"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-filled-circle"
|
||||
msgstr "Filled circle"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-filled-dot"
|
||||
msgstr "Filled dot"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-filled-sesame"
|
||||
msgstr "Filled sesame"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-none"
|
||||
msgstr "None"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-open-circle"
|
||||
msgstr "Open circle"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-open-dot"
|
||||
msgstr "Open dot"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-open-sesame"
|
||||
msgstr "Open sesame"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-orientation-mixed"
|
||||
msgstr "Mixed"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-orientation-upright"
|
||||
msgstr "Upright"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:509
|
||||
msgid "workspace.options.text-options.text-section"
|
||||
msgstr "Text section"
|
||||
@ -8037,6 +8233,22 @@ msgstr "Selection text"
|
||||
msgid "workspace.options.text-options.underline"
|
||||
msgstr "Underline (%s)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.warichu"
|
||||
msgstr "Warichu"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.warichu-none"
|
||||
msgstr "Normal"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.writing-mode-horizontal"
|
||||
msgstr "Horizontal"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.writing-mode-vertical"
|
||||
msgstr "Vertical (right to left)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788
|
||||
msgid "workspace.options.use-play-button"
|
||||
msgstr "Use the play button at the header to run the prototype view."
|
||||
|
||||
@ -777,6 +777,18 @@ msgstr "メンバーを招待"
|
||||
msgid "dashboard.invite-profile-disabled"
|
||||
msgstr "このチームにユーザーを招待する権限がありません"
|
||||
|
||||
#: src/app/main/ui/settings/options.cljs
|
||||
msgid "dashboard.japanese-layout.description"
|
||||
msgstr "すべてのファイルで日本語組版コントロールを利用できるようにします。"
|
||||
|
||||
#: src/app/main/ui/settings/options.cljs
|
||||
msgid "dashboard.japanese-layout.enable-all-files"
|
||||
msgstr "すべてのファイルで日本語組版を有効にする"
|
||||
|
||||
#: src/app/main/ui/settings/options.cljs
|
||||
msgid "dashboard.japanese-layout.title"
|
||||
msgstr "日本語組版"
|
||||
|
||||
#: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266
|
||||
msgid "dashboard.leave-organization"
|
||||
msgstr "組織から退出"
|
||||
@ -6168,6 +6180,10 @@ msgstr "放射状グラデーション"
|
||||
msgid "workspace.header.menu.disable-dynamic-alignment"
|
||||
msgstr "動的整列を無効化"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs
|
||||
msgid "workspace.header.menu.disable-japanese-layout"
|
||||
msgstr "日本語組版を無効にする"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs:254
|
||||
msgid "workspace.header.menu.disable-scale-content"
|
||||
msgstr "比率を保った拡大縮小を無効化"
|
||||
@ -6192,6 +6208,10 @@ msgstr "WebGL レンダリングを無効化(ベータ)"
|
||||
msgid "workspace.header.menu.enable-dynamic-alignment"
|
||||
msgstr "動的整列を有効化"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs
|
||||
msgid "workspace.header.menu.enable-japanese-layout"
|
||||
msgstr "日本語組版を有効にする"
|
||||
|
||||
#: src/app/main/ui/workspace/main_menu.cljs:255
|
||||
msgid "workspace.header.menu.enable-scale-content"
|
||||
msgstr "比率を保った拡大縮小を有効化"
|
||||
@ -7688,6 +7708,18 @@ msgstr "上下中央揃え"
|
||||
msgid "workspace.options.text-options.align-top"
|
||||
msgstr "上揃え"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.annotation-clearance"
|
||||
msgstr "注釈の間隔"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.annotation-clearance-auto"
|
||||
msgstr "自動"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.annotation-clearance-none"
|
||||
msgstr "行送りを使用"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:100
|
||||
msgid "workspace.options.text-options.direction-ltr"
|
||||
msgstr "LTR"
|
||||
@ -7696,6 +7728,22 @@ msgstr "LTR"
|
||||
msgid "workspace.options.text-options.direction-rtl"
|
||||
msgstr "RTL"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features"
|
||||
msgstr "プロポーショナルメトリクス"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features-none"
|
||||
msgstr "なし"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features-palt"
|
||||
msgstr "横組み(palt)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.font-features-vpal"
|
||||
msgstr "縦組み(vpal)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:167
|
||||
msgid "workspace.options.text-options.grow-auto-height"
|
||||
msgstr "高さの自動調整"
|
||||
@ -7708,6 +7756,10 @@ msgstr "幅の自動調整"
|
||||
msgid "workspace.options.text-options.grow-fixed"
|
||||
msgstr "固定"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.japanese-layout"
|
||||
msgstr "日本語組版"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:688
|
||||
msgid "workspace.options.text-options.letter-spacing"
|
||||
msgstr "文字間隔"
|
||||
@ -7716,6 +7768,82 @@ msgstr "文字間隔"
|
||||
msgid "workspace.options.text-options.line-height"
|
||||
msgstr "行の高さ"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby"
|
||||
msgstr "ルビ"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-advanced-options"
|
||||
msgstr "ふりがなの詳細設定"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align"
|
||||
msgstr "ルビの配置"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-center"
|
||||
msgstr "中央"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-space-around"
|
||||
msgstr "均等割り"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-space-between"
|
||||
msgstr "両端揃え"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-align-start"
|
||||
msgstr "先頭"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
|
||||
msgid "workspace.options.text-options.ruby-hidden"
|
||||
msgstr "ふりがなを非表示"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-overhang"
|
||||
msgstr "ルビのはみ出し"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-overhang-auto"
|
||||
msgstr "自動"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-overhang-none"
|
||||
msgstr "なし"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-placeholder"
|
||||
msgstr "ふりがな"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-side"
|
||||
msgstr "ルビの側"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-side-over"
|
||||
msgstr "上/右"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-side-under"
|
||||
msgstr "下/左"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size"
|
||||
msgstr "ルビの大きさ"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size-half"
|
||||
msgstr "二分"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size-quarter"
|
||||
msgstr "四分"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.ruby-size-third"
|
||||
msgstr "三分"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:212
|
||||
msgid "workspace.options.text-options.strikethrough"
|
||||
msgstr "取り消し線(%s)"
|
||||
@ -7736,6 +7864,74 @@ msgstr "左揃え"
|
||||
msgid "workspace.options.text-options.text-align-right"
|
||||
msgstr "右揃え"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-all"
|
||||
msgstr "縦中横"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits"
|
||||
msgstr "数字のみ"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-2"
|
||||
msgstr "2桁まで"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-3"
|
||||
msgstr "3桁まで"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-4"
|
||||
msgstr "4桁まで"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-digits-count"
|
||||
msgstr "桁数"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-combine-upright-none"
|
||||
msgstr "通常"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis"
|
||||
msgstr "圏点"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-filled-circle"
|
||||
msgstr "黒丸(大)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-filled-dot"
|
||||
msgstr "黒丸"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-filled-sesame"
|
||||
msgstr "ゴマ点(黒)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-none"
|
||||
msgstr "なし"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-open-circle"
|
||||
msgstr "白丸(大)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-open-dot"
|
||||
msgstr "白丸"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-emphasis-open-sesame"
|
||||
msgstr "ゴマ点(白)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-orientation-mixed"
|
||||
msgstr "混在"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.text-orientation-upright"
|
||||
msgstr "正立"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:509
|
||||
msgid "workspace.options.text-options.text-section"
|
||||
msgstr "テキストセクション"
|
||||
@ -7756,6 +7952,22 @@ msgstr "選択範囲のテキスト"
|
||||
msgid "workspace.options.text-options.underline"
|
||||
msgstr "下線(%s)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.warichu"
|
||||
msgstr "割注"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.warichu-none"
|
||||
msgstr "通常"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.writing-mode-horizontal"
|
||||
msgstr "横書き"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
|
||||
msgid "workspace.options.text-options.writing-mode-vertical"
|
||||
msgstr "縦書き(右から左)"
|
||||
|
||||
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788
|
||||
msgid "workspace.options.use-play-button"
|
||||
msgstr "ヘッダーの再生ボタンを使ってプロトタイプを表示してください。"
|
||||
|
||||
@ -110,6 +110,44 @@ test("create context with file and page", () => {
|
||||
assert.equal(rootShape.id, "00000000-0000-0000-0000-000000000000");
|
||||
});
|
||||
|
||||
test("create vertical text", () => {
|
||||
const context = penpot.createBuildContext();
|
||||
|
||||
const fileId = context.addFile({name: "file 1"});
|
||||
const pageId = context.addPage({name: "page 1"});
|
||||
const textId = context.addText({
|
||||
name: "Vertical text",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 200,
|
||||
content: {
|
||||
type: "root",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph-set",
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
children: [{text: "縦書き"}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const internalState = context.getInternalState();
|
||||
const paragraph =
|
||||
internalState.files[fileId].data.pagesIndex[pageId].objects[textId].content
|
||||
.children[0].children[0];
|
||||
|
||||
assert.equal(paragraph.writingMode, "vertical-rl");
|
||||
assert.equal(paragraph.textOrientation, "upright");
|
||||
});
|
||||
|
||||
test("create context with color", () => {
|
||||
const context = penpot.createBuildContext();
|
||||
|
||||
|
||||
@ -5,6 +5,14 @@
|
||||
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
|
||||
- **plugin-types**: Added `writingMode` and `textOrientation` properties for text shapes (vertical writing)
|
||||
- **plugin-types:** Added `textCombineUpright` property for text shapes (Tate-chu-yoko)
|
||||
- **plugin-types:** Added `ruby` property for text shapes (furigana annotation)
|
||||
- **plugin-types:** Added `textEmphasis` property for text shapes (emphasis marks / bouten)
|
||||
- **plugin-types:** Added `warichu` property for text shapes (inline two-line notes)
|
||||
- **plugin-types:** `textCombineUpright` now accepts `digits` (combine 2-4 digit runs)
|
||||
- **plugin-types:** Added `fontFeatures` (`none`, `palt`, or `vpal`) to text shapes and text ranges.
|
||||
- **plugin-types:** Added `annotationClearance` (`none` or `auto`) to text shapes.
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
|
||||
@ -81,6 +81,38 @@ describe('Text', () => {
|
||||
expect(t.direction).toBe('rtl');
|
||||
});
|
||||
|
||||
test('Japanese paragraph layout properties round-trip', (ctx) => {
|
||||
const t = text(ctx, '日本語');
|
||||
t.writingMode = 'vertical-rl';
|
||||
t.textOrientation = 'upright';
|
||||
expect(t.writingMode).toBe('vertical-rl');
|
||||
expect(t.textOrientation).toBe('upright');
|
||||
});
|
||||
|
||||
test('Japanese span layout properties round-trip', (ctx) => {
|
||||
const t = text(ctx, '日本語');
|
||||
t.textCombineUpright = 'digits2';
|
||||
t.textEmphasis = 'filled-dot';
|
||||
t.warichu = 'warichu';
|
||||
t.fontFeatures = 'vpal';
|
||||
t.annotationClearance = 'auto';
|
||||
t.ruby = 'にほんご';
|
||||
t.rubySize = 'third';
|
||||
t.rubyAlign = 'center';
|
||||
t.rubyOverhang = 'none';
|
||||
t.rubySide = 'under';
|
||||
expect(t.textCombineUpright).toBe('digits2');
|
||||
expect(t.textEmphasis).toBe('filled-dot');
|
||||
expect(t.warichu).toBe('warichu');
|
||||
expect(t.fontFeatures).toBe('vpal');
|
||||
expect(t.annotationClearance).toBe('auto');
|
||||
expect(t.ruby).toBe('にほんご');
|
||||
expect(t.rubySize).toBe('third');
|
||||
expect(t.rubyAlign).toBe('center');
|
||||
expect(t.rubyOverhang).toBe('none');
|
||||
expect(t.rubySide).toBe('under');
|
||||
});
|
||||
|
||||
test('font identity and variant setters accept a real font/variant', (ctx) => {
|
||||
const t = text(ctx);
|
||||
const font = ctx.penpot.fonts.all[0];
|
||||
@ -172,6 +204,31 @@ describe('Text', () => {
|
||||
expect(range.textDecoration).toBe('line-through');
|
||||
});
|
||||
|
||||
test('range Japanese layout properties round-trip', (ctx) => {
|
||||
const t = text(ctx, '日本語');
|
||||
const range = t.getRange(0, 3);
|
||||
range.textCombineUpright = 'all';
|
||||
range.textEmphasis = 'open-circle';
|
||||
range.warichu = 'warichu';
|
||||
range.fontFeatures = 'palt';
|
||||
range.annotationClearance = 'auto';
|
||||
range.ruby = 'にほんご';
|
||||
range.rubySize = 'quarter';
|
||||
range.rubyAlign = 'space-between';
|
||||
range.rubyOverhang = 'none';
|
||||
range.rubySide = 'under';
|
||||
expect(range.textCombineUpright).toBe('all');
|
||||
expect(range.textEmphasis).toBe('open-circle');
|
||||
expect(range.warichu).toBe('warichu');
|
||||
expect(range.fontFeatures).toBe('palt');
|
||||
expect(range.annotationClearance).toBe('auto');
|
||||
expect(range.ruby).toBe('にほんご');
|
||||
expect(range.rubySize).toBe('quarter');
|
||||
expect(range.rubyAlign).toBe('space-between');
|
||||
expect(range.rubyOverhang).toBe('none');
|
||||
expect(range.rubySide).toBe('under');
|
||||
});
|
||||
|
||||
test('range fills round-trip', (ctx) => {
|
||||
const t = text(ctx, 'Hello Penpot');
|
||||
const range = t.getRange(0, 5);
|
||||
|
||||
138
plugins/libs/plugin-types/index.d.ts
vendored
@ -4326,6 +4326,88 @@ export interface Text extends ShapeBase {
|
||||
*/
|
||||
verticalAlign: 'top' | 'center' | 'bottom' | null;
|
||||
|
||||
/**
|
||||
* The writing mode of the text shape. `horizontal-tb` lays text out in
|
||||
* horizontal lines; `vertical-rl` in vertical columns advancing right-to-left.
|
||||
* Returns 'mixed' if paragraphs use different modes.
|
||||
*/
|
||||
writingMode: 'horizontal-tb' | 'vertical-rl' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* The orientation of characters in vertical writing. `mixed` rotates
|
||||
* non-CJK runs sideways; `upright` keeps every character upright.
|
||||
* Returns 'mixed' if paragraphs use different orientations.
|
||||
*/
|
||||
textOrientation: 'mixed' | 'upright' | null;
|
||||
|
||||
/**
|
||||
* Combines the text shape upright in vertical writing. `all` draws the text
|
||||
* as one upright composite; `digits` combines runs of 2-4 consecutive
|
||||
* digits (`digits2`/`digits3` cap the run length at 2/3); `none` uses the
|
||||
* normal vertical layout.
|
||||
* Returns 'mixed' if text spans use different values.
|
||||
*/
|
||||
textCombineUpright:
|
||||
'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Emphasis marks (圏点 / bouten) drawn beside each base character, mirroring
|
||||
* CSS `text-emphasis-style`. `none` removes them.
|
||||
* Returns 'mixed' if text spans use different values.
|
||||
*/
|
||||
textEmphasis:
|
||||
| 'none'
|
||||
| 'filled-dot'
|
||||
| 'open-dot'
|
||||
| 'filled-circle'
|
||||
| 'open-circle'
|
||||
| 'filled-sesame'
|
||||
| 'open-sesame'
|
||||
| 'mixed'
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Warichu (割注): renders the span as two half-size lines stacked inline
|
||||
* within one column position of the vertical flow. `none` disables it.
|
||||
* Returns 'mixed' if text spans use different values.
|
||||
*/
|
||||
warichu: 'none' | 'warichu' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* OpenType proportional alternate metrics for Japanese text. `palt` applies
|
||||
* proportional alternate widths in horizontal writing; `vpal` applies
|
||||
* proportional alternate widths in vertical writing. `none` disables them.
|
||||
* Returns 'mixed' if text spans use different values.
|
||||
*/
|
||||
fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Controls annotation collision handling. `none` preserves the explicit
|
||||
* line gap; `auto` reserves separate half-em layers for ruby and emphasis.
|
||||
* Returns 'mixed' if text spans use different values.
|
||||
*/
|
||||
annotationClearance: 'none' | 'auto' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Ruby (furigana) annotation shown over the base text in vertical writing.
|
||||
* Set a string to annotate the selected span(s), or `null` to remove it.
|
||||
* Returns 'mixed' if text spans carry different ruby values.
|
||||
*/
|
||||
ruby: string | null;
|
||||
|
||||
/** Ruby annotation size relative to its base text. */
|
||||
rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null;
|
||||
|
||||
/** Distribution of ruby glyphs across the corresponding base text. */
|
||||
rubyAlign:
|
||||
'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null;
|
||||
|
||||
/** Whether ruby may extend beyond the corresponding base text. */
|
||||
rubyOverhang: 'auto' | 'none' | 'mixed' | null;
|
||||
|
||||
/** Annotation side: above/right (`over`) or below/left (`under`). */
|
||||
rubySide: 'over' | 'under' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Return the bounding box for the text as a (x, y, width, height) rectangle
|
||||
* This is the box that covers the text even if it overflows its selection rectangle.
|
||||
@ -4426,6 +4508,62 @@ export interface TextRange {
|
||||
*/
|
||||
textDecoration: 'underline' | 'line-through' | 'none' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Combines the range upright in vertical writing. Returns 'mixed' when the
|
||||
* range contains different span values.
|
||||
*/
|
||||
textCombineUpright:
|
||||
'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Emphasis marks (圏点 / bouten) applied to the range. Returns 'mixed' when
|
||||
* the range contains different span values.
|
||||
*/
|
||||
textEmphasis:
|
||||
| 'none'
|
||||
| 'filled-dot'
|
||||
| 'open-dot'
|
||||
| 'filled-circle'
|
||||
| 'open-circle'
|
||||
| 'filled-sesame'
|
||||
| 'open-sesame'
|
||||
| 'mixed'
|
||||
| null;
|
||||
|
||||
/** Warichu applied to the range, or 'mixed' for different span values. */
|
||||
warichu: 'none' | 'warichu' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* OpenType proportional alternate metrics for Japanese text. It can be a
|
||||
* specific feature or 'mixed' if multiple text spans are used.
|
||||
*/
|
||||
fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Annotation collision handling for the range, or 'mixed' for different
|
||||
* span values.
|
||||
*/
|
||||
annotationClearance: 'none' | 'auto' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Ruby (furigana) annotation for the text range. Set `null` to remove it.
|
||||
* Returns 'mixed' if the range contains different ruby values.
|
||||
*/
|
||||
ruby: string | 'mixed' | null;
|
||||
|
||||
/** Ruby annotation size relative to its base text. */
|
||||
rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null;
|
||||
|
||||
/** Distribution of ruby glyphs across the corresponding base text. */
|
||||
rubyAlign:
|
||||
'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null;
|
||||
|
||||
/** Whether ruby may extend beyond the corresponding base text. */
|
||||
rubyOverhang: 'auto' | 'none' | 'mixed' | null;
|
||||
|
||||
/** Annotation side: above/right (`over`) or below/left (`under`). */
|
||||
rubySide: 'over' | 'under' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* The text direction for the text range. It can be a specific direction or 'mixed' if multiple directions are used.
|
||||
*/
|
||||
|
||||
BIN
render-wasm/src/fonts/notosansjp-vmtx-test.ttf
Normal file
BIN
render-wasm/src/fonts/notosansjp-vpal-test.ttf
Normal file
@ -184,7 +184,7 @@ fn resources_init_headless() {
|
||||
}
|
||||
|
||||
/// Initializes DesignState.
|
||||
fn design_init() {
|
||||
pub(crate) fn design_init() {
|
||||
unsafe {
|
||||
let design_state = State::new();
|
||||
DESIGN_STATE = Box::into_raw(Box::new(design_state));
|
||||
|
||||
@ -1728,7 +1728,9 @@ impl RenderState {
|
||||
|
||||
// Plain fill (no strokes / parent shadows): reuse cached layout
|
||||
// paragraphs when valid. Skip builder rebuild + Skia layout.
|
||||
let can_use_layout_cache = !shape.has_visible_strokes()
|
||||
// The developer text grid is painted by the full text pass.
|
||||
let can_use_layout_cache = !self.options.is_text_grid_visible()
|
||||
&& !shape.has_visible_strokes()
|
||||
&& parent_shadows.is_none()
|
||||
&& (skip_effects
|
||||
|| (shape.blur.is_none()
|
||||
@ -1769,7 +1771,17 @@ impl RenderState {
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
if skip_effects {
|
||||
if text_content.is_vertical() {
|
||||
text::render_vertical_text(
|
||||
self,
|
||||
&shape,
|
||||
text_content,
|
||||
&mut paragraph_builders,
|
||||
fills_surface_id,
|
||||
strokes_surface_id,
|
||||
skip_effects,
|
||||
)?;
|
||||
} else if skip_effects {
|
||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||
text::render(
|
||||
Some(self),
|
||||
@ -2051,6 +2063,11 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !text_content.is_vertical() && self.options.is_text_grid_visible() {
|
||||
let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id);
|
||||
text::paint_horizontal_grid(canvas, &shape, text_content);
|
||||
}
|
||||
} // end layout-cache miss fallback
|
||||
}
|
||||
_ => {
|
||||
|
||||
@ -90,7 +90,11 @@ impl FontStore {
|
||||
is_emoji: bool,
|
||||
is_fallback: bool,
|
||||
) -> Result<()> {
|
||||
let alias = format!("{}", family);
|
||||
if self.has_family(&family, is_emoji) {
|
||||
if is_fallback {
|
||||
self.fallback_fonts.insert(alias);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -101,7 +105,6 @@ impl FontStore {
|
||||
"Failed to create typeface".to_string(),
|
||||
))?;
|
||||
|
||||
let alias = format!("{}", family);
|
||||
let font_name = if is_emoji {
|
||||
DEFAULT_EMOJI_FONT
|
||||
} else {
|
||||
@ -118,6 +121,16 @@ impl FontStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upgrade an already-uploaded family to participate in character
|
||||
/// fallback. Font bytes are cached independently from the role in which a
|
||||
/// face is used, so a family may first arrive as a document font and only
|
||||
/// later be requested as a fallback.
|
||||
pub fn mark_as_fallback(&mut self, family: &FontFamily) {
|
||||
if self.has_family(family, false) {
|
||||
self.fallback_fonts.insert(format!("{}", family));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_family(&self, family: &FontFamily, is_emoji: bool) -> bool {
|
||||
let alias = format!("{}", family);
|
||||
let font_name = if is_emoji {
|
||||
@ -304,4 +317,19 @@ mod tests {
|
||||
|
||||
assert!(css.is_empty());
|
||||
}
|
||||
|
||||
const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
|
||||
|
||||
#[test]
|
||||
fn uploaded_family_can_be_upgraded_to_fallback() {
|
||||
let mut store = FontStore::try_new().unwrap();
|
||||
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
|
||||
let alias = format!("{}", family);
|
||||
|
||||
store.add(family, TEST_FONT, false, false).unwrap();
|
||||
assert!(!store.get_fallback().contains(&alias));
|
||||
|
||||
store.mark_as_fallback(&family);
|
||||
assert!(store.get_fallback().contains(&alias));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ const DEBUG_VISIBLE: u32 = 0x01;
|
||||
const PROFILE_REBUILD_TILES: u32 = 0x02;
|
||||
const TEXT_EDITOR_V3: u32 = 0x04;
|
||||
const SHOW_WASM_INFO: u32 = 0x08;
|
||||
const TEXT_GRID_VISIBLE: u32 = 0x10;
|
||||
|
||||
// Render performance options
|
||||
// This is the extra area used for tile rendering (tiles beyond viewport).
|
||||
@ -121,4 +122,10 @@ impl RenderOptions {
|
||||
pub fn show_wasm_info(&self) -> bool {
|
||||
self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO
|
||||
}
|
||||
|
||||
/// jlreq-style character-frame grid overlay for Japanese vertical
|
||||
/// text: a developer aid, never part of exported output.
|
||||
pub fn is_text_grid_visible(&self) -> bool {
|
||||
self.flags & TEXT_GRID_VISIBLE == TEXT_GRID_VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,19 +3,161 @@ use crate::{
|
||||
error::Result,
|
||||
math::Rect,
|
||||
shapes::{
|
||||
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset,
|
||||
Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind,
|
||||
TextContent, TextDecorationSegment,
|
||||
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
|
||||
vertical_align_offset, Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout,
|
||||
Stroke, StrokeKind, TextContent, TextDecorationSegment,
|
||||
},
|
||||
utils::{get_fallback_fonts, get_font_collection},
|
||||
};
|
||||
use skia_safe::{
|
||||
self as skia,
|
||||
canvas::SaveLayerRec,
|
||||
textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration},
|
||||
textlayout::{ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration},
|
||||
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 skip_shadows = skip_effects || state.should_skip_drop_shadows();
|
||||
let mut drop_shadows = if skip_shadows {
|
||||
Vec::new()
|
||||
} else {
|
||||
shape.drop_shadow_paints()
|
||||
};
|
||||
if !skip_shadows {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Paint a viewport-only grid over SkParagraph horizontal text. Blue outlines
|
||||
/// show line boxes, green boxes show the per-scalar tight rectangles returned
|
||||
/// by SkParagraph, and amber rules show baselines. This mirrors the vertical
|
||||
/// text grid while making horizontal annotation anchors inspectable.
|
||||
pub fn paint_horizontal_grid(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
|
||||
let mut builders = text_content.paragraph_builder_group_from_text(None);
|
||||
let layout = calculate_text_layout_data(shape, text_content, &mut builders, true);
|
||||
|
||||
let mut line_paint = Paint::default();
|
||||
line_paint.set_anti_alias(true);
|
||||
line_paint.set_style(skia::PaintStyle::Stroke);
|
||||
line_paint.set_stroke_width(1.0);
|
||||
line_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED));
|
||||
|
||||
let mut glyph_paint = Paint::default();
|
||||
glyph_paint.set_anti_alias(true);
|
||||
glyph_paint.set_style(skia::PaintStyle::Stroke);
|
||||
glyph_paint.set_stroke_width(1.0);
|
||||
glyph_paint.set_color(skia::Color::from_argb(0x99, 0x27, 0xAE, 0x60));
|
||||
|
||||
let mut baseline_paint = Paint::default();
|
||||
baseline_paint.set_anti_alias(true);
|
||||
baseline_paint.set_stroke_width(1.0);
|
||||
baseline_paint.set_color(skia::Color::from_argb(0xCC, 0xEB, 0x57, 0x57));
|
||||
|
||||
for paragraph in &layout.paragraphs {
|
||||
for line in paragraph.paragraph.get_line_metrics() {
|
||||
let baseline = line.baseline as f32;
|
||||
let top = paragraph.y + baseline - line.ascent as f32;
|
||||
let left = paragraph.x + line.left as f32;
|
||||
let width = line.width as f32;
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(left, top, width, line.height as f32),
|
||||
&line_paint,
|
||||
);
|
||||
canvas.draw_line(
|
||||
(left, paragraph.y + baseline),
|
||||
(left + width, paragraph.y + baseline),
|
||||
&baseline_paint,
|
||||
);
|
||||
|
||||
for offset in line.start_index..line.end_index {
|
||||
for textbox in paragraph.paragraph.get_rects_for_range(
|
||||
offset..offset + 1,
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
) {
|
||||
let mut rect = textbox.rect;
|
||||
rect.offset((paragraph.x, paragraph.y));
|
||||
canvas.draw_rect(rect, &glyph_paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stroke_paragraph_builder_group_from_text(
|
||||
text_content: &TextContent,
|
||||
stroke: &Stroke,
|
||||
@ -32,7 +174,8 @@ pub fn stroke_paragraph_builder_group_from_text(
|
||||
let mut stroke_paragraphs_map: std::collections::HashMap<usize, ParagraphBuilder> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for span in paragraph.children().iter() {
|
||||
let (span_texts, _) = paragraph.layout_span_texts();
|
||||
for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
|
||||
let (stroke_paints, stroke_layer_opacity) =
|
||||
get_text_stroke_paints(stroke, bounds, remove_stroke_alpha);
|
||||
|
||||
@ -40,8 +183,6 @@ pub fn stroke_paragraph_builder_group_from_text(
|
||||
group_layer_opacity = stroke_layer_opacity;
|
||||
}
|
||||
|
||||
let text: String = span.apply_text_transform();
|
||||
|
||||
for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() {
|
||||
let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| {
|
||||
let paragraph_style = paragraph.paragraph_to_style();
|
||||
@ -56,7 +197,7 @@ pub fn stroke_paragraph_builder_group_from_text(
|
||||
paragraph.line_height(),
|
||||
);
|
||||
builder.push_style(&stroke_style);
|
||||
add_text_with_tabs(builder, &text, span.font_size);
|
||||
add_horizontal_span(builder, span, text, &stroke_style, fonts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,7 +211,7 @@ pub fn stroke_paragraph_builder_group_from_text(
|
||||
(paragraph_group, group_layer_opacity)
|
||||
}
|
||||
|
||||
fn get_text_stroke_paints(
|
||||
pub(crate) fn get_text_stroke_paints(
|
||||
stroke: &Stroke,
|
||||
bounds: &Rect,
|
||||
remove_stroke_alpha: bool,
|
||||
@ -332,6 +473,9 @@ pub fn try_paint_from_layout_cache(
|
||||
layout_cache_rotation_only: bool,
|
||||
) -> Result<bool> {
|
||||
let text_content = shape.get_text_content();
|
||||
if !text_content.can_paint_from_layout_cache() {
|
||||
return Ok(false);
|
||||
}
|
||||
let cache_usable = if layout_cache_rotation_only {
|
||||
text_content.layout_cache_versions_match()
|
||||
} else {
|
||||
@ -523,16 +667,62 @@ fn paint_text_with_emoji_overlay(
|
||||
overlay_emoji: bool,
|
||||
) {
|
||||
let text_content = shape.get_text_content();
|
||||
|
||||
// Vertical writing renders through the custom vertical pass.
|
||||
// Stored text bounds describe the measured content and can be taller than
|
||||
// a fixed shape. Rebind to the selrect so the shape height remains the
|
||||
// column-wrap budget used by the vertical painter.
|
||||
let vertical_text_content = text_content
|
||||
.is_vertical()
|
||||
.then(|| text_content.new_bounds(shape.selrect()));
|
||||
if vertical_text_content.as_ref().is_some_and(|content| {
|
||||
crate::shapes::text_vertical::paint_text_vertical(canvas, content, shape.vertical_align())
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut layout_info =
|
||||
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
|
||||
|
||||
// Ruby draws only when the fill pass has the standard one-layout-per-
|
||||
// paragraph shape (stroke/shadow silhouette passes never reach here).
|
||||
let ruby_per_paragraph = layout_info.paragraphs.len() == text_content.paragraphs().len();
|
||||
|
||||
for para in &mut layout_info.paragraphs {
|
||||
para.paragraph.paint(canvas, (para.x, para.y));
|
||||
|
||||
if let Some(source_paragraph) = text_content.paragraphs().get(para.source_paragraph) {
|
||||
crate::shapes::paint_horizontal_warichu(
|
||||
canvas,
|
||||
source_paragraph,
|
||||
¶.paragraph,
|
||||
para.x,
|
||||
para.y,
|
||||
);
|
||||
crate::shapes::paint_horizontal_emphasis(
|
||||
canvas,
|
||||
source_paragraph,
|
||||
¶.paragraph,
|
||||
para.x,
|
||||
para.y,
|
||||
);
|
||||
}
|
||||
|
||||
if overlay_emoji {
|
||||
paint_emoji_overlay(canvas, para);
|
||||
}
|
||||
|
||||
if ruby_per_paragraph {
|
||||
crate::shapes::text_vertical::paint_horizontal_ruby(
|
||||
canvas,
|
||||
text_content,
|
||||
para.source_paragraph,
|
||||
¶.paragraph,
|
||||
para.x,
|
||||
para.y,
|
||||
);
|
||||
}
|
||||
|
||||
for deco in ¶.decorations {
|
||||
draw_decoration_segment(canvas, deco);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use crate::render::options::RenderOptions;
|
||||
use crate::shapes::text_vertical;
|
||||
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
|
||||
use crate::state::{TextEditorState, TextSelection};
|
||||
use crate::view::Viewbox;
|
||||
@ -51,17 +52,33 @@ fn render_cursor(
|
||||
return;
|
||||
};
|
||||
|
||||
// In vertical writing the caret is a thin horizontal bar across the
|
||||
// column; in horizontal writing a thin vertical bar.
|
||||
let thin = editor_state.theme.cursor_width / zoom * dpr;
|
||||
let mut cursor_rect = Rect::new_empty();
|
||||
cursor_rect.set_xywh(
|
||||
rect.x(),
|
||||
rect.y(),
|
||||
if editor_state.is_overtype_mode {
|
||||
rect.width()
|
||||
} else {
|
||||
editor_state.theme.cursor_width / zoom * dpr
|
||||
},
|
||||
rect.height(),
|
||||
);
|
||||
if text_content.is_vertical() {
|
||||
cursor_rect.set_xywh(
|
||||
rect.x(),
|
||||
rect.y(),
|
||||
rect.width(),
|
||||
if editor_state.is_overtype_mode && rect.height() > 0.0 {
|
||||
rect.height()
|
||||
} else {
|
||||
thin
|
||||
},
|
||||
);
|
||||
} else {
|
||||
cursor_rect.set_xywh(
|
||||
rect.x(),
|
||||
rect.y(),
|
||||
if editor_state.is_overtype_mode {
|
||||
rect.width()
|
||||
} else {
|
||||
thin
|
||||
},
|
||||
rect.height(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_anti_alias(false);
|
||||
@ -124,6 +141,87 @@ fn paragraphs_vertical_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
|
||||
}
|
||||
|
||||
/// Caret rectangle relative to the shape's top-left corner.
|
||||
pub(crate) fn cursor_rect(
|
||||
editor_state: &TextEditorState,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Option<Rect> {
|
||||
calculate_cursor_rect(editor_state, text_content, shape)
|
||||
}
|
||||
|
||||
fn calculate_cursor_rect(
|
||||
editor_state: &TextEditorState,
|
||||
text_content: &TextContent,
|
||||
@ -135,6 +233,15 @@ fn calculate_cursor_rect(
|
||||
return None;
|
||||
}
|
||||
|
||||
if text_content.is_vertical() {
|
||||
return calculate_vertical_cursor_rect(
|
||||
text_content,
|
||||
shape,
|
||||
cursor.paragraph,
|
||||
cursor.offset,
|
||||
);
|
||||
}
|
||||
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
if cursor.paragraph >= layout_paragraphs.len() {
|
||||
@ -150,19 +257,30 @@ fn calculate_cursor_rect(
|
||||
// - At start of paragraph: use position 0
|
||||
// - At end of paragraph: use last position
|
||||
let para = ¶graphs[cursor.paragraph];
|
||||
if let Some(rect) =
|
||||
crate::shapes::horizontal_warichu_caret_rect(para, laid_out_para, char_pos)
|
||||
{
|
||||
return Some(Rect::from_xywh(
|
||||
rect.x(),
|
||||
y_offset + rect.y(),
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
));
|
||||
}
|
||||
let para_char_count: usize = para
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
// Skia ranges are UTF-16 code units, not characters.
|
||||
// Cursor offsets count source characters; the laid-out paragraph
|
||||
// indexes the transformed, kinsoku-shifted builder text.
|
||||
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
|
||||
// Empty paragraph - use default height
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
} else if char_pos == 0 {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
0..para.char_utf16_len_at(0),
|
||||
0..crate::shapes::horizontal_source_to_builder(para, 1),
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
@ -173,10 +291,13 @@ fn calculate_cursor_rect(
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
let last_char = para_char_count.saturating_sub(1);
|
||||
let last_start = para.char_offset_to_utf16(last_char);
|
||||
let last_start = crate::shapes::horizontal_source_to_builder(
|
||||
para,
|
||||
para_char_count.saturating_sub(1),
|
||||
);
|
||||
let last_end = crate::shapes::horizontal_source_to_builder(para, para_char_count);
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
last_start..last_start + para.char_utf16_len_at(last_char),
|
||||
last_start..last_end,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
@ -194,9 +315,10 @@ fn calculate_cursor_rect(
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else {
|
||||
let utf16_pos = para.char_offset_to_utf16(char_pos);
|
||||
let start = crate::shapes::horizontal_source_to_builder(para, char_pos);
|
||||
let end = crate::shapes::horizontal_source_to_builder(para, char_pos + 1);
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos),
|
||||
start..end,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
@ -228,12 +350,17 @@ fn calculate_selection_rects(
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let start = selection.start();
|
||||
let end = selection.end();
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
|
||||
if text_content.is_vertical() {
|
||||
return calculate_vertical_selection_rects(selection, text_content, shape);
|
||||
}
|
||||
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
|
||||
@ -268,15 +395,15 @@ fn calculate_selection_rects(
|
||||
};
|
||||
|
||||
if range_start < range_end {
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
let text_boxes = laid_out_para.get_rects_for_range(
|
||||
para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end),
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
// Selection offsets live in original text space; the
|
||||
// laid-out paragraph indexes the kinsoku-shifted text.
|
||||
let warichu_rects = crate::shapes::horizontal_warichu_range_rects(
|
||||
para,
|
||||
laid_out_para,
|
||||
range_start,
|
||||
range_end,
|
||||
);
|
||||
|
||||
for text_box in text_boxes {
|
||||
let r = text_box.rect;
|
||||
for r in warichu_rects {
|
||||
rects.push(Rect::from_xywh(
|
||||
r.left(),
|
||||
y_offset + r.top(),
|
||||
@ -284,6 +411,24 @@ fn calculate_selection_rects(
|
||||
r.height(),
|
||||
));
|
||||
}
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
for builder_range in
|
||||
crate::shapes::horizontal_normal_selection_ranges(para, range_start, range_end)
|
||||
{
|
||||
for text_box in laid_out_para.get_rects_for_range(
|
||||
builder_range,
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
) {
|
||||
let r = text_box.rect;
|
||||
rects.push(Rect::from_xywh(
|
||||
r.left(),
|
||||
y_offset + r.top(),
|
||||
r.width(),
|
||||
r.height(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
y_offset += para_height;
|
||||
|
||||
@ -3,7 +3,8 @@ use skia_safe::{self as skia, Canvas, Paint, RRect};
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{
|
||||
circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path,
|
||||
BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type,
|
||||
BlurType, Fill, Frame, ParagraphBuilderGroup, Path, Rect, Shape, Stroke, StrokeKind,
|
||||
StrokeStyle, TextContent, Type,
|
||||
};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
@ -56,6 +57,62 @@ impl<'a> VectorRenderer<'a> {
|
||||
}
|
||||
shape.image_filter(1.)
|
||||
}
|
||||
|
||||
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<'_> {
|
||||
@ -184,6 +241,15 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||
let blur_filter = self.layer_blur_filter(shape);
|
||||
|
||||
if text_content.is_vertical() {
|
||||
return self.draw_vertical_text(
|
||||
shape,
|
||||
&text_content,
|
||||
&mut paragraph_builders,
|
||||
blur_filter.as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
// Text drop shadows: one filter layer per shadow over fill + stroke
|
||||
// silhouettes (mirrors GPU `render_text_shadows`).
|
||||
let drop_shadows = shape.drop_shadow_paints();
|
||||
|
||||
@ -15,7 +15,10 @@ mod corners;
|
||||
mod fills;
|
||||
mod fonts;
|
||||
mod frames;
|
||||
mod gpos_vpal;
|
||||
mod groups;
|
||||
pub mod japanese;
|
||||
pub mod kinsoku;
|
||||
mod layouts;
|
||||
pub mod modifiers;
|
||||
mod paths;
|
||||
@ -27,7 +30,9 @@ mod strokes;
|
||||
mod svg_attrs;
|
||||
mod svgraw;
|
||||
mod text;
|
||||
mod text_japanese;
|
||||
pub mod text_paths;
|
||||
pub mod text_vertical;
|
||||
mod transform;
|
||||
|
||||
pub use blend::*;
|
||||
@ -49,6 +54,7 @@ pub use strokes::*;
|
||||
pub use svg_attrs::*;
|
||||
pub use svgraw::*;
|
||||
pub use text::*;
|
||||
pub use text_japanese::*;
|
||||
pub use transform::*;
|
||||
|
||||
use crate::math::{self, Bounds, Matrix, Point};
|
||||
@ -434,6 +440,7 @@ impl Shape {
|
||||
|
||||
pub fn set_vertical_align(&mut self, align: VerticalAlign) {
|
||||
self.vertical_align = align;
|
||||
self.invalidate_extrect();
|
||||
}
|
||||
|
||||
pub fn vertical_align(&self) -> VerticalAlign {
|
||||
|
||||
287
render-wasm/src/shapes/gpos_vpal.rs
Normal file
@ -0,0 +1,287 @@
|
||||
//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate
|
||||
//! metrics) feature. Vertical layout applies these deltas itself: cells
|
||||
//! flow by `vmtx` advances, so HarfBuzz never gets a chance to apply
|
||||
//! vertical GPOS positioning (SkShaper shapes on a horizontal line).
|
||||
//!
|
||||
//! Only single-adjustment lookups are read (SinglePos, directly or behind
|
||||
//! an Extension lookup) — `vpal` is metrics-only by design and real fonts
|
||||
//! (Noto CJK, Source Han) encode it exactly this way.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Font-unit deltas for one glyph. `y_placement` is y-up (positive lifts
|
||||
/// the ink toward the flow start); `y_advance` is negative when the
|
||||
/// vertical advance shrinks.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct VpalDelta {
|
||||
pub y_placement: i16,
|
||||
pub y_advance: i16,
|
||||
}
|
||||
|
||||
fn read_u16(data: &[u8], offset: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([
|
||||
*data.get(offset)?,
|
||||
*data.get(offset + 1)?,
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
|
||||
read_u16(data, offset).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*data.get(offset)?,
|
||||
*data.get(offset + 1)?,
|
||||
*data.get(offset + 2)?,
|
||||
*data.get(offset + 3)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// Glyph ids covered by a Coverage table, in coverage-index order.
|
||||
fn parse_coverage(data: &[u8], offset: usize) -> Option<Vec<u16>> {
|
||||
match read_u16(data, offset)? {
|
||||
1 => {
|
||||
let count = read_u16(data, offset + 2)? as usize;
|
||||
(0..count)
|
||||
.map(|i| read_u16(data, offset + 4 + i * 2))
|
||||
.collect()
|
||||
}
|
||||
2 => {
|
||||
let range_count = read_u16(data, offset + 2)? as usize;
|
||||
let mut glyphs = Vec::new();
|
||||
for i in 0..range_count {
|
||||
let o = offset + 4 + i * 6;
|
||||
let start = read_u16(data, o)?;
|
||||
let end = read_u16(data, o + 2)?;
|
||||
if end < start {
|
||||
return None;
|
||||
}
|
||||
glyphs.extend(start..=end);
|
||||
}
|
||||
Some(glyphs)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit
|
||||
/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device
|
||||
/// offsets). Returns the y deltas and consumes nothing else.
|
||||
fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option<VpalDelta> {
|
||||
let mut delta = VpalDelta::default();
|
||||
let mut o = offset;
|
||||
for bit in 0..8 {
|
||||
if value_format & (1 << bit) == 0 {
|
||||
continue;
|
||||
}
|
||||
match bit {
|
||||
1 => delta.y_placement = read_i16(data, o)?,
|
||||
3 => delta.y_advance = read_i16(data, o)?,
|
||||
_ => {}
|
||||
}
|
||||
o += 2;
|
||||
}
|
||||
Some(delta)
|
||||
}
|
||||
|
||||
fn value_record_size(value_format: u16) -> usize {
|
||||
(value_format & 0x00FF).count_ones() as usize * 2
|
||||
}
|
||||
|
||||
/// Accumulate a SinglePos subtable into `deltas`.
|
||||
fn parse_single_pos(
|
||||
data: &[u8],
|
||||
offset: usize,
|
||||
deltas: &mut HashMap<u16, VpalDelta>,
|
||||
) -> Option<()> {
|
||||
let format = read_u16(data, offset)?;
|
||||
let coverage = parse_coverage(data, offset + read_u16(data, offset + 2)? as usize)?;
|
||||
let value_format = read_u16(data, offset + 4)?;
|
||||
match format {
|
||||
1 => {
|
||||
let value = parse_value_record(data, offset + 6, value_format)?;
|
||||
for glyph in coverage {
|
||||
let entry = deltas.entry(glyph).or_default();
|
||||
entry.y_placement += value.y_placement;
|
||||
entry.y_advance += value.y_advance;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let count = read_u16(data, offset + 6)? as usize;
|
||||
let size = value_record_size(value_format);
|
||||
for (i, glyph) in coverage.into_iter().take(count).enumerate() {
|
||||
let value = parse_value_record(data, offset + 8 + i * size, value_format)?;
|
||||
let entry = deltas.entry(glyph).or_default();
|
||||
entry.y_placement += value.y_placement;
|
||||
entry.y_advance += value.y_advance;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Parse the `vpal` deltas out of a raw `GPOS` table. Returns `None` when
|
||||
/// the table is malformed or carries no `vpal` feature.
|
||||
pub fn parse_vpal(gpos: &[u8]) -> Option<HashMap<u16, VpalDelta>> {
|
||||
let feature_list = read_u16(gpos, 6)? as usize;
|
||||
let lookup_list = read_u16(gpos, 8)? as usize;
|
||||
|
||||
// Collect the lookup indices of every feature tagged `vpal`,
|
||||
// regardless of script: the deltas are per-glyph metrics.
|
||||
let feature_count = read_u16(gpos, feature_list)? as usize;
|
||||
let mut lookup_indices = Vec::new();
|
||||
for i in 0..feature_count {
|
||||
let record = feature_list + 2 + i * 6;
|
||||
if gpos.get(record..record.checked_add(4)?)? != b"vpal" {
|
||||
continue;
|
||||
}
|
||||
let feature = feature_list + read_u16(gpos, record + 4)? as usize;
|
||||
let index_count = read_u16(gpos, feature + 2)? as usize;
|
||||
for j in 0..index_count {
|
||||
lookup_indices.push(read_u16(gpos, feature + 4 + j * 2)? as usize);
|
||||
}
|
||||
}
|
||||
lookup_indices.sort_unstable();
|
||||
lookup_indices.dedup();
|
||||
if lookup_indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lookup_count = read_u16(gpos, lookup_list)? as usize;
|
||||
let mut deltas = HashMap::new();
|
||||
for index in lookup_indices {
|
||||
if index >= lookup_count {
|
||||
continue;
|
||||
}
|
||||
let lookup = lookup_list + read_u16(gpos, lookup_list + 2 + index * 2)? as usize;
|
||||
let lookup_type = read_u16(gpos, lookup)?;
|
||||
let subtable_count = read_u16(gpos, lookup + 4)? as usize;
|
||||
for s in 0..subtable_count {
|
||||
let subtable = lookup + read_u16(gpos, lookup + 6 + s * 2)? as usize;
|
||||
match lookup_type {
|
||||
1 => {
|
||||
parse_single_pos(gpos, subtable, &mut deltas)?;
|
||||
}
|
||||
9 => {
|
||||
// ExtensionPos: { format: u16, extensionLookupType: u16,
|
||||
// extensionOffset: u32 (from the subtable start) }.
|
||||
if read_u16(gpos, subtable + 2)? == 1 {
|
||||
let inner = subtable + read_u32(gpos, subtable + 4)? as usize;
|
||||
parse_single_pos(gpos, inner, &mut deltas)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if deltas.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(deltas)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Subset of Noto Sans JP: 〱あく「」、。 with `vert` alternates and the
|
||||
// real `vpal` deltas (Extension→SinglePos, both coverage and both
|
||||
// SinglePos formats, value formats 0x8 and 0xA).
|
||||
const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf");
|
||||
|
||||
fn gpos_table() -> Vec<u8> {
|
||||
// Locate the GPOS table in the raw sfnt directory.
|
||||
let data = VPAL_TEST_FONT;
|
||||
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||||
for i in 0..num_tables {
|
||||
let record = 12 + i * 16;
|
||||
if &data[record..record + 4] == b"GPOS" {
|
||||
let offset = u32::from_be_bytes([
|
||||
data[record + 8],
|
||||
data[record + 9],
|
||||
data[record + 10],
|
||||
data[record + 11],
|
||||
]) as usize;
|
||||
let length = u32::from_be_bytes([
|
||||
data[record + 12],
|
||||
data[record + 13],
|
||||
data[record + 14],
|
||||
data[record + 15],
|
||||
]) as usize;
|
||||
return data[offset..offset + length].to_vec();
|
||||
}
|
||||
}
|
||||
panic!("test font has no GPOS table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_noto_vpal_deltas() {
|
||||
let deltas = parse_vpal(&gpos_table()).expect("vpal deltas");
|
||||
// Vertical alternates: 、→8 。→9 「→10 」→11 あ→12 く→13.
|
||||
assert_eq!(
|
||||
deltas.get(&8),
|
||||
Some(&VpalDelta {
|
||||
y_placement: 0,
|
||||
y_advance: -500
|
||||
}),
|
||||
"、 halves its vertical advance"
|
||||
);
|
||||
assert_eq!(
|
||||
deltas.get(&10),
|
||||
Some(&VpalDelta {
|
||||
y_placement: 481,
|
||||
y_advance: -500
|
||||
}),
|
||||
"「 lifts its trailing-half ink into the compressed cell"
|
||||
);
|
||||
assert_eq!(
|
||||
deltas.get(&12),
|
||||
Some(&VpalDelta {
|
||||
y_placement: 39,
|
||||
y_advance: -58
|
||||
}),
|
||||
"あ tightens slightly"
|
||||
);
|
||||
// Base (horizontal) glyphs carry no vpal.
|
||||
assert!(!deltas.contains_key(&6), "base あ glyph is uncovered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_vpal_returns_none() {
|
||||
// A GPOS header with an empty feature list.
|
||||
let gpos = [
|
||||
0x00, 0x01, 0x00, 0x00, // version 1.0
|
||||
0x00, 0x0A, // scriptList
|
||||
0x00, 0x0C, // featureList
|
||||
0x00, 0x0E, // lookupList
|
||||
0x00, 0x00, // scriptCount
|
||||
0x00, 0x00, // featureCount
|
||||
0x00, 0x00, // lookupCount
|
||||
];
|
||||
assert!(parse_vpal(&gpos).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_table_is_rejected() {
|
||||
let table = gpos_table();
|
||||
for len in [0usize, 4, 9, 16] {
|
||||
assert!(parse_vpal(&table[..len.min(table.len())]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_feature_records_are_rejected() {
|
||||
let gpos = [
|
||||
0x00, 0x01, 0x00, 0x00, // version 1.0
|
||||
0x00, 0x0A, // scriptList
|
||||
0x00, 0x0A, // featureList
|
||||
0x00, 0x0A, // lookupList
|
||||
0x00, 0x02, // featureCount without matching records
|
||||
];
|
||||
|
||||
assert!(parse_vpal(&gpos).is_none());
|
||||
}
|
||||
}
|
||||
540
render-wasm/src/shapes/japanese.rs
Normal file
@ -0,0 +1,540 @@
|
||||
//! Shared JLREQ character classes and pair-rule tables.
|
||||
//!
|
||||
//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are
|
||||
//! contextual/virtual classes produced by higher-level inline composites;
|
||||
//! [`classify`] handles scalar characters and callers assign those virtual
|
||||
//! classes when constructing reference marks, ruby, grouped numerals,
|
||||
//! warichu, or tate-chu-yoko.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum JapaneseClass {
|
||||
OpeningBracket = 0,
|
||||
ClosingBracket,
|
||||
Hyphen,
|
||||
DividingPunctuation,
|
||||
MiddleDot,
|
||||
FullStop,
|
||||
Comma,
|
||||
Inseparable,
|
||||
IterationMark,
|
||||
ProlongedSoundMark,
|
||||
SmallKana,
|
||||
PrefixedAbbreviation,
|
||||
PostfixedAbbreviation,
|
||||
IdeographicSpace,
|
||||
Hiragana,
|
||||
Katakana,
|
||||
MathSymbol,
|
||||
MathOperator,
|
||||
Ideographic,
|
||||
ReferenceMark,
|
||||
OrnamentedComplex,
|
||||
SimpleRuby,
|
||||
JukugoRuby,
|
||||
GroupedNumeral,
|
||||
UnitSymbol,
|
||||
WesternWordSpace,
|
||||
Western,
|
||||
WarichuOpening,
|
||||
WarichuClosing,
|
||||
TateChuYoko,
|
||||
}
|
||||
|
||||
impl JapaneseClass {
|
||||
pub const COUNT: usize = 30;
|
||||
|
||||
pub const ALL: [Self; Self::COUNT] = [
|
||||
Self::OpeningBracket,
|
||||
Self::ClosingBracket,
|
||||
Self::Hyphen,
|
||||
Self::DividingPunctuation,
|
||||
Self::MiddleDot,
|
||||
Self::FullStop,
|
||||
Self::Comma,
|
||||
Self::Inseparable,
|
||||
Self::IterationMark,
|
||||
Self::ProlongedSoundMark,
|
||||
Self::SmallKana,
|
||||
Self::PrefixedAbbreviation,
|
||||
Self::PostfixedAbbreviation,
|
||||
Self::IdeographicSpace,
|
||||
Self::Hiragana,
|
||||
Self::Katakana,
|
||||
Self::MathSymbol,
|
||||
Self::MathOperator,
|
||||
Self::Ideographic,
|
||||
Self::ReferenceMark,
|
||||
Self::OrnamentedComplex,
|
||||
Self::SimpleRuby,
|
||||
Self::JukugoRuby,
|
||||
Self::GroupedNumeral,
|
||||
Self::UnitSymbol,
|
||||
Self::WesternWordSpace,
|
||||
Self::Western,
|
||||
Self::WarichuOpening,
|
||||
Self::WarichuClosing,
|
||||
Self::TateChuYoko,
|
||||
];
|
||||
|
||||
pub const fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
|
||||
pub const fn forbids_line_start(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ClosingBracket
|
||||
| Self::Hyphen
|
||||
| Self::DividingPunctuation
|
||||
| Self::MiddleDot
|
||||
| Self::FullStop
|
||||
| Self::Comma
|
||||
| Self::Inseparable
|
||||
| Self::IterationMark
|
||||
| Self::ProlongedSoundMark
|
||||
| Self::SmallKana
|
||||
| Self::PostfixedAbbreviation
|
||||
| Self::WarichuClosing
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn forbids_line_end(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::OpeningBracket | Self::PrefixedAbbreviation | Self::WarichuOpening
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn is_japanese_letter(self) -> bool {
|
||||
matches!(self, Self::Hiragana | Self::Katakana | Self::Ideographic)
|
||||
}
|
||||
|
||||
pub const fn is_western_run(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::GroupedNumeral | Self::UnitSymbol | Self::Western
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn is_emphasis_prohibited(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::OpeningBracket | Self::ClosingBracket | Self::FullStop | Self::Comma
|
||||
)
|
||||
}
|
||||
|
||||
/// Half-width punctuation whose normal character frame is completed by
|
||||
/// half an em after the glyph. Consecutive punctuation may suppress that
|
||||
/// appended spacing, but the glyph body itself remains half-width.
|
||||
pub const fn is_trailing_aki_punctuation(self) -> bool {
|
||||
matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PairRule {
|
||||
/// Preferred extra spacing between the two character frames, in em.
|
||||
pub preferred_em: f32,
|
||||
/// Smallest spacing allowed during oikomi, in em.
|
||||
pub minimum_em: f32,
|
||||
/// Largest spacing allowed during oidashi/justification, in em.
|
||||
pub maximum_em: f32,
|
||||
pub break_allowed: bool,
|
||||
/// Whether horizontal SkParagraph needs an inserted WORD JOINER for this
|
||||
/// pair. Atomic Western/numeral runs are already protected by its Unicode
|
||||
/// breaker, so they remain non-breakable without synthetic characters.
|
||||
pub suppress_break_with_joiner: bool,
|
||||
/// Lower values are adjusted first; zero means not adjustable.
|
||||
pub shrink_priority: u8,
|
||||
pub expand_priority: u8,
|
||||
}
|
||||
|
||||
impl PairRule {
|
||||
const SOLID: Self = Self {
|
||||
preferred_em: 0.0,
|
||||
minimum_em: 0.0,
|
||||
maximum_em: 0.0,
|
||||
break_allowed: true,
|
||||
suppress_break_with_joiner: false,
|
||||
shrink_priority: 0,
|
||||
expand_priority: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] {
|
||||
let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT];
|
||||
let mut before_index = 0;
|
||||
while before_index < JapaneseClass::COUNT {
|
||||
let before = JapaneseClass::ALL[before_index];
|
||||
let mut after_index = 0;
|
||||
while after_index < JapaneseClass::COUNT {
|
||||
let after = JapaneseClass::ALL[after_index];
|
||||
let mut rule = PairRule::SOLID;
|
||||
|
||||
rule.suppress_break_with_joiner =
|
||||
before.forbids_line_end() || after.forbids_line_start();
|
||||
rule.break_allowed = !rule.suppress_break_with_joiner;
|
||||
if before_index == after_index
|
||||
&& matches!(
|
||||
before,
|
||||
JapaneseClass::Inseparable
|
||||
| JapaneseClass::GroupedNumeral
|
||||
| JapaneseClass::Western
|
||||
)
|
||||
{
|
||||
rule.break_allowed = false;
|
||||
}
|
||||
|
||||
if matches!(before, JapaneseClass::WesternWordSpace) {
|
||||
rule.preferred_em = 1.0 / 3.0;
|
||||
rule.minimum_em = 0.25;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 1;
|
||||
rule.expand_priority = 1;
|
||||
} else if (before.is_japanese_letter() && after.is_western_run())
|
||||
|| (before.is_western_run() && after.is_japanese_letter())
|
||||
{
|
||||
rule.preferred_em = 0.25;
|
||||
rule.minimum_em = 0.125;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 5;
|
||||
rule.expand_priority = 2;
|
||||
} else if matches!(
|
||||
(before, after),
|
||||
(
|
||||
JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop,
|
||||
JapaneseClass::TateChuYoko
|
||||
) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket)
|
||||
) {
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 3;
|
||||
} else if matches!(
|
||||
(before, after),
|
||||
(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko)
|
||||
) {
|
||||
// Two adjacent cl-30 entries are necessarily separate TCY
|
||||
// composites (characters inside one composite are atomic).
|
||||
rule.maximum_em = 0.25;
|
||||
rule.expand_priority = 3;
|
||||
} else if matches!(before, JapaneseClass::DividingPunctuation)
|
||||
&& !matches!(after, JapaneseClass::ClosingBracket)
|
||||
{
|
||||
// A sentence-ending question/exclamation mark carries one em
|
||||
// after it. Line planning may discard this at the line edge.
|
||||
rule.preferred_em = 1.0;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 1.0;
|
||||
rule.shrink_priority = 2;
|
||||
} else if before.is_trailing_aki_punctuation()
|
||||
&& matches!(after, JapaneseClass::OpeningBracket)
|
||||
{
|
||||
// Only one half-em is retained between the two half-width
|
||||
// glyph bodies, not the sum of both characters' normal aki.
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 4;
|
||||
} else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() {
|
||||
// Consecutive closing punctuation sets solid internally; the
|
||||
// last character in the sequence supplies the trailing aki.
|
||||
} else if matches!(before, JapaneseClass::OpeningBracket)
|
||||
&& matches!(after, JapaneseClass::OpeningBracket)
|
||||
{
|
||||
// Consecutive opening brackets set solid internally; the first
|
||||
// character in the sequence supplies the leading aki.
|
||||
} else if (before.is_trailing_aki_punctuation()
|
||||
&& matches!(after, JapaneseClass::MiddleDot))
|
||||
|| (matches!(before, JapaneseClass::MiddleDot)
|
||||
&& matches!(after, JapaneseClass::OpeningBracket))
|
||||
{
|
||||
rule.preferred_em = 0.25;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.25;
|
||||
rule.shrink_priority = 3;
|
||||
} else if before.is_trailing_aki_punctuation() {
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = if matches!(before, JapaneseClass::FullStop) {
|
||||
0.5
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) {
|
||||
0
|
||||
} else {
|
||||
4
|
||||
};
|
||||
} else if matches!(after, JapaneseClass::OpeningBracket) {
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 4;
|
||||
} else if matches!(before, JapaneseClass::MiddleDot)
|
||||
|| matches!(after, JapaneseClass::MiddleDot)
|
||||
{
|
||||
rule.preferred_em = 0.25;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.25;
|
||||
rule.shrink_priority = 3;
|
||||
} else if before.is_japanese_letter() && after.is_japanese_letter() {
|
||||
// Solid Japanese text is the general third-stage expansion
|
||||
// opportunity. The planner may continue past this quarter-em
|
||||
// cap only in JLREQ's final equal-expansion fallback.
|
||||
rule.maximum_em = 0.25;
|
||||
rule.expand_priority = 3;
|
||||
}
|
||||
|
||||
table[before_index][after_index] = rule;
|
||||
after_index += 1;
|
||||
}
|
||||
before_index += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] =
|
||||
generated_pair_rules();
|
||||
|
||||
pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule {
|
||||
PAIR_RULES[before.index()][after.index()]
|
||||
}
|
||||
|
||||
const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“";
|
||||
const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”";
|
||||
const HYPHENS: &str = "‐゠–〜~";
|
||||
const DIVIDING_PUNCTUATION: &str = "!?‼⁇⁈⁉";
|
||||
const MIDDLE_DOTS: &str = "・・:;";
|
||||
const FULL_STOPS: &str = "。.";
|
||||
const COMMAS: &str = "、,";
|
||||
const INSEPARABLE: &str = "―…‥〳〴〵";
|
||||
const ITERATION_MARKS: &str = "々〻ゝゞヽヾ";
|
||||
const SMALL_KANA: &str = concat!(
|
||||
"ぁぃぅぇぉっゃゅょゎゕゖ",
|
||||
"ァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ"
|
||||
);
|
||||
const PREFIXED_ABBREVIATIONS: &str = "¥¥$$££##";
|
||||
const POSTFIXED_ABBREVIATIONS: &str = "°′″℃¢¢%%‰‱";
|
||||
const MATH_SYMBOLS: &str = "==≠<<>>≦≧≤≥∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔∥";
|
||||
const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏";
|
||||
|
||||
pub fn classify(c: char) -> JapaneseClass {
|
||||
if OPENING_BRACKETS.contains(c) {
|
||||
JapaneseClass::OpeningBracket
|
||||
} else if CLOSING_BRACKETS.contains(c) {
|
||||
JapaneseClass::ClosingBracket
|
||||
} else if HYPHENS.contains(c) {
|
||||
JapaneseClass::Hyphen
|
||||
} else if DIVIDING_PUNCTUATION.contains(c) {
|
||||
JapaneseClass::DividingPunctuation
|
||||
} else if MIDDLE_DOTS.contains(c) {
|
||||
JapaneseClass::MiddleDot
|
||||
} else if FULL_STOPS.contains(c) {
|
||||
JapaneseClass::FullStop
|
||||
} else if COMMAS.contains(c) {
|
||||
JapaneseClass::Comma
|
||||
} else if INSEPARABLE.contains(c) {
|
||||
JapaneseClass::Inseparable
|
||||
} else if ITERATION_MARKS.contains(c) {
|
||||
JapaneseClass::IterationMark
|
||||
} else if c == 'ー' {
|
||||
JapaneseClass::ProlongedSoundMark
|
||||
} else if SMALL_KANA.contains(c) {
|
||||
JapaneseClass::SmallKana
|
||||
} else if PREFIXED_ABBREVIATIONS.contains(c) {
|
||||
JapaneseClass::PrefixedAbbreviation
|
||||
} else if POSTFIXED_ABBREVIATIONS.contains(c) {
|
||||
JapaneseClass::PostfixedAbbreviation
|
||||
} else if c == '\u{3000}' {
|
||||
JapaneseClass::IdeographicSpace
|
||||
} else if MATH_SYMBOLS.contains(c) {
|
||||
JapaneseClass::MathSymbol
|
||||
} else if MATH_OPERATORS.contains(c) {
|
||||
JapaneseClass::MathOperator
|
||||
} else if c == ' ' || c == '\t' || c == '\u{00a0}' {
|
||||
JapaneseClass::WesternWordSpace
|
||||
} else if c.is_ascii_digit() {
|
||||
JapaneseClass::GroupedNumeral
|
||||
} else if is_hiragana(c) {
|
||||
JapaneseClass::Hiragana
|
||||
} else if is_katakana(c) {
|
||||
JapaneseClass::Katakana
|
||||
} else if is_ideographic(c) {
|
||||
JapaneseClass::Ideographic
|
||||
} else if is_unit_symbol(c) {
|
||||
JapaneseClass::UnitSymbol
|
||||
} else {
|
||||
JapaneseClass::Western
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hiragana(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x3041..=0x309F)
|
||||
}
|
||||
|
||||
fn is_katakana(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D)
|
||||
}
|
||||
|
||||
fn is_ideographic(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x2E80..=0x2FDF
|
||||
| 0x31C0..=0x31EF
|
||||
| 0x3400..=0x4DBF
|
||||
| 0x4E00..=0x9FFF
|
||||
| 0xF900..=0xFAFF
|
||||
| 0x20000..=0x2FA1F
|
||||
) || matches!(c, '〃' | '仝' | '〆' | '♂' | '♀')
|
||||
}
|
||||
|
||||
fn is_unit_symbol(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x2100..=0x214F | 0x3300..=0x33FF)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn class_model_contains_all_thirty_jlreq_classes_in_order() {
|
||||
assert_eq!(JapaneseClass::ALL.len(), 30);
|
||||
for (index, class) in JapaneseClass::ALL.iter().enumerate() {
|
||||
assert_eq!(class.index(), index);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_representative_jlreq_characters() {
|
||||
let cases = [
|
||||
('「', JapaneseClass::OpeningBracket),
|
||||
('」', JapaneseClass::ClosingBracket),
|
||||
('〜', JapaneseClass::Hyphen),
|
||||
('!', JapaneseClass::DividingPunctuation),
|
||||
('・', JapaneseClass::MiddleDot),
|
||||
('。', JapaneseClass::FullStop),
|
||||
('、', JapaneseClass::Comma),
|
||||
('…', JapaneseClass::Inseparable),
|
||||
('々', JapaneseClass::IterationMark),
|
||||
('ー', JapaneseClass::ProlongedSoundMark),
|
||||
('ょ', JapaneseClass::SmallKana),
|
||||
('¥', JapaneseClass::PrefixedAbbreviation),
|
||||
('%', JapaneseClass::PostfixedAbbreviation),
|
||||
('\u{3000}', JapaneseClass::IdeographicSpace),
|
||||
('あ', JapaneseClass::Hiragana),
|
||||
('ア', JapaneseClass::Katakana),
|
||||
('≠', JapaneseClass::MathSymbol),
|
||||
('+', JapaneseClass::MathOperator),
|
||||
('漢', JapaneseClass::Ideographic),
|
||||
('2', JapaneseClass::GroupedNumeral),
|
||||
('㎏', JapaneseClass::UnitSymbol),
|
||||
(' ', JapaneseClass::WesternWordSpace),
|
||||
('A', JapaneseClass::Western),
|
||||
];
|
||||
for (character, expected) in cases {
|
||||
assert_eq!(classify(character), expected, "wrong class for {character}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_cover_every_class_pair() {
|
||||
assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT);
|
||||
assert!(PAIR_RULES
|
||||
.iter()
|
||||
.all(|row| row.len() == JapaneseClass::COUNT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_encode_kinsoku_and_atomic_runs() {
|
||||
assert!(
|
||||
!pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed
|
||||
);
|
||||
assert!(
|
||||
!pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed
|
||||
);
|
||||
assert!(
|
||||
!pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed
|
||||
);
|
||||
assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_encode_script_and_tcy_spacing() {
|
||||
let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western);
|
||||
assert_eq!(script.preferred_em, 0.25);
|
||||
assert_eq!(script.minimum_em, 0.125);
|
||||
assert_eq!(script.maximum_em, 0.5);
|
||||
|
||||
let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko);
|
||||
assert_eq!(tcy.preferred_em, 0.5);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em,
|
||||
0.0
|
||||
);
|
||||
let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko);
|
||||
assert_eq!(adjacent_tcy.preferred_em, 0.0);
|
||||
assert_eq!(adjacent_tcy.maximum_em, 0.25);
|
||||
assert_eq!(adjacent_tcy.expand_priority, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_encode_punctuation_sequences() {
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em,
|
||||
0.25
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.25
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(
|
||||
JapaneseClass::DividingPunctuation,
|
||||
JapaneseClass::Ideographic
|
||||
)
|
||||
.preferred_em,
|
||||
1.0
|
||||
);
|
||||
let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana);
|
||||
assert_eq!(solid_japanese.preferred_em, 0.0);
|
||||
assert_eq!(solid_japanese.maximum_em, 0.25);
|
||||
assert_eq!(solid_japanese.expand_priority, 3);
|
||||
let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby);
|
||||
assert_eq!(ruby_adjacency.preferred_em, 0.0);
|
||||
assert_eq!(ruby_adjacency.maximum_em, 0.0);
|
||||
assert_eq!(ruby_adjacency.expand_priority, 0);
|
||||
}
|
||||
}
|
||||
514
render-wasm/src/shapes/kinsoku.rs
Normal file
@ -0,0 +1,514 @@
|
||||
//! Japanese line-breaking rules (kinsoku shori).
|
||||
//!
|
||||
//! Skia's default break iterator allows closing punctuation, the
|
||||
//! prolonged sound mark or small kana at a line start, and opening
|
||||
//! brackets at a line end. skia-safe exposes no ICU BreakIterator, so
|
||||
//! the forbidden break opportunities are suppressed by inserting U+2060 WORD
|
||||
//! JOINER into the text handed to skparagraph. The same lossless layout-text
|
||||
//! transform inserts JLREQ quarter-em Japanese/Western boundary space and
|
||||
//! normalizes Western word spaces to one third em.
|
||||
//!
|
||||
//! The inserted joiners shift every UTF-16 offset reported by the laid
|
||||
//! out paragraph (position-data, caret mapping, selection rects). The
|
||||
//! [`OffsetMap`] returned along with the modified texts translates
|
||||
//! between original and joiner-shifted offsets. It is a pure function
|
||||
//! of the span texts, so any consumer can recompute it and stay
|
||||
//! consistent with the builders by construction.
|
||||
|
||||
/// Zero-width character whose UAX #14 class forbids breaking on either
|
||||
/// side of it.
|
||||
pub const WORD_JOINER: char = '\u{2060}';
|
||||
/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap.
|
||||
pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}';
|
||||
/// Unicode THREE-PER-EM SPACE, used for Western word spacing in Japanese text.
|
||||
pub const WESTERN_WORD_SPACE: char = '\u{2004}';
|
||||
|
||||
use super::japanese::{classify, pair_rule};
|
||||
|
||||
pub fn forbidden_at_line_start(c: char) -> bool {
|
||||
classify(c).forbids_line_start()
|
||||
}
|
||||
|
||||
pub fn forbidden_at_line_end(c: char) -> bool {
|
||||
classify(c).forbids_line_end()
|
||||
}
|
||||
|
||||
/// Translates between original UTF-16 offsets (the source-of-truth span text)
|
||||
/// and layout-text UTF-16 offsets (the text handed to skparagraph).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct OffsetMap {
|
||||
/// UTF-16 indices of inserted joiners or boundary spaces, ascending and
|
||||
/// expressed in shifted coordinates. Same-length substitutions need no
|
||||
/// entry.
|
||||
inserted: Vec<usize>,
|
||||
}
|
||||
|
||||
impl OffsetMap {
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inserted.is_empty()
|
||||
}
|
||||
|
||||
/// Original offset for a shifted offset. An offset pointing at an inserted
|
||||
/// character resolves to the source boundary where it was inserted.
|
||||
pub fn to_original(&self, shifted: usize) -> usize {
|
||||
shifted - self.inserted.iter().take_while(|&&p| p < shifted).count()
|
||||
}
|
||||
|
||||
/// Shifted offset for an original offset. A boundary that received a
|
||||
/// layout character resolves after it, so carets avoid synthetic spacing.
|
||||
pub fn to_shifted(&self, original: usize) -> usize {
|
||||
let mut shifted = original;
|
||||
for &p in &self.inserted {
|
||||
if p <= shifted {
|
||||
shifted += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
shifted
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the horizontal Japanese layout-text transform. It inserts WORD
|
||||
/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space at
|
||||
/// Japanese↔Western boundaries, and normalizes breakable ASCII word spaces to
|
||||
/// one third em. Span boundaries are transparent. Returns `None` when the
|
||||
/// paragraph needs no transformation.
|
||||
/// Apply the normal Japanese layout transform while additionally protecting
|
||||
/// annotated base units. `ruby_breaks[span] == Some(boundaries)` means that
|
||||
/// every internal scalar boundary except those UTF-16 offsets is atomic.
|
||||
pub fn apply_to_span_texts_with_ruby_breaks(
|
||||
span_texts: &[String],
|
||||
ruby_breaks: &[Option<Vec<usize>>],
|
||||
) -> Option<(Vec<String>, OffsetMap)> {
|
||||
let mut inserted: Vec<usize> = Vec::new();
|
||||
let mut out: Vec<String> = Vec::with_capacity(span_texts.len());
|
||||
let mut prev: Option<char> = None;
|
||||
let mut changed = false;
|
||||
// Running position in shifted UTF-16 coordinates.
|
||||
let mut shifted_pos: usize = 0;
|
||||
|
||||
for (span_index, text) in span_texts.iter().enumerate() {
|
||||
let mut shifted_text = String::with_capacity(text.len() + 4);
|
||||
let mut local_utf16 = 0usize;
|
||||
for c in text.chars() {
|
||||
let ruby_forbids_break = local_utf16 > 0
|
||||
&& ruby_breaks
|
||||
.get(span_index)
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|breaks| !breaks.contains(&local_utf16));
|
||||
let forbid_break = ruby_forbids_break
|
||||
|| match prev {
|
||||
Some(p) => pair_rule(classify(p), classify(c)).suppress_break_with_joiner,
|
||||
None => false,
|
||||
};
|
||||
if forbid_break {
|
||||
shifted_text.push(WORD_JOINER);
|
||||
inserted.push(shifted_pos);
|
||||
shifted_pos += 1;
|
||||
changed = true;
|
||||
} else if prev.is_some_and(|p| {
|
||||
let before = classify(p);
|
||||
let after = classify(c);
|
||||
(before.is_japanese_letter() && after.is_western_run())
|
||||
|| (before.is_western_run() && after.is_japanese_letter())
|
||||
}) {
|
||||
shifted_text.push(JAPANESE_WESTERN_SPACE);
|
||||
inserted.push(shifted_pos);
|
||||
shifted_pos += 1;
|
||||
changed = true;
|
||||
}
|
||||
let layout_char = if c == ' ' {
|
||||
changed = true;
|
||||
WESTERN_WORD_SPACE
|
||||
} else {
|
||||
c
|
||||
};
|
||||
shifted_text.push(layout_char);
|
||||
shifted_pos += c.len_utf16();
|
||||
local_utf16 += c.len_utf16();
|
||||
prev = Some(c);
|
||||
}
|
||||
out.push(shifted_text);
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
Some((out, OffsetMap { inserted }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::FontMgr;
|
||||
|
||||
const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
|
||||
|
||||
fn strings(texts: &[&str]) -> Vec<String> {
|
||||
texts.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
fn apply(texts: &[&str]) -> (Vec<String>, OffsetMap) {
|
||||
apply_to_span_texts_with_ruby_breaks(&strings(texts), &[])
|
||||
.expect("expected kinsoku insertions")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Character classes
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classes_forbidden_at_start() {
|
||||
for c in "、。」』)ーっゃァッ々・!?".chars() {
|
||||
assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}");
|
||||
}
|
||||
for c in "あ漢A1「( ".chars() {
|
||||
assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classes_forbidden_at_end() {
|
||||
for c in "「『([【〈《".chars() {
|
||||
assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}");
|
||||
}
|
||||
for c in "あ漢A1」)。".chars() {
|
||||
assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Insertion
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn western_word_space_uses_one_third_em_character() {
|
||||
let (texts, map) = apply(&["hello world"]);
|
||||
assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]);
|
||||
assert!(
|
||||
map.inserted.is_empty(),
|
||||
"substitution does not shift offsets"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_insertion_for_plain_cjk_text() {
|
||||
assert!(
|
||||
apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_before_forbidden_start_char() {
|
||||
let (texts, map) = apply(&["雪国。"]);
|
||||
assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]);
|
||||
assert_eq!(map.inserted, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_after_forbidden_end_char() {
|
||||
let (texts, _) = apply(&["「雪"]);
|
||||
assert_eq!(texts, vec!["「\u{2060}雪".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_insertion_at_paragraph_start() {
|
||||
// A leading forbidden-at-start char has no break opportunity
|
||||
// before it; nothing to suppress.
|
||||
let (texts, _) = apply(&["。あ。"]);
|
||||
assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insertion_spans_boundary() {
|
||||
// The pair (end of span 0, start of span 1) is evaluated; the
|
||||
// joiner lands at the head of span 1.
|
||||
let (texts, _) = apply(&["雪国", "。です"]);
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_quarter_em_at_japanese_western_boundaries() {
|
||||
let (texts, map) = apply(&["日本Penpot版"]);
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![format!(
|
||||
"日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版"
|
||||
)]
|
||||
);
|
||||
assert_eq!(map.inserted, vec![2, 9]);
|
||||
assert_eq!(map.to_original(map.to_shifted(2)), 2);
|
||||
assert_eq!(map.to_original(map.to_shifted(8)), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_western_spacing_crosses_span_boundary() {
|
||||
let (texts, map) = apply(&["日本", "Penpot"]);
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
"日本".to_string(),
|
||||
format!("{JAPANESE_WESTERN_SPACE}Penpot")
|
||||
]
|
||||
);
|
||||
assert_eq!(map.inserted, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_forbidden_chars() {
|
||||
let (texts, map) = apply(&["た」。"]);
|
||||
assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]);
|
||||
assert_eq!(map.inserted, vec![1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_kana_and_prolonged_sound() {
|
||||
let (texts, _) = apply(&["コーヒー"]);
|
||||
assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]);
|
||||
let (texts, _) = apply(&["ちょっと"]);
|
||||
assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Offset map
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn offset_map_round_trips_every_original_index() {
|
||||
let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。";
|
||||
let (_, map) = apply(&[original]);
|
||||
let len = original.encode_utf16().count();
|
||||
for i in 0..=len {
|
||||
assert_eq!(
|
||||
map.to_original(map.to_shifted(i)),
|
||||
i,
|
||||
"round-trip failed at original index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_map_recovers_original_characters() {
|
||||
let original = "た」。あ";
|
||||
let (texts, map) = apply(&[original]);
|
||||
let shifted = texts.concat();
|
||||
let shifted_units: Vec<u16> = shifted.encode_utf16().collect();
|
||||
let original_units: Vec<u16> = original.encode_utf16().collect();
|
||||
for (s_idx, unit) in shifted_units.iter().enumerate() {
|
||||
if *unit == WORD_JOINER as u16 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(original_units[map.to_original(s_idx)], *unit);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_map_on_joiner_resolves_to_boundary() {
|
||||
// "雪国。" → joiner at shifted 2; both sides of the joiner map
|
||||
// to original boundary 2.
|
||||
let (_, map) = apply(&["雪国。"]);
|
||||
assert_eq!(map.to_original(2), 2);
|
||||
assert_eq!(map.to_original(3), 2);
|
||||
assert_eq!(map.to_shifted(2), 3);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Real skparagraph layout
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
fn font_collection() -> FontCollection {
|
||||
let font_mgr = FontMgr::new();
|
||||
let typeface = font_mgr
|
||||
.new_from_data(TEST_FONT, None)
|
||||
.expect("failed to load test font");
|
||||
let mut provider = TypefaceFontProvider::new();
|
||||
provider.register_typeface(typeface, Some("TestFont"));
|
||||
let mut collection = FontCollection::new();
|
||||
collection.set_asset_font_manager(Some(provider.into()));
|
||||
collection.set_default_font_manager(FontMgr::new(), None);
|
||||
collection
|
||||
}
|
||||
|
||||
/// Lays out `text` at `width` and returns each line as a UTF-16
|
||||
/// (start, end) range.
|
||||
fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> {
|
||||
let collection = font_collection();
|
||||
let paragraph_style = ParagraphStyle::default();
|
||||
let mut builder = ParagraphBuilder::new(¶graph_style, collection);
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
style.set_letter_spacing(letter_spacing);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut paragraph = builder.build();
|
||||
paragraph.layout(width);
|
||||
paragraph
|
||||
.get_line_metrics()
|
||||
.iter()
|
||||
.map(|line| (line.start_index, line.end_index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn utf16_chars(text: &str) -> Vec<char> {
|
||||
// BMP-only fixtures: one char per UTF-16 unit.
|
||||
text.chars().collect()
|
||||
}
|
||||
|
||||
/// Asserts no line starts with a start-forbidden char nor ends with
|
||||
/// an end-forbidden char, in ORIGINAL text coordinates.
|
||||
fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
|
||||
let chars = utf16_chars(original);
|
||||
for (i, (start, end)) in lines.iter().enumerate() {
|
||||
let orig_start = map.to_original(*start);
|
||||
let orig_end = map.to_original(*end);
|
||||
if i > 0 {
|
||||
let first = chars[orig_start];
|
||||
assert!(
|
||||
!forbidden_at_line_start(first),
|
||||
"line {i} starts with forbidden char {first} (text {original})"
|
||||
);
|
||||
}
|
||||
if i < lines.len() - 1 && orig_end > orig_start {
|
||||
// The last char actually rendered on the line (end may
|
||||
// include trailing whitespace-like positions).
|
||||
let last = chars[orig_end - 1];
|
||||
assert!(
|
||||
!forbidden_at_line_end(last),
|
||||
"line {i} ends with forbidden char {last} (text {original})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines mapped back to original offsets must tile the original
|
||||
/// text exactly: contiguous, in order, full coverage.
|
||||
fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
|
||||
let mut expected_start = 0;
|
||||
for (start, end) in lines {
|
||||
let orig_start = map.to_original(*start);
|
||||
let orig_end = map.to_original(*end);
|
||||
assert_eq!(orig_start, expected_start, "line ranges must be contiguous");
|
||||
expected_start = orig_end;
|
||||
}
|
||||
assert_eq!(expected_start, original.encode_utf16().count());
|
||||
}
|
||||
|
||||
fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) {
|
||||
let (texts, map) = apply(&[original]);
|
||||
let lines = layout_lines(&texts.concat(), width, 0.0);
|
||||
(lines, map)
|
||||
}
|
||||
|
||||
fn measure_width(text: &str) -> f32 {
|
||||
let collection = font_collection();
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection);
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::MAX);
|
||||
p.longest_line()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joiner_is_zero_width_in_layout() {
|
||||
let plain = layout_lines("国国", f32::MAX, 0.0);
|
||||
let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0);
|
||||
assert_eq!(plain.len(), 1);
|
||||
assert_eq!(joined.len(), 1);
|
||||
|
||||
let collection = font_collection();
|
||||
let measure = |text: &str| {
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::MAX);
|
||||
p.longest_line()
|
||||
};
|
||||
let diff = (measure("国国") - measure("国\u{2060}国")).abs();
|
||||
assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppresses_breaks_on_kinsoku_fixtures() {
|
||||
let fixtures = [
|
||||
// kinsoku-line-start
|
||||
"これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
|
||||
// kinsoku-line-end
|
||||
"開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
|
||||
// prolonged-sound
|
||||
"コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
|
||||
// small-kana-sokuon
|
||||
"ちょっと待ってください。キャッシュとクッキーをチェックする。",
|
||||
// long-paragraph-wrap
|
||||
"国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
|
||||
];
|
||||
// Several widths to move the break positions around. Widths
|
||||
// must exceed the longest unbreakable (joined) run, otherwise
|
||||
// skparagraph rightfully falls back to an emergency mid-run
|
||||
// break.
|
||||
let char_width = measure_width("国");
|
||||
for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) {
|
||||
for original in fixtures {
|
||||
let (lines, map) = fixture_lines(original, width);
|
||||
assert!(lines.len() > 1, "fixture must wrap at width {width}");
|
||||
assert_kinsoku_clean(original, &lines, &map);
|
||||
assert_lines_tile_original(original, &lines, &map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_latin_cjk_fixture() {
|
||||
let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!";
|
||||
let char_width = measure_width("国");
|
||||
for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) {
|
||||
let (lines, map) = fixture_lines(original, width);
|
||||
assert_kinsoku_clean(original, &lines, &map);
|
||||
assert_lines_tile_original(original, &lines, &map);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joiner_becomes_visible_under_letter_spacing() {
|
||||
// skparagraph applies letter-spacing per cluster, INCLUDING the
|
||||
// zero-width joiner, which would double the tracking at every
|
||||
// suppressed break. This is why callers disable kinsoku for
|
||||
// paragraphs with a non-zero letter-spacing. If this test ever
|
||||
// fails (Skia stops spacing ignorables), that gate can go.
|
||||
let collection = font_collection();
|
||||
let measure = |text: &str| {
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
style.set_letter_spacing(5.0);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::MAX);
|
||||
p.longest_line()
|
||||
};
|
||||
let diff = (measure("国\u{2060}国") - measure("国国")).abs();
|
||||
assert!(
|
||||
diff > 0.01,
|
||||
"letter-spacing no longer affects the joiner; the kinsoku \
|
||||
letter-spacing gate can be removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -268,15 +268,34 @@ fn propagate_transform(
|
||||
);
|
||||
match text_content.grow_type() {
|
||||
GrowType::AutoHeight => {
|
||||
let width_before = text_content.size.width;
|
||||
let height_before = text_content.size.height;
|
||||
let new_height = if width_changed {
|
||||
let (new_width, new_height) = if text_content.is_vertical() {
|
||||
// Vertical auto-height fixes the physical height (the
|
||||
// column wrap axis) and grows width as columns are added.
|
||||
if height_changed {
|
||||
let mut clone = text_content.clone();
|
||||
clone.update_layout(resized_selrect);
|
||||
(clone.size.width, shape_bounds_after.height())
|
||||
} else {
|
||||
(width_before, shape_bounds_after.height())
|
||||
}
|
||||
} else if width_changed {
|
||||
let mut clone = text_content.clone();
|
||||
clone.update_layout(resized_selrect);
|
||||
clone.size.height
|
||||
(shape_bounds_after.width(), clone.size.height)
|
||||
} else {
|
||||
height_before
|
||||
(shape_bounds_after.width(), height_before)
|
||||
};
|
||||
if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) {
|
||||
// Reflow only when the grow axis (the WASM-computed
|
||||
// dimension) changes; the wrap axis is driven by the
|
||||
// resize itself.
|
||||
let grow_axis_changed = if text_content.is_vertical() {
|
||||
!is_close_to(width_before, new_width)
|
||||
} else {
|
||||
!is_close_to(height_before, new_height)
|
||||
};
|
||||
if grow_axis_changed && reflowed_shapes.insert(shape.id) {
|
||||
entries.push_back(Modifier::reflow(shape.id, false));
|
||||
|
||||
if let Some(parent_id) = shape.parent_id {
|
||||
@ -290,7 +309,7 @@ fn propagate_transform(
|
||||
let resize_transform = math::resize_matrix(
|
||||
&shape_bounds_after,
|
||||
&shape_bounds_after,
|
||||
shape_bounds_after.width(),
|
||||
new_width,
|
||||
new_height,
|
||||
);
|
||||
shape_bounds_after = shape_bounds_after.transform(&resize_transform);
|
||||
|
||||
785
render-wasm/src/shapes/text_japanese.rs
Normal file
@ -0,0 +1,785 @@
|
||||
use super::text::{add_text_with_tabs, 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 {
|
||||
add_text_with_tabs(builder, builder_text, span.font_size);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
// `add_text_with_tabs` stores every tab as a U+FFFC placeholder.
|
||||
text.len() + text.matches('\t').count() * ('\u{FFFC}'.len_utf8() - 1)
|
||||
};
|
||||
HorizontalSpanRange {
|
||||
span: span_index,
|
||||
builder_start,
|
||||
builder_end: builder_cursor,
|
||||
shifted_start,
|
||||
source_start,
|
||||
source_end,
|
||||
warichu,
|
||||
style_anchor_start,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Transformed-text UTF-16 offset of every source character boundary: the
|
||||
/// space the kinsoku offset map reports as "original". Differs from the
|
||||
/// source UTF-16 when a transform changes the text length (`ß` -> `SS`).
|
||||
fn source_char_boundaries(paragraph: &Paragraph) -> Vec<usize> {
|
||||
let mut boundaries = vec![0usize];
|
||||
let mut span_base = 0usize;
|
||||
for span in paragraph.children() {
|
||||
let applied = span.apply_text_transform_with_source_ranges();
|
||||
let mut ranges = applied.source_ranges.iter().peekable();
|
||||
let mut source_utf16 = 0usize;
|
||||
let mut output_end = 0usize;
|
||||
for character in span.text.chars() {
|
||||
source_utf16 += character.len_utf16();
|
||||
while let Some((output, _)) = ranges.next_if(|(_, source)| source.end <= source_utf16) {
|
||||
output_end = output.end;
|
||||
}
|
||||
boundaries.push(span_base + output_end);
|
||||
}
|
||||
span_base += applied.text.encode_utf16().count();
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/// Placeholder rect of each horizontal warichu span, keyed by span index.
|
||||
/// Tabs in normal spans are placeholders too, so they are skipped.
|
||||
fn horizontal_warichu_placeholders(
|
||||
paragraph: &Paragraph,
|
||||
laid_out: &skia::textlayout::Paragraph,
|
||||
) -> Vec<(usize, skia::Rect)> {
|
||||
let mut placeholders = laid_out.get_rects_for_placeholders().into_iter();
|
||||
let mut result = Vec::new();
|
||||
for (index, span) in paragraph.children().iter().enumerate() {
|
||||
if span.warichu && span.text.chars().count() >= 2 {
|
||||
if let Some(textbox) = placeholders.next() {
|
||||
result.push((index, textbox.rect));
|
||||
}
|
||||
} else {
|
||||
for _ in span.text.matches('\t') {
|
||||
placeholders.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
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 = horizontal_warichu_placeholders(paragraph, laid_out)
|
||||
.into_iter()
|
||||
.peekable();
|
||||
let mut boxes = Vec::new();
|
||||
for (index, span) in paragraph.children().iter().enumerate() {
|
||||
let length = span.text.chars().count();
|
||||
if let Some((_, rect)) = placeholders.next_if(|(span_index, _)| *span_index == index) {
|
||||
boxes.push((span, source_start, source_start + length, 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 = horizontal_warichu_placeholders(paragraph, laid_out);
|
||||
let warichu_ranges: Vec<_> = ranges.iter().filter(|range| range.warichu).collect();
|
||||
if placeholders.len() != warichu_ranges.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (range, (_, rect)) 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 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())
|
||||
})
|
||||
}
|
||||
|
||||
/// Top of the horizontal base em inside SkParagraph's typographic rectangle.
|
||||
/// Its tight rect can include substantial ascender-side padding; anchoring an
|
||||
/// over annotation to that rect's top therefore leaves a visible gap above CJK
|
||||
/// ink. The em is bottom-aligned to the rect, matching the baseline model used
|
||||
/// by the horizontal painter. The ruby showcase needs a 12 px clearance at
|
||||
/// 56 px: the original 6 px adjustment still left it 6 px too close to the
|
||||
/// kanji. Keep the 3/14-em value proportional at other text sizes.
|
||||
pub(crate) fn horizontal_annotation_over_top(rect: skia::Rect, font_size: f32) -> f32 {
|
||||
let font_size = font_size.max(0.0);
|
||||
rect.top.max(rect.bottom - font_size) - font_size * (3.0 / 14.0)
|
||||
}
|
||||
|
||||
/// 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_ink = mark_paragraph
|
||||
.get_rects_for_range(
|
||||
0..mark.len_utf16(),
|
||||
RectHeightStyle::Tight,
|
||||
RectWidthStyle::Tight,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|textbox| textbox.rect)
|
||||
.reduce(|mut rect, next| {
|
||||
rect.join(next);
|
||||
rect
|
||||
});
|
||||
let mark_width = mark_ink
|
||||
.as_ref()
|
||||
.map(|rect| rect.width())
|
||||
.unwrap_or_else(|| mark_paragraph.longest_line());
|
||||
let mark_ink_bottom = mark_ink
|
||||
.as_ref()
|
||||
.map(|rect| rect.bottom())
|
||||
.unwrap_or_else(|| 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 + horizontal_annotation_over_top(placement.rect, span.font_size)
|
||||
- mark_ink_bottom
|
||||
- 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)
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,17 @@ impl TextPaths {
|
||||
}
|
||||
|
||||
pub fn get_paths(&self, vertical_align: VerticalAlign) -> Vec<skia::Path> {
|
||||
if self.0.is_vertical() {
|
||||
return crate::shapes::text_vertical::vertical_text_paths(
|
||||
&self.0,
|
||||
vertical_align,
|
||||
true,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(path, _)| path)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let layout_width = self.0.get_width(self.bounds.width());
|
||||
let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None);
|
||||
let mut paragraphs: Vec<SkiaParagraph> = paragraph_builders
|
||||
|
||||
7089
render-wasm/src/shapes/text_vertical.rs
Normal file
@ -281,6 +281,9 @@ pub struct TextComposition {
|
||||
pub previous: String,
|
||||
pub current: String,
|
||||
pub is_composing: bool,
|
||||
/// Where the preview text starts; the preview replaced by each update is
|
||||
/// `[start, start + previous]`, independent of where the caret is.
|
||||
pub start: Option<TextPositionWithAffinity>,
|
||||
}
|
||||
|
||||
impl TextComposition {
|
||||
@ -289,6 +292,7 @@ impl TextComposition {
|
||||
previous: String::new(),
|
||||
current: String::new(),
|
||||
is_composing: false,
|
||||
start: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -299,6 +303,7 @@ impl TextComposition {
|
||||
self.is_composing = true;
|
||||
self.previous = String::new();
|
||||
self.current = String::new();
|
||||
self.start = None;
|
||||
true
|
||||
}
|
||||
|
||||
@ -316,22 +321,26 @@ impl TextComposition {
|
||||
return false;
|
||||
}
|
||||
self.is_composing = false;
|
||||
self.start = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Range the next preview replaces: the previous preview, or `selection`
|
||||
/// (the pre-composition selection) when there is none yet.
|
||||
pub fn get_selection(&self, selection: &TextSelection) -> TextSelection {
|
||||
if self.previous.is_empty() {
|
||||
let Some(start) = self.start.filter(|_| !self.previous.is_empty()) else {
|
||||
return *selection;
|
||||
}
|
||||
};
|
||||
|
||||
let focus = selection.focus;
|
||||
let previous_len = self.previous.chars().count();
|
||||
let anchor = TextPositionWithAffinity::new_downstream_affinity(
|
||||
focus.paragraph,
|
||||
focus.offset + previous_len,
|
||||
start.paragraph,
|
||||
start.offset + self.previous.chars().count(),
|
||||
);
|
||||
|
||||
TextSelection { anchor, focus }
|
||||
TextSelection {
|
||||
anchor,
|
||||
focus: start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -889,8 +898,24 @@ impl TextEditorState {
|
||||
TextDirection::LTR
|
||||
};
|
||||
|
||||
// In vertical-rl the physical arrow keys map onto logical navigation
|
||||
// differently: Up/Down walk characters along the column, and Left/Right
|
||||
// cross columns (columns advance right-to-left).
|
||||
let is_vertical = text_content.is_vertical();
|
||||
let direction = if is_vertical {
|
||||
match direction {
|
||||
CursorDirection::Backward => CursorDirection::LineAfter, // Left -> next column
|
||||
CursorDirection::Forward => CursorDirection::LineBefore, // Right -> prev column
|
||||
CursorDirection::LineBefore => CursorDirection::Backward, // Up -> prev char
|
||||
CursorDirection::LineAfter => CursorDirection::Forward, // Down -> next char
|
||||
other => other,
|
||||
}
|
||||
} else {
|
||||
direction
|
||||
};
|
||||
|
||||
// For horizontal navigation, swap Backward/Forward when in RTL text
|
||||
let adjusted_direction = if text_span_text_direction == TextDirection::RTL {
|
||||
let adjusted_direction = if !is_vertical && text_span_text_direction == TextDirection::RTL {
|
||||
match direction {
|
||||
CursorDirection::Backward => CursorDirection::Forward,
|
||||
CursorDirection::Forward => CursorDirection::Backward,
|
||||
@ -940,3 +965,35 @@ impl Default for TextEditorState {
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn caret(paragraph: usize, offset: usize) -> TextSelection {
|
||||
TextSelection::from_position_with_affinity(
|
||||
TextPositionWithAffinity::new_downstream_affinity(paragraph, offset),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_replaces_the_previous_preview_wherever_the_caret_is() {
|
||||
let mut composition = TextComposition::new();
|
||||
composition.start();
|
||||
|
||||
// First update: the pre-composition caret is the replaced range.
|
||||
composition.update("に");
|
||||
let replaced = composition.get_selection(&caret(0, 2));
|
||||
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 2));
|
||||
composition.start = Some(replaced.start());
|
||||
|
||||
// The caret now sits after the preview; the next update still
|
||||
// replaces the preview itself.
|
||||
composition.update("にほ");
|
||||
let replaced = composition.get_selection(&caret(0, 3));
|
||||
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 3));
|
||||
|
||||
composition.end();
|
||||
assert!(composition.start.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,3 +124,11 @@ pub extern "C" fn is_font_uploaded(
|
||||
let family = FontFamily::new(id, weight, font_style.into());
|
||||
get_resources().fonts.has_family(&family, is_emoji)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn mark_font_as_fallback(a: u32, b: u32, c: u32, d: u32, weight: u32, style: u8) {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let font_style = RawFontStyle::from(style);
|
||||
let family = FontFamily::new(id, weight, font_style.into());
|
||||
get_resources().fonts.mark_as_fallback(&family);
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@ use crate::{with_current_shape, with_current_shape_mut, with_state};
|
||||
use crate::error::Error;
|
||||
|
||||
pub mod helpers;
|
||||
mod japanese_layout;
|
||||
|
||||
pub use japanese_layout::*;
|
||||
|
||||
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
|
||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||
@ -103,6 +106,11 @@ pub struct RawParagraphData {
|
||||
text_direction: RawTextDirection,
|
||||
text_decoration: RawTextDecoration,
|
||||
text_transform: RawTextTransform,
|
||||
writing_mode: RawWritingMode,
|
||||
text_orientation: RawTextOrientation,
|
||||
// Explicit padding so the CLJS writer and this struct agree on a
|
||||
// 4-byte-aligned layout; always written as zero.
|
||||
_padding: [u8; 2],
|
||||
line_height: f32,
|
||||
letter_spacing: f32,
|
||||
}
|
||||
@ -131,6 +139,19 @@ pub struct RawTextSpan {
|
||||
text_decoration: RawTextDecoration,
|
||||
text_transform: RawTextTransform,
|
||||
text_direction: RawTextDirection,
|
||||
text_orientation: RawTextOrientation,
|
||||
text_combine_upright: RawTextCombineUpright,
|
||||
text_emphasis: RawTextEmphasis,
|
||||
warichu: RawWarichu,
|
||||
font_features: RawFontFeatures,
|
||||
annotation_clearance: RawAnnotationClearance,
|
||||
ruby_size: RawRubySize,
|
||||
ruby_align: RawRubyAlign,
|
||||
ruby_overhang: RawRubyOverhang,
|
||||
ruby_side: RawRubySide,
|
||||
// Explicit padding so the CLJS writer and this struct agree on a
|
||||
// 4-byte-aligned layout; always written as zero.
|
||||
_padding: [u8; 2],
|
||||
font_size: f32,
|
||||
line_height: f32,
|
||||
letter_spacing: f32,
|
||||
@ -139,6 +160,7 @@ pub struct RawTextSpan {
|
||||
font_family: [u8; 4],
|
||||
font_variant_id: [u32; 4], // TODO: maybe add RawUUID type
|
||||
text_length: u32,
|
||||
ruby_length: u32,
|
||||
fill_count: u32,
|
||||
fills: [RawFillData; MAX_TEXT_FILLS],
|
||||
}
|
||||
@ -177,7 +199,7 @@ impl From<RawTextSpan> for shapes::TextSpan {
|
||||
.map(|fill| fill.into())
|
||||
.collect();
|
||||
|
||||
Self::new(
|
||||
let mut span = Self::new(
|
||||
text,
|
||||
font_family,
|
||||
value.font_size,
|
||||
@ -189,7 +211,18 @@ impl From<RawTextSpan> for shapes::TextSpan {
|
||||
value.font_weight,
|
||||
uuid_from_u32(value.font_variant_id),
|
||||
fills,
|
||||
)
|
||||
);
|
||||
span.set_text_orientation(value.text_orientation.into());
|
||||
span.set_text_combine_upright(value.text_combine_upright.into());
|
||||
span.set_text_emphasis(value.text_emphasis.into());
|
||||
span.set_warichu(value.warichu.into());
|
||||
span.set_font_features(value.font_features.into());
|
||||
span.set_annotation_clearance(value.annotation_clearance.into());
|
||||
span.set_ruby_size(value.ruby_size.into());
|
||||
span.set_ruby_align(value.ruby_align.into());
|
||||
span.set_ruby_overhang(value.ruby_overhang.into());
|
||||
span.set_ruby_side(value.ruby_side.into());
|
||||
span
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,21 +263,31 @@ impl From<RawParagraph> for shapes::Paragraph {
|
||||
fn from(value: RawParagraph) -> Self {
|
||||
let mut spans = vec![];
|
||||
|
||||
// Layout: [<all span texts> <all span ruby texts>]. Annotation blobs
|
||||
// begin after all base text.
|
||||
let mut offset = 0;
|
||||
let mut ruby_offset: usize = value.spans.iter().map(|s| s.text_length as usize).sum();
|
||||
for raw_span in value.spans.into_iter() {
|
||||
let delta = raw_span.text_length as usize;
|
||||
let text_buffer = &value.text_buffer[offset..offset + delta];
|
||||
|
||||
let text_buffer = value.text_buffer.get(offset..offset + delta).unwrap_or(&[]);
|
||||
let ruby_delta = raw_span.ruby_length as usize;
|
||||
let ruby_buffer = value
|
||||
.text_buffer
|
||||
.get(ruby_offset..ruby_offset + ruby_delta)
|
||||
.unwrap_or(&[]);
|
||||
let mut span = shapes::TextSpan::from(raw_span);
|
||||
if !text_buffer.is_empty() {
|
||||
span.set_text(String::from_utf8_lossy(text_buffer).to_string());
|
||||
}
|
||||
|
||||
if !ruby_buffer.is_empty() {
|
||||
span.set_ruby(String::from_utf8_lossy(ruby_buffer).to_string());
|
||||
}
|
||||
spans.push(span);
|
||||
offset += delta;
|
||||
ruby_offset += ruby_delta;
|
||||
}
|
||||
|
||||
shapes::Paragraph::new(
|
||||
let mut paragraph = shapes::Paragraph::new(
|
||||
value.attrs.text_align.into(),
|
||||
value.attrs.text_direction.into(),
|
||||
value.attrs.text_decoration.into(),
|
||||
@ -252,7 +295,10 @@ impl From<RawParagraph> for shapes::Paragraph {
|
||||
value.attrs.line_height,
|
||||
value.attrs.letter_spacing,
|
||||
spans,
|
||||
)
|
||||
);
|
||||
paragraph.set_writing_mode(value.attrs.writing_mode.into());
|
||||
paragraph.set_text_orientation(value.attrs.text_orientation.into());
|
||||
paragraph
|
||||
}
|
||||
}
|
||||
|
||||
@ -445,3 +491,81 @@ pub extern "C" fn calculate_position_data() -> *mut u8 {
|
||||
});
|
||||
mem::write_vec(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes::{
|
||||
AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize,
|
||||
};
|
||||
|
||||
/// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16)
|
||||
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)
|
||||
/// attr bytes before the fills block. These sizes must move in
|
||||
/// lockstep with the struct layouts.
|
||||
#[test]
|
||||
fn raw_struct_sizes_match_cljs_writer() {
|
||||
const PARAGRAPH_ATTR_U8_SIZE: usize = 16;
|
||||
const SPAN_ATTR_U8_SIZE: usize = 80;
|
||||
assert_eq!(RAW_PARAGRAPH_DATA_SIZE, 4 + PARAGRAPH_ATTR_U8_SIZE);
|
||||
assert_eq!(
|
||||
RAW_SPAN_DATA_SIZE,
|
||||
SPAN_ATTR_U8_SIZE + MAX_TEXT_FILLS * std::mem::size_of::<RawFillData>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_text_combine_upright_counts_deserialize() {
|
||||
// Byte 5 of the span attr block; the counted digits variants map
|
||||
// onto their max run length.
|
||||
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
|
||||
bytes[5] = RawTextCombineUpright::Digits2 as u8;
|
||||
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
|
||||
assert_eq!(span.text_combine_upright.digits_max(), Some(2));
|
||||
|
||||
bytes[5] = RawTextCombineUpright::Digits3 as u8;
|
||||
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
|
||||
assert_eq!(span.text_combine_upright.digits_max(), Some(3));
|
||||
|
||||
bytes[5] = RawTextCombineUpright::Digits as u8;
|
||||
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
|
||||
assert_eq!(span.text_combine_upright.digits_max(), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_font_features_deserializes_from_reserved_span_byte() {
|
||||
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
|
||||
bytes[8] = RawFontFeatures::Vpal as u8;
|
||||
|
||||
let raw = RawTextSpan::from(bytes);
|
||||
let span = shapes::TextSpan::from(raw);
|
||||
|
||||
assert_eq!(span.font_features, FontFeatures::Vpal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_annotation_clearance_deserializes_from_reserved_span_byte() {
|
||||
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
|
||||
bytes[9] = RawAnnotationClearance::Auto as u8;
|
||||
|
||||
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
|
||||
|
||||
assert_eq!(span.annotation_clearance, AnnotationClearance::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_ruby_customization_deserializes_from_span_bytes() {
|
||||
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
|
||||
bytes[10] = RawRubySize::Quarter as u8;
|
||||
bytes[11] = RawRubyAlign::SpaceBetween as u8;
|
||||
bytes[12] = RawRubyOverhang::None as u8;
|
||||
bytes[13] = RawRubySide::Under as u8;
|
||||
|
||||
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
|
||||
|
||||
assert_eq!(span.ruby_size, RubySize::Quarter);
|
||||
assert_eq!(span.ruby_align, RubyAlign::SpaceBetween);
|
||||
assert_eq!(span.ruby_overhang, RubyOverhang::None);
|
||||
assert_eq!(span.ruby_side, RubySide::Under);
|
||||
}
|
||||
}
|
||||
|
||||
@ -822,6 +822,8 @@ pub fn split_paragraph_at_cursor(
|
||||
let text_direction = para.text_direction();
|
||||
let text_decoration = para.text_decoration();
|
||||
let text_transform = para.text_transform();
|
||||
let writing_mode = para.writing_mode();
|
||||
let text_orientation = para.text_orientation();
|
||||
let line_height = para.line_height();
|
||||
let letter_spacing = para.letter_spacing();
|
||||
|
||||
@ -837,7 +839,7 @@ pub fn split_paragraph_at_cursor(
|
||||
span.set_text(new_text);
|
||||
}
|
||||
|
||||
let new_para = crate::shapes::Paragraph::new(
|
||||
let mut new_para = crate::shapes::Paragraph::new(
|
||||
text_align,
|
||||
text_direction,
|
||||
text_decoration,
|
||||
@ -846,6 +848,8 @@ pub fn split_paragraph_at_cursor(
|
||||
letter_spacing,
|
||||
new_para_children,
|
||||
);
|
||||
new_para.set_writing_mode(writing_mode);
|
||||
new_para.set_text_orientation(text_orientation);
|
||||
|
||||
paragraphs.insert(cursor.paragraph + 1, new_para);
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -153,7 +153,9 @@ where
|
||||
};
|
||||
|
||||
let point = Point::new(x, y);
|
||||
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
|
||||
{
|
||||
apply(get_text_editor_state(), text_content, &position);
|
||||
}
|
||||
})
|
||||
@ -207,7 +209,9 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
|
||||
};
|
||||
let point = Point::new(x, y);
|
||||
get_text_editor_state().start_pointer_selection();
|
||||
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
|
||||
{
|
||||
get_text_editor_state().set_caret_from_position(&position);
|
||||
get_text_editor_state().update_styles(text_content);
|
||||
}
|
||||
@ -233,7 +237,9 @@ pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) {
|
||||
};
|
||||
let point = Point::new(x, y);
|
||||
get_text_editor_state().start_pointer_selection();
|
||||
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
|
||||
{
|
||||
get_text_editor_state().extend_selection_from_position(&position);
|
||||
// The click after pointerup would collapse the caret and drop the
|
||||
// selection we just extended.
|
||||
@ -267,7 +273,9 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
|
||||
{
|
||||
get_text_editor_state().extend_selection_from_position(&position);
|
||||
// We need this flag to prevent handling the click behavior
|
||||
// just after a pointerup event.
|
||||
@ -296,7 +304,9 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
|
||||
{
|
||||
get_text_editor_state().extend_selection_from_position(&position);
|
||||
get_text_editor_state().update_styles(text_content);
|
||||
}
|
||||
@ -331,7 +341,9 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
|
||||
{
|
||||
get_text_editor_state().set_caret_from_position(&position);
|
||||
}
|
||||
});
|
||||
@ -356,9 +368,12 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||
{
|
||||
if let Some(position) = text_content.get_caret_position_from_screen_coords(
|
||||
&point,
|
||||
&view_matrix,
|
||||
&shape_matrix,
|
||||
shape.vertical_align(),
|
||||
) {
|
||||
get_text_editor_state().set_caret_from_position(&position);
|
||||
}
|
||||
});
|
||||
@ -411,11 +426,16 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
|
||||
.get_selection(&get_text_editor_state().selection);
|
||||
text_helpers::delete_selection_range(text_content, &selection);
|
||||
|
||||
let cursor = get_text_editor_state().selection.focus;
|
||||
// Insert at the start of the replaced range (the pre-composition
|
||||
// selection or the previous preview), not at a possibly stale
|
||||
// selection focus, and advance the caret past the committed text.
|
||||
let cursor = selection.start();
|
||||
if let Some(new_cursor) =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
||||
{
|
||||
get_text_editor_state().selection.set_caret(new_cursor);
|
||||
} else {
|
||||
get_text_editor_state().selection.set_caret(cursor);
|
||||
}
|
||||
|
||||
text_content.layout.clear();
|
||||
@ -466,8 +486,13 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
||||
.get_selection(&get_text_editor_state().selection);
|
||||
text_helpers::delete_selection_range(text_content, &selection);
|
||||
|
||||
let cursor = get_text_editor_state().selection.focus;
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text);
|
||||
// The preview always starts where the replaced range did; the caret
|
||||
// goes after it, as in a native text field.
|
||||
let cursor = selection.start();
|
||||
get_text_editor_state().composition.start = Some(cursor);
|
||||
let caret =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text).unwrap_or(cursor);
|
||||
get_text_editor_state().selection.set_caret(caret);
|
||||
|
||||
text_content.layout.clear();
|
||||
|
||||
@ -482,6 +507,28 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Place the caret `offset` characters into the composition preview, where
|
||||
/// the IME's own cursor is (clause conversion, cursor moves inside the
|
||||
/// preedit). Clamped to the preview; ignored outside a composition.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_set_composition_cursor(offset: u32) {
|
||||
let editor_state = get_text_editor_state();
|
||||
if !editor_state.has_focus || !editor_state.composition.is_composing {
|
||||
return;
|
||||
}
|
||||
let Some(start) = editor_state.composition.start else {
|
||||
return;
|
||||
};
|
||||
let offset = (offset as usize).min(editor_state.composition.current.chars().count());
|
||||
editor_state
|
||||
.selection
|
||||
.set_caret(TextPositionWithAffinity::new_downstream_affinity(
|
||||
start.paragraph,
|
||||
start.offset + offset,
|
||||
));
|
||||
editor_state.reset_blink();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
|
||||
@ -662,6 +709,48 @@ pub extern "C" fn text_editor_move_cursor(
|
||||
// RENDERING & EXPORT
|
||||
// ============================================================================
|
||||
|
||||
/// Caret rectangle in page coordinates (the unrotated selrect space of the
|
||||
/// text overlay), reported independently of the blink phase. The frontend
|
||||
/// keeps the IME capture surface on it.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
|
||||
with_state!(state, {
|
||||
let editor_state = get_text_editor_state();
|
||||
if !editor_state.has_focus {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
|
||||
let Some(shape_id) = editor_state.active_shape_id else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
|
||||
// A preview update clears the layout; rebuild it so the rect is
|
||||
// current right away instead of after the next render.
|
||||
update_text_layout_if_needed(state, shape_id);
|
||||
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
|
||||
let Some(rect) = crate::render::text_editor::cursor_rect(editor_state, text_content, shape)
|
||||
else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
|
||||
let selrect = shape.selrect();
|
||||
let mut bytes = vec![0u8; 16];
|
||||
bytes[0..4].copy_from_slice(&(selrect.x() + rect.left()).to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&(selrect.y() + rect.top()).to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&rect.width().to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&rect.height().to_le_bytes());
|
||||
mem::write_bytes(bytes)
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
|
||||
with_state!(state, {
|
||||
|
||||