diff --git a/common/src/app/common/fonts.cljs b/common/src/app/common/fonts.cljs index 65a1aa4781..9c30aee95c 100644 --- a/common/src/app/common/fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -244,28 +244,36 @@ used unicode-ranges)) -(defn- locale->cjk-language - "CJK language implied by an app locale string, or nil." - [locale] - (let [locale (str/lower (str locale))] - (cond - (str/starts-with? locale "ja") :japanese - (str/starts-with? locale "ko") :korean - (str/starts-with? locale "zh") :chinese - :else nil))) +(defn- language->cjk-font + "CJK font class implied by a BCP 47 language tag or an app locale + (`ja`, `zh-Hant-TW`, `zh_hant`), or nil for a non-CJK language." + [language] + (let [[primary & subtags] (str/split (str/lower (str language)) #"[-_]") + subtags (set subtags)] + (case primary + "ja" :japanese + "ko" :korean + "zh" (cond + (some subtags ["hk" "mo"]) :chinese-hongkong + (some subtags ["tw" "hant"]) :chinese-traditional + :else :chinese) + nil))) (defn resolve-ambiguous-cjk "Resolves the ambiguous CJK classes (:han, :cjk-punctuation) to a - concrete language: an unambiguous script in the same content wins - (kana implies Japanese, hangul implies Korean), then the `locale` - (nil when the host has none); Chinese is the final default." - [langs locale] + concrete font class: a CJK paragraph `language` wins, then an + unambiguous script in the same content (kana implies Japanese, hangul + implies Korean), then the app `locale`; Chinese is the final default. + `language` and `locale` are nil when unset." + [langs language locale] (if (or (contains? langs :han) (contains? langs :cjk-punctuation)) - (let [resolved (cond - (contains? langs :japanese) :japanese - (contains? langs :korean) :korean - :else (or (locale->cjk-language locale) :chinese))] + (let [resolved (or (language->cjk-font language) + (cond + (contains? langs :japanese) :japanese + (contains? langs :korean) :korean) + (language->cjk-font locale) + :chinese)] (-> langs (disj :han :cjk-punctuation) (conj resolved))) @@ -283,6 +291,8 @@ (def noto-fonts {:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} + :chinese-traditional {:font-id "gfont-noto-sans-tc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} + :chinese-hongkong {:font-id "gfont-noto-sans-hk" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} :cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true} diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs index fd643749af..94f546084c 100644 --- a/common/src/app/common/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -127,7 +127,7 @@ (mem/write-u8 dview text-transform) (mem/write-u8 dview writing-mode) (mem/write-u8 dview text-orientation) - (mem/write-u8 dview (if (= "spaces-only" (:korean-wrap paragraph)) 1 0)) + (mem/write-u8 dview (case (:korean-wrap paragraph) "spaces-only" 1 "keep-words" 2 0)) ;; Alignment padding; must match RawParagraphData in Rust. (mem/write-u8 dview 0) (mem/write-f32 dview line-height) diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index ddc8982ffa..e7189728dd 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -48,8 +48,8 @@ [: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})+)$"]] - ;; Experimental POC mode, deliberately distinct from CSS keep-all. - [:korean-wrap {:optional true} [:enum "syllable" "spaces-only"]] + ;; 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)] [:text-orientation {:optional true} (schema:jl-enum :text-orientation)] [:text-decoration {:optional true} ::sm/text] diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index f8f5b3129f..400d8d0b76 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -494,3 +494,8 @@ :children [{:text "b"}]}]}]}] (t/is (= "vertical-rl" (cjk/whole-shape-attr content :writing-mode))) (t/is (nil? (cjk/whole-shape-attr content :text-orientation))))) + +(t/deftest regional-chinese-and-korean-word-profiles-validate + (doseq [language ["zh-Hans-CN" "zh-Hant-CN" "zh-Hant-TW" "zh-Hant-HK" "ko-KR"]] + (t/is (ctst/valid-content? + (with-paragraph-attrs {:text-language language :korean-wrap "keep-words"}))))) diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index 30954bd30a..d3becb8d33 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -298,16 +298,24 @@ several languages map to one noto family and provisioning is concurrent — otherwise they all miss the byte cache at once and refetch the same TTF." [scene] - (let [texts (for [shape (vals scene) - :when (= :text (:type shape)) - node (or (some->> (:content shape) (tree-seq :children :children)) []) - text [(:text node) (:ruby node)] - :when (string? text)] - text) - emoji? (boolean (some cfnt/contains-emoji? texts)) - ;; The exporter has no user locale; Han-only text falls back to Chinese. - langs (-> (reduce cfnt/collect-used-languages #{} texts) - (cfnt/resolve-ambiguous-cjk nil))] + (let [paragraphs (for [shape (vals scene) + :when (= :text (:type shape)) + node (or (some->> (:content shape) (tree-seq :children :children)) []) + :when (= "paragraph" (:type node))] + node) + ;; Base text and ruby readings of each paragraph, which share its language. + texts (map (fn [paragraph] + (apply str (mapcat (juxt :text :ruby) (:children paragraph)))) + paragraphs) + emoji? (boolean (some cfnt/contains-emoji? texts)) + ;; The exporter has no user locale: Han follows the paragraph language, + ;; else the content, else Chinese. + langs (reduce into #{} + (map (fn [paragraph text] + (-> (cfnt/collect-used-languages #{} text) + (cfnt/resolve-ambiguous-cjk (:text-language paragraph) nil))) + paragraphs + texts))] (distinct (cond-> (cfnt/add-noto-fonts [] langs) emoji? (cfnt/add-emoji-font))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs index 2dc175b2b4..5618c7f879 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs @@ -19,7 +19,11 @@ (case language :japanese [{:id "ja" :label (translate "workspace.options.text-options.profile-japanese")}] :chinese [{:id "zh-Hans" :label (translate "workspace.options.text-options.profile-chinese-simplified")} - {:id "zh-Hant" :label (translate "workspace.options.text-options.profile-chinese-traditional")}] + {:id "zh-Hant" :label (translate "workspace.options.text-options.profile-chinese-traditional")} + {:id "zh-Hans-CN" :label (translate "workspace.options.text-options.profile-chinese-mainland-simplified")} + {:id "zh-Hant-CN" :label (translate "workspace.options.text-options.profile-chinese-mainland-traditional")} + {:id "zh-Hant-TW" :label (translate "workspace.options.text-options.profile-chinese-taiwan")} + {:id "zh-Hant-HK" :label (translate "workspace.options.text-options.profile-chinese-hong-kong")}] :korean [{:id "ko" :label (translate "workspace.options.text-options.profile-korean")}])) mixed? (= :multiple value) id (if mixed? "multiple" (or value ""))] @@ -30,6 +34,13 @@ :disabled true :dimmed true})))) +(defn korean-wrap-options + [value translate] + (cond-> [{:id "syllable" :label (translate "workspace.options.text-options.korean-wrap-syllable")} + {:id "keep-words" :label (translate "workspace.options.text-options.korean-wrap-keep-words")}] + (= value "spaces-only") + (conj {:id "spaces-only" :label (translate "workspace.options.text-options.korean-wrap-spaces-only")}))) + (defn profile-change [value] {:text-language (when (seq value) value)}) @@ -65,5 +76,4 @@ {:attr :korean-wrap :default-value "syllable" :label (tr "workspace.options.text-options.korean-wrap") - :options [{:id "syllable" :label (tr "workspace.options.text-options.korean-wrap-syllable")} - {:id "spaces-only" :label (tr "workspace.options.text-options.korean-wrap-spaces-only")}]})])])) + :options (korean-wrap-options (:korean-wrap values) tr)})])])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 67021b3019..e4cea6b556 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1367,11 +1367,18 @@ (props/set-shape-shadows shadows)) (defn- fallback-font-set - "Emoji and Noto faces covering `emoji?` and the scripts in `langs`." + "Emoji and Noto faces covering `emoji?` and the resolved scripts in `langs`." [emoji? langs] (-> #{} (cond-> ^boolean emoji? (cfnt/add-emoji-font)) - (cfnt/add-noto-fonts (cfnt/resolve-ambiguous-cjk langs @i18n/locale)))) + (cfnt/add-noto-fonts langs))) + +(defn- text-font-languages + "Scripts `text` needs, with Han resolved by the paragraph `language` + (nil when unset) and then the app locale." + [text language] + (-> (cfnt/collect-used-languages #{} text) + (cfnt/resolve-ambiguous-cjk language @i18n/locale))) (defn fonts-from-text-content [content fallback-fonts-only?] (let [paragraph-set (first (get content :children)) @@ -1392,7 +1399,7 @@ (let [text (apply str (map :text spans)) fallback-text (apply str (map #(str (:text %) (:ruby %)) spans)) emoji? (if emoji? emoji? (cfnt/contains-emoji? fallback-text)) - langs (cfnt/collect-used-languages langs fallback-text)] + langs (into langs (text-font-languages fallback-text (:text-language paragraph)))] ;; FIXME: this should probably be somewhere else (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) @@ -1409,6 +1416,13 @@ ;; Fallback faces a composition waits on, so preview updates do not queue more relayouts. (defonce ^:private composition-pending-faces (atom #{})) +(defn- caret-paragraph-language + "Language of the paragraph holding the editor caret in `shape-id`, or nil." + [shape-id] + (when-let [{:keys [focus-para]} (text-editor/text-editor-get-selection)] + (dm/get-in (text-editor/get-cached-content shape-id) + [:children 0 :children focus-para :text-language]))) + (defn load-composition-fonts! "Fetch the fallback faces (emoji, Noto, ...) that `text` needs and relayout `shape-id` once they are stored. For text that lives only in WASM, such as @@ -1416,7 +1430,7 @@ `set-shape-text-content`." [shape-id text] (let [emoji? (cfnt/contains-emoji? text) - langs (cfnt/collect-used-languages #{} text) + langs (text-font-languages text (caret-paragraph-language shape-id)) waiting @composition-pending-faces fonts (into [] (comp (filter :is-fallback) diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index e688822f0c..de5a100682 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -20,6 +20,7 @@ [app.render-wasm.api :as api] [app.render-wasm.api.texts :as texts] [app.render-wasm.text-editor :as text-editor] + [app.util.i18n :as i18n] [cljs.test :as t :include-macros true] [frontend-tests.helpers.mock :as mock])) @@ -404,6 +405,14 @@ [dview] (mapv #(.getUint8 dview %) (:padding span-attr-offset))) +(t/deftest paragraph-wrap-modes-have-stable-distinct-protocol-bytes + (doseq [[mode expected] [[nil 0] ["syllable" 0] ["spaces-only" 1] ["keep-words" 2] ["invalid" 0]]] + (let [dview (js/DataView. (js/ArrayBuffer. tc/PARAGRAPH-ATTR-U8-SIZE))] + (@#'tc/write-paragraph 0 dview {:text-language "ko" :korean-wrap mode}) + (t/is (= expected (.getUint8 dview 6))) + (t/is (= 0 (.getUint8 dview 7))) + (t/is (= 2 (.getUint32 dview 16 true)))))) + (t/deftest write-spans-serializes-font-features (let [dview (serialized-span {:text "日本語" :font-features "vpal"})] (t/is (= 2 (span-byte dview :font-features))) @@ -472,40 +481,70 @@ (t/deftest resolve-kana-wins-over-locale ;; Kana in the same content implies Japanese regardless of locale. (t/is (= #{:japanese} - (cfnt/resolve-ambiguous-cjk #{:japanese :han :cjk-punctuation} "zh"))) + (cfnt/resolve-ambiguous-cjk #{:japanese :han :cjk-punctuation} nil "zh"))) (t/is (= #{:japanese} - (cfnt/resolve-ambiguous-cjk #{:japanese :han} "en")))) + (cfnt/resolve-ambiguous-cjk #{:japanese :han} nil "en")))) (t/deftest resolve-hangul-wins-over-locale (t/is (= #{:korean} - (cfnt/resolve-ambiguous-cjk #{:korean :han} "ja")))) + (cfnt/resolve-ambiguous-cjk #{:korean :han} nil "ja")))) (t/deftest resolve-han-only-uses-locale - (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja"))) - (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja_paid"))) - (t/is (= #{:korean} (cfnt/resolve-ambiguous-cjk #{:han} "ko"))) - (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "zh_cn")))) + (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} nil "ja"))) + (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} nil "ja_paid"))) + (t/is (= #{:korean} (cfnt/resolve-ambiguous-cjk #{:han} nil "ko"))) + (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil "zh_cn")))) (t/deftest resolve-han-only-defaults-to-chinese ;; Without kana, hangul or a CJK locale, Han resolves to Chinese (Noto Sans SC). - (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "en"))) - (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil)))) + (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil "en"))) + (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil nil)))) (t/deftest resolve-punctuation-only - (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} "ja"))) - (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} "en")))) + (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} nil "ja"))) + (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:cjk-punctuation} nil "en")))) + +(t/deftest resolve-paragraph-language-wins + ;; The paragraph language decides Han, whatever the content or locale. + (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "ja" "en"))) + (t/is (= #{:chinese :japanese} + (cfnt/resolve-ambiguous-cjk #{:japanese :han} "zh-Hans" "ja"))) + (t/is (= #{:korean} (cfnt/resolve-ambiguous-cjk #{:han} "ko" nil))) + ;; A non-CJK paragraph language leaves the content and locale to decide. + (t/is (= #{:japanese} (cfnt/resolve-ambiguous-cjk #{:han} "en-GB" "ja")))) + +(t/deftest resolve-regional-chinese + (t/is (= #{:chinese-traditional} (cfnt/resolve-ambiguous-cjk #{:han} "zh-Hant-TW" nil))) + (t/is (= #{:chinese-traditional} (cfnt/resolve-ambiguous-cjk #{:han} "zh-TW" nil))) + (t/is (= #{:chinese-hongkong} (cfnt/resolve-ambiguous-cjk #{:han} "zh-Hant-HK" nil))) + (t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "zh-CN" nil))) + ;; App locales use underscores. + (t/is (= #{:chinese-traditional} (cfnt/resolve-ambiguous-cjk #{:han} nil "zh_hant")))) + +(t/deftest paragraph-language-selects-the-han-fallback-font + (let [content (fn [language] + {:children + [{:children + [{:text-language language + :children [{:text "東京都"}]}]}]}) + fonts #(font-ids (api/fonts-from-text-content (content %) false))] + (with-redefs [i18n/locale (atom "en")] + (t/is (contains? (fonts "ja") "gfont-noto-sans-jp")) + (t/is (not (contains? (fonts "ja") "gfont-noto-sans-sc"))) + (t/is (contains? (fonts "zh-Hant-TW") "gfont-noto-sans-tc")) + (t/is (contains? (fonts nil) "gfont-noto-sans-sc"))))) (t/deftest resolve-leaves-unambiguous-sets-alone - (t/is (= #{:latin-ext} (cfnt/resolve-ambiguous-cjk #{:latin-ext} "ja"))) - (t/is (= #{} (cfnt/resolve-ambiguous-cjk #{} "ja"))) + (t/is (= #{:latin-ext} (cfnt/resolve-ambiguous-cjk #{:latin-ext} nil "ja"))) + (t/is (= #{} (cfnt/resolve-ambiguous-cjk #{} nil "ja"))) ;; Other detected languages survive resolution. (t/is (= #{:japanese :cyrillic} - (cfnt/resolve-ambiguous-cjk #{:han :cyrillic} "ja")))) + (cfnt/resolve-ambiguous-cjk #{:han :cyrillic} nil "ja")))) (t/deftest resolve-han-unification ;; A kanji-only Japanese address resolves to Japanese under a ja locale. (t/is (= #{:japanese} - (cfnt/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") "ja")))) + (cfnt/resolve-ambiguous-cjk (langs "東京都渋谷区神南一丁目") nil "ja")))) (t/deftest wasm-enum-exports-match-the-japanese-value-table (doseq [[attr values] cjk/enum-values] diff --git a/frontend/test/frontend_tests/ui/text_options_test.cljs b/frontend/test/frontend_tests/ui/text_options_test.cljs index d414491639..1062d2f145 100644 --- a/frontend/test/frontend_tests/ui/text_options_test.cljs +++ b/frontend/test/frontend_tests/ui/text_options_test.cljs @@ -74,9 +74,18 @@ other (last (tll/profile-options :chinese "ja" identity))] (t/is (= {:id "multiple" :label "labels.mixed-values" :disabled true :dimmed true} mixed)) (t/is (= {:id "ja" :label "ja" :disabled true :dimmed true} other)) - (t/is (= ["" "zh-Hans" "zh-Hant"] + (t/is (= ["" "zh-Hans" "zh-Hant" "zh-Hans-CN" "zh-Hant-CN" "zh-Hant-TW" "zh-Hant-HK"] (mapv :id (tll/profile-options :chinese nil identity)))))) +(t/deftest korean-word-wrapping-is-distinct-from-the-saved-experiment + (t/is (= ["syllable" "keep-words"] + (mapv :id (tll/korean-wrap-options nil identity)))) + (t/is (= ["syllable" "keep-words" "spaces-only"] + (mapv :id (tll/korean-wrap-options "spaces-only" identity)))) + (t/is (= "workspace.options.text-options.profile-chinese-taiwan" + (:label (select/get-option (tll/profile-options :chinese "zh-Hant-TW" identity) "zh-Hant-TW")))) + (t/is (= {:text-language "zh-Hant-CN"} (tll/profile-change "zh-Hant-CN")))) + (t/deftest language-specific-controls-require-an-explicit-matching-profile (with-redefs [hooks/use-portal-container (fn ([] nil) ([_] nil))] (doseq [[language attr label] [[:japanese "ja" "workspace.options.text-options.line-adjustment"] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index ae86ef2b30..4f3c47e9d4 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10021,3 +10021,23 @@ msgstr "By syllable" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.korean-wrap-spaces-only" msgstr "At spaces (experimental)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-mainland-simplified" +msgstr "Mainland China · Simplified" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-mainland-traditional" +msgstr "Mainland China · Traditional" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-taiwan" +msgstr "Taiwan" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-hong-kong" +msgstr "Hong Kong" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.korean-wrap-keep-words" +msgstr "Keep words together" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index bff81c319d..7777334e4c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9897,3 +9897,23 @@ msgstr "Por sílaba" #: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs msgid "workspace.options.text-options.korean-wrap-spaces-only" msgstr "En los espacios (experimental)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-mainland-simplified" +msgstr "China continental · Simplificado" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-mainland-traditional" +msgstr "China continental · Tradicional" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-taiwan" +msgstr "Taiwán" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.profile-chinese-hong-kong" +msgstr "Hong Kong" + +#: src/app/main/ui/workspace/sidebar/options/menus/text_language_layout.cljs +msgid "workspace.options.text-options.korean-wrap-keep-words" +msgstr "Mantener las palabras juntas" diff --git a/render-wasm/docs/cjk-layout.md b/render-wasm/docs/cjk-layout.md new file mode 100644 index 0000000000..f634e493a2 --- /dev/null +++ b/render-wasm/docs/cjk-layout.md @@ -0,0 +1,80 @@ +# Chinese and Korean core layout + +Paragraph `text-language` selects a layout policy. Writing mode remains a +whole-shape value. UI availability never changes either attribute. + +## Named Chinese presets + +| Tags | Preset | +| --- | --- | +| `zh`, `zh-Hans`, `zh-Hant`, unknown regions | Original basic punctuation policy | +| `zh-Hans-CN`, `zh-Hant-CN`, `zh-CN`, `zh-SG` | Mainland: adjacent punctuation compression; quarter-em Han/Latin gaps | +| `zh-Hant-TW`, `zh-TW` | Taiwan: solid punctuation; quarter-em Han/Latin gaps | +| `zh-Hant-HK`, `zh-HK`, `zh-MO` | Hong Kong: centered punctuation spacing; quarter-em Han/Latin gaps | + +Script does not imply region. Changing a profile does not convert characters. +These presets select a limited set of styles; they do not claim that every +publication in a region uses the same settings. + +Compression applies to eligible adjacent full-width punctuation. Vertical +colon, semicolon, question and exclamation marks keep their full frame. +Proportional horizontal punctuation is not compressed again. Vertical cells +retain at least a half-em frame. The Taiwan and Hong Kong presets allow a +single comma or full stop to hang by at most half an em vertically; adjacent +punctuation and horizontal text do not hang. + +Chinese justification expands eligible character gaps by at most half an em. +The final line/column remains unexpanded. These bounds are explicit POC +choices, not numeric requirements imposed by a regional standard. + +## Korean modes + +- Missing `korean-wrap`, or `syllable`: syllable wrapping with punctuation rules. +- `keep-words`: keep adjoining letters and digits together across scripts and + style runs. Punctuation keeps its own opportunities and restrictions. +- `spaces-only`: preserve the earlier experiment. It remains readable in the + UI for saved text but is not offered for new selections by default. + +Fitting words move whole. Overlong words may use safe grapheme boundaries. +Nonbreaking spaces/joiners, numeric prefixes/suffixes, paired dashes/ellipses, +annotations and punctuation restrictions remain separate from letter ties. +Korean justification first expands spaces, at most half an em per gap. +Syllable mode may then expand eligible character gaps by at most one eighth +of an em. Word mode never expands inside a word. Original spaces remain in +source text; no Japanese word-space substitution applies. + +## Horizontal emergency wrapping and offsets + +SkParagraph can ignore word joins during emergency wrapping. The horizontal +plan measures candidate units and replaces joins with zero-width break +opportunities only inside overlong units, at safe grapheme boundaries. +Both marker characters occupy three UTF-8 bytes and one UTF-16 code unit. +Their offset maps therefore stay identical for drawing, hit testing, carets, +selection, text paths and export. Width participates in the plan cache key; +resizing a box restores whole-word joins when the word fits again. + +A horizontal indivisible unit wider than the entire box remains subject to +SkParagraph's last-resort fallback. Very narrow boxes containing punctuation +or annotation units need further work; this implementation does not claim +complete CSS `keep-all` conformance. + +## Evidence and release checks + +Renderer tests cover regional geometry, horizontal/vertical wrapping, +style boundaries, decomposed Jamo, punctuation, justification, stable offsets +and distinct wrap protocol bytes. Editor splits preserve the complete wrap +mode. Both WASM consumers use the same writer and byte values: 0 syllable, +1 experiment, 2 keep-words; unknown values fall back to syllable wrapping. + +Real Pinyin, Zhuyin and Korean IME checks and native-reader review remain +release checks. Font glyph coverage and localized forms still depend on the +chosen fonts. Pinyin/Zhuyin annotation tools belong to Stage 4. + +References checked on 2026-10-19: + +- [CLREQ draft, 2026-10-07](https://www.w3.org/TR/2026/DNOTE-clreq-20261007/), + sections 6.1 and 6.3.2: punctuation boundaries, compression and hanging. +- [KLREQ draft, 2026-03-21](https://www.w3.org/TR/2026/DNOTE-klreq-20260321/), + sections 7.1 and 7.2: syllable/word wrapping and justification restrictions. +- [CSS Text 3](https://www.w3.org/TR/css-text-3/#word-break-property): + letter-breaking policy is distinct from punctuation and overflow handling. diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index f5ccfec60a..31f1dedb25 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -1,5 +1,5 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use crate::error::{Error, Result}; use crate::shapes::{FontFamily, FontStyle}; @@ -19,13 +19,121 @@ fn default_font_uuid() -> Uuid { Uuid::nil() } +/// CJK region whose glyph forms a fallback face draws, read from its family +/// name (`Noto Sans JP`, `Noto Sans TC`, ...). The declaration order is the +/// fallback order when no language prefers a face. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum CjkFace { + SimplifiedChinese, + TraditionalChinese, + HongKongChinese, + Japanese, + Korean, +} + +impl CjkFace { + fn from_family_name(name: &str) -> Option { + match name.rsplit(' ').next()? { + "SC" => Some(Self::SimplifiedChinese), + "TC" => Some(Self::TraditionalChinese), + "HK" => Some(Self::HongKongChinese), + "JP" => Some(Self::Japanese), + "KR" => Some(Self::Korean), + _ => None, + } + } + + /// Faces drawing Han and CJK punctuation in the forms `language` (a BCP 47 + /// tag) expects, best first. Without a CJK language, kana or hangul in + /// `text` decide. + fn preferred(language: &str, text: &str) -> &'static [Self] { + let mut subtags = language.split(['-', '_']).map(str::to_ascii_lowercase); + match subtags.next().as_deref() { + Some("ja") => &[Self::Japanese], + Some("ko") => &[Self::Korean], + Some("zh") => { + let subtags: Vec = subtags.collect(); + let has = |tag: &str| subtags.iter().any(|subtag| subtag == tag); + if has("hk") || has("mo") { + &[Self::HongKongChinese, Self::TraditionalChinese] + } else if has("tw") || has("hant") { + &[Self::TraditionalChinese, Self::HongKongChinese] + } else { + &[Self::SimplifiedChinese] + } + } + _ if text.chars().any(is_kana) => &[Self::Japanese], + _ if text.chars().any(is_hangul) => &[Self::Korean], + _ => &[], + } + } +} + +fn is_kana(c: char) -> bool { + matches!(c, '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{FF66}'..='\u{FF9F}') +} + +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}') +} + +/// Families that take part in character fallback, with the CJK region of +/// those that draw Han. +#[derive(Debug, Default)] +pub struct FallbackFonts { + families: BTreeSet, + cjk_faces: HashMap, +} + +impl FallbackFonts { + /// Fallback families without CJK regions, in name order. + #[cfg(test)] + pub fn from_families(families: impl IntoIterator) -> Self { + Self { + families: families.into_iter().collect(), + cjk_faces: HashMap::new(), + } + } + + fn insert(&mut self, alias: String, cjk_face: Option) { + if let Some(cjk_face) = cjk_face { + self.cjk_faces.insert(alias.clone(), cjk_face); + } + self.families.insert(alias); + } + + #[cfg(test)] + pub fn contains(&self, alias: &str) -> bool { + self.families.contains(alias) + } + + /// Fallback families for `text` in `language`: the CJK faces the language + /// prefers, then the non-CJK faces, then the other CJK faces. CJK faces + /// share Han characters with different glyph forms, so the first one + /// decides how they look. + pub fn for_text(&self, language: &str, text: &str) -> Vec { + let preferred = CjkFace::preferred(language, text); + let mut families: Vec<&String> = self.families.iter().collect(); + families.sort_by_key(|alias| match self.cjk_faces.get(*alias) { + Some(face) => match preferred.iter().position(|p| p == face) { + Some(rank) => (0, rank), + None => (2, *face as usize), + }, + None => (1, 0), + }); + families.into_iter().cloned().collect() + } +} + pub struct FontStore { font_mgr: FontMgr, font_provider: textlayout::TypefaceFontProvider, font_collection: textlayout::FontCollection, debug_font: Font, ui_font: Font, - fallback_fonts: HashSet, + fallback_fonts: FallbackFonts, + /// CJK region of each registered family that draws Han. + cjk_faces: HashMap, /// Source URL registered when the font was fetched (SVG export references /// this in `@font-face` rules). source_urls: HashMap, @@ -62,7 +170,8 @@ impl FontStore { font_collection, debug_font, ui_font, - fallback_fonts: HashSet::new(), + fallback_fonts: FallbackFonts::default(), + cjk_faces: HashMap::new(), source_urls: HashMap::new(), }) } @@ -98,7 +207,7 @@ impl FontStore { let alias = format!("{}", family); if self.has_family(&family, is_emoji) { if is_fallback { - self.fallback_fonts.insert(alias); + self.insert_fallback(alias); } return Ok(()); } @@ -116,22 +225,30 @@ impl FontStore { alias.as_str() }; + if let Some(cjk_face) = CjkFace::from_family_name(&typeface.family_name()) { + self.cjk_faces.insert(alias.clone(), cjk_face); + } self.font_provider.register_typeface(typeface, font_name); self.font_collection.clear_caches(); if is_fallback { - self.fallback_fonts.insert(alias); + self.insert_fallback(alias); } Ok(()) } + fn insert_fallback(&mut self, alias: String) { + let cjk_face = self.cjk_faces.get(&alias).copied(); + self.fallback_fonts.insert(alias, cjk_face); + } + /// Upgrade an already-uploaded family to take part in character fallback. /// Font bytes are cached apart from a face's role, so a family may arrive /// as a document font and later be requested as a fallback. pub fn mark_as_fallback(&mut self, family: &FontFamily) { if self.has_family(family, false) { - self.fallback_fonts.insert(format!("{}", family)); + self.insert_fallback(format!("{}", family)); } } @@ -145,7 +262,7 @@ impl FontStore { self.font_provider.family_names().any(|x| x == font_name) } - pub fn get_fallback(&self) -> &HashSet { + pub fn get_fallback(&self) -> &FallbackFonts { &self.fallback_fonts } @@ -324,6 +441,63 @@ mod tests { const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); + fn cjk_fallbacks() -> FallbackFonts { + let mut fallbacks = FallbackFonts::default(); + fallbacks.insert("arabic".to_string(), None); + fallbacks.insert("jp".to_string(), Some(CjkFace::Japanese)); + fallbacks.insert("kr".to_string(), Some(CjkFace::Korean)); + fallbacks.insert("sc".to_string(), Some(CjkFace::SimplifiedChinese)); + fallbacks.insert("tc".to_string(), Some(CjkFace::TraditionalChinese)); + fallbacks.insert("hk".to_string(), Some(CjkFace::HongKongChinese)); + fallbacks + } + + #[test] + fn cjk_face_comes_from_the_family_region_suffix() { + assert_eq!( + CjkFace::from_family_name("Noto Sans JP"), + Some(CjkFace::Japanese) + ); + assert_eq!( + CjkFace::from_family_name("Noto Sans HK"), + Some(CjkFace::HongKongChinese) + ); + assert_eq!(CjkFace::from_family_name("Noto Sans Arabic"), None); + assert_eq!(CjkFace::from_family_name(""), None); + } + + #[test] + fn paragraph_language_puts_its_cjk_face_first() { + let fallbacks = cjk_fallbacks(); + assert_eq!(fallbacks.for_text("ja", "東京")[0], "jp"); + assert_eq!(fallbacks.for_text("ko", "漢字")[0], "kr"); + assert_eq!(fallbacks.for_text("zh-Hans", "东京")[0], "sc"); + assert_eq!(fallbacks.for_text("zh-Hant-TW", "東京")[..2], ["tc", "hk"]); + assert_eq!(fallbacks.for_text("zh-Hant-HK", "東京")[..2], ["hk", "tc"]); + assert_eq!(fallbacks.for_text("zh_hant", "東京")[0], "tc"); + } + + #[test] + fn untagged_text_prefers_the_face_of_its_kana_or_hangul() { + let fallbacks = cjk_fallbacks(); + assert_eq!(fallbacks.for_text("", "東京へ行く")[0], "jp"); + assert_eq!(fallbacks.for_text("en", "서울")[0], "kr"); + } + + #[test] + fn fallback_order_is_stable_without_a_preference() { + let fallbacks = cjk_fallbacks(); + assert_eq!( + fallbacks.for_text("", "東京"), + ["arabic", "sc", "tc", "hk", "jp", "kr"] + ); + // Non-CJK faces follow the preferred face, other CJK faces come last. + assert_eq!( + fallbacks.for_text("ja", "東京"), + ["jp", "arabic", "sc", "tc", "hk", "kr"] + ); + } + #[test] fn uploaded_family_can_be_upgraded_to_fallback() { let mut store = FontStore::try_new().unwrap(); diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/kinsoku.rs index 6543ee114b..18251bfdb4 100644 --- a/render-wasm/src/shapes/kinsoku.rs +++ b/render-wasm/src/shapes/kinsoku.rs @@ -108,10 +108,20 @@ pub fn apply_policy_to_span_texts( span_texts: &[String], ruby_breaks: &[Option>], policy: TextLayoutPolicy, +) -> Option<(Vec, OffsetMap)> { + apply_policy_with_emergency_breaks(span_texts, ruby_breaks, policy, &[]) +} + +pub fn apply_policy_with_emergency_breaks( + span_texts: &[String], + ruby_breaks: &[Option>], + policy: TextLayoutPolicy, + emergency_breaks: &[usize], ) -> Option<(Vec, OffsetMap)> { let source = span_texts.concat(); let boundaries: Vec = source.grapheme_indices(true).map(|(at, _)| at).collect(); let mut source_byte = 0; + let mut source_utf16 = 0; let mut inserted: Vec = Vec::new(); let mut out: Vec = Vec::with_capacity(span_texts.len()); // Base character of the previous grapheme cluster, with its class. @@ -126,6 +136,8 @@ pub fn apply_policy_to_span_texts( for c in text.chars() { let at_boundary = boundaries.binary_search(&source_byte).is_ok(); source_byte += c.len_utf8(); + let source_at = source_utf16; + source_utf16 += c.len_utf16(); if !at_boundary { shifted_text.push(c); shifted_pos += c.len_utf16(); @@ -165,7 +177,11 @@ pub fn apply_policy_to_span_texts( } // After an inserted space, the joiner keeps the break blocked. if forbid_break { - insert(WORD_JOINER); + insert(if emergency_breaks.binary_search(&source_at).is_ok() { + BREAK_OPPORTUNITY + } else { + WORD_JOINER + }); } else if policy == TextLayoutPolicy::Japanese && current.1 == JapaneseClass::Inseparable && prev.is_some_and(|p| pair_rule(p.1, current.1).break_allowed) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index e13d35100d..420424c0f6 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -30,6 +30,7 @@ use super::text_horizontal::*; use super::text_japanese::*; use super::{FontFamily, FontStyle}; use crate::math::Point; +use crate::render::FallbackFonts; use crate::shapes::{self, kinsoku, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; @@ -277,10 +278,11 @@ impl std::fmt::Debug for VerticalLayoutCache { #[derive(Clone)] struct HorizontalPlansCache { version: u64, + width: f32, plans: Rc>, } -/// Line adjustment of one horizontal Japanese paragraph at the layout width, +/// Line adjustment of one horizontal paragraph at the layout width, /// per span: the sheds that squeeze its lines and the letter-spacing that /// justifies them. #[derive(Debug, Clone, Default)] @@ -318,7 +320,7 @@ pub struct TextContentLayout { vertical: RefCell>, /// Last horizontal paragraph plans, shared across clones. horizontal: RefCell>, - /// Last Japanese line adjustment, shared across clones. + /// Last horizontal line adjustment, shared across clones. line_adjustment: RefCell>, } @@ -663,16 +665,23 @@ impl TextContent { } /// True when a horizontal paragraph gets its lines adjusted here rather - /// than by Skia: Japanese text that squeezes or is justified. + /// than by Skia: Japanese squeezing or policy-based CJK justification. fn adjusts_lines(&self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> bool { - uses_japanese_layout(paragraph, &plan.texts) + let policy = paragraph.layout_policy(); + (uses_japanese_layout(paragraph, &plan.texts) && (self.line_adjustment == LineAdjustment::PushInFirst - || paragraph.text_align() == skia::textlayout::TextAlign::Justify) + || paragraph.text_align() == skia::textlayout::TextAlign::Justify)) + || ((policy.is_chinese() + || matches!( + policy, + super::text_policy::TextLayoutPolicy::KoreanSyllable + | super::text_policy::TextLayoutPolicy::KoreanWords + )) + && paragraph.text_align() == skia::textlayout::TextAlign::Justify) } - /// Squeeze sheds and justify spacing of each horizontal Japanese - /// paragraph at the layout width (see `horizontal_squeeze_sheds` and - /// `horizontal_justify_spacing`), cached until the content or the width + /// Squeeze sheds and justify spacing of each horizontal paragraph at + /// the layout width, cached until the content or the width /// changes. `None` when no paragraph needs them. fn horizontal_line_adjustments(&self) -> Option { if self.is_vertical() || self.grow_type() == GrowType::AutoWidth { @@ -725,7 +734,10 @@ impl TextContent { laid_out.layout(width); laid_out }; - let (sheds, laid_out) = if self.line_adjustment == LineAdjustment::PushInFirst { + let (sheds, laid_out) = if paragraph.layout_policy() + == super::text_policy::TextLayoutPolicy::Japanese + && self.line_adjustment == LineAdjustment::PushInFirst + { horizontal_squeeze_sheds(paragraph, plan, width, &lay_out) } else { let sheds = vec![Vec::new(); plan.texts.len()]; @@ -733,7 +745,11 @@ impl TextContent { (sheds, laid_out) }; let spacing = if paragraph.text_align() == skia::textlayout::TextAlign::Justify { - horizontal_justify_spacing(&laid_out, plan, width) + if paragraph.layout_policy() == super::text_policy::TextLayoutPolicy::Japanese { + horizontal_justify_spacing(&laid_out, plan, width) + } else { + horizontal_policy_justify_spacing(paragraph, &laid_out, plan, width) + } } else { vec![Vec::new(); plan.texts.len()] }; @@ -788,22 +804,35 @@ impl TextContent { } /// Horizontal passes of every paragraph (kinsoku texts, offsets, aki - /// sheds, ruby spacing), cached until the content changes; clones share - /// them. A font load bumps the content version, which ruby spacing needs. + /// sheds, ruby spacing), cached until content or width changes; clones + /// share them. A font load bumps the content version for shaped spacing. pub(crate) fn horizontal_plans(&self) -> Rc> { + let width = self.get_width(self.bounds().width()); if let Some(entry) = self.layout.horizontal.borrow().as_ref() { - if entry.version == self.content_version { + if entry.version == self.content_version && entry.width == width { return Rc::clone(&entry.plans); } } let plans: Rc> = Rc::new( self.paragraphs .iter() - .map(HorizontalParagraphPlan::new) + .map(|paragraph| { + let mut plan = HorizontalParagraphPlan::new(paragraph); + plan.relax_overlong_words(paragraph, width, |plan| { + let sheds = vec![Vec::new(); plan.texts.len()]; + let mut measured = self + .horizontal_measure_builder(paragraph, plan, &sheds) + .build(); + measured.layout(width); + measured + }); + plan + }) .collect(), ); *self.layout.horizontal.borrow_mut() = Some(HorizontalPlansCache { version: self.content_version, + width, plans: Rc::clone(&plans), }); plans @@ -1865,7 +1894,7 @@ pub struct Paragraph { writing_mode: WritingMode, text_orientation: TextOrientation, language: String, - korean_spaces_only: bool, + korean_wrap: super::text_policy::KoreanWrap, /// Paragraph in the content tree last exchanged with the editor host. source_position: u32, line_height: f32, @@ -1883,7 +1912,7 @@ impl Default for Paragraph { writing_mode: WritingMode::default(), text_orientation: TextOrientation::default(), language: String::new(), - korean_spaces_only: false, + korean_wrap: super::text_policy::KoreanWrap::Syllable, source_position: u32::MAX, line_height: 1.0, letter_spacing: 0.0, @@ -1911,7 +1940,7 @@ impl Paragraph { writing_mode: WritingMode::default(), text_orientation: TextOrientation::default(), language: String::new(), - korean_spaces_only: false, + korean_wrap: super::text_policy::KoreanWrap::Syllable, source_position: u32::MAX, line_height, letter_spacing, @@ -1934,16 +1963,19 @@ impl Paragraph { self.language = language; } - pub fn korean_spaces_only(&self) -> bool { - self.korean_spaces_only + pub fn korean_wrap(&self) -> super::text_policy::KoreanWrap { + self.korean_wrap } - pub fn set_korean_spaces_only(&mut self, value: bool) { - self.korean_spaces_only = value; + pub fn set_korean_wrap(&mut self, value: super::text_policy::KoreanWrap) { + self.korean_wrap = value; } pub fn layout_policy(&self) -> super::text_policy::TextLayoutPolicy { - super::text_policy::TextLayoutPolicy::for_language(&self.language, self.korean_spaces_only) + super::text_policy::TextLayoutPolicy::for_language_with_wrap( + &self.language, + self.korean_wrap, + ) } pub fn source_position(&self) -> u32 { @@ -2397,7 +2429,7 @@ impl TextSpan { pub fn to_style( &self, content_bounds: &Rect, - fallback_fonts: &HashSet, + fallback_fonts: &FallbackFonts, remove_alpha: bool, paragraph_line_height: f32, ) -> skia::textlayout::TextStyle { @@ -2413,7 +2445,7 @@ impl TextSpan { fn to_style_with_paint( &self, content_bounds: &Rect, - fallback_fonts: &HashSet, + fallback_fonts: &FallbackFonts, remove_alpha: bool, paragraph_line_height: f32, fill_layer_from_bottom: Option, @@ -2459,7 +2491,7 @@ impl TextSpan { DEFAULT_EMOJI_FONT.to_string(), ]; - font_families.extend(fallback_fonts.iter().cloned()); + font_families.extend(fallback_fonts.for_text(&self.language, &self.text)); style.set_font_families(&font_families); style.set_font_size(self.font_size); style.set_letter_spacing(self.letter_spacing); @@ -2476,7 +2508,7 @@ impl TextSpan { pub fn to_stroke_style( &self, stroke_paint: &Paint, - fallback_fonts: &HashSet, + fallback_fonts: &FallbackFonts, remove_alpha: bool, paragraph_line_height: f32, ) -> skia::textlayout::TextStyle { diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs index e2ee5b7433..95906155a6 100644 --- a/render-wasm/src/shapes/text_horizontal/justify.rs +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -72,6 +72,77 @@ pub(crate) fn horizontal_justify_spacing( spacing } +/// Policy-based, bounded expansion for Chinese and Korean. The final line, +/// annotation interiors, nonbreaking pairs and grapheme interiors stay solid. +pub(crate) fn horizontal_policy_justify_spacing( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + plan: &HorizontalParagraphPlan, + width: f32, +) -> Vec> { + use unicode_segmentation::UnicodeSegmentation; + let policy = paragraph.layout_policy(); + let chars = line_chars(paragraph, plan); + let source: String = chars.iter().map(|c| c.ch).collect(); + let starts: Vec<_> = source + .graphemes(true) + .scan(0usize, |at, g| { + let start = *at; + *at += g.chars().count(); + Some(start) + }) + .collect(); + let mut result = vec![Vec::new(); plan.texts.len()]; + let notes: Vec<_> = plan + .offsets + .ranges + .iter() + .filter(|range| range.warichu) + .collect(); + let lines = laid_out.get_line_metrics(); + for line in lines.iter().take(lines.len().saturating_sub(1)) { + let mut remaining = (width - line.width as f32 - JUSTIFY_EPSILON).max(0.0); + let mut opportunities = Vec::new(); + let start = chars.partition_point(|c| c.utf16 < line.start_index); + let end = chars.partition_point(|c| c.utf16 < line.end_excluding_whitespaces); + for index in start..end.saturating_sub(1) { + let (before, after) = (&chars[index], &chars[index + 1]); + let note = notes.get(notes.partition_point(|r| r.builder_end <= before.utf16)); + if before.utf16 < line.start_index + || after.utf16 >= line.end_excluding_whitespaces + || after.joined + || starts.binary_search(&(index + 1)).is_err() + || note.is_some_and(|r| { + r.builder_start > before.utf16 && r.builder_start < after.utf16 + }) + { + continue; + } + let (priority, capacity) = policy.expansion_em(before.ch, after.ch); + if priority > 0 { + opportunities.push(( + index, + priority, + capacity * before.font_size.min(after.font_size), + )); + } + } + for priority in 1..=2 { + let caps: Vec<_> = opportunities + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(i, _, cap)| (*i, *cap)) + .collect(); + for (index, amount) in capped_equal_allocations(&caps, remaining) { + let ch = &chars[index]; + result[ch.span].push((ch.index, amount)); + remaining -= amount; + } + } + } + result +} + /// Lays a paragraph out with extra per-span sheds (char index, amount). pub(crate) type LayOutWithSheds<'a> = dyn Fn(&[Vec<(usize, f32)>]) -> skia::textlayout::Paragraph + 'a; diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index a12bda3b45..5a23cb647e 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -50,6 +50,13 @@ pub(crate) fn uses_japanese_layout(paragraph: &Paragraph, texts: &[String]) -> b } pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku::OffsetMap) { + layout_span_texts_with_emergency(paragraph, &[]) +} + +fn layout_span_texts_with_emergency( + paragraph: &Paragraph, + emergency: &[usize], +) -> (Vec, kinsoku::OffsetMap) { let texts: Vec = paragraph .children() .iter() @@ -57,22 +64,18 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku: .collect(); // Text without Japanese characters or ruby keeps plain Skia layout. let policy = paragraph.layout_policy(); - if uses_japanese_layout(paragraph, &texts) - || matches!( - policy, - TextLayoutPolicy::Chinese - | TextLayoutPolicy::KoreanSyllable - | TextLayoutPolicy::KoreanSpacesOnly - ) - { + if uses_japanese_layout(paragraph, &texts) || policy.is_chinese() || policy.is_korean() { let ruby_breaks: Vec>> = paragraph .children() .iter() .map(|span| span.has_ruby().then(Vec::new)) .collect(); - if let Some((shifted, map)) = + let shifted = if emergency.is_empty() { kinsoku::apply_policy_to_span_texts(&texts, &ruby_breaks, policy) - { + } else { + kinsoku::apply_policy_with_emergency_breaks(&texts, &ruby_breaks, policy, emergency) + }; + if let Some((shifted, map)) = shifted { return (shifted, map); } } @@ -89,7 +92,7 @@ pub(crate) fn horizontal_aki_sheds( texts: &[String], ) -> Vec> { if paragraph.layout_policy() != TextLayoutPolicy::Japanese { - return vec![Vec::new(); texts.len()]; + return horizontal_policy_spacing(paragraph, texts); } let spans = paragraph.children(); let half_em = |span: usize| spans.get(span).map_or(0.0, |span| span.font_size * 0.5); @@ -119,6 +122,41 @@ pub(crate) fn horizontal_aki_sheds( sheds } +/// Signed advance changes: positive removes punctuation space, negative adds +/// the regional Han/Latin gap. Source and builder text stay unchanged. +fn horizontal_policy_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec> { + let mut result = vec![Vec::new(); texts.len()]; + let policy = paragraph.layout_policy(); + let mut previous: Option<(usize, usize, char)> = None; + for (span, text) in texts.iter().enumerate() { + for (index, ch) in text.chars().enumerate() { + if kinsoku::is_inserted_zero_width(ch) { + continue; + } + if let Some((before_span, before_index, before)) = previous { + let a = ¶graph.children()[before_span]; + let b = ¶graph.children()[span]; + if !a.has_ruby() && !b.has_ruby() && !a.is_warichu() && !b.is_warichu() { + let em = a.font_size.min(b.font_size); + let compression = if a.font_features == FontFeatures::Palt + || b.font_features == FontFeatures::Palt + { + 0.0 + } else { + policy.compression_em(before, ch, false) + }; + let amount = (compression - policy.script_spacing_em(before, ch)) * em; + if amount != 0.0 { + result[before_span].push((before_index, amount)); + } + } + } + previous = Some((span, index, ch)); + } + } + result +} + /// Add a span to a horizontal paragraph builder. A warichu span becomes one /// inline placeholder so its two lines wrap as a unit; its glyphs are painted /// after layout. The characters at `sheds` (from `horizontal_aki_sheds`) @@ -182,17 +220,21 @@ fn add_text_with_sheds( text_style: &skia::textlayout::TextStyle, ) { let tracking = text_style.letter_spacing(); + let count = text.chars().count(); + let mut amounts = vec![(0.0, 0.0); count]; + for &(index, amount) in extra { + if let Some((added, _)) = amounts.get_mut(index) { + *added += amount; + } + } + for &(index, amount) in sheds { + if let Some((_, shed)) = amounts.get_mut(index) { + *shed += amount; + } + } let mut piece_start = 0; for (index, (byte, ch)) in text.char_indices().enumerate() { - let amount_at = |adjustments: &[(usize, f32)]| -> f32 { - adjustments - .iter() - .filter(|(char_index, _)| *char_index == index) - .map(|(_, amount)| amount) - .sum() - }; - let added = amount_at(extra); - let shed = amount_at(sheds); + let (added, shed) = amounts[index]; let letter_spacing = if kinsoku::is_inserted_zero_width(ch) { 0.0 } else { @@ -225,7 +267,117 @@ pub(crate) struct HorizontalParagraphPlan { impl HorizontalParagraphPlan { pub(crate) fn new(paragraph: &Paragraph) -> Self { - let (texts, offset_map) = paragraph.layout_span_texts(); + Self::with_emergency_breaks(paragraph, &[]) + } + + /// SkParagraph's emergency fallback can ignore punctuation joins inside + /// an overlong word. Measure whole units once, then reopen only safe + /// grapheme boundaries of units that cannot fit an empty line. The + /// replacement markers have identical UTF-8/UTF-16 lengths, so every + /// source/builder offset remains identical to the unrelaxed plan. + pub(crate) fn relax_overlong_words( + &mut self, + paragraph: &Paragraph, + width: f32, + measure: impl FnOnce(&Self) -> skia::textlayout::Paragraph, + ) { + use unicode_segmentation::UnicodeSegmentation; + let policy = paragraph.layout_policy(); + if !(policy.is_chinese() + || matches!( + policy, + TextLayoutPolicy::KoreanWords | TextLayoutPolicy::KoreanSyllable + )) + || !width.is_finite() + || width <= 0.0 + || width == f32::MAX + { + return; + } + let source: String = paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform) + .collect(); + let mut utf16 = 0; + let graphemes: Vec<_> = source + .graphemes(true) + .map(|g| { + let at = utf16; + utf16 += g.encode_utf16().count(); + (at, g.chars().next().unwrap()) + }) + .collect(); + let mut offset = 0; + let protected: Vec<_> = paragraph + .children() + .iter() + .filter_map(|span| { + let start = offset; + offset += span.apply_text_transform().encode_utf16().count(); + (span.has_ruby() || span.is_warichu()).then_some(start..offset) + }) + .collect(); + let mut units = Vec::new(); + let mut start = 0; + for at in 1..graphemes.len() { + if policy.break_allowed(graphemes[at - 1].1, graphemes[at].1) { + if at - start > 1 { + units.push(start..at); + } + start = at; + } + } + if graphemes.len() - start > 1 { + units.push(start..graphemes.len()); + } + if units.is_empty() { + return; + } + let measured = measure(self); + let line_starts: Vec<_> = measured + .get_line_metrics() + .iter() + .skip(1) + .map(|line| line.start_index) + .collect(); + let mut emergency = Vec::new(); + for unit in units { + let first = graphemes[unit.start].0; + let last = graphemes.get(unit.end).map_or(utf16, |g| g.0); + let from = self.offsets.transformed_to_builder(first); + let to = self.offsets.transformed_to_builder(last); + let split = line_starts.partition_point(|at| *at <= from); + if line_starts.get(split).is_none_or(|at| *at >= to) { + continue; + } + let extent: f32 = measured + .get_rects_for_range(from..to, RectHeightStyle::Tight, RectWidthStyle::Tight) + .iter() + .map(|box_| box_.rect.width()) + .sum(); + if extent <= width + 0.01 { + continue; + } + for at in unit.start + 1..unit.end { + let (position, after) = graphemes[at]; + let before = graphemes[at - 1].1; + if policy.emergency_break_allowed(before, after) + && protected + .get(protected.partition_point(|r| r.end <= position)) + .is_none_or(|r| position <= r.start) + { + emergency.push(position); + } + } + } + if !emergency.is_empty() { + *self = Self::with_emergency_breaks(paragraph, &emergency); + } + } + + fn with_emergency_breaks(paragraph: &Paragraph, emergency: &[usize]) -> Self { + let (texts, offset_map) = layout_span_texts_with_emergency(paragraph, emergency); let sheds = horizontal_aki_sheds(paragraph, &texts); let ruby_spacing = horizontal_ruby_spacing(paragraph, &texts, &offset_map); let offsets = HorizontalOffsets::from_layout_texts(paragraph, &texts, offset_map); diff --git a/render-wasm/src/shapes/text_horizontal/offsets.rs b/render-wasm/src/shapes/text_horizontal/offsets.rs index 0bd17537ae..6b06581c31 100644 --- a/render-wasm/src/shapes/text_horizontal/offsets.rs +++ b/render-wasm/src/shapes/text_horizontal/offsets.rs @@ -50,10 +50,17 @@ impl HorizontalOffsets { .get(source_char_offset) .copied() .unwrap_or_else(|| boundaries.last().copied().unwrap_or(0)); + self.transformed_to_builder(source_utf16) + } + + pub(super) fn transformed_to_builder(&self, source_utf16: usize) -> usize { + let at = self + .ranges + .partition_point(|range| range.source_end < source_utf16); let Some(range) = self .ranges - .iter() - .find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end) + .get(at) + .filter(|range| source_utf16 >= range.source_start) else { return self .ranges diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index 90cd49de36..3e21f6b55b 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -1,3 +1,4 @@ +use crate::render::FallbackFonts; use std::collections::HashMap; use super::*; @@ -67,13 +68,12 @@ pub(crate) fn horizontal_ruby_spacing( if !paragraph.children().iter().any(TextSpan::has_ruby) { return HorizontalRubySpacing::empty(texts.len()); } - let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); horizontal_ruby_spacing_with( paragraph, texts, offset_map, get_resources().fonts.font_provider(), - &fallback_families, + get_fallback_fonts(), ) } @@ -94,7 +94,7 @@ pub(super) fn horizontal_ruby_spacing_with( texts: &[String], offset_map: &kinsoku::OffsetMap, font_provider: &skia::textlayout::TypefaceFontProvider, - fallback_families: &[String], + fallback_families: &FallbackFonts, ) -> HorizontalRubySpacing { let spans = paragraph.children(); let mut spacing = HorizontalRubySpacing::empty(texts.len()); @@ -365,7 +365,7 @@ pub(crate) fn paint_horizontal_ruby( } let font_provider = get_resources().fonts.font_provider(); let fallback_mgr = FontMgr::from(font_provider.clone()); - let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); + let fallback_families = get_fallback_fonts(); let bounds = text_content.bounds(); let plans = text_content.horizontal_plans(); let Some(plan) = plans.get(paragraph_index) else { @@ -389,7 +389,7 @@ pub(crate) fn paint_horizontal_ruby( let shaped = shape_segment_with_fallbacks( span.ruby_text(), span.ruby_font_size(), - &span_font_families(span, &fallback_families), + &span_font_families(span, fallback_families), font_provider, false, span.font_features, diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index b984fa36c1..31a3c32cec 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -7,6 +7,181 @@ use super::*; use crate::shapes::{TextAlign, TextDirection, TextTransform}; use crate::Uuid; +fn cjk_horizontal_layout( + texts: &[&str], + language: &str, + wrap: crate::shapes::text_policy::KoreanWrap, + width: f32, +) -> ( + Paragraph, + HorizontalParagraphPlan, + skia::textlayout::Paragraph, +) { + use crate::shapes::text_vertical::test_support::{provider, EM}; + init_state(); + let provider = provider(include_bytes!("../../fonts/cjk-locl-test.ttf")); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_asset_font_manager(Some(provider.into())); + let spans = texts + .iter() + .map(|text| { + let mut span = make_span(text, 0.0); + span.font_size = EM; + span + }) + .collect(); + let mut paragraph = make_paragraph(spans, 0.0); + paragraph.set_language(language.to_owned()); + paragraph.set_korean_wrap(wrap); + let mut plan = HorizontalParagraphPlan::new(¶graph); + let lay_out = |plan: &HorizontalParagraphPlan, width: f32| { + let mut paragraph_style = paragraph.paragraph_to_style(); + paragraph_style.set_apply_rounding_hack(false); + let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); + for (index, span) in paragraph.children().iter().enumerate() { + let style = span.to_style( + &crate::math::Rect::from_xywh(0.0, 0.0, width, 200.0), + &crate::render::FallbackFonts::default(), + false, + 1.0, + ); + builder.push_style(&style); + add_horizontal_span( + &mut builder, + span, + &plan.texts[index], + &plan.sheds[index], + &[], + &style, + &fonts, + ); + builder.pop(); + } + let mut layout = builder.build(); + layout.layout(width); + layout + }; + plan.relax_overlong_words(¶graph, width, |plan| lay_out(plan, width)); + let layout = lay_out(&plan, width); + (paragraph, plan, layout) +} + +#[test] +fn horizontal_chinese_regions_apply_compression_across_styles() { + use crate::shapes::text_policy::KoreanWrap; + let (_, cn, cn_layout) = cjk_horizontal_layout( + &["文,", "。文"], + "zh-Hans-CN", + KoreanWrap::Syllable, + 1000.0, + ); + let (_, tw, tw_layout) = cjk_horizontal_layout( + &["文,", "。文"], + "zh-Hant-TW", + KoreanWrap::Syllable, + 1000.0, + ); + assert!(!cn.sheds[0].is_empty()); + assert!(tw.sheds.iter().all(Vec::is_empty)); + assert!((tw_layout.longest_line() - cn_layout.longest_line() - 10.0).abs() < 0.1); + assert_eq!(cn.texts.concat().replace('\u{2060}', ""), "文,。文"); +} + +#[test] +fn horizontal_korean_emergency_wrap_does_not_start_with_closing_punctuation() { + use crate::shapes::text_policy::KoreanWrap; + let (_, plan, layout) = + cjk_horizontal_layout(&["한한", "한한", "。"], "ko", KoreanWrap::KeepWords, 42.0); + let source: Vec<_> = "한한한한。".encode_utf16().collect(); + for line in layout.get_line_metrics() { + let start = plan.offsets.offset_map.to_original(line.start_index); + assert_ne!(source.get(start), Some(&(0x3002u16))); + } +} + +#[test] +fn word_mode_moves_a_fitting_word_whole_across_style_runs() { + use crate::shapes::text_policy::KoreanWrap; + let (_, plan, layout) = + cjk_horizontal_layout(&["한글 ", "한", "국어"], "ko", KoreanWrap::KeepWords, 72.0); + let starts: Vec<_> = layout + .get_line_metrics() + .iter() + .map(|line| plan.offsets.builder_to_source(line.start_index)) + .collect(); + assert_eq!(starts, vec![0, 3]); + assert!( + !plan.texts.concat().contains('\u{200b}'), + "a fitting word keeps all its joins" + ); +} + +#[test] +fn emergency_word_boundaries_keep_identical_offsets_and_graphemes() { + use crate::shapes::text_policy::KoreanWrap; + let (paragraph, plan, layout) = + cjk_horizontal_layout(&["ᄒ", "ᅡᆫ한。"], "ko", KoreanWrap::KeepWords, 42.0); + let original = HorizontalParagraphPlan::new(¶graph); + assert!(plan.texts.concat().contains('\u{200b}')); + for at in 0..=7 { + assert_eq!( + plan.offsets.source_to_builder(at), + original.offsets.source_to_builder(at) + ); + } + for line in layout.get_line_metrics() { + let at = plan.offsets.builder_to_source(line.start_index); + assert!( + at == 0 || at == 3, + "no break inside a decomposed syllable or before closing punctuation: {at}" + ); + } + let (_, wide, _) = cjk_horizontal_layout(&["ᄒ", "ᅡᆫ한。"], "ko", KoreanWrap::KeepWords, 200.0); + assert!( + !wide.texts.concat().contains('\u{200b}'), + "resizing restores whole-word joins" + ); +} + +#[test] +fn horizontal_cjk_justification_is_bounded_and_word_mode_only_expands_spaces() { + use crate::shapes::text_policy::KoreanWrap; + let (paragraph, plan, layout) = + cjk_horizontal_layout(&["文文", "文文"], "zh-Hant-TW", KoreanWrap::Syllable, 52.0); + let extras = + super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 52.0); + assert!(extras[1].is_empty(), "last line stays unexpanded"); + let total: f32 = extras[0].iter().map(|(_, gap)| *gap).sum(); + assert!(total > 9.0 && total <= 10.0); + let (paragraph, plan, layout) = + cjk_horizontal_layout(&["한 한 한 한"], "ko", KoreanWrap::KeepWords, 72.0); + let extras = + super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 72.0); + assert!(!extras[0].is_empty()); + for (at, amount) in &extras[0] { + assert_eq!(plan.texts[0].chars().nth(*at), Some(' ')); + assert!(*amount <= 10.0); + } + let (paragraph, plan, layout) = + cjk_horizontal_layout(&["한한한한"], "ko", KoreanWrap::KeepWords, 52.0); + assert!( + super::justify::horizontal_policy_justify_spacing(¶graph, &layout, &plan, 52.0) + .iter() + .all(Vec::is_empty) + ); +} + +#[test] +fn chinese_proportional_punctuation_is_not_compressed_twice() { + init_state(); + let mut span = make_span(",。", 0.0); + span.font_features = FontFeatures::Palt; + let mut paragraph = make_paragraph(vec![span], 0.0); + paragraph.set_language("zh-Hans-CN".into()); + let plan = HorizontalParagraphPlan::new(¶graph); + assert!(plan.sheds.iter().all(Vec::is_empty)); +} + #[test] fn untagged_cjk_keeps_plain_layout() { init_state(); @@ -47,7 +222,7 @@ fn explicit_profiles_use_distinct_rules_across_styles() { make_paragraph(vec![make_span("한", 0.0), make_span("국어 문장", 0.0)], 0.0); paragraph.set_language("ko".to_string()); assert_eq!(paragraph.layout_span_texts().0.concat(), "한국어 문장"); - paragraph.set_korean_spaces_only(true); + paragraph.set_korean_wrap(crate::shapes::text_policy::KoreanWrap::SpacesOnly); assert_eq!( paragraph.layout_span_texts().0.concat(), "한\u{2060}국\u{2060}어 문\u{2060}장" @@ -59,7 +234,7 @@ fn korean_joiners_stay_outside_decomposed_syllables_and_emoji() { init_state(); let mut paragraph = make_paragraph(vec![make_span("ᄒ", 0.0), make_span("ᅡᆫ국 👩‍💻", 0.0)], 0.0); paragraph.set_language("ko".to_string()); - paragraph.set_korean_spaces_only(true); + paragraph.set_korean_wrap(crate::shapes::text_policy::KoreanWrap::SpacesOnly); assert_eq!(paragraph.layout_span_texts().0.concat(), "한\u{2060}국 👩‍💻"); } @@ -69,7 +244,6 @@ fn same_paragraph_wraps_differently_by_profile_in_both_orientations() { content_of, layout_with_height, provider, EM, }; use crate::shapes::GrowType; - use std::collections::HashSet; init_state(); let font_provider = provider(include_bytes!("../../fonts/cjk-locl-test.ttf")); @@ -82,11 +256,16 @@ fn same_paragraph_wraps_differently_by_profile_in_both_orientations() { span.font_size = EM; let mut paragraph = make_paragraph(vec![span], 0.0); paragraph.set_language(language.to_string()); - paragraph.set_korean_spaces_only(true); + paragraph.set_korean_wrap(crate::shapes::text_policy::KoreanWrap::SpacesOnly); let plan = HorizontalParagraphPlan::new(¶graph); let content = content_of(vec![paragraph.clone()], EM * 5.45, GrowType::Fixed); let span = ¶graph.children()[0]; - let style = span.to_style(&content.bounds(), &HashSet::new(), false, 1.0); + let style = span.to_style( + &content.bounds(), + &crate::render::FallbackFonts::default(), + false, + 1.0, + ); let mut builder = ParagraphBuilder::new(¶graph.paragraph_to_style(), &fonts); builder.push_style(&style); add_horizontal_span( @@ -298,7 +477,7 @@ fn test_ruby_spacing(paragraph: &Paragraph) -> HorizontalRubySpacing { &texts, &offset_map, &provider, - &["sourcesanspro".to_string()], + &crate::render::FallbackFonts::from_families(["sourcesanspro".to_string()]), ) } @@ -389,7 +568,7 @@ fn annotation_room_follows_ruby_size_and_clearance() { } #[test] -fn horizontal_plans_are_cached_until_the_content_changes() { +fn horizontal_plans_are_cached_until_content_or_width_changes() { init_state(); let mut content = super::super::text::TextContent::new( crate::math::Rect::from_xywh(0.0, 0.0, 200.0, 40.0), @@ -409,6 +588,13 @@ fn horizontal_plans_are_cached_until_the_content_changes() { let edited = content.horizontal_plans(); assert!(!std::rc::Rc::ptr_eq(&first, &edited)); assert_eq!(edited[0].texts, vec!["です".to_string()]); + + content.set_xywh(10.0, 20.0, 200.0, 80.0); + assert!(std::rc::Rc::ptr_eq(&edited, &content.horizontal_plans())); + content.set_xywh(10.0, 20.0, 100.0, 80.0); + let resized = content.horizontal_plans(); + assert!(!std::rc::Rc::ptr_eq(&edited, &resized)); + assert!(std::rc::Rc::ptr_eq(&resized, &content.horizontal_plans())); } #[test] diff --git a/render-wasm/src/shapes/text_policy.rs b/render-wasm/src/shapes/text_policy.rs index c561dcd8f5..d3d718946b 100644 --- a/render-wasm/src/shapes/text_policy.rs +++ b/render-wasm/src/shapes/text_policy.rs @@ -1,51 +1,254 @@ -//! Paragraph typography policies. Geometry and source offsets are shared; -//! Japanese spacing remains in the Japanese layout passes. +//! Paragraph typography policies. Geometry and source offsets are shared. +//! Regional presets use CLREQ (2026-10-07); Korean restrictions use KLREQ +//! (2026-03-21). These are named presets, not universal regional mandates. use super::japanese::break_allowed_between; +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum KoreanWrap { + #[default] + Syllable = 0, + /// Preserve the original experiment when old content is reopened. + SpacesOnly = 1, + KeepWords = 2, +} + +impl From for KoreanWrap { + fn from(value: u8) -> Self { + match value { + 1 => Self::SpacesOnly, + 2 => Self::KeepWords, + _ => Self::Syllable, + } + } +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum TextLayoutPolicy { #[default] Plain, Japanese, + /// Original basic preset, retained for tags without a region. Chinese, + ChineseMainland, + ChineseTaiwan, + ChineseHongKong, KoreanSyllable, - /// POC only: suppress every non-space boundary, including punctuation. KoreanSpacesOnly, + KoreanWords, +} + +pub fn is_han(ch: char) -> bool { + matches!(ch as u32, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af) +} + +fn numeric(ch: char) -> bool { + ch.is_numeric() || "一二三四五六七八九十百千萬万億亿〇零".contains(ch) +} + +fn opening(ch: char) -> bool { + "([{〈《「『【〔〖〘〚‘“([{".contains(ch) +} + +fn closing(ch: char) -> bool { + ",。、.!?:;)]}〉》」』】〕〗〙〛’”)]},.!?:;".contains(ch) +} + +fn protected(ch: char) -> bool { + matches!( + ch, + '\u{00a0}' | '\u{202f}' | '\u{2060}' | '\u{feff}' | '\u{2011}' + ) } impl TextLayoutPolicy { pub fn for_language(language: &str, korean_spaces_only: bool) -> Self { - let primary = language.split('-').next().unwrap_or_default(); + Self::for_language_with_wrap( + language, + if korean_spaces_only { + KoreanWrap::SpacesOnly + } else { + KoreanWrap::Syllable + }, + ) + } + + pub fn for_language_with_wrap(language: &str, wrap: KoreanWrap) -> Self { + let mut parts = language.split('-'); + let primary = parts.next().unwrap_or_default(); if primary.eq_ignore_ascii_case("ja") { Self::Japanese } else if primary.eq_ignore_ascii_case("zh") { - Self::Chinese + // Region precedes extensions/private use; script never implies region. + let region = parts.take_while(|part| part.len() != 1).find(|part| { + part.len() == 2 || (part.len() == 3 && part.bytes().all(|b| b.is_ascii_digit())) + }); + match region { + Some(region) + if region.eq_ignore_ascii_case("CN") || region.eq_ignore_ascii_case("SG") => + { + Self::ChineseMainland + } + Some(region) if region.eq_ignore_ascii_case("TW") => Self::ChineseTaiwan, + Some(region) + if region.eq_ignore_ascii_case("HK") || region.eq_ignore_ascii_case("MO") => + { + Self::ChineseHongKong + } + _ => Self::Chinese, + } } else if primary.eq_ignore_ascii_case("ko") { - if korean_spaces_only { - Self::KoreanSpacesOnly - } else { - Self::KoreanSyllable + match wrap { + KoreanWrap::Syllable => Self::KoreanSyllable, + KoreanWrap::SpacesOnly => Self::KoreanSpacesOnly, + KoreanWrap::KeepWords => Self::KoreanWords, } } else { Self::Plain } } + pub fn is_chinese(self) -> bool { + matches!( + self, + Self::Chinese | Self::ChineseMainland | Self::ChineseTaiwan | Self::ChineseHongKong + ) + } + + pub fn is_regional_chinese(self) -> bool { + matches!( + self, + Self::ChineseMainland | Self::ChineseTaiwan | Self::ChineseHongKong + ) + } + + pub fn is_korean(self) -> bool { + matches!( + self, + Self::KoreanSyllable | Self::KoreanSpacesOnly | Self::KoreanWords + ) + } + + /// Even emergency wrapping must retain punctuation, nonbreaking controls, + /// numeric expressions and paired marks. Grapheme/annotation atomicity is + /// enforced by the caller on the complete paragraph, across style runs. + pub fn emergency_break_allowed(self, before: char, after: char) -> bool { + if matches!(self, Self::Plain | Self::KoreanSpacesOnly) { + return true; + } + !protected(before) + && !protected(after) + && !opening(before) + && !closing(after) + && !(self.is_korean() && "‐‑–-・·々〻ー".contains(after)) + && !(before == after && matches!(after, '…' | '‥' | '—' | '―')) + && !("$$£€₩¥¥₽₺₫++--−".contains(before) && numeric(after)) + && !(numeric(before) && "%%‰℃℉°".contains(after)) + && !(numeric(before) && matches!(after, '.' | ',' | '.' | ',')) + && !(matches!(before, '.' | ',' | '.' | ',') && numeric(after)) + } + /// Legal boundary between graphemes, before fitting a line or column. pub fn break_allowed(self, before: char, after: char) -> bool { match self { Self::Japanese => break_allowed_between(before, after), Self::KoreanSpacesOnly => before.is_whitespace() || after.is_whitespace(), - Self::Chinese | Self::KoreanSyllable => { - !"([{〈《「『【〔〖〘〚‘“([{".contains(before) - && !",。、.!?:;)]}〉》」』】〕〗〙〛’”)]},.!?:;".contains(after) - && !(before == after && matches!(after, '…' | '—')) - && !(before.is_ascii_alphanumeric() && after.is_ascii_alphanumeric()) - } Self::Plain => true, + _ => { + self.emergency_break_allowed(before, after) + && !(before.is_ascii_alphanumeric() && after.is_ascii_alphanumeric()) + && !(self == Self::KoreanWords + && before.is_alphanumeric() + && after.is_alphanumeric()) + } } } + + /// Removable leading/trailing side bearings of full-width punctuation. + /// TW uses a solid preset. CN/HK compress only adjacent punctuation; fixed + /// marks (notably vertical colon/semicolon/question/exclamation) keep 1em. + pub fn punctuation_space(self, ch: char, vertical: bool) -> (f32, f32) { + if !matches!(self, Self::ChineseMainland | Self::ChineseHongKong) { + return (0.0, 0.0); + } + if "([{〈《「『【〔〖〘〚".contains(ch) { + return (0.5, 0.0); + } + if ")]}〉》」』】〕〗〙〛".contains(ch) { + return (0.0, 0.5); + } + let adjustable = ",。、.".contains(ch) + || (!vertical && self == Self::ChineseMainland && ":;!?".contains(ch)) + || (!vertical && self == Self::ChineseHongKong && ":;".contains(ch)); + if !adjustable { + return (0.0, 0.0); + } + if self == Self::ChineseMainland { + (0.0, 0.5) + } else { + (0.25, 0.25) + } + } + + pub fn compression_em(self, before: char, after: char, vertical: bool) -> f32 { + let a = self.punctuation_space(before, vertical); + let b = self.punctuation_space(after, vertical); + // Both characters must be adjustable punctuation. Never trim ordinary + // Han cells or assume proportional ASCII punctuation contains half an em. + if a == (0.0, 0.0) || b == (0.0, 0.0) { + 0.0 + } else { + (a.1 + b.0).min(0.5) + } + } + + /// This preset permits hanging only in vertical traditional composition. + /// Consecutive marks are excluded by the planner. Horizontal CN uses + /// compression and push-out; no preset silently enables horizontal hanging. + pub fn hanging_em(self, ch: char, vertical: bool) -> f32 { + if vertical + && matches!(self, Self::ChineseTaiwan | Self::ChineseHongKong) + && ",。、.".contains(ch) + { + 0.5 + } else { + 0.0 + } + } + + pub fn script_spacing_em(self, before: char, after: char) -> f32 { + if self.is_regional_chinese() + && ((is_han(before) && after.is_ascii_alphanumeric()) + || (before.is_ascii_alphanumeric() && is_han(after))) + { + 0.25 + } else { + 0.0 + } + } + + /// Bounded justification: spaces first for Korean, character gaps for + /// Chinese and unspaced Korean syllable layout. Never stretch word mode. + pub fn expansion_em(self, before: char, after: char) -> (u8, f32) { + if !self.break_allowed(before, after) { + return (0, 0.0); + } + if before == ' ' && !after.is_whitespace() && self.is_korean() { + return (1, 0.5); + } + if self.is_chinese() + && !before.is_whitespace() + && !after.is_whitespace() + && (is_han(before) || is_han(after)) + { + return (1, 0.5); + } + if self == Self::KoreanSyllable && before.is_alphanumeric() && after.is_alphanumeric() { + return (2, 0.125); + } + (0, 0.0) + } } #[cfg(test)] @@ -58,7 +261,7 @@ mod tests { ("", TextLayoutPolicy::Plain), ("en", TextLayoutPolicy::Plain), ("ja-JP", TextLayoutPolicy::Japanese), - ("ZH-Hant-TW", TextLayoutPolicy::Chinese), + ("ZH-Hant-TW", TextLayoutPolicy::ChineseTaiwan), ("ko", TextLayoutPolicy::KoreanSyllable), ] { assert_eq!(TextLayoutPolicy::for_language(language, false), expected); @@ -73,6 +276,104 @@ mod tests { ); } + #[test] + fn chinese_regions_are_explicit_and_independent_of_script() { + let mainland = TextLayoutPolicy::for_language("zh-Hans-CN", false); + let mainland_traditional = TextLayoutPolicy::for_language("zh-Hant-CN", false); + let taiwan = TextLayoutPolicy::for_language("zh-Hant-TW", false); + let hong_kong = TextLayoutPolicy::for_language("zh-Hant-HK", false); + assert_eq!(mainland, mainland_traditional); + assert_ne!(mainland, taiwan); + assert_ne!(taiwan, hong_kong); + assert_eq!(mainland, TextLayoutPolicy::for_language("ZH-cn", false)); + assert_eq!( + TextLayoutPolicy::Chinese, + TextLayoutPolicy::for_language("zh-Hant", false) + ); + } + + #[test] + fn korean_punctuation_and_numeric_units_stay_attached() { + let policy = TextLayoutPolicy::KoreanSyllable; + for (before, after) in [ + ('한', '-'), + ('한', '·'), + ('₩', '1'), + ('1', '%'), + ('1', '.'), + ('.', '2'), + (',', '2'), + (',', '2'), + ('-', '2'), + ('-', '2'), + ('…', '…'), + ('―', '―'), + ('한', '\u{00a0}'), + ('\u{00a0}', '글'), + ('한', '\u{2060}'), + ] { + assert!(!policy.break_allowed(before, after), "{before}|{after}"); + } + assert!(policy.break_allowed('한', '글')); + assert!(policy.break_allowed('한', '漢')); + } + + #[test] + fn word_mode_retains_punctuation_opportunities_and_blocks_letter_pairs() { + let policy = TextLayoutPolicy::for_language_with_wrap("ko-KR", KoreanWrap::KeepWords); + for (before, after) in [ + ('한', '글'), + ('漢', '字'), + ('한', 'A'), + ('A', '漢'), + ('1', '글'), + ] { + assert!(!policy.break_allowed(before, after)); + assert!(policy.emergency_break_allowed(before, after)); + } + for (before, after) in [ + ('글', '('), + (')', '한'), + ('-', '글'), + ('글', ' '), + (' ', '한'), + ] { + assert!(policy.break_allowed(before, after), "{before}|{after}"); + } + for (before, after) in [('(', '한'), ('글', ')'), ('한', ','), ('글', '\u{00a0}')] { + assert!(!policy.emergency_break_allowed(before, after)); + } + assert!(!TextLayoutPolicy::KoreanSpacesOnly.break_allowed(')', '한')); + } + + #[test] + fn regional_fitting_uses_its_own_spacing_and_orientation_rules() { + let cn = TextLayoutPolicy::ChineseMainland; + let tw = TextLayoutPolicy::ChineseTaiwan; + let hk = TextLayoutPolicy::ChineseHongKong; + assert_eq!(cn.compression_em(',', '」', false), 0.5); + assert_eq!(tw.compression_em(',', '」', false), 0.0); + assert_eq!(hk.compression_em(',', '」', false), 0.25); + assert_eq!(cn.punctuation_space('!', true), (0.0, 0.0)); + assert_eq!(hk.punctuation_space('!', false), (0.0, 0.0)); + assert_eq!(tw.hanging_em(',', false), 0.0); + assert_eq!(tw.hanging_em(',', true), 0.5); + assert_eq!(cn.script_spacing_em('中', 'A'), 0.25); + assert_eq!(cn.script_spacing_em(',', 'A'), 0.0); + assert_eq!( + TextLayoutPolicy::KoreanWords.script_spacing_em('漢', 'A'), + 0.0 + ); + assert_eq!( + TextLayoutPolicy::KoreanWords.expansion_em('한', '글'), + (0, 0.0) + ); + assert_eq!( + TextLayoutPolicy::KoreanWords.expansion_em(' ', '한'), + (1, 0.5) + ); + } + #[test] fn chinese_basic_restrictions_do_not_include_small_kana() { let policy = TextLayoutPolicy::Chinese; diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index ebb51246bb..b3bf1f02f5 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -7,6 +7,7 @@ use unicode_segmentation::UnicodeSegmentation; use skia_safe::{textlayout::TypefaceFontProvider, FontMgr, Typeface}; +use crate::render::FallbackFonts; use crate::shapes::japanese::{classify, JapaneseClass}; use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; use crate::shapes::text_japanese::{warichu_split_chars, WARICHU_FONT_SCALE}; @@ -29,7 +30,7 @@ const MIN_TCY_SCALE: f32 = 0.5; pub(super) struct Fonts<'a> { pub provider: &'a TypefaceFontProvider, pub fallback_mgr: &'a FontMgr, - pub fallback_families: &'a [String], + pub fallback_families: &'a FallbackFonts, } /// Centered punctuation (jlreq cl-05 中点類: middle dot, full-width colon and diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index 8fed4fc787..ea1a0b2234 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -289,21 +289,27 @@ pub(super) fn plan_policy_columns( for grapheme in text.graphemes(true) { let first = grapheme.chars().next().unwrap(); let allowed = previous.is_none_or(|before| policy.break_allowed(before, first)); - boundaries.push((offset, allowed)); + let emergency = previous.is_none_or(|before| policy.emergency_break_allowed(before, first)); + boundaries.push((offset, allowed, emergency)); previous = Some(first); offset += grapheme.encode_utf16().count(); } - boundaries.push((offset, true)); - let mut atomic_start = Vec::with_capacity(cells.len()); + boundaries.push((offset, true, true)); + let mut emergency_start = Vec::with_capacity(cells.len()); let mut unit_start = Vec::with_capacity(cells.len()); let mut prefix = vec![0.0]; for (i, flow) in cells.iter().enumerate() { let boundary = boundaries - .binary_search_by_key(&flow.cell.start, |(at, _)| *at) + .binary_search_by_key(&flow.cell.start, |b| b.0) .ok(); let atomic = i > 0 && (flow.keep_with_previous || flow.shares_previous_box || boundary.is_none()); - atomic_start.push(if atomic { atomic_start[i - 1] } else { i }); + let emergency_joined = atomic || boundary.is_some_and(|at| !boundaries[at].2); + emergency_start.push(if i > 0 && emergency_joined { + emergency_start[i - 1] + } else { + i + }); let joined = atomic || boundary.is_some_and(|at| !boundaries[at].1); unit_start.push(if i > 0 && joined { unit_start[i - 1] @@ -312,19 +318,45 @@ pub(super) fn plan_policy_columns( }); prefix.push(prefix[i] + flow.cell.extent); } + let mut unit_end = vec![cells.len(); cells.len()]; + for i in (0..cells.len().saturating_sub(1)).rev() { + unit_end[i] = if unit_start[i] == unit_start[i + 1] { + unit_end[i + 1] + } else { + i + 1 + }; + } let mut placements = Vec::with_capacity(cells.len()); let (mut column, mut column_start, mut cursor) = (0, 0, 0.0); for (i, flow) in cells.iter().enumerate() { - if cursor > 0.0 && cursor + flow.cell.extent > max_height + FIT_TOLERANCE { + let punctuation = |at: usize| { + cells + .get(at) + .and_then(|c| c.ch) + .is_some_and(|c| ",。、.!?:;…‥—―「」『』()〔〕[]{}〈〉《》【】〖〗〘〙〚〛‘’“”.,;!?:()[]{}".contains(c)) + }; + let solitary = (i == 0 || !punctuation(i - 1)) && !punctuation(i + 1); + let hang = if solitary { + flow.ch + .map_or(0.0, |ch| policy.hanging_em(ch, true) * flow.cell.font_size) + } else { + 0.0 + }; + if cursor > 0.0 && cursor + flow.cell.extent - hang > max_height + FIT_TOLERANCE { let candidate = unit_start[i]; - let break_at = if candidate > column_start - && prefix[i + 1] - prefix[candidate] <= max_height + FIT_TOLERANCE - { + let end = if policy == TextLayoutPolicy::KoreanSpacesOnly { + i + 1 + } else { + unit_end[i] + }; + let fits_empty = prefix[end] - prefix[candidate] <= max_height + FIT_TOLERANCE; + let break_at = if candidate > column_start && fits_empty { candidate } else { - atomic_start[i] + emergency_start[i] }; - // An overlong unit may break, but a grapheme or annotation may not. + // Overlong words may use emergency boundaries; punctuation, + // graphemes, explicit no-break controls and annotations may not. if break_at > column_start { column += 1; cursor = 0.0; @@ -341,6 +373,129 @@ pub(super) fn plan_policy_columns( placements } +/// Source edge characters of every cell, including sideways runs. Offsets +/// stay in UTF-16 source space; typography never inserts saved spaces. +fn policy_cell_edges(cells: &[FlowCell], text: &str) -> Vec<(Option, Option)> { + let mut offset = 0; + let chars: Vec<_> = text + .chars() + .map(|ch| { + let at = offset; + offset += ch.len_utf16(); + (at, ch) + }) + .collect(); + cells + .iter() + .map(|flow| { + let start = chars.partition_point(|(at, _)| *at < flow.cell.start); + let end = chars.partition_point(|(at, _)| *at < flow.cell.end); + ( + chars.get(start).filter(|_| start < end).map(|(_, ch)| *ch), + end.checked_sub(1) + .and_then(|at| chars.get(at)) + .filter(|_| start < end) + .map(|(_, ch)| *ch), + ) + }) + .collect() +} + +pub(super) fn apply_policy_spacing(cells: &mut [FlowCell], text: &str, policy: TextLayoutPolicy) { + let edges = policy_cell_edges(cells, text); + for i in 1..cells.len() { + if cells[i].keep_with_previous || cells[i].shares_previous_box { + continue; + } + let (Some(before), Some(after)) = (edges[i - 1].1, edges[i].0) else { + continue; + }; + let em = cells[i - 1].cell.font_size.min(cells[i].cell.font_size); + let available = (cells[i - 1].cell.extent + - cells[i - 1].trailing_spacing + - 0.5 * cells[i - 1].cell.font_size) + .max(0.0); + let compression = (policy.compression_em(before, after, true) * em).min(available); + let gap = policy.script_spacing_em(before, after) * em; + cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression) + .max(cells[i - 1].trailing_spacing.max(0.0)); + cells[i - 1].cell.minimum_oikomi_extent = cells[i - 1].cell.extent; + } +} + +/// Justify non-final columns over policy-approved gaps with bounded amounts. +/// Korean word mode only expands spaces; grapheme and annotation interiors +/// never become opportunities merely because a font/style run split them. +pub(super) fn policy_aligned_tops( + cells: &[FlowCell], + text: &str, + policy: TextLayoutPolicy, + placements: &[(usize, f32)], + align: TextAlign, + budget: f32, +) -> Vec { + let mut tops: Vec = placements.iter().map(|(_, top)| *top).collect(); + let edges = policy_cell_edges(cells, text); + let graphemes: Vec = text + .graphemes(true) + .scan(0, |at, g| { + let start = *at; + *at += g.encode_utf16().count(); + Some(start) + }) + .collect(); + let last_column = placements.last().map_or(0, |p| p.0); + let mut start = 0; + while start < cells.len() { + let column = placements[start].0; + let end = placements.partition_point(|p| p.0 <= column); + let used = placements[end - 1].1 + cells[end - 1].cell.extent; + let shift = align_offset_along_column(align, budget, used); + for top in &mut tops[start..end] { + *top += shift; + } + if align == TextAlign::Justify && column < last_column && is_bounded(budget) { + let mut remaining = (budget - used).max(0.0); + let mut extras = vec![0.0; end - start]; + let mut opportunities = Vec::new(); + for i in start..end - 1 { + if cells[i + 1].keep_with_previous + || cells[i + 1].shares_previous_box + || graphemes.binary_search(&cells[i + 1].cell.start).is_err() + { + continue; + } + let (Some(before), Some(after)) = (edges[i].1, edges[i + 1].0) else { + continue; + }; + let (priority, cap) = policy.expansion_em(before, after); + let em = cells[i].cell.font_size.min(cells[i + 1].cell.font_size); + if priority > 0 { + opportunities.push((i, priority, cap * em)); + } + } + for priority in 1..=2 { + let caps: Vec<_> = opportunities + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(i, _, cap)| (*i, *cap)) + .collect(); + for (i, amount) in capped_equal_allocations(&caps, remaining) { + extras[i - start] += amount; + remaining -= amount; + } + } + let mut extra = 0.0; + for i in start..end { + tops[i] += extra; + extra += extras[i - start]; + } + } + start = end; + } + tops +} + /// True when `max_height` is a real wrap budget (auto-width columns are /// unbounded). pub(super) fn is_bounded(max_height: f32) -> bool { diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 365002aaed..4a16788364 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -8,6 +8,7 @@ use skia_safe::{ use crate::globals::get_resources; use crate::math::Rect; +use crate::render::FallbackFonts; use crate::shapes::japanese::JapaneseClass; use crate::shapes::text_japanese::EMPHASIS_FONT_SCALE; use crate::shapes::text_policy::TextLayoutPolicy; @@ -25,9 +26,9 @@ use super::annotations::{ use super::cells::{Fonts, SpanCells, WarichuNote}; use super::flow::{ aki_classes, align_offset_along_column, apply_inter_script_spacing, apply_ordered_oikomi, - flow_classes, is_bounded, materialize_explicit_pair_spacing, ordered_expansion_offsets, - plan_policy_columns, plan_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, - FlowCell, FIT_TOLERANCE, + apply_policy_spacing, flow_classes, is_bounded, materialize_explicit_pair_spacing, + ordered_expansion_offsets, plan_policy_columns, plan_with_edge_trimming, policy_aligned_tops, + preferred_pair_spacing, shed_punctuation_aki, FlowCell, FIT_TOLERANCE, }; use super::paint::LayoutDraws; use super::shaping::ShapedRun; @@ -519,6 +520,7 @@ fn aligned_tops( /// A paragraph's flow after spacing and column planning. struct PlannedParagraph { policy: TextLayoutPolicy, + text: String, flow: Vec, ruby_units: Vec, classes: Vec>, @@ -566,9 +568,11 @@ impl PlannedParagraph { .iter() .map(|t| t.text.as_str()) .collect(); + apply_policy_spacing(&mut flow, &text, policy); let placements = plan_policy_columns(&flow, &text, policy, max_height); return Self { policy, + text, classes: vec![None; flow.len()], pair_spacing_em: vec![0.0; flow.len().saturating_sub(1)], flow, @@ -610,6 +614,7 @@ impl PlannedParagraph { None => { return Self { policy, + text: String::new(), flow, ruby_units, classes, @@ -628,20 +633,31 @@ impl PlannedParagraph { /// Final cells, aligned along their columns, numbered from `column_base`. /// A later span's cell of a warichu piece takes the piece's box. fn place(&self, align: TextAlign, max_height: f32, column_base: usize) -> Vec { - // The POC defines no non-Japanese justification expansion table yet. - let align = if self.policy != TextLayoutPolicy::Japanese && align == TextAlign::Justify { - TextAlign::Left + let align = + if self.policy == TextLayoutPolicy::KoreanSpacesOnly && align == TextAlign::Justify { + TextAlign::Left + } else { + align + }; + let tops = if self.policy == TextLayoutPolicy::Japanese { + aligned_tops( + &self.flow, + &self.classes, + &self.pair_spacing_em, + &self.placements, + align, + max_height, + ) } else { - align + policy_aligned_tops( + &self.flow, + &self.text, + self.policy, + &self.placements, + align, + max_height, + ) }; - let tops = aligned_tops( - &self.flow, - &self.classes, - &self.pair_spacing_em, - &self.placements, - align, - max_height, - ); let mut cells: Vec = Vec::with_capacity(self.flow.len()); for ((flow, (column, _)), top) in self.flow.iter().zip(&self.placements).zip(tops) { let mut cell = VerticalCell { @@ -684,7 +700,7 @@ pub fn layout_vertical( max_height: f32, font_provider: &TypefaceFontProvider, fallback_mgr: FontMgr, - fallback_families: &[String], + fallback_families: &FallbackFonts, bounds: Rect, ) -> VerticalLayout { let fonts = Fonts { @@ -769,13 +785,12 @@ fn layout_from_content( ) -> VerticalLayout { let font_provider = get_resources().fonts.font_provider(); let fallback_mgr = FontMgr::from(font_provider.clone()); - let fallback_families: Vec = get_fallback_fonts().iter().cloned().collect(); layout_vertical( text_content, max_height, font_provider, fallback_mgr, - &fallback_families, + get_fallback_fonts(), bounds, ) } @@ -807,6 +822,128 @@ mod tests { use super::*; use crate::shapes::TextOrientation; + fn cjk_layout( + texts: &[&str], + language: &str, + wrap: crate::shapes::text_policy::KoreanWrap, + align: TextAlign, + height: f32, + ) -> VerticalLayout { + let mut paragraph = vertical_paragraph( + texts.iter().map(|text| make_span(text)).collect(), + align, + 1.0, + ); + paragraph.set_language(language.to_string()); + paragraph.set_korean_wrap(wrap); + let content = content_of(vec![paragraph], height, GrowType::Fixed); + layout_with_height( + &provider(include_bytes!("../../fonts/cjk-locl-test.ttf")), + &content, + height, + ) + } + + #[test] + fn korean_emergency_wrap_keeps_closing_punctuation_with_its_base() { + use crate::shapes::text_policy::KoreanWrap; + let layout = cjk_layout( + &["한한", "한한", "。"], + "ko", + KoreanWrap::KeepWords, + TextAlign::Left, + EM * 2.1, + ); + let cells = &layout.cells; + assert!(cells.last().unwrap().column > 0); + assert_eq!(cells[cells.len() - 1].column, cells[cells.len() - 2].column); + assert!(cells.iter().all(|cell| cell.extent > 0.0)); + } + + #[test] + fn regional_chinese_compression_and_justification_change_geometry() { + use crate::shapes::text_policy::KoreanWrap; + let cn = cjk_layout( + &["文,", "。文"], + "zh-Hans-CN", + KoreanWrap::Syllable, + TextAlign::Left, + EM * 10.0, + ); + let tw = cjk_layout( + &["文,", "。文"], + "zh-Hant-TW", + KoreanWrap::Syllable, + TextAlign::Left, + EM * 10.0, + ); + let end = |layout: &VerticalLayout| { + layout.cells.last().unwrap().top + layout.cells.last().unwrap().extent + }; + assert!((end(&tw) - end(&cn) - EM * 0.5).abs() < 0.01); + let justified = cjk_layout( + &["文文文文"], + "zh-Hant-TW", + KoreanWrap::Syllable, + TextAlign::Justify, + EM * 2.6, + ); + assert!(justified.cells[1].top > EM + 1.0); + assert_eq!(justified.cells[3].top, EM, "last column remains unexpanded"); + } + + #[test] + fn traditional_chinese_hangs_only_a_single_comma_in_vertical_text() { + use crate::shapes::text_policy::KoreanWrap; + let single = cjk_layout( + &["文文,文"], + "zh-Hant-TW", + KoreanWrap::Syllable, + TextAlign::Left, + EM * 2.6, + ); + assert_eq!(single.cells[2].column, 0); + assert_eq!(single.cells[3].column, 1); + let paired = cjk_layout( + &["文文,,文"], + "zh-Hant-TW", + KoreanWrap::Syllable, + TextAlign::Left, + EM * 2.6, + ); + assert!(paired.cells[2].column > 0); + assert_eq!(paired.cells[1].column, paired.cells[2].column); + } + + #[test] + fn word_mode_preserves_words_and_decomposed_syllables_across_styles() { + use crate::shapes::text_policy::KoreanWrap; + let layout = cjk_layout( + &["한글 ", "한", "국어"], + "ko", + KoreanWrap::KeepWords, + TextAlign::Left, + EM * 3.6, + ); + let word: Vec<_> = layout.cells.iter().filter(|c| c.start >= 3).collect(); + assert!(word.iter().all(|c| c.column == 1)); + let layout = cjk_layout( + &["ᄒ", "ᅡᆫ한。"], + "ko", + KoreanWrap::KeepWords, + TextAlign::Left, + EM * 4.1, + ); + for unit in [0..3, 3..7] { + let cells: Vec<_> = layout + .cells + .iter() + .filter(|c| unit.contains(&c.start)) + .collect(); + assert!(cells.iter().all(|c| c.column == cells[0].column)); + } + } + #[test] fn explicit_profiles_change_wrapping_without_japanese_spacing() { let columns = |language: &str, spaces_only: bool, texts: &[&str]| { @@ -816,7 +953,11 @@ mod tests { 1.0, ); paragraph.set_language(language.to_string()); - paragraph.set_korean_spaces_only(spaces_only); + paragraph.set_korean_wrap(if spaces_only { + crate::shapes::text_policy::KoreanWrap::SpacesOnly + } else { + crate::shapes::text_policy::KoreanWrap::Syllable + }); let content = content_of(vec![paragraph], EM * 2.1, GrowType::Fixed); let layout = layout_with_height( &provider(include_bytes!("../../fonts/cjk-locl-test.ttf")), @@ -856,7 +997,7 @@ mod tests { 1.0, ); paragraph.set_language("ko".to_string()); - paragraph.set_korean_spaces_only(true); + paragraph.set_korean_wrap(crate::shapes::text_policy::KoreanWrap::SpacesOnly); let content = content_of(vec![paragraph], EM * 3.6, GrowType::Fixed); let layout = layout_with_height( &provider(include_bytes!("../../fonts/cjk-locl-test.ttf")), diff --git a/render-wasm/src/shapes/text_vertical/shaping.rs b/render-wasm/src/shapes/text_vertical/shaping.rs index cdc7e1a5d5..68b27d6083 100644 --- a/render-wasm/src/shapes/text_vertical/shaping.rs +++ b/render-wasm/src/shapes/text_vertical/shaping.rs @@ -11,7 +11,7 @@ use skia_safe::{ Font, FontMgr, GlyphId, Point as SkPoint, TextBlob, TextBlobBuilder, Typeface, }; -use crate::render::DEFAULT_EMOJI_FONT; +use crate::render::{FallbackFonts, DEFAULT_EMOJI_FONT}; use crate::shapes::{FontFeatures, TextSpan}; use super::font_tables::upright_centre_metrics; @@ -20,13 +20,13 @@ use super::font_tables::upright_centre_metrics; pub(super) const WESTERN_WORD_SPACING_EM: f32 = 1.0 / 3.0; /// Candidate families for a span: its own font first, then emoji and the -/// registered fallback fonts (Noto etc.). -pub(crate) fn span_font_families(span: &TextSpan, fallback_families: &[String]) -> Vec { +/// registered fallback fonts (Noto etc.) in the span language's order. +pub(crate) fn span_font_families(span: &TextSpan, fallback_fonts: &FallbackFonts) -> Vec { let mut families = vec![ format!("{}", span.font_family), DEFAULT_EMOJI_FONT.to_string(), ]; - families.extend(fallback_families.iter().cloned()); + families.extend(fallback_fonts.for_text(&span.language, &span.text)); families } @@ -554,7 +554,6 @@ mod tests { fn selected_language_reaches_horizontal_and_vertical_localized_glyphs() { use crate::shapes::{GrowType, TextAlign}; use skia_safe::textlayout::{FontCollection, ParagraphBuilder}; - use std::collections::HashSet; let font_provider = provider(include_bytes!("../../fonts/cjk-locl-test.ttf")); let mut fonts = FontCollection::new(); @@ -567,7 +566,7 @@ mod tests { assert_eq!(layout.runs[0].glyphs, vec![glyph], "vertical {language}"); let span = ¶graph.children()[0]; - let style = span.to_style(&content.bounds(), &HashSet::new(), false, 1.0); + let style = span.to_style(&content.bounds(), &FallbackFonts::default(), false, 1.0); assert_eq!(style.locale(), language); let mut builder = ParagraphBuilder::new(¶graph.paragraph_to_style(), &fonts); builder.push_style(&style); @@ -584,7 +583,10 @@ mod tests { #[test] fn span_families_try_emoji_after_the_span_font() { - let families = span_font_families(&make_span("😆"), &["fallback".to_string()]); + let families = span_font_families( + &make_span("😆"), + &FallbackFonts::from_families(["fallback".to_string()]), + ); assert_eq!(families[1], DEFAULT_EMOJI_FONT); assert_eq!(families[2], "fallback"); } diff --git a/render-wasm/src/shapes/text_vertical/test_support.rs b/render-wasm/src/shapes/text_vertical/test_support.rs index 978b68538c..69614e16e1 100644 --- a/render-wasm/src/shapes/text_vertical/test_support.rs +++ b/render-wasm/src/shapes/text_vertical/test_support.rs @@ -5,6 +5,7 @@ use skia_safe::{textlayout::TypefaceFontProvider, FontMgr}; use crate::math::Rect; +use crate::render::FallbackFonts; use crate::shapes::{ FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextContent, TextDecoration, TextDirection, TextOrientation, TextSpan, WritingMode, @@ -163,7 +164,7 @@ pub(super) fn layout_with_fallback( max_height, provider, FontMgr::from(provider.clone()), - fallback_families, + &FallbackFonts::from_families(fallback_families.iter().cloned()), content.bounds(), ) } diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 12c9c51f90..371ab0d8da 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -1,9 +1,9 @@ use crate::get_resources; +use crate::render::FallbackFonts; use crate::skia::textlayout::FontCollection; use crate::skia::Image; use crate::uuid::Uuid; use crate::with_state; -use std::collections::HashSet; pub fn uuid_from_u32_quartet(a: u32, b: u32, c: u32, d: u32) -> Uuid { let hi: u64 = ((a as u64) << 32) | b as u64; @@ -29,7 +29,7 @@ pub fn get_image(image_id: &Uuid) -> Option<&Image> { } // FIXME: move to a different place ? -pub fn get_fallback_fonts() -> &'static HashSet { +pub fn get_fallback_fonts() -> &'static FallbackFonts { get_resources().fonts.get_fallback() } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 75e7d4311c..fa5703b122 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -304,7 +304,7 @@ impl From for shapes::Paragraph { .and_then(|end| value.text_buffer.get(ruby_offset..end)) .unwrap_or(&[]); paragraph.set_language(String::from_utf8_lossy(language).into_owned()); - paragraph.set_korean_spaces_only(value.attrs.korean_wrap == 1); + paragraph.set_korean_wrap(value.attrs.korean_wrap.into()); paragraph } } @@ -530,6 +530,26 @@ mod tests { ); } + #[test] + fn word_wrap_byte_is_distinct_from_the_experiment_and_unknown_defaults() { + use crate::shapes::text_policy::KoreanWrap; + for (byte, expected) in [ + (0, KoreanWrap::Syllable), + (1, KoreanWrap::SpacesOnly), + (2, KoreanWrap::KeepWords), + (255, KoreanWrap::Syllable), + ] { + let mut attrs = [0u8; RAW_PARAGRAPH_DATA_SIZE]; + attrs[10] = byte; + let paragraph = shapes::Paragraph::from(RawParagraph { + attrs: RawParagraphData::from(attrs), + spans: vec![], + text_buffer: vec![], + }); + assert_eq!(paragraph.korean_wrap(), expected); + } + } + #[test] fn language_decodes_after_base_and_ruby_text() { let mut attrs = [0u8; RAW_PARAGRAPH_DATA_SIZE]; @@ -544,7 +564,10 @@ mod tests { text_buffer: "韓한ko".as_bytes().to_vec(), }); assert_eq!(paragraph.language(), "ko"); - assert!(paragraph.korean_spaces_only()); + assert_eq!( + paragraph.korean_wrap(), + crate::shapes::text_policy::KoreanWrap::SpacesOnly + ); assert_eq!(paragraph.children()[0].text, "韓"); assert_eq!(paragraph.children()[0].ruby, "한"); assert_eq!(paragraph.children()[0].language, "ko"); diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index 5a442257f4..7011fa7171 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -816,7 +816,7 @@ pub fn split_paragraph_at_cursor( let writing_mode = para.writing_mode(); let text_orientation = para.text_orientation(); let language = para.language().to_owned(); - let korean_spaces_only = para.korean_spaces_only(); + let korean_wrap = para.korean_wrap(); let source_position = para.source_position(); let line_height = para.line_height(); let letter_spacing = para.letter_spacing(); @@ -845,7 +845,7 @@ pub fn split_paragraph_at_cursor( new_para.set_writing_mode(writing_mode); new_para.set_text_orientation(text_orientation); new_para.set_language(language); - new_para.set_korean_spaces_only(korean_spaces_only); + new_para.set_korean_wrap(korean_wrap); new_para.set_source_position(source_position); paragraphs.insert(cursor.paragraph + 1, new_para); @@ -862,22 +862,27 @@ mod tests { #[test] fn paragraph_split_keeps_language_and_korean_wrap() { - let mut para = paragraph(&["한국어"]); - para.set_language("ko".to_string()); - para.set_korean_spaces_only(true); - let mut content = content(vec![para]); - let cursor = TextPositionWithAffinity { - paragraph: 0, - offset: 1, - ..Default::default() - }; - assert!(split_paragraph_at_cursor(&mut content, &cursor)); - assert_eq!(content.paragraphs().len(), 2); - for para in content.paragraphs() { - assert_eq!(para.language(), "ko"); - assert!(para.korean_spaces_only()); - assert_eq!(para.source_position(), 0); - assert!(para.children().iter().all(|span| span.language == "ko")); + for wrap in [ + crate::shapes::text_policy::KoreanWrap::SpacesOnly, + crate::shapes::text_policy::KoreanWrap::KeepWords, + ] { + let mut para = paragraph(&["한국어"]); + para.set_language("ko".to_string()); + para.set_korean_wrap(wrap); + let mut content = content(vec![para]); + let cursor = TextPositionWithAffinity { + paragraph: 0, + offset: 1, + ..Default::default() + }; + assert!(split_paragraph_at_cursor(&mut content, &cursor)); + assert_eq!(content.paragraphs().len(), 2); + for para in content.paragraphs() { + assert_eq!(para.language(), "ko"); + assert_eq!(para.korean_wrap(), wrap); + assert_eq!(para.source_position(), 0); + assert!(para.children().iter().all(|span| span.language == "ko")); + } } }