diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 80f2e608f6..5279707c7b 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -795,7 +795,7 @@ :r4}) (def ^:private text-extract-props - (into #{} cat [txt/root-attrs txt/paragraph-attrs txt/text-node-attrs])) + (into #{} cat [txt/root-attrs txt/paragraph-attrs txt/text-node-style-attrs])) (def ^:private layout-extract-props (set ctsl/layout-attrs)) @@ -846,7 +846,7 @@ (assoc-props node txt/paragraph-attrs) (txt/is-text-node? node) - (assoc-props node txt/text-node-attrs))) + (assoc-props node txt/text-node-style-attrs))) props))) (extract-layout-attrs [props shape] @@ -889,7 +889,7 @@ (d/patch-object (select-keys props txt/paragraph-attrs)) (txt/is-text-node? node) - (d/patch-object (select-keys props txt/text-node-attrs)))))))))) + (d/patch-object (select-keys props txt/text-node-style-attrs)))))))))) (patch-layout-props [shape props] (let [shape (d/patch-object shape (select-keys props ctsl/layout-attrs))] diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index 122aae80a8..b17ddbd77c 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -7,7 +7,8 @@ (ns app.common.types.shape.text (:require [app.common.schema :as sm] - [app.common.types.fills :refer [schema:fills]])) + [app.common.types.fills :refer [schema:fills]] + [app.common.types.text.cjk-layout :as cjk])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SCHEMA @@ -15,10 +16,17 @@ (def node-types #{"root" "paragraph-set" "paragraph"}) +;; Values of a Japanese layout enum attr; paragraphs set the orientation +;; of the whole shape and spans carry a copy. +(defn- schema:jl-enum + [attr] + (into [:enum] (get cjk/enum-values attr))) + (def schema:content [:map [:type [:= "root"]] [:key {:optional true} :string] + [:line-adjustment {:optional true} (schema:jl-enum :line-adjustment)] [:children [:vector {:min 1 :gen/max 2 :gen/min 1} [:map @@ -36,6 +44,8 @@ [:font-style {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text] [:direction {:optional true} ::sm/text] + [:writing-mode {:optional true} (schema:jl-enum :writing-mode)] + [:text-orientation {:optional true} (schema:jl-enum :text-orientation)] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text] [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] @@ -52,6 +62,18 @@ [:font-style {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text] [:direction {:optional true} ::sm/text] + [:text-combine-upright {:optional true} (schema:jl-enum :text-combine-upright)] + [:text-emphasis {:optional true} (schema:jl-enum :text-emphasis)] + [:ruby {:optional true} :string] + [:ruby-hidden {:optional true} :boolean] + [:ruby-size {:optional true} (schema:jl-enum :ruby-size)] + [:ruby-align {:optional true} (schema:jl-enum :ruby-align)] + [:ruby-overhang {:optional true} (schema:jl-enum :ruby-overhang)] + [:ruby-side {:optional true} (schema:jl-enum :ruby-side)] + [:warichu {:optional true} (schema:jl-enum :warichu)] + [:font-features {:optional true} (schema:jl-enum :font-features)] + [:annotation-clearance {:optional true} (schema:jl-enum :annotation-clearance)] + [:text-orientation {:optional true} (schema:jl-enum :text-orientation)] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text] [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] @@ -73,6 +95,17 @@ [:font-style {:optional true} ::sm/text] [:font-weight {:optional true} ::sm/text] [:rtl {:optional true} :boolean] + [:writing-mode {:optional true} ::sm/text] + [:text-orientation {:optional true} ::sm/text] + [:text-combine-upright {:optional true} ::sm/text] + [:emphasis-mark {:optional true} :boolean] + [:ruby {:optional true} :string] + [:ruby-size {:optional true} ::sm/text] + [:ruby-align {:optional true} ::sm/text] + [:ruby-overhang {:optional true} ::sm/text] + [:ruby-side {:optional true} ::sm/text] + [:warichu {:optional true} ::sm/text] + [:font-features {:optional true} ::sm/text] [:text {:optional true} :string] [:text-decoration {:optional true} ::sm/text] [:text-transform {:optional true} ::sm/text]]]) diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 4e53c9b753..40f70afc16 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -12,6 +12,7 @@ [app.common.math :as mth] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] + [app.common.types.text.cjk-layout :as cjk] [clojure.set :as set] [clojure.walk :as walk] [cuerdas.core :as str])) @@ -50,6 +51,10 @@ (def text-valign-attrs [:vertical-align]) +;; How the shape fits lines slightly too long (see `cjk/enum-values`). +(def text-line-adjustment-attrs + [:line-adjustment]) + (def text-decoration-attrs [:text-decoration]) @@ -97,22 +102,29 @@ [:grow-type]) (def root-attrs - text-valign-attrs) + (d/concat-vec text-valign-attrs text-line-adjustment-attrs)) (def paragraph-attrs (d/concat-vec text-align-attrs - text-direction-attrs)) + text-direction-attrs + cjk/whole-shape-paragraph-attrs)) (def text-node-attrs (d/concat-vec text-typography-attrs text-font-attrs text-spacing-attrs + cjk/span-attrs text-decoration-attrs text-transform-attrs text-fills)) +(def text-node-style-attrs + "Text node attrs that style characters, without the attrs the characters + own (ruby reading, warichu)." + (vec (remove (set cjk/text-content-attrs) text-node-attrs))) + (def text-span-attrs "Inline text span attrs. Line-height is paragraph-level in the DOM editor; it may still be stored redundantly on span nodes." diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc new file mode 100644 index 0000000000..afa6b40576 --- /dev/null +++ b/common/src/app/common/types/text/cjk_layout.cljc @@ -0,0 +1,187 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.text.cjk-layout + (:require + [app.common.data.macros :as dm] + [cuerdas.core :as str])) + +;; Values of the enum attrs, in the order of the renderer's discriminants +;; (`Raw*` enums in render-wasm). The first value is the default. +(def enum-values + {;; Vertical writing (tategaki). + :writing-mode ["horizontal-tb" "vertical-rl"] + :text-orientation ["mixed" "upright"] + :text-combine-upright ["none" "all" "digits" "digits2" "digits3"] + ;; Emphasis mark (圏点 / bouten) applied per span. + :text-emphasis ["none" "filled-dot" "open-dot" "filled-circle" + "open-circle" "filled-sesame" "open-sesame"] + ;; Warichu (割注): two half-size lines in one line position. + :warichu ["none" "warichu"] + :font-features ["none" "palt" "vpal"] + ;; "auto" adds the ruby (at its size) and emphasis marks to the line + ;; height; "none" keeps it, so annotations sit in the line gap. + :annotation-clearance ["none" "auto"] + :ruby-size ["half" "third" "quarter"] + :ruby-align ["space-around" "center" "start" "space-between"] + :ruby-overhang ["auto" "none"] + :ruby-side ["over" "under"] + ;; Root attr: how lines slightly too long are fitted (Adobe's Kinsoku + ;; Type). It decides squeezing; alignment decides the space left over. + :line-adjustment ["push-in-first" "push-out-first" "push-out-only"]}) + +(def ^:private enum-value-sets + (update-vals enum-values set)) + +(defn enum-default + "Default value of the enum `attr`." + [attr] + (first (get enum-values attr))) + +(defn valid-enum-value? + "True when `value` is one of the values of the enum `attr`." + [attr value] + (contains? (get enum-value-sets attr) value)) + +;; Paragraph attrs. They apply to the whole shape: every paragraph carries +;; the first one's value. +(def whole-shape-paragraph-attrs + [:writing-mode :text-orientation]) + +;; Ruby attrs that change how a reading looks, not the reading itself. +(def ruby-presentation-attrs + [:ruby-hidden :ruby-size :ruby-align :ruby-overhang :ruby-side]) + +;; Span attrs; `:ruby` holds the reading (furigana) of the span. +(def span-attrs + (into [:text-combine-upright :text-emphasis :ruby] + (concat ruby-presentation-attrs + [:warichu :font-features :annotation-clearance]))) + +;; Span attrs owned by the span's characters, not its style: a reading or a +;; warichu note annotates one span and is never copied to other text. +(def text-content-attrs + [:ruby :warichu]) + +;; Values of the span attrs above when a span does not store them. +(def span-attr-defaults + (into {:ruby "" :ruby-hidden false} + (comp (filter #(contains? enum-values %)) + (map (juxt identity enum-default))) + span-attrs)) + +;; Values of the paragraph attrs when a paragraph does not store them. +(def paragraph-attr-defaults + (into {} (map (juxt identity enum-default)) whole-shape-paragraph-attrs)) + +;; Glyph of each emphasis style, per CSS `text-emphasis-style`. +(def ^:private emphasis-mark-chars + {"filled-dot" "•" + "open-dot" "◦" + "filled-circle" "●" + "open-circle" "○" + "filled-sesame" "﹅" + "open-sesame" "﹆"}) + +(defn emphasis-mark-char + "Mark glyph of a text-emphasis value, or nil for none." + [text-emphasis] + (get emphasis-mark-chars text-emphasis)) + +;; Emphasis mark font size relative to the base font size. +(def emphasis-font-scale 0.5) + +;; Warichu sub-line font size relative to the base font size. +(def warichu-font-scale 0.5) + +(defn ruby-font-scale + [ruby-size] + (case ruby-size + "third" (/ 1 3) + "quarter" 0.25 + 0.5)) + +(defn visible-ruby + "Ruby annotation text of a text node, or nil when absent, hidden or on a + warichu note, which shows no ruby." + [node] + (let [ruby (:ruby node)] + (when (and (string? ruby) + (not (str/blank? ruby)) + (not (true? (:ruby-hidden node))) + (not= "warichu" (:warichu node))) + ruby))) + +(defn western-reading? + "True when a ruby reading holds no CJK, kana or full-width character: it is + set as words, sideways in vertical text, and never spread letter by + letter." + [ruby] + (and (string? ruby) + (not (str/blank? ruby)) + (not (re-find #"[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]" ruby)))) + +(defn ruby-span? + "True when a text node carries a ruby reading, hidden or not." + [node] + (not (str/blank? (:ruby node)))) + +(defn annotated-span? + "True when a text node carries a ruby reading or is a warichu note." + [node] + (or (ruby-span? node) + (= "warichu" (:warichu node)))) + +(defn- digit? + [c] + (let [code #?(:clj (int c) :cljs (.charCodeAt c 0))] + (or (<= 48 code 57) (<= 0xFF10 code 0xFF19)))) + +;; Longest digit run each `digits` tate-chu-yoko value combines. +(def ^:private digit-combine-max-lengths + {"digits" 4 "digits2" 2 "digits3" 3}) + +(defn digit-combine? + "True for the tate-chu-yoko values that combine digit runs only." + [value] + (contains? digit-combine-max-lengths value)) + +(defn digit-combine-segments + "Text split for a `digits` tate-chu-yoko value: `[text combine?]` pairs, + where runs of two up to the value's maximum ASCII or full-width digits + combine and longer runs stay ordinary text. Nil for other values." + [text value] + (when-let [max-len (get digit-combine-max-lengths value)] + (->> (partition-by digit? (seq text)) + (mapv (fn [chars] + (let [run (apply str chars)] + [run (and (digit? (first chars)) (<= 2 (count chars) max-len))])))))) + +(defn warichu-text? + "True when a text node renders as warichu, which needs at least two + characters for its two sub-lines." + [node] + (let [text (:text node)] + (and (= "warichu" (:warichu node)) + (string? text) + (>= (count text) 2)))) + +(defn whole-shape-attr + "Value of a whole-shape paragraph `attr` (see `whole-shape-paragraph-attrs`) + in a text content: stored per paragraph, the first paragraph's value + decides. Nil when unset." + [content attr] + (dm/get-in content [:children 0 :children 0 attr])) + +(defn content-writing-mode + "Writing mode of a text content, or nil when unset." + [content] + (whole-shape-attr content :writing-mode)) + +(defn vertical-text-content? + "True when the text content flows vertically (vertical-rl)." + [content] + (= "vertical-rl" (content-writing-mode content))) diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index ace36f0c4d..ab7f15a668 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -8,7 +8,9 @@ (:require [app.common.types.shape :as cts] + [app.common.types.shape.text :as ctst] [app.common.types.text :as cttx] + [app.common.types.text.cjk-layout :as cjk] [clojure.test :as t :include-macros true])) (def content-base @@ -368,3 +370,114 @@ (t/is (every? #(= % "14") original-font-sizes)) (t/is (every? #(= % "32") updated-font-sizes)))) + +(defn- with-span-attrs + [attrs] + (update-in content-base [:children 0 :children 0 :children 0] merge attrs)) + +(defn- with-paragraph-attrs + [attrs] + (update-in content-base [:children 0 :children 0] merge attrs)) + +(t/deftest japanese-span-attrs-accept-supported-values + (t/is (ctst/valid-content? + (with-span-attrs {:text-combine-upright "digits2" + :text-emphasis "open-sesame" + :ruby "かんじ" + :ruby-hidden true + :ruby-size "quarter" + :ruby-align "space-between" + :ruby-overhang "none" + :ruby-side "under" + :warichu "warichu" + :font-features "vpal" + :annotation-clearance "auto" + :text-orientation "upright"}))) + (t/is (ctst/valid-content? + (with-paragraph-attrs {:writing-mode "vertical-rl" :text-orientation "mixed"})))) + +(t/deftest japanese-attrs-reject-unknown-values + (doseq [attrs [{:text-combine-upright "digits5"} + {:text-emphasis "dot"} + {:ruby-hidden "yes"} + {:ruby-size "full"} + {:ruby-align "end"} + {:ruby-overhang "always"} + {:ruby-side "left"} + {:warichu "yes"} + {:font-features "kern"} + {:annotation-clearance "max"} + {:text-orientation "sideways"}]] + (t/is (not (ctst/valid-content? (with-span-attrs attrs))) (pr-str attrs))) + (t/is (not (ctst/valid-content? (with-paragraph-attrs {:writing-mode "vertical-lr"}))))) + +(t/deftest digit-combine-segments-combine-short-digit-runs + (t/is (= [["平成" false] ["31" true] ["年" false]] + (cjk/digit-combine-segments "平成31年" "digits2"))) + (t/is (= [["第" false] ["123" true]] + (cjk/digit-combine-segments "第123" "digits3"))) + (t/is (= [["12345" false]] (cjk/digit-combine-segments "12345" "digits"))) + (t/is (= [["1" false]] (cjk/digit-combine-segments "1" "digits"))) + (t/is (nil? (cjk/digit-combine-segments "31" "all")))) + +(t/deftest western-reading-test + (t/is (true? (cjk/western-reading? "editor"))) + (t/is (true? (cjk/western-reading? "gross domestic product"))) + (t/is (false? (cjk/western-reading? "エディター"))) + (t/is (false? (cjk/western-reading? "GDP"))) + (t/is (false? (cjk/western-reading? "かnji"))) + (t/is (false? (cjk/western-reading? ""))) + (t/is (false? (cjk/western-reading? nil)))) + +(t/deftest schema-accepts-every-japanese-enum-value + (doseq [[attr values] (dissoc cjk/enum-values :writing-mode :line-adjustment) + value values] + (t/is (ctst/valid-content? (with-span-attrs {attr value})) (pr-str attr value))) + (doseq [value (:writing-mode cjk/enum-values)] + (t/is (ctst/valid-content? (with-paragraph-attrs {:writing-mode value})) value)) + (doseq [value (:line-adjustment cjk/enum-values)] + (t/is (ctst/valid-content? (assoc content-base :line-adjustment value)) value)) + (t/is (not (ctst/valid-content? (assoc content-base :line-adjustment "squeeze"))))) + +(t/deftest japanese-enum-defaults-are-the-first-values + (t/is (= "horizontal-tb" (cjk/enum-default :writing-mode))) + (t/is (= "space-around" (cjk/enum-default :ruby-align))) + (t/is (= "auto" (cjk/enum-default :ruby-overhang))) + (t/is (= "push-in-first" (cjk/enum-default :line-adjustment))) + (t/is (= {:writing-mode "horizontal-tb" :text-orientation "mixed"} + cjk/paragraph-attr-defaults))) + +(t/deftest span-attr-defaults-cover-every-span-attr + (t/is (= (set cjk/span-attrs) (set (keys cjk/span-attr-defaults)))) + (t/is (= "" (:ruby cjk/span-attr-defaults))) + (t/is (false? (:ruby-hidden cjk/span-attr-defaults))) + (t/is (= "half" (:ruby-size cjk/span-attr-defaults)))) + +(t/deftest valid-enum-value-checks-the-attr-values + (t/is (cjk/valid-enum-value? :ruby-side "under")) + (t/is (not (cjk/valid-enum-value? :ruby-side "left"))) + (t/is (not (cjk/valid-enum-value? :ruby-side nil)))) + +(t/deftest digit-combine-covers-the-digits-values-only + (t/is (every? cjk/digit-combine? ["digits" "digits2" "digits3"])) + (t/is (not-any? cjk/digit-combine? ["none" "all" nil]))) + +(t/deftest warichu-notes-show-no-ruby + (t/is (= "かん" (cjk/visible-ruby {:text "漢字" :ruby "かん"}))) + (t/is (nil? (cjk/visible-ruby {:text "漢字" :ruby "かん" :warichu "warichu"}))) + (t/is (nil? (cjk/visible-ruby {:text "漢字" :ruby "かん" :ruby-hidden true})))) + +(t/deftest ruby-span-is-true-for-hidden-readings + (t/is (cjk/ruby-span? {:ruby "かん" :ruby-hidden true})) + (t/is (not (cjk/ruby-span? {:ruby " "}))) + (t/is (not (cjk/ruby-span? {})))) + +(t/deftest whole-shape-attr-reads-the-first-paragraph + (let [content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" :writing-mode "vertical-rl" + :children [{:text "a"}]} + {:type "paragraph" :writing-mode "horizontal-tb" + :children [{:text "b"}]}]}]}] + (t/is (= "vertical-rl" (cjk/whole-shape-attr content :writing-mode))) + (t/is (nil? (cjk/whole-shape-attr content :text-orientation))))) diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index f8368aa34a..98de96504f 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -25,13 +25,14 @@ "SUMMARY" "TABLE" "TBODY" "TFOOT" "THEAD" "TR" "UL"}) ;; Elements whose text is read; any other element is skipped with its children. -;; `O:P` is Word's paragraph filler, which marks its empty lines. +;; `O:P` is Word's paragraph filler, which marks its empty lines. Ruby keeps its +;; base text (`RUBY`, `RB`); the reading (`RT`, `RTC`) and fallback `RP` are skipped. (def ^:private allowed-tags (into block-tags #{"A" "ABBR" "B" "BDI" "BDO" "BIG" "BR" "CENTER" "CITE" "CODE" "DATA" - "DEL" "DFN" "EM" "FONT" "I" "INS" "KBD" "LABEL" "MARK" "O:P" "Q" "S" - "SAMP" "SMALL" "SPAN" "STRIKE" "STRONG" "SUB" "SUP" "TD" "TH" "TIME" - "TT" "U" "VAR" "WBR"})) + "DEL" "DFN" "EM" "FONT" "I" "INS" "KBD" "LABEL" "MARK" "O:P" "Q" "RB" + "RUBY" "S" "SAMP" "SMALL" "SPAN" "STRIKE" "STRONG" "SUB" "SUP" "TD" + "TH" "TIME" "TT" "U" "VAR" "WBR"})) (def ^:private bold-tags #{"B" "STRONG" "TH" "H1" "H2" "H3" "H4" "H5" "H6"}) (def ^:private italic-tags #{"I" "EM" "CITE" "VAR" "DFN"}) diff --git a/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs b/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs index 86da4c21c0..e582530271 100644 --- a/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs +++ b/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs @@ -276,3 +276,20 @@ (t/testing "tokens shared by both shapes are applied" (t/is (= "opacity.50" (get-in result [:applied-tokens :opacity])))))) + +;; --------------------------------------------------------------------------- +;; Ruby readings and warichu notes belong to their own characters +;; --------------------------------------------------------------------------- + +(t/deftest copy-props-leaves-ruby-reading-and-warichu-behind + (let [source (text {:content (content {:text "漢字" + :font-size "20" + :ruby "かんじ" + :ruby-size "third" + :warichu "warichu"})}) + target (text {}) + node (-> (paste-props source target) :content :children first :children first :children first)] + (t/is (= "20" (:font-size node))) + (t/is (= "third" (:ruby-size node))) + (t/is (nil? (:ruby node))) + (t/is (nil? (:warichu node))))) diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index 88b1cbdcfe..a31320866b 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -144,6 +144,17 @@ (html->fragment "
| a | b |
| c | d |
漢字です
")))) + + (t/testing "rb bases and rp fallback parentheses" + (t/is (= [(paragraph (run "漢字"))] + (html->fragment + (str "