diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj
index 686884aeed..c0e147470f 100644
--- a/backend/src/app/rpc/commands/profile.clj
+++ b/backend/src/app/rpc/commands/profile.clj
@@ -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]
diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj
index 1bfb54aeee..011b74b126 100644
--- a/backend/test/backend_tests/rpc_profile_test.clj
+++ b/backend/test/backend_tests/rpc_profile_test.clj
@@ -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)
diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc
index ce45edd485..baffa8d91d 100644
--- a/common/src/app/common/files/changes.cljc
+++ b/common/src/app/common/files/changes.cljc
@@ -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
diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc
index 3317d91653..d948196886 100644
--- a/common/src/app/common/files/changes_builder.cljc
+++ b/common/src/app/common/files/changes_builder.cljc
@@ -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
diff --git a/common/src/app/common/fonts.cljs b/common/src/app/common/fonts.cljs
index 237a6b362b..48fda0c991 100644
--- a/common/src/app/common/fonts.cljs
+++ b/common/src/app/common/fonts.cljs
@@ -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"
diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj
index 0be29aacf9..f2817c772f 100644
--- a/common/src/app/common/render_wasm/enums.clj
+++ b/common/src/app/common/render_wasm/enums.clj
@@ -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"]
diff --git a/common/src/app/common/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs
index 02fe6632d2..69d75338ac 100644
--- a/common/src/app/common/render_wasm/serializers.cljs
+++ b/common/src/app/common/render_wasm/serializers.cljs
@@ -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]
diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs
index 1ffbebb054..131853b896 100644
--- a/common/src/app/common/render_wasm/text_content.cljs
+++ b/common/src/app/common/render_wasm/text_content.cljs
@@ -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")))
diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc
index c4ab29e3da..c64423088f 100644
--- a/common/src/app/common/types/file.cljc
+++ b/common/src/app/common/types/file.cljc
@@ -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"
diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc
index 122aae80a8..2244380c51 100644
--- a/common/src/app/common/types/shape/text.cljc
+++ b/common/src/app/common/types/shape/text.cljc
@@ -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]]])
diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc
index 4e53c9b753..4ef1b6f229 100644
--- a/common/src/app/common/types/text.cljc
+++ b/common/src/app/common/types/text.cljc
@@ -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))
diff --git a/common/src/app/common/types/text/japanese_layout.cljc b/common/src/app/common/types/text/japanese_layout.cljc
new file mode 100644
index 0000000000..b660037a00
--- /dev/null
+++ b/common/src/app/common/types/text/japanese_layout.cljc
@@ -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)))
diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc
index 935ff092fb..0d6dbc86f6 100644
--- a/common/test/common_tests/files_changes_test.cljc
+++ b/common/test/common_tests/files_changes_test.cljc
@@ -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)
diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs
index 77e53a9e6b..05151038d4 100644
--- a/exporter/src/app/wasm/render.cljs
+++ b/exporter/src/app/wasm/render.cljs
@@ -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)))))
diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js
index 5372d7ee40..97c4518f70 100644
--- a/frontend/packages/draft-js/index.js
+++ b/frontend/packages/draft-js/index.js
@@ -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);
diff --git a/frontend/resources/images/icons/text-combine-upright-all.svg b/frontend/resources/images/icons/text-combine-upright-all.svg
new file mode 100644
index 0000000000..594b7f49fd
--- /dev/null
+++ b/frontend/resources/images/icons/text-combine-upright-all.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-combine-upright-digits.svg b/frontend/resources/images/icons/text-combine-upright-digits.svg
new file mode 100644
index 0000000000..3e367a3fd3
--- /dev/null
+++ b/frontend/resources/images/icons/text-combine-upright-digits.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-combine-upright-none.svg b/frontend/resources/images/icons/text-combine-upright-none.svg
new file mode 100644
index 0000000000..f54cf6f1e3
--- /dev/null
+++ b/frontend/resources/images/icons/text-combine-upright-none.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-orientation-mixed.svg b/frontend/resources/images/icons/text-orientation-mixed.svg
new file mode 100644
index 0000000000..72cd592478
--- /dev/null
+++ b/frontend/resources/images/icons/text-orientation-mixed.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-orientation-upright.svg b/frontend/resources/images/icons/text-orientation-upright.svg
new file mode 100644
index 0000000000..67d241b2bb
--- /dev/null
+++ b/frontend/resources/images/icons/text-orientation-upright.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/warichu-none.svg b/frontend/resources/images/icons/warichu-none.svg
new file mode 100644
index 0000000000..7ede0d3d0a
--- /dev/null
+++ b/frontend/resources/images/icons/warichu-none.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/warichu.svg b/frontend/resources/images/icons/warichu.svg
new file mode 100644
index 0000000000..3348550253
--- /dev/null
+++ b/frontend/resources/images/icons/warichu.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/writing-mode-horizontal.svg b/frontend/resources/images/icons/writing-mode-horizontal.svg
new file mode 100644
index 0000000000..204e9190e7
--- /dev/null
+++ b/frontend/resources/images/icons/writing-mode-horizontal.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/writing-mode-vertical.svg b/frontend/resources/images/icons/writing-mode-vertical.svg
new file mode 100644
index 0000000000..e89a00a562
--- /dev/null
+++ b/frontend/resources/images/icons/writing-mode-vertical.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index b04bf5394b..a39d8db10a 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -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
diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs
index 5386fef3df..ba53c76998 100644
--- a/frontend/src/app/main/data/workspace/modifiers.cljs
+++ b/frontend/src/app/main/data/workspace/modifiers.cljs
@@ -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))))
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index 4ddaf0eded..cfea1dbf31 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -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))]
diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs
index e5bcaa1c8a..f10680696c 100644
--- a/frontend/src/app/main/data/workspace/transforms.cljs
+++ b/frontend/src/app/main/data/workspace/transforms.cljs
@@ -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)))
diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs
index 9ff79af325..d24e7a12af 100644
--- a/frontend/src/app/main/data/workspace/wasm_text.cljs
+++ b/frontend/src/app/main/data/workspace/wasm_text.cljs
@@ -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]
diff --git a/frontend/src/app/main/ui/css_cursors.cljs b/frontend/src/app/main/ui/css_cursors.cljs
index 8541d7bedf..91f30c1941 100644
--- a/frontend/src/app/main/ui/css_cursors.cljs
+++ b/frontend/src/app/main/ui/css_cursors.cljs
@@ -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]
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index da902d690e..4f70416dad 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -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"
diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs
index 9e62720d02..2ae3c6f77d 100644
--- a/frontend/src/app/main/ui/icons.cljs
+++ b/frontend/src/app/main/ui/icons.cljs
@@ -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"
diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs
index c4d711ba79..c329661d56 100644
--- a/frontend/src/app/main/ui/settings/options.cljs
+++ b/frontend/src/app/main/ui/settings/options.cljs
@@ -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}])]]))
diff --git a/frontend/src/app/main/ui/settings/options.scss b/frontend/src/app/main/ui/settings/options.scss
index cdae1e185b..af13b89b95 100644
--- a/frontend/src/app/main/ui/settings/options.scss
+++ b/frontend/src/app/main/ui/settings/options.scss
@@ -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;
diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs
index 13927d2fce..06363f00e7 100644
--- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs
@@ -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`
diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs
index 299ee49de7..29d394f121 100644
--- a/frontend/src/app/main/ui/shapes/text/html_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs
@@ -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)
diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs
index 657857f667..2aa7103f88 100644
--- a/frontend/src/app/main/ui/shapes/text/styles.cljs
+++ b/frontend/src/app/main/ui/shapes/text/styles.cljs
@@ -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 ` 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 " " 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")})
diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
index a2fbdd1ea5..70f87820f2 100644
--- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
@@ -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)])]))]]))
diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs
index 5a64cf76c9..10ec4b92a8 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/main_menu.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs
index 770a1ebec7..705fdf666f 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs
@@ -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"))}
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
index 8ed1cb50fe..d33f35cb3a 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
@@ -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"}]]]]]))
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss
index 5659b0646e..a28d4ddcea 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss
+++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss
@@ -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;
}
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs
index 3869f62a03..bd32ee5c17 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
index 61ee90bf07..d6008cd85e 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
@@ -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]])])]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
new file mode 100644
index 0000000000..64ec971ee5
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
@@ -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])]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss
new file mode 100644
index 0000000000..e9ddb3c921
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss
@@ -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);
+}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
index 3158f59be2..e58767127a 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
@@ -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}]]))
-
diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
index a4c378fa8d..2104e9f1dc 100644
--- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
@@ -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))))))
diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs
index 66b2f2fb88..0068c0cb76 100644
--- a/frontend/src/app/plugins/text.cljs
+++ b/frontend/src/app/plugins/text.cljs
@@ -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)})))
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index 59ad963352..0a4ce78ea3 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -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)
-
-
diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs
index 0b438c5e20..0d528c0f8e 100644
--- a/frontend/src/app/render_wasm/api/fonts.cljs
+++ b/frontend/src/app/render_wasm/api/fonts.cljs
@@ -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.
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index ed82300c09..b2071bea38 100644
--- a/frontend/src/app/render_wasm/text_editor.cljs
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -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)
diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs
index 3340e040e2..6cc2cadea1 100644
--- a/frontend/src/app/util/debug.cljs
+++ b/frontend/src/app/util/debug.cljs
@@ -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
diff --git a/frontend/src/app/util/text/writing_mode.cljs b/frontend/src/app/util/text/writing_mode.cljs
new file mode 100644
index 0000000000..0f7624d4bb
--- /dev/null
+++ b/frontend/src/app/util/text/writing_mode.cljs
@@ -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)))
diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs
index fb10dfef85..80cb4a9852 100644
--- a/frontend/test/frontend_tests/code_gen_style_test.cljs
+++ b/frontend/test/frontend_tests/code_gen_style_test.cljs
@@ -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 `"
+ (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 " (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 " markup))
"multi-select must NOT emit multiple 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 #"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)
diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs
new file mode 100644
index 0000000000..467b5b5a99
--- /dev/null
+++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs
@@ -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"))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index 6c0c436df0..56f5c475d0 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -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
diff --git a/frontend/test/frontend_tests/ui/css_cursors_test.cljs b/frontend/test/frontend_tests/ui/css_cursors_test.cljs
new file mode 100644
index 0000000000..5478bca52c
--- /dev/null
+++ b/frontend/test/frontend_tests/ui/css_cursors_test.cljs
@@ -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))))
diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs
new file mode 100644
index 0000000000..abf932c49d
--- /dev/null
+++ b/frontend/test/frontend_tests/ui/text_options_test.cljs
@@ -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 ">日"))
+ (t/is (not (str/includes? markup " {
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();
diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md
index 5c1e07dd74..5917c9ab65 100644
--- a/plugins/CHANGELOG.md
+++ b/plugins/CHANGELOG.md
@@ -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
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts
index 6b8ed2e448..2b37f98b83 100644
--- a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts
+++ b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts
@@ -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);
diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts
index b7dbcc02fc..ec107ccced 100644
--- a/plugins/libs/plugin-types/index.d.ts
+++ b/plugins/libs/plugin-types/index.d.ts
@@ -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.
*/
diff --git a/render-wasm/src/fonts/notosansjp-vmtx-test.ttf b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf
new file mode 100644
index 0000000000..5b36929f63
Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf differ
diff --git a/render-wasm/src/fonts/notosansjp-vpal-test.ttf b/render-wasm/src/fonts/notosansjp-vpal-test.ttf
new file mode 100644
index 0000000000..e1fd58e08f
Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vpal-test.ttf differ
diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs
index 26e149d97c..b9045f1d89 100644
--- a/render-wasm/src/globals.rs
+++ b/render-wasm/src/globals.rs
@@ -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));
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index f95f324bfb..a670eb7b74 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -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
}
_ => {
diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs
index 78ae886064..822b433988 100644
--- a/render-wasm/src/render/fonts.rs
+++ b/render-wasm/src/render/fonts.rs
@@ -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));
+ }
}
diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs
index 727073f18e..7868751e6c 100644
--- a/render-wasm/src/render/options.rs
+++ b/render-wasm/src/render/options.rs
@@ -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
+ }
}
diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs
index 19e299b31b..0ec6603710 100644
--- a/render-wasm/src/render/text.rs
+++ b/render-wasm/src/render/text.rs
@@ -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 = 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 =
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 {
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);
}
diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs
index d1825aeca8..8afea390f6 100644
--- a/render-wasm/src/render/text_editor.rs
+++ b/render-wasm/src/render/text_editor.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
- 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;
diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs
index c04be04683..e129927b74 100644
--- a/render-wasm/src/render/vector.rs
+++ b/render-wasm/src/render/vector.rs
@@ -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();
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 940e3d724b..b3d4fad799 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -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 {
diff --git a/render-wasm/src/shapes/gpos_vpal.rs b/render-wasm/src/shapes/gpos_vpal.rs
new file mode 100644
index 0000000000..28670ad911
--- /dev/null
+++ b/render-wasm/src/shapes/gpos_vpal.rs
@@ -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 {
+ Some(u16::from_be_bytes([
+ *data.get(offset)?,
+ *data.get(offset + 1)?,
+ ]))
+}
+
+fn read_i16(data: &[u8], offset: usize) -> Option {
+ read_u16(data, offset).map(|v| v as i16)
+}
+
+fn read_u32(data: &[u8], offset: usize) -> Option {
+ 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> {
+ 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 {
+ 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,
+) -> 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> {
+ 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 {
+ // 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());
+ }
+}
diff --git a/render-wasm/src/shapes/japanese.rs b/render-wasm/src/shapes/japanese.rs
new file mode 100644
index 0000000000..7038400083
--- /dev/null
+++ b/render-wasm/src/shapes/japanese.rs
@@ -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);
+ }
+}
diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/kinsoku.rs
new file mode 100644
index 0000000000..7130744dfd
--- /dev/null
+++ b/render-wasm/src/shapes/kinsoku.rs
@@ -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,
+}
+
+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>],
+) -> Option<(Vec, OffsetMap)> {
+ let mut inserted: Vec = Vec::new();
+ let mut out: Vec = Vec::with_capacity(span_texts.len());
+ let mut prev: Option = 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 {
+ texts.iter().map(|t| t.to_string()).collect()
+ }
+
+ fn apply(texts: &[&str]) -> (Vec, 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 = shifted.encode_utf16().collect();
+ let original_units: Vec = 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 {
+ // 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"
+ );
+ }
+}
diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs
index 1898ed7276..84fbf8bccc 100644
--- a/render-wasm/src/shapes/modifiers.rs
+++ b/render-wasm/src/shapes/modifiers.rs
@@ -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);
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 8122ee21be..803b331485 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -26,9 +26,10 @@ use std::cell::Cell;
use std::collections::HashSet;
use std::rc::Rc;
+use super::text_japanese::*;
use super::FontFamily;
use crate::math::Point;
-use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign};
+use crate::shapes::{self, kinsoku, merge_fills, Shape, Type, VerticalAlign};
use crate::utils::{get_fallback_fonts, get_font_collection};
use crate::Uuid;
@@ -557,6 +558,29 @@ impl TextContent {
self.size.normalized_line_height
}
+ /// Writing mode is a whole-shape property: the first paragraph
+ /// decides the flow for all of them.
+ pub fn is_vertical(&self) -> bool {
+ self.paragraphs
+ .first()
+ .is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl)
+ }
+
+ /// Vertical writing and horizontal ruby, warichu and emphasis marks are
+ /// painted by the full text pass, not from the cached paint layout.
+ pub fn can_paint_from_layout_cache(&self) -> bool {
+ !self.is_vertical()
+ && !self
+ .paragraphs
+ .iter()
+ .flat_map(|p| p.children())
+ .any(|span| {
+ span.warichu
+ || !span.ruby.trim().is_empty()
+ || span.text_emphasis.mark_char().is_some()
+ })
+ }
+
pub fn grow_type(&self) -> GrowType {
self.grow_type
}
@@ -637,7 +661,10 @@ impl TextContent {
// AutoWidth paragraphs are laid out with f32::MAX, so line metrics
// (line.left) reflect alignment within that huge width and are
// unusable for tight bounds. Fall back to content_rect.
- if self.grow_type() == GrowType::AutoWidth {
+ // Vertical writing bounds come from the vertical pass through
+ // content_rect; the skparagraph line metrics below describe the
+ // unused horizontal layout.
+ if self.grow_type() == GrowType::AutoWidth || self.is_vertical() {
return self.content_rect(selrect, valign);
}
@@ -720,6 +747,17 @@ impl TextContent {
}
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
+ // Vertical content anchors to the shape's right edge and always
+ // aligns to the top (vertical-align along columns is deferred).
+ if self.is_vertical() {
+ let (width, height) = if self.grow_type() == GrowType::AutoWidth {
+ (self.size.width, self.size.height)
+ } else {
+ (selrect.width(), selrect.height())
+ };
+ return Rect::from_xywh(selrect.right() - width, selrect.y(), width, height);
+ }
+
let x = selrect.x();
let mut y = selrect.y();
@@ -754,7 +792,26 @@ impl TextContent {
pub fn get_caret_position_from_shape_coords(
&self,
point: &Point,
+ vertical_align: VerticalAlign,
) -> Option {
+ // Vertical writing: resolve through the vertical pass. The point
+ // arrives selrect-local; the content block is right-anchored.
+ if self.is_vertical() {
+ let bounds = self.bounds();
+ let max_height = super::text_vertical::wrap_height(self, bounds.height());
+ let layout = super::text_vertical::layout_from_content(self, max_height);
+ let cx = point.x
+ - super::text_vertical::block_axis_offset(
+ bounds.width(),
+ layout.width,
+ vertical_align,
+ );
+ let (paragraph, offset) = super::text_vertical::caret_from_point(&layout, cx, point.y)?;
+ return Some(TextPositionWithAffinity::new_downstream_affinity(
+ paragraph, offset,
+ ));
+ }
+
let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
@@ -777,12 +834,25 @@ impl TextContent {
// the paragraph's top-left. For multi-paragraph or wrapped text, each
// paragraph has its own origin; subtract start_y so we pass paragraph-local coords.
let para_pt = Point::new(point.x, point.y - start_y);
+ if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
+ if let Some(original_position) =
+ horizontal_warichu_hit_test(paragraph, layout_paragraph, para_pt)
+ {
+ return Some(TextPositionWithAffinity::new_downstream_affinity(
+ paragraph_index,
+ original_position,
+ ));
+ }
+ }
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y));
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
- // Skia reports UTF-16 code units, the model counts characters.
- let offset =
- paragraph.utf16_offset_to_char(position_with_affinity.position as usize);
+ // Skia reports builder-text UTF-16 offsets (transformed and
+ // kinsoku-shifted); the model counts source characters.
+ let offset = horizontal_builder_to_source(
+ paragraph,
+ position_with_affinity.position as usize,
+ );
return Some(TextPositionWithAffinity::new(
position_with_affinity,
@@ -820,9 +890,10 @@ impl TextContent {
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
+ vertical_align: VerticalAlign,
) -> Option {
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
- self.get_caret_position_from_shape_coords(&shape_rel_point)
+ self.get_caret_position_from_shape_coords(&shape_rel_point, vertical_align)
}
/// Builds the ParagraphBuilders necessary to render
@@ -914,7 +985,8 @@ impl TextContent {
}
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
let mut has_text = false;
- for span in paragraph.children() {
+ let (span_texts, _) = paragraph.layout_span_texts();
+ for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
let mut style = span.to_style(
&self.bounds(),
@@ -972,12 +1044,11 @@ impl TextContent {
fill_layer,
)
};
- let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style);
- add_text_with_tabs(&mut builder, &text, span.font_size);
+ add_horizontal_span(&mut builder, span, text, &text_style, fonts);
}
if !has_text {
builder.add_text(" ");
@@ -1206,6 +1277,32 @@ impl TextContent {
}
}
+ // Vertical writing sizes come from the vertical pass. Auto-width
+ // fits both axes without wrapping. Auto-height keeps the shape height
+ // as its wrap budget and grows width as columns advance right-to-left.
+ // Fixed keeps both shape dimensions.
+ if self.is_vertical() {
+ match self.grow_type() {
+ GrowType::AutoWidth => {
+ let max_height = super::text_vertical::wrap_height(self, selrect.height());
+ let (width, height) = super::text_vertical::measure_content(self, max_height);
+ self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
+ self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
+ self.size.max_width = self.size.width;
+ }
+ GrowType::AutoHeight => {
+ let max_height = super::text_vertical::wrap_height(self, selrect.height());
+ let (width, _) = super::text_vertical::measure_content(self, max_height);
+ self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
+ self.size.height = selrect.height();
+ self.size.max_width = self.size.width;
+ }
+ GrowType::Fixed => {
+ self.size.set_size(selrect.width(), selrect.height());
+ }
+ }
+ }
+
if self.is_empty() {
let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect);
self.size.width = placeholder_width;
@@ -1314,6 +1411,20 @@ impl TextContent {
let result = matrix.map_point((x_pos, y_pos));
+ // Vertical writing: hit-test against the laid-out cells directly
+ // (absolute coordinates, right-anchored to the selrect).
+ if self.is_vertical() {
+ let max_height = super::text_vertical::wrap_height(self, shape.selrect.height());
+ let layout = super::text_vertical::layout_from_content(self, max_height);
+ return super::text_vertical::intersects(
+ &layout,
+ &shape.selrect,
+ shape.vertical_align(),
+ result.x,
+ result.y,
+ );
+ }
+
// Change coords to content space
let x_pos = result.x - rect.x();
let y_pos = result.y - rect.y();
@@ -1374,6 +1485,8 @@ pub struct Paragraph {
text_direction: TextDirection,
text_decoration: Option,
text_transform: Option,
+ writing_mode: WritingMode,
+ text_orientation: TextOrientation,
line_height: f32,
letter_spacing: f32,
children: Vec,
@@ -1386,6 +1499,8 @@ impl Default for Paragraph {
text_direction: TextDirection::LTR,
text_decoration: None,
text_transform: None,
+ writing_mode: WritingMode::default(),
+ text_orientation: TextOrientation::default(),
line_height: 1.0,
letter_spacing: 0.0,
children: vec![],
@@ -1409,12 +1524,30 @@ impl Paragraph {
text_direction,
text_decoration,
text_transform,
+ writing_mode: WritingMode::default(),
+ text_orientation: TextOrientation::default(),
line_height,
letter_spacing,
children,
}
}
+ pub fn writing_mode(&self) -> WritingMode {
+ self.writing_mode
+ }
+
+ pub fn set_writing_mode(&mut self, writing_mode: WritingMode) {
+ self.writing_mode = writing_mode;
+ }
+
+ pub fn text_orientation(&self) -> TextOrientation {
+ self.text_orientation
+ }
+
+ pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) {
+ self.text_orientation = text_orientation;
+ }
+
pub fn children(&self) -> &[TextSpan] {
&self.children
}
@@ -1429,13 +1562,6 @@ impl Paragraph {
}
}
- fn char_count(&self) -> usize {
- self.children
- .iter()
- .map(|span| span.text.chars().count())
- .sum()
- }
-
/// Translate a character offset into the UTF-16 offset Skia indexes by.
/// Both differ on astral-plane characters (emoji) and whenever a text
/// transform changes the length of the laid out text (`ß` -> `SS`).
@@ -1455,27 +1581,6 @@ impl Paragraph {
utf16
}
- /// Translate a UTF-16 offset coming from Skia into a character offset.
- /// An offset inside a character rounds up, so it never splits a glyph.
- pub fn utf16_offset_to_char(&self, utf16_offset: usize) -> usize {
- let (mut low, mut high) = (0, self.char_count());
- while low < high {
- let middle = (low + high) / 2;
- if self.char_offset_to_utf16(middle) < utf16_offset {
- low = middle + 1;
- } else {
- high = middle;
- }
- }
- low
- }
-
- /// UTF-16 length of the character at `char_offset`, so a caret range covers
- /// the whole glyph.
- pub fn char_utf16_len_at(&self, char_offset: usize) -> usize {
- self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset)
- }
-
pub fn line_height(&self) -> f32 {
self.line_height
}
@@ -1500,6 +1605,16 @@ impl Paragraph {
self.text_transform
}
+ /// Span texts as fed to the paragraph builders: text-transform applied,
+ /// Japanese spacing normalized, and kinsoku break suppressions inserted,
+ /// plus the map between original and builder-text UTF-16 offsets. Every
+ /// consumer of laid-out offsets must translate through the map. The layout
+ /// transform is skipped under letter-spacing, where skparagraph would add
+ /// letter spacing to synthetic layout characters.
+ pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) {
+ layout_span_texts(self)
+ }
+
pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default();
@@ -1523,6 +1638,7 @@ impl Paragraph {
/// Capitalize the first letter of each word, preserving all original whitespace.
/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after
/// any non-letter character (whitespace, punctuation, digits, symbols).
+#[cfg(test)]
fn capitalize_words(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut capitalize_next = true;
@@ -1562,6 +1678,7 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size:
/// Filter control characters below U+0020, preserving tabs and line breaks.
/// Browser-dependent: Firefox drops them, others replace with space.
+#[cfg(test)]
fn process_ignored_chars(text: &str, browser: u8) -> String {
text.chars()
.filter_map(|c| {
@@ -1581,6 +1698,94 @@ fn process_ignored_chars(text: &str, browser: u8) -> String {
.collect()
}
+/// Text after browser filtering and CSS text transformation, plus the source
+/// UTF-16 range that produced each transformed Unicode scalar. A single source
+/// scalar can produce several output scalars (for example `ß` uppercases to
+/// `SS`); keeping that ownership lets vertical layout wrap and export the
+/// transformed glyphs as one source-text unit.
+#[derive(Debug, Clone, PartialEq)]
+pub struct AppliedTextTransform {
+ pub text: String,
+ pub(crate) source_ranges: Vec<(std::ops::Range, std::ops::Range)>,
+}
+
+impl AppliedTextTransform {
+ pub fn source_utf16_range(
+ &self,
+ transformed: std::ops::Range,
+ ) -> std::ops::Range {
+ let mut ranges = self.source_ranges.iter().filter_map(|(output, source)| {
+ (output.start < transformed.end && output.end > transformed.start)
+ .then_some(source.clone())
+ });
+ let Some(first) = ranges.next() else {
+ return 0..0;
+ };
+ ranges.fold(first, |range, source| {
+ range.start.min(source.start)..range.end.max(source.end)
+ })
+ }
+}
+
+fn apply_text_transform_with_source_ranges(
+ text: &str,
+ browser: u8,
+ transform: Option,
+) -> AppliedTextTransform {
+ let mut output = String::with_capacity(text.len());
+ let mut source_ranges = Vec::new();
+ let mut source_utf16 = 0usize;
+ let mut output_utf16 = 0usize;
+ let mut capitalize_next = true;
+
+ for source_char in text.chars() {
+ let source_start = source_utf16;
+ source_utf16 += source_char.len_utf16();
+
+ let processed = if source_char == '\n'
+ || source_char == '\r'
+ || source_char == '\u{2028}'
+ || source_char == '\u{2029}'
+ || source_char >= '\u{0020}'
+ {
+ Some(source_char)
+ } else if browser == Browser::Firefox as u8 {
+ None
+ } else {
+ Some(' ')
+ };
+ let Some(processed) = processed else {
+ continue;
+ };
+
+ let transformed: String = match transform {
+ Some(TextTransform::Uppercase) => processed.to_uppercase().collect(),
+ Some(TextTransform::Lowercase) => processed.to_lowercase().collect(),
+ Some(TextTransform::Capitalize) if processed.is_alphabetic() && capitalize_next => {
+ capitalize_next = false;
+ processed.to_uppercase().collect()
+ }
+ Some(TextTransform::Capitalize) => {
+ capitalize_next = !processed.is_alphabetic();
+ processed.to_string()
+ }
+ None => processed.to_string(),
+ };
+
+ for transformed_char in transformed.chars() {
+ let transformed_start = output_utf16;
+ output_utf16 += transformed_char.len_utf16();
+ source_ranges.push((transformed_start..output_utf16, source_start..source_utf16));
+ output.push(transformed_char);
+ }
+ }
+
+ AppliedTextTransform {
+ text: output,
+ source_ranges,
+ }
+}
+
#[derive(Debug, PartialEq, Clone)]
pub struct TextSpan {
pub text: String,
@@ -1593,6 +1798,21 @@ pub struct TextSpan {
pub text_decoration: Option,
pub text_transform: Option,
pub text_direction: TextDirection,
+ pub text_orientation: TextOrientation,
+ pub text_combine_upright: TextCombineUpright,
+ /// Emphasis mark (圏点 / bouten) applied to each base character.
+ pub text_emphasis: TextEmphasis,
+ /// Ruby (furigana) annotation for this span; empty means no ruby.
+ pub ruby: String,
+ pub ruby_size: RubySize,
+ pub ruby_align: RubyAlign,
+ pub ruby_overhang: RubyOverhang,
+ pub ruby_side: RubySide,
+ /// Warichu (割注): render the span as two half-size lines stacked inline
+ /// within one column position of the vertical flow.
+ pub warichu: bool,
+ pub font_features: FontFeatures,
+ pub annotation_clearance: AnnotationClearance,
pub fills: Vec,
pub paragraph_position: u32,
pub span_position: u32,
@@ -1631,6 +1851,17 @@ impl TextSpan {
text_decoration,
text_transform,
text_direction,
+ text_orientation: TextOrientation::default(),
+ text_combine_upright: TextCombineUpright::default(),
+ text_emphasis: TextEmphasis::default(),
+ ruby: String::default(),
+ ruby_size: RubySize::default(),
+ ruby_align: RubyAlign::default(),
+ ruby_overhang: RubyOverhang::default(),
+ ruby_side: RubySide::default(),
+ warichu: false,
+ font_features: FontFeatures::default(),
+ annotation_clearance: AnnotationClearance::default(),
font_weight,
font_variant_id,
fills,
@@ -1648,6 +1879,50 @@ impl TextSpan {
self.span_position = span;
}
+ pub fn set_ruby(&mut self, ruby: String) {
+ self.ruby = ruby;
+ }
+
+ pub fn set_ruby_size(&mut self, value: RubySize) {
+ self.ruby_size = value;
+ }
+
+ pub fn set_ruby_align(&mut self, value: RubyAlign) {
+ self.ruby_align = value;
+ }
+
+ pub fn set_ruby_overhang(&mut self, value: RubyOverhang) {
+ self.ruby_overhang = value;
+ }
+
+ pub fn set_ruby_side(&mut self, value: RubySide) {
+ self.ruby_side = value;
+ }
+
+ pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) {
+ self.text_orientation = text_orientation;
+ }
+
+ pub fn set_text_combine_upright(&mut self, text_combine_upright: TextCombineUpright) {
+ self.text_combine_upright = text_combine_upright;
+ }
+
+ pub fn set_text_emphasis(&mut self, text_emphasis: TextEmphasis) {
+ self.text_emphasis = text_emphasis;
+ }
+
+ pub fn set_warichu(&mut self, warichu: bool) {
+ self.warichu = warichu;
+ }
+
+ pub fn set_font_features(&mut self, font_features: FontFeatures) {
+ self.font_features = font_features;
+ }
+
+ pub fn set_annotation_clearance(&mut self, clearance: AnnotationClearance) {
+ self.annotation_clearance = clearance;
+ }
+
pub fn to_style(
&self,
content_bounds: &Rect,
@@ -1691,7 +1966,13 @@ impl TextSpan {
merge_fills(&self.fills, *content_bounds)
};
- let max_line_height = f32::max(paragraph_line_height, self.line_height);
+ let annotation_layers = if self.annotation_clearance.is_auto() {
+ usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none())
+ } else {
+ 0
+ };
+ let max_line_height =
+ f32::max(paragraph_line_height, self.line_height) + annotation_layers as f32 * 0.5;
style.set_height(max_line_height);
style.set_height_override(true);
style.set_foreground_paint(&paint);
@@ -1713,6 +1994,11 @@ impl TextSpan {
style.set_font_families(&font_families);
style.set_font_size(self.font_size);
style.set_letter_spacing(self.letter_spacing);
+ match self.font_features {
+ FontFeatures::None => {}
+ FontFeatures::Palt => style.add_font_feature("palt", 1),
+ FontFeatures::Vpal => style.add_font_feature("vpal", 1),
+ }
style.set_half_leading(true);
style
@@ -1756,13 +2042,20 @@ impl TextSpan {
}
pub fn transform_text(&self, text: &str) -> String {
- let text = process_ignored_chars(text, crate::globals::current_browser());
- match self.text_transform {
- Some(TextTransform::Uppercase) => text.to_uppercase(),
- Some(TextTransform::Lowercase) => text.to_lowercase(),
- Some(TextTransform::Capitalize) => capitalize_words(&text),
- None => text,
- }
+ apply_text_transform_with_source_ranges(
+ text,
+ crate::globals::current_browser(),
+ self.text_transform,
+ )
+ .text
+ }
+
+ pub fn apply_text_transform_with_source_ranges(&self) -> AppliedTextTransform {
+ apply_text_transform_with_source_ranges(
+ &self.text,
+ crate::globals::current_browser(),
+ self.text_transform,
+ )
}
pub fn apply_text_transform(&self) -> String {
@@ -1797,6 +2090,7 @@ pub struct PositionData {
#[derive(Debug)]
pub struct ParagraphLayout {
pub paragraph: skia::textlayout::Paragraph,
+ pub source_paragraph: usize,
pub x: f32,
pub y: f32,
pub decorations: Vec,
@@ -1808,7 +2102,7 @@ pub struct TextLayoutData {
pub paragraphs: Vec,
}
-fn direction_to_int(direction: TextDirection) -> u32 {
+pub(crate) fn direction_to_int(direction: TextDirection) -> u32 {
match direction {
TextDirection::RTL => 0,
TextDirection::LTR => 1,
@@ -1874,6 +2168,7 @@ pub fn calculate_text_layout_data(
.unwrap_or_default();
paragraph_layouts.push(ParagraphLayout {
paragraph: skia_paragraph,
+ source_paragraph: i,
x,
y: y_accum,
decorations,
@@ -1884,21 +2179,41 @@ pub fn calculate_text_layout_data(
// Calculate position data from paragraph_layouts
if !skip_position_data {
- for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() {
+ for para_layout in ¶graph_layouts {
+ let paragraph_index = para_layout.source_paragraph;
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
- let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
- let mut cur = 0;
- for (span_index, span) in text_para.children().iter().enumerate() {
- let text: String = span.apply_text_transform();
- let text_len = text.encode_utf16().count();
- span_ranges.push((cur, cur + text_len, span_index));
- cur += text_len;
- }
- for (start, end, span_index) in span_ranges {
+ // Ranges are in the builder-text (kinsoku-shifted)
+ // space; exported positions are translated back to
+ // original span-relative offsets through the map.
+ let (_, offset_map) = text_para.layout_span_texts();
+ let span_ranges = horizontal_span_ranges(text_para);
+ let placeholder_rects = para_layout.paragraph.get_rects_for_placeholders();
+ let mut placeholder_index = 0usize;
+ for range in span_ranges {
+ if range.warichu {
+ if let Some(textbox) = placeholder_rects.get(placeholder_index) {
+ let mut rect = textbox.rect;
+ rect.offset((x, current_y));
+ position_data.push(PositionData {
+ paragraph: paragraph_index as u32,
+ span: range.span as u32,
+ start_pos: 0,
+ end_pos: (range.source_end - range.source_start) as u32,
+ x: rect.x(),
+ y: rect.y(),
+ width: rect.width(),
+ height: rect.height(),
+ direction: direction_to_int(TextDirection::LTR),
+ });
+ }
+ placeholder_index += 1;
+ continue;
+ }
+ let orig_span_start = range.source_start;
let rects = para_layout.paragraph.get_rects_for_range(
- start..end,
+ range.builder_start..range.builder_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
@@ -1909,22 +2224,30 @@ pub fn calculate_text_layout_data(
let cy = rect.top + rect.height() / 2.0;
// Get byte positions from Skia's transformed text layout
- let start_pos = para_layout
- .paragraph
- .get_glyph_position_at_coordinate((rect.left + 0.1, cy))
- .position as usize
- - start;
+ let to_source = |builder_position: usize| {
+ let within = builder_position
+ .saturating_sub(range.builder_start)
+ .min(range.builder_end - range.builder_start);
+ offset_map.to_original(range.shifted_start + within)
+ };
+ let start_pos = to_source(
+ para_layout
+ .paragraph
+ .get_glyph_position_at_coordinate((rect.left + 0.1, cy))
+ .position as usize,
+ ) - orig_span_start;
- let end_pos = para_layout
- .paragraph
- .get_glyph_position_at_coordinate((rect.right - 0.1, cy))
- .position as usize
- - start;
+ let end_pos = to_source(
+ para_layout
+ .paragraph
+ .get_glyph_position_at_coordinate((rect.right - 0.1, cy))
+ .position as usize,
+ ) - orig_span_start;
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
- span: span_index as u32,
+ span: range.span as u32,
start_pos: start_pos as u32,
end_pos: end_pos as u32,
x: rect.x(),
@@ -1953,6 +2276,20 @@ pub fn calculate_position_data(
let mut text_content = text_content.clone();
text_content.update_layout(shape.selrect);
+ // Vertical writing generates position data from the vertical cells.
+ if text_content.is_vertical() {
+ if skip_position_data {
+ return Vec::new();
+ }
+ let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height());
+ let layout = super::text_vertical::layout_from_content(&text_content, max_height);
+ return super::text_vertical::position_data(
+ &layout,
+ &shape.selrect,
+ shape.vertical_align(),
+ );
+ }
+
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let layout_info = calculate_text_layout_data(
shape,
@@ -2133,7 +2470,8 @@ mod tests {
let para = test_paragraph(&["Añadir"]);
for offset in 0..=6 {
assert_eq!(para.char_offset_to_utf16(offset), offset);
- assert_eq!(para.utf16_offset_to_char(offset), offset);
+ assert_eq!(horizontal_source_to_builder(¶, offset), offset);
+ assert_eq!(horizontal_builder_to_source(¶, offset), offset);
}
}
@@ -2146,16 +2484,16 @@ mod tests {
assert_eq!(para.char_offset_to_utf16(2), 3);
assert_eq!(para.char_offset_to_utf16(3), 4);
- assert_eq!(para.utf16_offset_to_char(0), 0);
- assert_eq!(para.utf16_offset_to_char(1), 1);
- assert_eq!(para.utf16_offset_to_char(3), 2);
- assert_eq!(para.utf16_offset_to_char(4), 3);
+ assert_eq!(horizontal_builder_to_source(¶, 0), 0);
+ assert_eq!(horizontal_builder_to_source(¶, 1), 1);
+ assert_eq!(horizontal_builder_to_source(¶, 3), 2);
+ assert_eq!(horizontal_builder_to_source(¶, 4), 3);
}
#[test]
- fn utf16_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() {
+ fn builder_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() {
let para = test_paragraph(&["a😀b"]);
- assert_eq!(para.utf16_offset_to_char(2), 2);
+ assert_eq!(horizontal_builder_to_source(¶, 2), 2);
}
#[test]
@@ -2166,16 +2504,22 @@ mod tests {
assert_eq!(para.char_offset_to_utf16(4), 4);
assert_eq!(para.char_offset_to_utf16(6), 7);
- assert_eq!(para.char_utf16_len_at(4), 2);
- assert_eq!(para.utf16_offset_to_char(7), 6);
+ assert_eq!(horizontal_source_to_builder(¶, 4), 4);
+ assert_eq!(horizontal_source_to_builder(¶, 5), 6);
+ assert_eq!(horizontal_source_to_builder(¶, 6), 7);
+ assert_eq!(horizontal_builder_to_source(¶, 7), 6);
}
#[test]
- fn char_utf16_len_at_covers_the_whole_glyph() {
+ fn builder_range_covers_the_whole_glyph() {
let para = test_paragraph(&["a😀b"]);
- assert_eq!(para.char_utf16_len_at(0), 1);
- assert_eq!(para.char_utf16_len_at(1), 2);
- assert_eq!(para.char_utf16_len_at(2), 1);
+ let len_at = |offset| {
+ horizontal_source_to_builder(¶, offset + 1)
+ - horizontal_source_to_builder(¶, offset)
+ };
+ assert_eq!(len_at(0), 1);
+ assert_eq!(len_at(1), 2);
+ assert_eq!(len_at(2), 1);
}
fn sample_text_content() -> TextContent {
@@ -2383,4 +2727,306 @@ mod tests {
layout.clear();
assert!(layout.needs_update());
}
+
+ #[test]
+ fn transformed_text_maps_expanded_scalars_to_their_source_range() {
+ let transformed = apply_text_transform_with_source_ranges(
+ "AßB",
+ Browser::Chrome as u8,
+ Some(TextTransform::Uppercase),
+ );
+
+ assert_eq!(transformed.text, "ASSB");
+ assert_eq!(transformed.source_utf16_range(0..1), 0..1);
+ assert_eq!(transformed.source_utf16_range(1..2), 1..2);
+ assert_eq!(transformed.source_utf16_range(2..3), 1..2);
+ assert_eq!(transformed.source_utf16_range(1..3), 1..2);
+ assert_eq!(transformed.source_utf16_range(3..4), 2..3);
+ }
+
+ // apply_text_transform reads the browser from the design state.
+ fn init_state() {
+ crate::globals::design_init();
+ }
+
+ fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
+ TextSpan {
+ text: text.to_string(),
+ font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal),
+ font_size: 16.0,
+ line_height: 1.0,
+ letter_spacing,
+ font_weight: 400,
+ font_variant_id: Uuid::nil(),
+ text_decoration: None,
+ text_transform: None,
+ text_direction: TextDirection::LTR,
+ text_orientation: TextOrientation::default(),
+ text_combine_upright: TextCombineUpright::default(),
+ text_emphasis: TextEmphasis::default(),
+ ruby: String::default(),
+ warichu: false,
+ font_features: FontFeatures::default(),
+ annotation_clearance: AnnotationClearance::default(),
+ ruby_size: RubySize::default(),
+ ruby_align: RubyAlign::default(),
+ ruby_overhang: RubyOverhang::default(),
+ ruby_side: RubySide::default(),
+ paragraph_position: u32::MAX,
+ span_position: u32::MAX,
+ fills: vec![],
+ }
+ }
+
+ fn make_paragraph(spans: Vec, letter_spacing: f32) -> Paragraph {
+ Paragraph::new(
+ TextAlign::default(),
+ TextDirection::LTR,
+ None,
+ None,
+ 1.0,
+ letter_spacing,
+ spans,
+ )
+ }
+
+ #[test]
+ fn layout_span_texts_applies_kinsoku() {
+ init_state();
+ let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
+ let (texts, map) = paragraph.layout_span_texts();
+ assert_eq!(
+ texts,
+ vec!["雪国".to_string(), "\u{2060}。です".to_string()]
+ );
+ assert!(!map.is_empty());
+ assert_eq!(map.to_original(3), 2);
+ }
+
+ #[test]
+ fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
+ init_state();
+ let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
+ let (texts, map) = paragraph.layout_span_texts();
+ assert_eq!(texts, vec!["雪国。".to_string()]);
+ assert!(map.is_empty());
+ }
+
+ #[test]
+ fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
+ init_state();
+ let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
+ let (texts, map) = paragraph.layout_span_texts();
+ assert_eq!(texts, vec!["雪国。".to_string()]);
+ assert!(map.is_empty());
+ }
+
+ #[test]
+ fn layout_span_texts_respects_text_transform() {
+ init_state();
+ let mut span = make_span("hello。", 0.0);
+ span.text_transform = Some(TextTransform::Uppercase);
+ let paragraph = make_paragraph(vec![span], 0.0);
+ let (texts, _) = paragraph.layout_span_texts();
+ assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
+ }
+
+ #[test]
+ fn layout_span_texts_identity_map_for_plain_text() {
+ init_state();
+ let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
+ let (texts, map) = paragraph.layout_span_texts();
+ assert_eq!(texts, vec!["helloworld".to_string()]);
+ assert!(map.is_empty());
+ assert_eq!(map.to_original(5), 5);
+ assert_eq!(map.to_shifted(5), 5);
+ }
+
+ #[test]
+ fn horizontal_ruby_is_atomic() {
+ init_state();
+ let mut group = make_span("日本", 0.0);
+ group.ruby = "にほん".to_string();
+ let paragraph = make_paragraph(vec![group], 0.0);
+ assert_eq!(
+ paragraph.layout_span_texts().0,
+ vec!["日\u{2060}本".to_string()]
+ );
+ }
+
+ #[test]
+ fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
+ let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
+
+ assert_eq!(
+ crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0),
+ 88.0
+ );
+ }
+
+ #[test]
+ fn horizontal_warichu_collapses_to_one_builder_position() {
+ init_state();
+ let mut warichu = make_span("割注入り", 0.0);
+ warichu.warichu = true;
+ let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
+
+ let ranges = horizontal_span_ranges(¶graph);
+ assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3);
+ assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4);
+ assert_eq!(horizontal_source_to_builder(¶graph, 2), 0);
+ assert_eq!(horizontal_source_to_builder(¶graph, 4), 3);
+ assert_eq!(horizontal_source_to_builder(¶graph, 5), 4);
+ assert_eq!(horizontal_builder_to_source(¶graph, 1), 4);
+ assert_eq!(horizontal_builder_to_source(¶graph, 2), 4);
+ assert_eq!(horizontal_builder_to_source(¶graph, 3), 4);
+ assert_eq!(horizontal_builder_to_source(¶graph, 4), 5);
+ assert_eq!(
+ horizontal_normal_selection_ranges(¶graph, 1, 5),
+ vec![3..4]
+ );
+ }
+
+ #[test]
+ fn horizontal_builder_mapping_preserves_non_bmp_boundaries() {
+ init_state();
+ let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0);
+
+ assert_eq!(horizontal_source_to_builder(¶graph, 1), 2);
+ assert_eq!(horizontal_builder_to_source(¶graph, 2), 1);
+ assert_eq!(horizontal_source_to_builder(¶graph, 2), 3);
+ assert_eq!(horizontal_builder_to_source(¶graph, 3), 2);
+ }
+
+ #[test]
+ fn horizontal_warichu_builder_emits_one_styled_placeholder() {
+ init_state();
+ let mut span = make_span("割注入り", 0.0);
+ span.warichu = true;
+ let mut style = skia::textlayout::TextStyle::default();
+ style.set_font_size(span.font_size);
+ let mut fonts = skia::textlayout::FontCollection::new();
+ fonts.set_default_font_manager(skia::FontMgr::new(), None);
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
+ builder.push_style(&style);
+ add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
+ let mut laid_out = builder.build();
+ laid_out.layout(200.0);
+
+ let placeholders = laid_out.get_rects_for_placeholders();
+ assert_eq!(placeholders.len(), 1);
+ assert!(placeholders[0].rect.width() > 0.0);
+ assert!(placeholders[0].rect.height() > 0.0);
+ let has_style = laid_out
+ .get_line_metrics()
+ .iter()
+ .any(|line| !line.get_style_metrics(3..5).is_empty());
+ assert!(
+ has_style,
+ "the paint pass must recover the placeholder style"
+ );
+ }
+
+ #[test]
+ fn horizontal_warichu_allows_wrapping_after_the_atomic_box() {
+ init_state();
+ let mut span = make_span("割注入り", 0.0);
+ span.warichu = true;
+ let following = make_span("A", 0.0);
+ let mut style = skia::textlayout::TextStyle::default();
+ style.set_font_size(span.font_size);
+ let mut fonts = skia::textlayout::FontCollection::new();
+ fonts.set_default_font_manager(skia::FontMgr::new(), None);
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
+ builder.push_style(&style);
+ add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
+ builder.push_style(&style);
+ builder.add_text(&following.text);
+
+ let mut laid_out = builder.build();
+ laid_out.layout(16.1);
+
+ assert_eq!(laid_out.get_rects_for_placeholders().len(), 1);
+ assert_eq!(laid_out.get_line_metrics().len(), 2);
+ }
+
+ #[test]
+ fn emphasis_excludes_whitespace_and_japanese_punctuation() {
+ for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars()
+ {
+ assert!(
+ !emphasis_char_allowed(character),
+ "emphasis must skip {character:?}"
+ );
+ }
+ for character in "漢あA1・!?".chars() {
+ assert!(
+ emphasis_char_allowed(character),
+ "emphasis should mark {character:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn horizontal_emphasis_tracks_eligible_unicode_characters() {
+ init_state();
+ let mut span = make_span("A😀。 B", 0.0);
+ span.text_emphasis = TextEmphasis::FilledDot;
+ let paragraph = make_paragraph(vec![span], 0.0);
+ let mut style = skia::textlayout::TextStyle::default();
+ style.set_font_size(16.0);
+ let mut fonts = skia::textlayout::FontCollection::new();
+ fonts.set_default_font_manager(skia::FontMgr::new(), None);
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
+ let (texts, _) = paragraph.layout_span_texts();
+ for (span, text) in paragraph.children().iter().zip(texts) {
+ builder.push_style(&style);
+ add_horizontal_span(&mut builder, span, &text, &style, &fonts);
+ }
+ let mut laid_out = builder.build();
+ laid_out.layout(200.0);
+
+ let placements = horizontal_emphasis_placements(¶graph, &laid_out);
+ assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each");
+ assert!(placements
+ .iter()
+ .all(|placement| placement.rect.width() > 0.0));
+ assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some());
+ }
+
+ #[test]
+ fn horizontal_emphasis_recovers_each_non_ascii_span_style() {
+ init_state();
+ let mut first = make_span("漢", 0.0);
+ first.text_emphasis = TextEmphasis::FilledDot;
+ let mut second = make_span("字", 0.0);
+ second.text_emphasis = TextEmphasis::OpenCircle;
+ let paragraph = make_paragraph(vec![first, second], 0.0);
+ let mut fonts = skia::textlayout::FontCollection::new();
+ fonts.set_default_font_manager(skia::FontMgr::new(), None);
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
+ let (texts, _) = paragraph.layout_span_texts();
+ for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() {
+ let mut style = skia::textlayout::TextStyle::default();
+ style.set_font_size(if index == 0 { 16.0 } else { 24.0 });
+ builder.push_style(&style);
+ add_horizontal_span(&mut builder, span, &text, &style, &fonts);
+ }
+ let mut laid_out = builder.build();
+ laid_out.layout(200.0);
+
+ let ranges = horizontal_span_ranges(¶graph);
+ assert_eq!(
+ horizontal_span_style(&laid_out, &ranges[0])
+ .unwrap()
+ .font_size(),
+ 16.0
+ );
+ assert_eq!(
+ horizontal_span_style(&laid_out, &ranges[1])
+ .unwrap()
+ .font_size(),
+ 24.0
+ );
+ }
}
diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs
new file mode 100644
index 0000000000..e3406cf58f
--- /dev/null
+++ b/render-wasm/src/shapes/text_japanese.rs
@@ -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, kinsoku::OffsetMap) {
+ let texts: Vec = 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