✨ Add japanese text layout support

This commit is contained in:
alonso.torres 2026-07-15 16:32:10 +02:00
parent 7c039231f7
commit d92b5392e6
93 changed files with 16059 additions and 494 deletions

View File

@ -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]

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"]

View File

@ -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]

View File

@ -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")))

View File

@ -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"

View File

@ -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]]])

View File

@ -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))

View File

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

View File

@ -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)

View File

@ -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)))))

View File

@ -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);

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 3.5v-2h11v2M2.5 12.5v2h11v-2M5 6.25h1.5v3.5M9 6.25h1.5v3.5H9v-1.75h1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 205 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4V2h12v2M2 12v2h12v-2M3.5 6H5v4M7.25 6h1.5v4h-1.5V8h1.5M11 6h1.5v4H11m0-2h1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 2.5h6M5 5.5h6M5 8.5h6M5 11.5h6M8 1v13.5"/>
</svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 1.5h6v5H5zM4 9.5l7 2.5-7 2.5M8 10.9v2.2"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 1.5h6v5H5zM5.5 14l2.5-6 2.5 6M6.5 11.5h3"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 2.5h6v4H5zM5 9.5h6v4H5z"/>
</svg>

After

Width:  |  Height:  |  Size: 156 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 2v12M14 2v12M4.5 3.5h2v3h-2zM9.5 3.5h2v3h-2zM4.5 9.5h2v3h-2zM9.5 9.5h2v3h-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M1.5 2.5h8m-8 3h8m-8 3h6M13 2v11m-2-2 2 2 2-2"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M13.5 1.5v8m-3-8v8m-3-8v6M14 13H3m2-2-2 2 2 2"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@ -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

View File

@ -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))))

View File

@ -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))]

View File

@ -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)))

View File

@ -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]

View File

@ -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]

View File

@ -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"

View File

@ -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"

View File

@ -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}])]]))

View File

@ -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;

View File

