diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index a5097a29a7..d7d993e214 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -41,6 +41,8 @@ (def ^:dynamic *wrap-with-objects-map-fn* identity) (def ^:dynamic *wrap-with-pointer-map-fn* identity) +;; CHANGEME: The name of text-vertical/v1 is not clear for the feature, change for japanese-layout/v1 + ;; A set of supported features (def supported-features #{"fdata/objects-map" @@ -56,6 +58,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" + "text-vertical/v1" "render-wasm/v1" "variants/v1"}) @@ -81,6 +84,7 @@ "text-editor/v2-html-paste" "text-editor/v2" "text-editor-wasm/v1" + "text-vertical/v1" "tokens/numeric-input" "render-wasm/v1"}) @@ -131,6 +135,7 @@ :feature-text-editor-v2 "text-editor/v2" :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-wasm "text-editor-wasm/v1" + :feature-text-vertical "text-vertical/v1" :feature-render-wasm "render-wasm/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index c5211e8a4a..9b6b5a7754 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -38,6 +38,8 @@ [:font-style {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text] [:direction {:optional true} ::sm/text] + [:writing-mode {:optional true} ::sm/text] + [:text-orientation {:optional true} ::sm/text] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text] [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] @@ -54,6 +56,17 @@ [:font-style {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text] [:direction {:optional true} ::sm/text] + [:text-combine-upright {:optional true} ::sm/text] + [:text-emphasis {:optional true} ::sm/text] + [:ruby {:optional true} :string] + [:ruby-size {:optional true} ::sm/text] + [:ruby-align {:optional true} ::sm/text] + [:ruby-overhang {:optional true} ::sm/text] + [:ruby-side {:optional true} ::sm/text] + [:warichu {:optional true} ::sm/text] + [:font-features {:optional true} ::sm/text] + [:annotation-clearance {:optional true} ::sm/text] + [:text-orientation {:optional true} ::sm/text] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text] [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] @@ -75,6 +88,19 @@ [:font-style {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text] [:rtl {:optional true} :boolean] + [:writing-mode {:optional true} ::sm/text] + [:text-orientation {:optional true} ::sm/text] + [:text-combine-upright {:optional true} ::sm/text] + [:text-emphasis {:optional true} ::sm/text] + [:ruby {:optional true} :string] + [:ruby-size {:optional true} ::sm/text] + [:ruby-align {:optional true} ::sm/text] + [:ruby-overhang {:optional true} ::sm/text] + [:ruby-side {:optional true} ::sm/text] + [:warichu {:optional true} ::sm/text] + [:font-features {:optional true} ::sm/text] + [:annotation-clearance {:optional true} ::sm/text] + [:annotation-has-ruby {:optional true} :boolean] [:text {:optional true} :string] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text]]]) diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 6068cfc829..87ad892f6e 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -42,6 +42,56 @@ (def text-direction-attrs [:text-direction]) +;; CHANGEME: All these new attributes can be moved to a new namespace in app.common.types.text.japanese-layout + +;; Vertical writing (tategaki). Absent values behave as "horizontal-tb" +;; and "mixed", so plain horizontal text never stores these attrs. +(def text-writing-mode-attrs + [:writing-mode]) + +(def text-orientation-attrs + [:text-orientation]) + +(def text-combine-upright-attrs + [:text-combine-upright]) + +;; Emphasis mark (圏点 / bouten) applied per span; absent means no emphasis. +(def text-emphasis-attrs + [:text-emphasis]) + +;; Ruby (furigana) annotation text and customization carried per span. +(def text-ruby-attrs + [:ruby + :ruby-size + :ruby-align + :ruby-overhang + :ruby-side]) + +;; Warichu (割注): the span renders as two half-size lines stacked inline +;; within one column position. Values "warichu" / "none"; absent means off. +(def text-warichu-attrs + [:warichu]) + +(def text-font-features-attrs + [:font-features]) + +;; Annotation collision policy. "none" preserves the explicit line height; +;; "auto" reserves an additional half-em layer for ruby and emphasis. +(def text-annotation-clearance-attrs + [:annotation-clearance]) + +(defn content-writing-mode + "Writing mode of a text content. Stored per paragraph but treated as a + whole-shape property: the first paragraph decides the flow (mirrors + TextContent::is_vertical in wasm)." + [content] + (dm/get-in content [:children 0 :children 0 :writing-mode])) + +(defn vertical-text-content? + "True when the text content flows vertically (vertical-rl)." + [content] + (= "vertical-rl" (content-writing-mode content))) + (def text-spacing-attrs [:line-height :letter-spacing]) @@ -67,13 +117,21 @@ (def paragraph-attrs (d/concat-vec text-align-attrs - text-direction-attrs)) + text-direction-attrs + text-writing-mode-attrs + text-orientation-attrs)) (def text-node-attrs (d/concat-vec text-typography-attrs text-font-attrs text-spacing-attrs + text-combine-upright-attrs + text-emphasis-attrs + text-ruby-attrs + text-warichu-attrs + text-font-features-attrs + text-annotation-clearance-attrs text-decoration-attrs text-transform-attrs text-fills)) diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index 400636c5dd..94ca12ef57 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -32,6 +32,10 @@ function mergeBlockData(block, newData) { let data = block.getData(); for (let key of Object.keys(newData)) { + if (newData[key] == null) { + data = data.delete(key); + continue; + } const oldVal = data.get(key); if (oldVal === newData[key]) { data = data.delete(key); diff --git a/frontend/resources/images/icons/text-combine-upright-all.svg b/frontend/resources/images/icons/text-combine-upright-all.svg new file mode 100644 index 0000000000..594b7f49fd --- /dev/null +++ b/frontend/resources/images/icons/text-combine-upright-all.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-combine-upright-digits.svg b/frontend/resources/images/icons/text-combine-upright-digits.svg new file mode 100644 index 0000000000..3e367a3fd3 --- /dev/null +++ b/frontend/resources/images/icons/text-combine-upright-digits.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-combine-upright-none.svg b/frontend/resources/images/icons/text-combine-upright-none.svg new file mode 100644 index 0000000000..f54cf6f1e3 --- /dev/null +++ b/frontend/resources/images/icons/text-combine-upright-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-orientation-mixed.svg b/frontend/resources/images/icons/text-orientation-mixed.svg new file mode 100644 index 0000000000..72cd592478 --- /dev/null +++ b/frontend/resources/images/icons/text-orientation-mixed.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-orientation-upright.svg b/frontend/resources/images/icons/text-orientation-upright.svg new file mode 100644 index 0000000000..67d241b2bb --- /dev/null +++ b/frontend/resources/images/icons/text-orientation-upright.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/warichu-none.svg b/frontend/resources/images/icons/warichu-none.svg new file mode 100644 index 0000000000..7ede0d3d0a --- /dev/null +++ b/frontend/resources/images/icons/warichu-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/warichu.svg b/frontend/resources/images/icons/warichu.svg new file mode 100644 index 0000000000..3348550253 --- /dev/null +++ b/frontend/resources/images/icons/warichu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/writing-mode-horizontal.svg b/frontend/resources/images/icons/writing-mode-horizontal.svg new file mode 100644 index 0000000000..204e9190e7 --- /dev/null +++ b/frontend/resources/images/icons/writing-mode-horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/writing-mode-vertical.svg b/frontend/resources/images/icons/writing-mode-vertical.svg new file mode 100644 index 0000000000..e89a00a562 --- /dev/null +++ b/frontend/resources/images/icons/writing-mode-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index b5696414c8..da3e0a0bd8 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -23,6 +23,7 @@ [app.common.types.shape-tree :as ctst] [app.common.types.shape.attrs :refer [editable-attrs]] [app.common.types.shape.layout :as ctl] + [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] [app.main.data.workspace.comments :as-alias dwcm] @@ -1010,21 +1011,34 @@ (rx/of (dwu/commit-undo-transaction undo-id)) (rx/empty)))))))) +(defn vertical-text-shape? + [shape] + (txt/vertical-text-content? (get shape :content))) + ;; Pure function to determine next grow-type for text layers (defn next-grow-type - [current-grow-type scalev] - (cond - (= current-grow-type :fixed) - :fixed + ([current-grow-type scalev] + (next-grow-type current-grow-type scalev false)) - (and (not (mth/close? (:y scalev) 1.0)) - (or (= current-grow-type :auto-width) - (= current-grow-type :auto-height))) - :fixed + ([current-grow-type scalev vertical?] + ;; CHANGEME: Simplify this comment + ;; Grow and wrap axes swap under vertical writing. Normalizing the scale + ;; vector lets the existing horizontal state machine remain authoritative. + (let [scalev (if vertical? + (gpt/point (:y scalev) (:x scalev)) + scalev)] + (cond + (= current-grow-type :fixed) + :fixed - (and (not (mth/close? (:x scalev) 1.0)) - (= current-grow-type :auto-width)) - :auto-height + (and (not (mth/close? (:y scalev) 1.0)) + (or (= current-grow-type :auto-width) + (= current-grow-type :auto-height))) + :fixed - :else - current-grow-type)) + (and (not (mth/close? (:x scalev) 1.0)) + (= current-grow-type :auto-width)) + :auto-height + + :else + current-grow-type)))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index df0508a1ba..d30d81299d 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -59,6 +59,9 @@ (declare v2-update-text-editor-styles) (declare v2-sync-wasm-text-layout) +(def ruby-presentation-attrs + [:ruby-size :ruby-align :ruby-overhang :ruby-side]) + ;; -- Content helpers (defn- v2-content-has-text? @@ -264,6 +267,13 @@ [{:keys [attrs shape]}] (shape-current-values shape txt/is-root-node? attrs)) +(defn current-ruby-values + [{:keys [attrs shape]}] + (shape-current-values shape + #(and (txt/is-text-node? %) + (not (str/blank? (:ruby %)))) + attrs)) + (defn v3-current-text-values [{:keys [editor-styles attrs]}] (let [result (-> editor-styles @@ -290,7 +300,9 @@ (defn current-paragraph-values [{:keys [editor-styles editor-state editor-instance attrs shape] :as options}] (cond - (some? editor-styles) (v3-current-text-values options) + ;; CHANGEME: is this correct? the v3-current-text-values is not here + (some? editor-styles) (merge (shape-current-values shape txt/is-paragraph-node? attrs) + (select-keys editor-styles attrs)) (some? editor-instance) (v2-current-text-values options) (some? editor-state) (v1-current-paragraph-values options) :else (shape-current-values shape txt/is-paragraph-node? attrs))) @@ -436,30 +448,45 @@ (defn update-paragraph-attrs [{:keys [id attrs]}] - (let [attrs (d/without-nils attrs)] - (ptk/reify ::update-paragraph-attrs - ptk/UpdateEvent - (update [_ state] - (d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs)) + (ptk/reify ::update-paragraph-attrs + ptk/UpdateEvent + (update [_ state] + ;; CHANGEME: check if the without nils is necesary here + (d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs)) - ptk/WatchEvent - (watch [_ state _] - (when-not (some? (get-in state [:workspace-editor-state id])) - (let [objects (dsh/lookup-page-objects state) - shape (get objects id) + ptk/WatchEvent + (watch [_ state _] + (when-not (some? (get-in state [:workspace-editor-state id])) + (let [objects (dsh/lookup-page-objects state) + shape (get objects id) - merge-fn (fn [node attrs] - (reduce-kv - (fn [node k v] (assoc node k v)) - node - attrs)) + merge-fn (fn [node attrs] + (reduce-kv + (fn [node k v] + (if (nil? v) + (dissoc node k) + (assoc node k v))) + node + attrs)) - update-fn #(txt/update-text-content % txt/is-paragraph-node? merge-fn attrs) - shape-ids (cond - (cfh/text-shape? shape) [id] - (cfh/group-shape? shape) (cfh/get-children-ids objects id))] + update-fn #(txt/update-text-content % txt/is-paragraph-node? merge-fn attrs) + shape-ids (cond + (cfh/text-shape? shape) [id] + (cfh/group-shape? shape) (cfh/get-children-ids objects id))] - (rx/of (dwsh/update-shapes shape-ids update-fn)))))))) + (rx/of (dwsh/update-shapes shape-ids update-fn))))))) + +(defn- whole-shape-attrs->editor-styles + "Map whole-shape paragraph attrs to DOM editor styles while preserving nil + as a removal instruction. The generic attrs->styles intentionally drops nil." + [attrs] + (clj->js + (into {} + (map (fn [[key value]] + [(styles/attr->style-key key) + (when (some? value) + (styles/attr->style-value key value))])) + attrs))) (defn update-text-attrs [{:keys [id attrs]}] @@ -491,6 +518,51 @@ updated-shape))] (rx/of (dwsh/update-shapes shape-ids merge-shape))))))) +(defn update-ruby-presentation-attrs + [shape attrs] + (txt/update-text-content + shape + #(and (txt/is-text-node? %) + (not (str/blank? (:ruby %)))) + d/txt-merge + attrs)) + +(defn update-ruby-presentation + [id attrs] + (ptk/reify ::update-ruby-presentation + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shape (get objects id) + wasm? (features/active-feature? state "render-wasm/v1") + shape-ids (cond + (cfh/text-shape? shape) [id] + (cfh/group-shape? shape) (cfh/get-children-ids objects id)) + update-fn (fn [shape] + (let [updated-shape (update-ruby-presentation-attrs shape attrs)] + (when (and wasm? (cfh/text-shape? updated-shape)) + (wasm.text-editor/cache-shape-text-content! + (:id updated-shape) + (:content updated-shape))) + updated-shape))] + (rx/concat + (rx/of (dwsh/update-shapes shape-ids update-fn)) + (if wasm? + (rx/of (dwwt/resize-wasm-text-all shape-ids)) + (rx/empty))))))) + +(defn update-all-ruby-presentation + [ids attrs] + (ptk/reify ::update-all-ruby-presentation + ptk/WatchEvent + (watch [_ _ _] + (let [undo-id (js/Symbol)] + (rx/concat + (rx/of (dwu/start-undo-transaction undo-id)) + (->> (rx/from ids) + (rx/map #(update-ruby-presentation % attrs))) + (rx/of (dwu/commit-undo-transaction undo-id))))))) + (defn migrate-node [node] (let [color-attrs (not-empty (select-keys node types.fills/fill-attrs))] @@ -815,12 +887,21 @@ (= :font-loaded (ptk/type event)) (= (:font-id (deref event)) font-id)))) +(defn globally-update-text-node-attrs? + "Whole-shape text-node updates must be skipped while the WASM editor owns an + active range; its selection-scoped content result is the source of truth." + [render-wasm? wasm-selection?] + (not (and render-wasm? wasm-selection?))) + (defn update-attrs [id attrs] (ptk/reify ::update-attrs ptk/WatchEvent (watch [_ state stream] - (let [text-editor-instance (:workspace-editor state)] + (let [text-editor-instance (:workspace-editor state) + render-wasm? (features/active-feature? state "render-wasm/v1") + wasm-selection? (and render-wasm? + (wasm.api/text-editor-has-selection?))] (if (and (features/active-feature? state "text-editor/v2") (some? text-editor-instance)) (rx/empty) @@ -836,7 +917,8 @@ (rx/empty))) (let [attrs (select-keys attrs txt/text-node-attrs)] - (if-not (empty? attrs) + (if (and (not (empty? attrs)) + (globally-update-text-node-attrs? render-wasm? wasm-selection?)) (rx/of (update-text-attrs {:id id :attrs attrs})) (rx/empty))) @@ -844,18 +926,17 @@ (not (features/active-feature? state "text-editor-wasm/v1"))) (rx/of (v2-update-text-editor-styles id attrs))) - (when (features/active-feature? state "render-wasm/v1") + (when render-wasm? (rx/concat ;; Apply style to selected spans and sync content - (let [has-selection? (wasm.api/text-editor-has-selection?)] - (when has-selection? - (let [span-attrs (select-keys attrs txt/text-node-attrs)] - (when (not (empty? span-attrs)) - (let [result (wasm.api/apply-styles-to-selection span-attrs)] - (when result - (rx/of (v2-update-text-shape-content - (:shape-id result) (:content result) - :update-name? true)))))))) + (when wasm-selection? + (let [span-attrs (select-keys attrs txt/text-node-attrs)] + (when (not (empty? span-attrs)) + (let [result (wasm.api/apply-styles-to-selection span-attrs)] + (when result + (rx/of (v2-update-text-shape-content + (:shape-id result) (:content result) + :update-name? true))))))) ;; Resize (with delay for font-id changes) (if (contains? attrs :font-id) (->> stream @@ -874,8 +955,19 @@ ;; it with sidebar `attrs` and applying to the whole selection collapses mixed ;; fills/fonts when the user only changes one property (e.g. line-height). ;; Apply only the explicit attributes from this action. - (let [styles (styles/attrs->styles attrs)] - (editor.v2/applyStylesToSelection instance styles)))))))) + (let [;; Writing mode and orientation are whole-shape properties: + ;; apply them to every paragraph so they cannot diverge + ;; from the first paragraph (which decides the flow). + whole-shape-attrs + (select-keys attrs (d/concat-vec txt/text-writing-mode-attrs + txt/text-orientation-attrs)) + + selection-attrs + (apply dissoc attrs (keys whole-shape-attrs))] + (when (seq whole-shape-attrs) + (editor.v2/applyStylesToAllParagraphs instance (whole-shape-attrs->editor-styles whole-shape-attrs))) + (when (seq selection-attrs) + (editor.v2/applyStylesToSelection instance (styles/attrs->styles selection-attrs)))))))))) (defn update-all-attrs [ids attrs] diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 5eb90ca433..f3898ba87c 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -226,7 +226,9 @@ ;; Calculate new grow-type for text layers new-grow-type (when (cfh/text-shape? shape) - (dwm/next-grow-type (dm/get-prop shape :grow-type) scalev)) + (dwm/next-grow-type (dm/get-prop shape :grow-type) + scalev + (dwm/vertical-text-shape? shape))) ;; When the horizontal/vertical scale a flex children with auto/fill ;; we change it too fixed @@ -415,7 +417,9 @@ new-height (if (= attr :height) value sr-height) scalev (gpt/point (/ new-width sr-width) (/ new-height sr-height)) current-grow-type (dm/get-prop shape :grow-type) - new-grow-type (dwm/next-grow-type current-grow-type scalev)] + new-grow-type (dwm/next-grow-type current-grow-type + scalev + (dwm/vertical-text-shape? shape))] (cond-> modifiers (not= new-grow-type current-grow-type) (ctm/change-property :grow-type new-grow-type))) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index fc97dba96e..24dc0f86d3 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -15,6 +15,7 @@ [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.types.modifiers :as ctm] + [app.common.types.text :as txt] [app.main.data.helpers :as dsh] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.shapes :as dwsh] @@ -26,6 +27,31 @@ (def debounce-resize-text-time 40) +(defn resolve-text-size + "Selects the axes controlled by WASM for a text grow type. Vertical + auto-height keeps the physical height as its wrap budget and grows width." + [selrect grow-type content dimension] + (when (or (= :fixed grow-type) (some? dimension)) + (let [vertical? (txt/vertical-text-content? content)] + {:width (cond + (= :fixed grow-type) + (:width selrect) + + (and (= :auto-height grow-type) (not vertical?)) + (:width selrect) + + :else + (:width dimension)) + :height (cond + (= :fixed grow-type) + (:height selrect) + + (and (= :auto-height grow-type) vertical?) + (:height selrect) + + :else + (:height dimension))}))) + (defn get-wasm-text-new-size "Computes the new {width, height} for a text shape from WASM text layout. For :fixed grow-type, updates WASM content and returns current dimensions (no resize)." @@ -48,14 +74,7 @@ (wasm.api/set-shape-text-images id content)) (let [dimension (when (not= :fixed grow-type) (wasm.api/get-text-dimensions))] - ;; nil dimension = shape not present in WASM state; skip the resize. - (when (or (= :fixed grow-type) (some? dimension)) - {:width (if (#{:fixed :auto-height} grow-type) - (:width selrect) - (:width dimension)) - :height (if (= :fixed grow-type) - (:height selrect) - (:height dimension))}))))) + (resolve-text-size selrect grow-type content dimension))))) (defn resize-wasm-text-modifiers ([shape] diff --git a/frontend/src/app/main/ui/css_cursors.cljs b/frontend/src/app/main/ui/css_cursors.cljs index 9c0a97276e..b87afa83df 100644 --- a/frontend/src/app/main/ui/css_cursors.cljs +++ b/frontend/src/app/main/ui/css_cursors.cljs @@ -17,6 +17,11 @@ [name rotation] (dm/str "cursor-" name "-" (mod (* (.floor js/Math (/ rotation angle-step)) angle-step) 360))) +(defn get-text + "Returns the text cursor class, rotating the I-beam with vertical text flow." + [rotation vertical?] + (get-dynamic "text" (+ (or rotation 0) (if vertical? 90 0)))) + (defn init-static-cursor-style "Initializes a static cursor style" [style name value] diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 44e44e075f..2e6b20d1cd 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -269,6 +269,9 @@ (def ^:icon-id text-auto-height "text-auto-height") (def ^:icon-id text-auto-width "text-auto-width") (def ^:icon-id text-bottom "text-bottom") +(def ^:icon-id text-combine-upright-all "text-combine-upright-all") +(def ^:icon-id text-combine-upright-digits "text-combine-upright-digits") +(def ^:icon-id text-combine-upright-none "text-combine-upright-none") (def ^:icon-id text-fixed "text-fixed") (def ^:icon-id text-font-family "text-font-family") (def ^:icon-id text-font-size "text-font-size") @@ -280,6 +283,8 @@ (def ^:icon-id text-ltr "text-ltr") (def ^:icon-id text-middle "text-middle") (def ^:icon-id text-mixed "text-mixed") +(def ^:icon-id text-orientation-mixed "text-orientation-mixed") +(def ^:icon-id text-orientation-upright "text-orientation-upright") (def ^:icon-id text-palette "text-palette") (def ^:icon-id text-paragraph "text-paragraph") (def ^:icon-id text-rtl "text-rtl") @@ -302,7 +307,11 @@ (def ^:icon-id vertical-align-items-start "vertical-align-items-start") (def ^:icon-id view-as-icons "view-as-icons") (def ^:icon-id view-as-list "view-as-list") +(def ^:icon-id warichu "warichu") +(def ^:icon-id warichu-none "warichu-none") (def ^:icon-id wrap "wrap") +(def ^:icon-id writing-mode-horizontal "writing-mode-horizontal") +(def ^:icon-id writing-mode-vertical "writing-mode-vertical") (def icon-list "A collection of all icons" diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 1ef023a079..aed7da456d 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -242,6 +242,9 @@ (def ^:icon text-auto-height (icon-xref :text-auto-height)) (def ^:icon text-auto-width (icon-xref :text-auto-width)) (def ^:icon text-bottom (icon-xref :text-bottom)) +(def ^:icon text-combine-upright-all (icon-xref :text-combine-upright-all)) +(def ^:icon text-combine-upright-digits (icon-xref :text-combine-upright-digits)) +(def ^:icon text-combine-upright-none (icon-xref :text-combine-upright-none)) (def ^:icon text-fixed (icon-xref :text-fixed)) (def ^:icon text-font-family (icon-xref :text-font-family)) (def ^:icon text-font-size (icon-xref :text-font-size)) @@ -253,6 +256,8 @@ (def ^:icon text-ltr (icon-xref :text-ltr)) (def ^:icon text-middle (icon-xref :text-middle)) (def ^:icon text-mixed (icon-xref :text-mixed)) +(def ^:icon text-orientation-mixed (icon-xref :text-orientation-mixed)) +(def ^:icon text-orientation-upright (icon-xref :text-orientation-upright)) (def ^:icon text-palette (icon-xref :text-palette)) (def ^:icon text-paragraph (icon-xref :text-paragraph)) (def ^:icon text-rtl (icon-xref :text-rtl)) @@ -279,7 +284,11 @@ (def ^:icon vertical-align-items-start (icon-xref :vertical-align-items-start)) (def ^:icon view-as-icons (icon-xref :view-as-icons)) (def ^:icon view-as-list (icon-xref :view-as-list)) +(def ^:icon warichu (icon-xref :warichu)) +(def ^:icon warichu-none (icon-xref :warichu-none)) (def ^:icon wrap (icon-xref :wrap)) +(def ^:icon writing-mode-horizontal (icon-xref :writing-mode-horizontal)) +(def ^:icon writing-mode-vertical (icon-xref :writing-mode-vertical)) (def default "A collection of all icons" diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 5bb224b673..89babc59ed 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] [app.common.types.color :as cc] + [app.common.types.text :as txt] [app.main.ui.shapes.text.styles :as sts] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -19,9 +20,15 @@ (let [text (:text node) style (if (= text "") (sts/generate-text-styles shape parent) - (sts/generate-text-styles shape node))] - [:span.text-node {:style style} - (if (= text "") "\u00A0" text)])) + (sts/generate-text-styles shape node)) + ruby (:ruby node) + ruby? (and (string? ruby) (not (str/blank? ruby)))] + (if ruby? + [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)} + [:span.text-node {:style style} text] + [:rt {:style (sts/generate-ruby-styles shape node)} ruby]] + [:span.text-node {:style style} + (if (= text "") "\u00A0" text)]))) (mf/defc render-root* [{:keys [node children shape]}] @@ -160,6 +167,11 @@ height (dm/get-prop shape :height) content (get shape :content) + ;; Vertical writing anchors columns to the box edges, so the oversized + ;; auto-grow box used to avoid horizontal wrapping/clipping would push + ;; the content off-position. Use the real selrect size instead. + vertical? (txt/vertical-text-content? content) + [colors _color-mapping color-mapping-inverse] (retrieve-colors shape)] [:foreignObject @@ -169,8 +181,8 @@ :data-colors (str/join "," colors) :data-mapping (-> color-mapping-inverse clj->js js/JSON.stringify) :transform transform - :width (if (#{:auto-width} grow-type) 100000 width) - :height (if (#{:auto-height :auto-width} grow-type) 100000 height) + :width (if (and (not vertical?) (#{:auto-width} grow-type)) 100000 width) + :height (if (and (not vertical?) (#{:auto-height :auto-width} grow-type)) 100000 height) :ref ref} ;; We use a class here because react has a bug that won't use the appropriate selector for ;; `background-clip` diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index 5bf0dc150a..869b16d2cd 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -9,7 +9,9 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.text :as legacy.txt] + [app.common.types.text :as txt] [app.main.ui.shapes.text.styles :as sts] + [cuerdas.core :as str] [rumext.v2 :as mf])) (mf/defc render-text* @@ -18,9 +20,15 @@ style (if (= text "") (sts/generate-text-styles shape parent) (sts/generate-text-styles shape node)) - class (when is-code (:$id node))] - [:span.text-node {:style style :class class} - (if (= text "") "\u00A0" text)])) + class (when is-code (:$id node)) + ruby (:ruby node) + ruby? (and (string? ruby) (not (str/blank? ruby)))] + (if ruby? + [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)} + [:span.text-node {:style style :class class} text] + [:rt {:style (sts/generate-ruby-styles shape node)} ruby]] + [:span.text-node {:style style :class class} + (if (= text "") "\u00A0" text)]))) (mf/defc render-root* [{:keys [node children shape is-code]}] @@ -75,14 +83,19 @@ content (if is-code (legacy.txt/index-content content) content) + ;; Vertical writing anchors columns to the box edges; the oversized + ;; auto-grow box (used to avoid horizontal wrapping) would push content + ;; off-position, so use the real selrect size instead. + vertical? (txt/vertical-text-content? content) + style (when-not is-code #js {:position "fixed" :left 0 :top 0 :background "white" - :width (if (#{:auto-width} grow-type) 100000 width) - :height (if (#{:auto-height :auto-width} grow-type) 100000 height)})] + :width (if (and (not vertical?) (#{:auto-width} grow-type)) 100000 width) + :height (if (and (not vertical?) (#{:auto-height :auto-width} grow-type)) 100000 height)})] [:div.text-node-html {:id (dm/str "html-text-node-" id) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index edf5329ddf..ae3200ebb2 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -21,6 +21,9 @@ (generate-root-styles props node false)) ([{:keys [width height]} node code?] (let [valign (:vertical-align node "top") + ;; Mirroring the shape's writing mode on the root makes paragraph + ;; blocks stack right-to-left. + writing-mode (txt/content-writing-mode node) base #js {:height (when-not code? (fmt/format-pixels height)) :width (when-not code? (fmt/format-pixels width)) :display "flex" @@ -28,7 +31,8 @@ (cond-> base (= valign "top") (obj/set! "alignItems" "flex-start") (= valign "center") (obj/set! "alignItems" "center") - (= valign "bottom") (obj/set! "alignItems" "flex-end"))))) + (= valign "bottom") (obj/set! "alignItems" "flex-end") + (some? writing-mode) (obj/set! "writingMode" writing-mode))))) (defn generate-paragraph-set-styles [{:keys [grow-type] :as shape}] @@ -56,6 +60,8 @@ (:line-height txt/default-typography)) text-align (:text-align data "start") + writing-mode (:writing-mode data) + text-orientation (:text-orientation data) base #js {;; Fix a problem when exporting HTML :fontSize 0 :lineHeight line-height @@ -63,7 +69,21 @@ (cond-> base (some? line-height) (obj/set! "lineHeight" line-height) - (some? text-align) (obj/set! "textAlign" text-align)))) + (some? text-align) (obj/set! "textAlign" text-align) + (some? writing-mode) (obj/set! "writingMode" writing-mode) + (some? writing-mode) (obj/set! "textSpacingTrim" "normal") + (= writing-mode "vertical-rl") (obj/set! "textAutospace" "normal") + (some? text-orientation) (obj/set! "textOrientation" text-orientation)))) + +(defn css-text-combine-upright + "CSS value for a persisted text-combine-upright: the digits variants + serialize with their max run length per the CSS `digits ` syntax." + [value] + (case value + "digits" "digits 4" + "digits2" "digits 2" + "digits3" "digits 3" + value)) (defn generate-text-styles ([shape data] @@ -71,6 +91,24 @@ ([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}] (let [letter-spacing (:letter-spacing data 0) + text-combine-upright (:text-combine-upright data) + text-emphasis (:text-emphasis data) + font-features (:font-features data) + annotation-clearance (:annotation-clearance data) + annotation-layers (if (= "auto" annotation-clearance) + (+ (if (and (string? (:ruby data)) + (seq (:ruby data))) 1 0) + (if (and (string? text-emphasis) + (not= "none" text-emphasis)) 1 0)) + 0) + line-height-num (js/parseFloat (or (:line-height data) + (:line-height txt/default-typography))) + auto-line-height (when (and (pos? annotation-layers) + (not (js/isNaN line-height-num))) + (+ line-height-num (* annotation-layers 0.5))) + warichu? (and (= "warichu" (:warichu data)) + (string? (:text data)) + (>= (count (:text data)) 2)) text-decoration (:text-decoration data) text-transform (:text-transform data) @@ -145,6 +183,26 @@ (and (string? letter-spacing) (pos? (alength letter-spacing))) (obj/set! "letterSpacing" (str letter-spacing "px")) + (and (string? text-combine-upright) (pos? (alength text-combine-upright))) + (obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright)) + + ;; Emphasis marks map to CSS text-emphasis-style: our kebab values + ;; ("filled-dot") become the CSS " " pair ("filled dot"). + (and (string? text-emphasis) (pos? (alength text-emphasis)) + (not= "none" text-emphasis)) + (obj/set! "textEmphasis" (str/replace text-emphasis "-" " ")) + + (and (string? font-features) (pos? (alength font-features)) + (not= "none" font-features)) + (obj/set! "fontFeatureSettings" (str/format "\"%s\"" font-features)) + + (and (string? annotation-clearance) + (pos? (alength annotation-clearance))) + (obj/set! "--annotation-clearance" annotation-clearance) + + (some? auto-line-height) + (obj/set! "lineHeight" auto-line-height) + (and (string? font-size) (pos? (alength font-size))) (obj/set! "fontSize" (str font-size "px")) @@ -154,4 +212,38 @@ (obj/set! "fontWeight" font-weight)) (= grow-type :auto-width) - (obj/set! "whiteSpace" "pre"))))) + (obj/set! "whiteSpace" "pre") + + ;; Warichu (割注) CSS emulation: an inline-block at half size whose + ;; inline-size fits half the characters, so the browser wraps it into + ;; two half-size sub-lines within one inline position in either writing + ;; mode. + warichu? + (-> (obj/set! "display" "inline-block") + (obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size))) + (str (/ (js/parseFloat font-size) 2) "px") + "50%")) + (obj/set! "lineHeight" "1") + (obj/set! "inlineSize" + (str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em"))))))) + +(defn generate-ruby-styles + [shape data] + (-> (generate-text-styles shape data) + (obj/set! "fontSize" (case (:ruby-size data) + "third" "33.333333%" + "quarter" "25%" + "50%")) + (obj/set! "lineHeight" "1") + (obj/set! "textDecoration" "none") + (obj/unset! "textCombineUpright"))) + +(defn generate-ruby-container-styles + [data] + #js {:rubyPosition (if (= "under" (:ruby-side data)) "under" "over") + :rubyAlign (case (:ruby-align data) + "center" "center" + "start" "start" + "space-between" "space-between" + "space-around") + :rubyOverhang (if (= "none" (:ruby-overhang data)) "none" "auto")}) diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index aad09c18ba..a03445df3f 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -15,11 +15,96 @@ [app.main.ui.shapes.custom-stroke :refer [shape-custom-strokes]] [app.main.ui.shapes.fills :as fills] [app.main.ui.shapes.gradients :as grad] + [app.main.ui.shapes.text.styles :as sts] [app.util.object :as obj] [rumext.v2 :as mf])) (def fill-attrs [:fill-color :fill-color-gradient :fill-opacity]) +(defn- ruby-font-scale + [ruby-size] + (case ruby-size + "third" (/ 1 3) + "quarter" 0.25 + 0.5)) + +(def ^:private emphasis-font-scale 0.5) + +(def ^:private warichu-font-scale 0.5) + +;; Kinsoku classes for the warichu sub-line split (same characters the +;; renderer's kinsoku module suppresses at line boundaries). +(def ^:private warichu-forbidden-at-start + (str "、。,.)」』]】〕〉》’”!?;:ー" + "ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ" + "々ゝゞヽヾ・")) + +(def ^:private warichu-forbidden-at-end "(「『[【〔〈《‘“") + +(defn- warichu-split-index + "Safe JavaScript string index where a warichu run splits into its two + sub-lines. The split is chosen in Unicode code-point space, then translated + back to a UTF-16 boundary for `subs`: the + balanced midpoint (first sub-line longer), nudged forward then backward + so the second sub-line does not start with a line-start-prohibited + character and the first does not end with a line-end-prohibited one. + Mirrors the renderer's `warichu_split_chars`." + [text] + (let [characters (vec (js/Array.from text)) + n (count characters) + mid (js/Math.ceil (/ n 2)) + valid? (fn [split] + (and (>= split 1) + (< split n) + (not (.includes warichu-forbidden-at-start (nth characters split))) + (not (.includes warichu-forbidden-at-end (nth characters (dec split)))))) + split (if (valid? mid) + mid + (or (->> (range 1 n) + (some (fn [distance] + (cond + (valid? (+ mid distance)) (+ mid distance) + (and (> mid distance) (valid? (- mid distance))) (- mid distance))))) + mid))] + (->> (take split characters) + (reduce (fn [index character] (+ index (.-length character))) 0)))) + +;; CSS `text-emphasis-style` character mapping (same glyphs the canvas +;; renderer shapes for each mark style). +(def ^:private emphasis-mark-chars + {"filled-dot" "•" + "open-dot" "◦" + "filled-circle" "●" + "open-circle" "○" + "filled-sesame" "﹅" + "open-sesame" "﹆"}) + +(def ^:private emphasis-prohibited-chars + "、。,.「」『』()[]【】〔〕〈〉《》‘’“”") + +(defn- emphasis-character? + [character] + (and (not (re-matches #"\s" character)) + (not (.includes emphasis-prohibited-chars character)))) + +(defn- emphasis-marks-text + "One mark per eligible Unicode base character. Spaces replace whitespace + and Japanese punctuation that does not normally carry emphasis so marks + stay aligned with the base slots." + [text mark] + (->> (js/Array.from text) + (map (fn [character] + (if (emphasis-character? character) mark " "))) + (apply str))) + +(defn- add-font-features! + [style font-features] + (cond-> style + (and (string? font-features) + (not= "none" font-features) + (pos? (alength font-features))) + (obj/set! "fontFeatureSettings" (dm/str "\"" font-features "\"")))) + (defn set-white-fill [shape] (let [update-color @@ -72,32 +157,124 @@ [:> :g group-props (for [[index data] (d/enumerate position-data)] (let [rtl? (= "rtl" (:direction data)) + vertical? (= "vertical-rl" (:writing-mode data)) + ruby (:ruby data) + ruby? (and (string? ruby) (seq ruby)) + ruby-side (:ruby-side data "over") + ruby-align (:ruby-align data "space-around") + ruby-overhang (:ruby-overhang data "auto") + auto-clearance? (= "auto" (:annotation-clearance data)) + has-ruby-layer? (and (not= "under" ruby-side) + (or ruby? (:annotation-has-ruby data))) + emphasis-mark (get emphasis-mark-chars (:text-emphasis data)) + emphasis? (some? emphasis-mark) + warichu? (and (= "warichu" (:warichu data)) + (string? (:text data)) + (>= (count (:text data)) 2)) + font-features (:font-features data) browser-props (cond - (cf/check-browser? :safari) + (and (not vertical?) (cf/check-browser? :safari)) #js {:dominantBaseline "hanging" :dy "0.2em" :y (- (:y data) (:height data))}) - props (-> #js {:key (dm/str "text-" (:id shape) "-" index) - :x (if rtl? (+ (:x data) (:width data)) (:x data)) - :y (:y data) - :dominantBaseline "ideographic" - :textLength (:width data) - :lengthAdjust "spacingAndGlyphs" - :style (-> #js {:fontFamily (:font-family data) - :fontSize (:font-size data) - :fontWeight (:font-weight data) - :textTransform (:text-transform data) - :textDecoration (:text-decoration data) - :letterSpacing (:letter-spacing data) - :fontStyle (:font-style data) - :direction (:direction data) - :whiteSpace "pre"} - (obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))} - (cond-> browser-props - (obj/merge! browser-props))) + base-style (add-font-features! + #js {:fontFamily (:font-family data) + :fontSize (:font-size data) + :fontWeight (:font-weight data) + :textTransform (:text-transform data) + :textDecoration (:text-decoration data) + :textCombineUpright (sts/css-text-combine-upright + (:text-combine-upright data)) + :letterSpacing (:letter-spacing data) + :fontStyle (:font-style data) + :direction (:direction data) + :textSpacingTrim "normal" + :whiteSpace "pre"} + font-features) + + font-size (js/parseFloat (:font-size data)) + ruby-font-size-num (if (js/isNaN font-size) + 0 + (* font-size (ruby-font-scale (:ruby-size data)))) + ruby-font-size (when (pos? ruby-font-size-num) + (dm/str ruby-font-size-num "px")) + ruby-style (add-font-features! + #js {:fontFamily (:font-family data) + :fontSize ruby-font-size + :fontWeight (:font-weight data) + :fontStyle (:font-style data) + :direction (:direction data) + :writingMode (if vertical? "vertical-rl" "horizontal-tb") + :textSpacingTrim "normal" + :textOrientation "upright" + :textAutospace "normal" + :whiteSpace "pre" + :fill (str "url(#fill-" index "-" render-id "-" index ")")} + font-features) + + emphasis-font-size-num (if (js/isNaN font-size) + 0 + (* font-size emphasis-font-scale)) + + warichu-font-size-num (if (js/isNaN font-size) + 0 + (* font-size warichu-font-scale)) + warichu-style (add-font-features! + #js {:fontFamily (:font-family data) + :fontSize (when (pos? warichu-font-size-num) + (dm/str warichu-font-size-num "px")) + :fontWeight (:font-weight data) + :fontStyle (:font-style data) + :direction (:direction data) + :writingMode (if vertical? "vertical-rl" "horizontal-tb") + :textSpacingTrim "normal" + :textOrientation (when vertical? "upright") + :textAutospace "normal" + :whiteSpace "pre" + :fill (str "url(#fill-" index "-" render-id "-" index ")")} + font-features) + emphasis-style (add-font-features! + #js {:fontFamily (:font-family data) + :fontSize (when (pos? emphasis-font-size-num) + (dm/str emphasis-font-size-num "px")) + :fontWeight (:font-weight data) + :fontStyle (:font-style data) + :direction (:direction data) + :writingMode (if vertical? "vertical-rl" "horizontal-tb") + :textSpacingTrim "normal" + :textOrientation (when vertical? "upright") + :textAutospace "normal" + :whiteSpace "pre" + :fill (str "url(#fill-" index "-" render-id "-" index ")")} + font-features) + + props (if vertical? + ;; Vertical strip: glyphs run down the column; x is + ;; the column's central axis and y the strip top + ;; (stored y is the strip bottom). + #js {:key (dm/str "text-" (:id shape) "-" index) + :x (+ (:x data) (/ (:width data) 2)) + :y (- (:y data) (:height data)) + :textLength (:height data) + :lengthAdjust "spacingAndGlyphs" + :style (-> base-style + (obj/set! "writingMode" "vertical-rl") + (obj/set! "textSpacingTrim" "normal") + (obj/set! "textOrientation" (or (:text-orientation data) "mixed")) + (obj/set! "textAutospace" "normal") + (obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))} + (-> #js {:key (dm/str "text-" (:id shape) "-" index) + :x (if rtl? (+ (:x data) (:width data)) (:x data)) + :y (:y data) + :dominantBaseline "ideographic" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style (obj/set! base-style "fill" (str "url(#fill-" index "-" render-id ")"))} + (cond-> browser-props + (obj/merge! browser-props)))) shape (-> shape (assoc :fills (:fills data)) ;; The text elements have the shadow and blur already applied in the @@ -113,4 +290,106 @@ [:& fills/fills {:shape shape :render-id render-id}]] [:& shape-custom-strokes {:shape shape :position index :render-id render-id} - [:> :text props (:text data)]]]))]])) + (if warichu? + ;; Warichu: two half-size sub-lines within one inline strip. + ;; Vertical reading order is right then left; horizontal is + ;; top then bottom. + (let [text (:text data) + split-index (warichu-split-index text) + centre (+ (:x data) (/ (:width data) 2)) + quarter (/ warichu-font-size-num 2) + top (- (:y data) (:height data))] + [:g {:key (dm/str "warichu-" (:id shape) "-" index)} + (if vertical? + [:* + [:> :text {:x (+ centre quarter) + :y top + :style warichu-style} + (subs text 0 split-index)] + [:> :text {:x (- centre quarter) + :y top + :style warichu-style} + (subs text split-index)]] + [:* + [:> :text {:x (:x data) + :y top + :dominantBaseline "hanging" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style warichu-style} + (subs text 0 split-index)] + [:> :text {:x (:x data) + :y (+ top (/ (:height data) 2)) + :dominantBaseline "hanging" + :textLength (:width data) + :lengthAdjust "spacingAndGlyphs" + :style warichu-style} + (subs text split-index)]])]) + [:> :text props (:text data)])] + (when ruby? + [:> :text (if vertical? + #js {:key (dm/str "ruby-" (:id shape) "-" index) + :x (if (= "under" ruby-side) + (- (:x data) (/ ruby-font-size-num 2)) + (+ (:x data) (:width data) (/ ruby-font-size-num 2))) + :y (- (:y data) (:height data)) + :textLength (:height data) + :lengthAdjust "spacingAndGlyphs" + :style ruby-style} + (let [start-x (if rtl? (+ (:x data) (:width data)) (:x data)) + center-x (+ (:x data) (/ (:width data) 2)) + constrain? (= "none" ruby-overhang)] + (cond-> #js {:key (dm/str "ruby-" (:id shape) "-" index) + :x (if (= "center" ruby-align) center-x start-x) + :y (if (= "under" ruby-side) + (:y data) + (- (:y data) (:height data))) + :dominantBaseline (if (= "under" ruby-side) + "hanging" + "text-after-edge") + :style ruby-style} + (= "center" ruby-align) + (obj/set! "textAnchor" "middle") + + (= "start" ruby-align) + (obj/set! "textAnchor" (if rtl? "end" "start")) + + (or constrain? (= "space-around" ruby-align)) + (obj/set! "textLength" (:width data)) + + (or constrain? (= "space-around" ruby-align)) + (obj/set! "lengthAdjust" "spacingAndGlyphs") + + (= "space-between" ruby-align) + (obj/set! "textLength" (:width data)) + + (= "space-between" ruby-align) + (obj/set! "lengthAdjust" "spacing")))) + ruby]) + ;; Emphasis marks (圏点): one half-size mark per eligible base + ;; character, right of vertical text or above horizontal text. + (when emphasis? + [:> :text (if vertical? + #js {:key (dm/str "emphasis-" (:id shape) "-" index) + :x (+ (:x data) + (:width data) + (/ emphasis-font-size-num 2) + (if (and auto-clearance? has-ruby-layer?) + ruby-font-size-num + 0)) + :y (- (:y data) (:height data)) + :textLength (:height data) + :lengthAdjust "spacing" + :style emphasis-style} + #js {:key (dm/str "emphasis-" (:id shape) "-" index) + :x (if rtl? (+ (:x data) (:width data)) (:x data)) + :y (- (:y data) + (:height data) + (if (and auto-clearance? has-ruby-layer?) + ruby-font-size-num + 0)) + :dominantBaseline "text-after-edge" + :textLength (:width data) + :lengthAdjust "spacing" + :style emphasis-style}) + (emphasis-marks-text (:text data) emphasis-mark)])]))]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 3eca4f72fd..5cc354bc50 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -14,6 +14,7 @@ [app.common.geom.shapes.text :as gst] [app.common.math :as mth] [app.common.text :as legacy.txt] + [app.common.types.text :as txt] [app.config :as cf] [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] @@ -235,7 +236,7 @@ :opacity (when @blurred 0)} :on-pointer-down on-pointer-down :class (dom/classnames - (cur/get-dynamic "text" (:rotation shape)) true + (cur/get-text (:rotation shape) (txt/vertical-text-content? content)) true :align-top (= (:vertical-align content "top") "top") :align-center (= (:vertical-align content) "center") :align-bottom (= (:vertical-align content) "bottom"))} diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index b5a16d2f5d..09023f72db 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -296,7 +296,7 @@ (dom/set-style! container-node "--text-editor-caret-color" text-color))) [:div - {:class (dm/str (cur/get-dynamic "text" (:rotation shape)) + {:class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? content)) " " (stl/css :text-editor-container)) :ref container-ref @@ -396,27 +396,24 @@ [{:keys [x y width height selrect-width selrect-height]} transform] (if render-wasm? - (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) + (let [{content-x :x + content-y :y + content-width :width + content-height :height} (wasm.api/get-text-dimensions shape-id) selrect-transform (mf/deref refs/workspace-selrect) [selrect transform] (dsh/get-selrect selrect-transform shape) selrect-height (:height selrect) selrect-width (:width selrect) - max-width (max width selrect-width) - max-height (max height selrect-height) + max-width (max content-width selrect-width) + max-height (max content-height selrect-height) ;; During auto-width editing we keep the shape width trimmed, but the caret ;; must be able to move after trailing spaces. Expand only the editor ;; overlay up to one viewport width to avoid clipping caret rendering. viewport-width (or (:width vbox) 0) overlay-width (if (= (:grow-type shape) :auto-width) (+ max-width viewport-width) - max-width) - valign (-> shape :content :vertical-align) - y (:y selrect) - y (case valign - "bottom" (+ y (- selrect-height height)) - "center" (+ y (/ (- selrect-height height) 2)) - y)] - [(assoc selrect :y y :width overlay-width :height max-height + max-width)] + [(assoc selrect :x content-x :y content-y :width overlay-width :height max-height :selrect-width selrect-width :selrect-height selrect-height) transform]) (let [bounds (gst/shape->rect shape) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 55310b0b87..7c4b041e35 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -405,7 +405,7 @@ :on-focus on-focus :on-blur on-blur :id "text-editor-wasm-input" - :class (dm/str (cur/get-dynamic "text" (:rotation shape)) + :class (dm/str (cur/get-text (:rotation shape) (txt/vertical-text-content? (:content shape))) " " (stl/css :text-editor-container)) :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 60b0c15c02..950464fe23 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -25,10 +25,14 @@ [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]] + [app.main.ui.ds.controls.switch :refer [switch*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]] [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] @@ -51,10 +55,126 @@ Evaluated once at module load time; cf/flags is immutable after startup." (contains? cf/flags :token-typography-row)) +;; CHANGEME: I want all the new options for japanese text to be in their own namespaced and then imported here + +(defn- radio-selected + ([value] + (radio-selected value "")) + ([value default] + (cond + (= value :multiple) "" + (or (nil? value) + (and (string? value) (empty? value))) default + (keyword? value) (d/name value) + (string? value) value + :else (str value)))) + +(def ^:private mixed-span-values + #{:mixed :multiple "mixed" "multiple"}) + +(defn- mixed-span-value? + [value] + (contains? mixed-span-values value)) + +(defn- span-input-value + [value] + (if (mixed-span-value? value) + "mixed" + (radio-selected value))) + +(defn- span-select-value + [value default] + (if (mixed-span-value? value) + "mixed" + (radio-selected value default))) + +(defn- with-mixed-span-option + [options value] + (cond-> options + (mixed-span-value? value) + (conj {:id "mixed" + :label (tr "labels.mixed-values") + :disabled true + :dimmed true}))) + +(defn japanese-layout-enabled? + "Japanese layout is opt-in. An explicit supported writing mode is the + persisted marker; absent, mixed, or reset values remain ordinary text." + [values] + (let [writing-mode (:writing-mode values)] + (cond + (= writing-mode :multiple) nil + (#{"horizontal-tb" "vertical-rl"} writing-mode) true + :else false))) + +(defn japanese-layout-toggle-attrs + "Attrs emitted by the Japanese layout switch. A nil writing mode removes the + persisted paragraph attribute, restoring the normal horizontal default." + [enabled?] + {:writing-mode (when enabled? "horizontal-tb")}) + +(defn reconcile-japanese-layout-state + "Keep the current opt-in state when a same-selection style snapshot omits + writing-mode. A new selection resets from its persisted paragraph value." + [current values selection-changed?] + (cond + selection-changed? (japanese-layout-enabled? values) + (#{"horizontal-tb" "vertical-rl"} (:writing-mode values)) true + :else current)) + +(defn vertical-japanese-layout? + "True when the Japanese layout controls are editing vertical text." + [values] + (= "vertical-rl" (:writing-mode values))) + +(defn proportional-metrics-feature + "Return the proportional metric feature relevant to the writing mode." + [writing-mode] + (if (= writing-mode "vertical-rl") "vpal" "palt")) + +(defn text-combine-upright-options + [text-selection-active translate] + (cond-> [{:value "none" + :id "none-text-combine-upright" + :label (translate "workspace.options.text-options.text-combine-upright-none") + :icon i/text-combine-upright-none}] + text-selection-active + (conj {:value "all" + :id "all-text-combine-upright" + :label (translate "workspace.options.text-options.text-combine-upright-all") + :icon i/text-combine-upright-all}) + + true + (conj {:value "digits" + :id "digits-text-combine-upright" + :label (translate "workspace.options.text-options.text-combine-upright-digits") + :icon i/text-combine-upright-digits}))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Sub-components ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(mf/defc japanese-layout-toggle* + [{:keys [values enabled on-change on-toggle on-blur]}] + (let [handle-change + (mf/use-fn + (mf/deps on-change on-toggle on-blur) + (fn [checked?] + (on-toggle checked?) + (on-change (japanese-layout-toggle-attrs checked?)) + (when (some? on-blur) (on-blur))))] + + ;; Repair the invalid reset sentinel emitted by the previous implementation + ;; so an already-open document can recover without a reload or manual edit. + (mf/with-effect [(:writing-mode values) on-change] + (when (= "" (:writing-mode values)) + (on-change {:writing-mode nil}))) + + [:div {:class (stl/css :japanese-layout-toggle)} + [:> switch* {:default-checked enabled + :label (tr "workspace.options.text-options.japanese-layout") + :on-change handle-change}]])) + (mf/defc text-align-options* [{:keys [values on-change on-blur]}] (let [options @@ -84,14 +204,14 @@ (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :align-options)} - [:> radio-buttons* {:selected (:text-align values) + [:> radio-buttons* {:selected (radio-selected (:text-align values)) :on-change handle-change :name "align-text-options" :options options}]])) (mf/defc text-direction-options* [{:keys [values on-change on-blur]}] - (let [direction (:text-direction values) + (let [direction (radio-selected (:text-direction values)) options (mf/with-memo [] [{:value "ltr" @@ -116,9 +236,400 @@ :name "text-direction-options" :options options}]])) +(mf/defc writing-mode-options* + [{:keys [values on-change on-blur]}] + (let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb") + options + (mf/with-memo [] + [{:value "horizontal-tb" + :id "horizontal-tb-writing-mode" + :label (tr "workspace.options.text-options.writing-mode-horizontal") + :icon i/writing-mode-horizontal} + {:value "vertical-rl" + :id "vertical-rl-writing-mode" + :label (tr "workspace.options.text-options.writing-mode-vertical") + :icon i/writing-mode-vertical}]) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:writing-mode value}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :writing-mode-options)} + [:> radio-buttons* {:selected writing-mode + :on-change handle-change + :name "writing-mode-options" + :options options}]])) + +(mf/defc text-orientation-options* + [{:keys [values on-change on-blur]}] + ;; The v2 editor can read the paragraph orientation back as an empty + ;; string when it is unset; treat that (and nil) as the "mixed" default. + (let [text-orientation (radio-selected (:text-orientation values) "mixed") + options + (mf/with-memo [] + [{:value "mixed" + :id "mixed-text-orientation" + :label (tr "workspace.options.text-options.text-orientation-mixed") + :icon i/text-orientation-mixed} + {:value "upright" + :id "upright-text-orientation" + :label (tr "workspace.options.text-options.text-orientation-upright") + :icon i/text-orientation-upright}]) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:text-orientation value}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :text-orientation-options)} + [:> radio-buttons* {:selected text-orientation + :on-change handle-change + :name "text-orientation-options" + :options options}]])) + +(mf/defc text-combine-upright-options* + ;; Digit TCY can be applied across a shape because it discovers eligible + ;; runs automatically. The unrestricted `all` value is only offered for an + ;; explicit text range. + [{:keys [values on-change on-blur text-selection-active]}] + (let [text-combine-upright (radio-selected (:text-combine-upright values) "none") + digits? (case text-combine-upright + ("digits" "digits2" "digits3") true + false) + selected (if digits? "digits" text-combine-upright) + options + (mf/with-memo [text-selection-active] + (text-combine-upright-options text-selection-active tr)) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:text-combine-upright value}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :text-combine-upright-options)} + [:> radio-buttons* {:selected selected + :on-change handle-change + :name "text-combine-upright-options" + :options options}]])) + +(mf/defc text-combine-upright-count-options* + [{:keys [values on-change on-blur]}] + (let [text-combine-upright (radio-selected (:text-combine-upright values) "none") + digits? (case text-combine-upright + ("digits" "digits2" "digits3") true + false) + options + (mf/with-memo [] + ;; select* matches and reports options by :id, so the id is the + ;; persisted attr value. + [{:id "digits2" + :label (tr "workspace.options.text-options.text-combine-upright-digits-2")} + {:id "digits3" + :label (tr "workspace.options.text-options.text-combine-upright-digits-3")} + {:id "digits" + :label (tr "workspace.options.text-options.text-combine-upright-digits-4")}]) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:text-combine-upright value}) + (when (some? on-blur) (on-blur))))] + + (when digits? + [:div {:class (stl/css :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} + (tr "workspace.options.text-options.text-combine-upright-digits-count")] + [:> select* {:default-selected text-combine-upright + :aria-label (tr "workspace.options.text-options.text-combine-upright-digits-count") + :options options + :on-change handle-change}]]))) + +(mf/defc ruby-options* + [{:keys [values on-change on-blur advanced-open on-toggle-advanced]}] + (let [ruby (span-input-value (:ruby values)) + ruby* (mf/use-state ruby) + dirty* (mf/use-state false) + value (deref ruby*) + dirty? (deref dirty*) + + commit! + (mf/use-fn + (mf/deps dirty? value on-change on-blur) + (fn [] + (when dirty? + (on-change {:ruby value}) + (reset! dirty* false)) + (when (some? on-blur) (on-blur)))) + + handle-change + (mf/use-fn + (fn [event] + (reset! dirty* true) + (reset! ruby* (dom/get-target-val event)))) + + handle-key-down + (mf/use-fn + (mf/deps commit!) + (fn [event] + (when (= "Enter" (.-key event)) + (dom/blur! (dom/get-target event)))))] + + (mf/with-effect [ruby] + (reset! ruby* ruby) + (reset! dirty* false)) + + [:div {:class (stl/css :ruby-options)} + [:> input* {:class (stl/css :ruby-input) + :label (tr "workspace.options.text-options.ruby") + :placeholder (tr "workspace.options.text-options.ruby-placeholder") + :value value + :on-change handle-change + :on-blur commit! + :on-key-down handle-key-down}] + [:> icon-button* {:variant "ghost" + :selected advanced-open + :aria-label (tr "workspace.options.text-options.ruby-advanced-options") + :aria-expanded advanced-open + :data-testid "ruby-advanced-options-toggle" + :on-click on-toggle-advanced + :icon i/menu}]])) + +(mf/defc ruby-select-option* + [{:keys [values on-change on-blur attr default-value label options]}] + (let [value (get values attr) + selected (span-select-value value default-value) + options (with-mixed-span-option options value) + handle-change + (mf/use-fn + (mf/deps attr on-change on-blur) + (fn [value] + (on-change {attr value}) + (when (some? on-blur) (on-blur))))] + [:div {:class (stl/css :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} label] + [:> select* {:default-selected selected + :aria-label label + :options options + :on-change handle-change}]])) + +(defn- ruby-common-props + [values on-change on-blur] + (mf/props + {:values values + :on-change on-change + :on-blur on-blur})) + +(mf/defc ruby-customization-options* + [{:keys [values on-change on-blur]}] + (let [common-props (ruby-common-props values on-change on-blur) + size-options [{:id "half" :label (tr "workspace.options.text-options.ruby-size-half")} + {:id "third" :label (tr "workspace.options.text-options.ruby-size-third")} + {:id "quarter" :label (tr "workspace.options.text-options.ruby-size-quarter")}] + align-options [{:id "space-around" :label (tr "workspace.options.text-options.ruby-align-space-around")} + {:id "center" :label (tr "workspace.options.text-options.ruby-align-center")} + {:id "start" :label (tr "workspace.options.text-options.ruby-align-start")} + {:id "space-between" :label (tr "workspace.options.text-options.ruby-align-space-between")}] + overhang-options [{:id "auto" :label (tr "workspace.options.text-options.ruby-overhang-auto")} + {:id "none" :label (tr "workspace.options.text-options.ruby-overhang-none")}] + side-options [{:id "over" :label (tr "workspace.options.text-options.ruby-side-over")} + {:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]] + [:div {:class (stl/css :japanese-layout-controls)} + [:> ruby-select-option* (mf/spread-props common-props + {:attr :ruby-size + :default-value "half" + :label (tr "workspace.options.text-options.ruby-size") + :options size-options})] + [:> ruby-select-option* (mf/spread-props common-props + {:attr :ruby-align + :default-value "space-around" + :label (tr "workspace.options.text-options.ruby-align") + :options align-options})] + [:> ruby-select-option* (mf/spread-props common-props + {:attr :ruby-overhang + :default-value "auto" + :label (tr "workspace.options.text-options.ruby-overhang") + :options overhang-options})] + [:> ruby-select-option* (mf/spread-props common-props + {:attr :ruby-side + :default-value "over" + :label (tr "workspace.options.text-options.ruby-side") + :options side-options})]])) + +(mf/defc ruby-advanced-options* + [{:keys [values on-change on-blur]}] + (let [open* (mf/use-state false) + open? (deref open*) + toggle-open (mf/use-fn #(swap! open* not)) + common-props (ruby-common-props values on-change on-blur)] + [:div {:class (stl/css :ruby-advanced-options)} + [:> ruby-options* (mf/spread-props common-props + {:advanced-open open? + :on-toggle-advanced toggle-open})] + [:> advanced-options* {:class (stl/css :ruby-advanced-content) + :is-visible open?} + [:> ruby-customization-options* common-props]]])) + +(mf/defc ruby-presentation-options* + [{:keys [values on-change on-blur]}] + (let [open* (mf/use-state false) + open? (deref open*) + toggle-open (mf/use-fn #(swap! open* not)) + common-props (ruby-common-props values on-change on-blur)] + [:div {:class (stl/css :ruby-advanced-options)} + [:> title-bar* {:collapsable true + :collapsed (not open?) + :title (tr "workspace.options.text-options.ruby-advanced-options") + :on-collapsed toggle-open + :aria-expanded open? + :data-testid "ruby-presentation-options-toggle"}] + [:> advanced-options* {:class (stl/css :ruby-advanced-content) + :is-visible open?} + [:> ruby-customization-options* common-props]]])) + +(mf/defc warichu-options* + ;; Warichu (割注): a span-scoped toggle that renders the selection as two + ;; half-size lines within one inline position (top/bottom in horizontal + ;; flow, right/left in vertical flow). Applies to the current selection + ;; through the shared node-attr on-change; "none" is the default. + [{:keys [values on-change on-blur]}] + (let [warichu (radio-selected (:warichu values) "none") + options + (mf/with-memo [] + [{:value "none" + :id "none-warichu" + :label (tr "workspace.options.text-options.warichu-none") + :icon i/warichu-none} + {:value "warichu" + :id "warichu-warichu" + :label (tr "workspace.options.text-options.warichu") + :icon i/warichu}]) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:warichu value}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :warichu-options)} + [:> radio-buttons* {:selected warichu + :on-change handle-change + :name "warichu-options" + :options options}]])) + +(mf/defc font-features-options* + ;; Japanese proportional metric alternates. `palt` is typically used for + ;; horizontal composition and `vpal` for vertical composition; the value is + ;; span-scoped and passed through browser, export, and render-wasm paths. + [{:keys [values on-change on-blur]}] + (let [writing-mode (:writing-mode values) + feature (proportional-metrics-feature writing-mode) + value (:font-features values) + enabled? (cond + (mixed-span-value? value) nil + (= feature value) true + :else false) + + handle-change + (mf/use-fn + (mf/deps feature on-change on-blur) + (fn [checked?] + (on-change {:font-features (if checked? feature "none")}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :font-features-options :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} + (tr "workspace.options.text-options.font-features")] + [:> switch* {:default-checked enabled? + :aria-label (tr "workspace.options.text-options.font-features") + :on-change handle-change}]])) + +(defn text-emphasis-options + ([] + (text-emphasis-options tr)) + ([translate] + [{:id "none" + :label (translate "workspace.options.text-options.text-emphasis-none")} + {:id "filled-dot" + :label (translate "workspace.options.text-options.text-emphasis-filled-dot")} + {:id "open-dot" + :label (translate "workspace.options.text-options.text-emphasis-open-dot")} + {:id "filled-circle" + :label (translate "workspace.options.text-options.text-emphasis-filled-circle")} + {:id "open-circle" + :label (translate "workspace.options.text-options.text-emphasis-open-circle")} + {:id "filled-sesame" + :label (translate "workspace.options.text-options.text-emphasis-filled-sesame")} + {:id "open-sesame" + :label (translate "workspace.options.text-options.text-emphasis-open-sesame")}])) + +(mf/defc text-emphasis-options* + ;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base + ;; character in vertical writing. Applies to the current selection through the + ;; shared node-attr on-change; "none" is the default. + [{:keys [values on-change on-blur]}] + (let [value (:text-emphasis values) + text-emphasis (span-select-value value "none") + options + (mf/with-memo [] + (text-emphasis-options)) + options (with-mixed-span-option options value) + + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:text-emphasis value}) + (when (some? on-blur) (on-blur))))] + + [:div {:class (stl/css :text-emphasis-options :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} + (tr "workspace.options.text-options.text-emphasis")] + [:> select* {:default-selected text-emphasis + :aria-label (tr "workspace.options.text-options.text-emphasis") + :options options + :on-change handle-change}]])) + +(defn annotation-clearance-options + ([] + (annotation-clearance-options tr)) + ([translate] + [{:id "none" + :label (translate "workspace.options.text-options.annotation-clearance-none")} + {:id "auto" + :label (translate "workspace.options.text-options.annotation-clearance-auto")}])) + +(mf/defc annotation-clearance-options* + [{:keys [values on-change on-blur]}] + (let [value (:annotation-clearance values) + annotation-clearance (span-select-value value "none") + options (mf/with-memo [] (annotation-clearance-options)) + options (with-mixed-span-option options value) + handle-change + (mf/use-fn + (mf/deps on-change on-blur) + (fn [value] + (on-change {:annotation-clearance value}) + (when (some? on-blur) (on-blur))))] + [:div {:class (stl/css :annotation-clearance-options :japanese-select-option)} + [:span {:class (stl/css :japanese-option-label)} + (tr "workspace.options.text-options.annotation-clearance")] + [:> select* {:default-selected annotation-clearance + :aria-label (tr "workspace.options.text-options.annotation-clearance") + :options options + :on-change handle-change}]])) + (mf/defc vertical-align* [{:keys [values on-change on-blur]}] - (let [vertical-align (or (:vertical-align values) "top") + (let [vertical-align (radio-selected (:vertical-align values) "top") options (mf/with-memo [] [{:value "top" @@ -150,6 +661,7 @@ (mf/defc grow-options* [{:keys [ids values on-blur]}] (let [grow-type (:grow-type values) + selected (radio-selected grow-type) editor-instance (mf/deref refs/workspace-editor) options (mf/with-memo [] @@ -190,14 +702,14 @@ (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :grow-options)} - [:> radio-buttons* {:selected (d/name grow-type) + [:> radio-buttons* {:selected selected :on-change handle-change :name "grow-text-options" :options options}]])) (mf/defc text-decoration-options* [{:keys [values on-change on-blur token-applied]}] - (let [text-decoration (some-> (:text-decoration values) d/name) + (let [text-decoration (radio-selected (:text-decoration values)) options (mf/with-memo [token-applied] [{:value "underline" @@ -221,8 +733,8 @@ [:div {:class (stl/css :text-decoration-options)} [:> radio-buttons* {:selected (if (= text-decoration "none") - nil - text-decoration) + "" + (radio-selected text-decoration)) :on-change handle-change :name "text-decoration-options" :disabled (and token-typography-row-enabled? (some? token-applied)) @@ -245,8 +757,10 @@ (d/seek #(= (:id %) (uuid/uuid id))))) (defn- check-props [n-props o-props] - (let [o-values (unchecked-get o-props "values") - n-values (unchecked-get n-props "values")] + (let [o-values (unchecked-get o-props "values") + n-values (unchecked-get n-props "values") + o-ruby-values (unchecked-get o-props "rubyValues") + n-ruby-values (unchecked-get n-props "rubyValues")] (and (identical? (unchecked-get n-props "ids") (unchecked-get o-props "ids")) (identical? (unchecked-get n-props "type") @@ -257,6 +771,9 @@ (unchecked-get o-props "fileId")) (identical? (unchecked-get n-props "typographies") (unchecked-get o-props "typographies")) + (identical? (unchecked-get n-props "textSelectionActive") + (unchecked-get o-props "textSelectionActive")) + (= n-ruby-values o-ruby-values) (identical? (get o-values :fills) (get n-values :fills)) (identical? (get o-values :font-family) @@ -283,6 +800,30 @@ (get n-values :text-decoration)) (identical? (get o-values :text-direction) (get n-values :text-direction)) + (identical? (get o-values :writing-mode) + (get n-values :writing-mode)) + (identical? (get o-values :text-orientation) + (get n-values :text-orientation)) + (identical? (get o-values :text-combine-upright) + (get n-values :text-combine-upright)) + (identical? (get o-values :warichu) + (get n-values :warichu)) + (identical? (get o-values :font-features) + (get n-values :font-features)) + (identical? (get o-values :annotation-clearance) + (get n-values :annotation-clearance)) + (identical? (get o-values :text-emphasis) + (get n-values :text-emphasis)) + (identical? (get o-values :ruby) + (get n-values :ruby)) + (identical? (get o-values :ruby-size) + (get n-values :ruby-size)) + (identical? (get o-values :ruby-align) + (get n-values :ruby-align)) + (identical? (get o-values :ruby-overhang) + (get n-values :ruby-overhang)) + (identical? (get o-values :ruby-side) + (get n-values :ruby-side)) (identical? (get o-values :text-transform) (get n-values :text-transform)) (identical? (get o-values :typography-ref-file) @@ -298,7 +839,8 @@ (mf/defc text-menu* {::mf/wrap [#(mf/memo' % check-props)]} - [{:keys [ids type values applied-tokens libraries file-id typographies]}] + [{:keys [ids type values ruby-values text-selection-active + applied-tokens libraries file-id typographies]}] (let [;; --- UI state menu-state* (mf/use-state {:main-menu true @@ -354,6 +896,17 @@ ;; --- Helpers multiple? (->> values vals (d/seek #(= % :multiple))) + vertical-text-enabled? + (features/use-feature "text-vertical/v1") + + selection-key (hash ids) + previous-selection-key-ref (mf/use-ref selection-key) + japanese-layout-active* (mf/use-state #(japanese-layout-enabled? values)) + japanese-layout-active? + (deref japanese-layout-active*) + vertical-mode? + (vertical-japanese-layout? values) + apply-token! (mf/use-fn (mf/deps ids typography-tokens) @@ -395,6 +948,10 @@ (mf/use-fn #(swap! token-dropdown-open* not)) + toggle-japanese-layout + (mf/use-fn + #(reset! japanese-layout-active* %)) + ;; --- Event handlers on-option-click (mf/use-fn @@ -421,6 +978,14 @@ (fn [attrs] (emit-update! ids attrs))) + on-ruby-presentation-change + (mf/use-fn + (mf/deps ids on-change text-selection-active) + (fn [attrs] + (if text-selection-active + (on-change attrs) + (st/emit! (dwt/update-all-ruby-presentation ids attrs))))) + on-convert-to-typography (mf/use-fn (mf/deps values ids file-id emit-update!) @@ -479,7 +1044,18 @@ :values values :on-change on-change :show-recent true - :on-blur on-text-blur})] + :on-blur on-text-blur}) + + ruby-presentation-props + (mf/props + {:values ruby-values + :on-change on-ruby-presentation-change + :on-blur on-text-blur}) + + japanese-toggle-props + (mf/spread-props common-props + {:enabled japanese-layout-active? + :on-toggle toggle-japanese-layout})] (hooks/use-stream expand-stream @@ -497,6 +1073,19 @@ (when token-dropdown-open? (ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!)))) + ;; Selection style snapshots may temporarily omit paragraph attrs when a + ;; span-level Japanese option changes. A new selection must instead trust + ;; its persisted value. Handle both changes in one effect so a writing-mode + ;; update cannot restore the previous selection's opt-in state. + (mf/with-effect [selection-key (:writing-mode values)] + (let [selection-changed? + (not= selection-key (mf/ref-val previous-selection-key-ref))] + (reset! japanese-layout-active* + (reconcile-japanese-layout-state japanese-layout-active? + values + selection-changed?)) + (mf/set-ref-val! previous-selection-key-ref selection-key))) + [:section {:class (stl/css :element-set) :aria-label (tr "workspace.options.text-options.text-section")} [:div {:class (stl/css :element-title)} @@ -582,7 +1171,32 @@ :icon i/menu}]] (when more-options-open? - [:div {:class (stl/css :text-decoration-options)} - [:> vertical-align* common-props] - [:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})] - [:> text-direction-options* common-props]])])])) + [:* + [:div {:class (stl/css :text-decoration-options)} + [:> vertical-align* common-props] + [:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})] + [:> text-direction-options* common-props]] + (when ^boolean vertical-text-enabled? + [:div {:class (stl/css :japanese-layout-options)} + [:> japanese-layout-toggle* japanese-toggle-props] + (when ^boolean japanese-layout-active? + [:div {:class (stl/css :japanese-layout-controls)} + [:div {:class (stl/css :japanese-icon-options)} + [:> writing-mode-options* common-props] + (when ^boolean vertical-mode? + [:* + [:> text-orientation-options* common-props] + [:> text-combine-upright-options* + (mf/spread-props common-props + {:text-selection-active text-selection-active})]]) + (when ^boolean text-selection-active + [:> warichu-options* common-props])] + (when ^boolean vertical-mode? + [:> text-combine-upright-count-options* common-props]) + [:> font-features-options* common-props] + (when ^boolean text-selection-active + [:> text-emphasis-options* common-props]) + [:> annotation-clearance-options* common-props] + (if text-selection-active + [:> ruby-advanced-options* common-props] + [:> ruby-presentation-options* ruby-presentation-props])])])])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss index adc7da205a..7c270eb8ef 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss @@ -57,13 +57,93 @@ .align-options, .text-direction-options, +.writing-mode-options, +.text-orientation-options, +.text-combine-upright-options, +.warichu-options, .vertical-align-options, .grow-options, .text-decoration-options { block-size: $sz-32; } +.ruby-advanced-options { + display: flex; + flex-direction: column; + overflow: visible; +} + +.ruby-options { + display: flex; + align-items: flex-end; + gap: var(--sp-xs); +} + +.ruby-input { + flex: 1; +} + +.ruby-advanced-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-xs); + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; +} + +.text-emphasis-options { + inline-size: 100%; +} + +.font-features-options { + inline-size: 100%; +} + +.annotation-clearance-options { + inline-size: 100%; +} + .text-decoration-options { display: flex; gap: var(--sp-xs); } + +.japanese-layout-options, +.japanese-layout-controls { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.japanese-layout-options { + padding-block-start: var(--sp-xs); + border-block-start: $b-1 solid var(--color-background-quaternary); +} + +.japanese-layout-toggle { + display: flex; + align-items: center; + min-block-size: $sz-32; +} + +.japanese-icon-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-xs); +} + +.japanese-select-option { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); + align-items: center; + gap: var(--sp-s); + inline-size: 100%; +} + +.japanese-option-label { + @include t.use-typography("body-small"); + + color: var(--color-foreground-secondary); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 9b3005ecea..2fc618b85c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -27,8 +27,30 @@ [app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]] [app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]] [app.main.ui.workspace.sidebar.options.menus.text :refer [text-menu*]] + [app.render-wasm.api :as wasm.api] [rumext.v2 :as mf])) +(defn- dom-text-range-selected? + [editor] + (when editor + (let [selection (.getSelection js/window) + element (.-element editor) + anchor (some-> selection .-anchorNode) + focus (some-> selection .-focusNode)] + (and selection + (not (.-isCollapsed selection)) + element + anchor + focus + (.contains element anchor) + (.contains element focus))))) + +(defn- draft-text-range-selected? + [editor-state] + (when editor-state + (let [selection (.getSelection editor-state)] + (and selection (not (.isCollapsed selection)))))) + (mf/defc options* [{:keys [shape libraries file-id page-id]}] (let [id (dm/get-prop shape :id) @@ -117,6 +139,19 @@ (when text-editor-v2? editor) + text-selection-active + (boolean + (cond + text-editor-wasm? + (and (= id (wasm.api/text-editor-get-active-shape-id)) + (wasm.api/text-editor-has-selection?)) + + text-editor-v2? + (dom-text-range-selected? editor-instance) + + :else + (draft-text-range-selected? editor-state))) + fill-values (dwt/current-text-values {:editor-styles editor-styles @@ -143,7 +178,14 @@ :editor-state editor-state :editor-instance editor-instance :shape shape - :attrs txt/text-node-attrs}))] + :attrs txt/text-node-attrs})) + + ruby-values + (if text-selection-active + (select-keys text-values dwt/ruby-presentation-attrs) + (dwt/current-ruby-values + {:shape shape + :attrs dwt/ruby-presentation-attrs}))] [:* [:> layer-menu* {:ids ids @@ -191,6 +233,8 @@ :type type :applied-tokens applied-tokens :values text-values + :ruby-values ruby-values + :text-selection-active text-selection-active :libraries libraries :file-id file-id :typographies typographies}] @@ -226,4 +270,3 @@ :values (select-keys shape exports-attrs) :page-id page-id :file-id file-id}]])) - diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 9e497ef8d0..76b9237413 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -15,6 +15,7 @@ [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.shape :as cts] + [app.common.types.text :as txt] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] @@ -497,23 +498,32 @@ (dom/stop-propagation event) (let [target (dom/get-current-target event) position (-> (dom/get-data target "position") - (keyword))] + (keyword)) + horizontal-resize? (contains? #{:right :left} position) + vertical-resize? (contains? #{:top :bottom} position) + vertical-text? (txt/vertical-text-content? (get shape :content)) + wrap-axis-resize? (if vertical-text? + vertical-resize? + horizontal-resize?) + resize-direction (if horizontal-resize? :horizontal :vertical)] (cond - ;; If text and in auto-width and the resize is horizontal, switch to auto-height and mark direction + ;; Resizing auto-width on the wrap axis switches to auto-height. + ;; The physical wrap axis is horizontal normally and vertical + ;; under vertical writing. (and (= shape-type :text) (= grow-type :auto-width) - (or (= position :right) (= position :left))) - (st/emit! (dwsh/update-shapes [shape-id] #(-> % (assoc :grow-type :auto-height) (assoc :last-resize-direction :horizontal)))) - ;; If text and in auto-height and the resize is horizontal, mark direction but do not change grow-type + wrap-axis-resize?) + (st/emit! (dwsh/update-shapes [shape-id] + #(assoc % + :grow-type :auto-height + :last-resize-direction resize-direction))) + (and (= shape-type :text) (= grow-type :auto-height) - (or (= position :right) (= position :left))) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :last-resize-direction :horizontal))) - ;; If text and in auto-height and the resize is vertical, mark direction - (and (= shape-type :text) - (= grow-type :auto-height) - (or (= position :top) (= position :bottom))) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :last-resize-direction :vertical))) + (or horizontal-resize? vertical-resize?)) + (st/emit! (dwsh/update-shapes [shape-id] + #(assoc % :last-resize-direction resize-direction))) + :else nil) (st/emit! (dw/start-resize position #{shape-id} shape)))))) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 3692ae1a59..17bc779203 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -39,6 +39,17 @@ (def ^:private text-direction-re #"ltr|rtl") (def ^:private text-align-re #"left|center|right|justify") (def ^:private vertical-align-re #"top|center|bottom") +(def ^:private writing-mode-re #"horizontal-tb|vertical-rl") +(def ^:private text-orientation-re #"mixed|upright") +(def ^:private text-combine-upright-re #"none|all|digits2|digits3|digits") +(def ^:private text-emphasis-re #"none|filled-dot|open-dot|filled-circle|open-circle|filled-sesame|open-sesame") +(def ^:private warichu-re #"none|warichu") +(def ^:private font-features-re #"none|palt|vpal") +(def ^:private annotation-clearance-re #"none|auto") +(def ^:private ruby-size-re #"half|third|quarter") +(def ^:private ruby-align-re #"space-around|center|start|space-between") +(def ^:private ruby-overhang-re #"auto|none") +(def ^:private ruby-side-re #"over|under") (defn- font-data [font variant] @@ -97,6 +108,25 @@ (recur (when continue? (rest styles)) taking? to result)) result)))) +(def ^:private japanese-range-defaults + {:text-combine-upright "none" + :text-emphasis "none" + :ruby nil + :ruby-size "half" + :ruby-align "space-around" + :ruby-overhang "auto" + :ruby-side "over" + :warichu "none" + :font-features "none" + :annotation-clearance "none"}) + +(defn- range-japanese-value + [range-data attr] + (let [default (get japanese-range-defaults attr)] + (->> range-data + (map #(get % attr default)) + (u/mixed-value)))) + (defn text-range-proxy? [range] (obj/type-of? range "TextRange")) @@ -368,6 +398,227 @@ :else (st/emit! (dwt/update-text-range id start end {:text-decoration value}))))} + ;; CHANGEME: All these new method need to be added to the plugin-api-test-suite + :fontFeatures + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :font-features))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches font-features-re value))) + (u/not-valid plugin-id :fontFeatures value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:font-features value}))))} + + :textCombineUpright + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :text-combine-upright))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches text-combine-upright-re value))) + (u/not-valid plugin-id :textCombineUpright value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:text-combine-upright value}))))} + + :textEmphasis + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :text-emphasis))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches text-emphasis-re value))) + (u/not-valid plugin-id :textEmphasis value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:text-emphasis value}))))} + + :warichu + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :warichu))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches warichu-re value))) + (u/not-valid plugin-id :warichu value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:warichu value}))))} + + :annotationClearance + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :annotation-clearance))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches annotation-clearance-re value))) + (u/not-valid plugin-id :annotationClearance value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:annotation-clearance value}))))} + + :ruby + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :ruby))) + :set + (fn [_ value] + (cond + (and (some? value) (not (string? value))) + (u/not-valid plugin-id :ruby value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:ruby value}))))} + + :rubySize + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :ruby-size))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches ruby-size-re value))) + (u/not-valid plugin-id :rubySize value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:ruby-size value}))))} + + :rubyAlign + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :ruby-align))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches ruby-align-re value))) + (u/not-valid plugin-id :rubyAlign value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:ruby-align value}))))} + + :rubyOverhang + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :ruby-overhang))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches ruby-overhang-re value))) + (u/not-valid plugin-id :rubyOverhang value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:ruby-overhang value}))))} + + :rubySide + {:this true + :get + (fn [self] + (let [range-data + (-> self u/proxy->shape :content (content-range->text+styles start end))] + (range-japanese-value range-data :ruby-side))) + :set + (fn [_ value] + (cond + (or (not (string? value)) (not (re-matches ruby-side-re value))) + (u/not-valid plugin-id :rubySide value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-text-range id start end {:ruby-side value}))))} + :direction {:this true :get @@ -761,5 +1012,209 @@ :else (st/emit! (dwt/update-attrs id {:vertical-align value})))))} + {:name "writingMode" + :get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches writing-mode-re value))) + (u/not-valid plugin-id :writingMode value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :writingMode "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :writingMode "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:writing-mode value})))))} + + {:name "textOrientation" + :get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches text-orientation-re value))) + (u/not-valid plugin-id :textOrientation value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :textOrientation "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :textOrientation "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:text-orientation value})))))} + + {:name "textCombineUpright" + :get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches text-combine-upright-re value))) + (u/not-valid plugin-id :textCombineUpright value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:text-combine-upright value})))))} + + {:name "textEmphasis" + :get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches text-emphasis-re value))) + (u/not-valid plugin-id :textEmphasis value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:text-emphasis value})))))} + + {:name "warichu" + :get #(-> % u/proxy->shape text-props :warichu format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches warichu-re value))) + (u/not-valid plugin-id :warichu value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:warichu value})))))} + + {:name "fontFeatures" + :get #(-> % u/proxy->shape text-props :font-features format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches font-features-re value))) + (u/not-valid plugin-id :fontFeatures value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:font-features value})))))} + + {:name "annotationClearance" + :get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches annotation-clearance-re value))) + (u/not-valid plugin-id :annotationClearance value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:annotation-clearance value})))))} + + {:name "ruby" + :get #(-> % u/proxy->shape text-props :ruby format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (and (some? value) (not (string? value))) + (u/not-valid plugin-id :ruby value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:ruby value})))))} + + {:name "rubySize" + :get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches ruby-size-re value))) + (u/not-valid plugin-id :rubySize value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active") + :else + (st/emit! (dwt/update-attrs id {:ruby-size value})))))} + + {:name "rubyAlign" + :get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches ruby-align-re value))) + (u/not-valid plugin-id :rubyAlign value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active") + :else + (st/emit! (dwt/update-attrs id {:ruby-align value})))))} + + {:name "rubyOverhang" + :get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches ruby-overhang-re value))) + (u/not-valid plugin-id :rubyOverhang value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active") + :else + (st/emit! (dwt/update-attrs id {:ruby-overhang value})))))} + + {:name "rubySide" + :get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (or (not (string? value)) (not (re-matches ruby-side-re value))) + (u/not-valid plugin-id :rubySide value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active") + :else + (st/emit! (dwt/update-attrs id {:ruby-side value})))))} + {:name "textBounds" :get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)}))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e4be664616..215e852f05 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -439,7 +439,9 @@ (case ev ;; StylesChanged Event TEXT_EDITOR_EVENT_STYLES_CHANGED - (let [current-styles (text-editor/text-editor-get-current-styles) + (let [current-styles (merge + (text-editor/text-editor-get-current-styles) + (text-editor/text-editor-get-current-japanese-styles)) shape-id (text-editor/text-editor-get-active-shape-id)] (st/emit! (texts/v3-update-text-editor-styles shape-id current-styles))) @@ -1268,9 +1270,10 @@ emoji? langs) - (let [text (apply str (map :text spans)) - emoji? (if emoji? emoji? (t/contains-emoji? text)) - langs (t/collect-used-languages langs text)] + (let [text (apply str (map :text spans)) + fallback-text (apply str (map #(str (:text %) (:ruby %)) spans)) + emoji? (if emoji? emoji? (t/contains-emoji? fallback-text)) + langs (t/collect-used-languages langs fallback-text)] ;; FIXME: this should probably be somewhere else (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) @@ -1282,7 +1285,7 @@ (let [updated-fonts (-> #{} (cond-> ^boolean emoji? (f/add-emoji-font)) - (f/add-noto-fonts langs)) + (f/add-noto-fonts (t/resolve-ambiguous-cjk langs))) fallback-fonts (filter #(get % :is-fallback) updated-fonts)] (if fallback-fonts-only? updated-fonts fallback-fonts)))))) @@ -2064,7 +2067,9 @@ (text-editor-wasm?) (bit-or 2r00000000000000000000000000000100) (contains? cf/flags :render-wasm-info) - (bit-or 2r00000000000000000000000000001000))) + (bit-or 2r00000000000000000000000000001000) + (dbg/enabled? :wasm-text-grid) + (bit-or 2r00000000000000000000000000010000))) (defn set-render-options! "Updates WASM render options with a new DPR value." @@ -2520,6 +2525,56 @@ (def POSITION-DATA-U8-SIZE 36) (def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) +(defn- ruby-font-scale + [ruby-size] + (case ruby-size + "third" (/ 1 3) + "quarter" 0.25 + 0.5)) + +(defn- horizontal-ruby-slice + "Returns the whole-span ruby annotation for a horizontal base strip." + [element _start-pos _end-pos] + (let [text (:text element) + ruby (:ruby element)] + (when (and (string? text) (seq text) (string? ruby) (seq ruby)) + ruby))) + +(defn- ruby-strip-entry + "Position-data entry for a ruby annotation strip (direction 3): the + offsets index the span's ruby string and the geometry is the exact + gutter placement the canvas paints." + [element {:keys [start-pos end-pos x y width height]}] + (let [ruby (get element :ruby)] + (when (string? ruby) + (let [text (subs ruby + (min start-pos (count ruby)) + (min end-pos (count ruby))) + font-size (js/parseFloat (get element :font-size))] + (when (seq text) + (d/patch-object + txt/default-text-attrs + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction "ltr" + :writing-mode "vertical-rl" + :text-orientation "upright" + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (when-not (js/isNaN font-size) + (dm/str (* (ruby-font-scale (:ruby-size element)) font-size) "px")) + :font-weight (get element :font-weight) + :font-style (get element :font-style) + :ruby-size (get element :ruby-size) + :ruby-align (get element :ruby-align) + :ruby-overhang (get element :ruby-overhang) + :ruby-side (get element :ruby-side) + :fills (get element :fills) + :text text}))))))) + (defn calculate-position-data [shape] (when (initialized?) @@ -2547,35 +2602,79 @@ (into [] (keep - (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] - (let [element (-> content :children - (get 0) :children ;; paragraph-set - (get paragraph) :children ;; paragraph + (fn [{:keys [paragraph span start-pos end-pos direction x y width height] :as entry}] + (let [paragraph-node (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph)) + element (-> paragraph-node :children ;; paragraph (get span)) element-text (:text element)] + (if (= direction 3) + (ruby-strip-entry element entry) + ;; Glyph orientation of a vertical strip; stored on the + ;; span or its paragraph. Empty reads normalize to nil + ;; (the SVG renderer then defaults to "mixed"). + (let [text-orientation + (when (= direction 2) + (let [orientation (or (get element :text-orientation) + (get paragraph-node :text-orientation))] + (when (seq orientation) orientation)))] - ;; Add comprehensive nil-safety checks - ;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos` - (when (and element element-text) - (let [text (subs element-text start-pos end-pos)] - (d/patch-object - txt/default-text-attrs - (d/without-nils - {:x x - :y (+ y height) - :width width - :height height - :direction (dr/translate-direction direction) - :font-id (get element :font-id) - :font-family (get element :font-family) - :font-size (dm/str (get element :font-size) "px") - :font-weight (get element :font-weight) - :text-transform (get element :text-transform) - :text-decoration (get element :text-decoration) - :letter-spacing (dm/str (get element :letter-spacing) "px") - :font-style (get element :font-style) - :fills (get element :fills) - :text text}))))))) + ;; Add comprehensive nil-safety checks + ;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos` + (when (and element element-text) + (let [text (subs element-text start-pos end-pos)] + (d/patch-object + txt/default-text-attrs + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + ;; Direction 2 marks a vertical-rl column strip; + ;; the SVG renderer draws it with CSS writing-mode. + :writing-mode (when (= direction 2) "vertical-rl") + :text-orientation text-orientation + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (dm/str (get element :font-size) "px") + :font-weight (get element :font-weight) + :text-transform (get element :text-transform) + :text-decoration (get element :text-decoration) + :text-combine-upright (get element :text-combine-upright) + ;; Emphasis marks (圏点) are drawn by the static SVG + ;; renderer; "none" carries no information. + :text-emphasis (let [emphasis (get element :text-emphasis)] + (when (and (string? emphasis) + (seq emphasis) + (not= "none" emphasis)) + emphasis)) + :annotation-clearance + (let [clearance (get element :annotation-clearance)] + (when (= "auto" clearance) clearance)) + :annotation-has-ruby + (let [ruby (get element :ruby)] + (when (and (string? ruby) (seq ruby)) true)) + ;; Horizontal position data has no separate ruby + ;; strip, so carry the annotation on the base entry + ;; for static SVG export. Vertical ruby has its own + ;; direction-3 position entry. + :ruby (when (not= direction 2) + (horizontal-ruby-slice + element start-pos end-pos)) + :ruby-size (get element :ruby-size) + :ruby-align (get element :ruby-align) + :ruby-overhang (get element :ruby-overhang) + :ruby-side (get element :ruby-side) + ;; Warichu spans render as two half-size sub-columns + ;; in the static SVG; "none" carries no information. + :warichu (let [warichu (get element :warichu)] + (when (= "warichu" warichu) warichu)) + :letter-spacing (dm/str (get element :letter-spacing) "px") + :font-style (get element :font-style) + :fills (get element :fills) + :text text}))))))))) result)))) (defn apply-canvas-blur @@ -2679,5 +2778,3 @@ viewport mount. Idempotent: the `delay` caches its in-flight promise." [] @module) - - diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index afe26e2726..5780e26640 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -13,10 +13,12 @@ [app.render-wasm.helpers :as h] [app.render-wasm.mem :as mem] [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm])) + [app.render-wasm.wasm :as wasm] + [app.util.i18n :as i18n] + [cuerdas.core :as str])) -(def ^:const PARAGRAPH-ATTR-U8-SIZE 12) -(def ^:const SPAN-ATTR-U8-SIZE 64) +(def ^:const PARAGRAPH-ATTR-U8-SIZE 16) +(def ^:const SPAN-ATTR-U8-SIZE 80) (def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS) (defn- encode-text @@ -51,18 +53,25 @@ (defn- write-paragraph [offset dview paragraph] - (let [text-align (sr/translate-text-align (get paragraph :text-align)) - text-direction (sr/translate-text-direction (get paragraph :text-direction)) - text-decoration (sr/translate-text-decoration (get paragraph :text-decoration)) - text-transform (sr/translate-text-transform (get paragraph :text-transform)) - line-height (f/serialize-line-height (get paragraph :line-height)) - letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))] + (let [text-align (sr/translate-text-align (get paragraph :text-align)) + text-direction (sr/translate-text-direction (get paragraph :text-direction)) + text-decoration (sr/translate-text-decoration (get paragraph :text-decoration)) + text-transform (sr/translate-text-transform (get paragraph :text-transform)) + writing-mode (sr/translate-writing-mode (get paragraph :writing-mode)) + text-orientation (sr/translate-text-orientation (get paragraph :text-orientation)) + line-height (f/serialize-line-height (get paragraph :line-height)) + letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))] (-> offset (mem/write-u8 dview text-align) (mem/write-u8 dview text-direction) (mem/write-u8 dview text-decoration) (mem/write-u8 dview text-transform) + (mem/write-u8 dview writing-mode) + (mem/write-u8 dview text-orientation) + ;; Alignment padding; must match RawParagraphData in Rust. + (mem/write-u8 dview 0) + (mem/write-u8 dview 0) (mem/write-f32 dview line-height) (mem/write-f32 dview letter-spacing) @@ -90,6 +99,10 @@ text-buffer (encode-text (get span :text "")) text-length (mem/size text-buffer) + + ruby-buffer (encode-text (get span :ruby "")) + ruby-length (mem/size ruby-buffer) + fills (take MAX-TEXT-FILLS (get span :fills [])) font-variant-id @@ -113,13 +126,51 @@ text-direction (or (sr/translate-text-direction (:text-direction span)) (sr/translate-text-direction (:text-direction paragraph)) - (sr/translate-text-direction "ltr"))] + (sr/translate-text-direction "ltr")) + + text-orientation + (sr/translate-text-orientation + (get span :text-orientation (get paragraph :text-orientation))) + + text-combine-upright + (sr/translate-text-combine-upright (get span :text-combine-upright)) + + text-emphasis + (sr/translate-text-emphasis (get span :text-emphasis)) + + warichu + (sr/translate-warichu (get span :warichu)) + + font-features + (sr/translate-font-features (get span :font-features)) + + annotation-clearance + (sr/translate-annotation-clearance + (get span :annotation-clearance)) + + ruby-size (sr/translate-ruby-size (get span :ruby-size)) + ruby-align (sr/translate-ruby-align (get span :ruby-align)) + ruby-overhang (sr/translate-ruby-overhang (get span :ruby-overhang)) + ruby-side (sr/translate-ruby-side (get span :ruby-side))] (-> offset (mem/write-u8 dview font-style) (mem/write-u8 dview text-decoration) (mem/write-u8 dview text-transform) (mem/write-u8 dview text-direction) + (mem/write-u8 dview text-orientation) + (mem/write-u8 dview text-combine-upright) + (mem/write-u8 dview text-emphasis) + (mem/write-u8 dview warichu) + (mem/write-u8 dview font-features) + (mem/write-u8 dview annotation-clearance) + (mem/write-u8 dview ruby-size) + (mem/write-u8 dview ruby-align) + (mem/write-u8 dview ruby-overhang) + (mem/write-u8 dview ruby-side) + ;; Alignment padding; must match RawTextSpan in Rust. + (mem/write-u8 dview 0) + (mem/write-u8 dview 0) (mem/write-f32 dview font-size) (mem/write-f32 dview line-height) @@ -131,6 +182,7 @@ (mem/write-uuid dview (d/nilv font-variant-id uuid/zero)) (mem/write-i32 dview text-length) + (mem/write-i32 dview ruby-length) (mem/write-i32 dview (count fills)) (mem/assert-written offset SPAN-ATTR-U8-SIZE) @@ -140,7 +192,9 @@ (defn write-shape-text ;; buffer has the following format: - ;; [ ] + ;; [ ] + ;; Ruby annotations are concatenated per span, in span order, after the base + ;; text. The reader splits them using the per-span byte lengths. [spans paragraph text] (let [normalized-paragraph (f/normalize-paragraph-font paragraph) normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans) @@ -152,7 +206,11 @@ text-buffer (encode-text text) text-size (mem/size text-buffer) - total-size (+ 4 metadata-size text-size) + ruby-text (apply str (map #(get % :ruby "") normalized-spans)) + ruby-buffer (encode-text ruby-text) + ruby-size (mem/size ruby-buffer) + + total-size (+ 4 metadata-size text-size ruby-size) heapu8 (mem/get-heap-u8) dview (mem/get-data-view) offset (mem/alloc total-size)] @@ -161,7 +219,8 @@ (mem/write-u32 dview num-spans) (write-paragraph dview normalized-paragraph) (write-spans dview normalized-spans normalized-paragraph) - (mem/write-buffer heapu8 text-buffer)) + (mem/write-buffer heapu8 text-buffer) + (mem/write-buffer heapu8 ruby-buffer)) (h/call wasm/internal-module "_set_shape_text_content"))) @@ -170,7 +229,14 @@ (def ^:private unicode-ranges {:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]" - :chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]" + ;; Han ideographs are shared by Japanese/Chinese/Korean (Han + ;; unification) and cannot identify a language by themselves; they + ;; are resolved to a concrete language by `resolve-ambiguous-cjk`. + :han #"[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]" + ;; CJK symbols/punctuation (U+3000-303F) and half/full-width forms + ;; (U+FF01-FF65, U+FFE0-FFEE) are likewise shared across + ;; Japanese/Chinese/Korean; resolved by `resolve-ambiguous-cjk`. + :cjk-punctuation #"[\u3000-\u303F\uFF01-\uFF65\uFFE0-\uFFEE]" :korean #"[\uAC00-\uD7AF]" :arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]" :cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]" @@ -243,3 +309,31 @@ used unicode-ranges)) +(defn- locale->cjk-language + "CJK language implied by an app locale string, or nil." + [locale] + (let [locale (str/lower (str locale))] + (cond + (str/starts-with? locale "ja") :japanese + (str/starts-with? locale "ko") :korean + (str/starts-with? locale "zh") :chinese + :else nil))) + +(defn resolve-ambiguous-cjk + "Resolves the ambiguous CJK classes (:han, :cjk-punctuation) to a + concrete language: an unambiguous script in the same content wins + (kana implies Japanese, hangul implies Korean), then the user + locale; Chinese is the final default." + ([langs] + (resolve-ambiguous-cjk langs @i18n/locale)) + ([langs locale] + (if (or (contains? langs :han) + (contains? langs :cjk-punctuation)) + (let [resolved (cond + (contains? langs :japanese) :japanese + (contains? langs :korean) :korean + :else (or (locale->cjk-language locale) :chinese))] + (-> langs + (disj :han :cjk-punctuation) + (conj resolved))) + langs))) diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index 3cc62c3c0c..b828c04642 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -256,6 +256,78 @@ default (unchecked-get values "ltr")] (d/nilv (unchecked-get values (d/name text-direction)) default))) +(defn translate-writing-mode + [writing-mode] + (let [values (unchecked-get wasm/serializers "writing-mode") + default (unchecked-get values "horizontal-tb")] + (d/nilv (unchecked-get values (d/name writing-mode)) default))) + +(defn translate-text-orientation + [text-orientation] + (let [values (unchecked-get wasm/serializers "text-orientation") + default (unchecked-get values "mixed")] + (d/nilv (unchecked-get values (d/name text-orientation)) default))) + +(defn translate-text-combine-upright + [text-combine-upright] + (let [values (unchecked-get wasm/serializers "text-combine-upright") + default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name text-combine-upright)) default))) + +(defn translate-text-emphasis + [text-emphasis] + (let [values (unchecked-get wasm/serializers "text-emphasis") + default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name text-emphasis)) default))) + +(defn translate-warichu + [warichu] + (let [values (unchecked-get wasm/serializers "warichu") + default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name warichu)) default))) + +(defn translate-font-features + [font-features] + (if-let [values (unchecked-get wasm/serializers "font-features")] + (let [default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name font-features)) default)) + 0)) + +(defn translate-annotation-clearance + [annotation-clearance] + (if-let [values (unchecked-get wasm/serializers "annotation-clearance")] + (let [default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name annotation-clearance)) default)) + 0)) + +(defn translate-ruby-size + [value] + (if-let [values (unchecked-get wasm/serializers "ruby-size")] + (let [default (unchecked-get values "half")] + (d/nilv (unchecked-get values (d/name value)) default)) + 0)) + +(defn translate-ruby-align + [value] + (if-let [values (unchecked-get wasm/serializers "ruby-align")] + (let [default (unchecked-get values "space-around")] + (d/nilv (unchecked-get values (d/name value)) default)) + 0)) + +(defn translate-ruby-overhang + [value] + (if-let [values (unchecked-get wasm/serializers "ruby-overhang")] + (let [default (unchecked-get values "auto")] + (d/nilv (unchecked-get values (d/name value)) default)) + 0)) + +(defn translate-ruby-side + [value] + (if-let [values (unchecked-get wasm/serializers "ruby-side")] + (let [default (unchecked-get values "over")] + (d/nilv (unchecked-get values (d/name value)) default)) + 0)) + (defn translate-font-style [font-style] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index d630346159..caf3db6cf2 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -626,6 +626,125 @@ {:start-para focus-para :start-offset focus-offset :end-para anchor-para :end-offset anchor-offset})) +(defn- text-char-count + [text] + (alength (js/Array.from text))) + +(defn- text-char-subs + [text start end] + (->> (js/Array.from text) + (drop start) + (take (- end start)) + (apply str))) + +(defn- para-char-count + [para] + (apply + (map (comp text-char-count :text) (:children para)))) + +(def ^:private japanese-span-style-defaults + "Explicit UI defaults for Japanese span attributes. These values must be + present in every selection snapshot so moving onto an unstyled span clears + the previous span's controls instead of leaving stale values behind." + {:text-combine-upright "none" + :text-emphasis "none" + :ruby "" + :ruby-size "half" + :ruby-align "space-around" + :ruby-overhang "auto" + :ruby-side "over" + :warichu "none" + :font-features "none" + :annotation-clearance "none"}) + +(defn- span-japanese-styles + [span] + (reduce-kv + (fn [styles attr default] + (assoc styles attr (or (get span attr) default))) + {} + japanese-span-style-defaults)) + +(defn- merge-selection-styles + [result styles] + (reduce-kv + (fn [result attr value] + (update result attr #(if (or (nil? %) (= % value)) value :multiple))) + result + styles)) + +(defn- span-at-offset + "Return the span used by the WASM editor for a collapsed caret. At a span + boundary the preceding span wins, matching find_text_span_at_offset in + render-wasm." + [paragraph offset] + (let [spans (:children paragraph)] + (loop [remaining-spans spans + accumulated 0] + (if-let [span (first remaining-spans)] + (let [span-end (+ accumulated (text-char-count (:text span)))] + (if (<= offset span-end) + span + (recur (rest remaining-spans) span-end))) + (last spans))))) + +(defn- selected-spans-in-paragraph + [paragraph selection-start selection-end] + (loop [spans (:children paragraph) + position 0 + selected []] + (if-let [span (first spans)] + (let [span-end (+ position (text-char-count (:text span)))] + (recur (rest spans) + span-end + (cond-> selected + (< (max position selection-start) + (min span-end selection-end)) + (conj span)))) + selected))) + +(defn selection-japanese-styles + "Read Japanese span styles for a normalized WASM selection from Penpot's + cached content tree. A range spanning different values reports :multiple; + a caret reports the style of its current span." + [content selection] + (when (and content selection) + (let [{:keys [start-para start-offset end-para end-offset]} + (normalize-selection selection) + paragraphs (-> content :children first :children) + collapsed? (and (= start-para end-para) + (= start-offset end-offset)) + selected-spans + (if collapsed? + (some-> (get paragraphs start-para) + (span-at-offset start-offset) + vector) + (mapcat + (fn [paragraph-index] + (when-let [paragraph (get paragraphs paragraph-index)] + (let [selection-start (if (= paragraph-index start-para) + start-offset + 0) + selection-end (if (= paragraph-index end-para) + end-offset + (para-char-count paragraph))] + (selected-spans-in-paragraph paragraph + selection-start + selection-end)))) + (range start-para (inc end-para))))] + (when (seq selected-spans) + (reduce (fn [result span] + (merge-selection-styles result (span-japanese-styles span))) + {} + selected-spans))))) + +(defn text-editor-get-current-japanese-styles + "Return Japanese span styles for the active WASM editor selection." + [] + (when wasm/context-initialized? + (let [shape-id (text-editor-get-active-shape-id) + selection (text-editor-get-selection)] + (selection-japanese-styles (get-cached-content shape-id) selection)))) + (defn- apply-attrs-to-paragraph "Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph. Splits spans at boundaries as needed." @@ -639,7 +758,7 @@ acc (let [span (first spans) text (:text span) - span-len (count text) + span-len (text-char-count text) span-end (+ pos span-len) ol-start (max pos sel-start) ol-end (min span-end sel-end) @@ -647,20 +766,16 @@ (if (not has-overlap?) (recur (rest spans) span-end (conj acc span)) (let [before (when (> ol-start pos) - (assoc span :text (subs text 0 (- ol-start pos)))) + (assoc span :text (text-char-subs text 0 (- ol-start pos)))) selected (merge span attrs - {:text (subs text (- ol-start pos) (- ol-end pos))}) + {:text (text-char-subs text (- ol-start pos) (- ol-end pos))}) after (when (< ol-end span-end) - (assoc span :text (subs text (- ol-end pos))))] + (assoc span :text (text-char-subs text (- ol-end pos) span-len)))] (recur (rest spans) span-end (-> acc (into (keep identity [before selected after])))))))))] (assoc para :children result))) -(defn- para-char-count - [para] - (apply + (map (fn [span] (count (:text span))) (:children para)))) - (defn apply-styles-to-selection [attrs use-shape-fn set-shape-text-content-fn] (when wasm/context-initialized? diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 77d8726347..7ebe8f3097 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -73,6 +73,17 @@ :fill-data shared/RawFillData :text-align shared/RawTextAlign :text-direction shared/RawTextDirection + :writing-mode shared/RawWritingMode + :text-orientation shared/RawTextOrientation + :text-combine-upright shared/RawTextCombineUpright + :text-emphasis shared/RawTextEmphasis + :warichu shared/RawWarichu + :font-features shared/RawFontFeatures + :annotation-clearance shared/RawAnnotationClearance + :ruby-size shared/RawRubySize + :ruby-align shared/RawRubyAlign + :ruby-overhang shared/RawRubyOverhang + :ruby-side shared/RawRubySide :text-decoration shared/RawTextDecoration :text-transform shared/RawTextTransform :multiple-state shared/MultipleState @@ -81,4 +92,3 @@ :stroke-linecap shared/RawStrokeLineCap :stroke-linejoin shared/RawStrokeLineJoin :fill-rule shared/RawFillRule}) - diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index 0f8d4471f2..021fe11de2 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -101,6 +101,9 @@ ;; Show viewbox. :wasm-viewbox + ;; Draw the jlreq-style character-frame grid over Japanese vertical text. + :wasm-text-grid + ;; Makes the GL context to fail on initialization. :wasm-gl-context-init-error diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index 20a2454e1f..a832c009cd 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -22,12 +22,37 @@ (def mapping {:fills [encode decode] + :ruby [identity #(when-not (= "" %) %)] + :ruby-size [identity #(when-not (= "" %) %)] + :ruby-align [identity #(when-not (= "" %) %)] + :ruby-overhang [identity #(when-not (= "" %) %)] + :ruby-side [identity #(when-not (= "" %) %)] + :text-emphasis [identity #(when-not (= "" %) %)] + :warichu [identity #(when-not (= "" %) %)] + :font-features [identity #(when-not (= "" %) %)] + :annotation-clearance [identity #(when-not (= "" %) %)] :typography-ref-id [encode decode] :typography-ref-file [encode decode] :font-id [identity identity] :font-variant-id [identity identity] :vertical-align [identity identity]}) +(defn- text-combine-upright->css + [value] + (case value + "digits2" "digits 2" + "digits3" "digits 3" + "digits" "digits 4" + value)) + +(defn- css->text-combine-upright + [value] + (case value + "digits 2" "digits2" + "digits 3" "digits3" + "digits 4" "digits" + value)) + (defn normalize-style-value "This function adds units to style values" [k v] @@ -114,14 +139,21 @@ ([key value] (attr->style-value key value false)) ([key value normalize?] - (if (attr-needs-mapping? key) + (cond + (= key :text-combine-upright) + (text-combine-upright->css value) + + (attr-needs-mapping? key) (let [[encoder] (get mapping key)] (if normalize? (normalize-style-value key (encoder value)) (encoder value))) - (if normalize? - (normalize-style-value key value) - value)))) + + normalize? + (normalize-style-value key value) + + :else + value))) (defn attr->style [[key value]] @@ -151,12 +183,18 @@ ([name value] (style->attr-value name value false)) ([name value normalize?] - (if (style-needs-mapping? name) + (cond + (= name "text-combine-upright") + (css->text-combine-upright value) + + (style-needs-mapping? name) (let [key (get-attr-keyword-from-css-variable name) [_ decoder] (get mapping key)] (if normalize? (normalize-attr-value key (decoder value)) (decoder value))) + + :else (let [key (get-attr-keyword name)] (if normalize? (normalize-attr-value key value) @@ -205,7 +243,10 @@ (assoc acc k (style-decode style-value)) acc)) (let [style-name (get-style-name k) - style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))] + style-value (.getPropertyValue style-declaration style-name) + style-value (if (= k :text-combine-upright) + (css->text-combine-upright style-value) + (normalize-attr-value k style-value))] (if (or (not removed-mixed) (not (contains? mixed-values style-value))) (assoc acc k style-value) acc)))) {} txt/text-style-attrs)) diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs index cd7ab9d5aa..a5091051ca 100644 --- a/frontend/src/app/util/text/content/to_dom.cljs +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -90,7 +90,14 @@ (defn get-root-styles [root] - (get-styles-from-attrs root txt/root-attrs txt/default-text-attrs)) + (let [styles (get-styles-from-attrs root txt/root-attrs txt/default-text-attrs) + ;; CHANGEME: simplify this comment + ;; Mirroring the shape's writing mode on the root makes paragraph + ;; blocks stack right-to-left in the DOM editor. It is not read + ;; back from the root (root-attrs does not include it). + writing-mode (txt/content-writing-mode root)] + (cond-> styles + (some? writing-mode) (assoc :writing-mode writing-mode)))) (defn get-text-span-styles [inline paragraph] diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index 4f04dab929..73cea41e66 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -10,14 +10,17 @@ Each test guards against a concrete bug found in the CSS/HTML generation of layout children and text shapes." (:require + ["react-dom/server" :as rds] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.uuid :as uuid] + [app.main.ui.shapes.text.fo-text :as fo-text] [app.util.code-gen.markup-html :as html] [app.util.code-gen.style-css :as css] [cljs.test :refer [deftest is testing] :include-macros true] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [rumext.v2 :as mf])) ;; --- Builders ------------------------------------------------------------ @@ -180,6 +183,29 @@ :children [{:text "Hello" :fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]}) +(def ^:private ruby-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :writing-mode "vertical-rl" + :text-orientation "upright" + :children [{:text "漢字" + :ruby "かんじ" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(defn- text-shape + [content] + (let [tid (uuid/next)] + {:id tid :name "Text" :type :text + :parent-id uuid/zero :frame-id uuid/zero + :x 0 :y 0 :width 100 :height 40 + :selrect (grc/make-rect 0 0 100 40) + :points (pts 0 0 100 40) + :grow-type :fixed + :content content})) + (deftest text-markup-emits-node-id-classes (testing "generated text markup carries the $id classes the CSS rules target" (let [tid (uuid/next) @@ -194,3 +220,90 @@ (is (str/includes? markup "root-0") "the text nodes must expose their $id as a class for the CSS to apply") (is (str/includes? markup "root-0-paragraph-set-0-paragraph-0"))))) + +(def ^:private warichu-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :writing-mode "vertical-rl" + :text-orientation "upright" + :children [{:text "割注入り" + :warichu "warichu" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(def ^:private palt-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "かな" + :font-features "palt" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(deftest foreign-object-text-emits-warichu-styles + (testing "browser/foreignObject render folds a warichu span into two half-size lines" + (let [text (text-shape warichu-text-content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "割注入り")) + (is (str/includes? markup "display:inline-block")) + (is (str/includes? markup "font-size:10px")) + (is (str/includes? markup "inline-size:2em"))))) + +(deftest foreign-object-warichu-sizes-by-unicode-code-points + (testing "non-BMP characters count as one slot when sizing warichu lines" + (let [content (assoc-in warichu-text-content + [:children 0 :children 0 :children 0 :text] + "割𠀀注😀") + text (text-shape content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "割𠀀注😀")) + (is (str/includes? markup "inline-size:2em"))))) + +(def ^:private tcy-digits2-text-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :writing-mode "vertical-rl" + :children [{:text "平成31年" + :text-combine-upright "digits2" + :font-size "20" + :fill-color "#000000" + :fill-opacity 1}]}]}]}) + +(deftest foreign-object-text-emits-counted-digits-css + (testing "the counted digits variants serialize as CSS `digits `" + (let [text (text-shape tcy-digits2-text-content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "text-combine-upright:digits 2"))))) + +(deftest foreign-object-text-emits-font-feature-settings + (testing "browser/foreignObject render emits palt/vpal as OpenType features" + (let [text (text-shape palt-text-content) + markup (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))] + (is (str/includes? markup "font-feature-settings:"palt""))))) + +(deftest text-markup-emits-ruby-annotations + (testing "generated text markup keeps ruby annotations beside the base text" + (let [text (text-shape ruby-text-content) + markup (html/generate-markup (objects text) [text])] + (is (str/includes? markup " (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 40 + :height 100}) + (assoc :name "Vertical text" + :position-data + [{:x 20 + :y 100 + :width 24 + :height 80 + :fills [{:fill-color "#112233" + :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "vertical-rl" + :text-orientation "upright" + :font-features "vpal" + :text "うA"}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :vertical-text + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :vertical-text))]})) + +(defn- setup-vertical-ruby-text + [] + (let [shape (-> (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 60 + :height 100}) + (assoc :name "Vertical ruby text" + :position-data + [{:x 20 + :y 100 + :width 24 + :height 80 + :fills [{:fill-color "#112233" + :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "vertical-rl" + :text-orientation "upright" + :text "漢字" + :ruby "かんじ"}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :vertical-ruby-text + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :vertical-ruby-text))]})) + +(defn- setup-vertical-emphasis-text + [] + (let [shape (-> (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 60 + :height 100}) + (assoc :name "Vertical emphasis text" + :position-data + [{:x 20 + :y 100 + :width 24 + :height 80 + :fills [{:fill-color "#112233" + :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "vertical-rl" + :text-orientation "upright" + :text "強調 あ" + :text-emphasis "filled-dot"}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :vertical-emphasis-text + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :vertical-emphasis-text))]})) + +(defn- setup-horizontal-emphasis-text + [] + (let [shape (-> (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 120 + :height 40}) + (assoc :name "Horizontal emphasis text" + :position-data + [{:x 20 + :y 60 + :width 100 + :height 20 + :fills [{:fill-color "#112233" + :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "horizontal-tb" + :text "強調、 あ" + :text-emphasis "filled-dot"}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :horizontal-emphasis-text + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :horizontal-emphasis-text))]})) + +(defn- setup-horizontal-stacked-annotations + [] + (let [shape (-> (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 120 + :height 40}) + (assoc :name "Horizontal stacked annotations" + :position-data + [{:x 20 + :y 60 + :width 100 + :height 20 + :fills [{:fill-color "#112233" :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "horizontal-tb" + :text "漢字" + :ruby "かんじ" + :text-emphasis "filled-dot" + :annotation-clearance "auto" + :annotation-has-ruby true}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :horizontal-stacked-annotations + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :horizontal-stacked-annotations))]})) + +(defn- setup-vertical-warichu-text + ([] (setup-vertical-warichu-text "割注入り")) + ([text] + (let [shape (-> (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 60 + :height 100}) + (assoc :name "Vertical warichu text" + :position-data + [{:x 20 + :y 100 + :width 24 + :height 40 + :fills [{:fill-color "#112233" + :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "vertical-rl" + :text-orientation "upright" + :text text + :warichu "warichu"}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :vertical-warichu-text + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :vertical-warichu-text))]}))) + +(defn- setup-horizontal-warichu-text + ([] (setup-horizontal-warichu-text "割注入り")) + ([text] + (let [shape (-> (cts/setup-shape {:type :text + :x 10 + :y 20 + :width 120 + :height 40}) + (assoc :name "Horizontal warichu text" + :position-data + [{:x 20 + :y 60 + :width 40 + :height 20 + :fills [{:fill-color "#112233" + :fill-opacity 1}] + :font-family "Noto Sans CJK JP" + :font-size "20" + :font-weight "400" + :writing-mode "horizontal-tb" + :text text + :warichu "warichu"}])) + file (cths/add-sample-shape + (cthf/sample-file :file1 :page-label :page1) + :horizontal-warichu-text + shape) + page (cthf/current-page file) + objects (:objects page)] + {:objects objects + :shapes [(get objects (cthi/id :horizontal-warichu-text))]}))) + (deftest empty-selection-yields-empty-string (is (= "" (svg/generate-markup {} [])))) @@ -59,3 +278,97 @@ "multi-select must NOT emit multiple roots") (is (= 1 (count-matches #"" markup)) "multi-select must NOT emit multiple closing tags")))) + +(deftest vertical-text-svg-preserves-browser-layout-properties + (testing "Static SVG carries the vertical writing properties used by browser exports" + (let [{:keys [objects shapes]} (setup-vertical-text) + markup (svg/generate-markup objects shapes)] + (is (re-find #"writing-mode:vertical-rl" markup)) + (is (re-find #"text-orientation:upright" markup)) + (is (re-find #"text-autospace:normal" markup)) + (is (re-find #"font-feature-settings:"vpal"" markup)) + (is (not (re-find #"shape (constantly {:content content})] + (t/is (= "digits2" (.-textCombineUpright range))) + (t/is (= "filled-dot" (.-textEmphasis range))) + (t/is (= "warichu" (.-warichu range))) + (t/is (= "vpal" (.-fontFeatures range))) + (t/is (= "auto" (.-annotationClearance range))) + (t/is (= "かんじ" (.-ruby range))) + (t/is (= "third" (.-rubySize range))) + (t/is (= "center" (.-rubyAlign range))) + (t/is (= "none" (.-rubyOverhang range))) + (t/is (= "under" (.-rubySide range)))))) + +(t/deftest text-range-japanese-properties-report-mixed-values + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "日" + :text-emphasis "filled-dot" + :ruby "にち" + :ruby-size "third"} + {:text "本" + :text-emphasis "none" + :ruby nil + :ruby-size "half"}]}]}]} + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)] + (with-redefs [u/proxy->shape (constantly {:content content})] + (t/is (= "mixed" (.-textEmphasis range))) + (t/is (= "mixed" (.-ruby range))) + (t/is (= "mixed" (.-rubySize range)))))) + +(t/deftest text-range-japanese-properties-update-the-selected-range + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4) + captured (atom [])] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + dwt/update-text-range + (fn [id start end attrs] + (swap! captured conj {:id id + :start start + :end end + :attrs attrs}) + :update-text-range) + st/emit! mock/noop] + (set! (.-textCombineUpright range) "digits2") + (set! (.-textEmphasis range) "filled-dot") + (set! (.-warichu range) "warichu") + (set! (.-fontFeatures range) "vpal") + (set! (.-annotationClearance range) "auto") + (set! (.-ruby range) "かんじ") + (set! (.-rubySize range) "third") + (set! (.-rubyAlign range) "center") + (set! (.-rubyOverhang range) "none") + (set! (.-rubySide range) "under") + (t/is (= [{:id shape-id :start 1 :end 4 :attrs {:text-combine-upright "digits2"}} + {:id shape-id :start 1 :end 4 :attrs {:text-emphasis "filled-dot"}} + {:id shape-id :start 1 :end 4 :attrs {:warichu "warichu"}} + {:id shape-id :start 1 :end 4 :attrs {:font-features "vpal"}} + {:id shape-id :start 1 :end 4 :attrs {:annotation-clearance "auto"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby "かんじ"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-size "third"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-align "center"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-overhang "none"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-side "under"}}] + @captured))))) + (t/deftest font-apply-to-text-uses-font-id-not-shape-id (let [file-id (random-uuid) diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs new file mode 100644 index 0000000000..33a43c5ae3 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -0,0 +1,282 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.render-wasm.texts-test + "Unit tests for CJK script classification and the Han-unification + resolution policy used to pick Noto fallback fonts." + (:require + [app.render-wasm.api :as api] + [app.render-wasm.api.texts :as texts] + [app.render-wasm.text-editor :as text-editor] + [app.render-wasm.wasm :as wasm] + [cljs.test :as t :include-macros true])) + +(def ^:private write-spans @#'texts/write-spans) + +(defn- langs [text] + (texts/collect-used-languages #{} text)) + +(defn- font-ids + [fonts] + (into #{} (map :font-id) fonts)) + +(defn- editor-content + [& paragraphs] + {:type "root" + :children [{:type "paragraph-set" + :children (mapv (fn [spans] + {:type "paragraph" + :children spans}) + paragraphs)}]}) + +(defn- selection + [anchor-para anchor-offset focus-para focus-offset] + {:anchor-para anchor-para + :anchor-offset anchor-offset + :focus-para focus-para + :focus-offset focus-offset}) + +(t/deftest japanese-styles-follow-the-selected-span + (let [content (editor-content + [{:text "日" :ruby "にち" :text-emphasis "filled-dot"} + {:text "本"}])] + (t/is (= {:text-combine-upright "none" + :text-emphasis "filled-dot" + :ruby "にち" + :ruby-size "half" + :ruby-align "space-around" + :ruby-overhang "auto" + :ruby-side "over" + :warichu "none" + :font-features "none" + :annotation-clearance "none"} + (text-editor/selection-japanese-styles + content (selection 0 0 0 1)))) + (t/is (= {:text-combine-upright "none" + :text-emphasis "none" + :ruby "" + :ruby-size "half" + :ruby-align "space-around" + :ruby-overhang "auto" + :ruby-side "over" + :warichu "none" + :font-features "none" + :annotation-clearance "none"} + (text-editor/selection-japanese-styles + content (selection 0 1 0 2)))))) + +(t/deftest japanese-styles-report-mixed-ranges-and-follow-the-caret + (let [content (editor-content + [{:text "平成" + :text-combine-upright "digits2" + :text-emphasis "filled-dot" + :ruby "へいせい" + :ruby-size "third" + :ruby-align "center" + :ruby-overhang "none" + :ruby-side "under" + :annotation-clearance "auto"} + {:text "年" + :warichu "warichu" + :font-features "vpal"}])] + (t/is (= {:text-combine-upright :multiple + :text-emphasis :multiple + :ruby :multiple + :ruby-size :multiple + :ruby-align :multiple + :ruby-overhang :multiple + :ruby-side :multiple + :warichu :multiple + :font-features :multiple + :annotation-clearance :multiple} + (text-editor/selection-japanese-styles + content (selection 0 0 0 3)))) + ;; A caret at the boundary belongs to the preceding span, as in WASM. + (t/is (= "digits2" + (:text-combine-upright + (text-editor/selection-japanese-styles + content (selection 0 2 0 2))))) + (t/is (= "warichu" + (:warichu + (text-editor/selection-japanese-styles + content (selection 0 3 0 3))))))) + +(t/deftest japanese-selection-offsets-count-non-bmp-text-as-one-character + (let [content (editor-content + [{:text "😀"} + {:text "日" :ruby "にち"}])] + (t/is (= "にち" + (:ruby + (text-editor/selection-japanese-styles + content (selection 0 1 0 2))))))) + +(t/deftest write-spans-serializes-font-features-in-reserved-byte + (let [sentinel (js-obj) + previous (if (.hasOwnProperty wasm/serializers "font-features") + (unchecked-get wasm/serializers "font-features") + sentinel)] + (try + (aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2}) + (let [buffer (js/ArrayBuffer. 256) + dview (js/DataView. buffer) + span {:text "日本語" + :font-size "16" + :font-weight "400" + :font-features "vpal"} + paragraph {:font-size "16" + :font-weight "400" + :line-height "1"}] + (write-spans 0 dview [span] paragraph) + (t/is (= 2 (.getUint8 dview 8))) + (t/is (= 0 (.getUint8 dview 9))) + (t/is (= 0 (.getUint8 dview 14))) + (t/is (= 0 (.getUint8 dview 15)))) + (finally + (if (identical? sentinel previous) + (js-delete wasm/serializers "font-features") + (aset wasm/serializers "font-features" previous)))))) + +(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte + (let [sentinel (js-obj) + previous (if (.hasOwnProperty wasm/serializers "annotation-clearance") + (unchecked-get wasm/serializers "annotation-clearance") + sentinel)] + (try + (aset wasm/serializers "annotation-clearance" #js {"none" 0 "auto" 1}) + (let [buffer (js/ArrayBuffer. 256) + dview (js/DataView. buffer) + span {:text "漢字" + :font-size "16" + :font-weight "400" + :annotation-clearance "auto"} + paragraph {:font-size "16" + :font-weight "400" + :line-height "1"}] + (write-spans 0 dview [span] paragraph) + (t/is (= 1 (.getUint8 dview 9))) + (t/is (= 0 (.getUint8 dview 14))) + (t/is (= 0 (.getUint8 dview 15)))) + (finally + (if (identical? sentinel previous) + (js-delete wasm/serializers "annotation-clearance") + (aset wasm/serializers "annotation-clearance" previous)))))) + +(t/deftest write-spans-serializes-ruby-customization-bytes + (let [buffer (js/ArrayBuffer. 256) + dview (js/DataView. buffer) + span {:text "漢字" + :font-size "16" + :font-weight "400" + :ruby-size "quarter" + :ruby-align "space-between" + :ruby-overhang "none" + :ruby-side "under"} + paragraph {:font-size "16" + :font-weight "400" + :line-height "1"}] + (write-spans 0 dview [span] paragraph) + (t/is (= [2 3 1 1 0 0] + (mapv #(.getUint8 dview %) (range 10 16)))))) + +(t/deftest write-spans-serializes-counted-digits-tcy + (let [sentinel (js-obj) + previous (if (.hasOwnProperty wasm/serializers "text-combine-upright") + (unchecked-get wasm/serializers "text-combine-upright") + sentinel)] + (try + (aset wasm/serializers "text-combine-upright" + #js {"none" 0 "all" 1 "digits" 2 "digits2" 3 "digits3" 4}) + (let [buffer (js/ArrayBuffer. 256) + dview (js/DataView. buffer) + span {:text "平成31年" + :font-size "16" + :font-weight "400" + :text-combine-upright "digits2"} + paragraph {:font-size "16" + :font-weight "400" + :line-height "1"}] + (write-spans 0 dview [span] paragraph) + ;; Byte 5 of the span attr block carries text-combine-upright. + (t/is (= 3 (.getUint8 dview 5)))) + (finally + (if (identical? sentinel previous) + (js-delete wasm/serializers "text-combine-upright") + (aset wasm/serializers "text-combine-upright" previous)))))) + +(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery + (let [content {:children + [{:children + [{:children + [{:text "Penpot" + :ruby "ぺんぽっと😀"}]}]}]} + expected #{"gfont-noto-sans-jp" "gfont-noto-color-emoji"}] + (with-redefs [texts/write-shape-text (fn [& _])] + (t/is (every? (font-ids (api/fonts-from-text-content content true)) expected))) + (t/is (every? (font-ids (api/fonts-from-text-content content false)) expected)))) + +(t/deftest classification-kana + ;; Hiragana/katakana are unambiguously Japanese. + (t/is (= #{:japanese} (langs "ひらがなとカタカナ"))) + ;; Half-width katakana too. + (t/is (= #{:japanese} (langs "デザイン")))) + +(t/deftest classification-han-is-ambiguous + ;; Kanji-only text (han-unification fixture) must NOT classify as a + ;; concrete language; it is ambiguous Han. + (t/is (= #{:han} (langs "東京都渋谷区神南一丁目")))) + +(t/deftest classification-cjk-punctuation + ;; CJK punctuation and full-width forms match the shared class + ;; (previously they matched no range at all). + (t/is (= #{:cjk-punctuation} (langs "、。「」『』()"))) + (t/is (= #{:cjk-punctuation} (langs "!?:;123ABC")))) + +(t/deftest classification-mixed-japanese + (t/is (= #{:japanese :han :cjk-punctuation} + (langs "「こんにちは」と彼は言った。")))) + +(t/deftest classification-korean + (t/is (= #{:korean} (langs "안녕하세요")))) + +(t/deftest resolve-kana-wins-over-locale + ;; Kana in the same content implies Japanese regardless of locale. + (t/is (= #{:japanese} + (texts/resolve-ambiguous-cjk #{:japanese :han :cjk-punctuation} "zh"))) + (t/is (= #{:japanese} + (texts/resolve-ambiguous-cjk #{:japanese :han} "en")))) + +(t/deftest resolve-hangul-wins-over-locale + (t/is (= #{:korean} + (texts/resolve-ambiguous-cjk #{:korean :han} "ja")))) + +(t/deftest resolve-han-only-uses-locale + (t/is (= #{:japanese} (texts/resolve-ambiguous-cjk #{:han} "ja"))) + (t/is (= #{:japanese} (texts/resolve-ambiguous-cjk #{:han} "ja_paid"))) + (t/is (= #{:korean} (texts/resolve-ambiguous-cjk #{:han} "ko"))) + (t/is (= #{:chinese} (texts/resolve-ambiguous-cjk #{:han} "zh_cn")))) + +(t/deftest resolve-han-only-defaults-to-chinese + ;; Without kana/hangul and without a CJK locale, keep the previous + ;; behavior (Noto Sans SC). + (t/is (= #{:chinese} (texts/resolve-ambiguous-cjk #{:han} "en"))) + (t/is (= #{:chinese} (texts/resolve-ambiguous-cjk #{:han} nil)))) + +(t/deftest resolve-punctuation-only + (t/is (= #{:japanese} (texts/resolve-ambiguous-cjk #{:cjk-punctuation} "ja"))) + (t/is (= #{:chinese} (texts/resolve-ambiguous-cjk #{:cjk-punctuation} "en")))) + +(t/deftest resolve-leaves-unambiguous-sets-alone + (t/is (= #{:latin-ext} (texts/resolve-ambiguous-cjk #{:latin-ext} "ja"))) + (t/is (= #{} (texts/resolve-ambiguous-cjk #{} "ja"))) + ;; Other detected languages survive resolution. + (t/is (= #{:japanese :cyrillic} + (texts/resolve-ambiguous-cjk #{:han :cyrillic} "ja")))) + +(t/deftest resolve-han-unification-fixture + ;; Acceptance case from render-wasm/fixtures/japanese-typography.json: + ;; a kanji-only Japanese address resolves to Japanese under a ja locale. + (t/is (= #{:japanese} + (texts/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja")))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 2adae3e61e..a69145812e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -46,6 +46,7 @@ [frontend-tests.plugins.utils-test] [frontend-tests.plugins.value-objects-test] [frontend-tests.render-wasm.process-objects-test] + [frontend-tests.render-wasm.texts-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] [frontend-tests.tokens.logic.token-actions-test] @@ -56,9 +57,11 @@ [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] + [frontend-tests.ui.css-cursors-test] [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] + [frontend-tests.ui.text-options-test] [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] @@ -120,6 +123,7 @@ 'frontend-tests.plugins.utils-test 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test + 'frontend-tests.render-wasm.texts-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.logic.token-actions-test @@ -130,9 +134,11 @@ 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test + 'frontend-tests.ui.css-cursors-test 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.ui.text-options-test 'frontend-tests.util-object-test 'frontend-tests.util-range-tree-test 'frontend-tests.util-simple-math-test diff --git a/frontend/test/frontend_tests/ui/css_cursors_test.cljs b/frontend/test/frontend_tests/ui/css_cursors_test.cljs new file mode 100644 index 0000000000..5478bca52c --- /dev/null +++ b/frontend/test/frontend_tests/ui/css_cursors_test.cljs @@ -0,0 +1,9 @@ +(ns frontend-tests.ui.css-cursors-test + (:require + [app.main.ui.css-cursors :as cursors] + [cljs.test :as t])) + +(t/deftest text-cursor-follows-writing-direction-and-shape-rotation + (t/is (= "cursor-text-0" (cursors/get-text 0 false))) + (t/is (= "cursor-text-90" (cursors/get-text 0 true))) + (t/is (= "cursor-text-10" (cursors/get-text 280 true)))) diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs new file mode 100644 index 0000000000..7fd1451b18 --- /dev/null +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -0,0 +1,207 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.ui.text-options-test + (:require + ["react-dom/server" :as rds] + [app.main.data.workspace.texts :as dwt] + [app.main.ui.ds.controls.select :as select] + [app.main.ui.shapes.text.styles :as text-styles] + [app.main.ui.workspace.sidebar.options.menus.text :as text-menu] + [app.util.text.content.styles :as content-styles] + [cljs.test :as t :include-macros true] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private japanese-span-attrs + [:text-combine-upright + :text-emphasis + :ruby + :ruby-size + :ruby-align + :ruby-overhang + :ruby-side + :warichu + :font-features + :annotation-clearance]) + +(def ^:private radio-selected @#'text-menu/radio-selected) +(def ^:private span-input-value @#'text-menu/span-input-value) +(def ^:private span-select-value @#'text-menu/span-select-value) +(def ^:private with-mixed-span-option @#'text-menu/with-mixed-span-option) +(def ^:private ruby-common-props @#'text-menu/ruby-common-props) + +(t/deftest text-emphasis-select-reports-and-restores-canonical-values + (let [options (text-menu/text-emphasis-options identity) + reported-id (:id (select/get-option options "filled-dot"))] + (t/is (= "filled-dot" reported-id)) + (t/is (= "filled-dot" + (:id (select/get-option options reported-id)))))) + +(t/deftest annotation-clearance-select-reports-canonical-values + (let [options (text-menu/annotation-clearance-options identity)] + (t/is (= ["none" "auto"] (mapv :id options))) + (t/is (= "auto" (:id (select/get-option options "auto")))))) + +(t/deftest unrestricted-tcy-is-only-offered-for-a-text-selection + (t/is (= ["none" "digits"] + (mapv :value (text-menu/text-combine-upright-options false identity)))) + (t/is (= ["none" "all" "digits"] + (mapv :value (text-menu/text-combine-upright-options true identity))))) + +(t/deftest ruby-container-styles-preserve-customization + (let [style (text-styles/generate-ruby-container-styles + {:ruby-align "space-between" + :ruby-overhang "none" + :ruby-side "under"})] + (t/is (= "space-between" (.-rubyAlign style))) + (t/is (= "none" (.-rubyOverhang style))) + (t/is (= "under" (.-rubyPosition style))))) + +(t/deftest advanced-furigana-options-are-collapsed-by-default + (let [markup (rds/renderToStaticMarkup + (mf/element text-menu/ruby-presentation-options* + #js {:values {} + :on-change identity + :on-blur identity}))] + (t/is (str/includes? markup "data-testid=\"ruby-presentation-options-toggle\"")) + (t/is (str/includes? markup "aria-expanded=\"false\"")) + (t/is (str/includes? markup "workspace.options.text-options.ruby-advanced-options")))) + +(t/deftest advanced-furigana-options-preserve-change-callbacks + (let [on-change identity + on-blur identity + props (ruby-common-props {} on-change on-blur)] + (t/is (identical? on-change (unchecked-get props "onChange"))) + (t/is (identical? on-blur (unchecked-get props "onBlur"))))) + +(t/deftest shape-level-furigana-presentation-only-updates-ruby-spans + (let [shape {:content + {:type "root" + :children + [{:type "paragraph-set" + :children + [{:type "paragraph" + :children + [{:text "日" + :ruby "にち" + :ruby-size "half"} + {:text "本" + :ruby "" + :ruby-size "quarter"}]}]}]}} + values (dwt/current-ruby-values {:shape shape + :attrs dwt/ruby-presentation-attrs}) + updated (dwt/update-ruby-presentation-attrs shape {:ruby-size "third"}) + spans (get-in updated [:content :children 0 :children 0 :children])] + (t/is (= "half" (:ruby-size values))) + (t/is (= "third" (:ruby-size (first spans)))) + (t/is (= "quarter" (:ruby-size (second spans)))))) + +(t/deftest mixed-japanese-span-values-have-distinct-control-states + (let [options (with-mixed-span-option + (text-menu/text-emphasis-options identity) + :multiple)] + (t/is (= "mixed" (span-select-value :multiple "none"))) + (t/is (= "mixed" (span-input-value :multiple))) + (t/is (= "mixed" (:id (last options)))) + (t/is (true? (:disabled (last options)))) + (t/is (= "" (radio-selected :multiple "none"))))) + +(t/deftest whole-text-selection-reports-every-differing-span-value-as-mixed + (let [shape {:content + {:type "root" + :children + [{:type "paragraph-set" + :children + [{:type "paragraph" + :children + [{:text "日" + :text-combine-upright "digits2" + :text-emphasis "filled-dot" + :ruby "にち" + :ruby-size "third" + :ruby-align "center" + :ruby-overhang "none" + :ruby-side "under" + :warichu "warichu" + :font-features "palt" + :annotation-clearance "auto"} + {:text "本" + :text-combine-upright "none" + :text-emphasis "none" + :ruby "" + :ruby-size "half" + :ruby-align "space-around" + :ruby-overhang "auto" + :ruby-side "over" + :warichu "none" + :font-features "none" + :annotation-clearance "none"}]}]}]}} + values (dwt/current-text-values {:shape shape + :attrs japanese-span-attrs})] + (t/is (= (zipmap japanese-span-attrs (repeat :multiple)) values)))) + +(t/deftest wasm-editor-span-styles-preserve-persisted-vertical-paragraph-values + (let [shape {:content + {:type "root" + :children + [{:type "paragraph-set" + :children + [{:type "paragraph" + :writing-mode "vertical-rl" + :text-orientation "upright" + :children [{:text "12" :text-combine-upright "none"}]}]}]}} + editor-styles {:text-combine-upright "digits2"} + paragraph-values (dwt/current-paragraph-values + {:editor-styles editor-styles + :shape shape + :attrs [:writing-mode :text-orientation]}) + text-values (dwt/current-text-values + {:editor-styles editor-styles + :shape shape + :attrs [:text-combine-upright]})] + (t/is (= "vertical-rl" (:writing-mode paragraph-values))) + (t/is (= "upright" (:text-orientation paragraph-values))) + (t/is (= "digits2" (:text-combine-upright text-values))))) + +(t/deftest counted-tcy-values-round-trip-through-valid-css + (t/is (= "digits 2" + (unchecked-get + (content-styles/attrs->styles {:text-combine-upright "digits2"}) + "text-combine-upright"))) + (t/is (= {:text-combine-upright "digits3"} + (content-styles/styles->attrs + {"text-combine-upright" "digits 3"})))) + +(t/deftest japanese-layout-is-explicitly-enabled-by-writing-mode + (t/is (false? (text-menu/japanese-layout-enabled? {}))) + (t/is (false? (text-menu/japanese-layout-enabled? {:writing-mode nil}))) + (t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "horizontal-tb"}))) + (t/is (true? (text-menu/japanese-layout-enabled? {:writing-mode "vertical-rl"}))) + (t/is (nil? (text-menu/japanese-layout-enabled? {:writing-mode :multiple})))) + +(t/deftest japanese-layout-toggle-emits-a-persisted-writing-mode + (t/is (= {:writing-mode "horizontal-tb"} + (text-menu/japanese-layout-toggle-attrs true))) + (t/is (= {:writing-mode nil} + (text-menu/japanese-layout-toggle-attrs false)))) + +(t/deftest japanese-layout-state-survives-transient-selection-styles + (t/is (true? (text-menu/reconcile-japanese-layout-state true {} false))) + (t/is (true? (text-menu/reconcile-japanese-layout-state false + {:writing-mode "vertical-rl"} + false))) + (t/is (false? (text-menu/reconcile-japanese-layout-state true {} true)))) + +(t/deftest japanese-controls-distinguish-horizontal-and-vertical-modes + (t/is (false? (text-menu/vertical-japanese-layout? + {:writing-mode "horizontal-tb"}))) + (t/is (true? (text-menu/vertical-japanese-layout? + {:writing-mode "vertical-rl"}))) + (t/is (= "palt" + (text-menu/proportional-metrics-feature "horizontal-tb"))) + (t/is (= "vpal" + (text-menu/proportional-metrics-feature "vertical-rl")))) diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index e2c320cf43..cb4aa89ad9 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -16,8 +16,15 @@ import { mapContentFragmentFromString, } from "./content/dom/Content.js"; import { resetInertElement } from "./content/dom/Style.js"; -import { createRoot, createEmptyRoot } from "./content/dom/Root.js"; -import { createParagraph } from "./content/dom/Paragraph.js"; +import { + createRoot, + createEmptyRoot, + setRootStyles, +} from "./content/dom/Root.js"; +import { + createParagraph, + setParagraphStyles, +} from "./content/dom/Paragraph.js"; import { createEmptyTextSpan, createTextSpan } from "./content/dom/TextSpan.js"; import { isLineBreak } from "./content/dom/LineBreak.js"; import LayoutType from "./layout/LayoutType.js"; @@ -385,8 +392,7 @@ export class TextEditor extends EventTarget { * @param {InputEvent} e */ #onBeforeInput = (e) => { - if (e.inputType === "historyUndo" - || e.inputType === "historyRedo") { + if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { return; } @@ -416,8 +422,7 @@ export class TextEditor extends EventTarget { * @param {InputEvent} e */ #onInput = (e) => { - if (e.inputType === "historyUndo" - || e.inputType === "historyRedo") { + if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { return; } @@ -628,6 +633,25 @@ export class TextEditor extends EventTarget { return this; } + /** + * Applies paragraph-level styles to the root and to every paragraph, + * regardless of the current selection. Used for properties that are + * whole-shape in the model (e.g. writing-mode) so paragraphs cannot + * diverge. + * + * @param {Object.} styles + * @returns {TextEditor} + */ + applyStylesToAllParagraphs(styles) { + setRootStyles(this.#root, styles); + for (const paragraph of this.#root.children) { + setParagraphStyles(paragraph, styles); + } + this.#notifyLayout(LayoutType.FULL); + this.#changeController.notifyImmediately(); + return this; + } + /** * Selects all content. * @@ -705,7 +729,7 @@ export function isEmpty(instance) { if (isTextEditor(instance)) { return instance.isEmpty; } - throw new TypeError('Instance is not a TextEditor'); + throw new TypeError("Instance is not a TextEditor"); } /** @@ -759,7 +783,7 @@ export function getCurrentStyle(instance) { if (isTextEditor(instance)) { return instance.currentStyle; } - throw new TypeError('Instance is not a TextEditor'); + throw new TypeError("Instance is not a TextEditor"); } /** @@ -774,7 +798,22 @@ export function applyStylesToSelection(instance, styles) { if (isTextEditor(instance)) { return instance.applyStylesToSelection(styles); } - throw new TypeError('Instance is not a TextEditor'); + throw new TypeError("Instance is not a TextEditor"); +} + +/** + * Applies the specified paragraph-level styles to every paragraph of the + * TextEditor passed, regardless of the selection. + * + * @param {TextEditor} instance + * @param {Object.} styles + * @returns {TextEditor|null} + */ +export function applyStylesToAllParagraphs(instance, styles) { + if (isTextEditor(instance)) { + return instance.applyStylesToAllParagraphs(styles); + } + throw new TypeError("Instance is not a TextEditor"); } /** @@ -788,7 +827,7 @@ export function dispose(instance) { if (isTextEditor(instance)) { return instance.dispose(); } - throw new TypeError('Instance is not a TextEditor'); + throw new TypeError("Instance is not a TextEditor"); } export default TextEditor; diff --git a/frontend/text-editor/src/editor/TextEditor.test.js b/frontend/text-editor/src/editor/TextEditor.test.js index cb78558a16..909a07197b 100644 --- a/frontend/text-editor/src/editor/TextEditor.test.js +++ b/frontend/text-editor/src/editor/TextEditor.test.js @@ -43,6 +43,47 @@ describe("TextEditor", () => { expect(textEditor.numParagraphs).toBe(4); }); + test("applyStylesToAllParagraphs styles every paragraph and the root", () => { + const textEditor = new TextEditor(document.createElement("div")); + textEditor.root = textEditor.createRoot([ + textEditor.createParagraph([ + textEditor.createTextSpanFromString("Hello, World!"), + ]), + textEditor.createParagraph([ + textEditor.createTextSpanFromString("¡Hola, Mundo!"), + ]), + ]); + textEditor.applyStylesToAllParagraphs({ "writing-mode": "vertical-rl" }); + expect(textEditor.root.style.getPropertyValue("writing-mode")).toBe( + "vertical-rl", + ); + for (const paragraph of textEditor.root.children) { + expect(paragraph.style.getPropertyValue("writing-mode")).toBe( + "vertical-rl", + ); + } + }); + + test("applyStylesToAllParagraphs removes a style when its value is null", () => { + const textEditor = new TextEditor(document.createElement("div")); + textEditor.root = textEditor.createRoot([ + textEditor.createParagraph([ + textEditor.createTextSpanFromString("Hello, World!"), + ]), + textEditor.createParagraph([ + textEditor.createTextSpanFromString("¡Hola, Mundo!"), + ]), + ]); + textEditor.applyStylesToAllParagraphs({ "writing-mode": "vertical-rl" }); + + textEditor.applyStylesToAllParagraphs({ "writing-mode": null }); + + expect(textEditor.root.style.getPropertyValue("writing-mode")).toBe(""); + for (const paragraph of textEditor.root.children) { + expect(paragraph.style.getPropertyValue("writing-mode")).toBe(""); + } + }); + test("Disposing a TextEditor nullifies everything", () => { const textEditor = new TextEditor(document.createElement("div")); expect(textEditor).toBeInstanceOf(TextEditor); diff --git a/frontend/text-editor/src/editor/content/dom/Paragraph.js b/frontend/text-editor/src/editor/content/dom/Paragraph.js index 465aaea95b..a85024e2a0 100644 --- a/frontend/text-editor/src/editor/content/dom/Paragraph.js +++ b/frontend/text-editor/src/editor/content/dom/Paragraph.js @@ -46,6 +46,8 @@ export const STYLES = [ ["text-transform"], ["text-align"], ["direction"], + ["writing-mode"], + ["text-orientation"], ]; /** @@ -146,18 +148,20 @@ export function createParagraphWith(text, styles, attrs) { if (text === "" || text === "\n") { return createEmptyParagraph(styles, attrs); } - return createParagraph([ - createTextSpan(new Text(text)) - ], styles, attrs); + return createParagraph([createTextSpan(new Text(text))], styles, attrs); } else if (Array.isArray(text)) { return createParagraph( text.map((text) => { if (text === "" || text === "\n") return createEmptyTextSpan(styles); return createTextSpan(new Text(text), styles); - }) - , styles, attrs); + }), + styles, + attrs, + ); } else { - throw new TypeError("Invalid text, it should be an array of strings or a string"); + throw new TypeError( + "Invalid text, it should be an array of strings or a string", + ); } } diff --git a/frontend/text-editor/src/editor/content/dom/Root.js b/frontend/text-editor/src/editor/content/dom/Root.js index f2dc401c90..8e51e67646 100644 --- a/frontend/text-editor/src/editor/content/dom/Root.js +++ b/frontend/text-editor/src/editor/content/dom/Root.js @@ -13,7 +13,7 @@ import { setStyles } from "./Style.js"; export const TAG = "DIV"; export const TYPE = "root"; export const QUERY = `[data-itype="${TYPE}"]`; -export const STYLES = [["--vertical-align"]]; +export const STYLES = [["--vertical-align"], ["writing-mode"]]; /** * Returns true if passed node is a root. diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js index bfadad1a6e..6352545a9a 100644 --- a/frontend/text-editor/src/editor/content/dom/Style.js +++ b/frontend/text-editor/src/editor/content/dom/Style.js @@ -243,8 +243,7 @@ export function normalizeStyles( * @returns {HTMLElement} */ export function setStyle(element, styleName, styleValue, styleUnit) { - if (styleValue === "mixed") - return element; + if (styleValue === "mixed") return element; if ( styleName.startsWith("--") && @@ -342,6 +341,10 @@ export function setStylesFromObject(element, allowedStyles, styleObject) { } let styleValue = styleObject[styleName]; + if (styleValue == null) { + element.style.removeProperty(styleName); + continue; + } if (!styleValue) { continue; } diff --git a/frontend/text-editor/src/editor/content/dom/TextSpan.js b/frontend/text-editor/src/editor/content/dom/TextSpan.js index 1c3a25cd25..d95958552c 100644 --- a/frontend/text-editor/src/editor/content/dom/TextSpan.js +++ b/frontend/text-editor/src/editor/content/dom/TextSpan.js @@ -24,7 +24,16 @@ export const STYLES = [ ["--typography-ref-file"], ["--font-id"], ["--font-variant-id"], + ["--font-features"], + ["--annotation-clearance"], ["--fills"], + ["--ruby"], + ["--ruby-size"], + ["--ruby-align"], + ["--ruby-overhang"], + ["--ruby-side"], + ["--text-emphasis"], + ["--warichu"], ["font-variant"], ["font-family"], ["font-size", "px"], @@ -32,6 +41,7 @@ export const STYLES = [ ["font-style"], ["line-height"], ["letter-spacing", "px"], + ["text-combine-upright"], ["text-decoration"], ["text-transform"], ]; diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 371d94e99f..91dcb64bd4 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -55,6 +55,19 @@ import { SafeGuard } from "./SafeGuard.js"; import { sanitizeFontFamily } from "../content/dom/Style.js"; import StyleDeclaration from "./StyleDeclaration.js"; +const JAPANESE_SPAN_STYLE_DEFAULTS = { + "text-combine-upright": "none", + "--text-emphasis": "none", + "--ruby": "", + "--ruby-size": "half", + "--ruby-align": "space-around", + "--ruby-overhang": "auto", + "--ruby-side": "over", + "--warichu": "none", + "--font-features": "none", + "--annotation-clearance": "none", +}; + /** * Supported options for the SelectionController. * @@ -228,6 +241,13 @@ export class SelectionController extends EventTarget { * @param {HTMLElement} element */ #applyStylesFromElementToCurrentStyle(element) { + if (isTextSpan(element)) { + for (const [styleName, defaultValue] of Object.entries( + JAPANESE_SPAN_STYLE_DEFAULTS, + )) { + this.#currentStyle.setProperty(styleName, defaultValue); + } + } for (let index = 0; index < element.style.length; index++) { const styleName = element.style.item(index); // Only merge fill styles from text spans. @@ -257,6 +277,15 @@ export class SelectionController extends EventTarget { } this.#currentStyle.mergeProperty(styleName, styleValue); } + if (isTextSpan(element)) { + for (const [styleName, defaultValue] of Object.entries( + JAPANESE_SPAN_STYLE_DEFAULTS, + )) { + if (!element.style.getPropertyValue(styleName)) { + this.#currentStyle.mergeProperty(styleName, defaultValue); + } + } + } } /** diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 533e4c751c..c5ca81d940 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -9,7 +9,7 @@ import { createLineBreak } from "../content/dom/LineBreak.js"; import { TextEditorMock } from "../../test/TextEditorMock.js"; import { SelectionController } from "./SelectionController.js"; import { SelectionDirection } from "./SelectionDirection.js"; -import StyleDeclaration from './StyleDeclaration.js'; +import StyleDeclaration from "./StyleDeclaration.js"; /* @vitest-environment jsdom */ @@ -340,15 +340,15 @@ describe("SelectionController", () => { expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( Text, ); - expect(textEditorMock.root.firstChild.children.item(0).firstChild.nodeValue).toBe( - "Lorem ", - ); + expect( + textEditorMock.root.firstChild.children.item(0).firstChild.nodeValue, + ).toBe("Lorem "); expect( textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue, ).toBe("ipsum "); - expect(textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue).toBe( - "dolor", - ); + expect( + textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue, + ).toBe("dolor"); }); test("`insertPaste` should insert a text span from a pasted fragment (at end)", () => { @@ -606,25 +606,29 @@ describe("SelectionController", () => { const textEditorMock = TextEditorMock.createTextEditorMockEmpty(); const root = textEditorMock.root; const selection = document.getSelection(); - const selectionController = new SelectionController(textEditorMock, selection); - focus( - selection, + const selectionController = new SelectionController( textEditorMock, - root.firstChild.firstChild.firstChild, - 0, + selection, ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0); selectionController.insertParagraph(); expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); expect(textEditorMock.root.dataset.itype).toBe("root"); expect(textEditorMock.root.children.length).toBe(2); expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); - expect(textEditorMock.root.children.item(0).dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe( + "paragraph", + ); expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( HTMLSpanElement, ); - expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe("span"); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe( + "span", + ); expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); - expect(textEditorMock.root.children.item(1).dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe( + "paragraph", + ); expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( HTMLSpanElement, ); @@ -636,7 +640,7 @@ describe("SelectionController", () => { test("`insertParagraph` should insert a new paragraph after a text", () => { const textEditorMock = TextEditorMock.createTextEditorMockWith([ - ["Hello, World!"] + ["Hello, World!"], ]); const root = textEditorMock.root; const selection = document.getSelection(); @@ -648,7 +652,7 @@ describe("SelectionController", () => { selection, textEditorMock, root.firstChild.firstChild.firstChild, - "Hello, World!".length + "Hello, World!".length, ); selectionController.insertParagraph(); expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); @@ -677,9 +681,9 @@ describe("SelectionController", () => { expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( "span", ); - expect(textEditorMock.root.children.item(1).firstChild.firstChild).toBeInstanceOf( - HTMLBRElement, - ); + expect( + textEditorMock.root.children.item(1).firstChild.firstChild, + ).toBeInstanceOf(HTMLBRElement); expect(textEditorMock.root.textContent).toBe("Hello, World!"); }); @@ -693,12 +697,7 @@ describe("SelectionController", () => { textEditorMock, selection, ); - focus( - selection, - textEditorMock, - root.firstChild.firstChild.firstChild, - 0, - ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0); selectionController.insertParagraph(); expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); expect(textEditorMock.root.dataset.itype).toBe("root"); @@ -713,9 +712,9 @@ describe("SelectionController", () => { expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe( "span", ); - expect(textEditorMock.root.children.item(0).firstChild.firstChild).toBeInstanceOf( - HTMLBRElement, - ); + expect( + textEditorMock.root.children.item(0).firstChild.firstChild, + ).toBeInstanceOf(HTMLBRElement); expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); expect(textEditorMock.root.children.item(1).dataset.itype).toBe( "paragraph", @@ -954,10 +953,9 @@ describe("SelectionController", () => { }); test("`replaceTextSpans` should replace the selected text in multiple text spans (2 completely selected)", () => { - const textEditorMock = TextEditorMock.createTextEditorMockWith([[ - "Hello, ", - "World!", - ]]); + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, ", "World!"], + ]); const root = textEditorMock.root; const selection = document.getSelection(); const selectionController = new SelectionController( @@ -995,10 +993,9 @@ describe("SelectionController", () => { }); test("`replaceTextSpans` should replace the selected text in multiple text spans (2 partially selected)", () => { - const textEditorMock = TextEditorMock.createTextEditorMockWith([[ - "Hello, ", - "World!", - ]]); + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, ", "World!"], + ]); const root = textEditorMock.root; const selection = document.getSelection(); const selectionController = new SelectionController( @@ -1041,10 +1038,9 @@ describe("SelectionController", () => { }); test("`replaceTextSpans` should replace the selected text in multiple text spans (1 partially selected, 1 completely selected)", () => { - const textEditorMock = TextEditorMock.createTextEditorMockWith([[ - "Hello, ", - "World!", - ]]); + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, ", "World!"], + ]); const root = textEditorMock.root; const selection = document.getSelection(); const selectionController = new SelectionController( @@ -1162,10 +1158,9 @@ describe("SelectionController", () => { }); test("`removeSelected` multiple text spans", () => { - const textEditorMock = TextEditorMock.createTextEditorMockWith([[ - "Hello, ", - "World!", - ]]); + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, ", "World!"], + ]); const root = textEditorMock.root; const selection = document.getSelection(); const selectionController = new SelectionController( @@ -1604,6 +1599,46 @@ describe("SelectionController", () => { ); }); + test("`applyStyles` keeps font features on the selected text span", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("日本語"); + const root = textEditorMock.root; + const textNode = root.firstChild.firstChild.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, textNode, 0, textNode, 3); + + selectionController.applyStyles({ "--font-features": "palt" }); + + const selectedSpan = root.firstChild.firstChild; + expect(selectedSpan.style.getPropertyValue("--font-features")).toBe("palt"); + expect(selectedSpan.getAttribute("style")).toContain( + "--font-features: palt", + ); + }); + + test("`applyStyles` keeps annotation clearance on the selected text span", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("漢字"); + const root = textEditorMock.root; + const textNode = root.firstChild.firstChild.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, textNode, 0, textNode, 2); + + selectionController.applyStyles({ "--annotation-clearance": "auto" }); + + const selectedSpan = root.firstChild.firstChild; + expect(selectedSpan.style.getPropertyValue("--annotation-clearance")).toBe( + "auto", + ); + }); + test("`applyStyles` to paragraphs", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ createParagraphWith(["Hello, "], { @@ -1695,11 +1730,14 @@ describe("SelectionController", () => { ]); const root = textEditorMock.root; const selection = document.getSelection(); - const selectionController = new SelectionController(textEditorMock, selection); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); textEditorMock.element.focus(); selectionController.selectAll(); expect(selectionController.anchorNode).toBe( - root.firstChild.firstChild.firstChild + root.firstChild.firstChild.firstChild, ); expect(selectionController.focusNode).toBe( root.lastChild.firstChild.firstChild, @@ -1717,12 +1755,17 @@ describe("SelectionController", () => { ]); const root = textEditorMock.root; const selection = document.getSelection(); - const selectionController = new SelectionController(textEditorMock, selection); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); textEditorMock.element.focus(); selectionController.cursorToEnd(); - expect(selectionController.focusNode).toBe(root.lastChild.firstChild.firstChild); + expect(selectionController.focusNode).toBe( + root.lastChild.firstChild.firstChild, + ); expect(selectionController.focusAtEnd).toBeTruthy(); - }) + }); test("`currentStyle` ignores empty text nodes when merging span styles (no false mixed font-size)", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ @@ -1772,6 +1815,65 @@ describe("SelectionController", () => { ); }); + test("`currentStyle` reports every differing Japanese span style as mixed", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([ + createTextSpan(new Text("平成"), { + "text-combine-upright": "digits 2", + "--text-emphasis": "filled-dot", + "--ruby": "へいせい", + "--ruby-size": "third", + "--ruby-align": "center", + "--ruby-overhang": "none", + "--ruby-side": "under", + "--warichu": "none", + "--font-features": "palt", + "--annotation-clearance": "auto", + }), + createTextSpan(new Text("年"), { + "text-combine-upright": "none", + "--text-emphasis": "none", + "--ruby": "", + "--ruby-size": "half", + "--ruby-align": "space-around", + "--ruby-overhang": "auto", + "--ruby-side": "over", + "--warichu": "warichu", + "--font-features": "none", + "--annotation-clearance": "none", + }), + ]), + ]); + const paragraph = textEditorMock.root.firstChild; + const firstTextNode = paragraph.firstChild.firstChild; + const lastTextNode = paragraph.lastChild.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 1); + document.dispatchEvent(new Event("selectionchange")); + + for (const property of [ + "text-combine-upright", + "--text-emphasis", + "--ruby", + "--ruby-size", + "--ruby-align", + "--ruby-overhang", + "--ruby-side", + "--warichu", + "--font-features", + "--annotation-clearance", + ]) { + expect(selectionController.currentStyle.getPropertyValue(property)).toBe( + "mixed", + ); + } + }); + test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ createParagraph([ @@ -1807,13 +1909,11 @@ describe("SelectionController", () => { ]); const root = textEditorMock.root; const selection = document.getSelection(); - const selectionController = new SelectionController(textEditorMock, selection); - focus( - selection, + const selectionController = new SelectionController( textEditorMock, - root.firstChild.firstChild.firstChild, - 0 + selection, ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0); selectionController.dispose(); expect(selectionController.selection).toBe(null); expect(selectionController.currentStyle).toBe(null); diff --git a/frontend/text-editor/src/test/TextEditorMock.js b/frontend/text-editor/src/test/TextEditorMock.js index 0e20d209e7..d2a42ff030 100644 --- a/frontend/text-editor/src/test/TextEditorMock.js +++ b/frontend/text-editor/src/test/TextEditorMock.js @@ -1,5 +1,8 @@ import { createRoot } from "../editor/content/dom/Root.js"; -import { createParagraph, createParagraphWith } from "../editor/content/dom/Paragraph.js"; +import { + createParagraph, + createParagraphWith, +} from "../editor/content/dom/Paragraph.js"; import { createEmptyTextSpan, createTextSpan, @@ -116,7 +119,9 @@ export class TextEditorMock extends EventTarget { * @returns {TextEditorMock} */ static createTextEditorMockWith(paragraphs) { - const root = createRoot(paragraphs.map((paragraph) => createParagraphWith(paragraph))); + const root = createRoot( + paragraphs.map((paragraph) => createParagraphWith(paragraph)), + ); return this.createTextEditorMockWithRoot(root); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index ea0d60d7ec..d9500c9e74 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8514,6 +8514,194 @@ msgstr "Title case" msgid "workspace.options.text-options.underline" msgstr "Underline (%s)" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-none" +msgstr "Normal" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.japanese-layout" +msgstr "Japanese text layout" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-all" +msgstr "Tate-chu-yoko" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits" +msgstr "Digits" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-count" +msgstr "Digit count" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-2" +msgstr "Up to 2 digits" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-3" +msgstr "Up to 3 digits" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-4" +msgstr "Up to 4 digits" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.warichu-none" +msgstr "Normal" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.warichu" +msgstr "Warichu" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features" +msgstr "Prop. metrics" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features-none" +msgstr "None" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features-palt" +msgstr "Horizontal (palt)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features-vpal" +msgstr "Vertical (vpal)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.annotation-clearance" +msgstr "Ann. clearance" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.annotation-clearance-none" +msgstr "Use line height" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.annotation-clearance-auto" +msgstr "Automatic" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis" +msgstr "Emphasis marks" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-none" +msgstr "None" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-filled-dot" +msgstr "Filled dot" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-open-dot" +msgstr "Open dot" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-filled-circle" +msgstr "Filled circle" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-open-circle" +msgstr "Open circle" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-filled-sesame" +msgstr "Filled sesame" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-open-sesame" +msgstr "Open sesame" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby" +msgstr "Ruby" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-placeholder" +msgstr "Furigana" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-advanced-options" +msgstr "Advanced furigana options" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size" +msgstr "Ruby size" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size-half" +msgstr "Half" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size-third" +msgstr "Third" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size-quarter" +msgstr "Quarter" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align" +msgstr "Ruby alignment" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-space-around" +msgstr "Space around" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-center" +msgstr "Center" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-start" +msgstr "Start" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-space-between" +msgstr "Space between" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-overhang" +msgstr "Ruby overhang" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-overhang-auto" +msgstr "Auto" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-overhang-none" +msgstr "None" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-side" +msgstr "Ruby side" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-side-over" +msgstr "Over / right" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-side-under" +msgstr "Under / left" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-orientation-mixed" +msgstr "Mixed" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-orientation-upright" +msgstr "Upright" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.writing-mode-horizontal" +msgstr "Horizontal" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.writing-mode-vertical" +msgstr "Vertical (right to left)" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs #, unused msgid "workspace.options.text-options.uppercase" @@ -9766,6 +9954,11 @@ msgstr "Create board. Click and drag to define its size. (%s)" msgid "workspace.toolbar.image" msgstr "Image (%s)" +#: src/app/main/ui/workspace/top_toolbar.cljs:339 +#, fuzzy +msgid "workspace.toolbar.label" +msgstr "" + msgid "workspace.toolbar.line" msgstr "Line (%s)" diff --git a/frontend/translations/jpn_JP.po b/frontend/translations/jpn_JP.po index 7e1853e51e..9a312ab4bf 100644 --- a/frontend/translations/jpn_JP.po +++ b/frontend/translations/jpn_JP.po @@ -983,3 +983,191 @@ msgstr "招待を再送" #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:320, src/app/main/ui/static.cljs:329, src/app/main/ui/static.cljs:450 msgid "labels.retry" msgstr "リトライ" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-none" +msgstr "通常" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.japanese-layout" +msgstr "日本語組版" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-all" +msgstr "縦中横" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits" +msgstr "数字のみ" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-count" +msgstr "桁数" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-2" +msgstr "2桁まで" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-3" +msgstr "3桁まで" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-combine-upright-digits-4" +msgstr "4桁まで" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.warichu-none" +msgstr "通常" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.warichu" +msgstr "割注" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features" +msgstr "プロポーショナルメトリクス" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features-none" +msgstr "なし" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features-palt" +msgstr "横組み(palt)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.font-features-vpal" +msgstr "縦組み(vpal)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.annotation-clearance" +msgstr "注釈の間隔" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.annotation-clearance-none" +msgstr "行送りを使用" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.annotation-clearance-auto" +msgstr "自動" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis" +msgstr "圏点" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-none" +msgstr "なし" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-filled-dot" +msgstr "黒丸" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-open-dot" +msgstr "白丸" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-filled-circle" +msgstr "黒丸(大)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-open-circle" +msgstr "白丸(大)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-filled-sesame" +msgstr "ゴマ点(黒)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-emphasis-open-sesame" +msgstr "ゴマ点(白)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby" +msgstr "ルビ" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-placeholder" +msgstr "ふりがな" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-advanced-options" +msgstr "ふりがなの詳細設定" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size" +msgstr "ルビの大きさ" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size-half" +msgstr "二分" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size-third" +msgstr "三分" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-size-quarter" +msgstr "四分" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align" +msgstr "ルビの配置" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-space-around" +msgstr "均等割り" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-center" +msgstr "中央" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-start" +msgstr "先頭" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-align-space-between" +msgstr "両端揃え" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-overhang" +msgstr "ルビのはみ出し" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-overhang-auto" +msgstr "自動" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-overhang-none" +msgstr "なし" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-side" +msgstr "ルビの側" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-side-over" +msgstr "上/右" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.ruby-side-under" +msgstr "下/左" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-orientation-mixed" +msgstr "混在" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-orientation-upright" +msgstr "正立" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.writing-mode-horizontal" +msgstr "横書き" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.writing-mode-vertical" +msgstr "縦書き(右から左)" diff --git a/library/test/builder.test.js b/library/test/builder.test.js index e4c7bf8a37..5dce3340d7 100644 --- a/library/test/builder.test.js +++ b/library/test/builder.test.js @@ -110,6 +110,44 @@ test("create context with file and page", () => { assert.equal(rootShape.id, "00000000-0000-0000-0000-000000000000"); }); +test("create vertical text", () => { + const context = penpot.createBuildContext(); + + const fileId = context.addFile({name: "file 1"}); + const pageId = context.addPage({name: "page 1"}); + const textId = context.addText({ + name: "Vertical text", + x: 10, + y: 20, + width: 100, + height: 200, + content: { + type: "root", + children: [ + { + type: "paragraph-set", + children: [ + { + type: "paragraph", + writingMode: "vertical-rl", + textOrientation: "upright", + children: [{text: "縦書き"}], + }, + ], + }, + ], + }, + }); + + const internalState = context.getInternalState(); + const paragraph = + internalState.files[fileId].data.pagesIndex[pageId].objects[textId].content + .children[0].children[0]; + + assert.equal(paragraph.writingMode, "vertical-rl"); + assert.equal(paragraph.textOrientation, "upright"); +}); + test("create context with color", () => { const context = penpot.createBuildContext(); diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 323e8319ab..5e6498f3ae 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -3,6 +3,14 @@ ### 🚀 Features - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. +- **plugin-types**: Added `writingMode` and `textOrientation` properties for text shapes (vertical writing) +- **plugin-types:** Added `textCombineUpright` property for text shapes (Tate-chu-yoko) +- **plugin-types:** Added `ruby` property for text shapes (furigana annotation) +- **plugin-types:** Added `textEmphasis` property for text shapes (emphasis marks / bouten) +- **plugin-types:** Added `warichu` property for text shapes (inline two-line notes) +- **plugin-types:** `textCombineUpright` now accepts `digits` (combine 2-4 digit runs) +- **plugin-types:** Added `fontFeatures` (`none`, `palt`, or `vpal`) to text shapes and text ranges. +- **plugin-types:** Added `annotationClearance` (`none` or `auto`) to text shapes. ### 🩹 Fixes diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index cb53532dd6..a22492697a 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4297,6 +4297,88 @@ export interface Text extends ShapeBase { */ verticalAlign: 'top' | 'center' | 'bottom' | null; + /** + * The writing mode of the text shape. `horizontal-tb` lays text out in + * horizontal lines; `vertical-rl` in vertical columns advancing right-to-left. + * Returns 'mixed' if paragraphs use different modes. + */ + writingMode: 'horizontal-tb' | 'vertical-rl' | 'mixed' | null; + + /** + * The orientation of characters in vertical writing. `mixed` rotates + * non-CJK runs sideways; `upright` keeps every character upright. + * Returns 'mixed' if paragraphs use different orientations. + */ + textOrientation: 'mixed' | 'upright' | null; + + /** + * Combines the text shape upright in vertical writing. `all` draws the text + * as one upright composite; `digits` combines runs of 2-4 consecutive + * digits (`digits2`/`digits3` cap the run length at 2/3); `none` uses the + * normal vertical layout. + * Returns 'mixed' if text spans use different values. + */ + textCombineUpright: + 'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed' | null; + + /** + * Emphasis marks (圏点 / bouten) drawn beside each base character, mirroring + * CSS `text-emphasis-style`. `none` removes them. + * Returns 'mixed' if text spans use different values. + */ + textEmphasis: + | 'none' + | 'filled-dot' + | 'open-dot' + | 'filled-circle' + | 'open-circle' + | 'filled-sesame' + | 'open-sesame' + | 'mixed' + | null; + + /** + * Warichu (割注): renders the span as two half-size lines stacked inline + * within one column position of the vertical flow. `none` disables it. + * Returns 'mixed' if text spans use different values. + */ + warichu: 'none' | 'warichu' | 'mixed' | null; + + /** + * OpenType proportional alternate metrics for Japanese text. `palt` applies + * proportional alternate widths in horizontal writing; `vpal` applies + * proportional alternate widths in vertical writing. `none` disables them. + * Returns 'mixed' if text spans use different values. + */ + fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null; + + /** + * Controls annotation collision handling. `none` preserves the explicit + * line gap; `auto` reserves separate half-em layers for ruby and emphasis. + * Returns 'mixed' if text spans use different values. + */ + annotationClearance: 'none' | 'auto' | 'mixed' | null; + + /** + * Ruby (furigana) annotation shown over the base text in vertical writing. + * Set a string to annotate the selected span(s), or `null` to remove it. + * Returns 'mixed' if text spans carry different ruby values. + */ + ruby: string | null; + + /** Ruby annotation size relative to its base text. */ + rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null; + + /** Distribution of ruby glyphs across the corresponding base text. */ + rubyAlign: + 'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null; + + /** Whether ruby may extend beyond the corresponding base text. */ + rubyOverhang: 'auto' | 'none' | 'mixed' | null; + + /** Annotation side: above/right (`over`) or below/left (`under`). */ + rubySide: 'over' | 'under' | 'mixed' | null; + /** * Return the bounding box for the text as a (x, y, width, height) rectangle * This is the box that covers the text even if it overflows its selection rectangle. @@ -4397,6 +4479,62 @@ export interface TextRange { */ textDecoration: 'underline' | 'line-through' | 'none' | 'mixed' | null; + /** + * Combines the range upright in vertical writing. Returns 'mixed' when the + * range contains different span values. + */ + textCombineUpright: + 'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed' | null; + + /** + * Emphasis marks (圏点 / bouten) applied to the range. Returns 'mixed' when + * the range contains different span values. + */ + textEmphasis: + | 'none' + | 'filled-dot' + | 'open-dot' + | 'filled-circle' + | 'open-circle' + | 'filled-sesame' + | 'open-sesame' + | 'mixed' + | null; + + /** Warichu applied to the range, or 'mixed' for different span values. */ + warichu: 'none' | 'warichu' | 'mixed' | null; + + /** + * OpenType proportional alternate metrics for Japanese text. It can be a + * specific feature or 'mixed' if multiple text spans are used. + */ + fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null; + + /** + * Annotation collision handling for the range, or 'mixed' for different + * span values. + */ + annotationClearance: 'none' | 'auto' | 'mixed' | null; + + /** + * Ruby (furigana) annotation for the text range. Set `null` to remove it. + * Returns 'mixed' if the range contains different ruby values. + */ + ruby: string | 'mixed' | null; + + /** Ruby annotation size relative to its base text. */ + rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null; + + /** Distribution of ruby glyphs across the corresponding base text. */ + rubyAlign: + 'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null; + + /** Whether ruby may extend beyond the corresponding base text. */ + rubyOverhang: 'auto' | 'none' | 'mixed' | null; + + /** Annotation side: above/right (`over`) or below/left (`under`). */ + rubySide: 'over' | 'under' | 'mixed' | null; + /** * The text direction for the text range. It can be a specific direction or 'mixed' if multiple directions are used. */ diff --git a/render-wasm/fixtures/japanese-typography.json b/render-wasm/fixtures/japanese-typography.json new file mode 100644 index 0000000000..6dc8798729 --- /dev/null +++ b/render-wasm/fixtures/japanese-typography.json @@ -0,0 +1,235 @@ +{ + "description": "Japanese typography fixture corpus (Phase 0). Each fixture is a paragraph-level test text plus the behaviors it exercises. Used to compare rendering across editor, WASM viewport, PNG, SVG and PDF exports, and later as regression fixtures for kinsoku/vertical/ruby/TCY work.", + "fixtures": [ + { + "id": "horizontal-basic", + "title": "Basic horizontal Japanese", + "text": "吾輩は猫である。名前はまだ無い。", + "exercises": ["kanji+kana rendering", "CJK punctuation 。", "font fallback"] + }, + { + "id": "horizontal-mixed", + "title": "Mixed kanji/kana/Latin/numbers/symbols", + "text": "Penpot UIは2024年にWASM rendererを導入した。価格は¥1,500(税込)です!", + "exercises": [ + "quarter-em CJK↔Latin boundaries", + "one-third-em Western word spaces", + "full-width !()", + "half-width digits", + "currency sign" + ] + }, + { + "id": "kinsoku-line-start", + "title": "Forbidden line-start characters", + "text": "これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。", + "exercises": ["kinsoku: 、。」not at line start", "small kana not at line start"] + }, + { + "id": "kinsoku-line-end", + "title": "Forbidden line-end characters", + "text": "開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。", + "exercises": ["kinsoku: 「『([【 not at line end"] + }, + { + "id": "prolonged-sound", + "title": "Prolonged sound mark and iteration marks", + "text": "コーヒーとケーキ、サーバーとルーター。人々の時々の心。", + "exercises": ["ー not at line start", "iteration mark 々", "katakana runs"] + }, + { + "id": "vertical-basic", + "title": "Basic vertical Japanese", + "text": "春はあけぼの。やうやう白くなりゆく山際、少し明かりて。", + "writing_mode": "vertical-rl", + "exercises": ["column flow top→bottom right→left", "vertical forms of 、。"] + }, + { + "id": "vertical-punctuation", + "title": "Vertical punctuation and brackets", + "text": "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)[注釈]【重要】!?", + "writing_mode": "vertical-rl", + "exercises": ["vert/vrt2 forms of 「」『』()[]【】", "full-width !? upright"] + }, + { + "id": "vertical-prolonged", + "title": "Prolonged sound mark in vertical", + "text": "コンピューターゲームのニュース。データベースサーバー。", + "writing_mode": "vertical-rl", + "exercises": ["ー must rotate to vertical form", "katakana column flow"] + }, + { + "id": "vertical-mixed-latin", + "title": "Latin and digits inside vertical text", + "text": "私はPenpot UIを使って2024年からWASM rendererを設計しています。", + "writing_mode": "vertical-rl", + "exercises": [ + "rotated Latin runs", + "quarter-em mixed orientation boundaries", + "one-third-em Western word spaces", + "digit runs (TCY candidates)" + ] + }, + { + "id": "tcy-candidates", + "title": "Tate-chu-yoko candidate runs", + "text": "第2章では、平成31年4月1日と12月25日。甲」20「乙、20。甲「20」乙。", + "writing_mode": "vertical-rl", + "exercises": [ + "1–2 digit runs for TCY", + "cl-30 half-em after closing punctuation and before opening brackets", + "cl-30 solid after opening and before closing punctuation", + "explicit all and digit TCY modes" + ] + }, + { + "id": "ruby-simple", + "title": "Simple ruby (annotation) examples", + "text": "漢字にふりがなを振る。振り仮名は難しい。", + "ruby": [ + { "base": "漢字", "ruby": "かんじ" }, + { "base": "振り仮名", "ruby": "ふりがな" } + ], + "exercises": ["whole-span base/annotation alignment", "line-height inflation"] + }, + { + "id": "horizontal-annotation-clearance-none", + "title": "Horizontal ruby and emphasis with line-height-only clearance", + "text": "漢字\n熟語", + "line_height": 1, + "ruby": [ + { "base": "漢字", "ruby": "かんじ" }, + { "base": "熟語", "ruby": "じゅくご" } + ], + "text_emphasis": "filled-dot", + "annotation_clearance": "none", + "exercises": ["legacy horizontal annotation collision", "explicit line-height compatibility"] + }, + { + "id": "horizontal-annotation-clearance-auto", + "title": "Horizontal ruby and emphasis with automatic clearance", + "text": "漢字\n熟語", + "line_height": 1, + "ruby": [ + { "base": "漢字", "ruby": "かんじ" }, + { "base": "熟語", "ruby": "じゅくご" } + ], + "text_emphasis": "filled-dot", + "annotation_clearance": "auto", + "exercises": ["automatic horizontal line-gap reservation", "ruby nearest and emphasis outside"] + }, + { + "id": "vertical-annotation-clearance-none", + "title": "Vertical ruby and emphasis with line-height-only clearance", + "text": "漢字\n熟語", + "writing_mode": "vertical-rl", + "line_height": 1, + "ruby": [ + { "base": "漢字", "ruby": "かんじ" }, + { "base": "熟語", "ruby": "じゅくご" } + ], + "text_emphasis": "filled-dot", + "annotation_clearance": "none", + "exercises": ["legacy vertical annotation collision", "explicit column-gap compatibility"] + }, + { + "id": "vertical-annotation-clearance-auto", + "title": "Vertical ruby and emphasis with automatic clearance", + "text": "漢字\n熟語", + "writing_mode": "vertical-rl", + "line_height": 1, + "ruby": [ + { "base": "漢字", "ruby": "かんじ" }, + { "base": "熟語", "ruby": "じゅくご" } + ], + "text_emphasis": "filled-dot", + "annotation_clearance": "auto", + "exercises": ["automatic vertical column-gap reservation", "ruby nearest and emphasis outside"] + }, + { + "id": "horizontal-ruby-customization", + "title": "Horizontal ruby size, alignment, overhang, and side", + "text": "標準 三分 中央 下側", + "ruby": [ + { "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" }, + { "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" }, + { "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" }, + { "base": "下側", "ruby": "したがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" } + ], + "exercises": [ + "half, third, and quarter ruby sizes", + "space-around, start, center, and space-between alignment", + "automatic and prohibited overhang", + "over and under annotation sides" + ] + }, + { + "id": "vertical-ruby-customization", + "title": "Vertical ruby size, alignment, overhang, and side", + "text": "標準 三分 中央 左側", + "writing_mode": "vertical-rl", + "ruby": [ + { "base": "標準", "ruby": "ひょうじゅん", "size": "half", "align": "space-around", "overhang": "auto", "side": "over" }, + { "base": "三分", "ruby": "さんぶん", "size": "third", "align": "start", "overhang": "none", "side": "over" }, + { "base": "中央", "ruby": "ちゅうおう", "size": "quarter", "align": "center", "overhang": "auto", "side": "over" }, + { "base": "左側", "ruby": "ひだりがわ", "size": "half", "align": "space-between", "overhang": "none", "side": "under" } + ], + "exercises": [ + "configured ruby gutter widths", + "vertical flow-axis annotation distribution", + "over/right and under/left annotation sides", + "viewport and export parity" + ] + }, + { + "id": "small-kana-sokuon", + "title": "Small kana and sokuon clusters", + "text": "ちょっと待ってください。キャッシュとクッキーをチェックする。", + "exercises": ["small kana ょっゃュッ", "kinsoku: small kana not at line start"] + }, + { + "id": "cjk-punct-spacing", + "title": "CJK punctuation spacing and full-width space", + "text": "これは、テスト。『「括弧」』。」「次」・「項」!? 全角スペースの後。", + "writing_mode": "vertical-rl", + "exercises": [ + "preferred half-em punctuation aki", + "solid consecutive opening and closing punctuation", + "half-em closing-to-opening spacing", + "quarter-em middle-dot adjacency", + "question/exclamation and full-width space" + ] + }, + { + "id": "jlreq-character-classes", + "title": "Representative JLREQ character classes", + "text": "「始」〜!?・。、…々ーっ¥100% あア漢A㎏", + "exercises": [ + "cl-01 through cl-19 scalar classification", + "cl-24 grouped numerals", + "cl-25 unit symbols", + "cl-26 Western word space", + "cl-27 Western characters" + ] + }, + { + "id": "han-unification", + "title": "Kanji-only text (Han unification trap)", + "text": "東京都渋谷区神南一丁目", + "exercises": ["kanji-only classifies as :chinese today → falls back to Noto Sans SC not JP", "JP-vs-CN disambiguation policy"] + }, + { + "id": "long-paragraph-wrap", + "title": "Long paragraph exercising many line breaks", + "text": "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。", + "writing_mode": "vertical-rl", + "text_align": "justify", + "exercises": [ + "many break opportunities", + "ordered oikomi before oidashi", + "priority-based justified expansion", + "kinsoku and burasage integration" + ] + } + ] +} diff --git a/render-wasm/src/fonts/notosansjp-vmtx-test.ttf b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf new file mode 100644 index 0000000000..5b36929f63 Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf differ diff --git a/render-wasm/src/fonts/notosansjp-vpal-test.ttf b/render-wasm/src/fonts/notosansjp-vpal-test.ttf new file mode 100644 index 0000000000..e1fd58e08f Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vpal-test.ttf differ diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 64e7d8c94f..1984b00d32 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -114,7 +114,7 @@ fn render_init(width: i32, height: i32) { } /// Initializes DesignState. -fn design_init() { +pub(crate) fn design_init() { unsafe { let design_state = State::new(); DESIGN_STATE = Box::into_raw(Box::new(design_state)); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 191f4cf234..7ce883d9bb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1453,7 +1453,102 @@ impl RenderState { ) }) .unzip(); - if skip_effects { + // CHANGEME: Extract all this code to its own function + if text_content.is_vertical() { + // Vertical writing paints through the custom vertical pass: + // drop shadows and strokes derive from the same laid-out cells + // as the fills (decorations are painted with the fill pass). + use crate::shapes::text_vertical; + let blur_filter = (!skip_effects).then(|| shape.image_filter(1.)).flatten(); + + // The shadow/stroke passes share one layout, computed only + // when at least one of them has something to paint (the + // fill pass lays out on its own inside text::render). + let v_bounds = text_content.bounds(); + let mut drop_shadows = if skip_effects { + Vec::new() + } else { + shape.drop_shadow_paints() + }; + if !skip_effects { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } + let strokes: Vec = shape.visible_strokes().rev().cloned().collect(); + let v_layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| { + let v_max_height = + text_vertical::wrap_height(text_content, v_bounds.height()); + text_vertical::layout_from_content(text_content, v_max_height) + }); + + // 1. Drop shadows (silhouettes behind the fills, on the same + // fills surface so they composite under the glyphs). + if let Some(v_layout) = v_layout.as_ref().filter(|_| !drop_shadows.is_empty()) { + let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id); + for shadow in &drop_shadows { + text_vertical::paint_drop_shadow( + canvas, + v_layout, + &v_bounds, + shape.vertical_align(), + shadow, + ); + } + } + + // 2. Fills + decorations. + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + None, + None, + )?; + + // 3. Strokes masked to the vertical cells. + if let Some(v_layout) = v_layout.as_ref().filter(|_| !strokes.is_empty()) { + let selrect = shape.selrect(); + let canvas = self.surfaces.canvas_and_mark_dirty(strokes_surface_id); + for stroke in &strokes { + text_vertical::paint_stroke( + canvas, + v_layout, + &v_bounds, + shape.vertical_align(), + stroke, + &selrect, + blur_filter.as_ref(), + ); + } + } + + // 4. Developer overlay: jlreq-style character-frame grid. + if self.options.is_text_grid_visible() { + let owned_layout; + let grid_layout = match v_layout.as_ref() { + Some(layout) => layout, + None => { + let v_max_height = + text_vertical::wrap_height(text_content, v_bounds.height()); + owned_layout = + text_vertical::layout_from_content(text_content, v_max_height); + &owned_layout + } + }; + let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id); + text_vertical::paint_grid( + canvas, + grid_layout, + &v_bounds, + shape.vertical_align(), + ); + } + } else if skip_effects { // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 7a6a3a3d39..05543d5177 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -3,6 +3,7 @@ const DEBUG_VISIBLE: u32 = 0x01; const PROFILE_REBUILD_TILES: u32 = 0x02; const TEXT_EDITOR_V3: u32 = 0x04; const SHOW_WASM_INFO: u32 = 0x08; +const TEXT_GRID_VISIBLE: u32 = 0x10; // Render performance options // This is the extra area used for tile rendering (tiles beyond viewport). @@ -113,6 +114,12 @@ impl RenderOptions { self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO } + /// jlreq-style character-frame grid overlay for Japanese vertical + /// text: a developer aid, never part of exported output. + pub fn is_text_grid_visible(&self) -> bool { + self.flags & TEXT_GRID_VISIBLE == TEXT_GRID_VISIBLE + } + pub fn set_antialias_threshold(&mut self, value: f32) -> bool { if value.is_finite() && value > 0.0 { self.antialias_threshold = value; diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index d972e69813..8e73177467 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, - StrokeKind, TextContent, + add_horizontal_span, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, + ParagraphLayout, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -31,7 +31,8 @@ pub fn stroke_paragraph_builder_group_from_text( let mut stroke_paragraphs_map: std::collections::HashMap = std::collections::HashMap::new(); - for span in paragraph.children().iter() { + let (span_texts, _) = paragraph.layout_span_texts(); + for (span, text) in paragraph.children().iter().zip(span_texts.iter()) { let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints(stroke, bounds, remove_stroke_alpha); @@ -39,8 +40,6 @@ pub fn stroke_paragraph_builder_group_from_text( group_layer_opacity = stroke_layer_opacity; } - let text: String = span.apply_text_transform(); - for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| { let paragraph_style = paragraph.paragraph_to_style(); @@ -55,7 +54,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph.line_height(), ); builder.push_style(&stroke_style); - builder.add_text(&text); + add_horizontal_span(builder, span, text, &stroke_style, fonts); } } @@ -69,7 +68,7 @@ pub fn stroke_paragraph_builder_group_from_text( (paragraph_group, group_layer_opacity) } -fn get_text_stroke_paints( +pub(crate) fn get_text_stroke_paints( stroke: &Stroke, bounds: &Rect, remove_stroke_alpha: bool, @@ -403,16 +402,62 @@ fn paint_text_with_emoji_overlay( overlay_emoji: bool, ) { let text_content = shape.get_text_content(); + + // Vertical writing renders through the custom vertical pass. + // Stored text bounds describe the measured content and can be taller than + // a fixed shape. Rebind to the selrect so the shape height remains the + // column-wrap budget used by the vertical painter. + let vertical_text_content = text_content + .is_vertical() + .then(|| text_content.new_bounds(shape.selrect())); + if vertical_text_content.as_ref().is_some_and(|content| { + crate::shapes::text_vertical::paint_text_vertical(canvas, content, shape.vertical_align()) + }) { + return; + } + let mut layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); + // Ruby draws only when the fill pass has the standard one-layout-per- + // paragraph shape (stroke/shadow silhouette passes never reach here). + let ruby_per_paragraph = layout_info.paragraphs.len() == text_content.paragraphs().len(); + for para in &mut layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); + if let Some(source_paragraph) = text_content.paragraphs().get(para.source_paragraph) { + crate::shapes::paint_horizontal_warichu( + canvas, + source_paragraph, + ¶.paragraph, + para.x, + para.y, + ); + crate::shapes::paint_horizontal_emphasis( + canvas, + source_paragraph, + ¶.paragraph, + para.x, + para.y, + ); + } + if overlay_emoji { paint_emoji_overlay(canvas, para); } + if ruby_per_paragraph { + crate::shapes::text_vertical::paint_horizontal_ruby( + canvas, + text_content, + para.source_paragraph, + ¶.paragraph, + para.x, + para.y, + ); + } + for deco in ¶.decorations { draw_text_decorations( canvas, diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 361b6db165..194e465fad 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,4 +1,5 @@ use crate::render::options::RenderOptions; +use crate::shapes::text_vertical; use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; @@ -48,17 +49,33 @@ fn render_cursor( return; }; + // In vertical writing the caret is a thin horizontal bar across the + // column; in horizontal writing a thin vertical bar. + let thin = editor_state.theme.cursor_width / zoom * dpr; let mut cursor_rect = Rect::new_empty(); - cursor_rect.set_xywh( - rect.x(), - rect.y(), - if editor_state.is_overtype_mode { - rect.width() - } else { - editor_state.theme.cursor_width / zoom * dpr - }, - rect.height(), - ); + if text_content.is_vertical() { + cursor_rect.set_xywh( + rect.x(), + rect.y(), + rect.width(), + if editor_state.is_overtype_mode && rect.height() > 0.0 { + rect.height() + } else { + thin + }, + ); + } else { + cursor_rect.set_xywh( + rect.x(), + rect.y(), + if editor_state.is_overtype_mode { + rect.width() + } else { + thin + }, + rect.height(), + ); + } let mut paint = Paint::default(); paint.set_anti_alias(false); @@ -127,6 +144,26 @@ fn calculate_cursor_rect( return None; } + // Vertical writing: selrect-local caret bar from the vertical cells. + // CHANGEME: extract to function + if text_content.is_vertical() { + let selrect = shape.selrect(); + let max_height = text_vertical::wrap_height(text_content, selrect.height()); + let layout = text_vertical::layout_from_content(text_content, max_height); + let origin_x = + text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align()); + let rect = text_vertical::caret_rect(&layout, cursor.paragraph, cursor.offset)?; + // The rect height is the character extent (zero after the last + // character); the renderer picks it for overtype carets and draws a + // thin bar otherwise. + return Some(Rect::from_xywh( + origin_x + rect.x(), + rect.y(), + rect.width(), + rect.height(), + )); + } + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); if cursor.paragraph >= layout_paragraphs.len() { @@ -142,12 +179,24 @@ fn calculate_cursor_rect( // - At start of paragraph: use position 0 // - At end of paragraph: use last position let para = ¶graphs[cursor.paragraph]; + if let Some(rect) = + crate::shapes::horizontal_warichu_caret_rect(para, laid_out_para, char_pos) + { + return Some(Rect::from_xywh( + rect.x(), + y_offset + rect.y(), + rect.width(), + rect.height(), + )); + } let para_char_count: usize = para .children() .iter() .map(|span| span.text.chars().count()) .sum(); + // Cursor offsets live in original text space; the laid-out + // paragraph indexes the kinsoku-shifted builder text. let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height (0.0, 0.0, 1.0, laid_out_para.height()) @@ -164,8 +213,12 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { + let last_pos = crate::shapes::horizontal_source_to_builder( + para, + para_char_count.saturating_sub(1), + ); let rects = laid_out_para.get_rects_for_range( - para_char_count.saturating_sub(1)..para_char_count, + last_pos..last_pos + 1, RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -181,8 +234,9 @@ fn calculate_cursor_rect( ) } } else { + let shifted_pos = crate::shapes::horizontal_source_to_builder(para, char_pos); let rects = laid_out_para.get_rects_for_range( - char_pos..char_pos + 1, + shifted_pos..shifted_pos + 1, RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -220,6 +274,48 @@ fn calculate_selection_rects( let end = selection.end(); let paragraphs = text_content.paragraphs(); + + // Vertical writing: selrect-local selection strips from the cells. + // CHANGEME: extract to function + if text_content.is_vertical() { + let selrect = shape.selrect(); + let max_height = text_vertical::wrap_height(text_content, selrect.height()); + let layout = text_vertical::layout_from_content(text_content, max_height); + let origin_x = + text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align()); + for (para_idx, paragraph) in paragraphs + .iter() + .enumerate() + .take(end.paragraph + 1) + .skip(start.paragraph) + { + let para_char_count: usize = paragraph + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let range_start = if para_idx == start.paragraph { + start.offset + } else { + 0 + }; + let range_end = if para_idx == end.paragraph { + end.offset + } else { + para_char_count + }; + for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) { + rects.push(Rect::from_xywh( + origin_x + rect.x(), + rect.y(), + rect.width(), + rect.height(), + )); + } + } + return rects; + } + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); @@ -254,15 +350,15 @@ fn calculate_selection_rects( }; if range_start < range_end { - use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - let text_boxes = laid_out_para.get_rects_for_range( - range_start..range_end, - RectHeightStyle::Max, - RectWidthStyle::Tight, + // Selection offsets live in original text space; the + // laid-out paragraph indexes the kinsoku-shifted text. + let warichu_rects = crate::shapes::horizontal_warichu_range_rects( + para, + laid_out_para, + range_start, + range_end, ); - - for text_box in text_boxes { - let r = text_box.rect; + for r in warichu_rects { rects.push(Rect::from_xywh( r.left(), y_offset + r.top(), @@ -270,6 +366,24 @@ fn calculate_selection_rects( r.height(), )); } + use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; + for builder_range in + crate::shapes::horizontal_normal_selection_ranges(para, range_start, range_end) + { + for text_box in laid_out_para.get_rects_for_range( + builder_range, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ) { + let r = text_box.rect; + rects.push(Rect::from_xywh( + r.left(), + y_offset + r.top(), + r.width(), + r.height(), + )); + } + } } y_offset += para_height; diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 04d91552d9..8913e4765e 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -154,6 +154,62 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let blur_filter = shape.image_filter(1.); + // Vertical writing paints through the custom vertical pass: drop + // shadows, fills+decorations and strokes all derive from the same + // laid-out cells, drawn back-to-front onto the single vector canvas. + // CHANGEME: extract to function + if text_content.is_vertical() { + use crate::shapes::text_vertical; + let v_bounds = text_content.bounds(); + // The shadow/stroke passes share one layout, computed only when + // at least one of them has something to paint (the fill pass + // lays out on its own inside render_overlay_emoji). + let drop_shadows = shape.drop_shadow_paints(); + let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect(); + let v_layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| { + let v_max_height = text_vertical::wrap_height(&text_content, v_bounds.height()); + text_vertical::layout_from_content(&text_content, v_max_height) + }); + + if let Some(v_layout) = &v_layout { + for shadow in &drop_shadows { + text_vertical::paint_drop_shadow( + self.canvas, + v_layout, + &v_bounds, + shape.vertical_align(), + shadow, + ); + } + } + + text::render_overlay_emoji( + self.canvas, + shape, + &mut paragraph_builders, + None, + blur_filter.as_ref(), + None, + None, + )?; + + if let Some(v_layout) = &v_layout { + let selrect = shape.selrect(); + for stroke in strokes { + text_vertical::paint_stroke( + self.canvas, + v_layout, + &v_bounds, + shape.vertical_align(), + stroke, + &selrect, + blur_filter.as_ref(), + ); + } + } + return Ok(()); + } + // Text drop shadows: one filter layer per shadow over fill + stroke // silhouettes (mirrors GPU `render_text_shadows`). let drop_shadows = shape.drop_shadow_paints(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 5dfbe9e384..166204272a 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -15,7 +15,10 @@ mod corners; mod fills; mod fonts; mod frames; +mod gpos_vpal; mod groups; +pub mod japanese; +pub mod kinsoku; mod layouts; pub mod modifiers; mod paths; @@ -28,6 +31,7 @@ mod svg_attrs; mod svgraw; mod text; pub mod text_paths; +pub mod text_vertical; mod transform; pub use blend::*; @@ -428,6 +432,7 @@ impl Shape { pub fn set_vertical_align(&mut self, align: VerticalAlign) { self.vertical_align = align; + self.invalidate_extrect(); } pub fn vertical_align(&self) -> VerticalAlign { diff --git a/render-wasm/src/shapes/gpos_vpal.rs b/render-wasm/src/shapes/gpos_vpal.rs new file mode 100644 index 0000000000..28670ad911 --- /dev/null +++ b/render-wasm/src/shapes/gpos_vpal.rs @@ -0,0 +1,287 @@ +//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate +//! metrics) feature. Vertical layout applies these deltas itself: cells +//! flow by `vmtx` advances, so HarfBuzz never gets a chance to apply +//! vertical GPOS positioning (SkShaper shapes on a horizontal line). +//! +//! Only single-adjustment lookups are read (SinglePos, directly or behind +//! an Extension lookup) — `vpal` is metrics-only by design and real fonts +//! (Noto CJK, Source Han) encode it exactly this way. + +use std::collections::HashMap; + +/// Font-unit deltas for one glyph. `y_placement` is y-up (positive lifts +/// the ink toward the flow start); `y_advance` is negative when the +/// vertical advance shrinks. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct VpalDelta { + pub y_placement: i16, + pub y_advance: i16, +} + +fn read_u16(data: &[u8], offset: usize) -> Option { + Some(u16::from_be_bytes([ + *data.get(offset)?, + *data.get(offset + 1)?, + ])) +} + +fn read_i16(data: &[u8], offset: usize) -> Option { + read_u16(data, offset).map(|v| v as i16) +} + +fn read_u32(data: &[u8], offset: usize) -> Option { + Some(u32::from_be_bytes([ + *data.get(offset)?, + *data.get(offset + 1)?, + *data.get(offset + 2)?, + *data.get(offset + 3)?, + ])) +} + +/// Glyph ids covered by a Coverage table, in coverage-index order. +fn parse_coverage(data: &[u8], offset: usize) -> Option> { + match read_u16(data, offset)? { + 1 => { + let count = read_u16(data, offset + 2)? as usize; + (0..count) + .map(|i| read_u16(data, offset + 4 + i * 2)) + .collect() + } + 2 => { + let range_count = read_u16(data, offset + 2)? as usize; + let mut glyphs = Vec::new(); + for i in 0..range_count { + let o = offset + 4 + i * 6; + let start = read_u16(data, o)?; + let end = read_u16(data, o + 2)?; + if end < start { + return None; + } + glyphs.extend(start..=end); + } + Some(glyphs) + } + _ => None, + } +} + +/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit +/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device +/// offsets). Returns the y deltas and consumes nothing else. +fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option { + let mut delta = VpalDelta::default(); + let mut o = offset; + for bit in 0..8 { + if value_format & (1 << bit) == 0 { + continue; + } + match bit { + 1 => delta.y_placement = read_i16(data, o)?, + 3 => delta.y_advance = read_i16(data, o)?, + _ => {} + } + o += 2; + } + Some(delta) +} + +fn value_record_size(value_format: u16) -> usize { + (value_format & 0x00FF).count_ones() as usize * 2 +} + +/// Accumulate a SinglePos subtable into `deltas`. +fn parse_single_pos( + data: &[u8], + offset: usize, + deltas: &mut HashMap, +) -> Option<()> { + let format = read_u16(data, offset)?; + let coverage = parse_coverage(data, offset + read_u16(data, offset + 2)? as usize)?; + let value_format = read_u16(data, offset + 4)?; + match format { + 1 => { + let value = parse_value_record(data, offset + 6, value_format)?; + for glyph in coverage { + let entry = deltas.entry(glyph).or_default(); + entry.y_placement += value.y_placement; + entry.y_advance += value.y_advance; + } + } + 2 => { + let count = read_u16(data, offset + 6)? as usize; + let size = value_record_size(value_format); + for (i, glyph) in coverage.into_iter().take(count).enumerate() { + let value = parse_value_record(data, offset + 8 + i * size, value_format)?; + let entry = deltas.entry(glyph).or_default(); + entry.y_placement += value.y_placement; + entry.y_advance += value.y_advance; + } + } + _ => {} + } + Some(()) +} + +/// Parse the `vpal` deltas out of a raw `GPOS` table. Returns `None` when +/// the table is malformed or carries no `vpal` feature. +pub fn parse_vpal(gpos: &[u8]) -> Option> { + let feature_list = read_u16(gpos, 6)? as usize; + let lookup_list = read_u16(gpos, 8)? as usize; + + // Collect the lookup indices of every feature tagged `vpal`, + // regardless of script: the deltas are per-glyph metrics. + let feature_count = read_u16(gpos, feature_list)? as usize; + let mut lookup_indices = Vec::new(); + for i in 0..feature_count { + let record = feature_list + 2 + i * 6; + if gpos.get(record..record.checked_add(4)?)? != b"vpal" { + continue; + } + let feature = feature_list + read_u16(gpos, record + 4)? as usize; + let index_count = read_u16(gpos, feature + 2)? as usize; + for j in 0..index_count { + lookup_indices.push(read_u16(gpos, feature + 4 + j * 2)? as usize); + } + } + lookup_indices.sort_unstable(); + lookup_indices.dedup(); + if lookup_indices.is_empty() { + return None; + } + + let lookup_count = read_u16(gpos, lookup_list)? as usize; + let mut deltas = HashMap::new(); + for index in lookup_indices { + if index >= lookup_count { + continue; + } + let lookup = lookup_list + read_u16(gpos, lookup_list + 2 + index * 2)? as usize; + let lookup_type = read_u16(gpos, lookup)?; + let subtable_count = read_u16(gpos, lookup + 4)? as usize; + for s in 0..subtable_count { + let subtable = lookup + read_u16(gpos, lookup + 6 + s * 2)? as usize; + match lookup_type { + 1 => { + parse_single_pos(gpos, subtable, &mut deltas)?; + } + 9 => { + // ExtensionPos: { format: u16, extensionLookupType: u16, + // extensionOffset: u32 (from the subtable start) }. + if read_u16(gpos, subtable + 2)? == 1 { + let inner = subtable + read_u32(gpos, subtable + 4)? as usize; + parse_single_pos(gpos, inner, &mut deltas)?; + } + } + _ => {} + } + } + } + if deltas.is_empty() { + None + } else { + Some(deltas) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Subset of Noto Sans JP: 〱あく「」、。 with `vert` alternates and the + // real `vpal` deltas (Extension→SinglePos, both coverage and both + // SinglePos formats, value formats 0x8 and 0xA). + const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf"); + + fn gpos_table() -> Vec { + // Locate the GPOS table in the raw sfnt directory. + let data = VPAL_TEST_FONT; + let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize; + for i in 0..num_tables { + let record = 12 + i * 16; + if &data[record..record + 4] == b"GPOS" { + let offset = u32::from_be_bytes([ + data[record + 8], + data[record + 9], + data[record + 10], + data[record + 11], + ]) as usize; + let length = u32::from_be_bytes([ + data[record + 12], + data[record + 13], + data[record + 14], + data[record + 15], + ]) as usize; + return data[offset..offset + length].to_vec(); + } + } + panic!("test font has no GPOS table"); + } + + #[test] + fn parses_noto_vpal_deltas() { + let deltas = parse_vpal(&gpos_table()).expect("vpal deltas"); + // Vertical alternates: 、→8 。→9 「→10 」→11 あ→12 く→13. + assert_eq!( + deltas.get(&8), + Some(&VpalDelta { + y_placement: 0, + y_advance: -500 + }), + "、 halves its vertical advance" + ); + assert_eq!( + deltas.get(&10), + Some(&VpalDelta { + y_placement: 481, + y_advance: -500 + }), + "「 lifts its trailing-half ink into the compressed cell" + ); + assert_eq!( + deltas.get(&12), + Some(&VpalDelta { + y_placement: 39, + y_advance: -58 + }), + "あ tightens slightly" + ); + // Base (horizontal) glyphs carry no vpal. + assert!(!deltas.contains_key(&6), "base あ glyph is uncovered"); + } + + #[test] + fn missing_vpal_returns_none() { + // A GPOS header with an empty feature list. + let gpos = [ + 0x00, 0x01, 0x00, 0x00, // version 1.0 + 0x00, 0x0A, // scriptList + 0x00, 0x0C, // featureList + 0x00, 0x0E, // lookupList + 0x00, 0x00, // scriptCount + 0x00, 0x00, // featureCount + 0x00, 0x00, // lookupCount + ]; + assert!(parse_vpal(&gpos).is_none()); + } + + #[test] + fn truncated_table_is_rejected() { + let table = gpos_table(); + for len in [0usize, 4, 9, 16] { + assert!(parse_vpal(&table[..len.min(table.len())]).is_none()); + } + } + + #[test] + fn truncated_feature_records_are_rejected() { + let gpos = [ + 0x00, 0x01, 0x00, 0x00, // version 1.0 + 0x00, 0x0A, // scriptList + 0x00, 0x0A, // featureList + 0x00, 0x0A, // lookupList + 0x00, 0x02, // featureCount without matching records + ]; + + assert!(parse_vpal(&gpos).is_none()); + } +} diff --git a/render-wasm/src/shapes/japanese.rs b/render-wasm/src/shapes/japanese.rs new file mode 100644 index 0000000000..7038400083 --- /dev/null +++ b/render-wasm/src/shapes/japanese.rs @@ -0,0 +1,540 @@ +//! Shared JLREQ character classes and pair-rule tables. +//! +//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are +//! contextual/virtual classes produced by higher-level inline composites; +//! [`classify`] handles scalar characters and callers assign those virtual +//! classes when constructing reference marks, ruby, grouped numerals, +//! warichu, or tate-chu-yoko. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum JapaneseClass { + OpeningBracket = 0, + ClosingBracket, + Hyphen, + DividingPunctuation, + MiddleDot, + FullStop, + Comma, + Inseparable, + IterationMark, + ProlongedSoundMark, + SmallKana, + PrefixedAbbreviation, + PostfixedAbbreviation, + IdeographicSpace, + Hiragana, + Katakana, + MathSymbol, + MathOperator, + Ideographic, + ReferenceMark, + OrnamentedComplex, + SimpleRuby, + JukugoRuby, + GroupedNumeral, + UnitSymbol, + WesternWordSpace, + Western, + WarichuOpening, + WarichuClosing, + TateChuYoko, +} + +impl JapaneseClass { + pub const COUNT: usize = 30; + + pub const ALL: [Self; Self::COUNT] = [ + Self::OpeningBracket, + Self::ClosingBracket, + Self::Hyphen, + Self::DividingPunctuation, + Self::MiddleDot, + Self::FullStop, + Self::Comma, + Self::Inseparable, + Self::IterationMark, + Self::ProlongedSoundMark, + Self::SmallKana, + Self::PrefixedAbbreviation, + Self::PostfixedAbbreviation, + Self::IdeographicSpace, + Self::Hiragana, + Self::Katakana, + Self::MathSymbol, + Self::MathOperator, + Self::Ideographic, + Self::ReferenceMark, + Self::OrnamentedComplex, + Self::SimpleRuby, + Self::JukugoRuby, + Self::GroupedNumeral, + Self::UnitSymbol, + Self::WesternWordSpace, + Self::Western, + Self::WarichuOpening, + Self::WarichuClosing, + Self::TateChuYoko, + ]; + + pub const fn index(self) -> usize { + self as usize + } + + pub const fn forbids_line_start(self) -> bool { + matches!( + self, + Self::ClosingBracket + | Self::Hyphen + | Self::DividingPunctuation + | Self::MiddleDot + | Self::FullStop + | Self::Comma + | Self::Inseparable + | Self::IterationMark + | Self::ProlongedSoundMark + | Self::SmallKana + | Self::PostfixedAbbreviation + | Self::WarichuClosing + ) + } + + pub const fn forbids_line_end(self) -> bool { + matches!( + self, + Self::OpeningBracket | Self::PrefixedAbbreviation | Self::WarichuOpening + ) + } + + pub const fn is_japanese_letter(self) -> bool { + matches!(self, Self::Hiragana | Self::Katakana | Self::Ideographic) + } + + pub const fn is_western_run(self) -> bool { + matches!( + self, + Self::GroupedNumeral | Self::UnitSymbol | Self::Western + ) + } + + pub const fn is_emphasis_prohibited(self) -> bool { + matches!( + self, + Self::OpeningBracket | Self::ClosingBracket | Self::FullStop | Self::Comma + ) + } + + /// Half-width punctuation whose normal character frame is completed by + /// half an em after the glyph. Consecutive punctuation may suppress that + /// appended spacing, but the glyph body itself remains half-width. + pub const fn is_trailing_aki_punctuation(self) -> bool { + matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PairRule { + /// Preferred extra spacing between the two character frames, in em. + pub preferred_em: f32, + /// Smallest spacing allowed during oikomi, in em. + pub minimum_em: f32, + /// Largest spacing allowed during oidashi/justification, in em. + pub maximum_em: f32, + pub break_allowed: bool, + /// Whether horizontal SkParagraph needs an inserted WORD JOINER for this + /// pair. Atomic Western/numeral runs are already protected by its Unicode + /// breaker, so they remain non-breakable without synthetic characters. + pub suppress_break_with_joiner: bool, + /// Lower values are adjusted first; zero means not adjustable. + pub shrink_priority: u8, + pub expand_priority: u8, +} + +impl PairRule { + const SOLID: Self = Self { + preferred_em: 0.0, + minimum_em: 0.0, + maximum_em: 0.0, + break_allowed: true, + suppress_break_with_joiner: false, + shrink_priority: 0, + expand_priority: 0, + }; +} + +const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] { + let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT]; + let mut before_index = 0; + while before_index < JapaneseClass::COUNT { + let before = JapaneseClass::ALL[before_index]; + let mut after_index = 0; + while after_index < JapaneseClass::COUNT { + let after = JapaneseClass::ALL[after_index]; + let mut rule = PairRule::SOLID; + + rule.suppress_break_with_joiner = + before.forbids_line_end() || after.forbids_line_start(); + rule.break_allowed = !rule.suppress_break_with_joiner; + if before_index == after_index + && matches!( + before, + JapaneseClass::Inseparable + | JapaneseClass::GroupedNumeral + | JapaneseClass::Western + ) + { + rule.break_allowed = false; + } + + if matches!(before, JapaneseClass::WesternWordSpace) { + rule.preferred_em = 1.0 / 3.0; + rule.minimum_em = 0.25; + rule.maximum_em = 0.5; + rule.shrink_priority = 1; + rule.expand_priority = 1; + } else if (before.is_japanese_letter() && after.is_western_run()) + || (before.is_western_run() && after.is_japanese_letter()) + { + rule.preferred_em = 0.25; + rule.minimum_em = 0.125; + rule.maximum_em = 0.5; + rule.shrink_priority = 5; + rule.expand_priority = 2; + } else if matches!( + (before, after), + ( + JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop, + JapaneseClass::TateChuYoko + ) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket) + ) { + rule.preferred_em = 0.5; + rule.minimum_em = 0.0; + rule.maximum_em = 0.5; + rule.shrink_priority = 3; + } else if matches!( + (before, after), + (JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko) + ) { + // Two adjacent cl-30 entries are necessarily separate TCY + // composites (characters inside one composite are atomic). + rule.maximum_em = 0.25; + rule.expand_priority = 3; + } else if matches!(before, JapaneseClass::DividingPunctuation) + && !matches!(after, JapaneseClass::ClosingBracket) + { + // A sentence-ending question/exclamation mark carries one em + // after it. Line planning may discard this at the line edge. + rule.preferred_em = 1.0; + rule.minimum_em = 0.0; + rule.maximum_em = 1.0; + rule.shrink_priority = 2; + } else if before.is_trailing_aki_punctuation() + && matches!(after, JapaneseClass::OpeningBracket) + { + // Only one half-em is retained between the two half-width + // glyph bodies, not the sum of both characters' normal aki. + rule.preferred_em = 0.5; + rule.minimum_em = 0.0; + rule.maximum_em = 0.5; + rule.shrink_priority = 4; + } else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() { + // Consecutive closing punctuation sets solid internally; the + // last character in the sequence supplies the trailing aki. + } else if matches!(before, JapaneseClass::OpeningBracket) + && matches!(after, JapaneseClass::OpeningBracket) + { + // Consecutive opening brackets set solid internally; the first + // character in the sequence supplies the leading aki. + } else if (before.is_trailing_aki_punctuation() + && matches!(after, JapaneseClass::MiddleDot)) + || (matches!(before, JapaneseClass::MiddleDot) + && matches!(after, JapaneseClass::OpeningBracket)) + { + rule.preferred_em = 0.25; + rule.minimum_em = 0.0; + rule.maximum_em = 0.25; + rule.shrink_priority = 3; + } else if before.is_trailing_aki_punctuation() { + rule.preferred_em = 0.5; + rule.minimum_em = if matches!(before, JapaneseClass::FullStop) { + 0.5 + } else { + 0.0 + }; + rule.maximum_em = 0.5; + rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) { + 0 + } else { + 4 + }; + } else if matches!(after, JapaneseClass::OpeningBracket) { + rule.preferred_em = 0.5; + rule.minimum_em = 0.0; + rule.maximum_em = 0.5; + rule.shrink_priority = 4; + } else if matches!(before, JapaneseClass::MiddleDot) + || matches!(after, JapaneseClass::MiddleDot) + { + rule.preferred_em = 0.25; + rule.minimum_em = 0.0; + rule.maximum_em = 0.25; + rule.shrink_priority = 3; + } else if before.is_japanese_letter() && after.is_japanese_letter() { + // Solid Japanese text is the general third-stage expansion + // opportunity. The planner may continue past this quarter-em + // cap only in JLREQ's final equal-expansion fallback. + rule.maximum_em = 0.25; + rule.expand_priority = 3; + } + + table[before_index][after_index] = rule; + after_index += 1; + } + before_index += 1; + } + table +} + +pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] = + generated_pair_rules(); + +pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule { + PAIR_RULES[before.index()][after.index()] +} + +const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“"; +const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”"; +const HYPHENS: &str = "‐゠–〜~"; +const DIVIDING_PUNCTUATION: &str = "!?‼⁇⁈⁉"; +const MIDDLE_DOTS: &str = "・・:;"; +const FULL_STOPS: &str = "。."; +const COMMAS: &str = "、,"; +const INSEPARABLE: &str = "―…‥〳〴〵"; +const ITERATION_MARKS: &str = "々〻ゝゞヽヾ"; +const SMALL_KANA: &str = concat!( + "ぁぃぅぇぉっゃゅょゎゕゖ", + "ァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ" +); +const PREFIXED_ABBREVIATIONS: &str = "¥¥$$££##"; +const POSTFIXED_ABBREVIATIONS: &str = "°′″℃¢¢%%‰‱"; +const MATH_SYMBOLS: &str = "==≠<<>>≦≧≤≥∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔∥"; +const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏"; + +pub fn classify(c: char) -> JapaneseClass { + if OPENING_BRACKETS.contains(c) { + JapaneseClass::OpeningBracket + } else if CLOSING_BRACKETS.contains(c) { + JapaneseClass::ClosingBracket + } else if HYPHENS.contains(c) { + JapaneseClass::Hyphen + } else if DIVIDING_PUNCTUATION.contains(c) { + JapaneseClass::DividingPunctuation + } else if MIDDLE_DOTS.contains(c) { + JapaneseClass::MiddleDot + } else if FULL_STOPS.contains(c) { + JapaneseClass::FullStop + } else if COMMAS.contains(c) { + JapaneseClass::Comma + } else if INSEPARABLE.contains(c) { + JapaneseClass::Inseparable + } else if ITERATION_MARKS.contains(c) { + JapaneseClass::IterationMark + } else if c == 'ー' { + JapaneseClass::ProlongedSoundMark + } else if SMALL_KANA.contains(c) { + JapaneseClass::SmallKana + } else if PREFIXED_ABBREVIATIONS.contains(c) { + JapaneseClass::PrefixedAbbreviation + } else if POSTFIXED_ABBREVIATIONS.contains(c) { + JapaneseClass::PostfixedAbbreviation + } else if c == '\u{3000}' { + JapaneseClass::IdeographicSpace + } else if MATH_SYMBOLS.contains(c) { + JapaneseClass::MathSymbol + } else if MATH_OPERATORS.contains(c) { + JapaneseClass::MathOperator + } else if c == ' ' || c == '\t' || c == '\u{00a0}' { + JapaneseClass::WesternWordSpace + } else if c.is_ascii_digit() { + JapaneseClass::GroupedNumeral + } else if is_hiragana(c) { + JapaneseClass::Hiragana + } else if is_katakana(c) { + JapaneseClass::Katakana + } else if is_ideographic(c) { + JapaneseClass::Ideographic + } else if is_unit_symbol(c) { + JapaneseClass::UnitSymbol + } else { + JapaneseClass::Western + } +} + +fn is_hiragana(c: char) -> bool { + matches!(u32::from(c), 0x3041..=0x309F) +} + +fn is_katakana(c: char) -> bool { + matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D) +} + +fn is_ideographic(c: char) -> bool { + matches!(u32::from(c), + 0x2E80..=0x2FDF + | 0x31C0..=0x31EF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xF900..=0xFAFF + | 0x20000..=0x2FA1F + ) || matches!(c, '〃' | '仝' | '〆' | '♂' | '♀') +} + +fn is_unit_symbol(c: char) -> bool { + matches!(u32::from(c), 0x2100..=0x214F | 0x3300..=0x33FF) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn class_model_contains_all_thirty_jlreq_classes_in_order() { + assert_eq!(JapaneseClass::ALL.len(), 30); + for (index, class) in JapaneseClass::ALL.iter().enumerate() { + assert_eq!(class.index(), index); + } + } + + #[test] + fn classifies_representative_jlreq_characters() { + let cases = [ + ('「', JapaneseClass::OpeningBracket), + ('」', JapaneseClass::ClosingBracket), + ('〜', JapaneseClass::Hyphen), + ('!', JapaneseClass::DividingPunctuation), + ('・', JapaneseClass::MiddleDot), + ('。', JapaneseClass::FullStop), + ('、', JapaneseClass::Comma), + ('…', JapaneseClass::Inseparable), + ('々', JapaneseClass::IterationMark), + ('ー', JapaneseClass::ProlongedSoundMark), + ('ょ', JapaneseClass::SmallKana), + ('¥', JapaneseClass::PrefixedAbbreviation), + ('%', JapaneseClass::PostfixedAbbreviation), + ('\u{3000}', JapaneseClass::IdeographicSpace), + ('あ', JapaneseClass::Hiragana), + ('ア', JapaneseClass::Katakana), + ('≠', JapaneseClass::MathSymbol), + ('+', JapaneseClass::MathOperator), + ('漢', JapaneseClass::Ideographic), + ('2', JapaneseClass::GroupedNumeral), + ('㎏', JapaneseClass::UnitSymbol), + (' ', JapaneseClass::WesternWordSpace), + ('A', JapaneseClass::Western), + ]; + for (character, expected) in cases { + assert_eq!(classify(character), expected, "wrong class for {character}"); + } + } + + #[test] + fn generated_rules_cover_every_class_pair() { + assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT); + assert!(PAIR_RULES + .iter() + .all(|row| row.len() == JapaneseClass::COUNT)); + } + + #[test] + fn generated_rules_encode_kinsoku_and_atomic_runs() { + assert!( + !pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed + ); + assert!( + !pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed + ); + assert!( + !pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed + ); + assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed); + } + + #[test] + fn generated_rules_encode_script_and_tcy_spacing() { + let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western); + assert_eq!(script.preferred_em, 0.25); + assert_eq!(script.minimum_em, 0.125); + assert_eq!(script.maximum_em, 0.5); + + let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko); + assert_eq!(tcy.preferred_em, 0.5); + assert_eq!( + pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em, + 0.0 + ); + let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko); + assert_eq!(adjacent_tcy.preferred_em, 0.0); + assert_eq!(adjacent_tcy.maximum_em, 0.25); + assert_eq!(adjacent_tcy.expand_priority, 3); + } + + #[test] + fn generated_rules_encode_punctuation_sequences() { + assert_eq!( + pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em, + 0.25 + ); + assert_eq!( + pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em, + 0.25 + ); + assert_eq!( + pair_rule( + JapaneseClass::DividingPunctuation, + JapaneseClass::Ideographic + ) + .preferred_em, + 1.0 + ); + let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana); + assert_eq!(solid_japanese.preferred_em, 0.0); + assert_eq!(solid_japanese.maximum_em, 0.25); + assert_eq!(solid_japanese.expand_priority, 3); + let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby); + assert_eq!(ruby_adjacency.preferred_em, 0.0); + assert_eq!(ruby_adjacency.maximum_em, 0.0); + assert_eq!(ruby_adjacency.expand_priority, 0); + } +} diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/kinsoku.rs new file mode 100644 index 0000000000..7130744dfd --- /dev/null +++ b/render-wasm/src/shapes/kinsoku.rs @@ -0,0 +1,514 @@ +//! Japanese line-breaking rules (kinsoku shori). +//! +//! Skia's default break iterator allows closing punctuation, the +//! prolonged sound mark or small kana at a line start, and opening +//! brackets at a line end. skia-safe exposes no ICU BreakIterator, so +//! the forbidden break opportunities are suppressed by inserting U+2060 WORD +//! JOINER into the text handed to skparagraph. The same lossless layout-text +//! transform inserts JLREQ quarter-em Japanese/Western boundary space and +//! normalizes Western word spaces to one third em. +//! +//! The inserted joiners shift every UTF-16 offset reported by the laid +//! out paragraph (position-data, caret mapping, selection rects). The +//! [`OffsetMap`] returned along with the modified texts translates +//! between original and joiner-shifted offsets. It is a pure function +//! of the span texts, so any consumer can recompute it and stay +//! consistent with the builders by construction. + +/// Zero-width character whose UAX #14 class forbids breaking on either +/// side of it. +pub const WORD_JOINER: char = '\u{2060}'; +/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap. +pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}'; +/// Unicode THREE-PER-EM SPACE, used for Western word spacing in Japanese text. +pub const WESTERN_WORD_SPACE: char = '\u{2004}'; + +use super::japanese::{classify, pair_rule}; + +pub fn forbidden_at_line_start(c: char) -> bool { + classify(c).forbids_line_start() +} + +pub fn forbidden_at_line_end(c: char) -> bool { + classify(c).forbids_line_end() +} + +/// Translates between original UTF-16 offsets (the source-of-truth span text) +/// and layout-text UTF-16 offsets (the text handed to skparagraph). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct OffsetMap { + /// UTF-16 indices of inserted joiners or boundary spaces, ascending and + /// expressed in shifted coordinates. Same-length substitutions need no + /// entry. + inserted: Vec, +} + +impl OffsetMap { + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.inserted.is_empty() + } + + /// Original offset for a shifted offset. An offset pointing at an inserted + /// character resolves to the source boundary where it was inserted. + pub fn to_original(&self, shifted: usize) -> usize { + shifted - self.inserted.iter().take_while(|&&p| p < shifted).count() + } + + /// Shifted offset for an original offset. A boundary that received a + /// layout character resolves after it, so carets avoid synthetic spacing. + pub fn to_shifted(&self, original: usize) -> usize { + let mut shifted = original; + for &p in &self.inserted { + if p <= shifted { + shifted += 1; + } else { + break; + } + } + shifted + } +} + +/// Applies the horizontal Japanese layout-text transform. It inserts WORD +/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space at +/// Japanese↔Western boundaries, and normalizes breakable ASCII word spaces to +/// one third em. Span boundaries are transparent. Returns `None` when the +/// paragraph needs no transformation. +/// Apply the normal Japanese layout transform while additionally protecting +/// annotated base units. `ruby_breaks[span] == Some(boundaries)` means that +/// every internal scalar boundary except those UTF-16 offsets is atomic. +pub fn apply_to_span_texts_with_ruby_breaks( + span_texts: &[String], + ruby_breaks: &[Option>], +) -> Option<(Vec, OffsetMap)> { + let mut inserted: Vec = Vec::new(); + let mut out: Vec = Vec::with_capacity(span_texts.len()); + let mut prev: Option = None; + let mut changed = false; + // Running position in shifted UTF-16 coordinates. + let mut shifted_pos: usize = 0; + + for (span_index, text) in span_texts.iter().enumerate() { + let mut shifted_text = String::with_capacity(text.len() + 4); + let mut local_utf16 = 0usize; + for c in text.chars() { + let ruby_forbids_break = local_utf16 > 0 + && ruby_breaks + .get(span_index) + .and_then(Option::as_ref) + .is_some_and(|breaks| !breaks.contains(&local_utf16)); + let forbid_break = ruby_forbids_break + || match prev { + Some(p) => pair_rule(classify(p), classify(c)).suppress_break_with_joiner, + None => false, + }; + if forbid_break { + shifted_text.push(WORD_JOINER); + inserted.push(shifted_pos); + shifted_pos += 1; + changed = true; + } else if prev.is_some_and(|p| { + let before = classify(p); + let after = classify(c); + (before.is_japanese_letter() && after.is_western_run()) + || (before.is_western_run() && after.is_japanese_letter()) + }) { + shifted_text.push(JAPANESE_WESTERN_SPACE); + inserted.push(shifted_pos); + shifted_pos += 1; + changed = true; + } + let layout_char = if c == ' ' { + changed = true; + WESTERN_WORD_SPACE + } else { + c + }; + shifted_text.push(layout_char); + shifted_pos += c.len_utf16(); + local_utf16 += c.len_utf16(); + prev = Some(c); + } + out.push(shifted_text); + } + + if !changed { + return None; + } + Some((out, OffsetMap { inserted })) +} + +#[cfg(test)] +mod tests { + use super::*; + use skia_safe::textlayout::{ + FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider, + }; + use skia_safe::FontMgr; + + const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); + + fn strings(texts: &[&str]) -> Vec { + texts.iter().map(|t| t.to_string()).collect() + } + + fn apply(texts: &[&str]) -> (Vec, OffsetMap) { + apply_to_span_texts_with_ruby_breaks(&strings(texts), &[]) + .expect("expected kinsoku insertions") + } + + // ----------------------------------------------------------------- + // Character classes + // ----------------------------------------------------------------- + + #[test] + fn classes_forbidden_at_start() { + for c in "、。」』)ーっゃァッ々・!?".chars() { + assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}"); + } + for c in "あ漢A1「( ".chars() { + assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}"); + } + } + + #[test] + fn classes_forbidden_at_end() { + for c in "「『([【〈《".chars() { + assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}"); + } + for c in "あ漢A1」)。".chars() { + assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}"); + } + } + + // ----------------------------------------------------------------- + // Insertion + // ----------------------------------------------------------------- + + #[test] + fn western_word_space_uses_one_third_em_character() { + let (texts, map) = apply(&["hello world"]); + assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]); + assert!( + map.inserted.is_empty(), + "substitution does not shift offsets" + ); + } + + #[test] + fn no_insertion_for_plain_cjk_text() { + assert!( + apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none() + ); + } + + #[test] + fn inserts_before_forbidden_start_char() { + let (texts, map) = apply(&["雪国。"]); + assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]); + assert_eq!(map.inserted, vec![2]); + } + + #[test] + fn inserts_after_forbidden_end_char() { + let (texts, _) = apply(&["「雪"]); + assert_eq!(texts, vec!["「\u{2060}雪".to_string()]); + } + + #[test] + fn no_insertion_at_paragraph_start() { + // A leading forbidden-at-start char has no break opportunity + // before it; nothing to suppress. + let (texts, _) = apply(&["。あ。"]); + assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]); + } + + #[test] + fn insertion_spans_boundary() { + // The pair (end of span 0, start of span 1) is evaluated; the + // joiner lands at the head of span 1. + let (texts, _) = apply(&["雪国", "。です"]); + assert_eq!( + texts, + vec!["雪国".to_string(), "\u{2060}。です".to_string()] + ); + } + + #[test] + fn inserts_quarter_em_at_japanese_western_boundaries() { + let (texts, map) = apply(&["日本Penpot版"]); + assert_eq!( + texts, + vec![format!( + "日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版" + )] + ); + assert_eq!(map.inserted, vec![2, 9]); + assert_eq!(map.to_original(map.to_shifted(2)), 2); + assert_eq!(map.to_original(map.to_shifted(8)), 8); + } + + #[test] + fn japanese_western_spacing_crosses_span_boundary() { + let (texts, map) = apply(&["日本", "Penpot"]); + assert_eq!( + texts, + vec![ + "日本".to_string(), + format!("{JAPANESE_WESTERN_SPACE}Penpot") + ] + ); + assert_eq!(map.inserted, vec![2]); + } + + #[test] + fn consecutive_forbidden_chars() { + let (texts, map) = apply(&["た」。"]); + assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]); + assert_eq!(map.inserted, vec![1, 3]); + } + + #[test] + fn small_kana_and_prolonged_sound() { + let (texts, _) = apply(&["コーヒー"]); + assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]); + let (texts, _) = apply(&["ちょっと"]); + assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]); + } + + // ----------------------------------------------------------------- + // Offset map + // ----------------------------------------------------------------- + + #[test] + fn offset_map_round_trips_every_original_index() { + let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。"; + let (_, map) = apply(&[original]); + let len = original.encode_utf16().count(); + for i in 0..=len { + assert_eq!( + map.to_original(map.to_shifted(i)), + i, + "round-trip failed at original index {i}" + ); + } + } + + #[test] + fn offset_map_recovers_original_characters() { + let original = "た」。あ"; + let (texts, map) = apply(&[original]); + let shifted = texts.concat(); + let shifted_units: Vec = shifted.encode_utf16().collect(); + let original_units: Vec = original.encode_utf16().collect(); + for (s_idx, unit) in shifted_units.iter().enumerate() { + if *unit == WORD_JOINER as u16 { + continue; + } + assert_eq!(original_units[map.to_original(s_idx)], *unit); + } + } + + #[test] + fn offset_map_on_joiner_resolves_to_boundary() { + // "雪国。" → joiner at shifted 2; both sides of the joiner map + // to original boundary 2. + let (_, map) = apply(&["雪国。"]); + assert_eq!(map.to_original(2), 2); + assert_eq!(map.to_original(3), 2); + assert_eq!(map.to_shifted(2), 3); + } + + // ----------------------------------------------------------------- + // Real skparagraph layout + // ----------------------------------------------------------------- + + fn font_collection() -> FontCollection { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(TEST_FONT, None) + .expect("failed to load test font"); + let mut provider = TypefaceFontProvider::new(); + provider.register_typeface(typeface, Some("TestFont")); + let mut collection = FontCollection::new(); + collection.set_asset_font_manager(Some(provider.into())); + collection.set_default_font_manager(FontMgr::new(), None); + collection + } + + /// Lays out `text` at `width` and returns each line as a UTF-16 + /// (start, end) range. + fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> { + let collection = font_collection(); + let paragraph_style = ParagraphStyle::default(); + let mut builder = ParagraphBuilder::new(¶graph_style, collection); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + style.set_letter_spacing(letter_spacing); + builder.push_style(&style); + builder.add_text(text); + let mut paragraph = builder.build(); + paragraph.layout(width); + paragraph + .get_line_metrics() + .iter() + .map(|line| (line.start_index, line.end_index)) + .collect() + } + + fn utf16_chars(text: &str) -> Vec { + // BMP-only fixtures: one char per UTF-16 unit. + text.chars().collect() + } + + /// Asserts no line starts with a start-forbidden char nor ends with + /// an end-forbidden char, in ORIGINAL text coordinates. + fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) { + let chars = utf16_chars(original); + for (i, (start, end)) in lines.iter().enumerate() { + let orig_start = map.to_original(*start); + let orig_end = map.to_original(*end); + if i > 0 { + let first = chars[orig_start]; + assert!( + !forbidden_at_line_start(first), + "line {i} starts with forbidden char {first} (text {original})" + ); + } + if i < lines.len() - 1 && orig_end > orig_start { + // The last char actually rendered on the line (end may + // include trailing whitespace-like positions). + let last = chars[orig_end - 1]; + assert!( + !forbidden_at_line_end(last), + "line {i} ends with forbidden char {last} (text {original})" + ); + } + } + } + + /// Lines mapped back to original offsets must tile the original + /// text exactly: contiguous, in order, full coverage. + fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) { + let mut expected_start = 0; + for (start, end) in lines { + let orig_start = map.to_original(*start); + let orig_end = map.to_original(*end); + assert_eq!(orig_start, expected_start, "line ranges must be contiguous"); + expected_start = orig_end; + } + assert_eq!(expected_start, original.encode_utf16().count()); + } + + fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) { + let (texts, map) = apply(&[original]); + let lines = layout_lines(&texts.concat(), width, 0.0); + (lines, map) + } + + fn measure_width(text: &str) -> f32 { + let collection = font_collection(); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + builder.push_style(&style); + builder.add_text(text); + let mut p = builder.build(); + p.layout(f32::MAX); + p.longest_line() + } + + #[test] + fn joiner_is_zero_width_in_layout() { + let plain = layout_lines("国国", f32::MAX, 0.0); + let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0); + assert_eq!(plain.len(), 1); + assert_eq!(joined.len(), 1); + + let collection = font_collection(); + let measure = |text: &str| { + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone()); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + builder.push_style(&style); + builder.add_text(text); + let mut p = builder.build(); + p.layout(f32::MAX); + p.longest_line() + }; + let diff = (measure("国国") - measure("国\u{2060}国")).abs(); + assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}"); + } + + #[test] + fn suppresses_breaks_on_kinsoku_fixtures() { + let fixtures = [ + // kinsoku-line-start + "これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。", + // kinsoku-line-end + "開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。", + // prolonged-sound + "コーヒーとケーキ、サーバーとルーター。人々の時々の心。", + // small-kana-sokuon + "ちょっと待ってください。キャッシュとクッキーをチェックする。", + // long-paragraph-wrap + "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。", + ]; + // Several widths to move the break positions around. Widths + // must exceed the longest unbreakable (joined) run, otherwise + // skparagraph rightfully falls back to an emergency mid-run + // break. + let char_width = measure_width("国"); + for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) { + for original in fixtures { + let (lines, map) = fixture_lines(original, width); + assert!(lines.len() > 1, "fixture must wrap at width {width}"); + assert_kinsoku_clean(original, &lines, &map); + assert_lines_tile_original(original, &lines, &map); + } + } + } + + #[test] + fn mixed_latin_cjk_fixture() { + let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!"; + let char_width = measure_width("国"); + for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) { + let (lines, map) = fixture_lines(original, width); + assert_kinsoku_clean(original, &lines, &map); + assert_lines_tile_original(original, &lines, &map); + } + } + + #[test] + fn joiner_becomes_visible_under_letter_spacing() { + // skparagraph applies letter-spacing per cluster, INCLUDING the + // zero-width joiner, which would double the tracking at every + // suppressed break. This is why callers disable kinsoku for + // paragraphs with a non-zero letter-spacing. If this test ever + // fails (Skia stops spacing ignorables), that gate can go. + let collection = font_collection(); + let measure = |text: &str| { + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone()); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + style.set_letter_spacing(5.0); + builder.push_style(&style); + builder.add_text(text); + let mut p = builder.build(); + p.layout(f32::MAX); + p.longest_line() + }; + let diff = (measure("国\u{2060}国") - measure("国国")).abs(); + assert!( + diff > 0.01, + "letter-spacing no longer affects the joiner; the kinsoku \ + letter-spacing gate can be removed" + ); + } +} diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 109fc605b2..78562f5072 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -211,15 +211,34 @@ fn propagate_transform( ); match text_content.grow_type() { GrowType::AutoHeight => { + let width_before = text_content.size.width; let height_before = text_content.size.height; - let new_height = if width_changed { + let (new_width, new_height) = if text_content.is_vertical() { + // Vertical auto-height fixes the physical height (the + // column wrap axis) and grows width as columns are added. + if height_changed { + let mut clone = text_content.clone(); + clone.update_layout(resized_selrect); + (clone.size.width, shape_bounds_after.height()) + } else { + (width_before, shape_bounds_after.height()) + } + } else if width_changed { let mut clone = text_content.clone(); clone.update_layout(resized_selrect); - clone.size.height + (shape_bounds_after.width(), clone.size.height) } else { - height_before + (shape_bounds_after.width(), height_before) }; - if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) { + // Reflow only when the grow axis (the WASM-computed + // dimension) changes; the wrap axis is driven by the + // resize itself. + let grow_axis_changed = if text_content.is_vertical() { + !is_close_to(width_before, new_width) + } else { + !is_close_to(height_before, new_height) + }; + if grow_axis_changed && reflowed_shapes.insert(shape.id) { entries.push_back(Modifier::reflow(shape.id, false)); if let Some(parent_id) = shape.parent_id { @@ -233,7 +252,7 @@ fn propagate_transform( let resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, - shape_bounds_after.width(), + new_width, new_height, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); diff --git a/render-wasm/src/shapes/shape_to_path.rs b/render-wasm/src/shapes/shape_to_path.rs index 32e058dcb6..ba35923d42 100644 --- a/render-wasm/src/shapes/shape_to_path.rs +++ b/render-wasm/src/shapes/shape_to_path.rs @@ -254,7 +254,12 @@ impl ToPath for Shape { Type::SVGRaw(_) => Path::default(), Type::Text(ref text) => { - let text_paths = TextPaths::new(text.clone()); + let text = if text.is_vertical() { + text.new_bounds(self.selrect) + } else { + text.clone() + }; + let text_paths = TextPaths::new(text, self.vertical_align()); let mut result = Path::default(); for (path, _) in text_paths.get_paths(true) { result = join_paths(result, Path::from_skia_path(path)); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1ba1381172..1f2995ac5e 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -7,7 +7,9 @@ use crate::{ use core::f32; use macros::ToJs; -use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; +use skia_safe::textlayout::{ + PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline, +}; use skia_safe::{ self as skia, paint::{self, Paint}, @@ -18,18 +20,553 @@ use skia_safe::{ Contains, }; +// CHANGEME: move all the custom japanese layout code to its own module + use std::cell::Cell; use std::collections::HashSet; use super::FontFamily; use crate::math::Point; -use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; +use crate::shapes::{self, kinsoku, merge_fills, Shape, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec; +pub const WARICHU_FONT_SCALE: f32 = 0.5; +pub const EMPHASIS_FONT_SCALE: f32 = 0.5; +const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3; +const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}'; +const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}'; + +/// Add a span to a horizontal paragraph builder. Warichu is represented by a +/// single inline placeholder so the two annotation lines wrap as one unit. +/// The actual glyphs are painted after SkParagraph has positioned the box. +pub(crate) fn add_horizontal_span( + builder: &mut ParagraphBuilder, + span: &TextSpan, + builder_text: &str, + text_style: &skia::textlayout::TextStyle, + fonts: &skia::textlayout::FontCollection, +) { + if span.warichu && span.text.chars().count() >= 2 { + let text = span.apply_text_transform(); + let split = super::text_vertical::warichu_split_chars(&text); + let split_byte = text + .char_indices() + .nth(split) + .map(|(index, _)| index) + .unwrap_or(text.len()); + let (first, second) = text.split_at(split_byte); + let mut mini_style = text_style.clone(); + mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE); + mini_style.set_height(1.0); + mini_style.set_height_override(true); + mini_style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); + let measure = |line: &str| { + let mut mini = ParagraphBuilder::new(&ParagraphStyle::default(), fonts); + mini.push_style(&mini_style); + mini.add_text(line); + let mut paragraph = mini.build(); + paragraph.layout(f32::MAX); + paragraph.longest_line() + }; + let width = measure(first).max(measure(second)).max(0.01); + let height = span.font_size.max(0.01); + builder.add_placeholder(&PlaceholderStyle::new( + width, + height, + PlaceholderAlignment::Middle, + TextBaseline::Alphabetic, + height, + )); + // SkParagraph does not expose a placeholder's TextStyle through line + // metrics. A near-zero, inkless NBSP preserves the exact + // fill/stroke/shadow style for the custom paint pass; the following + // zero-width space restores a legal wrapping boundary after the + // atomic placeholder. + let mut anchor_style = text_style.clone(); + anchor_style.set_font_size(0.01); + anchor_style.set_height(0.01); + anchor_style.set_height_override(true); + anchor_style.set_letter_spacing(0.0); + builder.push_style(&anchor_style); + builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string()); + builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string()); + } else { + builder.add_text(builder_text); + } +} + +#[derive(Debug, Clone)] +pub(crate) struct HorizontalSpanRange { + pub span: usize, + pub builder_start: usize, + pub builder_end: usize, + pub shifted_start: usize, + pub source_start: usize, + pub source_end: usize, + pub warichu: bool, + pub style_anchor_start: usize, +} + +/// Ranges shared by layout, position-data and editor mapping. `shifted_*` +/// addresses the normal kinsoku-adjusted paragraph text, while `builder_*` +/// addresses the paragraph where a whole warichu span occupies one U+FFFC. +pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec { + let (span_texts, offset_map) = paragraph.layout_span_texts(); + let mut builder_cursor = 0usize; + let mut builder_byte_cursor = 0usize; + let mut shifted_cursor = 0usize; + paragraph + .children() + .iter() + .zip(span_texts) + .enumerate() + .map(|(span_index, (span, text))| { + let shifted_start = shifted_cursor; + shifted_cursor += text.encode_utf16().count(); + let shifted_end = shifted_cursor; + let source_start = offset_map.to_original(shifted_start); + let source_end = offset_map.to_original(shifted_end); + let warichu = span.warichu && span.text.chars().count() >= 2; + let builder_start = builder_cursor; + let style_anchor_start = if warichu { + builder_byte_cursor + '\u{FFFC}'.len_utf8() + } else { + builder_byte_cursor + }; + builder_cursor += if warichu { + HORIZONTAL_WARICHU_BUILDER_LEN + } else { + shifted_end - shifted_start + }; + builder_byte_cursor += if warichu { + '\u{FFFC}'.len_utf8() + + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8() + + HORIZONTAL_WARICHU_BREAK_ANCHOR.len_utf8() + } else { + text.len() + }; + HorizontalSpanRange { + span: span_index, + builder_start, + builder_end: builder_cursor, + shifted_start, + source_start, + source_end, + warichu, + style_anchor_start, + } + }) + .collect() +} + +fn source_char_boundaries(paragraph: &Paragraph) -> Vec { + let mut boundaries = vec![0usize]; + for span in paragraph.children() { + for character in span.text.chars() { + boundaries.push(boundaries.last().copied().unwrap_or(0) + character.len_utf16()); + } + } + boundaries +} + +pub(crate) fn horizontal_source_to_builder( + paragraph: &Paragraph, + source_char_offset: usize, +) -> usize { + let boundaries = source_char_boundaries(paragraph); + let source_utf16 = boundaries + .get(source_char_offset) + .copied() + .unwrap_or_else(|| boundaries.last().copied().unwrap_or(0)); + let (_, offset_map) = paragraph.layout_span_texts(); + let ranges = horizontal_span_ranges(paragraph); + let Some(range) = ranges + .iter() + .find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end) + else { + return ranges.last().map(|range| range.builder_end).unwrap_or(0); + }; + if range.warichu { + return if source_utf16 >= range.source_end { + range.builder_end + } else { + range.builder_start + }; + } + let shifted = offset_map.to_shifted(source_utf16); + range.builder_start + shifted.saturating_sub(range.shifted_start) +} + +pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize { + let (_, offset_map) = paragraph.layout_span_texts(); + let ranges = horizontal_span_ranges(paragraph); + let source_utf16 = ranges + .iter() + .find(|range| builder_offset >= range.builder_start && builder_offset <= range.builder_end) + .map(|range| { + if range.warichu { + if builder_offset > range.builder_start { + range.source_end + } else { + range.source_start + } + } else { + let within = builder_offset + .saturating_sub(range.builder_start) + .min(range.builder_end - range.builder_start); + offset_map.to_original(range.shifted_start + within) + } + }) + .unwrap_or_else(|| ranges.last().map(|range| range.source_end).unwrap_or(0)); + let boundaries = source_char_boundaries(paragraph); + boundaries + .partition_point(|boundary| *boundary < source_utf16) + .min(boundaries.len().saturating_sub(1)) +} + +fn horizontal_warichu_boxes<'a>( + paragraph: &'a Paragraph, + laid_out: &skia::textlayout::Paragraph, +) -> Vec<(&'a TextSpan, usize, usize, skia::Rect)> { + let mut source_start = 0usize; + let mut placeholders = laid_out.get_rects_for_placeholders().into_iter(); + let mut boxes = Vec::new(); + for span in paragraph.children() { + let length = span.text.chars().count(); + if span.warichu && length >= 2 { + if let Some(textbox) = placeholders.next() { + boxes.push((span, source_start, source_start + length, textbox.rect)); + } + } + source_start += length; + } + boxes +} + +pub(crate) fn horizontal_warichu_hit_test( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + point: Point, +) -> Option { + for (span, source_start, _, rect) in horizontal_warichu_boxes(paragraph, laid_out) { + if !rect.contains(&point) { + continue; + } + let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); + let total = span.text.chars().count(); + let second = point.y >= rect.top() + rect.height() / 2.0; + let (line_start, line_len) = if second { + (split, total - split) + } else { + (0, split) + }; + let fraction = ((point.x - rect.left()) / rect.width().max(0.01)).clamp(0.0, 1.0); + let within = (fraction * line_len as f32).round() as usize; + return Some(source_start + line_start + within.min(line_len)); + } + None +} + +pub(crate) fn horizontal_warichu_caret_rect( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + source_offset: usize, +) -> Option { + for (span, source_start, source_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) { + if source_offset < source_start || source_offset > source_end { + continue; + } + let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); + let local = source_offset - source_start; + let total = source_end - source_start; + let (line_start, line_len, top) = if local >= split { + (split, total - split, rect.top() + rect.height() / 2.0) + } else { + (0, split, rect.top()) + }; + let within = local.saturating_sub(line_start).min(line_len); + let char_width = rect.width() / line_len.max(1) as f32; + return Some(skia::Rect::from_xywh( + rect.left() + within as f32 * char_width, + top, + char_width, + rect.height() / 2.0, + )); + } + None +} + +pub(crate) fn horizontal_warichu_range_rects( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + source_start: usize, + source_end: usize, +) -> Vec { + let mut rects = Vec::new(); + for (span, span_start, span_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) { + let selected_start = source_start.max(span_start); + let selected_end = source_end.min(span_end); + if selected_start >= selected_end { + continue; + } + let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); + for (line_start, line_end, top) in [ + (span_start, span_start + split, rect.top()), + ( + span_start + split, + span_end, + rect.top() + rect.height() / 2.0, + ), + ] { + let start = selected_start.max(line_start); + let end = selected_end.min(line_end); + if start >= end { + continue; + } + let line_len = line_end - line_start; + let char_width = rect.width() / line_len.max(1) as f32; + rects.push(skia::Rect::from_xywh( + rect.left() + (start - line_start) as f32 * char_width, + top, + (end - start) as f32 * char_width, + rect.height() / 2.0, + )); + } + } + rects +} + +pub(crate) fn horizontal_normal_selection_ranges( + paragraph: &Paragraph, + source_start: usize, + source_end: usize, +) -> Vec> { + let mut span_start = 0usize; + paragraph + .children() + .iter() + .filter_map(|span| { + let span_end = span_start + span.text.chars().count(); + let selected_start = source_start.max(span_start); + let selected_end = source_end.min(span_end); + let warichu = span.warichu && span.text.chars().count() >= 2; + span_start = span_end; + if warichu || selected_start >= selected_end { + return None; + } + Some( + horizontal_source_to_builder(paragraph, selected_start) + ..horizontal_source_to_builder(paragraph, selected_end), + ) + }) + .collect() +} + +fn warichu_text_lines(text: &str) -> (&str, &str) { + let split = super::text_vertical::warichu_split_chars(text); + let split_byte = text + .char_indices() + .nth(split) + .map(|(index, _)| index) + .unwrap_or(text.len()); + text.split_at(split_byte) +} + +fn warichu_mini_paragraph( + text: &str, + style: &skia::textlayout::TextStyle, + width: f32, +) -> skia::textlayout::Paragraph { + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), get_font_collection()); + builder.push_style(style); + builder.add_text(text); + let mut paragraph = builder.build(); + paragraph.layout(width.max(0.01)); + paragraph +} + +/// Paint the two horizontal warichu sub-lines into SkParagraph's inline +/// placeholder boxes. Reading order is top line then bottom line. +pub(crate) fn paint_horizontal_warichu( + canvas: &skia::Canvas, + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let ranges = horizontal_span_ranges(paragraph); + let placeholders = laid_out.get_rects_for_placeholders(); + let warichu_ranges: Vec<_> = ranges.iter().filter(|range| range.warichu).collect(); + if placeholders.len() != warichu_ranges.len() { + return; + } + + for (range, textbox) in warichu_ranges.into_iter().zip(placeholders) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + // Indexed style metrics expose builder UTF-8 byte positions even + // though glyph/line ranges use UTF-16 offsets. + let style_anchor = range.style_anchor_start + ..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8(); + let style = laid_out.get_line_metrics().iter().find_map(|line| { + line.get_style_metrics(style_anchor.clone()) + .into_iter() + .next() + .map(|(_, metric)| metric.text_style.clone()) + }); + let Some(mut style) = style else { + continue; + }; + style.set_font_size(span.font_size * WARICHU_FONT_SCALE); + style.set_height(1.0); + style.set_height_override(true); + style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); + let transformed = span.apply_text_transform(); + let (first, second) = warichu_text_lines(&transformed); + let rect = textbox.rect; + let first_para = warichu_mini_paragraph(first, &style, rect.width()); + let second_para = warichu_mini_paragraph(second, &style, rect.width()); + let half_height = rect.height() / 2.0; + first_para.paint(canvas, (x + rect.left(), y + rect.top())); + second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height)); + } +} + +pub(crate) fn emphasis_char_allowed(character: char) -> bool { + !character.is_whitespace() + && !crate::shapes::japanese::classify(character).is_emphasis_prohibited() +} + +#[derive(Debug, Clone, Copy)] +struct HorizontalEmphasisPlacement { + span: usize, + mark: char, + rect: skia::Rect, +} + +/// Locate one horizontal emphasis mark above each eligible source character. +/// SkParagraph owns wrapping and bidi placement; querying each transformed +/// character range keeps the marks attached to the actual laid-out glyphs. +fn horizontal_emphasis_placements( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, +) -> Vec { + let (_, offset_map) = paragraph.layout_span_texts(); + let ranges = horizontal_span_ranges(paragraph); + let mut placements = Vec::new(); + + for range in ranges.iter().filter(|range| !range.warichu) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let transformed = span.apply_text_transform(); + let mut local_utf16 = 0usize; + for character in transformed.chars() { + let next_utf16 = local_utf16 + character.len_utf16(); + if emphasis_char_allowed(character) { + let shifted_start = offset_map.to_shifted(range.source_start + local_utf16); + let shifted_end = offset_map.to_shifted(range.source_start + next_utf16); + let builder_start = + range.builder_start + shifted_start.saturating_sub(range.shifted_start); + let builder_end = + range.builder_start + shifted_end.saturating_sub(range.shifted_start); + let scalar_rect = laid_out + .get_rects_for_range( + builder_start..builder_end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .into_iter() + .map(|textbox| textbox.rect) + .reduce(|mut rect, next| { + rect.join(next); + rect + }); + if let Some(rect) = scalar_rect { + placements.push(HorizontalEmphasisPlacement { + span: range.span, + mark, + rect, + }); + } + } + local_utf16 = next_utf16; + } + } + placements +} + +fn horizontal_span_style( + laid_out: &skia::textlayout::Paragraph, + range: &HorizontalSpanRange, +) -> Option { + // Indexed style metrics use builder UTF-8 byte positions (the same + // convention used by the warichu style anchor above). + let anchor = range.style_anchor_start..range.style_anchor_start + 1; + laid_out.get_line_metrics().iter().find_map(|line| { + line.get_style_metrics(anchor.clone()) + .into_iter() + .next() + .map(|(_, metric)| metric.text_style.clone()) + }) +} + +/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs. +/// The base paragraph retains its normal metrics; interlinear collision and +/// automatic line-gap expansion remain a separate layout policy. +pub(crate) fn paint_horizontal_emphasis( + canvas: &skia::Canvas, + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let ranges = horizontal_span_ranges(paragraph); + let placements = horizontal_emphasis_placements(paragraph, laid_out); + for range in ranges.iter().filter(|range| !range.warichu) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let Some(mut style) = horizontal_span_style(laid_out, range) else { + continue; + }; + style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE); + style.set_height(1.0); + style.set_height_override(true); + style.set_letter_spacing(0.0); + let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX); + let mark_width = mark_paragraph.longest_line(); + let mark_height = mark_paragraph.height(); + for placement in placements + .iter() + .filter(|placement| placement.span == range.span && placement.mark == mark) + { + let mark_x = x + placement.rect.center_x() - mark_width / 2.0; + let ruby_offset = if span.annotation_clearance.is_auto() + && !span.ruby.trim().is_empty() + && span.ruby_side == RubySide::Over + { + span.font_size * span.ruby_size.scale() + } else { + 0.0 + }; + let mark_y = y + placement.rect.top() - mark_height - ruby_offset; + mark_paragraph.paint(canvas, (mark_x, mark_y)); + } + } +} + #[repr(u8)] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] pub enum GrowType { @@ -420,6 +957,14 @@ impl TextContent { self.size.normalized_line_height } + /// Writing mode is a whole-shape property: the first paragraph + /// decides the flow for all of them. + pub fn is_vertical(&self) -> bool { + self.paragraphs + .first() + .is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl) + } + pub fn grow_type(&self) -> GrowType { self.grow_type } @@ -500,7 +1045,10 @@ impl TextContent { // AutoWidth paragraphs are laid out with f32::MAX, so line metrics // (line.left) reflect alignment within that huge width and are // unusable for tight bounds. Fall back to content_rect. - if self.grow_type() == GrowType::AutoWidth { + // Vertical writing bounds come from the vertical pass through + // content_rect; the skparagraph line metrics below describe the + // unused horizontal layout. + if self.grow_type() == GrowType::AutoWidth || self.is_vertical() { return self.content_rect(selrect, valign); } @@ -579,6 +1127,17 @@ impl TextContent { } pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { + // Vertical content anchors to the shape's right edge and always + // aligns to the top (vertical-align along columns is deferred). + if self.is_vertical() { + let (width, height) = if self.grow_type() == GrowType::AutoWidth { + (self.size.width, self.size.height) + } else { + (selrect.width(), selrect.height()) + }; + return Rect::from_xywh(selrect.right() - width, selrect.y(), width, height); + } + let x = selrect.x(); let mut y = selrect.y(); @@ -613,7 +1172,26 @@ impl TextContent { pub fn get_caret_position_from_shape_coords( &self, point: &Point, + vertical_align: VerticalAlign, ) -> Option { + // Vertical writing: resolve through the vertical pass. The point + // arrives selrect-local; the content block is right-anchored. + if self.is_vertical() { + let bounds = self.bounds(); + let max_height = super::text_vertical::wrap_height(self, bounds.height()); + let layout = super::text_vertical::layout_from_content(self, max_height); + let cx = point.x + - super::text_vertical::block_axis_offset( + bounds.width(), + layout.width, + vertical_align, + ); + let (paragraph, offset) = super::text_vertical::caret_from_point(&layout, cx, point.y)?; + return Some(TextPositionWithAffinity::new_without_affinity( + paragraph, offset, + )); + } + let mut offset_y = 0.0; let layout_paragraphs = self.layout.paragraphs.iter().flatten(); @@ -636,9 +1214,28 @@ impl TextContent { // the paragraph's top-left. For multi-paragraph or wrapped text, each // paragraph has its own origin; subtract start_y so we pass paragraph-local coords. let para_pt = Point::new(point.x, point.y - start_y); - let position_with_affinity = + if let Some(paragraph) = self.paragraphs().get(paragraph_index) { + if let Some(original_position) = + horizontal_warichu_hit_test(paragraph, layout_paragraph, para_pt) + { + return Some(TextPositionWithAffinity::new_without_affinity( + paragraph_index, + original_position, + )); + } + } + let mut position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { + // The laid-out paragraph reports offsets in the + // builder-text (kinsoku-shifted) space; translate + // back to original text offsets. + let original_position = horizontal_builder_to_source( + paragraph, + position_with_affinity.position as usize, + ); + position_with_affinity.position = original_position as i32; + // Computed position keeps the current position in terms // of number of characters of text. This is used to know // in which span we are. @@ -650,7 +1247,7 @@ impl TextContent { let length = span.text.chars().count(); let start_position = computed_position; let end_position = computed_position + length; - let current_position = position_with_affinity.position as usize; + let current_position = original_position; // Handle empty spans: if the span is empty and current position // matches the start, this is the right span @@ -670,7 +1267,7 @@ impl TextContent { return Some(TextPositionWithAffinity::new( position_with_affinity, paragraph_index, - position_with_affinity.position as usize, + original_position, )); } } @@ -703,9 +1300,10 @@ impl TextContent { point: &Point, view_matrix: &Matrix, shape_matrix: &Matrix, + vertical_align: VerticalAlign, ) -> Option { let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?; - self.get_caret_position_from_shape_coords(&shape_rel_point) + self.get_caret_position_from_shape_coords(&shape_rel_point, vertical_align) } /// Builds the ParagraphBuilders necessary to render @@ -722,7 +1320,8 @@ impl TextContent { let paragraph_style = paragraph.paragraph_to_style(); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; - for span in paragraph.children() { + let (span_texts, _) = paragraph.layout_span_texts(); + for (span, text) in paragraph.children().iter().zip(span_texts.iter()) { let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); let text_style = span.to_style( &self.bounds(), @@ -730,12 +1329,11 @@ impl TextContent { remove_alpha, paragraph.line_height(), ); - let text: String = span.apply_text_transform(); if !text.is_empty() { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_horizontal_span(&mut builder, span, text, &text_style, fonts); } if !has_text { builder.add_text(" "); @@ -757,19 +1355,19 @@ impl TextContent { let paragraph_style = paragraph.paragraph_to_style(); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; - for span in paragraph.children() { + let (span_texts, _) = paragraph.layout_span_texts(); + for (span, text) in paragraph.children().iter().zip(span_texts.iter()) { let text_style = span.to_style( &self.bounds(), fallback_fonts, true, // always opaque paragraph.line_height(), ); - let text: String = span.apply_text_transform(); if !text.is_empty() { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_horizontal_span(&mut builder, span, text, &text_style, fonts); } if !has_text { builder.add_text(" "); @@ -930,6 +1528,32 @@ impl TextContent { } } + // Vertical writing sizes come from the vertical pass. Auto-width + // fits both axes without wrapping. Auto-height keeps the shape height + // as its wrap budget and grows width as columns advance right-to-left. + // Fixed keeps both shape dimensions. + if self.is_vertical() { + match self.grow_type() { + GrowType::AutoWidth => { + let max_height = super::text_vertical::wrap_height(self, selrect.height()); + let (width, height) = super::text_vertical::measure_content(self, max_height); + self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); + self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); + self.size.max_width = self.size.width; + } + GrowType::AutoHeight => { + let max_height = super::text_vertical::wrap_height(self, selrect.height()); + let (width, _) = super::text_vertical::measure_content(self, max_height); + self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); + self.size.height = selrect.height(); + self.size.max_width = self.size.width; + } + GrowType::Fixed => { + self.size.set_size(selrect.width(), selrect.height()); + } + } + } + if self.is_empty() { let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect); self.size.width = placeholder_width; @@ -1038,6 +1662,20 @@ impl TextContent { let result = matrix.map_point((x_pos, y_pos)); + // Vertical writing: hit-test against the laid-out cells directly + // (absolute coordinates, right-anchored to the selrect). + if self.is_vertical() { + let max_height = super::text_vertical::wrap_height(self, shape.selrect.height()); + let layout = super::text_vertical::layout_from_content(self, max_height); + return super::text_vertical::intersects( + &layout, + &shape.selrect, + shape.vertical_align(), + result.x, + result.y, + ); + } + // Change coords to content space let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); @@ -1082,6 +1720,153 @@ pub type TextAlign = skia::textlayout::TextAlign; pub type TextDirection = skia::textlayout::TextDirection; pub type TextDecoration = skia::textlayout::TextDecoration; +/// Block flow direction of a paragraph. Horizontal is the skparagraph +/// path; vertical-rl lays out columns top->bottom advancing right->left +/// through the custom vertical pass. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum WritingMode { + #[default] + HorizontalTb, + VerticalRl, +} + +/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs +/// sideways, `Upright` keeps every character upright. Ignored in +/// horizontal writing. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextOrientation { + #[default] + Mixed, + Upright, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextCombineUpright { + #[default] + None, + All, + /// Combine runs of 2-4 consecutive ASCII or full-width digits into one upright + /// composite; other characters keep the normal vertical layout. + Digits, + /// Like `Digits` but only runs of exactly 2 digits combine + /// (CSS `text-combine-upright: digits 2`). + Digits2, + /// Like `Digits` but runs of 2-3 digits combine. + Digits3, +} + +impl TextCombineUpright { + /// Longest digit run that combines, when digits mode is active. + pub fn digits_max(self) -> Option { + match self { + TextCombineUpright::Digits => Some(4), + TextCombineUpright::Digits2 => Some(2), + TextCombineUpright::Digits3 => Some(3), + _ => None, + } + } +} + +/// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS +/// `text-emphasis-style`. The mark is drawn above each eligible horizontal +/// base character or to the right of its vertical column. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextEmphasis { + #[default] + None, + FilledDot, + OpenDot, + FilledCircle, + OpenCircle, + FilledSesame, + OpenSesame, +} + +impl TextEmphasis { + pub fn is_none(self) -> bool { + matches!(self, TextEmphasis::None) + } + + /// The glyph drawn as the emphasis mark, following the CSS + /// `text-emphasis-style` character mapping. + pub fn mark_char(self) -> Option { + match self { + TextEmphasis::None => None, + TextEmphasis::FilledDot => Some('•'), + TextEmphasis::OpenDot => Some('◦'), + TextEmphasis::FilledCircle => Some('●'), + TextEmphasis::OpenCircle => Some('○'), + TextEmphasis::FilledSesame => Some('﹅'), + TextEmphasis::OpenSesame => Some('﹆'), + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum FontFeatures { + #[default] + None, + Palt, + Vpal, +} + +/// Controls whether annotation layers participate in line/column spacing. +/// The default preserves legacy documents; `Auto` reserves one half-em for +/// each active ruby or emphasis layer. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum AnnotationClearance { + #[default] + None, + Auto, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubySize { + #[default] + Half, + Third, + Quarter, +} + +impl RubySize { + pub fn scale(self) -> f32 { + match self { + Self::Half => 0.5, + Self::Third => 1.0 / 3.0, + Self::Quarter => 0.25, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubyAlign { + #[default] + SpaceAround, + Center, + Start, + SpaceBetween, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubyOverhang { + #[default] + Auto, + None, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubySide { + #[default] + Over, + Under, +} + +impl AnnotationClearance { + pub fn is_auto(self) -> bool { + matches!(self, AnnotationClearance::Auto) + } +} + #[derive(Debug, PartialEq, Clone, Copy)] pub enum TextTransform { Lowercase, @@ -1097,6 +1882,8 @@ pub struct Paragraph { text_direction: TextDirection, text_decoration: Option, text_transform: Option, + writing_mode: WritingMode, + text_orientation: TextOrientation, line_height: f32, letter_spacing: f32, children: Vec, @@ -1109,6 +1896,8 @@ impl Default for Paragraph { text_direction: TextDirection::LTR, text_decoration: None, text_transform: None, + writing_mode: WritingMode::default(), + text_orientation: TextOrientation::default(), line_height: 1.0, letter_spacing: 0.0, children: vec![], @@ -1132,12 +1921,30 @@ impl Paragraph { text_direction, text_decoration, text_transform, + writing_mode: WritingMode::default(), + text_orientation: TextOrientation::default(), line_height, letter_spacing, children, } } + pub fn writing_mode(&self) -> WritingMode { + self.writing_mode + } + + pub fn set_writing_mode(&mut self, writing_mode: WritingMode) { + self.writing_mode = writing_mode; + } + + pub fn text_orientation(&self) -> TextOrientation { + self.text_orientation + } + + pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) { + self.text_orientation = text_orientation; + } + pub fn children(&self) -> &[TextSpan] { &self.children } @@ -1170,6 +1977,36 @@ impl Paragraph { self.text_transform } + /// Span texts as fed to the paragraph builders: text-transform applied, + /// Japanese spacing normalized, and kinsoku break suppressions inserted, + /// plus the map between original and builder-text UTF-16 offsets. Every + /// consumer of laid-out offsets must translate through the map. The layout + /// transform is skipped under letter-spacing, where skparagraph would add + /// letter spacing to synthetic layout characters. + pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) { + let texts: Vec = self + .children + .iter() + .map(|s| s.apply_text_transform()) + .collect(); + let has_letter_spacing = + self.letter_spacing != 0.0 || self.children.iter().any(|s| s.letter_spacing != 0.0); + if !has_letter_spacing { + let ruby_breaks: Vec>> = 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()) + } + pub fn paragraph_to_style(&self) -> ParagraphStyle { let mut style = ParagraphStyle::default(); @@ -1193,6 +2030,7 @@ impl Paragraph { /// Capitalize the first letter of each word, preserving all original whitespace. /// Matches CSS `text-transform: capitalize` behavior: a "word" starts after /// any non-letter character (whitespace, punctuation, digits, symbols). +#[cfg(test)] fn capitalize_words(text: &str) -> String { let mut result = String::with_capacity(text.len()); let mut capitalize_next = true; @@ -1214,6 +2052,7 @@ fn capitalize_words(text: &str) -> String { /// Filter control characters below U+0020, preserving line breaks. /// Browser-dependent: Firefox drops them, others replace with space. +#[cfg(test)] fn process_ignored_chars(text: &str, browser: u8) -> String { text.chars() .filter_map(|c| { @@ -1233,6 +2072,94 @@ fn process_ignored_chars(text: &str, browser: u8) -> String { .collect() } +/// Text after browser filtering and CSS text transformation, plus the source +/// UTF-16 range that produced each transformed Unicode scalar. A single source +/// scalar can produce several output scalars (for example `ß` uppercases to +/// `SS`); keeping that ownership lets vertical layout wrap and export the +/// transformed glyphs as one source-text unit. +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedTextTransform { + pub text: String, + source_ranges: Vec<(std::ops::Range, std::ops::Range)>, +} + +impl AppliedTextTransform { + pub fn source_utf16_range( + &self, + transformed: std::ops::Range, + ) -> std::ops::Range { + let mut ranges = self.source_ranges.iter().filter_map(|(output, source)| { + (output.start < transformed.end && output.end > transformed.start) + .then_some(source.clone()) + }); + let Some(first) = ranges.next() else { + return 0..0; + }; + ranges.fold(first, |range, source| { + range.start.min(source.start)..range.end.max(source.end) + }) + } +} + +fn apply_text_transform_with_source_ranges( + text: &str, + browser: u8, + transform: Option, +) -> AppliedTextTransform { + let mut output = String::with_capacity(text.len()); + let mut source_ranges = Vec::new(); + let mut source_utf16 = 0usize; + let mut output_utf16 = 0usize; + let mut capitalize_next = true; + + for source_char in text.chars() { + let source_start = source_utf16; + source_utf16 += source_char.len_utf16(); + + let processed = if source_char == '\n' + || source_char == '\r' + || source_char == '\u{2028}' + || source_char == '\u{2029}' + || source_char >= '\u{0020}' + { + Some(source_char) + } else if browser == Browser::Firefox as u8 { + None + } else { + Some(' ') + }; + let Some(processed) = processed else { + continue; + }; + + let transformed: String = match transform { + Some(TextTransform::Uppercase) => processed.to_uppercase().collect(), + Some(TextTransform::Lowercase) => processed.to_lowercase().collect(), + Some(TextTransform::Capitalize) if processed.is_alphabetic() && capitalize_next => { + capitalize_next = false; + processed.to_uppercase().collect() + } + Some(TextTransform::Capitalize) => { + capitalize_next = !processed.is_alphabetic(); + processed.to_string() + } + None => processed.to_string(), + }; + + for transformed_char in transformed.chars() { + let transformed_start = output_utf16; + output_utf16 += transformed_char.len_utf16(); + source_ranges.push((transformed_start..output_utf16, source_start..source_utf16)); + output.push(transformed_char); + } + } + + AppliedTextTransform { + text: output, + source_ranges, + } +} + #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { pub text: String, @@ -1245,6 +2172,21 @@ pub struct TextSpan { pub text_decoration: Option, pub text_transform: Option, pub text_direction: TextDirection, + pub text_orientation: TextOrientation, + pub text_combine_upright: TextCombineUpright, + /// Emphasis mark (圏点 / bouten) applied to each base character. + pub text_emphasis: TextEmphasis, + /// Ruby (furigana) annotation for this span; empty means no ruby. + pub ruby: String, + pub ruby_size: RubySize, + pub ruby_align: RubyAlign, + pub ruby_overhang: RubyOverhang, + pub ruby_side: RubySide, + /// Warichu (割注): render the span as two half-size lines stacked inline + /// within one column position of the vertical flow. + pub warichu: bool, + pub font_features: FontFeatures, + pub annotation_clearance: AnnotationClearance, pub fills: Vec, } @@ -1272,6 +2214,17 @@ impl TextSpan { text_decoration, text_transform, text_direction, + text_orientation: TextOrientation::default(), + text_combine_upright: TextCombineUpright::default(), + text_emphasis: TextEmphasis::default(), + ruby: String::default(), + ruby_size: RubySize::default(), + ruby_align: RubyAlign::default(), + ruby_overhang: RubyOverhang::default(), + ruby_side: RubySide::default(), + warichu: false, + font_features: FontFeatures::default(), + annotation_clearance: AnnotationClearance::default(), font_weight, font_variant_id, fills, @@ -1282,6 +2235,50 @@ impl TextSpan { self.text = text; } + pub fn set_ruby(&mut self, ruby: String) { + self.ruby = ruby; + } + + pub fn set_ruby_size(&mut self, value: RubySize) { + self.ruby_size = value; + } + + pub fn set_ruby_align(&mut self, value: RubyAlign) { + self.ruby_align = value; + } + + pub fn set_ruby_overhang(&mut self, value: RubyOverhang) { + self.ruby_overhang = value; + } + + pub fn set_ruby_side(&mut self, value: RubySide) { + self.ruby_side = value; + } + + pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) { + self.text_orientation = text_orientation; + } + + pub fn set_text_combine_upright(&mut self, text_combine_upright: TextCombineUpright) { + self.text_combine_upright = text_combine_upright; + } + + pub fn set_text_emphasis(&mut self, text_emphasis: TextEmphasis) { + self.text_emphasis = text_emphasis; + } + + pub fn set_warichu(&mut self, warichu: bool) { + self.warichu = warichu; + } + + pub fn set_font_features(&mut self, font_features: FontFeatures) { + self.font_features = font_features; + } + + pub fn set_annotation_clearance(&mut self, clearance: AnnotationClearance) { + self.annotation_clearance = clearance; + } + pub fn to_style( &self, content_bounds: &Rect, @@ -1299,7 +2296,13 @@ impl TextSpan { paint = merge_fills(&self.fills, *content_bounds); } - let max_line_height = f32::max(paragraph_line_height, self.line_height); + let annotation_layers = if self.annotation_clearance.is_auto() { + usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none()) + } else { + 0 + }; + let max_line_height = + f32::max(paragraph_line_height, self.line_height) + annotation_layers as f32 * 0.5; style.set_height(max_line_height); style.set_height_override(true); style.set_foreground_paint(&paint); @@ -1321,6 +2324,11 @@ impl TextSpan { style.set_font_families(&font_families); style.set_font_size(self.font_size); style.set_letter_spacing(self.letter_spacing); + match self.font_features { + FontFeatures::None => {} + FontFeatures::Palt => style.add_font_feature("palt", 1), + FontFeatures::Vpal => style.add_font_feature("vpal", 1), + } style.set_half_leading(true); style @@ -1364,14 +2372,12 @@ impl TextSpan { } pub fn apply_text_transform(&self) -> String { + self.apply_text_transform_with_source_ranges().text + } + + pub fn apply_text_transform_with_source_ranges(&self) -> AppliedTextTransform { let browser = crate::with_state!(state, { state.current_browser }); - let text = process_ignored_chars(&self.text, browser); - match self.text_transform { - Some(TextTransform::Uppercase) => text.to_uppercase(), - Some(TextTransform::Lowercase) => text.to_lowercase(), - Some(TextTransform::Capitalize) => capitalize_words(&text), - None => text, - } + apply_text_transform_with_source_ranges(&self.text, browser, self.text_transform) } pub fn scale_content(&mut self, value: f32) { @@ -1402,6 +2408,7 @@ pub struct PositionData { #[derive(Debug)] pub struct ParagraphLayout { pub paragraph: skia::textlayout::Paragraph, + pub source_paragraph: usize, pub x: f32, pub y: f32, pub decorations: Vec, @@ -1413,7 +2420,7 @@ pub struct TextLayoutData { pub paragraphs: Vec, } -fn direction_to_int(direction: TextDirection) -> u32 { +pub(crate) fn direction_to_int(direction: TextDirection) -> u32 { match direction { TextDirection::RTL => 0, TextDirection::LTR => 1, @@ -1538,6 +2545,7 @@ pub fn calculate_text_layout_data( } paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, + source_paragraph: i, x, y: y_accum, decorations, @@ -1548,21 +2556,41 @@ pub fn calculate_text_layout_data( // Calculate position data from paragraph_layouts if !skip_position_data { - for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() { + for para_layout in ¶graph_layouts { + let paragraph_index = para_layout.source_paragraph; let current_y = para_layout.y; let text_paragraph = text_paragraphs.get(paragraph_index); if let Some(text_para) = text_paragraph { - let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; - let mut cur = 0; - for (span_index, span) in text_para.children().iter().enumerate() { - let text: String = span.apply_text_transform(); - let text_len = text.encode_utf16().count(); - span_ranges.push((cur, cur + text_len, span_index)); - cur += text_len; - } - for (start, end, span_index) in span_ranges { + // Ranges are in the builder-text (kinsoku-shifted) + // space; exported positions are translated back to + // original span-relative offsets through the map. + let (_, offset_map) = text_para.layout_span_texts(); + let span_ranges = horizontal_span_ranges(text_para); + let placeholder_rects = para_layout.paragraph.get_rects_for_placeholders(); + let mut placeholder_index = 0usize; + for range in span_ranges { + if range.warichu { + if let Some(textbox) = placeholder_rects.get(placeholder_index) { + let mut rect = textbox.rect; + rect.offset((x, current_y)); + position_data.push(PositionData { + paragraph: paragraph_index as u32, + span: range.span as u32, + start_pos: 0, + end_pos: (range.source_end - range.source_start) as u32, + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + direction: direction_to_int(TextDirection::LTR), + }); + } + placeholder_index += 1; + continue; + } + let orig_span_start = range.source_start; let rects = para_layout.paragraph.get_rects_for_range( - start..end, + range.builder_start..range.builder_end, RectHeightStyle::Tight, RectWidthStyle::Tight, ); @@ -1573,22 +2601,30 @@ pub fn calculate_text_layout_data( let cy = rect.top + rect.height() / 2.0; // Get byte positions from Skia's transformed text layout - let start_pos = para_layout - .paragraph - .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) - .position as usize - - start; + let to_source = |builder_position: usize| { + let within = builder_position + .saturating_sub(range.builder_start) + .min(range.builder_end - range.builder_start); + offset_map.to_original(range.shifted_start + within) + }; + let start_pos = to_source( + para_layout + .paragraph + .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) + .position as usize, + ) - orig_span_start; - let end_pos = para_layout - .paragraph - .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) - .position as usize - - start; + let end_pos = to_source( + para_layout + .paragraph + .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) + .position as usize, + ) - orig_span_start; rect.offset((x, current_y)); position_data.push(PositionData { paragraph: paragraph_index as u32, - span: span_index as u32, + span: range.span as u32, start_pos: start_pos as u32, end_pos: end_pos as u32, x: rect.x(), @@ -1617,6 +2653,20 @@ pub fn calculate_position_data( let mut text_content = text_content.clone(); text_content.update_layout(shape.selrect); + // Vertical writing generates position data from the vertical cells. + if text_content.is_vertical() { + if skip_position_data { + return Vec::new(); + } + let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height()); + let layout = super::text_vertical::layout_from_content(&text_content, max_height); + return super::text_vertical::position_data( + &layout, + &shape.selrect, + shape.vertical_align(), + ); + } + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let layout_info = calculate_text_layout_data( shape, @@ -1718,4 +2768,294 @@ mod tests { "ab" ); } + + #[test] + fn transformed_text_maps_expanded_scalars_to_their_source_range() { + let transformed = apply_text_transform_with_source_ranges( + "AßB", + Browser::Chrome as u8, + Some(TextTransform::Uppercase), + ); + + assert_eq!(transformed.text, "ASSB"); + assert_eq!(transformed.source_utf16_range(0..1), 0..1); + assert_eq!(transformed.source_utf16_range(1..2), 1..2); + assert_eq!(transformed.source_utf16_range(2..3), 1..2); + assert_eq!(transformed.source_utf16_range(1..3), 1..2); + assert_eq!(transformed.source_utf16_range(3..4), 2..3); + } + + // apply_text_transform reads the browser from the design state. + fn init_state() { + crate::globals::design_init(); + } + + fn make_span(text: &str, letter_spacing: f32) -> TextSpan { + TextSpan { + text: text.to_string(), + font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal), + font_size: 16.0, + line_height: 1.0, + letter_spacing, + font_weight: 400, + font_variant_id: Uuid::nil(), + text_decoration: None, + text_transform: None, + text_direction: TextDirection::LTR, + text_orientation: TextOrientation::default(), + text_combine_upright: TextCombineUpright::default(), + text_emphasis: TextEmphasis::default(), + ruby: String::default(), + warichu: false, + font_features: FontFeatures::default(), + annotation_clearance: AnnotationClearance::default(), + ruby_size: RubySize::default(), + ruby_align: RubyAlign::default(), + ruby_overhang: RubyOverhang::default(), + ruby_side: RubySide::default(), + fills: vec![], + } + } + + fn make_paragraph(spans: Vec, letter_spacing: f32) -> Paragraph { + Paragraph::new( + TextAlign::default(), + TextDirection::LTR, + None, + None, + 1.0, + letter_spacing, + spans, + ) + } + + #[test] + fn layout_span_texts_applies_kinsoku() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!( + texts, + vec!["雪国".to_string(), "\u{2060}。です".to_string()] + ); + assert!(!map.is_empty()); + assert_eq!(map.to_original(3), 2); + } + + #[test] + fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["雪国。".to_string()]); + assert!(map.is_empty()); + } + + #[test] + fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["雪国。".to_string()]); + assert!(map.is_empty()); + } + + #[test] + fn layout_span_texts_respects_text_transform() { + init_state(); + let mut span = make_span("hello。", 0.0); + span.text_transform = Some(TextTransform::Uppercase); + let paragraph = make_paragraph(vec![span], 0.0); + let (texts, _) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]); + } + + #[test] + fn layout_span_texts_identity_map_for_plain_text() { + init_state(); + let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["helloworld".to_string()]); + assert!(map.is_empty()); + assert_eq!(map.to_original(5), 5); + assert_eq!(map.to_shifted(5), 5); + } + + #[test] + fn horizontal_ruby_is_atomic() { + init_state(); + let mut group = make_span("日本", 0.0); + group.ruby = "にほん".to_string(); + let paragraph = make_paragraph(vec![group], 0.0); + assert_eq!( + paragraph.layout_span_texts().0, + vec!["日\u{2060}本".to_string()] + ); + } + + #[test] + fn horizontal_warichu_collapses_to_one_builder_position() { + init_state(); + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0); + + let ranges = horizontal_span_ranges(¶graph); + assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3); + assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4); + assert_eq!(horizontal_source_to_builder(¶graph, 2), 0); + assert_eq!(horizontal_source_to_builder(¶graph, 4), 3); + assert_eq!(horizontal_source_to_builder(¶graph, 5), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 1), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 2), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 3), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 4), 5); + assert_eq!( + horizontal_normal_selection_ranges(¶graph, 1, 5), + vec![3..4] + ); + } + + #[test] + fn horizontal_builder_mapping_preserves_non_bmp_boundaries() { + init_state(); + let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0); + + assert_eq!(horizontal_source_to_builder(¶graph, 1), 2); + assert_eq!(horizontal_builder_to_source(¶graph, 2), 1); + assert_eq!(horizontal_source_to_builder(¶graph, 2), 3); + assert_eq!(horizontal_builder_to_source(¶graph, 3), 2); + } + + #[test] + fn horizontal_warichu_builder_emits_one_styled_placeholder() { + init_state(); + let mut span = make_span("割注入り", 0.0); + span.warichu = true; + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts); + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let placeholders = laid_out.get_rects_for_placeholders(); + assert_eq!(placeholders.len(), 1); + assert!(placeholders[0].rect.width() > 0.0); + assert!(placeholders[0].rect.height() > 0.0); + let has_style = laid_out + .get_line_metrics() + .iter() + .any(|line| !line.get_style_metrics(3..5).is_empty()); + assert!( + has_style, + "the paint pass must recover the placeholder style" + ); + } + + #[test] + fn horizontal_warichu_allows_wrapping_after_the_atomic_box() { + init_state(); + let mut span = make_span("割注入り", 0.0); + span.warichu = true; + let following = make_span("A", 0.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts); + builder.push_style(&style); + builder.add_text(&following.text); + + let mut laid_out = builder.build(); + laid_out.layout(16.1); + + assert_eq!(laid_out.get_rects_for_placeholders().len(), 1); + assert_eq!(laid_out.get_line_metrics().len(), 2); + } + + #[test] + fn emphasis_excludes_whitespace_and_japanese_punctuation() { + for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars() + { + assert!( + !emphasis_char_allowed(character), + "emphasis must skip {character:?}" + ); + } + for character in "漢あA1・!?".chars() { + assert!( + emphasis_char_allowed(character), + "emphasis should mark {character:?}" + ); + } + } + + #[test] + fn horizontal_emphasis_tracks_eligible_unicode_characters() { + init_state(); + let mut span = make_span("A😀。 B", 0.0); + span.text_emphasis = TextEmphasis::FilledDot; + let paragraph = make_paragraph(vec![span], 0.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(16.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + let (texts, _) = paragraph.layout_span_texts(); + for (span, text) in paragraph.children().iter().zip(texts) { + builder.push_style(&style); + add_horizontal_span(&mut builder, span, &text, &style, &fonts); + } + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let placements = horizontal_emphasis_placements(¶graph, &laid_out); + assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each"); + assert!(placements + .iter() + .all(|placement| placement.rect.width() > 0.0)); + assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some()); + } + + #[test] + fn horizontal_emphasis_recovers_each_non_ascii_span_style() { + init_state(); + let mut first = make_span("漢", 0.0); + first.text_emphasis = TextEmphasis::FilledDot; + let mut second = make_span("字", 0.0); + second.text_emphasis = TextEmphasis::OpenCircle; + let paragraph = make_paragraph(vec![first, second], 0.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + let (texts, _) = paragraph.layout_span_texts(); + for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() { + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(if index == 0 { 16.0 } else { 24.0 }); + builder.push_style(&style); + add_horizontal_span(&mut builder, span, &text, &style, &fonts); + } + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let ranges = horizontal_span_ranges(¶graph); + assert_eq!( + horizontal_span_style(&laid_out, &ranges[0]) + .unwrap() + .font_size(), + 16.0 + ); + assert_eq!( + horizontal_span_style(&laid_out, &ranges[1]) + .unwrap() + .font_size(), + 24.0 + ); + } } diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 38cf30226f..977ce8a312 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -1,24 +1,37 @@ use crate::get_render_state; use crate::shapes::text::TextContent; +use crate::shapes::VerticalAlign; use skia_safe::{ self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob, }; use std::ops::Deref; -pub struct TextPaths(TextContent); +pub struct TextPaths { + text_content: TextContent, + vertical_align: VerticalAlign, +} -// Note: This class is not being currently used. -// It's an example of how to convert texts to paths #[allow(dead_code)] impl TextPaths { - pub fn new(text_content: TextContent) -> Self { - Self(text_content) + pub fn new(text_content: TextContent, vertical_align: VerticalAlign) -> Self { + Self { + text_content, + vertical_align, + } } pub fn get_paths(&self, antialias: bool) -> Vec<(skia::Path, skia::Paint)> { + if self.text_content.is_vertical() { + return crate::shapes::text_vertical::vertical_text_paths( + &self.text_content, + self.vertical_align, + antialias, + ); + } + let mut paths = Vec::new(); let mut offset_y = self.bounds.y(); - let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None); + let mut paragraph_builders = self.text_content.paragraph_builder_group_from_text(None); for paragraphs in paragraph_builders.iter_mut() { for paragraph_builder in paragraphs.iter_mut() { @@ -192,6 +205,6 @@ impl Deref for TextPaths { type Target = TextContent; fn deref(&self) -> &Self::Target { - &self.0 + &self.text_content } } diff --git a/render-wasm/src/shapes/text_vertical.rs b/render-wasm/src/shapes/text_vertical.rs new file mode 100644 index 0000000000..16b9991509 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical.rs @@ -0,0 +1,6661 @@ +// Vertical (tategaki) text layout: columns flow top->bottom and advance +// right->left. Skia's skparagraph has no vertical writing mode, so this +// module owns the whole vertical pipeline: +// +// segment spans by glyph orientation -> shape each segment with SkShaper +// (OpenType `vert`/`vrt2` for upright runs) -> plan kinsoku-aware column +// breaks -> derive cells (one per upright glyph cluster / one per rotated +// run) -> paint / position-data / hit-testing from the cells. +// +// Offset discipline: cells use UTF-16 offsets in transformed layout text. +// Position data maps those ranges back to the original span text before it +// crosses the WASM boundary; the WORD JOINER OffsetMap remains exclusive to +// skparagraph-driven horizontal breaks. +// +// Layouts are computed on demand (like the horizontal path, which rebuilds +// its skparagraph objects per paint); nothing is cached. +// +// Strokes (center/inner/outer, masked to the glyph silhouettes), drop +// shadows and decorations (underline / line-through as bars along the +// column) are painted from the same cells as the fills. +// +// Text-align aligns each column's glyphs along the vertical (inline) axis: +// Left/Start->top, Center->middle, Right/End->bottom of the wrap budget. +// Justify stretches every column but the last to fill the wrap budget, +// distributing the leftover space evenly between the column's cells. +// +// Letter-spacing adds inter-glyph advance along the column — once per +// upright cluster and once per glyph inside a rotated run — mirroring the +// horizontal `letter-spacing` that Skia applies to each glyph advance. +// Upright CJK <-> rotated alphanumeric boundaries additionally receive the +// conventional quarter-em inter-script gap. +// +// Deferred to a later phase (documented in the phase handoff): emoji +// overlays, inner shadows and block-axis vertical-align. + +use skia_safe::{ + self as skia, + canvas::SaveLayerRec, + shaper::{ + run_handler::{Buffer, RunInfo}, + Feature, RunHandler, + }, + textlayout::{Paragraph as SkiaParagraph, TextDecoration, TypefaceFontProvider}, + BlendMode, Canvas, Contains, Font, FontMgr, GlyphId, ImageFilter, Paint, Point as SkPoint, + TextBlob, TextBlobBuilder, +}; + +use crate::get_render_state; +use crate::math::Rect; +use crate::shapes::japanese::{classify, pair_rule, JapaneseClass}; +use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; +use crate::shapes::{ + merge_fills, AppliedTextTransform, FontFeatures, GrowType, PositionData, RubyAlign, + RubyOverhang, RubySide, Stroke, StrokeKind, TextAlign, TextCombineUpright, TextContent, + TextOrientation, TextSpan, VerticalAlign, +}; +use crate::utils::get_fallback_fonts; + +/// Characters that stay upright in vertical flow: kana, kanji, CJK +/// punctuation and full-width forms. Everything else rotates sideways +/// under `text-orientation: mixed`. +pub fn is_upright_char(c: char) -> bool { + matches!(u32::from(c), + 0x2E80..=0x2FDF // CJK radicals, Kangxi radicals + | 0x3000..=0x303F // CJK symbols and punctuation + | 0x3040..=0x30FF // hiragana, katakana + | 0x31C0..=0x31EF // CJK strokes + | 0x31F0..=0x31FF // katakana phonetic extensions + | 0x3200..=0x33FF // enclosed CJK, CJK compatibility + | 0x3400..=0x4DBF // CJK unified ideographs extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xAC00..=0xD7AF // hangul syllables + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF00..=0xFF60 // full-width forms + | 0xFFE0..=0xFFE6 // full-width signs + | 0x20000..=0x2FA1F // CJK extensions B..F + ) +} + +/// Characters whose horizontal glyph needs a vertical alternate. `vert` / +/// `vrt2` normally supplies that alternate; when the selected face has no +/// such substitution, rotate the horizontal glyph clockwise as a legible +/// fallback. The set covers UAX #50 `Tr`, plus comma/full-stop punctuation +/// whose untransformed glyph otherwise occupies the wrong half of the +/// vertical em box. +fn uses_rotated_vertical_fallback(c: char) -> bool { + matches!(u32::from(c), + 0x2018..=0x2019 // single quotation marks + | 0x201C..=0x201D // double quotation marks + | 0x2329..=0x232A // angle brackets + | 0x3001..=0x3002 // ideographic comma / full stop + | 0x3008..=0x301F // CJK brackets, wave dash and quotation marks + | 0x3030 // wavy dash + | 0x30A0 // katakana-hiragana double hyphen + | 0x30FC // prolonged sound mark + | 0xFE50..=0xFE52 // small comma / ideographic comma / full stop + | 0xFE59..=0xFE5E // small brackets + | 0xFF08..=0xFF09 // full-width parentheses + | 0xFF0C // full-width comma + | 0xFF0D // full-width hyphen-minus + | 0xFF0E // full-width full stop + | 0xFF1A..=0xFF1B // full-width colon / semicolon + | 0xFF1C..=0xFF1E // full-width comparison signs + | 0xFF3B // full-width left square bracket + | 0xFF3D // full-width right square bracket + | 0xFF3F // full-width low line + | 0xFF5B..=0xFF60 // full-width braces, bars, tilde and white parentheses + | 0xFFE3 // full-width macron + ) +} + +fn cluster_needs_rotated_vertical_fallback( + run: &ShapedRun, + glyph: usize, + count: usize, + ch: Option, +) -> bool { + let Some(ch) = ch else { + return false; + }; + count == 1 + && uses_rotated_vertical_fallback(ch) + && run.glyphs[glyph] == run.font.unichar_to_glyph(ch as i32) +} + +#[derive(Debug, PartialEq)] +pub struct Segment { + pub text: String, + /// UTF-16 offset of the segment start within its span text. + pub utf16_start: usize, + pub upright: bool, +} + +/// Split text into maximal runs of same orientation. Under +/// `TextOrientation::Upright` every character is upright. +pub fn segment_by_orientation(text: &str, orientation: TextOrientation) -> Vec { + let mut segments: Vec = Vec::new(); + let mut utf16_offset = 0; + for c in text.chars() { + let upright = orientation == TextOrientation::Upright || is_upright_char(c); + match segments.last_mut() { + Some(last) if last.upright == upright => last.text.push(c), + _ => segments.push(Segment { + text: c.to_string(), + utf16_start: utf16_offset, + upright, + }), + } + utf16_offset += c.len_utf16(); + } + segments +} + +/// One shaped run: glyphs from a single font, pen-relative positions, +/// per-glyph horizontal advances and per-glyph UTF-8 cluster starts +/// (relative to the shaped segment text). +pub struct ShapedRun { + pub font: Font, + pub glyphs: Vec, + pub positions: Vec, + pub advances: Vec, + pub clusters: Vec, + pub advance: f32, + pub utf8_range: std::ops::Range, + /// Local-y offset that centres the run's ink after 90° rotation. + pub rotated_baseline_shift: f32, +} + +#[derive(Default)] +struct RunCollector { + runs: Vec, + scratch_glyphs: Vec, + scratch_positions: Vec, + scratch_clusters: Vec, +} + +impl RunHandler for RunCollector { + fn begin_line(&mut self) {} + + fn run_info(&mut self, _info: &RunInfo) {} + + fn commit_run_info(&mut self) {} + + fn run_buffer(&mut self, info: &RunInfo) -> Buffer<'_> { + self.scratch_glyphs.resize(info.glyph_count, 0); + self.scratch_positions + .resize(info.glyph_count, SkPoint::default()); + self.scratch_clusters.resize(info.glyph_count, 0); + Buffer { + glyphs: &mut self.scratch_glyphs, + positions: &mut self.scratch_positions, + offsets: None, + clusters: Some(&mut self.scratch_clusters), + point: SkPoint::default(), + } + } + + fn commit_run_buffer(&mut self, info: &RunInfo) { + if info.glyph_count == 0 { + return; + } + let origin = self.scratch_positions[0]; + let positions: Vec = self + .scratch_positions + .iter() + .map(|p| SkPoint::new(p.x - origin.x, p.y)) + .collect(); + let end = positions[0].x + info.advance.x; + let mut advances: Vec = Vec::with_capacity(info.glyph_count); + for i in 0..positions.len() { + let next = if i + 1 < positions.len() { + positions[i + 1].x + } else { + end + }; + advances.push(next - positions[i].x); + } + self.runs.push(ShapedRun { + rotated_baseline_shift: rotated_run_baseline_shift( + info.font, + &self.scratch_glyphs, + &positions, + ), + font: info.font.clone(), + glyphs: self.scratch_glyphs.clone(), + positions, + advances, + clusters: self.scratch_clusters.clone(), + advance: info.advance.x, + utf8_range: info.utf8_range.clone(), + }); + } + + fn commit_line(&mut self) {} +} + +fn feature(tag: &[u8; 4]) -> Feature { + Feature { + tag: u32::from_be_bytes(*tag), + value: 1, + start: 0, + end: usize::MAX, + } +} + +/// `vpal` is deliberately absent: SkShaper shapes on a horizontal line, +/// where HarfBuzz would apply the feature's y-placement deltas as glyph +/// offsets without its advance deltas. Vertical layout applies the parsed +/// GPOS `vpal` metrics to upright cells itself. +fn font_feature(font_features: FontFeatures) -> Option { + match font_features { + FontFeatures::None => None, + FontFeatures::Palt => Some(feature(b"palt")), + FontFeatures::Vpal => None, + } +} + +/// Shape one orientation segment on a single unbounded line. Upright +/// segments get the OpenType vertical substitution features so CJK +/// punctuation and brackets take their vertical forms. +fn shape_segment( + text: &str, + font: &Font, + upright: bool, + font_features: FontFeatures, + fallback: FontMgr, +) -> Vec { + let shaper = skia::Shaper::new(fallback.clone()); + let mut font_iter = skia::Shaper::new_font_mgr_run_iterator(text, font, Some(fallback)); + let mut bidi_iter = skia::shapers::primitive::trivial_bidi_run_iterator(0, text.len()); + let mut script_iter = skia::Shaper::new_hb_icu_script_run_iterator(text); + let mut lang_iter = skia::Shaper::new_trivial_language_run_iterator("ja", text.len()); + + let mut features = if upright { + vec![feature(b"vert"), feature(b"vrt2")] + } else { + vec![] + }; + if let Some(font_feature) = font_feature(font_features) { + features.push(font_feature); + } + + let mut collector = RunCollector::default(); + shaper.shape_with_iterators_and_features( + text, + &mut font_iter, + &mut bidi_iter, + &mut script_iter, + &mut lang_iter, + &features, + f32::MAX, + &mut collector, + ); + collector.runs +} + +/// Vertical advances from a font's `vhea`/`vmtx` tables. Upright cells +/// advance down the column by the glyph's true vertical advance rather +/// than its shaped horizontal advance: identical for full-width CJK, but +/// correct for vertical alternates and proportional glyphs whose `vmtx` +/// differs from `hmtx` (e.g. the vertical kana repeat marks). +struct VerticalMetrics { + units_per_em: f32, + /// Advance heights of the first `advances.len()` glyph ids. + advances: Vec, + /// Advance for glyph ids at or beyond the long-metric count. + last: u16, +} + +impl VerticalMetrics { + /// Parse `vhea`/`vmtx` off the font's typeface. Returns `None` when the + /// font carries no vertical metrics (the caller then keeps horizontal + /// advances). + fn from_font(font: &Font) -> Option { + let typeface = font.typeface(); + let units_per_em = typeface.units_per_em()? as f32; + if units_per_em <= 0.0 { + return None; + } + let vhea = typeface.copy_table_data(u32::from_be_bytes(*b"vhea"))?; + let vmtx = typeface.copy_table_data(u32::from_be_bytes(*b"vmtx"))?; + let vhea = vhea.as_bytes(); + let vmtx = vmtx.as_bytes(); + // `numberOfLongVerMetrics` is the trailing u16 of the 36-byte header. + let num_long = u16::from_be_bytes([*vhea.get(34)?, *vhea.get(35)?]) as usize; + if num_long == 0 { + return None; + } + // Each long metric is { advanceHeight: u16, topSideBearing: i16 }. + let mut advances = Vec::with_capacity(num_long); + for i in 0..num_long { + let o = i * 4; + let (Some(&hi), Some(&lo)) = (vmtx.get(o), vmtx.get(o + 1)) else { + break; + }; + advances.push(u16::from_be_bytes([hi, lo])); + } + let last = *advances.last()?; + Some(Self { + units_per_em, + advances, + last, + }) + } + + /// Vertical advance of `glyph` at `font_size`, in pixels at the shaped + /// size (same units as the horizontal advances). + fn advance(&self, glyph: GlyphId, font_size: f32) -> f32 { + let raw = self + .advances + .get(glyph as usize) + .copied() + .unwrap_or(self.last); + raw as f32 * font_size / self.units_per_em + } +} + +thread_local! { + /// Parsed `vhea`/`vmtx` per typeface. Layouts are recomputed on every + /// paint but a face's vertical metrics never change, so the table + /// parse (which copies the whole table) is done once per typeface. + // Keyed by the typeface's unique id (`SkTypefaceID`, a `u32`). + static VERTICAL_METRICS_CACHE: std::cell::RefCell< + std::collections::HashMap>>, + > = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +fn vertical_metrics(font: &Font) -> Option> { + let id = font.typeface().unique_id(); + VERTICAL_METRICS_CACHE.with(|cache| { + cache + .borrow_mut() + .entry(id) + .or_insert_with(|| VerticalMetrics::from_font(font).map(std::rc::Rc::new)) + .clone() + }) +} + +/// GPOS `vpal` deltas for one typeface, in font units. +struct VpalTable { + units_per_em: f32, + deltas: std::collections::HashMap, +} + +impl VpalTable { + fn from_font(font: &Font) -> Option { + let typeface = font.typeface(); + let units_per_em = typeface.units_per_em()? as f32; + if units_per_em <= 0.0 { + return None; + } + let gpos = typeface.copy_table_data(u32::from_be_bytes(*b"GPOS"))?; + let deltas = super::gpos_vpal::parse_vpal(gpos.as_bytes())?; + Some(Self { + units_per_em, + deltas, + }) + } + + /// Pixel deltas for a cluster at `font_size`: summed advance delta + /// (negative when the cell tightens) and the flow-axis shift of the + /// drawn ink (positive down the column). GPOS `yPlacement` is y-up, + /// so its sign flips into flow space. `None` when no glyph of the + /// cluster is covered. + fn cluster_delta(&self, glyphs: &[GlyphId], font_size: f32) -> Option<(f32, f32)> { + let scale = font_size / self.units_per_em; + let mut advance = 0.0f32; + let mut flow_shift = None; + for glyph in glyphs { + if let Some(delta) = self.deltas.get(glyph) { + advance += delta.y_advance as f32 * scale; + flow_shift.get_or_insert(-(delta.y_placement as f32) * scale); + } + } + flow_shift.map(|shift| (advance, shift)) + } +} + +thread_local! { + /// Parsed GPOS `vpal` deltas per typeface, keyed by typeface unique id + /// (same lifetime argument as `VERTICAL_METRICS_CACHE`). + static VPAL_TABLE_CACHE: std::cell::RefCell< + std::collections::HashMap>>, + > = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +fn vpal_table(font: &Font) -> Option> { + let id = font.typeface().unique_id(); + VPAL_TABLE_CACHE.with(|cache| { + cache + .borrow_mut() + .entry(id) + .or_insert_with(|| VpalTable::from_font(font).map(std::rc::Rc::new)) + .clone() + }) +} + +/// OS/2 typographic ascender/descender, normalised to the em (design units / +/// unitsPerEm). These bound the ideographic em box and are what the browser +/// uses for the vertical central baseline; `hhea`/`Font::metrics` are oversized +/// for CJK faces (their ascent exceeds the em) and would push an upright glyph +/// off the ideographic centre of its cell. +#[derive(Clone, Copy)] +struct TypoMetrics { + /// sTypoAscender / unitsPerEm (positive, above the baseline). + ascender: f32, + /// sTypoDescender / unitsPerEm (negative, below the baseline). + descender: f32, +} + +impl TypoMetrics { + fn from_font(font: &Font) -> Option { + let typeface = font.typeface(); + let units_per_em = typeface.units_per_em()? as f32; + if units_per_em <= 0.0 { + return None; + } + let os2 = typeface.copy_table_data(u32::from_be_bytes(*b"OS/2"))?; + let os2 = os2.as_bytes(); + // sTypoAscender @ 68 (i16), sTypoDescender @ 70 (i16); present in every + // OS/2 table version. + let ascender = i16::from_be_bytes([*os2.get(68)?, *os2.get(69)?]) as f32; + let descender = i16::from_be_bytes([*os2.get(70)?, *os2.get(71)?]) as f32; + Some(Self { + ascender: ascender / units_per_em, + descender: descender / units_per_em, + }) + } +} + +thread_local! { + /// Parsed OS/2 typographic metrics per typeface, cached like the vertical + /// metrics because layouts recompute every paint but a face's metrics are + /// constant. Keyed by the typeface's unique id. + static TYPO_METRICS_CACHE: std::cell::RefCell< + std::collections::HashMap>, + > = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Ascent/descent for centring an upright cell, in Skia's sign convention +/// (ascent negative, descent positive) at the font's current size. Prefers the +/// OS/2 typographic metrics (the ideographic em box, matching the browser's +/// vertical central baseline used by the SVG/foreignObject export); falls back +/// to `Font::metrics` when the face carries no OS/2 table. +fn upright_centre_metrics(font: &Font) -> (f32, f32) { + let id = font.typeface().unique_id(); + let typo = TYPO_METRICS_CACHE.with(|cache| { + *cache + .borrow_mut() + .entry(id) + .or_insert_with(|| TypoMetrics::from_font(font)) + }); + if let Some(typo) = typo { + let size = font.size(); + return (-typo.ascender * size, -typo.descender * size); + } + let (_, metrics) = font.metrics(); + (metrics.ascent, metrics.descent) +} + +/// A placeable item in the vertical flow: its extent along the column and, +/// for single-character cells, the character (used for kinsoku decisions +/// at column breaks). Rotated runs carry no character and are unsplittable. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FlowItem { + pub extent: f32, + pub ch: Option, + /// This item and its predecessor came from the same source character + /// after a length-expanding text transform, so a column break must not + /// split them. + pub keep_with_previous: bool, +} + +/// Assign items to columns: returns (column-index, offset-from-top) per +/// item. An item that overflows the column height starts a new column; +/// items taller than the column occupy one on their own. Kinsoku: a break +/// may not leave a forbidden-at-line-end character at the column bottom +/// nor put a forbidden-at-line-start character at the next column top; +/// offending predecessors are pushed to the new column (oidashi), bounded +/// so a pathological run cannot empty its column. +/// Punctuation allowed to hang past the column bottom (ぶら下げ / burasage): the +/// ideographic and full-width comma and period. When such a mark is the item +/// that would overflow the column, it protrudes into the margin instead of +/// wrapping (itself and its predecessor) to the next column. +fn can_hang(c: char) -> bool { + matches!(c, '、' | '。' | ',' | '.') +} + +pub fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> { + const MAX_KINSOKU_SHIFT: usize = 4; + + let mut placements: Vec<(usize, f32)> = Vec::with_capacity(items.len()); + let mut column = 0usize; + let mut cursor = 0.0f32; + let mut column_start = 0usize; + + for (i, item) in items.iter().enumerate() { + // Burasage: let a comma/period hang past the column bottom rather than + // break. The next non-hangable item still sees the overflowed cursor + // and wraps normally; oidashi never pulls the hung marks because they + // are forbidden-at-line-start, not forbidden-at-line-end. + if cursor > 0.0 && cursor + item.extent > max_height && !item.ch.is_some_and(can_hang) { + let mut break_at = i; + while break_at > column_start && items[break_at].keep_with_previous { + break_at -= 1; + } + // The current item closes an atomic composite that began at the + // column head (group ruby or an expanded source scalar). Keep the + // complete unit together even when it is taller than the nominal + // column; there is no legal internal break to choose instead. + if break_at == column_start && items[i].keep_with_previous { + placements.push((column, cursor)); + cursor += item.extent; + continue; + } + let mut shifted = 0; + while break_at > column_start + && shifted < MAX_KINSOKU_SHIFT + && (items[break_at].ch.is_some_and(forbidden_at_line_start) + || items[break_at - 1].ch.is_some_and(forbidden_at_line_end)) + { + break_at -= 1; + while break_at > column_start && items[break_at].keep_with_previous { + break_at -= 1; + } + shifted += 1; + } + // Give up on the shift when the moved items plus the current one + // would overflow the new column too: overflowing the wrap budget + // is worse than the kinsoku violation. + let shifted_extent: f32 = items[break_at..i].iter().map(|it| it.extent).sum(); + if break_at < i && shifted_extent + item.extent > max_height { + break_at = i; + } + column += 1; + let mut new_cursor = 0.0; + for k in break_at..i { + placements[k] = (column, new_cursor); + new_cursor += items[k].extent; + } + cursor = new_cursor; + column_start = break_at; + } + placements.push((column, cursor)); + cursor += item.extent; + } + placements +} + +/// Offset of a column's content along the column (vertical/inline) axis for +/// a given `text-align`. In `vertical-rl` the inline axis runs top->bottom, so +/// Left/Start anchor to the top, Right/End to the bottom and Center to the +/// middle of the wrap budget. `budget` is the column wrap height (the box +/// height); an unbounded budget (auto-width, columns are snug) yields no shift. +/// Justify yields no uniform shift here — its space is distributed between +/// cells per column by `justify_extra` in the placement loop. +pub fn align_offset_along_column(align: TextAlign, budget: f32, used: f32) -> f32 { + if !budget.is_finite() || budget >= f32::MAX || budget <= 0.0 { + return 0.0; + } + let slack = (budget - used).max(0.0); + match align { + TextAlign::Center => slack / 2.0, + TextAlign::Right | TextAlign::End => slack, + _ => 0.0, + } +} + +const INTER_SCRIPT_SPACING_EM: f32 = 0.25; +const WESTERN_WORD_SPACING_EM: f32 = 1.0 / 3.0; + +#[derive(Debug, Clone, Copy)] +enum FlowScript { + Upright, + Rotated { + starts_alphanumeric: bool, + ends_alphanumeric: bool, + }, +} + +/// Full-width-font punctuation whose glyph body occupies the leading (top) +/// half of its em. Its normal trailing half-em aki remains in ordinary text +/// and is shed only at punctuation-sequence boundaries. +fn sheds_trailing_aki(c: char) -> bool { + classify(c).is_trailing_aki_punctuation() +} + +/// Opening punctuation has a half-width glyph body and normally keeps a +/// leading half-em aki. That aki is shed after another opening bracket or a +/// middle dot, and at a wrapped line head (JIS X 4051 tentsuki policy). +fn sheds_leading_aki(c: char) -> bool { + classify(c) == JapaneseClass::OpeningBracket +} + +fn embedded_leading_aki(class: JapaneseClass) -> f32 { + match class { + JapaneseClass::OpeningBracket => 0.5, + JapaneseClass::MiddleDot => 0.25, + _ => 0.0, + } +} + +fn embedded_trailing_aki(class: JapaneseClass) -> f32 { + match class { + JapaneseClass::ClosingBracket | JapaneseClass::FullStop | JapaneseClass::Comma => 0.5, + JapaneseClass::MiddleDot => 0.25, + _ => 0.0, + } +} + +fn flow_class(cell: &VerticalCell, item: &FlowItem) -> Option { + match cell.kind { + CellKind::TateChuYoko { .. } => Some(JapaneseClass::TateChuYoko), + CellKind::Rotated { .. } => Some(JapaneseClass::Western), + _ => item.ch.map(classify), + } +} + +/// Divide `amount` equally across capped opportunities, redistributing the +/// remainder whenever one opportunity reaches its cap. +fn capped_equal_allocations(capacities: &[(usize, f32)], amount: f32) -> Vec<(usize, f32)> { + let mut allocations: Vec<(usize, f32)> = capacities.iter().map(|(i, _)| (*i, 0.0)).collect(); + let mut remaining = amount.max(0.0); + while remaining > 0.0001 { + let active: Vec = capacities + .iter() + .enumerate() + .filter_map(|(slot, (_, cap))| (allocations[slot].1 + 0.0001 < *cap).then_some(slot)) + .collect(); + if active.is_empty() { + break; + } + let share = remaining / active.len() as f32; + let mut used = 0.0; + for slot in active { + let cap = capacities[slot].1; + let delta = share.min(cap - allocations[slot].1); + allocations[slot].1 += delta; + used += delta; + } + if used <= 0.0001 { + break; + } + remaining -= used; + } + allocations +} + +fn spacing_owner(_before: JapaneseClass, after: JapaneseClass, boundary: usize) -> (usize, bool) { + if matches!( + after, + JapaneseClass::OpeningBracket | JapaneseClass::MiddleDot + ) { + // Sequence layout removes any redundant preceding trailing half; the + // remaining aki is the next glyph's embedded leading space. + (boundary + 1, true) + } else { + (boundary, false) + } +} + +fn reduce_cell_spacing( + cells: &mut [VerticalCell], + items: &mut [FlowItem], + index: usize, + amount: f32, + leading: bool, +) { + let amount = amount.min(items[index].extent.max(0.0)); + items[index].extent -= amount; + cells[index].extent -= amount; + if leading { + cells[index].glyph_flow_shift -= amount; + } +} + +/// JLREQ oikomi: before wrapping a non-hanging item, try to keep it in the +/// current column by reducing only legal aki, in table priority order. If the +/// complete deficit cannot be recovered, leave the line untouched for the +/// subsequent oidashi/kinsoku planner. +fn apply_ordered_oikomi( + cells: &mut [VerticalCell], + items: &mut [FlowItem], + classes: &[Option], + pair_spacing_em: &mut [f32], + max_height: f32, +) { + if !max_height.is_finite() || max_height <= 0.0 || max_height >= f32::MAX { + return; + } + let mut column_start = 0usize; + let mut cursor = 0.0f32; + for i in 0..items.len() { + if cursor > 0.0 + && cursor + items[i].extent > max_height + && !items[i].ch.is_some_and(can_hang) + { + let deficit = cursor + items[i].extent - max_height; + let mut opportunities: Vec<(usize, u8, f32, f32)> = Vec::new(); + for boundary in column_start..i { + let (Some(before), Some(after)) = (classes[boundary], classes[boundary + 1]) else { + continue; + }; + let rule = pair_rule(before, after); + if rule.shrink_priority == 0 || pair_spacing_em[boundary] <= rule.minimum_em { + continue; + } + let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); + let capacity = (pair_spacing_em[boundary] - rule.minimum_em) * em; + opportunities.push((boundary, rule.shrink_priority, capacity, em)); + } + let total_capacity: f32 = opportunities.iter().map(|(_, _, cap, _)| *cap).sum(); + if total_capacity + 0.0001 >= deficit { + let mut remaining = deficit; + for priority in 1..=5 { + if remaining <= 0.0001 { + break; + } + let caps: Vec<(usize, f32)> = opportunities + .iter() + .filter(|(_, p, _, _)| *p == priority) + .map(|(boundary, _, cap, _)| (*boundary, *cap)) + .collect(); + if caps.is_empty() { + continue; + } + let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); + for (boundary, reduction) in + capped_equal_allocations(&caps, remaining.min(available)) + { + let (Some(before), Some(after)) = + (classes[boundary], classes[boundary + 1]) + else { + continue; + }; + let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); + let (owner, leading) = spacing_owner(before, after, boundary); + reduce_cell_spacing(cells, items, owner, reduction, leading); + pair_spacing_em[boundary] -= reduction / em; + if owner < i { + cursor -= reduction; + } + remaining -= reduction; + } + } + } + if cursor + items[i].extent > max_height + 0.0001 { + column_start = i; + cursor = 0.0; + } + } + cursor += items[i].extent; + } +} + +/// Ordered oidashi expansion for justified columns. Stages 1–3 respect the +/// table caps; if slack remains, stage 4 distributes it equally across every +/// otherwise-expandable boundary, as required by JLREQ §3.8.4. +fn ordered_expansion_offsets( + cells: &[VerticalCell], + classes: &[Option], + pair_spacing_em: &[f32], + placements: &[(usize, f32)], + column_used: &[f32], + max_height: f32, + last_column: usize, +) -> Vec { + let mut boundary_expansion = vec![0.0f32; cells.len().saturating_sub(1)]; + for (column, used) in column_used.iter().enumerate().take(last_column) { + let mut boundaries: Vec<(usize, u8, f32)> = Vec::new(); + for boundary in 0..cells.len().saturating_sub(1) { + if placements[boundary].0 != column || placements[boundary + 1].0 != column { + continue; + } + let (Some(before), Some(after)) = (classes[boundary], classes[boundary + 1]) else { + continue; + }; + let rule = pair_rule(before, after); + if rule.expand_priority == 0 { + continue; + } + let em = cells[boundary].font_size.min(cells[boundary + 1].font_size); + let cap = (rule.maximum_em - pair_spacing_em[boundary]).max(0.0) * em; + boundaries.push((boundary, rule.expand_priority, cap)); + } + let mut remaining = (max_height - used).max(0.0); + for priority in 1..=3 { + let caps: Vec<(usize, f32)> = boundaries + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(boundary, _, cap)| (*boundary, *cap)) + .collect(); + let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); + for (boundary, expansion) in capped_equal_allocations(&caps, remaining.min(available)) { + boundary_expansion[boundary] += expansion; + remaining -= expansion; + } + } + if remaining > 0.0001 && !boundaries.is_empty() { + let extra = remaining / boundaries.len() as f32; + for (boundary, _, _) in &boundaries { + boundary_expansion[*boundary] += extra; + } + } + } + + let mut offsets = vec![0.0f32; cells.len()]; + for i in 1..cells.len() { + if placements[i].0 == placements[i - 1].0 { + offsets[i] = offsets[i - 1] + boundary_expansion[i - 1]; + } + } + offsets +} + +/// Centered punctuation (jlreq class cl-05 中点類): the middle dot, the +/// full-width colon and the full-width semicolon are placed at the centre of +/// the em box in vertical writing rather than on the horizontal baseline. +fn is_centered_punctuation(c: char) -> bool { + classify(c) == JapaneseClass::MiddleDot +} + +/// Flow-axis shift that centres a glyph's ink band within its em body: moves +/// the ink midpoint (`(ink_top + ink_bottom) / 2`) to the body midpoint. +fn centered_flow_shift(ink_top: f32, ink_bottom: f32, em_body: f32) -> f32 { + em_body / 2.0 - (ink_top + ink_bottom) / 2.0 +} + +/// Japanese inter-script spacing at an upright CJK <-> rotated alphabetic or +/// numeric boundary. Punctuation and explicit whitespace do not create an +/// automatic gap. Use the smaller adjacent font size so a large neighboring +/// run cannot create a disproportionate gap. +fn inter_script_spacing( + previous: FlowScript, + previous_font_size: f32, + next: FlowScript, + next_font_size: f32, +) -> f32 { + let boundary = matches!( + (previous, next), + ( + FlowScript::Upright, + FlowScript::Rotated { + starts_alphanumeric: true, + .. + } + ) | ( + FlowScript::Rotated { + ends_alphanumeric: true, + .. + }, + FlowScript::Upright + ) + ); + if boundary { + previous_font_size.min(next_font_size) * INTER_SCRIPT_SPACING_EM + } else { + 0.0 + } +} + +/// Offset that centres a local-y band on the column axis after the run is +/// rotated 90°. The draw-time path supplies the glyphs' actual ink bounds; +/// ascent/descent metrics are only the fallback for runs without visible ink. +pub fn rotated_baseline_shift(top: f32, bottom: f32) -> f32 { + -(top + bottom) / 2.0 +} + +fn glyph_run_ink_bounds( + font: &Font, + glyphs: &[GlyphId], + positions: &[SkPoint], +) -> Option { + let mut bounds = vec![skia::Rect::default(); glyphs.len()]; + font.get_bounds(glyphs, &mut bounds, None); + + let mut ink = skia::Rect::new(f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for (bound, position) in bounds.iter().zip(positions) { + if bound.right > bound.left && bound.bottom > bound.top { + ink.left = ink.left.min(bound.left + position.x); + ink.top = ink.top.min(bound.top + position.y); + ink.right = ink.right.max(bound.right + position.x); + ink.bottom = ink.bottom.max(bound.bottom + position.y); + } + } + + (ink.right > ink.left && ink.bottom > ink.top).then_some(ink) +} + +fn rotated_run_baseline_shift(font: &Font, glyphs: &[GlyphId], positions: &[SkPoint]) -> f32 { + if let Some(ink) = glyph_run_ink_bounds(font, glyphs, positions) { + rotated_baseline_shift(ink.top, ink.bottom) + } else { + let (_, metrics) = font.metrics(); + rotated_baseline_shift(metrics.ascent, metrics.descent) + } +} + +/// Normalize ASCII word spaces inside a sideways Western run to JLREQ's +/// preferred one-third em. Shaping retains the source scalar and break +/// opportunity; only its advance and following glyph positions change. +fn normalize_rotated_word_spaces(segment_text: &str, run: &mut ShapedRun, font_size: f32) { + let mut glyph = 0usize; + let mut accumulated_shift = 0.0f32; + while glyph < run.glyphs.len() { + let cluster = run.clusters[glyph]; + let mut count = 1usize; + while glyph + count < run.glyphs.len() && run.clusters[glyph + count] == cluster { + count += 1; + } + for position in &mut run.positions[glyph..glyph + count] { + position.x += accumulated_shift; + } + let is_word_space = segment_text + .get(cluster as usize..) + .and_then(|text| text.chars().next()) + .is_some_and(|character| character == ' '); + if is_word_space { + let natural: f32 = run.advances[glyph..glyph + count].iter().sum(); + let delta = font_size * WESTERN_WORD_SPACING_EM - natural; + run.advances[glyph + count - 1] += delta; + accumulated_shift += delta; + } + glyph += count; + } + run.advance += accumulated_shift; +} + +/// Vertical offset from a cell's top edge to the glyph baseline for an upright +/// cell. Centres the glyph's line box within the em cell (as the Tate-chu-yoko +/// path does) instead of hanging it from the horizontal ascent: CJK faces have +/// an ascent larger than the em, so hanging from it pushes every glyph below +/// its cell and the whole column overflows its bounds. +fn upright_baseline_offset(ascent: f32, descent: f32, font_size: f32) -> f32 { + font_size / 2.0 - (ascent + descent) / 2.0 +} + +fn upright_flow_ink_bounds(run: &ShapedRun, glyph: usize, count: usize) -> Option<(f32, f32)> { + let base_x = run.positions[glyph].x; + let positions: Vec = run.positions[glyph..glyph + count] + .iter() + .map(|position| SkPoint::new(position.x - base_x, position.y)) + .collect(); + let ink = glyph_run_ink_bounds(&run.font, &run.glyphs[glyph..glyph + count], &positions)?; + let (ascent, descent) = upright_centre_metrics(&run.font); + let offset = upright_baseline_offset(ascent, descent, run.font.size()); + Some((ink.top + offset, ink.bottom + offset)) +} + +fn rotated_cluster_flow_ink_bounds( + run: &ShapedRun, + glyph: usize, + count: usize, +) -> Option<(f32, f32)> { + let base_x = run.positions[glyph].x; + let positions: Vec = run.positions[glyph..glyph + count] + .iter() + .map(|position| SkPoint::new(position.x - base_x, position.y)) + .collect(); + let ink = glyph_run_ink_bounds(&run.font, &run.glyphs[glyph..glyph + count], &positions)?; + Some((ink.left, ink.right)) +} + +fn cluster_text_blob(run: &ShapedRun, glyph: usize, count: usize) -> Option { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + let base_x = run.positions[glyph].x; + for i in 0..count { + glyphs[i] = run.glyphs[glyph + i]; + points[i] = SkPoint::new( + run.positions[glyph + i].x - base_x, + run.positions[glyph + i].y, + ); + } + builder.make() +} + +fn rotated_cluster_baseline_shift(run: &ShapedRun, glyph: usize, count: usize) -> f32 { + let base_x = run.positions[glyph].x; + let positions: Vec = run.positions[glyph..glyph + count] + .iter() + .map(|position| SkPoint::new(position.x - base_x, position.y)) + .collect(); + rotated_run_baseline_shift(&run.font, &run.glyphs[glyph..glyph + count], &positions) +} + +fn rotated_flow_ink_bounds(run: &ShapedRun) -> Option<(f32, f32)> { + let ink = glyph_run_ink_bounds(&run.font, &run.glyphs, &run.positions)?; + Some((ink.left, ink.right)) +} + +#[derive(Debug, Clone, Copy)] +pub enum CellKind { + Upright { + run: usize, + glyph: usize, + count: usize, + }, + /// A character that participates in upright Japanese flow and kinsoku, + /// but whose font has no `vert`/`vrt2` alternate. It is rotated per cell + /// instead of becoming a sideways run so wrapping and editor offsets stay + /// character-granular. + SyntheticRotated { + run: usize, + glyph: usize, + count: usize, + }, + Rotated { + run: usize, + }, + TateChuYoko { + /// Composite of one or more shaped runs (fallback fonts each add a + /// run) laid side by side; `[run_start, run_start + run_count)`. + run_start: usize, + run_count: usize, + scale: f32, + }, + Warichu { + /// Two half-size sub-lines stacked side by side within the column: + /// runs `[run_start, run_start + first_count)` are the first (right) + /// sub-line, the rest up to `run_start + run_count` the second (left). + run_start: usize, + run_count: usize, + first_count: usize, + /// UTF-16 length of the first sub-line's text (the split point, + /// relative to the cell's `start`). + first_chars: usize, + }, +} + +/// One placed piece of the vertical flow. Offsets are UTF-16, +/// paragraph-relative (all spans concatenated), in original text space. +pub struct VerticalCell { + pub kind: CellKind, + pub paragraph: usize, + pub span: usize, + pub start: usize, + pub end: usize, + pub column: usize, + pub top: f32, + /// Advance along the column (vertical/flow axis) — from `vmtx` when + /// available, else the shaped horizontal advance. + pub extent: f32, + /// Shaped horizontal glyph advance, used to centre the glyph on the + /// column axis (independent of the vertical flow `extent`). + pub h_advance: f32, + /// Visible glyph-ink edges along the flow axis, relative to `top`. + pub ink_top: f32, + pub ink_bottom: f32, + pub paint: usize, + /// Span font size, for decoration bar geometry. + pub font_size: f32, + /// Span text decoration, painted as vertical bars along the column. + pub decoration: Option, + /// Extra flow-axis (vertical) shift applied to the drawn glyph only, used to + /// pull half-width opening punctuation up into its compressed cell so its + /// ink hugs the preceding character (jlreq leading aki removal). Zero for + /// every other cell; never affects extent, caret, or position-data. + pub glyph_flow_shift: f32, +} + +/// A laid-out column, x measured from the *content* left edge (the content +/// block is anchored to the shape's right edge by consumers). +#[derive(Debug, Clone, Copy)] +pub struct VerticalColumn { + pub x: f32, + pub width: f32, + /// Reserved annotation gutter before the base band (left / `under`). + pub base_offset: f32, + /// Line-height-controlled column advance. Base glyphs centre on this band. + /// Ruby reserves additional column width but attaches to the centred base + /// em, so extra leading does not become base-to-ruby spacing. + pub base_width: f32, +} + +fn column_base_center(column: &VerticalColumn) -> f32 { + column.x + column.base_offset + column.base_width / 2.0 +} + +/// One shaped ruby glyph, retaining its fallback-font run and source range. +#[derive(Debug, Clone, Copy)] +pub struct RubyGlyph { + pub run: usize, + pub glyph: usize, + pub utf16_start: usize, + pub utf16_end: usize, +} + +/// A ruby annotation placed alongside one column of base characters. +/// Painted from `ruby_runs`; kept out of `cells` so base metrics and caret +/// geometry are unaffected. +#[derive(Debug, Clone)] +pub struct RubyCell { + /// Ordered glyphs for this base column. Each retains the shaped run that + /// supplied its font so fallback boundaries do not drop ruby content. + pub glyphs: Vec, + pub paragraph: usize, + pub span: usize, + pub column: usize, + /// Flow-axis (top, extent) of each base character this ruby annotates, + /// in flow order, restricted to the annotated column. Group ruby spreads + /// the annotation over the union; mono ruby maps ruby glyphs onto the + /// individual segments. + pub base_segments: Vec<(f32, f32)>, + /// Final flow-axis positions computed from the explicit ruby mapping. + pub glyph_tops: Vec, + pub font_size: f32, + pub base_font_size: f32, + pub side: RubySide, + pub paint: usize, +} + +struct RubyColumnSegments { + column: usize, + paint: usize, + segments: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct RubyBaseSegment { + top: f32, + extent: f32, +} + +/// One emphasis mark (圏点 / bouten) drawn beside a base character. Kept out of +/// `cells` like ruby, so base metrics, caret and position-data never see it. +/// The mark glyph is the single-glyph `run`; it is centred on the base cell's +/// flow extent and drawn in the column's right-side gutter. +pub struct EmphasisMark { + pub run: usize, + pub column: usize, + /// Flow-axis top and extent of the annotated base cell. + pub top: f32, + pub extent: f32, + pub paint: usize, + pub font_size: f32, + pub outside_offset: f32, +} + +pub struct VerticalLayout { + pub runs: Vec, + pub paints: Vec, + pub cells: Vec, + pub columns: Vec, + /// Shaped ruby annotation runs, indexed by `RubyCell::run`. + pub ruby_runs: Vec, + pub ruby_cells: Vec, + /// Shaped emphasis-mark runs (single glyph each), indexed by + /// `EmphasisMark::run`. + pub emphasis_runs: Vec, + pub emphasis_marks: Vec, + /// Per paragraph: [start, end) range into `columns`. + pub paragraph_columns: Vec<(usize, usize)>, + /// Per paragraph: UTF-16 start offset of each span (paragraph-relative). + pub span_utf16_starts: Vec>, + /// Per paragraph: source UTF-16 start offset of each span. + pub span_source_utf16_starts: Vec>, + /// Per paragraph and span: transformed scalar ownership in source text. + pub span_transforms: Vec>, + /// Per paragraph: UTF-16 offset of every Unicode scalar boundary. Editor + /// positions use indices into this table, while cells and position data + /// keep their browser-facing UTF-16 offsets. + pub paragraph_utf16_boundaries: Vec>, + pub width: f32, + pub height: f32, +} + +impl VerticalLayout { + /// Content origin (top-left of the laid-out block) in the same + /// coordinate space as `bounds`. In vertical-rl, block-start is the + /// right edge, block-center is the horizontal center and block-end is + /// the left edge. + pub fn origin(&self, bounds: &Rect, align: VerticalAlign) -> (f32, f32) { + ( + bounds.left + block_axis_offset(bounds.width(), self.width, align), + bounds.top, + ) + } +} + +/// Horizontal offset of vertical content within its shape. The existing +/// top/center/bottom values describe block-start/center/end; for vertical-rl +/// those positions map to right/center/left respectively. +pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 { + let slack = (container_width - content_width).max(0.0); + match align { + VerticalAlign::Top => slack, + VerticalAlign::Center => slack / 2.0, + VerticalAlign::Bottom => 0.0, + } +} + +/// The column-wrap limit for a vertical text content: auto-width shapes +/// grow to fit (columns never wrap), everything else wraps at the shape +/// height. This is the phase's explicit auto-size decision: auto-height +/// behaves like fixed under vertical writing for now. +pub fn wrap_height(text_content: &TextContent, height: f32) -> f32 { + match text_content.grow_type() { + GrowType::AutoWidth => f32::MAX, + _ => f32::max(height, 1.0), + } +} + +/// Return the proportional item range belonging to a contiguous slice of the +/// base text. This keeps a ruby reading monotonic when its base wraps across +/// columns while ensuring the final column receives any rounding remainder. +fn proportional_range( + item_count: usize, + base_start: usize, + base_count: usize, + total_base_count: usize, +) -> std::ops::Range { + if item_count == 0 || total_base_count == 0 { + return 0..0; + } + let start = base_start.saturating_mul(item_count) / total_base_count; + let end_base = base_start.saturating_add(base_count).min(total_base_count); + let end = if end_base == total_base_count { + item_count + } else { + end_base.saturating_mul(item_count) / total_base_count + }; + start.min(item_count)..end.min(item_count) +} + +/// Expand gaps between already-placed base cells for long ruby annotations. +/// +/// This is deliberately post-placement and bounded: it only shifts later base +/// cells in the same span/column, and only into slack before the next cell (or +/// the column bottom). It avoids re-wrapping columns while making the base span +/// long enough for common long compound-word readings when there is room. +#[derive(Debug, Clone, Copy)] +struct RubyBaseUnit { + span: usize, + start: usize, + end: usize, + ruby_len: usize, + ruby_font_size: f32, + overhang: RubyOverhang, +} + +fn spread_ruby_base_cells( + cells: &mut [VerticalCell], + ruby_units: &[RubyBaseUnit], + max_height: f32, +) { + for unit in ruby_units { + let mut columns: Vec = cells + .iter() + .filter(|cell| cell.span == unit.span && cell.start < unit.end && cell.end > unit.start) + .map(|cell| cell.column) + .collect(); + columns.sort_unstable(); + columns.dedup(); + + let total_base_count = cells + .iter() + .filter(|cell| cell.span == unit.span && cell.start < unit.end && cell.end > unit.start) + .count(); + let mut base_start = 0usize; + + for column in columns { + let mut group: Vec = cells + .iter() + .enumerate() + .filter(|(_, cell)| { + cell.span == unit.span + && cell.column == column + && cell.start < unit.end + && cell.end > unit.start + }) + .map(|(index, _)| index) + .collect(); + if group.len() < 2 { + base_start += group.len(); + continue; + } + group.sort_by(|a, b| { + cells[*a] + .top + .partial_cmp(&cells[*b].top) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let ruby_range = + proportional_range(unit.ruby_len, base_start, group.len(), total_base_count); + base_start += group.len(); + let ruby_line = unit.ruby_font_size * ruby_range.len() as f32; + let first = group[0]; + let last = *group.last().unwrap(); + let base_top = cells[first].top; + let base_bottom = cells[last].top + cells[last].extent; + let base_extent = base_bottom - base_top; + if ruby_line <= base_extent { + continue; + } + + let next_top = cells + .iter() + .enumerate() + .filter(|(index, cell)| { + cell.column == column && !group.contains(index) && cell.top >= base_bottom + }) + .map(|(_, cell)| cell.top) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let column_limit = if max_height.is_finite() && max_height < f32::MAX { + max_height + } else { + f32::MAX + }; + let limit = next_top.unwrap_or(column_limit); + let spread = (ruby_line - base_extent).min((limit - base_bottom).max(0.0)); + if spread <= 0.0 { + continue; + } + + let gap = spread / (group.len() - 1) as f32; + for (position, index) in group.iter().enumerate().skip(1) { + cells[*index].top += gap * position as f32; + } + } + } +} + +/// Split a TCY `digits` span into pieces: maximal ASCII or full-width digit runs of +/// 2..=max characters become upright composites, everything else keeps the +/// normal vertical layout. Returns (piece text, span-relative UTF-16 start, +/// tcy). +fn is_tcy_digit(c: char) -> bool { + c.is_ascii_digit() || ('0'..='9').contains(&c) +} + +fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> { + let mut pieces: Vec<(String, usize, bool)> = Vec::new(); + let mut utf16 = 0usize; + let mut current = String::new(); + let mut current_start = 0usize; + let mut current_digit = false; + for c in text.chars() { + let digit = is_tcy_digit(c); + if current.is_empty() { + current_start = utf16; + current_digit = digit; + } else if digit != current_digit { + pieces.push((std::mem::take(&mut current), current_start, current_digit)); + current_start = utf16; + current_digit = digit; + } + current.push(c); + utf16 += c.len_utf16(); + } + if !current.is_empty() { + pieces.push((current, current_start, current_digit)); + } + for piece in pieces.iter_mut() { + if piece.2 { + let len = piece.0.chars().count(); + piece.2 = (2..=max).contains(&len); + } + } + pieces +} + +/// Compose `piece` (a whole TCY span or a digit run inside one) into one +/// upright composite cell, shaping with the first family that covers the +/// piece and composing any fallback runs side by side. Returns false when +/// the composite would compress below `min_scale`; the caller then falls back +/// to the normal vertical segmentation. +#[allow(clippy::too_many_arguments)] +fn try_push_tcy_composite( + piece: &str, + piece_utf16_start: usize, + min_scale: f32, + span: &TextSpan, + span_utf16_offset: usize, + paragraph_index: usize, + span_index: usize, + paint_index: usize, + families: &[String], + font_provider: &TypefaceFontProvider, + fallback_mgr: &FontMgr, + runs: &mut Vec, + para_cells: &mut Vec, + items: &mut Vec, + scripts: &mut Vec, + trailing_spacings: &mut Vec, +) -> bool { + // Ruby and other span-level formatting can split an otherwise continuous + // vertical run into a one-character span while preserving an inherited + // `text-combine-upright: all`. A single CJK character is already upright; + // routing it through the horizontal TCY path can scale it down to fit the + // font's line metrics and make it smaller than adjacent base characters. + // Keep naturally upright single characters on the normal vertical path. + let mut chars = piece.chars(); + if matches!( + (chars.next(), chars.next()), + (Some(ch), None) if is_upright_char(ch) + ) { + return false; + } + + let letter_spacing = span.letter_spacing; + let probe = piece.chars().next().unwrap_or(' '); + let candidates = || { + families.iter().filter_map(|family| { + font_provider.match_family_style(family, skia::FontStyle::default()) + }) + }; + // Prefer a face that covers the whole run; else one covering the first + // character; else the span's own face. Any glyph still uncovered is + // shaped through the fallback manager, which adds extra runs that + // compose into the same upright cell. + let typeface = candidates() + .find(|tf| piece.chars().all(|c| tf.unichar_to_glyph(c as i32) != 0)) + .or_else(|| candidates().find(|tf| tf.unichar_to_glyph(probe as i32) != 0)) + .or_else(|| { + font_provider.match_family_style(families[0].as_str(), skia::FontStyle::default()) + }); + let Some(typeface) = typeface else { + return false; + }; + let font = Font::new(typeface, span.font_size); + let mut shaped = shape_segment( + piece, + &font, + false, + span.font_features, + fallback_mgr.clone(), + ); + if shaped.is_empty() { + return false; + } + let em = span.font_size.max(1.0); + let mut combined_advance = 0.0f32; + let mut run_height = 1.0f32; + for run in &mut shaped { + if letter_spacing != 0.0 { + for (i, position) in run.positions.iter_mut().enumerate() { + position.x += letter_spacing * i as f32; + } + run.advance += letter_spacing * run.glyphs.len() as f32; + } + let (_, metrics) = run.font.metrics(); + run_height = run_height.max(metrics.descent - metrics.ascent); + combined_advance += run.advance; + } + // Letter-spacing also separates adjacent fallback runs. + if letter_spacing != 0.0 && shaped.len() > 1 { + combined_advance += letter_spacing * (shaped.len() - 1) as f32; + } + let scale = (em / combined_advance.max(1.0)) + .min(em / run_height.max(1.0)) + .min(1.0); + // Width limit: reject runs wider than the active TCY mode permits. The + // generic `all` mode keeps the conservative half-scale limit, while the + // counted digit modes allow the 1/run-length scale needed by full-width + // digits. + if scale < min_scale { + return false; + } + let extent = em + letter_spacing; + let run_start = runs.len(); + let run_count = shaped.len(); + for run in shaped { + runs.push(run); + } + let start = span_utf16_offset + piece_utf16_start; + let end = start + piece.encode_utf16().count(); + para_cells.push(VerticalCell { + kind: CellKind::TateChuYoko { + run_start, + run_count, + scale, + }, + paragraph: paragraph_index, + span: span_index, + start, + end, + column: 0, + top: 0.0, + extent, + h_advance: combined_advance * scale, + ink_top: 0.0, + ink_bottom: em, + paint: paint_index, + font_size: span.font_size, + decoration: span.text_decoration, + glyph_flow_shift: 0.0, + }); + items.push(FlowItem { + extent, + ch: None, + keep_with_previous: false, + }); + scripts.push(FlowScript::Upright); + trailing_spacings.push(letter_spacing); + true +} + +/// Lay out the whole content vertically. Pure of global state: fonts come +/// through the provider/fallback arguments so native tests can supply +/// their own. +pub fn layout_vertical( + text_content: &TextContent, + max_height: f32, + font_provider: &TypefaceFontProvider, + fallback_mgr: FontMgr, + fallback_families: &[String], + bounds: Rect, +) -> VerticalLayout { + let mut runs: Vec = Vec::new(); + let mut paints: Vec = Vec::new(); + let mut cells: Vec = Vec::new(); + let mut columns: Vec = Vec::new(); + let mut paragraph_columns: Vec<(usize, usize)> = Vec::new(); + let mut span_utf16_starts: Vec> = Vec::new(); + let span_transforms: Vec> = text_content + .paragraphs() + .iter() + .map(|paragraph| { + paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform_with_source_ranges) + .collect() + }) + .collect(); + let paragraph_utf16_boundaries: Vec> = span_transforms + .iter() + .map(|paragraph| { + let mut boundaries = vec![0usize]; + for span in paragraph { + for character in span.text.chars() { + let next = boundaries.last().copied().unwrap_or(0) + character.len_utf16(); + boundaries.push(next); + } + } + boundaries + }) + .collect(); + let span_source_utf16_starts: Vec> = text_content + .paragraphs() + .iter() + .map(|paragraph| { + let mut offset = 0usize; + paragraph + .children() + .iter() + .map(|span| { + let start = offset; + offset += span.text.encode_utf16().count(); + start + }) + .collect() + }) + .collect(); + + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + let line_height = if paragraph.line_height() > 0.0 { + paragraph.line_height() + } else { + 1.2 + }; + let max_font_size = paragraph + .children() + .iter() + .map(|s| s.font_size) + .fold(12.0, f32::max); + let column_advance = max_font_size * line_height; + + // Ruby reserves its configured-size gutter on the logical annotation + // side. In vertical-rl, `over` is right and `under` is left. + let ruby_over_gutter = paragraph + .children() + .iter() + .filter(|span| !span.ruby.trim().is_empty() && span.ruby_side == RubySide::Over) + .map(|span| span.font_size * span.ruby_size.scale()) + .fold(0.0, f32::max); + let ruby_under_gutter = paragraph + .children() + .iter() + .filter(|span| !span.ruby.trim().is_empty() && span.ruby_side == RubySide::Under) + .map(|span| span.font_size * span.ruby_size.scale()) + .fold(0.0, f32::max); + // Emphasis occupies the over/right side. Auto-clearance spans carrying + // both annotation types stack there; opposite-side ruby stays separate. + let paragraph_has_emphasis = paragraph + .children() + .iter() + .any(|s| !s.text_emphasis.is_none()); + let emphasis_gutter = if paragraph_has_emphasis { + max_font_size * EMPHASIS_FONT_SCALE + } else { + 0.0 + }; + let has_stacked_auto_annotations = paragraph.children().iter().any(|s| { + s.annotation_clearance.is_auto() + && !s.ruby.trim().is_empty() + && s.ruby_side == RubySide::Over + && !s.text_emphasis.is_none() + }); + let over_gutter = if has_stacked_auto_annotations { + ruby_over_gutter + emphasis_gutter + } else { + ruby_over_gutter.max(emphasis_gutter) + }; + let column_width = ruby_under_gutter + column_advance + over_gutter; + + // Cells of this paragraph, parallel to `items`, placed after + // column planning. + let mut para_cells: Vec = Vec::new(); + let mut items: Vec = Vec::new(); + let mut scripts: Vec = Vec::new(); + let mut trailing_spacings: Vec = Vec::new(); + let mut span_starts: Vec = Vec::new(); + let mut span_utf16_offset = 0usize; + let mut ruby_units: Vec = Vec::new(); + + for (span_index, span) in paragraph.children().iter().enumerate() { + span_starts.push(span_utf16_offset); + let text = span_transforms[paragraph_index][span_index].text.clone(); + if text.is_empty() { + continue; + } + if !span.ruby.trim().is_empty() { + ruby_units.push(RubyBaseUnit { + span: span_index, + start: span_utf16_offset, + end: span_utf16_offset + text.encode_utf16().count(), + ruby_len: span.ruby.trim().chars().count(), + ruby_font_size: span.font_size * span.ruby_size.scale(), + overhang: span.ruby_overhang, + }); + } + paints.push(merge_fills(&span.fills, bounds)); + let paint_index = paints.len() - 1; + + // Candidate families: the span's own font first, then the + // registered fallback fonts (Noto etc.). The typeface font + // provider does no per-character fallback, so pick the first + // family that covers the whole segment (falling back to one that + // covers its first character). + let mut families = vec![format!("{}", span.font_family)]; + families.extend(fallback_families.iter().cloned()); + + let orientation = span.text_orientation; + let letter_spacing = span.letter_spacing; + if span.text_combine_upright == TextCombineUpright::All + && try_push_tcy_composite( + &text, + 0, + MIN_TCY_SCALE, + span, + span_utf16_offset, + paragraph_index, + span_index, + paint_index, + &families, + font_provider, + &fallback_mgr, + &mut runs, + &mut para_cells, + &mut items, + &mut scripts, + &mut trailing_spacings, + ) + { + span_utf16_offset += text.encode_utf16().count(); + continue; + } + // Warichu (割注): the span becomes one composite cell holding two + // half-size sub-lines laid side by side within the column (the + // first sub-line on the right, jlreq reading order). The split is + // balanced by character count with the first line the longer one, + // nudged so the sub-lines respect kinsoku. + if span.warichu && text.chars().count() >= 2 { + let candidates = || { + families.iter().filter_map(|family| { + font_provider.match_family_style(family, skia::FontStyle::default()) + }) + }; + let probe = text.chars().next().unwrap_or(' '); + let typeface = candidates() + .find(|tf| text.chars().all(|c| tf.unichar_to_glyph(c as i32) != 0)) + .or_else(|| candidates().find(|tf| tf.unichar_to_glyph(probe as i32) != 0)) + .or_else(|| { + font_provider + .match_family_style(families[0].as_str(), skia::FontStyle::default()) + }); + if let Some(typeface) = typeface { + let half_size = span.font_size * WARICHU_FONT_SCALE; + let font = Font::new(typeface, half_size); + let split_chars = warichu_split_chars(&text); + let split_utf8 = text + .char_indices() + .nth(split_chars) + .map(|(i, _)| i) + .unwrap_or(text.len()); + let (first_text, second_text) = text.split_at(split_utf8); + let first_chars = first_text.encode_utf16().count(); + let mut first_runs = shape_segment( + first_text, + &font, + true, + span.font_features, + fallback_mgr.clone(), + ); + let mut second_runs = shape_segment( + second_text, + &font, + true, + span.font_features, + fallback_mgr.clone(), + ); + if !first_runs.is_empty() && !second_runs.is_empty() { + let first_extent: f32 = first_runs.iter().map(|r| r.advance).sum(); + let second_extent: f32 = second_runs.iter().map(|r| r.advance).sum(); + let extent = first_extent.max(second_extent) + letter_spacing; + let run_start = runs.len(); + let first_count = first_runs.len(); + let run_count = first_count + second_runs.len(); + runs.append(&mut first_runs); + runs.append(&mut second_runs); + let end = span_utf16_offset + text.encode_utf16().count(); + para_cells.push(VerticalCell { + kind: CellKind::Warichu { + run_start, + run_count, + first_count, + first_chars, + }, + paragraph: paragraph_index, + span: span_index, + start: span_utf16_offset, + end, + column: 0, + top: 0.0, + extent, + // Two half-em sub-columns side by side fill the em. + h_advance: span.font_size, + ink_top: 0.0, + ink_bottom: extent - letter_spacing, + paint: paint_index, + font_size: span.font_size, + decoration: span.text_decoration, + glyph_flow_shift: 0.0, + }); + items.push(FlowItem { + extent, + ch: None, + keep_with_previous: false, + }); + scripts.push(FlowScript::Upright); + trailing_spacings.push(letter_spacing); + span_utf16_offset += text.encode_utf16().count(); + continue; + } + } + } + // `digits` combines runs of 2..=max consecutive ASCII or full-width digits + // (max 4, or 2/3 for the counted variants) into one upright + // composite; the rest of the span (and every other span) flows + // through the normal orientation segmentation. + let pieces: Vec<(String, usize, bool)> = + if let Some(max) = span.text_combine_upright.digits_max() { + split_digit_runs(&text, max) + } else { + vec![(text.clone(), 0, false)] + }; + for (piece_text, piece_utf16_start, piece_tcy) in &pieces { + let piece_base = span_utf16_offset + piece_utf16_start; + let min_tcy_scale = 1.0 / piece_text.chars().count().max(1) as f32; + if *piece_tcy + && try_push_tcy_composite( + piece_text, + *piece_utf16_start, + min_tcy_scale, + span, + span_utf16_offset, + paragraph_index, + span_index, + paint_index, + &families, + font_provider, + &fallback_mgr, + &mut runs, + &mut para_cells, + &mut items, + &mut scripts, + &mut trailing_spacings, + ) + { + continue; + } + for segment in segment_by_orientation(piece_text, orientation) { + let probe = segment.text.chars().next().unwrap_or(' '); + let typeface = families + .iter() + .filter_map(|family| { + font_provider.match_family_style(family, skia::FontStyle::default()) + }) + .find(|tf| tf.unichar_to_glyph(probe as i32) != 0) + .or_else(|| { + font_provider.match_family_style( + families[0].as_str(), + skia::FontStyle::default(), + ) + }); + let Some(typeface) = typeface else { + continue; + }; + let font = Font::new(typeface, span.font_size); + let shaped = shape_segment( + &segment.text, + &font, + segment.upright, + span.font_features, + fallback_mgr.clone(), + ); + + // Map UTF-8 offsets in the segment text to UTF-16 offsets + // in the span text. + let utf8_to_utf16 = |utf8: usize| -> usize { + segment.utf16_start + + segment.text[..utf8.min(segment.text.len())] + .chars() + .map(char::len_utf16) + .sum::() + }; + + for mut run in shaped { + let run_index = runs.len(); + let seg_utf16_end = utf8_to_utf16(run.utf8_range.end); + if segment.upright { + let vmetrics = vertical_metrics(&run.font); + // vpal deltas apply here in layout, not in the + // shaper: SkShaper shapes on a horizontal line, + // where vertical GPOS positioning never fires. + let vpal = (span.font_features == FontFeatures::Vpal) + .then(|| vpal_table(&run.font)) + .flatten(); + // Group glyphs by cluster so combining sequences + // stay in one cell. + let mut glyph = 0usize; + while glyph < run.glyphs.len() { + let cluster = run.clusters[glyph]; + let mut count = 1; + while glyph + count < run.glyphs.len() + && run.clusters[glyph + count] == cluster + { + count += 1; + } + let next_cluster_utf8 = run + .clusters + .get(glyph + count) + .map(|c| *c as usize) + .unwrap_or(run.utf8_range.end); + let h_advance: f32 = + run.advances[glyph..glyph + count].iter().sum(); + let h_advance = if h_advance > 0.0 { + h_advance + } else { + span.font_size + }; + // Flow down the column by the true vertical + // advance when the font has `vmtx`, else the + // horizontal advance (exact for full-width CJK). + // Letter-spacing widens the gap after each cluster. + let extent = vmetrics + .as_ref() + .map(|vm| { + run.glyphs[glyph..glyph + count] + .iter() + .map(|g| vm.advance(*g, span.font_size)) + .sum::() + }) + .filter(|v| *v > 0.0) + .unwrap_or(h_advance); + // vpal: the font's advance delta tightens the + // cell and its placement delta lifts the drawn + // ink to keep it inside. The tightened extent + // flows into caret, position-data and the aki + // sheds (which skip already-half-width cells). + let vpal_delta = vpal.as_ref().and_then(|table| { + table.cluster_delta( + &run.glyphs[glyph..glyph + count], + span.font_size, + ) + }); + let extent = extent + + vpal_delta.map(|(advance, _)| advance).unwrap_or(0.0) + + letter_spacing; + let vpal_flow_shift = + vpal_delta.map(|(_, shift)| shift).unwrap_or(0.0); + let start = piece_base + utf8_to_utf16(cluster as usize); + let end = piece_base + utf8_to_utf16(next_cluster_utf8); + let ch = segment.text[(cluster as usize)..].chars().next(); + let synthetic_rotation = + cluster_needs_rotated_vertical_fallback(&run, glyph, count, ch); + let (mut ink_top, mut ink_bottom) = if synthetic_rotation { + rotated_cluster_flow_ink_bounds(&run, glyph, count) + } else { + upright_flow_ink_bounds(&run, glyph, count) + } + .unwrap_or((0.0, extent - letter_spacing)); + ink_top += vpal_flow_shift; + ink_bottom += vpal_flow_shift; + // Centre middle-dot / colon / semicolon ink in the + // em body; the shift moves the drawn glyph only. + // A vpal-covered glyph keeps the font's own + // centring instead. + let glyph_flow_shift = if !synthetic_rotation + && vpal_delta.is_none() + && ch.is_some_and(is_centered_punctuation) + { + let shift = centered_flow_shift( + ink_top, + ink_bottom, + extent - letter_spacing, + ); + ink_top += shift; + ink_bottom += shift; + shift + } else { + vpal_flow_shift + }; + para_cells.push(VerticalCell { + kind: if synthetic_rotation { + CellKind::SyntheticRotated { + run: run_index, + glyph, + count, + } + } else { + CellKind::Upright { + run: run_index, + glyph, + count, + } + }, + paragraph: paragraph_index, + span: span_index, + start, + end, + column: 0, + top: 0.0, + extent, + h_advance, + ink_top, + ink_bottom, + paint: paint_index, + font_size: span.font_size, + decoration: span.text_decoration, + glyph_flow_shift, + }); + items.push(FlowItem { + extent, + ch, + keep_with_previous: false, + }); + scripts.push(FlowScript::Upright); + trailing_spacings.push(letter_spacing); + glyph += count; + } + } else { + normalize_rotated_word_spaces(&segment.text, &mut run, span.font_size); + // Spread the sideways glyphs down the column by + // letter-spacing (post-rotation +x runs down the + // column), matching the per-glyph horizontal spacing. + let extent = if letter_spacing != 0.0 { + for (i, position) in run.positions.iter_mut().enumerate() { + position.x += letter_spacing * i as f32; + } + run.advance + letter_spacing * run.glyphs.len() as f32 + } else { + run.advance + }; + let run_text = &segment.text[run.utf8_range.clone()]; + let starts_alphanumeric = + run_text.chars().next().is_some_and(char::is_alphanumeric); + let ends_alphanumeric = run_text + .chars() + .next_back() + .is_some_and(char::is_alphanumeric); + let start = piece_base + utf8_to_utf16(run.utf8_range.start); + let (ink_top, ink_bottom) = rotated_flow_ink_bounds(&run) + .unwrap_or((0.0, extent - letter_spacing)); + para_cells.push(VerticalCell { + kind: CellKind::Rotated { run: run_index }, + paragraph: paragraph_index, + span: span_index, + start, + end: piece_base + seg_utf16_end, + column: 0, + top: 0.0, + extent, + // Rotated runs draw from `run.positions`; the + // centring path doesn't read `h_advance`. + h_advance: run.advance, + ink_top, + ink_bottom, + paint: paint_index, + font_size: span.font_size, + decoration: span.text_decoration, + glyph_flow_shift: 0.0, + }); + items.push(FlowItem { + extent, + ch: None, + keep_with_previous: false, + }); + scripts.push(FlowScript::Rotated { + starts_alphanumeric, + ends_alphanumeric, + }); + trailing_spacings.push(letter_spacing); + } + runs.push(run); + } + } + } + span_utf16_offset += text.encode_utf16().count(); + } + + // A CSS transform may expand one source character into several shaped + // cells. Keep those cells in one column so a source slice is rendered + // exactly once by the SVG fallback (for example `ß` -> `SS`). + for index in 1..para_cells.len() { + let previous = ¶_cells[index - 1]; + let current = ¶_cells[index]; + if previous.span != current.span { + continue; + } + let transform = &span_transforms[paragraph_index][current.span]; + let transformed_span_start = span_starts[current.span]; + let previous_source = transform.source_utf16_range( + previous.start - transformed_span_start..previous.end - transformed_span_start, + ); + let current_source = transform.source_utf16_range( + current.start - transformed_span_start..current.end - transformed_span_start, + ); + items[index].keep_with_previous = previous_source == current_source; + } + + let ruby_classes: Vec> = paragraph + .children() + .iter() + .map(|span| (!span.ruby.trim().is_empty()).then_some(JapaneseClass::SimpleRuby)) + .collect(); + // Adjust the preceding cell's flow advance so the *visible ink* edges + // have the target gap. Logical advances alone are asymmetric around + // mixed fonts/glyphs (e.g. `うpenあ`) because their side bearings differ. + // Preserve the preceding cell's explicit trailing letter-spacing. + for i in 1..para_cells.len() { + let target_gap = inter_script_spacing( + scripts[i - 1], + para_cells[i - 1].font_size, + scripts[i], + para_cells[i].font_size, + ); + if target_gap > 0.0 { + let previous = ¶_cells[i - 1]; + let natural_gap_without_letter_spacing = previous.extent - trailing_spacings[i - 1] + + para_cells[i].ink_top + - previous.ink_bottom; + let correction = target_gap - natural_gap_without_letter_spacing; + para_cells[i - 1].extent = (previous.extent + correction).max(0.0); + items[i - 1].extent = para_cells[i - 1].extent; + } + } + + // JLREQ punctuation and cl-30 adjacency. Full-width fonts bake the half-em aki + // into punctuation advances. Keep that preferred aki in ordinary text, + // but remove one half-em at the internal boundaries defined by §3.1.4: + // closing punctuation sequences set solid, closing→opening retains a + // single half-em, opening sequences set solid after the first bracket, + // and middle-dot adjacency retains its own quarter-em side spacing. + let flow_classes: Vec> = para_cells + .iter() + .zip(&items) + .map(|(cell, item)| ruby_classes[cell.span].or_else(|| flow_class(cell, item))) + .collect(); + for i in 0..para_cells.len() { + let Some(ch) = items[i].ch else { + continue; + }; + let closing = sheds_trailing_aki(ch); + let opening = sheds_leading_aki(ch); + if !closing && !opening { + continue; + } + let previous_class = i.checked_sub(1).and_then(|index| flow_classes[index]); + let next_class = flow_classes.get(i + 1).copied().flatten(); + let shed = if closing { + next_class.is_some_and(|class| { + let natural = 0.5 + embedded_leading_aki(class); + let preferred = pair_rule(classify(ch), class).preferred_em; + natural > preferred + f32::EPSILON + }) + } else { + previous_class.is_some_and(|class| { + // A preceding trailing-aki mark owns the reduction for + // closing→opening, so never remove both halves. + !class.is_trailing_aki_punctuation() + && embedded_trailing_aki(class) + 0.5 + > pair_rule(class, classify(ch)).preferred_em + f32::EPSILON + }) + }; + if !shed { + continue; + } + let target = 0.5 * para_cells[i].font_size + trailing_spacings[i]; + if target >= para_cells[i].extent { + continue; + } + // Opening punctuation keeps its ink in the trailing half of the em, + // so pulling it up by the shed amount lands the glyph inside the + // compressed cell and hugs the preceding character. Closing + // punctuation already sits in the leading half; no shift is needed. + if opening { + para_cells[i].glyph_flow_shift += -(para_cells[i].extent - target); + } + para_cells[i].extent = target; + items[i].extent = target; + } + + // Long ruby with no slack: grow the base span's flow extent *before* + // column planning so the wrap itself makes room (forced spreading). + // The growth becomes inter-character gaps, so only the cells before + // the last one grow. Single-character bases keep the capped-overhang + // behaviour of the post-placement pass. + for unit in &ruby_units { + let indices: Vec = para_cells + .iter() + .enumerate() + .filter(|(_, cell)| { + cell.span == unit.span && cell.start < unit.end && cell.end > unit.start + }) + .map(|(index, _)| index) + .collect(); + if indices.is_empty() { + continue; + } + let ruby_line = unit.ruby_font_size * unit.ruby_len as f32; + let base_total: f32 = indices.iter().map(|index| para_cells[*index].extent).sum(); + let deficit = ruby_line - base_total; + if deficit <= 0.0 { + continue; + } + if indices.len() == 1 { + if unit.overhang == RubyOverhang::None { + para_cells[indices[0]].extent += deficit; + items[indices[0]].extent += deficit; + } + continue; + } + let gap = deficit / (indices.len() - 1) as f32; + for index in &indices[..indices.len() - 1] { + para_cells[*index].extent += gap; + items[*index].extent += gap; + } + } + + let mut pair_spacing_em: Vec = flow_classes + .windows(2) + .map(|pair| match (pair[0], pair[1]) { + (Some(before), Some(after)) => pair_rule(before, after).preferred_em, + _ => 0.0, + }) + .collect(); + apply_ordered_oikomi( + &mut para_cells, + &mut items, + &flow_classes, + &mut pair_spacing_em, + max_height, + ); + + // Wrapped opening brackets use the JIS X 4051 tentsuki policy: discard + // their leading half-em at a column head. Re-plan after each newly + // trimmed bracket because the shorter line may pull one more cell into + // the preceding column. The loop is bounded and each iteration can + // only shrink a previously untrimmed item. + let mut placements = plan_columns(&items, max_height); + for _ in 0..para_cells.len() { + let mut changed = false; + for i in 0..para_cells.len() { + if placements[i].1 != 0.0 || !items[i].ch.is_some_and(sheds_leading_aki) { + continue; + } + let target = 0.5 * para_cells[i].font_size + trailing_spacings[i]; + if target >= para_cells[i].extent { + continue; + } + para_cells[i].glyph_flow_shift += -(para_cells[i].extent - target); + para_cells[i].extent = target; + items[i].extent = target; + changed = true; + } + if !changed { + break; + } + placements = plan_columns(&items, max_height); + } + let columns_used = placements.last().map(|(c, _)| c + 1).unwrap_or(1); + + let column_base = columns.len(); + for _ in 0..columns_used { + columns.push(VerticalColumn { + x: 0.0, + width: column_width, + base_offset: ruby_under_gutter, + base_width: column_advance, + }); + } + paragraph_columns.push((column_base, column_base + columns_used)); + + // Align each column's cells along the vertical (inline) axis per the + // paragraph's text-align. The wrap height `max_height` is the budget; + // a column's used length is its farthest cell bottom. + let text_align = paragraph.text_align(); + let mut column_used = vec![0.0f32; columns_used]; + for (item, (column, top)) in items.iter().zip(placements.iter()) { + column_used[*column] = column_used[*column].max(top + item.extent); + } + let column_offset: Vec = column_used + .iter() + .map(|used| align_offset_along_column(text_align, max_height, *used)) + .collect(); + + // Justify stretches every column but the last (the last "line") to + // fill the wrap budget; a snug auto-width budget has no slack. + let justify = matches!(text_align, TextAlign::Justify) + && max_height.is_finite() + && max_height < f32::MAX + && max_height > 0.0; + let last_column = columns_used.saturating_sub(1); + let expansion_offsets = if justify { + ordered_expansion_offsets( + ¶_cells, + &flow_classes, + &pair_spacing_em, + &placements, + &column_used, + max_height, + last_column, + ) + } else { + vec![0.0; para_cells.len()] + }; + let paragraph_cell_start = cells.len(); + + for (index, (mut cell, (column, top))) in para_cells.into_iter().zip(placements).enumerate() + { + cell.column = column_base + column; + cell.top = top + column_offset[column] + expansion_offsets[index]; + cells.push(cell); + } + spread_ruby_base_cells(&mut cells[paragraph_cell_start..], &ruby_units, max_height); + + span_utf16_starts.push(span_starts); + } + + // Columns advance right->left: column 0 is the rightmost. + let width: f32 = columns.iter().map(|c| c.width).sum(); + let mut right = width; + for column in columns.iter_mut() { + right -= column.width; + column.x = right; + } + + // Ruby (furigana) post-pass. Runs after column placement because a ruby + // annotation's strip is positioned from its base characters' final column + // and flow extent. + let mut ruby_runs: Vec = Vec::new(); + let mut ruby_cells: Vec = Vec::new(); + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + for (span_index, span) in paragraph.children().iter().enumerate() { + let ruby_text = span.ruby.trim(); + if ruby_text.is_empty() { + continue; + } + // Base characters of this span grouped by column, in flow (column + // index) order; segments within a column sorted along the flow. + let mut span_columns: Vec = Vec::new(); + for cell in &cells { + if cell.paragraph != paragraph_index || cell.span != span_index { + continue; + } + match span_columns + .iter_mut() + .find(|data| data.column == cell.column) + { + Some(data) => data.segments.push(RubyBaseSegment { + top: cell.top, + extent: cell.extent, + }), + None => span_columns.push(RubyColumnSegments { + column: cell.column, + paint: cell.paint, + segments: vec![RubyBaseSegment { + top: cell.top, + extent: cell.extent, + }], + }), + } + } + if span_columns.is_empty() { + continue; + } + span_columns.sort_by_key(|data| data.column); + for data in span_columns.iter_mut() { + data.segments.sort_by(|a, b| { + a.top + .partial_cmp(&b.top) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + + let ruby_font_size = span.font_size * span.ruby_size.scale(); + let probe = ruby_text.chars().next().unwrap_or(' '); + let mut families = vec![format!("{}", span.font_family)]; + families.extend(fallback_families.iter().cloned()); + let typeface = families + .iter() + .filter_map(|family| { + font_provider.match_family_style(family, skia::FontStyle::default()) + }) + .find(|tf| tf.unichar_to_glyph(probe as i32) != 0) + .or_else(|| { + font_provider + .match_family_style(families[0].as_str(), skia::FontStyle::default()) + }); + let Some(typeface) = typeface else { + continue; + }; + let font = Font::new(typeface, ruby_font_size); + let shaped = shape_segment( + ruby_text, + &font, + true, + span.font_features, + fallback_mgr.clone(), + ); + if shaped.is_empty() { + continue; + } + let trim_utf16 = span.ruby[..span.ruby.len() - span.ruby.trim_start().len()] + .encode_utf16() + .count(); + let run_start = ruby_runs.len(); + let mut glyphs: Vec = shaped + .iter() + .enumerate() + .flat_map(|(run_index, run)| { + run.clusters + .iter() + .enumerate() + .map(move |(glyph, cluster)| { + let utf8 = (*cluster as usize).min(ruby_text.len()); + RubyGlyph { + run: run_start + run_index, + glyph, + utf16_start: trim_utf16 + ruby_text[..utf8].encode_utf16().count(), + utf16_end: 0, + } + }) + }) + .collect(); + if glyphs.is_empty() { + continue; + } + let ruby_utf16_end = trim_utf16 + ruby_text.encode_utf16().count(); + for index in 0..glyphs.len() { + glyphs[index].utf16_end = glyphs + .get(index + 1) + .map(|next| next.utf16_start) + .unwrap_or(ruby_utf16_end); + } + ruby_runs.extend(shaped); + + let total_base_count: usize = span_columns.iter().map(|data| data.segments.len()).sum(); + let mut base_start = 0usize; + for data in &span_columns { + let base_segments: Vec<(f32, f32)> = data + .segments + .iter() + .map(|segment| (segment.top, segment.extent)) + .collect(); + if base_segments.is_empty() { + continue; + } + let glyph_range = proportional_range( + glyphs.len(), + base_start, + base_segments.len(), + total_base_count, + ); + base_start += base_segments.len(); + let column_glyphs = glyphs[glyph_range].to_vec(); + if column_glyphs.is_empty() { + continue; + } + let top = base_segments + .first() + .map(|segment| segment.0) + .unwrap_or(0.0); + let bottom = base_segments + .last() + .map(|segment| segment.0 + segment.1) + .unwrap_or(top); + let glyph_tops = distribute_ruby_tops( + top, + (bottom - top).max(0.0), + column_glyphs.len(), + ruby_font_size, + span.ruby_align, + span.ruby_overhang, + ); + ruby_cells.push(RubyCell { + glyphs: column_glyphs, + paragraph: paragraph_index, + span: span_index, + column: data.column, + base_segments, + glyph_tops, + font_size: ruby_font_size, + base_font_size: span.font_size, + side: span.ruby_side, + paint: data.paint, + }); + } + } + } + + // Emphasis marks (圏点 / bouten): one mark glyph per base cell of every + // span that carries `text_emphasis`. The mark is shaped once per span and + // drawn beside each Upright base cell in the right-side gutter. Like ruby, + // marks stay out of `cells` so base metrics and the editor are untouched. + // Whitespace cells get no mark (CSS `text-emphasis` behaviour). + let mut emphasis_runs: Vec = Vec::new(); + let mut emphasis_marks: Vec = Vec::new(); + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + for (span_index, span) in paragraph.children().iter().enumerate() { + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let mark_font_size = span.font_size * EMPHASIS_FONT_SCALE; + let mut families = vec![format!("{}", span.font_family)]; + families.extend(fallback_families.iter().cloned()); + let typeface = families + .iter() + .filter_map(|family| { + font_provider.match_family_style(family, skia::FontStyle::default()) + }) + .find(|tf| tf.unichar_to_glyph(mark as i32) != 0) + .or_else(|| { + font_provider + .match_family_style(families[0].as_str(), skia::FontStyle::default()) + }); + let Some(typeface) = typeface else { + continue; + }; + let font = Font::new(typeface, mark_font_size); + let mut shaped = shape_segment( + &mark.to_string(), + &font, + true, + FontFeatures::None, + fallback_mgr.clone(), + ); + if shaped.is_empty() || shaped[0].glyphs.is_empty() { + continue; + } + let run_index = emphasis_runs.len(); + emphasis_runs.push(shaped.remove(0)); + let span_start = span_utf16_starts[paragraph_index][span_index]; + for cell in &cells { + if cell.paragraph != paragraph_index + || cell.span != span_index + || !matches!( + cell.kind, + CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } + ) + { + continue; + } + if !utf16_range_allows_emphasis( + &span.text, + cell.start - span_start, + cell.end - span_start, + ) { + continue; + } + emphasis_marks.push(EmphasisMark { + run: run_index, + column: cell.column, + top: cell.top, + extent: cell.extent, + paint: cell.paint, + font_size: mark_font_size, + outside_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 height = cells + .iter() + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max); + + VerticalLayout { + runs, + paints, + cells, + columns, + ruby_runs, + ruby_cells, + emphasis_runs, + emphasis_marks, + paragraph_columns, + span_utf16_starts, + span_source_utf16_starts, + span_transforms, + paragraph_utf16_boundaries, + width, + height, + } +} + +/// Emphasis-mark (圏点) font size relative to the base em. +const EMPHASIS_FONT_SCALE: f32 = super::text::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; + +/// Char index where a warichu run splits into its two sub-lines: the +/// balanced midpoint (first line longer), nudged so the second sub-line +/// does not start with a line-start-prohibited character and the first +/// does not end with a line-end-prohibited one. Nudging forward pulls the +/// offending mark up into the first sub-line (jlreq); backward is the +/// fallback, and the midpoint stands when no split satisfies kinsoku. +pub(crate) fn warichu_split_chars(text: &str) -> usize { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mid = n.div_ceil(2); + let valid = |split: usize| { + split >= 1 + && split < n + && !forbidden_at_line_start(chars[split]) + && !forbidden_at_line_end(chars[split - 1]) + }; + if valid(mid) { + return mid; + } + for distance in 1..n { + if valid(mid + distance) { + return mid + distance; + } + if mid > distance && valid(mid - distance) { + return mid - distance; + } + } + mid +} + +/// True when the UTF-16 range contains an emphasis-eligible character. +fn utf16_range_allows_emphasis(text: &str, start: usize, end: usize) -> bool { + let mut offset = 0; + for c in text.chars() { + if offset >= end { + break; + } + let len = c.len_utf16(); + if offset + len > start && super::text::emphasis_char_allowed(c) { + return true; + } + offset += len; + } + false +} + +/// Minimum scale for unconstrained `all` tate-chu-yoko. Counted digit modes +/// derive their limit from the eligible run length instead. +const MIN_TCY_SCALE: f32 = 0.5; + +/// Distribute `count` ruby glyphs of the given `advance` along a single base +/// segment `[seg_top, seg_top + seg_extent)`, returning each glyph's flow-axis +/// top. Two jlreq regimes: +/// +/// - Ruby no longer than the base (`Lr <= seg_extent`): even distribution +/// (均等割り付け). Each glyph gets an equal slot `seg_extent / count` and is +/// centred in its slot, which yields equal inter-glyph gaps and half gaps at +/// both ends. +/// - Ruby longer than the base (`Lr > seg_extent`): the glyphs are packed at +/// their own advance and the block is centred on the base, overhanging both +/// ends symmetrically (オーバーハング). The per-end overhang is capped at one +/// ruby em so a long annotation cannot swallow its neighbours' cells. +fn distribute_ruby_tops( + seg_top: f32, + seg_extent: f32, + count: usize, + advance: f32, + align: RubyAlign, + overhang_policy: RubyOverhang, +) -> Vec { + if count == 0 { + return Vec::new(); + } + let line = advance * count as f32; + if line <= seg_extent { + match align { + RubyAlign::SpaceAround => { + let slot = seg_extent / count as f32; + (0..count) + .map(|i| seg_top + slot * (i as f32 + 0.5) - advance / 2.0) + .collect() + } + RubyAlign::Center => { + let start = seg_top + (seg_extent - line) / 2.0; + (0..count).map(|i| start + advance * i as f32).collect() + } + RubyAlign::Start => (0..count).map(|i| seg_top + advance * i as f32).collect(), + RubyAlign::SpaceBetween if count > 1 => { + let gap = (seg_extent - line) / (count - 1) as f32; + (0..count) + .map(|i| seg_top + (advance + gap) * i as f32) + .collect() + } + RubyAlign::SpaceBetween => vec![seg_top + (seg_extent - advance) / 2.0], + } + } else { + let overflow = line - seg_extent; + let overhang = if overhang_policy == RubyOverhang::Auto { + (overflow / 2.0).min(advance) + } else { + 0.0 + }; + let start = seg_top - overhang; + (0..count).map(|i| start + advance * i as f32).collect() + } +} + +/// Production entry point: lay out with the render state's font store. +pub fn layout_from_content(text_content: &TextContent, max_height: f32) -> VerticalLayout { + let font_provider = get_render_state().fonts().font_provider(); + let fallback_mgr = FontMgr::from(font_provider.clone()); + let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); + layout_vertical( + text_content, + max_height, + font_provider, + fallback_mgr, + &fallback_families, + text_content.bounds(), + ) +} + +/// Content size (width, height) of a vertical layout for auto-sizing. +pub fn measure_content(text_content: &TextContent, max_height: f32) -> (f32, f32) { + let layout = layout_from_content(text_content, max_height); + (layout.width, layout.height) +} + +/// Paint the text content vertically inside its bounds. Returns false +/// when the content is not vertical, so the caller falls back to the +/// horizontal path. +pub fn paint_text_vertical( + canvas: &Canvas, + text_content: &TextContent, + vertical_align: VerticalAlign, +) -> bool { + if !text_content.is_vertical() { + return false; + } + + let bounds = text_content.bounds(); + let max_height = wrap_height(text_content, bounds.height()); + let layout = layout_from_content(text_content, max_height); + paint_layout(canvas, &layout, &bounds, vertical_align); + true +} + +fn text_blob_path(mut blob: TextBlob, offset: impl Into) -> skia::Path { + // SkParagraph normalizes extracted glyph outlines against the blob's ink + // bounds. Restore that origin before applying the draw offset so the path + // occupies the same document coordinates as Canvas::draw_text_blob. + let bounds = *blob.bounds(); + let offset = offset.into(); + SkiaParagraph::get_path(&mut blob).with_offset((offset.x + bounds.left, offset.y + bounds.top)) +} + +fn push_text_path( + paths: &mut Vec<(skia::Path, Paint)>, + path: skia::Path, + paint: &Paint, + antialias: bool, +) { + if path.is_empty() { + return; + } + let mut paint = paint.clone(); + paint.set_anti_alias(antialias); + paths.push((path, paint)); +} + +fn append_warichu_line_paths( + paths: &mut Vec<(skia::Path, Paint)>, + runs: &[ShapedRun], + x_center: f32, + y_top: f32, + paint: &Paint, + antialias: bool, +) { + let mut cursor = y_top; + for run in runs { + let font_size = run.font.size(); + let (ascent, descent) = upright_centre_metrics(&run.font); + let baseline_offset = upright_baseline_offset(ascent, descent, font_size); + let mut glyph = 0usize; + while glyph < run.glyphs.len() { + let cluster = run.clusters[glyph]; + let mut count = 1; + while glyph + count < run.glyphs.len() && run.clusters[glyph + count] == cluster { + count += 1; + } + let advance: f32 = run.advances[glyph..glyph + count].iter().sum(); + let advance = if advance > 0.0 { advance } else { font_size }; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + let base_x = run.positions[glyph].x; + for i in 0..count { + glyphs[i] = run.glyphs[glyph + i]; + points[i] = SkPoint::new( + run.positions[glyph + i].x - base_x, + run.positions[glyph + i].y, + ); + } + if let Some(blob) = builder.make() { + let path = + text_blob_path(blob, (x_center - advance / 2.0, cursor + baseline_offset)); + push_text_path(paths, path, paint, antialias); + } + cursor += advance; + glyph += count; + } + } +} + +fn append_cell_paths( + paths: &mut Vec<(skia::Path, Paint)>, + layout: &VerticalLayout, + cell: &VerticalCell, + origin_x: f32, + origin_y: f32, + antialias: bool, +) { + let column = &layout.columns[cell.column]; + let x_center = origin_x + column_base_center(column); + let y_top = origin_y + cell.top; + let paint = &layout.paints[cell.paint]; + match cell.kind { + CellKind::Upright { run, glyph, count } => { + let run = &layout.runs[run]; + let (ascent, descent) = upright_centre_metrics(&run.font); + let baseline = y_top + + cell.glyph_flow_shift + + upright_baseline_offset(ascent, descent, cell.font_size); + let x = x_center - cell.h_advance / 2.0; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + let base_x = run.positions[glyph].x; + for i in 0..count { + glyphs[i] = run.glyphs[glyph + i]; + points[i] = SkPoint::new( + run.positions[glyph + i].x - base_x, + run.positions[glyph + i].y, + ); + } + if let Some(blob) = builder.make() { + push_text_path(paths, text_blob_path(blob, (x, baseline)), paint, antialias); + } + } + CellKind::SyntheticRotated { run, glyph, count } => { + let run = &layout.runs[run]; + if let Some(blob) = cluster_text_blob(run, glyph, count) { + let path = text_blob_path( + blob, + (0.0, rotated_cluster_baseline_shift(run, glyph, count)), + ) + .make_transform(&skia::Matrix::rotate_deg(90.0)) + .with_offset((x_center, y_top + cell.glyph_flow_shift)); + push_text_path(paths, path, paint, antialias); + } + } + CellKind::Rotated { run } => { + let run = &layout.runs[run]; + let mut builder = TextBlobBuilder::new(); + let count = run.glyphs.len(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + glyphs.copy_from_slice(&run.glyphs); + points.copy_from_slice(&run.positions); + if let Some(blob) = builder.make() { + let path = text_blob_path(blob, (0.0, run.rotated_baseline_shift)) + .make_transform(&skia::Matrix::rotate_deg(90.0)) + .with_offset((x_center, y_top)); + push_text_path(paths, path, paint, antialias); + } + } + CellKind::TateChuYoko { + run_start, + run_count, + scale, + } => { + let composite = &layout.runs[run_start..run_start + run_count]; + let combined_advance: f32 = composite.iter().map(|run| run.advance).sum(); + let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| { + let (_, metrics) = run.font.metrics(); + (a.min(metrics.ascent), d.max(metrics.descent)) + }); + let x0 = x_center - (combined_advance * scale) / 2.0; + let baseline = y_top + cell.font_size / 2.0 - ((ascent + descent) * scale) / 2.0; + let mut cursor = 0.0f32; + for run in composite { + let mut builder = TextBlobBuilder::new(); + let count = run.glyphs.len(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + glyphs.copy_from_slice(&run.glyphs); + points.copy_from_slice(&run.positions); + if let Some(blob) = builder.make() { + let path = text_blob_path(blob, (cursor, 0.0)) + .make_transform(&skia::Matrix::scale((scale, scale))) + .with_offset((x0, baseline)); + push_text_path(paths, path, paint, antialias); + } + cursor += run.advance; + } + } + CellKind::Warichu { + run_start, + run_count, + first_count, + .. + } => { + let all = &layout.runs[run_start..run_start + run_count]; + let (first, second) = all.split_at(first_count); + let quarter = cell.font_size / 4.0; + append_warichu_line_paths(paths, first, x_center + quarter, y_top, paint, antialias); + append_warichu_line_paths(paths, second, x_center - quarter, y_top, paint, antialias); + } + } + + if let Some(decoration) = cell.decoration { + if decoration.contains(TextDecoration::UNDERLINE) { + push_text_path( + paths, + skia::Path::rect( + decoration_bar(layout, cell, origin_x, origin_y, false), + None, + ), + paint, + antialias, + ); + } + if decoration.contains(TextDecoration::LINE_THROUGH) { + push_text_path( + paths, + skia::Path::rect(decoration_bar(layout, cell, origin_x, origin_y, true), None), + paint, + antialias, + ); + } + } +} + +fn append_ruby_paths( + paths: &mut Vec<(skia::Path, Paint)>, + layout: &VerticalLayout, + ruby: &RubyCell, + origin_x: f32, + origin_y: f32, + antialias: bool, +) { + let column = &layout.columns[ruby.column]; + let paint = &layout.paints[ruby.paint]; + let gutter_center = origin_x + + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) + + ruby.font_size / 2.0; + for (ruby_glyph, top) in ruby.glyphs.iter().zip(&ruby.glyph_tops) { + let run = &layout.ruby_runs[ruby_glyph.run]; + let Some(glyph) = run.glyphs.get(ruby_glyph.glyph) else { + continue; + }; + let (_, metrics) = run.font.metrics(); + let baseline = origin_y + *top - metrics.ascent; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); + glyphs[0] = *glyph; + points[0] = SkPoint::new(0.0, 0.0); + if let Some(blob) = builder.make() { + let advance = run + .advances + .get(ruby_glyph.glyph) + .copied() + .unwrap_or(ruby.font_size); + push_text_path( + paths, + text_blob_path(blob, (gutter_center - advance / 2.0, baseline)), + paint, + antialias, + ); + } + } +} + +fn append_emphasis_path( + paths: &mut Vec<(skia::Path, Paint)>, + layout: &VerticalLayout, + mark: &EmphasisMark, + origin_x: f32, + origin_y: f32, + antialias: bool, +) { + let column = &layout.columns[mark.column]; + let run = &layout.emphasis_runs[mark.run]; + let Some(glyph) = run.glyphs.first() else { + return; + }; + let paint = &layout.paints[mark.paint]; + let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; + let gutter_center = origin_x + + column_base_center(column) + + base_font_size / 2.0 + + mark.outside_offset + + mark.font_size / 2.0; + let (_, metrics) = run.font.metrics(); + let cell_center = origin_y + mark.top + mark.extent / 2.0; + let baseline = cell_center - (metrics.ascent + metrics.descent) / 2.0; + let advance = run.advances.first().copied().unwrap_or(0.0); + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); + glyphs[0] = *glyph; + points[0] = SkPoint::new(0.0, 0.0); + if let Some(blob) = builder.make() { + push_text_path( + paths, + text_blob_path(blob, (gutter_center - advance / 2.0, baseline)), + paint, + antialias, + ); + } +} + +fn paths_from_layout( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + antialias: bool, +) -> Vec<(skia::Path, Paint)> { + let mut paths = Vec::new(); + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + for cell in &layout.cells { + append_cell_paths(&mut paths, layout, cell, origin_x, origin_y, antialias); + } + for ruby in &layout.ruby_cells { + append_ruby_paths(&mut paths, layout, ruby, origin_x, origin_y, antialias); + } + for mark in &layout.emphasis_marks { + append_emphasis_path(&mut paths, layout, mark, origin_x, origin_y, antialias); + } + paths +} + +/// Convert the custom vertical layout to glyph-outline paths using the same +/// cells, composite transforms, annotations and alignment as the canvas pass. +pub fn vertical_text_paths( + text_content: &TextContent, + vertical_align: VerticalAlign, + antialias: bool, +) -> Vec<(skia::Path, Paint)> { + if !text_content.is_vertical() { + return Vec::new(); + } + let bounds = text_content.bounds(); + let max_height = wrap_height(text_content, bounds.height()); + let layout = layout_from_content(text_content, max_height); + paths_from_layout(&layout, &bounds, vertical_align, antialias) +} + +/// Split `total` glyphs across segments proportionally to their extents +/// (rounded per segment, remainder to the last) so no glyph is dropped. +fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec { + let mut counts = vec![0usize; extents.len()]; + if extents.is_empty() || total == 0 { + return counts; + } + let sum: f32 = extents.iter().sum(); + if sum <= 0.0 { + counts[extents.len() - 1] = total; + return counts; + } + let mut assigned = 0usize; + let last = extents.len() - 1; + for (index, extent) in extents.iter().enumerate() { + let count = if index == last { + total - assigned + } else { + (((extent / sum) * total as f32).round() as usize).min(total - assigned) + }; + counts[index] = count; + assigned += count; + } + counts +} + +fn next_horizontal_ruby_range( + offset_map: &super::kinsoku::OffsetMap, + utf16_cursor: &mut usize, + text: &str, +) -> std::ops::Range { + let start = *utf16_cursor; + *utf16_cursor += text.encode_utf16().count(); + offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor) +} + +/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only: +/// base rects come from the already laid-out skparagraph +/// (`get_rects_for_range`), the annotation is shaped at half the span size +/// and distributed over each line's base rect with the same jlreq +/// distribution the vertical path uses (`distribute_ruby_tops` along the +/// horizontal flow). Lines are not reflowed to reserve an annotation band; +/// the ruby draws in the natural leading above the base line. +pub fn paint_horizontal_ruby( + canvas: &Canvas, + text_content: &TextContent, + paragraph_index: usize, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else { + return; + }; + if paragraph + .children() + .iter() + .all(|span| span.ruby.trim().is_empty()) + { + return; + } + let font_provider = get_render_state().fonts().font_provider(); + let fallback_mgr = FontMgr::from(font_provider.clone()); + let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); + let bounds = text_content.bounds(); + let (_, offset_map) = paragraph.layout_span_texts(); + + let mut utf16_cursor = 0usize; + for span in paragraph.children() { + let span_text = span.apply_text_transform(); + let span_range = next_horizontal_ruby_range(&offset_map, &mut utf16_cursor, &span_text); + let ruby_text = span.ruby.trim().to_string(); + if ruby_text.is_empty() || span_range.is_empty() { + continue; + } + let ruby_font_size = span.font_size * span.ruby_size.scale(); + let mut families = vec![format!("{}", span.font_family)]; + families.extend(fallback_families.iter().cloned()); + let paint = merge_fills(&span.fills, bounds); + let shifted_range = span_range; + let rects = laid_out.get_rects_for_range( + shifted_range, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let probe = ruby_text.chars().next().unwrap_or(' '); + let typeface = families + .iter() + .filter_map(|family| { + font_provider.match_family_style(family, skia::FontStyle::default()) + }) + .find(|tf| tf.unichar_to_glyph(probe as i32) != 0) + .or_else(|| { + font_provider.match_family_style(families[0].as_str(), skia::FontStyle::default()) + }); + let Some(typeface) = typeface else { + continue; + }; + let font = Font::new(typeface, ruby_font_size); + let shaped = shape_segment( + &ruby_text, + &font, + false, + span.font_features, + fallback_mgr.clone(), + ); + let glyphs: Vec<(usize, usize, f32)> = shaped + .iter() + .enumerate() + .flat_map(|(run_index, run)| { + (0..run.glyphs.len()).map(move |glyph| { + ( + run_index, + glyph, + run.advances.get(glyph).copied().unwrap_or(ruby_font_size), + ) + }) + }) + .collect(); + let extents: Vec = rects.iter().map(|rect| rect.rect.width()).collect(); + let counts = split_counts_by_extent(&extents, glyphs.len()); + let mut assigned = 0usize; + for (rect_box, count) in rects.iter().zip(counts) { + if count == 0 { + continue; + } + let slice = &glyphs[assigned..assigned + count]; + assigned += count; + let advance = slice + .iter() + .map(|(_, _, advance)| *advance) + .fold(0.0f32, f32::max) + .max(1.0); + // Horizontal SkParagraph has already fixed the base geometry. + // When overhang is prohibited, fit the annotation strip to + // that geometry instead of allowing either end to escape it. + let glyph_scale = if span.ruby_overhang == RubyOverhang::None { + (rect_box.rect.width() / (advance * count as f32)).min(1.0) + } else { + 1.0 + }; + let layout_advance = advance * glyph_scale; + let lefts = distribute_ruby_tops( + rect_box.rect.left(), + rect_box.rect.width(), + count, + layout_advance, + span.ruby_align, + span.ruby_overhang, + ); + for ((run_index, glyph, glyph_advance), left) in slice.iter().zip(lefts) { + let run = &shaped[*run_index]; + let (_, metrics) = run.font.metrics(); + let baseline = match span.ruby_side { + RubySide::Over => y + rect_box.rect.top() - metrics.descent, + RubySide::Under => y + rect_box.rect.bottom() - metrics.ascent, + }; + let mut builder = TextBlobBuilder::new(); + let (out_glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); + out_glyphs[0] = run.glyphs[*glyph]; + points[0] = SkPoint::new(0.0, 0.0); + if let Some(blob) = builder.make() { + let gx = x + left + (layout_advance - glyph_advance * glyph_scale) / 2.0; + if glyph_scale < 1.0 { + canvas.save(); + canvas.translate((gx, baseline)); + canvas.scale((glyph_scale, 1.0)); + canvas.draw_text_blob(&blob, (0.0, 0.0), &paint); + canvas.restore(); + } else { + canvas.draw_text_blob(&blob, (gx, baseline), &paint); + } + } + } + } + } +} + +/// Draw one cell's glyphs with an explicit paint (used both for the fill +/// pass — cell's own fill — and for the stroke / shadow silhouette passes, +/// which override the paint for every cell). +fn draw_cell_glyph( + canvas: &Canvas, + layout: &VerticalLayout, + cell: &VerticalCell, + origin_x: f32, + origin_y: f32, + paint: &Paint, +) { + let column = &layout.columns[cell.column]; + let x_center = origin_x + column_base_center(column); + let y_top = origin_y + cell.top; + match cell.kind { + CellKind::Upright { run, glyph, count } => { + let run = &layout.runs[run]; + let (ascent, descent) = upright_centre_metrics(&run.font); + let baseline = y_top + + cell.glyph_flow_shift + + upright_baseline_offset(ascent, descent, cell.font_size); + let x = x_center - cell.h_advance / 2.0; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + let base_x = run.positions[glyph].x; + for i in 0..count { + glyphs[i] = run.glyphs[glyph + i]; + points[i] = SkPoint::new( + run.positions[glyph + i].x - base_x, + run.positions[glyph + i].y, + ); + } + if let Some(blob) = builder.make() { + canvas.draw_text_blob(&blob, (x, baseline), paint); + } + } + CellKind::SyntheticRotated { run, glyph, count } => { + let run = &layout.runs[run]; + if let Some(blob) = cluster_text_blob(run, glyph, count) { + canvas.save(); + canvas.translate((x_center, y_top + cell.glyph_flow_shift)); + canvas.rotate(90.0, None); + canvas.draw_text_blob( + &blob, + (0.0, rotated_cluster_baseline_shift(run, glyph, count)), + paint, + ); + canvas.restore(); + } + } + CellKind::Rotated { run } => { + let run = &layout.runs[run]; + let mut builder = TextBlobBuilder::new(); + let count = run.glyphs.len(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + glyphs.copy_from_slice(&run.glyphs); + points.copy_from_slice(&run.positions); + if let Some(blob) = builder.make() { + canvas.save(); + canvas.translate((x_center, y_top)); + canvas.rotate(90.0, None); + // After rotation +x runs down the column and +y runs across it; + // centre the run's actual ink band on the column axis. + canvas.draw_text_blob(&blob, (0.0, run.rotated_baseline_shift), paint); + canvas.restore(); + } + } + CellKind::TateChuYoko { + run_start, + run_count, + scale, + } => { + let composite = &layout.runs[run_start..run_start + run_count]; + let combined_advance: f32 = composite.iter().map(|r| r.advance).sum(); + // Vertical centring uses the tallest run's ascent/descent. + let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), r| { + let (_, metrics) = r.font.metrics(); + (a.min(metrics.ascent), d.max(metrics.descent)) + }); + let x0 = x_center - (combined_advance * scale) / 2.0; + let baseline = y_top + cell.font_size / 2.0 - ((ascent + descent) * scale) / 2.0; + canvas.save(); + canvas.translate((x0, baseline)); + canvas.scale((scale, scale)); + let mut cursor = 0.0f32; + for run in composite { + let mut builder = TextBlobBuilder::new(); + let count = run.glyphs.len(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + glyphs.copy_from_slice(&run.glyphs); + points.copy_from_slice(&run.positions); + if let Some(blob) = builder.make() { + canvas.draw_text_blob(&blob, (cursor, 0.0), paint); + } + cursor += run.advance; + } + canvas.restore(); + } + CellKind::Warichu { + run_start, + run_count, + first_count, + .. + } => { + let all = &layout.runs[run_start..run_start + run_count]; + let (first, second) = all.split_at(first_count); + let quarter = cell.font_size / 4.0; + // vertical-rl: the first sub-line reads first, on the right half. + draw_warichu_line(canvas, first, x_center + quarter, y_top, paint); + draw_warichu_line(canvas, second, x_center - quarter, y_top, paint); + } + } +} + +/// Draw one warichu sub-line: upright half-size glyphs stacked down the +/// sub-column centred on `x_center`, clusters kept together like the normal +/// upright path. +fn draw_warichu_line( + canvas: &Canvas, + runs: &[ShapedRun], + x_center: f32, + y_top: f32, + paint: &Paint, +) { + let mut cursor = y_top; + for run in runs { + let font_size = run.font.size(); + let (ascent, descent) = upright_centre_metrics(&run.font); + let baseline_offset = upright_baseline_offset(ascent, descent, font_size); + let mut glyph = 0usize; + while glyph < run.glyphs.len() { + let cluster = run.clusters[glyph]; + let mut count = 1; + while glyph + count < run.glyphs.len() && run.clusters[glyph + count] == cluster { + count += 1; + } + let advance: f32 = run.advances[glyph..glyph + count].iter().sum(); + let advance = if advance > 0.0 { advance } else { font_size }; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + let base_x = run.positions[glyph].x; + for i in 0..count { + glyphs[i] = run.glyphs[glyph + i]; + points[i] = SkPoint::new( + run.positions[glyph + i].x - base_x, + run.positions[glyph + i].y, + ); + } + if let Some(blob) = builder.make() { + canvas.draw_text_blob( + &blob, + (x_center - advance / 2.0, cursor + baseline_offset), + paint, + ); + } + cursor += advance; + glyph += count; + } + } +} + +/// Decoration bar geometry for a cell in absolute coordinates. Underline +/// runs along the *left* side of the column (the under side in +/// `vertical-rl`); line-through runs down the column centre. Both span the +/// cell's vertical extent so consecutive decorated cells tile a continuous +/// bar. Returns (rect, thickness-adjusted rect) as a skia rect. +fn decoration_bar( + layout: &VerticalLayout, + cell: &VerticalCell, + origin_x: f32, + origin_y: f32, + line_through: bool, +) -> skia::Rect { + let column = &layout.columns[cell.column]; + let x_center = origin_x + column_base_center(column); + let y_top = origin_y + cell.top; + let thickness = (cell.font_size * 0.06).max(1.0); + let bar_x = if line_through { + x_center + } else { + x_center - cell.font_size / 2.0 - cell.font_size / 9.0 + }; + skia::Rect::from_ltrb( + bar_x - thickness / 2.0, + y_top, + bar_x + thickness / 2.0, + y_top + cell.extent, + ) +} + +fn draw_cell_decorations( + canvas: &Canvas, + layout: &VerticalLayout, + cell: &VerticalCell, + origin_x: f32, + origin_y: f32, +) { + let Some(decoration) = cell.decoration else { + return; + }; + let mut paint = layout.paints[cell.paint].clone(); + paint.set_style(skia::PaintStyle::Fill); + paint.set_anti_alias(true); + if decoration.contains(TextDecoration::UNDERLINE) { + canvas.draw_rect( + decoration_bar(layout, cell, origin_x, origin_y, false), + &paint, + ); + } + if decoration.contains(TextDecoration::LINE_THROUGH) { + canvas.draw_rect( + decoration_bar(layout, cell, origin_x, origin_y, true), + &paint, + ); + } +} + +/// Paint every cell's glyphs with a single overriding paint (stroke / +/// shadow silhouette passes). +fn paint_glyphs( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + paint: &Paint, +) { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + for cell in &layout.cells { + draw_cell_glyph(canvas, layout, cell, origin_x, origin_y, paint); + } +} + +/// Fill pass: each cell drawn with its own fill paint, then its decorations. +pub fn paint_layout( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, +) { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + for cell in &layout.cells { + let paint = &layout.paints[cell.paint]; + draw_cell_glyph(canvas, layout, cell, origin_x, origin_y, paint); + draw_cell_decorations(canvas, layout, cell, origin_x, origin_y); + } + for ruby in &layout.ruby_cells { + draw_ruby_cell(canvas, layout, ruby, origin_x, origin_y); + } + for mark in &layout.emphasis_marks { + draw_emphasis_mark(canvas, layout, mark, origin_x, origin_y); + } +} + +/// Draw one emphasis mark (圏点) centred on its base cell's flow extent, in the +/// column's right-side gutter. The mark reuses the base cell's fill paint. +fn draw_emphasis_mark( + canvas: &Canvas, + layout: &VerticalLayout, + mark: &EmphasisMark, + origin_x: f32, + origin_y: f32, +) { + let column = &layout.columns[mark.column]; + let run = &layout.emphasis_runs[mark.run]; + if run.glyphs.is_empty() { + return; + } + let paint = &layout.paints[mark.paint]; + let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; + let gutter_center = origin_x + + column_base_center(column) + + base_font_size / 2.0 + + mark.outside_offset + + mark.font_size / 2.0; + let (_, metrics) = run.font.metrics(); + let cell_center = origin_y + mark.top + mark.extent / 2.0; + let baseline = cell_center - (metrics.ascent + metrics.descent) / 2.0; + let advance = run.advances.first().copied().unwrap_or(0.0); + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); + glyphs[0] = run.glyphs[0]; + points[0] = SkPoint::new(0.0, 0.0); + if let Some(blob) = builder.make() { + let x = gutter_center - advance / 2.0; + canvas.draw_text_blob(&blob, (x, baseline), paint); + } +} + +/// Draw one column's slice of a ruby annotation stacked upright in its +/// column's ruby gutter. Each glyph's flow-axis position comes from +/// the whole base span; each glyph is centred across the gutter using its own +/// fallback-font run. +fn draw_ruby_cell( + canvas: &Canvas, + layout: &VerticalLayout, + ruby: &RubyCell, + origin_x: f32, + origin_y: f32, +) { + let column = &layout.columns[ruby.column]; + let paint = &layout.paints[ruby.paint]; + if ruby.glyphs.is_empty() { + return; + } + let gutter_center = origin_x + + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) + + ruby.font_size / 2.0; + for (ruby_glyph, top) in ruby.glyphs.iter().zip(&ruby.glyph_tops) { + let run = &layout.ruby_runs[ruby_glyph.run]; + let Some(glyph) = run.glyphs.get(ruby_glyph.glyph) else { + continue; + }; + let (_, metrics) = run.font.metrics(); + let glyph_top = origin_y + *top; + let baseline = glyph_top - metrics.ascent; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, 1, None); + glyphs[0] = *glyph; + points[0] = SkPoint::new(0.0, 0.0); + if let Some(blob) = builder.make() { + let h_advance = run + .advances + .get(ruby_glyph.glyph) + .copied() + .unwrap_or(ruby.font_size); + let x = gutter_center - h_advance / 2.0; + canvas.draw_text_blob(&blob, (x, baseline), paint); + } + } +} + +/// Cross-axis start of a vertical ruby strip. Line height controls the column +/// advance (`base_width`), but must not become spacing between an annotation +/// and its base glyph. Centre the base em in that advance and attach ruby to +/// the em edge; any extra leading remains outside the base+ruby group. +fn ruby_strip_x( + column: &VerticalColumn, + ruby_font_size: f32, + base_font_size: f32, + side: RubySide, +) -> f32 { + let base_center = column_base_center(column); + match side { + RubySide::Over => base_center + base_font_size / 2.0, + RubySide::Under => base_center - base_font_size / 2.0 - ruby_font_size, + } +} + +/// Developer overlay: draw a jlreq-style character-frame grid over the +/// laid-out vertical cells. For every column it outlines the column band; +/// for every cell it draws the advance box (the real layout cell), the +/// virtual body / em square centred on the column axis, and the glyph-ink +/// band. This exposes solid-setting, aki, letter-spacing and column +/// planning visually, mirroring the grids in the jlreq figures. It is never +/// emitted to exported or persisted output — only the on-screen fills pass +/// calls it, gated by the `TEXT_GRID_VISIBLE` render flag. +pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align: VerticalAlign) { + let (origin_x, origin_y) = layout.origin(bounds, align); + + let mut column_paint = Paint::default(); + column_paint.set_anti_alias(true); + column_paint.set_style(skia::PaintStyle::Stroke); + column_paint.set_stroke_width(1.0); + column_paint.set_color(skia::Color::from_argb(0x55, 0x88, 0x88, 0x88)); + + let mut advance_paint = Paint::default(); + advance_paint.set_anti_alias(true); + advance_paint.set_style(skia::PaintStyle::Stroke); + advance_paint.set_stroke_width(1.0); + advance_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED)); + + let mut em_paint = Paint::default(); + em_paint.set_anti_alias(true); + em_paint.set_style(skia::PaintStyle::Stroke); + em_paint.set_stroke_width(1.0); + em_paint.set_color(skia::Color::from_argb(0x99, 0xEB, 0x57, 0x57)); + + let mut ink_paint = Paint::default(); + ink_paint.set_anti_alias(true); + ink_paint.set_style(skia::PaintStyle::Stroke); + ink_paint.set_stroke_width(1.0); + ink_paint.set_color(skia::Color::from_argb(0x77, 0x27, 0xAE, 0x60)); + + // Column bands over the full used height. + for column in &layout.columns { + let x = origin_x + column.x; + canvas.draw_rect( + Rect::from_xywh(x, origin_y, column.width, layout.height), + &column_paint, + ); + } + + for cell in &layout.cells { + let column = &layout.columns[cell.column]; + let x_left = origin_x + column.x; + let y_top = origin_y + cell.top; + let x_center = x_left + column.base_offset + column.base_width / 2.0; + + // Advance box: the real layout cell along the column axis. + canvas.draw_rect( + Rect::from_xywh(x_left, y_top, column.base_width, cell.extent), + &advance_paint, + ); + + // Virtual body / em square, centred on the column axis and on the + // cell's advance so aki and letter-spacing are visible. + let em = cell.font_size.max(1.0); + canvas.draw_rect( + Rect::from_xywh( + x_center - em / 2.0, + y_top + (cell.extent - em) / 2.0, + em, + em, + ), + &em_paint, + ); + + // Glyph-ink band along the flow axis. + if cell.ink_bottom > cell.ink_top { + canvas.draw_line( + (x_left, origin_y + cell.top + cell.ink_top), + (x_left + column.width, origin_y + cell.top + cell.ink_top), + &ink_paint, + ); + canvas.draw_line( + (x_left, origin_y + cell.top + cell.ink_bottom), + (x_left + column.width, origin_y + cell.top + cell.ink_bottom), + &ink_paint, + ); + } + } +} + +/// Paint the vertical glyph shadows. The shadow paint carries the +/// blur/offset image filter; drawing the glyphs directly through it renders +/// each glyph's shadow without allocating a full-content offscreen layer +/// (a `save_layer` with the filter can exceed GPU limits on tall columns). +pub fn paint_drop_shadow( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + shadow_paint: &Paint, +) { + let mut paint = shadow_paint.clone(); + paint.set_color(skia::Color::BLACK); + paint.set_anti_alias(true); + paint_glyphs(canvas, layout, bounds, vertical_align, &paint); +} + +/// Paint a stroke masked to the vertical glyph silhouettes. Center strokes +/// draw the stroked outline directly; inner/outer strokes are masked with +/// `SrcIn` / `SrcOut` against the glyph silhouette (mirrors the horizontal +/// masked-stroke path). +pub fn paint_stroke( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + stroke: &Stroke, + selrect: &Rect, + blur: Option<&ImageFilter>, +) { + let (stroke_paints, layer_opacity) = + crate::render::text::get_text_stroke_paints(stroke, selrect, false); + + if let Some(blur_filter) = blur { + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + } + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + } + + for stroke_paint in &stroke_paints { + match stroke.kind { + StrokeKind::Center => { + paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint) + } + StrokeKind::Inner => paint_masked_stroke( + canvas, + layout, + bounds, + vertical_align, + stroke_paint, + BlendMode::SrcIn, + ), + StrokeKind::Outer => paint_masked_stroke( + canvas, + layout, + bounds, + vertical_align, + stroke_paint, + BlendMode::SrcOut, + ), + } + } + + if layer_opacity.is_some() { + canvas.restore(); + } + if blur.is_some() { + canvas.restore(); + } +} + +fn paint_masked_stroke( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + stroke_paint: &Paint, + blend: BlendMode, +) { + let mut mask = Paint::default(); + mask.set_color(skia::Color::BLACK); + mask.set_anti_alias(true); + + canvas.save_layer(&SaveLayerRec::default()); + paint_glyphs(canvas, layout, bounds, vertical_align, &mask); + + let mut blend_paint = Paint::default(); + blend_paint.set_blend_mode(blend); + canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint); + canvas.restore(); + + canvas.restore(); +} + +/// Position-data `direction` value marking a vertical (vertical-rl) +/// strip; 0/1 are the horizontal rtl/ltr values. The CLJS deserializer +/// turns it into `:writing-mode "vertical-rl"` on the entry so the +/// legacy SVG renderer can draw the strip vertically. +pub const DIRECTION_VERTICAL_RL: u32 = 2; + +/// Position-data `direction` value marking a ruby annotation strip: the +/// entry's offsets index the span's *ruby* string and the geometry is the +/// exact gutter placement the canvas paints. +pub const DIRECTION_VERTICAL_RUBY: u32 = 3; + +fn cell_source_utf16_range(layout: &VerticalLayout, cell: &VerticalCell) -> std::ops::Range { + let transformed_span_start = layout.span_utf16_starts[cell.paragraph][cell.span]; + let source_span_start = layout.span_source_utf16_starts[cell.paragraph][cell.span]; + let relative = layout.span_transforms[cell.paragraph][cell.span] + .source_utf16_range(cell.start - transformed_span_start..cell.end - transformed_span_start); + source_span_start + relative.start..source_span_start + relative.end +} + +/// Position-data entries for the v2 editor / exports: consecutive cells of +/// the same span in the same column merge into one vertical strip. +pub fn position_data( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, +) -> Vec { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + let mut result: Vec = Vec::new(); + + let mut i = 0; + while i < layout.cells.len() { + let first = &layout.cells[i]; + let mut source_range = cell_source_utf16_range(layout, first); + let mut bottom = first.top + first.extent; + let mut j = i + 1; + while j < layout.cells.len() { + let next = &layout.cells[j]; + if next.paragraph == first.paragraph + && next.span == first.span + && next.column == first.column + { + let next_source = cell_source_utf16_range(layout, next); + source_range.start = source_range.start.min(next_source.start); + source_range.end = source_range.end.max(next_source.end); + bottom = next.top + next.extent; + j += 1; + } else { + break; + } + } + let column = &layout.columns[first.column]; + let span_start = layout.span_source_utf16_starts[first.paragraph][first.span]; + result.push(PositionData { + paragraph: first.paragraph as u32, + span: first.span as u32, + start_pos: (source_range.start - span_start) as u32, + end_pos: (source_range.end - span_start) as u32, + x: origin_x + column.x, + y: origin_y + first.top, + // Base text occupies the base sub-band; any ruby gutter is + // excluded so the editor overlay and selection track the glyphs. + width: column.base_width, + height: bottom - first.top, + direction: DIRECTION_VERTICAL_RL, + }); + i = j; + } + + // Ruby annotation strips, with the exact flow-axis placement the canvas + // paints. `start_pos`/`end_pos` are UTF-16 offsets + // into the span's ruby string, taken from the shaped clusters so + // surrogate-pair readings slice correctly. The consumer renders them as + // their own half-size vertical strips in the column's right-side gutter. + for ruby in &layout.ruby_cells { + let (Some(first), Some(last)) = (ruby.glyphs.first(), ruby.glyphs.last()) else { + continue; + }; + if ruby.base_segments.is_empty() { + continue; + } + let column = &layout.columns[ruby.column]; + let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min); + let bottom = ruby.glyph_tops.iter().copied().fold(f32::MIN, f32::max) + ruby.font_size; + result.push(PositionData { + paragraph: ruby.paragraph as u32, + span: ruby.span as u32, + start_pos: first.utf16_start as u32, + end_pos: last.utf16_end as u32, + x: origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side), + y: origin_y + top, + width: ruby.font_size, + height: bottom - top, + direction: DIRECTION_VERTICAL_RUBY, + }); + } + result +} + +/// True when the point (in the same space as `bounds`) hits laid-out text. +pub fn intersects( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + x: f32, + y: f32, +) -> bool { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + layout.cells.iter().any(|cell| { + let column = &layout.columns[cell.column]; + let rect = Rect::from_xywh( + origin_x + column.x, + origin_y + cell.top, + column.width, + cell.extent, + ); + rect.contains(&SkPoint::new(x, y)) + }) +} + +fn scalar_offset_from_utf16( + layout: &VerticalLayout, + paragraph: usize, + utf16_offset: usize, +) -> Option { + let boundaries = layout.paragraph_utf16_boundaries.get(paragraph)?; + Some(match boundaries.binary_search(&utf16_offset) { + Ok(index) => index, + Err(index) => index.saturating_sub(1), + }) +} + +fn cell_scalar_range(layout: &VerticalLayout, cell: &VerticalCell) -> Option<(usize, usize)> { + Some(( + scalar_offset_from_utf16(layout, cell.paragraph, cell.start)?, + scalar_offset_from_utf16(layout, cell.paragraph, cell.end)?, + )) +} + +/// Caret position (paragraph index, paragraph-relative Unicode scalar offset) for +/// a point given relative to the content block's top-left origin. +pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usize, usize)> { + if layout.columns.is_empty() { + return None; + } + + // Columns are ordered right->left, i.e. descending x. + let column_index = layout + .columns + .iter() + .position(|c| x >= c.x && x < c.x + c.width) + .unwrap_or(if x >= layout.width { + 0 + } else { + layout.columns.len() - 1 + }); + + let paragraph = layout + .paragraph_columns + .iter() + .position(|(start, end)| column_index >= *start && column_index < *end)?; + + let column_cells: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .collect(); + + if column_cells.is_empty() { + return Some((paragraph, 0)); + } + + for cell in &column_cells { + if y < cell.top + cell.extent { + let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; + let chars = (cell_end - cell_start).max(1); + let offset = match cell.kind { + CellKind::Rotated { .. } => { + // Proportional position along the rotated run. + let frac = ((y - cell.top) / cell.extent).clamp(0.0, 1.0); + cell_start + ((frac * chars as f32).round() as usize).min(chars) + } + CellKind::TateChuYoko { .. } => { + // The digits run left->right inside the composite, so the + // horizontal position picks the offset within it. + let column = &layout.columns[cell.column]; + let left = column_base_center(column) - cell.h_advance / 2.0; + let frac = ((x - left) / cell.h_advance.max(1.0)).clamp(0.0, 1.0); + cell_start + ((frac * chars as f32).round() as usize).min(chars) + } + CellKind::Warichu { first_chars, .. } => { + // Right sub-column holds the first sub-line's characters, + // left sub-column the second; the vertical position picks + // the offset within the chosen sub-line. + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let first = + scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)? + .saturating_sub(cell_start) + .min(chars); + let (lo, hi) = if x >= centre { + (0, first) + } else { + (first, chars) + }; + let line_chars = (hi - lo).max(1); + let frac = ((y - cell.top) / cell.extent.max(1.0)).clamp(0.0, 1.0); + cell_start + lo + ((frac * line_chars as f32).round() as usize).min(hi - lo) + } + CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } => { + if y < cell.top + cell.extent / 2.0 { + cell_start + } else { + cell_end + } + } + }; + return Some((paragraph, offset)); + } + } + + Some(( + paragraph, + scalar_offset_from_utf16(layout, paragraph, column_cells.last().unwrap().end)?, + )) +} + +/// Caret rectangle for a paragraph-relative Unicode scalar offset, in +/// content-local coordinates. The rect spans the column width; its height +/// is the extent of the character at the offset (used by overtype-mode +/// carets to cover the glyph) or zero when the caret sits after the last +/// character, where a thin bar is drawn instead. +pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> Option { + let (col_start, _) = *layout.paragraph_columns.get(paragraph)?; + + let mut result: Option = None; + for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { + let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; + if cell_start > offset { + continue; + } + let column = &layout.columns[cell.column]; + let chars = (cell_end - cell_start).max(1); + let rect = if offset >= cell_end { + Rect::from_xywh(column.x, cell.top + cell.extent, column.base_width, 0.0) + } else { + match cell.kind { + CellKind::TateChuYoko { .. } => { + // Digits run left->right inside the composite: the offset + // moves the caret along the horizontal axis while the + // rect keeps the composite's flow extent. + let left = column_base_center(column) - cell.h_advance / 2.0; + let width = cell.h_advance / chars as f32; + let x = left + (offset - cell_start) as f32 * width; + Rect::from_xywh(x, cell.top, width, cell.extent) + } + CellKind::Warichu { first_chars, .. } => { + // Right sub-column holds the first sub-line's + // characters, left sub-column the second (jlreq reading + // order); the caret tracks the offset down the chosen + // half-width sub-line. + let first = + scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)? + .saturating_sub(cell_start) + .min(chars); + let within = offset - cell_start; + let centre = column_base_center(column); + let half = cell.font_size / 2.0; + let (band_x, line_start, line_chars) = if within < first { + (centre, 0, first) + } else { + (centre - half, first, chars - first) + }; + let line_chars = line_chars.max(1); + let frac = (within - line_start) as f32 / line_chars as f32; + Rect::from_xywh( + band_x, + cell.top + frac * cell.extent, + half, + cell.extent / line_chars as f32, + ) + } + _ => { + let frac = (offset - cell_start) as f32 / chars as f32; + Rect::from_xywh( + column.x, + cell.top + frac * cell.extent, + column.base_width, + cell.extent / chars as f32, + ) + } + } + }; + result = Some(rect); + if offset < cell_end { + break; + } + } + + result.or_else(|| { + // Empty paragraph: caret at the top of its (empty) first column. + layout + .columns + .get(col_start) + .map(|column| Rect::from_xywh(column.x, 0.0, column.base_width, 0.0)) + }) +} + +/// Selection rectangles for a paragraph-relative Unicode scalar offset range, in +/// content-local coordinates. +pub fn range_rects( + layout: &VerticalLayout, + paragraph: usize, + start: usize, + end: usize, +) -> Vec { + let mut rects: Vec = Vec::new(); + for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { + let Some((cell_start, cell_end)) = cell_scalar_range(layout, cell) else { + continue; + }; + if cell_end <= start || cell_start >= end { + continue; + } + let column = &layout.columns[cell.column]; + let chars = (cell_end - cell_start).max(1); + let sel_top = if start > cell_start { + cell.top + ((start - cell_start) as f32 / chars as f32) * cell.extent + } else { + cell.top + }; + let sel_bottom = if end < cell_end { + cell.top + ((end - cell_start) as f32 / chars as f32) * cell.extent + } else { + cell.top + cell.extent + }; + if sel_bottom <= sel_top { + continue; + } + // Merge with the previous rect when contiguous in the same column. + if let Some(last) = rects.last_mut() { + if (last.left - column.x).abs() < f32::EPSILON && (last.bottom - sel_top).abs() < 0.01 { + last.bottom = sel_bottom; + continue; + } + } + rects.push(Rect::from_ltrb( + column.x, + sel_top, + column.x + column.base_width, + sel_bottom, + )); + } + rects +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(extent: f32, ch: char) -> FlowItem { + FlowItem { + extent, + ch: Some(ch), + keep_with_previous: false, + } + } + + #[test] + fn upright_classification() { + assert!(is_upright_char('あ')); + assert!(is_upright_char('ア')); + assert!(is_upright_char('漢')); + assert!(is_upright_char('。')); + assert!(is_upright_char('「')); + assert!(is_upright_char('ー')); + assert!(is_upright_char('!')); + assert!(!is_upright_char('A')); + assert!(!is_upright_char('1')); + assert!(!is_upright_char(' ')); + assert!(!is_upright_char('.')); + } + + #[test] + fn transformed_japanese_punctuation_has_a_rotated_font_fallback() { + for character in [ + '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '、', '。', 'ー', '〜', + ':', ';', + ] { + assert!( + uses_rotated_vertical_fallback(character), + "{character} needs a transformed vertical glyph" + ); + } + assert!(!uses_rotated_vertical_fallback('あ')); + assert!(!uses_rotated_vertical_fallback('漢')); + assert!(!uses_rotated_vertical_fallback('!')); + } + + #[test] + fn missing_vertical_alternate_uses_a_character_granular_rotated_cell() { + let mut content = make_content(&["“"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&test_provider(), &content); + assert_eq!(layout.cells.len(), 1); + assert!(matches!( + layout.cells[0].kind, + CellKind::SyntheticRotated { .. } + )); + } + + #[test] + fn segments_mixed_text() { + let segments = segment_by_orientation("縦書きABC123です", TextOrientation::Mixed); + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].text, "縦書き"); + assert!(segments[0].upright); + assert_eq!(segments[0].utf16_start, 0); + assert_eq!(segments[1].text, "ABC123"); + assert!(!segments[1].upright); + assert_eq!(segments[1].utf16_start, 3); + assert_eq!(segments[2].text, "です"); + assert!(segments[2].upright); + assert_eq!(segments[2].utf16_start, 9); + } + + #[test] + fn segments_upright_orientation_keeps_latin_upright() { + let segments = segment_by_orientation("縦ABC", TextOrientation::Upright); + assert_eq!(segments.len(), 1); + assert!(segments[0].upright); + } + + #[test] + fn segments_empty_text() { + assert!(segment_by_orientation("", TextOrientation::Mixed).is_empty()); + } + + #[test] + fn plan_columns_breaks_on_overflow() { + let items: Vec = "あいうえお".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 25.0); + assert_eq!( + placements, + vec![(0, 0.0), (0, 10.0), (1, 0.0), (1, 10.0), (2, 0.0)] + ); + } + + #[test] + fn plan_columns_oversized_item_gets_own_column() { + let items = vec![item(10.0, 'あ'), item(100.0, 'い'), item(10.0, 'う')]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (2, 0.0)]); + } + + #[test] + fn plan_columns_burasage_hangs_comma_period() { + // 。 overflows the two-cell column; instead of oidashi (pushing い down + // with it), burasage lets it hang past the column bottom. The invariant + // "no forbidden-at-line-start char at a column top" still holds — 。 + // never reaches the next column's top. + let items = vec![ + item(10.0, 'あ'), + item(10.0, 'い'), + item(10.0, '。'), + item(10.0, 'う'), + ]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (0, 10.0), (0, 20.0), (1, 0.0)]); + assert_eq!(placements[2].0, 0, "。 hangs in the current column"); + } + + #[test] + fn plan_columns_kinsoku_no_forbidden_end_at_column_bottom() { + // 「 would be left at the bottom of column 0; it moves down. + let items = vec![ + item(10.0, 'あ'), + item(10.0, '「'), + item(10.0, 'い'), + item(10.0, 'う'), + ]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 10.0), (2, 0.0)]); + } + + #[test] + fn plan_columns_kinsoku_shift_is_bounded() { + // A column full of open brackets cannot be emptied: the shift + // stops after MAX_KINSOKU_SHIFT characters. + let items: Vec = "「「「「「「あ".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 60.0); + let first_column_count = placements.iter().filter(|(c, _)| *c == 0).count(); + assert!(first_column_count >= 2); + } + + #[test] + fn plan_columns_kinsoku_shift_never_overflows_budget() { + // A two-item budget: moving the trailing 「「 down with the + // overflowing い would put three items (30.0) in a 20.0 column; + // the shift is dropped instead, keeping every column within the + // wrap budget. + let items = vec![ + item(10.0, 'あ'), + item(10.0, '「'), + item(10.0, '「'), + item(10.0, 'い'), + ]; + let placements = plan_columns(&items, 20.0); + let mut column_used = std::collections::BTreeMap::new(); + for (it, (column, top)) in items.iter().zip(&placements) { + let used: &mut f32 = column_used.entry(*column).or_default(); + *used = used.max(top + it.extent); + } + for (column, used) in column_used { + assert!(used <= 20.0, "column {column} overflows: {used}"); + } + } + + // ----------------------------------------------------------------- + // Full layout pipeline (real shaping with the bundled test font; + // glyph coverage does not matter for offsets/columns, tofu still + // shapes with real advances) + // ----------------------------------------------------------------- + + use crate::math::Rect as MathRect; + use crate::shapes::{ + AnnotationClearance, Fill, FontFamily, FontStyle, GrowType, Paragraph, RubyAlign, + RubyOverhang, RubySide, RubySize, SolidColor, TextAlign, TextCombineUpright, TextContent, + TextDirection, TextEmphasis, TextOrientation, TextPositionWithAffinity, TextSpan, + TextTransform, WritingMode, + }; + use crate::wasm::text::helpers as text_helpers; + use crate::Uuid; + + const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); + const RUBY_FALLBACK_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vmtx-test.ttf"); + + fn test_provider() -> TypefaceFontProvider { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(TEST_FONT, None) + .expect("failed to load test font"); + let mut provider = TypefaceFontProvider::new(); + // The span font family serializes as "uuid-weight-style"; register + // the test face under the nil-uuid regular name so it matches. + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(typeface, Some(family.as_str())); + provider + } + + fn make_span(text: &str) -> TextSpan { + TextSpan { + text: text.to_string(), + font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size: 20.0, + line_height: 1.0, + letter_spacing: 0.0, + font_weight: 400, + font_variant_id: Uuid::nil(), + text_decoration: None, + text_transform: None, + text_direction: TextDirection::LTR, + text_orientation: TextOrientation::Mixed, + text_combine_upright: TextCombineUpright::None, + text_emphasis: TextEmphasis::None, + ruby: String::default(), + warichu: false, + font_features: FontFeatures::None, + annotation_clearance: AnnotationClearance::None, + ruby_size: RubySize::Half, + ruby_align: RubyAlign::SpaceAround, + ruby_overhang: RubyOverhang::Auto, + ruby_side: RubySide::Over, + fills: vec![], + } + } + + fn make_content(texts: &[&str], height: f32) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, height), + GrowType::Fixed, + ); + for text in texts { + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![make_span(text)], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + } + content + } + + fn make_content_with_spans(texts: &[&str], height: f32) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, height), + GrowType::Fixed, + ); + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + texts.iter().map(|text| make_span(text)).collect(), + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + fn make_content_aligned(text: &str, height: f32, align: TextAlign) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, height), + GrowType::Fixed, + ); + let mut paragraph = Paragraph::new( + align, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![make_span(text)], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + fn make_ruby_content(base: &str, ruby: &str, height: f32) -> TextContent { + make_ruby_content_with_line_height(base, ruby, height, 1.0) + } + + fn make_ruby_content_with_line_height( + base: &str, + ruby: &str, + height: f32, + line_height: f32, + ) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, height), + GrowType::Fixed, + ); + let mut span = make_span(base); + span.ruby = ruby.to_string(); + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + vec![span], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + fn make_emphasis_content(base: &str, emphasis: TextEmphasis, height: f32) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, height), + GrowType::Fixed, + ); + let mut span = make_span(base); + span.text_emphasis = emphasis; + span.text_orientation = TextOrientation::Upright; + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![span], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + #[test] + fn emphasis_span_reserves_gutter_and_emits_one_mark_per_upright_cell() { + let content = make_emphasis_content("AB", TextEmphasis::FilledDot, 400.0); + let layout = layout_content(&content, 400.0); + let upright = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::Upright { .. })) + .count(); + assert_eq!(upright, 2, "AB upright yields two base cells"); + assert_eq!( + layout.emphasis_marks.len(), + upright, + "one emphasis mark per upright base cell" + ); + for column in &layout.columns { + assert!( + column.width > column.base_width, + "an emphasis paragraph reserves a gutter beside the base band" + ); + } + let mark = &layout.emphasis_marks[0]; + assert!( + !layout.emphasis_runs[mark.run].glyphs.is_empty(), + "the emphasis mark must be shaped" + ); + } + + #[test] + fn emphasis_skips_whitespace_cells() { + let content = make_emphasis_content("A B", TextEmphasis::FilledDot, 400.0); + let layout = layout_content(&content, 400.0); + let upright = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::Upright { .. })) + .count(); + assert_eq!(upright, 3, "'A B' upright yields three base cells"); + assert_eq!( + layout.emphasis_marks.len(), + 2, + "the whitespace cell gets no emphasis mark" + ); + } + + #[test] + fn emphasis_skips_japanese_commas_stops_and_brackets() { + let content = make_emphasis_content("A、。(B)」", TextEmphasis::FilledDot, 400.0); + let layout = layout_content(&content, 400.0); + + assert_eq!( + layout.emphasis_marks.len(), + 2, + "only A and B receive emphasis marks" + ); + } + + fn make_warichu_content(base: &str, height: f32) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, height), + GrowType::Fixed, + ); + let mut span = make_span(base); + span.warichu = true; + span.text_orientation = TextOrientation::Upright; + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![span], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + #[test] + fn warichu_span_composes_two_half_size_sub_lines() { + let content = make_warichu_content("ABCD", 400.0); + let layout = layout_content(&content, 400.0); + assert_eq!(layout.cells.len(), 1, "the whole span is one composite"); + let cell = &layout.cells[0]; + let CellKind::Warichu { + run_count, + first_count, + .. + } = cell.kind + else { + panic!("expected a warichu cell"); + }; + assert!(first_count >= 1 && run_count > first_count); + // Two chars per sub-line at half size: the block's flow extent is + // roughly one em, far below the four em of normal layout. + assert!( + cell.extent < 2.0 * 20.0, + "two half-size sub-lines take about one em, got {}", + cell.extent + ); + assert!( + (cell.h_advance - 20.0).abs() < 0.001, + "the composite fills the full em width" + ); + assert_eq!(cell.start, 0); + assert_eq!(cell.end, 4); + } + + #[test] + fn warichu_single_char_keeps_normal_layout() { + let content = make_warichu_content("A", 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout + .cells + .iter() + .all(|c| !matches!(c.kind, CellKind::Warichu { .. }))); + } + + #[test] + fn no_emphasis_emits_no_marks() { + let content = make_content(&["AB"], 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout.emphasis_marks.is_empty()); + assert!(layout.emphasis_runs.is_empty()); + } + + fn layout_content(content: &TextContent, max_height: f32) -> VerticalLayout { + let provider = test_provider(); + let fallback_mgr = FontMgr::from(provider.clone()); + layout_vertical( + content, + max_height, + &provider, + fallback_mgr, + &[], + content.bounds(), + ) + } + + #[test] + fn ruby_span_reserves_gutter_and_emits_ruby_cells() { + let content = make_ruby_content("AB", "ab", 400.0); + let layout = layout_content(&content, 400.0); + assert!( + !layout.ruby_cells.is_empty(), + "a span with ruby must emit ruby cells" + ); + for column in &layout.columns { + assert!( + column.width > column.base_width, + "a ruby paragraph reserves a gutter beside the base band" + ); + } + let ruby = &layout.ruby_cells[0]; + assert!( + ruby.glyphs.iter().all(|glyph| layout + .ruby_runs + .get(glyph.run) + .and_then(|run| run.glyphs.get(glyph.glyph)) + .is_some()), + "every ruby glyph must retain its shaped run" + ); + } + + #[test] + fn ruby_size_and_side_control_gutter_geometry() { + let mut content = make_ruby_content("AB", "ab", 400.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.ruby_size = RubySize::Quarter; + span.ruby_side = RubySide::Under; + + let layout = layout_content(&content, 400.0); + let column = &layout.columns[0]; + let ruby = &layout.ruby_cells[0]; + + assert!((ruby.font_size - 5.0).abs() < 0.001); + assert!((column.base_offset - 5.0).abs() < 0.001); + assert!((column.width - column.base_width - 5.0).abs() < 0.001); + assert_eq!(ruby.side, RubySide::Under); + } + + #[test] + fn auto_clearance_stacks_ruby_and_emphasis_gutters() { + let mut legacy_content = make_ruby_content("漢字", "かんじ", 400.0); + legacy_content.paragraphs_mut()[0].children_mut()[0].text_emphasis = + TextEmphasis::FilledDot; + let legacy = layout_content(&legacy_content, 400.0); + + let mut auto_content = legacy_content.clone(); + auto_content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = + AnnotationClearance::Auto; + let auto = layout_content(&auto_content, 400.0); + + let legacy_gutter = legacy.columns[0].width - legacy.columns[0].base_width; + let auto_gutter = auto.columns[0].width - auto.columns[0].base_width; + assert!((legacy_gutter - 10.0).abs() < 0.001); + assert!((auto_gutter - 20.0).abs() < 0.001); + assert!(auto.emphasis_marks[0].outside_offset > 0.0); + } + + #[test] + fn ruby_attachment_distance_is_independent_of_line_height() { + let compact_content = make_ruby_content_with_line_height("AB", "ab", 400.0, 1.0); + let loose_content = make_ruby_content_with_line_height("AB", "ab", 400.0, 2.0); + let compact = layout_content(&compact_content, 400.0); + let loose = layout_content(&loose_content, 400.0); + + let attachment_gap = |layout: &VerticalLayout| { + let ruby = &layout.ruby_cells[0]; + let column = &layout.columns[ruby.column]; + let base_right = column_base_center(column) + ruby.base_font_size / 2.0; + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) - base_right + }; + + assert!( + loose.columns[0].base_width > compact.columns[0].base_width, + "line height must still increase column progression" + ); + assert!(attachment_gap(&compact).abs() < 0.001); + assert!(attachment_gap(&loose).abs() < 0.001); + + let loose_positions = position_data(&loose, &loose_content.bounds(), VerticalAlign::Top); + let ruby_position = loose_positions + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) + .expect("ruby position data"); + assert!( + (ruby_position.width - loose.ruby_cells[0].font_size).abs() < 0.001, + "export geometry must use the attached ruby strip, not the line-height band" + ); + } + + #[test] + fn vertical_ruby_preserves_every_fallback_run() { + let content = make_ruby_content("AB", "aあ", 400.0); + let font_mgr = FontMgr::new(); + let source = font_mgr.new_from_data(TEST_FONT, None).unwrap(); + let fallback = font_mgr.new_from_data(RUBY_FALLBACK_FONT, None).unwrap(); + let mut provider = TypefaceFontProvider::new(); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(source, Some(family.as_str())); + provider.register_typeface(fallback, Some("ruby-fallback")); + let fallback_mgr = FontMgr::from(provider.clone()); + let layout = layout_vertical( + &content, + 400.0, + &provider, + fallback_mgr, + &["ruby-fallback".to_string()], + content.bounds(), + ); + + let referenced_runs: std::collections::BTreeSet = layout + .ruby_cells + .iter() + .flat_map(|cell| cell.glyphs.iter().map(|glyph| glyph.run)) + .collect(); + assert_eq!(referenced_runs.len(), 2, "ruby must retain both typefaces"); + assert_eq!( + layout + .ruby_cells + .iter() + .map(|cell| cell.glyphs.len()) + .sum::(), + layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), + "every fallback glyph must be assigned to a ruby cell" + ); + + let position = position_data(&layout, &content.bounds(), VerticalAlign::Top); + let ruby_position = position + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) + .expect("ruby position data"); + assert_eq!(ruby_position.start_pos, 0); + assert_eq!(ruby_position.end_pos, 2); + + let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); + paint_layout( + surface.canvas(), + &layout, + &content.bounds(), + VerticalAlign::Top, + ); + } + + #[test] + fn ruby_selection_geometry_excludes_gutter() { + let content = make_ruby_content("AB", "ab", 400.0); + let layout = layout_content(&content, 400.0); + let base_width = layout.columns[0].base_width; + assert!(base_width < layout.columns[0].width); + + let pd = position_data(&layout, &content.bounds(), VerticalAlign::Top); + assert!(!pd.is_empty()); + for entry in pd.iter().filter(|e| e.direction == DIRECTION_VERTICAL_RL) { + assert!( + (entry.width - base_width).abs() < 0.01, + "position-data strip must be the base band, not the full column" + ); + } + + let rects = range_rects(&layout, 0, 0, 10); + assert!(!rects.is_empty()); + for rect in &rects { + assert!( + (rect.width() - base_width).abs() < 0.01, + "selection rect must be the base band, not the full column" + ); + } + } + + #[test] + fn no_ruby_keeps_base_width_equal_to_width() { + let content = make_content(&["AB"], 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout.ruby_cells.is_empty()); + for column in &layout.columns { + assert_eq!( + column.width, column.base_width, + "columns without ruby must not reserve a gutter" + ); + } + } + + #[test] + fn ruby_shorter_than_base_distributes_evenly() { + // One base char (extent 100) with 2 ruby glyphs at advance 50: the + // combined ruby line (100) equals the base, so glyphs sit in equal + // slots of 50, each centred (slot/2 - advance/2 = 0 offset). + let tops = distribute_ruby_tops( + 0.0, + 100.0, + 2, + 50.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ); + assert_eq!(tops.len(), 2); + assert!( + (tops[0] - 0.0).abs() < 0.001, + "first ruby glyph at slot start" + ); + assert!( + (tops[1] - 50.0).abs() < 0.001, + "second ruby glyph one slot down" + ); + + // A wide base (extent 200) with 2 glyphs of advance 50 must spread out + // (slot 100) rather than pack tight at advance 50. + let spread = distribute_ruby_tops( + 0.0, + 200.0, + 2, + 50.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ); + assert!( + (spread[0] - 25.0).abs() < 0.001, + "ruby glyph centred in its slot" + ); + assert!( + (spread[1] - spread[0] - 100.0).abs() < 0.001, + "even distribution keeps a full slot between glyphs, not the advance" + ); + } + + #[test] + fn ruby_longer_than_base_overhangs_symmetrically() { + // Base extent 40, four ruby glyphs of advance 20 (line 80 > 40): the + // block centres on the base and overhangs both ends. Overflow is 40, + // per-end overhang 20 (capped at one ruby em = 20), so it starts 20 + // above the base top. + let tops = distribute_ruby_tops( + 0.0, + 40.0, + 4, + 20.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ); + assert_eq!(tops.len(), 4); + assert!(tops[0] < 0.0, "long ruby overhangs above the base top"); + let block_center = (tops[0] + tops[3] + 20.0) / 2.0; + assert!( + (block_center - 20.0).abs() < 0.001, + "the ruby block stays centred on the base" + ); + } + + #[test] + fn ruby_alignment_modes_control_short_annotation_distribution() { + assert_eq!( + distribute_ruby_tops( + 0.0, + 20.0, + 2, + 4.0, + RubyAlign::SpaceAround, + RubyOverhang::Auto, + ), + vec![3.0, 13.0] + ); + assert_eq!( + distribute_ruby_tops(0.0, 20.0, 2, 4.0, RubyAlign::Center, RubyOverhang::Auto,), + vec![6.0, 10.0] + ); + assert_eq!( + distribute_ruby_tops(0.0, 20.0, 2, 4.0, RubyAlign::Start, RubyOverhang::Auto,), + vec![0.0, 4.0] + ); + assert_eq!( + distribute_ruby_tops( + 0.0, + 20.0, + 2, + 4.0, + RubyAlign::SpaceBetween, + RubyOverhang::Auto, + ), + vec![0.0, 16.0] + ); + } + + #[test] + fn ruby_overhang_none_keeps_long_annotation_at_base_start() { + let automatic = + distribute_ruby_tops(0.0, 10.0, 4, 4.0, RubyAlign::Center, RubyOverhang::Auto); + let constrained = + distribute_ruby_tops(0.0, 10.0, 4, 4.0, RubyAlign::Center, RubyOverhang::None); + + assert_eq!(automatic, vec![-3.0, 1.0, 5.0, 9.0]); + assert_eq!(constrained, vec![0.0, 4.0, 8.0, 12.0]); + } + + #[test] + fn ruby_side_attaches_to_opposite_base_edges() { + let column = VerticalColumn { + x: 10.0, + width: 40.0, + base_offset: 8.0, + base_width: 24.0, + }; + + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Over), 40.0); + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Under), 12.0); + } + + #[test] + fn ruby_base_spreading_expands_gap_for_long_reading() { + let content = make_ruby_content("日本", "にほんご", 120.0); + let layout = layout_content(&content, 120.0); + let ruby_cells: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell.span == 0 && cell.column == 0) + .collect(); + assert_eq!(ruby_cells.len(), 2); + let spacing = ruby_cells[1].top - ruby_cells[0].top; + assert!( + spacing > 20.0, + "long ruby should spread the base characters apart, got {}", + spacing + ); + let base_extent = ruby_cells[1].top + ruby_cells[1].extent - ruby_cells[0].top; + assert!( + base_extent >= 40.0, + "base span should be at least as long as the 4-glyph half-em ruby line" + ); + } + + #[test] + fn ruby_base_spreading_does_not_overlap_following_text() { + let content = make_content_with_spans(&["日本", "語"], 60.0); + let mut content = content; + content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんご".to_string(); + let layout = layout_content(&content, 60.0); + let mut same_column: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell.column == 0) + .collect(); + same_column.sort_by(|a, b| { + a.top + .partial_cmp(&b.top) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for pair in same_column.windows(2) { + assert!( + pair[0].top + pair[0].extent <= pair[1].top + 0.001, + "base spreading must not overlap the following cell" + ); + } + } + + #[test] + fn ruby_base_spreading_forces_room_when_no_slack() { + // Base 日本 (2 em = 40) with a 6-glyph half-em reading (60) followed + // by 語 in a 60 budget: there is no post-placement slack, so the base + // extents must grow before planning and push the follower to the next + // column instead of falling back to overhang. + let mut content = make_content_with_spans(&["日本", "語"], 60.0); + content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんごです".to_string(); + let layout = layout_content(&content, 60.0); + let base: Vec<&VerticalCell> = layout.cells.iter().filter(|c| c.span == 0).collect(); + assert_eq!(base.len(), 2); + let base_extent = + base.last().unwrap().top + base.last().unwrap().extent - base.first().unwrap().top; + assert!( + base_extent >= 60.0 - 0.001, + "the base span must grow to the ruby line length, got {}", + base_extent + ); + let follower = layout + .cells + .iter() + .find(|c| c.span == 1) + .expect("follower cell"); + assert_ne!( + follower.column, base[0].column, + "the follower must wrap to the next column instead of overlapping" + ); + } + + #[test] + fn ruby_base_and_reading_wrap_across_columns() { + let content = make_ruby_content("日本語文", "にほんごぶん", 40.0); + let layout = layout_content(&content, 40.0); + + let base_columns: std::collections::BTreeSet = + layout.cells.iter().map(|c| c.column).collect(); + assert_eq!(base_columns.len(), 2, "the ruby base must wrap normally"); + + let ruby_columns: std::collections::BTreeSet = + layout.ruby_cells.iter().map(|r| r.column).collect(); + assert_eq!(ruby_columns, base_columns); + assert!(!layout.ruby_cells.is_empty()); + let placed: usize = layout.ruby_cells.iter().map(|r| r.glyphs.len()).sum(); + assert_eq!( + placed, + layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), + "the reading is partitioned across columns without duplication or loss" + ); + assert!( + layout + .ruby_cells + .windows(2) + .all(|pair| pair[0].glyphs.last().unwrap().utf16_end + <= pair[1].glyphs.first().unwrap().utf16_start), + "ruby source ranges remain monotonic across the column break" + ); + } + + #[test] + fn tate_chu_yoko_span_becomes_one_upright_cell() { + let mut content = make_content_with_spans(&["20", "年"], 200.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + + let layout = layout_content(&content, 200.0); + + assert_eq!(layout.cells.len(), 2); + assert!(matches!(layout.cells[0].kind, CellKind::TateChuYoko { .. })); + assert_eq!(layout.cells[0].start, 0); + assert_eq!(layout.cells[0].end, 2); + assert_eq!(layout.cells[0].extent, 20.0); + assert_eq!(layout.cells[1].start, 2); + } + + #[test] + fn tate_chu_yoko_all_keeps_single_upright_ruby_base_at_full_size() { + let mut content = make_content_with_spans(&["く", "くくく"], 400.0); + let spans = content.paragraphs_mut()[0].children_mut(); + spans[0].ruby = "あ".to_string(); + for span in spans { + span.set_text_combine_upright(TextCombineUpright::All); + } + + let layout = layout_with(&vmtx_provider(), &content); + let ruby_base = layout + .cells + .iter() + .find(|cell| cell.span == 0) + .expect("ruby base cell"); + let following_base = layout + .cells + .iter() + .find(|cell| cell.span == 1) + .expect("following base cell"); + + assert!(matches!(ruby_base.kind, CellKind::Upright { .. })); + assert!(!layout.ruby_cells.is_empty(), "ruby annotation is present"); + assert_eq!(ruby_base.font_size, following_base.font_size); + assert!(layout + .cells + .iter() + .all(|cell| !matches!(cell.kind, CellKind::TateChuYoko { .. }))); + } + + #[test] + fn tate_chu_yoko_composes_covered_run_to_single_cell() { + // A CJK-covering face keeps the whole marked span in one upright + // composite cell (run_count >= 1); the next span is a separate cell. + let mut content = make_content_with_spans(&["くく", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_with(&vmtx_provider(), &content); + let CellKind::TateChuYoko { run_count, .. } = layout.cells[0].kind else { + panic!("expected a Tate-chu-yoko cell"); + }; + assert!(run_count >= 1, "composite references at least one run"); + assert_eq!(layout.cells[0].start, 0); + assert_eq!(layout.cells[0].end, 2); + assert_eq!(layout.cells[1].start, 2); + } + + #[test] + fn tate_chu_yoko_wide_run_falls_back_to_normal_layout() { + // A run far wider than the em would compress below MIN_TCY_SCALE; it is + // not combined into a squished composite but laid out normally. + let mut content = make_content_with_spans(&["123456789"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_content(&content, 400.0); + assert!( + !layout + .cells + .iter() + .any(|c| matches!(c.kind, CellKind::TateChuYoko { .. })), + "an over-wide run must not become a tate-chu-yoko composite" + ); + } + + #[test] + fn split_digit_runs_honours_the_max_parameter() { + // max 2: only exactly-two-digit runs combine. + let pieces = split_digit_runs("平成31年123日", 2); + let flags: Vec<(&str, bool)> = pieces + .iter() + .map(|(text, _, tcy)| (text.as_str(), *tcy)) + .collect(); + assert_eq!( + flags, + vec![ + ("平成", false), + ("31", true), + ("年", false), + ("123", false), + ("日", false), + ] + ); + // max 3 admits the three-digit run. + let pieces = split_digit_runs("平成31年123日", 3); + assert!(pieces.iter().any(|(text, _, tcy)| text == "123" && *tcy)); + } + + #[test] + fn split_digit_runs_marks_two_to_four_digit_runs() { + let pieces = split_digit_runs("平成31年12345日5", 4); + let flags: Vec<(&str, usize, bool)> = pieces + .iter() + .map(|(text, start, tcy)| (text.as_str(), *start, *tcy)) + .collect(); + assert_eq!( + flags, + vec![ + ("平成", 0, false), + ("31", 2, true), + ("年", 4, false), + ("12345", 5, false), + ("日", 10, false), + ("5", 11, false), + ] + ); + } + + #[test] + fn split_digit_runs_recognizes_full_width_digits() { + let pieces = split_digit_runs("2026夏号", 4); + let flags: Vec<(&str, bool)> = pieces + .iter() + .map(|(text, _, tcy)| (text.as_str(), *tcy)) + .collect(); + assert_eq!(flags, vec![("2026", true), ("夏号", false)]); + } + + #[test] + fn tate_chu_yoko_digits_combines_only_digit_runs() { + let mut content = make_content_with_spans(&["あ31く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&vmtx_provider(), &content); + let tcy: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::TateChuYoko { .. })) + .collect(); + assert_eq!(tcy.len(), 1, "exactly the digit run combines"); + assert_eq!(tcy[0].start, 1); + assert_eq!(tcy[0].end, 3); + // The surrounding characters keep their own cells in text order. + let starts: Vec = layout.cells.iter().map(|c| c.start).collect(); + let mut sorted = starts.clone(); + sorted.sort_unstable(); + assert_eq!(starts, sorted, "cells stay in text order"); + assert_eq!(layout.cells.len(), 3); + } + + #[test] + fn tate_chu_yoko_digits_combines_four_full_width_digits() { + let mut content = make_content_with_spans(&["2025年"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&vmtx_provider(), &content); + let tcy = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("four-digit run combines"); + assert_eq!(tcy.start, 0); + assert_eq!(tcy.end, 4); + } + + #[test] + fn tate_chu_yoko_digits_combines_full_width_unicode_digits() { + let mut content = make_content_with_spans(&["2026年"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&vmtx_provider(), &content); + let tcy = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("full-width digit run combines"); + assert_eq!(tcy.start, 0); + assert_eq!(tcy.end, 4); + } + + #[test] + fn tate_chu_yoko_all_uses_asymmetric_punctuation_aki() { + // 」→TCY keeps the closing mark's trailing half-em, and TCY→「 keeps + // the opening mark's leading half-em. TCY on the opposite side of + // either bracket sets solid. + let mut content = make_content_with_spans(&["く", "」", "20", "「", "く"], 400.0); + content.paragraphs_mut()[0].children_mut()[2] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_with(&vmtx_provider(), &content); + assert!(matches!(layout.cells[2].kind, CellKind::TateChuYoko { .. })); + for (index, label) in [(1, "」 before TCY"), (2, "TCY"), (3, "「 after TCY")] { + assert!( + (layout.cells[index].extent - 20.0).abs() < 0.01, + "{label} should occupy its preferred one-em frame, got {}", + layout.cells[index].extent + ); + } + + let mut reverse = make_content_with_spans(&["く", "「", "20", "」", "く"], 400.0); + reverse.paragraphs_mut()[0].children_mut()[2] + .set_text_combine_upright(TextCombineUpright::All); + let reverse = layout_with(&vmtx_provider(), &reverse); + assert!(matches!( + reverse.cells[2].kind, + CellKind::TateChuYoko { .. } + )); + assert!((reverse.cells[1].extent - 20.0).abs() < 0.01); + assert!((reverse.cells[3].extent - 20.0).abs() < 0.01); + } + + #[test] + fn tate_chu_yoko_digits_uses_the_same_cl30_adjacency() { + let mut content = make_content_with_spans(&["く」31「く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::Digits); + let layout = layout_with(&vmtx_provider(), &content); + let tcy_index = layout + .cells + .iter() + .position(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("digit TCY cell"); + assert!((layout.cells[tcy_index - 1].extent - 20.0).abs() < 0.01); + assert!((layout.cells[tcy_index].extent - 20.0).abs() < 0.01); + assert!((layout.cells[tcy_index + 1].extent - 20.0).abs() < 0.01); + } + + #[test] + fn caret_from_point_lands_inside_tcy_composite() { + let mut content = make_content_with_spans(&["1234", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_content(&content, 400.0); + let cell = &layout.cells[0]; + assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); + let column = &layout.columns[cell.column]; + let y_mid = cell.top + cell.extent / 2.0; + let (_, at_left) = caret_from_point(&layout, column.x + 0.5, y_mid).unwrap(); + let (_, at_right) = + caret_from_point(&layout, column.x + column.base_width - 0.5, y_mid).unwrap(); + assert!(at_left <= 1, "left edge maps near the start, got {at_left}"); + assert!( + at_right >= 3, + "right edge maps near the end, got {at_right}" + ); + } + + #[test] + fn caret_rect_tracks_horizontal_axis_inside_tcy_composite() { + let mut content = make_content_with_spans(&["1234", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0] + .set_text_combine_upright(TextCombineUpright::All); + let layout = layout_content(&content, 400.0); + let cell = &layout.cells[0]; + assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); + let left = column_base_center(&layout.columns[cell.column]) - cell.h_advance / 2.0; + let digit = cell.h_advance / 4.0; + for i in 0..4 { + let rect = caret_rect(&layout, 0, cell.start + i).expect("caret rect"); + assert!( + (rect.left - (left + i as f32 * digit)).abs() < 0.01, + "digit {i} caret sits at its horizontal slot, got {}", + rect.left + ); + assert!( + (rect.width() - digit).abs() < 0.01, + "digit {i} caret is one digit wide, got {}", + rect.width() + ); + assert!( + (rect.top - cell.top).abs() < 0.01 && (rect.height() - cell.extent).abs() < 0.01, + "digit {i} caret spans the composite's flow extent" + ); + } + // After the composite the caret falls back to the flow axis. + let rect = caret_rect(&layout, 0, cell.end).expect("caret rect"); + assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01); + } + + #[test] + fn warichu_split_balances_and_respects_kinsoku() { + // Balanced midpoint when nothing forbids it, first line longer. + assert_eq!(warichu_split_chars("あいうえおか"), 3); + assert_eq!(warichu_split_chars("あいうえお"), 3); + // A comma at the midpoint may end the first sub-line... + assert_eq!(warichu_split_chars("あい、うえ"), 3); + // ...but must not start the second one: the split moves forward. + assert_eq!(warichu_split_chars("あいう、えお"), 4); + // An opening bracket must not end the first sub-line. + assert_eq!(warichu_split_chars("あい「うえお"), 4); + // Pathological all-forbidden text keeps the midpoint. + assert_eq!(warichu_split_chars("、、、、"), 2); + } + + #[test] + fn warichu_cell_carries_kinsoku_split() { + let content = make_warichu_content("あいう、えお", 400.0); + let layout = layout_content(&content, 400.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Warichu { .. })) + .expect("a warichu cell"); + let CellKind::Warichu { first_chars, .. } = cell.kind else { + unreachable!(); + }; + assert_eq!( + first_chars, 4, + "the comma is pulled up into the first sub-line" + ); + // The caret for the second sub-line's first character restarts at + // the composite top, left of the axis. + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let rect = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); + assert!((rect.top - cell.top).abs() < 0.01); + assert!(rect.left < centre); + } + + #[test] + fn caret_rect_tracks_sub_lines_inside_warichu() { + let content = make_warichu_content("割注二行説明", 400.0); + let layout = layout_content(&content, 400.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Warichu { .. })) + .expect("a warichu cell"); + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let half = cell.font_size / 2.0; + // First sub-line (offsets 0..3) sits right of the column axis. + let first = caret_rect(&layout, 0, cell.start).expect("caret rect"); + assert!( + (first.left - centre).abs() < 0.01, + "first sub-line caret starts at the axis, got {}", + first.left + ); + assert!( + (first.width() - half).abs() < 0.01, + "sub-line caret is half-size wide, got {}", + first.width() + ); + assert!((first.top - cell.top).abs() < 0.01); + // Second sub-line (offsets 3..6) sits left of the axis, restarting + // at the composite top. + let second = caret_rect(&layout, 0, cell.start + 3).expect("caret rect"); + assert!( + (second.left - (centre - half)).abs() < 0.01, + "second sub-line caret sits left of the axis, got {}", + second.left + ); + assert!( + (second.top - cell.top).abs() < 0.01, + "second sub-line restarts at the composite top, got {}", + second.top + ); + // Offsets advance down the sub-line. + let deeper = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); + assert!( + deeper.top > second.top, + "later offsets move down the sub-line" + ); + } + + fn visible_flow_gap(previous: &VerticalCell, next: &VerticalCell) -> f32 { + next.top + next.ink_top - (previous.top + previous.ink_bottom) + } + + fn spaced_content(text: &str, letter_spacing: f32) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, 1000.0), + GrowType::Fixed, + ); + let mut span = make_span(text); + span.letter_spacing = letter_spacing; + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![span], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + #[test] + fn letter_spacing_extends_upright_cells() { + // Each upright cluster gains `letter_spacing` of flow advance, so + // cells stack further apart down the column; the centring width + // (`h_advance`) is unaffected. + let plain = layout_content(&spaced_content("あい", 0.0), 1000.0); + let spaced = layout_content(&spaced_content("あい", 5.0), 1000.0); + assert_eq!(plain.cells.len(), spaced.cells.len()); + for i in 0..plain.cells.len() { + assert!( + (spaced.cells[i].extent - plain.cells[i].extent - 5.0).abs() < 0.01, + "cell {i} extent grows by letter-spacing" + ); + assert!( + (spaced.cells[i].h_advance - plain.cells[i].h_advance).abs() < 0.01, + "cell {i} centring width is unchanged" + ); + } + assert!( + spaced.cells[1].top - plain.cells[1].top - 5.0 > -0.01, + "the second cell is pushed down by the spacing" + ); + } + + #[test] + fn letter_spacing_spreads_rotated_run() { + // A sideways Latin run grows by `letter_spacing` per glyph and its + // glyphs shift apart along the (post-rotation) column axis. + let plain = layout_content(&spaced_content("AB", 0.0), 1000.0); + let spaced = layout_content(&spaced_content("AB", 5.0), 1000.0); + let plain_cell = plain + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .expect("a rotated cell"); + let spaced_cell = spaced + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .expect("a rotated cell"); + let (CellKind::Rotated { run: plain_run }, CellKind::Rotated { run: spaced_run }) = + (plain_cell.kind, spaced_cell.kind) + else { + unreachable!(); + }; + let glyphs = spaced.runs[spaced_run].glyphs.len(); + assert!(glyphs >= 2, "AB shapes to at least two glyphs"); + assert!( + (spaced_cell.extent - plain_cell.extent - 5.0 * glyphs as f32).abs() < 0.01, + "rotated extent grows by letter_spacing * glyph count" + ); + let plain_gap = plain.runs[plain_run].positions[1].x - plain.runs[plain_run].positions[0].x; + let spaced_gap = + spaced.runs[spaced_run].positions[1].x - spaced.runs[spaced_run].positions[0].x; + assert!( + (spaced_gap - plain_gap - 5.0).abs() < 0.01, + "adjacent glyph gap grows by letter-spacing" + ); + } + + // A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the + // vertical kana repeat mark) has a 2em vertical advance vs a 1em + // horizontal advance; U+3042/U+304F are symmetric controls. + const VMTX_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vmtx-test.ttf"); + + fn vmtx_provider() -> TypefaceFontProvider { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(VMTX_TEST_FONT, None) + .expect("failed to load vmtx test font"); + let mut provider = TypefaceFontProvider::new(); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(typeface, Some(family.as_str())); + provider + } + + fn layout_with(provider: &TypefaceFontProvider, content: &TextContent) -> VerticalLayout { + let fallback_mgr = FontMgr::from(provider.clone()); + layout_vertical( + content, + 1000.0, + provider, + fallback_mgr, + &[], + content.bounds(), + ) + } + + #[test] + fn vertical_advance_uses_vmtx() { + // 〱 flows down the column by its 2em vertical advance (vmtx), not + // its 1em horizontal width; the glyph still centres on the 1em width. + let content = make_content(&["〱"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + let cell = &layout.cells[0]; + let CellKind::Upright { run, glyph, count } = cell.kind else { + panic!("expected an upright cell"); + }; + let horizontal: f32 = layout.runs[run].advances[glyph..glyph + count].iter().sum(); + assert!( + (horizontal - 20.0).abs() < 0.5, + "horizontal advance ~1em, got {horizontal}" + ); + assert!( + (cell.extent - 40.0).abs() < 0.5, + "vertical extent ~2em from vmtx, got {}", + cell.extent + ); + assert!( + (cell.h_advance - 20.0).abs() < 0.5, + "h_advance stays ~1em, got {}", + cell.h_advance + ); + } + + #[test] + fn shape_to_path_places_vertical_glyphs_down_the_column() { + let content = make_content(&["あく"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + + let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true); + + assert_eq!(paths.len(), 2, "one outline path per upright glyph"); + let first = paths[0].0.bounds(); + let second = paths[1].0.bounds(); + assert!( + second.top > first.top, + "the second glyph outline follows the first down the vertical flow axis" + ); + let horizontal_shift = (second.center_x() - first.center_x()).abs(); + let vertical_shift = second.center_y() - first.center_y(); + assert!( + vertical_shift > horizontal_shift, + "vertical flow dominates the glyphs' optical side-bearing difference" + ); + } + + #[test] + fn shape_to_path_preserves_the_text_blob_draw_origin() { + let content = make_content(&["あ"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + let cell = &layout.cells[0]; + let CellKind::Upright { run, glyph, count } = cell.kind else { + panic!("expected an upright cell"); + }; + let run = &layout.runs[run]; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&run.font, count, None); + let base_x = run.positions[glyph].x; + for i in 0..count { + glyphs[i] = run.glyphs[glyph + i]; + points[i] = SkPoint::new( + run.positions[glyph + i].x - base_x, + run.positions[glyph + i].y, + ); + } + let blob = builder.make().expect("a glyph text blob"); + let blob_bounds = *blob.bounds(); + let draw_origin = SkPoint::new(120.0, 340.0); + let mut normalized_blob = blob.clone(); + let normalized_path = SkiaParagraph::get_path(&mut normalized_blob); + let normalized_bounds = normalized_path.bounds(); + + let path = text_blob_path(blob, draw_origin); + let path_bounds = path.bounds(); + + assert!( + (path_bounds.left - (draw_origin.x + blob_bounds.left + normalized_bounds.left)).abs() + < 0.01 + ); + assert!( + (path_bounds.top - (draw_origin.y + blob_bounds.top + normalized_bounds.top)).abs() + < 0.01 + ); + assert!((path_bounds.width() - normalized_bounds.width()).abs() < 0.01); + assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01); + } + + #[test] + fn vertical_advance_symmetric_glyph_unchanged() { + // A glyph whose vmtx equals its hmtx keeps extent == h_advance. + let content = make_content(&["く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + let cell = &layout.cells[0]; + assert!( + (cell.extent - 20.0).abs() < 0.5, + "extent ~1em, got {}", + cell.extent + ); + assert!( + (cell.h_advance - cell.extent).abs() < 0.01, + "symmetric glyph: extent == h_advance" + ); + } + + // Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the + // vertical alternates of 、。「」 halve their vertical advances (「 also + // lifts its ink by 481 units) and the あ/く alternates tighten by + // 58/60 units with small placement lifts. + const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf"); + + fn vpal_provider() -> TypefaceFontProvider { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(VPAL_TEST_FONT, None) + .expect("failed to load vpal test font"); + let mut provider = TypefaceFontProvider::new(); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(typeface, Some(family.as_str())); + provider + } + + fn vpal_content(text: &str, font_features: FontFeatures) -> TextContent { + let mut content = make_content(&[text], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].font_features = font_features; + content + } + + #[test] + fn native_vertical_alternates_are_not_synthetically_rotated() { + let layout = layout_with( + &vpal_provider(), + &vpal_content("「」、。", FontFeatures::None), + ); + assert_eq!(layout.cells.len(), 4); + assert!(layout + .cells + .iter() + .all(|cell| matches!(cell.kind, CellKind::Upright { .. }))); + } + + #[test] + fn vpal_tightens_upright_kana() { + let provider = vpal_provider(); + let plain = layout_with(&provider, &vpal_content("あ", FontFeatures::None)); + let tight = layout_with(&provider, &vpal_content("あ", FontFeatures::Vpal)); + assert!( + (plain.cells[0].extent - 20.0).abs() < 0.01, + "without vpal あ keeps its full em, got {}", + plain.cells[0].extent + ); + // あ's vertical alternate carries YAdvance -58, YPlacement 39. + let expected = 20.0 * (1000.0 - 58.0) / 1000.0; + assert!( + (tight.cells[0].extent - expected).abs() < 0.01, + "vpal advance delta tightens the extent, got {}", + tight.cells[0].extent + ); + let expected_shift = -20.0 * 39.0 / 1000.0; + assert!( + (tight.cells[0].glyph_flow_shift - expected_shift).abs() < 0.01, + "vpal placement lifts the drawn ink, got {}", + tight.cells[0].glyph_flow_shift + ); + } + + #[test] + fn vpal_punctuation_is_not_double_compressed() { + // 、's vertical alternate already halves its advance under vpal; + // the aki shed must not compress the half-width cell again. + let provider = vpal_provider(); + let layout = layout_with(&provider, &vpal_content("あ、あ", FontFeatures::Vpal)); + assert!( + (layout.cells[1].extent - 10.0).abs() < 0.01, + "、 is exactly half-width under vpal, got {}", + layout.cells[1].extent + ); + } + + #[test] + fn vpal_opening_bracket_uses_font_placement() { + // In the middle of a line a normal opening bracket keeps its leading + // aki. Under vpal its placement still comes from the font's own + // YPlacement (481 units), not a synthetic sequence shed. + let provider = vpal_provider(); + let shed = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::None)); + let vpal = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::Vpal)); + assert_eq!( + shed.cells[1].glyph_flow_shift, 0.0, + "without vpal the preferred aki needs no synthetic shift, got {}", + shed.cells[1].glyph_flow_shift + ); + let expected = -20.0 * 481.0 / 1000.0; + assert!( + (vpal.cells[1].glyph_flow_shift - expected).abs() < 0.01, + "with vpal 「 lifts by the font's placement delta, got {}", + vpal.cells[1].glyph_flow_shift + ); + assert!( + (vpal.cells[1].extent - 10.0).abs() < 0.01, + "「 is half-width under vpal, got {}", + vpal.cells[1].extent + ); + } + + #[test] + fn ordinary_closing_punctuation_keeps_preferred_aki() { + // In ordinary text, the comma and closing bracket keep their normal + // half-em glyph body plus half-em trailing aki: one em in total. + let content = make_content(&["く、く」く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + assert_eq!(layout.cells.len(), 5, "one cell per character"); + let em = 20.0; + assert!( + (layout.cells[0].extent - em).abs() < 1.0, + "leading ideograph keeps full advance, got {}", + layout.cells[0].extent + ); + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "、 before an ideograph keeps its aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[3].extent - em).abs() < 1.0, + "」 before an ideograph keeps its aki, got {}", + layout.cells[3].extent + ); + assert!( + (layout.cells[4].extent - em).abs() < 1.0, + "trailing ideograph keeps full advance, got {}", + layout.cells[4].extent + ); + } + + #[test] + fn closing_then_opening_keeps_half_em_aki() { + // く」「く: the closing bracket sheds its trailing half, but the + // opening bracket after it keeps its full em (leading half blank), so + // the pair keeps the half-em aki JIS X 4051 asks for instead of + // setting solid. + let content = make_content(&["く」「く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + assert_eq!(layout.cells.len(), 4, "one cell per character"); + let em = 20.0; + assert!( + layout.cells[1].extent <= 0.5 * em + 0.01, + "」 sheds its trailing aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "「 after a closing mark keeps its full em, got {}", + layout.cells[2].extent + ); + assert_eq!( + layout.cells[2].glyph_flow_shift, 0.0, + "the unshed opening bracket is not shifted" + ); + } + + #[test] + fn ordinary_opening_punctuation_keeps_preferred_aki() { + // く「く: the opening bracket keeps its leading half-em aki in the + // middle of a line, so its total advance remains one em. + let content = make_content(&["く「く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + assert_eq!(layout.cells.len(), 3, "one cell per character"); + let em = 20.0; + assert!( + (layout.cells[0].extent - em).abs() < 1.0, + "leading ideograph keeps full advance, got {}", + layout.cells[0].extent + ); + assert_eq!( + layout.cells[0].glyph_flow_shift, 0.0, + "ideograph is not shifted" + ); + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "「 keeps its leading aki, got {}", + layout.cells[1].extent + ); + assert_eq!( + layout.cells[1].glyph_flow_shift, 0.0, + "an uncompressed opening bracket is not shifted, got {}", + layout.cells[1].glyph_flow_shift + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "trailing ideograph keeps full advance, got {}", + layout.cells[2].extent + ); + } + + #[test] + fn consecutive_closing_punctuation_sets_solid_internally() { + let content = make_content(&["く。」く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + let em = 20.0; + assert!( + layout.cells[1].extent <= 0.5 * em + 0.01, + "。 sheds its internal trailing aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "the final 」 keeps the sequence's trailing aki, got {}", + layout.cells[2].extent + ); + } + + #[test] + fn consecutive_opening_punctuation_sets_solid_after_first() { + let content = make_content(&["く「『く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + let em = 20.0; + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "the first opening bracket keeps the sequence's leading aki, got {}", + layout.cells[1].extent + ); + assert!( + layout.cells[2].extent <= 0.5 * em + 0.01, + "the second opening bracket sets solid, got {}", + layout.cells[2].extent + ); + assert!(layout.cells[2].glyph_flow_shift < -0.01); + } + + #[test] + fn opening_bracket_at_column_head_is_tentsuki() { + let content = make_content(&["「く"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + let em = 20.0; + assert_eq!(layout.cells[0].top, 0.0); + assert!( + layout.cells[0].extent <= 0.5 * em + 0.01, + "line-head 「 sheds its leading aki, got {}", + layout.cells[0].extent + ); + assert!(layout.cells[0].glyph_flow_shift < -0.01); + } + + #[test] + fn line_end_punctuation_keeps_preferred_half_em_aki() { + let content = make_content(&["く、"], 40.0); + let layout = layout_with(&vmtx_provider(), &content); + assert_eq!(layout.cells[0].column, layout.cells[1].column); + assert!( + (layout.cells[1].extent - 20.0).abs() < 1.0, + "line-end 、 keeps a half-em after its glyph, got {}", + layout.cells[1].extent + ); + } + + #[test] + fn centered_punctuation_shift_centres_ink_in_em_body() { + assert!(is_centered_punctuation('・')); + assert!(is_centered_punctuation(':')); + assert!(is_centered_punctuation(';')); + assert!(!is_centered_punctuation('あ')); + // Ink hugging the bottom of the body (14..18 in a 20 body) shifts up so + // its midpoint (16) lands on the body midpoint (10): shift == -6. + let shift = centered_flow_shift(14.0, 18.0, 20.0); + assert!((shift + 6.0).abs() < 1e-4, "expected -6, got {shift}"); + // Already-centred ink needs no shift. + assert!(centered_flow_shift(8.0, 12.0, 20.0).abs() < 1e-4); + } + + #[test] + fn plain_ideographs_keep_full_advance() { + let content = make_content(&["くくく"], 1000.0); + let layout = layout_with(&vmtx_provider(), &content); + for cell in &layout.cells { + assert!( + (cell.extent - 20.0).abs() < 1.0, + "no punctuation: full advance kept, got {}", + cell.extent + ); + } + } + + #[test] + fn vertical_metrics_parse_vmtx() { + let font_mgr = FontMgr::new(); + let typeface = font_mgr.new_from_data(VMTX_TEST_FONT, None).unwrap(); + let font = Font::new(typeface, 20.0); + let vm = VerticalMetrics::from_font(&font).expect("vmtx present"); + // gid 1 = 〱 (2000 units), gid 3+ fall back to the last long metric. + assert!((vm.advance(1, 20.0) - 40.0).abs() < 0.01); + assert!((vm.advance(3, 20.0) - 20.0).abs() < 0.01); + assert!((vm.advance(99, 20.0) - 20.0).abs() < 0.01); + } + + #[test] + fn vertical_metrics_absent_without_vmtx() { + // The bundled Source Sans face carries no `vmtx`. + let font_mgr = FontMgr::new(); + let typeface = font_mgr.new_from_data(TEST_FONT, None).unwrap(); + let font = Font::new(typeface, 20.0); + assert!(VerticalMetrics::from_font(&font).is_none()); + } + + #[test] + fn layout_cells_tile_the_text() { + let text = "縦書きのAB12テスト。"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + let mut expected = 0usize; + for cell in &layout.cells { + assert_eq!(cell.paragraph, 0); + assert_eq!(cell.start, expected, "cells must tile without gaps"); + assert!(cell.end > cell.start); + expected = cell.end; + } + assert_eq!(expected, text.encode_utf16().count()); + } + + #[test] + fn layout_columns_respect_wrap_height() { + let content = make_content(&["あいうえおかきくけこ"], 100.0); + let layout = layout_content(&content, 60.0); + + assert!(layout.columns.len() > 1, "content must wrap into columns"); + for column_index in 0..layout.columns.len() { + let bottom = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max); + assert!(bottom <= 60.0 + 0.01, "column overflows the wrap height"); + } + // Columns advance leftward: column 0 is the rightmost. + assert!(layout.columns[0].x > layout.columns[1].x); + let total: f32 = layout.columns.iter().map(|c| c.width).sum(); + assert!((layout.width - total).abs() < 0.01); + } + + #[test] + fn layout_kinsoku_no_period_at_column_top() { + // Wrap height fits exactly 2 characters per column; the 。 after + // the second character would start column 2 without kinsoku. + let content = make_content(&["あい。うえお"], 100.0); + let cell_extent = { + let layout = layout_content(&content, 1000.0); + layout.cells[0].extent + }; + let layout = layout_content(&content, cell_extent * 2.0 + 0.1); + + for column_index in 0..layout.columns.len() { + let first = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .min_by(|a, b| a.top.partial_cmp(&b.top).unwrap()); + if let Some(first) = first { + if first.top == 0.0 && first.end - first.start == 1 { + let text: Vec = "あい。うえお".chars().collect(); + let c = text[first.start]; + assert!( + !forbidden_at_line_start(c), + "column starts with forbidden char {c}" + ); + } + } + } + } + + #[test] + fn layout_each_paragraph_starts_a_new_column() { + let content = make_content(&["あい", "うえ"], 1000.0); + let layout = layout_content(&content, 1000.0); + + assert_eq!(layout.paragraph_columns.len(), 2); + let (p0_start, p0_end) = layout.paragraph_columns[0]; + let (p1_start, _) = layout.paragraph_columns[1]; + assert_eq!(p0_start, 0); + assert_eq!(p0_end, p1_start); + assert!(layout + .cells + .iter() + .all(|c| (c.paragraph == 0) == (c.column < p0_end))); + } + + #[test] + fn layout_empty_paragraph_still_takes_a_column() { + let content = make_content(&["あ", "", "い"], 1000.0); + let layout = layout_content(&content, 1000.0); + assert_eq!(layout.columns.len(), 3); + assert_eq!(layout.paragraph_columns[1], (1, 2)); + } + + #[test] + fn caret_round_trip() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + for cell in &layout.cells { + let column = &layout.columns[cell.column]; + // A point in the upper half of the cell resolves to its start. + let (paragraph, offset) = caret_from_point( + &layout, + column.x + column.width / 2.0, + cell.top + cell.extent * 0.25, + ) + .expect("caret"); + assert_eq!(paragraph, 0); + assert_eq!(offset, cell.start); + + // caret_rect for that offset lands inside the same column and + // carries the character extent (for overtype carets). + let rect = caret_rect(&layout, paragraph, offset).expect("caret rect"); + assert!((rect.x() - column.x).abs() < 0.01); + let chars = (cell.end - cell.start).max(1); + assert!((rect.height() - cell.extent / chars as f32).abs() < 0.01); + } + + // After the last character there is no glyph to cover: zero height. + let end_offset = layout.cells.last().unwrap().end; + let rect = caret_rect(&layout, 0, end_offset).expect("caret rect"); + assert_eq!(rect.height(), 0.0); + } + + #[test] + fn non_bmp_caret_offsets_round_trip_through_editor_operations() { + let content = make_content(&["𠀀あ"], 1000.0); + let layout = layout_content(&content, 1000.0); + let first = &layout.cells[0]; + let second = &layout.cells[1]; + let column = &layout.columns[first.column]; + let x = column_base_center(column); + + let before = caret_from_point(&layout, x, first.top + first.extent * 0.25).unwrap(); + let after = caret_from_point(&layout, x, first.top + first.extent * 0.75).unwrap(); + assert_eq!(before, (0, 0)); + assert_eq!(after, (0, 1)); + + let rect = caret_rect(&layout, after.0, after.1).expect("caret rect"); + assert!((rect.top - second.top).abs() < 0.01); + assert!((rect.height() - second.extent).abs() < 0.01); + + let rects = range_rects(&layout, 0, 0, 1); + assert_eq!(rects.len(), 1); + assert!((rects[0].height() - first.extent).abs() < 0.01); + + let start = TextPositionWithAffinity::new_without_affinity(0, 0); + let end = TextPositionWithAffinity::new_without_affinity(after.0, after.1); + assert_eq!( + text_helpers::move_cursor_forward(&start, content.paragraphs(), false), + end + ); + assert_eq!( + text_helpers::move_cursor_backward(&end, content.paragraphs(), false), + start + ); + + let mut inserted = make_content(&["𠀀あ"], 1000.0); + assert_eq!( + text_helpers::insert_text_at_cursor(&mut inserted, &end, "X"), + Some(2) + ); + assert_eq!(inserted.paragraphs()[0].children()[0].text, "𠀀Xあ"); + + let mut deleted = make_content(&["𠀀あ"], 1000.0); + assert_eq!( + text_helpers::delete_char_before(&mut deleted, &end), + Some(start) + ); + assert_eq!(deleted.paragraphs()[0].children()[0].text, "あ"); + } + + #[test] + fn selection_rects_cover_the_range() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + let rects = range_rects(&layout, 0, 1, 3); + assert!(!rects.is_empty()); + let covered: f32 = rects.iter().map(|r| r.height()).sum(); + let expected: f32 = layout + .cells + .iter() + .filter(|c| c.start >= 1 && c.end <= 3) + .map(|c| c.extent) + .sum(); + assert!((covered - expected).abs() < 0.01); + } + + #[test] + fn position_data_merges_by_column_and_maps_span_offsets() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + + let data = position_data(&layout, &bounds, VerticalAlign::Top); + assert_eq!(data.len(), 1, "one column, one span => one entry"); + assert_eq!(data[0].start_pos, 0); + assert_eq!(data[0].end_pos, text.encode_utf16().count() as u32); + // Right-anchored: the strip ends at the bounds right edge. + assert!((data[0].x + data[0].width - bounds.right).abs() < 0.01); + } + + #[test] + fn position_data_keeps_expanded_transform_in_one_source_safe_strip() { + let mut content = make_content(&["AßB"], 1000.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.text_transform = Some(TextTransform::Uppercase); + span.text_orientation = TextOrientation::Upright; + let unwrapped = layout_content(&content, 1000.0); + let expanded_extent: f32 = unwrapped + .cells + .iter() + .filter(|cell| cell_source_utf16_range(&unwrapped, cell) == (1..2)) + .map(|cell| cell.extent) + .sum(); + let layout = layout_content(&content, expanded_extent + 0.01); + + let expanded: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell_source_utf16_range(&layout, cell) == (1..2)) + .collect(); + assert_eq!(expanded.len(), 2, "ß must shape as the two cells in SS"); + assert_eq!( + expanded[0].column, expanded[1].column, + "glyphs from one source character must not split across columns" + ); + + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + let ranges: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]); + } + + #[test] + fn position_data_emits_ruby_annotation_strips() { + let content = make_ruby_content("漢字", "かんじ", 400.0); + let layout = layout_content(&content, 400.0); + let bounds = content.bounds(); + let data = position_data(&layout, &bounds, VerticalAlign::Top); + + let base: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RL) + .collect(); + let ruby: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) + .collect(); + assert_eq!(base.len(), 1); + assert_eq!(ruby.len(), 1, "one annotated column => one ruby strip"); + assert_eq!(ruby[0].start_pos, 0); + assert_eq!(ruby[0].end_pos, 3, "offsets index the ruby string"); + // The strip sits in the gutter, to the right of the base band. + assert!(ruby[0].x >= base[0].x + base[0].width - 0.001); + assert!(ruby[0].width > 0.0); + assert!(ruby[0].height > 0.0); + } + + #[test] + fn position_data_ruby_offsets_are_utf16_for_non_bmp_readings() { + // Two surrogate-pair reading characters: 2 glyphs but 4 UTF-16 + // units. The strip offsets must slice the ruby string by UTF-16. + let content = make_ruby_content("\u{6f22}\u{5b57}", "\u{1d4aa}\u{1d4ab}", 400.0); + let layout = layout_content(&content, 400.0); + let bounds = content.bounds(); + let data = position_data(&layout, &bounds, VerticalAlign::Top); + let ruby: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) + .collect(); + assert_eq!(ruby.len(), 1); + assert_eq!(ruby[0].start_pos, 0); + assert_eq!( + ruby[0].end_pos, 4, + "surrogate pairs count two UTF-16 units each" + ); + } + + #[test] + fn horizontal_ruby_ranges_use_utf16_across_spans() { + let offset_map = crate::shapes::kinsoku::OffsetMap::default(); + let mut cursor = 0; + + assert_eq!( + next_horizontal_ruby_range(&offset_map, &mut cursor, "𠀀"), + 0..2 + ); + assert_eq!( + next_horizontal_ruby_range(&offset_map, &mut cursor, "漢"), + 2..3 + ); + } + + #[test] + fn split_counts_by_extent_drops_no_glyph() { + assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]); + assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]); + assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]); + assert_eq!( + split_counts_by_extent(&[1.0, 1.0, 1.0], 2) + .iter() + .sum::(), + 2 + ); + assert!(split_counts_by_extent(&[], 3).is_empty()); + } + + #[test] + fn block_axis_alignment_maps_start_center_end_to_right_center_left() { + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Top), 160.0); + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Center), 80.0); + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Bottom), 0.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), 0.0); + } + + #[test] + fn position_data_follows_block_axis_alignment() { + let content = make_content(&["あいう"], 1000.0); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + let top = position_data(&layout, &bounds, VerticalAlign::Top); + let center = position_data(&layout, &bounds, VerticalAlign::Center); + let bottom = position_data(&layout, &bounds, VerticalAlign::Bottom); + + assert!(top[0].x > center[0].x); + assert!(center[0].x > bottom[0].x); + assert!((bottom[0].x - bounds.left).abs() < 0.01); + } + + #[test] + fn wrapped_vertical_content_grows_across_columns() { + let content = make_content(&["あいうえおかきくけこ"], 60.0); + let layout = layout_content(&content, 60.0); + assert!(layout.columns.len() > 1); + assert!(layout.width > layout.columns[0].width); + assert!(layout.height <= 60.0 + 0.01); + } + + #[test] + fn layout_mixed_text_has_rotated_run() { + let content = make_content(&["あAB1い"], 1000.0); + let layout = layout_content(&content, 1000.0); + assert!(layout + .cells + .iter() + .any(|c| matches!(c.kind, CellKind::Rotated { .. }))); + // The rotated run covers the Latin range 1..4 (UTF-16). + let rotated = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .unwrap(); + assert_eq!(rotated.start, 1); + assert_eq!(rotated.end, 4); + } + + #[test] + fn mixed_spans_preserve_fill_color_and_opacity() { + let mut content = make_content_with_spans(&["あ", "A", "い"], 1000.0); + let colors = [ + skia::Color::from_argb(255, 255, 0, 0), + skia::Color::from_argb(128, 0, 255, 0), + skia::Color::from_argb(64, 0, 0, 255), + ]; + for (span, color) in content.paragraphs_mut()[0] + .children_mut() + .iter_mut() + .zip(colors) + { + span.fills = vec![Fill::Solid(SolidColor(color))]; + } + + let layout = layout_content(&content, 1000.0); + assert_eq!(layout.paints.len(), colors.len()); + for (paint, color) in layout.paints.iter().zip(colors) { + assert_eq!(paint.color(), color); + } + assert!(layout + .cells + .iter() + .any(|cell| matches!(cell.kind, CellKind::Rotated { .. }))); + } + + #[test] + fn inter_script_spacing_separates_cjk_and_latin() { + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; + + let cjk_latin = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + assert_eq!(cjk_latin.cells.len(), 2); + assert!( + (visible_flow_gap(&cjk_latin.cells[0], &cjk_latin.cells[1]) - expected_gap).abs() + < 0.01 + ); + + let latin_cjk = layout_content(&make_content(&["aあ"], 1000.0), 1000.0); + assert_eq!(latin_cjk.cells.len(), 2); + assert!( + (visible_flow_gap(&latin_cjk.cells[0], &latin_cjk.cells[1]) - expected_gap).abs() + < 0.01 + ); + } + + #[test] + fn inter_script_spacing_is_visually_symmetric_around_latin_run() { + let layout = layout_content(&make_content(&["うpenあ"], 1000.0), 1000.0); + assert_eq!(layout.cells.len(), 3); + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; + let before = visible_flow_gap(&layout.cells[0], &layout.cells[1]); + let after = visible_flow_gap(&layout.cells[1], &layout.cells[2]); + assert!((before - expected_gap).abs() < 0.01); + assert!((after - expected_gap).abs() < 0.01); + assert!((before - after).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_preserves_explicit_letter_spacing() { + let letter_spacing = 3.0; + let layout = layout_content(&spaced_content("うpenあ", letter_spacing), 1000.0); + assert_eq!(layout.cells.len(), 3); + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM + letter_spacing; + assert!((visible_flow_gap(&layout.cells[0], &layout.cells[1]) - expected_gap).abs() < 0.01); + assert!((visible_flow_gap(&layout.cells[1], &layout.cells[2]) - expected_gap).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_crosses_span_boundaries() { + let joined = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + let split = layout_content(&make_content_with_spans(&["あ", "a"], 1000.0), 1000.0); + assert_eq!(joined.cells.len(), split.cells.len()); + for (joined, split) in joined.cells.iter().zip(&split.cells) { + assert!((joined.top - split.top).abs() < 0.01); + assert!((joined.extent - split.extent).abs() < 0.01); + } + } + + #[test] + fn inter_script_spacing_excludes_punctuation_and_whitespace() { + let cjk_extent = layout_content(&make_content(&["あ"], 1000.0), 1000.0).cells[0].extent; + for text in ["あ.", "あ a"] { + let layout = layout_content(&make_content(&[text], 1000.0), 1000.0); + assert!( + (layout.cells[0].extent - cjk_extent).abs() < 0.01, + "{text:?} must not add spacing immediately after the CJK cell" + ); + } + } + + #[test] + fn oikomi_reduces_script_gap_before_oidashi() { + let natural = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + assert_eq!(natural.cells.len(), 2); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = natural_total - 2.0; + let adjusted = layout_content(&make_content(&["あa"], budget), budget); + assert_eq!(adjusted.cells[0].column, adjusted.cells[1].column); + let gap = visible_flow_gap(&adjusted.cells[0], &adjusted.cells[1]); + assert!( + (gap - (20.0 * INTER_SCRIPT_SPACING_EM - 2.0)).abs() < 0.01, + "oikomi should recover the two-pixel deficit from the script gap, got {gap}" + ); + } + + #[test] + fn oikomi_preserves_sentence_final_full_stop_aki() { + let natural = layout_content(&make_content(&["あ。あ"], 1000.0), 1000.0); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = natural_total - 2.0; + let adjusted = layout_content(&make_content(&["あ。あ"], budget), budget); + assert_ne!( + adjusted.cells[1].column, adjusted.cells[2].column, + "fixed full-stop aki must not be compressed to retain the next ideograph" + ); + assert!((adjusted.cells[1].extent - natural.cells[1].extent).abs() < 0.01); + } + + #[test] + fn capped_adjustment_redistributes_after_a_boundary_saturates() { + let allocations = capped_equal_allocations(&[(0, 1.0), (1, 3.0)], 3.0); + assert_eq!(allocations, vec![(0, 1.0), (1, 2.0)]); + } + + #[test] + fn western_word_space_uses_one_third_em() { + let without_space = layout_content(&make_content(&["ab"], 1000.0), 1000.0); + let with_space = layout_content(&make_content(&["a b"], 1000.0), 1000.0); + assert_eq!(without_space.cells.len(), 1); + assert_eq!(with_space.cells.len(), 1); + let added = with_space.cells[0].extent - without_space.cells[0].extent; + assert!( + (added - 20.0 * WESTERN_WORD_SPACING_EM).abs() < 0.01, + "word space should add one third em, got {added}" + ); + } + + #[test] + fn align_offset_helper_maps_edges() { + // Unbounded budget (auto-width): no shift. + assert_eq!( + align_offset_along_column(TextAlign::Center, f32::MAX, 40.0), + 0.0 + ); + // Left/Start anchors to the top. + assert_eq!(align_offset_along_column(TextAlign::Left, 100.0, 40.0), 0.0); + // Center splits the slack. + assert_eq!( + align_offset_along_column(TextAlign::Center, 100.0, 40.0), + 30.0 + ); + // Right/End pushes to the bottom. + assert_eq!( + align_offset_along_column(TextAlign::Right, 100.0, 40.0), + 60.0 + ); + assert_eq!(align_offset_along_column(TextAlign::End, 100.0, 40.0), 60.0); + // Overfull column: no negative shift. + assert_eq!( + align_offset_along_column(TextAlign::Right, 40.0, 100.0), + 0.0 + ); + } + + #[test] + fn text_align_shifts_column_cells() { + let text = "あいう"; + // Baseline (top-aligned) used length of the single column. + let used = { + let content = make_content_aligned(text, 1000.0, TextAlign::Left); + let layout = layout_content(&content, 1000.0); + layout + .cells + .iter() + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max) + }; + + let top_of = |align: TextAlign| { + let content = make_content_aligned(text, 1000.0, align); + let layout = layout_content(&content, 1000.0); + layout.cells[0].top + }; + + assert!(top_of(TextAlign::Left).abs() < 0.01); + assert!((top_of(TextAlign::Center) - (1000.0 - used) / 2.0).abs() < 0.5); + assert!((top_of(TextAlign::Right) - (1000.0 - used)).abs() < 0.5); + // Cells stay in reading order and keep tiling under the shift. + let content = make_content_aligned(text, 1000.0, TextAlign::Right); + let layout = layout_content(&content, 1000.0); + for pair in layout.cells.windows(2) { + assert!(pair[1].top >= pair[0].top); + } + } + + #[test] + fn text_align_auto_width_stays_top() { + // Auto-width columns are snug: alignment must not shift them. + crate::globals::design_init(); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, 60.0), + GrowType::AutoWidth, + ); + let mut paragraph = Paragraph::new( + TextAlign::Right, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![make_span("あいうえお")], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + + let layout = layout_content(&content, wrap_height(&content, 60.0)); + assert!(layout.cells[0].top.abs() < 0.01); + } + + #[test] + fn rotated_baseline_shift_centres_band() { + let top = -30.0; + let bottom = 10.0; + let shift = rotated_baseline_shift(top, bottom); + assert!(((top + bottom) / 2.0 + shift).abs() < f32::EPSILON); + // Symmetric metrics need no shift. + assert_eq!(rotated_baseline_shift(-20.0, 20.0), 0.0); + } + + #[test] + fn rotated_run_centres_actual_lowercase_ink() { + let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0); + let cell = &layout.cells[0]; + let CellKind::Rotated { run } = cell.kind else { + panic!("expected a rotated run"); + }; + let run = &layout.runs[run]; + let mut bounds = vec![skia::Rect::default(); run.glyphs.len()]; + run.font.get_bounds(&run.glyphs, &mut bounds, None); + let top = bounds + .iter() + .zip(&run.positions) + .map(|(bound, position)| bound.top + position.y) + .fold(f32::MAX, f32::min); + let bottom = bounds + .iter() + .zip(&run.positions) + .map(|(bound, position)| bound.bottom + position.y) + .fold(f32::MIN, f32::max); + let shift = run.rotated_baseline_shift; + assert!(((top + bottom) / 2.0 + shift).abs() < 0.01); + + // Lowercase ink does not occupy the face's full ascent/descent band; + // this guards against regressing to font-wide metric centring. + let (_, metrics) = run.font.metrics(); + let metrics_shift = rotated_baseline_shift(metrics.ascent, metrics.descent); + assert!((shift - metrics_shift).abs() > 0.1); + } + + #[test] + fn justify_fills_non_last_columns() { + use std::collections::BTreeMap; + let text = "あいうえお"; + // Uniform per-cell extent for the test font. + let e = { + let content = make_content_aligned(text, 1000.0, TextAlign::Left); + layout_content(&content, 1000.0).cells[0].extent + }; + // A 2.5-cell budget wraps into three columns: {0,1}, {2,3}, {4}. The + // first two break with 0.5e of slack; the last column is one cell. + let budget = e * 2.5; + let content = make_content_aligned(text, budget, TextAlign::Justify); + let layout = layout_content(&content, budget); + + let mut by_col: BTreeMap> = BTreeMap::new(); + for c in &layout.cells { + by_col.entry(c.column).or_default().push(c); + } + assert!(by_col.len() >= 2, "text must wrap into multiple columns"); + let last = *by_col.keys().max().unwrap(); + + for (&col, col_cells) in &by_col { + if col != last && col_cells.len() > 1 { + assert!(col_cells[0].top.abs() < 0.01, "col {col} top cell at top"); + let bottom = col_cells.last().unwrap().top + col_cells.last().unwrap().extent; + assert!( + (bottom - budget).abs() < 0.5, + "justified col {col} fills the budget, bottom {bottom} vs {budget}" + ); + } + } + // The last column keeps its natural top (start-aligned, not stretched). + assert!( + by_col[&last][0].top.abs() < 0.01, + "last column is not justified" + ); + } + + // ----------------------------------------------------------------- + // Strokes / shadows / decorations + // ----------------------------------------------------------------- + + use crate::shapes::{Stroke, StrokeStyle}; + + fn make_decorated_content(text: &str, decoration: TextDecoration) -> TextContent { + crate::globals::design_init(); + let mut span = make_span(text); + span.text_decoration = Some(decoration); + let mut content = TextContent::new( + MathRect::from_xywh(0.0, 0.0, 200.0, 1000.0), + GrowType::Fixed, + ); + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![span], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + content + } + + #[test] + fn cells_carry_font_size_and_decoration() { + let content = make_decorated_content("あい", TextDecoration::UNDERLINE); + let layout = layout_content(&content, 1000.0); + assert!(!layout.cells.is_empty()); + for cell in &layout.cells { + assert_eq!(cell.font_size, 20.0); + assert_eq!(cell.decoration, Some(TextDecoration::UNDERLINE)); + } + } + + #[test] + fn decoration_bar_underline_left_of_center_line_through_centered() { + let content = make_decorated_content("あい", TextDecoration::UNDERLINE); + let layout = layout_content(&content, 1000.0); + let (ox, oy) = layout.origin(&content.bounds(), VerticalAlign::Top); + let cell = &layout.cells[0]; + let column = &layout.columns[cell.column]; + let x_center = ox + column.x + column.width / 2.0; + + let underline = decoration_bar(&layout, cell, ox, oy, false); + let strike = decoration_bar(&layout, cell, ox, oy, true); + + // Underline sits left of the column axis; line-through is centered. + assert!(underline.center_x() < x_center); + assert!((strike.center_x() - x_center).abs() < 0.01); + // Both bars span the cell's vertical extent. + assert!((underline.height() - cell.extent).abs() < 0.01); + // Bar thickness is at least 1px. + assert!(underline.width() >= 1.0 - 0.01); + } + + #[test] + fn paint_passes_do_not_panic() { + let content = make_decorated_content("あいAB。", TextDecoration::LINE_THROUGH); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + let selrect = content.bounds(); + + let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); + let canvas = surface.canvas(); + + // Shape transforms are applied on the caller's canvas. Exercise a + // non-trivial transform with mixed upright/rotated cells so every + // vertical paint pass remains transform-safe. + canvas.translate((12.0, 8.0)); + canvas.rotate(7.0, Some((64.0, 64.0).into())); + + paint_layout(canvas, &layout, &bounds, VerticalAlign::Top); + paint_drop_shadow( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &Paint::default(), + ); + + // With a real drop-shadow image filter (as `drop_shadow_paints` builds). + let mut shadow_paint = Paint::default(); + shadow_paint.set_image_filter(skia::image_filters::drop_shadow( + (12.0, 12.0), + (6.0, 6.0), + skia::Color::from_argb(230, 0, 0, 255), + None, + None, + None, + )); + paint_drop_shadow(canvas, &layout, &bounds, VerticalAlign::Top, &shadow_paint); + + for kind in [ + Stroke::new_center_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + Stroke::new_inner_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + Stroke::new_outer_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + ] { + paint_stroke( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &kind, + &selrect, + None, + ); + } + } +} diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 3216586998..22815a3d9e 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -863,8 +863,24 @@ impl TextEditorState { TextDirection::LTR }; + // In vertical-rl the physical arrow keys map onto logical navigation + // differently: Up/Down walk characters along the column, and Left/Right + // cross columns (columns advance right-to-left). + let is_vertical = text_content.is_vertical(); + let direction = if is_vertical { + match direction { + CursorDirection::Backward => CursorDirection::LineAfter, // Left -> next column + CursorDirection::Forward => CursorDirection::LineBefore, // Right -> prev column + CursorDirection::LineBefore => CursorDirection::Backward, // Up -> prev char + CursorDirection::LineAfter => CursorDirection::Forward, // Down -> next char + other => other, + } + } else { + direction + }; + // For horizontal navigation, swap Backward/Forward when in RTL text - let adjusted_direction = if text_span_text_direction == TextDirection::RTL { + let adjusted_direction = if !is_vertical && text_span_text_direction == TextDirection::RTL { match direction { CursorDirection::Backward => CursorDirection::Forward, CursorDirection::Forward => CursorDirection::Backward, diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 6e2575741f..b06a8d8899 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -4,7 +4,9 @@ use super::{fills::RawFillData, fonts::RawFontStyle}; use crate::mem::{self, SerializableResult}; use crate::shapes::{ - self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, + self, AnnotationClearance, FontFeatures, GrowType, RubyAlign, RubyOverhang, RubySide, RubySize, + Shape, TextAlign, TextCombineUpright, TextDecoration, TextDirection, TextEmphasis, + TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{with_current_shape, with_current_shape_mut, with_state}; @@ -18,6 +20,8 @@ const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); const MAX_TEXT_FILLS: usize = 8; +// CHANGEME: Move all the types from japanes text layout to its own module + #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] pub enum RawTextAlign { @@ -54,6 +58,214 @@ impl From 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 { @@ -103,6 +315,11 @@ pub struct RawParagraphData { text_direction: RawTextDirection, text_decoration: RawTextDecoration, text_transform: RawTextTransform, + writing_mode: RawWritingMode, + text_orientation: RawTextOrientation, + // Explicit padding so the CLJS writer and this struct agree on a + // 4-byte-aligned layout; always written as zero. + _padding: [u8; 2], line_height: f32, letter_spacing: f32, } @@ -131,6 +348,19 @@ pub struct RawTextSpan { text_decoration: RawTextDecoration, text_transform: RawTextTransform, text_direction: RawTextDirection, + text_orientation: RawTextOrientation, + text_combine_upright: RawTextCombineUpright, + text_emphasis: RawTextEmphasis, + warichu: RawWarichu, + font_features: RawFontFeatures, + annotation_clearance: RawAnnotationClearance, + ruby_size: RawRubySize, + ruby_align: RawRubyAlign, + ruby_overhang: RawRubyOverhang, + ruby_side: RawRubySide, + // Explicit padding so the CLJS writer and this struct agree on a + // 4-byte-aligned layout; always written as zero. + _padding: [u8; 2], font_size: f32, line_height: f32, letter_spacing: f32, @@ -139,6 +369,7 @@ pub struct RawTextSpan { font_family: [u8; 4], font_variant_id: [u32; 4], // TODO: maybe add RawUUID type text_length: u32, + ruby_length: u32, fill_count: u32, fills: [RawFillData; MAX_TEXT_FILLS], } @@ -177,7 +408,7 @@ impl From for shapes::TextSpan { .map(|fill| fill.into()) .collect(); - Self::new( + let mut span = Self::new( text, font_family, value.font_size, @@ -189,7 +420,18 @@ impl From for shapes::TextSpan { value.font_weight, uuid_from_u32(value.font_variant_id), fills, - ) + ); + span.set_text_orientation(value.text_orientation.into()); + span.set_text_combine_upright(value.text_combine_upright.into()); + span.set_text_emphasis(value.text_emphasis.into()); + span.set_warichu(value.warichu.into()); + span.set_font_features(value.font_features.into()); + span.set_annotation_clearance(value.annotation_clearance.into()); + span.set_ruby_size(value.ruby_size.into()); + span.set_ruby_align(value.ruby_align.into()); + span.set_ruby_overhang(value.ruby_overhang.into()); + span.set_ruby_side(value.ruby_side.into()); + span } } @@ -230,21 +472,31 @@ impl From for shapes::Paragraph { fn from(value: RawParagraph) -> Self { let mut spans = vec![]; + // Layout: [ ]. Annotation blobs + // begin after all base text. let mut offset = 0; + let mut ruby_offset: usize = value.spans.iter().map(|s| s.text_length as usize).sum(); for raw_span in value.spans.into_iter() { let delta = raw_span.text_length as usize; - let text_buffer = &value.text_buffer[offset..offset + delta]; - + let text_buffer = value.text_buffer.get(offset..offset + delta).unwrap_or(&[]); + let ruby_delta = raw_span.ruby_length as usize; + let ruby_buffer = value + .text_buffer + .get(ruby_offset..ruby_offset + ruby_delta) + .unwrap_or(&[]); let mut span = shapes::TextSpan::from(raw_span); if !text_buffer.is_empty() { span.set_text(String::from_utf8_lossy(text_buffer).to_string()); } - + if !ruby_buffer.is_empty() { + span.set_ruby(String::from_utf8_lossy(ruby_buffer).to_string()); + } spans.push(span); offset += delta; + ruby_offset += ruby_delta; } - shapes::Paragraph::new( + let mut paragraph = shapes::Paragraph::new( value.attrs.text_align.into(), value.attrs.text_direction.into(), value.attrs.text_decoration.into(), @@ -252,7 +504,10 @@ impl From for shapes::Paragraph { value.attrs.line_height, value.attrs.letter_spacing, spans, - ) + ); + paragraph.set_writing_mode(value.attrs.writing_mode.into()); + paragraph.set_text_orientation(value.attrs.text_orientation.into()); + paragraph } } @@ -444,3 +699,78 @@ pub extern "C" fn calculate_position_data() -> *mut u8 { }); mem::write_vec(result) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16) + /// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80) + /// attr bytes before the fills block. These sizes must move in + /// lockstep with the struct layouts. + #[test] + fn raw_struct_sizes_match_cljs_writer() { + const PARAGRAPH_ATTR_U8_SIZE: usize = 16; + const SPAN_ATTR_U8_SIZE: usize = 80; + assert_eq!(RAW_PARAGRAPH_DATA_SIZE, 4 + PARAGRAPH_ATTR_U8_SIZE); + assert_eq!( + RAW_SPAN_DATA_SIZE, + SPAN_ATTR_U8_SIZE + MAX_TEXT_FILLS * std::mem::size_of::() + ); + } + + #[test] + fn raw_text_combine_upright_counts_deserialize() { + // Byte 5 of the span attr block; the counted digits variants map + // onto their max run length. + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[5] = RawTextCombineUpright::Digits2 as u8; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!(span.text_combine_upright.digits_max(), Some(2)); + + bytes[5] = RawTextCombineUpright::Digits3 as u8; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!(span.text_combine_upright.digits_max(), Some(3)); + + bytes[5] = RawTextCombineUpright::Digits as u8; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!(span.text_combine_upright.digits_max(), Some(4)); + } + + #[test] + fn raw_font_features_deserializes_from_reserved_span_byte() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[8] = RawFontFeatures::Vpal as u8; + + let raw = RawTextSpan::from(bytes); + let span = shapes::TextSpan::from(raw); + + assert_eq!(span.font_features, FontFeatures::Vpal); + } + + #[test] + fn raw_annotation_clearance_deserializes_from_reserved_span_byte() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[9] = RawAnnotationClearance::Auto as u8; + + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + + assert_eq!(span.annotation_clearance, AnnotationClearance::Auto); + } + + #[test] + fn raw_ruby_customization_deserializes_from_span_bytes() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[10] = RawRubySize::Quarter as u8; + bytes[11] = RawRubyAlign::SpaceBetween as u8; + bytes[12] = RawRubyOverhang::None as u8; + bytes[13] = RawRubySide::Under as u8; + + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + + assert_eq!(span.ruby_size, RubySize::Quarter); + assert_eq!(span.ruby_align, RubyAlign::SpaceBetween); + assert_eq!(span.ruby_overhang, RubyOverhang::None); + assert_eq!(span.ruby_side, RubySide::Under); + } +} diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index 67044b4788..8d97023f13 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -811,6 +811,8 @@ pub fn split_paragraph_at_cursor( let text_direction = para.text_direction(); let text_decoration = para.text_decoration(); let text_transform = para.text_transform(); + let writing_mode = para.writing_mode(); + let text_orientation = para.text_orientation(); let line_height = para.line_height(); let letter_spacing = para.letter_spacing(); @@ -826,7 +828,7 @@ pub fn split_paragraph_at_cursor( span.set_text(new_text); } - let new_para = crate::shapes::Paragraph::new( + let mut new_para = crate::shapes::Paragraph::new( text_align, text_direction, text_decoration, @@ -835,6 +837,8 @@ pub fn split_paragraph_at_cursor( letter_spacing, new_para_children, ); + new_para.set_writing_mode(writing_mode); + new_para.set_text_orientation(text_orientation); paragraphs.insert(cursor.paragraph + 1, new_para); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 1ee391b40b..a21230a5d6 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -5,6 +5,7 @@ use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; +use crate::shapes::text_vertical; use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::{TextEditorEvent, TextSelection}; use crate::utils::uuid_from_u32_quartet; @@ -147,7 +148,9 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { }; let point = Point::new(x, y); - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = + text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align()) + { get_text_editor_state().select_word_boundary(text_content, &position); } }) @@ -179,7 +182,9 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { }; let point = Point::new(x, y); get_text_editor_state().start_pointer_selection(); - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = + text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align()) + { get_text_editor_state().set_caret_from_position(&position); get_text_editor_state().update_styles(text_content); } @@ -210,7 +215,9 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { return; }; - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = + text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align()) + { get_text_editor_state().extend_selection_from_position(&position); // We need this flag to prevent handling the click behavior // just after a pointerup event. @@ -239,7 +246,9 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = + text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align()) + { get_text_editor_state().extend_selection_from_position(&position); get_text_editor_state().update_styles(text_content); } @@ -274,7 +283,9 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { return; }; - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = + text_content.get_caret_position_from_shape_coords(&point, shape.vertical_align()) + { get_text_editor_state().set_caret_from_position(&position); } }); @@ -299,9 +310,12 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_screen_coords( + &point, + &view_matrix, + &shape_matrix, + shape.vertical_align(), + ) { get_text_editor_state().set_caret_from_position(&position); } }); @@ -1035,6 +1049,24 @@ fn get_cursor_rect( return None; } + // Vertical writing: the caret is a thin horizontal bar across the + // column, computed from the vertical cells. + if text_content.is_vertical() { + let selrect = shape.selrect(); + let max_height = text_vertical::wrap_height(text_content, selrect.height()); + let layout = text_vertical::layout_from_content(text_content, max_height); + let (origin_x, origin_y) = layout.origin(&selrect, shape.vertical_align()); + let rect = text_vertical::caret_rect(&layout, cursor.paragraph, cursor.offset)?; + // The rect height is the extent of the character at the cursor, + // matching the line height reported by the horizontal path. + return Some(Rect::from_xywh( + origin_x + rect.x(), + origin_y + rect.y(), + rect.width(), + rect.height().max(1.0), + )); + } + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); @@ -1047,7 +1079,10 @@ fn get_cursor_rect( let mut y_offset = valign_offset; for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { if idx == cursor.paragraph { - let char_pos = cursor.offset; + // Cursor offsets live in original text space; the laid-out + // paragraph indexes the kinsoku-shifted builder text. + let (_, offset_map) = paragraphs[cursor.paragraph].layout_span_texts(); + let char_pos = offset_map.to_shifted(cursor.offset); use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let rects = laid_out_para.get_rects_for_range( @@ -1088,6 +1123,46 @@ fn get_selection_rects( let end = selection.end(); let paragraphs = text_content.paragraphs(); + + // Vertical writing: selection rectangles come from the vertical cells. + if text_content.is_vertical() { + let selrect = shape.selrect(); + let max_height = text_vertical::wrap_height(text_content, selrect.height()); + let layout = text_vertical::layout_from_content(text_content, max_height); + let (origin_x, origin_y) = layout.origin(&selrect, shape.vertical_align()); + for (para_idx, paragraph) in paragraphs + .iter() + .enumerate() + .take(end.paragraph + 1) + .skip(start.paragraph) + { + let para_char_count: usize = paragraph + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let range_start = if para_idx == start.paragraph { + start.offset + } else { + 0 + }; + let range_end = if para_idx == end.paragraph { + end.offset + } else { + para_char_count + }; + for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) { + rects.push(Rect::from_xywh( + origin_x + rect.x(), + origin_y + rect.y(), + rect.width(), + rect.height(), + )); + } + } + return rects; + } + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); let selrect = shape.selrect(); @@ -1133,6 +1208,12 @@ fn get_selection_rects( }; if range_start < range_end { + // Selection offsets live in original text space; the + // laid-out paragraph indexes the kinsoku-shifted text. + let (_, offset_map) = para.layout_span_texts(); + let range_start = offset_map.to_shifted(range_start); + let range_end = offset_map.to_shifted(range_end); + use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let text_boxes = laid_out_para.get_rects_for_range( range_start..range_end,