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