diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 66b2f2fb88..4e0202891a 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -13,6 +13,7 @@ [app.common.schema :as sm] [app.common.types.fills :as types.fills] [app.common.types.text :as txt] + [app.common.types.text.japanese-layout :as jl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.wasm-text :as dwwt] @@ -90,6 +91,105 @@ (recur (when continue? (rest styles)) taking? to result)) result)))) +(def ^:private japanese-range-defaults + ;; A range without ruby reports null to plugins. + (assoc jl/span-attr-defaults :ruby nil)) + +(defn- shape-japanese-value + "Value of a Japanese span `attr` over a whole text shape proxy, with unset + spans at their default: 'mixed' when spans differ." + [shape-proxy attr] + (let [default (get japanese-range-defaults attr) + values (->> (-> shape-proxy u/proxy->shape :content) + (txt/node-seq txt/is-text-node?) + (map #(get % attr default)))] + (if (seq values) (u/mixed-value values) default))) + +(defn- whole-shape-value + "Value of a whole-shape paragraph `attr` (writing mode, orientation): the + first paragraph's, which every paragraph shares, or the default." + [shape-proxy attr] + (-> shape-proxy u/proxy->shape :content (jl/whole-shape-attr attr) (d/nilv (jl/enum-default attr)))) + +(defn- range-japanese-value + "Value of a Japanese span `attr` over a text range proxy's characters." + [range-proxy start end attr] + (let [default (get japanese-range-defaults attr)] + (->> (-> range-proxy u/proxy->shape :content (content-range->text+styles start end)) + (map #(get % attr default)) + (u/mixed-value)))) + +(defn- enum-value? + "Validator accepting the values of the Japanese layout enum `attr`." + [attr] + (fn [value] + (jl/valid-enum-value? attr value))) + +(defn- optional-string? + [value] + (or (nil? value) (string? value))) + +(defn- attr-setter + "Property setter that checks `valid?` and the plugin's write access before + emitting `(update-event self attrs)` with `{attr value}`." + [plugin-id page-id prop attr valid? update-event] + (fn [self value] + (cond + (not (valid? value)) + (u/not-valid plugin-id prop value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id prop "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id prop "Cannot modify a page that is not currently active") + + :else + (st/emit! (update-event self {attr value}))))) + +(defn- range-attr-setter + "Setter of a span attribute over the characters [start, end) of shape `id`." + [plugin-id page-id id start end prop attr valid?] + (attr-setter plugin-id page-id prop attr valid? + (fn [_ attrs] (dwt/update-text-range id start end attrs)))) + +(defn- shape-attr-setter + "Setter of a text attribute over the whole text shape." + [plugin-id page-id prop attr valid?] + (attr-setter plugin-id page-id prop attr valid? + (fn [self attrs] (dwt/update-attrs (obj/get self "$id") attrs)))) + +(def ^:private multi-span-annotation-message + "A ruby reading or warichu note must cover characters of a single text span") + +(defn- annotating? + "True when setting `attr` to `value` adds a ruby reading or a warichu note." + [attr value] + (case attr + :ruby (and (string? value) (not (str/blank? value))) + :warichu (= "warichu" value) + false)) + +(defn- annotation-setter + "Wraps `setter` so a reading or note is only added over characters [start, + end) of one text span: copying it to several spans would repeat it." + [plugin-id prop attr start end setter] + (fn [self value] + (if (and (annotating? attr value) + (not (dwt/single-span-range? (-> self u/proxy->shape :content) start end))) + (u/not-valid plugin-id prop multi-span-annotation-message) + (setter self value)))) + +(defn- range-annotation-setter + [plugin-id page-id id start end prop attr valid?] + (annotation-setter plugin-id prop attr start end + (range-attr-setter plugin-id page-id id start end prop attr valid?))) + +(defn- shape-annotation-setter + [plugin-id page-id prop attr valid?] + (annotation-setter plugin-id prop attr 0 ##Inf + (shape-attr-setter plugin-id page-id prop attr valid?))) + (defn text-range-proxy? [range] (obj/type-of? range "TextRange")) @@ -362,6 +462,61 @@ :else (st/emit! (dwt/update-text-range id start end {:text-decoration value}))))} + :fontFeatures + {:this true + :get (fn [self] (range-japanese-value self start end :font-features)) + :set (range-attr-setter plugin-id page-id id start end :fontFeatures :font-features (enum-value? :font-features))} + + :textCombineUpright + {:this true + :get (fn [self] (range-japanese-value self start end :text-combine-upright)) + :set (range-attr-setter plugin-id page-id id start end :textCombineUpright :text-combine-upright (enum-value? :text-combine-upright))} + + :textEmphasis + {:this true + :get (fn [self] (range-japanese-value self start end :text-emphasis)) + :set (range-attr-setter plugin-id page-id id start end :textEmphasis :text-emphasis (enum-value? :text-emphasis))} + + :warichu + {:this true + :get (fn [self] (range-japanese-value self start end :warichu)) + :set (range-annotation-setter plugin-id page-id id start end :warichu :warichu (enum-value? :warichu))} + + :annotationClearance + {:this true + :get (fn [self] (range-japanese-value self start end :annotation-clearance)) + :set (range-attr-setter plugin-id page-id id start end :annotationClearance :annotation-clearance (enum-value? :annotation-clearance))} + + :ruby + {:this true + :get (fn [self] (range-japanese-value self start end :ruby)) + :set (range-annotation-setter plugin-id page-id id start end :ruby :ruby optional-string?)} + + :rubyHidden + {:this true + :get (fn [self] (range-japanese-value self start end :ruby-hidden)) + :set (range-attr-setter plugin-id page-id id start end :rubyHidden :ruby-hidden boolean?)} + + :rubySize + {:this true + :get (fn [self] (range-japanese-value self start end :ruby-size)) + :set (range-attr-setter plugin-id page-id id start end :rubySize :ruby-size (enum-value? :ruby-size))} + + :rubyAlign + {:this true + :get (fn [self] (range-japanese-value self start end :ruby-align)) + :set (range-attr-setter plugin-id page-id id start end :rubyAlign :ruby-align (enum-value? :ruby-align))} + + :rubyOverhang + {:this true + :get (fn [self] (range-japanese-value self start end :ruby-overhang)) + :set (range-attr-setter plugin-id page-id id start end :rubyOverhang :ruby-overhang (enum-value? :ruby-overhang))} + + :rubySide + {:this true + :get (fn [self] (range-japanese-value self start end :ruby-side)) + :set (range-attr-setter plugin-id page-id id start end :rubySide :ruby-side (enum-value? :ruby-side))} + :direction {:this true :get @@ -756,5 +911,76 @@ :else (st/emit! (dwt/update-attrs id {:vertical-align value})))))} + {:name "lineAdjustment" + :get #(-> % u/proxy->shape text-props :line-adjustment + (d/nilv (jl/enum-default :line-adjustment))) + :set + (fn [self value] + (let [id (obj/get self "$id")] + (cond + (not (jl/valid-enum-value? :line-adjustment value)) + (u/not-valid plugin-id :lineAdjustment value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :lineAdjustment "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :lineAdjustment "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwt/update-attrs id {:line-adjustment value})))))} + + {:name "writingMode" + :get #(whole-shape-value % :writing-mode) + :set (shape-attr-setter plugin-id page-id :writingMode :writing-mode (enum-value? :writing-mode))} + + {:name "textOrientation" + :get #(whole-shape-value % :text-orientation) + :set (shape-attr-setter plugin-id page-id :textOrientation :text-orientation (enum-value? :text-orientation))} + + {:name "textCombineUpright" + :get #(shape-japanese-value % :text-combine-upright) + :set (shape-attr-setter plugin-id page-id :textCombineUpright :text-combine-upright (enum-value? :text-combine-upright))} + + {:name "textEmphasis" + :get #(shape-japanese-value % :text-emphasis) + :set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? :text-emphasis))} + + {:name "warichu" + :get #(shape-japanese-value % :warichu) + :set (shape-annotation-setter plugin-id page-id :warichu :warichu (enum-value? :warichu))} + + {:name "fontFeatures" + :get #(shape-japanese-value % :font-features) + :set (shape-attr-setter plugin-id page-id :fontFeatures :font-features (enum-value? :font-features))} + + {:name "annotationClearance" + :get #(shape-japanese-value % :annotation-clearance) + :set (shape-attr-setter plugin-id page-id :annotationClearance :annotation-clearance (enum-value? :annotation-clearance))} + + {:name "ruby" + :get #(shape-japanese-value % :ruby) + :set (shape-annotation-setter plugin-id page-id :ruby :ruby optional-string?)} + + {:name "rubyHidden" + :get #(shape-japanese-value % :ruby-hidden) + :set (shape-attr-setter plugin-id page-id :rubyHidden :ruby-hidden boolean?)} + + {:name "rubySize" + :get #(shape-japanese-value % :ruby-size) + :set (shape-attr-setter plugin-id page-id :rubySize :ruby-size (enum-value? :ruby-size))} + + {:name "rubyAlign" + :get #(shape-japanese-value % :ruby-align) + :set (shape-attr-setter plugin-id page-id :rubyAlign :ruby-align (enum-value? :ruby-align))} + + {:name "rubyOverhang" + :get #(shape-japanese-value % :ruby-overhang) + :set (shape-attr-setter plugin-id page-id :rubyOverhang :ruby-overhang (enum-value? :ruby-overhang))} + + {:name "rubySide" + :get #(shape-japanese-value % :ruby-side) + :set (shape-attr-setter plugin-id page-id :rubySide :ruby-side (enum-value? :ruby-side))} + {:name "textBounds" :get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)}))) diff --git a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs index 46d83f8891..754f87bb91 100644 --- a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs @@ -193,6 +193,11 @@ ["TextRangeProxy.fontSize" #(set! (.-fontSize range) "16")] ["TextRangeProxy.lineHeight" #(set! (.-lineHeight range) "1.2")] ["TextRangeProxy.letterSpacing" #(set! (.-letterSpacing range) "1")] + ["TextRangeProxy.ruby" #(set! (.-ruby range) "にち")] + ["TextRangeProxy.rubySize" #(set! (.-rubySize range) "third")] + ["TextRangeProxy.rubyAlign" #(set! (.-rubyAlign range) "center")] + ["TextRangeProxy.rubyOverhang" #(set! (.-rubyOverhang range) "none")] + ["TextRangeProxy.rubySide" #(set! (.-rubySide range) "under")] ["TextRangeProxy.fills" #(set! (.-fills range) #js [#js {:fillColor "#fabada" :fillOpacity 1}])] ;; ---- RulerGuideProxy ---- @@ -319,4 +324,3 @@ ["borderRadiusBottomRight" #(set! (.-borderRadiusBottomRight rect) 2.5)] ["borderRadiusBottomLeft" #(set! (.-borderRadiusBottomLeft rect) 2.5)]]] (t/is (not (throws? thunk)) (str label " must accept a fractional value"))))))) - diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index 49b4dc4ea3..c4a6864f84 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -26,10 +26,10 @@ ;; so negative values were refused. Pin the accept/reject contract of the ;; plugin setter here. -(defn- apply-letter-spacing - "Sets `letterSpacing` on a text range proxy and returns the attributes sent - to the update event, or nil when the plugin rejected the value." - [value] +(defn- apply-range-property + "Sets `property` on a text range proxy and returns the attributes sent to + the update event, or nil when the plugin rejected the value." + [property value] (let [captured (atom nil) range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 4)] @@ -41,9 +41,13 @@ (reset! captured attrs) :update-text-range) st/emit! mock/noop] - (set! (.-letterSpacing range) value) + (unchecked-set range property value) @captured))) +(defn- apply-letter-spacing + [value] + (apply-range-property "letterSpacing" value)) + (t/deftest letter-spacing-accepts-negative-values (t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56"))) (t/is (= {:letter-spacing "-12"} (apply-letter-spacing "-12"))) @@ -59,6 +63,112 @@ (t/is (nil? (apply-letter-spacing "1-2"))) (t/is (nil? (apply-letter-spacing "--1")))) +(t/deftest japanese-range-properties-accept-only-supported-values + (doseq [[property attr accepted rejected] + [["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]] + ["annotationClearance" :annotation-clearance ["none" "auto"] ["always"]] + ["rubySize" :ruby-size ["half" "third" "quarter"] ["full"]] + ["rubyAlign" :ruby-align ["space-around" "center" "start" "space-between"] ["end"]] + ["rubyOverhang" :ruby-overhang ["auto" "none"] ["always"]] + ["rubySide" :ruby-side ["over" "under"] ["right"]] + ["ruby" :ruby ["かんじ" nil] [42]]]] + (doseq [value accepted] + (t/is (= {attr value} (apply-range-property property value)) + (str property " accepts " (pr-str value)))) + (doseq [value rejected] + (t/is (nil? (apply-range-property property value)) + (str property " rejects " (pr-str value)))))) + +(t/deftest text-range-japanese-properties-read-span-values + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "漢字" + :text-combine-upright "digits2" + :text-emphasis "filled-dot" + :warichu "warichu" + :font-features "vpal" + :annotation-clearance "auto" + :ruby "かんじ" + :ruby-size "third" + :ruby-align "center" + :ruby-overhang "none" + :ruby-side "under"}]}]}]} + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)] + (with-redefs [u/proxy->shape (constantly {:content content})] + (t/is (= "digits2" (.-textCombineUpright range))) + (t/is (= "filled-dot" (.-textEmphasis range))) + (t/is (= "warichu" (.-warichu range))) + (t/is (= "vpal" (.-fontFeatures range))) + (t/is (= "auto" (.-annotationClearance range))) + (t/is (= "かんじ" (.-ruby range))) + (t/is (= "third" (.-rubySize range))) + (t/is (= "center" (.-rubyAlign range))) + (t/is (= "none" (.-rubyOverhang range))) + (t/is (= "under" (.-rubySide range)))))) + +(t/deftest text-range-japanese-properties-report-mixed-values + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "日" + :text-emphasis "filled-dot" + :ruby "にち" + :ruby-size "third"} + {:text "本" + :text-emphasis "none" + :ruby nil + :ruby-size "half"}]}]}]} + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 0 2)] + (with-redefs [u/proxy->shape (constantly {:content content})] + (t/is (= "mixed" (.-textEmphasis range))) + (t/is (= "mixed" (.-ruby range))) + (t/is (= "mixed" (.-rubySize range)))))) + +(t/deftest text-range-japanese-properties-update-the-selected-range + (let [file-id (random-uuid) + page-id (random-uuid) + shape-id (random-uuid) + range (plugins.text/text-range-proxy plugin-id file-id page-id shape-id 1 4) + captured (atom [])] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + dwt/update-text-range + (fn [id start end attrs] + (swap! captured conj {:id id + :start start + :end end + :attrs attrs}) + :update-text-range) + st/emit! mock/noop] + (set! (.-textCombineUpright range) "digits2") + (set! (.-textEmphasis range) "filled-dot") + (set! (.-warichu range) "warichu") + (set! (.-fontFeatures range) "vpal") + (set! (.-annotationClearance range) "auto") + (set! (.-ruby range) "かんじ") + (set! (.-rubySize range) "third") + (set! (.-rubyAlign range) "center") + (set! (.-rubyOverhang range) "none") + (set! (.-rubySide range) "under") + (t/is (= [{:id shape-id :start 1 :end 4 :attrs {:text-combine-upright "digits2"}} + {:id shape-id :start 1 :end 4 :attrs {:text-emphasis "filled-dot"}} + {:id shape-id :start 1 :end 4 :attrs {:warichu "warichu"}} + {:id shape-id :start 1 :end 4 :attrs {:font-features "vpal"}} + {:id shape-id :start 1 :end 4 :attrs {:annotation-clearance "auto"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby "かんじ"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-size "third"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-align "center"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-overhang "none"}} + {:id shape-id :start 1 :end 4 :attrs {:ruby-side "under"}}] + @captured))))) + (t/deftest font-apply-to-text-uses-font-id-not-shape-id (let [file-id (random-uuid) @@ -126,3 +236,116 @@ (let [text-shape (.-shape range)] (t/is (shape/shape-proxy? text-shape)) (t/is (= shape-id (aget text-shape "$id"))))))) + +(def ^:private two-span-content + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "漢" :font-weight "700"} + {:text "字" :font-weight "400"}]}]}]}) + +(defn- capture-japanese-update + "Sets `property` to `value` on `target` over `content`; returns the attrs + sent to the update and the rejection reason, if any." + [target property value content] + (let [captured (atom {})] + (with-redefs [r/check-permission (constantly true) + u/page-active? (constantly true) + u/proxy->shape (constantly {:content content}) + u/not-valid (fn [_ _ reason] (swap! captured assoc :rejected reason)) + dwt/update-text-range + (fn [_ _ _ attrs] (swap! captured assoc :attrs attrs) :update) + dwt/update-attrs + (fn [_ attrs] (swap! captured assoc :attrs attrs) :update) + st/emit! mock/noop] + (unchecked-set target property value) + @captured))) + +(t/deftest text-range-ruby-across-spans-is-rejected + (let [range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 2) + result (capture-japanese-update range "ruby" "かんじ" two-span-content)] + (t/is (nil? (:attrs result))) + (t/is (string? (:rejected result))))) + +(t/deftest text-range-warichu-across-spans-is-rejected + (let [range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 2) + result (capture-japanese-update range "warichu" "warichu" two-span-content)] + (t/is (nil? (:attrs result))))) + +(t/deftest text-range-ruby-inside-one-span-is-applied + (let [range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 1 2) + result (capture-japanese-update range "ruby" "じ" two-span-content)] + (t/is (= {:ruby "じ"} (:attrs result))))) + +(t/deftest text-ruby-on-a-multi-span-text-is-rejected + (let [text (plugins.text/add-text-props #js {:$id (random-uuid) :$page (random-uuid)} plugin-id) + result (capture-japanese-update text "ruby" "かんじ" two-span-content)] + (t/is (nil? (:attrs result))) + (t/is (string? (:rejected result))))) + +(t/deftest text-ruby-can-be-cleared-on-a-multi-span-text + (let [text (plugins.text/add-text-props #js {:$id (random-uuid) :$page (random-uuid)} plugin-id) + result (capture-japanese-update text "ruby" "" two-span-content)] + (t/is (= {:ruby ""} (:attrs result))))) + +(defn- text-proxy-over + [content] + [(plugins.text/add-text-props #js {:$id (random-uuid) :$page (random-uuid)} plugin-id) + {:content content}]) + +(t/deftest text-japanese-getters-report-defaults-when-unset + (let [[^js text shape] (text-proxy-over two-span-content)] + (with-redefs [u/proxy->shape (constantly shape)] + (t/is (= "horizontal-tb" (.-writingMode text))) + (t/is (= "mixed" (.-textOrientation text))) + (t/is (= "none" (.-textCombineUpright text))) + (t/is (= "none" (.-textEmphasis text))) + (t/is (= "none" (.-warichu text))) + (t/is (= "none" (.-fontFeatures text))) + (t/is (= "none" (.-annotationClearance text))) + (t/is (= "half" (.-rubySize text))) + (t/is (= "push-in-first" (.-lineAdjustment text))) + (t/is (false? (.-rubyHidden text))) + (t/is (nil? (.-ruby text)))))) + +(t/deftest text-line-adjustment-is-a-root-value + (let [[^js text shape] (text-proxy-over (assoc two-span-content :line-adjustment "push-out-only"))] + (with-redefs [u/proxy->shape (constantly shape)] + (t/is (= "push-out-only" (.-lineAdjustment text))))) + (let [text (plugins.text/add-text-props #js {:$id (random-uuid) :$page (random-uuid)} plugin-id) + result (capture-japanese-update text "lineAdjustment" "push-out-first" two-span-content)] + (t/is (= {:line-adjustment "push-out-first"} (:attrs result)))) + (let [text (plugins.text/add-text-props #js {:$id (random-uuid) :$page (random-uuid)} plugin-id) + result (capture-japanese-update text "lineAdjustment" "squeeze" two-span-content)] + (t/is (nil? (:attrs result))) + (t/is (= "squeeze" (:rejected result))))) + +(t/deftest text-orientation-is-a-whole-shape-value + (let [content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :text-orientation "upright" + :children [{:text "縦"}]} + {:type "paragraph" + :children [{:text "書き"}]}]}]} + [^js text shape] (text-proxy-over content)] + (with-redefs [u/proxy->shape (constantly shape)] + (t/is (= "upright" (.-textOrientation text)))))) + +(t/deftest ruby-hidden-is-exposed-on-text-and-ranges + (let [range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 1) + result (capture-japanese-update range "rubyHidden" true two-span-content) + bad (capture-japanese-update range "rubyHidden" "yes" two-span-content) + [text] (text-proxy-over two-span-content) + shape (capture-japanese-update text "rubyHidden" false two-span-content)] + (t/is (= {:ruby-hidden true} (:attrs result))) + (t/is (nil? (:attrs bad))) + (t/is (= {:ruby-hidden false} (:attrs shape))))) + +(t/deftest text-japanese-getters-treat-unset-spans-as-default + (let [content (assoc-in two-span-content + [:children 0 :children 0 :children 0 :text-emphasis] + "none") + [^js text shape] (text-proxy-over content)] + (with-redefs [u/proxy->shape (constantly shape)] + (t/is (= "none" (.-textEmphasis text)))))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 4b5fe142df..18f46d4738 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -11,6 +11,15 @@ - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished - **plugin-types:** `Stroke` exposes per-side widths for boards and rectangles, also supported by `borderWidth` and `dimension` tokens. Read-only `strokeWidthType` reports `simple` when all four effective widths match, otherwise `multiple`. +- **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. +- **plugin-types:** Added `lineAdjustment` (`push-in-first`, `push-out-first` or `push-out-only`) to text shapes: how Japanese lines slightly too long are fitted. ### 🩹 Fixes diff --git a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts index 6b8ed2e448..0925bc1fb7 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/text.test.ts @@ -81,6 +81,45 @@ describe('Text', () => { expect(t.direction).toBe('rtl'); }); + test('Japanese paragraph layout properties round-trip', (ctx) => { + const t = text(ctx, '日本語'); + t.writingMode = 'vertical-rl'; + t.textOrientation = 'upright'; + expect(t.writingMode).toBe('vertical-rl'); + expect(t.textOrientation).toBe('upright'); + }); + + test('line adjustment round-trips', (ctx) => { + const t = text(ctx, '日本語'); + expect(t.lineAdjustment).toBe('push-in-first'); + t.lineAdjustment = 'push-out-only'; + expect(t.lineAdjustment).toBe('push-out-only'); + }); + + test('Japanese span layout properties round-trip', (ctx) => { + const t = text(ctx, '日本語'); + t.textCombineUpright = 'digits2'; + t.textEmphasis = 'filled-dot'; + t.warichu = 'warichu'; + t.fontFeatures = 'vpal'; + t.annotationClearance = 'auto'; + t.ruby = 'にほんご'; + t.rubySize = 'third'; + t.rubyAlign = 'center'; + t.rubyOverhang = 'none'; + t.rubySide = 'under'; + expect(t.textCombineUpright).toBe('digits2'); + expect(t.textEmphasis).toBe('filled-dot'); + expect(t.warichu).toBe('warichu'); + expect(t.fontFeatures).toBe('vpal'); + expect(t.annotationClearance).toBe('auto'); + expect(t.ruby).toBe('にほんご'); + expect(t.rubySize).toBe('third'); + expect(t.rubyAlign).toBe('center'); + expect(t.rubyOverhang).toBe('none'); + expect(t.rubySide).toBe('under'); + }); + test('font identity and variant setters accept a real font/variant', (ctx) => { const t = text(ctx); const font = ctx.penpot.fonts.all[0]; @@ -172,6 +211,31 @@ describe('Text', () => { expect(range.textDecoration).toBe('line-through'); }); + test('range Japanese layout properties round-trip', (ctx) => { + const t = text(ctx, '日本語'); + const range = t.getRange(0, 3); + range.textCombineUpright = 'all'; + range.textEmphasis = 'open-circle'; + range.warichu = 'warichu'; + range.fontFeatures = 'palt'; + range.annotationClearance = 'auto'; + range.ruby = 'にほんご'; + range.rubySize = 'quarter'; + range.rubyAlign = 'space-between'; + range.rubyOverhang = 'none'; + range.rubySide = 'under'; + expect(range.textCombineUpright).toBe('all'); + expect(range.textEmphasis).toBe('open-circle'); + expect(range.warichu).toBe('warichu'); + expect(range.fontFeatures).toBe('palt'); + expect(range.annotationClearance).toBe('auto'); + expect(range.ruby).toBe('にほんご'); + expect(range.rubySize).toBe('quarter'); + expect(range.rubyAlign).toBe('space-between'); + expect(range.rubyOverhang).toBe('none'); + expect(range.rubySide).toBe('under'); + }); + test('range fills round-trip', (ctx) => { const t = text(ctx, 'Hello Penpot'); const range = t.getRange(0, 5); diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index ed48feefb3..fa52bebb6d 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4693,6 +4693,104 @@ export interface Text extends ShapeBase { */ verticalAlign: 'top' | 'center' | 'bottom' | null; + /** + * How lines slightly too long for the box are fitted (JLREQ line + * adjustment, Adobe's Kinsoku Type) in Japanese text. `push-in-first` (the + * default) squeezes punctuation spacing to keep the next character on the + * line; `push-out-first` moves characters to the next line and squeezes + * only when line-breaking rules leave no other choice; `push-out-only` + * never squeezes. It applies to the whole shape; `align` still decides + * what happens to the space a line leaves over. + */ + lineAdjustment: 'push-in-first' | 'push-out-first' | 'push-out-only'; + + /** + * The writing mode of the text shape: `horizontal-tb` (horizontal lines, + * the default) or `vertical-rl` (vertical columns, right to left). It + * applies to the whole shape. + */ + writingMode: 'horizontal-tb' | 'vertical-rl'; + + /** + * Character orientation in vertical writing: `mixed` (the default) turns + * non-CJK runs sideways; `upright` keeps every character upright. It + * applies to the whole shape, so `mixed` is always the orientation value, + * never a report of differing values. + */ + textOrientation: 'mixed' | 'upright'; + + /** + * Tate-chu-yoko: sets text upright as one block in vertical writing. `all` + * combines the whole span; `digits` combines runs of 2-4 digits + * (`digits2`/`digits3` cap the run at 2/3); `none` (the default) turns it + * off. Returns 'mixed' if text spans use different values. + */ + textCombineUpright: + 'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed'; + + /** + * Emphasis marks (圏点 / bouten) beside each base character, as in CSS + * `text-emphasis-style`. `none` (the default) 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'; + + /** + * Warichu (割注): sets the text as two half-size lines in one line position. + * `none` (the default) turns it off. Setting `warichu` on a text with more + * than one text span is rejected: use a `TextRange` inside one span. + * Returns 'mixed' if text spans use different values. + */ + warichu: 'none' | 'warichu' | 'mixed'; + + /** + * OpenType proportional metrics for Japanese text: `palt` for horizontal + * writing, `vpal` for vertical. `none` (the default) turns them off. + * Returns 'mixed' if text spans use different values. + */ + fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed'; + + /** + * Room for annotations. `auto` adds the ruby (at its size) and the + * emphasis marks to the line height, or the column width in vertical + * text; `none` (the default) keeps the set line height, so annotations sit + * in the gap between lines. Returns 'mixed' if text spans use different + * values. + */ + annotationClearance: 'none' | 'auto' | 'mixed'; + + /** + * Ruby (furigana) annotation text for the base text, as one group reading. + * Set a string to annotate the whole text, or `null` or `''` to remove it. + * Adding a reading to a text with more than one text span is rejected: use + * a `TextRange` inside one span. Returns 'mixed' if text spans carry + * different ruby values. + */ + ruby: string | null; + + /** Whether the ruby reading is hidden; `false` by default. */ + rubyHidden: boolean | 'mixed'; + + /** Ruby size relative to the base text: `half` (default), `third` or `quarter`. */ + rubySize: 'half' | 'third' | 'quarter' | 'mixed'; + + /** How ruby glyphs spread over the base text; defaults to `space-around`. */ + rubyAlign: 'space-around' | 'center' | 'start' | 'space-between' | 'mixed'; + + /** Whether ruby may extend past its base text: `auto` (default) or `none`. */ + rubyOverhang: 'auto' | 'none' | 'mixed'; + + /** Ruby side: `over` (above or right, the default) or `under` (below or left). */ + rubySide: 'over' | 'under' | 'mixed'; + /** * 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. @@ -4793,6 +4891,69 @@ 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 metrics for the range: `palt` (horizontal), `vpal` + * (vertical) or `none`. Returns 'mixed' for different span values. + */ + fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null; + + /** + * Room for annotations in the range. `auto` adds the ruby (at its size) + * and the emphasis marks to the line height, or the column width in + * vertical text; `none` (the default) keeps the set line height. Returns + * 'mixed' for different span values. + */ + annotationClearance: 'none' | 'auto' | 'mixed' | null; + + /** + * Ruby (furigana) annotation for the text range, as one group reading. + * Set `null` to remove it. A reading must cover characters of a single + * text span; a range that covers several spans is rejected. Returns + * 'mixed' if the range contains different ruby values. + */ + ruby: string | 'mixed' | null; + + /** Whether the ruby reading of the range is hidden; `false` by default. */ + rubyHidden: boolean | 'mixed' | null; + + /** Ruby size relative to the base text: `half` (default), `third` or `quarter`. */ + rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null; + + /** How ruby glyphs spread over the base text; defaults to `space-around`. */ + rubyAlign: + 'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null; + + /** Whether ruby may extend past its base text: `auto` (default) or `none`. */ + rubyOverhang: 'auto' | 'none' | 'mixed' | null; + + /** Ruby side: `over` (above or right, the default) or `under` (below or left). */ + 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. */