✨ Add japanese text layout support
@ -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"
|
||||
|
||||
@ -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]]])
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 3.5v-2h11v2M2.5 12.5v2h11v-2M5 6.25h1.5v3.5M9 6.25h1.5v3.5H9v-1.75h1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 205 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 4V2h12v2M2 12v2h12v-2M3.5 6H5v4M7.25 6h1.5v4h-1.5V8h1.5M11 6h1.5v4H11m0-2h1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 210 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 2.5h6M5 5.5h6M5 8.5h6M5 11.5h6M8 1v13.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 172 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 1.5h6v5H5zM4 9.5l7 2.5-7 2.5M8 10.9v2.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 1.5h6v5H5zM5.5 14l2.5-6 2.5 6M6.5 11.5h3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 175 B |
3
frontend/resources/images/icons/warichu-none.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 2.5h6v4H5zM5 9.5h6v4H5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 156 B |
3
frontend/resources/images/icons/warichu.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 2v12M14 2v12M4.5 3.5h2v3h-2zM9.5 3.5h2v3h-2zM4.5 9.5h2v3h-2zM9.5 9.5h2v3h-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 209 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1.5 2.5h8m-8 3h8m-8 3h6M13 2v11m-2-2 2 2 2-2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.5 1.5v8m-3-8v8m-3-8v6M14 13H3m2-2-2 2 2 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@ -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))))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")})
|
||||
|
||||
@ -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)])]))]]))
|
||||
|
||||
@ -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"))}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"}]]]]))
|
||||
|
||||
@ -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])])])])])]))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}]]))
|
||||
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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)})))
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -10,14 +10,17 @@
|
||||
Each test guards against a concrete bug found in the CSS/HTML generation
|
||||
of layout children and text shapes."
|
||||
(:require
|
||||
["react-dom/server" :as rds]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.ui.shapes.text.fo-text :as fo-text]
|
||||
[app.util.code-gen.markup-html :as html]
|
||||
[app.util.code-gen.style-css :as css]
|
||||
[cljs.test :refer [deftest is testing] :include-macros true]
|
||||
[cuerdas.core :as str]))
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; --- Builders ------------------------------------------------------------
|
||||
|
||||
@ -180,6 +183,29 @@
|
||||
:children [{:text "Hello"
|
||||
:fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]})
|
||||
|
||||
(def ^:private ruby-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:children [{:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(defn- text-shape
|
||||
[content]
|
||||
(let [tid (uuid/next)]
|
||||
{:id tid :name "Text" :type :text
|
||||
:parent-id uuid/zero :frame-id uuid/zero
|
||||
:x 0 :y 0 :width 100 :height 40
|
||||
:selrect (grc/make-rect 0 0 100 40)
|
||||
:points (pts 0 0 100 40)
|
||||
:grow-type :fixed
|
||||
:content content}))
|
||||
|
||||
(deftest text-markup-emits-node-id-classes
|
||||
(testing "generated text markup carries the $id classes the CSS rules target"
|
||||
(let [tid (uuid/next)
|
||||
@ -194,3 +220,90 @@
|
||||
(is (str/includes? markup "root-0")
|
||||
"the text nodes must expose their $id as a class for the CSS to apply")
|
||||
(is (str/includes? markup "root-0-paragraph-set-0-paragraph-0")))))
|
||||
|
||||
(def ^:private warichu-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:children [{:text "割注入り"
|
||||
:warichu "warichu"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(def ^:private palt-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "かな"
|
||||
:font-features "palt"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(deftest foreign-object-text-emits-warichu-styles
|
||||
(testing "browser/foreignObject render folds a warichu span into two half-size lines"
|
||||
(let [text (text-shape warichu-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "割注入り"))
|
||||
(is (str/includes? markup "display:inline-block"))
|
||||
(is (str/includes? markup "font-size:10px"))
|
||||
(is (str/includes? markup "inline-size:2em")))))
|
||||
|
||||
(deftest foreign-object-warichu-sizes-by-unicode-code-points
|
||||
(testing "non-BMP characters count as one slot when sizing warichu lines"
|
||||
(let [content (assoc-in warichu-text-content
|
||||
[:children 0 :children 0 :children 0 :text]
|
||||
"割𠀀注😀")
|
||||
text (text-shape content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "割𠀀注😀"))
|
||||
(is (str/includes? markup "inline-size:2em")))))
|
||||
|
||||
(def ^:private tcy-digits2-text-content
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:writing-mode "vertical-rl"
|
||||
:children [{:text "平成31年"
|
||||
:text-combine-upright "digits2"
|
||||
:font-size "20"
|
||||
:fill-color "#000000"
|
||||
:fill-opacity 1}]}]}]})
|
||||
|
||||
(deftest foreign-object-text-emits-counted-digits-css
|
||||
(testing "the counted digits variants serialize as CSS `digits <n>`"
|
||||
(let [text (text-shape tcy-digits2-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "text-combine-upright:digits 2")))))
|
||||
|
||||
(deftest foreign-object-text-emits-font-feature-settings
|
||||
(testing "browser/foreignObject render emits palt/vpal as OpenType features"
|
||||
(let [text (text-shape palt-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "font-feature-settings:"palt"")))))
|
||||
|
||||
(deftest text-markup-emits-ruby-annotations
|
||||
(testing "generated text markup keeps ruby annotations beside the base text"
|
||||
(let [text (text-shape ruby-text-content)
|
||||
markup (html/generate-markup (objects text) [text])]
|
||||
(is (str/includes? markup "<ruby"))
|
||||
(is (str/includes? markup "<rt"))
|
||||
(is (str/includes? markup "漢字"))
|
||||
(is (str/includes? markup "かんじ")))))
|
||||
|
||||
(deftest foreign-object-text-emits-ruby-annotations
|
||||
(testing "browser/foreignObject text render keeps ruby annotations"
|
||||
(let [text (text-shape ruby-text-content)
|
||||
markup (rds/renderToStaticMarkup
|
||||
(mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
|
||||
(is (str/includes? markup "<ruby"))
|
||||
(is (str/includes? markup "<rt"))
|
||||
(is (str/includes? markup "漢字"))
|
||||
(is (str/includes? markup "かんじ")))))
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.util.code-gen.markup-svg :as svg]
|
||||
[cljs.test :refer [deftest is testing] :include-macros true]))
|
||||
|
||||
@ -37,6 +38,224 @@
|
||||
[re s]
|
||||
(count (re-seq re s)))
|
||||
|
||||
(defn- setup-vertical-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 40
|
||||
:height 100})
|
||||
(assoc :name "Vertical text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:font-features "vpal"
|
||||
:text "うA"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-text))]}))
|
||||
|
||||
(defn- setup-vertical-ruby-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical ruby text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text "漢字"
|
||||
:ruby "かんじ"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-ruby-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-ruby-text))]}))
|
||||
|
||||
(defn- setup-vertical-emphasis-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical emphasis text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text "強調 あ"
|
||||
:text-emphasis "filled-dot"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-emphasis-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-emphasis-text))]}))
|
||||
|
||||
(defn- setup-horizontal-emphasis-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal emphasis text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 100
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text "強調、 あ"
|
||||
:text-emphasis "filled-dot"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-emphasis-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-emphasis-text))]}))
|
||||
|
||||
(defn- setup-horizontal-stacked-annotations
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal stacked annotations"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 100
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233" :fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:text-emphasis "filled-dot"
|
||||
:annotation-clearance "auto"
|
||||
:annotation-has-ruby true}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-stacked-annotations
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-stacked-annotations))]}))
|
||||
|
||||
(defn- setup-vertical-warichu-text
|
||||
([] (setup-vertical-warichu-text "割注入り"))
|
||||
([text]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical warichu text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 40
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text text
|
||||
:warichu "warichu"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-warichu-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-warichu-text))]})))
|
||||
|
||||
(defn- setup-horizontal-warichu-text
|
||||
([] (setup-horizontal-warichu-text "割注入り"))
|
||||
([text]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal warichu text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 40
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text text
|
||||
:warichu "warichu"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-warichu-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-warichu-text))]})))
|
||||
|
||||
(deftest empty-selection-yields-empty-string
|
||||
(is (= "" (svg/generate-markup {} []))))
|
||||
|
||||
@ -59,3 +278,97 @@
|
||||
"multi-select must NOT emit multiple <svg> roots")
|
||||
(is (= 1 (count-matches #"</svg>" markup))
|
||||
"multi-select must NOT emit multiple </svg> closing tags"))))
|
||||
|
||||
(deftest vertical-text-svg-preserves-browser-layout-properties
|
||||
(testing "Static SVG carries the vertical writing properties used by browser exports"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"writing-mode:vertical-rl" markup))
|
||||
(is (re-find #"text-orientation:upright" markup))
|
||||
(is (re-find #"text-autospace:normal" markup))
|
||||
(is (re-find #"font-feature-settings:"vpal"" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest vertical-emphasis-svg-emits-static-marks
|
||||
(testing "Static SVG draws emphasis marks and skips whitespace characters"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-emphasis-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調 あ" markup))
|
||||
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
|
||||
(is (re-find #"•• •" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
|
||||
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-emphasis-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調、 あ" markup))
|
||||
;; Five Unicode base characters: two marks, punctuation and whitespace
|
||||
;; slots, then one mark.
|
||||
(is (re-find #"•• •" markup))
|
||||
(is (re-find #"writing-mode:horizontal-tb" markup))
|
||||
(is (re-find #"dominant-baseline=\"text-after-edge\"" markup))
|
||||
(is (re-find #"lengthAdjust=\"spacing\"" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (re-find #"fill:url\(#fill-0-[^)]+-0\)" markup)
|
||||
"the emphasis mark references its generated per-strip fill")
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
|
||||
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-stacked-annotations)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"かんじ" markup))
|
||||
(is (re-find #"••" markup))
|
||||
(is (re-find #"y=\"40\"" markup) "ruby occupies the first half-em layer")
|
||||
(is (re-find #"y=\"30\"" markup) "emphasis occupies the outer half-em layer"))))
|
||||
|
||||
(deftest vertical-warichu-svg-emits-two-sub-columns
|
||||
(testing "Static SVG splits a warichu strip into two half-size sub-columns"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
(is (not (re-find #"割注入り" markup))
|
||||
"the base text must be split, not drawn as one strip")
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest vertical-warichu-svg-split-respects-kinsoku
|
||||
(testing "The second sub-line must not start with a line-start-prohibited mark"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割り注、と")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割り注、" markup)
|
||||
"the comma is pulled up into the first sub-line")
|
||||
(is (not (re-find #"割り注、と" markup))
|
||||
"the strip is still split into two sub-lines"))))
|
||||
|
||||
(deftest vertical-warichu-svg-preserves-non-bmp-characters
|
||||
(testing "Static SVG never splits a surrogate pair between warichu sub-lines"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割𠀀注😀")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割𠀀" markup))
|
||||
(is (re-find #"注😀" markup))
|
||||
(is (not (re-find #"<22>" markup))))))
|
||||
|
||||
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
|
||||
(testing "Static SVG splits horizontal warichu into top and bottom half-size lines"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-warichu-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
(is (not (re-find #"割注入り" markup)))
|
||||
(is (re-find #"writing-mode:horizontal-tb" markup))
|
||||
(is (= 2 (count-matches #"dominant-baseline=.?hanging" markup)))
|
||||
(is (re-find #"font-size:10px" markup)))))
|
||||
|
||||
(deftest vertical-ruby-svg-emits-static-annotation
|
||||
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-ruby-text)
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"漢字" markup))
|
||||
(is (re-find #"かんじ" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (re-find #"text-orientation:upright" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
@ -6,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
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@ -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")))))))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
282
frontend/test/frontend_tests/render_wasm/texts_test.cljs
Normal 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 "!?:;123ABC"))))
|
||||
|
||||
(t/deftest classification-mixed-japanese
|
||||
(t/is (= #{:japanese :han :cjk-punctuation}
|
||||
(langs "「こんにちは」と彼は言った。"))))
|
||||
|
||||
(t/deftest classification-korean
|
||||
(t/is (= #{:korean} (langs "안녕하세요"))))
|
||||
|
||||
(t/deftest resolve-kana-wins-over-locale
|
||||
;; Kana in the same content implies Japanese regardless of locale.
|
||||
(t/is (= #{:japanese}
|
||||
(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"))))
|
||||
@ -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
|
||||
|
||||
9
frontend/test/frontend_tests/ui/css_cursors_test.cljs
Normal file
@ -0,0 +1,9 @@
|
||||
(ns frontend-tests.ui.css-cursors-test
|
||||
(:require
|
||||
[app.main.ui.css-cursors :as cursors]
|
||||
[cljs.test :as t]))
|
||||
|
||||
(t/deftest text-cursor-follows-writing-direction-and-shape-rotation
|
||||
(t/is (= "cursor-text-0" (cursors/get-text 0 false)))
|
||||
(t/is (= "cursor-text-90" (cursors/get-text 0 true)))
|
||||
(t/is (= "cursor-text-10" (cursors/get-text 280 true))))
|
||||
207
frontend/test/frontend_tests/ui/text_options_test.cljs
Normal 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"))))
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"],
|
||||
];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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)"
|
||||
|
||||
|
||||
@ -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 "縦書き(右から左)"
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
138
plugins/libs/plugin-types/index.d.ts
vendored
@ -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.
|
||||
*/
|
||||
|
||||
235
render-wasm/fixtures/japanese-typography.json
Normal 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": [
|
||||
"1–2 digit runs for TCY",
|
||||
"cl-30 half-em after closing punctuation and before opening brackets",
|
||||
"cl-30 solid after opening and before closing punctuation",
|
||||
"explicit all and digit TCY modes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ruby-simple",
|
||||
"title": "Simple ruby (annotation) examples",
|
||||
"text": "漢字にふりがなを振る。振り仮名は難しい。",
|
||||
"ruby": [
|
||||
{ "base": "漢字", "ruby": "かんじ" },
|
||||
{ "base": "振り仮名", "ruby": "ふりがな" }
|
||||
],
|
||||
"exercises": ["whole-span base/annotation alignment", "line-height inflation"]
|
||||
},
|
||||
{
|
||||
"id": "horizontal-annotation-clearance-none",
|
||||
"title": "Horizontal ruby and emphasis with line-height-only clearance",
|
||||
"text": "漢字\n熟語",
|
||||
"line_height": 1,
|
||||
"ruby": [
|
||||
{ "base": "漢字", "ruby": "かんじ" },
|
||||
{ "base": "熟語", "ruby": "じゅくご" }
|
||||
],
|
||||
"text_emphasis": "filled-dot",
|
||||
"annotation_clearance": "none",
|
||||
"exercises": ["legacy horizontal annotation collision", "explicit line-height compatibility"]
|
||||
},
|
||||
{
|
||||
"id": "horizontal-annotation-clearance-auto",
|
||||
"title": "Horizontal ruby and emphasis with automatic clearance",
|
||||
"text": "漢字\n熟語",
|
||||
"line_height": 1,
|
||||
"ruby": [
|
||||
{ "base": "漢字", "ruby": "かんじ" },
|
||||
{ "base": "熟語", "ruby": "じゅくご" }
|
||||
],
|
||||
"text_emphasis": "filled-dot",
|
||||
"annotation_clearance": "auto",
|
||||
"exercises": ["automatic horizontal line-gap reservation", "ruby nearest and emphasis outside"]
|
||||
},
|
||||
{
|
||||
"id": "vertical-annotation-clearance-none",
|
||||
"title": "Vertical ruby and emphasis with line-height-only clearance",
|
||||
"text": "漢字\n熟語",
|
||||
"writing_mode": "vertical-rl",
|
||||
"line_height": 1,
|
||||
"ruby": [
|
||||
{ "base": "漢字", "ruby": "かんじ" },
|
||||
{ "base": "熟語", "ruby": "じゅくご" }
|
||||
],
|
||||
"text_emphasis": "filled-dot",
|
||||
"annotation_clearance": "none",
|
||||
"exercises": ["legacy vertical annotation collision", "explicit column-gap compatibility"]
|
||||
},
|
||||
{
|
||||
"id": "vertical-annotation-clearance-auto",
|
||||
"title": "Vertical ruby and emphasis with automatic clearance",
|
||||
"text": "漢字\n熟語",
|
||||
"writing_mode": "vertical-rl",
|
||||
"line_height": 1,
|
||||
"ruby": [
|
||||
{ "base": "漢字", "ruby": "かんじ" },
|
||||
{ "base": "熟語", "ruby": "じゅくご" }
|
||||
],
|
||||
"text_emphasis": "filled-dot",
|
||||
"annotation_clearance": "auto",
|
||||
"exercises": ["automatic vertical column-gap reservation", "ruby nearest and emphasis outside"]
|
||||
},
|
||||
{
|
||||
"id": "horizontal-ruby-customization",
|
||||
"title": "Horizontal ruby size, alignment, overhang, and side",
|
||||
"text": "標準 三分 中央 下側",
|
||||
"ruby": [
|
||||
{ "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" },
|
||||
{ "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" },
|
||||
{ "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" },
|
||||
{ "base": "下側", "ruby": "したがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" }
|
||||
],
|
||||
"exercises": [
|
||||
"half, third, and quarter ruby sizes",
|
||||
"space-around, start, center, and space-between alignment",
|
||||
"automatic and prohibited overhang",
|
||||
"over and under annotation sides"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vertical-ruby-customization",
|
||||
"title": "Vertical ruby size, alignment, overhang, and side",
|
||||
"text": "標準 三分 中央 左側",
|
||||
"writing_mode": "vertical-rl",
|
||||
"ruby": [
|
||||
{ "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" },
|
||||
{ "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" },
|
||||
{ "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" },
|
||||
{ "base": "左側", "ruby": "ひだりがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" }
|
||||
],
|
||||
"exercises": [
|
||||
"configured ruby gutter widths",
|
||||
"vertical flow-axis annotation distribution",
|
||||
"over/right and under/left annotation sides",
|
||||
"viewport and export parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "small-kana-sokuon",
|
||||
"title": "Small kana and sokuon clusters",
|
||||
"text": "ちょっと待ってください。キャッシュとクッキーをチェックする。",
|
||||
"exercises": ["small kana ょっゃュッ", "kinsoku: small kana not at line start"]
|
||||
},
|
||||
{
|
||||
"id": "cjk-punct-spacing",
|
||||
"title": "CJK punctuation spacing and full-width space",
|
||||
"text": "これは、テスト。『「括弧」』。」「次」・「項」!? 全角スペースの後。",
|
||||
"writing_mode": "vertical-rl",
|
||||
"exercises": [
|
||||
"preferred half-em punctuation aki",
|
||||
"solid consecutive opening and closing punctuation",
|
||||
"half-em closing-to-opening spacing",
|
||||
"quarter-em middle-dot adjacency",
|
||||
"question/exclamation and full-width space"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jlreq-character-classes",
|
||||
"title": "Representative JLREQ character classes",
|
||||
"text": "「始」〜!?・。、…々ーっ¥100% あア漢A㎏",
|
||||
"exercises": [
|
||||
"cl-01 through cl-19 scalar classification",
|
||||
"cl-24 grouped numerals",
|
||||
"cl-25 unit symbols",
|
||||
"cl-26 Western word space",
|
||||
"cl-27 Western characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "han-unification",
|
||||
"title": "Kanji-only text (Han unification trap)",
|
||||
"text": "東京都渋谷区神南一丁目",
|
||||
"exercises": ["kanji-only classifies as :chinese today → falls back to Noto Sans SC not JP", "JP-vs-CN disambiguation policy"]
|
||||
},
|
||||
{
|
||||
"id": "long-paragraph-wrap",
|
||||
"title": "Long paragraph exercising many line breaks",
|
||||
"text": "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
|
||||
"writing_mode": "vertical-rl",
|
||||
"text_align": "justify",
|
||||
"exercises": [
|
||||
"many break opportunities",
|
||||
"ordered oikomi before oidashi",
|
||||
"priority-based justified expansion",
|
||||
"kinsoku and burasage integration"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
render-wasm/src/fonts/notosansjp-vmtx-test.ttf
Normal file
BIN
render-wasm/src/fonts/notosansjp-vpal-test.ttf
Normal 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));
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
¶.paragraph,
|
||||
para.x,
|
||||
para.y,
|
||||
);
|
||||
crate::shapes::paint_horizontal_emphasis(
|
||||
canvas,
|
||||
source_paragraph,
|
||||
¶.paragraph,
|
||||
para.x,
|
||||
para.y,
|
||||
);
|
||||
}
|
||||
|
||||
if overlay_emoji {
|
||||
paint_emoji_overlay(canvas, para);
|
||||
}
|
||||
|
||||
if ruby_per_paragraph {
|
||||
crate::shapes::text_vertical::paint_horizontal_ruby(
|
||||
canvas,
|
||||
text_content,
|
||||
para.source_paragraph,
|
||||
¶.paragraph,
|
||||
para.x,
|
||||
para.y,
|
||||
);
|
||||
}
|
||||
|
||||
for deco in ¶.decorations {
|
||||
draw_text_decorations(
|
||||
canvas,
|
||||
|
||||
@ -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 = ¶graphs[cursor.paragraph];
|
||||
if let Some(rect) =
|
||||
crate::shapes::horizontal_warichu_caret_rect(para, laid_out_para, char_pos)
|
||||
{
|
||||
return Some(Rect::from_xywh(
|
||||
rect.x(),
|
||||
y_offset + rect.y(),
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
));
|
||||
}
|
||||
let para_char_count: usize = para
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
// 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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
287
render-wasm/src/shapes/gpos_vpal.rs
Normal file
@ -0,0 +1,287 @@
|
||||
//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate
|
||||
//! metrics) feature. Vertical layout applies these deltas itself: cells
|
||||
//! flow by `vmtx` advances, so HarfBuzz never gets a chance to apply
|
||||
//! vertical GPOS positioning (SkShaper shapes on a horizontal line).
|
||||
//!
|
||||
//! Only single-adjustment lookups are read (SinglePos, directly or behind
|
||||
//! an Extension lookup) — `vpal` is metrics-only by design and real fonts
|
||||
//! (Noto CJK, Source Han) encode it exactly this way.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Font-unit deltas for one glyph. `y_placement` is y-up (positive lifts
|
||||
/// the ink toward the flow start); `y_advance` is negative when the
|
||||
/// vertical advance shrinks.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct VpalDelta {
|
||||
pub y_placement: i16,
|
||||
pub y_advance: i16,
|
||||
}
|
||||
|
||||
fn read_u16(data: &[u8], offset: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([
|
||||
*data.get(offset)?,
|
||||
*data.get(offset + 1)?,
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
|
||||
read_u16(data, offset).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*data.get(offset)?,
|
||||
*data.get(offset + 1)?,
|
||||
*data.get(offset + 2)?,
|
||||
*data.get(offset + 3)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// Glyph ids covered by a Coverage table, in coverage-index order.
|
||||
fn parse_coverage(data: &[u8], offset: usize) -> Option<Vec<u16>> {
|
||||
match read_u16(data, offset)? {
|
||||
1 => {
|
||||
let count = read_u16(data, offset + 2)? as usize;
|
||||
(0..count)
|
||||
.map(|i| read_u16(data, offset + 4 + i * 2))
|
||||
.collect()
|
||||
}
|
||||
2 => {
|
||||
let range_count = read_u16(data, offset + 2)? as usize;
|
||||
let mut glyphs = Vec::new();
|
||||
for i in 0..range_count {
|
||||
let o = offset + 4 + i * 6;
|
||||
let start = read_u16(data, o)?;
|
||||
let end = read_u16(data, o + 2)?;
|
||||
if end < start {
|
||||
return None;
|
||||
}
|
||||
glyphs.extend(start..=end);
|
||||
}
|
||||
Some(glyphs)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit
|
||||
/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device
|
||||
/// offsets). Returns the y deltas and consumes nothing else.
|
||||
fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option<VpalDelta> {
|
||||
let mut delta = VpalDelta::default();
|
||||
let mut o = offset;
|
||||
for bit in 0..8 {
|
||||
if value_format & (1 << bit) == 0 {
|
||||
continue;
|
||||
}
|
||||
match bit {
|
||||
1 => delta.y_placement = read_i16(data, o)?,
|
||||
3 => delta.y_advance = read_i16(data, o)?,
|
||||
_ => {}
|
||||
}
|
||||
o += 2;
|
||||
}
|
||||
Some(delta)
|
||||
}
|
||||
|
||||
fn value_record_size(value_format: u16) -> usize {
|
||||
(value_format & 0x00FF).count_ones() as usize * 2
|
||||
}
|
||||
|
||||
/// Accumulate a SinglePos subtable into `deltas`.
|
||||
fn parse_single_pos(
|
||||
data: &[u8],
|
||||
offset: usize,
|
||||
deltas: &mut HashMap<u16, VpalDelta>,
|
||||
) -> Option<()> {
|
||||
let format = read_u16(data, offset)?;
|
||||
let coverage = parse_coverage(data, offset + read_u16(data, offset + 2)? as usize)?;
|
||||
let value_format = read_u16(data, offset + 4)?;
|
||||
match format {
|
||||
1 => {
|
||||
let value = parse_value_record(data, offset + 6, value_format)?;
|
||||
for glyph in coverage {
|
||||
let entry = deltas.entry(glyph).or_default();
|
||||
entry.y_placement += value.y_placement;
|
||||
entry.y_advance += value.y_advance;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let count = read_u16(data, offset + 6)? as usize;
|
||||
let size = value_record_size(value_format);
|
||||
for (i, glyph) in coverage.into_iter().take(count).enumerate() {
|
||||
let value = parse_value_record(data, offset + 8 + i * size, value_format)?;
|
||||
let entry = deltas.entry(glyph).or_default();
|
||||
entry.y_placement += value.y_placement;
|
||||
entry.y_advance += value.y_advance;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Parse the `vpal` deltas out of a raw `GPOS` table. Returns `None` when
|
||||
/// the table is malformed or carries no `vpal` feature.
|
||||
pub fn parse_vpal(gpos: &[u8]) -> Option<HashMap<u16, VpalDelta>> {
|
||||
let feature_list = read_u16(gpos, 6)? as usize;
|
||||
let lookup_list = read_u16(gpos, 8)? as usize;
|
||||
|
||||
// Collect the lookup indices of every feature tagged `vpal`,
|
||||
// regardless of script: the deltas are per-glyph metrics.
|
||||
let feature_count = read_u16(gpos, feature_list)? as usize;
|
||||
let mut lookup_indices = Vec::new();
|
||||
for i in 0..feature_count {
|
||||
let record = feature_list + 2 + i * 6;
|
||||
if gpos.get(record..record.checked_add(4)?)? != b"vpal" {
|
||||
continue;
|
||||
}
|
||||
let feature = feature_list + read_u16(gpos, record + 4)? as usize;
|
||||
let index_count = read_u16(gpos, feature + 2)? as usize;
|
||||
for j in 0..index_count {
|
||||
lookup_indices.push(read_u16(gpos, feature + 4 + j * 2)? as usize);
|
||||
}
|
||||
}
|
||||
lookup_indices.sort_unstable();
|
||||
lookup_indices.dedup();
|
||||
if lookup_indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lookup_count = read_u16(gpos, lookup_list)? as usize;
|
||||
let mut deltas = HashMap::new();
|
||||
for index in lookup_indices {
|
||||
if index >= lookup_count {
|
||||
continue;
|
||||
}
|
||||
let lookup = lookup_list + read_u16(gpos, lookup_list + 2 + index * 2)? as usize;
|
||||
let lookup_type = read_u16(gpos, lookup)?;
|
||||
let subtable_count = read_u16(gpos, lookup + 4)? as usize;
|
||||
for s in 0..subtable_count {
|
||||
let subtable = lookup + read_u16(gpos, lookup + 6 + s * 2)? as usize;
|
||||
match lookup_type {
|
||||
1 => {
|
||||
parse_single_pos(gpos, subtable, &mut deltas)?;
|
||||
}
|
||||
9 => {
|
||||
// ExtensionPos: { format: u16, extensionLookupType: u16,
|
||||
// extensionOffset: u32 (from the subtable start) }.
|
||||
if read_u16(gpos, subtable + 2)? == 1 {
|
||||
let inner = subtable + read_u32(gpos, subtable + 4)? as usize;
|
||||
parse_single_pos(gpos, inner, &mut deltas)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if deltas.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(deltas)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Subset of Noto Sans JP: 〱あく「」、。 with `vert` alternates and the
|
||||
// real `vpal` deltas (Extension→SinglePos, both coverage and both
|
||||
// SinglePos formats, value formats 0x8 and 0xA).
|
||||
const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf");
|
||||
|
||||
fn gpos_table() -> Vec<u8> {
|
||||
// Locate the GPOS table in the raw sfnt directory.
|
||||
let data = VPAL_TEST_FONT;
|
||||
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||||
for i in 0..num_tables {
|
||||
let record = 12 + i * 16;
|
||||
if &data[record..record + 4] == b"GPOS" {
|
||||
let offset = u32::from_be_bytes([
|
||||
data[record + 8],
|
||||
data[record + 9],
|
||||
data[record + 10],
|
||||
data[record + 11],
|
||||
]) as usize;
|
||||
let length = u32::from_be_bytes([
|
||||
data[record + 12],
|
||||
data[record + 13],
|
||||
data[record + 14],
|
||||
data[record + 15],
|
||||
]) as usize;
|
||||
return data[offset..offset + length].to_vec();
|
||||
}
|
||||
}
|
||||
panic!("test font has no GPOS table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_noto_vpal_deltas() {
|
||||
let deltas = parse_vpal(&gpos_table()).expect("vpal deltas");
|
||||
// Vertical alternates: 、→8 。→9 「→10 」→11 あ→12 く→13.
|
||||
assert_eq!(
|
||||
deltas.get(&8),
|
||||
Some(&VpalDelta {
|
||||
y_placement: 0,
|
||||
y_advance: -500
|
||||
}),
|
||||
"、 halves its vertical advance"
|
||||
);
|
||||
assert_eq!(
|
||||
deltas.get(&10),
|
||||
Some(&VpalDelta {
|
||||
y_placement: 481,
|
||||
y_advance: -500
|
||||
}),
|
||||
"「 lifts its trailing-half ink into the compressed cell"
|
||||
);
|
||||
assert_eq!(
|
||||
deltas.get(&12),
|
||||
Some(&VpalDelta {
|
||||
y_placement: 39,
|
||||
y_advance: -58
|
||||
}),
|
||||
"あ tightens slightly"
|
||||
);
|
||||
// Base (horizontal) glyphs carry no vpal.
|
||||
assert!(!deltas.contains_key(&6), "base あ glyph is uncovered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_vpal_returns_none() {
|
||||
// A GPOS header with an empty feature list.
|
||||
let gpos = [
|
||||
0x00, 0x01, 0x00, 0x00, // version 1.0
|
||||
0x00, 0x0A, // scriptList
|
||||
0x00, 0x0C, // featureList
|
||||
0x00, 0x0E, // lookupList
|
||||
0x00, 0x00, // scriptCount
|
||||
0x00, 0x00, // featureCount
|
||||
0x00, 0x00, // lookupCount
|
||||
];
|
||||
assert!(parse_vpal(&gpos).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_table_is_rejected() {
|
||||
let table = gpos_table();
|
||||
for len in [0usize, 4, 9, 16] {
|
||||
assert!(parse_vpal(&table[..len.min(table.len())]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_feature_records_are_rejected() {
|
||||
let gpos = [
|
||||
0x00, 0x01, 0x00, 0x00, // version 1.0
|
||||
0x00, 0x0A, // scriptList
|
||||
0x00, 0x0A, // featureList
|
||||
0x00, 0x0A, // lookupList
|
||||
0x00, 0x02, // featureCount without matching records
|
||||
];
|
||||
|
||||
assert!(parse_vpal(&gpos).is_none());
|
||||
}
|
||||
}
|
||||
540
render-wasm/src/shapes/japanese.rs
Normal file
@ -0,0 +1,540 @@
|
||||
//! Shared JLREQ character classes and pair-rule tables.
|
||||
//!
|
||||
//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are
|
||||
//! contextual/virtual classes produced by higher-level inline composites;
|
||||
//! [`classify`] handles scalar characters and callers assign those virtual
|
||||
//! classes when constructing reference marks, ruby, grouped numerals,
|
||||
//! warichu, or tate-chu-yoko.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum JapaneseClass {
|
||||
OpeningBracket = 0,
|
||||
ClosingBracket,
|
||||
Hyphen,
|
||||
DividingPunctuation,
|
||||
MiddleDot,
|
||||
FullStop,
|
||||
Comma,
|
||||
Inseparable,
|
||||
IterationMark,
|
||||
ProlongedSoundMark,
|
||||
SmallKana,
|
||||
PrefixedAbbreviation,
|
||||
PostfixedAbbreviation,
|
||||
IdeographicSpace,
|
||||
Hiragana,
|
||||
Katakana,
|
||||
MathSymbol,
|
||||
MathOperator,
|
||||
Ideographic,
|
||||
ReferenceMark,
|
||||
OrnamentedComplex,
|
||||
SimpleRuby,
|
||||
JukugoRuby,
|
||||
GroupedNumeral,
|
||||
UnitSymbol,
|
||||
WesternWordSpace,
|
||||
Western,
|
||||
WarichuOpening,
|
||||
WarichuClosing,
|
||||
TateChuYoko,
|
||||
}
|
||||
|
||||
impl JapaneseClass {
|
||||
pub const COUNT: usize = 30;
|
||||
|
||||
pub const ALL: [Self; Self::COUNT] = [
|
||||
Self::OpeningBracket,
|
||||
Self::ClosingBracket,
|
||||
Self::Hyphen,
|
||||
Self::DividingPunctuation,
|
||||
Self::MiddleDot,
|
||||
Self::FullStop,
|
||||
Self::Comma,
|
||||
Self::Inseparable,
|
||||
Self::IterationMark,
|
||||
Self::ProlongedSoundMark,
|
||||
Self::SmallKana,
|
||||
Self::PrefixedAbbreviation,
|
||||
Self::PostfixedAbbreviation,
|
||||
Self::IdeographicSpace,
|
||||
Self::Hiragana,
|
||||
Self::Katakana,
|
||||
Self::MathSymbol,
|
||||
Self::MathOperator,
|
||||
Self::Ideographic,
|
||||
Self::ReferenceMark,
|
||||
Self::OrnamentedComplex,
|
||||
Self::SimpleRuby,
|
||||
Self::JukugoRuby,
|
||||
Self::GroupedNumeral,
|
||||
Self::UnitSymbol,
|
||||
Self::WesternWordSpace,
|
||||
Self::Western,
|
||||
Self::WarichuOpening,
|
||||
Self::WarichuClosing,
|
||||
Self::TateChuYoko,
|
||||
];
|
||||
|
||||
pub const fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
|
||||
pub const fn forbids_line_start(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ClosingBracket
|
||||
| Self::Hyphen
|
||||
| Self::DividingPunctuation
|
||||
| Self::MiddleDot
|
||||
| Self::FullStop
|
||||
| Self::Comma
|
||||
| Self::Inseparable
|
||||
| Self::IterationMark
|
||||
| Self::ProlongedSoundMark
|
||||
| Self::SmallKana
|
||||
| Self::PostfixedAbbreviation
|
||||
| Self::WarichuClosing
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn forbids_line_end(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::OpeningBracket | Self::PrefixedAbbreviation | Self::WarichuOpening
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn is_japanese_letter(self) -> bool {
|
||||
matches!(self, Self::Hiragana | Self::Katakana | Self::Ideographic)
|
||||
}
|
||||
|
||||
pub const fn is_western_run(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::GroupedNumeral | Self::UnitSymbol | Self::Western
|
||||
)
|
||||
}
|
||||
|
||||
pub const fn is_emphasis_prohibited(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::OpeningBracket | Self::ClosingBracket | Self::FullStop | Self::Comma
|
||||
)
|
||||
}
|
||||
|
||||
/// Half-width punctuation whose normal character frame is completed by
|
||||
/// half an em after the glyph. Consecutive punctuation may suppress that
|
||||
/// appended spacing, but the glyph body itself remains half-width.
|
||||
pub const fn is_trailing_aki_punctuation(self) -> bool {
|
||||
matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PairRule {
|
||||
/// Preferred extra spacing between the two character frames, in em.
|
||||
pub preferred_em: f32,
|
||||
/// Smallest spacing allowed during oikomi, in em.
|
||||
pub minimum_em: f32,
|
||||
/// Largest spacing allowed during oidashi/justification, in em.
|
||||
pub maximum_em: f32,
|
||||
pub break_allowed: bool,
|
||||
/// Whether horizontal SkParagraph needs an inserted WORD JOINER for this
|
||||
/// pair. Atomic Western/numeral runs are already protected by its Unicode
|
||||
/// breaker, so they remain non-breakable without synthetic characters.
|
||||
pub suppress_break_with_joiner: bool,
|
||||
/// Lower values are adjusted first; zero means not adjustable.
|
||||
pub shrink_priority: u8,
|
||||
pub expand_priority: u8,
|
||||
}
|
||||
|
||||
impl PairRule {
|
||||
const SOLID: Self = Self {
|
||||
preferred_em: 0.0,
|
||||
minimum_em: 0.0,
|
||||
maximum_em: 0.0,
|
||||
break_allowed: true,
|
||||
suppress_break_with_joiner: false,
|
||||
shrink_priority: 0,
|
||||
expand_priority: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] {
|
||||
let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT];
|
||||
let mut before_index = 0;
|
||||
while before_index < JapaneseClass::COUNT {
|
||||
let before = JapaneseClass::ALL[before_index];
|
||||
let mut after_index = 0;
|
||||
while after_index < JapaneseClass::COUNT {
|
||||
let after = JapaneseClass::ALL[after_index];
|
||||
let mut rule = PairRule::SOLID;
|
||||
|
||||
rule.suppress_break_with_joiner =
|
||||
before.forbids_line_end() || after.forbids_line_start();
|
||||
rule.break_allowed = !rule.suppress_break_with_joiner;
|
||||
if before_index == after_index
|
||||
&& matches!(
|
||||
before,
|
||||
JapaneseClass::Inseparable
|
||||
| JapaneseClass::GroupedNumeral
|
||||
| JapaneseClass::Western
|
||||
)
|
||||
{
|
||||
rule.break_allowed = false;
|
||||
}
|
||||
|
||||
if matches!(before, JapaneseClass::WesternWordSpace) {
|
||||
rule.preferred_em = 1.0 / 3.0;
|
||||
rule.minimum_em = 0.25;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 1;
|
||||
rule.expand_priority = 1;
|
||||
} else if (before.is_japanese_letter() && after.is_western_run())
|
||||
|| (before.is_western_run() && after.is_japanese_letter())
|
||||
{
|
||||
rule.preferred_em = 0.25;
|
||||
rule.minimum_em = 0.125;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 5;
|
||||
rule.expand_priority = 2;
|
||||
} else if matches!(
|
||||
(before, after),
|
||||
(
|
||||
JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop,
|
||||
JapaneseClass::TateChuYoko
|
||||
) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket)
|
||||
) {
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 3;
|
||||
} else if matches!(
|
||||
(before, after),
|
||||
(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko)
|
||||
) {
|
||||
// Two adjacent cl-30 entries are necessarily separate TCY
|
||||
// composites (characters inside one composite are atomic).
|
||||
rule.maximum_em = 0.25;
|
||||
rule.expand_priority = 3;
|
||||
} else if matches!(before, JapaneseClass::DividingPunctuation)
|
||||
&& !matches!(after, JapaneseClass::ClosingBracket)
|
||||
{
|
||||
// A sentence-ending question/exclamation mark carries one em
|
||||
// after it. Line planning may discard this at the line edge.
|
||||
rule.preferred_em = 1.0;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 1.0;
|
||||
rule.shrink_priority = 2;
|
||||
} else if before.is_trailing_aki_punctuation()
|
||||
&& matches!(after, JapaneseClass::OpeningBracket)
|
||||
{
|
||||
// Only one half-em is retained between the two half-width
|
||||
// glyph bodies, not the sum of both characters' normal aki.
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 4;
|
||||
} else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() {
|
||||
// Consecutive closing punctuation sets solid internally; the
|
||||
// last character in the sequence supplies the trailing aki.
|
||||
} else if matches!(before, JapaneseClass::OpeningBracket)
|
||||
&& matches!(after, JapaneseClass::OpeningBracket)
|
||||
{
|
||||
// Consecutive opening brackets set solid internally; the first
|
||||
// character in the sequence supplies the leading aki.
|
||||
} else if (before.is_trailing_aki_punctuation()
|
||||
&& matches!(after, JapaneseClass::MiddleDot))
|
||||
|| (matches!(before, JapaneseClass::MiddleDot)
|
||||
&& matches!(after, JapaneseClass::OpeningBracket))
|
||||
{
|
||||
rule.preferred_em = 0.25;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.25;
|
||||
rule.shrink_priority = 3;
|
||||
} else if before.is_trailing_aki_punctuation() {
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = if matches!(before, JapaneseClass::FullStop) {
|
||||
0.5
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) {
|
||||
0
|
||||
} else {
|
||||
4
|
||||
};
|
||||
} else if matches!(after, JapaneseClass::OpeningBracket) {
|
||||
rule.preferred_em = 0.5;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.5;
|
||||
rule.shrink_priority = 4;
|
||||
} else if matches!(before, JapaneseClass::MiddleDot)
|
||||
|| matches!(after, JapaneseClass::MiddleDot)
|
||||
{
|
||||
rule.preferred_em = 0.25;
|
||||
rule.minimum_em = 0.0;
|
||||
rule.maximum_em = 0.25;
|
||||
rule.shrink_priority = 3;
|
||||
} else if before.is_japanese_letter() && after.is_japanese_letter() {
|
||||
// Solid Japanese text is the general third-stage expansion
|
||||
// opportunity. The planner may continue past this quarter-em
|
||||
// cap only in JLREQ's final equal-expansion fallback.
|
||||
rule.maximum_em = 0.25;
|
||||
rule.expand_priority = 3;
|
||||
}
|
||||
|
||||
table[before_index][after_index] = rule;
|
||||
after_index += 1;
|
||||
}
|
||||
before_index += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] =
|
||||
generated_pair_rules();
|
||||
|
||||
pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule {
|
||||
PAIR_RULES[before.index()][after.index()]
|
||||
}
|
||||
|
||||
const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“";
|
||||
const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”";
|
||||
const HYPHENS: &str = "‐゠–〜~";
|
||||
const DIVIDING_PUNCTUATION: &str = "!?‼⁇⁈⁉";
|
||||
const MIDDLE_DOTS: &str = "・・:;";
|
||||
const FULL_STOPS: &str = "。.";
|
||||
const COMMAS: &str = "、,";
|
||||
const INSEPARABLE: &str = "―…‥〳〴〵";
|
||||
const ITERATION_MARKS: &str = "々〻ゝゞヽヾ";
|
||||
const SMALL_KANA: &str = concat!(
|
||||
"ぁぃぅぇぉっゃゅょゎゕゖ",
|
||||
"ァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ"
|
||||
);
|
||||
const PREFIXED_ABBREVIATIONS: &str = "¥¥$$££##";
|
||||
const POSTFIXED_ABBREVIATIONS: &str = "°′″℃¢¢%%‰‱";
|
||||
const MATH_SYMBOLS: &str = "==≠<<>>≦≧≤≥∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔∥";
|
||||
const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏";
|
||||
|
||||
pub fn classify(c: char) -> JapaneseClass {
|
||||
if OPENING_BRACKETS.contains(c) {
|
||||
JapaneseClass::OpeningBracket
|
||||
} else if CLOSING_BRACKETS.contains(c) {
|
||||
JapaneseClass::ClosingBracket
|
||||
} else if HYPHENS.contains(c) {
|
||||
JapaneseClass::Hyphen
|
||||
} else if DIVIDING_PUNCTUATION.contains(c) {
|
||||
JapaneseClass::DividingPunctuation
|
||||
} else if MIDDLE_DOTS.contains(c) {
|
||||
JapaneseClass::MiddleDot
|
||||
} else if FULL_STOPS.contains(c) {
|
||||
JapaneseClass::FullStop
|
||||
} else if COMMAS.contains(c) {
|
||||
JapaneseClass::Comma
|
||||
} else if INSEPARABLE.contains(c) {
|
||||
JapaneseClass::Inseparable
|
||||
} else if ITERATION_MARKS.contains(c) {
|
||||
JapaneseClass::IterationMark
|
||||
} else if c == 'ー' {
|
||||
JapaneseClass::ProlongedSoundMark
|
||||
} else if SMALL_KANA.contains(c) {
|
||||
JapaneseClass::SmallKana
|
||||
} else if PREFIXED_ABBREVIATIONS.contains(c) {
|
||||
JapaneseClass::PrefixedAbbreviation
|
||||
} else if POSTFIXED_ABBREVIATIONS.contains(c) {
|
||||
JapaneseClass::PostfixedAbbreviation
|
||||
} else if c == '\u{3000}' {
|
||||
JapaneseClass::IdeographicSpace
|
||||
} else if MATH_SYMBOLS.contains(c) {
|
||||
JapaneseClass::MathSymbol
|
||||
} else if MATH_OPERATORS.contains(c) {
|
||||
JapaneseClass::MathOperator
|
||||
} else if c == ' ' || c == '\t' || c == '\u{00a0}' {
|
||||
JapaneseClass::WesternWordSpace
|
||||
} else if c.is_ascii_digit() {
|
||||
JapaneseClass::GroupedNumeral
|
||||
} else if is_hiragana(c) {
|
||||
JapaneseClass::Hiragana
|
||||
} else if is_katakana(c) {
|
||||
JapaneseClass::Katakana
|
||||
} else if is_ideographic(c) {
|
||||
JapaneseClass::Ideographic
|
||||
} else if is_unit_symbol(c) {
|
||||
JapaneseClass::UnitSymbol
|
||||
} else {
|
||||
JapaneseClass::Western
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hiragana(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x3041..=0x309F)
|
||||
}
|
||||
|
||||
fn is_katakana(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D)
|
||||
}
|
||||
|
||||
fn is_ideographic(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x2E80..=0x2FDF
|
||||
| 0x31C0..=0x31EF
|
||||
| 0x3400..=0x4DBF
|
||||
| 0x4E00..=0x9FFF
|
||||
| 0xF900..=0xFAFF
|
||||
| 0x20000..=0x2FA1F
|
||||
) || matches!(c, '〃' | '仝' | '〆' | '♂' | '♀')
|
||||
}
|
||||
|
||||
fn is_unit_symbol(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x2100..=0x214F | 0x3300..=0x33FF)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn class_model_contains_all_thirty_jlreq_classes_in_order() {
|
||||
assert_eq!(JapaneseClass::ALL.len(), 30);
|
||||
for (index, class) in JapaneseClass::ALL.iter().enumerate() {
|
||||
assert_eq!(class.index(), index);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_representative_jlreq_characters() {
|
||||
let cases = [
|
||||
('「', JapaneseClass::OpeningBracket),
|
||||
('」', JapaneseClass::ClosingBracket),
|
||||
('〜', JapaneseClass::Hyphen),
|
||||
('!', JapaneseClass::DividingPunctuation),
|
||||
('・', JapaneseClass::MiddleDot),
|
||||
('。', JapaneseClass::FullStop),
|
||||
('、', JapaneseClass::Comma),
|
||||
('…', JapaneseClass::Inseparable),
|
||||
('々', JapaneseClass::IterationMark),
|
||||
('ー', JapaneseClass::ProlongedSoundMark),
|
||||
('ょ', JapaneseClass::SmallKana),
|
||||
('¥', JapaneseClass::PrefixedAbbreviation),
|
||||
('%', JapaneseClass::PostfixedAbbreviation),
|
||||
('\u{3000}', JapaneseClass::IdeographicSpace),
|
||||
('あ', JapaneseClass::Hiragana),
|
||||
('ア', JapaneseClass::Katakana),
|
||||
('≠', JapaneseClass::MathSymbol),
|
||||
('+', JapaneseClass::MathOperator),
|
||||
('漢', JapaneseClass::Ideographic),
|
||||
('2', JapaneseClass::GroupedNumeral),
|
||||
('㎏', JapaneseClass::UnitSymbol),
|
||||
(' ', JapaneseClass::WesternWordSpace),
|
||||
('A', JapaneseClass::Western),
|
||||
];
|
||||
for (character, expected) in cases {
|
||||
assert_eq!(classify(character), expected, "wrong class for {character}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_cover_every_class_pair() {
|
||||
assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT);
|
||||
assert!(PAIR_RULES
|
||||
.iter()
|
||||
.all(|row| row.len() == JapaneseClass::COUNT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_encode_kinsoku_and_atomic_runs() {
|
||||
assert!(
|
||||
!pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed
|
||||
);
|
||||
assert!(
|
||||
!pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed
|
||||
);
|
||||
assert!(
|
||||
!pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed
|
||||
);
|
||||
assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_encode_script_and_tcy_spacing() {
|
||||
let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western);
|
||||
assert_eq!(script.preferred_em, 0.25);
|
||||
assert_eq!(script.minimum_em, 0.125);
|
||||
assert_eq!(script.maximum_em, 0.5);
|
||||
|
||||
let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko);
|
||||
assert_eq!(tcy.preferred_em, 0.5);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em,
|
||||
0.0
|
||||
);
|
||||
let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko);
|
||||
assert_eq!(adjacent_tcy.preferred_em, 0.0);
|
||||
assert_eq!(adjacent_tcy.maximum_em, 0.25);
|
||||
assert_eq!(adjacent_tcy.expand_priority, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rules_encode_punctuation_sequences() {
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em,
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em,
|
||||
0.25
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em,
|
||||
0.25
|
||||
);
|
||||
assert_eq!(
|
||||
pair_rule(
|
||||
JapaneseClass::DividingPunctuation,
|
||||
JapaneseClass::Ideographic
|
||||
)
|
||||
.preferred_em,
|
||||
1.0
|
||||
);
|
||||
let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana);
|
||||
assert_eq!(solid_japanese.preferred_em, 0.0);
|
||||
assert_eq!(solid_japanese.maximum_em, 0.25);
|
||||
assert_eq!(solid_japanese.expand_priority, 3);
|
||||
let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby);
|
||||
assert_eq!(ruby_adjacency.preferred_em, 0.0);
|
||||
assert_eq!(ruby_adjacency.maximum_em, 0.0);
|
||||
assert_eq!(ruby_adjacency.expand_priority, 0);
|
||||
}
|
||||
}
|
||||
514
render-wasm/src/shapes/kinsoku.rs
Normal file
@ -0,0 +1,514 @@
|
||||
//! Japanese line-breaking rules (kinsoku shori).
|
||||
//!
|
||||
//! Skia's default break iterator allows closing punctuation, the
|
||||
//! prolonged sound mark or small kana at a line start, and opening
|
||||
//! brackets at a line end. skia-safe exposes no ICU BreakIterator, so
|
||||
//! the forbidden break opportunities are suppressed by inserting U+2060 WORD
|
||||
//! JOINER into the text handed to skparagraph. The same lossless layout-text
|
||||
//! transform inserts JLREQ quarter-em Japanese/Western boundary space and
|
||||
//! normalizes Western word spaces to one third em.
|
||||
//!
|
||||
//! The inserted joiners shift every UTF-16 offset reported by the laid
|
||||
//! out paragraph (position-data, caret mapping, selection rects). The
|
||||
//! [`OffsetMap`] returned along with the modified texts translates
|
||||
//! between original and joiner-shifted offsets. It is a pure function
|
||||
//! of the span texts, so any consumer can recompute it and stay
|
||||
//! consistent with the builders by construction.
|
||||
|
||||
/// Zero-width character whose UAX #14 class forbids breaking on either
|
||||
/// side of it.
|
||||
pub const WORD_JOINER: char = '\u{2060}';
|
||||
/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap.
|
||||
pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}';
|
||||
/// Unicode THREE-PER-EM SPACE, used for Western word spacing in Japanese text.
|
||||
pub const WESTERN_WORD_SPACE: char = '\u{2004}';
|
||||
|
||||
use super::japanese::{classify, pair_rule};
|
||||
|
||||
pub fn forbidden_at_line_start(c: char) -> bool {
|
||||
classify(c).forbids_line_start()
|
||||
}
|
||||
|
||||
pub fn forbidden_at_line_end(c: char) -> bool {
|
||||
classify(c).forbids_line_end()
|
||||
}
|
||||
|
||||
/// Translates between original UTF-16 offsets (the source-of-truth span text)
|
||||
/// and layout-text UTF-16 offsets (the text handed to skparagraph).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct OffsetMap {
|
||||
/// UTF-16 indices of inserted joiners or boundary spaces, ascending and
|
||||
/// expressed in shifted coordinates. Same-length substitutions need no
|
||||
/// entry.
|
||||
inserted: Vec<usize>,
|
||||
}
|
||||
|
||||
impl OffsetMap {
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inserted.is_empty()
|
||||
}
|
||||
|
||||
/// Original offset for a shifted offset. An offset pointing at an inserted
|
||||
/// character resolves to the source boundary where it was inserted.
|
||||
pub fn to_original(&self, shifted: usize) -> usize {
|
||||
shifted - self.inserted.iter().take_while(|&&p| p < shifted).count()
|
||||
}
|
||||
|
||||
/// Shifted offset for an original offset. A boundary that received a
|
||||
/// layout character resolves after it, so carets avoid synthetic spacing.
|
||||
pub fn to_shifted(&self, original: usize) -> usize {
|
||||
let mut shifted = original;
|
||||
for &p in &self.inserted {
|
||||
if p <= shifted {
|
||||
shifted += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
shifted
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the horizontal Japanese layout-text transform. It inserts WORD
|
||||
/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space at
|
||||
/// Japanese↔Western boundaries, and normalizes breakable ASCII word spaces to
|
||||
/// one third em. Span boundaries are transparent. Returns `None` when the
|
||||
/// paragraph needs no transformation.
|
||||
/// Apply the normal Japanese layout transform while additionally protecting
|
||||
/// annotated base units. `ruby_breaks[span] == Some(boundaries)` means that
|
||||
/// every internal scalar boundary except those UTF-16 offsets is atomic.
|
||||
pub fn apply_to_span_texts_with_ruby_breaks(
|
||||
span_texts: &[String],
|
||||
ruby_breaks: &[Option<Vec<usize>>],
|
||||
) -> Option<(Vec<String>, OffsetMap)> {
|
||||
let mut inserted: Vec<usize> = Vec::new();
|
||||
let mut out: Vec<String> = Vec::with_capacity(span_texts.len());
|
||||
let mut prev: Option<char> = None;
|
||||
let mut changed = false;
|
||||
// Running position in shifted UTF-16 coordinates.
|
||||
let mut shifted_pos: usize = 0;
|
||||
|
||||
for (span_index, text) in span_texts.iter().enumerate() {
|
||||
let mut shifted_text = String::with_capacity(text.len() + 4);
|
||||
let mut local_utf16 = 0usize;
|
||||
for c in text.chars() {
|
||||
let ruby_forbids_break = local_utf16 > 0
|
||||
&& ruby_breaks
|
||||
.get(span_index)
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|breaks| !breaks.contains(&local_utf16));
|
||||
let forbid_break = ruby_forbids_break
|
||||
|| match prev {
|
||||
Some(p) => pair_rule(classify(p), classify(c)).suppress_break_with_joiner,
|
||||
None => false,
|
||||
};
|
||||
if forbid_break {
|
||||
shifted_text.push(WORD_JOINER);
|
||||
inserted.push(shifted_pos);
|
||||
shifted_pos += 1;
|
||||
changed = true;
|
||||
} else if prev.is_some_and(|p| {
|
||||
let before = classify(p);
|
||||
let after = classify(c);
|
||||
(before.is_japanese_letter() && after.is_western_run())
|
||||
|| (before.is_western_run() && after.is_japanese_letter())
|
||||
}) {
|
||||
shifted_text.push(JAPANESE_WESTERN_SPACE);
|
||||
inserted.push(shifted_pos);
|
||||
shifted_pos += 1;
|
||||
changed = true;
|
||||
}
|
||||
let layout_char = if c == ' ' {
|
||||
changed = true;
|
||||
WESTERN_WORD_SPACE
|
||||
} else {
|
||||
c
|
||||
};
|
||||
shifted_text.push(layout_char);
|
||||
shifted_pos += c.len_utf16();
|
||||
local_utf16 += c.len_utf16();
|
||||
prev = Some(c);
|
||||
}
|
||||
out.push(shifted_text);
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
Some((out, OffsetMap { inserted }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::FontMgr;
|
||||
|
||||
const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
|
||||
|
||||
fn strings(texts: &[&str]) -> Vec<String> {
|
||||
texts.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
fn apply(texts: &[&str]) -> (Vec<String>, OffsetMap) {
|
||||
apply_to_span_texts_with_ruby_breaks(&strings(texts), &[])
|
||||
.expect("expected kinsoku insertions")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Character classes
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classes_forbidden_at_start() {
|
||||
for c in "、。」』)ーっゃァッ々・!?".chars() {
|
||||
assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}");
|
||||
}
|
||||
for c in "あ漢A1「( ".chars() {
|
||||
assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classes_forbidden_at_end() {
|
||||
for c in "「『([【〈《".chars() {
|
||||
assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}");
|
||||
}
|
||||
for c in "あ漢A1」)。".chars() {
|
||||
assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Insertion
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn western_word_space_uses_one_third_em_character() {
|
||||
let (texts, map) = apply(&["hello world"]);
|
||||
assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]);
|
||||
assert!(
|
||||
map.inserted.is_empty(),
|
||||
"substitution does not shift offsets"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_insertion_for_plain_cjk_text() {
|
||||
assert!(
|
||||
apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_before_forbidden_start_char() {
|
||||
let (texts, map) = apply(&["雪国。"]);
|
||||
assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]);
|
||||
assert_eq!(map.inserted, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_after_forbidden_end_char() {
|
||||
let (texts, _) = apply(&["「雪"]);
|
||||
assert_eq!(texts, vec!["「\u{2060}雪".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_insertion_at_paragraph_start() {
|
||||
// A leading forbidden-at-start char has no break opportunity
|
||||
// before it; nothing to suppress.
|
||||
let (texts, _) = apply(&["。あ。"]);
|
||||
assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insertion_spans_boundary() {
|
||||
// The pair (end of span 0, start of span 1) is evaluated; the
|
||||
// joiner lands at the head of span 1.
|
||||
let (texts, _) = apply(&["雪国", "。です"]);
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_quarter_em_at_japanese_western_boundaries() {
|
||||
let (texts, map) = apply(&["日本Penpot版"]);
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![format!(
|
||||
"日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版"
|
||||
)]
|
||||
);
|
||||
assert_eq!(map.inserted, vec![2, 9]);
|
||||
assert_eq!(map.to_original(map.to_shifted(2)), 2);
|
||||
assert_eq!(map.to_original(map.to_shifted(8)), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_western_spacing_crosses_span_boundary() {
|
||||
let (texts, map) = apply(&["日本", "Penpot"]);
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
"日本".to_string(),
|
||||
format!("{JAPANESE_WESTERN_SPACE}Penpot")
|
||||
]
|
||||
);
|
||||
assert_eq!(map.inserted, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_forbidden_chars() {
|
||||
let (texts, map) = apply(&["た」。"]);
|
||||
assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]);
|
||||
assert_eq!(map.inserted, vec![1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_kana_and_prolonged_sound() {
|
||||
let (texts, _) = apply(&["コーヒー"]);
|
||||
assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]);
|
||||
let (texts, _) = apply(&["ちょっと"]);
|
||||
assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Offset map
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn offset_map_round_trips_every_original_index() {
|
||||
let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。";
|
||||
let (_, map) = apply(&[original]);
|
||||
let len = original.encode_utf16().count();
|
||||
for i in 0..=len {
|
||||
assert_eq!(
|
||||
map.to_original(map.to_shifted(i)),
|
||||
i,
|
||||
"round-trip failed at original index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_map_recovers_original_characters() {
|
||||
let original = "た」。あ";
|
||||
let (texts, map) = apply(&[original]);
|
||||
let shifted = texts.concat();
|
||||
let shifted_units: Vec<u16> = shifted.encode_utf16().collect();
|
||||
let original_units: Vec<u16> = original.encode_utf16().collect();
|
||||
for (s_idx, unit) in shifted_units.iter().enumerate() {
|
||||
if *unit == WORD_JOINER as u16 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(original_units[map.to_original(s_idx)], *unit);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_map_on_joiner_resolves_to_boundary() {
|
||||
// "雪国。" → joiner at shifted 2; both sides of the joiner map
|
||||
// to original boundary 2.
|
||||
let (_, map) = apply(&["雪国。"]);
|
||||
assert_eq!(map.to_original(2), 2);
|
||||
assert_eq!(map.to_original(3), 2);
|
||||
assert_eq!(map.to_shifted(2), 3);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Real skparagraph layout
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
fn font_collection() -> FontCollection {
|
||||
let font_mgr = FontMgr::new();
|
||||
let typeface = font_mgr
|
||||
.new_from_data(TEST_FONT, None)
|
||||
.expect("failed to load test font");
|
||||
let mut provider = TypefaceFontProvider::new();
|
||||
provider.register_typeface(typeface, Some("TestFont"));
|
||||
let mut collection = FontCollection::new();
|
||||
collection.set_asset_font_manager(Some(provider.into()));
|
||||
collection.set_default_font_manager(FontMgr::new(), None);
|
||||
collection
|
||||
}
|
||||
|
||||
/// Lays out `text` at `width` and returns each line as a UTF-16
|
||||
/// (start, end) range.
|
||||
fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> {
|
||||
let collection = font_collection();
|
||||
let paragraph_style = ParagraphStyle::default();
|
||||
let mut builder = ParagraphBuilder::new(¶graph_style, collection);
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
style.set_letter_spacing(letter_spacing);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut paragraph = builder.build();
|
||||
paragraph.layout(width);
|
||||
paragraph
|
||||
.get_line_metrics()
|
||||
.iter()
|
||||
.map(|line| (line.start_index, line.end_index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn utf16_chars(text: &str) -> Vec<char> {
|
||||
// BMP-only fixtures: one char per UTF-16 unit.
|
||||
text.chars().collect()
|
||||
}
|
||||
|
||||
/// Asserts no line starts with a start-forbidden char nor ends with
|
||||
/// an end-forbidden char, in ORIGINAL text coordinates.
|
||||
fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
|
||||
let chars = utf16_chars(original);
|
||||
for (i, (start, end)) in lines.iter().enumerate() {
|
||||
let orig_start = map.to_original(*start);
|
||||
let orig_end = map.to_original(*end);
|
||||
if i > 0 {
|
||||
let first = chars[orig_start];
|
||||
assert!(
|
||||
!forbidden_at_line_start(first),
|
||||
"line {i} starts with forbidden char {first} (text {original})"
|
||||
);
|
||||
}
|
||||
if i < lines.len() - 1 && orig_end > orig_start {
|
||||
// The last char actually rendered on the line (end may
|
||||
// include trailing whitespace-like positions).
|
||||
let last = chars[orig_end - 1];
|
||||
assert!(
|
||||
!forbidden_at_line_end(last),
|
||||
"line {i} ends with forbidden char {last} (text {original})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines mapped back to original offsets must tile the original
|
||||
/// text exactly: contiguous, in order, full coverage.
|
||||
fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
|
||||
let mut expected_start = 0;
|
||||
for (start, end) in lines {
|
||||
let orig_start = map.to_original(*start);
|
||||
let orig_end = map.to_original(*end);
|
||||
assert_eq!(orig_start, expected_start, "line ranges must be contiguous");
|
||||
expected_start = orig_end;
|
||||
}
|
||||
assert_eq!(expected_start, original.encode_utf16().count());
|
||||
}
|
||||
|
||||
fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) {
|
||||
let (texts, map) = apply(&[original]);
|
||||
let lines = layout_lines(&texts.concat(), width, 0.0);
|
||||
(lines, map)
|
||||
}
|
||||
|
||||
fn measure_width(text: &str) -> f32 {
|
||||
let collection = font_collection();
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection);
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::MAX);
|
||||
p.longest_line()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joiner_is_zero_width_in_layout() {
|
||||
let plain = layout_lines("国国", f32::MAX, 0.0);
|
||||
let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0);
|
||||
assert_eq!(plain.len(), 1);
|
||||
assert_eq!(joined.len(), 1);
|
||||
|
||||
let collection = font_collection();
|
||||
let measure = |text: &str| {
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::MAX);
|
||||
p.longest_line()
|
||||
};
|
||||
let diff = (measure("国国") - measure("国\u{2060}国")).abs();
|
||||
assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppresses_breaks_on_kinsoku_fixtures() {
|
||||
let fixtures = [
|
||||
// kinsoku-line-start
|
||||
"これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
|
||||
// kinsoku-line-end
|
||||
"開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
|
||||
// prolonged-sound
|
||||
"コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
|
||||
// small-kana-sokuon
|
||||
"ちょっと待ってください。キャッシュとクッキーをチェックする。",
|
||||
// long-paragraph-wrap
|
||||
"国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
|
||||
];
|
||||
// Several widths to move the break positions around. Widths
|
||||
// must exceed the longest unbreakable (joined) run, otherwise
|
||||
// skparagraph rightfully falls back to an emergency mid-run
|
||||
// break.
|
||||
let char_width = measure_width("国");
|
||||
for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) {
|
||||
for original in fixtures {
|
||||
let (lines, map) = fixture_lines(original, width);
|
||||
assert!(lines.len() > 1, "fixture must wrap at width {width}");
|
||||
assert_kinsoku_clean(original, &lines, &map);
|
||||
assert_lines_tile_original(original, &lines, &map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_latin_cjk_fixture() {
|
||||
let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!";
|
||||
let char_width = measure_width("国");
|
||||
for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) {
|
||||
let (lines, map) = fixture_lines(original, width);
|
||||
assert_kinsoku_clean(original, &lines, &map);
|
||||
assert_lines_tile_original(original, &lines, &map);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joiner_becomes_visible_under_letter_spacing() {
|
||||
// skparagraph applies letter-spacing per cluster, INCLUDING the
|
||||
// zero-width joiner, which would double the tracking at every
|
||||
// suppressed break. This is why callers disable kinsoku for
|
||||
// paragraphs with a non-zero letter-spacing. If this test ever
|
||||
// fails (Skia stops spacing ignorables), that gate can go.
|
||||
let collection = font_collection();
|
||||
let measure = |text: &str| {
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
|
||||
let mut style = TextStyle::default();
|
||||
style.set_font_families(&["TestFont"]);
|
||||
style.set_font_size(20.0);
|
||||
style.set_letter_spacing(5.0);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(text);
|
||||
let mut p = builder.build();
|
||||
p.layout(f32::MAX);
|
||||
p.longest_line()
|
||||
};
|
||||
let diff = (measure("国\u{2060}国") - measure("国国")).abs();
|
||||
assert!(
|
||||
diff > 0.01,
|
||||
"letter-spacing no longer affects the joiner; the kinsoku \
|
||||
letter-spacing gate can be removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
6661
render-wasm/src/shapes/text_vertical.rs
Normal 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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||