mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ Add lang to text shapes and ranges in the Plugin API
The paragraph language selects the CJK typesetting rules (line breaking, punctuation spacing, the default emphasis position and localized glyphs), but plugins could not read or set it. `lang` holds it as a BCP 47 tag, as the HTML `lang` attribute does; `null` or `''` removes it. On a text shape it applies to every paragraph and reports 'mixed' when they differ. On a text range it applies to the whole paragraphs the range touches: `update-text-range-attrs` now writes paragraph attrs such as the language there instead of copying them into spans. The tag pattern moves to `schema:text-language`, shared by the content schema and the plugin validator. AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
b053ac8751
commit
2d56f26d5f
@ -22,6 +22,11 @@
|
||||
[attr]
|
||||
(into [:enum] (get cjk/enum-values attr)))
|
||||
|
||||
;; BCP 47 shaped language tag of a paragraph, as the HTML `lang` attribute.
|
||||
(def schema:text-language
|
||||
[:re {:gen/elements ["ja" "zh-Hans" "zh-Hant-TW" "ko" "en-GB"]}
|
||||
#"^(?:[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*|[xXiI](?:-[A-Za-z0-9]{1,8})+)$"])
|
||||
|
||||
(def schema:content
|
||||
[:map
|
||||
[:type [:= "root"]]
|
||||
@ -45,9 +50,7 @@
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
;; Paragraph language selects typography; it never follows the UI locale.
|
||||
[:text-language {:optional true}
|
||||
[:re {:gen/elements ["ja" "zh-Hans" "zh-Hant-TW" "ko" "en-GB"]}
|
||||
#"^(?:[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*|[xXiI](?:-[A-Za-z0-9]{1,8})+)$"]]
|
||||
[:text-language {:optional true} schema:text-language]
|
||||
;; Word mode keeps punctuation rules; retain the old POC value on reload.
|
||||
[:korean-wrap {:optional true} [:enum "syllable" "spaces-only" "keep-words"]]
|
||||
[:writing-mode {:optional true} (schema:jl-enum :writing-mode)]
|
||||
|
||||
@ -593,17 +593,36 @@
|
||||
[start end] (annotated-span-bounds content start end)]
|
||||
(<= (count (overlapping-text-nodes content start end)) 1))))
|
||||
|
||||
;; Paragraph attrs a text range sets on the whole paragraphs it touches.
|
||||
(def ^:private range-paragraph-attrs
|
||||
[:text-language])
|
||||
|
||||
(defn- update-paragraph-range
|
||||
"Applies `attrs` to the paragraphs of a range-decorated content with
|
||||
characters in [start, end); a nil value removes the attr."
|
||||
[content start end attrs]
|
||||
(txt/transform-nodes
|
||||
(fn [node]
|
||||
(and (txt/is-paragraph-node? node)
|
||||
(let [{node-start :start node-end :end} (meta node)]
|
||||
(and (< node-start end) (< start node-end)))))
|
||||
#(d/patch-object % attrs)
|
||||
content))
|
||||
|
||||
(defn update-text-range-attrs
|
||||
"Applies `attrs` to the characters [start, end) of a text shape. A range
|
||||
that cuts a ruby or warichu span covers the whole span. Spans left with
|
||||
the same style as their neighbour are joined back."
|
||||
the same style as their neighbour are joined back. Paragraph attrs such
|
||||
as the language go to the whole paragraphs the range touches."
|
||||
[shape start end attrs]
|
||||
(let [content (decorate-range-info (:content shape))
|
||||
[start end] (annotated-span-bounds content start end)
|
||||
span-attrs (apply dissoc attrs range-paragraph-attrs)
|
||||
new-content (->> (-> content
|
||||
(update-paragraph-range start end (select-keys attrs range-paragraph-attrs))
|
||||
(split-content-at start)
|
||||
(split-content-at end)
|
||||
(update-content-range start end attrs))
|
||||
(update-content-range start end span-attrs))
|
||||
(txt/transform-nodes txt/is-paragraph-node?
|
||||
#(d/update-when % :children txt/merge-equal-spans)))]
|
||||
(assoc shape :content new-content)))
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.record :as crc]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.shape.text :as ctst]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.text.cjk-layout :as cjk]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
@ -129,6 +130,18 @@
|
||||
[value]
|
||||
(or (nil? value) (string? value)))
|
||||
|
||||
(defn- language-tag?
|
||||
"Validator for a text language: a BCP 47 tag, or nil or '' to remove it."
|
||||
[value]
|
||||
(or (nil? value)
|
||||
(= "" value)
|
||||
(sm/validate ctst/schema:text-language value)))
|
||||
|
||||
(defn- language-attrs
|
||||
"Update attrs for a language value; nil or '' removes the language."
|
||||
[attrs]
|
||||
(update attrs :text-language not-empty))
|
||||
|
||||
(defn- attr-setter
|
||||
"Property setter that checks `valid?` and the plugin's write access before
|
||||
emitting `(update-event self attrs)` with `{attr value}`."
|
||||
@ -532,6 +545,17 @@
|
||||
: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))}
|
||||
|
||||
:lang
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(->> (-> self u/proxy->shape :content (content-range->text+styles start end))
|
||||
(map :text-language)
|
||||
(u/mixed-value)))
|
||||
:set
|
||||
(attr-setter plugin-id page-id :lang :text-language language-tag?
|
||||
(fn [_ attrs] (dwt/update-text-range id start end (language-attrs attrs))))}
|
||||
|
||||
:direction
|
||||
{:this true
|
||||
:get
|
||||
@ -1009,5 +1033,11 @@
|
||||
:get #(shape-japanese-value % :ruby-side)
|
||||
:set (shape-attr-setter plugin-id page-id :rubySide :ruby-side (enum-value? :ruby-side))}
|
||||
|
||||
{:name "lang"
|
||||
:get #(-> % u/proxy->shape text-props :text-language format/format-mixed)
|
||||
:set (attr-setter plugin-id page-id :lang :text-language language-tag?
|
||||
(fn [self attrs]
|
||||
(dwt/update-attrs (obj/get self "$id") (language-attrs attrs))))}
|
||||
|
||||
{:name "textBounds"
|
||||
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})))
|
||||
|
||||
@ -948,3 +948,17 @@
|
||||
shape (dwt/update-text-range-attrs shape 0 4 {:ruby nil})]
|
||||
(t/is (= [{:text "一盅兩件"}] (spans-of shape)))
|
||||
(t/is (dwt/single-span-range? (:content shape) 0 4)))))
|
||||
|
||||
(t/deftest update-text-range-sets-the-language-on-the-paragraphs-it-touches
|
||||
(let [content {:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph" :children [{:text "ab" :font-size "20"}]}
|
||||
{:type "paragraph" :children [{:text "cd" :font-size "20"}]}]}]}
|
||||
languages (fn [shape]
|
||||
(->> (:content shape) (txt/node-seq txt/is-paragraph-node?) (mapv :text-language)))
|
||||
shape (dwt/update-text-range-attrs {:content content} 3 4 {:text-language "ja"})]
|
||||
(t/is (= [nil "ja"] (languages shape)))
|
||||
(t/is (= [{:text "ab"} {:text "cd"}] (spans-of shape))
|
||||
"spans are neither split nor given the language")
|
||||
(t/is (= [nil nil]
|
||||
(languages (dwt/update-text-range-attrs shape 0 5 {:text-language nil}))))))
|
||||
|
||||
@ -366,3 +366,45 @@
|
||||
(t/is (= {:ruby-zhuyin true} (:attrs result)))
|
||||
(t/is (nil? (:attrs bad)))
|
||||
(t/is (= {:ruby-zhuyin false} (:attrs shape)))))
|
||||
|
||||
(defn- paragraphs-content
|
||||
"Content with one single-span paragraph per [text language] pair."
|
||||
[& paragraphs]
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children (vec (for [[text language] paragraphs]
|
||||
(cond-> {:type "paragraph" :children [{:text text}]}
|
||||
language (assoc :text-language language))))}]})
|
||||
|
||||
(t/deftest text-lang-reads-the-paragraph-language
|
||||
(let [[^js text shape] (text-proxy-over (paragraphs-content ["香港" "zh-Hant-HK"]))]
|
||||
(with-redefs [u/proxy->shape (constantly shape)]
|
||||
(t/is (= "zh-Hant-HK" (.-lang text)))))
|
||||
(let [[^js text shape] (text-proxy-over (paragraphs-content ["plain" nil]))]
|
||||
(with-redefs [u/proxy->shape (constantly shape)]
|
||||
(t/is (nil? (.-lang text)))))
|
||||
(let [[^js text shape] (text-proxy-over (paragraphs-content ["香港" "zh-Hant-HK"] ["plain" nil]))]
|
||||
(with-redefs [u/proxy->shape (constantly shape)]
|
||||
(t/is (= "mixed" (.-lang text))))))
|
||||
|
||||
(t/deftest text-range-lang-reads-the-paragraphs-it-touches
|
||||
(let [content (paragraphs-content ["香港" "zh-Hant-HK"] ["東京" "ja"])
|
||||
range (fn [start end]
|
||||
(plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) start end))]
|
||||
(with-redefs [u/proxy->shape (constantly {:content content})]
|
||||
(t/is (= "zh-Hant-HK" (.-lang (range 0 2))))
|
||||
(t/is (= "ja" (.-lang (range 3 5))))
|
||||
(t/is (= "mixed" (.-lang (range 1 4)))))))
|
||||
|
||||
(t/deftest lang-accepts-language-tags-and-clears-with-empty-values
|
||||
(let [content (paragraphs-content ["香港" nil])
|
||||
text (plugins.text/add-text-props #js {:$id (random-uuid) :$page (random-uuid)} plugin-id)
|
||||
range (plugins.text/text-range-proxy plugin-id (random-uuid) (random-uuid) (random-uuid) 0 2)]
|
||||
(doseq [target [text range]]
|
||||
(t/is (= {:text-language "zh-Hant-HK"} (:attrs (capture-japanese-update target "lang" "zh-Hant-HK" content))))
|
||||
(t/is (= {:text-language nil} (:attrs (capture-japanese-update target "lang" "" content))))
|
||||
(t/is (= {:text-language nil} (:attrs (capture-japanese-update target "lang" nil content))))
|
||||
(doseq [bad ["not a tag" "zh_Hant" 42]]
|
||||
(let [result (capture-japanese-update target "lang" bad content)]
|
||||
(t/is (nil? (:attrs result)))
|
||||
(t/is (= bad (:rejected result))))))))
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
- **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.
|
||||
- **plugin-types:** Added `lang` to text shapes and ranges: the paragraph language as a BCP 47 tag (`ja`, `zh-Hant-HK`, `ko`...), which selects the CJK typesetting rules.
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
|
||||
25
plugins/libs/plugin-types/index.d.ts
vendored
25
plugins/libs/plugin-types/index.d.ts
vendored
@ -4820,6 +4820,18 @@ export interface Text extends ShapeBase {
|
||||
/** Ruby side: `over` (above or right, the default) or `under` (below or left). */
|
||||
rubySide: 'over' | 'under' | 'mixed';
|
||||
|
||||
/**
|
||||
* Language of the text as a BCP 47 tag, like the HTML `lang` attribute.
|
||||
* Japanese (`ja`), Chinese (`zh-Hans`, `zh-Hant`, or with a region:
|
||||
* `zh-Hans-CN`, `zh-Hant-TW`, `zh-Hant-HK`...) and Korean (`ko`) tags
|
||||
* select their typesetting rules: line breaking, punctuation spacing, the
|
||||
* default emphasis position and localized glyph forms. Other tags are
|
||||
* stored without rules. `null` (the default) applies none; set `null` or
|
||||
* `''` to remove it. It belongs to paragraphs: setting it applies
|
||||
* to every paragraph. Returns 'mixed' if paragraphs use different values.
|
||||
*/
|
||||
lang: string | '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.
|
||||
@ -5007,6 +5019,19 @@ export interface TextRange {
|
||||
/** Ruby side: `over` (above or right, the default) or `under` (below or left). */
|
||||
rubySide: 'over' | 'under' | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* Language of the text as a BCP 47 tag, like the HTML `lang` attribute.
|
||||
* Japanese (`ja`), Chinese (`zh-Hans`, `zh-Hant`, or with a region:
|
||||
* `zh-Hans-CN`, `zh-Hant-TW`, `zh-Hant-HK`...) and Korean (`ko`) tags
|
||||
* select their typesetting rules: line breaking, punctuation spacing, the
|
||||
* default emphasis position and localized glyph forms. Other tags are
|
||||
* stored without rules. `null` (the default) applies none; set `null` or
|
||||
* `''` to remove it. It belongs to paragraphs: setting it applies
|
||||
* to every paragraph the range touches. Returns 'mixed' if those
|
||||
* paragraphs use different values.
|
||||
*/
|
||||
lang: string | 'mixed' | null;
|
||||
|
||||
/**
|
||||
* The text direction for the text range. It can be a specific direction or 'mixed' if multiple directions are used.
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user