@ -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`

View File

@ -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)

View File

@ -14,6 +14,7 @@
[app.main.ui.formats :as fmt]
[app.util.color :as uc]
[app.util.object :as obj]
[app.util.text.writing-mode :as wm]
[cuerdas.core :as str]))
(defn generate-root-styles
@ -21,6 +22,9 @@
(generate-root-styles props node false))
([{:keys [width height]} node code?]
(let [valign (:vertical-align node "top")
;; Mirroring the shape's writing mode on the root makes paragraph
;; blocks stack right-to-left.
writing-mode (wm/content-writing-mode node)
base #js {:height (when-not code? (fmt/format-pixels height))
:width (when-not code? (fmt/format-pixels width))
:display "flex"
@ -28,7 +32,8 @@
(cond-> base
(= valign "top") (obj/set! "alignItems" "flex-start")
(= valign "center") (obj/set! "alignItems" "center")
(= valign "bottom") (obj/set! "alignItems" "flex-end")))))
(= valign "bottom") (obj/set! "alignItems" "flex-end")
(some? writing-mode) (obj/set! "writingMode" writing-mode)))))
(defn generate-paragraph-set-styles
[{:keys [grow-type] :as shape}]
@ -56,6 +61,9 @@
(:line-height txt/default-typography))
text-align (:text-align data "start")
vertical-layout? (wm/vertical-layout-active?)
writing-mode (when vertical-layout? (:writing-mode data))
text-orientation (when vertical-layout? (:text-orientation data))
base #js {;; Fix a problem when exporting HTML
:fontSize 0
:lineHeight line-height
@ -63,7 +71,21 @@
(cond-> base
(some? line-height) (obj/set! "lineHeight" line-height)
(some? text-align) (obj/set! "textAlign" text-align))))
(some? text-align) (obj/set! "textAlign" text-align)
(some? writing-mode) (obj/set! "writingMode" writing-mode)
(some? writing-mode) (obj/set! "textSpacingTrim" "normal")
(= writing-mode "vertical-rl") (obj/set! "textAutospace" "normal")
(some? text-orientation) (obj/set! "textOrientation" text-orientation))))
(defn css-text-combine-upright
"CSS value for a persisted text-combine-upright: the digits variants
serialize with their max run length per the CSS `digits <n>` syntax."
[value]
(case value
"digits" "digits 4"
"digits2" "digits 2"
"digits3" "digits 3"
value))
(defn generate-text-styles
([shape data]
@ -71,6 +93,25 @@
([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}]
(let [letter-spacing (:letter-spacing data 0)
text-combine-upright (:text-combine-upright data)
text-emphasis (:text-emphasis data)
font-features (:font-features data)
annotation-clearance (:annotation-clearance data)
annotation-layers (if (= "auto" annotation-clearance)
(+ (if (and (not (true? (:ruby-hidden data)))
(string? (:ruby data))
(seq (:ruby data))) 1 0)
(if (and (string? text-emphasis)
(not= "none" text-emphasis)) 1 0))
0)
line-height-num (js/parseFloat (or (:line-height data)
(:line-height txt/default-typography)))
auto-line-height (when (and (pos? annotation-layers)
(not (js/isNaN line-height-num)))
(+ line-height-num (* annotation-layers 0.5)))
warichu? (and (= "warichu" (:warichu data))
(string? (:text data))
(>= (count (:text data)) 2))
text-decoration (:text-decoration data)
text-transform (:text-transform data)
@ -145,6 +186,26 @@
(and (string? letter-spacing) (pos? (alength letter-spacing)))
(obj/set! "letterSpacing" (str letter-spacing "px"))
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
(and (string? text-emphasis) (pos? (alength text-emphasis))
(not= "none" text-emphasis))
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
(and (string? font-features) (pos? (alength font-features))
(not= "none" font-features))
(obj/set! "fontFeatureSettings" (str/format "\"%s\"" font-features))
(and (string? annotation-clearance)
(pos? (alength annotation-clearance)))
(obj/set! "--annotation-clearance" annotation-clearance)
(some? auto-line-height)
(obj/set! "lineHeight" auto-line-height)
(and (string? font-size) (pos? (alength font-size)))
(obj/set! "fontSize" (str font-size "px"))
@ -154,4 +215,38 @@
(obj/set! "fontWeight" font-weight))
(= grow-type :auto-width)
(obj/set! "whiteSpace" "pre")))))
(obj/set! "whiteSpace" "pre")
;; Warichu (割注) CSS emulation: an inline-block at half size whose
;; inline-size fits half the characters, so the browser wraps it into
;; two half-size sub-lines within one inline position in either writing
;; mode.
warichu?
(-> (obj/set! "display" "inline-block")
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))
(str (/ (js/parseFloat font-size) 2) "px")
"50%"))
(obj/set! "lineHeight" "1")
(obj/set! "inlineSize"
(str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em")))))))
(defn generate-ruby-styles
[shape data]
(-> (generate-text-styles shape data)
(obj/set! "fontSize" (case (:ruby-size data)
"third" "33.333333%"
"quarter" "25%"
"50%"))
(obj/set! "lineHeight" "1")
(obj/set! "textDecoration" "none")
(obj/unset! "textCombineUpright")))
(defn generate-ruby-container-styles
[data]
#js {:rubyPosition (if (= "under" (:ruby-side data)) "under" "over")
:rubyAlign (case (:ruby-align data)
"center" "center"
"start" "start"
"space-between" "space-between"
"space-around")
:rubyOverhang (if (= "none" (:ruby-overhang data)) "none" "auto")})

View File

@ -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)])]))]]))

View File

@ -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

View File

@ -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"))}

View File

@ -10,6 +10,7 @@
(:require
[app.common.data.macros :as dm]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.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"}]]]]]))

View File

@ -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;
}

View File

@ -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

View File

@ -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]])])]))

View File

@ -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])]]))

View File

@ -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);
}

View File

@ -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}]]))

View File

@ -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))))))

View File

@ -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)})))

View File

@ -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)

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,38 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.util.text.writing-mode
"Writing mode of text content as the active renderer lays it out. Only the
WASM renderer supports vertical writing; under the SVG renderer every text
flows horizontally, while the stored attrs are kept untouched."
(:require
[app.common.types.shape :as cts]
[app.common.types.text.japanese-layout :as jl]))
(defn vertical-layout-active?
"True when the active renderer lays out vertical writing (WASM)."
[]
cts/wasm-enabled?)
(defn content-writing-mode
"Effective writing mode of a text content (see `jl/content-writing-mode`)."
[content]
(when ^boolean (vertical-layout-active?)
(jl/content-writing-mode content)))
(defn vertical-text-content?
"True when the text content flows vertically under the active renderer."
[content]
(and ^boolean (vertical-layout-active?)
(jl/vertical-text-content? content)))
(defn stale-vertical-layout?
"True when the content is stored as vertical but the active renderer lays
it out horizontally, so a layout computed by the WASM renderer (such as
`:position-data`) no longer matches."
[content]
(and (not ^boolean (vertical-layout-active?))
(jl/vertical-text-content? content)))

View File

@ -10,14 +10,17 @@
Each test guards against a concrete bug found in the CSS/HTML generation
of layout children and text shapes."
(:require
["react-dom/server" :as rds]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.uuid :as uuid]
[app.main.ui.shapes.text.fo-text :as fo-text]
[app.util.code-gen.markup-html :as html]
[app.util.code-gen.style-css :as css]
[cljs.test :refer [deftest is testing] :include-macros true]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; --- Builders ------------------------------------------------------------
@ -180,6 +183,29 @@
:children [{:text "Hello"
:fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]})
(def ^:private ruby-text-content
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:writing-mode "vertical-rl"
:text-orientation "upright"
:children [{:text "漢字"
:ruby "かんじ"
:font-size "20"
:fill-color "#000000"
:fill-opacity 1}]}]}]})
(defn- text-shape
[content]
(let [tid (uuid/next)]
{:id tid :name "Text" :type :text
:parent-id uuid/zero :frame-id uuid/zero
:x 0 :y 0 :width 100 :height 40
:selrect (grc/make-rect 0 0 100 40)
:points (pts 0 0 100 40)
:grow-type :fixed
:content content}))
(deftest text-markup-emits-node-id-classes
(testing "generated text markup carries the $id classes the CSS rules target"
(let [tid (uuid/next)
@ -194,3 +220,90 @@
(is (str/includes? markup "root-0")
"the text nodes must expose their $id as a class for the CSS to apply")
(is (str/includes? markup "root-0-paragraph-set-0-paragraph-0")))))
(def ^:private warichu-text-content
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:writing-mode "vertical-rl"
:text-orientation "upright"
:children [{:text "割注入り"
:warichu "warichu"
:font-size "20"
:fill-color "#000000"
:fill-opacity 1}]}]}]})
(def ^:private palt-text-content
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:children [{:text "かな"
:font-features "palt"
:font-size "20"
:fill-color "#000000"
:fill-opacity 1}]}]}]})
(deftest foreign-object-text-emits-warichu-styles
(testing "browser/foreignObject render folds a warichu span into two half-size lines"
(let [text (text-shape warichu-text-content)
markup (rds/renderToStaticMarkup
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
(is (str/includes? markup "割注入り"))
(is (str/includes? markup "display:inline-block"))
(is (str/includes? markup "font-size:10px"))
(is (str/includes? markup "inline-size:2em")))))
(deftest foreign-object-warichu-sizes-by-unicode-code-points
(testing "non-BMP characters count as one slot when sizing warichu lines"
(let [content (assoc-in warichu-text-content
[:children 0 :children 0 :children 0 :text]
"割𠀀注😀")
text (text-shape content)
markup (rds/renderToStaticMarkup
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
(is (str/includes? markup "割𠀀注😀"))
(is (str/includes? markup "inline-size:2em")))))
(def ^:private tcy-digits2-text-content
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:writing-mode "vertical-rl"
:children [{:text "平成31年"
:text-combine-upright "digits2"
:font-size "20"
:fill-color "#000000"
:fill-opacity 1}]}]}]})
(deftest foreign-object-text-emits-counted-digits-css
(testing "the counted digits variants serialize as CSS `digits <n>`"
(let [text (text-shape tcy-digits2-text-content)
markup (rds/renderToStaticMarkup
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
(is (str/includes? markup "text-combine-upright:digits 2")))))
(deftest foreign-object-text-emits-font-feature-settings
(testing "browser/foreignObject render emits palt/vpal as OpenType features"
(let [text (text-shape palt-text-content)
markup (rds/renderToStaticMarkup
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
(is (str/includes? markup "font-feature-settings:&quot;palt&quot;")))))
(deftest text-markup-emits-ruby-annotations
(testing "generated text markup keeps ruby annotations beside the base text"
(let [text (text-shape ruby-text-content)
markup (html/generate-markup (objects text) [text])]
(is (str/includes? markup "<ruby"))
(is (str/includes? markup "<rt"))
(is (str/includes? markup "漢字"))
(is (str/includes? markup "かんじ")))))
(deftest foreign-object-text-emits-ruby-annotations
(testing "browser/foreignObject text render keeps ruby annotations"
(let [text (text-shape ruby-text-content)
markup (rds/renderToStaticMarkup
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
(is (str/includes? markup "<ruby"))
(is (str/includes? markup "<rt"))
(is (str/includes? markup "漢字"))
(is (str/includes? markup "かんじ")))))

View File

@ -16,6 +16,7 @@
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.shape :as cts]
[app.util.code-gen.markup-svg :as svg]
[cljs.test :refer [deftest is testing] :include-macros true]))
@ -37,6 +38,224 @@
[re s]
(count (re-seq re s)))
(defn- setup-vertical-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 40
:height 100})
(assoc :name "Vertical text"
:position-data
[{:x 20
:y 100
:width 24
:height 80
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:font-features "vpal"
:text "うA"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-text))]}))
(defn- setup-vertical-ruby-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 60
:height 100})
(assoc :name "Vertical ruby text"
:position-data
[{:x 20
:y 100
:width 24
:height 80
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:text "漢字"
:ruby "かんじ"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-ruby-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-ruby-text))]}))
(defn- setup-vertical-emphasis-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 60
:height 100})
(assoc :name "Vertical emphasis text"
:position-data
[{:x 20
:y 100
:width 24
:height 80
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:text "強調 あ"
:text-emphasis "filled-dot"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-emphasis-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-emphasis-text))]}))
(defn- setup-horizontal-emphasis-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 120
:height 40})
(assoc :name "Horizontal emphasis text"
:position-data
[{:x 20
:y 60
:width 100
:height 20
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "horizontal-tb"
:text "強調、 あ"
:text-emphasis "filled-dot"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:horizontal-emphasis-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :horizontal-emphasis-text))]}))
(defn- setup-horizontal-stacked-annotations
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 120
:height 40})
(assoc :name "Horizontal stacked annotations"
:position-data
[{:x 20
:y 60
:width 100
:height 20
:fills [{:fill-color "#112233" :fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "horizontal-tb"
:text "漢字"
:ruby "かんじ"
:text-emphasis "filled-dot"
:annotation-clearance "auto"
:annotation-has-ruby true}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:horizontal-stacked-annotations
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :horizontal-stacked-annotations))]}))
(defn- setup-vertical-warichu-text
([] (setup-vertical-warichu-text "割注入り"))
([text]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 60
:height 100})
(assoc :name "Vertical warichu text"
:position-data
[{:x 20
:y 100
:width 24
:height 40
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:text text
:warichu "warichu"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-warichu-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-warichu-text))]})))
(defn- setup-horizontal-warichu-text
([] (setup-horizontal-warichu-text "割注入り"))
([text]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 120
:height 40})
(assoc :name "Horizontal warichu text"
:position-data
[{:x 20
:y 60
:width 40
:height 20
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "horizontal-tb"
:text text
:warichu "warichu"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:horizontal-warichu-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :horizontal-warichu-text))]})))
(deftest empty-selection-yields-empty-string
(is (= "" (svg/generate-markup {} []))))
@ -59,3 +278,97 @@
"multi-select must NOT emit multiple <svg> roots")
(is (= 1 (count-matches #"</svg>" markup))
"multi-select must NOT emit multiple </svg> closing tags"))))
(deftest vertical-text-svg-preserves-browser-layout-properties
(testing "Static SVG carries the vertical writing properties used by browser exports"
(let [{:keys [objects shapes]} (setup-vertical-text)
markup (svg/generate-markup objects shapes)]
(is (re-find #"writing-mode:vertical-rl" markup))
(is (re-find #"text-orientation:upright" markup))
(is (re-find #"text-autospace:normal" markup))
(is (re-find #"font-feature-settings:&quot;vpal&quot;" markup))
(is (not (re-find #"<foreignObject\b" markup))))))
(deftest vertical-emphasis-svg-emits-static-marks
(testing "Static SVG draws emphasis marks and skips whitespace characters"
(let [{:keys [objects shapes]} (setup-vertical-emphasis-text)
markup (svg/generate-markup objects shapes)]
(is (re-find #"強調 あ" markup))
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
(is (re-find #"•• •" markup))
(is (re-find #"font-size:10px" markup))
(is (not (re-find #"<foreignObject\b" markup))))))
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
(let [{:keys [objects shapes]} (setup-horizontal-emphasis-text)
markup (svg/generate-markup objects shapes)]
(is (re-find #"強調、 あ" markup))
;; Five Unicode base characters: two marks, punctuation and whitespace
;; slots, then one mark.
(is (re-find #"•• •" markup))
(is (re-find #"writing-mode:horizontal-tb" markup))
(is (re-find #"dominant-baseline=\"text-after-edge\"" markup))
(is (re-find #"lengthAdjust=\"spacing\"" markup))
(is (re-find #"font-size:10px" markup))
(is (re-find #"fill:url\(#fill-0-[^)]+-0\)" markup)
"the emphasis mark references its generated per-strip fill")
(is (not (re-find #"<foreignObject\b" markup))))))
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
(let [{:keys [objects shapes]} (setup-horizontal-stacked-annotations)
markup (svg/generate-markup objects shapes)]
(is (re-find #"かんじ" markup))
(is (re-find #"••" markup))
(is (re-find #"y=\"40\"" markup) "ruby occupies the first half-em layer")
(is (re-find #"y=\"30\"" markup) "emphasis occupies the outer half-em layer"))))
(deftest vertical-warichu-svg-emits-two-sub-columns
(testing "Static SVG splits a warichu strip into two half-size sub-columns"
(let [{:keys [objects shapes]} (setup-vertical-warichu-text)
markup (svg/generate-markup objects shapes)]
(is (re-find #"割注" markup))
(is (re-find #"入り" markup))
(is (not (re-find #"割注入り" markup))
"the base text must be split, not drawn as one strip")
(is (re-find #"font-size:10px" markup))
(is (not (re-find #"<foreignObject\b" markup))))))
(deftest vertical-warichu-svg-split-respects-kinsoku
(testing "The second sub-line must not start with a line-start-prohibited mark"
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割り注、と")
markup (svg/generate-markup objects shapes)]
(is (re-find #"割り注、" markup)
"the comma is pulled up into the first sub-line")
(is (not (re-find #"割り注、と" markup))
"the strip is still split into two sub-lines"))))
(deftest vertical-warichu-svg-preserves-non-bmp-characters
(testing "Static SVG never splits a surrogate pair between warichu sub-lines"
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割𠀀注😀")
markup (svg/generate-markup objects shapes)]
(is (re-find #"割𠀀" markup))
(is (re-find #"注😀" markup))
(is (not (re-find #"<22>" markup))))))
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
(testing "Static SVG splits horizontal warichu into top and bottom half-size lines"
(let [{:keys [objects shapes]} (setup-horizontal-warichu-text)
markup (svg/generate-markup objects shapes)]
(is (re-find #"割注" markup))
(is (re-find #"入り" markup))
(is (not (re-find #"割注入り" markup)))
(is (re-find #"writing-mode:horizontal-tb" markup))
(is (= 2 (count-matches #"dominant-baseline=.?hanging" markup)))
(is (re-find #"font-size:10px" markup)))))
(deftest vertical-ruby-svg-emits-static-annotation
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
(let [{:keys [objects shapes]} (setup-vertical-ruby-text)
markup (svg/generate-markup objects shapes)]
(is (re-find #"漢字" markup))
(is (re-find #"かんじ" markup))
(is (re-find #"font-size:10px" markup))
(is (re-find #"text-orientation:upright" markup))
(is (not (re-find #"<foreignObject\b" markup))))))

View File

@ -6,6 +6,7 @@
(ns frontend-tests.data.workspace-texts-test
(:require
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
@ -13,12 +14,80 @@
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.texts-events :as dwte]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.ui.shapes.text.styles :as text.styles]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]))
(defn- text-content
[writing-mode]
{:children [{:children [{:writing-mode writing-mode}]}]})
(t/deftest vertical-auto-height-grows-width
(let [selrect {:width 100 :height 200}
dimension {:width 240 :height 360}
content (text-content "vertical-rl")]
(t/is (= {:width 240 :height 200}
(dwwt/resolve-text-size selrect :auto-height content dimension)))))
(t/deftest horizontal-auto-height-grows-height
(let [selrect {:width 100 :height 200}
dimension {:width 240 :height 360}
content (text-content "horizontal-tb")]
(t/is (= {:width 100 :height 360}
(dwwt/resolve-text-size selrect :auto-height content dimension)))))
(t/deftest vertical-grow-type-resize-axes-are-remapped
(t/is (= :auto-height
(dwm/next-grow-type :auto-width (gpt/point 1 2) true)))
(t/is (= :fixed
(dwm/next-grow-type :auto-height (gpt/point 2 1) true)))
(t/is (= :auto-height
(dwm/next-grow-type :auto-height (gpt/point 1 2) true))))
(t/deftest vertical-export-styles-enable-inter-script-spacing
(let [vertical (text.styles/generate-paragraph-styles
nil
{:writing-mode "vertical-rl"
:text-orientation "upright"})
horizontal (text.styles/generate-paragraph-styles
nil
{:writing-mode "horizontal-tb"})]
(t/is (= "vertical-rl" (aget vertical "writingMode")))
(t/is (= "upright" (aget vertical "textOrientation")))
(t/is (= "normal" (aget vertical "textAutospace")))
(t/is (nil? (aget horizontal "textAutospace")))))
(t/deftest text-export-styles-emit-font-features
(let [palt (text.styles/generate-text-styles
{:grow-type :fixed}
{:font-features "palt"
:font-size "20"
:fills [{:fill-color "#000000" :fill-opacity 1}]})
none (text.styles/generate-text-styles
{:grow-type :fixed}
{:font-features "none"
:font-size "20"
:fills [{:fill-color "#000000" :fill-opacity 1}]})]
(t/is (= "\"palt\"" (aget palt "fontFeatureSettings")))
(t/is (nil? (aget none "fontFeatureSettings")))))
(t/deftest text-export-styles-emit-annotation-clearance
(let [style (text.styles/generate-text-styles
{:grow-type :fixed}
{:annotation-clearance "auto"
:line-height "1.2"
:ruby "かんじ"
:text-emphasis "filled-dot"
:font-size "20"
:fills [{:fill-color "#000000" :fill-opacity 1}]})]
(t/is (= "auto" (aget style "--annotation-clearance")))
(t/is (= 2.2 (aget style "lineHeight")))))
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------

View File

@ -189,6 +189,11 @@
["TextRangeProxy.fontSize" #(set! (.-fontSize range) "16")]
["TextRangeProxy.lineHeight" #(set! (.-lineHeight range) "1.2")]
["TextRangeProxy.letterSpacing" #(set! (.-letterSpacing range) "1")]
["TextRangeProxy.ruby" #(set! (.-ruby range) "にち")]
["TextRangeProxy.rubySize" #(set! (.-rubySize range) "third")]
["TextRangeProxy.rubyAlign" #(set! (.-rubyAlign range) "center")]
["TextRangeProxy.rubyOverhang" #(set! (.-rubyOverhang range) "none")]
["TextRangeProxy.rubySide" #(set! (.-rubySide range) "under")]
["TextRangeProxy.fills" #(set! (.-fills range) #js [#js {:fillColor "#fabada" :fillOpacity 1}])]
;; ---- RulerGuideProxy ----
@ -315,4 +320,3 @@
["borderRadiusBottomRight" #(set! (.-borderRadiusBottomRight rect) 2.5)]
["borderRadiusBottomLeft" #(set! (.-borderRadiusBottomLeft rect) 2.5)]]]
(t/is (not (throws? thunk)) (str label " must accept a fractional value")))))))

View File

@ -44,6 +44,17 @@
(set! (.-letterSpacing range) value)
@captured)))
(def ^:private font-features-re @#'plugins.text/font-features-re)
(def ^:private annotation-clearance-re @#'plugins.text/annotation-clearance-re)
(def ^:private ruby-size-re @#'plugins.text/ruby-size-re)
(def ^:private ruby-align-re @#'plugins.text/ruby-align-re)
(def ^:private ruby-overhang-re @#'plugins.text/ruby-overhang-re)
(def ^:private ruby-side-re @#'plugins.text/ruby-side-re)
(defn- valid-font-features? [s] (boolean (re-matches font-features-re s)))
(defn- valid-annotation-clearance? [s]
(boolean (re-matches annotation-clearance-re s)))
(t/deftest letter-spacing-accepts-negative-values
(t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56")))
(t/is (= {:letter-spacing "-12"} (apply-letter-spacing "-12")))
@ -59,6 +70,119 @@
(t/is (nil? (apply-letter-spacing "1-2")))
(t/is (nil? (apply-letter-spacing "--1"))))
(t/deftest font-features-re-accepts-supported-japanese-proportional-features
(t/is (valid-font-features? "none"))
(t/is (valid-font-features? "palt"))
(t/is (valid-font-features? "vpal"))
(t/is (not (valid-font-features? "liga")))
(t/is (not (valid-font-features? "palt,vpal"))))
(t/deftest annotation-clearance-re-accepts-supported-policies
(t/is (valid-annotation-clearance? "none"))
(t/is (valid-annotation-clearance? "auto"))
(t/is (not (valid-annotation-clearance? "always"))))
(t/deftest ruby-customization-validates-supported-values
(t/is (every? #(re-matches ruby-size-re %) ["half" "third" "quarter"]))
(t/is (not (re-matches ruby-size-re "full")))
(t/is (every? #(re-matches ruby-align-re %)
["space-around" "center" "start" "space-between"]))
(t/is (not (re-matches ruby-align-re "end")))
(t/is (every? #(re-matches ruby-overhang-re %) ["auto" "none"]))
(t/is (not (re-matches ruby-overhang-re "always")))
(t/is (every? #(re-matches ruby-side-re %) ["over" "under"]))
(t/is (not (re-matches ruby-side-re "right"))))
(t/deftest text-range-japanese-properties-read-span-values
(let [file-id (random-uuid)
page-id (random-uuid)
shape-id (random-uuid)
content {:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:children [{:text "漢字"
:text-combine-upright "digits2"
:text-emphasis "filled-dot"
:warichu "warichu"
:font-features "vpal"
:annotation-clearance "auto"
:ruby "かんじ"
:ruby-size "third"
:ruby-align "center"
:ruby-overhang "none"
:ruby-side "under"}]}]}]}
range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)]
(with-redefs [u/proxy->shape (constantly {:content content})]
(t/is (= "digits2" (.-textCombineUpright range)))
(t/is (= "filled-dot" (.-textEmphasis range)))
(t/is (= "warichu" (.-warichu range)))
(t/is (= "vpal" (.-fontFeatures range)))
(t/is (= "auto" (.-annotationClearance range)))
(t/is (= "かんじ" (.-ruby range)))
(t/is (= "third" (.-rubySize range)))
(t/is (= "center" (.-rubyAlign range)))
(t/is (= "none" (.-rubyOverhang range)))
(t/is (= "under" (.-rubySide range))))))
(t/deftest text-range-japanese-properties-report-mixed-values
(let [file-id (random-uuid)
page-id (random-uuid)
shape-id (random-uuid)
content {:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:children [{:text "日"
:text-emphasis "filled-dot"
:ruby "にち"
:ruby-size "third"}
{:text "本"
:text-emphasis "none"
:ruby nil
:ruby-size "half"}]}]}]}
range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)]
(with-redefs [u/proxy->shape (constantly {:content content})]
(t/is (= "mixed" (.-textEmphasis range)))
(t/is (= "mixed" (.-ruby range)))
(t/is (= "mixed" (.-rubySize range))))))
(t/deftest text-range-japanese-properties-update-the-selected-range
(let [file-id (random-uuid)
page-id (random-uuid)
shape-id (random-uuid)
range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4)
captured (atom [])]
(with-redefs [r/check-permission (constantly true)
u/page-active? (constantly true)
dwt/update-text-range
(fn [id start end attrs]
(swap! captured conj {:id id
:start start
:end end
:attrs attrs})
:update-text-range)
st/emit! mock/noop]
(set! (.-textCombineUpright range) "digits2")
(set! (.-textEmphasis range) "filled-dot")
(set! (.-warichu range) "warichu")
(set! (.-fontFeatures range) "vpal")
(set! (.-annotationClearance range) "auto")
(set! (.-ruby range) "かんじ")
(set! (.-rubySize range) "third")
(set! (.-rubyAlign range) "center")
(set! (.-rubyOverhang range) "none")
(set! (.-rubySide range) "under")
(t/is (= [{:id shape-id :start 1 :end 4 :attrs {:text-combine-upright "digits2"}}
{:id shape-id :start 1 :end 4 :attrs {:text-emphasis "filled-dot"}}
{:id shape-id :start 1 :end 4 :attrs {:warichu "warichu"}}
{:id shape-id :start 1 :end 4 :attrs {:font-features "vpal"}}
{:id shape-id :start 1 :end 4 :attrs {:annotation-clearance "auto"}}
{:id shape-id :start 1 :end 4 :attrs {:ruby "かんじ"}}
{:id shape-id :start 1 :end 4 :attrs {:ruby-size "third"}}
{:id shape-id :start 1 :end 4 :attrs {:ruby-align "center"}}
{:id shape-id :start 1 :end 4 :attrs {:ruby-overhang "none"}}
{:id shape-id :start 1 :end 4 :attrs {:ruby-side "under"}}]
@captured)))))
(t/deftest font-apply-to-text-uses-font-id-not-shape-id
(let [file-id (random-uuid)

View File

@ -0,0 +1,337 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.render-wasm.texts-test
"Unit tests for CJK script classification and the Han-unification
resolution policy used to pick Noto fallback fonts."
(:require
[app.common.fonts :as cfnt]
[app.common.render-wasm.text-content :as tc]
[app.common.render-wasm.wasm :as wasm]
[app.main.ui.workspace.shapes.text.v3-editor :as v3-editor]
[app.render-wasm.api :as api]
[app.render-wasm.api.texts :as texts]
[app.render-wasm.text-editor :as text-editor]
[cljs.test :as t :include-macros true]))
(defn- write-spans
[offset dview spans paragraph]
(@#'tc/write-spans offset dview spans paragraph cfnt/font-id->uuid))
(defn- langs [text]
(cfnt/collect-used-languages #{} text))
(defn- font-ids
[fonts]
(into #{} (map :font-id) fonts))
(defn- editor-content
[& paragraphs]
{:type "root"
:children [{:type "paragraph-set"
:children (mapv (fn [spans]
{:type "paragraph"
:children spans})
paragraphs)}]})
(defn- selection
[anchor-para anchor-offset focus-para focus-offset]
{:anchor-para anchor-para
:anchor-offset anchor-offset
:focus-para focus-para
:focus-offset focus-offset})
(t/deftest japanese-styles-follow-the-selected-span
(let [content (editor-content
[{:text "日" :ruby "にち" :text-emphasis "filled-dot"}
{:text "本"}])]
(t/is (= {:text-combine-upright "none"
:text-emphasis "filled-dot"
:ruby "にち"
:ruby-hidden false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
:ruby-side "over"
:warichu "none"
:font-features "none"
:annotation-clearance "none"}
(text-editor/selection-japanese-styles
content (selection 0 0 0 1))))
(t/is (= {:text-combine-upright "none"
:text-emphasis "none"
:ruby ""
:ruby-hidden false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
:ruby-side "over"
:warichu "none"
:font-features "none"
:annotation-clearance "none"}
(text-editor/selection-japanese-styles
content (selection 0 1 0 2))))))
(t/deftest japanese-styles-report-mixed-ranges-and-follow-the-caret
(let [content (editor-content
[{:text "平成"
:text-combine-upright "digits2"
:text-emphasis "filled-dot"
:ruby "へいせい"
:ruby-hidden true
:ruby-size "third"
:ruby-align "center"
:ruby-overhang "none"
:ruby-side "under"
:annotation-clearance "auto"}
{:text "年"
:ruby-hidden false
:warichu "warichu"
:font-features "vpal"}])]
(t/is (= {:text-combine-upright :multiple
:text-emphasis :multiple
:ruby :multiple
:ruby-hidden :multiple
:ruby-size :multiple
:ruby-align :multiple
:ruby-overhang :multiple
:ruby-side :multiple
:warichu :multiple
:font-features :multiple
:annotation-clearance :multiple}
(text-editor/selection-japanese-styles
content (selection 0 0 0 3))))
;; A caret at the boundary belongs to the preceding span, as in WASM.
(t/is (= "digits2"
(:text-combine-upright
(text-editor/selection-japanese-styles
content (selection 0 2 0 2)))))
(t/is (= "warichu"
(:warichu
(text-editor/selection-japanese-styles
content (selection 0 3 0 3)))))))
(t/deftest japanese-selection-offsets-are-utf16-code-units
(let [content (editor-content
[{:text "😀"}
{:text "日" :ruby "にち"}])]
(t/is (= "にち"
(:ruby
(text-editor/selection-japanese-styles
content (selection 0 2 0 3)))))))
(t/deftest composition-caret-goes-to-the-end-of-the-changed-part
;; Typing appends, converting replaces everything, and picking a candidate
;; for a middle clause changes only that clause.
(t/is (= 1 (v3-editor/changed-span-end "" "に")))
(t/is (= 2 (v3-editor/changed-span-end "に" "にほ")))
(t/is (= 3 (v3-editor/changed-span-end "にほんご" "日本語")))
(t/is (= 5 (v3-editor/changed-span-end "日本語を書く" "日本語を掻く")))
(t/is (= 3 (v3-editor/changed-span-end "ああ" "あああ")))
(t/is (= 0 (v3-editor/changed-span-end "にほ" "")))
(t/is (= 2 (v3-editor/changed-span-end "😀" "😀😀"))))
(t/deftest whole-shape-paragraph-attrs-apply-to-every-paragraph
(let [content (-> (editor-content [{:text "一"}] [{:text "二"}] [{:text "三"}])
(assoc-in [:children 0 :children 2 :text-orientation] "upright"))
result (text-editor/apply-paragraph-attrs-to-range
content
(selection 1 0 1 0)
{:writing-mode "vertical-rl"
:text-orientation nil
:text-align "center"})
paragraphs (-> result :children first :children)]
(t/is (every? #(= "vertical-rl" (:writing-mode %)) paragraphs))
(t/is (not-any? #(contains? % :text-orientation) paragraphs))
(t/is (= [nil "center" nil] (mapv :text-align paragraphs)))))
(t/deftest write-spans-serializes-font-features-in-reserved-byte
(let [sentinel (js-obj)
previous (if (.hasOwnProperty wasm/serializers "font-features")
(unchecked-get wasm/serializers "font-features")
sentinel)]
(try
(aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2})
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "日本語"
:font-size "16"
:font-weight "400"
:font-features "vpal"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
(t/is (= 2 (.getUint8 dview 8)))
(t/is (= 0 (.getUint8 dview 9)))
(t/is (= 0 (.getUint8 dview 14)))
(t/is (= 0 (.getUint8 dview 15))))
(finally
(if (identical? sentinel previous)
(js-delete wasm/serializers "font-features")
(aset wasm/serializers "font-features" previous))))))
(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte
(let [sentinel (js-obj)
previous (if (.hasOwnProperty wasm/serializers "annotation-clearance")
(unchecked-get wasm/serializers "annotation-clearance")
sentinel)]
(try
(aset wasm/serializers "annotation-clearance" #js {"none" 0 "auto" 1})
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "漢字"
:font-size "16"
:font-weight "400"
:annotation-clearance "auto"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
(t/is (= 1 (.getUint8 dview 9)))
(t/is (= 0 (.getUint8 dview 14)))
(t/is (= 0 (.getUint8 dview 15))))
(finally
(if (identical? sentinel previous)
(js-delete wasm/serializers "annotation-clearance")
(aset wasm/serializers "annotation-clearance" previous))))))
(t/deftest write-spans-serializes-ruby-customization-bytes
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "漢字"
:font-size "16"
:font-weight "400"
:ruby-size "quarter"
:ruby-align "space-between"
:ruby-overhang "none"
:ruby-side "under"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
(t/is (= [2 3 1 1 0 0]
(mapv #(.getUint8 dview %) (range 10 16))))))
(t/deftest write-spans-omits-hidden-ruby-bytes
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [{:text "日"
:ruby "にち"
:ruby-hidden true
:font-size "16"
:font-weight "400"}]
paragraph)
(t/is (= 0 (.getInt32 dview 72 true)))
(write-spans 0 dview [{:text "日"
:ruby "にち"
:ruby-hidden false
:font-size "16"
:font-weight "400"}]
paragraph)
(t/is (pos? (.getInt32 dview 72 true)))))
(t/deftest write-spans-serializes-counted-digits-tcy
(let [sentinel (js-obj)
previous (if (.hasOwnProperty wasm/serializers "text-combine-upright")
(unchecked-get wasm/serializers "text-combine-upright")
sentinel)]
(try
(aset wasm/serializers "text-combine-upright"
#js {"none" 0 "all" 1 "digits" 2 "digits2" 3 "digits3" 4})
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "平成31年"
:font-size "16"
:font-weight "400"
:text-combine-upright "digits2"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
;; Byte 5 of the span attr block carries text-combine-upright.
(t/is (= 3 (.getUint8 dview 5))))
(finally
(if (identical? sentinel previous)
(js-delete wasm/serializers "text-combine-upright")
(aset wasm/serializers "text-combine-upright" previous))))))
(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery
(let [content {:children
[{:children
[{:children
[{:text "Penpot"
:ruby "ぺんぽっと😀"}]}]}]}
expected #{"gfont-noto-sans-jp" "gfont-noto-color-emoji"}]
(with-redefs [texts/write-shape-text (fn [& _])]
(t/is (every? (font-ids (api/fonts-from-text-content content true)) expected)))
(t/is (every? (font-ids (api/fonts-from-text-content content false)) expected))))
(t/deftest classification-kana
;; Hiragana/katakana are unambiguously Japanese.
(t/is (= #{:japanese} (langs "ひらがなとカタカナ")))
;; Half-width katakana too.
(t/is (= #{:japanese} (langs "デザイン"))))
(t/deftest classification-han-is-ambiguous
;; Kanji-only text (han-unification fixture) must NOT classify as a
;; concrete language; it is ambiguous Han.
(t/is (= #{:han} (langs "東京都渋谷区神南一丁目"))))
(t/deftest classification-cjk-punctuation
;; CJK punctuation and full-width forms match the shared class
;; (previously they matched no range at all).
(t/is (= #{:cjk-punctuation} (langs "、。「」『』()")))
(t/is (= #{:cjk-punctuation} (langs "!?:;123ABC"))))
(t/deftest classification-mixed-japanese
(t/is (= #{:japanese :han :cjk-punctuation}
(langs "「こんにちは」と彼は言った。"))))
(t/deftest classification-korean
(t/is (= #{:korean} (langs "안녕하세요"))))
(t/deftest resolve-kana-wins-over-locale
;; Kana in the same content implies Japanese regardless of locale.
(t/is (= #{:japanese}
(cfnt/resolve-ambiguous-cjk #{:japanese :han :cjk-punctuation} "zh")))
(t/is (= #{:japanese}
(cfnt/resolve-ambiguous-cjk #{:japanese :han} "en"))))
(t/deftest resolve-hangul-wins-over-locale
(t/is (= #{:korean}
(cfnt/resolve-ambiguous-cjk #{:korean :han} "ja"))))
(t/deftest resolve-han-only-uses-locale
(t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja")))
(t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja_paid")))
(t/is (= #{:korean} (cfnt/resolve-ambiguous-cjk #{:han} "ko")))
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "zh_cn"))))
(t/deftest resolve-han-only-defaults-to-chinese
;; Without kana/hangul and without a CJK locale, keep the previous
;; behavior (Noto Sans SC).
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "en")))
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil))))
(t/deftest resolve-punctuation-only
(t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} "ja")))
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} "en"))))
(t/deftest resolve-leaves-unambiguous-sets-alone
(t/is (= #{:latin-ext} (cfnt/resolve-ambiguous-cjk #{:latin-ext} "ja")))
(t/is (= #{} (cfnt/resolve-ambiguous-cjk #{} "ja")))
;; Other detected languages survive resolution.
(t/is (= #{:japanese :cyrillic}
(cfnt/resolve-ambiguous-cjk #{:han :cyrillic} "ja"))))
(t/deftest resolve-han-unification
;; A kanji-only Japanese address resolves to Japanese under a ja locale.
(t/is (= #{:japanese}
(cfnt/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja"))))

View File

@ -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

View File

@ -0,0 +1,9 @@
(ns frontend-tests.ui.css-cursors-test
(:require
[app.main.ui.css-cursors :as cursors]
[cljs.test :as t]))
(t/deftest text-cursor-follows-writing-direction-and-shape-rotation
(t/is (= "cursor-text-0" (cursors/get-text 0 false)))
(t/is (= "cursor-text-90" (cursors/get-text 0 true)))
(t/is (= "cursor-text-10" (cursors/get-text 280 true))))

View File

@ -0,0 +1,223 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.ui.text-options-test
(:require
["react-dom/server" :as rds]
[app.main.data.workspace.texts :as dwt]
[app.main.ui.ds.controls.select :as select]
[app.main.ui.shapes.text.html-text :as html-text]
[app.main.ui.shapes.text.styles :as text-styles]
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private japanese-span-attrs
[:text-combine-upright
:text-emphasis
:ruby
:ruby-hidden
:ruby-size
:ruby-align
:ruby-overhang
:ruby-side
:warichu
:font-features
:annotation-clearance])
(def ^:private radio-selected @#'tjl/radio-selected)
(def ^:private span-input-value @#'tjl/span-input-value)
(def ^:private span-select-value @#'tjl/span-select-value)
(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option)
(def ^:private ruby-common-props @#'tjl/ruby-common-props)
(t/deftest japanese-layout-controls-follow-file-or-profile-configuration
(t/is (false? (tjl/japanese-layout-config-enabled? {} {})))
(t/is (true? (tjl/japanese-layout-config-enabled?
{:options {:japanese-layout true}}
{})))
(t/is (true? (tjl/japanese-layout-config-enabled?
{}
{:props {:japanese-layout-all-files true}})))
(t/is (true? (tjl/japanese-layout-config-enabled?
{:options {:japanese-layout true}}
{:props {:japanese-layout-all-files false}}))))
(t/deftest text-emphasis-select-reports-and-restores-canonical-values
(let [options (tjl/text-emphasis-options identity)
reported-id (:id (select/get-option options "filled-dot"))]
(t/is (= "filled-dot" reported-id))
(t/is (= "filled-dot"
(:id (select/get-option options reported-id))))))
(t/deftest annotation-clearance-select-reports-canonical-values
(let [options (tjl/annotation-clearance-options identity)]
(t/is (= ["none" "auto"] (mapv :id options)))
(t/is (= "auto" (:id (select/get-option options "auto"))))))
(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection
(t/is (= ["none" "digits"]
(mapv :value (tjl/text-combine-upright-options false identity))))
(t/is (= ["none" "all" "digits"]
(mapv :value (tjl/text-combine-upright-options true identity)))))
(t/deftest ruby-container-styles-preserve-customization
(let [style (text-styles/generate-ruby-container-styles
{:ruby-align "space-between"
:ruby-overhang "none"
:ruby-side "under"})]
(t/is (= "space-between" (.-rubyAlign style)))
(t/is (= "none" (.-rubyOverhang style)))
(t/is (= "under" (.-rubyPosition style)))))
(t/deftest advanced-furigana-options-are-collapsed-by-default
(let [markup (rds/renderToStaticMarkup
(mf/element tjl/ruby-presentation-options*
#js {:values {}
:on-change identity
:on-blur identity}))]
(t/is (str/includes? markup "data-testid=\"ruby-presentation-options-toggle\""))
(t/is (str/includes? markup "aria-expanded=\"false\""))
(t/is (str/includes? markup "workspace.options.text-options.ruby-advanced-options"))))
(t/deftest advanced-furigana-options-include-hide-toggle
(let [markup (rds/renderToStaticMarkup
(mf/element tjl/ruby-hidden-option*
#js {:values {:ruby-hidden true}
:on-change identity
:on-blur identity}))]
(t/is (str/includes? markup "workspace.options.text-options.ruby-hidden"))
(t/is (str/includes? markup "role=\"switch\""))
(t/is (str/includes? markup "aria-checked=\"true\""))))
(t/deftest hidden-furigana-renders-base-text-without-ruby-layout
(let [markup (rds/renderToStaticMarkup
(mf/element html-text/render-text*
#js {:node {:text "日"
:ruby "にち"
:ruby-hidden true}
:parent {}
:shape {}}))]
(t/is (str/includes? markup ">日</span>"))
(t/is (not (str/includes? markup "<ruby")))))
(t/deftest advanced-furigana-options-preserve-change-callbacks
(let [on-change identity
on-blur identity
props (ruby-common-props {} on-change on-blur)]
(t/is (identical? on-change (unchecked-get props "onChange")))
(t/is (identical? on-blur (unchecked-get props "onBlur")))))
(t/deftest shape-level-furigana-presentation-only-updates-ruby-spans
(let [shape {:content
{:type "root"
:children
[{:type "paragraph-set"
:children
[{:type "paragraph"
:children
[{:text "日"
:ruby "にち"
:ruby-hidden false
:ruby-size "half"}
{:text "本"
:ruby ""
:ruby-hidden false
:ruby-size "quarter"}]}]}]}}
values (dwt/current-ruby-values {:shape shape
:attrs dwt/ruby-presentation-attrs})
updated (dwt/update-ruby-presentation-attrs
shape {:ruby-hidden true :ruby-size "third"})
spans (get-in updated [:content :children 0 :children 0 :children])]
(t/is (= "half" (:ruby-size values)))
(t/is (false? (:ruby-hidden values)))
(t/is (= "third" (:ruby-size (first spans))))
(t/is (true? (:ruby-hidden (first spans))))
(t/is (= "quarter" (:ruby-size (second spans))))
(t/is (false? (:ruby-hidden (second spans))))))
(t/deftest mixed-japanese-span-values-have-distinct-control-states
(let [options (with-mixed-span-option
(tjl/text-emphasis-options identity)
:multiple)]
(t/is (= "mixed" (span-select-value :multiple "none")))
(t/is (= "mixed" (span-input-value :multiple)))
(t/is (= "mixed" (:id (last options))))
(t/is (true? (:disabled (last options))))
(t/is (= "" (radio-selected :multiple "none")))))
(t/deftest whole-text-selection-reports-every-differing-span-value-as-mixed
(let [shape {:content
{:type "root"
:children
[{:type "paragraph-set"
:children
[{:type "paragraph"
:children
[{:text "日"
:text-combine-upright "digits2"
:text-emphasis "filled-dot"
:ruby "にち"
:ruby-hidden true
:ruby-size "third"
:ruby-align "center"
:ruby-overhang "none"
:ruby-side "under"
:warichu "warichu"
:font-features "palt"
:annotation-clearance "auto"}
{:text "本"
:text-combine-upright "none"
:text-emphasis "none"
:ruby ""
:ruby-hidden false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
:ruby-side "over"
:warichu "none"
:font-features "none"
:annotation-clearance "none"}]}]}]}}
values (dwt/current-text-values {:shape shape
:attrs japanese-span-attrs})]
(t/is (= (zipmap japanese-span-attrs (repeat :multiple)) values))))
(t/deftest wasm-editor-span-styles-preserve-persisted-vertical-paragraph-values
(let [shape {:content
{:type "root"
:children
[{:type "paragraph-set"
:children
[{:type "paragraph"
:writing-mode "vertical-rl"
:text-orientation "upright"
:children [{:text "12" :text-combine-upright "none"}]}]}]}}
editor-styles {:text-combine-upright "digits2"}
paragraph-values (dwt/current-paragraph-values
{:editor-styles editor-styles
:shape shape
:attrs [:writing-mode :text-orientation]})
text-values (dwt/current-text-values
{:editor-styles editor-styles
:shape shape
:attrs [:text-combine-upright]})]
(t/is (= "vertical-rl" (:writing-mode paragraph-values)))
(t/is (= "upright" (:text-orientation paragraph-values)))
(t/is (= "digits2" (:text-combine-upright text-values)))))
(t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes
(t/is (false? (tjl/vertical-japanese-layout? {})))
(t/is (false? (tjl/vertical-japanese-layout?
{:writing-mode "horizontal-tb"})))
(t/is (true? (tjl/vertical-japanese-layout?
{:writing-mode "vertical-rl"})))
(t/is (= "palt"
(tjl/proportional-metrics-feature "horizontal-tb")))
(t/is (= "palt"
(tjl/proportional-metrics-feature nil)))
(t/is (= "vpal"
(tjl/proportional-metrics-feature "vertical-rl"))))

View File

@ -811,6 +811,18 @@ msgstr "Invite people"
msgid "dashboard.invite-profile-disabled"
msgstr "You don't have permission to invite people to this team"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.japanese-layout.description"
msgstr "Makes Japanese text layout controls available in every file."
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.japanese-layout.enable-all-files"
msgstr "Enable Japanese text layout for all files"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.japanese-layout.title"
msgstr "Japanese text layout"
#: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266
msgid "dashboard.leave-organization"
msgstr "Leave org"
@ -6417,6 +6429,10 @@ msgstr "Radial gradient"
msgid "workspace.header.menu.disable-dynamic-alignment"
msgstr "Disable dynamic alignment"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.disable-japanese-layout"
msgstr "Disable Japanese text layout"
#: src/app/main/ui/workspace/main_menu.cljs:254
msgid "workspace.header.menu.disable-scale-content"
msgstr "Disable proportional scale"
@ -6441,6 +6457,10 @@ msgstr "Disable WebGL rendering (beta)"
msgid "workspace.header.menu.enable-dynamic-alignment"
msgstr "Enable dynamic alignment"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.enable-japanese-layout"
msgstr "Enable Japanese text layout"
#: src/app/main/ui/workspace/main_menu.cljs:255
msgid "workspace.header.menu.enable-scale-content"
msgstr "Enable proportional scale"
@ -7969,6 +7989,18 @@ msgstr "Align middle"
msgid "workspace.options.text-options.align-top"
msgstr "Align top"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.annotation-clearance"
msgstr "Ann. clearance"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.annotation-clearance-auto"
msgstr "Automatic"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.annotation-clearance-none"
msgstr "Use line height"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:100
msgid "workspace.options.text-options.direction-ltr"
msgstr "LTR"
@ -7977,6 +8009,22 @@ msgstr "LTR"
msgid "workspace.options.text-options.direction-rtl"
msgstr "RTL"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features"
msgstr "Prop. metrics"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features-none"
msgstr "None"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features-palt"
msgstr "Horizontal (palt)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features-vpal"
msgstr "Vertical (vpal)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:167
msgid "workspace.options.text-options.grow-auto-height"
msgstr "Auto height"
@ -7989,6 +8037,10 @@ msgstr "Auto width"
msgid "workspace.options.text-options.grow-fixed"
msgstr "Fixed"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.japanese-layout"
msgstr "Japanese text layout"
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:688
msgid "workspace.options.text-options.letter-spacing"
msgstr "Letter Spacing"
@ -7997,6 +8049,82 @@ msgstr "Letter Spacing"
msgid "workspace.options.text-options.line-height"
msgstr "Line height"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby"
msgstr "Ruby"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-advanced-options"
msgstr "Advanced furigana options"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align"
msgstr "Ruby alignment"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-center"
msgstr "Center"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-space-around"
msgstr "Space around"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-space-between"
msgstr "Space between"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-start"
msgstr "Start"
#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
msgid "workspace.options.text-options.ruby-hidden"
msgstr "Hide furigana"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-overhang"
msgstr "Ruby overhang"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-overhang-auto"
msgstr "Auto"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-overhang-none"
msgstr "None"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-placeholder"
msgstr "Furigana"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-side"
msgstr "Ruby side"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-side-over"
msgstr "Over / right"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-side-under"
msgstr "Under / left"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size"
msgstr "Ruby size"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size-half"
msgstr "Half"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size-quarter"
msgstr "Quarter"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size-third"
msgstr "Third"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:212
msgid "workspace.options.text-options.strikethrough"
msgstr "Strikethrough (%s)"
@ -8017,6 +8145,74 @@ msgstr "Align left"
msgid "workspace.options.text-options.text-align-right"
msgstr "Align right"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-all"
msgstr "Tate-chu-yoko"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits"
msgstr "Digits"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-2"
msgstr "Up to 2 digits"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-3"
msgstr "Up to 3 digits"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-4"
msgstr "Up to 4 digits"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-count"
msgstr "Digit count"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-none"
msgstr "Normal"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis"
msgstr "Emphasis marks"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-filled-circle"
msgstr "Filled circle"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-filled-dot"
msgstr "Filled dot"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-filled-sesame"
msgstr "Filled sesame"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-none"
msgstr "None"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-open-circle"
msgstr "Open circle"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-open-dot"
msgstr "Open dot"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-open-sesame"
msgstr "Open sesame"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-orientation-mixed"
msgstr "Mixed"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-orientation-upright"
msgstr "Upright"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:509
msgid "workspace.options.text-options.text-section"
msgstr "Text section"
@ -8037,6 +8233,22 @@ msgstr "Selection text"
msgid "workspace.options.text-options.underline"
msgstr "Underline (%s)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.warichu"
msgstr "Warichu"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.warichu-none"
msgstr "Normal"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.writing-mode-horizontal"
msgstr "Horizontal"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.writing-mode-vertical"
msgstr "Vertical (right to left)"
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788
msgid "workspace.options.use-play-button"
msgstr "Use the play button at the header to run the prototype view."

View File

@ -777,6 +777,18 @@ msgstr "メンバーを招待"
msgid "dashboard.invite-profile-disabled"
msgstr "このチームにユーザーを招待する権限がありません"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.japanese-layout.description"
msgstr "すべてのファイルで日本語組版コントロールを利用できるようにします。"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.japanese-layout.enable-all-files"
msgstr "すべてのファイルで日本語組版を有効にする"
#: src/app/main/ui/settings/options.cljs
msgid "dashboard.japanese-layout.title"
msgstr "日本語組版"
#: src/app/main/ui/dashboard/organization_team_switch_menus.cljs:235, src/app/main/ui/dashboard/organization_team_switch_menus.cljs:266
msgid "dashboard.leave-organization"
msgstr "組織から退出"
@ -6168,6 +6180,10 @@ msgstr "放射状グラデーション"
msgid "workspace.header.menu.disable-dynamic-alignment"
msgstr "動的整列を無効化"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.disable-japanese-layout"
msgstr "日本語組版を無効にする"
#: src/app/main/ui/workspace/main_menu.cljs:254
msgid "workspace.header.menu.disable-scale-content"
msgstr "比率を保った拡大縮小を無効化"
@ -6192,6 +6208,10 @@ msgstr "WebGL レンダリングを無効化(ベータ)"
msgid "workspace.header.menu.enable-dynamic-alignment"
msgstr "動的整列を有効化"
#: src/app/main/ui/workspace/main_menu.cljs
msgid "workspace.header.menu.enable-japanese-layout"
msgstr "日本語組版を有効にする"
#: src/app/main/ui/workspace/main_menu.cljs:255
msgid "workspace.header.menu.enable-scale-content"
msgstr "比率を保った拡大縮小を有効化"
@ -7688,6 +7708,18 @@ msgstr "上下中央揃え"
msgid "workspace.options.text-options.align-top"
msgstr "上揃え"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.annotation-clearance"
msgstr "注釈の間隔"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.annotation-clearance-auto"
msgstr "自動"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.annotation-clearance-none"
msgstr "行送りを使用"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:100
msgid "workspace.options.text-options.direction-ltr"
msgstr "LTR"
@ -7696,6 +7728,22 @@ msgstr "LTR"
msgid "workspace.options.text-options.direction-rtl"
msgstr "RTL"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features"
msgstr "プロポーショナルメトリクス"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features-none"
msgstr "なし"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features-palt"
msgstr "横組み(palt)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.font-features-vpal"
msgstr "縦組み(vpal)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:167
msgid "workspace.options.text-options.grow-auto-height"
msgstr "高さの自動調整"
@ -7708,6 +7756,10 @@ msgstr "幅の自動調整"
msgid "workspace.options.text-options.grow-fixed"
msgstr "固定"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.japanese-layout"
msgstr "日本語組版"
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:688
msgid "workspace.options.text-options.letter-spacing"
msgstr "文字間隔"
@ -7716,6 +7768,82 @@ msgstr "文字間隔"
msgid "workspace.options.text-options.line-height"
msgstr "行の高さ"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby"
msgstr "ルビ"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-advanced-options"
msgstr "ふりがなの詳細設定"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align"
msgstr "ルビの配置"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-center"
msgstr "中央"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-space-around"
msgstr "均等割り"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-space-between"
msgstr "両端揃え"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-align-start"
msgstr "先頭"
#: src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs
msgid "workspace.options.text-options.ruby-hidden"
msgstr "ふりがなを非表示"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-overhang"
msgstr "ルビのはみ出し"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-overhang-auto"
msgstr "自動"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-overhang-none"
msgstr "なし"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-placeholder"
msgstr "ふりがな"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-side"
msgstr "ルビの側"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-side-over"
msgstr "上/右"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-side-under"
msgstr "下/左"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size"
msgstr "ルビの大きさ"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size-half"
msgstr "二分"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size-quarter"
msgstr "四分"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.ruby-size-third"
msgstr "三分"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:212
msgid "workspace.options.text-options.strikethrough"
msgstr "取り消し線(%s)"
@ -7736,6 +7864,74 @@ msgstr "左揃え"
msgid "workspace.options.text-options.text-align-right"
msgstr "右揃え"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-all"
msgstr "縦中横"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits"
msgstr "数字のみ"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-2"
msgstr "2桁まで"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-3"
msgstr "3桁まで"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-4"
msgstr "4桁まで"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-digits-count"
msgstr "桁数"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-combine-upright-none"
msgstr "通常"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis"
msgstr "圏点"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-filled-circle"
msgstr "黒丸(大)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-filled-dot"
msgstr "黒丸"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-filled-sesame"
msgstr "ゴマ点(黒)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-none"
msgstr "なし"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-open-circle"
msgstr "白丸(大)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-open-dot"
msgstr "白丸"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-emphasis-open-sesame"
msgstr "ゴマ点(白)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-orientation-mixed"
msgstr "混在"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.text-orientation-upright"
msgstr "正立"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:509
msgid "workspace.options.text-options.text-section"
msgstr "テキストセクション"
@ -7756,6 +7952,22 @@ msgstr "選択範囲のテキスト"
msgid "workspace.options.text-options.underline"
msgstr "下線(%s)"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.warichu"
msgstr "割注"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.warichu-none"
msgstr "通常"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.writing-mode-horizontal"
msgstr "横書き"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.options.text-options.writing-mode-vertical"
msgstr "縦書き(右から左)"
#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788
msgid "workspace.options.use-play-button"
msgstr "ヘッダーの再生ボタンを使ってプロトタイプを表示してください。"

View File

@ -110,6 +110,44 @@ test("create context with file and page", () => {
assert.equal(rootShape.id, "00000000-0000-0000-0000-000000000000");
});
test("create vertical text", () => {
const context = penpot.createBuildContext();
const fileId = context.addFile({name: "file 1"});
const pageId = context.addPage({name: "page 1"});
const textId = context.addText({
name: "Vertical text",
x: 10,
y: 20,
width: 100,
height: 200,
content: {
type: "root",
children: [
{
type: "paragraph-set",
children: [
{
type: "paragraph",
writingMode: "vertical-rl",
textOrientation: "upright",
children: [{text: "縦書き"}],
},
],
},
],
},
});
const internalState = context.getInternalState();
const paragraph =
internalState.files[fileId].data.pagesIndex[pageId].objects[textId].content
.children[0].children[0];
assert.equal(paragraph.writingMode, "vertical-rl");
assert.equal(paragraph.textOrientation, "upright");
});
test("create context with color", () => {
const context = penpot.createBuildContext();

View File

@ -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

View File

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

View File

@ -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.
*/

Binary file not shown.

Binary file not shown.

View File

@ -184,7 +184,7 @@ fn resources_init_headless() {
}
/// Initializes DesignState.
fn design_init() {
pub(crate) fn design_init() {
unsafe {
let design_state = State::new();
DESIGN_STATE = Box::into_raw(Box::new(design_state));

View File

@ -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
}
_ => {

View File

@ -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));
}
}

View File

@ -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
}
}

View File

@ -3,19 +3,161 @@ use crate::{
error::Result,
math::Rect,
shapes::{
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset,
Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind,
TextContent, TextDecorationSegment,
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
vertical_align_offset, Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout,
Stroke, StrokeKind, TextContent, TextDecorationSegment,
},
utils::{get_fallback_fonts, get_font_collection},
};
use skia_safe::{
self as skia,
canvas::SaveLayerRec,
textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration},
textlayout::{ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration},
Canvas, ImageFilter, Paint,
};
pub fn render_vertical_text(
state: &mut RenderState,
shape: &Shape,
text_content: &TextContent,
paragraph_builders: &mut [ParagraphBuilderGroup],
fills_surface_id: SurfaceId,
strokes_surface_id: SurfaceId,
skip_effects: bool,
) -> Result<()> {
let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten();
let bounds = text_content.bounds();
let skip_shadows = skip_effects || state.should_skip_drop_shadows();
let mut drop_shadows = if skip_shadows {
Vec::new()
} else {
shape.drop_shadow_paints()
};
if !skip_shadows {
if let Some(inherited_shadows) = state.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
}
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
text_vertical::layout_from_content(text_content, max_height)
});
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
canvas,
layout,
&bounds,
shape.vertical_align(),
shadow,
);
}
}
render(
Some(state),
None,
shape,
paragraph_builders,
Some(fills_surface_id),
None,
blur_filter.as_ref(),
None,
None,
)?;
if let Some(layout) = layout.as_ref().filter(|_| !strokes.is_empty()) {
let selrect = shape.selrect();
let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id);
for stroke in &strokes {
text_vertical::paint_stroke(
canvas,
layout,
&bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter.as_ref(),
);
}
}
if state.options.is_text_grid_visible() {
let owned_layout;
let grid_layout = match layout.as_ref() {
Some(layout) => layout,
None => {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
owned_layout = text_vertical::layout_from_content(text_content, max_height);
&owned_layout
}
};
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
text_vertical::paint_grid(canvas, grid_layout, &bounds, shape.vertical_align());
}
Ok(())
}
/// Paint a viewport-only grid over SkParagraph horizontal text. Blue outlines
/// show line boxes, green boxes show the per-scalar tight rectangles returned
/// by SkParagraph, and amber rules show baselines. This mirrors the vertical
/// text grid while making horizontal annotation anchors inspectable.
pub fn paint_horizontal_grid(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
let mut builders = text_content.paragraph_builder_group_from_text(None);
let layout = calculate_text_layout_data(shape, text_content, &mut builders, true);
let mut line_paint = Paint::default();
line_paint.set_anti_alias(true);
line_paint.set_style(skia::PaintStyle::Stroke);
line_paint.set_stroke_width(1.0);
line_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED));
let mut glyph_paint = Paint::default();
glyph_paint.set_anti_alias(true);
glyph_paint.set_style(skia::PaintStyle::Stroke);
glyph_paint.set_stroke_width(1.0);
glyph_paint.set_color(skia::Color::from_argb(0x99, 0x27, 0xAE, 0x60));
let mut baseline_paint = Paint::default();
baseline_paint.set_anti_alias(true);
baseline_paint.set_stroke_width(1.0);
baseline_paint.set_color(skia::Color::from_argb(0xCC, 0xEB, 0x57, 0x57));
for paragraph in &layout.paragraphs {
for line in paragraph.paragraph.get_line_metrics() {
let baseline = line.baseline as f32;
let top = paragraph.y + baseline - line.ascent as f32;
let left = paragraph.x + line.left as f32;
let width = line.width as f32;
canvas.draw_rect(
Rect::from_xywh(left, top, width, line.height as f32),
&line_paint,
);
canvas.draw_line(
(left, paragraph.y + baseline),
(left + width, paragraph.y + baseline),
&baseline_paint,
);
for offset in line.start_index..line.end_index {
for textbox in paragraph.paragraph.get_rects_for_range(
offset..offset + 1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
) {
let mut rect = textbox.rect;
rect.offset((paragraph.x, paragraph.y));
canvas.draw_rect(rect, &glyph_paint);
}
}
}
}
}
pub fn stroke_paragraph_builder_group_from_text(
text_content: &TextContent,
stroke: &Stroke,
@ -32,7 +174,8 @@ pub fn stroke_paragraph_builder_group_from_text(
let mut stroke_paragraphs_map: std::collections::HashMap<usize, ParagraphBuilder> =
std::collections::HashMap::new();
for span in paragraph.children().iter() {
let (span_texts, _) = paragraph.layout_span_texts();
for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
let (stroke_paints, stroke_layer_opacity) =
get_text_stroke_paints(stroke, bounds, remove_stroke_alpha);
@ -40,8 +183,6 @@ pub fn stroke_paragraph_builder_group_from_text(
group_layer_opacity = stroke_layer_opacity;
}
let text: String = span.apply_text_transform();
for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() {
let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| {
let paragraph_style = paragraph.paragraph_to_style();
@ -56,7 +197,7 @@ pub fn stroke_paragraph_builder_group_from_text(
paragraph.line_height(),
);
builder.push_style(&stroke_style);
add_text_with_tabs(builder, &text, span.font_size);
add_horizontal_span(builder, span, text, &stroke_style, fonts);
}
}
@ -70,7 +211,7 @@ pub fn stroke_paragraph_builder_group_from_text(
(paragraph_group, group_layer_opacity)
}
fn get_text_stroke_paints(
pub(crate) fn get_text_stroke_paints(
stroke: &Stroke,
bounds: &Rect,
remove_stroke_alpha: bool,
@ -332,6 +473,9 @@ pub fn try_paint_from_layout_cache(
layout_cache_rotation_only: bool,
) -> Result<bool> {
let text_content = shape.get_text_content();
if !text_content.can_paint_from_layout_cache() {
return Ok(false);
}
let cache_usable = if layout_cache_rotation_only {
text_content.layout_cache_versions_match()
} else {
@ -523,16 +667,62 @@ fn paint_text_with_emoji_overlay(
overlay_emoji: bool,
) {
let text_content = shape.get_text_content();
// Vertical writing renders through the custom vertical pass.
// Stored text bounds describe the measured content and can be taller than
// a fixed shape. Rebind to the selrect so the shape height remains the
// column-wrap budget used by the vertical painter.
let vertical_text_content = text_content
.is_vertical()
.then(|| text_content.new_bounds(shape.selrect()));
if vertical_text_content.as_ref().is_some_and(|content| {
crate::shapes::text_vertical::paint_text_vertical(canvas, content, shape.vertical_align())
}) {
return;
}
let mut layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
// Ruby draws only when the fill pass has the standard one-layout-per-
// paragraph shape (stroke/shadow silhouette passes never reach here).
let ruby_per_paragraph = layout_info.paragraphs.len() == text_content.paragraphs().len();
for para in &mut layout_info.paragraphs {
para.paragraph.paint(canvas, (para.x, para.y));
if let Some(source_paragraph) = text_content.paragraphs().get(para.source_paragraph) {
crate::shapes::paint_horizontal_warichu(
canvas,
source_paragraph,
&para.paragraph,
para.x,
para.y,
);
crate::shapes::paint_horizontal_emphasis(
canvas,
source_paragraph,
&para.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,
&para.paragraph,
para.x,
para.y,
);
}
for deco in &para.decorations {
draw_decoration_segment(canvas, deco);
}

View File

@ -1,4 +1,5 @@
use crate::render::options::RenderOptions;
use crate::shapes::text_vertical;
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox;
@ -51,17 +52,33 @@ fn render_cursor(
return;
};
// In vertical writing the caret is a thin horizontal bar across the
// column; in horizontal writing a thin vertical bar.
let thin = editor_state.theme.cursor_width / zoom * dpr;
let mut cursor_rect = Rect::new_empty();
cursor_rect.set_xywh(
rect.x(),
rect.y(),
if editor_state.is_overtype_mode {
rect.width()
} else {
editor_state.theme.cursor_width / zoom * dpr
},
rect.height(),
);
if text_content.is_vertical() {
cursor_rect.set_xywh(
rect.x(),
rect.y(),
rect.width(),
if editor_state.is_overtype_mode && rect.height() > 0.0 {
rect.height()
} else {
thin
},
);
} else {
cursor_rect.set_xywh(
rect.x(),
rect.y(),
if editor_state.is_overtype_mode {
rect.width()
} else {
thin
},
rect.height(),
);
}
let mut paint = Paint::default();
paint.set_anti_alias(false);
@ -124,6 +141,87 @@ fn paragraphs_vertical_offset(
)
}
fn vertical_layout_for_shape(
text_content: &TextContent,
shape: &Shape,
) -> (text_vertical::VerticalLayout, f32) {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
(layout, origin_x)
}
fn calculate_vertical_cursor_rect(
text_content: &TextContent,
shape: &Shape,
paragraph: usize,
offset: usize,
) -> Option<Rect> {
let (layout, origin_x) = vertical_layout_for_shape(text_content, shape);
let rect = text_vertical::caret_rect(&layout, paragraph, offset)?;
Some(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
))
}
fn calculate_vertical_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
let (layout, origin_x) = vertical_layout_for_shape(text_content, shape);
let mut rects = Vec::new();
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
.take(end.paragraph + 1)
.skip(start.paragraph)
{
let para_char_count: usize = paragraph
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
} else {
para_char_count
};
for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) {
rects.push(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
));
}
}
rects
}
/// Caret rectangle relative to the shape's top-left corner.
pub(crate) fn cursor_rect(
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
calculate_cursor_rect(editor_state, text_content, shape)
}
fn calculate_cursor_rect(
editor_state: &TextEditorState,
text_content: &TextContent,
@ -135,6 +233,15 @@ fn calculate_cursor_rect(
return None;
}
if text_content.is_vertical() {
return calculate_vertical_cursor_rect(
text_content,
shape,
cursor.paragraph,
cursor.offset,
);
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
@ -150,19 +257,30 @@ fn calculate_cursor_rect(
// - At start of paragraph: use position 0
// - At end of paragraph: use last position
let para = &paragraphs[cursor.paragraph];
if let Some(rect) =
crate::shapes::horizontal_warichu_caret_rect(para, laid_out_para, char_pos)
{
return Some(Rect::from_xywh(
rect.x(),
y_offset + rect.y(),
rect.width(),
rect.height(),
));
}
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
// Skia ranges are UTF-16 code units, not characters.
// Cursor offsets count source characters; the laid-out paragraph
// indexes the transformed, kinsoku-shifted builder text.
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, 0.0, 1.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..para.char_utf16_len_at(0),
0..crate::shapes::horizontal_source_to_builder(para, 1),
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@ -173,10 +291,13 @@ fn calculate_cursor_rect(
(0.0, 0.0, 1.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let last_char = para_char_count.saturating_sub(1);
let last_start = para.char_offset_to_utf16(last_char);
let last_start = crate::shapes::horizontal_source_to_builder(
para,
para_char_count.saturating_sub(1),
);
let last_end = crate::shapes::horizontal_source_to_builder(para, para_char_count);
let rects = laid_out_para.get_rects_for_range(
last_start..last_start + para.char_utf16_len_at(last_char),
last_start..last_end,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@ -194,9 +315,10 @@ fn calculate_cursor_rect(
(0.0, 0.0, 1.0, laid_out_para.height())
}
} else {
let utf16_pos = para.char_offset_to_utf16(char_pos);
let start = crate::shapes::horizontal_source_to_builder(para, char_pos);
let end = crate::shapes::horizontal_source_to_builder(para, char_pos + 1);
let rects = laid_out_para.get_rects_for_range(
utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos),
start..end,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@ -228,12 +350,17 @@ fn calculate_selection_rects(
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
if text_content.is_vertical() {
return calculate_vertical_selection_rects(selection, text_content, shape);
}
let mut rects = Vec::new();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
@ -268,15 +395,15 @@ fn calculate_selection_rects(
};
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end),
RectHeightStyle::Max,
RectWidthStyle::Tight,
// Selection offsets live in original text space; the
// laid-out paragraph indexes the kinsoku-shifted text.
let warichu_rects = crate::shapes::horizontal_warichu_range_rects(
para,
laid_out_para,
range_start,
range_end,
);
for text_box in text_boxes {
let r = text_box.rect;
for r in warichu_rects {
rects.push(Rect::from_xywh(
r.left(),
y_offset + r.top(),
@ -284,6 +411,24 @@ fn calculate_selection_rects(
r.height(),
));
}
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
for builder_range in
crate::shapes::horizontal_normal_selection_ranges(para, range_start, range_end)
{
for text_box in laid_out_para.get_rects_for_range(
builder_range,
RectHeightStyle::Max,
RectWidthStyle::Tight,
) {
let r = text_box.rect;
rects.push(Rect::from_xywh(
r.left(),
y_offset + r.top(),
r.width(),
r.height(),
));
}
}
}
y_offset += para_height;

View File

@ -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();

View File

@ -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 {

View File

@ -0,0 +1,287 @@
//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate
//! metrics) feature. Vertical layout applies these deltas itself: cells
//! flow by `vmtx` advances, so HarfBuzz never gets a chance to apply
//! vertical GPOS positioning (SkShaper shapes on a horizontal line).
//!
//! Only single-adjustment lookups are read (SinglePos, directly or behind
//! an Extension lookup) — `vpal` is metrics-only by design and real fonts
//! (Noto CJK, Source Han) encode it exactly this way.
use std::collections::HashMap;
/// Font-unit deltas for one glyph. `y_placement` is y-up (positive lifts
/// the ink toward the flow start); `y_advance` is negative when the
/// vertical advance shrinks.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct VpalDelta {
pub y_placement: i16,
pub y_advance: i16,
}
fn read_u16(data: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_be_bytes([
*data.get(offset)?,
*data.get(offset + 1)?,
]))
}
fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
read_u16(data, offset).map(|v| v as i16)
}
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*data.get(offset)?,
*data.get(offset + 1)?,
*data.get(offset + 2)?,
*data.get(offset + 3)?,
]))
}
/// Glyph ids covered by a Coverage table, in coverage-index order.
fn parse_coverage(data: &[u8], offset: usize) -> Option<Vec<u16>> {
match read_u16(data, offset)? {
1 => {
let count = read_u16(data, offset + 2)? as usize;
(0..count)
.map(|i| read_u16(data, offset + 4 + i * 2))
.collect()
}
2 => {
let range_count = read_u16(data, offset + 2)? as usize;
let mut glyphs = Vec::new();
for i in 0..range_count {
let o = offset + 4 + i * 6;
let start = read_u16(data, o)?;
let end = read_u16(data, o + 2)?;
if end < start {
return None;
}
glyphs.extend(start..=end);
}
Some(glyphs)
}
_ => None,
}
}
/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit
/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device
/// offsets). Returns the y deltas and consumes nothing else.
fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option<VpalDelta> {
let mut delta = VpalDelta::default();
let mut o = offset;
for bit in 0..8 {
if value_format & (1 << bit) == 0 {
continue;
}
match bit {
1 => delta.y_placement = read_i16(data, o)?,
3 => delta.y_advance = read_i16(data, o)?,
_ => {}
}
o += 2;
}
Some(delta)
}
fn value_record_size(value_format: u16) -> usize {
(value_format & 0x00FF).count_ones() as usize * 2
}
/// Accumulate a SinglePos subtable into `deltas`.
fn parse_single_pos(
data: &[u8],
offset: usize,
deltas: &mut HashMap<u16, VpalDelta>,
) -> Option<()> {
let format = read_u16(data, offset)?;
let coverage = parse_coverage(data, offset + read_u16(data, offset + 2)? as usize)?;
let value_format = read_u16(data, offset + 4)?;
match format {
1 => {
let value = parse_value_record(data, offset + 6, value_format)?;
for glyph in coverage {
let entry = deltas.entry(glyph).or_default();
entry.y_placement += value.y_placement;
entry.y_advance += value.y_advance;
}
}
2 => {
let count = read_u16(data, offset + 6)? as usize;
let size = value_record_size(value_format);
for (i, glyph) in coverage.into_iter().take(count).enumerate() {
let value = parse_value_record(data, offset + 8 + i * size, value_format)?;
let entry = deltas.entry(glyph).or_default();
entry.y_placement += value.y_placement;
entry.y_advance += value.y_advance;
}
}
_ => {}
}
Some(())
}
/// Parse the `vpal` deltas out of a raw `GPOS` table. Returns `None` when
/// the table is malformed or carries no `vpal` feature.
pub fn parse_vpal(gpos: &[u8]) -> Option<HashMap<u16, VpalDelta>> {
let feature_list = read_u16(gpos, 6)? as usize;
let lookup_list = read_u16(gpos, 8)? as usize;
// Collect the lookup indices of every feature tagged `vpal`,
// regardless of script: the deltas are per-glyph metrics.
let feature_count = read_u16(gpos, feature_list)? as usize;
let mut lookup_indices = Vec::new();
for i in 0..feature_count {
let record = feature_list + 2 + i * 6;
if gpos.get(record..record.checked_add(4)?)? != b"vpal" {
continue;
}
let feature = feature_list + read_u16(gpos, record + 4)? as usize;
let index_count = read_u16(gpos, feature + 2)? as usize;
for j in 0..index_count {
lookup_indices.push(read_u16(gpos, feature + 4 + j * 2)? as usize);
}
}
lookup_indices.sort_unstable();
lookup_indices.dedup();
if lookup_indices.is_empty() {
return None;
}
let lookup_count = read_u16(gpos, lookup_list)? as usize;
let mut deltas = HashMap::new();
for index in lookup_indices {
if index >= lookup_count {
continue;
}
let lookup = lookup_list + read_u16(gpos, lookup_list + 2 + index * 2)? as usize;
let lookup_type = read_u16(gpos, lookup)?;
let subtable_count = read_u16(gpos, lookup + 4)? as usize;
for s in 0..subtable_count {
let subtable = lookup + read_u16(gpos, lookup + 6 + s * 2)? as usize;
match lookup_type {
1 => {
parse_single_pos(gpos, subtable, &mut deltas)?;
}
9 => {
// ExtensionPos: { format: u16, extensionLookupType: u16,
// extensionOffset: u32 (from the subtable start) }.
if read_u16(gpos, subtable + 2)? == 1 {
let inner = subtable + read_u32(gpos, subtable + 4)? as usize;
parse_single_pos(gpos, inner, &mut deltas)?;
}
}
_ => {}
}
}
}
if deltas.is_empty() {
None
} else {
Some(deltas)
}
}
#[cfg(test)]
mod tests {
use super::*;
// Subset of Noto Sans JP: 〱あく「」、。 with `vert` alternates and the
// real `vpal` deltas (Extension→SinglePos, both coverage and both
// SinglePos formats, value formats 0x8 and 0xA).
const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf");
fn gpos_table() -> Vec<u8> {
// Locate the GPOS table in the raw sfnt directory.
let data = VPAL_TEST_FONT;
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
for i in 0..num_tables {
let record = 12 + i * 16;
if &data[record..record + 4] == b"GPOS" {
let offset = u32::from_be_bytes([
data[record + 8],
data[record + 9],
data[record + 10],
data[record + 11],
]) as usize;
let length = u32::from_be_bytes([
data[record + 12],
data[record + 13],
data[record + 14],
data[record + 15],
]) as usize;
return data[offset..offset + length].to_vec();
}
}
panic!("test font has no GPOS table");
}
#[test]
fn parses_noto_vpal_deltas() {
let deltas = parse_vpal(&gpos_table()).expect("vpal deltas");
// Vertical alternates: 、→8 。→9 「→10 」→11 あ→12 く→13.
assert_eq!(
deltas.get(&8),
Some(&VpalDelta {
y_placement: 0,
y_advance: -500
}),
"、 halves its vertical advance"
);
assert_eq!(
deltas.get(&10),
Some(&VpalDelta {
y_placement: 481,
y_advance: -500
}),
"「 lifts its trailing-half ink into the compressed cell"
);
assert_eq!(
deltas.get(&12),
Some(&VpalDelta {
y_placement: 39,
y_advance: -58
}),
"あ tightens slightly"
);
// Base (horizontal) glyphs carry no vpal.
assert!(!deltas.contains_key(&6), "base あ glyph is uncovered");
}
#[test]
fn missing_vpal_returns_none() {
// A GPOS header with an empty feature list.
let gpos = [
0x00, 0x01, 0x00, 0x00, // version 1.0
0x00, 0x0A, // scriptList
0x00, 0x0C, // featureList
0x00, 0x0E, // lookupList
0x00, 0x00, // scriptCount
0x00, 0x00, // featureCount
0x00, 0x00, // lookupCount
];
assert!(parse_vpal(&gpos).is_none());
}
#[test]
fn truncated_table_is_rejected() {
let table = gpos_table();
for len in [0usize, 4, 9, 16] {
assert!(parse_vpal(&table[..len.min(table.len())]).is_none());
}
}
#[test]
fn truncated_feature_records_are_rejected() {
let gpos = [
0x00, 0x01, 0x00, 0x00, // version 1.0
0x00, 0x0A, // scriptList
0x00, 0x0A, // featureList
0x00, 0x0A, // lookupList
0x00, 0x02, // featureCount without matching records
];
assert!(parse_vpal(&gpos).is_none());
}
}

View File

@ -0,0 +1,540 @@
//! Shared JLREQ character classes and pair-rule tables.
//!
//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are
//! contextual/virtual classes produced by higher-level inline composites;
//! [`classify`] handles scalar characters and callers assign those virtual
//! classes when constructing reference marks, ruby, grouped numerals,
//! warichu, or tate-chu-yoko.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum JapaneseClass {
OpeningBracket = 0,
ClosingBracket,
Hyphen,
DividingPunctuation,
MiddleDot,
FullStop,
Comma,
Inseparable,
IterationMark,
ProlongedSoundMark,
SmallKana,
PrefixedAbbreviation,
PostfixedAbbreviation,
IdeographicSpace,
Hiragana,
Katakana,
MathSymbol,
MathOperator,
Ideographic,
ReferenceMark,
OrnamentedComplex,
SimpleRuby,
JukugoRuby,
GroupedNumeral,
UnitSymbol,
WesternWordSpace,
Western,
WarichuOpening,
WarichuClosing,
TateChuYoko,
}
impl JapaneseClass {
pub const COUNT: usize = 30;
pub const ALL: [Self; Self::COUNT] = [
Self::OpeningBracket,
Self::ClosingBracket,
Self::Hyphen,
Self::DividingPunctuation,
Self::MiddleDot,
Self::FullStop,
Self::Comma,
Self::Inseparable,
Self::IterationMark,
Self::ProlongedSoundMark,
Self::SmallKana,
Self::PrefixedAbbreviation,
Self::PostfixedAbbreviation,
Self::IdeographicSpace,
Self::Hiragana,
Self::Katakana,
Self::MathSymbol,
Self::MathOperator,
Self::Ideographic,
Self::ReferenceMark,
Self::OrnamentedComplex,
Self::SimpleRuby,
Self::JukugoRuby,
Self::GroupedNumeral,
Self::UnitSymbol,
Self::WesternWordSpace,
Self::Western,
Self::WarichuOpening,
Self::WarichuClosing,
Self::TateChuYoko,
];
pub const fn index(self) -> usize {
self as usize
}
pub const fn forbids_line_start(self) -> bool {
matches!(
self,
Self::ClosingBracket
| Self::Hyphen
| Self::DividingPunctuation
| Self::MiddleDot
| Self::FullStop
| Self::Comma
| Self::Inseparable
| Self::IterationMark
| Self::ProlongedSoundMark
| Self::SmallKana
| Self::PostfixedAbbreviation
| Self::WarichuClosing
)
}
pub const fn forbids_line_end(self) -> bool {
matches!(
self,
Self::OpeningBracket | Self::PrefixedAbbreviation | Self::WarichuOpening
)
}
pub const fn is_japanese_letter(self) -> bool {
matches!(self, Self::Hiragana | Self::Katakana | Self::Ideographic)
}
pub const fn is_western_run(self) -> bool {
matches!(
self,
Self::GroupedNumeral | Self::UnitSymbol | Self::Western
)
}
pub const fn is_emphasis_prohibited(self) -> bool {
matches!(
self,
Self::OpeningBracket | Self::ClosingBracket | Self::FullStop | Self::Comma
)
}
/// Half-width punctuation whose normal character frame is completed by
/// half an em after the glyph. Consecutive punctuation may suppress that
/// appended spacing, but the glyph body itself remains half-width.
pub const fn is_trailing_aki_punctuation(self) -> bool {
matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PairRule {
/// Preferred extra spacing between the two character frames, in em.
pub preferred_em: f32,
/// Smallest spacing allowed during oikomi, in em.
pub minimum_em: f32,
/// Largest spacing allowed during oidashi/justification, in em.
pub maximum_em: f32,
pub break_allowed: bool,
/// Whether horizontal SkParagraph needs an inserted WORD JOINER for this
/// pair. Atomic Western/numeral runs are already protected by its Unicode
/// breaker, so they remain non-breakable without synthetic characters.
pub suppress_break_with_joiner: bool,
/// Lower values are adjusted first; zero means not adjustable.
pub shrink_priority: u8,
pub expand_priority: u8,
}
impl PairRule {
const SOLID: Self = Self {
preferred_em: 0.0,
minimum_em: 0.0,
maximum_em: 0.0,
break_allowed: true,
suppress_break_with_joiner: false,
shrink_priority: 0,
expand_priority: 0,
};
}
const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] {
let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT];
let mut before_index = 0;
while before_index < JapaneseClass::COUNT {
let before = JapaneseClass::ALL[before_index];
let mut after_index = 0;
while after_index < JapaneseClass::COUNT {
let after = JapaneseClass::ALL[after_index];
let mut rule = PairRule::SOLID;
rule.suppress_break_with_joiner =
before.forbids_line_end() || after.forbids_line_start();
rule.break_allowed = !rule.suppress_break_with_joiner;
if before_index == after_index
&& matches!(
before,
JapaneseClass::Inseparable
| JapaneseClass::GroupedNumeral
| JapaneseClass::Western
)
{
rule.break_allowed = false;
}
if matches!(before, JapaneseClass::WesternWordSpace) {
rule.preferred_em = 1.0 / 3.0;
rule.minimum_em = 0.25;
rule.maximum_em = 0.5;
rule.shrink_priority = 1;
rule.expand_priority = 1;
} else if (before.is_japanese_letter() && after.is_western_run())
|| (before.is_western_run() && after.is_japanese_letter())
{
rule.preferred_em = 0.25;
rule.minimum_em = 0.125;
rule.maximum_em = 0.5;
rule.shrink_priority = 5;
rule.expand_priority = 2;
} else if matches!(
(before, after),
(
JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop,
JapaneseClass::TateChuYoko
) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket)
) {
rule.preferred_em = 0.5;
rule.minimum_em = 0.0;
rule.maximum_em = 0.5;
rule.shrink_priority = 3;
} else if matches!(
(before, after),
(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko)
) {
// Two adjacent cl-30 entries are necessarily separate TCY
// composites (characters inside one composite are atomic).
rule.maximum_em = 0.25;
rule.expand_priority = 3;
} else if matches!(before, JapaneseClass::DividingPunctuation)
&& !matches!(after, JapaneseClass::ClosingBracket)
{
// A sentence-ending question/exclamation mark carries one em
// after it. Line planning may discard this at the line edge.
rule.preferred_em = 1.0;
rule.minimum_em = 0.0;
rule.maximum_em = 1.0;
rule.shrink_priority = 2;
} else if before.is_trailing_aki_punctuation()
&& matches!(after, JapaneseClass::OpeningBracket)
{
// Only one half-em is retained between the two half-width
// glyph bodies, not the sum of both characters' normal aki.
rule.preferred_em = 0.5;
rule.minimum_em = 0.0;
rule.maximum_em = 0.5;
rule.shrink_priority = 4;
} else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() {
// Consecutive closing punctuation sets solid internally; the
// last character in the sequence supplies the trailing aki.
} else if matches!(before, JapaneseClass::OpeningBracket)
&& matches!(after, JapaneseClass::OpeningBracket)
{
// Consecutive opening brackets set solid internally; the first
// character in the sequence supplies the leading aki.
} else if (before.is_trailing_aki_punctuation()
&& matches!(after, JapaneseClass::MiddleDot))
|| (matches!(before, JapaneseClass::MiddleDot)
&& matches!(after, JapaneseClass::OpeningBracket))
{
rule.preferred_em = 0.25;
rule.minimum_em = 0.0;
rule.maximum_em = 0.25;
rule.shrink_priority = 3;
} else if before.is_trailing_aki_punctuation() {
rule.preferred_em = 0.5;
rule.minimum_em = if matches!(before, JapaneseClass::FullStop) {
0.5
} else {
0.0
};
rule.maximum_em = 0.5;
rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) {
0
} else {
4
};
} else if matches!(after, JapaneseClass::OpeningBracket) {
rule.preferred_em = 0.5;
rule.minimum_em = 0.0;
rule.maximum_em = 0.5;
rule.shrink_priority = 4;
} else if matches!(before, JapaneseClass::MiddleDot)
|| matches!(after, JapaneseClass::MiddleDot)
{
rule.preferred_em = 0.25;
rule.minimum_em = 0.0;
rule.maximum_em = 0.25;
rule.shrink_priority = 3;
} else if before.is_japanese_letter() && after.is_japanese_letter() {
// Solid Japanese text is the general third-stage expansion
// opportunity. The planner may continue past this quarter-em
// cap only in JLREQ's final equal-expansion fallback.
rule.maximum_em = 0.25;
rule.expand_priority = 3;
}
table[before_index][after_index] = rule;
after_index += 1;
}
before_index += 1;
}
table
}
pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] =
generated_pair_rules();
pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule {
PAIR_RULES[before.index()][after.index()]
}
const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“";
const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”";
const HYPHENS: &str = "‐゠–〜~";
const DIVIDING_PUNCTUATION: &str = "!?‼⁇⁈⁉";
const MIDDLE_DOTS: &str = "・・:;";
const FULL_STOPS: &str = "。.";
const COMMAS: &str = "、,";
const INSEPARABLE: &str = "―…‥〳〴〵";
const ITERATION_MARKS: &str = "々〻ゝゞヽヾ";
const SMALL_KANA: &str = concat!(
"ぁぃぅぇぉっゃゅょゎゕゖ",
"ァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ"
);
const PREFIXED_ABBREVIATIONS: &str = "¥¥$$££##";
const POSTFIXED_ABBREVIATIONS: &str = "°′″℃¢¢%%‰‱";
const MATH_SYMBOLS: &str = "==≠<<>>≦≧≤≥∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔∥";
const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏";
pub fn classify(c: char) -> JapaneseClass {
if OPENING_BRACKETS.contains(c) {
JapaneseClass::OpeningBracket
} else if CLOSING_BRACKETS.contains(c) {
JapaneseClass::ClosingBracket
} else if HYPHENS.contains(c) {
JapaneseClass::Hyphen
} else if DIVIDING_PUNCTUATION.contains(c) {
JapaneseClass::DividingPunctuation
} else if MIDDLE_DOTS.contains(c) {
JapaneseClass::MiddleDot
} else if FULL_STOPS.contains(c) {
JapaneseClass::FullStop
} else if COMMAS.contains(c) {
JapaneseClass::Comma
} else if INSEPARABLE.contains(c) {
JapaneseClass::Inseparable
} else if ITERATION_MARKS.contains(c) {
JapaneseClass::IterationMark
} else if c == 'ー' {
JapaneseClass::ProlongedSoundMark
} else if SMALL_KANA.contains(c) {
JapaneseClass::SmallKana
} else if PREFIXED_ABBREVIATIONS.contains(c) {
JapaneseClass::PrefixedAbbreviation
} else if POSTFIXED_ABBREVIATIONS.contains(c) {
JapaneseClass::PostfixedAbbreviation
} else if c == '\u{3000}' {
JapaneseClass::IdeographicSpace
} else if MATH_SYMBOLS.contains(c) {
JapaneseClass::MathSymbol
} else if MATH_OPERATORS.contains(c) {
JapaneseClass::MathOperator
} else if c == ' ' || c == '\t' || c == '\u{00a0}' {
JapaneseClass::WesternWordSpace
} else if c.is_ascii_digit() {
JapaneseClass::GroupedNumeral
} else if is_hiragana(c) {
JapaneseClass::Hiragana
} else if is_katakana(c) {
JapaneseClass::Katakana
} else if is_ideographic(c) {
JapaneseClass::Ideographic
} else if is_unit_symbol(c) {
JapaneseClass::UnitSymbol
} else {
JapaneseClass::Western
}
}
fn is_hiragana(c: char) -> bool {
matches!(u32::from(c), 0x3041..=0x309F)
}
fn is_katakana(c: char) -> bool {
matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D)
}
fn is_ideographic(c: char) -> bool {
matches!(u32::from(c),
0x2E80..=0x2FDF
| 0x31C0..=0x31EF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xF900..=0xFAFF
| 0x20000..=0x2FA1F
) || matches!(c, '〃' | '仝' | '〆' | '♂' | '♀')
}
fn is_unit_symbol(c: char) -> bool {
matches!(u32::from(c), 0x2100..=0x214F | 0x3300..=0x33FF)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn class_model_contains_all_thirty_jlreq_classes_in_order() {
assert_eq!(JapaneseClass::ALL.len(), 30);
for (index, class) in JapaneseClass::ALL.iter().enumerate() {
assert_eq!(class.index(), index);
}
}
#[test]
fn classifies_representative_jlreq_characters() {
let cases = [
('「', JapaneseClass::OpeningBracket),
('」', JapaneseClass::ClosingBracket),
('〜', JapaneseClass::Hyphen),
('!', JapaneseClass::DividingPunctuation),
('・', JapaneseClass::MiddleDot),
('。', JapaneseClass::FullStop),
('、', JapaneseClass::Comma),
('…', JapaneseClass::Inseparable),
('々', JapaneseClass::IterationMark),
('ー', JapaneseClass::ProlongedSoundMark),
('ょ', JapaneseClass::SmallKana),
('¥', JapaneseClass::PrefixedAbbreviation),
('%', JapaneseClass::PostfixedAbbreviation),
('\u{3000}', JapaneseClass::IdeographicSpace),
('あ', JapaneseClass::Hiragana),
('ア', JapaneseClass::Katakana),
('≠', JapaneseClass::MathSymbol),
('+', JapaneseClass::MathOperator),
('漢', JapaneseClass::Ideographic),
('2', JapaneseClass::GroupedNumeral),
('㎏', JapaneseClass::UnitSymbol),
(' ', JapaneseClass::WesternWordSpace),
('A', JapaneseClass::Western),
];
for (character, expected) in cases {
assert_eq!(classify(character), expected, "wrong class for {character}");
}
}
#[test]
fn generated_rules_cover_every_class_pair() {
assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT);
assert!(PAIR_RULES
.iter()
.all(|row| row.len() == JapaneseClass::COUNT));
}
#[test]
fn generated_rules_encode_kinsoku_and_atomic_runs() {
assert!(
!pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed
);
assert!(
!pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed
);
assert!(
!pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed
);
assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed);
}
#[test]
fn generated_rules_encode_script_and_tcy_spacing() {
let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western);
assert_eq!(script.preferred_em, 0.25);
assert_eq!(script.minimum_em, 0.125);
assert_eq!(script.maximum_em, 0.5);
let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko);
assert_eq!(tcy.preferred_em, 0.5);
assert_eq!(
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em,
0.0
);
assert_eq!(
pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em,
0.0
);
assert_eq!(
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em,
0.5
);
assert_eq!(
pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em,
0.0
);
assert_eq!(
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em,
0.0
);
let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko);
assert_eq!(adjacent_tcy.preferred_em, 0.0);
assert_eq!(adjacent_tcy.maximum_em, 0.25);
assert_eq!(adjacent_tcy.expand_priority, 3);
}
#[test]
fn generated_rules_encode_punctuation_sequences() {
assert_eq!(
pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em,
0.5
);
assert_eq!(
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em,
0.5
);
assert_eq!(
pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em,
0.0
);
assert_eq!(
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em,
0.5
);
assert_eq!(
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em,
0.25
);
assert_eq!(
pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em,
0.25
);
assert_eq!(
pair_rule(
JapaneseClass::DividingPunctuation,
JapaneseClass::Ideographic
)
.preferred_em,
1.0
);
let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana);
assert_eq!(solid_japanese.preferred_em, 0.0);
assert_eq!(solid_japanese.maximum_em, 0.25);
assert_eq!(solid_japanese.expand_priority, 3);
let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby);
assert_eq!(ruby_adjacency.preferred_em, 0.0);
assert_eq!(ruby_adjacency.maximum_em, 0.0);
assert_eq!(ruby_adjacency.expand_priority, 0);
}
}

View File

@ -0,0 +1,514 @@
//! Japanese line-breaking rules (kinsoku shori).
//!
//! Skia's default break iterator allows closing punctuation, the
//! prolonged sound mark or small kana at a line start, and opening
//! brackets at a line end. skia-safe exposes no ICU BreakIterator, so
//! the forbidden break opportunities are suppressed by inserting U+2060 WORD
//! JOINER into the text handed to skparagraph. The same lossless layout-text
//! transform inserts JLREQ quarter-em Japanese/Western boundary space and
//! normalizes Western word spaces to one third em.
//!
//! The inserted joiners shift every UTF-16 offset reported by the laid
//! out paragraph (position-data, caret mapping, selection rects). The
//! [`OffsetMap`] returned along with the modified texts translates
//! between original and joiner-shifted offsets. It is a pure function
//! of the span texts, so any consumer can recompute it and stay
//! consistent with the builders by construction.
/// Zero-width character whose UAX #14 class forbids breaking on either
/// side of it.
pub const WORD_JOINER: char = '\u{2060}';
/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap.
pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}';
/// Unicode THREE-PER-EM SPACE, used for Western word spacing in Japanese text.
pub const WESTERN_WORD_SPACE: char = '\u{2004}';
use super::japanese::{classify, pair_rule};
pub fn forbidden_at_line_start(c: char) -> bool {
classify(c).forbids_line_start()
}
pub fn forbidden_at_line_end(c: char) -> bool {
classify(c).forbids_line_end()
}
/// Translates between original UTF-16 offsets (the source-of-truth span text)
/// and layout-text UTF-16 offsets (the text handed to skparagraph).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct OffsetMap {
/// UTF-16 indices of inserted joiners or boundary spaces, ascending and
/// expressed in shifted coordinates. Same-length substitutions need no
/// entry.
inserted: Vec<usize>,
}
impl OffsetMap {
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.inserted.is_empty()
}
/// Original offset for a shifted offset. An offset pointing at an inserted
/// character resolves to the source boundary where it was inserted.
pub fn to_original(&self, shifted: usize) -> usize {
shifted - self.inserted.iter().take_while(|&&p| p < shifted).count()
}
/// Shifted offset for an original offset. A boundary that received a
/// layout character resolves after it, so carets avoid synthetic spacing.
pub fn to_shifted(&self, original: usize) -> usize {
let mut shifted = original;
for &p in &self.inserted {
if p <= shifted {
shifted += 1;
} else {
break;
}
}
shifted
}
}
/// Applies the horizontal Japanese layout-text transform. It inserts WORD
/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space at
/// Japanese↔Western boundaries, and normalizes breakable ASCII word spaces to
/// one third em. Span boundaries are transparent. Returns `None` when the
/// paragraph needs no transformation.
/// Apply the normal Japanese layout transform while additionally protecting
/// annotated base units. `ruby_breaks[span] == Some(boundaries)` means that
/// every internal scalar boundary except those UTF-16 offsets is atomic.
pub fn apply_to_span_texts_with_ruby_breaks(
span_texts: &[String],
ruby_breaks: &[Option<Vec<usize>>],
) -> Option<(Vec<String>, OffsetMap)> {
let mut inserted: Vec<usize> = Vec::new();
let mut out: Vec<String> = Vec::with_capacity(span_texts.len());
let mut prev: Option<char> = None;
let mut changed = false;
// Running position in shifted UTF-16 coordinates.
let mut shifted_pos: usize = 0;
for (span_index, text) in span_texts.iter().enumerate() {
let mut shifted_text = String::with_capacity(text.len() + 4);
let mut local_utf16 = 0usize;
for c in text.chars() {
let ruby_forbids_break = local_utf16 > 0
&& ruby_breaks
.get(span_index)
.and_then(Option::as_ref)
.is_some_and(|breaks| !breaks.contains(&local_utf16));
let forbid_break = ruby_forbids_break
|| match prev {
Some(p) => pair_rule(classify(p), classify(c)).suppress_break_with_joiner,
None => false,
};
if forbid_break {
shifted_text.push(WORD_JOINER);
inserted.push(shifted_pos);
shifted_pos += 1;
changed = true;
} else if prev.is_some_and(|p| {
let before = classify(p);
let after = classify(c);
(before.is_japanese_letter() && after.is_western_run())
|| (before.is_western_run() && after.is_japanese_letter())
}) {
shifted_text.push(JAPANESE_WESTERN_SPACE);
inserted.push(shifted_pos);
shifted_pos += 1;
changed = true;
}
let layout_char = if c == ' ' {
changed = true;
WESTERN_WORD_SPACE
} else {
c
};
shifted_text.push(layout_char);
shifted_pos += c.len_utf16();
local_utf16 += c.len_utf16();
prev = Some(c);
}
out.push(shifted_text);
}
if !changed {
return None;
}
Some((out, OffsetMap { inserted }))
}
#[cfg(test)]
mod tests {
use super::*;
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider,
};
use skia_safe::FontMgr;
const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
fn strings(texts: &[&str]) -> Vec<String> {
texts.iter().map(|t| t.to_string()).collect()
}
fn apply(texts: &[&str]) -> (Vec<String>, OffsetMap) {
apply_to_span_texts_with_ruby_breaks(&strings(texts), &[])
.expect("expected kinsoku insertions")
}
// -----------------------------------------------------------------
// Character classes
// -----------------------------------------------------------------
#[test]
fn classes_forbidden_at_start() {
for c in "、。」』)ーっゃァッ々・!?".chars() {
assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}");
}
for c in "あ漢A1「( ".chars() {
assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}");
}
}
#[test]
fn classes_forbidden_at_end() {
for c in "「『([【〈《".chars() {
assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}");
}
for c in "あ漢A1」)。".chars() {
assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}");
}
}
// -----------------------------------------------------------------
// Insertion
// -----------------------------------------------------------------
#[test]
fn western_word_space_uses_one_third_em_character() {
let (texts, map) = apply(&["hello world"]);
assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]);
assert!(
map.inserted.is_empty(),
"substitution does not shift offsets"
);
}
#[test]
fn no_insertion_for_plain_cjk_text() {
assert!(
apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none()
);
}
#[test]
fn inserts_before_forbidden_start_char() {
let (texts, map) = apply(&["雪国。"]);
assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]);
assert_eq!(map.inserted, vec![2]);
}
#[test]
fn inserts_after_forbidden_end_char() {
let (texts, _) = apply(&["「雪"]);
assert_eq!(texts, vec!["「\u{2060}雪".to_string()]);
}
#[test]
fn no_insertion_at_paragraph_start() {
// A leading forbidden-at-start char has no break opportunity
// before it; nothing to suppress.
let (texts, _) = apply(&["。あ。"]);
assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]);
}
#[test]
fn insertion_spans_boundary() {
// The pair (end of span 0, start of span 1) is evaluated; the
// joiner lands at the head of span 1.
let (texts, _) = apply(&["雪国", "。です"]);
assert_eq!(
texts,
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
);
}
#[test]
fn inserts_quarter_em_at_japanese_western_boundaries() {
let (texts, map) = apply(&["日本Penpot版"]);
assert_eq!(
texts,
vec![format!(
"日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版"
)]
);
assert_eq!(map.inserted, vec![2, 9]);
assert_eq!(map.to_original(map.to_shifted(2)), 2);
assert_eq!(map.to_original(map.to_shifted(8)), 8);
}
#[test]
fn japanese_western_spacing_crosses_span_boundary() {
let (texts, map) = apply(&["日本", "Penpot"]);
assert_eq!(
texts,
vec![
"日本".to_string(),
format!("{JAPANESE_WESTERN_SPACE}Penpot")
]
);
assert_eq!(map.inserted, vec![2]);
}
#[test]
fn consecutive_forbidden_chars() {
let (texts, map) = apply(&["た」。"]);
assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]);
assert_eq!(map.inserted, vec![1, 3]);
}
#[test]
fn small_kana_and_prolonged_sound() {
let (texts, _) = apply(&["コーヒー"]);
assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]);
let (texts, _) = apply(&["ちょっと"]);
assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]);
}
// -----------------------------------------------------------------
// Offset map
// -----------------------------------------------------------------
#[test]
fn offset_map_round_trips_every_original_index() {
let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。";
let (_, map) = apply(&[original]);
let len = original.encode_utf16().count();
for i in 0..=len {
assert_eq!(
map.to_original(map.to_shifted(i)),
i,
"round-trip failed at original index {i}"
);
}
}
#[test]
fn offset_map_recovers_original_characters() {
let original = "た」。あ";
let (texts, map) = apply(&[original]);
let shifted = texts.concat();
let shifted_units: Vec<u16> = shifted.encode_utf16().collect();
let original_units: Vec<u16> = original.encode_utf16().collect();
for (s_idx, unit) in shifted_units.iter().enumerate() {
if *unit == WORD_JOINER as u16 {
continue;
}
assert_eq!(original_units[map.to_original(s_idx)], *unit);
}
}
#[test]
fn offset_map_on_joiner_resolves_to_boundary() {
// "雪国。" → joiner at shifted 2; both sides of the joiner map
// to original boundary 2.
let (_, map) = apply(&["雪国。"]);
assert_eq!(map.to_original(2), 2);
assert_eq!(map.to_original(3), 2);
assert_eq!(map.to_shifted(2), 3);
}
// -----------------------------------------------------------------
// Real skparagraph layout
// -----------------------------------------------------------------
fn font_collection() -> FontCollection {
let font_mgr = FontMgr::new();
let typeface = font_mgr
.new_from_data(TEST_FONT, None)
.expect("failed to load test font");
let mut provider = TypefaceFontProvider::new();
provider.register_typeface(typeface, Some("TestFont"));
let mut collection = FontCollection::new();
collection.set_asset_font_manager(Some(provider.into()));
collection.set_default_font_manager(FontMgr::new(), None);
collection
}
/// Lays out `text` at `width` and returns each line as a UTF-16
/// (start, end) range.
fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> {
let collection = font_collection();
let paragraph_style = ParagraphStyle::default();
let mut builder = ParagraphBuilder::new(&paragraph_style, collection);
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
style.set_letter_spacing(letter_spacing);
builder.push_style(&style);
builder.add_text(text);
let mut paragraph = builder.build();
paragraph.layout(width);
paragraph
.get_line_metrics()
.iter()
.map(|line| (line.start_index, line.end_index))
.collect()
}
fn utf16_chars(text: &str) -> Vec<char> {
// BMP-only fixtures: one char per UTF-16 unit.
text.chars().collect()
}
/// Asserts no line starts with a start-forbidden char nor ends with
/// an end-forbidden char, in ORIGINAL text coordinates.
fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
let chars = utf16_chars(original);
for (i, (start, end)) in lines.iter().enumerate() {
let orig_start = map.to_original(*start);
let orig_end = map.to_original(*end);
if i > 0 {
let first = chars[orig_start];
assert!(
!forbidden_at_line_start(first),
"line {i} starts with forbidden char {first} (text {original})"
);
}
if i < lines.len() - 1 && orig_end > orig_start {
// The last char actually rendered on the line (end may
// include trailing whitespace-like positions).
let last = chars[orig_end - 1];
assert!(
!forbidden_at_line_end(last),
"line {i} ends with forbidden char {last} (text {original})"
);
}
}
}
/// Lines mapped back to original offsets must tile the original
/// text exactly: contiguous, in order, full coverage.
fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
let mut expected_start = 0;
for (start, end) in lines {
let orig_start = map.to_original(*start);
let orig_end = map.to_original(*end);
assert_eq!(orig_start, expected_start, "line ranges must be contiguous");
expected_start = orig_end;
}
assert_eq!(expected_start, original.encode_utf16().count());
}
fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) {
let (texts, map) = apply(&[original]);
let lines = layout_lines(&texts.concat(), width, 0.0);
(lines, map)
}
fn measure_width(text: &str) -> f32 {
let collection = font_collection();
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection);
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
builder.push_style(&style);
builder.add_text(text);
let mut p = builder.build();
p.layout(f32::MAX);
p.longest_line()
}
#[test]
fn joiner_is_zero_width_in_layout() {
let plain = layout_lines("国国", f32::MAX, 0.0);
let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0);
assert_eq!(plain.len(), 1);
assert_eq!(joined.len(), 1);
let collection = font_collection();
let measure = |text: &str| {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
builder.push_style(&style);
builder.add_text(text);
let mut p = builder.build();
p.layout(f32::MAX);
p.longest_line()
};
let diff = (measure("国国") - measure("国\u{2060}国")).abs();
assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}");
}
#[test]
fn suppresses_breaks_on_kinsoku_fixtures() {
let fixtures = [
// kinsoku-line-start
"これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
// kinsoku-line-end
"開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
// prolonged-sound
"コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
// small-kana-sokuon
"ちょっと待ってください。キャッシュとクッキーをチェックする。",
// long-paragraph-wrap
"国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
];
// Several widths to move the break positions around. Widths
// must exceed the longest unbreakable (joined) run, otherwise
// skparagraph rightfully falls back to an emergency mid-run
// break.
let char_width = measure_width("国");
for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) {
for original in fixtures {
let (lines, map) = fixture_lines(original, width);
assert!(lines.len() > 1, "fixture must wrap at width {width}");
assert_kinsoku_clean(original, &lines, &map);
assert_lines_tile_original(original, &lines, &map);
}
}
}
#[test]
fn mixed_latin_cjk_fixture() {
let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!";
let char_width = measure_width("国");
for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) {
let (lines, map) = fixture_lines(original, width);
assert_kinsoku_clean(original, &lines, &map);
assert_lines_tile_original(original, &lines, &map);
}
}
#[test]
fn joiner_becomes_visible_under_letter_spacing() {
// skparagraph applies letter-spacing per cluster, INCLUDING the
// zero-width joiner, which would double the tracking at every
// suppressed break. This is why callers disable kinsoku for
// paragraphs with a non-zero letter-spacing. If this test ever
// fails (Skia stops spacing ignorables), that gate can go.
let collection = font_collection();
let measure = |text: &str| {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
let mut style = TextStyle::default();
style.set_font_families(&["TestFont"]);
style.set_font_size(20.0);
style.set_letter_spacing(5.0);
builder.push_style(&style);
builder.add_text(text);
let mut p = builder.build();
p.layout(f32::MAX);
p.longest_line()
};
let diff = (measure("国\u{2060}国") - measure("国国")).abs();
assert!(
diff > 0.01,
"letter-spacing no longer affects the joiner; the kinsoku \
letter-spacing gate can be removed"
);
}
}

View File

@ -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);

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -16,6 +16,17 @@ impl TextPaths {
}
pub fn get_paths(&self, vertical_align: VerticalAlign) -> Vec<skia::Path> {
if self.0.is_vertical() {
return crate::shapes::text_vertical::vertical_text_paths(
&self.0,
vertical_align,
true,
)
.into_iter()
.map(|(path, _)| path)
.collect();
}
let layout_width = self.0.get_width(self.bounds.width());
let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None);
let mut paragraphs: Vec<SkiaParagraph> = paragraph_builders

File diff suppressed because it is too large Load Diff

View File

@ -281,6 +281,9 @@ pub struct TextComposition {
pub previous: String,
pub current: String,
pub is_composing: bool,
/// Where the preview text starts; the preview replaced by each update is
/// `[start, start + previous]`, independent of where the caret is.
pub start: Option<TextPositionWithAffinity>,
}
impl TextComposition {
@ -289,6 +292,7 @@ impl TextComposition {
previous: String::new(),
current: String::new(),
is_composing: false,
start: None,
}
}
@ -299,6 +303,7 @@ impl TextComposition {
self.is_composing = true;
self.previous = String::new();
self.current = String::new();
self.start = None;
true
}
@ -316,22 +321,26 @@ impl TextComposition {
return false;
}
self.is_composing = false;
self.start = None;
true
}
/// Range the next preview replaces: the previous preview, or `selection`
/// (the pre-composition selection) when there is none yet.
pub fn get_selection(&self, selection: &TextSelection) -> TextSelection {
if self.previous.is_empty() {
let Some(start) = self.start.filter(|_| !self.previous.is_empty()) else {
return *selection;
}
};
let focus = selection.focus;
let previous_len = self.previous.chars().count();
let anchor = TextPositionWithAffinity::new_downstream_affinity(
focus.paragraph,
focus.offset + previous_len,
start.paragraph,
start.offset + self.previous.chars().count(),
);
TextSelection { anchor, focus }
TextSelection {
anchor,
focus: start,
}
}
}
@ -889,8 +898,24 @@ impl TextEditorState {
TextDirection::LTR
};
// In vertical-rl the physical arrow keys map onto logical navigation
// differently: Up/Down walk characters along the column, and Left/Right
// cross columns (columns advance right-to-left).
let is_vertical = text_content.is_vertical();
let direction = if is_vertical {
match direction {
CursorDirection::Backward => CursorDirection::LineAfter, // Left -> next column
CursorDirection::Forward => CursorDirection::LineBefore, // Right -> prev column
CursorDirection::LineBefore => CursorDirection::Backward, // Up -> prev char
CursorDirection::LineAfter => CursorDirection::Forward, // Down -> next char
other => other,
}
} else {
direction
};
// For horizontal navigation, swap Backward/Forward when in RTL text
let adjusted_direction = if text_span_text_direction == TextDirection::RTL {
let adjusted_direction = if !is_vertical && text_span_text_direction == TextDirection::RTL {
match direction {
CursorDirection::Backward => CursorDirection::Forward,
CursorDirection::Forward => CursorDirection::Backward,
@ -940,3 +965,35 @@ impl Default for TextEditorState {
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
fn caret(paragraph: usize, offset: usize) -> TextSelection {
TextSelection::from_position_with_affinity(
TextPositionWithAffinity::new_downstream_affinity(paragraph, offset),
)
}
#[test]
fn composition_replaces_the_previous_preview_wherever_the_caret_is() {
let mut composition = TextComposition::new();
composition.start();
// First update: the pre-composition caret is the replaced range.
composition.update("に");
let replaced = composition.get_selection(&caret(0, 2));
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 2));
composition.start = Some(replaced.start());
// The caret now sits after the preview; the next update still
// replaces the preview itself.
composition.update("にほ");
let replaced = composition.get_selection(&caret(0, 3));
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 3));
composition.end();
assert!(composition.start.is_none());
}
}

View File

@ -124,3 +124,11 @@ pub extern "C" fn is_font_uploaded(
let family = FontFamily::new(id, weight, font_style.into());
get_resources().fonts.has_family(&family, is_emoji)
}
#[no_mangle]
pub extern "C" fn mark_font_as_fallback(a: u32, b: u32, c: u32, d: u32, weight: u32, style: u8) {
let id = uuid_from_u32_quartet(a, b, c, d);
let font_style = RawFontStyle::from(style);
let family = FontFamily::new(id, weight, font_style.into());
get_resources().fonts.mark_as_fallback(&family);
}

View File

@ -12,6 +12,9 @@ use crate::{with_current_shape, with_current_shape_mut, with_state};
use crate::error::Error;
pub mod helpers;
mod japanese_layout;
pub use japanese_layout::*;
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
@ -103,6 +106,11 @@ pub struct RawParagraphData {
text_direction: RawTextDirection,
text_decoration: RawTextDecoration,
text_transform: RawTextTransform,
writing_mode: RawWritingMode,
text_orientation: RawTextOrientation,
// Explicit padding so the CLJS writer and this struct agree on a
// 4-byte-aligned layout; always written as zero.
_padding: [u8; 2],
line_height: f32,
letter_spacing: f32,
}
@ -131,6 +139,19 @@ pub struct RawTextSpan {
text_decoration: RawTextDecoration,
text_transform: RawTextTransform,
text_direction: RawTextDirection,
text_orientation: RawTextOrientation,
text_combine_upright: RawTextCombineUpright,
text_emphasis: RawTextEmphasis,
warichu: RawWarichu,
font_features: RawFontFeatures,
annotation_clearance: RawAnnotationClearance,
ruby_size: RawRubySize,
ruby_align: RawRubyAlign,
ruby_overhang: RawRubyOverhang,
ruby_side: RawRubySide,
// Explicit padding so the CLJS writer and this struct agree on a
// 4-byte-aligned layout; always written as zero.
_padding: [u8; 2],
font_size: f32,
line_height: f32,
letter_spacing: f32,
@ -139,6 +160,7 @@ pub struct RawTextSpan {
font_family: [u8; 4],
font_variant_id: [u32; 4], // TODO: maybe add RawUUID type
text_length: u32,
ruby_length: u32,
fill_count: u32,
fills: [RawFillData; MAX_TEXT_FILLS],
}
@ -177,7 +199,7 @@ impl From<RawTextSpan> for shapes::TextSpan {
.map(|fill| fill.into())
.collect();
Self::new(
let mut span = Self::new(
text,
font_family,
value.font_size,
@ -189,7 +211,18 @@ impl From<RawTextSpan> for shapes::TextSpan {
value.font_weight,
uuid_from_u32(value.font_variant_id),
fills,
)
);
span.set_text_orientation(value.text_orientation.into());
span.set_text_combine_upright(value.text_combine_upright.into());
span.set_text_emphasis(value.text_emphasis.into());
span.set_warichu(value.warichu.into());
span.set_font_features(value.font_features.into());
span.set_annotation_clearance(value.annotation_clearance.into());
span.set_ruby_size(value.ruby_size.into());
span.set_ruby_align(value.ruby_align.into());
span.set_ruby_overhang(value.ruby_overhang.into());
span.set_ruby_side(value.ruby_side.into());
span
}
}
@ -230,21 +263,31 @@ impl From<RawParagraph> for shapes::Paragraph {
fn from(value: RawParagraph) -> Self {
let mut spans = vec![];
// Layout: [<all span texts> <all span ruby texts>]. Annotation blobs
// begin after all base text.
let mut offset = 0;
let mut ruby_offset: usize = value.spans.iter().map(|s| s.text_length as usize).sum();
for raw_span in value.spans.into_iter() {
let delta = raw_span.text_length as usize;
let text_buffer = &value.text_buffer[offset..offset + delta];
let text_buffer = value.text_buffer.get(offset..offset + delta).unwrap_or(&[]);
let ruby_delta = raw_span.ruby_length as usize;
let ruby_buffer = value
.text_buffer
.get(ruby_offset..ruby_offset + ruby_delta)
.unwrap_or(&[]);
let mut span = shapes::TextSpan::from(raw_span);
if !text_buffer.is_empty() {
span.set_text(String::from_utf8_lossy(text_buffer).to_string());
}
if !ruby_buffer.is_empty() {
span.set_ruby(String::from_utf8_lossy(ruby_buffer).to_string());
}
spans.push(span);
offset += delta;
ruby_offset += ruby_delta;
}
shapes::Paragraph::new(
let mut paragraph = shapes::Paragraph::new(
value.attrs.text_align.into(),
value.attrs.text_direction.into(),
value.attrs.text_decoration.into(),
@ -252,7 +295,10 @@ impl From<RawParagraph> for shapes::Paragraph {
value.attrs.line_height,
value.attrs.letter_spacing,
spans,
)
);
paragraph.set_writing_mode(value.attrs.writing_mode.into());
paragraph.set_text_orientation(value.attrs.text_orientation.into());
paragraph
}
}
@ -445,3 +491,81 @@ pub extern "C" fn calculate_position_data() -> *mut u8 {
});
mem::write_vec(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{
AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize,
};
/// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16)
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)
/// attr bytes before the fills block. These sizes must move in
/// lockstep with the struct layouts.
#[test]
fn raw_struct_sizes_match_cljs_writer() {
const PARAGRAPH_ATTR_U8_SIZE: usize = 16;
const SPAN_ATTR_U8_SIZE: usize = 80;
assert_eq!(RAW_PARAGRAPH_DATA_SIZE, 4 + PARAGRAPH_ATTR_U8_SIZE);
assert_eq!(
RAW_SPAN_DATA_SIZE,
SPAN_ATTR_U8_SIZE + MAX_TEXT_FILLS * std::mem::size_of::<RawFillData>()
);
}
#[test]
fn raw_text_combine_upright_counts_deserialize() {
// Byte 5 of the span attr block; the counted digits variants map
// onto their max run length.
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[5] = RawTextCombineUpright::Digits2 as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_combine_upright.digits_max(), Some(2));
bytes[5] = RawTextCombineUpright::Digits3 as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_combine_upright.digits_max(), Some(3));
bytes[5] = RawTextCombineUpright::Digits as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_combine_upright.digits_max(), Some(4));
}
#[test]
fn raw_font_features_deserializes_from_reserved_span_byte() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[8] = RawFontFeatures::Vpal as u8;
let raw = RawTextSpan::from(bytes);
let span = shapes::TextSpan::from(raw);
assert_eq!(span.font_features, FontFeatures::Vpal);
}
#[test]
fn raw_annotation_clearance_deserializes_from_reserved_span_byte() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[9] = RawAnnotationClearance::Auto as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.annotation_clearance, AnnotationClearance::Auto);
}
#[test]
fn raw_ruby_customization_deserializes_from_span_bytes() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[10] = RawRubySize::Quarter as u8;
bytes[11] = RawRubyAlign::SpaceBetween as u8;
bytes[12] = RawRubyOverhang::None as u8;
bytes[13] = RawRubySide::Under as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.ruby_size, RubySize::Quarter);
assert_eq!(span.ruby_align, RubyAlign::SpaceBetween);
assert_eq!(span.ruby_overhang, RubyOverhang::None);
assert_eq!(span.ruby_side, RubySide::Under);
}
}

View File

@ -822,6 +822,8 @@ pub fn split_paragraph_at_cursor(
let text_direction = para.text_direction();
let text_decoration = para.text_decoration();
let text_transform = para.text_transform();
let writing_mode = para.writing_mode();
let text_orientation = para.text_orientation();
let line_height = para.line_height();
let letter_spacing = para.letter_spacing();
@ -837,7 +839,7 @@ pub fn split_paragraph_at_cursor(
span.set_text(new_text);
}
let new_para = crate::shapes::Paragraph::new(
let mut new_para = crate::shapes::Paragraph::new(
text_align,
text_direction,
text_decoration,
@ -846,6 +848,8 @@ pub fn split_paragraph_at_cursor(
letter_spacing,
new_para_children,
);
new_para.set_writing_mode(writing_mode);
new_para.set_text_orientation(text_orientation);
paragraphs.insert(cursor.paragraph + 1, new_para);

View File

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

View File

@ -153,7 +153,9 @@ where
};
let point = Point::new(x, y);
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
{
apply(get_text_editor_state(), text_content, &position);
}
})
@ -207,7 +209,9 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
};
let point = Point::new(x, y);
get_text_editor_state().start_pointer_selection();
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
{
get_text_editor_state().set_caret_from_position(&position);
get_text_editor_state().update_styles(text_content);
}
@ -233,7 +237,9 @@ pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) {
};
let point = Point::new(x, y);
get_text_editor_state().start_pointer_selection();
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
{
get_text_editor_state().extend_selection_from_position(&position);
// The click after pointerup would collapse the caret and drop the
// selection we just extended.
@ -267,7 +273,9 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
return;
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
{
get_text_editor_state().extend_selection_from_position(&position);
// We need this flag to prevent handling the click behavior
// just after a pointerup event.
@ -296,7 +304,9 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
{
get_text_editor_state().extend_selection_from_position(&position);
get_text_editor_state().update_styles(text_content);
}
@ -331,7 +341,9 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
return;
};
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
if let Some(position) =
text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align())
{
get_text_editor_state().set_caret_from_position(&position);
}
});
@ -356,9 +368,12 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
if let Some(position) = text_content.get_caret_position_from_screen_coords(
&point,
&view_matrix,
&shape_matrix,
shape.vertical_align(),
) {
get_text_editor_state().set_caret_from_position(&position);
}
});
@ -411,11 +426,16 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
.get_selection(&get_text_editor_state().selection);
text_helpers::delete_selection_range(text_content, &selection);
let cursor = get_text_editor_state().selection.focus;
// Insert at the start of the replaced range (the pre-composition
// selection or the previous preview), not at a possibly stale
// selection focus, and advance the caret past the committed text.
let cursor = selection.start();
if let Some(new_cursor) =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
{
get_text_editor_state().selection.set_caret(new_cursor);
} else {
get_text_editor_state().selection.set_caret(cursor);
}
text_content.layout.clear();
@ -466,8 +486,13 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
.get_selection(&get_text_editor_state().selection);
text_helpers::delete_selection_range(text_content, &selection);
let cursor = get_text_editor_state().selection.focus;
text_helpers::insert_text_with_newlines(text_content, &cursor, &text);
// The preview always starts where the replaced range did; the caret
// goes after it, as in a native text field.
let cursor = selection.start();
get_text_editor_state().composition.start = Some(cursor);
let caret =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text).unwrap_or(cursor);
get_text_editor_state().selection.set_caret(caret);
text_content.layout.clear();
@ -482,6 +507,28 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
Ok(())
}
/// Place the caret `offset` characters into the composition preview, where
/// the IME's own cursor is (clause conversion, cursor moves inside the
/// preedit). Clamped to the preview; ignored outside a composition.
#[no_mangle]
pub extern "C" fn text_editor_set_composition_cursor(offset: u32) {
let editor_state = get_text_editor_state();
if !editor_state.has_focus || !editor_state.composition.is_composing {
return;
}
let Some(start) = editor_state.composition.start else {
return;
};
let offset = (offset as usize).min(editor_state.composition.current.chars().count());
editor_state
.selection
.set_caret(TextPositionWithAffinity::new_downstream_affinity(
start.paragraph,
start.offset + offset,
));
editor_state.reset_blink();
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
@ -662,6 +709,48 @@ pub extern "C" fn text_editor_move_cursor(
// RENDERING & EXPORT
// ============================================================================
/// Caret rectangle in page coordinates (the unrotated selrect space of the
/// text overlay), reported independently of the blink phase. The frontend
/// keeps the IME capture surface on it.
#[no_mangle]
pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
with_state!(state, {
let editor_state = get_text_editor_state();
if !editor_state.has_focus {
return std::ptr::null_mut();
}
let Some(shape_id) = editor_state.active_shape_id else {
return std::ptr::null_mut();
};
// A preview update clears the layout; rebuild it so the rect is
// current right away instead of after the next render.
update_text_layout_if_needed(state, shape_id);
let Some(shape) = state.shapes.get(&shape_id) else {
return std::ptr::null_mut();
};
let Type::Text(text_content) = &shape.shape_type else {
return std::ptr::null_mut();
};
let Some(rect) = crate::render::text_editor::cursor_rect(editor_state, text_content, shape)
else {
return std::ptr::null_mut();
};
let selrect = shape.selrect();
let mut bytes = vec![0u8; 16];
bytes[0..4].copy_from_slice(&(selrect.x() + rect.left()).to_le_bytes());
bytes[4..8].copy_from_slice(&(selrect.y() + rect.top()).to_le_bytes());
bytes[8..12].copy_from_slice(&rect.width().to_le_bytes());
bytes[12..16].copy_from_slice(&rect.height().to_le_bytes());
mem::write_bytes(bytes)
})
}
#[no_mangle]
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
with_state!(state, {