Add japanese text layout support

This commit is contained in:
alonso.torres 2026-07-15 16:32:10 +02:00
parent bdc078d5ea
commit 6cdbfe4b51
85 changed files with 14982 additions and 382 deletions

View File

@ -41,6 +41,8 @@
(def ^:dynamic *wrap-with-objects-map-fn* identity)
(def ^:dynamic *wrap-with-pointer-map-fn* identity)
;; CHANGEME: The name of text-vertical/v1 is not clear for the feature, change for japanese-layout/v1
;; A set of supported features
(def supported-features
#{"fdata/objects-map"
@ -56,6 +58,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"text-vertical/v1"
"render-wasm/v1"
"variants/v1"})
@ -81,6 +84,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"text-vertical/v1"
"tokens/numeric-input"
"render-wasm/v1"})
@ -131,6 +135,7 @@
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-text-vertical "text-vertical/v1"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@ -38,6 +38,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]]
@ -54,6 +56,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]]
@ -75,6 +88,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

@ -42,6 +42,56 @@
(def text-direction-attrs
[:text-direction])
;; CHANGEME: All these new attributes can be moved to a new namespace in app.common.types.text.japanese-layout
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
;; and "mixed", so plain horizontal text never stores these attrs.
(def text-writing-mode-attrs
[:writing-mode])
(def text-orientation-attrs
[:text-orientation])
(def text-combine-upright-attrs
[:text-combine-upright])
;; Emphasis mark (圏点 / bouten) applied per span; absent means no emphasis.
(def text-emphasis-attrs
[:text-emphasis])
;; Ruby (furigana) annotation text and customization carried per span.
(def text-ruby-attrs
[:ruby
:ruby-size
:ruby-align
:ruby-overhang
:ruby-side])
;; Warichu (割注): the span renders as two half-size lines stacked inline
;; within one column position. Values "warichu" / "none"; absent means off.
(def text-warichu-attrs
[:warichu])
(def text-font-features-attrs
[:font-features])
;; Annotation collision policy. "none" preserves the explicit line height;
;; "auto" reserves an additional half-em layer for ruby and emphasis.
(def text-annotation-clearance-attrs
[:annotation-clearance])
(defn content-writing-mode
"Writing mode of a text content. Stored per paragraph but treated as a
whole-shape property: the first paragraph decides the flow (mirrors
TextContent::is_vertical in wasm)."
[content]
(dm/get-in content [:children 0 :children 0 :writing-mode]))
(defn vertical-text-content?
"True when the text content flows vertically (vertical-rl)."
[content]
(= "vertical-rl" (content-writing-mode content)))
(def text-spacing-attrs
[:line-height
:letter-spacing])
@ -67,13 +117,21 @@
(def paragraph-attrs
(d/concat-vec
text-align-attrs
text-direction-attrs))
text-direction-attrs
text-writing-mode-attrs
text-orientation-attrs))
(def text-node-attrs
(d/concat-vec
text-typography-attrs
text-font-attrs
text-spacing-attrs
text-combine-upright-attrs
text-emphasis-attrs
text-ruby-attrs
text-warichu-attrs
text-font-features-attrs
text-annotation-clearance-attrs
text-decoration-attrs
text-transform-attrs
text-fills))

View File

@ -32,6 +32,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

@ -23,6 +23,7 @@
[app.common.types.shape-tree :as ctst]
[app.common.types.shape.attrs :refer [editable-attrs]]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.comments :as-alias dwcm]
@ -1010,21 +1011,34 @@
(rx/of (dwu/commit-undo-transaction undo-id))
(rx/empty))))))))
(defn vertical-text-shape?
[shape]
(txt/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?]
;; CHANGEME: Simplify this comment
;; Grow and wrap axes swap under vertical writing. Normalizing the scale
;; vector lets the existing horizontal state machine remain authoritative.
(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

@ -59,6 +59,9 @@
(declare v2-update-text-editor-styles)
(declare v2-sync-wasm-text-layout)
(def ruby-presentation-attrs
[:ruby-size :ruby-align :ruby-overhang :ruby-side])
;; -- Content helpers
(defn- v2-content-has-text?
@ -264,6 +267,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
@ -290,7 +300,9 @@
(defn current-paragraph-values
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
(cond
(some? editor-styles) (v3-current-text-values options)
;; CHANGEME: is this correct? the v3-current-text-values is not here
(some? editor-styles) (merge (shape-current-values shape txt/is-paragraph-node? attrs)
(select-keys editor-styles attrs))
(some? editor-instance) (v2-current-text-values options)
(some? editor-state) (v1-current-paragraph-values options)
:else (shape-current-values shape txt/is-paragraph-node? attrs)))
@ -436,30 +448,45 @@
(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]
;; CHANGEME: check if the without nils is necesary here
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs))
ptk/WatchEvent
(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- whole-shape-attrs->editor-styles
"Map whole-shape paragraph attrs to DOM editor styles while preserving nil
as a removal instruction. The generic attrs->styles intentionally drops nil."
[attrs]
(clj->js
(into {}
(map (fn [[key value]]
[(styles/attr->style-key key)
(when (some? value)
(styles/attr->style-value key value))]))
attrs)))
(defn update-text-attrs
[{:keys [id attrs]}]
@ -491,6 +518,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))]
@ -815,12 +887,21 @@
(= :font-loaded (ptk/type event))
(= (:font-id (deref event)) font-id))))
(defn globally-update-text-node-attrs?
"Whole-shape text-node updates must be skipped while the WASM editor owns an
active range; its selection-scoped content result is the source of truth."
[render-wasm? wasm-selection?]
(not (and render-wasm? wasm-selection?)))
(defn update-attrs
[id attrs]
(ptk/reify ::update-attrs
ptk/WatchEvent
(watch [_ state stream]
(let [text-editor-instance (:workspace-editor state)]
(let [text-editor-instance (:workspace-editor state)
render-wasm? (features/active-feature? state "render-wasm/v1")
wasm-selection? (and render-wasm?
(wasm.api/text-editor-has-selection?))]
(if (and (features/active-feature? state "text-editor/v2")
(some? text-editor-instance))
(rx/empty)
@ -836,7 +917,8 @@
(rx/empty)))
(let [attrs (select-keys attrs txt/text-node-attrs)]
(if-not (empty? attrs)
(if (and (not (empty? attrs))
(globally-update-text-node-attrs? render-wasm? wasm-selection?))
(rx/of (update-text-attrs {:id id :attrs attrs}))
(rx/empty)))
@ -844,18 +926,17 @@
(not (features/active-feature? state "text-editor-wasm/v1")))
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
(when render-wasm?
(rx/concat
;; Apply style to selected spans and sync content
(let [has-selection? (wasm.api/text-editor-has-selection?)]
(when has-selection?
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
(when (not (empty? span-attrs))
(let [result (wasm.api/apply-styles-to-selection span-attrs)]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true))))))))
(when wasm-selection?
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
(when (not (empty? span-attrs))
(let [result (wasm.api/apply-styles-to-selection span-attrs)]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
:update-name? true)))))))
;; Resize (with delay for font-id changes)
(if (contains? attrs :font-id)
(->> stream
@ -874,8 +955,19 @@
;; it with sidebar `attrs` and applying to the whole selection collapses mixed
;; fills/fonts when the user only changes one property (e.g. line-height).
;; Apply only the explicit attributes from this action.
(let [styles (styles/attrs->styles attrs)]
(editor.v2/applyStylesToSelection instance styles))))))))
(let [;; Writing mode and orientation are whole-shape properties:
;; apply them to every paragraph so they cannot diverge
;; from the first paragraph (which decides the flow).
whole-shape-attrs
(select-keys attrs (d/concat-vec txt/text-writing-mode-attrs
txt/text-orientation-attrs))
selection-attrs
(apply dissoc attrs (keys whole-shape-attrs))]
(when (seq whole-shape-attrs)
(editor.v2/applyStylesToAllParagraphs instance (whole-shape-attrs->editor-styles whole-shape-attrs)))
(when (seq selection-attrs)
(editor.v2/applyStylesToSelection instance (styles/attrs->styles selection-attrs))))))))))
(defn update-all-attrs
[ids attrs]

View File

@ -226,7 +226,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
@ -415,7 +417,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

@ -15,6 +15,7 @@
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.common.types.text :as txt]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.shapes :as dwsh]
@ -26,6 +27,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? (txt/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)."
@ -48,14 +74,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

@ -269,6 +269,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")
@ -280,6 +283,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")
@ -302,7 +307,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

@ -242,6 +242,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))
@ -253,6 +256,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))
@ -279,7 +284,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

@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as cc]
[app.common.types.text :as txt]
[app.main.ui.shapes.text.styles :as sts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@ -19,9 +20,15 @@
(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 (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]}]
@ -160,6 +167,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? (txt/vertical-text-content? content)
[colors _color-mapping color-mapping-inverse] (retrieve-colors shape)]
[:foreignObject
@ -169,8 +181,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

@ -9,7 +9,9 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.text :as legacy.txt]
[app.common.types.text :as txt]
[app.main.ui.shapes.text.styles :as sts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(mf/defc render-text*
@ -18,9 +20,15 @@
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 (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 +83,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? (txt/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

@ -21,6 +21,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 (txt/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 +31,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 +60,8 @@
(:line-height txt/default-typography))
text-align (:text-align data "start")
writing-mode (:writing-mode data)
text-orientation (:text-orientation data)
base #js {;; Fix a problem when exporting HTML
:fontSize 0
:lineHeight line-height
@ -63,7 +69,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 +91,24 @@
([{: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 (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 +183,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 +212,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

@ -14,6 +14,7 @@
[app.common.geom.shapes.text :as gst]
[app.common.math :as mth]
[app.common.text :as legacy.txt]
[app.common.types.text :as txt]
[app.config :as cf]
[app.main.data.workspace :as dw]
[app.main.data.workspace.texts :as dwt]
@ -235,7 +236,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) (txt/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

@ -296,7 +296,7 @@
(dom/set-style! container-node "--text-editor-caret-color" text-color)))
[:div
{:class (dm/str (cur/get-dynamic "text" (:rotation shape))
{:class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? content))
" "
(stl/css :text-editor-container))
:ref container-ref
@ -396,27 +396,24 @@
[{:keys [x y width height selrect-width selrect-height]} transform]
(if render-wasm?
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
(let [{content-x :x
content-y :y
content-width :width
content-height :height} (wasm.api/get-text-dimensions shape-id)
selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height)
max-width (max content-width selrect-width)
max-height (max content-height selrect-height)
;; During auto-width editing we keep the shape width trimmed, but the caret
;; must be able to move after trailing spaces. Expand only the editor
;; overlay up to one viewport width to avoid clipping caret rendering.
viewport-width (or (:width vbox) 0)
overlay-width (if (= (:grow-type shape) :auto-width)
(+ max-width viewport-width)
max-width)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (case valign
"bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width overlay-width :height max-height
max-width)]
[(assoc selrect :x content-x :y content-y :width overlay-width :height max-height
:selrect-width selrect-width :selrect-height selrect-height) transform])
(let [bounds (gst/shape->rect shape)

View File

@ -405,7 +405,7 @@
:on-focus on-focus
:on-blur on-blur
:id "text-editor-wasm-input"
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
:class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? (:content shape)))
" "
(stl/css :text-editor-container))
:data-testid "text-editor-container"}]]]]))

View File

