diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc
index a5097a29a7..d7d993e214 100644
--- a/common/src/app/common/features.cljc
+++ b/common/src/app/common/features.cljc
@@ -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"
diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc
index c5211e8a4a..9b6b5a7754 100644
--- a/common/src/app/common/types/shape/text.cljc
+++ b/common/src/app/common/types/shape/text.cljc
@@ -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]]])
diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc
index 6068cfc829..87ad892f6e 100644
--- a/common/src/app/common/types/text.cljc
+++ b/common/src/app/common/types/text.cljc
@@ -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))
diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js
index 400636c5dd..94ca12ef57 100644
--- a/frontend/packages/draft-js/index.js
+++ b/frontend/packages/draft-js/index.js
@@ -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);
diff --git a/frontend/resources/images/icons/text-combine-upright-all.svg b/frontend/resources/images/icons/text-combine-upright-all.svg
new file mode 100644
index 0000000000..594b7f49fd
--- /dev/null
+++ b/frontend/resources/images/icons/text-combine-upright-all.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-combine-upright-digits.svg b/frontend/resources/images/icons/text-combine-upright-digits.svg
new file mode 100644
index 0000000000..3e367a3fd3
--- /dev/null
+++ b/frontend/resources/images/icons/text-combine-upright-digits.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-combine-upright-none.svg b/frontend/resources/images/icons/text-combine-upright-none.svg
new file mode 100644
index 0000000000..f54cf6f1e3
--- /dev/null
+++ b/frontend/resources/images/icons/text-combine-upright-none.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-orientation-mixed.svg b/frontend/resources/images/icons/text-orientation-mixed.svg
new file mode 100644
index 0000000000..72cd592478
--- /dev/null
+++ b/frontend/resources/images/icons/text-orientation-mixed.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-orientation-upright.svg b/frontend/resources/images/icons/text-orientation-upright.svg
new file mode 100644
index 0000000000..67d241b2bb
--- /dev/null
+++ b/frontend/resources/images/icons/text-orientation-upright.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/warichu-none.svg b/frontend/resources/images/icons/warichu-none.svg
new file mode 100644
index 0000000000..7ede0d3d0a
--- /dev/null
+++ b/frontend/resources/images/icons/warichu-none.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/warichu.svg b/frontend/resources/images/icons/warichu.svg
new file mode 100644
index 0000000000..3348550253
--- /dev/null
+++ b/frontend/resources/images/icons/warichu.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/writing-mode-horizontal.svg b/frontend/resources/images/icons/writing-mode-horizontal.svg
new file mode 100644
index 0000000000..204e9190e7
--- /dev/null
+++ b/frontend/resources/images/icons/writing-mode-horizontal.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/writing-mode-vertical.svg b/frontend/resources/images/icons/writing-mode-vertical.svg
new file mode 100644
index 0000000000..e89a00a562
--- /dev/null
+++ b/frontend/resources/images/icons/writing-mode-vertical.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs
index b5696414c8..da3e0a0bd8 100644
--- a/frontend/src/app/main/data/workspace/modifiers.cljs
+++ b/frontend/src/app/main/data/workspace/modifiers.cljs
@@ -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))))
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index df0508a1ba..d30d81299d 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -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]
diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs
index 5eb90ca433..f3898ba87c 100644
--- a/frontend/src/app/main/data/workspace/transforms.cljs
+++ b/frontend/src/app/main/data/workspace/transforms.cljs
@@ -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)))
diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs
index fc97dba96e..24dc0f86d3 100644
--- a/frontend/src/app/main/data/workspace/wasm_text.cljs
+++ b/frontend/src/app/main/data/workspace/wasm_text.cljs
@@ -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]
diff --git a/frontend/src/app/main/ui/css_cursors.cljs b/frontend/src/app/main/ui/css_cursors.cljs
index 9c0a97276e..b87afa83df 100644
--- a/frontend/src/app/main/ui/css_cursors.cljs
+++ b/frontend/src/app/main/ui/css_cursors.cljs
@@ -17,6 +17,11 @@
[name rotation]
(dm/str "cursor-" name "-" (mod (* (.floor js/Math (/ rotation angle-step)) angle-step) 360)))
+(defn get-text
+ "Returns the text cursor class, rotating the I-beam with vertical text flow."
+ [rotation vertical?]
+ (get-dynamic "text" (+ (or rotation 0) (if vertical? 90 0))))
+
(defn init-static-cursor-style
"Initializes a static cursor style"
[style name value]
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index 44e44e075f..2e6b20d1cd 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -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"
diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs
index 1ef023a079..aed7da456d 100644
--- a/frontend/src/app/main/ui/icons.cljs
+++ b/frontend/src/app/main/ui/icons.cljs
@@ -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"
diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs
index 5bb224b673..89babc59ed 100644
--- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs
@@ -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`
diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs
index 5bf0dc150a..869b16d2cd 100644
--- a/frontend/src/app/main/ui/shapes/text/html_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs
@@ -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)
diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs
index edf5329ddf..ae3200ebb2 100644
--- a/frontend/src/app/main/ui/shapes/text/styles.cljs
+++ b/frontend/src/app/main/ui/shapes/text/styles.cljs
@@ -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 ` 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 " " 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")})
diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
index aad09c18ba..a03445df3f 100644
--- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
@@ -15,11 +15,96 @@
[app.main.ui.shapes.custom-stroke :refer [shape-custom-strokes]]
[app.main.ui.shapes.fills :as fills]
[app.main.ui.shapes.gradients :as grad]
+ [app.main.ui.shapes.text.styles :as sts]
[app.util.object :as obj]
[rumext.v2 :as mf]))
(def fill-attrs [:fill-color :fill-color-gradient :fill-opacity])
+(defn- ruby-font-scale
+ [ruby-size]
+ (case ruby-size
+ "third" (/ 1 3)
+ "quarter" 0.25
+ 0.5))
+
+(def ^:private emphasis-font-scale 0.5)
+
+(def ^:private warichu-font-scale 0.5)
+
+;; Kinsoku classes for the warichu sub-line split (same characters the
+;; renderer's kinsoku module suppresses at line boundaries).
+(def ^:private warichu-forbidden-at-start
+ (str "、。,.)」』]】〕〉》’”!?;:ー"
+ "ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ"
+ "々ゝゞヽヾ・"))
+
+(def ^:private warichu-forbidden-at-end "(「『[【〔〈《‘“")
+
+(defn- warichu-split-index
+ "Safe JavaScript string index where a warichu run splits into its two
+ sub-lines. The split is chosen in Unicode code-point space, then translated
+ back to a UTF-16 boundary for `subs`: the
+ balanced midpoint (first sub-line longer), nudged forward then backward
+ so the second sub-line does not start with a line-start-prohibited
+ character and the first does not end with a line-end-prohibited one.
+ Mirrors the renderer's `warichu_split_chars`."
+ [text]
+ (let [characters (vec (js/Array.from text))
+ n (count characters)
+ mid (js/Math.ceil (/ n 2))
+ valid? (fn [split]
+ (and (>= split 1)
+ (< split n)
+ (not (.includes warichu-forbidden-at-start (nth characters split)))
+ (not (.includes warichu-forbidden-at-end (nth characters (dec split))))))
+ split (if (valid? mid)
+ mid
+ (or (->> (range 1 n)
+ (some (fn [distance]
+ (cond
+ (valid? (+ mid distance)) (+ mid distance)
+ (and (> mid distance) (valid? (- mid distance))) (- mid distance)))))
+ mid))]
+ (->> (take split characters)
+ (reduce (fn [index character] (+ index (.-length character))) 0))))
+
+;; CSS `text-emphasis-style` character mapping (same glyphs the canvas
+;; renderer shapes for each mark style).
+(def ^:private emphasis-mark-chars
+ {"filled-dot" "•"
+ "open-dot" "◦"
+ "filled-circle" "●"
+ "open-circle" "○"
+ "filled-sesame" "﹅"
+ "open-sesame" "﹆"})
+
+(def ^:private emphasis-prohibited-chars
+ "、。,.「」『』()[]【】〔〕〈〉《》‘’“”")
+
+(defn- emphasis-character?
+ [character]
+ (and (not (re-matches #"\s" character))
+ (not (.includes emphasis-prohibited-chars character))))
+
+(defn- emphasis-marks-text
+ "One mark per eligible Unicode base character. Spaces replace whitespace
+ and Japanese punctuation that does not normally carry emphasis so marks
+ stay aligned with the base slots."
+ [text mark]
+ (->> (js/Array.from text)
+ (map (fn [character]
+ (if (emphasis-character? character) mark " ")))
+ (apply str)))
+
+(defn- add-font-features!
+ [style font-features]
+ (cond-> style
+ (and (string? font-features)
+ (not= "none" font-features)
+ (pos? (alength font-features)))
+ (obj/set! "fontFeatureSettings" (dm/str "\"" font-features "\""))))
+
(defn set-white-fill
[shape]
(let [update-color
@@ -72,32 +157,124 @@
[:> :g group-props
(for [[index data] (d/enumerate position-data)]
(let [rtl? (= "rtl" (:direction data))
+ vertical? (= "vertical-rl" (:writing-mode data))
+ ruby (:ruby data)
+ ruby? (and (string? ruby) (seq ruby))
+ ruby-side (:ruby-side data "over")
+ ruby-align (:ruby-align data "space-around")
+ ruby-overhang (:ruby-overhang data "auto")
+ auto-clearance? (= "auto" (:annotation-clearance data))
+ has-ruby-layer? (and (not= "under" ruby-side)
+ (or ruby? (:annotation-has-ruby data)))
+ emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
+ emphasis? (some? emphasis-mark)
+ warichu? (and (= "warichu" (:warichu data))
+ (string? (:text data))
+ (>= (count (:text data)) 2))
+ font-features (:font-features data)
browser-props
(cond
- (cf/check-browser? :safari)
+ (and (not vertical?) (cf/check-browser? :safari))
#js {:dominantBaseline "hanging"
:dy "0.2em"
:y (- (:y data) (:height data))})
- props (-> #js {:key (dm/str "text-" (:id shape) "-" index)
- :x (if rtl? (+ (:x data) (:width data)) (:x data))
- :y (:y data)
- :dominantBaseline "ideographic"
- :textLength (:width data)
- :lengthAdjust "spacingAndGlyphs"
- :style (-> #js {:fontFamily (:font-family data)
- :fontSize (:font-size data)
- :fontWeight (:font-weight data)
- :textTransform (:text-transform data)
- :textDecoration (:text-decoration data)
- :letterSpacing (:letter-spacing data)
- :fontStyle (:font-style data)
- :direction (:direction data)
- :whiteSpace "pre"}
- (obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
- (cond-> browser-props
- (obj/merge! browser-props)))
+ base-style (add-font-features!
+ #js {:fontFamily (:font-family data)
+ :fontSize (:font-size data)
+ :fontWeight (:font-weight data)
+ :textTransform (:text-transform data)
+ :textDecoration (:text-decoration data)
+ :textCombineUpright (sts/css-text-combine-upright
+ (:text-combine-upright data))
+ :letterSpacing (:letter-spacing data)
+ :fontStyle (:font-style data)
+ :direction (:direction data)
+ :textSpacingTrim "normal"
+ :whiteSpace "pre"}
+ font-features)
+
+ font-size (js/parseFloat (:font-size data))
+ ruby-font-size-num (if (js/isNaN font-size)
+ 0
+ (* font-size (ruby-font-scale (:ruby-size data))))
+ ruby-font-size (when (pos? ruby-font-size-num)
+ (dm/str ruby-font-size-num "px"))
+ ruby-style (add-font-features!
+ #js {:fontFamily (:font-family data)
+ :fontSize ruby-font-size
+ :fontWeight (:font-weight data)
+ :fontStyle (:font-style data)
+ :direction (:direction data)
+ :writingMode (if vertical? "vertical-rl" "horizontal-tb")
+ :textSpacingTrim "normal"
+ :textOrientation "upright"
+ :textAutospace "normal"
+ :whiteSpace "pre"
+ :fill (str "url(#fill-" index "-" render-id "-" index ")")}
+ font-features)
+
+ emphasis-font-size-num (if (js/isNaN font-size)
+ 0
+ (* font-size emphasis-font-scale))
+
+ warichu-font-size-num (if (js/isNaN font-size)
+ 0
+ (* font-size warichu-font-scale))
+ warichu-style (add-font-features!
+ #js {:fontFamily (:font-family data)
+ :fontSize (when (pos? warichu-font-size-num)
+ (dm/str warichu-font-size-num "px"))
+ :fontWeight (:font-weight data)
+ :fontStyle (:font-style data)
+ :direction (:direction data)
+ :writingMode (if vertical? "vertical-rl" "horizontal-tb")
+ :textSpacingTrim "normal"
+ :textOrientation (when vertical? "upright")
+ :textAutospace "normal"
+ :whiteSpace "pre"
+ :fill (str "url(#fill-" index "-" render-id "-" index ")")}
+ font-features)
+ emphasis-style (add-font-features!
+ #js {:fontFamily (:font-family data)
+ :fontSize (when (pos? emphasis-font-size-num)
+ (dm/str emphasis-font-size-num "px"))
+ :fontWeight (:font-weight data)
+ :fontStyle (:font-style data)
+ :direction (:direction data)
+ :writingMode (if vertical? "vertical-rl" "horizontal-tb")
+ :textSpacingTrim "normal"
+ :textOrientation (when vertical? "upright")
+ :textAutospace "normal"
+ :whiteSpace "pre"
+ :fill (str "url(#fill-" index "-" render-id "-" index ")")}
+ font-features)
+
+ props (if vertical?
+ ;; Vertical strip: glyphs run down the column; x is
+ ;; the column's central axis and y the strip top
+ ;; (stored y is the strip bottom).
+ #js {:key (dm/str "text-" (:id shape) "-" index)
+ :x (+ (:x data) (/ (:width data) 2))
+ :y (- (:y data) (:height data))
+ :textLength (:height data)
+ :lengthAdjust "spacingAndGlyphs"
+ :style (-> base-style
+ (obj/set! "writingMode" "vertical-rl")
+ (obj/set! "textSpacingTrim" "normal")
+ (obj/set! "textOrientation" (or (:text-orientation data) "mixed"))
+ (obj/set! "textAutospace" "normal")
+ (obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
+ (-> #js {:key (dm/str "text-" (:id shape) "-" index)
+ :x (if rtl? (+ (:x data) (:width data)) (:x data))
+ :y (:y data)
+ :dominantBaseline "ideographic"
+ :textLength (:width data)
+ :lengthAdjust "spacingAndGlyphs"
+ :style (obj/set! base-style "fill" (str "url(#fill-" index "-" render-id ")"))}
+ (cond-> browser-props
+ (obj/merge! browser-props))))
shape (-> shape
(assoc :fills (:fills data))
;; The text elements have the shadow and blur already applied in the
@@ -113,4 +290,106 @@
[:& fills/fills {:shape shape :render-id render-id}]]
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
- [:> :text props (:text data)]]]))]]))
+ (if warichu?
+ ;; Warichu: two half-size sub-lines within one inline strip.
+ ;; Vertical reading order is right then left; horizontal is
+ ;; top then bottom.
+ (let [text (:text data)
+ split-index (warichu-split-index text)
+ centre (+ (:x data) (/ (:width data) 2))
+ quarter (/ warichu-font-size-num 2)
+ top (- (:y data) (:height data))]
+ [:g {:key (dm/str "warichu-" (:id shape) "-" index)}
+ (if vertical?
+ [:*
+ [:> :text {:x (+ centre quarter)
+ :y top
+ :style warichu-style}
+ (subs text 0 split-index)]
+ [:> :text {:x (- centre quarter)
+ :y top
+ :style warichu-style}
+ (subs text split-index)]]
+ [:*
+ [:> :text {:x (:x data)
+ :y top
+ :dominantBaseline "hanging"
+ :textLength (:width data)
+ :lengthAdjust "spacingAndGlyphs"
+ :style warichu-style}
+ (subs text 0 split-index)]
+ [:> :text {:x (:x data)
+ :y (+ top (/ (:height data) 2))
+ :dominantBaseline "hanging"
+ :textLength (:width data)
+ :lengthAdjust "spacingAndGlyphs"
+ :style warichu-style}
+ (subs text split-index)]])])
+ [:> :text props (:text data)])]
+ (when ruby?
+ [:> :text (if vertical?
+ #js {:key (dm/str "ruby-" (:id shape) "-" index)
+ :x (if (= "under" ruby-side)
+ (- (:x data) (/ ruby-font-size-num 2))
+ (+ (:x data) (:width data) (/ ruby-font-size-num 2)))
+ :y (- (:y data) (:height data))
+ :textLength (:height data)
+ :lengthAdjust "spacingAndGlyphs"
+ :style ruby-style}
+ (let [start-x (if rtl? (+ (:x data) (:width data)) (:x data))
+ center-x (+ (:x data) (/ (:width data) 2))
+ constrain? (= "none" ruby-overhang)]
+ (cond-> #js {:key (dm/str "ruby-" (:id shape) "-" index)
+ :x (if (= "center" ruby-align) center-x start-x)
+ :y (if (= "under" ruby-side)
+ (:y data)
+ (- (:y data) (:height data)))
+ :dominantBaseline (if (= "under" ruby-side)
+ "hanging"
+ "text-after-edge")
+ :style ruby-style}
+ (= "center" ruby-align)
+ (obj/set! "textAnchor" "middle")
+
+ (= "start" ruby-align)
+ (obj/set! "textAnchor" (if rtl? "end" "start"))
+
+ (or constrain? (= "space-around" ruby-align))
+ (obj/set! "textLength" (:width data))
+
+ (or constrain? (= "space-around" ruby-align))
+ (obj/set! "lengthAdjust" "spacingAndGlyphs")
+
+ (= "space-between" ruby-align)
+ (obj/set! "textLength" (:width data))
+
+ (= "space-between" ruby-align)
+ (obj/set! "lengthAdjust" "spacing"))))
+ ruby])
+ ;; Emphasis marks (圏点): one half-size mark per eligible base
+ ;; character, right of vertical text or above horizontal text.
+ (when emphasis?
+ [:> :text (if vertical?
+ #js {:key (dm/str "emphasis-" (:id shape) "-" index)
+ :x (+ (:x data)
+ (:width data)
+ (/ emphasis-font-size-num 2)
+ (if (and auto-clearance? has-ruby-layer?)
+ ruby-font-size-num
+ 0))
+ :y (- (:y data) (:height data))
+ :textLength (:height data)
+ :lengthAdjust "spacing"
+ :style emphasis-style}
+ #js {:key (dm/str "emphasis-" (:id shape) "-" index)
+ :x (if rtl? (+ (:x data) (:width data)) (:x data))
+ :y (- (:y data)
+ (:height data)
+ (if (and auto-clearance? has-ruby-layer?)
+ ruby-font-size-num
+ 0))
+ :dominantBaseline "text-after-edge"
+ :textLength (:width data)
+ :lengthAdjust "spacing"
+ :style emphasis-style})
+ (emphasis-marks-text (:text data) emphasis-mark)])]))]]))
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs
index 3eca4f72fd..5cc354bc50 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs
@@ -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"))}
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
index b5a16d2f5d..09023f72db 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
@@ -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)
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
index 55310b0b87..7c4b041e35 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
@@ -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"}]]]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
index 60b0c15c02..950464fe23 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs
@@ -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])])])])])]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss
index adc7da205a..7c270eb8ef 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss
@@ -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);
+}
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
index 9b3005ecea..2fc618b85c 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs
@@ -27,8 +27,30 @@
[app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]]
[app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]]
[app.main.ui.workspace.sidebar.options.menus.text :refer [text-menu*]]
+ [app.render-wasm.api :as wasm.api]
[rumext.v2 :as mf]))
+(defn- dom-text-range-selected?
+ [editor]
+ (when editor
+ (let [selection (.getSelection js/window)
+ element (.-element editor)
+ anchor (some-> selection .-anchorNode)
+ focus (some-> selection .-focusNode)]
+ (and selection
+ (not (.-isCollapsed selection))
+ element
+ anchor
+ focus
+ (.contains element anchor)
+ (.contains element focus)))))
+
+(defn- draft-text-range-selected?
+ [editor-state]
+ (when editor-state
+ (let [selection (.getSelection editor-state)]
+ (and selection (not (.isCollapsed selection))))))
+
(mf/defc options*
[{:keys [shape libraries file-id page-id]}]
(let [id (dm/get-prop shape :id)
@@ -117,6 +139,19 @@
(when text-editor-v2?
editor)
+ text-selection-active
+ (boolean
+ (cond
+ text-editor-wasm?
+ (and (= id (wasm.api/text-editor-get-active-shape-id))
+ (wasm.api/text-editor-has-selection?))
+
+ text-editor-v2?
+ (dom-text-range-selected? editor-instance)
+
+ :else
+ (draft-text-range-selected? editor-state)))
+
fill-values
(dwt/current-text-values
{:editor-styles editor-styles
@@ -143,7 +178,14 @@
:editor-state editor-state
:editor-instance editor-instance
:shape shape
- :attrs txt/text-node-attrs}))]
+ :attrs txt/text-node-attrs}))
+
+ ruby-values
+ (if text-selection-active
+ (select-keys text-values dwt/ruby-presentation-attrs)
+ (dwt/current-ruby-values
+ {:shape shape
+ :attrs dwt/ruby-presentation-attrs}))]
[:*
[:> layer-menu* {:ids ids
@@ -191,6 +233,8 @@
:type type
:applied-tokens applied-tokens
:values text-values
+ :ruby-values ruby-values
+ :text-selection-active text-selection-active
:libraries libraries
:file-id file-id
:typographies typographies}]
@@ -226,4 +270,3 @@
:values (select-keys shape exports-attrs)
:page-id page-id
:file-id file-id}]]))
-
diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
index 9e497ef8d0..76b9237413 100644
--- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
@@ -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))))))
diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs
index 3692ae1a59..17bc779203 100644
--- a/frontend/src/app/plugins/text.cljs
+++ b/frontend/src/app/plugins/text.cljs
@@ -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)})))
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index e4be664616..215e852f05 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -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)
-
-
diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs
index afe26e2726..5780e26640 100644
--- a/frontend/src/app/render_wasm/api/texts.cljs
+++ b/frontend/src/app/render_wasm/api/texts.cljs
@@ -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:
- ;; [ ]
+ ;; [ ]
+ ;; 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)))
diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs
index 3cc62c3c0c..b828c04642 100644
--- a/frontend/src/app/render_wasm/serializers.cljs
+++ b/frontend/src/app/render_wasm/serializers.cljs
@@ -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]
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index d630346159..caf3db6cf2 100644
--- a/frontend/src/app/render_wasm/text_editor.cljs
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -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?
diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs
index 77d8726347..7ebe8f3097 100644
--- a/frontend/src/app/render_wasm/wasm.cljs
+++ b/frontend/src/app/render_wasm/wasm.cljs
@@ -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})
-
diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs
index 0f8d4471f2..021fe11de2 100644
--- a/frontend/src/app/util/debug.cljs
+++ b/frontend/src/app/util/debug.cljs
@@ -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
diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs
index 20a2454e1f..a832c009cd 100644
--- a/frontend/src/app/util/text/content/styles.cljs
+++ b/frontend/src/app/util/text/content/styles.cljs
@@ -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))
diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs
index cd7ab9d5aa..a5091051ca 100644
--- a/frontend/src/app/util/text/content/to_dom.cljs
+++ b/frontend/src/app/util/text/content/to_dom.cljs
@@ -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]
diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs
index 4f04dab929..73cea41e66 100644
--- a/frontend/test/frontend_tests/code_gen_style_test.cljs
+++ b/frontend/test/frontend_tests/code_gen_style_test.cljs
@@ -10,14 +10,17 @@
Each test guards against a concrete bug found in the CSS/HTML generation
of layout children and text shapes."
(:require
+ ["react-dom/server" :as rds]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.uuid :as uuid]
+ [app.main.ui.shapes.text.fo-text :as fo-text]
[app.util.code-gen.markup-html :as html]
[app.util.code-gen.style-css :as css]
[cljs.test :refer [deftest is testing] :include-macros true]
- [cuerdas.core :as str]))
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
;; --- Builders ------------------------------------------------------------
@@ -180,6 +183,29 @@
:children [{:text "Hello"
:fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]})
+(def ^:private ruby-text-content
+ {:type "root"
+ :children [{:type "paragraph-set"
+ :children [{:type "paragraph"
+ :writing-mode "vertical-rl"
+ :text-orientation "upright"
+ :children [{:text "漢字"
+ :ruby "かんじ"
+ :font-size "20"
+ :fill-color "#000000"
+ :fill-opacity 1}]}]}]})
+
+(defn- text-shape
+ [content]
+ (let [tid (uuid/next)]
+ {:id tid :name "Text" :type :text
+ :parent-id uuid/zero :frame-id uuid/zero
+ :x 0 :y 0 :width 100 :height 40
+ :selrect (grc/make-rect 0 0 100 40)
+ :points (pts 0 0 100 40)
+ :grow-type :fixed
+ :content content}))
+
(deftest text-markup-emits-node-id-classes
(testing "generated text markup carries the $id classes the CSS rules target"
(let [tid (uuid/next)
@@ -194,3 +220,90 @@
(is (str/includes? markup "root-0")
"the text nodes must expose their $id as a class for the CSS to apply")
(is (str/includes? markup "root-0-paragraph-set-0-paragraph-0")))))
+
+(def ^:private warichu-text-content
+ {:type "root"
+ :children [{:type "paragraph-set"
+ :children [{:type "paragraph"
+ :writing-mode "vertical-rl"
+ :text-orientation "upright"
+ :children [{:text "割注入り"
+ :warichu "warichu"
+ :font-size "20"
+ :fill-color "#000000"
+ :fill-opacity 1}]}]}]})
+
+(def ^:private palt-text-content
+ {:type "root"
+ :children [{:type "paragraph-set"
+ :children [{:type "paragraph"
+ :children [{:text "かな"
+ :font-features "palt"
+ :font-size "20"
+ :fill-color "#000000"
+ :fill-opacity 1}]}]}]})
+
+(deftest foreign-object-text-emits-warichu-styles
+ (testing "browser/foreignObject render folds a warichu span into two half-size lines"
+ (let [text (text-shape warichu-text-content)
+ markup (rds/renderToStaticMarkup
+ (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
+ (is (str/includes? markup "割注入り"))
+ (is (str/includes? markup "display:inline-block"))
+ (is (str/includes? markup "font-size:10px"))
+ (is (str/includes? markup "inline-size:2em")))))
+
+(deftest foreign-object-warichu-sizes-by-unicode-code-points
+ (testing "non-BMP characters count as one slot when sizing warichu lines"
+ (let [content (assoc-in warichu-text-content
+ [:children 0 :children 0 :children 0 :text]
+ "割𠀀注😀")
+ text (text-shape content)
+ markup (rds/renderToStaticMarkup
+ (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
+ (is (str/includes? markup "割𠀀注😀"))
+ (is (str/includes? markup "inline-size:2em")))))
+
+(def ^:private tcy-digits2-text-content
+ {:type "root"
+ :children [{:type "paragraph-set"
+ :children [{:type "paragraph"
+ :writing-mode "vertical-rl"
+ :children [{:text "平成31年"
+ :text-combine-upright "digits2"
+ :font-size "20"
+ :fill-color "#000000"
+ :fill-opacity 1}]}]}]})
+
+(deftest foreign-object-text-emits-counted-digits-css
+ (testing "the counted digits variants serialize as CSS `digits `"
+ (let [text (text-shape tcy-digits2-text-content)
+ markup (rds/renderToStaticMarkup
+ (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
+ (is (str/includes? markup "text-combine-upright:digits 2")))))
+
+(deftest foreign-object-text-emits-font-feature-settings
+ (testing "browser/foreignObject render emits palt/vpal as OpenType features"
+ (let [text (text-shape palt-text-content)
+ markup (rds/renderToStaticMarkup
+ (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]
+ (is (str/includes? markup "font-feature-settings:"palt"")))))
+
+(deftest text-markup-emits-ruby-annotations
+ (testing "generated text markup keeps ruby annotations beside the base text"
+ (let [text (text-shape ruby-text-content)
+ markup (html/generate-markup (objects text) [text])]
+ (is (str/includes? markup " (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 40
+ :height 100})
+ (assoc :name "Vertical text"
+ :position-data
+ [{:x 20
+ :y 100
+ :width 24
+ :height 80
+ :fills [{:fill-color "#112233"
+ :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "vertical-rl"
+ :text-orientation "upright"
+ :font-features "vpal"
+ :text "うA"}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :vertical-text
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :vertical-text))]}))
+
+(defn- setup-vertical-ruby-text
+ []
+ (let [shape (-> (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 60
+ :height 100})
+ (assoc :name "Vertical ruby text"
+ :position-data
+ [{:x 20
+ :y 100
+ :width 24
+ :height 80
+ :fills [{:fill-color "#112233"
+ :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "vertical-rl"
+ :text-orientation "upright"
+ :text "漢字"
+ :ruby "かんじ"}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :vertical-ruby-text
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :vertical-ruby-text))]}))
+
+(defn- setup-vertical-emphasis-text
+ []
+ (let [shape (-> (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 60
+ :height 100})
+ (assoc :name "Vertical emphasis text"
+ :position-data
+ [{:x 20
+ :y 100
+ :width 24
+ :height 80
+ :fills [{:fill-color "#112233"
+ :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "vertical-rl"
+ :text-orientation "upright"
+ :text "強調 あ"
+ :text-emphasis "filled-dot"}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :vertical-emphasis-text
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :vertical-emphasis-text))]}))
+
+(defn- setup-horizontal-emphasis-text
+ []
+ (let [shape (-> (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 120
+ :height 40})
+ (assoc :name "Horizontal emphasis text"
+ :position-data
+ [{:x 20
+ :y 60
+ :width 100
+ :height 20
+ :fills [{:fill-color "#112233"
+ :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "horizontal-tb"
+ :text "強調、 あ"
+ :text-emphasis "filled-dot"}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :horizontal-emphasis-text
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :horizontal-emphasis-text))]}))
+
+(defn- setup-horizontal-stacked-annotations
+ []
+ (let [shape (-> (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 120
+ :height 40})
+ (assoc :name "Horizontal stacked annotations"
+ :position-data
+ [{:x 20
+ :y 60
+ :width 100
+ :height 20
+ :fills [{:fill-color "#112233" :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "horizontal-tb"
+ :text "漢字"
+ :ruby "かんじ"
+ :text-emphasis "filled-dot"
+ :annotation-clearance "auto"
+ :annotation-has-ruby true}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :horizontal-stacked-annotations
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :horizontal-stacked-annotations))]}))
+
+(defn- setup-vertical-warichu-text
+ ([] (setup-vertical-warichu-text "割注入り"))
+ ([text]
+ (let [shape (-> (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 60
+ :height 100})
+ (assoc :name "Vertical warichu text"
+ :position-data
+ [{:x 20
+ :y 100
+ :width 24
+ :height 40
+ :fills [{:fill-color "#112233"
+ :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "vertical-rl"
+ :text-orientation "upright"
+ :text text
+ :warichu "warichu"}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :vertical-warichu-text
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :vertical-warichu-text))]})))
+
+(defn- setup-horizontal-warichu-text
+ ([] (setup-horizontal-warichu-text "割注入り"))
+ ([text]
+ (let [shape (-> (cts/setup-shape {:type :text
+ :x 10
+ :y 20
+ :width 120
+ :height 40})
+ (assoc :name "Horizontal warichu text"
+ :position-data
+ [{:x 20
+ :y 60
+ :width 40
+ :height 20
+ :fills [{:fill-color "#112233"
+ :fill-opacity 1}]
+ :font-family "Noto Sans CJK JP"
+ :font-size "20"
+ :font-weight "400"
+ :writing-mode "horizontal-tb"
+ :text text
+ :warichu "warichu"}]))
+ file (cths/add-sample-shape
+ (cthf/sample-file :file1 :page-label :page1)
+ :horizontal-warichu-text
+ shape)
+ page (cthf/current-page file)
+ objects (:objects page)]
+ {:objects objects
+ :shapes [(get objects (cthi/id :horizontal-warichu-text))]})))
+
(deftest empty-selection-yields-empty-string
(is (= "" (svg/generate-markup {} []))))
@@ -59,3 +278,97 @@
"multi-select must NOT emit multiple " markup))
"multi-select must NOT emit multiple closing tags"))))
+
+(deftest vertical-text-svg-preserves-browser-layout-properties
+ (testing "Static SVG carries the vertical writing properties used by browser exports"
+ (let [{:keys [objects shapes]} (setup-vertical-text)
+ markup (svg/generate-markup objects shapes)]
+ (is (re-find #"writing-mode:vertical-rl" markup))
+ (is (re-find #"text-orientation:upright" markup))
+ (is (re-find #"text-autospace:normal" markup))
+ (is (re-find #"font-feature-settings:"vpal"" markup))
+ (is (not (re-find #"shape (constantly {:content content})]
+ (t/is (= "digits2" (.-textCombineUpright range)))
+ (t/is (= "filled-dot" (.-textEmphasis range)))
+ (t/is (= "warichu" (.-warichu range)))
+ (t/is (= "vpal" (.-fontFeatures range)))
+ (t/is (= "auto" (.-annotationClearance range)))
+ (t/is (= "かんじ" (.-ruby range)))
+ (t/is (= "third" (.-rubySize range)))
+ (t/is (= "center" (.-rubyAlign range)))
+ (t/is (= "none" (.-rubyOverhang range)))
+ (t/is (= "under" (.-rubySide range))))))
+
+(t/deftest text-range-japanese-properties-report-mixed-values
+ (let [file-id (random-uuid)
+ page-id (random-uuid)
+ shape-id (random-uuid)
+ content {:type "root"
+ :children [{:type "paragraph-set"
+ :children [{:type "paragraph"
+ :children [{:text "日"
+ :text-emphasis "filled-dot"
+ :ruby "にち"
+ :ruby-size "third"}
+ {:text "本"
+ :text-emphasis "none"
+ :ruby nil
+ :ruby-size "half"}]}]}]}
+ range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)]
+ (with-redefs [u/proxy->shape (constantly {:content content})]
+ (t/is (= "mixed" (.-textEmphasis range)))
+ (t/is (= "mixed" (.-ruby range)))
+ (t/is (= "mixed" (.-rubySize range))))))
+
+(t/deftest text-range-japanese-properties-update-the-selected-range
+ (let [file-id (random-uuid)
+ page-id (random-uuid)
+ shape-id (random-uuid)
+ range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4)
+ captured (atom [])]
+ (with-redefs [r/check-permission (constantly true)
+ u/page-active? (constantly true)
+ dwt/update-text-range
+ (fn [id start end attrs]
+ (swap! captured conj {:id id
+ :start start
+ :end end
+ :attrs attrs})
+ :update-text-range)
+ st/emit! mock/noop]
+ (set! (.-textCombineUpright range) "digits2")
+ (set! (.-textEmphasis range) "filled-dot")
+ (set! (.-warichu range) "warichu")
+ (set! (.-fontFeatures range) "vpal")
+ (set! (.-annotationClearance range) "auto")
+ (set! (.-ruby range) "かんじ")
+ (set! (.-rubySize range) "third")
+ (set! (.-rubyAlign range) "center")
+ (set! (.-rubyOverhang range) "none")
+ (set! (.-rubySide range) "under")
+ (t/is (= [{:id shape-id :start 1 :end 4 :attrs {:text-combine-upright "digits2"}}
+ {:id shape-id :start 1 :end 4 :attrs {:text-emphasis "filled-dot"}}
+ {:id shape-id :start 1 :end 4 :attrs {:warichu "warichu"}}
+ {:id shape-id :start 1 :end 4 :attrs {:font-features "vpal"}}
+ {:id shape-id :start 1 :end 4 :attrs {:annotation-clearance "auto"}}
+ {:id shape-id :start 1 :end 4 :attrs {:ruby "かんじ"}}
+ {:id shape-id :start 1 :end 4 :attrs {:ruby-size "third"}}
+ {:id shape-id :start 1 :end 4 :attrs {:ruby-align "center"}}
+ {:id shape-id :start 1 :end 4 :attrs {:ruby-overhang "none"}}
+ {:id shape-id :start 1 :end 4 :attrs {:ruby-side "under"}}]
+ @captured)))))
+
(t/deftest font-apply-to-text-uses-font-id-not-shape-id
(let [file-id (random-uuid)
diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs
new file mode 100644
index 0000000000..33a43c5ae3
--- /dev/null
+++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs
@@ -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"))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index 2adae3e61e..a69145812e 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -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
diff --git a/frontend/test/frontend_tests/ui/css_cursors_test.cljs b/frontend/test/frontend_tests/ui/css_cursors_test.cljs
new file mode 100644
index 0000000000..5478bca52c
--- /dev/null
+++ b/frontend/test/frontend_tests/ui/css_cursors_test.cljs
@@ -0,0 +1,9 @@
+(ns frontend-tests.ui.css-cursors-test
+ (:require
+ [app.main.ui.css-cursors :as cursors]
+ [cljs.test :as t]))
+
+(t/deftest text-cursor-follows-writing-direction-and-shape-rotation
+ (t/is (= "cursor-text-0" (cursors/get-text 0 false)))
+ (t/is (= "cursor-text-90" (cursors/get-text 0 true)))
+ (t/is (= "cursor-text-10" (cursors/get-text 280 true))))
diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs
new file mode 100644
index 0000000000..7fd1451b18
--- /dev/null
+++ b/frontend/test/frontend_tests/ui/text_options_test.cljs
@@ -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"))))
diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js
index e2c320cf43..cb4aa89ad9 100644
--- a/frontend/text-editor/src/editor/TextEditor.js
+++ b/frontend/text-editor/src/editor/TextEditor.js
@@ -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.} 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.} 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;
diff --git a/frontend/text-editor/src/editor/TextEditor.test.js b/frontend/text-editor/src/editor/TextEditor.test.js
index cb78558a16..909a07197b 100644
--- a/frontend/text-editor/src/editor/TextEditor.test.js
+++ b/frontend/text-editor/src/editor/TextEditor.test.js
@@ -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);
diff --git a/frontend/text-editor/src/editor/content/dom/Paragraph.js b/frontend/text-editor/src/editor/content/dom/Paragraph.js
index 465aaea95b..a85024e2a0 100644
--- a/frontend/text-editor/src/editor/content/dom/Paragraph.js
+++ b/frontend/text-editor/src/editor/content/dom/Paragraph.js
@@ -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",
+ );
}
}
diff --git a/frontend/text-editor/src/editor/content/dom/Root.js b/frontend/text-editor/src/editor/content/dom/Root.js
index f2dc401c90..8e51e67646 100644
--- a/frontend/text-editor/src/editor/content/dom/Root.js
+++ b/frontend/text-editor/src/editor/content/dom/Root.js
@@ -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.
diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js
index bfadad1a6e..6352545a9a 100644
--- a/frontend/text-editor/src/editor/content/dom/Style.js
+++ b/frontend/text-editor/src/editor/content/dom/Style.js
@@ -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;
}
diff --git a/frontend/text-editor/src/editor/content/dom/TextSpan.js b/frontend/text-editor/src/editor/content/dom/TextSpan.js
index 1c3a25cd25..d95958552c 100644
--- a/frontend/text-editor/src/editor/content/dom/TextSpan.js
+++ b/frontend/text-editor/src/editor/content/dom/TextSpan.js
@@ -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"],
];
diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js
index 371d94e99f..91dcb64bd4 100644
--- a/frontend/text-editor/src/editor/controllers/SelectionController.js
+++ b/frontend/text-editor/src/editor/controllers/SelectionController.js
@@ -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);
+ }
+ }
+ }
}
/**
diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js
index 533e4c751c..c5ca81d940 100644
--- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js
+++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js
@@ -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);
diff --git a/frontend/text-editor/src/test/TextEditorMock.js b/frontend/text-editor/src/test/TextEditorMock.js
index 0e20d209e7..d2a42ff030 100644
--- a/frontend/text-editor/src/test/TextEditorMock.js
+++ b/frontend/text-editor/src/test/TextEditorMock.js
@@ -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);
}
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index ea0d60d7ec..d9500c9e74 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -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)"
diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po
index 7e1853e51e..9a312ab4bf 100644
--- a/frontend/translations/jpn_JP.po
+++ b/frontend/translations/jpn_JP.po
@@ -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 "縦書き(右から左)"
diff --git a/library/test/builder.test.js b/library/test/builder.test.js
index e4c7bf8a37..5dce3340d7 100644
--- a/library/test/builder.test.js
+++ b/library/test/builder.test.js
@@ -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();
diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md
index 323e8319ab..5e6498f3ae 100644
--- a/plugins/CHANGELOG.md
+++ b/plugins/CHANGELOG.md
@@ -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
diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts
index cb53532dd6..a22492697a 100644
--- a/plugins/libs/plugin-types/index.d.ts
+++ b/plugins/libs/plugin-types/index.d.ts
@@ -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.
*/
diff --git a/render-wasm/fixtures/japanese-typography.json b/render-wasm/fixtures/japanese-typography.json
new file mode 100644
index 0000000000..6dc8798729
--- /dev/null
+++ b/render-wasm/fixtures/japanese-typography.json
@@ -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"
+ ]
+ }
+ ]
+}
diff --git a/render-wasm/src/fonts/notosansjp-vmtx-test.ttf b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf
new file mode 100644
index 0000000000..5b36929f63
Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf differ
diff --git a/render-wasm/src/fonts/notosansjp-vpal-test.ttf b/render-wasm/src/fonts/notosansjp-vpal-test.ttf
new file mode 100644
index 0000000000..e1fd58e08f
Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vpal-test.ttf differ
diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs
index 64e7d8c94f..1984b00d32 100644
--- a/render-wasm/src/globals.rs
+++ b/render-wasm/src/globals.rs
@@ -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));
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index 191f4cf234..7ce883d9bb 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -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 = 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),
diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs
index 7a6a3a3d39..05543d5177 100644
--- a/render-wasm/src/render/options.rs
+++ b/render-wasm/src/render/options.rs
@@ -3,6 +3,7 @@ const DEBUG_VISIBLE: u32 = 0x01;
const PROFILE_REBUILD_TILES: u32 = 0x02;
const TEXT_EDITOR_V3: u32 = 0x04;
const SHOW_WASM_INFO: u32 = 0x08;
+const TEXT_GRID_VISIBLE: u32 = 0x10;
// Render performance options
// This is the extra area used for tile rendering (tiles beyond viewport).
@@ -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;
diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs
index d972e69813..8e73177467 100644
--- a/render-wasm/src/render/text.rs
+++ b/render-wasm/src/render/text.rs
@@ -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 =
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,
diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs
index 361b6db165..194e465fad 100644
--- a/render-wasm/src/render/text_editor.rs
+++ b/render-wasm/src/render/text_editor.rs
@@ -1,4 +1,5 @@
use crate::render::options::RenderOptions;
+use crate::shapes::text_vertical;
use crate::shapes::{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;
diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs
index 04d91552d9..8913e4765e 100644
--- a/render-wasm/src/render/vector.rs
+++ b/render-wasm/src/render/vector.rs
@@ -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();
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 5dfbe9e384..166204272a 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -15,7 +15,10 @@ mod corners;
mod fills;
mod fonts;
mod frames;
+mod gpos_vpal;
mod groups;
+pub mod japanese;
+pub mod kinsoku;
mod layouts;
pub mod modifiers;
mod paths;
@@ -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 {
diff --git a/render-wasm/src/shapes/gpos_vpal.rs b/render-wasm/src/shapes/gpos_vpal.rs
new file mode 100644
index 0000000000..28670ad911
--- /dev/null
+++ b/render-wasm/src/shapes/gpos_vpal.rs
@@ -0,0 +1,287 @@
+//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate
+//! metrics) feature. Vertical layout applies these deltas itself: cells
+//! flow by `vmtx` advances, so HarfBuzz never gets a chance to apply
+//! vertical GPOS positioning (SkShaper shapes on a horizontal line).
+//!
+//! Only single-adjustment lookups are read (SinglePos, directly or behind
+//! an Extension lookup) — `vpal` is metrics-only by design and real fonts
+//! (Noto CJK, Source Han) encode it exactly this way.
+
+use std::collections::HashMap;
+
+/// Font-unit deltas for one glyph. `y_placement` is y-up (positive lifts
+/// the ink toward the flow start); `y_advance` is negative when the
+/// vertical advance shrinks.
+#[derive(Debug, Clone, Copy, Default, PartialEq)]
+pub struct VpalDelta {
+ pub y_placement: i16,
+ pub y_advance: i16,
+}
+
+fn read_u16(data: &[u8], offset: usize) -> Option {
+ Some(u16::from_be_bytes([
+ *data.get(offset)?,
+ *data.get(offset + 1)?,
+ ]))
+}
+
+fn read_i16(data: &[u8], offset: usize) -> Option {
+ read_u16(data, offset).map(|v| v as i16)
+}
+
+fn read_u32(data: &[u8], offset: usize) -> Option {
+ Some(u32::from_be_bytes([
+ *data.get(offset)?,
+ *data.get(offset + 1)?,
+ *data.get(offset + 2)?,
+ *data.get(offset + 3)?,
+ ]))
+}
+
+/// Glyph ids covered by a Coverage table, in coverage-index order.
+fn parse_coverage(data: &[u8], offset: usize) -> Option> {
+ match read_u16(data, offset)? {
+ 1 => {
+ let count = read_u16(data, offset + 2)? as usize;
+ (0..count)
+ .map(|i| read_u16(data, offset + 4 + i * 2))
+ .collect()
+ }
+ 2 => {
+ let range_count = read_u16(data, offset + 2)? as usize;
+ let mut glyphs = Vec::new();
+ for i in 0..range_count {
+ let o = offset + 4 + i * 6;
+ let start = read_u16(data, o)?;
+ let end = read_u16(data, o + 2)?;
+ if end < start {
+ return None;
+ }
+ glyphs.extend(start..=end);
+ }
+ Some(glyphs)
+ }
+ _ => None,
+ }
+}
+
+/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit
+/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device
+/// offsets). Returns the y deltas and consumes nothing else.
+fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option {
+ let mut delta = VpalDelta::default();
+ let mut o = offset;
+ for bit in 0..8 {
+ if value_format & (1 << bit) == 0 {
+ continue;
+ }
+ match bit {
+ 1 => delta.y_placement = read_i16(data, o)?,
+ 3 => delta.y_advance = read_i16(data, o)?,
+ _ => {}
+ }
+ o += 2;
+ }
+ Some(delta)
+}
+
+fn value_record_size(value_format: u16) -> usize {
+ (value_format & 0x00FF).count_ones() as usize * 2
+}
+
+/// Accumulate a SinglePos subtable into `deltas`.
+fn parse_single_pos(
+ data: &[u8],
+ offset: usize,
+ deltas: &mut HashMap,
+) -> Option<()> {
+ let format = read_u16(data, offset)?;
+ let coverage = parse_coverage(data, offset + read_u16(data, offset + 2)? as usize)?;
+ let value_format = read_u16(data, offset + 4)?;
+ match format {
+ 1 => {
+ let value = parse_value_record(data, offset + 6, value_format)?;
+ for glyph in coverage {
+ let entry = deltas.entry(glyph).or_default();
+ entry.y_placement += value.y_placement;
+ entry.y_advance += value.y_advance;
+ }
+ }
+ 2 => {
+ let count = read_u16(data, offset + 6)? as usize;
+ let size = value_record_size(value_format);
+ for (i, glyph) in coverage.into_iter().take(count).enumerate() {
+ let value = parse_value_record(data, offset + 8 + i * size, value_format)?;
+ let entry = deltas.entry(glyph).or_default();
+ entry.y_placement += value.y_placement;
+ entry.y_advance += value.y_advance;
+ }
+ }
+ _ => {}
+ }
+ Some(())
+}
+
+/// Parse the `vpal` deltas out of a raw `GPOS` table. Returns `None` when
+/// the table is malformed or carries no `vpal` feature.
+pub fn parse_vpal(gpos: &[u8]) -> Option> {
+ let feature_list = read_u16(gpos, 6)? as usize;
+ let lookup_list = read_u16(gpos, 8)? as usize;
+
+ // Collect the lookup indices of every feature tagged `vpal`,
+ // regardless of script: the deltas are per-glyph metrics.
+ let feature_count = read_u16(gpos, feature_list)? as usize;
+ let mut lookup_indices = Vec::new();
+ for i in 0..feature_count {
+ let record = feature_list + 2 + i * 6;
+ if gpos.get(record..record.checked_add(4)?)? != b"vpal" {
+ continue;
+ }
+ let feature = feature_list + read_u16(gpos, record + 4)? as usize;
+ let index_count = read_u16(gpos, feature + 2)? as usize;
+ for j in 0..index_count {
+ lookup_indices.push(read_u16(gpos, feature + 4 + j * 2)? as usize);
+ }
+ }
+ lookup_indices.sort_unstable();
+ lookup_indices.dedup();
+ if lookup_indices.is_empty() {
+ return None;
+ }
+
+ let lookup_count = read_u16(gpos, lookup_list)? as usize;
+ let mut deltas = HashMap::new();
+ for index in lookup_indices {
+ if index >= lookup_count {
+ continue;
+ }
+ let lookup = lookup_list + read_u16(gpos, lookup_list + 2 + index * 2)? as usize;
+ let lookup_type = read_u16(gpos, lookup)?;
+ let subtable_count = read_u16(gpos, lookup + 4)? as usize;
+ for s in 0..subtable_count {
+ let subtable = lookup + read_u16(gpos, lookup + 6 + s * 2)? as usize;
+ match lookup_type {
+ 1 => {
+ parse_single_pos(gpos, subtable, &mut deltas)?;
+ }
+ 9 => {
+ // ExtensionPos: { format: u16, extensionLookupType: u16,
+ // extensionOffset: u32 (from the subtable start) }.
+ if read_u16(gpos, subtable + 2)? == 1 {
+ let inner = subtable + read_u32(gpos, subtable + 4)? as usize;
+ parse_single_pos(gpos, inner, &mut deltas)?;
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ if deltas.is_empty() {
+ None
+ } else {
+ Some(deltas)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Subset of Noto Sans JP: 〱あく「」、。 with `vert` alternates and the
+ // real `vpal` deltas (Extension→SinglePos, both coverage and both
+ // SinglePos formats, value formats 0x8 and 0xA).
+ const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf");
+
+ fn gpos_table() -> Vec {
+ // Locate the GPOS table in the raw sfnt directory.
+ let data = VPAL_TEST_FONT;
+ let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
+ for i in 0..num_tables {
+ let record = 12 + i * 16;
+ if &data[record..record + 4] == b"GPOS" {
+ let offset = u32::from_be_bytes([
+ data[record + 8],
+ data[record + 9],
+ data[record + 10],
+ data[record + 11],
+ ]) as usize;
+ let length = u32::from_be_bytes([
+ data[record + 12],
+ data[record + 13],
+ data[record + 14],
+ data[record + 15],
+ ]) as usize;
+ return data[offset..offset + length].to_vec();
+ }
+ }
+ panic!("test font has no GPOS table");
+ }
+
+ #[test]
+ fn parses_noto_vpal_deltas() {
+ let deltas = parse_vpal(&gpos_table()).expect("vpal deltas");
+ // Vertical alternates: 、→8 。→9 「→10 」→11 あ→12 く→13.
+ assert_eq!(
+ deltas.get(&8),
+ Some(&VpalDelta {
+ y_placement: 0,
+ y_advance: -500
+ }),
+ "、 halves its vertical advance"
+ );
+ assert_eq!(
+ deltas.get(&10),
+ Some(&VpalDelta {
+ y_placement: 481,
+ y_advance: -500
+ }),
+ "「 lifts its trailing-half ink into the compressed cell"
+ );
+ assert_eq!(
+ deltas.get(&12),
+ Some(&VpalDelta {
+ y_placement: 39,
+ y_advance: -58
+ }),
+ "あ tightens slightly"
+ );
+ // Base (horizontal) glyphs carry no vpal.
+ assert!(!deltas.contains_key(&6), "base あ glyph is uncovered");
+ }
+
+ #[test]
+ fn missing_vpal_returns_none() {
+ // A GPOS header with an empty feature list.
+ let gpos = [
+ 0x00, 0x01, 0x00, 0x00, // version 1.0
+ 0x00, 0x0A, // scriptList
+ 0x00, 0x0C, // featureList
+ 0x00, 0x0E, // lookupList
+ 0x00, 0x00, // scriptCount
+ 0x00, 0x00, // featureCount
+ 0x00, 0x00, // lookupCount
+ ];
+ assert!(parse_vpal(&gpos).is_none());
+ }
+
+ #[test]
+ fn truncated_table_is_rejected() {
+ let table = gpos_table();
+ for len in [0usize, 4, 9, 16] {
+ assert!(parse_vpal(&table[..len.min(table.len())]).is_none());
+ }
+ }
+
+ #[test]
+ fn truncated_feature_records_are_rejected() {
+ let gpos = [
+ 0x00, 0x01, 0x00, 0x00, // version 1.0
+ 0x00, 0x0A, // scriptList
+ 0x00, 0x0A, // featureList
+ 0x00, 0x0A, // lookupList
+ 0x00, 0x02, // featureCount without matching records
+ ];
+
+ assert!(parse_vpal(&gpos).is_none());
+ }
+}
diff --git a/render-wasm/src/shapes/japanese.rs b/render-wasm/src/shapes/japanese.rs
new file mode 100644
index 0000000000..7038400083
--- /dev/null
+++ b/render-wasm/src/shapes/japanese.rs
@@ -0,0 +1,540 @@
+//! Shared JLREQ character classes and pair-rule tables.
+//!
+//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are
+//! contextual/virtual classes produced by higher-level inline composites;
+//! [`classify`] handles scalar characters and callers assign those virtual
+//! classes when constructing reference marks, ruby, grouped numerals,
+//! warichu, or tate-chu-yoko.
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[repr(u8)]
+pub enum JapaneseClass {
+ OpeningBracket = 0,
+ ClosingBracket,
+ Hyphen,
+ DividingPunctuation,
+ MiddleDot,
+ FullStop,
+ Comma,
+ Inseparable,
+ IterationMark,
+ ProlongedSoundMark,
+ SmallKana,
+ PrefixedAbbreviation,
+ PostfixedAbbreviation,
+ IdeographicSpace,
+ Hiragana,
+ Katakana,
+ MathSymbol,
+ MathOperator,
+ Ideographic,
+ ReferenceMark,
+ OrnamentedComplex,
+ SimpleRuby,
+ JukugoRuby,
+ GroupedNumeral,
+ UnitSymbol,
+ WesternWordSpace,
+ Western,
+ WarichuOpening,
+ WarichuClosing,
+ TateChuYoko,
+}
+
+impl JapaneseClass {
+ pub const COUNT: usize = 30;
+
+ pub const ALL: [Self; Self::COUNT] = [
+ Self::OpeningBracket,
+ Self::ClosingBracket,
+ Self::Hyphen,
+ Self::DividingPunctuation,
+ Self::MiddleDot,
+ Self::FullStop,
+ Self::Comma,
+ Self::Inseparable,
+ Self::IterationMark,
+ Self::ProlongedSoundMark,
+ Self::SmallKana,
+ Self::PrefixedAbbreviation,
+ Self::PostfixedAbbreviation,
+ Self::IdeographicSpace,
+ Self::Hiragana,
+ Self::Katakana,
+ Self::MathSymbol,
+ Self::MathOperator,
+ Self::Ideographic,
+ Self::ReferenceMark,
+ Self::OrnamentedComplex,
+ Self::SimpleRuby,
+ Self::JukugoRuby,
+ Self::GroupedNumeral,
+ Self::UnitSymbol,
+ Self::WesternWordSpace,
+ Self::Western,
+ Self::WarichuOpening,
+ Self::WarichuClosing,
+ Self::TateChuYoko,
+ ];
+
+ pub const fn index(self) -> usize {
+ self as usize
+ }
+
+ pub const fn forbids_line_start(self) -> bool {
+ matches!(
+ self,
+ Self::ClosingBracket
+ | Self::Hyphen
+ | Self::DividingPunctuation
+ | Self::MiddleDot
+ | Self::FullStop
+ | Self::Comma
+ | Self::Inseparable
+ | Self::IterationMark
+ | Self::ProlongedSoundMark
+ | Self::SmallKana
+ | Self::PostfixedAbbreviation
+ | Self::WarichuClosing
+ )
+ }
+
+ pub const fn forbids_line_end(self) -> bool {
+ matches!(
+ self,
+ Self::OpeningBracket | Self::PrefixedAbbreviation | Self::WarichuOpening
+ )
+ }
+
+ pub const fn is_japanese_letter(self) -> bool {
+ matches!(self, Self::Hiragana | Self::Katakana | Self::Ideographic)
+ }
+
+ pub const fn is_western_run(self) -> bool {
+ matches!(
+ self,
+ Self::GroupedNumeral | Self::UnitSymbol | Self::Western
+ )
+ }
+
+ pub const fn is_emphasis_prohibited(self) -> bool {
+ matches!(
+ self,
+ Self::OpeningBracket | Self::ClosingBracket | Self::FullStop | Self::Comma
+ )
+ }
+
+ /// Half-width punctuation whose normal character frame is completed by
+ /// half an em after the glyph. Consecutive punctuation may suppress that
+ /// appended spacing, but the glyph body itself remains half-width.
+ pub const fn is_trailing_aki_punctuation(self) -> bool {
+ matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct PairRule {
+ /// Preferred extra spacing between the two character frames, in em.
+ pub preferred_em: f32,
+ /// Smallest spacing allowed during oikomi, in em.
+ pub minimum_em: f32,
+ /// Largest spacing allowed during oidashi/justification, in em.
+ pub maximum_em: f32,
+ pub break_allowed: bool,
+ /// Whether horizontal SkParagraph needs an inserted WORD JOINER for this
+ /// pair. Atomic Western/numeral runs are already protected by its Unicode
+ /// breaker, so they remain non-breakable without synthetic characters.
+ pub suppress_break_with_joiner: bool,
+ /// Lower values are adjusted first; zero means not adjustable.
+ pub shrink_priority: u8,
+ pub expand_priority: u8,
+}
+
+impl PairRule {
+ const SOLID: Self = Self {
+ preferred_em: 0.0,
+ minimum_em: 0.0,
+ maximum_em: 0.0,
+ break_allowed: true,
+ suppress_break_with_joiner: false,
+ shrink_priority: 0,
+ expand_priority: 0,
+ };
+}
+
+const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] {
+ let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT];
+ let mut before_index = 0;
+ while before_index < JapaneseClass::COUNT {
+ let before = JapaneseClass::ALL[before_index];
+ let mut after_index = 0;
+ while after_index < JapaneseClass::COUNT {
+ let after = JapaneseClass::ALL[after_index];
+ let mut rule = PairRule::SOLID;
+
+ rule.suppress_break_with_joiner =
+ before.forbids_line_end() || after.forbids_line_start();
+ rule.break_allowed = !rule.suppress_break_with_joiner;
+ if before_index == after_index
+ && matches!(
+ before,
+ JapaneseClass::Inseparable
+ | JapaneseClass::GroupedNumeral
+ | JapaneseClass::Western
+ )
+ {
+ rule.break_allowed = false;
+ }
+
+ if matches!(before, JapaneseClass::WesternWordSpace) {
+ rule.preferred_em = 1.0 / 3.0;
+ rule.minimum_em = 0.25;
+ rule.maximum_em = 0.5;
+ rule.shrink_priority = 1;
+ rule.expand_priority = 1;
+ } else if (before.is_japanese_letter() && after.is_western_run())
+ || (before.is_western_run() && after.is_japanese_letter())
+ {
+ rule.preferred_em = 0.25;
+ rule.minimum_em = 0.125;
+ rule.maximum_em = 0.5;
+ rule.shrink_priority = 5;
+ rule.expand_priority = 2;
+ } else if matches!(
+ (before, after),
+ (
+ JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop,
+ JapaneseClass::TateChuYoko
+ ) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket)
+ ) {
+ rule.preferred_em = 0.5;
+ rule.minimum_em = 0.0;
+ rule.maximum_em = 0.5;
+ rule.shrink_priority = 3;
+ } else if matches!(
+ (before, after),
+ (JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko)
+ ) {
+ // Two adjacent cl-30 entries are necessarily separate TCY
+ // composites (characters inside one composite are atomic).
+ rule.maximum_em = 0.25;
+ rule.expand_priority = 3;
+ } else if matches!(before, JapaneseClass::DividingPunctuation)
+ && !matches!(after, JapaneseClass::ClosingBracket)
+ {
+ // A sentence-ending question/exclamation mark carries one em
+ // after it. Line planning may discard this at the line edge.
+ rule.preferred_em = 1.0;
+ rule.minimum_em = 0.0;
+ rule.maximum_em = 1.0;
+ rule.shrink_priority = 2;
+ } else if before.is_trailing_aki_punctuation()
+ && matches!(after, JapaneseClass::OpeningBracket)
+ {
+ // Only one half-em is retained between the two half-width
+ // glyph bodies, not the sum of both characters' normal aki.
+ rule.preferred_em = 0.5;
+ rule.minimum_em = 0.0;
+ rule.maximum_em = 0.5;
+ rule.shrink_priority = 4;
+ } else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() {
+ // Consecutive closing punctuation sets solid internally; the
+ // last character in the sequence supplies the trailing aki.
+ } else if matches!(before, JapaneseClass::OpeningBracket)
+ && matches!(after, JapaneseClass::OpeningBracket)
+ {
+ // Consecutive opening brackets set solid internally; the first
+ // character in the sequence supplies the leading aki.
+ } else if (before.is_trailing_aki_punctuation()
+ && matches!(after, JapaneseClass::MiddleDot))
+ || (matches!(before, JapaneseClass::MiddleDot)
+ && matches!(after, JapaneseClass::OpeningBracket))
+ {
+ rule.preferred_em = 0.25;
+ rule.minimum_em = 0.0;
+ rule.maximum_em = 0.25;
+ rule.shrink_priority = 3;
+ } else if before.is_trailing_aki_punctuation() {
+ rule.preferred_em = 0.5;
+ rule.minimum_em = if matches!(before, JapaneseClass::FullStop) {
+ 0.5
+ } else {
+ 0.0
+ };
+ rule.maximum_em = 0.5;
+ rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) {
+ 0
+ } else {
+ 4
+ };
+ } else if matches!(after, JapaneseClass::OpeningBracket) {
+ rule.preferred_em = 0.5;
+ rule.minimum_em = 0.0;
+ rule.maximum_em = 0.5;
+ rule.shrink_priority = 4;
+ } else if matches!(before, JapaneseClass::MiddleDot)
+ || matches!(after, JapaneseClass::MiddleDot)
+ {
+ rule.preferred_em = 0.25;
+ rule.minimum_em = 0.0;
+ rule.maximum_em = 0.25;
+ rule.shrink_priority = 3;
+ } else if before.is_japanese_letter() && after.is_japanese_letter() {
+ // Solid Japanese text is the general third-stage expansion
+ // opportunity. The planner may continue past this quarter-em
+ // cap only in JLREQ's final equal-expansion fallback.
+ rule.maximum_em = 0.25;
+ rule.expand_priority = 3;
+ }
+
+ table[before_index][after_index] = rule;
+ after_index += 1;
+ }
+ before_index += 1;
+ }
+ table
+}
+
+pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] =
+ generated_pair_rules();
+
+pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule {
+ PAIR_RULES[before.index()][after.index()]
+}
+
+const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“";
+const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”";
+const HYPHENS: &str = "‐゠–〜~";
+const DIVIDING_PUNCTUATION: &str = "!?‼⁇⁈⁉";
+const MIDDLE_DOTS: &str = "・・:;";
+const FULL_STOPS: &str = "。.";
+const COMMAS: &str = "、,";
+const INSEPARABLE: &str = "―…‥〳〴〵";
+const ITERATION_MARKS: &str = "々〻ゝゞヽヾ";
+const SMALL_KANA: &str = concat!(
+ "ぁぃぅぇぉっゃゅょゎゕゖ",
+ "ァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ"
+);
+const PREFIXED_ABBREVIATIONS: &str = "¥¥$$££##";
+const POSTFIXED_ABBREVIATIONS: &str = "°′″℃¢¢%%‰‱";
+const MATH_SYMBOLS: &str = "==≠<<>>≦≧≤≥∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔∥";
+const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏";
+
+pub fn classify(c: char) -> JapaneseClass {
+ if OPENING_BRACKETS.contains(c) {
+ JapaneseClass::OpeningBracket
+ } else if CLOSING_BRACKETS.contains(c) {
+ JapaneseClass::ClosingBracket
+ } else if HYPHENS.contains(c) {
+ JapaneseClass::Hyphen
+ } else if DIVIDING_PUNCTUATION.contains(c) {
+ JapaneseClass::DividingPunctuation
+ } else if MIDDLE_DOTS.contains(c) {
+ JapaneseClass::MiddleDot
+ } else if FULL_STOPS.contains(c) {
+ JapaneseClass::FullStop
+ } else if COMMAS.contains(c) {
+ JapaneseClass::Comma
+ } else if INSEPARABLE.contains(c) {
+ JapaneseClass::Inseparable
+ } else if ITERATION_MARKS.contains(c) {
+ JapaneseClass::IterationMark
+ } else if c == 'ー' {
+ JapaneseClass::ProlongedSoundMark
+ } else if SMALL_KANA.contains(c) {
+ JapaneseClass::SmallKana
+ } else if PREFIXED_ABBREVIATIONS.contains(c) {
+ JapaneseClass::PrefixedAbbreviation
+ } else if POSTFIXED_ABBREVIATIONS.contains(c) {
+ JapaneseClass::PostfixedAbbreviation
+ } else if c == '\u{3000}' {
+ JapaneseClass::IdeographicSpace
+ } else if MATH_SYMBOLS.contains(c) {
+ JapaneseClass::MathSymbol
+ } else if MATH_OPERATORS.contains(c) {
+ JapaneseClass::MathOperator
+ } else if c == ' ' || c == '\t' || c == '\u{00a0}' {
+ JapaneseClass::WesternWordSpace
+ } else if c.is_ascii_digit() {
+ JapaneseClass::GroupedNumeral
+ } else if is_hiragana(c) {
+ JapaneseClass::Hiragana
+ } else if is_katakana(c) {
+ JapaneseClass::Katakana
+ } else if is_ideographic(c) {
+ JapaneseClass::Ideographic
+ } else if is_unit_symbol(c) {
+ JapaneseClass::UnitSymbol
+ } else {
+ JapaneseClass::Western
+ }
+}
+
+fn is_hiragana(c: char) -> bool {
+ matches!(u32::from(c), 0x3041..=0x309F)
+}
+
+fn is_katakana(c: char) -> bool {
+ matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D)
+}
+
+fn is_ideographic(c: char) -> bool {
+ matches!(u32::from(c),
+ 0x2E80..=0x2FDF
+ | 0x31C0..=0x31EF
+ | 0x3400..=0x4DBF
+ | 0x4E00..=0x9FFF
+ | 0xF900..=0xFAFF
+ | 0x20000..=0x2FA1F
+ ) || matches!(c, '〃' | '仝' | '〆' | '♂' | '♀')
+}
+
+fn is_unit_symbol(c: char) -> bool {
+ matches!(u32::from(c), 0x2100..=0x214F | 0x3300..=0x33FF)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn class_model_contains_all_thirty_jlreq_classes_in_order() {
+ assert_eq!(JapaneseClass::ALL.len(), 30);
+ for (index, class) in JapaneseClass::ALL.iter().enumerate() {
+ assert_eq!(class.index(), index);
+ }
+ }
+
+ #[test]
+ fn classifies_representative_jlreq_characters() {
+ let cases = [
+ ('「', JapaneseClass::OpeningBracket),
+ ('」', JapaneseClass::ClosingBracket),
+ ('〜', JapaneseClass::Hyphen),
+ ('!', JapaneseClass::DividingPunctuation),
+ ('・', JapaneseClass::MiddleDot),
+ ('。', JapaneseClass::FullStop),
+ ('、', JapaneseClass::Comma),
+ ('…', JapaneseClass::Inseparable),
+ ('々', JapaneseClass::IterationMark),
+ ('ー', JapaneseClass::ProlongedSoundMark),
+ ('ょ', JapaneseClass::SmallKana),
+ ('¥', JapaneseClass::PrefixedAbbreviation),
+ ('%', JapaneseClass::PostfixedAbbreviation),
+ ('\u{3000}', JapaneseClass::IdeographicSpace),
+ ('あ', JapaneseClass::Hiragana),
+ ('ア', JapaneseClass::Katakana),
+ ('≠', JapaneseClass::MathSymbol),
+ ('+', JapaneseClass::MathOperator),
+ ('漢', JapaneseClass::Ideographic),
+ ('2', JapaneseClass::GroupedNumeral),
+ ('㎏', JapaneseClass::UnitSymbol),
+ (' ', JapaneseClass::WesternWordSpace),
+ ('A', JapaneseClass::Western),
+ ];
+ for (character, expected) in cases {
+ assert_eq!(classify(character), expected, "wrong class for {character}");
+ }
+ }
+
+ #[test]
+ fn generated_rules_cover_every_class_pair() {
+ assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT);
+ assert!(PAIR_RULES
+ .iter()
+ .all(|row| row.len() == JapaneseClass::COUNT));
+ }
+
+ #[test]
+ fn generated_rules_encode_kinsoku_and_atomic_runs() {
+ assert!(
+ !pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed
+ );
+ assert!(
+ !pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed
+ );
+ assert!(
+ !pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed
+ );
+ assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed);
+ }
+
+ #[test]
+ fn generated_rules_encode_script_and_tcy_spacing() {
+ let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western);
+ assert_eq!(script.preferred_em, 0.25);
+ assert_eq!(script.minimum_em, 0.125);
+ assert_eq!(script.maximum_em, 0.5);
+
+ let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko);
+ assert_eq!(tcy.preferred_em, 0.5);
+ assert_eq!(
+ pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em,
+ 0.0
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em,
+ 0.0
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em,
+ 0.5
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em,
+ 0.0
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em,
+ 0.0
+ );
+ let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko);
+ assert_eq!(adjacent_tcy.preferred_em, 0.0);
+ assert_eq!(adjacent_tcy.maximum_em, 0.25);
+ assert_eq!(adjacent_tcy.expand_priority, 3);
+ }
+
+ #[test]
+ fn generated_rules_encode_punctuation_sequences() {
+ assert_eq!(
+ pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em,
+ 0.5
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em,
+ 0.5
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em,
+ 0.0
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em,
+ 0.5
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em,
+ 0.25
+ );
+ assert_eq!(
+ pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em,
+ 0.25
+ );
+ assert_eq!(
+ pair_rule(
+ JapaneseClass::DividingPunctuation,
+ JapaneseClass::Ideographic
+ )
+ .preferred_em,
+ 1.0
+ );
+ let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana);
+ assert_eq!(solid_japanese.preferred_em, 0.0);
+ assert_eq!(solid_japanese.maximum_em, 0.25);
+ assert_eq!(solid_japanese.expand_priority, 3);
+ let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby);
+ assert_eq!(ruby_adjacency.preferred_em, 0.0);
+ assert_eq!(ruby_adjacency.maximum_em, 0.0);
+ assert_eq!(ruby_adjacency.expand_priority, 0);
+ }
+}
diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/kinsoku.rs
new file mode 100644
index 0000000000..7130744dfd
--- /dev/null
+++ b/render-wasm/src/shapes/kinsoku.rs
@@ -0,0 +1,514 @@
+//! Japanese line-breaking rules (kinsoku shori).
+//!
+//! Skia's default break iterator allows closing punctuation, the
+//! prolonged sound mark or small kana at a line start, and opening
+//! brackets at a line end. skia-safe exposes no ICU BreakIterator, so
+//! the forbidden break opportunities are suppressed by inserting U+2060 WORD
+//! JOINER into the text handed to skparagraph. The same lossless layout-text
+//! transform inserts JLREQ quarter-em Japanese/Western boundary space and
+//! normalizes Western word spaces to one third em.
+//!
+//! The inserted joiners shift every UTF-16 offset reported by the laid
+//! out paragraph (position-data, caret mapping, selection rects). The
+//! [`OffsetMap`] returned along with the modified texts translates
+//! between original and joiner-shifted offsets. It is a pure function
+//! of the span texts, so any consumer can recompute it and stay
+//! consistent with the builders by construction.
+
+/// Zero-width character whose UAX #14 class forbids breaking on either
+/// side of it.
+pub const WORD_JOINER: char = '\u{2060}';
+/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap.
+pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}';
+/// Unicode THREE-PER-EM SPACE, used for Western word spacing in Japanese text.
+pub const WESTERN_WORD_SPACE: char = '\u{2004}';
+
+use super::japanese::{classify, pair_rule};
+
+pub fn forbidden_at_line_start(c: char) -> bool {
+ classify(c).forbids_line_start()
+}
+
+pub fn forbidden_at_line_end(c: char) -> bool {
+ classify(c).forbids_line_end()
+}
+
+/// Translates between original UTF-16 offsets (the source-of-truth span text)
+/// and layout-text UTF-16 offsets (the text handed to skparagraph).
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct OffsetMap {
+ /// UTF-16 indices of inserted joiners or boundary spaces, ascending and
+ /// expressed in shifted coordinates. Same-length substitutions need no
+ /// entry.
+ inserted: Vec,
+}
+
+impl OffsetMap {
+ #[cfg(test)]
+ pub fn is_empty(&self) -> bool {
+ self.inserted.is_empty()
+ }
+
+ /// Original offset for a shifted offset. An offset pointing at an inserted
+ /// character resolves to the source boundary where it was inserted.
+ pub fn to_original(&self, shifted: usize) -> usize {
+ shifted - self.inserted.iter().take_while(|&&p| p < shifted).count()
+ }
+
+ /// Shifted offset for an original offset. A boundary that received a
+ /// layout character resolves after it, so carets avoid synthetic spacing.
+ pub fn to_shifted(&self, original: usize) -> usize {
+ let mut shifted = original;
+ for &p in &self.inserted {
+ if p <= shifted {
+ shifted += 1;
+ } else {
+ break;
+ }
+ }
+ shifted
+ }
+}
+
+/// Applies the horizontal Japanese layout-text transform. It inserts WORD
+/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space at
+/// Japanese↔Western boundaries, and normalizes breakable ASCII word spaces to
+/// one third em. Span boundaries are transparent. Returns `None` when the
+/// paragraph needs no transformation.
+/// Apply the normal Japanese layout transform while additionally protecting
+/// annotated base units. `ruby_breaks[span] == Some(boundaries)` means that
+/// every internal scalar boundary except those UTF-16 offsets is atomic.
+pub fn apply_to_span_texts_with_ruby_breaks(
+ span_texts: &[String],
+ ruby_breaks: &[Option>],
+) -> Option<(Vec, OffsetMap)> {
+ let mut inserted: Vec = Vec::new();
+ let mut out: Vec = Vec::with_capacity(span_texts.len());
+ let mut prev: Option = None;
+ let mut changed = false;
+ // Running position in shifted UTF-16 coordinates.
+ let mut shifted_pos: usize = 0;
+
+ for (span_index, text) in span_texts.iter().enumerate() {
+ let mut shifted_text = String::with_capacity(text.len() + 4);
+ let mut local_utf16 = 0usize;
+ for c in text.chars() {
+ let ruby_forbids_break = local_utf16 > 0
+ && ruby_breaks
+ .get(span_index)
+ .and_then(Option::as_ref)
+ .is_some_and(|breaks| !breaks.contains(&local_utf16));
+ let forbid_break = ruby_forbids_break
+ || match prev {
+ Some(p) => pair_rule(classify(p), classify(c)).suppress_break_with_joiner,
+ None => false,
+ };
+ if forbid_break {
+ shifted_text.push(WORD_JOINER);
+ inserted.push(shifted_pos);
+ shifted_pos += 1;
+ changed = true;
+ } else if prev.is_some_and(|p| {
+ let before = classify(p);
+ let after = classify(c);
+ (before.is_japanese_letter() && after.is_western_run())
+ || (before.is_western_run() && after.is_japanese_letter())
+ }) {
+ shifted_text.push(JAPANESE_WESTERN_SPACE);
+ inserted.push(shifted_pos);
+ shifted_pos += 1;
+ changed = true;
+ }
+ let layout_char = if c == ' ' {
+ changed = true;
+ WESTERN_WORD_SPACE
+ } else {
+ c
+ };
+ shifted_text.push(layout_char);
+ shifted_pos += c.len_utf16();
+ local_utf16 += c.len_utf16();
+ prev = Some(c);
+ }
+ out.push(shifted_text);
+ }
+
+ if !changed {
+ return None;
+ }
+ Some((out, OffsetMap { inserted }))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use skia_safe::textlayout::{
+ FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider,
+ };
+ use skia_safe::FontMgr;
+
+ const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
+
+ fn strings(texts: &[&str]) -> Vec {
+ texts.iter().map(|t| t.to_string()).collect()
+ }
+
+ fn apply(texts: &[&str]) -> (Vec, OffsetMap) {
+ apply_to_span_texts_with_ruby_breaks(&strings(texts), &[])
+ .expect("expected kinsoku insertions")
+ }
+
+ // -----------------------------------------------------------------
+ // Character classes
+ // -----------------------------------------------------------------
+
+ #[test]
+ fn classes_forbidden_at_start() {
+ for c in "、。」』)ーっゃァッ々・!?".chars() {
+ assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}");
+ }
+ for c in "あ漢A1「( ".chars() {
+ assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}");
+ }
+ }
+
+ #[test]
+ fn classes_forbidden_at_end() {
+ for c in "「『([【〈《".chars() {
+ assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}");
+ }
+ for c in "あ漢A1」)。".chars() {
+ assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}");
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Insertion
+ // -----------------------------------------------------------------
+
+ #[test]
+ fn western_word_space_uses_one_third_em_character() {
+ let (texts, map) = apply(&["hello world"]);
+ assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]);
+ assert!(
+ map.inserted.is_empty(),
+ "substitution does not shift offsets"
+ );
+ }
+
+ #[test]
+ fn no_insertion_for_plain_cjk_text() {
+ assert!(
+ apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none()
+ );
+ }
+
+ #[test]
+ fn inserts_before_forbidden_start_char() {
+ let (texts, map) = apply(&["雪国。"]);
+ assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]);
+ assert_eq!(map.inserted, vec![2]);
+ }
+
+ #[test]
+ fn inserts_after_forbidden_end_char() {
+ let (texts, _) = apply(&["「雪"]);
+ assert_eq!(texts, vec!["「\u{2060}雪".to_string()]);
+ }
+
+ #[test]
+ fn no_insertion_at_paragraph_start() {
+ // A leading forbidden-at-start char has no break opportunity
+ // before it; nothing to suppress.
+ let (texts, _) = apply(&["。あ。"]);
+ assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]);
+ }
+
+ #[test]
+ fn insertion_spans_boundary() {
+ // The pair (end of span 0, start of span 1) is evaluated; the
+ // joiner lands at the head of span 1.
+ let (texts, _) = apply(&["雪国", "。です"]);
+ assert_eq!(
+ texts,
+ vec!["雪国".to_string(), "\u{2060}。です".to_string()]
+ );
+ }
+
+ #[test]
+ fn inserts_quarter_em_at_japanese_western_boundaries() {
+ let (texts, map) = apply(&["日本Penpot版"]);
+ assert_eq!(
+ texts,
+ vec![format!(
+ "日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版"
+ )]
+ );
+ assert_eq!(map.inserted, vec![2, 9]);
+ assert_eq!(map.to_original(map.to_shifted(2)), 2);
+ assert_eq!(map.to_original(map.to_shifted(8)), 8);
+ }
+
+ #[test]
+ fn japanese_western_spacing_crosses_span_boundary() {
+ let (texts, map) = apply(&["日本", "Penpot"]);
+ assert_eq!(
+ texts,
+ vec![
+ "日本".to_string(),
+ format!("{JAPANESE_WESTERN_SPACE}Penpot")
+ ]
+ );
+ assert_eq!(map.inserted, vec![2]);
+ }
+
+ #[test]
+ fn consecutive_forbidden_chars() {
+ let (texts, map) = apply(&["た」。"]);
+ assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]);
+ assert_eq!(map.inserted, vec![1, 3]);
+ }
+
+ #[test]
+ fn small_kana_and_prolonged_sound() {
+ let (texts, _) = apply(&["コーヒー"]);
+ assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]);
+ let (texts, _) = apply(&["ちょっと"]);
+ assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]);
+ }
+
+ // -----------------------------------------------------------------
+ // Offset map
+ // -----------------------------------------------------------------
+
+ #[test]
+ fn offset_map_round_trips_every_original_index() {
+ let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。";
+ let (_, map) = apply(&[original]);
+ let len = original.encode_utf16().count();
+ for i in 0..=len {
+ assert_eq!(
+ map.to_original(map.to_shifted(i)),
+ i,
+ "round-trip failed at original index {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn offset_map_recovers_original_characters() {
+ let original = "た」。あ";
+ let (texts, map) = apply(&[original]);
+ let shifted = texts.concat();
+ let shifted_units: Vec = shifted.encode_utf16().collect();
+ let original_units: Vec = original.encode_utf16().collect();
+ for (s_idx, unit) in shifted_units.iter().enumerate() {
+ if *unit == WORD_JOINER as u16 {
+ continue;
+ }
+ assert_eq!(original_units[map.to_original(s_idx)], *unit);
+ }
+ }
+
+ #[test]
+ fn offset_map_on_joiner_resolves_to_boundary() {
+ // "雪国。" → joiner at shifted 2; both sides of the joiner map
+ // to original boundary 2.
+ let (_, map) = apply(&["雪国。"]);
+ assert_eq!(map.to_original(2), 2);
+ assert_eq!(map.to_original(3), 2);
+ assert_eq!(map.to_shifted(2), 3);
+ }
+
+ // -----------------------------------------------------------------
+ // Real skparagraph layout
+ // -----------------------------------------------------------------
+
+ fn font_collection() -> FontCollection {
+ let font_mgr = FontMgr::new();
+ let typeface = font_mgr
+ .new_from_data(TEST_FONT, None)
+ .expect("failed to load test font");
+ let mut provider = TypefaceFontProvider::new();
+ provider.register_typeface(typeface, Some("TestFont"));
+ let mut collection = FontCollection::new();
+ collection.set_asset_font_manager(Some(provider.into()));
+ collection.set_default_font_manager(FontMgr::new(), None);
+ collection
+ }
+
+ /// Lays out `text` at `width` and returns each line as a UTF-16
+ /// (start, end) range.
+ fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> {
+ let collection = font_collection();
+ let paragraph_style = ParagraphStyle::default();
+ let mut builder = ParagraphBuilder::new(¶graph_style, collection);
+ let mut style = TextStyle::default();
+ style.set_font_families(&["TestFont"]);
+ style.set_font_size(20.0);
+ style.set_letter_spacing(letter_spacing);
+ builder.push_style(&style);
+ builder.add_text(text);
+ let mut paragraph = builder.build();
+ paragraph.layout(width);
+ paragraph
+ .get_line_metrics()
+ .iter()
+ .map(|line| (line.start_index, line.end_index))
+ .collect()
+ }
+
+ fn utf16_chars(text: &str) -> Vec {
+ // BMP-only fixtures: one char per UTF-16 unit.
+ text.chars().collect()
+ }
+
+ /// Asserts no line starts with a start-forbidden char nor ends with
+ /// an end-forbidden char, in ORIGINAL text coordinates.
+ fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
+ let chars = utf16_chars(original);
+ for (i, (start, end)) in lines.iter().enumerate() {
+ let orig_start = map.to_original(*start);
+ let orig_end = map.to_original(*end);
+ if i > 0 {
+ let first = chars[orig_start];
+ assert!(
+ !forbidden_at_line_start(first),
+ "line {i} starts with forbidden char {first} (text {original})"
+ );
+ }
+ if i < lines.len() - 1 && orig_end > orig_start {
+ // The last char actually rendered on the line (end may
+ // include trailing whitespace-like positions).
+ let last = chars[orig_end - 1];
+ assert!(
+ !forbidden_at_line_end(last),
+ "line {i} ends with forbidden char {last} (text {original})"
+ );
+ }
+ }
+ }
+
+ /// Lines mapped back to original offsets must tile the original
+ /// text exactly: contiguous, in order, full coverage.
+ fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) {
+ let mut expected_start = 0;
+ for (start, end) in lines {
+ let orig_start = map.to_original(*start);
+ let orig_end = map.to_original(*end);
+ assert_eq!(orig_start, expected_start, "line ranges must be contiguous");
+ expected_start = orig_end;
+ }
+ assert_eq!(expected_start, original.encode_utf16().count());
+ }
+
+ fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) {
+ let (texts, map) = apply(&[original]);
+ let lines = layout_lines(&texts.concat(), width, 0.0);
+ (lines, map)
+ }
+
+ fn measure_width(text: &str) -> f32 {
+ let collection = font_collection();
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection);
+ let mut style = TextStyle::default();
+ style.set_font_families(&["TestFont"]);
+ style.set_font_size(20.0);
+ builder.push_style(&style);
+ builder.add_text(text);
+ let mut p = builder.build();
+ p.layout(f32::MAX);
+ p.longest_line()
+ }
+
+ #[test]
+ fn joiner_is_zero_width_in_layout() {
+ let plain = layout_lines("国国", f32::MAX, 0.0);
+ let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0);
+ assert_eq!(plain.len(), 1);
+ assert_eq!(joined.len(), 1);
+
+ let collection = font_collection();
+ let measure = |text: &str| {
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
+ let mut style = TextStyle::default();
+ style.set_font_families(&["TestFont"]);
+ style.set_font_size(20.0);
+ builder.push_style(&style);
+ builder.add_text(text);
+ let mut p = builder.build();
+ p.layout(f32::MAX);
+ p.longest_line()
+ };
+ let diff = (measure("国国") - measure("国\u{2060}国")).abs();
+ assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}");
+ }
+
+ #[test]
+ fn suppresses_breaks_on_kinsoku_fixtures() {
+ let fixtures = [
+ // kinsoku-line-start
+ "これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。",
+ // kinsoku-line-end
+ "開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。",
+ // prolonged-sound
+ "コーヒーとケーキ、サーバーとルーター。人々の時々の心。",
+ // small-kana-sokuon
+ "ちょっと待ってください。キャッシュとクッキーをチェックする。",
+ // long-paragraph-wrap
+ "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
+ ];
+ // Several widths to move the break positions around. Widths
+ // must exceed the longest unbreakable (joined) run, otherwise
+ // skparagraph rightfully falls back to an emergency mid-run
+ // break.
+ let char_width = measure_width("国");
+ for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) {
+ for original in fixtures {
+ let (lines, map) = fixture_lines(original, width);
+ assert!(lines.len() > 1, "fixture must wrap at width {width}");
+ assert_kinsoku_clean(original, &lines, &map);
+ assert_lines_tile_original(original, &lines, &map);
+ }
+ }
+ }
+
+ #[test]
+ fn mixed_latin_cjk_fixture() {
+ let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!";
+ let char_width = measure_width("国");
+ for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) {
+ let (lines, map) = fixture_lines(original, width);
+ assert_kinsoku_clean(original, &lines, &map);
+ assert_lines_tile_original(original, &lines, &map);
+ }
+ }
+
+ #[test]
+ fn joiner_becomes_visible_under_letter_spacing() {
+ // skparagraph applies letter-spacing per cluster, INCLUDING the
+ // zero-width joiner, which would double the tracking at every
+ // suppressed break. This is why callers disable kinsoku for
+ // paragraphs with a non-zero letter-spacing. If this test ever
+ // fails (Skia stops spacing ignorables), that gate can go.
+ let collection = font_collection();
+ let measure = |text: &str| {
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());
+ let mut style = TextStyle::default();
+ style.set_font_families(&["TestFont"]);
+ style.set_font_size(20.0);
+ style.set_letter_spacing(5.0);
+ builder.push_style(&style);
+ builder.add_text(text);
+ let mut p = builder.build();
+ p.layout(f32::MAX);
+ p.longest_line()
+ };
+ let diff = (measure("国\u{2060}国") - measure("国国")).abs();
+ assert!(
+ diff > 0.01,
+ "letter-spacing no longer affects the joiner; the kinsoku \
+ letter-spacing gate can be removed"
+ );
+ }
+}
diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs
index 109fc605b2..78562f5072 100644
--- a/render-wasm/src/shapes/modifiers.rs
+++ b/render-wasm/src/shapes/modifiers.rs
@@ -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);
diff --git a/render-wasm/src/shapes/shape_to_path.rs b/render-wasm/src/shapes/shape_to_path.rs
index 32e058dcb6..ba35923d42 100644
--- a/render-wasm/src/shapes/shape_to_path.rs
+++ b/render-wasm/src/shapes/shape_to_path.rs
@@ -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));
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 1ba1381172..1f2995ac5e 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -7,7 +7,9 @@ use crate::{
use core::f32;
use macros::ToJs;
-use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
+use skia_safe::textlayout::{
+ PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline,
+};
use skia_safe::{
self as skia,
paint::{self, Paint},
@@ -18,18 +20,553 @@ use skia_safe::{
Contains,
};
+// CHANGEME: move all the custom japanese layout code to its own module
+
use std::cell::Cell;
use std::collections::HashSet;
use super::FontFamily;
use crate::math::Point;
-use crate::shapes::{self, merge_fills, Shape, VerticalAlign};
+use crate::shapes::{self, kinsoku, merge_fills, Shape, VerticalAlign};
use crate::utils::{get_fallback_fonts, get_font_collection};
use crate::Uuid;
// TODO: maybe move this to the wasm module?
pub type ParagraphBuilderGroup = Vec;
+pub const WARICHU_FONT_SCALE: f32 = 0.5;
+pub const EMPHASIS_FONT_SCALE: f32 = 0.5;
+const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3;
+const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}';
+const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}';
+
+/// Add a span to a horizontal paragraph builder. Warichu is represented by a
+/// single inline placeholder so the two annotation lines wrap as one unit.
+/// The actual glyphs are painted after SkParagraph has positioned the box.
+pub(crate) fn add_horizontal_span(
+ builder: &mut ParagraphBuilder,
+ span: &TextSpan,
+ builder_text: &str,
+ text_style: &skia::textlayout::TextStyle,
+ fonts: &skia::textlayout::FontCollection,
+) {
+ if span.warichu && span.text.chars().count() >= 2 {
+ let text = span.apply_text_transform();
+ let split = super::text_vertical::warichu_split_chars(&text);
+ let split_byte = text
+ .char_indices()
+ .nth(split)
+ .map(|(index, _)| index)
+ .unwrap_or(text.len());
+ let (first, second) = text.split_at(split_byte);
+ let mut mini_style = text_style.clone();
+ mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
+ mini_style.set_height(1.0);
+ mini_style.set_height_override(true);
+ mini_style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
+ let measure = |line: &str| {
+ let mut mini = ParagraphBuilder::new(&ParagraphStyle::default(), fonts);
+ mini.push_style(&mini_style);
+ mini.add_text(line);
+ let mut paragraph = mini.build();
+ paragraph.layout(f32::MAX);
+ paragraph.longest_line()
+ };
+ let width = measure(first).max(measure(second)).max(0.01);
+ let height = span.font_size.max(0.01);
+ builder.add_placeholder(&PlaceholderStyle::new(
+ width,
+ height,
+ PlaceholderAlignment::Middle,
+ TextBaseline::Alphabetic,
+ height,
+ ));
+ // SkParagraph does not expose a placeholder's TextStyle through line
+ // metrics. A near-zero, inkless NBSP preserves the exact
+ // fill/stroke/shadow style for the custom paint pass; the following
+ // zero-width space restores a legal wrapping boundary after the
+ // atomic placeholder.
+ let mut anchor_style = text_style.clone();
+ anchor_style.set_font_size(0.01);
+ anchor_style.set_height(0.01);
+ anchor_style.set_height_override(true);
+ anchor_style.set_letter_spacing(0.0);
+ builder.push_style(&anchor_style);
+ builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string());
+ builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string());
+ } else {
+ builder.add_text(builder_text);
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct HorizontalSpanRange {
+ pub span: usize,
+ pub builder_start: usize,
+ pub builder_end: usize,
+ pub shifted_start: usize,
+ pub source_start: usize,
+ pub source_end: usize,
+ pub warichu: bool,
+ pub style_anchor_start: usize,
+}
+
+/// Ranges shared by layout, position-data and editor mapping. `shifted_*`
+/// addresses the normal kinsoku-adjusted paragraph text, while `builder_*`
+/// addresses the paragraph where a whole warichu span occupies one U+FFFC.
+pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec {
+ let (span_texts, offset_map) = paragraph.layout_span_texts();
+ let mut builder_cursor = 0usize;
+ let mut builder_byte_cursor = 0usize;
+ let mut shifted_cursor = 0usize;
+ paragraph
+ .children()
+ .iter()
+ .zip(span_texts)
+ .enumerate()
+ .map(|(span_index, (span, text))| {
+ let shifted_start = shifted_cursor;
+ shifted_cursor += text.encode_utf16().count();
+ let shifted_end = shifted_cursor;
+ let source_start = offset_map.to_original(shifted_start);
+ let source_end = offset_map.to_original(shifted_end);
+ let warichu = span.warichu && span.text.chars().count() >= 2;
+ let builder_start = builder_cursor;
+ let style_anchor_start = if warichu {
+ builder_byte_cursor + '\u{FFFC}'.len_utf8()
+ } else {
+ builder_byte_cursor
+ };
+ builder_cursor += if warichu {
+ HORIZONTAL_WARICHU_BUILDER_LEN
+ } else {
+ shifted_end - shifted_start
+ };
+ builder_byte_cursor += if warichu {
+ '\u{FFFC}'.len_utf8()
+ + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8()
+ + HORIZONTAL_WARICHU_BREAK_ANCHOR.len_utf8()
+ } else {
+ text.len()
+ };
+ HorizontalSpanRange {
+ span: span_index,
+ builder_start,
+ builder_end: builder_cursor,
+ shifted_start,
+ source_start,
+ source_end,
+ warichu,
+ style_anchor_start,
+ }
+ })
+ .collect()
+}
+
+fn source_char_boundaries(paragraph: &Paragraph) -> Vec {
+ let mut boundaries = vec![0usize];
+ for span in paragraph.children() {
+ for character in span.text.chars() {
+ boundaries.push(boundaries.last().copied().unwrap_or(0) + character.len_utf16());
+ }
+ }
+ boundaries
+}
+
+pub(crate) fn horizontal_source_to_builder(
+ paragraph: &Paragraph,
+ source_char_offset: usize,
+) -> usize {
+ let boundaries = source_char_boundaries(paragraph);
+ let source_utf16 = boundaries
+ .get(source_char_offset)
+ .copied()
+ .unwrap_or_else(|| boundaries.last().copied().unwrap_or(0));
+ let (_, offset_map) = paragraph.layout_span_texts();
+ let ranges = horizontal_span_ranges(paragraph);
+ let Some(range) = ranges
+ .iter()
+ .find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end)
+ else {
+ return ranges.last().map(|range| range.builder_end).unwrap_or(0);
+ };
+ if range.warichu {
+ return if source_utf16 >= range.source_end {
+ range.builder_end
+ } else {
+ range.builder_start
+ };
+ }
+ let shifted = offset_map.to_shifted(source_utf16);
+ range.builder_start + shifted.saturating_sub(range.shifted_start)
+}
+
+pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize {
+ let (_, offset_map) = paragraph.layout_span_texts();
+ let ranges = horizontal_span_ranges(paragraph);
+ let source_utf16 = ranges
+ .iter()
+ .find(|range| builder_offset >= range.builder_start && builder_offset <= range.builder_end)
+ .map(|range| {
+ if range.warichu {
+ if builder_offset > range.builder_start {
+ range.source_end
+ } else {
+ range.source_start
+ }
+ } else {
+ let within = builder_offset
+ .saturating_sub(range.builder_start)
+ .min(range.builder_end - range.builder_start);
+ offset_map.to_original(range.shifted_start + within)
+ }
+ })
+ .unwrap_or_else(|| ranges.last().map(|range| range.source_end).unwrap_or(0));
+ let boundaries = source_char_boundaries(paragraph);
+ boundaries
+ .partition_point(|boundary| *boundary < source_utf16)
+ .min(boundaries.len().saturating_sub(1))
+}
+
+fn horizontal_warichu_boxes<'a>(
+ paragraph: &'a Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+) -> Vec<(&'a TextSpan, usize, usize, skia::Rect)> {
+ let mut source_start = 0usize;
+ let mut placeholders = laid_out.get_rects_for_placeholders().into_iter();
+ let mut boxes = Vec::new();
+ for span in paragraph.children() {
+ let length = span.text.chars().count();
+ if span.warichu && length >= 2 {
+ if let Some(textbox) = placeholders.next() {
+ boxes.push((span, source_start, source_start + length, textbox.rect));
+ }
+ }
+ source_start += length;
+ }
+ boxes
+}
+
+pub(crate) fn horizontal_warichu_hit_test(
+ paragraph: &Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+ point: Point,
+) -> Option {
+ for (span, source_start, _, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
+ if !rect.contains(&point) {
+ continue;
+ }
+ let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
+ let total = span.text.chars().count();
+ let second = point.y >= rect.top() + rect.height() / 2.0;
+ let (line_start, line_len) = if second {
+ (split, total - split)
+ } else {
+ (0, split)
+ };
+ let fraction = ((point.x - rect.left()) / rect.width().max(0.01)).clamp(0.0, 1.0);
+ let within = (fraction * line_len as f32).round() as usize;
+ return Some(source_start + line_start + within.min(line_len));
+ }
+ None
+}
+
+pub(crate) fn horizontal_warichu_caret_rect(
+ paragraph: &Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+ source_offset: usize,
+) -> Option {
+ for (span, source_start, source_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
+ if source_offset < source_start || source_offset > source_end {
+ continue;
+ }
+ let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
+ let local = source_offset - source_start;
+ let total = source_end - source_start;
+ let (line_start, line_len, top) = if local >= split {
+ (split, total - split, rect.top() + rect.height() / 2.0)
+ } else {
+ (0, split, rect.top())
+ };
+ let within = local.saturating_sub(line_start).min(line_len);
+ let char_width = rect.width() / line_len.max(1) as f32;
+ return Some(skia::Rect::from_xywh(
+ rect.left() + within as f32 * char_width,
+ top,
+ char_width,
+ rect.height() / 2.0,
+ ));
+ }
+ None
+}
+
+pub(crate) fn horizontal_warichu_range_rects(
+ paragraph: &Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+ source_start: usize,
+ source_end: usize,
+) -> Vec {
+ let mut rects = Vec::new();
+ for (span, span_start, span_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) {
+ let selected_start = source_start.max(span_start);
+ let selected_end = source_end.min(span_end);
+ if selected_start >= selected_end {
+ continue;
+ }
+ let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
+ for (line_start, line_end, top) in [
+ (span_start, span_start + split, rect.top()),
+ (
+ span_start + split,
+ span_end,
+ rect.top() + rect.height() / 2.0,
+ ),
+ ] {
+ let start = selected_start.max(line_start);
+ let end = selected_end.min(line_end);
+ if start >= end {
+ continue;
+ }
+ let line_len = line_end - line_start;
+ let char_width = rect.width() / line_len.max(1) as f32;
+ rects.push(skia::Rect::from_xywh(
+ rect.left() + (start - line_start) as f32 * char_width,
+ top,
+ (end - start) as f32 * char_width,
+ rect.height() / 2.0,
+ ));
+ }
+ }
+ rects
+}
+
+pub(crate) fn horizontal_normal_selection_ranges(
+ paragraph: &Paragraph,
+ source_start: usize,
+ source_end: usize,
+) -> Vec> {
+ let mut span_start = 0usize;
+ paragraph
+ .children()
+ .iter()
+ .filter_map(|span| {
+ let span_end = span_start + span.text.chars().count();
+ let selected_start = source_start.max(span_start);
+ let selected_end = source_end.min(span_end);
+ let warichu = span.warichu && span.text.chars().count() >= 2;
+ span_start = span_end;
+ if warichu || selected_start >= selected_end {
+ return None;
+ }
+ Some(
+ horizontal_source_to_builder(paragraph, selected_start)
+ ..horizontal_source_to_builder(paragraph, selected_end),
+ )
+ })
+ .collect()
+}
+
+fn warichu_text_lines(text: &str) -> (&str, &str) {
+ let split = super::text_vertical::warichu_split_chars(text);
+ let split_byte = text
+ .char_indices()
+ .nth(split)
+ .map(|(index, _)| index)
+ .unwrap_or(text.len());
+ text.split_at(split_byte)
+}
+
+fn warichu_mini_paragraph(
+ text: &str,
+ style: &skia::textlayout::TextStyle,
+ width: f32,
+) -> skia::textlayout::Paragraph {
+ let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), get_font_collection());
+ builder.push_style(style);
+ builder.add_text(text);
+ let mut paragraph = builder.build();
+ paragraph.layout(width.max(0.01));
+ paragraph
+}
+
+/// Paint the two horizontal warichu sub-lines into SkParagraph's inline
+/// placeholder boxes. Reading order is top line then bottom line.
+pub(crate) fn paint_horizontal_warichu(
+ canvas: &skia::Canvas,
+ paragraph: &Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+ x: f32,
+ y: f32,
+) {
+ let ranges = horizontal_span_ranges(paragraph);
+ let placeholders = laid_out.get_rects_for_placeholders();
+ let warichu_ranges: Vec<_> = ranges.iter().filter(|range| range.warichu).collect();
+ if placeholders.len() != warichu_ranges.len() {
+ return;
+ }
+
+ for (range, textbox) in warichu_ranges.into_iter().zip(placeholders) {
+ let Some(span) = paragraph.children().get(range.span) else {
+ continue;
+ };
+ // Indexed style metrics expose builder UTF-8 byte positions even
+ // though glyph/line ranges use UTF-16 offsets.
+ let style_anchor = range.style_anchor_start
+ ..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8();
+ let style = laid_out.get_line_metrics().iter().find_map(|line| {
+ line.get_style_metrics(style_anchor.clone())
+ .into_iter()
+ .next()
+ .map(|(_, metric)| metric.text_style.clone())
+ });
+ let Some(mut style) = style else {
+ continue;
+ };
+ style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
+ style.set_height(1.0);
+ style.set_height_override(true);
+ style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
+ let transformed = span.apply_text_transform();
+ let (first, second) = warichu_text_lines(&transformed);
+ let rect = textbox.rect;
+ let first_para = warichu_mini_paragraph(first, &style, rect.width());
+ let second_para = warichu_mini_paragraph(second, &style, rect.width());
+ let half_height = rect.height() / 2.0;
+ first_para.paint(canvas, (x + rect.left(), y + rect.top()));
+ second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height));
+ }
+}
+
+pub(crate) fn emphasis_char_allowed(character: char) -> bool {
+ !character.is_whitespace()
+ && !crate::shapes::japanese::classify(character).is_emphasis_prohibited()
+}
+
+#[derive(Debug, Clone, Copy)]
+struct HorizontalEmphasisPlacement {
+ span: usize,
+ mark: char,
+ rect: skia::Rect,
+}
+
+/// Locate one horizontal emphasis mark above each eligible source character.
+/// SkParagraph owns wrapping and bidi placement; querying each transformed
+/// character range keeps the marks attached to the actual laid-out glyphs.
+fn horizontal_emphasis_placements(
+ paragraph: &Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+) -> Vec {
+ let (_, offset_map) = paragraph.layout_span_texts();
+ let ranges = horizontal_span_ranges(paragraph);
+ let mut placements = Vec::new();
+
+ for range in ranges.iter().filter(|range| !range.warichu) {
+ let Some(span) = paragraph.children().get(range.span) else {
+ continue;
+ };
+ let Some(mark) = span.text_emphasis.mark_char() else {
+ continue;
+ };
+ let transformed = span.apply_text_transform();
+ let mut local_utf16 = 0usize;
+ for character in transformed.chars() {
+ let next_utf16 = local_utf16 + character.len_utf16();
+ if emphasis_char_allowed(character) {
+ let shifted_start = offset_map.to_shifted(range.source_start + local_utf16);
+ let shifted_end = offset_map.to_shifted(range.source_start + next_utf16);
+ let builder_start =
+ range.builder_start + shifted_start.saturating_sub(range.shifted_start);
+ let builder_end =
+ range.builder_start + shifted_end.saturating_sub(range.shifted_start);
+ let scalar_rect = laid_out
+ .get_rects_for_range(
+ builder_start..builder_end,
+ RectHeightStyle::Tight,
+ RectWidthStyle::Tight,
+ )
+ .into_iter()
+ .map(|textbox| textbox.rect)
+ .reduce(|mut rect, next| {
+ rect.join(next);
+ rect
+ });
+ if let Some(rect) = scalar_rect {
+ placements.push(HorizontalEmphasisPlacement {
+ span: range.span,
+ mark,
+ rect,
+ });
+ }
+ }
+ local_utf16 = next_utf16;
+ }
+ }
+ placements
+}
+
+fn horizontal_span_style(
+ laid_out: &skia::textlayout::Paragraph,
+ range: &HorizontalSpanRange,
+) -> Option {
+ // Indexed style metrics use builder UTF-8 byte positions (the same
+ // convention used by the warichu style anchor above).
+ let anchor = range.style_anchor_start..range.style_anchor_start + 1;
+ laid_out.get_line_metrics().iter().find_map(|line| {
+ line.get_style_metrics(anchor.clone())
+ .into_iter()
+ .next()
+ .map(|(_, metric)| metric.text_style.clone())
+ })
+}
+
+/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs.
+/// The base paragraph retains its normal metrics; interlinear collision and
+/// automatic line-gap expansion remain a separate layout policy.
+pub(crate) fn paint_horizontal_emphasis(
+ canvas: &skia::Canvas,
+ paragraph: &Paragraph,
+ laid_out: &skia::textlayout::Paragraph,
+ x: f32,
+ y: f32,
+) {
+ let ranges = horizontal_span_ranges(paragraph);
+ let placements = horizontal_emphasis_placements(paragraph, laid_out);
+ for range in ranges.iter().filter(|range| !range.warichu) {
+ let Some(span) = paragraph.children().get(range.span) else {
+ continue;
+ };
+ let Some(mark) = span.text_emphasis.mark_char() else {
+ continue;
+ };
+ let Some(mut style) = horizontal_span_style(laid_out, range) else {
+ continue;
+ };
+ style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE);
+ style.set_height(1.0);
+ style.set_height_override(true);
+ style.set_letter_spacing(0.0);
+ let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX);
+ let mark_width = mark_paragraph.longest_line();
+ let mark_height = mark_paragraph.height();
+ for placement in placements
+ .iter()
+ .filter(|placement| placement.span == range.span && placement.mark == mark)
+ {
+ let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
+ let ruby_offset = if span.annotation_clearance.is_auto()
+ && !span.ruby.trim().is_empty()
+ && span.ruby_side == RubySide::Over
+ {
+ span.font_size * span.ruby_size.scale()
+ } else {
+ 0.0
+ };
+ let mark_y = y + placement.rect.top() - mark_height - ruby_offset;
+ mark_paragraph.paint(canvas, (mark_x, mark_y));
+ }
+ }
+}
+
#[repr(u8)]
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
pub enum GrowType {
@@ -420,6 +957,14 @@ impl TextContent {
self.size.normalized_line_height
}
+ /// Writing mode is a whole-shape property: the first paragraph
+ /// decides the flow for all of them.
+ pub fn is_vertical(&self) -> bool {
+ self.paragraphs
+ .first()
+ .is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl)
+ }
+
pub fn grow_type(&self) -> GrowType {
self.grow_type
}
@@ -500,7 +1045,10 @@ impl TextContent {
// AutoWidth paragraphs are laid out with f32::MAX, so line metrics
// (line.left) reflect alignment within that huge width and are
// unusable for tight bounds. Fall back to content_rect.
- if self.grow_type() == GrowType::AutoWidth {
+ // Vertical writing bounds come from the vertical pass through
+ // content_rect; the skparagraph line metrics below describe the
+ // unused horizontal layout.
+ if self.grow_type() == GrowType::AutoWidth || self.is_vertical() {
return self.content_rect(selrect, valign);
}
@@ -579,6 +1127,17 @@ impl TextContent {
}
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
+ // Vertical content anchors to the shape's right edge and always
+ // aligns to the top (vertical-align along columns is deferred).
+ if self.is_vertical() {
+ let (width, height) = if self.grow_type() == GrowType::AutoWidth {
+ (self.size.width, self.size.height)
+ } else {
+ (selrect.width(), selrect.height())
+ };
+ return Rect::from_xywh(selrect.right() - width, selrect.y(), width, height);
+ }
+
let x = selrect.x();
let mut y = selrect.y();
@@ -613,7 +1172,26 @@ impl TextContent {
pub fn get_caret_position_from_shape_coords(
&self,
point: &Point,
+ vertical_align: VerticalAlign,
) -> Option {
+ // Vertical writing: resolve through the vertical pass. The point
+ // arrives selrect-local; the content block is right-anchored.
+ if self.is_vertical() {
+ let bounds = self.bounds();
+ let max_height = super::text_vertical::wrap_height(self, bounds.height());
+ let layout = super::text_vertical::layout_from_content(self, max_height);
+ let cx = point.x
+ - super::text_vertical::block_axis_offset(
+ bounds.width(),
+ layout.width,
+ vertical_align,
+ );
+ let (paragraph, offset) = super::text_vertical::caret_from_point(&layout, cx, point.y)?;
+ return Some(TextPositionWithAffinity::new_without_affinity(
+ paragraph, offset,
+ ));
+ }
+
let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
@@ -636,9 +1214,28 @@ impl TextContent {
// the paragraph's top-left. For multi-paragraph or wrapped text, each
// paragraph has its own origin; subtract start_y so we pass paragraph-local coords.
let para_pt = Point::new(point.x, point.y - start_y);
- let position_with_affinity =
+ if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
+ if let Some(original_position) =
+ horizontal_warichu_hit_test(paragraph, layout_paragraph, para_pt)
+ {
+ return Some(TextPositionWithAffinity::new_without_affinity(
+ paragraph_index,
+ original_position,
+ ));
+ }
+ }
+ let mut position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y));
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
+ // The laid-out paragraph reports offsets in the
+ // builder-text (kinsoku-shifted) space; translate
+ // back to original text offsets.
+ let original_position = horizontal_builder_to_source(
+ paragraph,
+ position_with_affinity.position as usize,
+ );
+ position_with_affinity.position = original_position as i32;
+
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know
// in which span we are.
@@ -650,7 +1247,7 @@ impl TextContent {
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
- let current_position = position_with_affinity.position as usize;
+ let current_position = original_position;
// Handle empty spans: if the span is empty and current position
// matches the start, this is the right span
@@ -670,7 +1267,7 @@ impl TextContent {
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
- position_with_affinity.position as usize,
+ original_position,
));
}
}
@@ -703,9 +1300,10 @@ impl TextContent {
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
+ vertical_align: VerticalAlign,
) -> Option {
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
- self.get_caret_position_from_shape_coords(&shape_rel_point)
+ self.get_caret_position_from_shape_coords(&shape_rel_point, vertical_align)
}
/// Builds the ParagraphBuilders necessary to render
@@ -722,7 +1320,8 @@ impl TextContent {
let paragraph_style = paragraph.paragraph_to_style();
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
let mut has_text = false;
- for span in paragraph.children() {
+ let (span_texts, _) = paragraph.layout_span_texts();
+ for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent();
let text_style = span.to_style(
&self.bounds(),
@@ -730,12 +1329,11 @@ impl TextContent {
remove_alpha,
paragraph.line_height(),
);
- let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style);
- builder.add_text(&text);
+ add_horizontal_span(&mut builder, span, text, &text_style, fonts);
}
if !has_text {
builder.add_text(" ");
@@ -757,19 +1355,19 @@ impl TextContent {
let paragraph_style = paragraph.paragraph_to_style();
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
let mut has_text = false;
- for span in paragraph.children() {
+ let (span_texts, _) = paragraph.layout_span_texts();
+ for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
let text_style = span.to_style(
&self.bounds(),
fallback_fonts,
true, // always opaque
paragraph.line_height(),
);
- let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style);
- builder.add_text(&text);
+ add_horizontal_span(&mut builder, span, text, &text_style, fonts);
}
if !has_text {
builder.add_text(" ");
@@ -930,6 +1528,32 @@ impl TextContent {
}
}
+ // Vertical writing sizes come from the vertical pass. Auto-width
+ // fits both axes without wrapping. Auto-height keeps the shape height
+ // as its wrap budget and grows width as columns advance right-to-left.
+ // Fixed keeps both shape dimensions.
+ if self.is_vertical() {
+ match self.grow_type() {
+ GrowType::AutoWidth => {
+ let max_height = super::text_vertical::wrap_height(self, selrect.height());
+ let (width, height) = super::text_vertical::measure_content(self, max_height);
+ self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
+ self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
+ self.size.max_width = self.size.width;
+ }
+ GrowType::AutoHeight => {
+ let max_height = super::text_vertical::wrap_height(self, selrect.height());
+ let (width, _) = super::text_vertical::measure_content(self, max_height);
+ self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
+ self.size.height = selrect.height();
+ self.size.max_width = self.size.width;
+ }
+ GrowType::Fixed => {
+ self.size.set_size(selrect.width(), selrect.height());
+ }
+ }
+ }
+
if self.is_empty() {
let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect);
self.size.width = placeholder_width;
@@ -1038,6 +1662,20 @@ impl TextContent {
let result = matrix.map_point((x_pos, y_pos));
+ // Vertical writing: hit-test against the laid-out cells directly
+ // (absolute coordinates, right-anchored to the selrect).
+ if self.is_vertical() {
+ let max_height = super::text_vertical::wrap_height(self, shape.selrect.height());
+ let layout = super::text_vertical::layout_from_content(self, max_height);
+ return super::text_vertical::intersects(
+ &layout,
+ &shape.selrect,
+ shape.vertical_align(),
+ result.x,
+ result.y,
+ );
+ }
+
// Change coords to content space
let x_pos = result.x - rect.x();
let y_pos = result.y - rect.y();
@@ -1082,6 +1720,153 @@ pub type TextAlign = skia::textlayout::TextAlign;
pub type TextDirection = skia::textlayout::TextDirection;
pub type TextDecoration = skia::textlayout::TextDecoration;
+/// Block flow direction of a paragraph. Horizontal is the skparagraph
+/// path; vertical-rl lays out columns top->bottom advancing right->left
+/// through the custom vertical pass.
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum WritingMode {
+ #[default]
+ HorizontalTb,
+ VerticalRl,
+}
+
+/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs
+/// sideways, `Upright` keeps every character upright. Ignored in
+/// horizontal writing.
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum TextOrientation {
+ #[default]
+ Mixed,
+ Upright,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum TextCombineUpright {
+ #[default]
+ None,
+ All,
+ /// Combine runs of 2-4 consecutive ASCII or full-width digits into one upright
+ /// composite; other characters keep the normal vertical layout.
+ Digits,
+ /// Like `Digits` but only runs of exactly 2 digits combine
+ /// (CSS `text-combine-upright: digits 2`).
+ Digits2,
+ /// Like `Digits` but runs of 2-3 digits combine.
+ Digits3,
+}
+
+impl TextCombineUpright {
+ /// Longest digit run that combines, when digits mode is active.
+ pub fn digits_max(self) -> Option {
+ match self {
+ TextCombineUpright::Digits => Some(4),
+ TextCombineUpright::Digits2 => Some(2),
+ TextCombineUpright::Digits3 => Some(3),
+ _ => None,
+ }
+ }
+}
+
+/// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS
+/// `text-emphasis-style`. The mark is drawn above each eligible horizontal
+/// base character or to the right of its vertical column.
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum TextEmphasis {
+ #[default]
+ None,
+ FilledDot,
+ OpenDot,
+ FilledCircle,
+ OpenCircle,
+ FilledSesame,
+ OpenSesame,
+}
+
+impl TextEmphasis {
+ pub fn is_none(self) -> bool {
+ matches!(self, TextEmphasis::None)
+ }
+
+ /// The glyph drawn as the emphasis mark, following the CSS
+ /// `text-emphasis-style` character mapping.
+ pub fn mark_char(self) -> Option {
+ match self {
+ TextEmphasis::None => None,
+ TextEmphasis::FilledDot => Some('•'),
+ TextEmphasis::OpenDot => Some('◦'),
+ TextEmphasis::FilledCircle => Some('●'),
+ TextEmphasis::OpenCircle => Some('○'),
+ TextEmphasis::FilledSesame => Some('﹅'),
+ TextEmphasis::OpenSesame => Some('﹆'),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum FontFeatures {
+ #[default]
+ None,
+ Palt,
+ Vpal,
+}
+
+/// Controls whether annotation layers participate in line/column spacing.
+/// The default preserves legacy documents; `Auto` reserves one half-em for
+/// each active ruby or emphasis layer.
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum AnnotationClearance {
+ #[default]
+ None,
+ Auto,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum RubySize {
+ #[default]
+ Half,
+ Third,
+ Quarter,
+}
+
+impl RubySize {
+ pub fn scale(self) -> f32 {
+ match self {
+ Self::Half => 0.5,
+ Self::Third => 1.0 / 3.0,
+ Self::Quarter => 0.25,
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum RubyAlign {
+ #[default]
+ SpaceAround,
+ Center,
+ Start,
+ SpaceBetween,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum RubyOverhang {
+ #[default]
+ Auto,
+ None,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Default)]
+pub enum RubySide {
+ #[default]
+ Over,
+ Under,
+}
+
+impl AnnotationClearance {
+ pub fn is_auto(self) -> bool {
+ matches!(self, AnnotationClearance::Auto)
+ }
+}
+
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum TextTransform {
Lowercase,
@@ -1097,6 +1882,8 @@ pub struct Paragraph {
text_direction: TextDirection,
text_decoration: Option,
text_transform: Option,
+ writing_mode: WritingMode,
+ text_orientation: TextOrientation,
line_height: f32,
letter_spacing: f32,
children: Vec,
@@ -1109,6 +1896,8 @@ impl Default for Paragraph {
text_direction: TextDirection::LTR,
text_decoration: None,
text_transform: None,
+ writing_mode: WritingMode::default(),
+ text_orientation: TextOrientation::default(),
line_height: 1.0,
letter_spacing: 0.0,
children: vec![],
@@ -1132,12 +1921,30 @@ impl Paragraph {
text_direction,
text_decoration,
text_transform,
+ writing_mode: WritingMode::default(),
+ text_orientation: TextOrientation::default(),
line_height,
letter_spacing,
children,
}
}
+ pub fn writing_mode(&self) -> WritingMode {
+ self.writing_mode
+ }
+
+ pub fn set_writing_mode(&mut self, writing_mode: WritingMode) {
+ self.writing_mode = writing_mode;
+ }
+
+ pub fn text_orientation(&self) -> TextOrientation {
+ self.text_orientation
+ }
+
+ pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) {
+ self.text_orientation = text_orientation;
+ }
+
pub fn children(&self) -> &[TextSpan] {
&self.children
}
@@ -1170,6 +1977,36 @@ impl Paragraph {
self.text_transform
}
+ /// Span texts as fed to the paragraph builders: text-transform applied,
+ /// Japanese spacing normalized, and kinsoku break suppressions inserted,
+ /// plus the map between original and builder-text UTF-16 offsets. Every
+ /// consumer of laid-out offsets must translate through the map. The layout
+ /// transform is skipped under letter-spacing, where skparagraph would add
+ /// letter spacing to synthetic layout characters.
+ pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) {
+ let texts: Vec = self
+ .children
+ .iter()
+ .map(|s| s.apply_text_transform())
+ .collect();
+ let has_letter_spacing =
+ self.letter_spacing != 0.0 || self.children.iter().any(|s| s.letter_spacing != 0.0);
+ if !has_letter_spacing {
+ let ruby_breaks: Vec