From 2d56f26d5ff51f9d046e20976a594ff9936c8fb8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 27 Oct 2026 11:58:04 +0000 Subject: [PATCH] :sparkles: 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 --- common/src/app/common/types/shape/text.cljc | 9 ++-- .../src/app/main/data/workspace/texts.cljs | 23 +++++++++- frontend/src/app/plugins/text.cljs | 30 +++++++++++++ .../data/workspace_texts_test.cljs | 14 +++++++ .../frontend_tests/plugins/text_test.cljs | 42 +++++++++++++++++++ plugins/CHANGELOG.md | 1 + plugins/libs/plugin-types/index.d.ts | 25 +++++++++++ 7 files changed, 139 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index 50bbeaa6d7..7b493580c0 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -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)] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index d0c07386a5..83dd201960 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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))) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 593cb862b5..3b405dc4b3 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -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)}))) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 1baa5c14d3..23ec29aba8 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -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})))))) diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index c5f472aa51..47cdac8467 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -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)))))))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index fc396c7d7c..0d4b51b1c6 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -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 diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 39a3ff48ba..b4e7493811 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -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. */