✨ Expose Japanese text layout in the Plugin API

Expose Japanese layout properties on Text and TextRange with validated
values and whole-shape writing mode behavior. Keep ruby readings tied
to their spans when applying styles through ranges.

Update public TypeScript declarations, the API changelog and runtime
and plugin test coverage, including active-page checks.
This commit is contained in:
alonso.torres 2026-10-07 17:12:40 +02:00
parent 70727ca4d7
commit 23ca83d874
6 changed files with 693 additions and 6 deletions

View File

@ -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)})))

View File

@ -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")))))))

View File

@ -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))))))

View File

@ -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

View File

@ -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);

View File

@ -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.
*/