diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index d7d993e214..2b908a0bdd 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -41,8 +41,6 @@ (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" @@ -58,7 +56,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" - "text-vertical/v1" + "japanese-layout/v1" "render-wasm/v1" "variants/v1"}) @@ -84,7 +82,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" - "text-vertical/v1" + "japanese-layout/v1" "tokens/numeric-input" "render-wasm/v1"}) @@ -135,7 +133,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-japanese-layout "japanese-layout/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/text.cljc b/common/src/app/common/types/text.cljc index 87ad892f6e..bb73760d69 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -11,6 +11,7 @@ [app.common.flags :as flags] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] + [app.common.types.text.japanese-layout :as jl] [clojure.set :as set] [clojure.walk :as walk] [cuerdas.core :as str])) @@ -42,56 +43,6 @@ (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]) @@ -118,20 +69,20 @@ (d/concat-vec text-align-attrs text-direction-attrs - text-writing-mode-attrs - text-orientation-attrs)) + jl/text-writing-mode-attrs + jl/text-orientation-attrs)) (def text-node-attrs (d/concat-vec text-typography-attrs text-font-attrs text-spacing-attrs - text-combine-upright-attrs - text-emphasis-attrs - text-ruby-attrs - text-warichu-attrs - text-font-features-attrs - text-annotation-clearance-attrs + jl/text-combine-upright-attrs + jl/text-emphasis-attrs + jl/text-ruby-attrs + jl/text-warichu-attrs + jl/text-font-features-attrs + jl/text-annotation-clearance-attrs text-decoration-attrs text-transform-attrs text-fills)) diff --git a/common/src/app/common/types/text/japanese_layout.cljc b/common/src/app/common/types/text/japanese_layout.cljc new file mode 100644 index 0000000000..bdd9e4707f --- /dev/null +++ b/common/src/app/common/types/text/japanese_layout.cljc @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.text.japanese-layout + (:require + [app.common.data.macros :as dm])) + +;; Vertical writing (tategaki). Absent values behave as "horizontal-tb" +;; and "mixed", so plain horizontal text never stores these attrs. +(def text-writing-mode-attrs + [:writing-mode]) + +(def text-orientation-attrs + [:text-orientation]) + +(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." + [content] + (dm/get-in content [:children 0 :children 0 :writing-mode])) + +(defn vertical-text-content? + "True when the text content flows vertically (vertical-rl)." + [content] + (= "vertical-rl" (content-writing-mode content))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index da3e0a0bd8..259ba2e205 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -23,7 +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.types.text.japanese-layout :as jl] [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] [app.main.data.workspace.comments :as-alias dwcm] @@ -1013,7 +1013,7 @@ (defn vertical-text-shape? [shape] - (txt/vertical-text-content? (get shape :content))) + (jl/vertical-text-content? (get shape :content))) ;; Pure function to determine next grow-type for text layers (defn next-grow-type @@ -1021,9 +1021,7 @@ (next-grow-type current-grow-type scalev false)) ([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. + ;; Reuse the horizontal state machine with swapped vertical axes. (let [scalev (if vertical? (gpt/point (:y scalev) (:x scalev)) scalev)] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index d30d81299d..f101553f5c 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -20,6 +20,7 @@ [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.event :as ev] @@ -300,7 +301,6 @@ (defn current-paragraph-values [{:keys [editor-styles editor-state editor-instance attrs shape] :as options}] (cond - ;; 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) @@ -451,7 +451,6 @@ (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 @@ -959,8 +958,8 @@ ;; 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)) + (select-keys attrs (d/concat-vec jl/text-writing-mode-attrs + jl/text-orientation-attrs)) selection-attrs (apply dissoc attrs (keys whole-shape-attrs))] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 24dc0f86d3..ad68cf0b31 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -15,7 +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.common.types.text.japanese-layout :as jl] [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.shapes :as dwsh] @@ -32,7 +32,7 @@ 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)] + (let [vertical? (jl/vertical-text-content? content)] {:width (cond (= :fixed grow-type) (:width selrect) 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 89babc59ed..6caa72de4f 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -10,7 +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.common.types.text.japanese-layout :as jl] [app.main.ui.shapes.text.styles :as sts] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -170,7 +170,7 @@ ;; 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) + vertical? (jl/vertical-text-content? content) [colors _color-mapping color-mapping-inverse] (retrieve-colors shape)] 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 869b16d2cd..5e6c3a39f6 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,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.text :as legacy.txt] - [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.main.ui.shapes.text.styles :as sts] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -86,7 +86,7 @@ ;; 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) + vertical? (jl/vertical-text-content? content) style (when-not is-code diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index ae3200ebb2..c7b374dcf3 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -10,6 +10,7 @@ [app.common.transit :as transit] [app.common.types.color :as cc] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.main.fonts :as fonts] [app.main.ui.formats :as fmt] [app.util.color :as uc] @@ -23,7 +24,7 @@ (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) + writing-mode (jl/content-writing-mode node) base #js {:height (when-not code? (fmt/format-pixels height)) :width (when-not code? (fmt/format-pixels width)) :display "flex" 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 5cc354bc50..2a0f9cdda1 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -14,7 +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.common.types.text.japanese-layout :as jl] [app.config :as cf] [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] @@ -236,7 +236,7 @@ :opacity (when @blurred 0)} :on-pointer-down on-pointer-down :class (dom/classnames - (cur/get-text (:rotation shape) (txt/vertical-text-content? content)) true + (cur/get-text (:rotation shape) (jl/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 09023f72db..8270623d3a 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 @@ -16,6 +16,7 @@ [app.common.math :as mth] [app.common.types.color :as color] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.config :as cf] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] @@ -296,7 +297,7 @@ (dom/set-style! container-node "--text-editor-caret-color" text-color))) [:div - {:class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? content)) + {:class (dm/str (cur/get-text (:rotation shape) (jl/vertical-text-content? content)) " " (stl/css :text-editor-container)) :ref container-ref 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 7c4b041e35..72ac024eed 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -10,6 +10,7 @@ (:require [app.common.data.macros :as dm] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.main.data.helpers :as dsh] [app.main.data.workspace.texts :as dwt] [app.main.refs :as refs] @@ -405,7 +406,7 @@ :on-focus on-focus :on-blur on-blur :id "text-editor-wasm-input" - :class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? (:content shape))) + :class (dm/str (cur/get-text (:rotation shape) (jl/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 950464fe23..f451fcffbd 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,14 +25,11 @@ [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.text-japanese-layout :as tjl] [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] @@ -55,8 +52,6 @@ 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 "")) @@ -69,112 +64,6 @@ (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 @@ -236,397 +125,6 @@ :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 (radio-selected (:vertical-align values) "top") @@ -896,17 +394,6 @@ ;; --- 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) @@ -948,10 +435,6 @@ (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 @@ -1046,16 +529,15 @@ :show-recent true :on-blur on-text-blur}) - ruby-presentation-props + japanese-layout-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})] + {:ids ids + :values values + :ruby-values ruby-values + :text-selection-active text-selection-active + :on-change on-change + :on-ruby-presentation-change on-ruby-presentation-change + :on-blur on-text-blur})] (hooks/use-stream expand-stream @@ -1073,19 +555,6 @@ (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)} @@ -1176,27 +645,4 @@ [:> 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])])])])])])) + [:> tjl/japanese-layout-options* japanese-layout-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 7c270eb8ef..adc7da205a 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,93 +57,13 @@ .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/menus/text_japanese_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs new file mode 100644 index 0000000000..b2c19e28f5 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.cljs @@ -0,0 +1,591 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.main.features :as features] + [app.main.ui.components.title-bar :refer [title-bar*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] + [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [rumext.v2 :as mf])) + +(defn- radio-selected + ([value] + (radio-selected value "")) + ([value default] + (cond + (= value :multiple) "" + (or (nil? value) + (and (string? value) (empty? value))) default + (keyword? value) (d/name value) + (string? value) value + :else (str value)))) + +(def ^:private mixed-span-values + #{:mixed :multiple "mixed" "multiple"}) + +(defn- mixed-span-value? + [value] + (contains? mixed-span-values value)) + +(defn- span-input-value + [value] + (if (mixed-span-value? value) + "mixed" + (radio-selected value))) + +(defn- span-select-value + [value default] + (if (mixed-span-value? value) + "mixed" + (radio-selected value default))) + +(defn- with-mixed-span-option + [options value] + (cond-> options + (mixed-span-value? value) + (conj {:id "mixed" + :label (tr "labels.mixed-values") + :disabled true + :dimmed true}))) + +(defn japanese-layout-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 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 japanese-layout-options* + [{:keys [ids values ruby-values text-selection-active + on-change on-ruby-presentation-change on-blur]}] + (let [enabled? (features/use-feature "japanese-layout/v1") + selection-key (hash ids) + previous-selection-key-ref (mf/use-ref selection-key) + active* (mf/use-state #(japanese-layout-enabled? values)) + active? (deref active*) + vertical? (vertical-japanese-layout? values) + toggle (mf/use-fn #(reset! active* %)) + common-props (mf/props + {:values values + :on-change on-change + :on-blur on-blur}) + ruby-presentation-props (mf/props + {:values ruby-values + :on-change on-ruby-presentation-change + :on-blur on-blur}) + toggle-props (mf/spread-props + common-props + {:enabled active? + :on-toggle toggle})] + + (mf/with-effect [selection-key (:writing-mode values)] + (let [selection-changed? + (not= selection-key (mf/ref-val previous-selection-key-ref))] + (reset! active* + (reconcile-japanese-layout-state active? + values + selection-changed?)) + (mf/set-ref-val! previous-selection-key-ref selection-key))) + + (when ^boolean enabled? + [:div {:class (stl/css :japanese-layout-options)} + [:> japanese-layout-toggle* toggle-props] + (when ^boolean active? + [:div {:class (stl/css :japanese-layout-controls)} + [:div {:class (stl/css :japanese-icon-options)} + [:> writing-mode-options* common-props] + (when ^boolean vertical? + [:* + [:> text-orientation-options* common-props] + [:> text-combine-upright-options* + (mf/spread-props common-props + {:text-selection-active text-selection-active})]]) + (when ^boolean text-selection-active + [:> warichu-options* common-props])] + (when ^boolean vertical? + [:> text-combine-upright-count-options* common-props]) + [:> font-features-options* common-props] + (when ^boolean text-selection-active + [:> text-emphasis-options* common-props]) + [:> annotation-clearance-options* common-props] + (if text-selection-active + [:> ruby-advanced-options* common-props] + [:> ruby-presentation-options* ruby-presentation-props])])]))) + diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss new file mode 100644 index 0000000000..ce77246a82 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_japanese_layout.scss @@ -0,0 +1,87 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC Sucursal en España SL + +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; + +.writing-mode-options, +.text-orientation-options, +.text-combine-upright-options, +.warichu-options { + block-size: $sz-32; +} + +.ruby-advanced-options { + display: flex; + flex-direction: column; + overflow: visible; +} + +.ruby-options { + display: flex; + align-items: flex-end; + gap: var(--sp-xs); +} + +.ruby-input { + flex: 1; +} + +.ruby-advanced-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-xs); + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; +} + +.text-emphasis-options, +.font-features-options, +.annotation-clearance-options { + inline-size: 100%; +} + +.japanese-layout-options, +.japanese-layout-controls { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.japanese-layout-options { + padding-block-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/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 76b9237413..f1d15c9f1d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -15,7 +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.common.types.text.japanese-layout :as jl] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] @@ -501,7 +501,7 @@ (keyword)) horizontal-resize? (contains? #{:right :left} position) vertical-resize? (contains? #{:top :bottom} position) - vertical-text? (txt/vertical-text-content? (get shape :content)) + vertical-text? (jl/vertical-text-content? (get shape :content)) wrap-axis-resize? (if vertical-text? vertical-resize? horizontal-resize?) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 17bc779203..a603e7e61d 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -398,7 +398,6 @@ :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 diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs index a5091051ca..8a5b48755b 100644 --- a/frontend/src/app/util/text/content/to_dom.cljs +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.util.dom :as dom] [app.util.text.content.styles :as styles])) @@ -91,11 +92,8 @@ (defn get-root-styles [root] (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)] + ;; Stack vertical paragraphs right-to-left in the DOM editor. + writing-mode (jl/content-writing-mode root)] (cond-> styles (some? writing-mode) (assoc :writing-mode writing-mode)))) diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index 33a43c5ae3..7d9c008b56 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -275,8 +275,7 @@ (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/deftest resolve-han-unification + ;; 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/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs index 7fd1451b18..ca4c830b7c 100644 --- a/frontend/test/frontend_tests/ui/text_options_test.cljs +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -10,7 +10,7 @@ [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.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl] [app.util.text.content.styles :as content-styles] [cljs.test :as t :include-macros true] [cuerdas.core :as str] @@ -28,29 +28,29 @@ :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) +(def ^:private radio-selected @#'tjl/radio-selected) +(def ^:private span-input-value @#'tjl/span-input-value) +(def ^:private span-select-value @#'tjl/span-select-value) +(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option) +(def ^:private ruby-common-props @#'tjl/ruby-common-props) (t/deftest text-emphasis-select-reports-and-restores-canonical-values - (let [options (text-menu/text-emphasis-options identity) + (let [options (tjl/text-emphasis-options identity) reported-id (:id (select/get-option options "filled-dot"))] (t/is (= "filled-dot" reported-id)) (t/is (= "filled-dot" (:id (select/get-option options reported-id)))))) (t/deftest annotation-clearance-select-reports-canonical-values - (let [options (text-menu/annotation-clearance-options identity)] + (let [options (tjl/annotation-clearance-options identity)] (t/is (= ["none" "auto"] (mapv :id options))) (t/is (= "auto" (:id (select/get-option options "auto")))))) (t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection (t/is (= ["none" "digits"] - (mapv :value (text-menu/text-combine-upright-options false identity)))) + (mapv :value (tjl/text-combine-upright-options false identity)))) (t/is (= ["none" "all" "digits"] - (mapv :value (text-menu/text-combine-upright-options true identity))))) + (mapv :value (tjl/text-combine-upright-options true identity))))) (t/deftest ruby-container-styles-preserve-customization (let [style (text-styles/generate-ruby-container-styles @@ -63,7 +63,7 @@ (t/deftest advanced-furigana-options-are-collapsed-by-default (let [markup (rds/renderToStaticMarkup - (mf/element text-menu/ruby-presentation-options* + (mf/element tjl/ruby-presentation-options* #js {:values {} :on-change identity :on-blur identity}))] @@ -102,7 +102,7 @@ (t/deftest mixed-japanese-span-values-have-distinct-control-states (let [options (with-mixed-span-option - (text-menu/text-emphasis-options identity) + (tjl/text-emphasis-options identity) :multiple)] (t/is (= "mixed" (span-select-value :multiple "none"))) (t/is (= "mixed" (span-input-value :multiple))) @@ -177,31 +177,31 @@ {"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/is (false? (tjl/japanese-layout-enabled? {}))) + (t/is (false? (tjl/japanese-layout-enabled? {:writing-mode nil}))) + (t/is (true? (tjl/japanese-layout-enabled? {:writing-mode "horizontal-tb"}))) + (t/is (true? (tjl/japanese-layout-enabled? {:writing-mode "vertical-rl"}))) + (t/is (nil? (tjl/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))) + (tjl/japanese-layout-toggle-attrs true))) (t/is (= {:writing-mode nil} - (text-menu/japanese-layout-toggle-attrs false)))) + (tjl/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/is (true? (tjl/reconcile-japanese-layout-state true {} false))) + (t/is (true? (tjl/reconcile-japanese-layout-state false + {:writing-mode "vertical-rl"} + false))) + (t/is (false? (tjl/reconcile-japanese-layout-state true {} true)))) (t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes - (t/is (false? (text-menu/vertical-japanese-layout? + (t/is (false? (tjl/vertical-japanese-layout? {:writing-mode "horizontal-tb"}))) - (t/is (true? (text-menu/vertical-japanese-layout? + (t/is (true? (tjl/vertical-japanese-layout? {:writing-mode "vertical-rl"}))) (t/is (= "palt" - (text-menu/proportional-metrics-feature "horizontal-tb"))) + (tjl/proportional-metrics-feature "horizontal-tb"))) (t/is (= "vpal" - (text-menu/proportional-metrics-feature "vertical-rl")))) + (tjl/proportional-metrics-feature "vertical-rl")))) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts index 3dbaaf61c9..51b12c6706 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts @@ -81,6 +81,38 @@ describe('Text', () => { expect(t.direction).toBe('rtl'); }); + test('Japanese paragraph layout properties round-trip', (ctx) => { + const t = text(ctx, '日本語'); + t.writingMode = 'vertical-rl'; + t.textOrientation = 'upright'; + expect(t.writingMode).toBe('vertical-rl'); + expect(t.textOrientation).toBe('upright'); + }); + + test('Japanese span layout properties round-trip', (ctx) => { + const t = text(ctx, '日本語'); + t.textCombineUpright = 'digits2'; + t.textEmphasis = 'filled-dot'; + t.warichu = 'warichu'; + t.fontFeatures = 'vpal'; + t.annotationClearance = 'auto'; + t.ruby = 'にほんご'; + t.rubySize = 'third'; + t.rubyAlign = 'center'; + t.rubyOverhang = 'none'; + t.rubySide = 'under'; + expect(t.textCombineUpright).toBe('digits2'); + expect(t.textEmphasis).toBe('filled-dot'); + expect(t.warichu).toBe('warichu'); + expect(t.fontFeatures).toBe('vpal'); + expect(t.annotationClearance).toBe('auto'); + expect(t.ruby).toBe('にほんご'); + expect(t.rubySize).toBe('third'); + expect(t.rubyAlign).toBe('center'); + expect(t.rubyOverhang).toBe('none'); + expect(t.rubySide).toBe('under'); + }); + test('font identity and variant setters accept a real font/variant', (ctx) => { const t = text(ctx); const font = ctx.penpot.fonts.all[0]; @@ -172,6 +204,31 @@ describe('Text', () => { expect(range.textDecoration).toBe('line-through'); }); + test('range Japanese layout properties round-trip', (ctx) => { + const t = text(ctx, '日本語'); + const range = t.getRange(0, 3); + range.textCombineUpright = 'all'; + range.textEmphasis = 'open-circle'; + range.warichu = 'warichu'; + range.fontFeatures = 'palt'; + range.annotationClearance = 'auto'; + range.ruby = 'にほんご'; + range.rubySize = 'quarter'; + range.rubyAlign = 'space-between'; + range.rubyOverhang = 'none'; + range.rubySide = 'under'; + expect(range.textCombineUpright).toBe('all'); + expect(range.textEmphasis).toBe('open-circle'); + expect(range.warichu).toBe('warichu'); + expect(range.fontFeatures).toBe('palt'); + expect(range.annotationClearance).toBe('auto'); + expect(range.ruby).toBe('にほんご'); + expect(range.rubySize).toBe('quarter'); + expect(range.rubyAlign).toBe('space-between'); + expect(range.rubyOverhang).toBe('none'); + expect(range.rubySide).toBe('under'); + }); + test('range fills round-trip', (ctx) => { const t = text(ctx, 'Hello Penpot'); const range = t.getRange(0, 5); diff --git a/render-wasm/fixtures/japanese-typography.json b/render-wasm/fixtures/japanese-typography.json deleted file mode 100644 index 6dc8798729..0000000000 --- a/render-wasm/fixtures/japanese-typography.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "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/render.rs b/render-wasm/src/render.rs index 7ce883d9bb..763a8cebed 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1453,101 +1453,16 @@ impl RenderState { ) }) .unzip(); - // 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, + text::render_vertical_text( + self, &shape, + text_content, &mut paragraph_builders, - Some(fills_surface_id), - None, - blur_filter.as_ref(), - None, - None, + fills_surface_id, + strokes_surface_id, + skip_effects, )?; - - // 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( diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 8e73177467..0addce190d 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::{ - add_horizontal_span, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, - ParagraphLayout, Stroke, StrokeKind, TextContent, + add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical, + ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -15,6 +15,91 @@ use skia_safe::{ Canvas, ImageFilter, Paint, }; +pub fn render_vertical_text( + state: &mut RenderState, + shape: &Shape, + text_content: &TextContent, + paragraph_builders: &mut [ParagraphBuilderGroup], + fills_surface_id: SurfaceId, + strokes_surface_id: SurfaceId, + skip_effects: bool, +) -> Result<()> { + let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten(); + let bounds = text_content.bounds(); + let mut drop_shadows = if skip_effects { + Vec::new() + } else { + shape.drop_shadow_paints() + }; + if !skip_effects { + if let Some(inherited_shadows) = state.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } + let strokes: Vec = shape.visible_strokes().rev().cloned().collect(); + let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| { + let max_height = text_vertical::wrap_height(text_content, bounds.height()); + text_vertical::layout_from_content(text_content, max_height) + }); + + if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) { + let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); + for shadow in &drop_shadows { + text_vertical::paint_drop_shadow( + canvas, + layout, + &bounds, + shape.vertical_align(), + shadow, + ); + } + } + + render( + Some(state), + None, + shape, + paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + None, + None, + )?; + + if let Some(layout) = layout.as_ref().filter(|_| !strokes.is_empty()) { + let selrect = shape.selrect(); + let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id); + for stroke in &strokes { + text_vertical::paint_stroke( + canvas, + layout, + &bounds, + shape.vertical_align(), + stroke, + &selrect, + blur_filter.as_ref(), + ); + } + } + + if state.options.is_text_grid_visible() { + let owned_layout; + let grid_layout = match layout.as_ref() { + Some(layout) => layout, + None => { + let max_height = text_vertical::wrap_height(text_content, bounds.height()); + owned_layout = text_vertical::layout_from_content(text_content, max_height); + &owned_layout + } + }; + let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); + text_vertical::paint_grid(canvas, grid_layout, &bounds, shape.vertical_align()); + } + + Ok(()) +} + pub fn stroke_paragraph_builder_group_from_text( text_content: &TextContent, stroke: &Stroke, diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 194e465fad..acf587233b 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -133,6 +133,78 @@ fn vertical_align_offset( } } +fn vertical_layout_for_shape( + text_content: &TextContent, + shape: &Shape, +) -> (text_vertical::VerticalLayout, f32) { + let selrect = shape.selrect(); + let max_height = text_vertical::wrap_height(text_content, selrect.height()); + let layout = text_vertical::layout_from_content(text_content, max_height); + let origin_x = + text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align()); + (layout, origin_x) +} + +fn calculate_vertical_cursor_rect( + text_content: &TextContent, + shape: &Shape, + paragraph: usize, + offset: usize, +) -> Option { + let (layout, origin_x) = vertical_layout_for_shape(text_content, shape); + let rect = text_vertical::caret_rect(&layout, paragraph, offset)?; + Some(Rect::from_xywh( + origin_x + rect.x(), + rect.y(), + rect.width(), + rect.height(), + )) +} + +fn calculate_vertical_selection_rects( + selection: &TextSelection, + text_content: &TextContent, + shape: &Shape, +) -> Vec { + let start = selection.start(); + let end = selection.end(); + let paragraphs = text_content.paragraphs(); + let (layout, origin_x) = vertical_layout_for_shape(text_content, shape); + let mut rects = Vec::new(); + + for (para_idx, paragraph) in paragraphs + .iter() + .enumerate() + .take(end.paragraph + 1) + .skip(start.paragraph) + { + let para_char_count: usize = paragraph + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let range_start = if para_idx == start.paragraph { + start.offset + } else { + 0 + }; + let range_end = if para_idx == end.paragraph { + end.offset + } else { + para_char_count + }; + for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) { + rects.push(Rect::from_xywh( + origin_x + rect.x(), + rect.y(), + rect.width(), + rect.height(), + )); + } + } + rects +} + fn calculate_cursor_rect( editor_state: &TextEditorState, text_content: &TextContent, @@ -144,24 +216,13 @@ 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(), - )); + return calculate_vertical_cursor_rect( + text_content, + shape, + cursor.paragraph, + cursor.offset, + ); } let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); @@ -268,54 +329,17 @@ fn calculate_selection_rects( text_content: &TextContent, shape: &Shape, ) -> Vec { - let mut rects = Vec::new(); - let start = selection.start(); let end = selection.end(); let paragraphs = text_content.paragraphs(); - // 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; + return calculate_vertical_selection_rects(selection, text_content, shape); } + let mut rects = Vec::new(); + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 8913e4765e..5bc32acce2 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -2,7 +2,8 @@ use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ - merge_fills, radius_to_sigma, BlurType, Fill, Frame, Rect, Shape, Stroke, StrokeKind, Type, + merge_fills, radius_to_sigma, BlurType, Fill, Frame, ParagraphBuilderGroup, Rect, Shape, + Stroke, StrokeKind, TextContent, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -48,6 +49,62 @@ impl<'a> VectorRenderer<'a> { _target: target, } } + + fn draw_vertical_text( + &mut self, + shape: &Shape, + text_content: &TextContent, + paragraph_builders: &mut [ParagraphBuilderGroup], + blur_filter: Option<&skia::ImageFilter>, + ) -> Result<()> { + use crate::shapes::text_vertical; + + let bounds = text_content.bounds(); + let drop_shadows = shape.drop_shadow_paints(); + let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect(); + let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| { + let max_height = text_vertical::wrap_height(text_content, bounds.height()); + text_vertical::layout_from_content(text_content, max_height) + }); + + if let Some(layout) = &layout { + for shadow in &drop_shadows { + text_vertical::paint_drop_shadow( + self.canvas, + layout, + &bounds, + shape.vertical_align(), + shadow, + ); + } + } + + text::render_overlay_emoji( + self.canvas, + shape, + paragraph_builders, + None, + blur_filter, + None, + None, + )?; + + if let Some(layout) = &layout { + let selrect = shape.selrect(); + for stroke in strokes { + text_vertical::paint_stroke( + self.canvas, + layout, + &bounds, + shape.vertical_align(), + stroke, + &selrect, + blur_filter, + ); + } + } + Ok(()) + } } impl ShapeRenderer for VectorRenderer<'_> { @@ -154,60 +211,13 @@ 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, + return self.draw_vertical_text( shape, + &text_content, &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 diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 166204272a..aec969d54d 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -30,6 +30,7 @@ mod strokes; mod svg_attrs; mod svgraw; mod text; +mod text_japanese; pub mod text_paths; pub mod text_vertical; mod transform; @@ -53,6 +54,7 @@ pub use strokes::*; pub use svg_attrs::*; pub use svgraw::*; pub use text::*; +pub use text_japanese::*; pub use transform::*; use crate::math::{self, Bounds, Matrix, Point}; diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1f2995ac5e..38c7e0745b 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -7,9 +7,7 @@ use crate::{ use core::f32; use macros::ToJs; -use skia_safe::textlayout::{ - PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline, -}; +use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::{ self as skia, paint::{self, Paint}, @@ -20,11 +18,10 @@ 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::text_japanese::*; use super::FontFamily; use crate::math::Point; use crate::shapes::{self, kinsoku, merge_fills, Shape, VerticalAlign}; @@ -34,539 +31,6 @@ 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 { @@ -1720,153 +1184,6 @@ 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, @@ -1984,27 +1301,7 @@ impl Paragraph { /// 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>> = self - .children - .iter() - .zip(&texts) - .map(|(span, _text)| (!span.ruby.trim().is_empty()).then(Vec::new)) - .collect(); - if let Some((shifted, map)) = - kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks) - { - return (shifted, map); - } - } - (texts, kinsoku::OffsetMap::default()) + layout_span_texts(self) } pub fn paragraph_to_style(&self) -> ParagraphStyle { diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs new file mode 100644 index 0000000000..a0e19a0657 --- /dev/null +++ b/render-wasm/src/shapes/text_japanese.rs @@ -0,0 +1,718 @@ +use super::text::{Paragraph, TextSpan}; +use crate::math::Point; +use crate::shapes::kinsoku; +use crate::utils::get_font_collection; +use skia_safe::{ + self as skia, + textlayout::{ + ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, + RectWidthStyle, TextBaseline, + }, + Contains, +}; + +pub const WARICHU_FONT_SCALE: f32 = 0.5; +pub const EMPHASIS_FONT_SCALE: f32 = 0.5; +const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3; +const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}'; +const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}'; + +pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku::OffsetMap) { + let texts: Vec = paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform) + .collect(); + let has_letter_spacing = paragraph.letter_spacing() != 0.0 + || paragraph + .children() + .iter() + .any(|span| span.letter_spacing != 0.0); + if !has_letter_spacing { + let ruby_breaks: Vec>> = paragraph + .children() + .iter() + .map(|span| (!span.ruby.trim().is_empty()).then(Vec::new)) + .collect(); + if let Some((shifted, map)) = + kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks) + { + return (shifted, map); + } + } + (texts, kinsoku::OffsetMap::default()) +} + +/// Add a span to a horizontal paragraph builder. Warichu is represented by a +/// single inline placeholder so the two annotation lines wrap as one unit. +/// The actual glyphs are painted after SkParagraph has positioned the box. +pub(crate) fn add_horizontal_span( + builder: &mut ParagraphBuilder, + span: &TextSpan, + builder_text: &str, + text_style: &skia::textlayout::TextStyle, + fonts: &skia::textlayout::FontCollection, +) { + if span.warichu && span.text.chars().count() >= 2 { + let text = span.apply_text_transform(); + let split = super::text_vertical::warichu_split_chars(&text); + let split_byte = text + .char_indices() + .nth(split) + .map(|(index, _)| index) + .unwrap_or(text.len()); + let (first, second) = text.split_at(split_byte); + let mut mini_style = text_style.clone(); + mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE); + mini_style.set_height(1.0); + mini_style.set_height_override(true); + mini_style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); + let measure = |line: &str| { + let mut mini = ParagraphBuilder::new(&ParagraphStyle::default(), fonts); + mini.push_style(&mini_style); + mini.add_text(line); + let mut paragraph = mini.build(); + paragraph.layout(f32::MAX); + paragraph.longest_line() + }; + let width = measure(first).max(measure(second)).max(0.01); + let height = span.font_size.max(0.01); + builder.add_placeholder(&PlaceholderStyle::new( + width, + height, + PlaceholderAlignment::Middle, + TextBaseline::Alphabetic, + height, + )); + // SkParagraph does not expose a placeholder's TextStyle through line + // metrics. A near-zero, inkless NBSP preserves the exact + // fill/stroke/shadow style for the custom paint pass; the following + // zero-width space restores a legal wrapping boundary after the + // atomic placeholder. + let mut anchor_style = text_style.clone(); + anchor_style.set_font_size(0.01); + anchor_style.set_height(0.01); + anchor_style.set_height_override(true); + anchor_style.set_letter_spacing(0.0); + builder.push_style(&anchor_style); + builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string()); + builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string()); + } else { + 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)] +pub(crate) struct HorizontalEmphasisPlacement { + pub(crate) span: usize, + pub(crate) mark: char, + pub(crate) rect: skia::Rect, +} + +/// Locate one horizontal emphasis mark above each eligible source character. +/// SkParagraph owns wrapping and bidi placement; querying each transformed +/// character range keeps the marks attached to the actual laid-out glyphs. +pub(crate) fn horizontal_emphasis_placements( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, +) -> Vec { + let (_, offset_map) = paragraph.layout_span_texts(); + let ranges = horizontal_span_ranges(paragraph); + let mut placements = Vec::new(); + + for range in ranges.iter().filter(|range| !range.warichu) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let transformed = span.apply_text_transform(); + let mut local_utf16 = 0usize; + for character in transformed.chars() { + let next_utf16 = local_utf16 + character.len_utf16(); + if emphasis_char_allowed(character) { + let shifted_start = offset_map.to_shifted(range.source_start + local_utf16); + let shifted_end = offset_map.to_shifted(range.source_start + next_utf16); + let builder_start = + range.builder_start + shifted_start.saturating_sub(range.shifted_start); + let builder_end = + range.builder_start + shifted_end.saturating_sub(range.shifted_start); + let scalar_rect = laid_out + .get_rects_for_range( + builder_start..builder_end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .into_iter() + .map(|textbox| textbox.rect) + .reduce(|mut rect, next| { + rect.join(next); + rect + }); + if let Some(rect) = scalar_rect { + placements.push(HorizontalEmphasisPlacement { + span: range.span, + mark, + rect, + }); + } + } + local_utf16 = next_utf16; + } + } + placements +} + +pub(crate) fn horizontal_span_style( + laid_out: &skia::textlayout::Paragraph, + range: &HorizontalSpanRange, +) -> Option { + // 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)); + } + } +} + +/// 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) + } +} diff --git a/render-wasm/src/shapes/text_vertical.rs b/render-wasm/src/shapes/text_vertical.rs index 16b9991509..8d2ef5011b 100644 --- a/render-wasm/src/shapes/text_vertical.rs +++ b/render-wasm/src/shapes/text_vertical.rs @@ -2578,11 +2578,11 @@ pub fn layout_vertical( } /// Emphasis-mark (圏点) font size relative to the base em. -const EMPHASIS_FONT_SCALE: f32 = super::text::EMPHASIS_FONT_SCALE; +const EMPHASIS_FONT_SCALE: f32 = super::text_japanese::EMPHASIS_FONT_SCALE; /// Warichu (割注) sub-line font size relative to the base em; two half-size /// sub-columns side by side fill the base em width. -const WARICHU_FONT_SCALE: f32 = super::text::WARICHU_FONT_SCALE; +const WARICHU_FONT_SCALE: f32 = super::text_japanese::WARICHU_FONT_SCALE; /// Char index where a warichu run splits into its two sub-lines: the /// balanced midpoint (first line longer), nudged so the second sub-line @@ -2622,7 +2622,7 @@ fn utf16_range_allows_emphasis(text: &str, start: usize, end: usize) -> bool { break; } let len = c.len_utf16(); - if offset + len > start && super::text::emphasis_char_allowed(c) { + if offset + len > start && super::text_japanese::emphasis_char_allowed(c) { return true; } offset += len; diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index b06a8d8899..2604e636ad 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -4,9 +4,7 @@ use super::{fills::RawFillData, fonts::RawFontStyle}; use crate::mem::{self, SerializableResult}; use crate::shapes::{ - self, AnnotationClearance, FontFeatures, GrowType, RubyAlign, RubyOverhang, RubySide, RubySize, - Shape, TextAlign, TextCombineUpright, TextDecoration, TextDirection, TextEmphasis, - TextTransform, Type, + self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{with_current_shape, with_current_shape_mut, with_state}; @@ -14,14 +12,15 @@ use crate::{with_current_shape, with_current_shape_mut, with_state}; use crate::error::Error; pub mod helpers; +mod japanese_layout; + +pub use japanese_layout::*; const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); const MAX_TEXT_FILLS: usize = 8; -// CHANGEME: Move all the types from japanes text layout to its own module - #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] pub enum RawTextAlign { @@ -58,214 +57,6 @@ impl From for TextDirection { } } -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawWritingMode { - HorizontalTb = 0, - VerticalRl = 1, -} - -impl From for shapes::WritingMode { - fn from(value: RawWritingMode) -> Self { - match value { - RawWritingMode::HorizontalTb => shapes::WritingMode::HorizontalTb, - RawWritingMode::VerticalRl => shapes::WritingMode::VerticalRl, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawTextOrientation { - Mixed = 0, - Upright = 1, -} - -impl From for shapes::TextOrientation { - fn from(value: RawTextOrientation) -> Self { - match value { - RawTextOrientation::Mixed => shapes::TextOrientation::Mixed, - RawTextOrientation::Upright => shapes::TextOrientation::Upright, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawTextCombineUpright { - None = 0, - All = 1, - Digits = 2, - Digits2 = 3, - Digits3 = 4, -} - -impl From for TextCombineUpright { - fn from(value: RawTextCombineUpright) -> Self { - match value { - RawTextCombineUpright::None => TextCombineUpright::None, - RawTextCombineUpright::All => TextCombineUpright::All, - RawTextCombineUpright::Digits => TextCombineUpright::Digits, - RawTextCombineUpright::Digits2 => TextCombineUpright::Digits2, - RawTextCombineUpright::Digits3 => TextCombineUpright::Digits3, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawTextEmphasis { - None = 0, - FilledDot = 1, - OpenDot = 2, - FilledCircle = 3, - OpenCircle = 4, - FilledSesame = 5, - OpenSesame = 6, -} - -impl From for TextEmphasis { - fn from(value: RawTextEmphasis) -> Self { - match value { - RawTextEmphasis::None => TextEmphasis::None, - RawTextEmphasis::FilledDot => TextEmphasis::FilledDot, - RawTextEmphasis::OpenDot => TextEmphasis::OpenDot, - RawTextEmphasis::FilledCircle => TextEmphasis::FilledCircle, - RawTextEmphasis::OpenCircle => TextEmphasis::OpenCircle, - RawTextEmphasis::FilledSesame => TextEmphasis::FilledSesame, - RawTextEmphasis::OpenSesame => TextEmphasis::OpenSesame, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawWarichu { - None = 0, - Warichu = 1, -} - -impl From for bool { - fn from(value: RawWarichu) -> Self { - value == RawWarichu::Warichu - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawFontFeatures { - None = 0, - Palt = 1, - Vpal = 2, -} - -impl From for FontFeatures { - fn from(value: RawFontFeatures) -> Self { - match value { - RawFontFeatures::None => FontFeatures::None, - RawFontFeatures::Palt => FontFeatures::Palt, - RawFontFeatures::Vpal => FontFeatures::Vpal, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawAnnotationClearance { - None = 0, - Auto = 1, -} - -impl From for AnnotationClearance { - fn from(value: RawAnnotationClearance) -> Self { - match value { - RawAnnotationClearance::None => AnnotationClearance::None, - RawAnnotationClearance::Auto => AnnotationClearance::Auto, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawRubySize { - Half = 0, - Third = 1, - Quarter = 2, -} - -impl From for RubySize { - fn from(value: RawRubySize) -> Self { - match value { - RawRubySize::Half => RubySize::Half, - RawRubySize::Third => RubySize::Third, - RawRubySize::Quarter => RubySize::Quarter, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawRubyAlign { - SpaceAround = 0, - Center = 1, - Start = 2, - SpaceBetween = 3, -} - -impl From for RubyAlign { - fn from(value: RawRubyAlign) -> Self { - match value { - RawRubyAlign::SpaceAround => RubyAlign::SpaceAround, - RawRubyAlign::Center => RubyAlign::Center, - RawRubyAlign::Start => RubyAlign::Start, - RawRubyAlign::SpaceBetween => RubyAlign::SpaceBetween, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawRubyOverhang { - Auto = 0, - None = 1, -} - -impl From for RubyOverhang { - fn from(value: RawRubyOverhang) -> Self { - match value { - RawRubyOverhang::Auto => RubyOverhang::Auto, - RawRubyOverhang::None => RubyOverhang::None, - } - } -} - -#[derive(Debug, PartialEq, Clone, Copy, ToJs)] -#[repr(u8)] -#[allow(dead_code)] -pub enum RawRubySide { - Over = 0, - Under = 1, -} - -impl From for RubySide { - fn from(value: RawRubySide) -> Self { - match value { - RawRubySide::Over => RubySide::Over, - RawRubySide::Under => RubySide::Under, - } - } -} - #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] pub enum RawTextDecoration { @@ -703,6 +494,9 @@ pub extern "C" fn calculate_position_data() -> *mut u8 { #[cfg(test)] mod tests { use super::*; + use crate::shapes::{ + AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize, + }; /// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16) /// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80) diff --git a/render-wasm/src/wasm/text/japanese_layout.rs b/render-wasm/src/wasm/text/japanese_layout.rs new file mode 100644 index 0000000000..5d5384bf79 --- /dev/null +++ b/render-wasm/src/wasm/text/japanese_layout.rs @@ -0,0 +1,213 @@ +use crate::shapes::{ + self, AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize, + TextCombineUpright, TextEmphasis, +}; +use macros::ToJs; + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawWritingMode { + HorizontalTb = 0, + VerticalRl = 1, +} + +impl From for shapes::WritingMode { + fn from(value: RawWritingMode) -> Self { + match value { + RawWritingMode::HorizontalTb => shapes::WritingMode::HorizontalTb, + RawWritingMode::VerticalRl => shapes::WritingMode::VerticalRl, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawTextOrientation { + Mixed = 0, + Upright = 1, +} + +impl From for shapes::TextOrientation { + fn from(value: RawTextOrientation) -> Self { + match value { + RawTextOrientation::Mixed => shapes::TextOrientation::Mixed, + RawTextOrientation::Upright => shapes::TextOrientation::Upright, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawTextCombineUpright { + None = 0, + All = 1, + Digits = 2, + Digits2 = 3, + Digits3 = 4, +} + +impl From for TextCombineUpright { + fn from(value: RawTextCombineUpright) -> Self { + match value { + RawTextCombineUpright::None => TextCombineUpright::None, + RawTextCombineUpright::All => TextCombineUpright::All, + RawTextCombineUpright::Digits => TextCombineUpright::Digits, + RawTextCombineUpright::Digits2 => TextCombineUpright::Digits2, + RawTextCombineUpright::Digits3 => TextCombineUpright::Digits3, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawTextEmphasis { + None = 0, + FilledDot = 1, + OpenDot = 2, + FilledCircle = 3, + OpenCircle = 4, + FilledSesame = 5, + OpenSesame = 6, +} + +impl From for TextEmphasis { + fn from(value: RawTextEmphasis) -> Self { + match value { + RawTextEmphasis::None => TextEmphasis::None, + RawTextEmphasis::FilledDot => TextEmphasis::FilledDot, + RawTextEmphasis::OpenDot => TextEmphasis::OpenDot, + RawTextEmphasis::FilledCircle => TextEmphasis::FilledCircle, + RawTextEmphasis::OpenCircle => TextEmphasis::OpenCircle, + RawTextEmphasis::FilledSesame => TextEmphasis::FilledSesame, + RawTextEmphasis::OpenSesame => TextEmphasis::OpenSesame, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawWarichu { + None = 0, + Warichu = 1, +} + +impl From for bool { + fn from(value: RawWarichu) -> Self { + value == RawWarichu::Warichu + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawFontFeatures { + None = 0, + Palt = 1, + Vpal = 2, +} + +impl From for FontFeatures { + fn from(value: RawFontFeatures) -> Self { + match value { + RawFontFeatures::None => FontFeatures::None, + RawFontFeatures::Palt => FontFeatures::Palt, + RawFontFeatures::Vpal => FontFeatures::Vpal, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawAnnotationClearance { + None = 0, + Auto = 1, +} + +impl From for AnnotationClearance { + fn from(value: RawAnnotationClearance) -> Self { + match value { + RawAnnotationClearance::None => AnnotationClearance::None, + RawAnnotationClearance::Auto => AnnotationClearance::Auto, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawRubySize { + Half = 0, + Third = 1, + Quarter = 2, +} + +impl From for RubySize { + fn from(value: RawRubySize) -> Self { + match value { + RawRubySize::Half => RubySize::Half, + RawRubySize::Third => RubySize::Third, + RawRubySize::Quarter => RubySize::Quarter, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawRubyAlign { + SpaceAround = 0, + Center = 1, + Start = 2, + SpaceBetween = 3, +} + +impl From for RubyAlign { + fn from(value: RawRubyAlign) -> Self { + match value { + RawRubyAlign::SpaceAround => RubyAlign::SpaceAround, + RawRubyAlign::Center => RubyAlign::Center, + RawRubyAlign::Start => RubyAlign::Start, + RawRubyAlign::SpaceBetween => RubyAlign::SpaceBetween, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawRubyOverhang { + Auto = 0, + None = 1, +} + +impl From for RubyOverhang { + fn from(value: RawRubyOverhang) -> Self { + match value { + RawRubyOverhang::Auto => RubyOverhang::Auto, + RawRubyOverhang::None => RubyOverhang::None, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +#[allow(dead_code)] +pub enum RawRubySide { + Over = 0, + Under = 1, +} + +impl From for RubySide { + fn from(value: RawRubySide) -> Self { + match value { + RawRubySide::Over => RubySide::Over, + RawRubySide::Under => RubySide::Under, + } + } +}