@ -25,10 +25,14 @@
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
[app.main.ui.workspace.sidebar.options.menus.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]
@ -51,10 +55,126 @@
Evaluated once at module load time; cf/flags is immutable after startup."
(contains? cf/flags :token-typography-row))
;; CHANGEME: I want all the new options for japanese text to be in their own namespaced and then imported here
(defn- radio-selected
([value]
(radio-selected value ""))
([value default]
(cond
(= value :multiple) ""
(or (nil? value)
(and (string? value) (empty? value))) default
(keyword? value) (d/name value)
(string? value) value
:else (str value))))
(def ^:private mixed-span-values
#{:mixed :multiple "mixed" "multiple"})
(defn- mixed-span-value?
[value]
(contains? mixed-span-values value))
(defn- span-input-value
[value]
(if (mixed-span-value? value)
"mixed"
(radio-selected value)))
(defn- span-select-value
[value default]
(if (mixed-span-value? value)
"mixed"
(radio-selected value default)))
(defn- with-mixed-span-option
[options value]
(cond-> options
(mixed-span-value? value)
(conj {:id "mixed"
:label (tr "labels.mixed-values")
:disabled true
:dimmed true})))
(defn japanese-layout-enabled?
"Japanese layout is opt-in. An explicit supported writing mode is the
persisted marker; absent, mixed, or reset values remain ordinary text."
[values]
(let [writing-mode (:writing-mode values)]
(cond
(= writing-mode :multiple) nil
(#{"horizontal-tb" "vertical-rl"} writing-mode) true
:else false)))
(defn japanese-layout-toggle-attrs
"Attrs emitted by the Japanese layout switch. A nil writing mode removes the
persisted paragraph attribute, restoring the normal horizontal default."
[enabled?]
{:writing-mode (when enabled? "horizontal-tb")})
(defn reconcile-japanese-layout-state
"Keep the current opt-in state when a same-selection style snapshot omits
writing-mode. A new selection resets from its persisted paragraph value."
[current values selection-changed?]
(cond
selection-changed? (japanese-layout-enabled? values)
(#{"horizontal-tb" "vertical-rl"} (:writing-mode values)) true
:else current))
(defn vertical-japanese-layout?
"True when the Japanese layout controls are editing vertical text."
[values]
(= "vertical-rl" (:writing-mode values)))
(defn proportional-metrics-feature
"Return the proportional metric feature relevant to the writing mode."
[writing-mode]
(if (= writing-mode "vertical-rl") "vpal" "palt"))
(defn text-combine-upright-options
[text-selection-active translate]
(cond-> [{:value "none"
:id "none-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-none")
:icon i/text-combine-upright-none}]
text-selection-active
(conj {:value "all"
:id "all-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-all")
:icon i/text-combine-upright-all})
true
(conj {:value "digits"
:id "digits-text-combine-upright"
:label (translate "workspace.options.text-options.text-combine-upright-digits")
:icon i/text-combine-upright-digits})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Sub-components
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc japanese-layout-toggle*
[{:keys [values enabled on-change on-toggle on-blur]}]
(let [handle-change
(mf/use-fn
(mf/deps on-change on-toggle on-blur)
(fn [checked?]
(on-toggle checked?)
(on-change (japanese-layout-toggle-attrs checked?))
(when (some? on-blur) (on-blur))))]
;; Repair the invalid reset sentinel emitted by the previous implementation
;; so an already-open document can recover without a reload or manual edit.
(mf/with-effect [(:writing-mode values) on-change]
(when (= "" (:writing-mode values))
(on-change {:writing-mode nil})))
[:div {:class (stl/css :japanese-layout-toggle)}
[:> switch* {:default-checked enabled
:label (tr "workspace.options.text-options.japanese-layout")
:on-change handle-change}]]))
(mf/defc text-align-options*
[{:keys [values on-change on-blur]}]
(let [options
@ -84,14 +204,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"
@ -116,9 +236,400 @@
:name "text-direction-options"
:options options}]]))
(mf/defc writing-mode-options*
[{:keys [values on-change on-blur]}]
(let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb")
options
(mf/with-memo []
[{:value "horizontal-tb"
:id "horizontal-tb-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-horizontal")
:icon i/writing-mode-horizontal}
{:value "vertical-rl"
:id "vertical-rl-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-vertical")
:icon i/writing-mode-vertical}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:writing-mode value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :writing-mode-options)}
[:> radio-buttons* {:selected writing-mode
:on-change handle-change
:name "writing-mode-options"
:options options}]]))
(mf/defc text-orientation-options*
[{:keys [values on-change on-blur]}]
;; The v2 editor can read the paragraph orientation back as an empty
;; string when it is unset; treat that (and nil) as the "mixed" default.
(let [text-orientation (radio-selected (:text-orientation values) "mixed")
options
(mf/with-memo []
[{:value "mixed"
:id "mixed-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-mixed")
:icon i/text-orientation-mixed}
{:value "upright"
:id "upright-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-orientation value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-orientation-options)}
[:> radio-buttons* {:selected text-orientation
:on-change handle-change
:name "text-orientation-options"
:options options}]]))
(mf/defc text-combine-upright-options*
;; Digit TCY can be applied across a shape because it discovers eligible
;; runs automatically. The unrestricted `all` value is only offered for an
;; explicit text range.
[{:keys [values on-change on-blur text-selection-active]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
("digits" "digits2" "digits3") true
false)
selected (if digits? "digits" text-combine-upright)
options
(mf/with-memo [text-selection-active]
(text-combine-upright-options text-selection-active tr))
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-combine-upright value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-combine-upright-options)}
[:> radio-buttons* {:selected selected
:on-change handle-change
:name "text-combine-upright-options"
:options options}]]))
(mf/defc text-combine-upright-count-options*
[{:keys [values on-change on-blur]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
("digits" "digits2" "digits3") true
false)
options
(mf/with-memo []
;; select* matches and reports options by :id, so the id is the
;; persisted attr value.
[{:id "digits2"
:label (tr "workspace.options.text-options.text-combine-upright-digits-2")}
{:id "digits3"
:label (tr "workspace.options.text-options.text-combine-upright-digits-3")}
{:id "digits"
:label (tr "workspace.options.text-options.text-combine-upright-digits-4")}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-combine-upright value})
(when (some? on-blur) (on-blur))))]
(when digits?
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-combine-upright-digits-count")]
[:> select* {:default-selected text-combine-upright
:aria-label (tr "workspace.options.text-options.text-combine-upright-digits-count")
:options options
:on-change handle-change}]])))
(mf/defc ruby-options*
[{:keys [values on-change on-blur advanced-open on-toggle-advanced]}]
(let [ruby (span-input-value (:ruby values))
ruby* (mf/use-state ruby)
dirty* (mf/use-state false)
value (deref ruby*)
dirty? (deref dirty*)
commit!
(mf/use-fn
(mf/deps dirty? value on-change on-blur)
(fn []
(when dirty?
(on-change {:ruby value})
(reset! dirty* false))
(when (some? on-blur) (on-blur))))
handle-change
(mf/use-fn
(fn [event]
(reset! dirty* true)
(reset! ruby* (dom/get-target-val event))))
handle-key-down
(mf/use-fn
(mf/deps commit!)
(fn [event]
(when (= "Enter" (.-key event))
(dom/blur! (dom/get-target event)))))]
(mf/with-effect [ruby]
(reset! ruby* ruby)
(reset! dirty* false))
[:div {:class (stl/css :ruby-options)}
[:> input* {:class (stl/css :ruby-input)
:label (tr "workspace.options.text-options.ruby")
:placeholder (tr "workspace.options.text-options.ruby-placeholder")
:value value
:on-change handle-change
:on-blur commit!
:on-key-down handle-key-down}]
[:> icon-button* {:variant "ghost"
:selected advanced-open
:aria-label (tr "workspace.options.text-options.ruby-advanced-options")
:aria-expanded advanced-open
:data-testid "ruby-advanced-options-toggle"
:on-click on-toggle-advanced
:icon i/menu}]]))
(mf/defc ruby-select-option*
[{:keys [values on-change on-blur attr default-value label options]}]
(let [value (get values attr)
selected (span-select-value value default-value)
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps attr on-change on-blur)
(fn [value]
(on-change {attr value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)} label]
[:> select* {:default-selected selected
:aria-label label
:options options
:on-change handle-change}]]))
(defn- ruby-common-props
[values on-change on-blur]
(mf/props
{:values values
:on-change on-change
:on-blur on-blur}))
(mf/defc ruby-customization-options*
[{:keys [values on-change on-blur]}]
(let [common-props (ruby-common-props values on-change on-blur)
size-options [{:id "half" :label (tr "workspace.options.text-options.ruby-size-half")}
{:id "third" :label (tr "workspace.options.text-options.ruby-size-third")}
{:id "quarter" :label (tr "workspace.options.text-options.ruby-size-quarter")}]
align-options [{:id "space-around" :label (tr "workspace.options.text-options.ruby-align-space-around")}
{:id "center" :label (tr "workspace.options.text-options.ruby-align-center")}
{:id "start" :label (tr "workspace.options.text-options.ruby-align-start")}
{:id "space-between" :label (tr "workspace.options.text-options.ruby-align-space-between")}]
overhang-options [{:id "auto" :label (tr "workspace.options.text-options.ruby-overhang-auto")}
{:id "none" :label (tr "workspace.options.text-options.ruby-overhang-none")}]
side-options [{:id "over" :label (tr "workspace.options.text-options.ruby-side-over")}
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
[:div {:class (stl/css :japanese-layout-controls)}
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-size
:default-value "half"
:label (tr "workspace.options.text-options.ruby-size")
:options size-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-align
:default-value "space-around"
:label (tr "workspace.options.text-options.ruby-align")
:options align-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-overhang
:default-value "auto"
:label (tr "workspace.options.text-options.ruby-overhang")
:options overhang-options})]
[:> ruby-select-option* (mf/spread-props common-props
{:attr :ruby-side
:default-value "over"
:label (tr "workspace.options.text-options.ruby-side")
:options side-options})]]))
(mf/defc ruby-advanced-options*
[{:keys [values on-change on-blur]}]
(let [open* (mf/use-state false)
open? (deref open*)
toggle-open (mf/use-fn #(swap! open* not))
common-props (ruby-common-props values on-change on-blur)]
[:div {:class (stl/css :ruby-advanced-options)}
[:> ruby-options* (mf/spread-props common-props
{:advanced-open open?
:on-toggle-advanced toggle-open})]
[:> advanced-options* {:class (stl/css :ruby-advanced-content)
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc ruby-presentation-options*
[{:keys [values on-change on-blur]}]
(let [open* (mf/use-state false)
open? (deref open*)
toggle-open (mf/use-fn #(swap! open* not))
common-props (ruby-common-props values on-change on-blur)]
[:div {:class (stl/css :ruby-advanced-options)}
[:> title-bar* {:collapsable true
:collapsed (not open?)
:title (tr "workspace.options.text-options.ruby-advanced-options")
:on-collapsed toggle-open
:aria-expanded open?
:data-testid "ruby-presentation-options-toggle"}]
[:> advanced-options* {:class (stl/css :ruby-advanced-content)
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc warichu-options*
;; Warichu (割注): a span-scoped toggle that renders the selection as two
;; half-size lines within one inline position (top/bottom in horizontal
;; flow, right/left in vertical flow). Applies to the current selection
;; through the shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [warichu (radio-selected (:warichu values) "none")
options
(mf/with-memo []
[{:value "none"
:id "none-warichu"
:label (tr "workspace.options.text-options.warichu-none")
:icon i/warichu-none}
{:value "warichu"
:id "warichu-warichu"
:label (tr "workspace.options.text-options.warichu")
:icon i/warichu}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:warichu value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :warichu-options)}
[:> radio-buttons* {:selected warichu
:on-change handle-change
:name "warichu-options"
:options options}]]))
(mf/defc font-features-options*
;; Japanese proportional metric alternates. `palt` is typically used for
;; horizontal composition and `vpal` for vertical composition; the value is
;; span-scoped and passed through browser, export, and render-wasm paths.
[{:keys [values on-change on-blur]}]
(let [writing-mode (:writing-mode values)
feature (proportional-metrics-feature writing-mode)
value (:font-features values)
enabled? (cond
(mixed-span-value? value) nil
(= feature value) true
:else false)
handle-change
(mf/use-fn
(mf/deps feature on-change on-blur)
(fn [checked?]
(on-change {:font-features (if checked? feature "none")})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :font-features-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.font-features")]
[:> switch* {:default-checked enabled?
:aria-label (tr "workspace.options.text-options.font-features")
:on-change handle-change}]]))
(defn text-emphasis-options
([]
(text-emphasis-options tr))
([translate]
[{:id "none"
:label (translate "workspace.options.text-options.text-emphasis-none")}
{:id "filled-dot"
:label (translate "workspace.options.text-options.text-emphasis-filled-dot")}
{:id "open-dot"
:label (translate "workspace.options.text-options.text-emphasis-open-dot")}
{:id "filled-circle"
:label (translate "workspace.options.text-options.text-emphasis-filled-circle")}
{:id "open-circle"
:label (translate "workspace.options.text-options.text-emphasis-open-circle")}
{:id "filled-sesame"
:label (translate "workspace.options.text-options.text-emphasis-filled-sesame")}
{:id "open-sesame"
:label (translate "workspace.options.text-options.text-emphasis-open-sesame")}]))
(mf/defc text-emphasis-options*
;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base
;; character in vertical writing. Applies to the current selection through the
;; shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [value (:text-emphasis values)
text-emphasis (span-select-value value "none")
options
(mf/with-memo []
(text-emphasis-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-emphasis value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-emphasis-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-emphasis")]
[:> select* {:default-selected text-emphasis
:aria-label (tr "workspace.options.text-options.text-emphasis")
:options options
:on-change handle-change}]]))
(defn annotation-clearance-options
([]
(annotation-clearance-options tr))
([translate]
[{:id "none"
:label (translate "workspace.options.text-options.annotation-clearance-none")}
{:id "auto"
:label (translate "workspace.options.text-options.annotation-clearance-auto")}]))
(mf/defc annotation-clearance-options*
[{:keys [values on-change on-blur]}]
(let [value (:annotation-clearance values)
annotation-clearance (span-select-value value "none")
options (mf/with-memo [] (annotation-clearance-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:annotation-clearance value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :annotation-clearance-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.annotation-clearance")]
[:> select* {:default-selected annotation-clearance
:aria-label (tr "workspace.options.text-options.annotation-clearance")
:options options
:on-change handle-change}]]))
(mf/defc vertical-align*
[{:keys [values on-change on-blur]}]
(let [vertical-align (or (:vertical-align values) "top")
(let [vertical-align (radio-selected (:vertical-align values) "top")
options
(mf/with-memo []
[{:value "top"
@ -150,6 +661,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 []
@ -190,14 +702,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"
@ -221,8 +733,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))
@ -245,8 +757,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")
@ -257,6 +771,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)
@ -283,6 +800,30 @@
(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-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)
@ -298,7 +839,8 @@
(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
@ -354,6 +896,17 @@
;; --- Helpers
multiple? (->> values vals (d/seek #(= % :multiple)))
vertical-text-enabled?
(features/use-feature "text-vertical/v1")
selection-key (hash ids)
previous-selection-key-ref (mf/use-ref selection-key)
japanese-layout-active* (mf/use-state #(japanese-layout-enabled? values))
japanese-layout-active?
(deref japanese-layout-active*)
vertical-mode?
(vertical-japanese-layout? values)
apply-token!
(mf/use-fn
(mf/deps ids typography-tokens)
@ -395,6 +948,10 @@
(mf/use-fn
#(swap! token-dropdown-open* not))
toggle-japanese-layout
(mf/use-fn
#(reset! japanese-layout-active* %))
;; --- Event handlers
on-option-click
(mf/use-fn
@ -421,6 +978,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!)
@ -479,7 +1044,18 @@
:values values
:on-change on-change
:show-recent true
:on-blur on-text-blur})]
:on-blur on-text-blur})
ruby-presentation-props
(mf/props
{:values ruby-values
:on-change on-ruby-presentation-change
:on-blur on-text-blur})
japanese-toggle-props
(mf/spread-props common-props
{:enabled japanese-layout-active?
:on-toggle toggle-japanese-layout})]
(hooks/use-stream
expand-stream
@ -497,6 +1073,19 @@
(when token-dropdown-open?
(ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!))))
;; Selection style snapshots may temporarily omit paragraph attrs when a
;; span-level Japanese option changes. A new selection must instead trust
;; its persisted value. Handle both changes in one effect so a writing-mode
;; update cannot restore the previous selection's opt-in state.
(mf/with-effect [selection-key (:writing-mode values)]
(let [selection-changed?
(not= selection-key (mf/ref-val previous-selection-key-ref))]
(reset! japanese-layout-active*
(reconcile-japanese-layout-state japanese-layout-active?
values
selection-changed?))
(mf/set-ref-val! previous-selection-key-ref selection-key)))
[:section {:class (stl/css :element-set)
:aria-label (tr "workspace.options.text-options.text-section")}
[:div {:class (stl/css :element-title)}
@ -582,7 +1171,32 @@
: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]])])]))
[:*
[: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 ^boolean vertical-text-enabled?
[:div {:class (stl/css :japanese-layout-options)}
[:> japanese-layout-toggle* japanese-toggle-props]
(when ^boolean japanese-layout-active?
[:div {:class (stl/css :japanese-layout-controls)}
[:div {:class (stl/css :japanese-icon-options)}
[:> writing-mode-options* common-props]
(when ^boolean vertical-mode?
[:*
[:> text-orientation-options* common-props]
[:> text-combine-upright-options*
(mf/spread-props common-props
{:text-selection-active text-selection-active})]])
(when ^boolean text-selection-active
[:> warichu-options* common-props])]
(when ^boolean vertical-mode?
[:> text-combine-upright-count-options* common-props])
[:> font-features-options* common-props]
(when ^boolean text-selection-active
[:> text-emphasis-options* common-props])
[:> annotation-clearance-options* common-props]
(if text-selection-active
[:> ruby-advanced-options* common-props]
[:> ruby-presentation-options* ruby-presentation-props])])])])])]))

View File

@ -57,13 +57,93 @@
.align-options,
.text-direction-options,
.writing-mode-options,
.text-orientation-options,
.text-combine-upright-options,
.warichu-options,
.vertical-align-options,
.grow-options,
.text-decoration-options {
block-size: $sz-32;
}
.ruby-advanced-options {
display: flex;
flex-direction: column;
overflow: visible;
}
.ruby-options {
display: flex;
align-items: flex-end;
gap: var(--sp-xs);
}
.ruby-input {
flex: 1;
}
.ruby-advanced-content {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-xs);
border: $b-1 solid var(--color-background-quaternary);
border-radius: $br-8;
}
.text-emphasis-options {
inline-size: 100%;
}
.font-features-options {
inline-size: 100%;
}
.annotation-clearance-options {
inline-size: 100%;
}
.text-decoration-options {
display: flex;
gap: var(--sp-xs);
}
.japanese-layout-options,
.japanese-layout-controls {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.japanese-layout-options {
padding-block-start: var(--sp-xs);
border-block-start: $b-1 solid var(--color-background-quaternary);
}
.japanese-layout-toggle {
display: flex;
align-items: center;
min-block-size: $sz-32;
}
.japanese-icon-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-xs);
}
.japanese-select-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
align-items: center;
gap: var(--sp-s);
inline-size: 100%;
}
.japanese-option-label {
@include t.use-typography("body-small");
color: var(--color-foreground-secondary);
}

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

@ -15,6 +15,7 @@
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
[app.main.data.workspace.shapes :as dwsh]
@ -497,23 +498,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? (txt/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

@ -39,6 +39,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]
@ -97,6 +108,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"))
@ -368,6 +398,227 @@
:else
(st/emit! (dwt/update-text-range id start end {:text-decoration value}))))}
;; CHANGEME: All these new method need to be added to the plugin-api-test-suite
:fontFeatures
{:this true
:get
(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
@ -761,5 +1012,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

@ -439,7 +439,9 @@
(case ev
;; StylesChanged Event
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)]
(st/emit! (texts/v3-update-text-editor-styles shape-id current-styles)))
@ -1268,9 +1270,10 @@
emoji?
langs)
(let [text (apply str (map :text spans))
emoji? (if emoji? emoji? (t/contains-emoji? text))
langs (t/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? (t/contains-emoji? fallback-text))
langs (t/collect-used-languages langs fallback-text)]
;; FIXME: this should probably be somewhere else
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
@ -1282,7 +1285,7 @@
(let [updated-fonts
(-> #{}
(cond-> ^boolean emoji? (f/add-emoji-font))
(f/add-noto-fonts langs))
(f/add-noto-fonts (t/resolve-ambiguous-cjk langs)))
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
@ -2064,7 +2067,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."
@ -2520,6 +2525,56 @@
(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 (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 (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/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?)
@ -2547,35 +2602,79 @@
(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/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/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 (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
@ -2679,5 +2778,3 @@
viewport mount. Idempotent: the `delay` caches its in-flight promise."
[]
@module)

View File

@ -13,10 +13,12 @@
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]))
[app.render-wasm.wasm :as wasm]
[app.util.i18n :as i18n]
[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)
(defn- encode-text
@ -51,18 +53,25 @@
(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 (f/serialize-line-height (get paragraph :line-height))
letter-spacing (f/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 (f/serialize-line-height (get paragraph :line-height))
letter-spacing (f/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)
@ -90,6 +99,10 @@
text-buffer (encode-text (get span :text ""))
text-length (mem/size text-buffer)
ruby-buffer (encode-text (get span :ruby ""))
ruby-length (mem/size ruby-buffer)
fills (take MAX-TEXT-FILLS (get span :fills []))
font-variant-id
@ -113,13 +126,51 @@
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)
@ -131,6 +182,7 @@
(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)
@ -140,7 +192,9 @@
(defn write-shape-text
;; buffer has the following format:
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text> <ruby-text>]
;; Ruby annotations are concatenated per span, in span order, after the base
;; text. The reader splits them using the per-span byte lengths.
[spans paragraph text]
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
@ -152,7 +206,11 @@
text-buffer (encode-text text)
text-size (mem/size text-buffer)
total-size (+ 4 metadata-size text-size)
ruby-text (apply str (map #(get % :ruby "") normalized-spans))
ruby-buffer (encode-text ruby-text)
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)]
@ -161,7 +219,8 @@
(mem/write-u32 dview num-spans)
(write-paragraph dview normalized-paragraph)
(write-spans dview normalized-spans normalized-paragraph)
(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")))
@ -170,7 +229,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]"
@ -243,3 +309,31 @@
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 user
locale; Chinese is the final default."
([langs]
(resolve-ambiguous-cjk langs @i18n/locale))
([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)))

View File

@ -256,6 +256,78 @@
default (unchecked-get values "ltr")]
(d/nilv (unchecked-get values (d/name text-direction)) default)))
(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

@ -626,6 +626,125 @@
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn- text-char-count
[text]
(alength (js/Array.from text)))
(defn- text-char-subs
[text start end]
(->> (js/Array.from text)
(drop start)
(take (- end start))
(apply str)))
(defn- para-char-count
[para]
(apply + (map (comp text-char-count :text) (: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-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 (text-char-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 (text-char-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/context-initialized?
(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 attrs to spans within [sel-start, sel-end) char range of a single paragraph.
Splits spans at boundaries as needed."
@ -639,7 +758,7 @@
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-len (text-char-count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
@ -647,20 +766,16 @@
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
(assoc span :text (text-char-subs text 0 (- ol-start pos))))
selected (merge span attrs
{:text (subs text (- ol-start pos) (- ol-end pos))})
{:text (text-char-subs text (- ol-start pos) (- ol-end pos))})
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(assoc span :text (text-char-subs text (- ol-end pos) span-len)))]
(recur (rest spans) span-end
(-> acc
(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 apply-styles-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
(when wasm/context-initialized?

View File

@ -73,6 +73,17 @@
:fill-data shared/RawFillData
:text-align shared/RawTextAlign
:text-direction shared/RawTextDirection
:writing-mode shared/RawWritingMode
:text-orientation shared/RawTextOrientation
:text-combine-upright shared/RawTextCombineUpright
:text-emphasis shared/RawTextEmphasis
:warichu shared/RawWarichu
:font-features shared/RawFontFeatures
:annotation-clearance shared/RawAnnotationClearance
:ruby-size shared/RawRubySize
:ruby-align shared/RawRubyAlign
:ruby-overhang shared/RawRubyOverhang
:ruby-side shared/RawRubySide
:text-decoration shared/RawTextDecoration
:text-transform shared/RawTextTransform
:multiple-state shared/MultipleState
@ -81,4 +92,3 @@
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})

View File

@ -101,6 +101,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

@ -22,12 +22,37 @@
(def mapping
{:fills [encode decode]
:ruby [identity #(when-not (= "" %) %)]
:ruby-size [identity #(when-not (= "" %) %)]
:ruby-align [identity #(when-not (= "" %) %)]
:ruby-overhang [identity #(when-not (= "" %) %)]
:ruby-side [identity #(when-not (= "" %) %)]
:text-emphasis [identity #(when-not (= "" %) %)]
:warichu [identity #(when-not (= "" %) %)]
:font-features [identity #(when-not (= "" %) %)]
:annotation-clearance [identity #(when-not (= "" %) %)]
:typography-ref-id [encode decode]
:typography-ref-file [encode decode]
:font-id [identity identity]
:font-variant-id [identity identity]
:vertical-align [identity identity]})
(defn- text-combine-upright->css
[value]
(case value
"digits2" "digits 2"
"digits3" "digits 3"
"digits" "digits 4"
value))
(defn- css->text-combine-upright
[value]
(case value
"digits 2" "digits2"
"digits 3" "digits3"
"digits 4" "digits"
value))
(defn normalize-style-value
"This function adds units to style values"
[k v]
@ -114,14 +139,21 @@
([key value]
(attr->style-value key value false))
([key value normalize?]
(if (attr-needs-mapping? key)
(cond
(= key :text-combine-upright)
(text-combine-upright->css value)
(attr-needs-mapping? key)
(let [[encoder] (get mapping key)]
(if normalize?
(normalize-style-value key (encoder value))
(encoder value)))
(if normalize?
(normalize-style-value key value)
value))))
normalize?
(normalize-style-value key value)
:else
value)))
(defn attr->style
[[key value]]
@ -151,12 +183,18 @@
([name value]
(style->attr-value name value false))
([name value normalize?]
(if (style-needs-mapping? name)
(cond
(= name "text-combine-upright")
(css->text-combine-upright value)
(style-needs-mapping? name)
(let [key (get-attr-keyword-from-css-variable name)
[_ decoder] (get mapping key)]
(if normalize?
(normalize-attr-value key (decoder value))
(decoder value)))
:else
(let [key (get-attr-keyword name)]
(if normalize?
(normalize-attr-value key value)
@ -205,7 +243,10 @@
(assoc acc k (style-decode style-value))
acc))
(let [style-name (get-style-name k)
style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))]
style-value (.getPropertyValue style-declaration style-name)
style-value (if (= k :text-combine-upright)
(css->text-combine-upright style-value)
(normalize-attr-value k style-value))]
(if (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k style-value)
acc)))) {} txt/text-style-attrs))

View File

@ -90,7 +90,14 @@
(defn get-root-styles
[root]
(get-styles-from-attrs root txt/root-attrs txt/default-text-attrs))
(let [styles (get-styles-from-attrs root txt/root-attrs txt/default-text-attrs)
;; CHANGEME: simplify this comment
;; Mirroring the shape's writing mode on the root makes paragraph
;; blocks stack right-to-left in the DOM editor. It is not read
;; back from the root (root-attrs does not include it).
writing-mode (txt/content-writing-mode root)]
(cond-> styles
(some? writing-mode) (assoc :writing-mode writing-mode))))
(defn get-text-span-styles
[inline paragraph]

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,17 +6,91 @@
(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]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.common.types.text :as txt]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.texts :as dwt]
[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")))))
(t/deftest wasm-selection-skips-whole-shape-text-node-updates
(t/is (false? (dwt/globally-update-text-node-attrs? true true)))
(t/is (true? (dwt/globally-update-text-node-attrs? true false)))
(t/is (true? (dwt/globally-update-text-node-attrs? false true))))
;; ---------------------------------------------------------------------------
;; 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

@ -28,8 +28,17 @@
;; accept/reject contract here.
(def ^:private letter-spacing-re @#'plugins.text/letter-spacing-re)
(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? [s] (boolean (re-matches letter-spacing-re s)))
(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-re-accepts-negative-values
(t/is (valid? "-0.56"))
@ -46,6 +55,119 @@
(t/is (not (valid? "1-2")))
(t/is (not (valid? "--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,282 @@
;; 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.render-wasm.api :as api]
[app.render-wasm.api.texts :as texts]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[cljs.test :as t :include-macros true]))
(def ^:private write-spans @#'texts/write-spans)
(defn- langs [text]
(texts/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-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-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-size "third"
:ruby-align "center"
:ruby-overhang "none"
:ruby-side "under"
:annotation-clearance "auto"}
{:text "年"
:warichu "warichu"
:font-features "vpal"}])]
(t/is (= {:text-combine-upright :multiple
:text-emphasis :multiple
:ruby :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-count-non-bmp-text-as-one-character
(let [content (editor-content
[{:text "😀"}
{:text "日" :ruby "にち"}])]
(t/is (= "にち"
(:ruby
(text-editor/selection-japanese-styles
content (selection 0 1 0 2)))))))
(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-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 ""))))
(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}
(texts/resolve-ambiguous-cjk #{:japanese :han :cjk-punctuation} "zh")))
(t/is (= #{:japanese}
(texts/resolve-ambiguous-cjk #{:japanese :han} "en"))))
(t/deftest resolve-hangul-wins-over-locale
(t/is (= #{:korean}
(texts/resolve-ambiguous-cjk #{:korean :han} "ja"))))
(t/deftest resolve-han-only-uses-locale
(t/is (= #{:japanese} (texts/resolve-ambiguous-cjk #{:han} "ja")))
(t/is (= #{:japanese} (texts/resolve-ambiguous-cjk #{:han} "ja_paid")))
(t/is (= #{:korean} (texts/resolve-ambiguous-cjk #{:han} "ko")))
(t/is (= #{:chinese} (texts/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} (texts/resolve-ambiguous-cjk #{:han} "en")))
(t/is (= #{:chinese} (texts/resolve-ambiguous-cjk #{:han} nil))))
(t/deftest resolve-punctuation-only
(t/is (= #{:japanese} (texts/resolve-ambiguous-cjk #{:cjk-punctuation} "ja")))
(t/is (= #{:chinese} (texts/resolve-ambiguous-cjk #{:cjk-punctuation} "en"))))
(t/deftest resolve-leaves-unambiguous-sets-alone
(t/is (= #{:latin-ext} (texts/resolve-ambiguous-cjk #{:latin-ext} "ja")))
(t/is (= #{} (texts/resolve-ambiguous-cjk #{} "ja")))
;; Other detected languages survive resolution.
(t/is (= #{:japanese :cyrillic}
(texts/resolve-ambiguous-cjk #{:han :cyrillic} "ja"))))
(t/deftest resolve-han-unification-fixture
;; Acceptance case from render-wasm/fixtures/japanese-typography.json:
;; a kanji-only Japanese address resolves to Japanese under a ja locale.
(t/is (= #{:japanese}
(texts/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja"))))

View File

@ -46,6 +46,7 @@
[frontend-tests.plugins.utils-test]
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.render-wasm.texts-test]
[frontend-tests.svg-fills-test]
[frontend-tests.tokens.import-export-test]
[frontend-tests.tokens.logic.token-actions-test]
@ -56,9 +57,11 @@
[frontend-tests.tokens.workspace-tokens-remap-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.layout-container-multiple-test]
[frontend-tests.ui.measures-menu-props-test]
[frontend-tests.ui.text-options-test]
[frontend-tests.util-object-test]
[frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test]
@ -120,6 +123,7 @@
'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-objects-test
'frontend-tests.render-wasm.texts-test
'frontend-tests.svg-fills-test
'frontend-tests.tokens.import-export-test
'frontend-tests.tokens.logic.token-actions-test
@ -130,9 +134,11 @@
'frontend-tests.tokens.workspace-tokens-remap-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.layout-container-multiple-test
'frontend-tests.ui.measures-menu-props-test
'frontend-tests.ui.text-options-test
'frontend-tests.util-object-test
'frontend-tests.util-range-tree-test
'frontend-tests.util-simple-math-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,207 @@
;; 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.styles :as text-styles]
[app.main.ui.workspace.sidebar.options.menus.text :as text-menu]
[app.util.text.content.styles :as content-styles]
[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-size
:ruby-align
:ruby-overhang
:ruby-side
:warichu
:font-features
:annotation-clearance])
(def ^:private radio-selected @#'text-menu/radio-selected)
(def ^:private span-input-value @#'text-menu/span-input-value)
(def ^:private span-select-value @#'text-menu/span-select-value)
(def ^:private with-mixed-span-option @#'text-menu/with-mixed-span-option)
(def ^:private ruby-common-props @#'text-menu/ruby-common-props)
(t/deftest text-emphasis-select-reports-and-restores-canonical-values
(let [options (text-menu/text-emphasis-options identity)
reported-id (:id (select/get-option options "filled-dot"))]
(t/is (= "filled-dot" reported-id))
(t/is (= "filled-dot"
(:id (select/get-option options reported-id))))))
(t/deftest annotation-clearance-select-reports-canonical-values
(let [options (text-menu/annotation-clearance-options identity)]
(t/is (= ["none" "auto"] (mapv :id options)))
(t/is (= "auto" (:id (select/get-option options "auto"))))))
(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection
(t/is (= ["none" "digits"]
(mapv :value (text-menu/text-combine-upright-options false identity))))
(t/is (= ["none" "all" "digits"]
(mapv :value (text-menu/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 text-menu/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-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-size "half"}
{:text "本"
:ruby ""
:ruby-size "quarter"}]}]}]}}
values (dwt/current-ruby-values {:shape shape
:attrs dwt/ruby-presentation-attrs})
updated (dwt/update-ruby-presentation-attrs shape {:ruby-size "third"})
spans (get-in updated [:content :children 0 :children 0 :children])]
(t/is (= "half" (:ruby-size values)))
(t/is (= "third" (:ruby-size (first spans))))
(t/is (= "quarter" (:ruby-size (second spans))))))
(t/deftest mixed-japanese-span-values-have-distinct-control-states
(let [options (with-mixed-span-option
(text-menu/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-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-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 counted-tcy-values-round-trip-through-valid-css
(t/is (= "digits 2"
(unchecked-get
(content-styles/attrs->styles {:text-combine-upright "digits2"})
"text-combine-upright")))
(t/is (= {:text-combine-upright "digits3"}
(content-styles/styles->attrs
{"text-combine-upright" "digits 3"}))))
(t/deftest japanese-layout-is-explicitly-enabled-by-writing-mode
(t/is (false? (text-menu/japanese-layout-enabled? {})))
(t/is (false? (text-menu/japanese-layout-enabled? {:writing-mode nil})))
(t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "horizontal-tb"})))
(t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "vertical-rl"})))
(t/is (nil? (text-menu/japanese-layout-enabled? {:writing-mode :multiple}))))
(t/deftest japanese-layout-toggle-emits-a-persisted-writing-mode
(t/is (= {:writing-mode "horizontal-tb"}
(text-menu/japanese-layout-toggle-attrs true)))
(t/is (= {:writing-mode nil}
(text-menu/japanese-layout-toggle-attrs false))))
(t/deftest japanese-layout-state-survives-transient-selection-styles
(t/is (true? (text-menu/reconcile-japanese-layout-state true {} false)))
(t/is (true? (text-menu/reconcile-japanese-layout-state false
{:writing-mode "vertical-rl"}
false)))
(t/is (false? (text-menu/reconcile-japanese-layout-state true {} true))))
(t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes
(t/is (false? (text-menu/vertical-japanese-layout?
{:writing-mode "horizontal-tb"})))
(t/is (true? (text-menu/vertical-japanese-layout?
{:writing-mode "vertical-rl"})))
(t/is (= "palt"
(text-menu/proportional-metrics-feature "horizontal-tb")))
(t/is (= "vpal"
(text-menu/proportional-metrics-feature "vertical-rl"))))

View File

@ -16,8 +16,15 @@ import {
mapContentFragmentFromString,
} from "./content/dom/Content.js";
import { resetInertElement } from "./content/dom/Style.js";
import { createRoot, createEmptyRoot } from "./content/dom/Root.js";
import { createParagraph } from "./content/dom/Paragraph.js";
import {
createRoot,
createEmptyRoot,
setRootStyles,
} from "./content/dom/Root.js";
import {
createParagraph,
setParagraphStyles,
} from "./content/dom/Paragraph.js";
import { createEmptyTextSpan, createTextSpan } from "./content/dom/TextSpan.js";
import { isLineBreak } from "./content/dom/LineBreak.js";
import LayoutType from "./layout/LayoutType.js";
@ -385,8 +392,7 @@ export class TextEditor extends EventTarget {
* @param {InputEvent} e
*/
#onBeforeInput = (e) => {
if (e.inputType === "historyUndo"
|| e.inputType === "historyRedo") {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
return;
}
@ -416,8 +422,7 @@ export class TextEditor extends EventTarget {
* @param {InputEvent} e
*/
#onInput = (e) => {
if (e.inputType === "historyUndo"
|| e.inputType === "historyRedo") {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
return;
}
@ -628,6 +633,25 @@ export class TextEditor extends EventTarget {
return this;
}
/**
* Applies paragraph-level styles to the root and to every paragraph,
* regardless of the current selection. Used for properties that are
* whole-shape in the model (e.g. writing-mode) so paragraphs cannot
* diverge.
*
* @param {Object.<string, *>} styles
* @returns {TextEditor}
*/
applyStylesToAllParagraphs(styles) {
setRootStyles(this.#root, styles);
for (const paragraph of this.#root.children) {
setParagraphStyles(paragraph, styles);
}
this.#notifyLayout(LayoutType.FULL);
this.#changeController.notifyImmediately();
return this;
}
/**
* Selects all content.
*
@ -705,7 +729,7 @@ export function isEmpty(instance) {
if (isTextEditor(instance)) {
return instance.isEmpty;
}
throw new TypeError('Instance is not a TextEditor');
throw new TypeError("Instance is not a TextEditor");
}
/**
@ -759,7 +783,7 @@ export function getCurrentStyle(instance) {
if (isTextEditor(instance)) {
return instance.currentStyle;
}
throw new TypeError('Instance is not a TextEditor');
throw new TypeError("Instance is not a TextEditor");
}
/**
@ -774,7 +798,22 @@ export function applyStylesToSelection(instance, styles) {
if (isTextEditor(instance)) {
return instance.applyStylesToSelection(styles);
}
throw new TypeError('Instance is not a TextEditor');
throw new TypeError("Instance is not a TextEditor");
}
/**
* Applies the specified paragraph-level styles to every paragraph of the
* TextEditor passed, regardless of the selection.
*
* @param {TextEditor} instance
* @param {Object.<string, *>} styles
* @returns {TextEditor|null}
*/
export function applyStylesToAllParagraphs(instance, styles) {
if (isTextEditor(instance)) {
return instance.applyStylesToAllParagraphs(styles);
}
throw new TypeError("Instance is not a TextEditor");
}
/**
@ -788,7 +827,7 @@ export function dispose(instance) {
if (isTextEditor(instance)) {
return instance.dispose();
}
throw new TypeError('Instance is not a TextEditor');
throw new TypeError("Instance is not a TextEditor");
}
export default TextEditor;

View File

@ -43,6 +43,47 @@ describe("TextEditor", () => {
expect(textEditor.numParagraphs).toBe(4);
});
test("applyStylesToAllParagraphs styles every paragraph and the root", () => {
const textEditor = new TextEditor(document.createElement("div"));
textEditor.root = textEditor.createRoot([
textEditor.createParagraph([
textEditor.createTextSpanFromString("Hello, World!"),
]),
textEditor.createParagraph([
textEditor.createTextSpanFromString("¡Hola, Mundo!"),
]),
]);
textEditor.applyStylesToAllParagraphs({ "writing-mode": "vertical-rl" });
expect(textEditor.root.style.getPropertyValue("writing-mode")).toBe(
"vertical-rl",
);
for (const paragraph of textEditor.root.children) {
expect(paragraph.style.getPropertyValue("writing-mode")).toBe(
"vertical-rl",
);
}
});
test("applyStylesToAllParagraphs removes a style when its value is null", () => {
const textEditor = new TextEditor(document.createElement("div"));
textEditor.root = textEditor.createRoot([
textEditor.createParagraph([
textEditor.createTextSpanFromString("Hello, World!"),
]),
textEditor.createParagraph([
textEditor.createTextSpanFromString("¡Hola, Mundo!"),
]),
]);
textEditor.applyStylesToAllParagraphs({ "writing-mode": "vertical-rl" });
textEditor.applyStylesToAllParagraphs({ "writing-mode": null });
expect(textEditor.root.style.getPropertyValue("writing-mode")).toBe("");
for (const paragraph of textEditor.root.children) {
expect(paragraph.style.getPropertyValue("writing-mode")).toBe("");
}
});
test("Disposing a TextEditor nullifies everything", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);

View File

@ -46,6 +46,8 @@ export const STYLES = [
["text-transform"],
["text-align"],
["direction"],
["writing-mode"],
["text-orientation"],
];
/**
@ -146,18 +148,20 @@ export function createParagraphWith(text, styles, attrs) {
if (text === "" || text === "\n") {
return createEmptyParagraph(styles, attrs);
}
return createParagraph([
createTextSpan(new Text(text))
], styles, attrs);
return createParagraph([createTextSpan(new Text(text))], styles, attrs);
} else if (Array.isArray(text)) {
return createParagraph(
text.map((text) => {
if (text === "" || text === "\n") return createEmptyTextSpan(styles);
return createTextSpan(new Text(text), styles);
})
, styles, attrs);
}),
styles,
attrs,
);
} else {
throw new TypeError("Invalid text, it should be an array of strings or a string");
throw new TypeError(
"Invalid text, it should be an array of strings or a string",
);
}
}

View File

@ -13,7 +13,7 @@ import { setStyles } from "./Style.js";
export const TAG = "DIV";
export const TYPE = "root";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [["--vertical-align"]];
export const STYLES = [["--vertical-align"], ["writing-mode"]];
/**
* Returns true if passed node is a root.

View File

@ -243,8 +243,7 @@ export function normalizeStyles(
* @returns {HTMLElement}
*/
export function setStyle(element, styleName, styleValue, styleUnit) {
if (styleValue === "mixed")
return element;
if (styleValue === "mixed") return element;
if (
styleName.startsWith("--") &&
@ -342,6 +341,10 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
}
let styleValue = styleObject[styleName];
if (styleValue == null) {
element.style.removeProperty(styleName);
continue;
}
if (!styleValue) {
continue;
}

View File

@ -24,7 +24,16 @@ export const STYLES = [
["--typography-ref-file"],
["--font-id"],
["--font-variant-id"],
["--font-features"],
["--annotation-clearance"],
["--fills"],
["--ruby"],
["--ruby-size"],
["--ruby-align"],
["--ruby-overhang"],
["--ruby-side"],
["--text-emphasis"],
["--warichu"],
["font-variant"],
["font-family"],
["font-size", "px"],
@ -32,6 +41,7 @@ export const STYLES = [
["font-style"],
["line-height"],
["letter-spacing", "px"],
["text-combine-upright"],
["text-decoration"],
["text-transform"],
];

View File

@ -55,6 +55,19 @@ import { SafeGuard } from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from "./StyleDeclaration.js";
const JAPANESE_SPAN_STYLE_DEFAULTS = {
"text-combine-upright": "none",
"--text-emphasis": "none",
"--ruby": "",
"--ruby-size": "half",
"--ruby-align": "space-around",
"--ruby-overhang": "auto",
"--ruby-side": "over",
"--warichu": "none",
"--font-features": "none",
"--annotation-clearance": "none",
};
/**
* Supported options for the SelectionController.
*
@ -228,6 +241,13 @@ export class SelectionController extends EventTarget {
* @param {HTMLElement} element
*/
#applyStylesFromElementToCurrentStyle(element) {
if (isTextSpan(element)) {
for (const [styleName, defaultValue] of Object.entries(
JAPANESE_SPAN_STYLE_DEFAULTS,
)) {
this.#currentStyle.setProperty(styleName, defaultValue);
}
}
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
// Only merge fill styles from text spans.
@ -257,6 +277,15 @@ export class SelectionController extends EventTarget {
}
this.#currentStyle.mergeProperty(styleName, styleValue);
}
if (isTextSpan(element)) {
for (const [styleName, defaultValue] of Object.entries(
JAPANESE_SPAN_STYLE_DEFAULTS,
)) {
if (!element.style.getPropertyValue(styleName)) {
this.#currentStyle.mergeProperty(styleName, defaultValue);
}
}
}
}
/**

View File

@ -9,7 +9,7 @@ import { createLineBreak } from "../content/dom/LineBreak.js";
import { TextEditorMock } from "../../test/TextEditorMock.js";
import { SelectionController } from "./SelectionController.js";
import { SelectionDirection } from "./SelectionDirection.js";
import StyleDeclaration from './StyleDeclaration.js';
import StyleDeclaration from "./StyleDeclaration.js";
/* @vitest-environment jsdom */
@ -340,15 +340,15 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf(
Text,
);
expect(textEditorMock.root.firstChild.children.item(0).firstChild.nodeValue).toBe(
"Lorem ",
);
expect(
textEditorMock.root.firstChild.children.item(0).firstChild.nodeValue,
).toBe("Lorem ");
expect(
textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
).toBe("ipsum ");
expect(textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue).toBe(
"dolor",
);
expect(
textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue,
).toBe("dolor");
});
test("`insertPaste` should insert a text span from a pasted fragment (at end)", () => {
@ -606,25 +606,29 @@ describe("SelectionController", () => {
const textEditorMock = TextEditorMock.createTextEditorMockEmpty();
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
focus(
selection,
const selectionController = new SelectionController(
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
selection,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe("paragraph");
expect(textEditorMock.root.children.item(0).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe("span");
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe("paragraph");
expect(textEditorMock.root.children.item(1).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
@ -636,7 +640,7 @@ describe("SelectionController", () => {
test("`insertParagraph` should insert a new paragraph after a text", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"]
["Hello, World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
@ -648,7 +652,7 @@ describe("SelectionController", () => {
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Hello, World!".length
"Hello, World!".length,
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
@ -677,9 +681,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(1).firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
expect(
textEditorMock.root.children.item(1).firstChild.firstChild,
).toBeInstanceOf(HTMLBRElement);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
@ -693,12 +697,7 @@ describe("SelectionController", () => {
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
@ -713,9 +712,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(0).firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
expect(
textEditorMock.root.children.item(0).firstChild.firstChild,
).toBeInstanceOf(HTMLBRElement);
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe(
"paragraph",
@ -954,10 +953,9 @@ describe("SelectionController", () => {
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (2 completely selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, ", "World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@ -995,10 +993,9 @@ describe("SelectionController", () => {
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (2 partially selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, ", "World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@ -1041,10 +1038,9 @@ describe("SelectionController", () => {
});
test("`replaceTextSpans` should replace the selected text in multiple text spans (1 partially selected, 1 completely selected)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, ", "World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@ -1162,10 +1158,9 @@ describe("SelectionController", () => {
});
test("`removeSelected` multiple text spans", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([[
"Hello, ",
"World!",
]]);
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, ", "World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
@ -1604,6 +1599,46 @@ describe("SelectionController", () => {
);
});
test("`applyStyles` keeps font features on the selected text span", () => {
const textEditorMock =
TextEditorMock.createTextEditorMockWithText("日本語");
const root = textEditorMock.root;
const textNode = root.firstChild.firstChild.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, textNode, 0, textNode, 3);
selectionController.applyStyles({ "--font-features": "palt" });
const selectedSpan = root.firstChild.firstChild;
expect(selectedSpan.style.getPropertyValue("--font-features")).toBe("palt");
expect(selectedSpan.getAttribute("style")).toContain(
"--font-features: palt",
);
});
test("`applyStyles` keeps annotation clearance on the selected text span", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText("漢字");
const root = textEditorMock.root;
const textNode = root.firstChild.firstChild.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(selection, textEditorMock, textNode, 0, textNode, 2);
selectionController.applyStyles({ "--annotation-clearance": "auto" });
const selectedSpan = root.firstChild.firstChild;
expect(selectedSpan.style.getPropertyValue("--annotation-clearance")).toBe(
"auto",
);
});
test("`applyStyles` to paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
@ -1695,11 +1730,14 @@ describe("SelectionController", () => {
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selectionController.selectAll();
expect(selectionController.anchorNode).toBe(
root.firstChild.firstChild.firstChild
root.firstChild.firstChild.firstChild,
);
expect(selectionController.focusNode).toBe(
root.lastChild.firstChild.firstChild,
@ -1717,12 +1755,17 @@ describe("SelectionController", () => {
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selectionController.cursorToEnd();
expect(selectionController.focusNode).toBe(root.lastChild.firstChild.firstChild);
expect(selectionController.focusNode).toBe(
root.lastChild.firstChild.firstChild,
);
expect(selectionController.focusAtEnd).toBeTruthy();
})
});
test("`currentStyle` ignores empty text nodes when merging span styles (no false mixed font-size)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
@ -1772,6 +1815,65 @@ describe("SelectionController", () => {
);
});
test("`currentStyle` reports every differing Japanese span style as mixed", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
createTextSpan(new Text("平成"), {
"text-combine-upright": "digits 2",
"--text-emphasis": "filled-dot",
"--ruby": "へいせい",
"--ruby-size": "third",
"--ruby-align": "center",
"--ruby-overhang": "none",
"--ruby-side": "under",
"--warichu": "none",
"--font-features": "palt",
"--annotation-clearance": "auto",
}),
createTextSpan(new Text("年"), {
"text-combine-upright": "none",
"--text-emphasis": "none",
"--ruby": "",
"--ruby-size": "half",
"--ruby-align": "space-around",
"--ruby-overhang": "auto",
"--ruby-side": "over",
"--warichu": "warichu",
"--font-features": "none",
"--annotation-clearance": "none",
}),
]),
]);
const paragraph = textEditorMock.root.firstChild;
const firstTextNode = paragraph.firstChild.firstChild;
const lastTextNode = paragraph.lastChild.firstChild;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
textEditorMock.element.focus();
selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 1);
document.dispatchEvent(new Event("selectionchange"));
for (const property of [
"text-combine-upright",
"--text-emphasis",
"--ruby",
"--ruby-size",
"--ruby-align",
"--ruby-overhang",
"--ruby-side",
"--warichu",
"--font-features",
"--annotation-clearance",
]) {
expect(selectionController.currentStyle.getPropertyValue(property)).toBe(
"mixed",
);
}
});
test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([
@ -1807,13 +1909,11 @@ describe("SelectionController", () => {
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
focus(
selection,
const selectionController = new SelectionController(
textEditorMock,
root.firstChild.firstChild.firstChild,
0
selection,
);
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
selectionController.dispose();
expect(selectionController.selection).toBe(null);
expect(selectionController.currentStyle).toBe(null);

View File

@ -1,5 +1,8 @@
import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph, createParagraphWith } from "../editor/content/dom/Paragraph.js";
import {
createParagraph,
createParagraphWith,
} from "../editor/content/dom/Paragraph.js";
import {
createEmptyTextSpan,
createTextSpan,
@ -116,7 +119,9 @@ export class TextEditorMock extends EventTarget {
* @returns {TextEditorMock}
*/
static createTextEditorMockWith(paragraphs) {
const root = createRoot(paragraphs.map((paragraph) => createParagraphWith(paragraph)));
const root = createRoot(
paragraphs.map((paragraph) => createParagraphWith(paragraph)),
);
return this.createTextEditorMockWithRoot(root);
}

View File

@ -8514,6 +8514,194 @@ msgstr "Title case"
msgid "workspace.options.text-options.underline"
msgstr "Underline (%s)"
#: 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.japanese-layout"
msgstr "Japanese text layout"
#: 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-count"
msgstr "Digit count"
#: 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.warichu-none"
msgstr "Normal"
#: 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.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
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-none"
msgstr "Use line height"
#: 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.text-emphasis"
msgstr "Emphasis marks"
#: 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-filled-dot"
msgstr "Filled dot"
#: 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-filled-circle"
msgstr "Filled circle"
#: 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-filled-sesame"
msgstr "Filled sesame"
#: 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.ruby"
msgstr "Ruby"
#: 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-advanced-options"
msgstr "Advanced furigana options"
#: 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-third"
msgstr "Third"
#: 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-align"
msgstr "Ruby alignment"
#: 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-center"
msgstr "Center"
#: 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.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-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-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.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
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/typography.cljs
#, unused
msgid "workspace.options.text-options.uppercase"
@ -9766,6 +9954,11 @@ msgstr "Create board. Click and drag to define its size. (%s)"
msgid "workspace.toolbar.image"
msgstr "Image (%s)"
#: src/app/main/ui/workspace/top_toolbar.cljs:339
#, fuzzy
msgid "workspace.toolbar.label"
msgstr ""
msgid "workspace.toolbar.line"
msgstr "Line (%s)"

View File

@ -983,3 +983,191 @@ msgstr "招待を再送"
#: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:320, src/app/main/ui/static.cljs:329, src/app/main/ui/static.cljs:450
msgid "labels.retry"
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.japanese-layout"
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-count"
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.warichu-none"
msgstr "通常"
#: 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.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
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-none"
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.text-emphasis"
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-filled-dot"
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-filled-circle"
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-filled-sesame"
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.ruby"
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-advanced-options"
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-third"
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-align"
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-center"
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.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-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-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.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
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 "縦書き(右から左)"

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

@ -3,6 +3,14 @@
### 🚀 Features
- **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 `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

@ -4297,6 +4297,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.
@ -4397,6 +4479,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.
*/

View File

@ -0,0 +1,235 @@
{
"description": "Japanese typography fixture corpus (Phase 0). Each fixture is a paragraph-level test text plus the behaviors it exercises. Used to compare rendering across editor, WASM viewport, PNG, SVG and PDF exports, and later as regression fixtures for kinsoku/vertical/ruby/TCY work.",
"fixtures": [
{
"id": "horizontal-basic",
"title": "Basic horizontal Japanese",
"text": "吾輩は猫である。名前はまだ無い。",
"exercises": ["kanji+kana rendering", "CJK punctuation 。", "font fallback"]
},
{
"id": "horizontal-mixed",
"title": "Mixed kanji/kana/Latin/numbers/symbols",
"text": "Penpot UIは2024年にWASM rendererを導入した。価格は¥1,500税込です",
"exercises": [
"quarter-em CJK↔Latin boundaries",
"one-third-em Western word spaces",
"full-width ",
"half-width digits",
"currency sign"
]
},
{
"id": "kinsoku-line-start",
"title": "Forbidden line-start characters",
"text": "これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
"exercises": ["kinsoku: 、。」not at line start", "small kana not at line start"]
},
{
"id": "kinsoku-line-end",
"title": "Forbidden line-end characters",
"text": "開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
"exercises": ["kinsoku: 「『([【 not at line end"]
},
{
"id": "prolonged-sound",
"title": "Prolonged sound mark and iteration marks",
"text": "コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
"exercises": ["ー not at line start", "iteration mark 々", "katakana runs"]
},
{
"id": "vertical-basic",
"title": "Basic vertical Japanese",
"text": "春はあけぼの。やうやう白くなりゆく山際、少し明かりて。",
"writing_mode": "vertical-rl",
"exercises": ["column flow top→bottom right→left", "vertical forms of 、。"]
},
{
"id": "vertical-punctuation",
"title": "Vertical punctuation and brackets",
"text": "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)[注釈]【重要】!?",
"writing_mode": "vertical-rl",
"exercises": ["vert/vrt2 forms of 「」『』()[]【】", "full-width upright"]
},
{
"id": "vertical-prolonged",
"title": "Prolonged sound mark in vertical",
"text": "コンピューターゲームのニュース。データベースサーバー。",
"writing_mode": "vertical-rl",
"exercises": ["ー must rotate to vertical form", "katakana column flow"]
},
{
"id": "vertical-mixed-latin",
"title": "Latin and digits inside vertical text",
"text": "私はPenpot UIを使って2024年からWASM rendererを設計しています。",
"writing_mode": "vertical-rl",
"exercises": [
"rotated Latin runs",
"quarter-em mixed orientation boundaries",
"one-third-em Western word spaces",
"digit runs (TCY candidates)"
]
},
{
"id": "tcy-candidates",
"title": "Tate-chu-yoko candidate runs",
"text": "第2章では、平成31年4月1日と12月25日。甲」20「乙、20。甲「20」乙。",
"writing_mode": "vertical-rl",
"exercises": [
"12 digit runs for TCY",
"cl-30 half-em after closing punctuation and before opening brackets",
"cl-30 solid after opening and before closing punctuation",
"explicit all and digit TCY modes"
]
},
{
"id": "ruby-simple",
"title": "Simple ruby (annotation) examples",
"text": "漢字にふりがなを振る。振り仮名は難しい。",
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "振り仮名", "ruby": "ふりがな" }
],
"exercises": ["whole-span base/annotation alignment", "line-height inflation"]
},
{
"id": "horizontal-annotation-clearance-none",
"title": "Horizontal ruby and emphasis with line-height-only clearance",
"text": "漢字\n熟語",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "none",
"exercises": ["legacy horizontal annotation collision", "explicit line-height compatibility"]
},
{
"id": "horizontal-annotation-clearance-auto",
"title": "Horizontal ruby and emphasis with automatic clearance",
"text": "漢字\n熟語",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "auto",
"exercises": ["automatic horizontal line-gap reservation", "ruby nearest and emphasis outside"]
},
{
"id": "vertical-annotation-clearance-none",
"title": "Vertical ruby and emphasis with line-height-only clearance",
"text": "漢字\n熟語",
"writing_mode": "vertical-rl",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "none",
"exercises": ["legacy vertical annotation collision", "explicit column-gap compatibility"]
},
{
"id": "vertical-annotation-clearance-auto",
"title": "Vertical ruby and emphasis with automatic clearance",
"text": "漢字\n熟語",
"writing_mode": "vertical-rl",
"line_height": 1,
"ruby": [
{ "base": "漢字", "ruby": "かんじ" },
{ "base": "熟語", "ruby": "じゅくご" }
],
"text_emphasis": "filled-dot",
"annotation_clearance": "auto",
"exercises": ["automatic vertical column-gap reservation", "ruby nearest and emphasis outside"]
},
{
"id": "horizontal-ruby-customization",
"title": "Horizontal ruby size, alignment, overhang, and side",
"text": "標準 三分 中央 下側",
"ruby": [
{ "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" },
{ "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" },
{ "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" },
{ "base": "下側", "ruby": "したがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" }
],
"exercises": [
"half, third, and quarter ruby sizes",
"space-around, start, center, and space-between alignment",
"automatic and prohibited overhang",
"over and under annotation sides"
]
},
{
"id": "vertical-ruby-customization",
"title": "Vertical ruby size, alignment, overhang, and side",
"text": "標準 三分 中央 左側",
"writing_mode": "vertical-rl",
"ruby": [
{ "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" },
{ "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" },
{ "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" },
{ "base": "左側", "ruby": "ひだりがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" }
],
"exercises": [
"configured ruby gutter widths",
"vertical flow-axis annotation distribution",
"over/right and under/left annotation sides",
"viewport and export parity"
]
},
{
"id": "small-kana-sokuon",
"title": "Small kana and sokuon clusters",
"text": "ちょっと待ってください。キャッシュとクッキーをチェックする。",
"exercises": ["small kana ょっゃュッ", "kinsoku: small kana not at line start"]
},
{
"id": "cjk-punct-spacing",
"title": "CJK punctuation spacing and full-width space",
"text": "これは、テスト。『「括弧」』。」「次」・「項」!? 全角スペースの後。",
"writing_mode": "vertical-rl",
"exercises": [
"preferred half-em punctuation aki",
"solid consecutive opening and closing punctuation",
"half-em closing-to-opening spacing",
"quarter-em middle-dot adjacency",
"question/exclamation and full-width space"
]
},
{
"id": "jlreq-character-classes",
"title": "Representative JLREQ character classes",
"text": "「始」〜・。、…々ーっ¥100 あア漢A㎏",
"exercises": [
"cl-01 through cl-19 scalar classification",
"cl-24 grouped numerals",
"cl-25 unit symbols",
"cl-26 Western word space",
"cl-27 Western characters"
]
},
{
"id": "han-unification",
"title": "Kanji-only text (Han unification trap)",
"text": "東京都渋谷区神南一丁目",
"exercises": ["kanji-only classifies as :chinese today → falls back to Noto Sans SC not JP", "JP-vs-CN disambiguation policy"]
},
{
"id": "long-paragraph-wrap",
"title": "Long paragraph exercising many line breaks",
"text": "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
"writing_mode": "vertical-rl",
"text_align": "justify",
"exercises": [
"many break opportunities",
"ordered oikomi before oidashi",
"priority-based justified expansion",
"kinsoku and burasage integration"
]
}
]
}

Binary file not shown.

Binary file not shown.

View File

@ -114,7 +114,7 @@ fn render_init(width: i32, height: i32) {
}
/// 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

@ -1453,7 +1453,102 @@ impl RenderState {
)
})
.unzip();
if skip_effects {
// CHANGEME: Extract all this code to its own function
if text_content.is_vertical() {
// Vertical writing paints through the custom vertical pass:
// drop shadows and strokes derive from the same laid-out cells
// as the fills (decorations are painted with the fill pass).
use crate::shapes::text_vertical;
let blur_filter = (!skip_effects).then(|| shape.image_filter(1.)).flatten();
// The shadow/stroke passes share one layout, computed only
// when at least one of them has something to paint (the
// fill pass lays out on its own inside text::render).
let v_bounds = text_content.bounds();
let mut drop_shadows = if skip_effects {
Vec::new()
} else {
shape.drop_shadow_paints()
};
if !skip_effects {
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
}
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
let v_layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let v_max_height =
text_vertical::wrap_height(text_content, v_bounds.height());
text_vertical::layout_from_content(text_content, v_max_height)
});
// 1. Drop shadows (silhouettes behind the fills, on the same
// fills surface so they composite under the glyphs).
if let Some(v_layout) = v_layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id);
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
shadow,
);
}
}
// 2. Fills + decorations.
text::render(
Some(self),
None,
&shape,
&mut paragraph_builders,
Some(fills_surface_id),
None,
blur_filter.as_ref(),
None,
None,
)?;
// 3. Strokes masked to the vertical cells.
if let Some(v_layout) = v_layout.as_ref().filter(|_| !strokes.is_empty()) {
let selrect = shape.selrect();
let canvas = self.surfaces.canvas_and_mark_dirty(strokes_surface_id);
for stroke in &strokes {
text_vertical::paint_stroke(
canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter.as_ref(),
);
}
}
// 4. Developer overlay: jlreq-style character-frame grid.
if self.options.is_text_grid_visible() {
let owned_layout;
let grid_layout = match v_layout.as_ref() {
Some(layout) => layout,
None => {
let v_max_height =
text_vertical::wrap_height(text_content, v_bounds.height());
owned_layout =
text_vertical::layout_from_content(text_content, v_max_height);
&owned_layout
}
};
let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id);
text_vertical::paint_grid(
canvas,
grid_layout,
&v_bounds,
shape.vertical_align(),
);
}
} else if skip_effects {
// Fast path: render fills and strokes only (skip shadows/blur).
text::render(
Some(self),

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).
@ -113,6 +114,12 @@ impl RenderOptions {
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
}
pub fn set_antialias_threshold(&mut self, value: f32) -> bool {
if value.is_finite() && value > 0.0 {
self.antialias_threshold = value;

View File

@ -3,8 +3,8 @@ use crate::{
error::Result,
math::Rect,
shapes::{
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke,
StrokeKind, TextContent,
add_horizontal_span, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
ParagraphLayout, Stroke, StrokeKind, TextContent,
},
utils::{get_fallback_fonts, get_font_collection},
};
@ -31,7 +31,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);
@ -39,8 +40,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();
@ -55,7 +54,7 @@ pub fn stroke_paragraph_builder_group_from_text(
paragraph.line_height(),
);
builder.push_style(&stroke_style);
builder.add_text(&text);
add_horizontal_span(builder, span, text, &stroke_style, fonts);
}
}
@ -69,7 +68,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,
@ -403,16 +402,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_text_decorations(
canvas,

View File

@ -1,4 +1,5 @@
use crate::render::options::RenderOptions;
use crate::shapes::text_vertical;
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox;
@ -48,17 +49,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);
@ -127,6 +144,26 @@ fn calculate_cursor_rect(
return None;
}
// Vertical writing: selrect-local caret bar from the vertical cells.
// CHANGEME: extract to function
if text_content.is_vertical() {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
let rect = text_vertical::caret_rect(&layout, cursor.paragraph, cursor.offset)?;
// The rect height is the character extent (zero after the last
// character); the renderer picks it for overtype carets and draws a
// thin bar otherwise.
return Some(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
));
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
@ -142,12 +179,24 @@ 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();
// Cursor offsets live in original text space; the laid-out
// paragraph indexes the 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())
@ -164,8 +213,12 @@ fn calculate_cursor_rect(
(0.0, 0.0, 1.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let last_pos = crate::shapes::horizontal_source_to_builder(
para,
para_char_count.saturating_sub(1),
);
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
last_pos..last_pos + 1,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@ -181,8 +234,9 @@ fn calculate_cursor_rect(
)
}
} else {
let shifted_pos = crate::shapes::horizontal_source_to_builder(para, char_pos);
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
shifted_pos..shifted_pos + 1,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
@ -220,6 +274,48 @@ fn calculate_selection_rects(
let end = selection.end();
let paragraphs = text_content.paragraphs();
// Vertical writing: selrect-local selection strips from the cells.
// CHANGEME: extract to function
if text_content.is_vertical() {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
.take(end.paragraph + 1)
.skip(start.paragraph)
{
let para_char_count: usize = paragraph
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
} else {
para_char_count
};
for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) {
rects.push(Rect::from_xywh(
origin_x + rect.x(),
rect.y(),
rect.width(),
rect.height(),
));
}
}
return rects;
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
@ -254,15 +350,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(
range_start..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(),
@ -270,6 +366,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

@ -154,6 +154,62 @@ impl ShapeRenderer for VectorRenderer<'_> {
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
// Vertical writing paints through the custom vertical pass: drop
// shadows, fills+decorations and strokes all derive from the same
// laid-out cells, drawn back-to-front onto the single vector canvas.
// CHANGEME: extract to function
if text_content.is_vertical() {
use crate::shapes::text_vertical;
let v_bounds = text_content.bounds();
// The shadow/stroke passes share one layout, computed only when
// at least one of them has something to paint (the fill pass
// lays out on its own inside render_overlay_emoji).
let drop_shadows = shape.drop_shadow_paints();
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
let v_layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let v_max_height = text_vertical::wrap_height(&text_content, v_bounds.height());
text_vertical::layout_from_content(&text_content, v_max_height)
});
if let Some(v_layout) = &v_layout {
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
self.canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
shadow,
);
}
}
text::render_overlay_emoji(
self.canvas,
shape,
&mut paragraph_builders,
None,
blur_filter.as_ref(),
None,
None,
)?;
if let Some(v_layout) = &v_layout {
let selrect = shape.selrect();
for stroke in strokes {
text_vertical::paint_stroke(
self.canvas,
v_layout,
&v_bounds,
shape.vertical_align(),
stroke,
&selrect,
blur_filter.as_ref(),
);
}
}
return Ok(());
}
// Text drop shadows: one filter layer per shadow over fill + stroke
// 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;
@ -28,6 +31,7 @@ mod svg_attrs;
mod svgraw;
mod text;
pub mod text_paths;
pub mod text_vertical;
mod transform;
pub use blend::*;
@ -428,6 +432,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 2024 and 2830 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

@ -211,15 +211,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 {
@ -233,7 +252,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);

View File

@ -254,7 +254,12 @@ impl ToPath for Shape {
Type::SVGRaw(_) => Path::default(),
Type::Text(ref text) => {
let text_paths = TextPaths::new(text.clone());
let text = if text.is_vertical() {
text.new_bounds(self.selrect)
} else {
text.clone()
};
let text_paths = TextPaths::new(text, self.vertical_align());
let mut result = Path::default();
for (path, _) in text_paths.get_paths(true) {
result = join_paths(result, Path::from_skia_path(path));

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,37 @@
use crate::get_render_state;
use crate::shapes::text::TextContent;
use crate::shapes::VerticalAlign;
use skia_safe::{
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
};
use std::ops::Deref;
pub struct TextPaths(TextContent);
pub struct TextPaths {
text_content: TextContent,
vertical_align: VerticalAlign,
}
// Note: This class is not being currently used.
// It's an example of how to convert texts to paths
#[allow(dead_code)]
impl TextPaths {
pub fn new(text_content: TextContent) -> Self {
Self(text_content)
pub fn new(text_content: TextContent, vertical_align: VerticalAlign) -> Self {
Self {
text_content,
vertical_align,
}
}
pub fn get_paths(&self, antialias: bool) -> Vec<(skia::Path, skia::Paint)> {
if self.text_content.is_vertical() {
return crate::shapes::text_vertical::vertical_text_paths(
&self.text_content,
self.vertical_align,
antialias,
);
}
let mut paths = Vec::new();
let mut offset_y = self.bounds.y();
let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None);
let mut paragraph_builders = self.text_content.paragraph_builder_group_from_text(None);
for paragraphs in paragraph_builders.iter_mut() {
for paragraph_builder in paragraphs.iter_mut() {
@ -192,6 +205,6 @@ impl Deref for TextPaths {
type Target = TextContent;
fn deref(&self) -> &Self::Target {
&self.0
&self.text_content
}
}

File diff suppressed because it is too large Load Diff

View File

@ -863,8 +863,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,

View File

@ -4,7 +4,9 @@ use super::{fills::RawFillData, fonts::RawFontStyle};
use crate::mem::{self, SerializableResult};
use crate::shapes::{
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
self, AnnotationClearance, FontFeatures, GrowType, RubyAlign, RubyOverhang, RubySide, RubySize,
Shape, TextAlign, TextCombineUpright, TextDecoration, TextDirection, TextEmphasis,
TextTransform, Type,
};
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{with_current_shape, with_current_shape_mut, with_state};
@ -18,6 +20,8 @@ const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
const MAX_TEXT_FILLS: usize = 8;
// CHANGEME: Move all the types from japanes text layout to its own module
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextAlign {
@ -54,6 +58,214 @@ impl From<RawTextDirection> for TextDirection {
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawWritingMode {
HorizontalTb = 0,
VerticalRl = 1,
}
impl From<RawWritingMode> for shapes::WritingMode {
fn from(value: RawWritingMode) -> Self {
match value {
RawWritingMode::HorizontalTb => shapes::WritingMode::HorizontalTb,
RawWritingMode::VerticalRl => shapes::WritingMode::VerticalRl,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextOrientation {
Mixed = 0,
Upright = 1,
}
impl From<RawTextOrientation> for shapes::TextOrientation {
fn from(value: RawTextOrientation) -> Self {
match value {
RawTextOrientation::Mixed => shapes::TextOrientation::Mixed,
RawTextOrientation::Upright => shapes::TextOrientation::Upright,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextCombineUpright {
None = 0,
All = 1,
Digits = 2,
Digits2 = 3,
Digits3 = 4,
}
impl From<RawTextCombineUpright> for TextCombineUpright {
fn from(value: RawTextCombineUpright) -> Self {
match value {
RawTextCombineUpright::None => TextCombineUpright::None,
RawTextCombineUpright::All => TextCombineUpright::All,
RawTextCombineUpright::Digits => TextCombineUpright::Digits,
RawTextCombineUpright::Digits2 => TextCombineUpright::Digits2,
RawTextCombineUpright::Digits3 => TextCombineUpright::Digits3,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawTextEmphasis {
None = 0,
FilledDot = 1,
OpenDot = 2,
FilledCircle = 3,
OpenCircle = 4,
FilledSesame = 5,
OpenSesame = 6,
}
impl From<RawTextEmphasis> for TextEmphasis {
fn from(value: RawTextEmphasis) -> Self {
match value {
RawTextEmphasis::None => TextEmphasis::None,
RawTextEmphasis::FilledDot => TextEmphasis::FilledDot,
RawTextEmphasis::OpenDot => TextEmphasis::OpenDot,
RawTextEmphasis::FilledCircle => TextEmphasis::FilledCircle,
RawTextEmphasis::OpenCircle => TextEmphasis::OpenCircle,
RawTextEmphasis::FilledSesame => TextEmphasis::FilledSesame,
RawTextEmphasis::OpenSesame => TextEmphasis::OpenSesame,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawWarichu {
None = 0,
Warichu = 1,
}
impl From<RawWarichu> for bool {
fn from(value: RawWarichu) -> Self {
value == RawWarichu::Warichu
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawFontFeatures {
None = 0,
Palt = 1,
Vpal = 2,
}
impl From<RawFontFeatures> for FontFeatures {
fn from(value: RawFontFeatures) -> Self {
match value {
RawFontFeatures::None => FontFeatures::None,
RawFontFeatures::Palt => FontFeatures::Palt,
RawFontFeatures::Vpal => FontFeatures::Vpal,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawAnnotationClearance {
None = 0,
Auto = 1,
}
impl From<RawAnnotationClearance> for AnnotationClearance {
fn from(value: RawAnnotationClearance) -> Self {
match value {
RawAnnotationClearance::None => AnnotationClearance::None,
RawAnnotationClearance::Auto => AnnotationClearance::Auto,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubySize {
Half = 0,
Third = 1,
Quarter = 2,
}
impl From<RawRubySize> for RubySize {
fn from(value: RawRubySize) -> Self {
match value {
RawRubySize::Half => RubySize::Half,
RawRubySize::Third => RubySize::Third,
RawRubySize::Quarter => RubySize::Quarter,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubyAlign {
SpaceAround = 0,
Center = 1,
Start = 2,
SpaceBetween = 3,
}
impl From<RawRubyAlign> for RubyAlign {
fn from(value: RawRubyAlign) -> Self {
match value {
RawRubyAlign::SpaceAround => RubyAlign::SpaceAround,
RawRubyAlign::Center => RubyAlign::Center,
RawRubyAlign::Start => RubyAlign::Start,
RawRubyAlign::SpaceBetween => RubyAlign::SpaceBetween,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubyOverhang {
Auto = 0,
None = 1,
}
impl From<RawRubyOverhang> for RubyOverhang {
fn from(value: RawRubyOverhang) -> Self {
match value {
RawRubyOverhang::Auto => RubyOverhang::Auto,
RawRubyOverhang::None => RubyOverhang::None,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawRubySide {
Over = 0,
Under = 1,
}
impl From<RawRubySide> for RubySide {
fn from(value: RawRubySide) -> Self {
match value {
RawRubySide::Over => RubySide::Over,
RawRubySide::Under => RubySide::Under,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextDecoration {
@ -103,6 +315,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 +348,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 +369,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 +408,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 +420,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 +472,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 +504,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
}
}
@ -444,3 +699,78 @@ pub extern "C" fn calculate_position_data() -> *mut u8 {
});
mem::write_vec(result)
}
#[cfg(test)]
mod tests {
use super::*;
/// 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

@ -811,6 +811,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();
@ -826,7 +828,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,
@ -835,6 +837,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

@ -5,6 +5,7 @@ use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::render::text_editor as text_editor_render;
use crate::render::SurfaceId;
use crate::shapes::text_vertical;
use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::{TextEditorEvent, TextSelection};
use crate::utils::uuid_from_u32_quartet;
@ -147,7 +148,9 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
};
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())
{
get_text_editor_state().select_word_boundary(text_content, &position);
}
})
@ -179,7 +182,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);
}
@ -210,7 +215,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.
@ -239,7 +246,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);
}
@ -274,7 +283,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);
}
});
@ -299,9 +310,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);
}
});
@ -1035,6 +1049,24 @@ fn get_cursor_rect(
return None;
}
// Vertical writing: the caret is a thin horizontal bar across the
// column, computed from the vertical cells.
if text_content.is_vertical() {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let (origin_x, origin_y) = layout.origin(&selrect, shape.vertical_align());
let rect = text_vertical::caret_rect(&layout, cursor.paragraph, cursor.offset)?;
// The rect height is the extent of the character at the cursor,
// matching the line height reported by the horizontal path.
return Some(Rect::from_xywh(
origin_x + rect.x(),
origin_y + rect.y(),
rect.width(),
rect.height().max(1.0),
));
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
@ -1047,7 +1079,10 @@ fn get_cursor_rect(
let mut y_offset = valign_offset;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.offset;
// Cursor offsets live in original text space; the laid-out
// paragraph indexes the kinsoku-shifted builder text.
let (_, offset_map) = paragraphs[cursor.paragraph].layout_span_texts();
let char_pos = offset_map.to_shifted(cursor.offset);
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let rects = laid_out_para.get_rects_for_range(
@ -1088,6 +1123,46 @@ fn get_selection_rects(
let end = selection.end();
let paragraphs = text_content.paragraphs();
// Vertical writing: selection rectangles come from the vertical cells.
if text_content.is_vertical() {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let (origin_x, origin_y) = layout.origin(&selrect, shape.vertical_align());
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
.take(end.paragraph + 1)
.skip(start.paragraph)
{
let para_char_count: usize = paragraph
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset
} else {
para_char_count
};
for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) {
rects.push(Rect::from_xywh(
origin_x + rect.x(),
origin_y + rect.y(),
rect.width(),
rect.height(),
));
}
}
return rects;
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
@ -1133,6 +1208,12 @@ fn get_selection_rects(
};
if range_start < range_end {
// Selection offsets live in original text space; the
// laid-out paragraph indexes the kinsoku-shifted text.
let (_, offset_map) = para.layout_span_texts();
let range_start = offset_map.to_shifted(range_start);
let range_end = offset_map.to_shifted(range_end);
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,