From 5e8092a5ab0c1ebfcb417f5b38700e18744438b1 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 7 Oct 2026 17:10:49 +0200 Subject: [PATCH] :sparkles: Render Japanese text layouts in WASM Lay out horizontal and vertical Japanese text with kinsoku, ruby, emphasis, warichu, tate-chu-yoko and proportional font features. Carry the layout through glyph paths, SVG export and text selection. Update the Rust and ClojureScript binary formats together, including the editor bridge and headless exporter. Resolve CJK fallback fonts and add renderer tests and font fixtures. --- common/src/app/common/fonts.cljs | 32 +- common/src/app/common/render_wasm/enums.clj | 12 + .../app/common/render_wasm/serializers.cljs | 17 + .../app/common/render_wasm/text_content.cljs | 73 +- exporter/src/app/wasm/render.cljs | 6 +- exporter/src/app/wasm/text.cljs | 5 +- frontend/src/app/render_wasm/api.cljs | 224 ++- frontend/src/app/render_wasm/api/fonts.cljs | 13 + frontend/src/app/render_wasm/text_editor.cljs | 190 +- frontend/src/app/util/debug.cljs | 6 + .../src/fonts/notosansjp-vmtx-test.ttf | Bin 0 -> 2736 bytes .../src/fonts/notosansjp-vpal-test.ttf | Bin 0 -> 3348 bytes render-wasm/src/globals.rs | 2 +- render-wasm/src/render.rs | 489 ++--- render-wasm/src/render/fonts.rs | 29 +- render-wasm/src/render/options.rs | 7 + render-wasm/src/render/svg/mod.rs | 41 + render-wasm/src/render/svg/tests.rs | 72 + render-wasm/src/render/svg/text.rs | 32 +- render-wasm/src/render/text.rs | 447 ++++- render-wasm/src/render/text_editor.rs | 379 +++- render-wasm/src/render/vector.rs | 50 +- render-wasm/src/shapes.rs | 12 +- render-wasm/src/shapes/gpos_vpal.rs | 285 +++ render-wasm/src/shapes/japanese.rs | 885 +++++++++ render-wasm/src/shapes/kinsoku.rs | 734 +++++++ render-wasm/src/shapes/modifiers.rs | 29 +- render-wasm/src/shapes/text.rs | 1137 +++++++++-- .../src/shapes/text_horizontal/emphasis.rs | 143 ++ .../src/shapes/text_horizontal/justify.rs | 244 +++ render-wasm/src/shapes/text_horizontal/mod.rs | 265 +++ .../src/shapes/text_horizontal/offsets.rs | 222 +++ .../src/shapes/text_horizontal/ruby.rs | 517 +++++ .../src/shapes/text_horizontal/tests.rs | 1020 ++++++++++ .../src/shapes/text_horizontal/warichu.rs | 188 ++ render-wasm/src/shapes/text_japanese.rs | 584 ++++++ render-wasm/src/shapes/text_paths.rs | 14 +- .../src/shapes/text_vertical/annotations.rs | 1345 +++++++++++++ render-wasm/src/shapes/text_vertical/cells.rs | 1340 +++++++++++++ render-wasm/src/shapes/text_vertical/flow.rs | 1729 +++++++++++++++++ .../src/shapes/text_vertical/font_tables.rs | 226 +++ .../src/shapes/text_vertical/layout.rs | 1016 ++++++++++ render-wasm/src/shapes/text_vertical/mod.rs | 55 + .../src/shapes/text_vertical/orientation.rs | 180 ++ render-wasm/src/shapes/text_vertical/paint.rs | 1041 ++++++++++ .../src/shapes/text_vertical/positions.rs | 1116 +++++++++++ .../src/shapes/text_vertical/shaping.rs | 607 ++++++ .../src/shapes/text_vertical/test_support.rs | 189 ++ render-wasm/src/state/text_editor.rs | 66 +- render-wasm/src/wasm/fonts.rs | 8 + render-wasm/src/wasm/text.rs | 183 +- render-wasm/src/wasm/text/helpers.rs | 6 +- render-wasm/src/wasm/text/japanese_layout.rs | 286 +++ render-wasm/src/wasm/text_editor.rs | 126 +- 54 files changed, 17200 insertions(+), 724 deletions(-) create mode 100644 render-wasm/src/fonts/notosansjp-vmtx-test.ttf create mode 100644 render-wasm/src/fonts/notosansjp-vpal-test.ttf create mode 100644 render-wasm/src/shapes/gpos_vpal.rs create mode 100644 render-wasm/src/shapes/japanese.rs create mode 100644 render-wasm/src/shapes/kinsoku.rs create mode 100644 render-wasm/src/shapes/text_horizontal/emphasis.rs create mode 100644 render-wasm/src/shapes/text_horizontal/justify.rs create mode 100644 render-wasm/src/shapes/text_horizontal/mod.rs create mode 100644 render-wasm/src/shapes/text_horizontal/offsets.rs create mode 100644 render-wasm/src/shapes/text_horizontal/ruby.rs create mode 100644 render-wasm/src/shapes/text_horizontal/tests.rs create mode 100644 render-wasm/src/shapes/text_horizontal/warichu.rs create mode 100644 render-wasm/src/shapes/text_japanese.rs create mode 100644 render-wasm/src/shapes/text_vertical/annotations.rs create mode 100644 render-wasm/src/shapes/text_vertical/cells.rs create mode 100644 render-wasm/src/shapes/text_vertical/flow.rs create mode 100644 render-wasm/src/shapes/text_vertical/font_tables.rs create mode 100644 render-wasm/src/shapes/text_vertical/layout.rs create mode 100644 render-wasm/src/shapes/text_vertical/mod.rs create mode 100644 render-wasm/src/shapes/text_vertical/orientation.rs create mode 100644 render-wasm/src/shapes/text_vertical/paint.rs create mode 100644 render-wasm/src/shapes/text_vertical/positions.rs create mode 100644 render-wasm/src/shapes/text_vertical/shaping.rs create mode 100644 render-wasm/src/shapes/text_vertical/test_support.rs create mode 100644 render-wasm/src/wasm/text/japanese_layout.rs diff --git a/common/src/app/common/fonts.cljs b/common/src/app/common/fonts.cljs index ee96b6c9df..65a1aa4781 100644 --- a/common/src/app/common/fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -163,7 +163,10 @@ (def ^:private unicode-ranges {:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]" - :chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]" + ;; Han ideographs, shared by all CJK languages; see `resolve-ambiguous-cjk`. + :han #"[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]" + ;; CJK punctuation and half/full-width forms, shared; see `resolve-ambiguous-cjk`. + :cjk-punctuation #"[\u3000-\u303F\uFF01-\uFF65\uFFE0-\uFFEE]" :korean #"[\uAC00-\uD7AF]" :arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]" :cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]" @@ -241,6 +244,33 @@ 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 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] + (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))] + (-> langs + (disj :han :cjk-punctuation) + (conj resolved))) + langs)) + (defn add-emoji-font [fonts] (conj fonts {:font-id "gfont-noto-color-emoji" diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj index 0be29aacf9..e5354a1e59 100644 --- a/common/src/app/common/render_wasm/enums.clj +++ b/common/src/app/common/render_wasm/enums.clj @@ -36,6 +36,18 @@ ["fill-data" "RawFillData"] ["text-align" "RawTextAlign"] ["text-direction" "RawTextDirection"] + ["writing-mode" "RawWritingMode"] + ["text-orientation" "RawTextOrientation"] + ["text-combine-upright" "RawTextCombineUpright"] + ["text-emphasis" "RawTextEmphasis"] + ["warichu" "RawWarichu"] + ["font-features" "RawFontFeatures"] + ["annotation-clearance" "RawAnnotationClearance"] + ["ruby-size" "RawRubySize"] + ["ruby-align" "RawRubyAlign"] + ["ruby-overhang" "RawRubyOverhang"] + ["ruby-side" "RawRubySide"] + ["line-adjustment" "RawLineAdjustment"] ["text-decoration" "RawTextDecoration"] ["text-transform" "RawTextTransform"] ["multiple-state" "MultipleState"] diff --git a/common/src/app/common/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs index 02fe6632d2..25a0dd1069 100644 --- a/common/src/app/common/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -9,10 +9,12 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.logging :as log] [app.common.render-wasm.serializers.color :as sr-clr] [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.shape-tree :as ctst] + [app.common.types.text.cjk-layout :as cjk] [app.common.uuid :as uuid] [cuerdas.core :as str])) @@ -302,6 +304,21 @@ [text-direction] (untranslate "text-direction" text-direction "ltr")) +(defn translate-text-layout-enum + "Wasm discriminant of the value of the CJK text layout enum `attr`. An + unset value takes the default; an unknown one also takes it, with a + warning, since content validation should have rejected it. 0 when the + enum is not exported." + [attr value] + (let [default (cjk/enum-default attr) + value (some-> value d/name) + value (if (str/blank? value) default value)] + (if-let [values (unchecked-get wasm/serializers (name attr))] + (if-let [discriminant (unchecked-get values value)] + discriminant + (do (log/wrn :hint "unknown text layout value" :attr attr :value value) + (unchecked-get values default))) + 0))) (defn translate-font-style [font-style] diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs index 1ffbebb054..2aec0df710 100644 --- a/common/src/app/common/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -6,7 +6,7 @@ (ns app.common.render-wasm.text-content "Single source of truth for writing a text shape's content into the WASM design - state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is + state. The binary layout ([num-spans][paragraph attrs][span attrs][text][ruby-text]) is identical for the workspace and the headless exporter, and so is the font-id -> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs — the workspace has a loaded fonts DB, the exporter does not — so that part is @@ -24,8 +24,8 @@ [app.common.uuid :as uuid] [cuerdas.core :as str])) -(def ^:const PARAGRAPH-ATTR-U8-SIZE 12) -(def ^:const SPAN-ATTR-U8-SIZE 64) +(def ^:const PARAGRAPH-ATTR-U8-SIZE 16) +(def ^:const SPAN-ATTR-U8-SIZE 80) (def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS) (def ^:private default-font-size 14) @@ -75,6 +75,13 @@ ;; --- binary writers -------------------------------------------------------- +(defn- span-ruby + "Ruby annotation text sent to WASM; hidden ruby is omitted." + [span] + (if (true? (:ruby-hidden span)) + "" + (d/nilv (:ruby span) ""))) + (defn- encode-text "Into an UTF8 buffer. Returns an ArrayBuffer instance." [text] @@ -104,17 +111,24 @@ (defn- write-paragraph [offset dview paragraph] - (let [text-align (sr/translate-text-align (get paragraph :text-align)) - text-direction (sr/translate-text-direction (get paragraph :text-direction)) - text-decoration (sr/translate-text-decoration (get paragraph :text-decoration)) - text-transform (sr/translate-text-transform (get paragraph :text-transform)) - line-height (serialize-line-height (get paragraph :line-height)) - letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))] + (let [text-align (sr/translate-text-align (get paragraph :text-align)) + text-direction (sr/translate-text-direction (get paragraph :text-direction)) + text-decoration (sr/translate-text-decoration (get paragraph :text-decoration)) + text-transform (sr/translate-text-transform (get paragraph :text-transform)) + writing-mode (sr/translate-text-layout-enum :writing-mode (get paragraph :writing-mode)) + text-orientation (sr/translate-text-layout-enum :text-orientation (get paragraph :text-orientation)) + line-height (serialize-line-height (get paragraph :line-height)) + letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))] (-> offset (mem/write-u8 dview text-align) (mem/write-u8 dview text-direction) (mem/write-u8 dview text-decoration) (mem/write-u8 dview text-transform) + (mem/write-u8 dview writing-mode) + (mem/write-u8 dview text-orientation) + ;; Alignment padding; must match RawParagraphData in Rust. + (mem/write-u8 dview 0) + (mem/write-u8 dview 0) (mem/write-f32 dview line-height) (mem/write-f32 dview letter-spacing) (mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE)))) @@ -135,6 +149,8 @@ font-family (hash (get span :font-family "sourcesanspro")) text-buffer (encode-text (get span :text "")) text-length (mem/size text-buffer) + ruby-buffer (encode-text (span-ruby span)) + ruby-length (mem/size ruby-buffer) fills (take MAX-TEXT-FILLS (get span :fills [])) font-variant-id (get span :font-variant-id) font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero) @@ -146,12 +162,37 @@ (sr/translate-text-transform "none")) text-direction (or (sr/translate-text-direction (:text-direction span)) (sr/translate-text-direction (:text-direction paragraph)) - (sr/translate-text-direction "ltr"))] + (sr/translate-text-direction "ltr")) + text-orientation (sr/translate-text-layout-enum + :text-orientation + (get span :text-orientation (get paragraph :text-orientation))) + text-combine-upright (sr/translate-text-layout-enum :text-combine-upright (get span :text-combine-upright)) + text-emphasis (sr/translate-text-layout-enum :text-emphasis (get span :text-emphasis)) + warichu (sr/translate-text-layout-enum :warichu (get span :warichu)) + font-features (sr/translate-text-layout-enum :font-features (get span :font-features)) + annotation-clearance (sr/translate-text-layout-enum :annotation-clearance (get span :annotation-clearance)) + ruby-size (sr/translate-text-layout-enum :ruby-size (get span :ruby-size)) + ruby-align (sr/translate-text-layout-enum :ruby-align (get span :ruby-align)) + ruby-overhang (sr/translate-text-layout-enum :ruby-overhang (get span :ruby-overhang)) + ruby-side (sr/translate-text-layout-enum :ruby-side (get span :ruby-side))] (-> offset (mem/write-u8 dview font-style) (mem/write-u8 dview text-decoration) (mem/write-u8 dview text-transform) (mem/write-u8 dview text-direction) + (mem/write-u8 dview text-orientation) + (mem/write-u8 dview text-combine-upright) + (mem/write-u8 dview text-emphasis) + (mem/write-u8 dview warichu) + (mem/write-u8 dview font-features) + (mem/write-u8 dview annotation-clearance) + (mem/write-u8 dview ruby-size) + (mem/write-u8 dview ruby-align) + (mem/write-u8 dview ruby-overhang) + (mem/write-u8 dview ruby-side) + ;; Alignment padding; must match RawTextSpan in Rust. + (mem/write-u8 dview 0) + (mem/write-u8 dview 0) (mem/write-f32 dview font-size) (mem/write-f32 dview line-height) (mem/write-f32 dview letter-spacing) @@ -160,6 +201,7 @@ (mem/write-i32 dview font-family) (mem/write-uuid dview (d/nilv font-variant-id uuid/zero)) (mem/write-i32 dview text-length) + (mem/write-i32 dview ruby-length) (mem/write-i32 dview (count fills)) (mem/assert-written offset SPAN-ATTR-U8-SIZE) (write-span-fills dview fills)))) @@ -168,7 +210,9 @@ (defn write-shape-text! "Writes one paragraph's spans + text into WASM and appends it to the current - shape via `_set_shape_text_content`. + shape via `_set_shape_text_content`. Ruby annotations are concatenated per + span, in span order, after the base text; the reader splits them using the + per-span byte lengths. `opts` injects host-specific font handling: - `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared @@ -189,7 +233,9 @@ (* num-spans (+ SPAN-ATTR-U8-SIZE fills-size))) text-buffer (encode-text text) text-size (mem/size text-buffer) - total-size (+ 4 metadata-size text-size) + ruby-buffer (encode-text (apply str (map span-ruby spans))) + ruby-size (mem/size ruby-buffer) + total-size (+ 4 metadata-size text-size ruby-size) heapu8 (mem/get-heap-u8) dview (mem/get-data-view) offset (mem/alloc total-size)] @@ -197,5 +243,6 @@ (mem/write-u32 dview num-spans) (write-paragraph dview paragraph) (write-spans dview spans paragraph normalize-font-id) - (mem/write-buffer heapu8 text-buffer)) + (mem/write-buffer heapu8 text-buffer) + (mem/write-buffer heapu8 ruby-buffer)) (h/call wasm/internal-module "_set_shape_text_content"))) diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index 7889a6e3c5..30954bd30a 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -301,11 +301,13 @@ (let [texts (for [shape (vals scene) :when (= :text (:type shape)) node (or (some->> (:content shape) (tree-seq :children :children)) []) - :let [text (:text node)] + text [(:text node) (:ruby node)] :when (string? text)] text) emoji? (boolean (some cfnt/contains-emoji? texts)) - langs (reduce cfnt/collect-used-languages #{} 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))] (distinct (cond-> (cfnt/add-noto-fonts [] langs) emoji? (cfnt/add-emoji-font))))) diff --git a/exporter/src/app/wasm/text.cljs b/exporter/src/app/wasm/text.cljs index 13b037652b..33dd4ad470 100644 --- a/exporter/src/app/wasm/text.cljs +++ b/exporter/src/app/wasm/text.cljs @@ -16,7 +16,8 @@ (defn set-shape-text! "Serializes a text shape's content into the current WASM shape. Mirrors the - editor's sequence: clear -> vertical-align -> append each paragraph -> layout. + editor's sequence: clear -> vertical-align and line adjustment -> append each + paragraph -> layout. Byte writing and font resolution are the shared `text-content/write-shape-text!` defaults; the exporter has no fonts DB, so it injects no variant normalization." @@ -25,6 +26,8 @@ (h/call wasm/internal-module "_clear_shape_text") (h/call wasm/internal-module "_set_shape_vertical_align" (sr/translate-vertical-align (get content :vertical-align))) + (h/call wasm/internal-module "_set_shape_line_adjustment" + (sr/translate-text-layout-enum :line-adjustment (get content :line-adjustment))) (let [paragraph-set (first (get content :children)) paragraphs (get paragraph-set :children)] (doseq [paragraph paragraphs] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 8a4ad7d427..b187816fa8 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -31,6 +31,7 @@ [app.common.types.path.impl :as path.impl] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] + [app.common.types.text.cjk-layout :as cjk] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.helpers :as dsh] @@ -56,7 +57,7 @@ [app.util.dom :as dom] [app.util.functions :as fns] [app.util.globals :as ug] - [app.util.i18n :refer [tr]] + [app.util.i18n :as i18n :refer [tr]] [app.util.modules :as mod] [app.util.text.content :as tc] [app.util.timers :as timers] @@ -110,7 +111,6 @@ ;; the next `tiles-complete` arrives. (defonce ^:private snapshot-capture-in-flight? (atom false)) - (defn initialized? "True when the WASM render context is safe for normal application calls. False while missing/lost or while `reload-renderer!` is reconstructing it. @@ -123,7 +123,6 @@ (declare sync-workspace-local-viewport!) - (defn set-transition-image-from-background! "Sets `transition-image*` to a data URL representing a solid background color." [background] @@ -339,7 +338,7 @@ ;; forward declare helpers so render can call them (declare request-render) -(declare set-shape-vertical-align fonts-from-text-content) +(declare set-shape-vertical-align set-shape-line-adjustment fonts-from-text-content) (declare reload-renderer!) (declare request-render-preserving-target) (declare render-pending?) @@ -428,7 +427,9 @@ needs-render? (do (when (= ev TEXT_EDITOR_EVENT_STYLES_CHANGED) - (let [current-styles (text-editor/text-editor-get-current-styles) + (let [current-styles (merge + (text-editor/text-editor-get-current-styles) + (text-editor/text-editor-get-current-japanese-styles)) shape-id (text-editor/text-editor-get-active-shape-id)] ;; Keep the caret color matching the text at the caret position. (text-editor/text-editor-apply-caret-color (:fills current-styles)) @@ -554,7 +555,6 @@ (when (initialized?) (h/call wasm/internal-module "_render_preview"))) - (defonce pending-render (atom false)) (defonce shapes-loading? (atom false)) (defonce deferred-render? (atom false)) @@ -757,6 +757,7 @@ (h/call wasm/internal-module "_clear_shape_text") (set-shape-vertical-align (get content :vertical-align)) + (set-shape-line-adjustment (get content :line-adjustment)) (let [fonts (f/get-content-fonts content) fallback-fonts (fonts-from-text-content content false) @@ -1098,6 +1099,12 @@ [vertical-align] (h/call wasm/internal-module "_set_shape_vertical_align" (sr/translate-vertical-align vertical-align))) +(defn set-shape-line-adjustment + "How the text shape fits lines slightly too long (`cjk/enum-values`)." + [line-adjustment] + (h/call wasm/internal-module "_set_shape_line_adjustment" + (sr/translate-text-layout-enum :line-adjustment line-adjustment))) + (defn set-shape-opacity [opacity] (h/call wasm/internal-module "_set_shape_opacity" (or opacity 1))) @@ -1358,6 +1365,13 @@ [shadows] (props/set-shape-shadows shadows)) +(defn- fallback-font-set + "Emoji and Noto faces covering `emoji?` and the scripts in `langs`." + [emoji? langs] + (-> #{} + (cond-> ^boolean emoji? (cfnt/add-emoji-font)) + (cfnt/add-noto-fonts (cfnt/resolve-ambiguous-cjk langs @i18n/locale)))) + (defn fonts-from-text-content [content fallback-fonts-only?] (let [paragraph-set (first (get content :children)) paragraphs (get paragraph-set :children) @@ -1374,9 +1388,10 @@ emoji? langs) - (let [text (apply str (map :text spans)) - emoji? (if emoji? emoji? (cfnt/contains-emoji? text)) - langs (cfnt/collect-used-languages langs text)] + (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)] ;; FIXME: this should probably be somewhere else (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) @@ -1385,14 +1400,47 @@ emoji? langs)))) - (let [updated-fonts - (-> #{} - (cond-> ^boolean emoji? (cfnt/add-emoji-font)) - (cfnt/add-noto-fonts langs)) + (let [updated-fonts (fallback-font-set emoji? langs) fallback-fonts (filter #(get % :is-fallback) updated-fonts)] (if fallback-fonts-only? updated-fonts fallback-fonts)))))) +;; Fallback faces a composition waits on, so preview updates do not queue more relayouts. +(defonce ^:private composition-pending-faces (atom #{})) + +(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 + an IME composition preview; committed content loads its faces through + `set-shape-text-content`." + [shape-id text] + (let [emoji? (cfnt/contains-emoji? text) + langs (cfnt/collect-used-languages #{} text) + waiting @composition-pending-faces + fonts (into [] + (comp (filter :is-fallback) + (map (fn [font] [font (f/make-font-data font)])) + (remove (fn [[_ font-data]] + (or (f/font-ready? font-data) + (contains? waiting (f/font-data-key font-data)))))) + (fallback-font-set emoji? langs)) + faces (into #{} (map (comp f/font-data-key second)) fonts)] + (when (seq faces) + (swap! composition-pending-faces into faces) + (->> (rx/merge f/font-stored-stream f/font-storage-failed-stream) + (rx/filter faces) + (rx/scan disj faces) + (rx/filter empty?) + (rx/take 1) + (rx/subs! (fn [_] + (swap! composition-pending-faces #(reduce disj % faces)) + (f/force-update-text-layout shape-id) + (request-render-preserving-target "composition-fonts")))) + (->> (rx/from (f/store-fonts (map first fonts))) + (rx/merge-map (fn [{:keys [callback]}] + (if (fn? callback) (callback) (rx/empty)))) + (rx/subs! noop-fn noop-fn))))) + (defn set-shape-grow-type [grow-type] (when (initialized?) @@ -1893,7 +1941,6 @@ (request-render "images-loaded")))))))))))] (process-next-chunk 0 [] [] empty-text-font-state)))))) - ;; This is a version of process-pending that doesn't have sideffects ;; with like request render or update layout. (defn- process-pending-no-sideffects @@ -2100,7 +2147,6 @@ heapu32 (mem/get-heap-u32) heapf32 (mem/get-heap-f32)] - (reduce (fn [offset {:keys [type parent id index value]}] (-> offset (mem.h32/write-u32 heapu32 (sr/translate-structure-modifier-type type)) @@ -2343,7 +2389,9 @@ (text-editor-wasm?) (bit-or 2r00000000000000000000000000000100) (contains? cf/flags :render-wasm-info) - (bit-or 2r00000000000000000000000000001000))) + (bit-or 2r00000000000000000000000000001000) + (dbg/enabled? :wasm-text-grid) + (bit-or 2r00000000000000000000000000010000))) (defn set-render-options! "Updates WASM render options with a new DPR value." @@ -2823,6 +2871,110 @@ (def POSITION-DATA-U8-SIZE 36) (def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) +(def ^:private POSITION-DATA-VERTICAL 2) +(def ^:private POSITION-DATA-RUBY 3) +(def ^:private POSITION-DATA-EMPHASIS-MARK 4) + +(defn- emphasis-mark-entry + "Position-data entry for one emphasis mark (圏点): the geometry is the + mark's em box, centred where the canvas paints it." + [element {:keys [x y width height]}] + (when-let [mark (cjk/emphasis-mark-char (get element :text-emphasis))] + (d/patch-object + (txt/get-default-text-attrs) + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction "ltr" + :emphasis-mark true + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (dm/str width "px") + :font-weight (get element :font-weight) + :font-style (get element :font-style) + :fills (get element :fills) + :text mark})))) + +(defn- ruby-strip-entry + "Position-data entry for a ruby annotation strip: the offsets index the + span's ruby string and the geometry is the exact gutter placement the + canvas paints." + [element {:keys [start-pos end-pos x y width height]}] + (when-let [ruby (cjk/visible-ruby element)] + (let [text (subs ruby + (min start-pos (count ruby)) + (min end-pos (count ruby))) + font-size (js/parseFloat (get element :font-size))] + (when (seq text) + (d/patch-object + (txt/get-default-text-attrs) + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction "ltr" + :writing-mode "vertical-rl" + :text-orientation "upright" + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (when-not (js/isNaN font-size) + (dm/str (* (cjk/ruby-font-scale (:ruby-size element)) font-size) "px")) + :font-weight (get element :font-weight) + :font-style (get element :font-style) + :ruby-size (get element :ruby-size) + :ruby-align (get element :ruby-align) + :ruby-overhang (get element :ruby-overhang) + :ruby-side (get element :ruby-side) + :fills (get element :fills) + :text text})))))) + +(defn- text-strip-entry + "Position-data entry for a strip of base text. Be aware that for RTL texts + `start-pos` can be greater than `end-pos`." + [paragraph element {:keys [start-pos end-pos direction x y width height]}] + (when-let [element-text (:text element)] + (let [vertical? (= direction POSITION-DATA-VERTICAL) + ruby (cjk/visible-ruby element)] + (d/patch-object + (txt/get-default-text-attrs) + (d/without-nils + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + ;; The SVG renderer draws vertical strips with CSS writing-mode. + :writing-mode (when vertical? "vertical-rl") + ;; Glyph orientation is stored on the span or its paragraph. + :text-orientation (when vertical? + (not-empty (or (get element :text-orientation) + (get paragraph :text-orientation)))) + :font-id (get element :font-id) + :font-family (get element :font-family) + :font-size (dm/str (get element :font-size) "px") + :font-weight (get element :font-weight) + :text-transform (get element :text-transform) + :text-decoration (get element :text-decoration) + :text-combine-upright (get element :text-combine-upright) + :font-features (let [features (get element :font-features)] + (when (and (seq features) (not= "none" features)) + features)) + ;; Each warichu sub-line has its own entry, drawn at half size. + :warichu (when (= "warichu" (get element :warichu)) "warichu") + ;; Horizontal data has no ruby strip; the base entry carries ruby for SVG export. + :ruby (when (and (not vertical?) (seq element-text)) ruby) + :ruby-size (get element :ruby-size) + :ruby-align (get element :ruby-align) + :ruby-overhang (get element :ruby-overhang) + :ruby-side (get element :ruby-side) + :letter-spacing (dm/str (get element :letter-spacing) "px") + :font-style (get element :font-style) + :fills (get element :fills) + :text (subs element-text start-pos end-pos)}))))) + (defn calculate-position-data [shape] (when (initialized?) @@ -2850,35 +3002,15 @@ (into [] (keep - (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] - (let [element (-> content :children - (get 0) :children ;; paragraph-set - (get paragraph) :children ;; paragraph - (get span)) - element-text (:text element)] - - ;; Add comprehensive nil-safety checks - ;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos` - (when (and element element-text) - (let [text (subs element-text start-pos end-pos)] - (d/patch-object - (txt/get-default-text-attrs) - (d/without-nils - {:x x - :y (+ y height) - :width width - :height height - :direction (dr/translate-direction direction) - :font-id (get element :font-id) - :font-family (get element :font-family) - :font-size (dm/str (get element :font-size) "px") - :font-weight (get element :font-weight) - :text-transform (get element :text-transform) - :text-decoration (get element :text-decoration) - :letter-spacing (dm/str (get element :letter-spacing) "px") - :font-style (get element :font-style) - :fills (get element :fills) - :text text}))))))) + (fn [{:keys [paragraph span direction] :as entry}] + (let [paragraph-node (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph)) + element (-> paragraph-node :children (get span))] + (condp = direction + POSITION-DATA-RUBY (ruby-strip-entry element entry) + POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry) + (text-strip-entry paragraph-node element entry))))) result)))) (defn apply-canvas-blur @@ -3125,5 +3257,3 @@ viewport mount. Idempotent: the `delay` caches its in-flight promise." [] @module) - - diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 0b438c5e20..0d528c0f8e 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -282,6 +282,17 @@ (or (contains? @failed-font-data-keys (font-data-key font-data)) (font-stored? font-data (:emoji? font-data)))) +(defn- mark-font-as-fallback! + [font-data] + (let [id-buffer (uuid/get-u32 (:wasm-id font-data))] + (h/call wasm/internal-module "_mark_font_as_fallback" + (aget id-buffer 0) + (aget id-buffer 1) + (aget id-buffer 2) + (aget id-buffer 3) + (:weight font-data) + (:style font-data)))) + (defn- store-font-id [font-data asset-id emoji? fallback?] (if asset-id @@ -299,6 +310,8 @@ (do (store-font-url font-data uri) (clear-font-storage-failure! font-data) + (when fallback? + (mark-font-as-fallback! font-data)) (tm/schedule #(rx/push! font-stored-stream (font-data-key font-data)))) (fetch-font font-data uri emoji? fallback?))) ;; Report missing font assets asynchronously. diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index ebfabdb07d..b95dc91b42 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -14,6 +14,7 @@ [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.types.text :as txt] + [app.common.types.text.cjk-layout :as cjk] [app.common.uuid :as uuid] [app.main.fonts :as main-fonts] ;; Required for side effects: binds the generated enums. @@ -258,7 +259,7 @@ caret/selection overlay on top, submitting one atomic frame. Pixel identical to the last full render at any zoom, so the blink does not flash." [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_render_caret"))) (defn text-editor-poll-event @@ -377,6 +378,25 @@ (mem/free) result))))) +(defn text-editor-get-cursor-rect + "Read the caret rectangle from the WASM editor. + + Coordinates are in the shape's local selrect space (the same space as the + text overlay's foreignObject). Returns a map with :x :y :width :height, or + nil when there is no active caret." + [] + (when (wasm/ready?) + (let [ptr (h/call wasm/internal-module "_text_editor_get_cursor_rect")] + (when (and ptr (not (zero? ptr))) + (let [heap-f32 (mem/get-heap-f32) + offset (mem/->offset-32 ptr) + x (aget heap-f32 offset) + y (aget heap-f32 (+ offset 1)) + width (aget heap-f32 (+ offset 2)) + height (aget heap-f32 (+ offset 3))] + (mem/free) + {:x x :y y :width width :height height}))))) + (defn text-editor-encode-text-pre [text] (when (and (not (empty? text)) @@ -406,6 +426,12 @@ (h/call wasm/internal-module "_text_editor_composition_update") (text-editor-encode-text-post text))) +(defn text-editor-set-composition-cursor + "Place the WASM caret `offset` characters into the composition preview." + [offset] + (when (wasm/ready?) + (h/call wasm/internal-module "_text_editor_set_composition_cursor" offset))) + (defn text-editor-composition-end [text] (when (wasm/ready?) @@ -694,6 +720,96 @@ (when (and before after) (normalize-selection {:anchor-para (:para before) :anchor-offset (:offset before) :focus-para (:para after) :focus-offset (:offset after)}))) +(defn- para-char-count + [para] + (apply + (map (fn [span] (count (:text span))) (:children para)))) + +(defn- span-japanese-styles + "Japanese span attributes with their defaults filled in. Every selection + snapshot carries all of them, so moving onto an unstyled span resets the + controls." + [span] + (reduce-kv + (fn [styles attr default] + (assoc styles attr (or (get span attr) default))) + {} + cjk/span-attr-defaults)) + +(defn- merge-selection-styles + [result styles] + (reduce-kv + (fn [result attr value] + (update result attr #(if (or (nil? %) (= % value)) value :multiple))) + result + styles)) + +(defn- span-at-offset + "Span the WASM editor uses for a collapsed caret. At a span boundary the + preceding span wins, as in find_text_span_at_offset in render-wasm." + [paragraph offset] + (let [spans (:children paragraph)] + (loop [remaining-spans spans + accumulated 0] + (if-let [span (first remaining-spans)] + (let [span-end (+ accumulated (count (:text span)))] + (if (<= offset span-end) + span + (recur (rest remaining-spans) span-end))) + (last spans))))) + +(defn- paragraph-selected-spans + "Return the spans of `para` that overlap the [sel-start, sel-end) char range." + [para sel-start sel-end] + (loop [spans (:children para) + pos 0 + acc []] + (if (empty? spans) + acc + (let [span (first spans) + span-end (+ pos (count (:text span))) + overlap? (< (max pos sel-start) (min span-end sel-end))] + (recur (rest spans) span-end (cond-> acc overlap? (conj span))))))) + +(defn- selected-spans + "Spans of `paragraphs` that overlap a normalized selection range." + [paragraphs {:keys [start-para start-offset end-para end-offset]}] + (mapcat (fn [idx] + (when-let [para (nth paragraphs idx nil)] + (paragraph-selected-spans para + (if (= idx start-para) start-offset 0) + (if (= idx end-para) end-offset (para-char-count para))))) + (range start-para (inc end-para)))) + +(defn selection-japanese-styles + "Japanese span styles of a WASM selection, read from the cached content. + A range with differing values reports :multiple; a caret reports its + span's style." + [content selection] + (when (and content selection) + (let [{:keys [start-para start-offset end-para end-offset] :as sel-range} + (normalize-selection selection) + paragraphs (-> content :children first :children) + collapsed? (and (= start-para end-para) + (= start-offset end-offset)) + spans + (if collapsed? + (some-> (get paragraphs start-para) + (span-at-offset start-offset) + vector) + (selected-spans paragraphs sel-range))] + (when (seq spans) + (reduce (fn [result span] + (merge-selection-styles result (span-japanese-styles span))) + {} + spans))))) + +(defn text-editor-get-current-japanese-styles + "Japanese span styles of the active WASM editor selection." + [] + (when (wasm/ready?) + (let [shape-id (text-editor-get-active-shape-id) + selection (text-editor-get-selection)] + (selection-japanese-styles (get-cached-content shape-id) selection)))) (defn apply-attrs-to-paragraph "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans." @@ -731,37 +847,11 @@ (into (keep identity [before selected after])))))))))] (assoc para :children result))) -(defn- para-char-count - [para] - (apply + (map (fn [span] (count (:text span))) (:children para)))) - -(defn- paragraph-selected-spans - "Return the spans of `para` that overlap the [sel-start, sel-end) char range." - [para sel-start sel-end] - (loop [spans (:children para) - pos 0 - acc []] - (if (empty? spans) - acc - (let [span (first spans) - span-end (+ pos (count (:text span))) - overlap? (< (max pos sel-start) (min span-end sel-end))] - (recur (rest spans) span-end (cond-> acc overlap? (conj span))))))) - (defn selection-fills "The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty." - [content {:keys [start-para start-offset end-para end-offset]}] - (let [paragraphs (:children (first (:children content))) - selected (mapcat (fn [idx para] - (cond - (or (< idx start-para) (> idx end-para)) nil - (= start-para end-para) (paragraph-selected-spans para start-offset end-offset) - (= idx start-para) (paragraph-selected-spans para start-offset (para-char-count para)) - (= idx end-para) (paragraph-selected-spans para 0 end-offset) - :else (paragraph-selected-spans para 0 (para-char-count para)))) - (range (count paragraphs)) - paragraphs) - fills-set (into #{} (map :fills) selected)] + [content selection] + (let [selected (selected-spans (:children (first (:children content))) selection) + fills-set (into #{} (map :fills) selected)] (cond (empty? selected) nil (= 1 (count fills-set)) (first fills-set) @@ -963,26 +1053,40 @@ {:shape-id shape-id :content new-content}))))) +(defn apply-paragraph-attrs-to-range + "Apply paragraph attrs to the paragraphs `selection` touches (a caret + touches one). Whole-shape attrs (writing mode, orientation) go to every + paragraph; a nil value removes them." + [content selection attrs] + (let [{:keys [start-para end-para]} (normalize-selection selection) + whole-attrs (select-keys attrs cjk/whole-shape-paragraph-attrs) + range-attrs (apply dissoc attrs cjk/whole-shape-paragraph-attrs) + apply-whole (fn [para] + (reduce-kv (fn [para attr value] + (if (some? value) + (assoc para attr value) + (dissoc para attr))) + para + whole-attrs)) + paragraph-set (first (:children content)) + new-paragraphs (into [] + (map-indexed (fn [idx para] + (cond-> (apply-whole para) + (<= start-para idx end-para) + (merge range-attrs)))) + (:children paragraph-set))] + (assoc content :children [(assoc paragraph-set :children new-paragraphs)]))) + (defn apply-paragraph-attrs-to-selection - "Apply paragraph level attrs (text-align, text-direction) to the whole - paragraphs the editor selection touches; a collapsed caret means just the one - it sits in." + "Apply paragraph level attrs to the active editor selection (see + `apply-paragraph-attrs-to-range`)." [attrs use-shape-fn set-shape-text-content-fn] (when (wasm/ready?) (let [shape-id (text-editor-get-active-shape-id) selection (text-editor-get-selection)] (when (and shape-id selection) (when-let [content (get-cached-content shape-id)] - (let [{:keys [start-para end-para]} (normalize-selection selection) - paragraph-set (first (:children content)) - new-paragraphs (into [] - (map-indexed (fn [idx para] - (if (<= start-para idx end-para) - (merge para attrs) - para))) - (:children paragraph-set)) - new-content (assoc content :children - [(assoc paragraph-set :children new-paragraphs)])] + (let [new-content (apply-paragraph-attrs-to-range content selection attrs)] (update-cached-content! shape-id new-content) (use-shape-fn shape-id) (set-shape-text-content-fn shape-id new-content) diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index 3340e040e2..045ced95a2 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -104,6 +104,12 @@ ;; Show viewbox. :wasm-viewbox + ;; Draw the jlreq-style character-frame grid over Japanese vertical text. + :wasm-text-grid + + ;; Show browser composition bounds and the WASM/DOM carets during text input. + :ime + ;; Makes the GL context to fail on initialization. :wasm-gl-context-init-error diff --git a/render-wasm/src/fonts/notosansjp-vmtx-test.ttf b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5b36929f6369579b987017f3ab962d7f762236e1 GIT binary patch literal 2736 zcmcguZETxY6+ZX9j=!4PPG-M}9oKOZe^LND zl4Wfbwc2Pa1k*%8lZwV4r2YVDnkK}iX-sHC>JaS&r~<7BgpjBZKls7^AZ_Nkubq_^ zegHqXuip2bbME?a*JwHThv3uknU>&7tXw?z73@XgDaifm za%T&(3H}j-w5#bCRvJIOHBQ9rMEu@bDtV#0|D$Q>gQw6M1lmL10A9p*tz~liYCjtS zKMUTF&Mqf;oAe_1Qo(1E`&*=^4J7jy@XpO-Ce_py`#X^u7$x6Ub|;sAnSuyCFV=VA zU-z~w1Mkv;1!x??)7-Qo=_;zqo=4pv%ZcC5tAyorw<{3_{O{MpYx zNcaYw-jS(b;M%vpbE~(p=UcD8cD2bD?W!qjB2Jn7J${4RsFE6K9kJDFjX|=QWf>5p z_O>1=913|gLExw`@VZ)H@;amvg9I%=5`v;qYfzRgW=Op~u=9B8YKxv)gCY0`L&|=w zD&bm_zO2ePY_yp)dYy_%Y;yklug6&E^3^LHmaZ;~RNm9ae#kW%?wDO_3v9PLL(Y>E z%N>Co|KiOtcl=VK@k>{SL#t!G8=j7Z&rwKv&rV_YG32&I|o{`tVNYR%>P(=z+L1i?i5$b;!ss+JYJ<@$dij~rT0zs z$8Dq2<93JH;%}!PiReNlc&nD{pHdm89J~v@C*65@^l-B7hy8F8;Z8f!axpQDR z=8U@BRl}1bC+fSq+?9Pp{Whb=fJ*J=AMmUEng36ve#6z0#-uk?m5my$2DMJlCC0`Q zcWw``_JhM0!o+(^wkOKrMKdb4p@XVi;IjYt~R zj@oS2ox7FuxdK(;hSsI|nB^HuBs&*uRNt5C^-uZjW!9<1CWo`J;ner`wPuS3 zmAae1EB%5yo~{&b7LM?frFz%oIc^=98mn@cyfes-&OG1iH z_J}UfJCvahX_xLoAlmEo&wsD1WN4A!P&^COqrphUAFx-b1bynJB)ZdBVP?-!kPSk5 ztmwR8QOH1{BLmSm_4xz-IN!>n538;-Cl*AmBvni3#^eMxFnEQX8DFt#M$h+S*5bh$ zJVgEReEu_!j5URXChWq+je zbd&<_$8qMA40e!}z%F*|>9}hOUacKkd*8 zQ9E8gwf;w_6R=&URi!G0lZFG5zXnwQZZKjvYet+dR2_Fxj3t~%^+0TVmORTZY^SMX zbvv~|9c#(Wb@FT^Hvr!ddaI4uApBAAH%?eo&Y3WYT{giL6EG59|KM2gK| literal 0 HcmV?d00001 diff --git a/render-wasm/src/fonts/notosansjp-vpal-test.ttf b/render-wasm/src/fonts/notosansjp-vpal-test.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e1fd58e08f5e09f397fbbd5abf6e7f3fb859d22d GIT binary patch literal 3348 zcmcgudu)@}6+ic0$FC&L1KWuoIF92->=4Is5+@`f#Ktyxkar*{jE58QYF;J@Ny-2r z5EEl%>n7T%5?i-FCLz>GsG25Lp>AqvH`;Af8)EC)Y27qc{5e%jn>xVz-OphNHceCi z*k^y=J?EZt&-tBm&v))ejEIWyP?0kh9ZHOy5bH$xQOy2%XMf*Nd{gyXM7gct@9Z4f z9UFDUl0o797WVFg*Q`o^-!Ab2|6jnnSK-guskec@0X{&C>N7uMyXj+sk3^9B8XdfZ z6*+hHxTEg=p0cLO{;thiH}C5Az#RoLZ?S!Jj|#{^v=%m-O-8l4!sYcg)JGyE;YdTh z*IVIIt4&6;`OT_$AP}z#B>euQzpBL++-wWl?ZJEgL?E#7_djf@QYLNo8bD(FJpEL> zBYG)^C|Yi+D5~J+S?i}=qBrs24amurxxx%ipeA=}q!DEoV{@_D? zH>ZjKHR=#0nxOD`y*_Uw(%7bIEHS7wYPGqfRs(2 zqEx436tliItW#2%wNw|5z(ylx*>6=jLR+XWvK4d`SPM0Jor-bRJNoJ?NftSA`ec)- zwbjIn+uGR=g+?PB!(;WeOAXG5b4$;7Q|)r-;Q4M(-|>;EeWyDjQ{CHVy-oWgo9&+c zwL6ww9HvWkmR$eIuO-@#E!LQ;*UoIOpPSlSo^REi)Ef)CbOpI;Ay~IitB$HoCfyfa zUaRzk?M;EYiM>0`#pc+NWMpih*|0+wn~c^D);WqS!>M3dS)i)<@-eN^ti>+9o%u1p zBiw|VR5B%$ZE>hdG+v*wV~A78X!%bHEAy>ggS+ewqseLS7#Qv?+gawd_C7OG5wg0g z?R|TCECuB{{-?YOeaW^R+e)qFF1xuU-jQ_1+#Xv;Z|CO9ZLOZt_MJPd1zrQ{>m}|H zuZbqo!Z)@-*1AZCR_og<`gS?Er+F>VWbf|v5a$=T5I+(%*-DT8_evKP{%HP;mC4Ys1L0*gVd z(+kd6_sHc-an^9`)E65~EzKrwY~9AbFDq_y-@(S3biK2|5$H}e)~0LrpX>7U9NQgu z=2W77vUA&vw{dU8Zg=kwMHkD3N+k;QCHl%^=i|}U**a6r@`?8F?8K-u&#F7FHx_j0 z3iPt#I=M!*tTq~TdtY1$xNB{V{<^Wzq-m2Wx)^Ub)W6jb)3;A-4-JIuMV5hs)edKs zf9vPlDl5$&9`(8blV(8b6NR;sWIQqLRYc1XJmIp z&|0y@-Z#{3F}Sq+qr7sxxi#8mwv@Z9ww^tszF4uR*qZF=u=`qCJSFYPsHMPTkUfjY zz_m1tG0Q2$6Av>bX%9`%cUco#WnW>}*?a7F{0P6gv4S&o=iduG2Mb#NNdRz^U@z|x z_debF+pXKTZrr-|B<^c0bM~t00!+Zdq_nESxki_8XK99a=F$eu$v{8YpauNCxk0O_ zlJN~Xhn{7D4O&fZb}>scMtSTSRnj>55lfgtREvC9D^wV?lcLyxMH<7tI-xgBEBK|U znQHJ`Q+`28{YxhZON-d28ra){6>B(2R9Etqab99&{>4m5VRIx!%1D#uh^S{<{W01fHw&2Aq9H}xEA1!Q;hmRJ+km< zy0GD%meQ%tjMS) zF|Omz+G&7Rgc@*%sP2D4J%Y6}G^OlHwgOQgnKMB3pDsoW^;dvO!>-e_ipm%&RUJ?6 z8Yb`fb4zp7G_^D_OHI?Mg&FeBrWR7I& zMCLU*j(0nW$Qj-k6`4sjGd?+ndu8VE#NyK8{KDt*CwDfB;E_KEI)VU>JotNq)EQi- zvhOBCP8qQ3sf|XT@a9#b^Zr|OULeJV?8rBn5nr87&5>a>ji>ycmKn?1=0M?;-vx6(y~_-BR+{%54?Hs$-pBYhyis%@)VMK zQs1aN?ZnrnrdQBMWJY8U{LA%6Vwq5f+Z3VrDcA=`G= 0).then(|| 1.0 / self.get_scale()); - let text_stroke_blur_outset = - Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = - text_content.paragraph_builder_group_from_text(None); - let stroke_kinds: Vec = - shape.visible_strokes().rev().map(|s| s.kind).collect(); - let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - None, - ) - }) - .unzip(); - if skip_effects { - // Fast path: render fills and strokes only (skip shadows/blur). - text::render( - Some(self), - None, + if text_content.is_vertical() { + text::render_vertical_text( + self, &shape, - &mut paragraph_builders, - Some(fills_surface_id), - None, - None, - text_fill_inset, - None, + text_content, + fills_surface_id, + strokes_surface_id, + skip_effects, )?; - - for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list - .iter_mut() - .zip(stroke_opacities.iter()) - .enumerate() - { - if stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_inner_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - &mut fill_builders, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else if stroke_kinds[i] == StrokeKind::Outer { - text::render_outer_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; - } - } - - if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); - let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_emoji_overlay( - self, - &shape, - &mut emoji_builders, - &mut deco_builders, - strokes_surface_id, - None, - ); - } } else { - let shape_scale = self.get_scale(); - let mut drop_shadows = if skip_drop_shadows { - Vec::new() - } else { + let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = + (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); + let text_stroke_blur_outset = + Stroke::max_bounds_width(shape.visible_strokes(), false); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec = + shape.visible_strokes().rev().map(|s| s.kind).collect(); + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .drop_shadows_visible() - .filter(|s| s.is_perceptible_at_scale(shape_scale)) - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - paint.set_image_filter(shadow.get_drop_shadow_filter()); - paint - }) - .collect() - }; - - if !skip_drop_shadows { - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); - let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( - Vec<_>, - Vec<_>, - ) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - Some(true), - ) - }) - .unzip(); - - if let Some(parent_shadows) = parent_shadows { - if !skip_drop_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( text_content, - )?; - } - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), + stroke, + &shape.selrect(), None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } - - // 2. Text fills + ) + }) + .unzip(); + if skip_effects { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -1954,25 +1810,11 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, text_fill_inset, None, )?; - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - - // 4. Stroke fills for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) @@ -1988,7 +1830,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1999,7 +1841,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -2011,7 +1853,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -2019,7 +1861,7 @@ impl RenderState { } } - if shape.has_visible_strokes() && text_content.has_non_ascii() { + if shape.has_visible_strokes() && text_content.has_emoji() { let mut emoji_builders = text_content.paragraph_builder_group_opaque(); let mut deco_builders = @@ -2030,41 +1872,236 @@ impl RenderState { &mut emoji_builders, &mut deco_builders, strokes_surface_id, - blur_filter.as_ref(), + None, ); } + } else { + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { - text::render( - Some(self), - None, + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + // Shadow builder groups only serve shadow passes. + let needs_shadow_groups = parent_shadows.is_some() + || !drop_shadows.is_empty() + || !inner_shadows.is_empty(); + let mut paragraphs_with_shadows = if needs_shadow_groups { + text_content.paragraph_builder_group_from_text(Some(true)) + } else { + Vec::new() + }; + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = if needs_shadow_groups { + shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip() + } else { + (Vec::new(), Vec::new()) + }; + + if let Some(parent_shadows) = parent_shadows { + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + } + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + text_fill_inset, + None, + )?; + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 4. Stroke fills + for (i, (stroke_paragraphs, layer_opacity)) in + stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + .enumerate() + { + if stroke_kinds[i] == StrokeKind::Inner { + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else if stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } + } + + if shape.has_visible_strokes() && text_content.has_emoji() { + let mut emoji_builders = + text_content.paragraph_builder_group_opaque(); + let mut deco_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_emoji_overlay( + self, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + &mut emoji_builders, + &mut deco_builders, + strokes_surface_id, blur_filter.as_ref(), - None, - None, - )?; + ); + } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } } } } } + + if !text_content.is_vertical() && self.options.is_text_grid_visible() { + let canvas = self.surfaces.canvas_and_mark_dirty(fills_surface_id); + text::paint_horizontal_grid(canvas, &shape, text_content); + } } // end layout-cache miss fallback } _ => { diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index b1a09531cc..f5ccfec60a 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -95,7 +95,11 @@ impl FontStore { is_emoji: bool, is_fallback: bool, ) -> Result<()> { + let alias = format!("{}", family); if self.has_family(&family, is_emoji) { + if is_fallback { + self.fallback_fonts.insert(alias); + } return Ok(()); } @@ -106,7 +110,6 @@ impl FontStore { "Failed to create typeface".to_string(), ))?; - let alias = format!("{}", family); let font_name = if is_emoji { DEFAULT_EMOJI_FONT } else { @@ -123,6 +126,15 @@ impl FontStore { Ok(()) } + /// 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)); + } + } + pub fn has_family(&self, family: &FontFamily, is_emoji: bool) -> bool { let alias = format!("{}", family); let font_name = if is_emoji { @@ -309,4 +321,19 @@ mod tests { assert!(css.is_empty()); } + + const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); + + #[test] + fn uploaded_family_can_be_upgraded_to_fallback() { + let mut store = FontStore::try_new().unwrap(); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let alias = format!("{}", family); + + store.add(family, TEST_FONT, false, false).unwrap(); + assert!(!store.get_fallback().contains(&alias)); + + store.mark_as_fallback(&family); + assert!(store.get_fallback().contains(&alias)); + } } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 727073f18e..841082fa20 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -3,6 +3,7 @@ const DEBUG_VISIBLE: u32 = 0x01; const PROFILE_REBUILD_TILES: u32 = 0x02; const TEXT_EDITOR_V3: u32 = 0x04; const SHOW_WASM_INFO: u32 = 0x08; +const TEXT_GRID_VISIBLE: u32 = 0x10; // Render performance options // This is the extra area used for tile rendering (tiles beyond viewport). @@ -121,4 +122,10 @@ impl RenderOptions { pub fn show_wasm_info(&self) -> bool { self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO } + + /// jlreq-style character-frame grid overlay for Japanese vertical + /// text: a developer aid, never exported. + pub fn is_text_grid_visible(&self) -> bool { + self.flags & TEXT_GRID_VISIBLE == TEXT_GRID_VISIBLE + } } diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 997a68e71c..1c098dbeef 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -1,6 +1,7 @@ use skia_safe::{self as skia}; use std::borrow::Cow; +use std::cell::Cell; use std::collections::HashSet; use crate::error::Result; @@ -53,6 +54,44 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect } } +thread_local! { + /// Set while an SVG document is being written. + static WRITING_SVG: Cell = const { Cell::new(false) }; +} + +/// True while an SVG export draws. `SkSVGDevice` rebuilds `` from glyph +/// IDs through the font's cmap, which loses vertical alternates and maps +/// shared glyphs to the wrong code point, so vertical text draws outlines. +pub(crate) fn writing_svg() -> bool { + WRITING_SVG.with(Cell::get) +} + +/// Glyph output for vertical text drawn on the current canvas: outlines +/// while an SVG export draws, text blobs otherwise. +pub(crate) fn vertical_glyph_output() -> crate::shapes::text_vertical::GlyphOutput { + if writing_svg() { + crate::shapes::text_vertical::GlyphOutput::Outlines + } else { + crate::shapes::text_vertical::GlyphOutput::Text + } +} + +/// Marks the SVG export as running until dropped. +struct WritingSvg; + +impl WritingSvg { + fn start() -> Self { + WRITING_SVG.with(|writing| writing.set(true)); + Self + } +} + +impl Drop for WritingSvg { + fn drop(&mut self) { + WRITING_SVG.with(|writing| writing.set(false)); + } +} + /// Renders a shape tree to an SVG document and returns the raw SVG bytes. /// /// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted @@ -98,7 +137,9 @@ pub(crate) fn render_tree_to_svg( let page_h = bounds.height() * scale; let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + let writing = WritingSvg::start(); let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; + drop(writing); let mut aliases = HashSet::new(); collect_font_aliases(tree, id, &mut aliases); diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 490d5ef27a..42b286978e 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -3979,3 +3979,75 @@ fn raster_export_of_masked_group_draws_inner_shadow() { // Outside the circle the mask still clears everything. assert_eq!(raster_pixel(&png, (3, 3)).a(), 0); } + +#[test] +fn exports_vertical_text_as_glyph_outlines() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_text( + &mut pool, + id, + (0.0, 0.0, 240.0, 560.0), + "HOLA", + 200.0, + skia::Color::from_rgb(0, 63, 255), + ); + let shape = pool.get_mut(&id).expect("text shape"); + if let crate::shapes::Type::Text(content) = &mut shape.shape_type { + for paragraph in content.paragraphs_mut() { + paragraph.set_writing_mode(crate::shapes::WritingMode::VerticalRl); + } + } + + let svg = render(&pool, id); + assert!( + !svg.contains(": {svg}" + ); + assert!( + svg.contains(" Result<()> { let Type::Text(text_content) = &shape.shape_type else { return Ok(()); }; + // The vertical pass paints span fills; clips and luminance masks need an + // opaque black silhouette. + let mut silhouette = skia_safe::Paint::default(); + silhouette.set_color(skia_safe::Color::BLACK); + silhouette.set_anti_alias(true); + if text_vertical::paint_text_vertical_with( + canvas, + text_content, + &shape.selrect(), + shape.vertical_align(), + &silhouette, + text_vertical::GlyphOutput::Outlines, + ) { + return Ok(()); + } let text_content = text_content.new_bounds(shape.selrect()); let mut mask_builders = text_content.paragraph_builder_group_opaque(); text::render_overlay_emoji(canvas, shape, &mut mask_builders, None, None, None, None)?; @@ -325,6 +340,21 @@ fn paint_text_stroke_opaque(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> let Type::Text(text_content) = &shape.shape_type else { return Ok(()); }; + if text_content.is_vertical() { + // One cached layout serves every stroke paint. + let (stroke_paints, _) = text::get_text_stroke_paints(stroke, &shape.selrect(), false); + for paint in &stroke_paints { + text_vertical::paint_text_vertical_with( + canvas, + text_content, + &shape.selrect(), + shape.vertical_align(), + paint, + text_vertical::GlyphOutput::Outlines, + ); + } + return Ok(()); + } let text_content = text_content.new_bounds(shape.selrect()); let stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let (mut stroke_paragraphs, _) = text::stroke_paragraph_builder_group_from_text( diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 19e299b31b..55bcbdf37b 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,18 +3,158 @@ use crate::{ error::Result, math::Rect, shapes::{ - add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset, - Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, - TextContent, TextDecorationSegment, + add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical, + vertical_align_offset, HorizontalOffsets, Paragraph as TextParagraph, + ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent, + TextDecorationSegment, }, utils::{get_fallback_fonts, get_font_collection}, }; use skia_safe::{ self as skia, canvas::SaveLayerRec, - textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration}, + textlayout::{ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration}, Canvas, ImageFilter, Paint, }; +use text_vertical::GlyphOutput; + +/// Vertical text: shadows, fill, strokes and the debug grid, all painted from +/// one layout. Rebinds the content to the selrect: stored text bounds +/// describe the measured content and can be taller than a fixed shape, whose +/// height is the column-wrap budget. +pub fn render_vertical_text( + state: &mut RenderState, + shape: &Shape, + text_content: &TextContent, + fills_surface_id: SurfaceId, + strokes_surface_id: SurfaceId, + skip_effects: bool, +) -> Result<()> { + let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten(); + let bounds = shape.selrect(); + let vertical_align = shape.vertical_align(); + let layout = text_content.vertical_layout(&bounds); + + let skip_shadows = skip_effects || state.should_skip_drop_shadows(); + if !skip_shadows { + let mut drop_shadows = shape.drop_shadow_paints(); + if let Some(inherited_shadows) = state.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + if !drop_shadows.is_empty() { + let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); + for shadow in &drop_shadows { + text_vertical::paint_drop_shadow( + canvas, + &layout, + &bounds, + vertical_align, + shadow, + GlyphOutput::Text, + ); + } + } + } + + render_to_surface( + state, + shape, + fills_surface_id, + blur_filter.as_ref(), + 0.0, + |canvas| { + paint_vertical_fill( + canvas, + shape, + &layout, + &bounds, + blur_filter.as_ref(), + GlyphOutput::Text, + ) + }, + )?; + + let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect(); + if !strokes.is_empty() { + let selrect = shape.selrect(); + let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id); + for stroke in strokes { + text_vertical::paint_stroke( + canvas, + &layout, + &bounds, + vertical_align, + stroke, + &selrect, + blur_filter.as_ref(), + GlyphOutput::Text, + ); + } + } + + if state.options.is_text_grid_visible() { + let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id); + text_vertical::paint_grid(canvas, &layout, &bounds, vertical_align); + } + + Ok(()) +} + +/// Paint a viewport-only debug grid over SkParagraph horizontal text: blue +/// line boxes, green per-scalar tight rects from SkParagraph, amber +/// baselines. Matches the vertical text grid and shows horizontal annotation +/// anchors. +pub fn paint_horizontal_grid(canvas: &Canvas, shape: &Shape, text_content: &TextContent) { + let mut builders = text_content.paragraph_builder_group_from_text(None); + let layout = calculate_text_layout_data(shape, text_content, &mut builders, true); + + let mut line_paint = Paint::default(); + line_paint.set_anti_alias(true); + line_paint.set_style(skia::PaintStyle::Stroke); + line_paint.set_stroke_width(1.0); + line_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED)); + + let mut glyph_paint = Paint::default(); + glyph_paint.set_anti_alias(true); + glyph_paint.set_style(skia::PaintStyle::Stroke); + glyph_paint.set_stroke_width(1.0); + glyph_paint.set_color(skia::Color::from_argb(0x99, 0x27, 0xAE, 0x60)); + + let mut baseline_paint = Paint::default(); + baseline_paint.set_anti_alias(true); + baseline_paint.set_stroke_width(1.0); + baseline_paint.set_color(skia::Color::from_argb(0xCC, 0xEB, 0x57, 0x57)); + + for paragraph in &layout.paragraphs { + for line in paragraph.paragraph.get_line_metrics() { + let baseline = line.baseline as f32; + let top = paragraph.y + baseline - line.ascent as f32; + let left = paragraph.x + line.left as f32; + let width = line.width as f32; + canvas.draw_rect( + Rect::from_xywh(left, top, width, line.height as f32), + &line_paint, + ); + canvas.draw_line( + (left, paragraph.y + baseline), + (left + width, paragraph.y + baseline), + &baseline_paint, + ); + + for offset in line.start_index..line.end_index { + for textbox in paragraph.paragraph.get_rects_for_range( + offset..offset + 1, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) { + let mut rect = textbox.rect; + rect.offset((paragraph.x, paragraph.y)); + canvas.draw_rect(rect, &glyph_paint); + } + } + } + } +} pub fn stroke_paragraph_builder_group_from_text( text_content: &TextContent, @@ -28,11 +168,18 @@ pub fn stroke_paragraph_builder_group_from_text( let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent(); let mut group_layer_opacity: Option = None; - for paragraph in text_content.paragraphs() { + let plans = text_content.horizontal_plans(); + for (paragraph, plan) in text_content.paragraphs().iter().zip(plans.iter()) { let mut stroke_paragraphs_map: std::collections::HashMap = std::collections::HashMap::new(); - for span in paragraph.children().iter() { + for (((span, text), sheds), extra) in paragraph + .children() + .iter() + .zip(&plan.texts) + .zip(&plan.sheds) + .zip(&plan.ruby_spacing.adjustments) + { let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints(stroke, bounds, remove_stroke_alpha); @@ -40,8 +187,6 @@ pub fn stroke_paragraph_builder_group_from_text( group_layer_opacity = stroke_layer_opacity; } - let text: String = span.apply_text_transform(); - for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() { let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| { let paragraph_style = paragraph.paragraph_to_style(); @@ -56,7 +201,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph.line_height(), ); builder.push_style(&stroke_style); - add_text_with_tabs(builder, &text, span.font_size); + add_horizontal_span(builder, span, text, sheds, extra, &stroke_style, fonts); } } @@ -70,7 +215,7 @@ pub fn stroke_paragraph_builder_group_from_text( (paragraph_group, group_layer_opacity) } -fn get_text_stroke_paints( +pub(crate) fn get_text_stroke_paints( stroke: &Stroke, bounds: &Rect, remove_stroke_alpha: bool, @@ -189,6 +334,71 @@ pub fn render_with_bounds_outset_overlay_emoji( ) } +/// Run `paint` on `target_surface`. With a blur, it paints into a filter +/// surface sized to the blurred text bounds (grown by `stroke_bounds_outset`) +/// when those bounds are usable. +fn render_to_surface( + render_state: &mut RenderState, + shape: &Shape, + target_surface: SurfaceId, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + mut paint: impl FnMut(&Canvas), +) -> Result<()> { + if let Some(blur_filter) = blur { + let mut text_bounds = shape + .get_text_content() + .calculate_bounds(shape, false) + .to_rect(); + if stroke_bounds_outset > 0.0 { + text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); + } + let bounds = blur_filter.compute_fast_bounds(text_bounds); + if bounds.is_finite() + && bounds.width() > 0.0 + && bounds.height() > 0.0 + && filters::render_with_filter_surface( + render_state, + bounds, + target_surface, + |state, temp_surface| { + paint(state.surfaces.canvas(temp_surface)); + Ok(()) + }, + )? + { + return Ok(()); + } + } + paint(render_state.surfaces.canvas_and_mark_dirty(target_surface)); + Ok(()) +} + +/// Fill pass of vertical text: `layout` painted inside the shape's layer +/// blur. +pub fn paint_vertical_fill( + canvas: &Canvas, + shape: &Shape, + layout: &text_vertical::VerticalLayout, + bounds: &Rect, + blur: Option<&ImageFilter>, + output: GlyphOutput, +) { + if let Some(blur_filter) = blur { + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&shape.layer_bounds()) + .paint(&blur_paint), + ); + } + text_vertical::paint_layout(canvas, layout, bounds, shape.vertical_align(), output); + if blur.is_some() { + canvas.restore(); + } +} + #[allow(clippy::too_many_arguments)] fn render_with_bounds_outset_inner( render_state: Option<&mut RenderState>, @@ -204,55 +414,25 @@ fn render_with_bounds_outset_inner( overlay_emoji: bool, ) -> Result<()> { if let Some(render_state) = render_state { - let target_surface = surface_id.unwrap_or(SurfaceId::Fills); - - if let Some(blur_filter) = blur { - let mut text_bounds = shape - .get_text_content() - .calculate_bounds(shape, false) - .to_rect(); - if stroke_bounds_outset > 0.0 { - text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); - } - let bounds = blur_filter.compute_fast_bounds(text_bounds); - if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 { - let blur_filter_clone = blur_filter.clone(); - if filters::render_with_filter_surface( - render_state, - bounds, - target_surface, - |state, temp_surface| { - let temp_canvas = state.surfaces.canvas(temp_surface); - render_text_on_canvas( - temp_canvas, - shape, - paragraph_builders, - shadow, - Some(&blur_filter_clone), - fill_inset, - layer_opacity, - false, - ); - Ok(()) - }, - )? { - return Ok(()); - } - } - } - - let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_text_on_canvas( - canvas, + return render_to_surface( + render_state, shape, - paragraph_builders, - shadow, + surface_id.unwrap_or(SurfaceId::Fills), blur, - fill_inset, - layer_opacity, - false, + stroke_bounds_outset, + |canvas| { + render_text_on_canvas( + canvas, + shape, + paragraph_builders, + shadow, + blur, + fill_inset, + layer_opacity, + false, + ) + }, ); - return Ok(()); } if let Some(canvas) = canvas { @@ -332,6 +512,9 @@ pub fn try_paint_from_layout_cache( layout_cache_rotation_only: bool, ) -> Result { let text_content = shape.get_text_content(); + if !text_content.can_paint_from_layout_cache() { + return Ok(false); + } let cache_usable = if layout_cache_rotation_only { text_content.layout_cache_versions_match() } else { @@ -382,15 +565,44 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC let vertical_offset = vertical_align_offset(selrect.height(), total_text_height, shape.vertical_align()); + let plans = text_content.horizontal_plans(); let mut y_accum = base_y + vertical_offset; for (index, group) in paragraphs.iter().enumerate() { let Some(paragraph) = group.first() else { continue; }; paragraph.paint(canvas, (x, y_accum)); - if draw_decorations { - if let Some(text_paragraph) = text_content.paragraphs().get(index) { - for deco in decoration_segments(paragraph, text_paragraph, x, y_accum) { + if let (Some(text_paragraph), Some(plan)) = + (text_content.paragraphs().get(index), plans.get(index)) + { + crate::shapes::paint_horizontal_warichu( + canvas, + text_paragraph, + plan, + paragraph, + x, + y_accum, + ); + crate::shapes::paint_horizontal_emphasis( + canvas, + text_paragraph, + plan, + paragraph, + x, + y_accum, + ); + crate::shapes::paint_horizontal_ruby( + canvas, + text_content, + index, + paragraph, + x, + y_accum, + ); + if draw_decorations { + for deco in + decoration_segments(paragraph, text_paragraph, &plan.offsets, x, y_accum) + { draw_decoration_segment(canvas, &deco); } } @@ -523,16 +735,63 @@ fn paint_text_with_emoji_overlay( overlay_emoji: bool, ) { let text_content = shape.get_text_content(); + + // Vertical writing paints through the vertical pass. Stored text bounds + // describe the measured content and can be taller than a fixed shape, so + // the selrect is the column-wrap budget. + if crate::shapes::text_vertical::paint_text_vertical( + canvas, + text_content, + &shape.selrect(), + shape.vertical_align(), + crate::render::svg::vertical_glyph_output(), + ) { + return; + } + let mut layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); + let plans = text_content.horizontal_plans(); for para in &mut layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); + if let (Some(source_paragraph), Some(plan)) = ( + text_content.paragraphs().get(para.source_paragraph), + plans.get(para.source_paragraph), + ) { + crate::shapes::paint_horizontal_warichu( + canvas, + source_paragraph, + plan, + ¶.paragraph, + para.x, + para.y, + ); + crate::shapes::paint_horizontal_emphasis( + canvas, + source_paragraph, + plan, + ¶.paragraph, + para.x, + para.y, + ); + } + if overlay_emoji { paint_emoji_overlay(canvas, para); } + // Like warichu and emphasis, ruby takes the paint of this pass. + crate::shapes::paint_horizontal_ruby( + canvas, + text_content, + para.source_paragraph, + ¶.paragraph, + para.x, + para.y, + ); + for deco in ¶.decorations { draw_decoration_segment(canvas, deco); } @@ -1080,22 +1339,27 @@ fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) { type LineDecoration<'a> = (usize, usize, TextDecoration, &'a StyleMetrics<'a>); /// UTF-16 ranges of the spans that ask for a decoration we draw. -fn decorated_span_ranges(text_paragraph: &TextParagraph) -> Vec<(usize, usize, TextDecoration)> { - let mut ranges = Vec::new(); - let mut offset = 0; - for span in text_paragraph.children() { - let len = span.apply_text_transform().encode_utf16().count(); - match span.text_decoration { +/// Builder-text range of every underlined or struck span. Warichu spans +/// collapse to a placeholder and get no bar. +fn decorated_span_ranges( + text_paragraph: &TextParagraph, + offsets: &HorizontalOffsets, +) -> Vec<(usize, usize, TextDecoration)> { + text_paragraph + .children() + .iter() + .zip(&offsets.ranges) + .filter_map(|(span, range)| match span.text_decoration { Some(kind) - if kind == TextDecoration::UNDERLINE || kind == TextDecoration::LINE_THROUGH => + if !range.warichu + && (kind == TextDecoration::UNDERLINE + || kind == TextDecoration::LINE_THROUGH) => { - ranges.push((offset, offset + len, kind)) + Some((range.builder_start, range.builder_end, kind)) } - _ => {} - } - offset += len; - } - ranges + _ => None, + }) + .collect() } /// Style run covering `offset`; runs are keyed by their start index. @@ -1118,10 +1382,11 @@ fn style_metric_at<'a>( pub fn decoration_segments( skia_paragraph: &skia::textlayout::Paragraph, text_paragraph: &TextParagraph, + offsets: &HorizontalOffsets, x: f32, y_accum: f32, ) -> Vec { - let decorated = decorated_span_ranges(text_paragraph); + let decorated = decorated_span_ranges(text_paragraph, offsets); if decorated.is_empty() { return Vec::new(); } @@ -1280,7 +1545,7 @@ mod tests { ]); assert_eq!( - decorated_span_ranges(¶), + decorated_span_ranges(¶, &HorizontalOffsets::new(¶)), vec![ (6, 11, TextDecoration::UNDERLINE), (18, 24, TextDecoration::LINE_THROUGH), @@ -1302,7 +1567,7 @@ mod tests { // The emoji takes two UTF-16 units and `ß` uppercases to `SS`. assert_eq!( - decorated_span_ranges(¶), + decorated_span_ranges(¶, &HorizontalOffsets::new(¶)), vec![ (2, 9, TextDecoration::UNDERLINE), (9, 10, TextDecoration::UNDERLINE), @@ -1310,6 +1575,36 @@ mod tests { ); } + #[test] + fn decorated_ranges_follow_inserted_kinsoku_characters() { + // A word joiner goes before 。, so the next span starts one unit later. + let para = paragraph(vec![ + span("あ。", None, None), + span("い", Some(TextDecoration::UNDERLINE), None), + ]); + + assert_eq!( + decorated_span_ranges(¶, &HorizontalOffsets::new(¶)), + vec![(3, 4, TextDecoration::UNDERLINE)] + ); + } + + #[test] + fn decorated_ranges_skip_warichu_placeholders() { + let mut note = span("割注入り", None, None); + note.warichu = true; + let para = paragraph(vec![ + note, + span("い", Some(TextDecoration::UNDERLINE), None), + ]); + + // The warichu span collapses to a three-unit placeholder. + assert_eq!( + decorated_span_ranges(¶, &HorizontalOffsets::new(¶)), + vec![(3, 4, TextDecoration::UNDERLINE)] + ); + } + #[test] fn undecorated_paragraphs_have_no_ranges() { let para = paragraph(vec![ @@ -1317,6 +1612,6 @@ mod tests { span("none", Some(TextDecoration::NO_DECORATION), None), ]); - assert!(decorated_span_ranges(¶).is_empty()); + assert!(decorated_span_ranges(¶, &HorizontalOffsets::new(¶)).is_empty()); } } diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index fdf9adb037..af78d6f375 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,4 +1,5 @@ use crate::render::options::RenderOptions; +use crate::shapes::text_vertical; use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; @@ -47,21 +48,37 @@ fn render_cursor( text_content: &TextContent, shape: &Shape, ) { - let Some(rect) = calculate_cursor_rect(editor_state, text_content, shape) else { + let Some(rect) = cursor_rect(editor_state, text_content, shape) else { return; }; + // In vertical writing the caret is a thin horizontal bar across the + // column; in horizontal writing a thin vertical bar. + let thin = editor_state.theme.cursor_width / zoom * dpr; let mut cursor_rect = Rect::new_empty(); - cursor_rect.set_xywh( - rect.x(), - rect.y(), - if editor_state.is_overtype_mode { - rect.width() - } else { - editor_state.theme.cursor_width / zoom * dpr - }, - rect.height(), - ); + if text_content.is_vertical() { + cursor_rect.set_xywh( + rect.x(), + rect.y(), + rect.width(), + if editor_state.is_overtype_mode && rect.height() > 0.0 { + rect.height() + } else { + thin + }, + ); + } else { + cursor_rect.set_xywh( + rect.x(), + rect.y(), + if editor_state.is_overtype_mode { + rect.width() + } else { + thin + }, + rect.height(), + ); + } let mut paint = Paint::default(); paint.set_anti_alias(false); @@ -124,7 +141,82 @@ fn paragraphs_vertical_offset( ) } -fn calculate_cursor_rect( +fn vertical_layout_for_shape( + text_content: &TextContent, + shape: &Shape, +) -> (std::rc::Rc, f32) { + let selrect = shape.selrect(); + let layout = text_content.vertical_layout(&selrect); + let origin_x = + text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align()); + (layout, origin_x) +} + +fn calculate_vertical_cursor_rect( + text_content: &TextContent, + shape: &Shape, + paragraph: usize, + offset: usize, +) -> Option { + let (layout, origin_x) = vertical_layout_for_shape(text_content, shape); + let rect = text_vertical::caret_rect(&layout, paragraph, offset)?; + Some(Rect::from_xywh( + origin_x + rect.x(), + rect.y(), + rect.width(), + rect.height(), + )) +} + +/// Vertical-layout boxes, shape-local, of the text between `start` and `end` +/// (paragraph, char offset). +fn vertical_range_rects( + start: (usize, usize), + end: (usize, usize), + text_content: &TextContent, + shape: &Shape, +) -> Vec { + let (start_paragraph, start_offset) = start; + let (end_paragraph, end_offset) = end; + let paragraphs = text_content.paragraphs(); + let (layout, origin_x) = vertical_layout_for_shape(text_content, shape); + let mut rects = Vec::new(); + + for (para_idx, paragraph) in paragraphs + .iter() + .enumerate() + .take(end_paragraph + 1) + .skip(start_paragraph) + { + let para_char_count: usize = paragraph + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let range_start = if para_idx == start_paragraph { + start_offset + } else { + 0 + }; + let range_end = if para_idx == end_paragraph { + end_offset + } else { + para_char_count + }; + for rect in text_vertical::range_rects(&layout, para_idx, range_start, range_end) { + rects.push(Rect::from_xywh( + origin_x + rect.x(), + rect.y(), + rect.width(), + rect.height(), + )); + } + } + rects +} + +/// Caret rectangle relative to the shape's top-left corner. +pub(crate) fn cursor_rect( editor_state: &TextEditorState, text_content: &TextContent, shape: &Shape, @@ -135,92 +227,85 @@ fn calculate_cursor_rect( return None; } + if text_content.is_vertical() { + return calculate_vertical_cursor_rect( + text_content, + shape, + cursor.paragraph, + cursor.offset, + ); + } + let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + let laid_out_para = layout_paragraphs.get(cursor.paragraph)?; + let y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs) + + layout_paragraphs[..cursor.paragraph] + .iter() + .map(|paragraph| paragraph.height()) + .sum::(); + let rect = horizontal_caret_rect(¶graphs[cursor.paragraph], laid_out_para, cursor.offset); + Some(rect.with_offset((0.0, y_offset))) +} - if cursor.paragraph >= layout_paragraphs.len() { - return None; +/// Caret rect at the source character offset `char_pos` of a laid-out +/// horizontal paragraph, in the paragraph's coordinates. It covers the +/// character at the offset (overtype carets use its width), or follows the +/// last character at the paragraph end. +fn horizontal_caret_rect( + para: &crate::shapes::Paragraph, + laid_out_para: &skia_safe::textlayout::Paragraph, + char_pos: usize, +) -> Rect { + if let Some(rect) = crate::shapes::horizontal_warichu_caret_rect(para, laid_out_para, char_pos) + { + return rect; + } + let para_char_count: usize = para + .children() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let default_rect = || Rect::from_xywh(0.0, 0.0, 1.0, laid_out_para.height()); + if para_char_count == 0 { + return default_rect(); } - let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs); - for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { - if idx == cursor.paragraph { - let char_pos = cursor.offset; - // For cursor, we get a zero-width range at the position - // We need to handle edge cases: - // - At start of paragraph: use position 0 - // - At end of paragraph: use last position - let para = ¶graphs[cursor.paragraph]; - let para_char_count: usize = para - .children() - .iter() - .map(|span| span.text.chars().count()) - .sum(); - - // Skia ranges are UTF-16 code units, not characters. - let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { - // Empty paragraph - use default height - (0.0, 0.0, 1.0, laid_out_para.height()) - } else if char_pos == 0 { - let rects = laid_out_para.get_rects_for_range( - 0..para.char_offset_to_utf16(para.next_grapheme_offset(0)), - RectHeightStyle::Max, - RectWidthStyle::Tight, - ); - if !rects.is_empty() { - let r = &rects[0].rect; - (r.left(), r.top(), r.width(), r.height()) - } else { - (0.0, 0.0, 1.0, laid_out_para.height()) - } - } else if char_pos >= para_char_count { - let last_start = - para.char_offset_to_utf16(para.prev_grapheme_offset(para_char_count)); - let rects = laid_out_para.get_rects_for_range( - last_start..para.char_offset_to_utf16(para_char_count), - RectHeightStyle::Max, - RectWidthStyle::Tight, - ); - if !rects.is_empty() { - let r = &rects[0].rect; - (r.right(), r.top(), r.width(), r.height()) - } else if let Some(line) = laid_out_para.get_line_metrics().last() { - ( - line.left as f32 + line.width as f32, - 0.0, - 1.0, - laid_out_para.height(), - ) - } else { - (0.0, 0.0, 1.0, laid_out_para.height()) - } - } else { - let utf16_pos = para.char_offset_to_utf16(char_pos); - let rects = laid_out_para.get_rects_for_range( - utf16_pos..para.char_offset_to_utf16(para.next_grapheme_offset(char_pos)), - RectHeightStyle::Max, - RectWidthStyle::Tight, - ); - if !rects.is_empty() { - let r = &rects[0].rect; - (r.left(), r.top(), r.width(), r.height()) - } else { - // Fallback: use glyph position - let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); - (pos.position as f32, 0.0, 1.0, laid_out_para.height()) - } - }; - - return Some(Rect::from_xywh( - cursor_x, - y_offset + cursor_y, - cursor_width, // cursor_width - cursor_height, - )); + // Cursor offsets count source characters; the laid-out paragraph indexes + // the transformed, kinsoku-shifted builder text. + let offsets = crate::shapes::HorizontalOffsets::new(para); + let first_rect = |start: usize, end: usize| { + laid_out_para + .get_rects_for_range(start..end, RectHeightStyle::Max, RectWidthStyle::Tight) + .first() + .map(|textbox| textbox.rect) + }; + if char_pos >= para_char_count { + let last_start = offsets.source_to_builder(para.prev_grapheme_offset(para_char_count)); + let last_end = offsets.source_to_builder(para_char_count); + return match first_rect(last_start, last_end) { + Some(r) => Rect::from_xywh(r.right(), r.top(), r.width(), r.height()), + None => match laid_out_para.get_line_metrics().last() { + Some(line) => Rect::from_xywh( + line.left as f32 + line.width as f32, + 0.0, + 1.0, + laid_out_para.height(), + ), + None => default_rect(), + }, + }; + } + let start = offsets.source_to_builder(char_pos); + let end = offsets.source_to_builder(para.next_grapheme_offset(char_pos)); + match first_rect(start, end) { + Some(rect) => rect, + None if char_pos == 0 => default_rect(), + None => { + // Fallback: use glyph position + let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); + Rect::from_xywh(pos.position as f32, 0.0, 1.0, laid_out_para.height()) } - y_offset += laid_out_para.height(); } - - None } fn calculate_selection_rects( @@ -259,6 +344,10 @@ fn range_rects( text_content: &TextContent, shape: &Shape, ) -> Vec { + if text_content.is_vertical() { + return vertical_range_rects(start, end, text_content, shape); + } + let mut rects = Vec::new(); let (start_paragraph, start_offset) = start; let (end_paragraph, end_offset) = end; @@ -298,15 +387,15 @@ fn range_rects( }; if range_start < range_end { - use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - let text_boxes = laid_out_para.get_rects_for_range( - para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end), - RectHeightStyle::Max, - RectWidthStyle::Tight, + // Selection offsets live in original text space; the + // laid-out paragraph indexes the kinsoku-shifted text. + let warichu_rects = crate::shapes::horizontal_warichu_range_rects( + para, + laid_out_para, + range_start, + range_end, ); - - for text_box in text_boxes { - let r = text_box.rect; + for r in warichu_rects { rects.push(Rect::from_xywh( r.left(), y_offset + r.top(), @@ -314,6 +403,24 @@ fn range_rects( r.height(), )); } + use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; + for builder_range in crate::shapes::HorizontalOffsets::new(para) + .normal_selection_ranges(para, range_start, range_end) + { + for text_box in laid_out_para.get_rects_for_range( + builder_range, + RectHeightStyle::Max, + RectWidthStyle::Tight, + ) { + let r = text_box.rect; + rects.push(Rect::from_xywh( + r.left(), + y_offset + r.top(), + r.width(), + r.height(), + )); + } + } } y_offset += para_height; @@ -331,6 +438,11 @@ mod tests { FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextDirection, TextSpan, }; use crate::uuid::Uuid; + use skia_safe::textlayout::{ + FontCollection, Paragraph as SkiaParagraph, ParagraphBuilder, ParagraphStyle, TextStyle, + TypefaceFontProvider, + }; + use skia_safe::FontMgr; /// A laid-out fixed text, `width` wide, with a single span of `text`. fn text_shape(text: &str, width: f32) -> Shape { @@ -408,4 +520,71 @@ mod tests { assert!(origin_of(&shape, 2, 2).is_none()); } + + fn paragraph(text: &str) -> Paragraph { + crate::globals::design_init(); + let span = TextSpan { + text: text.to_string(), + font_size: 16.0, + ..TextSpan::default() + }; + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.0, + 0.0, + vec![span], + ) + } + + fn laid_out(text: &str) -> SkiaParagraph { + let mut provider = TypefaceFontProvider::new(); + let typeface = FontMgr::new() + .new_from_data( + skia_safe::Data::new_copy(include_bytes!("../fonts/sourcesanspro-regular.ttf")), + None, + ) + .expect("test font"); + provider.register_typeface(typeface, Some("test")); + let mut fonts = FontCollection::new(); + fonts.set_default_font_manager(FontMgr::from(provider), None); + let mut style = TextStyle::new(); + style.set_font_families(&["test"]); + style.set_font_size(16.0); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + builder.add_text(text); + let mut paragraph = builder.build(); + paragraph.layout(1000.0); + paragraph + } + + fn glyph_rect(laid_out: &SkiaParagraph, range: std::ops::Range) -> Rect { + laid_out.get_rects_for_range(range, RectHeightStyle::Max, RectWidthStyle::Tight)[0].rect + } + + #[test] + fn caret_inside_the_text_covers_the_character_at_the_offset() { + let laid_out = laid_out("abc"); + let caret = horizontal_caret_rect(¶graph("abc"), &laid_out, 1); + assert_eq!(caret, glyph_rect(&laid_out, 1..2)); + } + + #[test] + fn caret_at_the_end_follows_the_last_character() { + let laid_out = laid_out("abc"); + let caret = horizontal_caret_rect(¶graph("abc"), &laid_out, 3); + let last = glyph_rect(&laid_out, 2..3); + assert_eq!(caret.left, last.right); + assert_eq!(caret.width(), last.width()); + } + + #[test] + fn caret_in_an_empty_paragraph_spans_its_height() { + let laid_out = laid_out(""); + let caret = horizontal_caret_rect(¶graph(""), &laid_out, 0); + assert_eq!(caret, Rect::from_xywh(0.0, 0.0, 1.0, laid_out.height())); + } } diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index bfbeedf0d0..aec960381d 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -3,7 +3,7 @@ use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path, - BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type, + BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, TextContent, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -57,6 +57,46 @@ impl<'a> VectorRenderer<'a> { } shape.image_filter(1.) } + + /// Vertical text: shadows, fill and strokes painted from one layout. + fn draw_vertical_text( + &mut self, + shape: &Shape, + text_content: &TextContent, + blur_filter: Option<&skia::ImageFilter>, + ) { + use crate::shapes::text_vertical; + + let bounds = text_content.bounds(); + let vertical_align = shape.vertical_align(); + let layout = text_content.vertical_layout(&bounds); + let output = crate::render::svg::vertical_glyph_output(); + + for shadow in &shape.drop_shadow_paints() { + text_vertical::paint_drop_shadow( + self.canvas, + &layout, + &bounds, + vertical_align, + shadow, + output, + ); + } + text::paint_vertical_fill(self.canvas, shape, &layout, &bounds, blur_filter, output); + let selrect = shape.selrect(); + for stroke in shape.visible_strokes().rev() { + text_vertical::paint_stroke( + self.canvas, + &layout, + &bounds, + vertical_align, + stroke, + &selrect, + blur_filter, + output, + ); + } + } } impl ShapeRenderer for VectorRenderer<'_> { @@ -182,9 +222,15 @@ impl ShapeRenderer for VectorRenderer<'_> { }; let text_content = text_content.new_bounds(shape.selrect()); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let blur_filter = self.layer_blur_filter(shape); + if text_content.is_vertical() { + self.draw_vertical_text(shape, &text_content, blur_filter.as_ref()); + return Ok(()); + } + + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + // Text drop shadows: one filter layer per shadow over fill + stroke // silhouettes (mirrors GPU `render_text_shadows`). let drop_shadows = shape.drop_shadow_paints(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3baa6dd66c..069fce58c0 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -15,7 +15,10 @@ mod corners; mod fills; mod fonts; mod frames; +mod gpos_vpal; mod groups; +pub mod japanese; +pub mod kinsoku; mod layouts; pub mod modifiers; mod paths; @@ -27,7 +30,10 @@ mod strokes; mod svg_attrs; mod svgraw; mod text; +mod text_horizontal; +mod text_japanese; pub mod text_paths; +pub mod text_vertical; mod transform; pub use blend::*; @@ -49,6 +55,8 @@ pub use strokes::*; pub use svg_attrs::*; pub use svgraw::*; pub use text::*; +pub(crate) use text_horizontal::*; +pub use text_japanese::*; pub use transform::*; use crate::math::{self, Bounds, Matrix, Point}; @@ -435,6 +443,7 @@ impl Shape { pub fn set_vertical_align(&mut self, align: VerticalAlign) { self.vertical_align = align; + self.invalidate_extrect(); } pub fn vertical_align(&self) -> VerticalAlign { @@ -1539,7 +1548,8 @@ impl Shape { pub fn clear_text(&mut self) { self.invalidate_extrect(); if let Type::Text(old_text_content) = &self.shape_type { - let new_text_content = TextContent::new(self.selrect, old_text_content.grow_type()); + let mut new_text_content = TextContent::new(self.selrect, old_text_content.grow_type()); + new_text_content.set_line_adjustment(old_text_content.line_adjustment()); self.shape_type = Type::Text(new_text_content); } } diff --git a/render-wasm/src/shapes/gpos_vpal.rs b/render-wasm/src/shapes/gpos_vpal.rs new file mode 100644 index 0000000000..2ab8b833b8 --- /dev/null +++ b/render-wasm/src/shapes/gpos_vpal.rs @@ -0,0 +1,285 @@ +//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate +//! metrics) feature. Vertical layout places cells by `vmtx` advances and +//! applies these deltas itself: SkShaper shapes on a horizontal line, so +//! HarfBuzz never applies vertical GPOS positioning. +//! +//! Reads only single-adjustment lookups (SinglePos, direct or behind an +//! Extension lookup): `vpal` is metrics-only, and real fonts (Noto CJK, +//! Source Han) encode it this way. + +use std::collections::HashMap; + +/// Font-unit deltas for one glyph. `y_placement` is y-up (positive lifts +/// the ink toward the flow start); `y_advance` is negative when the +/// vertical advance shrinks. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct VpalDelta { + pub y_placement: i16, + pub y_advance: i16, +} + +fn read_u16(data: &[u8], offset: usize) -> Option { + Some(u16::from_be_bytes([ + *data.get(offset)?, + *data.get(offset + 1)?, + ])) +} + +fn read_i16(data: &[u8], offset: usize) -> Option { + read_u16(data, offset).map(|v| v as i16) +} + +fn read_u32(data: &[u8], offset: usize) -> Option { + Some(u32::from_be_bytes([ + *data.get(offset)?, + *data.get(offset + 1)?, + *data.get(offset + 2)?, + *data.get(offset + 3)?, + ])) +} + +/// Glyph ids covered by a Coverage table, in coverage-index order. +fn parse_coverage(data: &[u8], offset: usize) -> Option> { + match read_u16(data, offset)? { + 1 => { + let count = read_u16(data, offset + 2)? as usize; + (0..count) + .map(|i| read_u16(data, offset + 4 + i * 2)) + .collect() + } + 2 => { + let range_count = read_u16(data, offset + 2)? as usize; + let mut glyphs = Vec::new(); + for i in 0..range_count { + let o = offset + 4 + i * 6; + let start = read_u16(data, o)?; + let end = read_u16(data, o + 2)?; + if end < start { + return None; + } + glyphs.extend(start..=end); + } + Some(glyphs) + } + _ => None, + } +} + +/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit +/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device +/// offsets). Returns only the y deltas. +fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option { + let mut delta = VpalDelta::default(); + let mut o = offset; + for bit in 0..8 { + if value_format & (1 << bit) == 0 { + continue; + } + match bit { + 1 => delta.y_placement = read_i16(data, o)?, + 3 => delta.y_advance = read_i16(data, o)?, + _ => {} + } + o += 2; + } + Some(delta) +} + +fn value_record_size(value_format: u16) -> usize { + (value_format & 0x00FF).count_ones() as usize * 2 +} + +/// Accumulate a SinglePos subtable into `deltas`. +fn parse_single_pos( + data: &[u8], + offset: usize, + deltas: &mut HashMap, +) -> Option<()> { + let format = read_u16(data, offset)?; + let coverage = parse_coverage(data, offset + read_u16(data, offset + 2)? as usize)?; + let value_format = read_u16(data, offset + 4)?; + match format { + 1 => { + let value = parse_value_record(data, offset + 6, value_format)?; + for glyph in coverage { + let entry = deltas.entry(glyph).or_default(); + entry.y_placement += value.y_placement; + entry.y_advance += value.y_advance; + } + } + 2 => { + let count = read_u16(data, offset + 6)? as usize; + let size = value_record_size(value_format); + for (i, glyph) in coverage.into_iter().take(count).enumerate() { + let value = parse_value_record(data, offset + 8 + i * size, value_format)?; + let entry = deltas.entry(glyph).or_default(); + entry.y_placement += value.y_placement; + entry.y_advance += value.y_advance; + } + } + _ => {} + } + Some(()) +} + +/// Parse the `vpal` deltas out of a raw `GPOS` table. Returns `None` when +/// the table is malformed or carries no `vpal` feature. +pub fn parse_vpal(gpos: &[u8]) -> Option> { + let feature_list = read_u16(gpos, 6)? as usize; + let lookup_list = read_u16(gpos, 8)? as usize; + + // Collect the lookup indices of every feature tagged `vpal`, + // regardless of script: the deltas are per-glyph metrics. + let feature_count = read_u16(gpos, feature_list)? as usize; + let mut lookup_indices = Vec::new(); + for i in 0..feature_count { + let record = feature_list + 2 + i * 6; + if gpos.get(record..record.checked_add(4)?)? != b"vpal" { + continue; + } + let feature = feature_list + read_u16(gpos, record + 4)? as usize; + let index_count = read_u16(gpos, feature + 2)? as usize; + for j in 0..index_count { + lookup_indices.push(read_u16(gpos, feature + 4 + j * 2)? as usize); + } + } + lookup_indices.sort_unstable(); + lookup_indices.dedup(); + if lookup_indices.is_empty() { + return None; + } + + let lookup_count = read_u16(gpos, lookup_list)? as usize; + let mut deltas = HashMap::new(); + for index in lookup_indices { + if index >= lookup_count { + continue; + } + let lookup = lookup_list + read_u16(gpos, lookup_list + 2 + index * 2)? as usize; + let lookup_type = read_u16(gpos, lookup)?; + let subtable_count = read_u16(gpos, lookup + 4)? as usize; + for s in 0..subtable_count { + let subtable = lookup + read_u16(gpos, lookup + 6 + s * 2)? as usize; + match lookup_type { + 1 => { + parse_single_pos(gpos, subtable, &mut deltas)?; + } + // ExtensionPos: { format: u16, extensionLookupType: u16, + // extensionOffset: u32 (from the subtable start) }. + 9 if read_u16(gpos, subtable + 2)? == 1 => { + let inner = subtable + read_u32(gpos, subtable + 4)? as usize; + parse_single_pos(gpos, inner, &mut deltas)?; + } + _ => {} + } + } + } + if deltas.is_empty() { + None + } else { + Some(deltas) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Subset of Noto Sans JP: 〱あく「」、。 with `vert` alternates and the + // real `vpal` deltas (Extension→SinglePos, both coverage and both + // SinglePos formats, value formats 0x8 and 0xA). + const VPAL_TEST_FONT: &[u8] = include_bytes!("../fonts/notosansjp-vpal-test.ttf"); + + fn gpos_table() -> Vec { + // Locate the GPOS table in the raw sfnt directory. + let data = VPAL_TEST_FONT; + let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize; + for i in 0..num_tables { + let record = 12 + i * 16; + if &data[record..record + 4] == b"GPOS" { + let offset = u32::from_be_bytes([ + data[record + 8], + data[record + 9], + data[record + 10], + data[record + 11], + ]) as usize; + let length = u32::from_be_bytes([ + data[record + 12], + data[record + 13], + data[record + 14], + data[record + 15], + ]) as usize; + return data[offset..offset + length].to_vec(); + } + } + panic!("test font has no GPOS table"); + } + + #[test] + fn parses_noto_vpal_deltas() { + let deltas = parse_vpal(&gpos_table()).expect("vpal deltas"); + // Vertical alternates: 、→8 。→9 「→10 」→11 あ→12 く→13. + assert_eq!( + deltas.get(&8), + Some(&VpalDelta { + y_placement: 0, + y_advance: -500 + }), + "、 halves its vertical advance" + ); + assert_eq!( + deltas.get(&10), + Some(&VpalDelta { + y_placement: 481, + y_advance: -500 + }), + "「 lifts its trailing-half ink into the compressed cell" + ); + assert_eq!( + deltas.get(&12), + Some(&VpalDelta { + y_placement: 39, + y_advance: -58 + }), + "あ tightens slightly" + ); + // Base (horizontal) glyphs carry no vpal. + assert!(!deltas.contains_key(&6), "base あ glyph is uncovered"); + } + + #[test] + fn missing_vpal_returns_none() { + // A GPOS header with an empty feature list. + let gpos = [ + 0x00, 0x01, 0x00, 0x00, // version 1.0 + 0x00, 0x0A, // scriptList + 0x00, 0x0C, // featureList + 0x00, 0x0E, // lookupList + 0x00, 0x00, // scriptCount + 0x00, 0x00, // featureCount + 0x00, 0x00, // lookupCount + ]; + assert!(parse_vpal(&gpos).is_none()); + } + + #[test] + fn truncated_table_is_rejected() { + let table = gpos_table(); + for len in [0usize, 4, 9, 16] { + assert!(parse_vpal(&table[..len.min(table.len())]).is_none()); + } + } + + #[test] + fn truncated_feature_records_are_rejected() { + let gpos = [ + 0x00, 0x01, 0x00, 0x00, // version 1.0 + 0x00, 0x0A, // scriptList + 0x00, 0x0A, // featureList + 0x00, 0x0A, // lookupList + 0x00, 0x02, // featureCount without matching records + ]; + + assert!(parse_vpal(&gpos).is_none()); + } +} diff --git a/render-wasm/src/shapes/japanese.rs b/render-wasm/src/shapes/japanese.rs new file mode 100644 index 0000000000..315a692d35 --- /dev/null +++ b/render-wasm/src/shapes/japanese.rs @@ -0,0 +1,885 @@ +//! Shared JLREQ character classes and pair-rule tables. +//! +//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are +//! virtual classes for inline composites: [`classify`] handles single +//! characters, and callers assign the virtual classes when building +//! reference marks, ruby, grouped numerals, warichu, or tate-chu-yoko. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum JapaneseClass { + OpeningBracket = 0, + ClosingBracket, + Hyphen, + DividingPunctuation, + MiddleDot, + FullStop, + Comma, + Inseparable, + IterationMark, + ProlongedSoundMark, + SmallKana, + PrefixedAbbreviation, + PostfixedAbbreviation, + IdeographicSpace, + Hiragana, + Katakana, + MathSymbol, + MathOperator, + Ideographic, + ReferenceMark, + OrnamentedComplex, + SimpleRuby, + JukugoRuby, + GroupedNumeral, + UnitSymbol, + WesternWordSpace, + Western, + WarichuOpening, + WarichuClosing, + TateChuYoko, +} + +impl JapaneseClass { + pub const COUNT: usize = 30; + + pub const ALL: [Self; Self::COUNT] = [ + Self::OpeningBracket, + Self::ClosingBracket, + Self::Hyphen, + Self::DividingPunctuation, + Self::MiddleDot, + Self::FullStop, + Self::Comma, + Self::Inseparable, + Self::IterationMark, + Self::ProlongedSoundMark, + Self::SmallKana, + Self::PrefixedAbbreviation, + Self::PostfixedAbbreviation, + Self::IdeographicSpace, + Self::Hiragana, + Self::Katakana, + Self::MathSymbol, + Self::MathOperator, + Self::Ideographic, + Self::ReferenceMark, + Self::OrnamentedComplex, + Self::SimpleRuby, + Self::JukugoRuby, + Self::GroupedNumeral, + Self::UnitSymbol, + Self::WesternWordSpace, + Self::Western, + Self::WarichuOpening, + Self::WarichuClosing, + Self::TateChuYoko, + ]; + + pub const fn index(self) -> usize { + self as usize + } + + pub const fn forbids_line_start(self) -> bool { + matches!( + self, + Self::ClosingBracket + | Self::Hyphen + | Self::DividingPunctuation + | Self::MiddleDot + | Self::FullStop + | Self::Comma + | Self::IterationMark + | Self::ProlongedSoundMark + | Self::SmallKana + | Self::WarichuClosing + ) + } + + pub const fn forbids_line_end(self) -> bool { + matches!(self, Self::OpeningBracket | Self::WarichuOpening) + } + + pub const fn is_japanese_letter(self) -> bool { + matches!(self, Self::Hiragana | Self::Katakana | Self::Ideographic) + } + + /// Kana, kanji and the marks that read as part of a word: small kana, + /// the prolonged sound mark and iteration marks. + pub const fn is_japanese_text(self) -> bool { + self.is_japanese_letter() + || matches!( + self, + Self::SmallKana | Self::ProlongedSoundMark | Self::IterationMark + ) + } + + pub const fn is_western_run(self) -> bool { + matches!( + self, + Self::GroupedNumeral | Self::UnitSymbol | Self::Western + ) + } + + pub const fn is_emphasis_prohibited(self) -> bool { + matches!( + self, + Self::OpeningBracket | Self::ClosingBracket | Self::FullStop | Self::Comma + ) + } + + /// Half-width punctuation followed by a half-em of aki that completes + /// its character frame. Consecutive punctuation may drop that aki; the + /// glyph body stays half-width. + pub const fn is_trailing_aki_punctuation(self) -> bool { + matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma) + } + + /// Aki (in em) a full-width font builds into the glyph before its ink. + pub const fn embedded_leading_aki_em(self) -> f32 { + match self { + Self::OpeningBracket => 0.5, + Self::MiddleDot => 0.25, + _ => 0.0, + } + } + + /// Aki (in em) a full-width font builds into the glyph after its ink. + pub const fn embedded_trailing_aki_em(self) -> f32 { + match self { + Self::ClosingBracket | Self::FullStop | Self::Comma => 0.5, + Self::MiddleDot => 0.25, + _ => 0.0, + } + } +} + +/// Whether the half-em aki between `before` and `after` goes (JLREQ §3.1.4): +/// closing sequences set solid, closing→opening keeps one half-em, opening +/// sequences set solid after the first bracket. Returns (shed the trailing +/// aki of `before`, shed the leading aki of `after`); a closing mark owns the +/// reduction, so both halves never go. +pub fn shed_pair_aki(before: JapaneseClass, after: JapaneseClass) -> (bool, bool) { + let preferred = pair_rule(before, after).preferred_em + f32::EPSILON; + if before.is_trailing_aki_punctuation() { + (0.5 + after.embedded_leading_aki_em() > preferred, false) + } else if after == JapaneseClass::OpeningBracket { + (false, before.embedded_trailing_aki_em() + 0.5 > preferred) + } else { + (false, false) + } +} + +/// Which built-in half-em aki of a character goes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AkiShed { + /// The aki after a closing mark. + Trailing, + /// The aki before an opening bracket. + Leading, +} + +/// Class a character takes for punctuation aki, or `None` when its built-in +/// aki is unknown: punctuation set proportionally (`proportional`, from +/// `palt` or `vpal` in the flow direction) and curly quotes, which are +/// full-width in some fonts and proportional in others. A ruby base counts +/// as simple ruby (cl-22). +pub fn aki_class(ch: char, ruby_base: bool, proportional: bool) -> Option { + if ruby_base { + return Some(JapaneseClass::SimpleRuby); + } + let curly_quote = matches!(ch, '‘' | '’' | '“' | '”'); + (!proportional && !curly_quote).then(|| classify(ch)) +} + +/// The half-em aki that go in a run of characters classed by `aki_class` +/// (JLREQ §3.1.4, see `shed_pair_aki`), as (character index, which aki). +/// A character of unknown aki (`None`) sheds nothing and makes its +/// neighbours keep theirs. +pub fn punctuation_aki_sheds(classes: &[Option]) -> Vec<(usize, AkiShed)> { + let mut sheds = Vec::new(); + for (index, pair) in classes.windows(2).enumerate() { + let [Some(before), Some(after)] = *pair else { + continue; + }; + match shed_pair_aki(before, after) { + (true, _) => sheds.push((index, AkiShed::Trailing)), + (_, true) => sheds.push((index + 1, AkiShed::Leading)), + _ => {} + } + } + sheds +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PairRule { + /// Preferred extra spacing between the two character frames, in em. + pub preferred_em: f32, + /// Smallest spacing allowed during oikomi, in em. + pub minimum_em: f32, + /// Largest spacing allowed during oidashi/justification, in em. + pub maximum_em: f32, + pub break_allowed: bool, + /// Whether horizontal SkParagraph needs a WORD JOINER to block a break + /// in this pair. Its Unicode breaker already keeps Western/numeral runs + /// whole. + pub suppress_break_with_joiner: bool, + /// Lower values are adjusted first; zero means not adjustable. + pub shrink_priority: u8, + pub expand_priority: u8, +} + +impl PairRule { + const SOLID: Self = Self { + preferred_em: 0.0, + minimum_em: 0.0, + maximum_em: 0.0, + break_allowed: true, + suppress_break_with_joiner: false, + shrink_priority: 0, + expand_priority: 0, + }; +} + +const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] { + let mut table = [[PairRule::SOLID; JapaneseClass::COUNT]; JapaneseClass::COUNT]; + let mut before_index = 0; + while before_index < JapaneseClass::COUNT { + let before = JapaneseClass::ALL[before_index]; + let mut after_index = 0; + while after_index < JapaneseClass::COUNT { + let after = JapaneseClass::ALL[after_index]; + let mut rule = PairRule::SOLID; + + rule.suppress_break_with_joiner = + before.forbids_line_end() || after.forbids_line_start(); + rule.break_allowed = !rule.suppress_break_with_joiner; + if before_index == after_index + && matches!( + before, + JapaneseClass::GroupedNumeral | JapaneseClass::Western + ) + { + rule.break_allowed = false; + } + + if matches!(before, JapaneseClass::WesternWordSpace) { + rule.preferred_em = 1.0 / 3.0; + rule.minimum_em = 0.25; + rule.maximum_em = 0.5; + rule.shrink_priority = 1; + rule.expand_priority = 1; + } else if (before.is_japanese_text() && after.is_western_run()) + || (before.is_western_run() && after.is_japanese_text()) + { + rule.preferred_em = 0.25; + rule.minimum_em = 0.125; + rule.maximum_em = 0.5; + rule.shrink_priority = 5; + rule.expand_priority = 2; + } else if matches!( + (before, after), + ( + JapaneseClass::ClosingBracket | JapaneseClass::Comma | JapaneseClass::FullStop, + JapaneseClass::TateChuYoko + ) | (JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket) + ) { + rule.preferred_em = 0.5; + rule.minimum_em = 0.0; + rule.maximum_em = 0.5; + rule.shrink_priority = 3; + } else if matches!( + (before, after), + (JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko) + ) { + // Adjacent cl-30 entries are always separate TCY composites + // (characters inside one composite are atomic). + rule.maximum_em = 0.25; + rule.expand_priority = 3; + } else if matches!(before, JapaneseClass::DividingPunctuation) + && !matches!( + after, + JapaneseClass::ClosingBracket + | JapaneseClass::DividingPunctuation + | JapaneseClass::IdeographicSpace + | JapaneseClass::WesternWordSpace + | JapaneseClass::FullStop + | JapaneseClass::Comma + | JapaneseClass::MiddleDot + ) + { + // A sentence-ending question/exclamation mark carries one em + // after it, unless punctuation or a typed space follows. Line + // planning may drop it at the line edge. + rule.preferred_em = 1.0; + rule.minimum_em = 0.0; + rule.maximum_em = 1.0; + rule.shrink_priority = 2; + } else if before.is_trailing_aki_punctuation() + && matches!(after, JapaneseClass::OpeningBracket) + { + // Keep one half-em between the two half-width glyph bodies, + // not the sum of both characters' aki. + rule.preferred_em = 0.5; + rule.minimum_em = 0.0; + rule.maximum_em = 0.5; + rule.shrink_priority = 4; + } else if before.is_trailing_aki_punctuation() && after.is_trailing_aki_punctuation() { + // Consecutive closing punctuation sets solid internally; the + // last character in the sequence supplies the trailing aki. + } else if matches!(before, JapaneseClass::OpeningBracket) + && matches!(after, JapaneseClass::OpeningBracket) + { + // Consecutive opening brackets set solid internally; the first + // character in the sequence supplies the leading aki. + } else if (before.is_trailing_aki_punctuation() + && matches!(after, JapaneseClass::MiddleDot)) + || (matches!(before, JapaneseClass::MiddleDot) + && matches!(after, JapaneseClass::OpeningBracket)) + { + rule.preferred_em = 0.25; + rule.minimum_em = 0.0; + rule.maximum_em = 0.25; + rule.shrink_priority = 3; + } else if before.is_trailing_aki_punctuation() { + rule.preferred_em = 0.5; + rule.minimum_em = if matches!(before, JapaneseClass::FullStop) { + 0.5 + } else { + 0.0 + }; + rule.maximum_em = 0.5; + rule.shrink_priority = if matches!(before, JapaneseClass::FullStop) { + 0 + } else { + 4 + }; + } else if matches!(after, JapaneseClass::OpeningBracket) { + rule.preferred_em = 0.5; + rule.minimum_em = 0.0; + rule.maximum_em = 0.5; + rule.shrink_priority = 4; + } else if matches!(before, JapaneseClass::MiddleDot) + || matches!(after, JapaneseClass::MiddleDot) + { + rule.preferred_em = 0.25; + rule.minimum_em = 0.0; + rule.maximum_em = 0.25; + rule.shrink_priority = 3; + } else if before.is_japanese_letter() && after.is_japanese_letter() { + // Solid Japanese text is the general third-stage expansion + // point. The planner passes this quarter-em cap only in + // JLREQ's final equal-expansion fallback. + rule.maximum_em = 0.25; + rule.expand_priority = 3; + } + + table[before_index][after_index] = rule; + after_index += 1; + } + before_index += 1; + } + table +} + +pub const PAIR_RULES: [[PairRule; JapaneseClass::COUNT]; JapaneseClass::COUNT] = + generated_pair_rules(); + +pub const fn pair_rule(before: JapaneseClass, after: JapaneseClass) -> PairRule { + PAIR_RULES[before.index()][after.index()] +} + +const OPENING_BRACKETS: &str = "(〔[{〈《「『【〖〘〚‘“〝«⦅⦅「"; +const CLOSING_BRACKETS: &str = ")〕]}〉》」』】〗〙〛’”〟〞»⦆⦆」"; +const HYPHENS: &str = "‐゠–〜~"; +const DIVIDING_PUNCTUATION: &str = "!?‼⁇⁈⁉"; +const MIDDLE_DOTS: &str = "・・:;"; +const FULL_STOPS: &str = "。.。"; +const COMMAS: &str = "、,、"; +const INSEPARABLE: &str = "—―…‥〳〴〵"; +const ITERATION_MARKS: &str = "々〻ゝゞヽヾ"; +const SMALL_KANA: &str = concat!( + "ぁぃぅぇぉっゃゅょゎゕゖ", + "ァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ", + "ァィゥェォャュョッ" +); +const PREFIXED_ABBREVIATIONS: &str = "¥¥$$££##€"; +const POSTFIXED_ABBREVIATIONS: &str = concat!("°′″℃¢¢%%‰‱ℓ", "㌃㌍㌔㌘㌢㌣㌦㌧㌫㌶㌻㍉㍊㍍㍑㍗"); +const MATH_SYMBOLS: &str = "==≠≒≃≅≈≡≢<<>>≦≧≤≥≪≫≶≷⋚⋛∈∋⊆⊇⊂⊃∪∩⊄⊅⊊⊋∉⌅⌆∧∨⇒⇔↔∥∦∽∝⊥⊕⊗"; +const MATH_OPERATORS: &str = "++-−-÷×±∓∗∙√∫∬∭∑∏"; + +pub fn classify(c: char) -> JapaneseClass { + // Common letters skip the table scans. + match c { + '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' => JapaneseClass::Ideographic, + 'a'..='z' | 'A'..='Z' => JapaneseClass::Western, + _ => classify_by_tables(c), + } +} + +fn classify_by_tables(c: char) -> JapaneseClass { + if OPENING_BRACKETS.contains(c) { + JapaneseClass::OpeningBracket + } else if CLOSING_BRACKETS.contains(c) { + JapaneseClass::ClosingBracket + } else if HYPHENS.contains(c) { + JapaneseClass::Hyphen + } else if DIVIDING_PUNCTUATION.contains(c) { + JapaneseClass::DividingPunctuation + } else if MIDDLE_DOTS.contains(c) { + JapaneseClass::MiddleDot + } else if FULL_STOPS.contains(c) { + JapaneseClass::FullStop + } else if COMMAS.contains(c) { + JapaneseClass::Comma + } else if INSEPARABLE.contains(c) { + JapaneseClass::Inseparable + } else if ITERATION_MARKS.contains(c) { + JapaneseClass::IterationMark + } else if c == 'ー' || c == 'ー' { + JapaneseClass::ProlongedSoundMark + } else if SMALL_KANA.contains(c) { + JapaneseClass::SmallKana + } else if PREFIXED_ABBREVIATIONS.contains(c) { + JapaneseClass::PrefixedAbbreviation + } else if POSTFIXED_ABBREVIATIONS.contains(c) { + JapaneseClass::PostfixedAbbreviation + } else if c == '\u{3000}' { + JapaneseClass::IdeographicSpace + } else if MATH_SYMBOLS.contains(c) { + JapaneseClass::MathSymbol + } else if MATH_OPERATORS.contains(c) { + JapaneseClass::MathOperator + } else if c == ' ' || c == '\t' || c == '\u{00a0}' { + JapaneseClass::WesternWordSpace + } else if c.is_ascii_digit() { + JapaneseClass::GroupedNumeral + } else if is_hiragana(c) { + JapaneseClass::Hiragana + } else if is_katakana(c) { + JapaneseClass::Katakana + } else if is_ideographic(c) { + JapaneseClass::Ideographic + } else if is_squared_unit(c) { + JapaneseClass::PostfixedAbbreviation + } else if is_ideographic_symbol(c) { + JapaneseClass::Ideographic + } else { + JapaneseClass::Western + } +} + +/// Digits that group with an adjacent prefixed or postfixed abbreviation. +fn is_numeral(c: char) -> bool { + c.is_ascii_digit() || matches!(u32::from(c), 0xFF10..=0xFF19) +} + +/// Character pairs kept on one line beyond the class table: two identical +/// inseparable marks (`——`, `……`), an abbreviation with its numeral +/// (`¥100`, `60㎏`, `100%`), per JLREQ §3.1.10, and digits of one +/// number, full-width ones included (`2026`), which JLREQ would let break +/// as ideographs. ASCII digit pairs are already grouped numerals. +pub fn keeps_together(before: char, after: char) -> bool { + keeps_together_classified((before, classify(before)), (after, classify(after))) +} + +/// `keeps_together` for characters already paired with their classes. +pub fn keeps_together_classified( + (before, before_class): (char, JapaneseClass), + (after, after_class): (char, JapaneseClass), +) -> bool { + (before == after && before_class == JapaneseClass::Inseparable) + || (is_numeral(before) + && is_numeral(after) + && !(before.is_ascii_digit() && after.is_ascii_digit())) + || (is_numeral(before) && after_class == JapaneseClass::PostfixedAbbreviation) + || (before_class == JapaneseClass::PrefixedAbbreviation && is_numeral(after)) +} + +/// Whether a line may break between two adjacent characters: the pair table +/// (kinsoku, Western and numeral runs) plus `keeps_together`. +pub fn break_allowed_between(before: char, after: char) -> bool { + pair_rule(classify(before), classify(after)).break_allowed && !keeps_together(before, after) +} + +/// True for characters that only appear in Japanese text: kana, kanji, and +/// the CJK punctuation and full-width form blocks. +pub fn is_japanese_text_char(c: char) -> bool { + classify(c).is_japanese_letter() + || matches!(u32::from(c), 0x3000..=0x30FF | 0x31F0..=0x31FF | 0xFF00..=0xFFEF) +} + +/// True for characters that extend the preceding grapheme cluster +/// (Grapheme_Cluster_Break=Extend or ZWJ): combining marks, voiced sound +/// marks, variation selectors, emoji modifiers and tags. +pub fn extends_grapheme(c: char) -> bool { + matches!(u32::from(c), + 0x0300..=0x036F + | 0x1AB0..=0x1AFF + | 0x1DC0..=0x1DFF + | 0x200C..=0x200D + | 0x20D0..=0x20FF + | 0x3099..=0x309A + | 0xFE00..=0xFE0F + | 0xFE20..=0xFE2F + | 0xFF9E..=0xFF9F + | 0x1F3FB..=0x1F3FF + | 0xE0020..=0xE007F + | 0xE0100..=0xE01EF + ) +} + +fn is_hiragana(c: char) -> bool { + matches!(u32::from(c), 0x3041..=0x309F) +} + +fn is_katakana(c: char) -> bool { + matches!(u32::from(c), 0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F) +} + +fn is_ideographic(c: char) -> bool { + matches!(u32::from(c), + 0x2E80..=0x2FDF + | 0x31C0..=0x31EF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xF900..=0xFAFF + | 0x20000..=0x2FA1F + | 0x30000..=0x323AF + ) || matches!(c, '〃' | '仝' | '〆' | '♂' | '♀') +} + +/// Squared Latin unit abbreviations (㎏, ㎡, ㏄, …), cl-13 like %. ㏍ is a +/// company mark, cl-19. +fn is_squared_unit(c: char) -> bool { + matches!(u32::from(c), 0x3380..=0x33DF) && c != '㏍' +} + +/// Symbols JLREQ sets as full-width ideographic characters (cl-19): the +/// full-width forms, CJK symbols, circled and parenthesized characters, +/// Roman numerals, arrows, shapes and squared words. Latin-1 symbols such +/// as © or † stay Western, as they also appear in Latin text. +fn is_ideographic_symbol(c: char) -> bool { + matches!(u32::from(c), + 0xFF01..=0xFF5E + | 0x2160..=0x217F + | 0x2190..=0x21FF + | 0x2460..=0x24FF + | 0x25A0..=0x25FF + | 0x2600..=0x26FF + | 0x2776..=0x277F + | 0x3000..=0x303F + | 0x3200..=0x33FF + ) || matches!( + c, + '※' | '⁂' | '⁑' | '℡' | '✓' | '❖' | '⤴' | '⤵' | '⦿' | 'ゟ' | 'ヿ' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aki_class_leaves_out_curly_quotes_and_proportional_punctuation() { + assert_eq!(aki_class('“', false, false), None); + assert_eq!(aki_class('」', false, true), None); + assert_eq!( + aki_class('」', false, false), + Some(JapaneseClass::ClosingBracket) + ); + assert_eq!( + aki_class('」', true, false), + Some(JapaneseClass::SimpleRuby) + ); + } + + #[test] + fn punctuation_aki_sheds_the_trailing_aki_of_a_closing_mark() { + use JapaneseClass::*; + let classes = [Some(ClosingBracket), Some(OpeningBracket)]; + assert_eq!( + punctuation_aki_sheds(&classes), + vec![(0, AkiShed::Trailing)] + ); + } + + #[test] + fn punctuation_aki_sheds_the_leading_aki_of_a_second_opening_bracket() { + use JapaneseClass::*; + let classes = [Some(OpeningBracket), Some(OpeningBracket)]; + assert_eq!(punctuation_aki_sheds(&classes), vec![(1, AkiShed::Leading)]); + } + + #[test] + fn punctuation_aki_sheds_nothing_next_to_an_unknown_aki() { + use JapaneseClass::*; + let classes = [Some(ClosingBracket), None, Some(OpeningBracket)]; + assert!(punctuation_aki_sheds(&classes).is_empty()); + } + + #[test] + fn class_model_contains_all_thirty_jlreq_classes_in_order() { + assert_eq!(JapaneseClass::ALL.len(), 30); + for (index, class) in JapaneseClass::ALL.iter().enumerate() { + assert_eq!(class.index(), index); + } + } + + #[test] + fn classifies_representative_jlreq_characters() { + let cases = [ + ('「', JapaneseClass::OpeningBracket), + ('」', JapaneseClass::ClosingBracket), + ('〜', JapaneseClass::Hyphen), + ('!', JapaneseClass::DividingPunctuation), + ('・', JapaneseClass::MiddleDot), + ('。', JapaneseClass::FullStop), + ('、', JapaneseClass::Comma), + ('…', JapaneseClass::Inseparable), + ('々', JapaneseClass::IterationMark), + ('ー', JapaneseClass::ProlongedSoundMark), + ('ょ', JapaneseClass::SmallKana), + ('¥', JapaneseClass::PrefixedAbbreviation), + ('%', JapaneseClass::PostfixedAbbreviation), + ('\u{3000}', JapaneseClass::IdeographicSpace), + ('あ', JapaneseClass::Hiragana), + ('ア', JapaneseClass::Katakana), + ('≠', JapaneseClass::MathSymbol), + ('+', JapaneseClass::MathOperator), + ('漢', JapaneseClass::Ideographic), + ('2', JapaneseClass::GroupedNumeral), + ('㎏', JapaneseClass::PostfixedAbbreviation), + (' ', JapaneseClass::WesternWordSpace), + ('A', JapaneseClass::Western), + ]; + for (character, expected) in cases { + assert_eq!(classify(character), expected, "wrong class for {character}"); + } + } + + #[test] + fn generated_rules_cover_every_class_pair() { + assert_eq!(PAIR_RULES.len(), JapaneseClass::COUNT); + assert!(PAIR_RULES + .iter() + .all(|row| row.len() == JapaneseClass::COUNT)); + } + + #[test] + fn generated_rules_encode_kinsoku_and_atomic_runs() { + assert!( + !pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::Ideographic).break_allowed + ); + assert!( + !pair_rule(JapaneseClass::Ideographic, JapaneseClass::ClosingBracket).break_allowed + ); + assert!( + !pair_rule(JapaneseClass::GroupedNumeral, JapaneseClass::GroupedNumeral).break_allowed + ); + assert!(pair_rule(JapaneseClass::Ideographic, JapaneseClass::Ideographic).break_allowed); + } + + #[test] + fn generated_rules_encode_script_and_tcy_spacing() { + let script = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Western); + assert_eq!(script.preferred_em, 0.25); + assert_eq!(script.minimum_em, 0.125); + assert_eq!(script.maximum_em, 0.5); + + let tcy = pair_rule(JapaneseClass::Comma, JapaneseClass::TateChuYoko); + assert_eq!(tcy.preferred_em, 0.5); + assert_eq!( + pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Comma).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::OpeningBracket, JapaneseClass::TateChuYoko).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::OpeningBracket).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::Ideographic, JapaneseClass::TateChuYoko).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::Ideographic).preferred_em, + 0.0 + ); + let adjacent_tcy = pair_rule(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko); + assert_eq!(adjacent_tcy.preferred_em, 0.0); + assert_eq!(adjacent_tcy.maximum_em, 0.25); + assert_eq!(adjacent_tcy.expand_priority, 3); + } + + #[test] + fn generated_rules_encode_punctuation_sequences() { + assert_eq!( + pair_rule(JapaneseClass::Ideographic, JapaneseClass::OpeningBracket).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::Ideographic).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::FullStop, JapaneseClass::ClosingBracket).preferred_em, + 0.0 + ); + assert_eq!( + pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::OpeningBracket).preferred_em, + 0.5 + ); + assert_eq!( + pair_rule(JapaneseClass::ClosingBracket, JapaneseClass::MiddleDot).preferred_em, + 0.25 + ); + assert_eq!( + pair_rule(JapaneseClass::MiddleDot, JapaneseClass::OpeningBracket).preferred_em, + 0.25 + ); + assert_eq!( + pair_rule( + JapaneseClass::DividingPunctuation, + JapaneseClass::Ideographic + ) + .preferred_em, + 1.0 + ); + assert_eq!( + pair_rule( + JapaneseClass::DividingPunctuation, + JapaneseClass::DividingPunctuation + ) + .preferred_em, + 0.0 + ); + assert_eq!( + pair_rule( + JapaneseClass::DividingPunctuation, + JapaneseClass::IdeographicSpace + ) + .preferred_em, + 0.0 + ); + let solid_japanese = pair_rule(JapaneseClass::Ideographic, JapaneseClass::Hiragana); + assert_eq!(solid_japanese.preferred_em, 0.0); + assert_eq!(solid_japanese.maximum_em, 0.25); + assert_eq!(solid_japanese.expand_priority, 3); + let ruby_adjacency = pair_rule(JapaneseClass::SimpleRuby, JapaneseClass::JukugoRuby); + assert_eq!(ruby_adjacency.preferred_em, 0.0); + assert_eq!(ruby_adjacency.maximum_em, 0.0); + assert_eq!(ruby_adjacency.expand_priority, 0); + } + + #[test] + fn classify_follows_jlreq_appendix_a() { + let cases = [ + ('—', JapaneseClass::Inseparable), + ('〝', JapaneseClass::OpeningBracket), + ('〟', JapaneseClass::ClosingBracket), + ('〞', JapaneseClass::ClosingBracket), + ('«', JapaneseClass::OpeningBracket), + ('»', JapaneseClass::ClosingBracket), + ('⦅', JapaneseClass::OpeningBracket), + ('⦆', JapaneseClass::ClosingBracket), + ('⦅', JapaneseClass::OpeningBracket), + ('⦆', JapaneseClass::ClosingBracket), + ('A', JapaneseClass::Ideographic), + ('z', JapaneseClass::Ideographic), + ('1', JapaneseClass::Ideographic), + ('〇', JapaneseClass::Ideographic), + ('〒', JapaneseClass::Ideographic), + ('※', JapaneseClass::Ideographic), + ('①', JapaneseClass::Ideographic), + ('㈱', JapaneseClass::Ideographic), + ('○', JapaneseClass::Ideographic), + ('★', JapaneseClass::Ideographic), + ('→', JapaneseClass::Ideographic), + ('Ⅳ', JapaneseClass::Ideographic), + ('㏍', JapaneseClass::Ideographic), + ('≒', JapaneseClass::MathSymbol), + ('≡', JapaneseClass::MathSymbol), + ('≪', JapaneseClass::MathSymbol), + ('≈', JapaneseClass::MathSymbol), + ('∝', JapaneseClass::MathSymbol), + ('↔', JapaneseClass::MathSymbol), + ('€', JapaneseClass::PrefixedAbbreviation), + ('㎡', JapaneseClass::PostfixedAbbreviation), + ('㏄', JapaneseClass::PostfixedAbbreviation), + ('㌔', JapaneseClass::PostfixedAbbreviation), + ('ℓ', JapaneseClass::PostfixedAbbreviation), + ('。', JapaneseClass::FullStop), + ('、', JapaneseClass::Comma), + ('「', JapaneseClass::OpeningBracket), + ('」', JapaneseClass::ClosingBracket), + ('ァ', JapaneseClass::SmallKana), + ('ッ', JapaneseClass::SmallKana), + ('ー', JapaneseClass::ProlongedSoundMark), + ('\u{30000}', JapaneseClass::Ideographic), + ('\u{31350}', JapaneseClass::Ideographic), + ]; + for (character, expected) in cases { + assert_eq!(classify(character), expected, "wrong class for {character}"); + } + } + + #[test] + fn fast_classes_match_the_tables() { + let fast = ('\u{4E00}'..='\u{9FFF}') + .chain('\u{3400}'..='\u{4DBF}') + .chain('a'..='z') + .chain('A'..='Z'); + for c in fast { + assert_eq!(classify(c), classify_by_tables(c), "{c:?}"); + } + } + + #[test] + fn ascii_and_latin_symbols_stay_western() { + for character in ['(', ')', '[', ']', 'A', '©', '†', 'α'] { + assert_eq!(classify(character), JapaneseClass::Western, "{character}"); + } + } + + #[test] + fn only_identical_inseparable_pairs_are_unbreakable() { + assert!(!JapaneseClass::Inseparable.forbids_line_start()); + assert!(break_allowed_between('あ', '…')); + assert!(!break_allowed_between('…', '…')); + assert!(!break_allowed_between('—', '—')); + assert!(break_allowed_between('—', '…')); + } + + #[test] + fn abbreviations_bind_only_to_numerals() { + assert!(!JapaneseClass::PostfixedAbbreviation.forbids_line_start()); + assert!(!JapaneseClass::PrefixedAbbreviation.forbids_line_end()); + assert!(!break_allowed_between('0', '㎏')); + assert!(!break_allowed_between('0', '%')); + assert!(!break_allowed_between('$', '5')); + assert!(!break_allowed_between('¥', '1')); + assert!(break_allowed_between('#', 'は')); + assert!(break_allowed_between('漢', '%')); + } + + #[test] + fn breaks_follow_kinsoku_and_western_runs() { + assert!(!break_allowed_between('あ', 'っ')); + assert!(!break_allowed_between('「', 'あ')); + assert!(!break_allowed_between('P', 'e')); + assert!(!break_allowed_between('2', '0')); + assert!(break_allowed_between('あ', 'い')); + } + + #[test] + fn digit_runs_stay_together() { + assert!(!break_allowed_between('2', '0')); + assert!(!break_allowed_between('2', '0')); + assert!(break_allowed_between('6', '年')); + assert!(break_allowed_between('年', '2')); + } +} diff --git a/render-wasm/src/shapes/kinsoku.rs b/render-wasm/src/shapes/kinsoku.rs new file mode 100644 index 0000000000..8a7bcabad0 --- /dev/null +++ b/render-wasm/src/shapes/kinsoku.rs @@ -0,0 +1,734 @@ +//! Japanese line-breaking rules (kinsoku shori). +//! +//! Skia's default break iterator allows closing punctuation, the prolonged +//! sound mark or small kana at a line start, and opening brackets at a line +//! end. skia-safe exposes no ICU BreakIterator, so this module suppresses +//! those breaks by inserting U+2060 WORD JOINER into the text handed to +//! skparagraph. UAX #14 also forbids a break before … and ‥, which JLREQ +//! allows; U+200B ZERO WIDTH SPACE reopens it. The same transform inserts the JLREQ quarter-em space at +//! Japanese/Western boundaries and sets Western word spaces to one third em. +//! +//! Inserted characters shift every UTF-16 offset the laid-out paragraph +//! reports (position-data, carets, selection rects). [`OffsetMap`] +//! translates between original and shifted offsets. It depends only on the +//! span texts, so any consumer can recompute it and match the builders. + +/// Zero-width character; its UAX #14 class forbids a break on either side. +pub const WORD_JOINER: char = '\u{2060}'; +/// ZERO WIDTH SPACE: opens a break UAX #14 forbids but JLREQ allows, before +/// an inseparable mark such as … (JLREQ only keeps two of them together). +pub const BREAK_OPPORTUNITY: char = '\u{200B}'; + +/// True for the zero-width characters this pass inserts to steer breaks; +/// they take no letter-spacing and count as no character. +pub fn is_inserted_zero_width(ch: char) -> bool { + ch == WORD_JOINER || ch == BREAK_OPPORTUNITY +} +/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap. +pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}'; +/// Unicode THREE-PER-EM SPACE, used for Western word spaces. +pub const WESTERN_WORD_SPACE: char = '\u{2004}'; +/// Unicode IDEOGRAPHIC SPACE, the one-em space after a sentence-ending +/// question or exclamation mark. +pub const IDEOGRAPHIC_SPACE: char = '\u{3000}'; +/// Joins emoji into one grapheme cluster. +const ZERO_WIDTH_JOINER: char = '\u{200D}'; + +use super::japanese::{ + classify, extends_grapheme, keeps_together_classified, pair_rule, JapaneseClass, +}; + +pub fn forbidden_at_line_start(c: char) -> bool { + classify(c).forbids_line_start() +} + +pub fn forbidden_at_line_end(c: char) -> bool { + classify(c).forbids_line_end() +} + +/// Translates between original UTF-16 offsets (the source-of-truth span text) +/// and layout-text UTF-16 offsets (the text handed to skparagraph). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct OffsetMap { + /// Ascending shifted UTF-16 indices of inserted (not substituted) chars. + inserted: Vec, +} + +impl OffsetMap { + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.inserted.is_empty() + } + + /// True when the character at a shifted offset was inserted by the + /// transform. + pub fn is_inserted(&self, shifted: usize) -> bool { + self.inserted.binary_search(&shifted).is_ok() + } + + /// Original offset for a shifted offset. An offset on an inserted + /// character resolves to the boundary where it was inserted. + pub fn to_original(&self, shifted: usize) -> usize { + shifted - self.inserted.partition_point(|&p| p < shifted) + } + + /// Shifted offset for an original offset. A boundary that received an + /// inserted character resolves after it, so carets skip synthetic spacing. + pub fn to_shifted(&self, original: usize) -> usize { + // The i-th insertion lands before `original` when it sits at or + // before `original + i`; `inserted[i] - i` never decreases, so the + // count is a partition point. + let (mut low, mut high) = (0, self.inserted.len()); + while low < high { + let middle = (low + high) / 2; + if self.inserted[middle] - middle <= original { + low = middle + 1; + } else { + high = middle; + } + } + original + low + } +} + +/// Applies the horizontal Japanese layout-text transform: inserts WORD +/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space +/// between Japanese letters and Western letters or digits, and sets +/// breakable ASCII spaces to one third em. Span boundaries are transparent; +/// grapheme clusters are not, so nothing lands inside one. `ruby_breaks[span] +/// == Some(boundaries)` forbids breaks at every internal scalar boundary of +/// that span except those UTF-16 offsets. Returns `None` when nothing changes. +pub fn apply_to_span_texts_with_ruby_breaks( + span_texts: &[String], + ruby_breaks: &[Option>], +) -> Option<(Vec, OffsetMap)> { + 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. + let mut prev: Option<(char, JapaneseClass)> = None; + let mut after_zwj = false; + let mut changed = false; + // Running position in shifted UTF-16 coordinates. + let mut shifted_pos: usize = 0; + + for (span_index, text) in span_texts.iter().enumerate() { + let mut shifted_text = String::with_capacity(text.len() + 4); + let mut local_utf16 = 0usize; + for c in text.chars() { + if after_zwj || extends_grapheme(c) { + shifted_text.push(c); + shifted_pos += c.len_utf16(); + local_utf16 += c.len_utf16(); + after_zwj = c == ZERO_WIDTH_JOINER; + continue; + } + let current = (c, classify(c)); + let ruby_forbids_break = local_utf16 > 0 + && ruby_breaks + .get(span_index) + .and_then(Option::as_ref) + .is_some_and(|breaks| !breaks.contains(&local_utf16)); + let forbid_break = ruby_forbids_break + || prev.is_some_and(|p| { + pair_rule(p.1, current.1).suppress_break_with_joiner + || keeps_together_classified(p, current) + }); + let mut insert = |ch: char| { + shifted_text.push(ch); + inserted.push(shifted_pos); + shifted_pos += 1; + changed = true; + }; + if prev.is_some_and(|p| takes_em_space_after(p, current)) { + insert(IDEOGRAPHIC_SPACE); + } + if prev.is_some_and(|p| is_japanese_western_boundary(p, current)) { + insert(JAPANESE_WESTERN_SPACE); + } + // After an inserted space, the joiner keeps the break blocked. + if forbid_break { + insert(WORD_JOINER); + } else if current.1 == JapaneseClass::Inseparable + && prev.is_some_and(|p| pair_rule(p.1, current.1).break_allowed) + { + insert(BREAK_OPPORTUNITY); + } + let layout_char = if c == ' ' { + changed = true; + WESTERN_WORD_SPACE + } else { + c + }; + shifted_text.push(layout_char); + shifted_pos += c.len_utf16(); + local_utf16 += c.len_utf16(); + prev = Some(current); + } + out.push(shifted_text); + } + + if !changed { + return None; + } + Some((out, OffsetMap { inserted })) +} + +/// JLREQ §3.2.6 boundary between Japanese text and a Western letter or +/// digit. Western symbols, brackets and emoji set solid. +fn is_japanese_western_boundary( + (before, before_class): (char, JapaneseClass), + (after, after_class): (char, JapaneseClass), +) -> bool { + (before_class.is_japanese_text() && after_class.is_western_run() && after.is_alphanumeric()) + || (before_class.is_western_run() + && before.is_alphanumeric() + && after_class.is_japanese_text()) +} + +/// A question or exclamation mark that ends a sentence takes a one-em space +/// (JLREQ §3.1.6), as in vertical layout. +fn takes_em_space_after( + (_, before_class): (char, JapaneseClass), + (_, after_class): (char, JapaneseClass), +) -> bool { + before_class == JapaneseClass::DividingPunctuation + && pair_rule(before_class, after_class).preferred_em >= 1.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use skia_safe::textlayout::{ + FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle, TypefaceFontProvider, + }; + use skia_safe::FontMgr; + + const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); + + fn strings(texts: &[&str]) -> Vec { + texts.iter().map(|t| t.to_string()).collect() + } + + fn apply(texts: &[&str]) -> (Vec, OffsetMap) { + apply_to_span_texts_with_ruby_breaks(&strings(texts), &[]) + .expect("expected kinsoku insertions") + } + + // ----------------------------------------------------------------- + // Character classes + // ----------------------------------------------------------------- + + #[test] + fn classes_forbidden_at_start() { + for c in "、。」』)ーっゃァッ々・!?".chars() { + assert!(forbidden_at_line_start(c), "expected start-forbidden: {c}"); + } + for c in "あ漢A1「( ".chars() { + assert!(!forbidden_at_line_start(c), "not start-forbidden: {c}"); + } + } + + #[test] + fn classes_forbidden_at_end() { + for c in "「『([【〈《".chars() { + assert!(forbidden_at_line_end(c), "expected end-forbidden: {c}"); + } + for c in "あ漢A1」)。".chars() { + assert!(!forbidden_at_line_end(c), "not end-forbidden: {c}"); + } + } + + // ----------------------------------------------------------------- + // Insertion + // ----------------------------------------------------------------- + + #[test] + fn western_word_space_uses_one_third_em_character() { + let (texts, map) = apply(&["hello world"]); + assert_eq!(texts, vec![format!("hello{WESTERN_WORD_SPACE}world")]); + assert!( + map.inserted.is_empty(), + "substitution does not shift offsets" + ); + } + + #[test] + fn no_insertion_for_plain_cjk_text() { + assert!( + apply_to_span_texts_with_ruby_breaks(&strings(&["国境の長いトンネル"]), &[]).is_none() + ); + } + + #[test] + fn inserts_before_forbidden_start_char() { + let (texts, map) = apply(&["雪国。"]); + assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]); + assert_eq!(map.inserted, vec![2]); + } + + #[test] + fn inserts_after_forbidden_end_char() { + let (texts, _) = apply(&["「雪"]); + assert_eq!(texts, vec!["「\u{2060}雪".to_string()]); + } + + #[test] + fn no_insertion_at_paragraph_start() { + // A leading start-forbidden char has no break before it to suppress. + let (texts, _) = apply(&["。あ。"]); + assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]); + } + + #[test] + fn insertion_spans_boundary() { + // The pair (end of span 0, start of span 1) is evaluated; the + // joiner lands at the head of span 1. + let (texts, _) = apply(&["雪国", "。です"]); + assert_eq!( + texts, + vec!["雪国".to_string(), "\u{2060}。です".to_string()] + ); + } + + #[test] + fn inserts_quarter_em_at_japanese_western_boundaries() { + let (texts, map) = apply(&["日本Penpot版"]); + assert_eq!( + texts, + vec![format!( + "日本{JAPANESE_WESTERN_SPACE}Penpot{JAPANESE_WESTERN_SPACE}版" + )] + ); + assert_eq!(map.inserted, vec![2, 9]); + assert_eq!(map.to_original(map.to_shifted(2)), 2); + assert_eq!(map.to_original(map.to_shifted(8)), 8); + } + + #[test] + fn japanese_western_spacing_crosses_span_boundary() { + let (texts, map) = apply(&["日本", "Penpot"]); + assert_eq!( + texts, + vec![ + "日本".to_string(), + format!("{JAPANESE_WESTERN_SPACE}Penpot") + ] + ); + assert_eq!(map.inserted, vec![2]); + } + + #[test] + fn no_insertion_inside_a_variation_sequence() { + assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["葛\u{E0100}城"]), &[]).is_none()); + } + + #[test] + fn no_insertion_before_halfwidth_voiced_marks() { + assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["ガギ"]), &[]).is_none()); + } + + #[test] + fn no_insertion_inside_an_emoji_zwj_sequence() { + assert!( + apply_to_span_texts_with_ruby_breaks(&strings(&["👩\u{200D}💻\u{200D}あ"]), &[]) + .is_none() + ); + } + + #[test] + fn grapheme_extenders_keep_the_base_as_previous_character() { + let (texts, _) = apply(&["葛\u{E0100}Penpot"]); + assert_eq!( + texts, + vec![format!("葛\u{E0100}{JAPANESE_WESTERN_SPACE}Penpot")] + ); + } + + #[test] + fn ruby_joiners_stay_outside_grapheme_clusters() { + let texts = strings(&["葛\u{E0100}城"]); + let (shifted, _) = apply_to_span_texts_with_ruby_breaks(&texts, &[Some(Vec::new())]) + .expect("expected a ruby joiner"); + assert_eq!(shifted, vec!["葛\u{E0100}\u{2060}城".to_string()]); + } + + #[test] + fn no_japanese_western_space_beside_symbols() { + assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["あ😀い"]), &[]).is_none()); + assert!(apply_to_span_texts_with_ruby_breaks(&strings(&["(注)あ"]), &[]).is_none()); + } + + #[test] + fn full_width_digits_stay_together() { + let (texts, _) = apply(&["2026年"]); + assert_eq!( + texts, + vec!["2\u{2060}0\u{2060}2\u{2060}6年".to_string()] + ); + } + + #[test] + fn numerals_keep_their_unit() { + let (texts, _) = apply(&["体重60㎏"]); + assert_eq!( + texts, + vec![format!("体重{JAPANESE_WESTERN_SPACE}60\u{2060}㎏")] + ); + } + + #[test] + fn prefixed_signs_bind_only_to_numerals() { + let (texts, _) = apply(&["C# は"]); + assert_eq!(texts, vec![format!("C#{WESTERN_WORD_SPACE}は")]); + } + + #[test] + fn a_line_may_start_with_an_inseparable_mark() { + // UAX #14 forbids the break before …; the zero-width space reopens it. + let (texts, _) = apply(&["あ…"]); + assert_eq!(texts, vec!["あ\u{200B}…".to_string()]); + let (texts, _) = apply(&["あ……"]); + assert_eq!(texts, vec!["あ\u{200B}…\u{2060}…".to_string()]); + let (texts, _) = apply(&["「……"]); + assert_eq!(texts, vec!["「\u{2060}…\u{2060}…".to_string()]); + } + + #[test] + fn japanese_western_space_follows_long_vowels_and_small_kana() { + let (texts, _) = apply(&["ユーザーID"]); + assert_eq!( + texts, + vec![format!( + "ユ\u{2060}ーザ\u{2060}ー{JAPANESE_WESTERN_SPACE}ID" + )] + ); + } + + #[test] + fn japanese_western_space_keeps_a_forbidden_break_blocked() { + let (texts, _) = apply(&["Excelっぽい"]); + assert_eq!( + texts, + vec![format!("Excel{JAPANESE_WESTERN_SPACE}\u{2060}っぽい")] + ); + } + + #[test] + fn dividing_punctuation_takes_an_em_space_before_text() { + let (texts, _) = apply(&["え?はい"]); + assert_eq!(texts, vec![format!("え\u{2060}?{IDEOGRAPHIC_SPACE}はい")]); + } + + #[test] + fn dividing_punctuation_adds_no_space_in_a_sequence_or_before_a_typed_space() { + let (texts, _) = apply(&["え!?はい"]); + assert_eq!( + texts, + vec![format!("え\u{2060}!\u{2060}?{IDEOGRAPHIC_SPACE}はい")] + ); + let (texts, _) = apply(&["え? はい"]); + assert_eq!(texts, vec!["え\u{2060}? はい".to_string()]); + let (texts, _) = apply(&["え?」"]); + assert_eq!(texts, vec!["え\u{2060}?\u{2060}」".to_string()]); + } + + #[test] + fn offset_map_lookups_match_a_linear_scan() { + let linear_to_shifted = |inserted: &[usize], original: usize| { + let mut shifted = original; + for &p in inserted { + if p <= shifted { + shifted += 1; + } else { + break; + } + } + shifted + }; + for inserted in [ + vec![], + vec![0], + vec![2, 3, 4], + vec![1, 5, 6, 10], + vec![0, 1, 2], + ] { + let map = OffsetMap { + inserted: inserted.clone(), + }; + for offset in 0..16 { + assert_eq!( + map.to_shifted(offset), + linear_to_shifted(&inserted, offset), + "to_shifted({offset}) for {inserted:?}" + ); + assert_eq!( + map.to_original(offset), + offset - inserted.iter().take_while(|&&p| p < offset).count(), + "to_original({offset}) for {inserted:?}" + ); + } + } + } + + #[test] + fn consecutive_forbidden_chars() { + let (texts, map) = apply(&["た」。"]); + assert_eq!(texts, vec!["た\u{2060}」\u{2060}。".to_string()]); + assert_eq!(map.inserted, vec![1, 3]); + } + + #[test] + fn small_kana_and_prolonged_sound() { + let (texts, _) = apply(&["コーヒー"]); + assert_eq!(texts, vec!["コ\u{2060}ーヒ\u{2060}ー".to_string()]); + let (texts, _) = apply(&["ちょっと"]); + assert_eq!(texts, vec!["ち\u{2060}ょ\u{2060}っと".to_string()]); + } + + // ----------------------------------------------------------------- + // Offset map + // ----------------------------------------------------------------- + + #[test] + fn offset_map_round_trips_every_original_index() { + let original = "「こんにちは」と彼は言った。『吾輩は猫である』(夏目漱石)!?コーヒー。"; + let (_, map) = apply(&[original]); + let len = original.encode_utf16().count(); + for i in 0..=len { + assert_eq!( + map.to_original(map.to_shifted(i)), + i, + "round-trip failed at original index {i}" + ); + } + } + + #[test] + fn offset_map_recovers_original_characters() { + let original = "た」。あ"; + let (texts, map) = apply(&[original]); + let shifted = texts.concat(); + let shifted_units: Vec = shifted.encode_utf16().collect(); + let original_units: Vec = original.encode_utf16().collect(); + for (s_idx, unit) in shifted_units.iter().enumerate() { + if *unit == WORD_JOINER as u16 { + continue; + } + assert_eq!(original_units[map.to_original(s_idx)], *unit); + } + } + + #[test] + fn offset_map_on_joiner_resolves_to_boundary() { + // "雪国。" → joiner at shifted 2; both sides of the joiner map + // to original boundary 2. + let (_, map) = apply(&["雪国。"]); + assert_eq!(map.to_original(2), 2); + assert_eq!(map.to_original(3), 2); + assert_eq!(map.to_shifted(2), 3); + } + + // ----------------------------------------------------------------- + // Real skparagraph layout + // ----------------------------------------------------------------- + + fn font_collection() -> FontCollection { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(skia_safe::Data::new_copy(TEST_FONT), None) + .expect("failed to load test font"); + let mut provider = TypefaceFontProvider::new(); + provider.register_typeface(typeface, Some("TestFont")); + let mut collection = FontCollection::new(); + collection.set_asset_font_manager(Some(provider.into())); + collection.set_default_font_manager(FontMgr::new(), None); + collection + } + + /// Lays out `text` at `width` and returns each line as a UTF-16 + /// (start, end) range. + fn layout_lines(text: &str, width: f32, letter_spacing: f32) -> Vec<(usize, usize)> { + let collection = font_collection(); + let paragraph_style = ParagraphStyle::default(); + let mut builder = ParagraphBuilder::new(¶graph_style, collection); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + style.set_letter_spacing(letter_spacing); + builder.push_style(&style); + builder.add_text(text); + let mut paragraph = builder.build(); + paragraph.layout(width); + paragraph + .get_line_metrics() + .iter() + .map(|line| (line.start_index, line.end_index)) + .collect() + } + + #[test] + fn layout_breaks_before_an_ellipsis() { + // JLREQ Fig 81: という……そういう breaks as という|……そういう; only the + // two dots of …… stay together. + let (texts, map) = apply(&["という……そういう"]); + let shifted = texts.concat(); + assert_eq!(shifted.matches(BREAK_OPPORTUNITY).count(), 1); + assert!(shifted.contains("う\u{200B}…\u{2060}…")); + let lines = layout_lines(&shifted, 20.0 * 4.0 + 1.0, 0.0); + let second = map.to_original(lines[1].0); + assert_eq!(second, 3, "the second line starts at ……: {lines:?}"); + } + + fn utf16_chars(text: &str) -> Vec { + // BMP-only fixtures: one char per UTF-16 unit. + text.chars().collect() + } + + /// Asserts no line starts with a start-forbidden char nor ends with + /// an end-forbidden char, in ORIGINAL text coordinates. + fn assert_kinsoku_clean(original: &str, lines: &[(usize, usize)], map: &OffsetMap) { + let chars = utf16_chars(original); + for (i, (start, end)) in lines.iter().enumerate() { + let orig_start = map.to_original(*start); + let orig_end = map.to_original(*end); + if i > 0 { + let first = chars[orig_start]; + assert!( + !forbidden_at_line_start(first), + "line {i} starts with forbidden char {first} (text {original})" + ); + } + if i < lines.len() - 1 && orig_end > orig_start { + // The last char actually rendered on the line (end may + // include trailing whitespace-like positions). + let last = chars[orig_end - 1]; + assert!( + !forbidden_at_line_end(last), + "line {i} ends with forbidden char {last} (text {original})" + ); + } + } + } + + /// Lines mapped back to original offsets must tile the original + /// text exactly: contiguous, in order, full coverage. + fn assert_lines_tile_original(original: &str, lines: &[(usize, usize)], map: &OffsetMap) { + let mut expected_start = 0; + for (start, end) in lines { + let orig_start = map.to_original(*start); + let orig_end = map.to_original(*end); + assert_eq!(orig_start, expected_start, "line ranges must be contiguous"); + expected_start = orig_end; + } + assert_eq!(expected_start, original.encode_utf16().count()); + } + + fn fixture_lines(original: &str, width: f32) -> (Vec<(usize, usize)>, OffsetMap) { + let (texts, map) = apply(&[original]); + let lines = layout_lines(&texts.concat(), width, 0.0); + (lines, map) + } + + fn measure_width(text: &str) -> f32 { + let collection = font_collection(); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + builder.push_style(&style); + builder.add_text(text); + let mut p = builder.build(); + p.layout(f32::MAX); + p.longest_line() + } + + #[test] + fn joiner_is_zero_width_in_layout() { + let plain = layout_lines("国国", f32::MAX, 0.0); + let joined = layout_lines("国\u{2060}国", f32::MAX, 0.0); + assert_eq!(plain.len(), 1); + assert_eq!(joined.len(), 1); + + let collection = font_collection(); + let measure = |text: &str| { + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone()); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + builder.push_style(&style); + builder.add_text(text); + let mut p = builder.build(); + p.layout(f32::MAX); + p.longest_line() + }; + let diff = (measure("国国") - measure("国\u{2060}国")).abs(); + assert!(diff < 0.01, "WORD JOINER must not add width, diff {diff}"); + } + + #[test] + fn suppresses_breaks_on_kinsoku_fixtures() { + let fixtures = [ + // kinsoku-line-start + "これは長い文章です、句読点や閉じ括弧」が行頭に来てはいけません。小さい「ゃゅょっ」も同様です。", + // kinsoku-line-end + "開き括弧「や『それに(と[や【は行末に置けないので、次の行に送り込まれます。", + // prolonged-sound + "コーヒーとケーキ、サーバーとルーター。人々の時々の心。", + // small-kana-sokuon + "ちょっと待ってください。キャッシュとクッキーをチェックする。", + // long-paragraph-wrap + "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。", + ]; + // Vary the width to move the breaks. Each width must exceed the + // longest joined run, or skparagraph falls back to an emergency + // mid-run break. + let char_width = measure_width("国"); + for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) { + for original in fixtures { + let (lines, map) = fixture_lines(original, width); + assert!(lines.len() > 1, "fixture must wrap at width {width}"); + assert_kinsoku_clean(original, &lines, &map); + assert_lines_tile_original(original, &lines, &map); + } + } + } + + #[test] + fn mixed_latin_cjk_fixture() { + let original = "Penpotは2024年にWASMレンダラーを導入した。価格は¥1,500(税込)です!"; + let char_width = measure_width("国"); + for width in [9.0, 13.0, 18.0].map(|n: f32| n * char_width) { + let (lines, map) = fixture_lines(original, width); + assert_kinsoku_clean(original, &lines, &map); + assert_lines_tile_original(original, &lines, &map); + } + } + + #[test] + fn joiner_becomes_visible_under_letter_spacing() { + // skparagraph applies letter-spacing per cluster, INCLUDING the + // zero-width joiner, which would double the tracking at every + // suppressed break, so the horizontal builder gives inserted + // joiners zero letter-spacing. If this test fails (Skia stops + // spacing ignorables), that special style can go. + let collection = font_collection(); + let measure = |text: &str| { + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone()); + let mut style = TextStyle::default(); + style.set_font_families(&["TestFont"]); + style.set_font_size(20.0); + style.set_letter_spacing(5.0); + builder.push_style(&style); + builder.add_text(text); + let mut p = builder.build(); + p.layout(f32::MAX); + p.longest_line() + }; + let diff = (measure("国\u{2060}国") - measure("国国")).abs(); + assert!( + diff > 0.01, + "letter-spacing no longer affects the joiner; the joiner \ + style in add_text_with_sheds can be removed" + ); + } +} diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 1898ed7276..78d17085ea 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -268,15 +268,34 @@ fn propagate_transform( ); match text_content.grow_type() { GrowType::AutoHeight => { + let width_before = text_content.size.width; let height_before = text_content.size.height; - let new_height = if width_changed { + let (new_width, new_height) = if text_content.is_vertical() { + // Vertical auto-height keeps the physical height (the + // column wrap axis) and grows width as columns are added. + if height_changed { + let mut clone = text_content.clone(); + clone.update_layout(resized_selrect); + (clone.size.width, shape_bounds_after.height()) + } else { + (width_before, shape_bounds_after.height()) + } + } else if width_changed { let mut clone = text_content.clone(); clone.update_layout(resized_selrect); - clone.size.height + (shape_bounds_after.width(), clone.size.height) } else { - height_before + (shape_bounds_after.width(), height_before) }; - if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) { + // Reflow only when the grow axis (the WASM-computed + // dimension) changes; the resize itself sets the wrap + // axis. + let grow_axis_changed = if text_content.is_vertical() { + !is_close_to(width_before, new_width) + } else { + !is_close_to(height_before, new_height) + }; + if grow_axis_changed && reflowed_shapes.insert(shape.id) { entries.push_back(Modifier::reflow(shape.id, false)); if let Some(parent_id) = shape.parent_id { @@ -290,7 +309,7 @@ fn propagate_transform( let resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, - shape_bounds_after.width(), + new_width, new_height, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index a8328be0d1..f8daba1624 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -21,14 +21,16 @@ use skia_safe::{ }; use std::borrow::Cow; -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::rc::Rc; use unicode_segmentation::UnicodeSegmentation; -use super::FontFamily; +use super::text_horizontal::*; +use super::text_japanese::*; +use super::{FontFamily, FontStyle}; use crate::math::Point; -use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; +use crate::shapes::{self, kinsoku, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; @@ -248,6 +250,57 @@ struct CachedExtrect { bottom: f32, } +/// Vertical layout with the content version and box it was laid out for. +#[derive(Clone)] +struct VerticalLayoutCache { + version: u64, + rect: Rect, + layout: Rc, +} + +impl std::fmt::Debug for VerticalLayoutCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VerticalLayoutCache") + .field("version", &self.version) + .field("rect", &self.rect) + .finish_non_exhaustive() + } +} + +/// Horizontal paragraph plans with the content version they were built for. +#[derive(Clone)] +struct HorizontalPlansCache { + version: u64, + plans: Rc>, +} + +/// Line adjustment of one horizontal Japanese paragraph at the layout width, +/// per span: the sheds that squeeze its lines and the letter-spacing that +/// justifies them. +#[derive(Debug, Clone, Default)] +pub(crate) struct HorizontalAdjustment { + sheds: Vec>, + spacing: Vec>, +} + +/// Per paragraph; `None` for paragraphs laid out by Skia alone. +type HorizontalAdjustments = Rc>>; + +#[derive(Debug, Clone)] +struct LineAdjustmentCache { + version: u64, + width: f32, + adjustments: HorizontalAdjustments, +} + +impl std::fmt::Debug for HorizontalPlansCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HorizontalPlansCache") + .field("version", &self.version) + .finish_non_exhaustive() + } +} + #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, @@ -255,6 +308,12 @@ pub struct TextContentLayout { /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. pub paragraphs: Rc>>, cached_extrect: Cell>, + /// Last vertical layout, shared across clones like `paragraphs`. + vertical: RefCell>, + /// Last horizontal paragraph plans, shared across clones. + horizontal: RefCell>, + /// Last Japanese line adjustment, shared across clones. + line_adjustment: RefCell>, } impl Default for TextContentLayout { @@ -269,6 +328,9 @@ impl Clone for TextContentLayout { paragraph_builders: vec![], paragraphs: Rc::clone(&self.paragraphs), cached_extrect: Cell::new(self.cached_extrect.get()), + vertical: RefCell::new(self.vertical.borrow().clone()), + horizontal: RefCell::new(self.horizontal.borrow().clone()), + line_adjustment: RefCell::new(self.line_adjustment.borrow().clone()), } } } @@ -285,6 +347,9 @@ impl TextContentLayout { paragraph_builders: vec![], paragraphs: Rc::new(Vec::new()), cached_extrect: Cell::new(None), + vertical: RefCell::new(None), + horizontal: RefCell::new(None), + line_adjustment: RefCell::new(None), } } @@ -415,6 +480,8 @@ pub struct TextContent { pub paragraphs: Vec, pub bounds: Rect, pub grow_type: GrowType, + /// How over-long lines are fitted, for the whole shape. + line_adjustment: LineAdjustment, pub size: TextContentSize, pub layout: TextContentLayout, content_version: u64, @@ -445,6 +512,7 @@ impl TextContent { paragraphs: Vec::new(), bounds, grow_type, + line_adjustment: LineAdjustment::default(), size: TextContentSize::default(), layout: TextContentLayout::new(), content_version: 0, @@ -462,6 +530,7 @@ impl TextContent { paragraphs, bounds, grow_type, + line_adjustment: self.line_adjustment, size: TextContentSize::new_with_size(bounds.width(), bounds.height()), layout: TextContentLayout::new(), content_version: 0, @@ -542,11 +611,15 @@ impl TextContent { &self.paragraphs } - pub fn has_non_ascii(&self) -> bool { + /// True when some span holds an emoji: an emoji code point, or an emoji + /// presentation selector or keycap mark (`©️`, `#️⃣`). Color emoji need an + /// overlay above strokes. + pub fn has_emoji(&self) -> bool { self.paragraphs .iter() .flat_map(|p| p.children()) - .any(|span| !span.text.is_ascii()) + .flat_map(|span| span.text.chars()) + .any(|c| super::text_vertical::is_emoji_char(c) || matches!(c, '\u{FE0F}' | '\u{20E3}')) } pub fn paragraphs_mut(&mut self) -> &mut Vec { @@ -562,10 +635,200 @@ impl TextContent { self.size.normalized_line_height } + /// Vertical layout for the box `rect`: its height is the column-wrap + /// budget and span paints resolve against it. Cached until the content or + /// the box changes; clones share it. + pub fn vertical_layout(&self, rect: &Rect) -> Rc { + if let Some(entry) = self.layout.vertical.borrow().as_ref() { + if entry.version == self.content_version && entry.rect == *rect { + return Rc::clone(&entry.layout); + } + } + let layout = Rc::new(super::text_vertical::layout_for_rect(self, rect)); + *self.layout.vertical.borrow_mut() = Some(VerticalLayoutCache { + version: self.content_version, + rect: *rect, + layout: Rc::clone(&layout), + }); + layout + } + + /// True when a horizontal paragraph gets its lines adjusted here rather + /// than by Skia: Japanese text that squeezes or is justified. + fn adjusts_lines(&self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> bool { + uses_japanese_layout(paragraph, &plan.texts) + && (self.line_adjustment == LineAdjustment::PushInFirst + || 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 + /// changes. `None` when no paragraph needs them. + fn horizontal_line_adjustments(&self) -> Option { + if self.is_vertical() || self.grow_type() == GrowType::AutoWidth { + return None; + } + let plans = self.horizontal_plans(); + let adjusted: Vec = self + .paragraphs + .iter() + .zip(plans.iter()) + .map(|(paragraph, plan)| self.adjusts_lines(paragraph, plan)) + .collect(); + if !adjusted.contains(&true) { + return None; + } + let width = self.get_width(self.bounds().width()); + if let Some(entry) = self.layout.line_adjustment.borrow().as_ref() { + if entry.version == self.content_version && entry.width == width { + return Some(Rc::clone(&entry.adjustments)); + } + } + let adjustments: HorizontalAdjustments = Rc::new( + self.paragraphs + .iter() + .zip(plans.iter()) + .zip(&adjusted) + .map(|((paragraph, plan), adjusted)| { + adjusted.then(|| self.adjust_horizontal_lines(paragraph, plan, width)) + }) + .collect(), + ); + *self.layout.line_adjustment.borrow_mut() = Some(LineAdjustmentCache { + version: self.content_version, + width, + adjustments: Rc::clone(&adjustments), + }); + Some(adjustments) + } + + fn adjust_horizontal_lines( + &self, + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, + width: f32, + ) -> HorizontalAdjustment { + let lay_out = |extra_sheds: &[Vec<(usize, f32)>]| { + let mut laid_out = self + .horizontal_measure_builder(paragraph, plan, extra_sheds) + .build(); + laid_out.layout(width); + laid_out + }; + let (sheds, laid_out) = if self.line_adjustment == LineAdjustment::PushInFirst { + horizontal_squeeze_sheds(paragraph, plan, width, &lay_out) + } else { + let sheds = vec![Vec::new(); plan.texts.len()]; + let laid_out = lay_out(&sheds); + (sheds, laid_out) + }; + let spacing = if paragraph.text_align() == skia::textlayout::TextAlign::Justify { + horizontal_justify_spacing(&laid_out, plan, width) + } else { + vec![Vec::new(); plan.texts.len()] + }; + HorizontalAdjustment { sheds, spacing } + } + + /// Left-aligned builder of one paragraph with plain fills, its plan's + /// sheds plus `extra_sheds`, for measuring line adjustment. + fn horizontal_measure_builder( + &self, + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, + extra_sheds: &[Vec<(usize, f32)>], + ) -> ParagraphBuilder { + let fonts = get_font_collection(); + let fallback_fonts = get_fallback_fonts(); + let mut paragraph_style = paragraph.paragraph_to_style(); + paragraph_style.set_apply_rounding_hack(false); + paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); + let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + for (span_index, ((span, text), sheds)) in paragraph + .children() + .iter() + .zip(&plan.texts) + .zip(&plan.sheds) + .enumerate() + { + let combined: Vec<(usize, f32)> = sheds + .iter() + .chain(extra_sheds.get(span_index).into_iter().flatten()) + .copied() + .collect(); + let text_style = span.to_style_with_paint( + &self.bounds(), + fallback_fonts, + false, + paragraph.line_height(), + None, + ); + builder.push_style(&text_style); + add_horizontal_span( + &mut builder, + span, + text, + &combined, + &plan.ruby_spacing.adjustments[span_index], + &text_style, + fonts, + ); + } + builder + } + + /// 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. + pub(crate) fn horizontal_plans(&self) -> Rc> { + if let Some(entry) = self.layout.horizontal.borrow().as_ref() { + if entry.version == self.content_version { + return Rc::clone(&entry.plans); + } + } + let plans: Rc> = Rc::new( + self.paragraphs + .iter() + .map(HorizontalParagraphPlan::new) + .collect(), + ); + *self.layout.horizontal.borrow_mut() = Some(HorizontalPlansCache { + version: self.content_version, + plans: Rc::clone(&plans), + }); + plans + } + + /// Writing mode applies to the whole shape: the first paragraph sets it. + pub fn is_vertical(&self) -> bool { + self.paragraphs + .first() + .is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl) + } + + /// Vertical writing paints from its own layout, not the cached + /// horizontal paragraphs. Horizontal ruby, warichu and emphasis marks + /// paint from the cached paragraphs like the base text. + pub fn can_paint_from_layout_cache(&self) -> bool { + !self.is_vertical() + } + pub fn grow_type(&self) -> GrowType { self.grow_type } + pub fn line_adjustment(&self) -> LineAdjustment { + self.line_adjustment + } + + pub fn set_line_adjustment(&mut self, line_adjustment: LineAdjustment) { + if self.line_adjustment != line_adjustment { + self.line_adjustment = line_adjustment; + self.content_version = self.content_version.wrapping_add(1); + } + } + pub fn set_grow_type(&mut self, grow_type: GrowType) { if self.grow_type != grow_type { self.grow_type = grow_type; @@ -642,6 +905,11 @@ impl TextContent { // AutoWidth paragraphs are laid out with f32::MAX, so line metrics // (line.left) reflect alignment within that huge width and are // unusable for tight bounds. Fall back to content_rect. + // Vertical writing takes its bounds from content_rect; the + // skparagraph line metrics below describe an unused horizontal layout. + if self.is_vertical() { + return self.vertical_extrect(selrect, valign); + } if self.grow_type() == GrowType::AutoWidth { return self.content_rect(selrect, valign); } @@ -724,7 +992,34 @@ impl TextContent { bounds } + /// Content rect of vertical text grown to the laid-out column block, which + /// overflows a fixed shape that is too narrow for its columns. + fn vertical_extrect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { + let mut rect = self.content_rect(selrect, valign); + let layout = self.vertical_layout(selrect); + let left = selrect.left() + + super::text_vertical::block_axis_offset(selrect.width(), layout.width, valign); + rect.join(Rect::from_xywh( + left, + selrect.top(), + layout.width, + layout.height, + )); + rect + } + pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { + // Vertical content anchors to the shape's right edge and always + // aligns to the top; vertical-align does not apply. + if self.is_vertical() { + let (width, height) = if self.grow_type() == GrowType::AutoWidth { + (self.size.width, self.size.height) + } else { + (selrect.width(), selrect.height()) + }; + return Rect::from_xywh(selrect.right() - width, selrect.y(), width, height); + } + let x = selrect.x(); let mut y = selrect.y(); @@ -756,10 +1051,31 @@ impl TextContent { self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y); } + /// Caret position for a selrect-local point. Vertical text wraps its + /// columns at the `selrect` height, as painting does; stored bounds can + /// be taller than a fixed shape. pub fn get_caret_position_from_shape_coords( &self, point: &Point, + selrect: &Rect, + vertical_align: VerticalAlign, ) -> Option { + // Vertical writing: resolve through the vertical pass. The content + // block is right-anchored. + if self.is_vertical() { + let layout = self.vertical_layout(selrect); + let cx = point.x + - super::text_vertical::block_axis_offset( + selrect.width(), + layout.width, + vertical_align, + ); + let (paragraph, offset) = super::text_vertical::caret_from_point(&layout, cx, point.y)?; + return Some(TextPositionWithAffinity::new_downstream_affinity( + paragraph, offset, + )); + } + let mut offset_y = 0.0; let layout_paragraphs = self.layout.paragraphs.iter().flatten(); @@ -782,12 +1098,24 @@ impl TextContent { // the paragraph's top-left. For multi-paragraph or wrapped text, each // paragraph has its own origin; subtract start_y so we pass paragraph-local coords. let para_pt = Point::new(point.x, point.y - start_y); + if let Some(paragraph) = self.paragraphs().get(paragraph_index) { + if let Some(original_position) = + horizontal_warichu_hit_test(paragraph, layout_paragraph, para_pt) + { + return Some(TextPositionWithAffinity::new_downstream_affinity( + paragraph_index, + original_position, + )); + } + } let position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); - if let Some(paragraph) = self.paragraphs().get(paragraph_index) { - // Skia reports UTF-16 code units, the model counts characters. - let offset = - paragraph.utf16_offset_to_char(position_with_affinity.position as usize); + if let Some(plan) = self.horizontal_plans().get(paragraph_index) { + // Skia reports builder-text UTF-16 offsets (transformed and + // kinsoku-shifted); the model counts source characters. + let offset = plan + .offsets + .builder_to_source(position_with_affinity.position as usize); return Some(TextPositionWithAffinity::new( position_with_affinity, @@ -825,9 +1153,11 @@ impl TextContent { point: &Point, view_matrix: &Matrix, shape_matrix: &Matrix, + selrect: &Rect, + vertical_align: VerticalAlign, ) -> Option { let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?; - self.get_caret_position_from_shape_coords(&shape_rel_point) + self.get_caret_position_from_shape_coords(&shape_rel_point, selrect, vertical_align) } /// Builds the ParagraphBuilders necessary to render @@ -911,15 +1241,62 @@ impl TextContent { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); + let plans = self.horizontal_plans(); + let adjustments = align_override + .is_none() + .then(|| self.horizontal_line_adjustments()) + .flatten(); - for paragraph in self.paragraphs() { + for (paragraph_index, (paragraph, plan)) in + self.paragraphs().iter().zip(plans.iter()).enumerate() + { + let adjustment = adjustments + .as_ref() + .and_then(|adjustments| adjustments.get(paragraph_index)) + .and_then(Option::as_ref); let mut paragraph_style = paragraph.paragraph_to_style(); + if self.adjusts_lines(paragraph, plan) { + // Line adjustment gives characters runs of their own; rounding + // each run up would wrap the adjusted lines. The measuring + // pass shares this style so both break alike. + paragraph_style.set_apply_rounding_hack(false); + } if let Some(align) = align_override { paragraph_style.set_text_align(align); + } else if adjustment.is_some() + && paragraph.text_align() == skia::textlayout::TextAlign::Justify + { + // The justify spacing already fills the lines. + paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); } let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; - for span in paragraph.children() { + for (span_index, (((span, text), plan_sheds), ruby_extra)) in paragraph + .children() + .iter() + .zip(&plan.texts) + .zip(&plan.sheds) + .zip(&plan.ruby_spacing.adjustments) + .enumerate() + { + let squeeze = adjustment.and_then(|adjustment| adjustment.sheds.get(span_index)); + let justify = adjustment.and_then(|adjustment| adjustment.spacing.get(span_index)); + let combined_sheds: Vec<(usize, f32)>; + let sheds: &[(usize, f32)] = match squeeze { + Some(squeeze) if !squeeze.is_empty() => { + combined_sheds = plan_sheds.iter().chain(squeeze).copied().collect(); + &combined_sheds + } + _ => plan_sheds, + }; + let combined: Vec<(usize, f32)>; + let extra: &[(usize, f32)] = match justify { + Some(justify) if !justify.is_empty() => { + combined = ruby_extra.iter().chain(justify).copied().collect(); + &combined + } + _ => ruby_extra, + }; let text_style = if let Some((layer, image_fill)) = opaque_image_layer { let mut style = span.to_style( &self.bounds(), @@ -977,12 +1354,11 @@ impl TextContent { fill_layer, ) }; - let text: String = span.apply_text_transform(); if !text.is_empty() { has_text = true; } builder.push_style(&text_style); - add_text_with_tabs(&mut builder, &text, span.font_size); + add_horizontal_span(&mut builder, span, text, sheds, extra, &text_style, fonts); } if !has_text { builder.add_text(" "); @@ -1217,21 +1593,51 @@ impl TextContent { self.size.set_size(selrect.width(), selrect.height()); - match self.grow_type() { - GrowType::AutoHeight => { - let result = self.text_layout_auto_height(); - self.layout_width = Some(result.2.width); - self.set_layout_from_result(result, selrect.width(), selrect.height()); + if self.is_vertical() { + // Vertical writing takes sizes from the vertical pass, so the + // horizontal skparagraph layout is skipped. Empty paragraph slots + // mark the layout as done. Auto-width fits both axes without + // wrapping. Auto-height wraps at the shape height and grows width + // as columns advance right-to-left. Fixed keeps both dimensions. + self.layout.set( + Vec::new(), + (0..self.paragraphs.len().max(1)) + .map(|_| Vec::new()) + .collect(), + ); + self.layout_width = Some(selrect.width()); + match self.grow_type() { + GrowType::AutoWidth => { + let layout = self.vertical_layout(&selrect); + self.size.width = layout.width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); + self.size.height = layout.height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); + self.size.max_width = self.size.width; + } + GrowType::AutoHeight => { + let layout = self.vertical_layout(&selrect); + self.size.width = layout.width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); + self.size.height = selrect.height(); + self.size.max_width = self.size.width; + } + GrowType::Fixed => {} } - GrowType::AutoWidth => { - let result = self.text_layout_auto_width(); - self.layout_width = Some(result.2.width); - self.set_layout_from_result(result, selrect.width(), selrect.height()); - } - GrowType::Fixed => { - let result = self.text_layout_fixed(); - self.layout_width = Some(result.2.width); - self.set_layout_from_result(result, selrect.width(), selrect.height()); + } else { + match self.grow_type() { + GrowType::AutoHeight => { + let result = self.text_layout_auto_height(); + self.layout_width = Some(result.2.width); + self.set_layout_from_result(result, selrect.width(), selrect.height()); + } + GrowType::AutoWidth => { + let result = self.text_layout_auto_width(); + self.layout_width = Some(result.2.width); + self.set_layout_from_result(result, selrect.width(), selrect.height()); + } + GrowType::Fixed => { + let result = self.text_layout_fixed(); + self.layout_width = Some(result.2.width); + self.set_layout_from_result(result, selrect.width(), selrect.height()); + } } } @@ -1343,6 +1749,19 @@ impl TextContent { let result = matrix.map_point((x_pos, y_pos)); + // Vertical writing: hit-test against the laid-out cells (absolute + // coordinates, right-anchored to the selrect). + if self.is_vertical() { + let layout = self.vertical_layout(&shape.selrect); + return super::text_vertical::intersects( + &layout, + &shape.selrect, + shape.vertical_align(), + result.x, + result.y, + ); + } + // Change coords to content space let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); @@ -1374,6 +1793,7 @@ impl Default for TextContent { paragraphs: vec![], bounds: Rect::default(), grow_type: GrowType::Fixed, + line_adjustment: LineAdjustment::default(), size: TextContentSize::default(), layout: TextContentLayout::new(), content_version: 0, @@ -1404,6 +1824,8 @@ pub struct Paragraph { text_direction: TextDirection, text_decoration: Option, text_transform: Option, + writing_mode: WritingMode, + text_orientation: TextOrientation, line_height: f32, letter_spacing: f32, children: Vec, @@ -1416,6 +1838,8 @@ impl Default for Paragraph { text_direction: TextDirection::LTR, text_decoration: None, text_transform: None, + writing_mode: WritingMode::default(), + text_orientation: TextOrientation::default(), line_height: 1.0, letter_spacing: 0.0, children: vec![], @@ -1439,12 +1863,30 @@ impl Paragraph { text_direction, text_decoration, text_transform, + writing_mode: WritingMode::default(), + text_orientation: TextOrientation::default(), line_height, letter_spacing, children, } } + pub fn writing_mode(&self) -> WritingMode { + self.writing_mode + } + + pub fn set_writing_mode(&mut self, writing_mode: WritingMode) { + self.writing_mode = writing_mode; + } + + pub fn text_orientation(&self) -> TextOrientation { + self.text_orientation + } + + pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) { + self.text_orientation = text_orientation; + } + pub fn children(&self) -> &[TextSpan] { &self.children } @@ -1485,8 +1927,8 @@ impl Paragraph { utf16 } - /// Translate a UTF-16 offset coming from Skia into a character offset. - /// An offset inside a character rounds up, so it never splits a glyph. + /// Inverse of `char_offset_to_utf16`. An offset inside a character rounds + /// up, so it never splits a glyph. pub fn utf16_offset_to_char(&self, utf16_offset: usize) -> usize { let (mut low, mut high) = (0, self.char_count()); while low < high { @@ -1557,6 +1999,15 @@ impl Paragraph { self.text_transform } + /// Span texts as fed to the paragraph builders (text-transform applied, + /// Japanese spacing normalized, kinsoku break suppressions inserted), + /// plus the map from original to builder-text UTF-16 offsets. Consumers + /// of laid-out offsets must translate through the map. Only paragraphs + /// with Japanese text or ruby are transformed. + pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) { + layout_span_texts(self) + } + pub fn paragraph_to_style(&self) -> ParagraphStyle { let mut style = ParagraphStyle::default(); @@ -1577,28 +2028,6 @@ impl Paragraph { } } -/// Capitalize the first letter of each word, preserving all original whitespace. -/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after -/// any non-letter character (whitespace, punctuation, digits, symbols). -fn capitalize_words(text: &str) -> String { - let mut result = String::with_capacity(text.len()); - let mut capitalize_next = true; - for c in text.chars() { - if c.is_alphabetic() { - if capitalize_next { - result.extend(c.to_uppercase()); - } else { - result.push(c); - } - capitalize_next = false; - } else { - result.push(c); - capitalize_next = true; - } - } - result -} - /// Add `text`, pushing every '\t' as a one em wide placeholder. pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { let tab = PlaceholderStyle::new( @@ -1617,25 +2046,96 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: } } -/// Filter control characters below U+0020, preserving tabs and line breaks. -/// Browser-dependent: Firefox drops them, others replace with space. -fn process_ignored_chars(text: &str, browser: u8) -> String { - text.chars() - .filter_map(|c| { - if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { - return Some(c); - } - if c < '\u{0020}' { - if browser == Browser::Firefox as u8 { - None - } else { - Some(' ') - } - } else { - Some(c) - } +/// Text after browser filtering and CSS text-transform, plus the source +/// UTF-16 range behind each output scalar. One source scalar can yield +/// several (`ß` uppercases to `SS`); the ranges let vertical layout wrap and +/// export those glyphs as one source unit. +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedTextTransform { + pub text: String, + pub(crate) source_ranges: Vec<(std::ops::Range, std::ops::Range)>, +} + +impl AppliedTextTransform { + pub fn source_utf16_range( + &self, + transformed: std::ops::Range, + ) -> std::ops::Range { + // Output ranges are contiguous and ascending: skip to the first one + // that ends past `transformed.start`. + let first = self + .source_ranges + .partition_point(|(output, _)| output.end <= transformed.start); + let mut ranges = self.source_ranges[first..] + .iter() + .take_while(|(output, _)| output.start < transformed.end) + .filter(|(output, _)| output.end > transformed.start) + .map(|(_, source)| source.clone()); + let Some(first) = ranges.next() else { + return 0..0; + }; + ranges.fold(first, |range, source| { + range.start.min(source.start)..range.end.max(source.end) }) - .collect() + } +} + +fn apply_text_transform_with_source_ranges( + text: &str, + browser: u8, + transform: Option, +) -> AppliedTextTransform { + let mut output = String::with_capacity(text.len()); + let mut source_ranges = Vec::new(); + let mut source_utf16 = 0usize; + let mut output_utf16 = 0usize; + let mut capitalize_next = true; + + for source_char in text.chars() { + let source_start = source_utf16; + source_utf16 += source_char.len_utf16(); + + // Control characters below U+0020 are filtered (Firefox drops them, + // other browsers show a space); tabs and line breaks are kept. + let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}') + || source_char >= '\u{0020}' + { + Some(source_char) + } else if browser == Browser::Firefox as u8 { + None + } else { + Some(' ') + }; + let Some(processed) = processed else { + continue; + }; + + let transformed: String = match transform { + Some(TextTransform::Uppercase) => processed.to_uppercase().collect(), + Some(TextTransform::Lowercase) => processed.to_lowercase().collect(), + Some(TextTransform::Capitalize) if processed.is_alphabetic() && capitalize_next => { + capitalize_next = false; + processed.to_uppercase().collect() + } + Some(TextTransform::Capitalize) => { + capitalize_next = !processed.is_alphabetic(); + processed.to_string() + } + None => processed.to_string(), + }; + + for transformed_char in transformed.chars() { + let transformed_start = output_utf16; + output_utf16 += transformed_char.len_utf16(); + source_ranges.push((transformed_start..output_utf16, source_start..source_utf16)); + output.push(transformed_char); + } + } + + AppliedTextTransform { + text: output, + source_ranges, + } } #[derive(Debug, PartialEq, Clone)] @@ -1650,11 +2150,56 @@ pub struct TextSpan { pub text_decoration: Option, pub text_transform: Option, pub text_direction: TextDirection, + pub text_orientation: TextOrientation, + pub text_combine_upright: TextCombineUpright, + /// Emphasis mark (圏点 / bouten) applied to each base character. + pub text_emphasis: TextEmphasis, + /// Ruby (furigana) annotation for this span; empty means no ruby. + pub ruby: String, + pub ruby_size: RubySize, + pub ruby_align: RubyAlign, + pub ruby_overhang: RubyOverhang, + pub ruby_side: RubySide, + /// Warichu (割注): two half-size lines stacked in one column position. + pub warichu: bool, + pub font_features: FontFeatures, + pub annotation_clearance: AnnotationClearance, pub fills: Vec, pub paragraph_position: u32, pub span_position: u32, } +impl Default for TextSpan { + fn default() -> Self { + Self { + text: String::default(), + font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size: 14.0, + line_height: 1.2, + letter_spacing: 0.0, + font_weight: 400, + font_variant_id: Uuid::nil(), + text_decoration: None, + text_transform: None, + text_direction: TextDirection::LTR, + text_orientation: TextOrientation::default(), + text_combine_upright: TextCombineUpright::default(), + text_emphasis: TextEmphasis::default(), + ruby: String::default(), + ruby_size: RubySize::default(), + ruby_align: RubyAlign::default(), + ruby_overhang: RubyOverhang::default(), + ruby_side: RubySide::default(), + warichu: false, + font_features: FontFeatures::default(), + annotation_clearance: AnnotationClearance::default(), + fills: vec![], + paragraph_position: u32::MAX, + span_position: u32::MAX, + } + } +} + impl TextSpan { /// Fill at `layer` counting from the bottom (`0` = last / bottommost fill). pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> { @@ -1691,8 +2236,7 @@ impl TextSpan { font_weight, font_variant_id, fills, - paragraph_position: u32::MAX, - span_position: u32::MAX, + ..Self::default() } } @@ -1705,6 +2249,60 @@ impl TextSpan { self.span_position = span; } + /// Ruby annotation text, without surrounding whitespace. + pub fn ruby_text(&self) -> &str { + self.ruby.trim() + } + + /// True when the span shows a reading. A warichu span shows none: the + /// note replaces the base text, so a reading has nothing to annotate. + pub fn has_ruby(&self) -> bool { + !self.warichu && !self.ruby_text().is_empty() + } + + pub fn ruby_font_size(&self) -> f32 { + self.font_size * self.ruby_size.scale() + } + + /// Warichu needs at least two characters to fill its two sub-lines. + pub fn is_warichu(&self) -> bool { + self.warichu && self.text.chars().count() >= 2 + } + + /// Room, in em of the span, that automatic annotation clearance adds to + /// the line height: the ruby at its size plus the emphasis marks. `None` + /// keeps the set line height, so annotations sit in the line gap. + pub fn annotation_room_em(&self) -> f32 { + if !self.annotation_clearance.is_auto() { + return 0.0; + } + let ruby = if self.has_ruby() { + self.ruby_size.scale() + } else { + 0.0 + }; + let emphasis = if self.text_emphasis.is_none() { + 0.0 + } else { + super::text_japanese::EMPHASIS_FONT_SCALE + }; + ruby + emphasis + } + + /// Automatic clearance stacks emphasis marks outside an over-side ruby. + pub fn stacks_emphasis_outside_ruby(&self) -> bool { + self.annotation_clearance.is_auto() && self.has_ruby() && self.ruby_side == RubySide::Over + } + + /// Cross-axis offset of the emphasis marks past a stacked ruby layer. + pub fn emphasis_ruby_offset(&self) -> f32 { + if self.stacks_emphasis_outside_ruby() { + self.ruby_font_size() + } else { + 0.0 + } + } + pub fn to_style( &self, content_bounds: &Rect, @@ -1748,7 +2346,8 @@ impl TextSpan { merge_fills(&self.fills, *content_bounds) }; - let max_line_height = f32::max(paragraph_line_height, self.line_height); + let max_line_height = + f32::max(paragraph_line_height, self.line_height) + self.annotation_room_em(); style.set_height(max_line_height); style.set_height_override(true); style.set_foreground_paint(&paint); @@ -1770,6 +2369,11 @@ impl TextSpan { style.set_font_families(&font_families); style.set_font_size(self.font_size); style.set_letter_spacing(self.letter_spacing); + match self.font_features { + FontFeatures::None => {} + FontFeatures::Palt => style.add_font_feature("palt", 1), + FontFeatures::Vpal => style.add_font_feature("vpal", 1), + } style.set_half_leading(true); style @@ -1813,13 +2417,20 @@ impl TextSpan { } pub fn transform_text(&self, text: &str) -> String { - let text = process_ignored_chars(text, crate::globals::current_browser()); - match self.text_transform { - Some(TextTransform::Uppercase) => text.to_uppercase(), - Some(TextTransform::Lowercase) => text.to_lowercase(), - Some(TextTransform::Capitalize) => capitalize_words(&text), - None => text, - } + apply_text_transform_with_source_ranges( + text, + crate::globals::current_browser(), + self.text_transform, + ) + .text + } + + pub fn apply_text_transform_with_source_ranges(&self) -> AppliedTextTransform { + apply_text_transform_with_source_ranges( + &self.text, + crate::globals::current_browser(), + self.text_transform, + ) } pub fn apply_text_transform(&self) -> String { @@ -1854,6 +2465,7 @@ pub struct PositionData { #[derive(Debug)] pub struct ParagraphLayout { pub paragraph: skia::textlayout::Paragraph, + pub source_paragraph: usize, pub x: f32, pub y: f32, pub decorations: Vec, @@ -1865,7 +2477,7 @@ pub struct TextLayoutData { pub paragraphs: Vec, } -fn direction_to_int(direction: TextDirection) -> u32 { +pub(crate) fn direction_to_int(direction: TextDirection) -> u32 { match direction { TextDirection::RTL => 0, TextDirection::LTR => 1, @@ -1886,6 +2498,7 @@ pub fn calculate_text_layout_data( let mut position_data: Vec = Vec::new(); let mut previous_line_height = text_content.normalized_line_height(); let text_paragraphs = text_content.paragraphs(); + let plans = text_content.horizontal_plans(); // 1. Build + layout each paragraph once, recording heights as we go. let mut paragraph_heights: Vec = Vec::new(); @@ -1925,12 +2538,14 @@ pub fn calculate_text_layout_data( for skia_paragraph in group_paragraphs.into_iter() { let decorations = text_paragraphs .get(i) - .map(|text_paragraph| { - decoration_segments(&skia_paragraph, text_paragraph, x, y_accum) + .zip(plans.get(i)) + .map(|(text_paragraph, plan)| { + decoration_segments(&skia_paragraph, text_paragraph, &plan.offsets, x, y_accum) }) .unwrap_or_default(); paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, + source_paragraph: i, x, y: y_accum, decorations, @@ -1941,57 +2556,19 @@ pub fn calculate_text_layout_data( // Calculate position data from paragraph_layouts if !skip_position_data { - for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() { - let current_y = para_layout.y; - let text_paragraph = text_paragraphs.get(paragraph_index); - if let Some(text_para) = text_paragraph { - let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; - let mut cur = 0; - for (span_index, span) in text_para.children().iter().enumerate() { - let text: String = span.apply_text_transform(); - let text_len = text.encode_utf16().count(); - span_ranges.push((cur, cur + text_len, span_index)); - cur += text_len; - } - for (start, end, span_index) in span_ranges { - let rects = para_layout.paragraph.get_rects_for_range( - start..end, - RectHeightStyle::Tight, - RectWidthStyle::Tight, - ); - - for textbox in rects { - let direction = textbox.direct; - let mut rect = textbox.rect; - let cy = rect.top + rect.height() / 2.0; - - // Get byte positions from Skia's transformed text layout - let start_pos = para_layout - .paragraph - .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) - .position as usize - - start; - - let end_pos = para_layout - .paragraph - .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) - .position as usize - - start; - - rect.offset((x, current_y)); - position_data.push(PositionData { - paragraph: paragraph_index as u32, - span: span_index as u32, - start_pos: start_pos as u32, - end_pos: end_pos as u32, - x: rect.x(), - y: rect.y(), - width: rect.width(), - height: rect.height(), - direction: direction_to_int(direction), - }); - } - } + for para_layout in ¶graph_layouts { + let paragraph_index = para_layout.source_paragraph; + if let (Some(text_para), Some(plan)) = ( + text_paragraphs.get(paragraph_index), + plans.get(paragraph_index), + ) { + let entries = HorizontalPositionEntries { + paragraph_index, + paragraph: text_para, + offsets: &plan.offsets, + layout: para_layout, + }; + position_data.extend(entries.collect()); } } } @@ -2002,11 +2579,152 @@ pub fn calculate_text_layout_data( } } +/// Position data of one laid-out horizontal paragraph: strips of the spans +/// outside warichu, one strip per warichu sub-line, and one box per +/// emphasis mark. +struct HorizontalPositionEntries<'a> { + paragraph_index: usize, + paragraph: &'a Paragraph, + /// Ranges in builder-text (kinsoku-shifted) space; the map translates + /// exported positions back to span offsets. + offsets: &'a HorizontalOffsets, + layout: &'a ParagraphLayout, +} + +impl HorizontalPositionEntries<'_> { + fn collect(&self) -> Vec { + let mut entries = Vec::new(); + // Tabs are placeholders too; this keys warichu boxes by span. + let warichu_rects = super::text_horizontal::horizontal_warichu_placeholders( + self.paragraph, + &self.layout.paragraph, + ); + for range in &self.offsets.ranges { + if range.warichu { + let rect = warichu_rects + .iter() + .find(|(span, _)| *span == range.span) + .map(|(_, rect)| *rect); + if let Some(rect) = rect { + entries.extend(self.warichu_entries(range, rect)); + } + } else { + entries.extend(self.span_entries(range)); + } + } + for placement in + horizontal_emphasis_placements(self.paragraph, self.offsets, &self.layout.paragraph) + { + if let Some(span) = self.paragraph.children().get(placement.span) { + entries.push(self.entry( + placement.span, + placement.range.clone(), + horizontal_emphasis_mark_box(span, &placement), + super::text_vertical::DIRECTION_EMPHASIS_MARK, + )); + } + } + entries + } + + /// An entry for `range` of `span`, with `rect` in the laid-out paragraph. + fn entry( + &self, + span: usize, + range: std::ops::Range, + mut rect: Rect, + direction: u32, + ) -> PositionData { + rect.offset((self.layout.x, self.layout.y)); + PositionData { + paragraph: self.paragraph_index as u32, + span: span as u32, + start_pos: range.start as u32, + end_pos: range.end as u32, + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + direction, + } + } + + /// One strip per warichu sub-line: the top half of the placeholder holds + /// the first line, the bottom half the second. + fn warichu_entries(&self, range: &HorizontalSpanRange, rect: Rect) -> Vec { + let Some(span) = self.paragraph.children().get(range.span) else { + return Vec::new(); + }; + let text = span.apply_text_transform(); + let split = warichu_text_lines(&text).0.encode_utf16().count(); + let end = range.source_end - range.source_start; + let half = rect.height() / 2.0; + let ltr = direction_to_int(TextDirection::LTR); + let top = Rect::from_xywh(rect.x(), rect.y(), rect.width(), half); + let bottom = Rect::from_xywh(rect.x(), rect.y() + half, rect.width(), half); + vec![ + self.entry(range.span, 0..split, top, ltr), + self.entry(range.span, split..end, bottom, ltr), + ] + } + + /// One strip per laid-out rect of a span, with its source offsets read + /// from the glyphs at the rect's edges. + fn span_entries(&self, range: &HorizontalSpanRange) -> Vec { + let laid_out = &self.layout.paragraph; + let to_span_offset = |builder_position: usize| { + let within = builder_position + .saturating_sub(range.builder_start) + .min(range.builder_end - range.builder_start); + self.offsets + .offset_map + .to_original(range.shifted_start + within) + - range.source_start + }; + laid_out + .get_rects_for_range( + range.builder_start..range.builder_end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .into_iter() + .map(|textbox| { + let rect = textbox.rect; + let cy = rect.top + rect.height() / 2.0; + let position_at = + |x: f32| laid_out.get_glyph_position_at_coordinate((x, cy)).position as usize; + let start_pos = to_span_offset(position_at(rect.left + 0.1)); + let end_pos = to_span_offset(position_at(rect.right - 0.1)); + self.entry( + range.span, + start_pos..end_pos, + rect, + direction_to_int(textbox.direct), + ) + }) + .collect() + } +} + pub fn calculate_position_data( shape: &Shape, text_content: &TextContent, skip_position_data: bool, ) -> Vec { + // Vertical writing generates position data from the vertical cells and + // needs no horizontal layout. + if text_content.is_vertical() { + if skip_position_data { + return Vec::new(); + } + let layout = text_content.vertical_layout(&shape.selrect); + return super::text_vertical::position_data( + &layout, + &shape.selrect, + shape.vertical_align(), + ); + } + let mut text_content = text_content.clone(); text_content.update_layout(shape.selrect); @@ -2027,6 +2745,19 @@ mod tests { use crate::globals::TestRenderResourcesGuard; use crate::render::RenderResources; + #[test] + fn a_warichu_span_shows_no_ruby_and_reserves_no_room_for_it() { + let span = TextSpan { + text: "割注".to_string(), + ruby: "よみ".to_string(), + warichu: true, + annotation_clearance: AnnotationClearance::Auto, + ..TextSpan::default() + }; + assert!(!span.has_ruby()); + assert_eq!(span.annotation_room_em(), 0.0); + } + #[test] fn vertical_align_top_keeps_the_content_at_the_origin() { assert_eq!(vertical_align_offset(200.0, 60.0, VerticalAlign::Top), 0.0); @@ -2060,100 +2791,107 @@ mod tests { ); } + fn capitalize(text: &str) -> String { + apply_text_transform_with_source_ranges( + text, + Browser::Chrome as u8, + Some(TextTransform::Capitalize), + ) + .text + } + + fn filter_ignored_chars(text: &str, browser: u8) -> String { + apply_text_transform_with_source_ranges(text, browser, None).text + } + #[test] fn capitalize_basic_words() { - assert_eq!(capitalize_words("hello world"), "Hello World"); + assert_eq!(capitalize("hello world"), "Hello World"); } #[test] fn capitalize_preserves_leading_whitespace() { - assert_eq!(capitalize_words(" hello"), " Hello"); + assert_eq!(capitalize(" hello"), " Hello"); } #[test] fn capitalize_preserves_trailing_whitespace() { - assert_eq!(capitalize_words("hello "), "Hello "); + assert_eq!(capitalize("hello "), "Hello "); } #[test] fn capitalize_preserves_multiple_spaces() { - assert_eq!(capitalize_words("hello world"), "Hello World"); + assert_eq!(capitalize("hello world"), "Hello World"); } #[test] fn capitalize_whitespace_only() { - assert_eq!(capitalize_words(" "), " "); - assert_eq!(capitalize_words(" "), " "); + assert_eq!(capitalize(" "), " "); + assert_eq!(capitalize(" "), " "); } #[test] fn capitalize_empty_string() { - assert_eq!(capitalize_words(""), ""); + assert_eq!(capitalize(""), ""); } #[test] fn capitalize_single_char() { - assert_eq!(capitalize_words("a"), "A"); + assert_eq!(capitalize("a"), "A"); } #[test] fn capitalize_already_uppercase() { - assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD"); + assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD"); } #[test] fn capitalize_preserves_tabs_and_newlines() { - assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld"); - assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld"); + assert_eq!(capitalize("hello\tworld"), "Hello\tWorld"); + assert_eq!(capitalize("hello\nworld"), "Hello\nWorld"); } #[test] fn capitalize_after_punctuation() { - assert_eq!(capitalize_words("(readonly)"), "(Readonly)"); - assert_eq!(capitalize_words("hello-world"), "Hello-World"); - assert_eq!(capitalize_words("one/two/three"), "One/Two/Three"); + assert_eq!(capitalize("(readonly)"), "(Readonly)"); + assert_eq!(capitalize("hello-world"), "Hello-World"); + assert_eq!(capitalize("one/two/three"), "One/Two/Three"); } #[test] fn capitalize_after_digits() { - assert_eq!(capitalize_words("item1name"), "Item1Name"); + assert_eq!(capitalize("item1name"), "Item1Name"); } #[test] - fn process_ignored_chars_preserves_spaces() { - assert_eq!(process_ignored_chars("hello world", 0), "hello world"); + fn ignored_chars_preserves_spaces() { + assert_eq!(filter_ignored_chars("hello world", 0), "hello world"); } #[test] - fn process_ignored_chars_preserves_line_breaks() { - assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld"); - assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); + fn ignored_chars_preserves_line_breaks() { + assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld"); + assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld"); } #[test] - fn process_ignored_chars_preserves_tabs() { - assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); + fn ignored_chars_preserves_tabs() { + assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld"); assert_eq!( - process_ignored_chars("hello\tworld", Browser::Firefox as u8), + filter_ignored_chars("hello\tworld", Browser::Firefox as u8), "hello\tworld" ); } #[test] - fn process_ignored_chars_replaces_control_chars_chrome() { + fn ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox - assert_eq!( - process_ignored_chars("a\x01b", Browser::Chrome as u8), - "a b" - ); + assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b"); } #[test] - fn process_ignored_chars_removes_control_chars_firefox() { - assert_eq!( - process_ignored_chars("a\x01b", Browser::Firefox as u8), - "ab" - ); + fn ignored_chars_removes_control_chars_firefox() { + assert_eq!(filter_ignored_chars("a\x01b", Browser::Firefox as u8), "ab"); } fn test_paragraph(texts: &[&str]) -> Paragraph { @@ -2192,7 +2930,11 @@ mod tests { let para = test_paragraph(&["Añadir"]); for offset in 0..=6 { assert_eq!(para.char_offset_to_utf16(offset), offset); - assert_eq!(para.utf16_offset_to_char(offset), offset); + assert_eq!( + HorizontalOffsets::new(¶).source_to_builder(offset), + offset + ); + assert_eq!(horizontal_builder_to_source(¶, offset), offset); } } @@ -2205,16 +2947,30 @@ mod tests { assert_eq!(para.char_offset_to_utf16(2), 3); assert_eq!(para.char_offset_to_utf16(3), 4); - assert_eq!(para.utf16_offset_to_char(0), 0); - assert_eq!(para.utf16_offset_to_char(1), 1); - assert_eq!(para.utf16_offset_to_char(3), 2); - assert_eq!(para.utf16_offset_to_char(4), 3); + assert_eq!(horizontal_builder_to_source(¶, 0), 0); + assert_eq!(horizontal_builder_to_source(¶, 1), 1); + assert_eq!(horizontal_builder_to_source(¶, 3), 2); + assert_eq!(horizontal_builder_to_source(¶, 4), 3); } #[test] - fn utf16_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() { - let para = test_paragraph(&["a😀b"]); + fn utf16_offsets_map_back_to_char_offsets() { + let para = test_paragraph(&["a", "😀b"]); + assert_eq!(para.utf16_offset_to_char(0), 0); + assert_eq!(para.utf16_offset_to_char(1), 1); assert_eq!(para.utf16_offset_to_char(2), 2); + assert_eq!(para.utf16_offset_to_char(3), 2); + assert_eq!(para.utf16_offset_to_char(4), 3); + + let mut para = test_paragraph(&["Straße"]); + para.children_mut()[0].text_transform = Some(TextTransform::Uppercase); + assert_eq!(para.utf16_offset_to_char(7), 6); + } + + #[test] + fn builder_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() { + let para = test_paragraph(&["a😀b"]); + assert_eq!(horizontal_builder_to_source(¶, 2), 2); } #[test] @@ -2226,7 +2982,10 @@ mod tests { assert_eq!(para.char_offset_to_utf16(4), 4); assert_eq!(para.char_offset_to_utf16(6), 7); assert_eq!(para.char_offset_to_utf16(5), 6); - assert_eq!(para.utf16_offset_to_char(7), 6); + assert_eq!(HorizontalOffsets::new(¶).source_to_builder(4), 4); + assert_eq!(HorizontalOffsets::new(¶).source_to_builder(5), 6); + assert_eq!(HorizontalOffsets::new(¶).source_to_builder(6), 7); + assert_eq!(horizontal_builder_to_source(¶, 7), 6); } #[test] @@ -2250,6 +3009,18 @@ mod tests { assert_eq!(para.prev_grapheme_offset(3), 1); } + #[test] + fn builder_range_covers_the_whole_glyph() { + let para = test_paragraph(&["a😀b"]); + let len_at = |offset| { + HorizontalOffsets::new(¶).source_to_builder(offset + 1) + - HorizontalOffsets::new(¶).source_to_builder(offset) + }; + assert_eq!(len_at(0), 1); + assert_eq!(len_at(1), 2); + assert_eq!(len_at(2), 1); + } + fn sample_text_content() -> TextContent { let bounds = Rect::from_xywh(0.0, 0.0, 200.0, 100.0); let mut content = TextContent::new(bounds, GrowType::Fixed); @@ -2518,4 +3289,20 @@ mod tests { layout.clear(); assert!(layout.needs_update()); } + + #[test] + fn transformed_text_maps_expanded_scalars_to_their_source_range() { + let transformed = apply_text_transform_with_source_ranges( + "AßB", + Browser::Chrome as u8, + Some(TextTransform::Uppercase), + ); + + assert_eq!(transformed.text, "ASSB"); + assert_eq!(transformed.source_utf16_range(0..1), 0..1); + assert_eq!(transformed.source_utf16_range(1..2), 1..2); + assert_eq!(transformed.source_utf16_range(2..3), 1..2); + assert_eq!(transformed.source_utf16_range(1..3), 1..2); + assert_eq!(transformed.source_utf16_range(3..4), 2..3); + } } diff --git a/render-wasm/src/shapes/text_horizontal/emphasis.rs b/render-wasm/src/shapes/text_horizontal/emphasis.rs new file mode 100644 index 0000000000..90f2eea7bb --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/emphasis.rs @@ -0,0 +1,143 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct HorizontalEmphasisPlacement { + pub(crate) span: usize, + /// UTF-16 range of the base character in the span's transformed text. + pub(crate) range: std::ops::Range, + pub(crate) mark: char, + /// Base character rect in the laid-out paragraph. + pub(crate) rect: skia::Rect, +} + +/// Place one emphasis mark above each eligible character, from the laid-out +/// rect of each character so marks follow SkParagraph's wrapping and bidi. +pub(crate) fn horizontal_emphasis_placements( + paragraph: &Paragraph, + offsets: &HorizontalOffsets, + laid_out: &skia::textlayout::Paragraph, +) -> Vec { + let offset_map = &offsets.offset_map; + let mut placements = Vec::new(); + + for range in offsets.ranges.iter().filter(|range| !range.warichu) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let transformed = span.apply_text_transform(); + let mut local_utf16 = 0usize; + for character in transformed.chars() { + let next_utf16 = local_utf16 + character.len_utf16(); + if emphasis_char_allowed(character) { + let shifted_start = offset_map.to_shifted(range.source_start + local_utf16); + let shifted_end = offset_map.to_shifted(range.source_start + next_utf16); + let builder_start = + range.builder_start + shifted_start.saturating_sub(range.shifted_start); + let builder_end = + range.builder_start + shifted_end.saturating_sub(range.shifted_start); + let scalar_rect = laid_out + .get_rects_for_range( + builder_start..builder_end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .into_iter() + .map(|textbox| textbox.rect) + .reduce(|mut rect, next| { + rect.join(next); + rect + }); + if let Some(rect) = scalar_rect { + placements.push(HorizontalEmphasisPlacement { + span: range.span, + range: local_utf16..next_utf16, + mark, + rect, + }); + } + } + local_utf16 = next_utf16; + } + } + placements +} + +/// Em box of a horizontal emphasis mark in the laid-out paragraph: centred +/// over its base character, outside any stacked ruby layer. +pub(crate) fn horizontal_emphasis_mark_box( + span: &TextSpan, + placement: &HorizontalEmphasisPlacement, +) -> skia::Rect { + let size = span.font_size * EMPHASIS_FONT_SCALE; + let bottom = horizontal_annotation_over_top(placement.rect, span.font_size) + - span.emphasis_ruby_offset(); + skia::Rect::from_xywh( + placement.rect.center_x() - size / 2.0, + bottom - size, + size, + size, + ) +} + +/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs. +/// Draw-only: the base paragraph keeps its own metrics. +pub(crate) fn paint_horizontal_emphasis( + canvas: &skia::Canvas, + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let offsets = &plan.offsets; + let placements = horizontal_emphasis_placements(paragraph, offsets, laid_out); + for range in offsets.ranges.iter().filter(|range| !range.warichu) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let Some(mut style) = horizontal_span_style(laid_out, range) else { + continue; + }; + style.set_font_size(span.font_size * EMPHASIS_FONT_SCALE); + style.set_height(1.0); + style.set_height_override(true); + style.set_letter_spacing(0.0); + let mark_paragraph = mini_paragraph(&mark.to_string(), &style, get_font_collection()); + let mark_ink = mark_paragraph + .get_rects_for_range( + 0..mark.len_utf16(), + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .into_iter() + .map(|textbox| textbox.rect) + .reduce(|mut rect, next| { + rect.join(next); + rect + }); + let mark_width = mark_ink + .as_ref() + .map(|rect| rect.width()) + .unwrap_or_else(|| mark_paragraph.longest_line()); + let mark_ink_bottom = mark_ink + .as_ref() + .map(|rect| rect.bottom()) + .unwrap_or_else(|| mark_paragraph.height()); + for placement in placements + .iter() + .filter(|placement| placement.span == range.span && placement.mark == mark) + { + let mark_x = x + placement.rect.center_x() - mark_width / 2.0; + let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size) + - mark_ink_bottom + - span.emphasis_ruby_offset(); + mark_paragraph.paint(canvas, (mark_x, mark_y)); + } + } +} diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs new file mode 100644 index 0000000000..e2ee5b7433 --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -0,0 +1,244 @@ +// Justification of horizontal Japanese text (JLREQ §3.8). SkParagraph only +// expands around ideographs, which bunches the space next to kanji; this pass +// spreads each line's slack evenly between its characters instead. + +use super::*; + +/// Slack kept back from each justified line so float noise never wraps it. +const JUSTIFY_EPSILON: f32 = 0.05; + +/// True for a character that belongs to a Western word: no space opens +/// between two of them. +fn is_word_char(ch: char) -> bool { + !ch.is_whitespace() && !is_japanese_text_char(ch) && !kinsoku::is_inserted_zero_width(ch) +} + +/// Per-span letter-spacing (char index in the layout text, amount) that +/// justifies `laid_out`, the paragraph of `plan` laid out unjustified at +/// `width`. Every line but the last spreads its slack evenly over the gaps +/// between its characters, skipping gaps inside a Western word and the +/// zero-width word joiners; warichu notes take none. +pub(crate) fn horizontal_justify_spacing( + laid_out: &skia::textlayout::Paragraph, + plan: &HorizontalParagraphPlan, + width: f32, +) -> Vec> { + let mut spacing: Vec> = vec![Vec::new(); plan.texts.len()]; + // (builder UTF-16 offset, span, char index, char) of every character. + let mut chars: Vec<(usize, usize, usize, char)> = Vec::new(); + for range in &plan.offsets.ranges { + if range.warichu { + continue; + } + let Some(text) = plan.texts.get(range.span) else { + continue; + }; + let mut utf16 = range.builder_start; + for (index, ch) in text.chars().enumerate() { + if !kinsoku::is_inserted_zero_width(ch) { + chars.push((utf16, range.span, index, ch)); + } + utf16 += ch.len_utf16(); + } + } + let lines = laid_out.get_line_metrics(); + let Some((_, justified)) = lines.split_last() else { + return spacing; + }; + for line in justified { + let slack = width - line.width as f32 - JUSTIFY_EPSILON; + if slack <= 0.0 { + continue; + } + let in_line: Vec<&(usize, usize, usize, char)> = chars + .iter() + .filter(|(utf16, ..)| { + *utf16 >= line.start_index && *utf16 < line.end_excluding_whitespaces + }) + .collect(); + let gaps: Vec<&(usize, usize, usize, char)> = in_line + .windows(2) + .filter(|pair| !(is_word_char(pair[0].3) && is_word_char(pair[1].3))) + .map(|pair| pair[0]) + .collect(); + if gaps.is_empty() { + continue; + } + let share = slack / gaps.len() as f32; + for (_, span, index, _) in gaps { + spacing[*span].push((*index, share)); + } + } + spacing +} + +/// 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; + +/// One real character of a horizontal paragraph in builder order. +struct LineChar { + /// UTF-16 offset in the builder text. + utf16: usize, + span: usize, + /// Char index in the span's layout text. + index: usize, + ch: char, + class: Option, + font_size: f32, + /// A WORD JOINER before it forbids a break. + joined: bool, +} + +fn line_chars(paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> Vec { + let spans = paragraph.children(); + let mut chars = Vec::new(); + for range in &plan.offsets.ranges { + let (Some(span), Some(text)) = (spans.get(range.span), plan.texts.get(range.span)) else { + continue; + }; + if range.warichu { + continue; + } + let proportional = span.font_features == FontFeatures::Palt; + let mut utf16 = range.builder_start; + let mut joined = false; + for (index, ch) in text.chars().enumerate() { + if ch == kinsoku::WORD_JOINER { + joined = true; + } else if !kinsoku::is_inserted_zero_width(ch) { + chars.push(LineChar { + utf16, + span: range.span, + index, + ch, + class: aki_class(ch, span.has_ruby(), proportional), + font_size: span.font_size, + joined, + }); + joined = false; + } + utf16 += ch.len_utf16(); + } + } + chars +} + +/// Chars `[start, end)` that start a line at `start` and move as one: a +/// word joiner or a Western word keeps the next character with them. +fn line_start_unit(chars: &[LineChar], start: usize) -> std::ops::Range { + let mut end = start + 1; + while end < chars.len() + && (chars[end].joined || (is_word_char(chars[end - 1].ch) && is_word_char(chars[end].ch))) + { + end += 1; + } + start..end +} + +/// Squeezable spacing (char, priority, px) after each char of `range`: what +/// the JLREQ pair table lets oikomi take between it and the next char. +fn squeeze_capacities(chars: &[LineChar], range: std::ops::Range) -> Vec<(usize, u8, f32)> { + range + .filter(|at| at + 1 < chars.len()) + .filter_map(|at| { + let (before, after) = (&chars[at], &chars[at + 1]); + let rule = pair_rule(before.class?, after.class?); + let em = before.font_size.min(after.font_size); + let capacity = (rule.preferred_em - rule.minimum_em) * em; + (rule.shrink_priority > 0 && capacity > 0.0).then_some(( + at, + rule.shrink_priority, + capacity, + )) + }) + .collect() +} + +/// Per-span sheds (char index, amount) that squeeze lines of a horizontal +/// Japanese paragraph to keep the character that would start the next line +/// (JLREQ §3.8 oikomi). Each line, in order, takes the unit starting the next +/// line when the pair table's squeezable aki on the line covers the +/// overflow, reduced in priority order and equally within a priority. A +/// trailing closing bracket or comma pulled to the line end sheds its aki +/// there. `lay_out` lays the paragraph out with extra sheds; returns the +/// sheds and the final layout. +pub(crate) fn horizontal_squeeze_sheds( + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, + width: f32, + lay_out: &LayOutWithSheds, +) -> (Vec>, skia::textlayout::Paragraph) { + let chars = line_chars(paragraph, plan); + let mut sheds: Vec> = vec![Vec::new(); plan.texts.len()]; + let mut laid_out = lay_out(&sheds); + let mut line_index = 0usize; + for _ in 0..chars.len() + 1 { + let lines = laid_out.get_line_metrics(); + if line_index + 1 >= lines.len() { + break; + } + let (line, next) = (&lines[line_index], &lines[line_index + 1]); + let Some(unit_start) = chars.iter().position(|c| c.utf16 >= next.start_index) else { + break; + }; + let unit = line_start_unit(&chars, unit_start); + let unit_end_utf16 = chars.get(unit.end).map_or(next.end_index, |c| c.utf16); + let unit_width = laid_out + .get_rects_for_range( + chars[unit.start].utf16..unit_end_utf16, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ) + .iter() + .map(|rect| rect.rect.width()) + .sum::(); + let last = &chars[unit.end - 1]; + let trailing_aki = match last.class { + Some(JapaneseClass::ClosingBracket | JapaneseClass::Comma) => last.font_size / 2.0, + _ => 0.0, + }; + let needed = line.width as f32 + unit_width - trailing_aki - width + JUSTIFY_EPSILON; + let line_chars_end = unit.start; + let line_chars_start = chars + .iter() + .position(|c| c.utf16 >= line.start_index) + .unwrap_or(line_chars_end); + let capacities = squeeze_capacities(&chars, line_chars_start..line_chars_end); + let total: f32 = capacities.iter().map(|(_, _, cap)| cap).sum(); + if needed <= 0.0 || total < needed { + line_index += 1; + continue; + } + let mut candidate = sheds.clone(); + let mut remaining = needed; + for priority in 1..=5 { + let caps: Vec<(usize, f32)> = capacities + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(at, _, cap)| (*at, *cap)) + .collect(); + for (at, amount) in capped_equal_allocations(&caps, remaining) { + candidate[chars[at].span].push((chars[at].index, amount)); + remaining -= amount; + } + if remaining <= 0.0 { + break; + } + } + if trailing_aki > 0.0 { + candidate[last.span].push((last.index, trailing_aki)); + } + let squeezed = lay_out(&candidate); + let kept = squeezed + .get_line_metrics() + .get(line_index) + .is_some_and(|squeezed_line| squeezed_line.end_index >= unit_end_utf16); + if kept { + sheds = candidate; + laid_out = squeezed; + } + line_index += 1; + } + (sheds, laid_out) +} diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs new file mode 100644 index 0000000000..d6c702e9ee --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -0,0 +1,265 @@ +// Horizontal Japanese layout on top of SkParagraph: the kinsoku layout +// texts, punctuation aki, offset maps between source, layout and builder +// text, and the ruby, emphasis and warichu painters. + +mod emphasis; +mod justify; +mod offsets; +mod ruby; +mod warichu; + +#[cfg(test)] +mod tests; + +pub(crate) use emphasis::*; +pub(crate) use justify::*; +pub(crate) use offsets::*; +pub(crate) use ruby::*; +pub(crate) use warichu::*; + +use super::text::{add_text_with_tabs, Paragraph, TextContent, TextSpan}; +use super::text_japanese::*; +use super::text_vertical::{ + is_upright_char, shape_segment_with_fallbacks, single_glyph_blob, span_font_families, ShapedRun, +}; +use crate::globals::get_resources; +use crate::math::Point; +use crate::shapes::japanese::{ + aki_class, is_japanese_text_char, pair_rule, punctuation_aki_sheds, AkiShed, JapaneseClass, +}; +use crate::shapes::{kinsoku, merge_fills}; +use crate::utils::{get_fallback_fonts, get_font_collection}; +use skia_safe::{ + self as skia, + textlayout::{ + ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, + RectWidthStyle, TextBaseline, + }, + Canvas, Contains, Font, FontMgr, GlyphId, +}; + +/// True when a paragraph with layout texts `texts` needs the Japanese passes: +/// it has ruby or a Japanese character. +pub(crate) fn uses_japanese_layout(paragraph: &Paragraph, texts: &[String]) -> bool { + paragraph.children().iter().any(TextSpan::has_ruby) + || texts + .iter() + .any(|text| text.chars().any(is_japanese_text_char)) +} + +pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku::OffsetMap) { + let texts: Vec = paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform) + .collect(); + // Text without Japanese characters or ruby keeps plain Skia layout. + if uses_japanese_layout(paragraph, &texts) { + let ruby_breaks: Vec>> = paragraph + .children() + .iter() + .map(|span| span.has_ruby().then(Vec::new)) + .collect(); + if let Some((shifted, map)) = + kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks) + { + return (shifted, map); + } + } + (texts, kinsoku::OffsetMap::default()) +} + +/// (char index, amount) per span of the layout `texts` for the characters +/// whose advance loses a half-em to `punctuation_aki_sheds`: a closing mark +/// sheds half of its own em, and the character before an opening bracket +/// gives up half of the bracket's em. Inserted word joiners are transparent; +/// `palt` spans set punctuation proportionally. +pub(crate) fn horizontal_aki_sheds( + paragraph: &Paragraph, + texts: &[String], +) -> Vec> { + let spans = paragraph.children(); + let half_em = |span: usize| spans.get(span).map_or(0.0, |span| span.font_size * 0.5); + // (span, char index in the span text) of every real character. + let mut chars: Vec<(usize, usize)> = Vec::new(); + let mut classes: Vec> = Vec::new(); + for (span_index, text) in texts.iter().enumerate() { + let span = spans.get(span_index); + let ruby_base = span.is_some_and(TextSpan::has_ruby); + let proportional = span.is_some_and(|span| span.font_features == FontFeatures::Palt); + for (index, ch) in text.chars().enumerate() { + if kinsoku::is_inserted_zero_width(ch) { + continue; + } + chars.push((span_index, index)); + classes.push(aki_class(ch, ruby_base, proportional)); + } + } + let mut sheds: Vec> = vec![Vec::new(); texts.len()]; + for (at, shed) in punctuation_aki_sheds(&classes) { + let (span, amount_span, index) = match shed { + AkiShed::Trailing => (chars[at].0, chars[at].0, chars[at].1), + AkiShed::Leading => (chars[at - 1].0, chars[at].0, chars[at - 1].1), + }; + sheds[span].push((index, half_em(amount_span))); + } + sheds +} + +/// 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`) +/// set narrower by their amounts; `extra` (from `horizontal_ruby_spacing`) +/// adds letter-spacing to characters. +pub(crate) fn add_horizontal_span( + builder: &mut ParagraphBuilder, + span: &TextSpan, + builder_text: &str, + sheds: &[(usize, f32)], + extra: &[(usize, f32)], + text_style: &skia::textlayout::TextStyle, + fonts: &skia::textlayout::FontCollection, +) { + if span.is_warichu() { + let text = span.apply_text_transform(); + let (first, second) = warichu_text_lines(&text); + let mut mini_style = text_style.clone(); + mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE); + mini_style.set_height(1.0); + mini_style.set_height_override(true); + mini_style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); + let measure = |line: &str| mini_paragraph(line, &mini_style, fonts).longest_line(); + let width = measure(first).max(measure(second)).max(0.01); + let height = span.font_size.max(0.01); + builder.add_placeholder(&PlaceholderStyle::new( + width, + height, + PlaceholderAlignment::Middle, + TextBaseline::Alphabetic, + height, + )); + // SkParagraph does not expose a placeholder's TextStyle through line + // metrics. A near-zero, inkless NBSP carries the span style for the + // paint pass; the zero-width space after it allows a line break after + // the atomic placeholder. + let mut anchor_style = text_style.clone(); + anchor_style.set_font_size(0.01); + anchor_style.set_height(0.01); + anchor_style.set_height_override(true); + anchor_style.set_letter_spacing(0.0); + builder.push_style(&anchor_style); + builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string()); + builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string()); + } else { + add_text_with_sheds(builder, span, builder_text, sheds, extra, text_style); + } +} + +/// Add `text`, adjusting the letter-spacing of single characters so the +/// builder text stays unchanged: those at `sheds` lose their amount and +/// those in `extra` take its added spacing. Skia tracks every cluster, +/// including the zero-width word joiners the kinsoku pass inserts, so those +/// take no letter-spacing. +fn add_text_with_sheds( + builder: &mut ParagraphBuilder, + span: &TextSpan, + text: &str, + sheds: &[(usize, f32)], + extra: &[(usize, f32)], + text_style: &skia::textlayout::TextStyle, +) { + let tracking = text_style.letter_spacing(); + 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 letter_spacing = if kinsoku::is_inserted_zero_width(ch) { + 0.0 + } else { + tracking + added - shed + }; + if letter_spacing == tracking { + continue; + } + let mut style = text_style.clone(); + style.set_letter_spacing(letter_spacing); + let end = byte + ch.len_utf8(); + add_text_with_tabs(builder, &text[piece_start..byte], span.font_size); + builder.push_style(&style); + builder.add_text(&text[byte..end]); + builder.pop(); + piece_start = end; + } + add_text_with_tabs(builder, &text[piece_start..], span.font_size); +} + +/// The horizontal passes of one paragraph that layout, painting, position +/// data and the editor share: kinsoku layout texts, offsets, punctuation aki +/// sheds and long-ruby spacing. `TextContent::horizontal_plans` caches them. +pub(crate) struct HorizontalParagraphPlan { + pub(crate) texts: Vec, + pub(crate) offsets: HorizontalOffsets, + pub(crate) sheds: Vec>, + pub(crate) ruby_spacing: HorizontalRubySpacing, +} + +impl HorizontalParagraphPlan { + pub(crate) fn new(paragraph: &Paragraph) -> Self { + let (texts, offset_map) = paragraph.layout_span_texts(); + 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); + Self { + texts, + offsets, + sheds, + ruby_spacing, + } + } +} + +/// Single-line paragraph for an annotation run. Laid out unbounded: a +/// re-layout at its own `longest_line()` can wrap the last glyph. +fn mini_paragraph( + text: &str, + style: &skia::textlayout::TextStyle, + fonts: &skia::textlayout::FontCollection, +) -> skia::textlayout::Paragraph { + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), fonts); + builder.push_style(style); + builder.add_text(text); + let mut paragraph = builder.build(); + paragraph.layout(f32::MAX); + paragraph +} + +pub(crate) fn horizontal_span_style( + laid_out: &skia::textlayout::Paragraph, + range: &HorizontalSpanRange, +) -> Option { + // Indexed style metrics use builder UTF-8 byte positions, unlike the + // UTF-16 offsets of glyph and line ranges. + let anchor = range.style_anchor_start..range.style_anchor_start + 1; + laid_out.get_line_metrics().iter().find_map(|line| { + line.get_style_metrics(anchor.clone()) + .into_iter() + .next() + .map(|(_, metric)| metric.text_style.clone()) + }) +} + +/// Top of the horizontal base em inside SkParagraph's typographic rect, less +/// a 3/14-em clearance (12 px at 56 px). The tight rect includes ascender-side +/// padding, so anchoring an over annotation to its top leaves a gap above CJK +/// ink; the em is bottom-aligned to the rect. +pub(crate) fn horizontal_annotation_over_top(rect: skia::Rect, font_size: f32) -> f32 { + let font_size = font_size.max(0.0); + rect.top.max(rect.bottom - font_size) - font_size * (3.0 / 14.0) +} diff --git a/render-wasm/src/shapes/text_horizontal/offsets.rs b/render-wasm/src/shapes/text_horizontal/offsets.rs new file mode 100644 index 0000000000..0bd17537ae --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/offsets.rs @@ -0,0 +1,222 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct HorizontalSpanRange { + pub span: usize, + pub builder_start: usize, + pub builder_end: usize, + pub shifted_start: usize, + pub source_start: usize, + pub source_end: usize, + pub warichu: bool, + pub style_anchor_start: usize, +} + +/// Offset maps of one horizontal paragraph between source characters, the +/// kinsoku-adjusted layout text and the builder text. Building it runs the +/// kinsoku pass; reuse it for every lookup in the paragraph. +pub(crate) struct HorizontalOffsets { + /// Map between the transformed text and the kinsoku-adjusted text. + pub(crate) offset_map: kinsoku::OffsetMap, + pub(crate) ranges: Vec, + /// Transformed-text UTF-16 offset of every source character boundary. + boundaries: Vec, +} + +impl HorizontalOffsets { + pub(crate) fn new(paragraph: &Paragraph) -> Self { + let (span_texts, offset_map) = paragraph.layout_span_texts(); + Self::from_layout_texts(paragraph, &span_texts, offset_map) + } + + /// Offsets for layout `span_texts` already built by `layout_span_texts`. + pub(super) fn from_layout_texts( + paragraph: &Paragraph, + span_texts: &[String], + offset_map: kinsoku::OffsetMap, + ) -> Self { + let ranges = span_ranges(paragraph, span_texts, &offset_map); + Self { + offset_map, + ranges, + boundaries: source_char_boundaries(paragraph), + } + } + + /// Builder-text UTF-16 offset of a paragraph source character offset. + pub(crate) fn source_to_builder(&self, source_char_offset: usize) -> usize { + let boundaries = &self.boundaries; + let source_utf16 = boundaries + .get(source_char_offset) + .copied() + .unwrap_or_else(|| boundaries.last().copied().unwrap_or(0)); + let Some(range) = self + .ranges + .iter() + .find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end) + else { + return self + .ranges + .last() + .map(|range| range.builder_end) + .unwrap_or(0); + }; + if range.warichu { + return if source_utf16 >= range.source_end { + range.builder_end + } else { + range.builder_start + }; + } + let shifted = self.offset_map.to_shifted(source_utf16); + range.builder_start + shifted.saturating_sub(range.shifted_start) + } + + /// Paragraph source character offset of a builder-text UTF-16 offset. + pub(crate) fn builder_to_source(&self, builder_offset: usize) -> usize { + let source_utf16 = self + .ranges + .iter() + .find(|range| { + builder_offset >= range.builder_start && builder_offset <= range.builder_end + }) + .map(|range| { + if range.warichu { + if builder_offset > range.builder_start { + range.source_end + } else { + range.source_start + } + } else { + let within = builder_offset + .saturating_sub(range.builder_start) + .min(range.builder_end - range.builder_start); + self.offset_map.to_original(range.shifted_start + within) + } + }) + .unwrap_or_else(|| { + self.ranges + .last() + .map(|range| range.source_end) + .unwrap_or(0) + }); + let boundaries = &self.boundaries; + boundaries + .partition_point(|boundary| *boundary < source_utf16) + .min(boundaries.len().saturating_sub(1)) + } + + /// Builder-text ranges of the selected source characters + /// `[source_start, source_end)` outside warichu spans. + pub(crate) fn normal_selection_ranges( + &self, + paragraph: &Paragraph, + source_start: usize, + source_end: usize, + ) -> Vec> { + let mut span_start = 0usize; + paragraph + .children() + .iter() + .filter_map(|span| { + let span_end = span_start + span.text.chars().count(); + let selected_start = source_start.max(span_start); + let selected_end = source_end.min(span_end); + span_start = span_end; + if span.is_warichu() || selected_start >= selected_end { + return None; + } + Some(self.source_to_builder(selected_start)..self.source_to_builder(selected_end)) + }) + .collect() + } +} + +#[cfg(test)] +pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec { + HorizontalOffsets::new(paragraph).ranges +} + +#[cfg(test)] +pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize { + HorizontalOffsets::new(paragraph).builder_to_source(builder_offset) +} + +/// Ranges shared by layout, position data and editor mapping. `shifted_*` +/// addresses the kinsoku-adjusted text; `builder_*` addresses the builder +/// text, where each warichu span collapses to one placeholder. +fn span_ranges( + paragraph: &Paragraph, + span_texts: &[String], + offset_map: &kinsoku::OffsetMap, +) -> Vec { + let mut builder_cursor = 0usize; + let mut builder_byte_cursor = 0usize; + let mut shifted_cursor = 0usize; + paragraph + .children() + .iter() + .zip(span_texts) + .enumerate() + .map(|(span_index, (span, text))| { + let shifted_start = shifted_cursor; + shifted_cursor += text.encode_utf16().count(); + let shifted_end = shifted_cursor; + let source_start = offset_map.to_original(shifted_start); + let source_end = offset_map.to_original(shifted_end); + let warichu = span.is_warichu(); + let builder_start = builder_cursor; + let style_anchor_start = if warichu { + builder_byte_cursor + '\u{FFFC}'.len_utf8() + } else { + builder_byte_cursor + }; + builder_cursor += if warichu { + HORIZONTAL_WARICHU_BUILDER_LEN + } else { + shifted_end - shifted_start + }; + builder_byte_cursor += if warichu { + '\u{FFFC}'.len_utf8() + + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8() + + HORIZONTAL_WARICHU_BREAK_ANCHOR.len_utf8() + } else { + // `add_text_with_tabs` stores every tab as a U+FFFC placeholder. + text.len() + text.matches('\t').count() * ('\u{FFFC}'.len_utf8() - 1) + }; + HorizontalSpanRange { + span: span_index, + builder_start, + builder_end: builder_cursor, + shifted_start, + source_start, + source_end, + warichu, + style_anchor_start, + } + }) + .collect() +} + +/// Transformed-text UTF-16 offset of every source character boundary: the +/// space the kinsoku offset map reports as "original". Differs from the +/// source UTF-16 when a transform changes the text length (`ß` -> `SS`). +fn source_char_boundaries(paragraph: &Paragraph) -> Vec { + let mut boundaries = vec![0usize]; + let mut span_base = 0usize; + for span in paragraph.children() { + let applied = span.apply_text_transform_with_source_ranges(); + let mut ranges = applied.source_ranges.iter().peekable(); + let mut source_utf16 = 0usize; + let mut output_end = 0usize; + for character in span.text.chars() { + source_utf16 += character.len_utf16(); + while let Some((output, _)) = ranges.next_if(|(_, source)| source.end <= source_utf16) { + output_end = output.end; + } + boundaries.push(span_base + output_end); + } + span_base += applied.text.encode_utf16().count(); + } + boundaries +} diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs new file mode 100644 index 0000000000..65b4c793f5 --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -0,0 +1,517 @@ +use std::collections::HashMap; + +use super::*; + +/// Extra spacing for long horizontal ruby, per span of a paragraph. +#[derive(Debug, Clone, Default)] +pub(crate) struct HorizontalRubySpacing { + /// Letter-spacing added to characters of the layout texts: (char index, + /// amount) per span. + pub adjustments: Vec>, + /// Gap added between the base characters of each ruby span. + pub base_gaps: Vec, + /// Spacing added before each ruby base, on the previous character: half + /// a gap, or none at a paragraph start, where the base takes it after. + pub leading_gaps: Vec, + /// Overhang room (before, after) of each ruby span over its neighbours. + pub rooms: Vec<(f32, f32)>, +} + +/// The span that owns a character of the layout `texts`, its char index and +/// the character, searching from (span, index) in `step` direction and +/// skipping inserted word joiners. +fn layout_neighbour(texts: &[String], span: usize, forward: bool) -> Option<(usize, usize, char)> { + let real = + |(index, ch): (usize, char)| (!kinsoku::is_inserted_zero_width(ch)).then_some((index, ch)); + if forward { + texts + .iter() + .enumerate() + .skip(span + 1) + .find_map(|(owner, text)| { + text.chars() + .enumerate() + .find_map(real) + .map(|(index, ch)| (owner, index, ch)) + }) + } else { + texts + .iter() + .enumerate() + .take(span) + .rev() + .find_map(|(owner, text)| { + let chars: Vec = text.chars().collect(); + chars + .iter() + .copied() + .enumerate() + .rev() + .find_map(real) + .map(|(index, ch)| (owner, index, ch)) + }) + } +} + +/// Spacing that makes room for horizontal ruby longer than its base. The +/// reading overhangs its neighbours within their `ruby_overhang_room`, less +/// what an earlier reading already hangs over the same character (JLREQ +/// Fig 138); the rest spreads the base (JLREQ §3.3.8): an equal gap between base +/// characters and half a gap at each end, the leading half on the previous +/// character. +pub(crate) fn horizontal_ruby_spacing( + paragraph: &Paragraph, + texts: &[String], + offset_map: &kinsoku::OffsetMap, +) -> HorizontalRubySpacing { + 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, + ) +} + +impl HorizontalRubySpacing { + fn empty(spans: usize) -> Self { + Self { + adjustments: vec![Vec::new(); spans], + base_gaps: vec![0.0; spans], + leading_gaps: vec![0.0; spans], + rooms: vec![(0.0, 0.0); spans], + } + } +} + +/// `horizontal_ruby_spacing` with explicit fonts. +pub(super) fn horizontal_ruby_spacing_with( + paragraph: &Paragraph, + texts: &[String], + offset_map: &kinsoku::OffsetMap, + font_provider: &skia::textlayout::TypefaceFontProvider, + fallback_families: &[String], +) -> HorizontalRubySpacing { + let spans = paragraph.children(); + let mut spacing = HorizontalRubySpacing::empty(texts.len()); + let fallback_mgr = FontMgr::from(font_provider.clone()); + let advances = |span: &TextSpan, text: &str, font_size: f32| -> Vec { + shape_segment_with_fallbacks( + text, + font_size, + &span_font_families(span, fallback_families), + font_provider, + false, + span.font_features, + &fallback_mgr, + ) + .iter() + .flat_map(|run| run.advances.iter().copied()) + .collect() + }; + // Overhang an earlier reading already takes on a character after it. + let mut claimed: HashMap<(usize, usize), f32> = HashMap::new(); + let mut span_start = 0usize; + for (index, (span, text)) in spans.iter().zip(texts).enumerate() { + let text_start = span_start; + span_start += text.encode_utf16().count(); + if !span.has_ruby() { + continue; + } + // Source characters of the base; inserted spacing and joiners are not. + let mut utf16 = text_start; + let base: Vec = text + .chars() + .enumerate() + .filter(|(_, ch)| { + let inserted = offset_map.is_inserted(utf16); + utf16 += ch.len_utf16(); + !inserted + }) + .map(|(char_index, _)| char_index) + .collect(); + let Some(&last) = base.last() else { + continue; + }; + let ruby_font_size = span.ruby_font_size(); + let base_width = advances(span, &span.apply_text_transform(), span.font_size) + .iter() + .sum::() + + span.letter_spacing * base.len() as f32; + // The painter sets the reading in pieces (see `RubyLine::paint`). + let shaped = shape_segment_with_fallbacks( + span.ruby_text(), + ruby_font_size, + &span_font_families(span, fallback_families), + font_provider, + false, + span.font_features, + &fallback_mgr, + ); + let (_, pieces, _) = + horizontal_ruby_pieces(&ruby_glyph_refs(span.ruby_text(), &shaped, ruby_font_size)); + let ruby_length: f32 = pieces.iter().sum(); + let previous = layout_neighbour(texts, index, false); + let next = layout_neighbour(texts, index, true); + let room = |neighbour: Option<(usize, usize, char)>| { + neighbour.map_or(0.0, |(owner, _, ch)| { + ruby_overhang_room( + span.ruby_overhang, + Some(ch), + spans[owner].has_ruby(), + spans[owner].font_size, + ruby_font_size, + ) + }) + }; + let taken = previous + .and_then(|(owner, char_index, _)| claimed.get(&(owner, char_index))) + .copied() + .unwrap_or(0.0); + let rooms = ((room(previous) - taken).max(0.0), room(next)); + spacing.rooms[index] = rooms; + if let Some((owner, char_index, _)) = next { + let hang_after = ruby_overhangs(ruby_length - base_width, rooms).1; + claimed.insert((owner, char_index), hang_after); + } + let gap = long_ruby_gap(ruby_length, base_width, rooms, base.len()); + if gap <= 0.0 { + continue; + } + spacing.base_gaps[index] = gap; + for &char_index in &base { + let amount = if char_index == last { gap / 2.0 } else { gap }; + spacing.adjustments[index].push((char_index, amount)); + } + match previous { + Some((owner, char_index, _)) => { + spacing.adjustments[owner].push((char_index, gap / 2.0)); + spacing.leading_gaps[index] = gap / 2.0; + } + None => spacing.adjustments[index].push((last, gap / 2.0)), + } + } + spacing +} + +/// Split `total` glyphs across segments proportionally to their extents +/// (rounded per segment, remainder to the last) so no glyph is dropped. +pub(super) fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec { + let mut counts = vec![0usize; extents.len()]; + if extents.is_empty() || total == 0 { + return counts; + } + let sum: f32 = extents.iter().sum(); + if sum <= 0.0 { + counts[extents.len() - 1] = total; + return counts; + } + let mut assigned = 0usize; + let last = extents.len() - 1; + for (index, extent) in extents.iter().enumerate() { + let count = if index == last { + total - assigned + } else { + (((extent / sum) * total as f32).round() as usize).min(total - assigned) + }; + counts[index] = count; + assigned += count; + } + counts +} + +/// (span index, builder-text range) of every span whose ruby is painted: +/// visible ruby on a base that is not warichu. +pub(super) fn horizontal_ruby_targets( + paragraph: &Paragraph, + offsets: &HorizontalOffsets, +) -> Vec<(usize, std::ops::Range)> { + paragraph + .children() + .iter() + .zip(&offsets.ranges) + .filter(|(span, range)| span.has_ruby() && range.builder_start < range.builder_end) + .map(|(_, range)| (range.span, range.builder_start..range.builder_end)) + .collect() +} + +/// Paint of the pass that laid out the base: fill, stroke, shadow or mask. +/// `fallback` covers a base Skia kept no style metrics for. +pub(super) fn horizontal_ruby_paint( + laid_out: &skia::textlayout::Paragraph, + range: &HorizontalSpanRange, + fallback: skia::Paint, +) -> skia::Paint { + horizontal_span_style(laid_out, range).map_or(fallback, |style| style.foreground()) +} + +/// Ink edge of `glyph` (bottom when `over`, else top), used to attach ruby +/// ink to its base. Font-wide ascent/descent include leading, which leaves +/// ruby detached in many Japanese faces. +fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 { + let mut bounds = [skia::Rect::default()]; + font.get_bounds(&[glyph], &mut bounds, None); + let bound = bounds[0]; + if bound.right > bound.left && bound.bottom > bound.top { + if over { + bound.bottom + } else { + bound.top + } + } else { + fallback + } +} + +/// One shaped ruby glyph: (run index, glyph index in the run, advance, +/// whether it belongs to a Western word). +pub(super) type RubyGlyphRef = (usize, usize, f32, bool); + +/// The glyphs of a reading shaped from `ruby_text`, in order. +pub(super) fn ruby_glyph_refs( + ruby_text: &str, + shaped: &[ShapedRun], + ruby_font_size: f32, +) -> Vec { + shaped + .iter() + .enumerate() + .flat_map(|(run_index, run)| { + (0..run.glyphs.len()).map(move |glyph| { + let advance = run.advances.get(glyph).copied().unwrap_or(ruby_font_size); + let western = run + .clusters + .get(glyph) + .and_then(|cluster| ruby_text.get(*cluster as usize..)) + .and_then(|rest| rest.chars().next()) + .is_some_and(|ch| !is_upright_char(ch)); + (run_index, glyph, advance, western) + }) + }) + .collect() +} + +/// Pieces of a horizontal reading (`ruby_pieces`): each upright glyph in a +/// slot as wide as the widest upright glyph, each Western word whole. +/// Returns the slot, the piece extents and each glyph's piece and offset. +pub(super) fn horizontal_ruby_pieces( + glyphs: &[RubyGlyphRef], +) -> (f32, Vec, Vec<(usize, f32)>) { + let slot = glyphs + .iter() + .filter(|(_, _, _, western)| !western) + .map(|(_, _, advance, _)| *advance) + .fold(0.0f32, f32::max) + .max(1.0); + let flags: Vec<(bool, f32)> = glyphs + .iter() + .map(|(_, _, advance, western)| (*western, *advance)) + .collect(); + let (extents, placement) = ruby_pieces(&flags, slot); + (slot, extents, placement) +} + +/// Ink edge shared by the glyphs of one ruby line: the lowest ink bottom for +/// over ruby, the highest ink top for under ruby. One edge for the whole line +/// keeps marks whose ink sits mid-height, such as ー and ・, on the line's +/// baseline instead of pulling each of them onto the base. +pub(super) fn ruby_line_ink_edge(shaped: &[ShapedRun], glyphs: &[RubyGlyphRef], over: bool) -> f32 { + let edges = glyphs.iter().map(|(run_index, glyph, _, _)| { + let run = &shaped[*run_index]; + let (_, metrics) = run.font.metrics(); + let fallback = if over { + metrics.descent + } else { + metrics.ascent + }; + horizontal_ruby_ink_edge(&run.font, run.glyphs[*glyph], fallback, over) + }); + let edge = if over { + edges.fold(f32::MIN, f32::max) + } else { + edges.fold(f32::MAX, f32::min) + }; + if edge.is_finite() && edge.abs() < f32::MAX { + edge + } else { + 0.0 + } +} + +/// Paint ruby for one laid-out horizontal paragraph. Draw-only: base rects +/// come from `get_rects_for_range`, and the ruby is shaped at +/// `ruby_font_size` and spread over each line's rect with the vertical path's +/// jlreq distribution (`distribute_ruby_pieces`). Lines are not reflowed; ruby +/// draws in the line's leading. +pub(crate) fn paint_horizontal_ruby( + canvas: &Canvas, + text_content: &TextContent, + paragraph_index: usize, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else { + return; + }; + if !paragraph.children().iter().any(TextSpan::has_ruby) { + return; + } + 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 bounds = text_content.bounds(); + let plans = text_content.horizontal_plans(); + let Some(plan) = plans.get(paragraph_index) else { + return; + }; + let offsets = &plan.offsets; + let lines = laid_out.get_line_metrics(); + + for (span_index, span_range) in horizontal_ruby_targets(paragraph, offsets) { + let span = ¶graph.children()[span_index]; + let paint = horizontal_ruby_paint( + laid_out, + &offsets.ranges[span_index], + merge_fills(&span.fills, bounds), + ); + let rects = laid_out.get_rects_for_range( + span_range, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let shaped = shape_segment_with_fallbacks( + span.ruby_text(), + span.ruby_font_size(), + &span_font_families(span, &fallback_families), + font_provider, + false, + span.font_features, + &fallback_mgr, + ); + let glyphs = ruby_glyph_refs(span.ruby_text(), &shaped, span.ruby_font_size()); + let extents: Vec = rects.iter().map(|rect| rect.rect.width()).collect(); + let counts = split_counts_by_extent(&extents, glyphs.len()); + let mut assigned = 0usize; + for (rect_box, count) in rects.iter().zip(counts) { + let slice = &glyphs[assigned..assigned + count]; + assigned += count; + if slice.is_empty() { + continue; + } + let leading_gap = plan.ruby_spacing.leading_gaps[span_index]; + let room = room_inside_line( + &lines, + rect_box.rect, + rect_box.rect.left() - leading_gap, + rect_box.rect.width() + leading_gap, + plan.ruby_spacing.rooms[span_index], + ); + let ruby_line = RubyLine { + span, + shaped: &shaped, + glyphs: slice, + base: rect_box.rect, + leading_gap, + room, + }; + ruby_line.paint(canvas, &paint, x, y); + } + } +} + +/// The ruby glyphs over one line's rect of a base. +pub(super) struct RubyLine<'a> { + pub span: &'a TextSpan, + pub shaped: &'a [ShapedRun], + pub glyphs: &'a [RubyGlyphRef], + /// Base rect in the laid-out paragraph. + pub base: skia::Rect, + /// Spacing before the base, which sits on the previous character. + pub leading_gap: f32, + /// Overhang room (before, after) inside the line. + pub room: (f32, f32), +} + +impl RubyLine<'_> { + /// Glyphs set in pieces (`horizontal_ruby_pieces`), distributed over the + /// base and the spacing before it. + fn paint(&self, canvas: &Canvas, paint: &skia::Paint, x: f32, y: f32) { + let baseline = self.baseline(y); + for ((run_index, glyph, _, _), left) in self.glyphs.iter().zip(self.glyph_lefts()) { + let run = &self.shaped[*run_index]; + if let Some(blob) = single_glyph_blob(&run.font, run.glyphs[*glyph]) { + canvas.draw_text_blob(&blob, (x + left, baseline), paint); + } + } + } + + /// Left edge of each glyph: upright glyphs centred in their slot, the + /// letters of a Western word at their own advances. + pub(super) fn glyph_lefts(&self) -> Vec { + let (slot, pieces, placement) = horizontal_ruby_pieces(self.glyphs); + let piece_lefts = distribute_ruby_pieces( + self.base.left() - self.leading_gap, + self.base.width() + self.leading_gap, + &pieces, + self.span.ruby_align, + self.room, + ); + self.glyphs + .iter() + .zip(placement) + .map(|((_, _, advance, western), (piece, offset))| { + let centring = if *western { + 0.0 + } else { + (slot - advance) / 2.0 + }; + piece_lefts[piece] + offset + centring + }) + .collect() + } + + /// Baseline of the line that attaches its ink to the base: above the base + /// em for over ruby, below the base rect for under ruby. + pub(super) fn baseline(&self, y: f32) -> f32 { + let over = self.span.ruby_side == RubySide::Over; + let edge = ruby_line_ink_edge(self.shaped, self.glyphs, over); + if over { + y + horizontal_annotation_over_top(self.base, self.span.font_size) - edge + } else { + y + self.base.bottom() - edge + } + } +} + +/// Overhang room of a horizontal ruby base, without the sides at its line's +/// edges: ruby never sticks out past the first or last character. +fn room_inside_line( + lines: &[skia::textlayout::LineMetrics], + rect: skia::Rect, + left: f32, + width: f32, + room: (f32, f32), +) -> (f32, f32) { + let middle = rect.center_y(); + let Some(line) = lines.iter().find(|line| { + let baseline = line.baseline as f32; + middle >= baseline - line.ascent as f32 && middle <= baseline + line.descent as f32 + }) else { + return room; + }; + let line_left = line.left as f32; + let line_right = line_left + line.width as f32; + ( + if left <= line_left + 0.5 { 0.0 } else { room.0 }, + if left + width >= line_right - 0.5 { + 0.0 + } else { + room.1 + }, + ) +} diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs new file mode 100644 index 0000000000..d38b27012d --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -0,0 +1,1020 @@ +use super::justify::{horizontal_justify_spacing, horizontal_squeeze_sheds}; +use super::ruby::{ + horizontal_ruby_paint, horizontal_ruby_spacing_with, horizontal_ruby_targets, ruby_glyph_refs, + split_counts_by_extent, RubyGlyphRef, RubyLine, +}; +use super::*; +use crate::shapes::{TextAlign, TextDirection, TextTransform}; +use crate::Uuid; + +#[test] +fn horizontal_ruby_ranges_use_utf16_across_spans() { + init_state(); + let mut astral = make_span("𠀀", 0.0); + astral.ruby = "あ".to_string(); + let mut kanji = make_span("漢", 0.0); + kanji.ruby = "かん".to_string(); + let paragraph = make_paragraph(vec![astral, kanji], 0.0); + let offsets = HorizontalOffsets::new(¶graph); + + assert_eq!( + horizontal_ruby_targets(¶graph, &offsets), + vec![(0, 0..2), (1, 2..3)] + ); +} + +#[test] +fn split_counts_by_extent_drops_no_glyph() { + assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]); + assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]); + assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]); + assert_eq!( + split_counts_by_extent(&[1.0, 1.0, 1.0], 2) + .iter() + .sum::(), + 2 + ); + assert!(split_counts_by_extent(&[], 3).is_empty()); +} + +// apply_text_transform reads the browser from the design state. +fn init_state() { + crate::globals::design_init(); +} + +fn make_span(text: &str, letter_spacing: f32) -> TextSpan { + TextSpan { + text: text.to_string(), + font_size: 16.0, + line_height: 1.0, + letter_spacing, + ..TextSpan::default() + } +} + +fn make_paragraph(spans: Vec, letter_spacing: f32) -> Paragraph { + Paragraph::new( + TextAlign::default(), + TextDirection::LTR, + None, + None, + 1.0, + letter_spacing, + spans, + ) +} + +#[test] +fn layout_span_texts_applies_kinsoku() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!( + texts, + vec!["雪国".to_string(), "\u{2060}。です".to_string()] + ); + assert!(!map.is_empty()); + assert_eq!(map.to_original(3), 2); +} + +#[test] +fn layout_span_texts_applies_kinsoku_under_paragraph_letter_spacing() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0); + let (texts, _) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]); +} + +#[test] +fn layout_span_texts_applies_kinsoku_under_span_letter_spacing() { + init_state(); + let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0); + let (texts, _) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["雪国\u{2060}。".to_string()]); +} + +#[test] +fn inserted_joiners_take_no_letter_spacing() { + init_state(); + let measure = |text: &str| { + let span = make_span(text, 5.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + style.set_letter_spacing(span.letter_spacing); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &span, text, &[], &[], &style, &fonts); + let mut laid_out = builder.build(); + laid_out.layout(f32::MAX); + laid_out.longest_line() + }; + assert!((measure("A\u{2060}B") - measure("AB")).abs() < 0.01); +} + +#[test] +fn layout_span_texts_respects_text_transform() { + init_state(); + let mut span = make_span("hello。", 0.0); + span.text_transform = Some(TextTransform::Uppercase); + let paragraph = make_paragraph(vec![span], 0.0); + let (texts, _) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]); +} + +#[test] +fn layout_span_texts_identity_map_for_plain_text() { + init_state(); + let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["helloworld".to_string()]); + assert!(map.is_empty()); + assert_eq!(map.to_original(5), 5); + assert_eq!(map.to_shifted(5), 5); +} + +#[test] +fn layout_span_texts_leaves_text_without_japanese_unchanged() { + init_state(); + let paragraph = make_paragraph(vec![make_span("hello world foo", 0.0)], 0.0); + let (texts, map) = paragraph.layout_span_texts(); + assert_eq!(texts, vec!["hello world foo".to_string()]); + assert!(map.is_empty()); +} + +#[test] +fn layout_span_texts_sets_western_spaces_inside_japanese_text() { + init_state(); + let paragraph = make_paragraph(vec![make_span("日本 hello world", 0.0)], 0.0); + let (texts, _) = paragraph.layout_span_texts(); + assert_eq!( + texts, + vec![format!( + "日本{}hello{}world", + kinsoku::WESTERN_WORD_SPACE, + kinsoku::WESTERN_WORD_SPACE + )] + ); +} + +#[test] +fn layout_span_texts_keeps_ruby_atomic_without_japanese_text() { + init_state(); + let mut group = make_span("ab", 0.0); + group.ruby = "x".to_string(); + let paragraph = make_paragraph(vec![group], 0.0); + assert_eq!( + paragraph.layout_span_texts().0, + vec!["a\u{2060}b".to_string()] + ); +} + +fn test_ruby_spacing(paragraph: &Paragraph) -> HorizontalRubySpacing { + let (texts, offset_map) = paragraph.layout_span_texts(); + let provider = skia::textlayout::TypefaceFontProvider::new(); + let typeface = FontMgr::new() + .new_from_data( + skia_safe::Data::new_copy(include_bytes!("../../fonts/sourcesanspro-regular.ttf")), + None, + ) + .expect("test font"); + let mut provider = provider; + provider.register_typeface(typeface, Some("sourcesanspro")); + horizontal_ruby_spacing_with( + paragraph, + &texts, + &offset_map, + &provider, + &["sourcesanspro".to_string()], + ) +} + +#[test] +fn horizontal_ruby_overhang_room_covers_kana_neighbours_only() { + init_state(); + let mut base = make_span("字", 0.0); + base.ruby = "じじじ".to_string(); + let paragraph = make_paragraph(vec![make_span("か", 0.0), base, make_span("漢", 0.0)], 0.0); + let spacing = test_ruby_spacing(¶graph); + assert_eq!(spacing.rooms[1], (8.0, 0.0)); +} + +#[test] +fn horizontal_long_ruby_spreads_its_base_without_overhang() { + init_state(); + let mut base = make_span("i", 0.0); + base.ruby = "MMMMMMMM".to_string(); + base.ruby_overhang = RubyOverhang::None; + let paragraph = make_paragraph(vec![make_span("a", 0.0), base, make_span("b", 0.0)], 0.0); + let spacing = test_ruby_spacing(¶graph); + let gap = spacing.base_gaps[1]; + assert!(gap > 0.0, "the base grows under a long reading"); + assert_eq!(spacing.adjustments[1], vec![(0, gap / 2.0)]); + assert_eq!(spacing.adjustments[0], vec![(0, gap / 2.0)]); + assert_eq!(spacing.leading_gaps[1], gap / 2.0); +} + +#[test] +fn horizontal_ruby_base_at_paragraph_start_takes_all_spacing_after() { + init_state(); + let mut base = make_span("i", 0.0); + base.ruby = "MMMMMMMM".to_string(); + base.ruby_overhang = RubyOverhang::None; + let paragraph = make_paragraph(vec![base, make_span("b", 0.0)], 0.0); + let spacing = test_ruby_spacing(¶graph); + let gap = spacing.base_gaps[0]; + assert!(gap > 0.0); + assert_eq!( + spacing.leading_gaps[0], 0.0, + "nothing sticks out before the line start" + ); + assert_eq!(spacing.adjustments[0], vec![(0, gap / 2.0), (0, gap / 2.0)]); +} + +#[test] +fn horizontal_long_ruby_counts_only_source_characters_of_its_base() { + init_state(); + let mut base = make_span("第3", 0.0); + base.ruby = "MMMMMMMMMMMM".to_string(); + base.ruby_overhang = RubyOverhang::None; + let paragraph = make_paragraph(vec![base], 0.0); + let (texts, _) = paragraph.layout_span_texts(); + assert!( + texts[0].chars().count() > 2, + "the layout text has inserted characters" + ); + let last = texts[0].chars().count() - 1; + let spacing = test_ruby_spacing(¶graph); + let gap = spacing.base_gaps[0]; + assert_eq!( + spacing.adjustments[0], + vec![(0, gap), (last, gap / 2.0), (last, gap / 2.0)] + ); +} + +#[test] +fn horizontal_short_ruby_adds_no_spacing() { + init_state(); + let mut base = make_span("MMMM", 0.0); + base.ruby = "i".to_string(); + let paragraph = make_paragraph(vec![base], 0.0); + let spacing = test_ruby_spacing(¶graph); + assert_eq!(spacing.base_gaps, vec![0.0]); + assert!(spacing.adjustments[0].is_empty()); +} + +#[test] +fn annotation_room_follows_ruby_size_and_clearance() { + let mut span = make_span("漢字", 0.0); + span.ruby = "かんじ".to_string(); + assert_eq!(span.annotation_room_em(), 0.0, "none keeps the line height"); + span.annotation_clearance = AnnotationClearance::Auto; + span.ruby_size = RubySize::Quarter; + assert_eq!(span.annotation_room_em(), 0.25); + span.text_emphasis = TextEmphasis::FilledDot; + assert_eq!(span.annotation_room_em(), 0.75); +} + +#[test] +fn horizontal_plans_are_cached_until_the_content_changes() { + init_state(); + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 200.0, 40.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![make_span("雪国。", 0.0)], 0.0)); + let first = content.horizontal_plans(); + + assert!(std::rc::Rc::ptr_eq(&first, &content.horizontal_plans())); + assert!( + std::rc::Rc::ptr_eq(&first, &content.clone().horizontal_plans()), + "clones share the cached plans" + ); + assert_eq!(first[0].texts, vec!["雪国\u{2060}。".to_string()]); + + content.paragraphs_mut()[0].children_mut()[0].text = "です".to_string(); + let edited = content.horizontal_plans(); + assert!(!std::rc::Rc::ptr_eq(&first, &edited)); + assert_eq!(edited[0].texts, vec!["です".to_string()]); +} + +#[test] +fn horizontal_annotations_can_paint_from_the_layout_cache() { + let content_with = |span: TextSpan| { + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 200.0, 40.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![span], 0.0)); + content + }; + let mut ruby = make_span("漢字", 0.0); + ruby.ruby = "かんじ".to_string(); + let mut emphasis = make_span("強調", 0.0); + emphasis.text_emphasis = TextEmphasis::FilledDot; + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + for span in [ruby, emphasis, warichu] { + assert!(content_with(span).can_paint_from_layout_cache()); + } + + let mut vertical = content_with(make_span("縦", 0.0)); + vertical.paragraphs_mut()[0].set_writing_mode(WritingMode::VerticalRl); + assert!(!vertical.can_paint_from_layout_cache()); +} + +#[test] +fn emoji_detection_ignores_japanese_text() { + let content_with = |text: &str| { + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 200.0, 40.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![make_span(text, 0.0)], 0.0)); + content + }; + assert!(!content_with("日本語のテキスト、「括弧」").has_emoji()); + assert!(content_with("あ😀").has_emoji()); + assert!(content_with("#\u{FE0F}\u{20E3}").has_emoji()); + assert!(content_with("©\u{FE0F}").has_emoji()); +} + +#[test] +fn horizontal_ruby_is_atomic() { + init_state(); + let mut group = make_span("日本", 0.0); + group.ruby = "にほん".to_string(); + let paragraph = make_paragraph(vec![group], 0.0); + assert_eq!( + paragraph.layout_span_texts().0, + vec!["日\u{2060}本".to_string()] + ); +} + +#[test] +fn horizontal_annotation_uses_the_base_em_not_typographic_leading() { + let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0); + + assert_eq!(horizontal_annotation_over_top(rect, 56.0), 88.0); +} + +#[test] +fn horizontal_warichu_collapses_to_one_builder_position() { + init_state(); + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0); + + let ranges = horizontal_span_ranges(¶graph); + assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3); + assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(2), 0); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(4), 3); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(5), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 1), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 2), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 3), 4); + assert_eq!(horizontal_builder_to_source(¶graph, 4), 5); + assert_eq!( + HorizontalOffsets::new(¶graph).normal_selection_ranges(¶graph, 1, 5), + vec![3..4] + ); +} + +#[test] +fn horizontal_builder_mapping_preserves_non_bmp_boundaries() { + init_state(); + let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0); + + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(1), 2); + assert_eq!(horizontal_builder_to_source(¶graph, 2), 1); + assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(2), 3); + assert_eq!(horizontal_builder_to_source(¶graph, 3), 2); +} + +#[test] +fn horizontal_warichu_builder_emits_one_styled_placeholder() { + init_state(); + let mut span = make_span("割注入り", 0.0); + span.warichu = true; + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &span, &span.text, &[], &[], &style, &fonts); + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let placeholders = laid_out.get_rects_for_placeholders(); + assert_eq!(placeholders.len(), 1); + assert!(placeholders[0].rect.width() > 0.0); + assert!(placeholders[0].rect.height() > 0.0); + let has_style = laid_out + .get_line_metrics() + .iter() + .any(|line| !line.get_style_metrics(3..5).is_empty()); + assert!( + has_style, + "the paint pass must recover the placeholder style" + ); +} + +#[test] +fn horizontal_warichu_allows_wrapping_after_the_atomic_box() { + init_state(); + let mut span = make_span("割注入り", 0.0); + span.warichu = true; + let following = make_span("A", 0.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(span.font_size); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &span, &span.text, &[], &[], &style, &fonts); + builder.push_style(&style); + builder.add_text(&following.text); + + let mut laid_out = builder.build(); + laid_out.layout(16.1); + + assert_eq!(laid_out.get_rects_for_placeholders().len(), 1); + assert_eq!(laid_out.get_line_metrics().len(), 2); +} + +#[test] +fn warichu_sub_lines_paint_on_one_line() { + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + for (text, size, letter_spacing) in [ + ("書き下ろし", 13.0, 0.75), + ("浅煎り・", 13.0, 0.0), + ("税込・", 53.0, -1.5), + ("架空の", 7.5, 0.0), + ] { + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(size); + style.set_height(1.0); + style.set_height_override(true); + style.set_letter_spacing(letter_spacing); + let paragraph = mini_paragraph(text, &style, &fonts); + assert_eq!(paragraph.line_number(), 1, "{text:?} must not wrap"); + } +} + +#[test] +fn horizontal_aki_sheds_follow_punctuation_pairs() { + init_state(); + let texts = vec!["あ。」い、".to_string(), "「「う".to_string()]; + let paragraph = make_paragraph(texts.iter().map(|text| make_span(text, 0.0)).collect(), 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &texts), + vec![vec![(1, 8.0), (4, 8.0)], vec![(0, 8.0)]], + "。 before 」, 、 before 「 across spans, and 「 before 「" + ); +} + +#[test] +fn leading_aki_shed_uses_the_bracket_font_size() { + init_state(); + let texts = vec!["「".to_string(), "「う".to_string()]; + let mut large = make_span("「う", 0.0); + large.font_size = 32.0; + let paragraph = make_paragraph(vec![make_span("「", 0.0), large], 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &texts), + vec![vec![(0, 16.0)], vec![]], + "the 16px 「 gives up half of the next 32px bracket's em" + ); +} + +#[test] +fn horizontal_aki_sheds_skip_curly_quotes() { + init_state(); + for text in ["“‘Hi’”", "あ“い”。", "あ‘い’、"] { + let texts = vec![text.to_string()]; + let paragraph = make_paragraph(vec![make_span(text, 0.0)], 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &texts), + vec![Vec::<(usize, f32)>::new()], + "no aki shed in {text:?}" + ); + } +} + +#[test] +fn horizontal_aki_sheds_skip_word_joiners_and_palt() { + init_state(); + let joined = vec!["。\u{2060}」".to_string()]; + let paragraph = make_paragraph(vec![make_span(&joined[0], 0.0)], 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &joined), + vec![vec![(0, 8.0)]] + ); + + let mut palt = make_span("。」", 0.0); + palt.font_features = FontFeatures::Palt; + let paragraph = make_paragraph(vec![palt], 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &["。」".to_string()]), + vec![Vec::<(usize, f32)>::new()] + ); +} + +#[test] +fn horizontal_shed_sets_the_closing_mark_half_an_em_narrower() { + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let width = |span: TextSpan| { + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 400.0, 100.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![span], 0.0)); + let mut groups = content.paragraph_builder_group_from_text(None); + let mut paragraph = groups[0][0].build(); + paragraph.layout(1000.0); + paragraph.max_intrinsic_width() + }; + let mut palt = make_span("。」", 0.0); + palt.font_features = FontFeatures::Palt; + let solid = width(make_span("。」", 0.0)); + let spaced = width(palt); + assert!( + (spaced - solid - 8.0).abs() < 0.01, + "the period sheds half of its 16px em: {spaced} vs {solid}" + ); +} + +#[test] +fn horizontal_ruby_targets_use_builder_offsets() { + init_state(); + let mut note = make_span("割注入り", 0.0); + note.warichu = true; + let mut base = make_span("漢字", 0.0); + base.ruby = "かんじ".to_string(); + let paragraph = make_paragraph(vec![note, base], 0.0); + let offsets = HorizontalOffsets::new(¶graph); + assert_eq!( + horizontal_ruby_targets(¶graph, &offsets), + vec![(1, 3..6)], + "the warichu span takes three builder units; 漢\u{2060}字 carries a joiner" + ); +} + +#[test] +fn horizontal_ruby_paint_follows_the_pass_paint() { + init_state(); + let mut base = make_span("漢字", 0.0); + base.ruby = "かんじ".to_string(); + let paragraph = make_paragraph(vec![base.clone()], 0.0); + let mut stroke = skia::Paint::default(); + stroke.set_style(skia::PaintStyle::Stroke); + stroke.set_color(skia::Color::RED); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(base.font_size); + style.set_foreground_paint(&stroke); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + builder.push_style(&style); + add_horizontal_span(&mut builder, &base, &base.text, &[], &[], &style, &fonts); + let mut laid_out = builder.build(); + laid_out.layout(400.0); + + let offsets = HorizontalOffsets::new(¶graph); + let paint = horizontal_ruby_paint(&laid_out, &offsets.ranges[0], skia::Paint::default()); + assert_eq!(paint.style(), skia::PaintStyle::Stroke); + assert_eq!(paint.color(), skia::Color::RED); +} + +#[test] +fn horizontal_position_data_skips_tab_placeholders_before_warichu() { + init_state(); + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 400.0, 100.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![make_span("A\tB", 0.0), warichu], 0.0)); + let mut shape = crate::shapes::Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 400.0, 100.0); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let data = super::super::text::calculate_position_data(&shape, &content, false); + + let text_right = data + .iter() + .filter(|entry| entry.span == 0) + .map(|entry| entry.x + entry.width) + .fold(0.0f32, f32::max); + let warichu_left = data + .iter() + .filter(|entry| entry.span == 1) + .map(|entry| entry.x) + .fold(f32::MAX, f32::min); + assert!( + warichu_left >= text_right - 0.5, + "the warichu strips start at {warichu_left}, inside A\tB which ends at {text_right}" + ); +} + +#[test] +fn horizontal_position_data_carries_warichu_lines_and_emphasis_marks() { + init_state(); + let mut emphasized = make_span("A、B", 0.0); + emphasized.text_emphasis = TextEmphasis::FilledDot; + let mut warichu = make_span("割注入り", 0.0); + warichu.warichu = true; + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 400.0, 100.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![emphasized, warichu], 0.0)); + let mut shape = crate::shapes::Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 400.0, 100.0); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + + let data = super::super::text::calculate_position_data(&shape, &content, false); + + let warichu_lines: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.span == 1) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(warichu_lines, vec![(0, 2), (2, 4)]); + let marks: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.direction == super::super::text_vertical::DIRECTION_EMPHASIS_MARK) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(marks, vec![(0, 1), (2, 3)], "A and B are marked; 、 is not"); +} + +#[test] +fn horizontal_emphasis_mark_stacks_outside_auto_clearance_ruby() { + let placement = HorizontalEmphasisPlacement { + span: 0, + range: 0..1, + mark: '•', + rect: skia::Rect::from_xywh(10.0, 40.0, 16.0, 20.0), + }; + let plain = make_span("漢", 0.0); + let mut stacked = make_span("漢", 0.0); + stacked.ruby = "かん".to_string(); + stacked.annotation_clearance = AnnotationClearance::Auto; + + let plain_box = horizontal_emphasis_mark_box(&plain, &placement); + let stacked_box = horizontal_emphasis_mark_box(&stacked, &placement); + + assert_eq!(plain_box.width(), plain.font_size * EMPHASIS_FONT_SCALE); + assert_eq!(plain_box.center_x(), placement.rect.center_x()); + assert_eq!( + stacked_box.bottom, + plain_box.bottom - stacked.ruby_font_size(), + "the mark sits outside the ruby layer" + ); +} + +#[test] +fn horizontal_emphasis_tracks_eligible_unicode_characters() { + init_state(); + let mut span = make_span("A😀。 B", 0.0); + span.text_emphasis = TextEmphasis::FilledDot; + let paragraph = make_paragraph(vec![span], 0.0); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(16.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + let (texts, _) = paragraph.layout_span_texts(); + for (span, text) in paragraph.children().iter().zip(texts) { + builder.push_style(&style); + add_horizontal_span(&mut builder, span, &text, &[], &[], &style, &fonts); + } + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let placements = + horizontal_emphasis_placements(¶graph, &HorizontalOffsets::new(¶graph), &laid_out); + assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each"); + assert!(placements + .iter() + .all(|placement| placement.rect.width() > 0.0)); + assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some()); +} + +#[test] +fn horizontal_emphasis_recovers_each_non_ascii_span_style() { + init_state(); + let mut first = make_span("漢", 0.0); + first.text_emphasis = TextEmphasis::FilledDot; + let mut second = make_span("字", 0.0); + second.text_emphasis = TextEmphasis::OpenCircle; + let paragraph = make_paragraph(vec![first, second], 0.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); + let (texts, _) = paragraph.layout_span_texts(); + for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() { + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(if index == 0 { 16.0 } else { 24.0 }); + builder.push_style(&style); + add_horizontal_span(&mut builder, span, &text, &[], &[], &style, &fonts); + } + let mut laid_out = builder.build(); + laid_out.layout(200.0); + + let ranges = horizontal_span_ranges(¶graph); + assert_eq!( + horizontal_span_style(&laid_out, &ranges[0]) + .unwrap() + .font_size(), + 16.0 + ); + assert_eq!( + horizontal_span_style(&laid_out, &ranges[1]) + .unwrap() + .font_size(), + 24.0 + ); +} + +#[test] +fn horizontal_ruby_glyphs_share_one_baseline() { + // JLREQ Fig 123: ー in コーデックス has its ink mid-height. Attaching + // each glyph's own ink bottom to the base dropped it onto the base line; + // 「 here has the same shape problem as ー. + use crate::shapes::text_vertical::test_support::{provider, EM, VPAL_TEST_FONT}; + use crate::shapes::{FontFamily, FontStyle}; + use skia_safe::FontMgr; + + let font_provider = provider(VPAL_TEST_FONT); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + let shaped = shape_segment_with_fallbacks( + "あ「", + EM / 2.0, + &[family], + &font_provider, + false, + FontFeatures::None, + &FontMgr::from(font_provider.clone()), + ); + let glyphs: Vec = shaped + .iter() + .enumerate() + .flat_map(|(run, shaped_run)| { + (0..shaped_run.glyphs.len()).map(move |g| (run, g, EM / 2.0, false)) + }) + .collect(); + assert_eq!(glyphs.len(), 2); + let span = TextSpan { + ruby: "あ「".to_string(), + ..make_span("漢字", 0.0) + }; + let base = skia::Rect::from_xywh(0.0, 0.0, EM * 2.0, EM); + let line = |glyphs: &[RubyGlyphRef]| { + RubyLine { + span: &span, + shaped: &shaped, + glyphs, + base, + leading_gap: 0.0, + room: (0.0, 0.0), + } + .baseline(0.0) + }; + + let both = line(&glyphs); + assert_eq!( + both, + line(&glyphs[..1]), + "the line sits where あ alone sits" + ); + let alone = line(&glyphs[1..]); + assert!( + alone > both + EM / 20.0, + "「 alone would have dropped onto the base: {alone} vs {both}" + ); +} + +#[test] +fn horizontal_western_ruby_keeps_its_word_whole() { + // JLREQ Fig 112: "editor" over 編集者 is centred as one word, never + // spread letter by letter. + use crate::shapes::text_vertical::test_support::{provider, EM, TEST_FONT}; + use crate::shapes::{FontFamily, FontStyle}; + use skia_safe::FontMgr; + + let font_provider = provider(TEST_FONT); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + let shaped = shape_segment_with_fallbacks( + "editor", + EM / 2.0, + &[family], + &font_provider, + false, + FontFeatures::None, + &FontMgr::from(font_provider.clone()), + ); + let glyphs = ruby_glyph_refs("editor", &shaped, EM / 2.0); + assert!(glyphs.iter().all(|(_, _, _, western)| *western)); + let span = TextSpan { + ruby: "editor".to_string(), + ..make_span("漢字字", 0.0) + }; + let base = skia::Rect::from_xywh(0.0, 0.0, EM * 3.0, EM); + let lefts = RubyLine { + span: &span, + shaped: &shaped, + glyphs: &glyphs, + base, + leading_gap: 0.0, + room: (0.0, 0.0), + } + .glyph_lefts(); + + for (index, pair) in lefts.windows(2).enumerate() { + assert!((pair[1] - pair[0] - glyphs[index].2).abs() < 0.01); + } + let word: f32 = glyphs.iter().map(|(_, _, advance, _)| advance).sum(); + let after = base.right() - (lefts[0] + word); + assert!( + (lefts[0] - after).abs() < 0.01, + "centred: {} vs {after}", + lefts[0] + ); +} + +#[test] +fn horizontal_readings_never_meet_over_a_shared_kana() { + init_state(); + // JLREQ Fig 138: に暁(あかつき)の趣(おもむき)を. The later reading may not + // hang over the の the earlier one already covers. The test face sets + // kana as narrow tofu, so the first reading is doubled to fill its room. + let paragraph = make_paragraph( + vec![ + make_span("に", 0.0), + TextSpan { + ruby: "あかつきあかつき".to_string(), + ..make_span("暁", 0.0) + }, + make_span("の", 0.0), + TextSpan { + ruby: "おもむき".to_string(), + ..make_span("趣", 0.0) + }, + make_span("を", 0.0), + ], + 0.0, + ); + let spacing = test_ruby_spacing(¶graph); + assert!(spacing.rooms[1].1 > 0.0, "あかつき may hang over の"); + assert_eq!(spacing.rooms[3].0, 0.0, "おもむき may not"); +} + +#[test] +fn horizontal_justify_spreads_every_line_evenly() { + // JLREQ Fig 90: SkParagraph expands only around kanji; our spacing fills + // each line but the last, with the same breaks, never inside a word. + init_state(); + let text = "あいうえおかきくABCけこさしすせそたちつてとなにぬねの"; + let paragraph = make_paragraph(vec![make_span(text, 0.0)], 0.0); + let plan = HorizontalParagraphPlan::new(¶graph); + let width = 16.0 * 7.6; + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(16.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + // As `paragraph_builders` sets it for justified Japanese paragraphs. + let mut paragraph_style = ParagraphStyle::default(); + paragraph_style.set_apply_rounding_hack(false); + let lay_out = |extra: &[(usize, f32)]| { + let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); + builder.push_style(&style); + add_horizontal_span( + &mut builder, + ¶graph.children()[0], + &plan.texts[0], + &plan.sheds[0], + extra, + &style, + &fonts, + ); + let mut laid_out = builder.build(); + laid_out.layout(width); + laid_out + }; + + let natural = lay_out(&[]); + let spacing = horizontal_justify_spacing(&natural, &plan, width); + let justified = lay_out(&spacing[0]); + + let natural_lines = natural.get_line_metrics(); + let lines = justified.get_line_metrics(); + assert!(lines.len() > 2, "the text wraps"); + assert_eq!(lines.len(), natural_lines.len(), "same line count"); + for (line, before) in lines.iter().zip(&natural_lines) { + assert_eq!(line.start_index, before.start_index, "same breaks"); + } + let (last, full) = lines.split_last().unwrap(); + for line in full { + assert!( + (line.width as f32 - width).abs() < 0.2, + "line width {}", + line.width + ); + } + assert!((last.width - natural_lines.last().unwrap().width).abs() < 0.01); + + let chars: Vec = plan.texts[0].chars().collect(); + for (index, _) in &spacing[0] { + let pair = (chars[*index], chars.get(index + 1).copied().unwrap_or(' ')); + assert!( + !(pair.0.is_ascii_alphabetic() && pair.1.is_ascii_alphabetic()), + "no space inside ABC: {pair:?}" + ); + } +} + +#[test] +fn horizontal_squeeze_keeps_the_next_character_on_the_line() { + // JLREQ Fig 90: a line slightly too long squeezes its comma aki instead + // of moving its last character down. + init_state(); + let text = "あい、うえ、おかきくけこさしすせそ"; + let paragraph = make_paragraph(vec![make_span(text, 0.0)], 0.0); + let plan = HorizontalParagraphPlan::new(¶graph); + let mut style = skia::textlayout::TextStyle::default(); + style.set_font_size(16.0); + let mut fonts = skia::textlayout::FontCollection::new(); + fonts.set_default_font_manager(skia::FontMgr::new(), None); + let mut paragraph_style = ParagraphStyle::default(); + paragraph_style.set_apply_rounding_hack(false); + let advance = { + let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); + builder.push_style(&style); + builder.add_text("あ"); + let mut probe = builder.build(); + probe.layout(1000.0); + probe.max_intrinsic_width() + }; + // Seven characters fit; the eighth overflows by less than the 、 aki. + let width = advance * 7.6; + let lay_out = |extra: &[Vec<(usize, f32)>]| { + let sheds: Vec<(usize, f32)> = plan.sheds[0] + .iter() + .chain(extra.first().into_iter().flatten()) + .copied() + .collect(); + let mut builder = ParagraphBuilder::new(¶graph_style, &fonts); + builder.push_style(&style); + add_horizontal_span( + &mut builder, + ¶graph.children()[0], + &plan.texts[0], + &sheds, + &[], + &style, + &fonts, + ); + let mut laid_out = builder.build(); + laid_out.layout(width); + laid_out + }; + + // Line ends count builder UTF-16 units, word joiners included. + let builder_text: Vec = plan.texts[0].chars().collect(); + let natural = lay_out(&[]); + let natural_end = natural.get_line_metrics()[0].end_index; + assert_eq!( + builder_text[natural_end], 'か', + "the natural line ends before か" + ); + let (sheds, squeezed) = horizontal_squeeze_sheds(¶graph, &plan, width, &lay_out); + + assert_eq!( + squeezed.get_line_metrics()[0].end_index, + natural_end + 1, + "か joins the line" + ); + let squeezed_chars: Vec = sheds[0].iter().map(|(index, _)| *index).collect(); + let commas: Vec = builder_text + .iter() + .enumerate() + .filter(|(_, ch)| **ch == '、') + .map(|(index, _)| index) + .collect(); + assert_eq!(squeezed_chars, commas, "both commas give up aki"); + assert!((sheds[0][0].1 - sheds[0][1].1).abs() < 0.01, "equally"); +} diff --git a/render-wasm/src/shapes/text_horizontal/warichu.rs b/render-wasm/src/shapes/text_horizontal/warichu.rs new file mode 100644 index 0000000000..7a4b764e4f --- /dev/null +++ b/render-wasm/src/shapes/text_horizontal/warichu.rs @@ -0,0 +1,188 @@ +use super::*; + +pub(super) const HORIZONTAL_WARICHU_BUILDER_LEN: usize = 3; +pub(super) const HORIZONTAL_WARICHU_STYLE_ANCHOR: char = '\u{00A0}'; +pub(super) const HORIZONTAL_WARICHU_BREAK_ANCHOR: char = '\u{200B}'; + +/// Placeholder rect of each horizontal warichu span, keyed by span index. +/// Tabs in normal spans are placeholders too, so they are skipped. +pub(crate) fn horizontal_warichu_placeholders( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, +) -> Vec<(usize, skia::Rect)> { + let mut placeholders = laid_out.get_rects_for_placeholders().into_iter(); + let mut result = Vec::new(); + for (index, span) in paragraph.children().iter().enumerate() { + if span.is_warichu() { + if let Some(textbox) = placeholders.next() { + result.push((index, textbox.rect)); + } + } else { + for _ in span.text.matches('\t') { + placeholders.next(); + } + } + } + result +} + +fn horizontal_warichu_boxes<'a>( + paragraph: &'a Paragraph, + laid_out: &skia::textlayout::Paragraph, +) -> Vec<(&'a TextSpan, usize, usize, skia::Rect)> { + let mut source_start = 0usize; + let mut placeholders = horizontal_warichu_placeholders(paragraph, laid_out) + .into_iter() + .peekable(); + let mut boxes = Vec::new(); + for (index, span) in paragraph.children().iter().enumerate() { + let length = span.text.chars().count(); + if let Some((_, rect)) = placeholders.next_if(|(span_index, _)| *span_index == index) { + boxes.push((span, source_start, source_start + length, rect)); + } + source_start += length; + } + boxes +} + +pub(crate) fn horizontal_warichu_hit_test( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + point: Point, +) -> Option { + for (span, source_start, _, rect) in horizontal_warichu_boxes(paragraph, laid_out) { + if !rect.contains(&point) { + continue; + } + let split = warichu_split_chars(&span.apply_text_transform()); + let total = span.text.chars().count(); + let second = point.y >= rect.top() + rect.height() / 2.0; + let (line_start, line_len) = if second { + (split, total - split) + } else { + (0, split) + }; + let fraction = ((point.x - rect.left()) / rect.width().max(0.01)).clamp(0.0, 1.0); + let within = (fraction * line_len as f32).round() as usize; + return Some(source_start + line_start + within.min(line_len)); + } + None +} + +pub(crate) fn horizontal_warichu_caret_rect( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + source_offset: usize, +) -> Option { + for (span, source_start, source_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) { + if source_offset < source_start || source_offset > source_end { + continue; + } + let split = warichu_split_chars(&span.apply_text_transform()); + let local = source_offset - source_start; + let total = source_end - source_start; + let (line_start, line_len, top) = if local >= split { + (split, total - split, rect.top() + rect.height() / 2.0) + } else { + (0, split, rect.top()) + }; + let within = local.saturating_sub(line_start).min(line_len); + let char_width = rect.width() / line_len.max(1) as f32; + return Some(skia::Rect::from_xywh( + rect.left() + within as f32 * char_width, + top, + char_width, + rect.height() / 2.0, + )); + } + None +} + +pub(crate) fn horizontal_warichu_range_rects( + paragraph: &Paragraph, + laid_out: &skia::textlayout::Paragraph, + source_start: usize, + source_end: usize, +) -> Vec { + let mut rects = Vec::new(); + for (span, span_start, span_end, rect) in horizontal_warichu_boxes(paragraph, laid_out) { + let selected_start = source_start.max(span_start); + let selected_end = source_end.min(span_end); + if selected_start >= selected_end { + continue; + } + let split = warichu_split_chars(&span.apply_text_transform()); + for (line_start, line_end, top) in [ + (span_start, span_start + split, rect.top()), + ( + span_start + split, + span_end, + rect.top() + rect.height() / 2.0, + ), + ] { + let start = selected_start.max(line_start); + let end = selected_end.min(line_end); + if start >= end { + continue; + } + let line_len = line_end - line_start; + let char_width = rect.width() / line_len.max(1) as f32; + rects.push(skia::Rect::from_xywh( + rect.left() + (start - line_start) as f32 * char_width, + top, + (end - start) as f32 * char_width, + rect.height() / 2.0, + )); + } + } + rects +} + +/// The two warichu sub-lines of `text`, split at `warichu_split_chars`. +pub(crate) fn warichu_text_lines(text: &str) -> (&str, &str) { + let split = warichu_split_chars(text); + let split_byte = text + .char_indices() + .nth(split) + .map(|(index, _)| index) + .unwrap_or(text.len()); + text.split_at(split_byte) +} + +/// Paint the two horizontal warichu sub-lines into SkParagraph's inline +/// placeholder boxes. Reading order is top line then bottom line. +pub(crate) fn paint_horizontal_warichu( + canvas: &skia::Canvas, + paragraph: &Paragraph, + plan: &HorizontalParagraphPlan, + laid_out: &skia::textlayout::Paragraph, + x: f32, + y: f32, +) { + let ranges = &plan.offsets.ranges; + let placeholders = horizontal_warichu_placeholders(paragraph, laid_out); + let warichu_ranges: Vec<_> = ranges.iter().filter(|range| range.warichu).collect(); + if placeholders.len() != warichu_ranges.len() { + return; + } + + for (range, (_, rect)) in warichu_ranges.into_iter().zip(placeholders) { + let Some(span) = paragraph.children().get(range.span) else { + continue; + }; + let Some(mut style) = horizontal_span_style(laid_out, range) else { + continue; + }; + style.set_font_size(span.font_size * WARICHU_FONT_SCALE); + style.set_height(1.0); + style.set_height_override(true); + style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); + let transformed = span.apply_text_transform(); + let (first, second) = warichu_text_lines(&transformed); + let first_para = mini_paragraph(first, &style, get_font_collection()); + let second_para = mini_paragraph(second, &style, get_font_collection()); + let half_height = rect.height() / 2.0; + first_para.paint(canvas, (x + rect.left(), y + rect.top())); + second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height)); + } +} diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs new file mode 100644 index 0000000000..61d1be23bb --- /dev/null +++ b/render-wasm/src/shapes/text_japanese.rs @@ -0,0 +1,584 @@ +// Japanese layout values and rules shared by the horizontal and vertical +// engines: span attributes, annotation scales, emphasis eligibility, the +// warichu split and the ruby overhang, distribution and spreading rules. + +use crate::shapes::japanese::{classify, JapaneseClass}; +use crate::shapes::kinsoku; + +pub const WARICHU_FONT_SCALE: f32 = 0.5; +pub const EMPHASIS_FONT_SCALE: f32 = 0.5; +pub(crate) fn emphasis_char_allowed(character: char) -> bool { + !character.is_whitespace() + && !crate::shapes::japanese::classify(character).is_emphasis_prohibited() +} + +/// Char index where a warichu run splits into its two sub-lines: the +/// midpoint (first line longer), moved to the nearest split that keeps +/// kinsoku. At equal distance a forward move wins, pulling the mark up into +/// the first sub-line (jlreq). Falls back to the midpoint. +pub(crate) fn warichu_split_chars(text: &str) -> usize { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mid = n.div_ceil(2); + let valid = |split: usize| { + split >= 1 + && split < n + && !kinsoku::forbidden_at_line_start(chars[split]) + && !kinsoku::forbidden_at_line_end(chars[split - 1]) + }; + if valid(mid) { + return mid; + } + for distance in 1..n { + if valid(mid + distance) { + return mid + distance; + } + if mid > distance && valid(mid - distance) { + return mid - distance; + } + } + mid +} + +/// Room a long reading may overhang one neighbouring character (JLREQ +/// §3.3.8, Fig 135): up to one ruby character, and half the neighbour, over +/// kana or a comma, full stop or middle dot without ruby of their own. +/// Kanji, brackets, another ruby base and a line edge (`None`) get no +/// overhang, nor does any neighbour under `RubyOverhang::None`. +pub(crate) fn ruby_overhang_room( + policy: RubyOverhang, + neighbour: Option, + neighbour_has_ruby: bool, + neighbour_extent: f32, + ruby_font_size: f32, +) -> f32 { + let overhangable = neighbour.is_some_and(|ch| { + matches!( + classify(ch), + JapaneseClass::Hiragana + | JapaneseClass::Katakana + | JapaneseClass::SmallKana + | JapaneseClass::ProlongedSoundMark + | JapaneseClass::Comma + | JapaneseClass::FullStop + | JapaneseClass::MiddleDot + ) + }); + if policy == RubyOverhang::Auto && overhangable && !neighbour_has_ruby { + ruby_font_size.min(neighbour_extent / 2.0).max(0.0) + } else { + 0.0 + } +} + +/// How far a reading `overflow` longer than its base hangs over the +/// neighbours before and after it, given their `room`: centred where the room +/// allows, as `distribute_ruby_pieces` places it. Overflow past the room +/// spreads the base instead. +pub(crate) fn ruby_overhangs(overflow: f32, room: (f32, f32)) -> (f32, f32) { + let (before, after) = room; + if overflow <= 0.0 { + return (0.0, 0.0); + } + if overflow >= before + after { + return (before, after); + } + let hang_before = (overflow / 2.0) + .min(before) + .max((overflow - after).min(before)) + .max(0.0); + (hang_before, (overflow - hang_before).clamp(0.0, after)) +} + +/// Gap that spreads a base under a reading longer than itself (JLREQ +/// §3.3.8). What the reading set solid (`ruby_length`) overflows the base +/// by, less the overhang `room`, becomes one gap per base character: a full +/// gap between characters and half a gap at each end. +pub(crate) fn long_ruby_gap( + ruby_length: f32, + base_length: f32, + room: (f32, f32), + base_chars: usize, +) -> f32 { + if base_chars == 0 { + return 0.0; + } + let overflow = ruby_length - base_length - room.0 - room.1; + (overflow / base_chars as f32).max(0.0) +} + +/// Pieces a reading is set in (JLREQ §3.3.4): an upright glyph is a piece +/// of its own in a `slot`; a run of Western glyphs is one word at its own +/// advances, which distribution never spreads. `glyphs` holds (western, +/// advance) per glyph. Returns each piece's extent and, per glyph, its piece +/// and its offset inside it. +pub(crate) fn ruby_pieces(glyphs: &[(bool, f32)], slot: f32) -> (Vec, Vec<(usize, f32)>) { + let mut extents: Vec = Vec::new(); + let mut placement = Vec::with_capacity(glyphs.len()); + let mut previous_western = false; + for &(western, advance) in glyphs { + if western && previous_western { + let piece = extents.len() - 1; + placement.push((piece, extents[piece])); + extents[piece] += advance; + } else { + placement.push((extents.len(), 0.0)); + extents.push(if western { advance } else { slot }); + } + previous_western = western; + } + (extents, placement) +} + +/// Flow-axis start of each reading piece of `extents` along the base segment +/// `[seg_top, seg_top + seg_extent)`. Per jlreq: +/// +/// - A reading that fits the base is placed by `align`; `SpaceAround` is even +/// distribution (均等割り付け): an equal gap around each piece. +/// - A longer reading packs solid and overhangs the base, centred where the +/// `room` (before, after) of its neighbours allows. Layout grows the base so +/// the overflow fits that room. +pub(crate) fn distribute_ruby_pieces( + seg_top: f32, + seg_extent: f32, + extents: &[f32], + align: RubyAlign, + room: (f32, f32), +) -> Vec { + let count = extents.len(); + if count == 0 { + return Vec::new(); + } + let line: f32 = extents.iter().sum(); + let packed = |start: f32, gap: f32| -> Vec { + let mut cursor = start; + extents + .iter() + .map(|extent| { + let top = cursor; + cursor += extent + gap; + top + }) + .collect() + }; + if line <= seg_extent { + let free = seg_extent - line; + match align { + RubyAlign::SpaceAround => { + let gap = free / count as f32; + packed(seg_top + gap / 2.0, gap) + } + RubyAlign::Center => packed(seg_top + free / 2.0, 0.0), + RubyAlign::Start => packed(seg_top, 0.0), + RubyAlign::SpaceBetween if count > 1 => packed(seg_top, free / (count - 1) as f32), + RubyAlign::SpaceBetween => packed(seg_top + free / 2.0, 0.0), + } + } else { + let overflow = line - seg_extent; + let (before, after) = room; + let overhang = (overflow / 2.0) + .min(before) + .max((overflow - after).min(before)); + packed(seg_top - overhang.max(0.0), 0.0) + } +} + +/// Divide `amount` equally across capped opportunities, redistributing the +/// remainder whenever one opportunity reaches its cap. +/// Amounts below this count as zero when dividing adjustments. +const ALLOCATION_EPSILON: f32 = 0.0001; + +pub(crate) fn capped_equal_allocations( + capacities: &[(usize, f32)], + amount: f32, +) -> Vec<(usize, f32)> { + let mut allocations: Vec<(usize, f32)> = capacities.iter().map(|(i, _)| (*i, 0.0)).collect(); + let mut remaining = amount.max(0.0); + while remaining > ALLOCATION_EPSILON { + let active: Vec = capacities + .iter() + .enumerate() + .filter_map(|(slot, (_, cap))| { + (allocations[slot].1 + ALLOCATION_EPSILON < *cap).then_some(slot) + }) + .collect(); + if active.is_empty() { + break; + } + let share = remaining / active.len() as f32; + let mut used = 0.0; + for slot in active { + let cap = capacities[slot].1; + let delta = share.min(cap - allocations[slot].1); + allocations[slot].1 += delta; + used += delta; + } + if used <= ALLOCATION_EPSILON { + break; + } + remaining -= used; + } + allocations +} + +/// Block flow direction of a paragraph. Horizontal uses skparagraph; +/// vertical-rl uses the custom vertical pass: columns top to bottom, +/// advancing right to left. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum WritingMode { + #[default] + HorizontalTb, + VerticalRl, +} + +/// How a line slightly too long for its box is fitted (JLREQ §3.8.1; Adobe's +/// Kinsoku Type). Squeezing reduces punctuation aki to keep the next +/// character; pushing out moves characters to the next line. Alignment then +/// decides what happens to the space a line leaves over. +// The variants are Adobe's Kinsoku Type values and the JS keys. +#[allow(clippy::enum_variant_names)] +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum LineAdjustment { + /// Squeeze first; push out when the aki cannot absorb the overflow. + #[default] + PushInFirst, + /// Push out first; squeeze only when kinsoku leaves no other break. + PushOutFirst, + /// Never squeeze. + PushOutOnly, +} + +/// Glyph orientation inside vertical flow: `Mixed` rotates non-CJK runs +/// sideways, `Upright` keeps every character upright. Ignored in +/// horizontal writing. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextOrientation { + #[default] + Mixed, + Upright, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextCombineUpright { + #[default] + None, + All, + /// Runs of 2-4 ASCII or full-width digits combine into one upright cell. + Digits, + /// Like `Digits`, for runs of exactly 2 (CSS `digits 2`). + Digits2, + /// Like `Digits`, for runs of 2-3. + Digits3, +} + +impl TextCombineUpright { + /// Longest digit run that combines, when digits mode is active. + pub fn digits_max(self) -> Option { + match self { + TextCombineUpright::Digits => Some(4), + TextCombineUpright::Digits2 => Some(2), + TextCombineUpright::Digits3 => Some(3), + _ => None, + } + } +} + +/// Emphasis mark (圏点 / bouten) applied per span, mirroring CSS +/// `text-emphasis-style`. The mark is drawn above each eligible horizontal +/// base character or to the right of its vertical column. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TextEmphasis { + #[default] + None, + FilledDot, + OpenDot, + FilledCircle, + OpenCircle, + FilledSesame, + OpenSesame, +} + +impl TextEmphasis { + pub fn is_none(self) -> bool { + matches!(self, TextEmphasis::None) + } + + /// The glyph drawn as the emphasis mark, following the CSS + /// `text-emphasis-style` character mapping. + pub fn mark_char(self) -> Option { + match self { + TextEmphasis::None => None, + TextEmphasis::FilledDot => Some('•'), + TextEmphasis::OpenDot => Some('◦'), + TextEmphasis::FilledCircle => Some('●'), + TextEmphasis::OpenCircle => Some('○'), + TextEmphasis::FilledSesame => Some('﹅'), + TextEmphasis::OpenSesame => Some('﹆'), + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum FontFeatures { + #[default] + None, + Palt, + Vpal, +} + +/// Whether ruby and emphasis on the same side stack. `Auto` places emphasis +/// outside over-side ruby and reserves room for both; `None` lets them share +/// one layer. +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum AnnotationClearance { + #[default] + None, + Auto, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubySize { + #[default] + Half, + Third, + Quarter, +} + +impl RubySize { + pub fn scale(self) -> f32 { + match self { + Self::Half => 0.5, + Self::Third => 1.0 / 3.0, + Self::Quarter => 0.25, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubyAlign { + #[default] + SpaceAround, + Center, + Start, + SpaceBetween, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubyOverhang { + #[default] + Auto, + None, +} + +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum RubySide { + #[default] + Over, + Under, +} + +impl AnnotationClearance { + pub fn is_auto(self) -> bool { + matches!(self, AnnotationClearance::Auto) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warichu_split_balances_and_respects_kinsoku() { + // Balanced midpoint when nothing forbids it, first line longer. + assert_eq!(warichu_split_chars("あいうえおか"), 3); + assert_eq!(warichu_split_chars("あいうえお"), 3); + // A comma at the midpoint may end the first sub-line... + assert_eq!(warichu_split_chars("あい、うえ"), 3); + // ...but must not start the second one: the split moves forward. + assert_eq!(warichu_split_chars("あいう、えお"), 4); + // An opening bracket must not end the first sub-line. + assert_eq!(warichu_split_chars("あい「うえお"), 4); + // Pathological all-forbidden text keeps the midpoint. + assert_eq!(warichu_split_chars("、、、、"), 2); + } + + #[test] + fn emphasis_excludes_whitespace_and_japanese_punctuation() { + for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars() + { + assert!( + !emphasis_char_allowed(character), + "emphasis must skip {character:?}" + ); + } + for character in "漢あA1・!?".chars() { + assert!( + emphasis_char_allowed(character), + "emphasis should mark {character:?}" + ); + } + } + + #[test] + fn ruby_shorter_than_base_distributes_evenly() { + // A ruby line of 2 x 50 equals the base extent (100): one glyph per + // slot, with no offset. + let tops = + distribute_ruby_pieces(0.0, 100.0, &[50.0; 2], RubyAlign::SpaceAround, (50.0, 50.0)); + assert_eq!(tops.len(), 2); + assert!( + (tops[0] - 0.0).abs() < 0.001, + "first ruby glyph at slot start" + ); + assert!( + (tops[1] - 50.0).abs() < 0.001, + "second ruby glyph one slot down" + ); + + // A wide base (extent 200) spreads 2 glyphs of advance 50 into slots + // of 100. + let spread = + distribute_ruby_pieces(0.0, 200.0, &[50.0; 2], RubyAlign::SpaceAround, (50.0, 50.0)); + assert!( + (spread[0] - 25.0).abs() < 0.001, + "ruby glyph centred in its slot" + ); + assert!( + (spread[1] - spread[0] - 100.0).abs() < 0.001, + "even distribution keeps a full slot between glyphs, not the advance" + ); + } + + #[test] + fn ruby_longer_than_base_overhangs_symmetrically() { + // A ruby line of 80 over a 40 base centres on the base, overhanging + // each end by 20 (the one-em cap). + let tops = + distribute_ruby_pieces(0.0, 40.0, &[20.0; 4], RubyAlign::SpaceAround, (50.0, 50.0)); + assert_eq!(tops.len(), 4); + assert!(tops[0] < 0.0, "long ruby overhangs above the base top"); + let block_center = (tops[0] + tops[3] + 20.0) / 2.0; + assert!( + (block_center - 20.0).abs() < 0.001, + "the ruby block stays centred on the base" + ); + } + + #[test] + fn ruby_alignment_modes_control_short_annotation_distribution() { + assert_eq!( + distribute_ruby_pieces(0.0, 20.0, &[4.0; 2], RubyAlign::SpaceAround, (4.0, 4.0),), + vec![3.0, 13.0] + ); + assert_eq!( + distribute_ruby_pieces(0.0, 20.0, &[4.0; 2], RubyAlign::Center, (4.0, 4.0)), + vec![6.0, 10.0] + ); + assert_eq!( + distribute_ruby_pieces(0.0, 20.0, &[4.0; 2], RubyAlign::Start, (4.0, 4.0)), + vec![0.0, 4.0] + ); + assert_eq!( + distribute_ruby_pieces(0.0, 20.0, &[4.0; 2], RubyAlign::SpaceBetween, (4.0, 4.0),), + vec![0.0, 16.0] + ); + } + + #[test] + fn ruby_overhang_none_keeps_long_annotation_at_base_start() { + let automatic = distribute_ruby_pieces(0.0, 10.0, &[4.0; 4], RubyAlign::Center, (4.0, 4.0)); + let constrained = + distribute_ruby_pieces(0.0, 10.0, &[4.0; 4], RubyAlign::Center, (0.0, 0.0)); + + assert_eq!(automatic, vec![-3.0, 1.0, 5.0, 9.0]); + assert_eq!(constrained, vec![0.0, 4.0, 8.0, 12.0]); + } + + #[test] + fn long_ruby_gap_spreads_the_overflow_left_after_the_room() { + // 60 of reading over 40 of base, 4 of room on each side: 12 left + // over two characters, one gap each, half of it at each end. + assert_eq!(long_ruby_gap(60.0, 40.0, (4.0, 4.0), 2), 6.0); + assert_eq!(long_ruby_gap(20.0, 40.0, (0.0, 0.0), 2), 0.0); + assert_eq!(long_ruby_gap(60.0, 40.0, (0.0, 0.0), 0), 0.0); + } + + #[test] + fn ruby_overhangs_follow_the_distribution() { + assert_eq!(ruby_overhangs(0.0, (5.0, 5.0)), (0.0, 0.0)); + assert_eq!(ruby_overhangs(6.0, (5.0, 5.0)), (3.0, 3.0)); + assert_eq!(ruby_overhangs(6.0, (5.0, 0.0)), (5.0, 0.0)); + assert_eq!(ruby_overhangs(6.0, (0.0, 5.0)), (0.0, 5.0)); + assert_eq!(ruby_overhangs(20.0, (5.0, 5.0)), (5.0, 5.0)); + } + + #[test] + fn ruby_pieces_keep_a_western_word_whole() { + // エディ + "tor": three upright slots, then one word at its advances. + let glyphs = [ + (false, 9.0), + (false, 9.0), + (true, 4.0), + (true, 5.0), + (true, 3.0), + ]; + let (extents, placement) = ruby_pieces(&glyphs, 10.0); + assert_eq!(extents, vec![10.0, 10.0, 12.0]); + assert_eq!( + placement, + vec![(0, 0.0), (1, 0.0), (2, 0.0), (2, 4.0), (2, 9.0)] + ); + } + + #[test] + fn distributing_a_word_never_spreads_its_letters() { + // JLREQ Fig 112: "editor" over 編集者 is one piece centred on the base. + let tops = distribute_ruby_pieces(0.0, 60.0, &[30.0], RubyAlign::SpaceAround, (0.0, 0.0)); + assert_eq!(tops, vec![15.0]); + let tops = + distribute_ruby_pieces(0.0, 60.0, &[10.0, 30.0], RubyAlign::SpaceAround, (0.0, 0.0)); + assert_eq!(tops, vec![5.0, 25.0]); + } + + #[test] + fn ruby_overhang_room_allows_kana_and_punctuation_without_ruby() { + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, Some('か'), false, 20.0, 10.0), + 10.0 + ); + // JLREQ Fig 135: 偏、冠、脚 hangs かんむり over the commas; Fig 136: + // never over a bracket. + for ch in ['、', '。', '・'] { + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, Some(ch), false, 20.0, 10.0), + 10.0 + ); + } + for ch in ['「', '」'] { + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, Some(ch), false, 20.0, 10.0), + 0.0 + ); + } + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, Some('か'), false, 12.0, 10.0), + 6.0 + ); + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, Some('漢'), false, 20.0, 10.0), + 0.0 + ); + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, Some('か'), true, 20.0, 10.0), + 0.0 + ); + assert_eq!( + ruby_overhang_room(RubyOverhang::Auto, None, false, 20.0, 10.0), + 0.0 + ); + assert_eq!( + ruby_overhang_room(RubyOverhang::None, Some('か'), false, 20.0, 10.0), + 0.0 + ); + } +} diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 44cc21a115..2f4f24921f 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -16,6 +16,17 @@ impl TextPaths { } pub fn get_paths(&self, vertical_align: VerticalAlign) -> Vec { + if self.0.is_vertical() { + return crate::shapes::text_vertical::vertical_text_paths( + &self.0, + vertical_align, + true, + ) + .into_iter() + .map(|(path, _)| path) + .collect(); + } + let layout_width = self.0.get_width(self.bounds.width()); let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None); let mut paragraphs: Vec = paragraph_builders @@ -48,7 +59,8 @@ impl TextPaths { origin: Point, paths: &mut Vec, ) { - for deco in decoration_segments(paragraph, text_paragraph, origin.x, origin.y) { + let offsets = crate::shapes::HorizontalOffsets::new(text_paragraph); + for deco in decoration_segments(paragraph, text_paragraph, &offsets, origin.x, origin.y) { let mut builder = skia::PathBuilder::new(); builder.add_rect(deco.rect(), None, None); paths.push(builder.detach()); diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs new file mode 100644 index 0000000000..3fd9160d98 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -0,0 +1,1345 @@ +// Ruby (furigana) and emphasis marks (圏点) in vertical flow. Annotations +// stay out of `cells`, so base metrics, caret geometry and position data +// ignore them; they are placed from the base cells' final columns and flow +// extents. + +use std::collections::HashMap; + +use skia_safe::{self as skia, Font}; + +use crate::shapes::text_japanese::{ + distribute_ruby_pieces, emphasis_char_allowed, long_ruby_gap, ruby_overhang_room, + ruby_overhangs, ruby_pieces, EMPHASIS_FONT_SCALE, +}; +use crate::shapes::{ + AppliedTextTransform, FontFeatures, Paragraph, RubyOverhang, RubySide, TextContent, TextSpan, +}; + +use super::cells::Fonts; +use super::flow::FlowCell; +use super::layout::{ + column_base_center, CellKind, ParagraphTextMap, VerticalCell, VerticalColumn, VerticalLayout, +}; +use super::orientation::segment_by_orientation; +use super::shaping::{shape_segment, shape_segment_with_fallbacks, span_font_families, ShapedRun}; + +/// One shaped ruby glyph, retaining its fallback-font run and source range. +#[derive(Debug, Clone, Copy)] +pub struct RubyGlyph { + pub run: usize, + pub glyph: usize, + pub utf16_start: usize, + pub utf16_end: usize, + /// Part of a Western word set on its side, as base text in mixed + /// orientation; other glyphs stand upright. + pub sideways: bool, + /// Flow extent: the ruby em upright, the glyph advance sideways. + pub extent: f32, +} + +/// The reading of one ruby span, beside its base in one column, painted from +/// `ruby_runs`. Group ruby keeps a base in one column. +#[derive(Debug, Clone)] +pub struct RubyCell { + /// Glyphs in order; each keeps its shaped run so fallback fonts survive. + pub glyphs: Vec, + pub paragraph: usize, + pub span: usize, + pub column: usize, + /// Flow-axis top of each glyph. + pub glyph_tops: Vec, + pub font_size: f32, + pub base_font_size: f32, + pub side: RubySide, + pub paint: usize, +} + +/// One emphasis mark (圏点 / bouten) beside a base character: the +/// single-glyph `run`, centred on the character along the flow, in the +/// column's right-side gutter. +pub struct EmphasisMark { + pub run: usize, + /// Index of the annotated base cell in `VerticalLayout::cells`. + pub cell: usize, + /// Flow offset of the mark centre from the cell top. + pub flow_center: f32, + pub font_size: f32, + /// Cross-axis offset past a stacked ruby layer. + pub outside_offset: f32, +} + +/// Cross-axis start of a vertical ruby strip. Ruby attaches to the edge of the +/// base em, centred in the column advance (`base_width`), so line height adds +/// no gap between ruby and base. +pub(super) fn ruby_strip_x( + column: &VerticalColumn, + ruby_font_size: f32, + base_font_size: f32, + side: RubySide, +) -> f32 { + let base_center = column_base_center(column); + match side { + RubySide::Over => base_center + base_font_size / 2.0, + RubySide::Under => base_center - base_font_size / 2.0 - ruby_font_size, + } +} + +/// The base text of one ruby span: the transformed UTF-16 range it covers in +/// its paragraph and the glyph count of its reading. +#[derive(Debug, Clone, Copy)] +pub(super) struct RubyBaseUnit { + span: usize, + start: usize, + end: usize, + /// Flow length of the reading set solid. + ruby_length: f32, + ruby_font_size: f32, + overhang: RubyOverhang, + /// Overhang room over the characters before and after the base. + room: (f32, f32), +} + +impl RubyBaseUnit { + fn annotates(&self, cell: &VerticalCell) -> bool { + cell.span == self.span && cell.start < self.end && cell.end > self.start + } + + /// Flow length of the reading set solid. + fn ruby_line(&self) -> f32 { + self.ruby_length + } +} + +/// Ruby base units of a paragraph, from its spans' layout text. Shapes each +/// reading to count its glyphs. +pub(super) fn ruby_base_units( + paragraph: &Paragraph, + transforms: &[AppliedTextTransform], + span_starts: &[usize], + fonts: &Fonts, +) -> Vec { + paragraph + .children() + .iter() + .zip(transforms) + .enumerate() + .filter(|(_, (span, transform))| span.has_ruby() && !transform.text.is_empty()) + .map(|(span_index, (span, transform))| RubyBaseUnit { + span: span_index, + start: span_starts[span_index], + end: span_starts[span_index] + transform.text.encode_utf16().count(), + ruby_length: ruby_glyphs( + span.ruby_text(), + 0, + &shape_ruby(span, fonts), + 0, + span.ruby_font_size(), + ) + .iter() + .map(|glyph| glyph.extent) + .sum(), + ruby_font_size: span.ruby_font_size(), + overhang: span.ruby_overhang, + room: (0.0, 0.0), + }) + .collect() +} + +/// The span's reading shaped at the ruby size: CJK upright, Western words on +/// their side under mixed orientation, as base text is set. Each run comes +/// with whether it is sideways; clusters index the whole reading. +fn shape_ruby(span: &TextSpan, fonts: &Fonts) -> Vec<(ShapedRun, bool)> { + let families = span_font_families(span, fonts.fallback_families); + let mut utf8_start = 0usize; + let mut runs = Vec::new(); + for segment in segment_by_orientation(span.ruby_text(), span.text_orientation) { + let shaped = shape_segment_with_fallbacks( + &segment.text, + span.ruby_font_size(), + &families, + fonts.provider, + segment.upright, + span.font_features, + fonts.fallback_mgr, + ); + for mut run in shaped { + for cluster in &mut run.clusters { + *cluster += utf8_start as u32; + } + run.utf8_range = run.utf8_range.start + utf8_start..run.utf8_range.end + utf8_start; + runs.push((run, !segment.upright)); + } + utf8_start += segment.text.len(); + } + runs +} + +/// Overhang room of every unit over its flow neighbours, before placement. +/// Placement later removes the room at a column edge. A kana between two +/// readings lends the later one only what the earlier one leaves (JLREQ +/// Fig 138), so the two readings never meet over it. +pub(super) fn set_ruby_overhang_rooms(flow: &[FlowCell], ruby_units: &mut [RubyBaseUnit]) { + let unit_of_span = units_by_span(ruby_units); + let annotated = |index: usize| { + let cell = &flow[index].cell; + unit_of_span + .get(&cell.span) + .is_some_and(|unit| ruby_units[*unit].annotates(cell)) + }; + let indices = annotated_indices(flow.iter().map(|item| &item.cell), ruby_units); + let mut claimed: HashMap = HashMap::new(); + let rooms: Vec<(f32, f32)> = ruby_units + .iter() + .zip(&indices) + .map(|(unit, indices)| { + let (Some(first), Some(last)) = (indices.first(), indices.last()) else { + return unit.room; + }; + let room = |index: Option| { + index.map_or(0.0, |index| { + ruby_overhang_room( + unit.overhang, + flow[index].ch, + annotated(index), + flow[index].cell.extent, + unit.ruby_font_size, + ) + }) + }; + let before = first.checked_sub(1); + let after = Some(last + 1).filter(|index| *index < flow.len()); + let taken = before.and_then(|index| claimed.get(&index)).copied(); + let room = ((room(before) - taken.unwrap_or(0.0)).max(0.0), room(after)); + if let Some(after) = after { + let base: f32 = indices.iter().map(|index| flow[*index].cell.extent).sum(); + claimed.insert(after, ruby_overhangs(unit.ruby_line() - base, room).1); + } + room + }) + .collect(); + for (unit, room) in ruby_units.iter_mut().zip(rooms) { + unit.room = room; + } +} + +/// Index of the unit of each ruby span. +fn units_by_span(ruby_units: &[RubyBaseUnit]) -> HashMap { + ruby_units + .iter() + .enumerate() + .map(|(index, unit)| (unit.span, index)) + .collect() +} + +/// Indices of the cells each unit annotates, in cell order. +fn annotated_indices<'a>( + cells: impl Iterator, + ruby_units: &[RubyBaseUnit], +) -> Vec> { + let unit_of_span = units_by_span(ruby_units); + let mut indices = vec![Vec::new(); ruby_units.len()]; + for (index, cell) in cells.enumerate() { + if let Some(unit) = unit_of_span.get(&cell.span) { + if ruby_units[*unit].annotates(cell) { + indices[*unit].push(index); + } + } + } + indices +} + +/// Indices of the cells of each (paragraph, span), in cell order. +fn cells_by_span(cells: &[VerticalCell]) -> HashMap<(usize, usize), Vec> { + let mut by_span: HashMap<(usize, usize), Vec> = HashMap::new(); + for (index, cell) in cells.iter().enumerate() { + by_span + .entry((cell.paragraph, cell.span)) + .or_default() + .push(index); + } + by_span +} + +/// Indices of the cells of each column, in cell order. +fn cells_by_column(cells: &[VerticalCell]) -> HashMap> { + let mut by_column: HashMap> = HashMap::new(); + for (index, cell) in cells.iter().enumerate() { + by_column.entry(cell.column).or_default().push(index); + } + by_column +} + +/// Overhang room (before, after) of each ruby span, keyed by (paragraph, +/// span). +pub(super) type RubyRooms = HashMap<(usize, usize), (f32, f32)>; + +/// Overhang room of every ruby span of a paragraph, for `layout_ruby`. +pub(super) fn ruby_rooms(paragraph: usize, ruby_units: &[RubyBaseUnit]) -> RubyRooms { + ruby_units + .iter() + .map(|unit| ((paragraph, unit.span), unit.room)) + .collect() +} + +/// Grow the flow extent of base cells under long ruby before column planning, +/// so wrapping makes room (forced spreading). Only the part of the reading +/// that its overhang room cannot take grows the base: each cell grows by one +/// `long_ruby_gap`, its glyph centred, which leaves a full gap between +/// characters and half a gap at each end. +pub(super) fn grow_ruby_bases(flow: &mut [FlowCell], ruby_units: &[RubyBaseUnit]) { + let all_indices = annotated_indices(flow.iter().map(|item| &item.cell), ruby_units); + for (unit, indices) in ruby_units.iter().zip(&all_indices) { + let base_total: f32 = indices.iter().map(|index| flow[*index].cell.extent).sum(); + let gap = long_ruby_gap(unit.ruby_length, base_total, unit.room, indices.len()); + if gap <= 0.0 { + continue; + } + for index in indices { + let cell = &mut flow[*index].cell; + cell.extent += gap; + cell.glyph_flow_shift += gap / 2.0; + cell.ink_top += gap / 2.0; + cell.ink_bottom += gap / 2.0; + } + } +} + +/// Widen gaps between placed base cells under long ruby. Shifts only later +/// cells of the span, and only into slack before the next cell or the +/// column bottom, so columns never re-wrap. A base sits in one column. +pub(super) fn spread_ruby_base_cells( + cells: &mut [VerticalCell], + ruby_units: &[RubyBaseUnit], + max_height: f32, +) { + let column_limit = if max_height.is_finite() && max_height < f32::MAX { + max_height + } else { + f32::MAX + }; + let column_cells = cells_by_column(cells); + let all_indices = annotated_indices(cells.iter(), ruby_units); + for (unit, mut base) in ruby_units.iter().zip(all_indices) { + if base.len() < 2 { + continue; + } + base.sort_by(|a, b| cells[*a].top.total_cmp(&cells[*b].top)); + let ruby_line = unit.ruby_line() - unit.room.0 - unit.room.1; + let first = base[0]; + let last = base[base.len() - 1]; + let base_top = cells[first].top; + let base_bottom = cells[last].top + cells[last].extent; + let base_extent = base_bottom - base_top; + if ruby_line <= base_extent { + continue; + } + + let next_top = column_cells[&cells[first].column] + .iter() + .map(|index| &cells[*index]) + .filter(|cell| !unit.annotates(cell) && cell.top >= base_bottom) + .map(|cell| cell.top) + .min_by(f32::total_cmp); + let limit = next_top.unwrap_or(column_limit); + let spread = (ruby_line - base_extent).min((limit - base_bottom).max(0.0)); + if spread <= 0.0 { + continue; + } + + let gap = spread / (base.len() - 1) as f32; + for (position, index) in base.iter().enumerate().skip(1) { + cells[*index].top += gap * position as f32; + } + } +} + +/// One `RubyGlyph` per shaped glyph of `ruby_text`, with UTF-16 offsets into +/// the span's untrimmed ruby string (`leading_utf16` is the trimmed prefix). +/// `run_start` is the index the first shaped run will take in `ruby_runs`. +fn ruby_glyphs( + ruby_text: &str, + leading_utf16: usize, + shaped: &[(ShapedRun, bool)], + run_start: usize, + ruby_font_size: f32, +) -> Vec { + let mut glyphs: Vec = shaped + .iter() + .enumerate() + .flat_map(|(run_index, (run, sideways))| { + run.clusters + .iter() + .enumerate() + .map(move |(glyph, cluster)| { + let utf8 = (*cluster as usize).min(ruby_text.len()); + RubyGlyph { + run: run_start + run_index, + glyph, + utf16_start: leading_utf16 + ruby_text[..utf8].encode_utf16().count(), + utf16_end: 0, + sideways: *sideways, + extent: if *sideways { + run.advances.get(glyph).copied().unwrap_or(0.0) + } else { + ruby_font_size + }, + } + }) + }) + .collect(); + let ruby_end = leading_utf16 + ruby_text.encode_utf16().count(); + for index in 0..glyphs.len() { + glyphs[index].utf16_end = glyphs + .get(index + 1) + .map_or(ruby_end, |next| next.utf16_start); + } + glyphs +} + +/// Overhang room of a base segment, without the sides that touch a column +/// edge: ruby never sticks out past the first or last character. +/// `column_cells` indexes the cells of the column. +fn room_inside_column( + cells: &[VerticalCell], + column_cells: &[usize], + base_top: f32, + base_bottom: f32, + room: (f32, f32), +) -> (f32, f32) { + let in_column = || column_cells.iter().map(|index| &cells[*index]); + let has_before = in_column().any(|cell| cell.top + cell.extent <= base_top + 0.01); + let has_after = in_column().any(|cell| cell.top >= base_bottom - 0.01); + ( + if has_before { room.0 } else { 0.0 }, + if has_after { room.1 } else { 0.0 }, + ) +} + +/// Ruby (furigana) placement. Runs after column placement, since ruby follows +/// its base's final column and flow extent. Group ruby keeps each base in one +/// column, so each reading is one `RubyCell`. `rooms` holds the overhang +/// room of each (paragraph, span). +pub(super) fn layout_ruby( + text_content: &TextContent, + cells: &[VerticalCell], + fonts: &Fonts, + rooms: &RubyRooms, +) -> (Vec, Vec) { + let mut ruby_runs: Vec = Vec::new(); + let mut ruby_cells: Vec = Vec::new(); + let span_cells = cells_by_span(cells); + let column_cells = cells_by_column(cells); + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + for (span_index, span) in paragraph.children().iter().enumerate() { + if !span.has_ruby() { + continue; + } + let Some(base) = span_cells.get(&(paragraph_index, span_index)) else { + continue; + }; + let (Some(first), Some(last)) = ( + base.iter() + .map(|index| &cells[*index]) + .min_by(|a, b| a.top.total_cmp(&b.top)), + base.iter() + .map(|index| &cells[*index]) + .max_by(|a, b| (a.top + a.extent).total_cmp(&(b.top + b.extent))), + ) else { + continue; + }; + let ruby_text = span.ruby_text(); + let ruby_font_size = span.ruby_font_size(); + let shaped = shape_ruby(span, fonts); + let leading_utf16 = span.ruby[..span.ruby.len() - span.ruby.trim_start().len()] + .encode_utf16() + .count(); + let glyphs = ruby_glyphs( + ruby_text, + leading_utf16, + &shaped, + ruby_runs.len(), + ruby_font_size, + ); + if glyphs.is_empty() { + continue; + } + ruby_runs.extend(shaped.into_iter().map(|(run, _)| run)); + + let top = first.top; + let bottom = last.top + last.extent; + let room = rooms + .get(&(paragraph_index, span_index)) + .copied() + .unwrap_or((0.0, 0.0)); + let room = room_inside_column(cells, &column_cells[&first.column], top, bottom, room); + let (pieces, placement) = ruby_pieces( + &glyphs + .iter() + .map(|glyph| (glyph.sideways, glyph.extent)) + .collect::>(), + ruby_font_size, + ); + let piece_tops = distribute_ruby_pieces( + top, + (bottom - top).max(0.0), + &pieces, + span.ruby_align, + room, + ); + let glyph_tops = placement + .iter() + .map(|(piece, offset)| piece_tops[*piece] + offset) + .collect(); + ruby_cells.push(RubyCell { + glyphs, + paragraph: paragraph_index, + span: span_index, + column: first.column, + glyph_tops, + font_size: ruby_font_size, + base_font_size: span.font_size, + side: span.ruby_side, + paint: first.paint, + }); + } + } + (ruby_runs, ruby_cells) +} + +/// Flow centres (from the cell top) of the emphasis marks of one base cell: +/// one per eligible character of a sideways run, one for a tate-chu-yoko +/// composite and one centred on the glyph ink of an upright character. +/// `text` is the cell's slice of its span's transformed text. +fn emphasis_flow_centers(cell: &VerticalCell, text: &str, runs: &[ShapedRun]) -> Vec { + let ink_center = || { + if cell.ink_bottom > cell.ink_top { + (cell.ink_top + cell.ink_bottom) / 2.0 + } else { + cell.extent / 2.0 + } + }; + match cell.kind { + CellKind::Upright { .. } + | CellKind::SyntheticRotated { .. } + | CellKind::TateChuYoko { .. } => { + if text.chars().any(emphasis_char_allowed) { + vec![ink_center()] + } else { + Vec::new() + } + } + CellKind::Rotated { run } => { + let chars: Vec = text.chars().collect(); + let Some(offsets) = runs + .get(run) + .and_then(|run| run.scalar_flow_offsets(cell.extent, chars.len())) + else { + return Vec::new(); + }; + chars + .iter() + .enumerate() + .filter(|(_, ch)| emphasis_char_allowed(**ch)) + .map(|(index, _)| (offsets[index] + offsets[index + 1]) / 2.0) + .collect() + } + _ => Vec::new(), + } +} + +/// (UTF-16, byte) offset of every character boundary of a text, end +/// included, for slicing it by UTF-16 ranges. +struct Utf16Boundaries<'a> { + text: &'a str, + boundaries: Vec<(usize, usize)>, +} + +impl<'a> Utf16Boundaries<'a> { + fn new(text: &'a str) -> Self { + let mut utf16 = 0; + let mut boundaries: Vec<(usize, usize)> = text + .char_indices() + .map(|(byte, ch)| { + let boundary = (utf16, byte); + utf16 += ch.len_utf16(); + boundary + }) + .collect(); + boundaries.push((utf16, text.len())); + Self { text, boundaries } + } + + /// The characters that start inside the UTF-16 range `start..end`. + fn slice(&self, start: usize, end: usize) -> &'a str { + let byte = |offset: usize| { + let index = self + .boundaries + .partition_point(|(utf16, _)| *utf16 < offset); + self.boundaries[index.min(self.boundaries.len() - 1)].1 + }; + let start = byte(start); + &self.text[start..byte(end).max(start)] + } +} + +/// Emphasis marks (圏点 / bouten) of each span with `text_emphasis`, shaped +/// once per span: one per upright character, per letter of a sideways run +/// and per tate-chu-yoko composite. Whitespace and Japanese punctuation get +/// no mark, as in CSS `text-emphasis`. +pub(super) fn layout_emphasis( + text_content: &TextContent, + cells: &[VerticalCell], + runs: &[ShapedRun], + text_maps: &[ParagraphTextMap], + fonts: &Fonts, +) -> (Vec, Vec) { + let mut emphasis_runs: Vec = Vec::new(); + let mut emphasis_marks: Vec = Vec::new(); + let span_cells = cells_by_span(cells); + for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { + for (span_index, span) in paragraph.children().iter().enumerate() { + let Some(mark) = span.text_emphasis.mark_char() else { + continue; + }; + let Some(span_cells) = span_cells.get(&(paragraph_index, span_index)) else { + continue; + }; + let mark_font_size = span.font_size * EMPHASIS_FONT_SCALE; + let families = span_font_families(span, fonts.fallback_families); + let match_family = |family: &str| { + fonts + .provider + .match_family_style(family, skia::FontStyle::default()) + }; + let typeface = families + .iter() + .filter_map(|family| match_family(family)) + .find(|typeface| typeface.unichar_to_glyph(mark as i32) != 0) + .or_else(|| match_family(&families[0])); + let Some(typeface) = typeface else { + continue; + }; + let mut shaped = shape_segment( + &mark.to_string(), + &Font::new(typeface, mark_font_size), + true, + FontFeatures::None, + fonts.fallback_mgr.clone(), + ); + if shaped.first().is_none_or(|run| run.glyphs.is_empty()) { + continue; + } + let run_index = emphasis_runs.len(); + emphasis_runs.push(shaped.remove(0)); + let text_map = &text_maps[paragraph_index]; + let span_start = text_map.span_starts[span_index]; + // Cell offsets index the span's transformed text. + let span_text = Utf16Boundaries::new(&text_map.span_transforms[span_index].text); + for cell_index in span_cells.iter().copied() { + let cell = &cells[cell_index]; + let text = span_text.slice(cell.start - span_start, cell.end - span_start); + for flow_center in emphasis_flow_centers(cell, text, runs) { + emphasis_marks.push(EmphasisMark { + run: run_index, + cell: cell_index, + flow_center, + font_size: mark_font_size, + outside_offset: span.emphasis_ruby_offset(), + }); + } + } + } + } + (emphasis_runs, emphasis_marks) +} + +/// Centre of an emphasis mark, relative to the layout's content origin: in +/// its column's gutter past any stacked ruby, at its character's centre. +pub(super) fn emphasis_mark_center(layout: &VerticalLayout, mark: &EmphasisMark) -> (f32, f32) { + let cell = &layout.cells[mark.cell]; + let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE; + let x = column_base_center(&layout.columns[cell.column]) + + base_font_size / 2.0 + + mark.outside_offset + + mark.font_size / 2.0; + (x, cell.top + mark.flow_center) +} + +#[cfg(test)] +mod tests { + use super::super::layout::VerticalLayout; + use super::super::positions::{position_data, DIRECTION_VERTICAL_RUBY}; + use super::super::test_support::*; + use super::*; + use crate::shapes::{AnnotationClearance, GrowType, RubySize}; + use crate::shapes::{ + TextAlign, TextEmphasis, TextOrientation, TextSpan, TextTransform, VerticalAlign, + }; + + fn emphasis_content(base: &str, emphasis: TextEmphasis) -> TextContent { + spans_content( + vec![TextSpan { + text_emphasis: emphasis, + text_orientation: TextOrientation::Upright, + ..make_span(base) + }], + 400.0, + ) + } + + fn ruby_content_with_line_height(line_height: f32) -> TextContent { + let span = TextSpan { + ruby: "ab".to_string(), + ..make_span("AB") + }; + content_of( + vec![vertical_paragraph(vec![span], TextAlign::Left, line_height)], + 400.0, + GrowType::Fixed, + ) + } + + #[test] + fn utf16_slices_keep_the_characters_starting_in_range() { + let linear = |text: &str, start: usize, end: usize| { + let mut offset = 0; + text.chars() + .filter(|ch| { + let inside = offset >= start && offset < end; + offset += ch.len_utf16(); + inside + }) + .collect::() + }; + let text = "a𠮷漢🇯🇵b"; + let boundaries = Utf16Boundaries::new(text); + for start in 0..10 { + for end in 0..10 { + assert_eq!( + boundaries.slice(start, end), + linear(text, start, end), + "{start}..{end}" + ); + } + } + } + + #[test] + fn emphasis_span_reserves_gutter_and_emits_one_mark_per_upright_cell() { + let mut content = emphasis_content("AB", TextEmphasis::FilledDot); + content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = + AnnotationClearance::Auto; + let layout = layout_content(&content, 400.0); + let upright = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::Upright { .. })) + .count(); + assert_eq!(upright, 2, "AB upright yields two base cells"); + assert_eq!( + layout.emphasis_marks.len(), + upright, + "one emphasis mark per upright base cell" + ); + for column in &layout.columns { + assert!( + column.width > column.base_width, + "an emphasis paragraph reserves a gutter beside the base band" + ); + } + let mark = &layout.emphasis_marks[0]; + assert!( + !layout.emphasis_runs[mark.run].glyphs.is_empty(), + "the emphasis mark must be shaped" + ); + } + + #[test] + fn emphasis_skips_whitespace_cells() { + let content = emphasis_content("A B", TextEmphasis::FilledDot); + let layout = layout_content(&content, 400.0); + let upright = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::Upright { .. })) + .count(); + assert_eq!(upright, 3, "'A B' upright yields three base cells"); + assert_eq!( + layout.emphasis_marks.len(), + 2, + "the whitespace cell gets no emphasis mark" + ); + } + + #[test] + fn emphasis_skips_japanese_commas_stops_and_brackets() { + let content = emphasis_content("A、。(B)」", TextEmphasis::FilledDot); + let layout = layout_content(&content, 400.0); + + assert_eq!( + layout.emphasis_marks.len(), + 2, + "only A and B receive emphasis marks" + ); + } + + #[test] + fn emphasis_follows_the_transformed_text() { + // Uppercase expands ß into SS, so the transformed cells S S 、 A are + // offset from the source text ß、A. + let content = spans_content( + vec![TextSpan { + text_emphasis: TextEmphasis::FilledDot, + text_orientation: TextOrientation::Upright, + text_transform: Some(TextTransform::Uppercase), + ..make_span("ß、A") + }], + 400.0, + ); + let layout = layout_content(&content, 400.0); + assert_eq!(layout.cells.len(), 4); + let marked: Vec = layout.emphasis_marks.iter().map(|mark| mark.cell).collect(); + assert_eq!(marked, vec![0, 1, 3], "S, S and A are marked; 、 is not"); + } + + #[test] + fn no_emphasis_emits_no_marks() { + let content = make_content(&["AB"], 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout.emphasis_marks.is_empty()); + assert!(layout.emphasis_runs.is_empty()); + } + + #[test] + fn long_ruby_stays_inside_the_column_end_under_end_alignment() { + // JLREQ Fig 141: ……の径(こみち) set at the column end; the reading + // overhangs の but never the line end. + let height = 8.0 * EM; + let spans = vec![ + make_span("……の"), + TextSpan { + ruby: "こみち".to_string(), + ..make_span("径") + }, + ]; + let content = content_of( + vec![vertical_paragraph(spans, TextAlign::Right, 1.0)], + height, + GrowType::Fixed, + ); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, height); + let ruby = &layout.ruby_cells[0]; + let end = ruby + .glyph_tops + .iter() + .zip(&ruby.glyphs) + .map(|(top, glyph)| top + glyph.extent) + .fold(f32::MIN, f32::max); + let base = layout.cells.last().expect("base cell"); + assert!( + (base.top + base.extent - height).abs() < 0.01, + "the base sits at the column end" + ); + assert!(end <= height + 0.01, "ruby ends at {end}, past {height}"); + } + + #[test] + fn long_ruby_overhangs_the_kana_side_only() { + // JLREQ Fig 122: の旬(しゅん)又 hangs しゅん over の, never over 又. + let content = spans_content( + vec![ + make_span("の"), + TextSpan { + ruby: "しゅん".to_string(), + ..make_span("旬") + }, + make_span("又"), + ], + 400.0, + ); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let ruby = &layout.ruby_cells[0]; + let base = layout + .cells + .iter() + .find(|cell| cell.span == 1) + .expect("base"); + let start = ruby.glyph_tops[0]; + let end = ruby.glyph_tops[2] + ruby.glyphs[2].extent; + assert!( + (end - (base.top + base.extent)).abs() < 0.01, + "ends with 旬: {end}" + ); + assert!( + (base.top - start - EM / 2.0).abs() < 0.01, + "hangs 1/2 em over の" + ); + } + + #[test] + fn neighbouring_readings_never_meet_over_a_shared_kana() { + // JLREQ Fig 138: に暁(あかつき)の趣(おもむき)を. あかつき hangs over の; + // おもむき may not, so 趣 spreads and the readings stay apart. + let content = spans_content( + vec![ + make_span("に"), + TextSpan { + ruby: "あかつき".to_string(), + ..make_span("暁") + }, + make_span("の"), + TextSpan { + ruby: "おもむき".to_string(), + ..make_span("趣") + }, + make_span("を"), + ], + 400.0, + ); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let no = layout.cells.iter().find(|cell| cell.span == 2).expect("の"); + let (first, second) = (&layout.ruby_cells[0], &layout.ruby_cells[1]); + let first_end = first.glyph_tops[3] + first.glyphs[3].extent; + let second_start = second.glyph_tops[0]; + assert!(first_end > no.top + 0.01, "あかつき hangs over の"); + assert!( + second_start >= no.top + no.extent - 0.01, + "おもむき does not" + ); + assert!( + second_start - first_end >= EM / 2.0 - 0.01, + "a ruby em apart" + ); + } + + #[test] + fn western_ruby_is_one_sideways_word_centred_on_its_base() { + // JLREQ Fig 112: "editor" beside 編集者 turns on its side like base + // Latin text and is never spread letter by letter. + let content = ruby_content("くくくくく", "editor", 400.0); + let layout = layout_content(&content, 400.0); + let ruby = &layout.ruby_cells[0]; + assert_eq!(ruby.glyphs.len(), 6); + assert!(ruby.glyphs.iter().all(|glyph| glyph.sideways)); + for (index, pair) in ruby.glyph_tops.windows(2).enumerate() { + assert!( + (pair[1] - pair[0] - ruby.glyphs[index].extent).abs() < 0.01, + "letters are set at their own advances" + ); + } + let word: f32 = ruby.glyphs.iter().map(|glyph| glyph.extent).sum(); + let base_bottom = layout + .cells + .last() + .map_or(0.0, |cell| cell.top + cell.extent); + let before = ruby.glyph_tops[0] - layout.cells[0].top; + let after = base_bottom - (ruby.glyph_tops[0] + word); + assert!( + (before - after).abs() < 0.01, + "centred: {before} vs {after}" + ); + } + + #[test] + fn ruby_span_reserves_gutter_and_emits_ruby_cells() { + let mut content = ruby_content("AB", "ab", 400.0); + content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = + AnnotationClearance::Auto; + let layout = layout_content(&content, 400.0); + assert!( + !layout.ruby_cells.is_empty(), + "a span with ruby must emit ruby cells" + ); + for column in &layout.columns { + assert!( + column.width > column.base_width, + "a ruby paragraph reserves a gutter beside the base band" + ); + } + let ruby = &layout.ruby_cells[0]; + assert!( + ruby.glyphs.iter().all(|glyph| layout + .ruby_runs + .get(glyph.run) + .and_then(|run| run.glyphs.get(glyph.glyph)) + .is_some()), + "every ruby glyph must retain its shaped run" + ); + } + + #[test] + fn ruby_size_and_side_control_gutter_geometry() { + let mut content = ruby_content("AB", "ab", 400.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.ruby_size = RubySize::Quarter; + span.ruby_side = RubySide::Under; + span.annotation_clearance = AnnotationClearance::Auto; + + let layout = layout_content(&content, 400.0); + let column = &layout.columns[0]; + let ruby = &layout.ruby_cells[0]; + + assert!((ruby.font_size - 5.0).abs() < 0.001); + assert!((column.base_offset - 5.0).abs() < 0.001); + assert!((column.width - column.base_width - 5.0).abs() < 0.001); + assert_eq!(ruby.side, RubySide::Under); + } + + #[test] + fn auto_clearance_stacks_ruby_and_emphasis_gutters() { + let mut none_content = ruby_content("漢字", "かんじ", 400.0); + none_content.paragraphs_mut()[0].children_mut()[0].text_emphasis = TextEmphasis::FilledDot; + let none = layout_content(&none_content, 400.0); + + let mut auto_content = none_content.clone(); + auto_content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = + AnnotationClearance::Auto; + let auto = layout_content(&auto_content, 400.0); + + let none_gutter = none.columns[0].width - none.columns[0].base_width; + let auto_gutter = auto.columns[0].width - auto.columns[0].base_width; + assert!(none_gutter.abs() < 0.001, "none keeps the column advance"); + assert!((auto_gutter - 20.0).abs() < 0.001); + assert!(auto.emphasis_marks[0].outside_offset > 0.0); + } + + #[test] + fn ruby_attachment_distance_is_independent_of_line_height() { + let compact_content = ruby_content_with_line_height(1.0); + let loose_content = ruby_content_with_line_height(2.0); + let compact = layout_content(&compact_content, 400.0); + let loose = layout_content(&loose_content, 400.0); + + let attachment_gap = |layout: &VerticalLayout| { + let ruby = &layout.ruby_cells[0]; + let column = &layout.columns[ruby.column]; + let base_right = column_base_center(column) + ruby.base_font_size / 2.0; + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) - base_right + }; + + assert!( + loose.columns[0].base_width > compact.columns[0].base_width, + "line height must still increase column progression" + ); + assert!(attachment_gap(&compact).abs() < 0.001); + assert!(attachment_gap(&loose).abs() < 0.001); + + let loose_positions = position_data(&loose, &loose_content.bounds(), VerticalAlign::Top); + let ruby_position = loose_positions + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) + .expect("ruby position data"); + assert!( + (ruby_position.width - loose.ruby_cells[0].font_size).abs() < 0.001, + "export geometry must use the attached ruby strip, not the line-height band" + ); + } + + #[test] + fn vertical_ruby_preserves_every_fallback_run() { + let content = ruby_content("AB", "aあ", 400.0); + let provider = provider_with_fallback(TEST_FONT, VMTX_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]); + + let referenced_runs: std::collections::BTreeSet = layout + .ruby_cells + .iter() + .flat_map(|cell| cell.glyphs.iter().map(|glyph| glyph.run)) + .collect(); + assert_eq!(referenced_runs.len(), 2, "ruby must retain both typefaces"); + assert_eq!( + layout + .ruby_cells + .iter() + .map(|cell| cell.glyphs.len()) + .sum::(), + layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum(), + "every fallback glyph must be assigned to a ruby cell" + ); + + let position = position_data(&layout, &content.bounds(), VerticalAlign::Top); + let ruby_position = position + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RUBY) + .expect("ruby position data"); + assert_eq!(ruby_position.start_pos, 0); + assert_eq!(ruby_position.end_pos, 2); + } + + #[test] + fn no_ruby_keeps_base_width_equal_to_width() { + let content = make_content(&["AB"], 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout.ruby_cells.is_empty()); + for column in &layout.columns { + assert_eq!( + column.width, column.base_width, + "columns without ruby must not reserve a gutter" + ); + } + } + + #[test] + fn ruby_side_attaches_to_opposite_base_edges() { + let column = VerticalColumn { + x: 10.0, + width: 40.0, + base_offset: 8.0, + base_width: 24.0, + }; + + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Over), 40.0); + assert_eq!(ruby_strip_x(&column, 8.0, 20.0, RubySide::Under), 12.0); + } + + #[test] + fn long_ruby_spreads_a_vertical_base_with_half_gaps_at_the_ends() { + let plain = layout_content(&make_content(&["日本"], 1000.0), 1000.0); + let mut content = ruby_content("日本", "にほんごです", 1000.0); + content.paragraphs_mut()[0].children_mut()[0].ruby_overhang = RubyOverhang::None; + let layout = layout_content(&content, 1000.0); + // Six half-em ruby glyphs; what the base lacks is shared by its two + // characters. + let base: f32 = plain.cells.iter().map(|cell| cell.extent).sum(); + let gap = (6.0 * EM / 2.0 - base) / 2.0; + assert!(gap > 0.0); + for (spread, solid) in layout.cells.iter().zip(&plain.cells) { + assert!((spread.extent - (solid.extent + gap)).abs() < 0.01); + assert!((spread.glyph_flow_shift - (solid.glyph_flow_shift + gap / 2.0)).abs() < 0.01); + } + } + + #[test] + fn ruby_base_spreading_expands_gap_for_long_reading() { + let content = ruby_content("日本", "にほんご", 120.0); + let layout = layout_content(&content, 120.0); + let plain = layout_content(&make_content(&["日本"], 120.0), 120.0); + let ruby_cells: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell.span == 0 && cell.column == 0) + .collect(); + assert_eq!(ruby_cells.len(), 2); + let spacing = ruby_cells[1].top - ruby_cells[0].top; + assert!( + spacing > plain.cells[1].top - plain.cells[0].top, + "long ruby should spread the base characters apart, got {}", + spacing + ); + let base_extent = ruby_cells[1].top + ruby_cells[1].extent - ruby_cells[0].top; + assert!( + base_extent >= 40.0, + "base span should be at least as long as the 4-glyph half-em ruby line" + ); + } + + #[test] + fn ruby_base_spreading_does_not_overlap_following_text() { + let content = make_content_with_spans(&["日本", "語"], 60.0); + let mut content = content; + content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんご".to_string(); + let layout = layout_content(&content, 60.0); + let mut same_column: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell.column == 0) + .collect(); + same_column.sort_by(|a, b| { + a.top + .partial_cmp(&b.top) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for pair in same_column.windows(2) { + assert!( + pair[0].top + pair[0].extent <= pair[1].top + 0.001, + "base spreading must not overlap the following cell" + ); + } + } + + #[test] + fn ruby_base_spreading_forces_room_when_no_slack() { + // Base 日本 (40) with a 60-long reading, then 語, in a 60 column: with + // no slack after placement, the base grows before planning and pushes + // 語 to the next column. + let mut content = make_content_with_spans(&["日本", "語"], 60.0); + content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんごです".to_string(); + let layout = layout_content(&content, 60.0); + let base: Vec<&VerticalCell> = layout.cells.iter().filter(|c| c.span == 0).collect(); + assert_eq!(base.len(), 2); + let base_extent = + base.last().unwrap().top + base.last().unwrap().extent - base.first().unwrap().top; + assert!( + base_extent >= 60.0 - 0.001, + "the base span must grow to the ruby line length, got {}", + base_extent + ); + let follower = layout + .cells + .iter() + .find(|c| c.span == 1) + .expect("follower cell"); + assert_ne!( + follower.column, base[0].column, + "the follower must wrap to the next column instead of overlapping" + ); + } + + #[test] + fn emphasis_marks_sideways_letters_and_tcy() { + let content = spans_content( + vec![ + TextSpan { + text_emphasis: TextEmphasis::FilledDot, + ..make_span("かabc") + }, + TextSpan { + text_emphasis: TextEmphasis::FilledDot, + text_combine_upright: crate::shapes::TextCombineUpright::All, + ..make_span("12") + }, + ], + 400.0, + ); + let layout = layout_with(&provider_with_fallback(VMTX_TEST_FONT, TEST_FONT), &content); + assert_eq!( + layout.emphasis_marks.len(), + 5, + "か, a, b, c and the TCY composite each get one mark" + ); + } + + #[test] + fn emphasis_mark_centres_on_the_glyph_not_the_spacing() { + let content = spans_content( + vec![TextSpan { + text_emphasis: TextEmphasis::FilledDot, + letter_spacing: 10.0, + ..make_span("かき") + }], + 400.0, + ); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + for mark in &layout.emphasis_marks { + let cell = &layout.cells[mark.cell]; + let ink_centre = cell.top + (cell.ink_top + cell.ink_bottom) / 2.0; + let (_, centre) = emphasis_mark_center(&layout, mark); + assert!( + (centre - ink_centre).abs() < 0.01, + "mark at {centre}, glyph ink centred at {ink_centre}" + ); + } + } + + fn neighbour_ruby_content(overhang: RubyOverhang) -> TextContent { + spans_content( + vec![ + make_span("か"), + TextSpan { + ruby: "かんじかんじ".to_string(), + ruby_overhang: overhang, + ..make_span("漢") + }, + TextSpan { + ruby: "じ".to_string(), + ..make_span("字") + }, + make_span("か"), + ], + 1000.0, + ) + } + + fn ruby_of_span(layout: &VerticalLayout, span: usize) -> &RubyCell { + layout + .ruby_cells + .iter() + .find(|ruby| ruby.span == span) + .expect("ruby cell") + } + + #[test] + fn long_reading_never_covers_the_next_ruby_base() { + let layout = layout_with( + &provider(VMTX_TEST_FONT), + &neighbour_ruby_content(RubyOverhang::Auto), + ); + let ruby = ruby_of_span(&layout, 1); + let next_base = layout.cells.iter().find(|cell| cell.span == 2).unwrap(); + let ruby_bottom = ruby.glyph_tops.last().unwrap() + ruby.font_size; + assert!( + ruby_bottom <= next_base.top + 0.01, + "reading ends at {ruby_bottom}, the next ruby base starts at {}", + next_base.top + ); + } + + #[test] + fn long_reading_overhangs_kana_by_at_most_one_ruby_character() { + let layout = layout_with( + &provider(VMTX_TEST_FONT), + &neighbour_ruby_content(RubyOverhang::Auto), + ); + let ruby = ruby_of_span(&layout, 1); + let base = layout.cells.iter().find(|cell| cell.span == 1).unwrap(); + assert!(ruby.glyph_tops[0] >= base.top - ruby.font_size - 0.01); + } + + #[test] + fn long_reading_without_overhang_grows_a_single_base() { + let layout = layout_with( + &provider(VMTX_TEST_FONT), + &neighbour_ruby_content(RubyOverhang::None), + ); + let ruby = ruby_of_span(&layout, 1); + let base = layout.cells.iter().find(|cell| cell.span == 1).unwrap(); + assert!(ruby.glyph_tops[0] >= base.top - 0.01); + assert!(ruby.glyph_tops.last().unwrap() + ruby.font_size <= base.top + base.extent + 0.01); + } + + #[test] + fn ruby_base_moves_to_the_next_column_whole() { + let content = |height: f32| { + spans_content( + vec![ + make_span("あい"), + TextSpan { + ruby: "にほん".to_string(), + ..make_span("日本") + }, + ], + height, + ) + }; + let wide = layout_content(&content(1000.0), 1000.0); + // Room for あい and the first base character only. + let height = wide.cells[..3].iter().map(|cell| cell.extent).sum::() + 0.5; + let layout = layout_content(&content(height), height); + let base_columns: Vec = layout + .cells + .iter() + .filter(|cell| cell.span == 1) + .map(|cell| cell.column) + .collect(); + assert_eq!(base_columns, vec![1, 1], "the ruby base is one group"); + let ruby_columns: std::collections::BTreeSet = + layout.ruby_cells.iter().map(|r| r.column).collect(); + assert_eq!(ruby_columns.into_iter().collect::>(), vec![1]); + } + + #[test] + fn ruby_base_longer_than_a_column_keeps_its_reading_whole() { + let content = ruby_content("日本語文", "にほんごぶん", 40.0); + let layout = layout_content(&content, 40.0); + + let base_columns: std::collections::BTreeSet = + layout.cells.iter().map(|c| c.column).collect(); + assert_eq!(base_columns.len(), 1, "the ruby base never wraps"); + assert_eq!(layout.ruby_cells.len(), 1, "the reading stays in one piece"); + let placed: usize = layout.ruby_cells.iter().map(|r| r.glyphs.len()).sum(); + assert_eq!( + placed, + layout.ruby_runs.iter().map(|run| run.glyphs.len()).sum() + ); + } +} diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs new file mode 100644 index 0000000000..0e03b51c07 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -0,0 +1,1340 @@ +// Builders that turn one span's text into flow cells: one per upright glyph +// cluster, one per rotated (sideways) run, and one per tate-chu-yoko +// composite. Warichu notes span several spans and build their own cells. + +use std::ops::Range; + +use skia_safe::{textlayout::TypefaceFontProvider, FontMgr, Typeface}; + +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}; +use crate::shapes::{FontFeatures, TextCombineUpright, TextSpan}; + +use super::flow::{minimum_oikomi_extent, FlowCell, FlowScript, FIT_TOLERANCE}; +use super::font_tables::{upright_baseline, vertical_metrics, vpal_table}; +use super::layout::{CellKind, VerticalCell}; +use super::orientation::{ + is_upright_char, segment_by_orientation, uses_rotated_vertical_fallback, Segment, +}; +use super::shaping::{resolve_typefaces, shape_with_typefaces, span_font_families, ShapedRun}; + +/// Smallest horizontal scale for `all` tate-chu-yoko; digit modes use 1 / +/// run length. +const MIN_TCY_SCALE: f32 = 0.5; + +/// Fonts available to the layout: the provider's registered faces and the +/// fallback families tried after each span's own font. +pub(super) struct Fonts<'a> { + pub provider: &'a TypefaceFontProvider, + pub fallback_mgr: &'a FontMgr, + pub fallback_families: &'a [String], +} + +/// Centered punctuation (jlreq cl-05 中点類: middle dot, full-width colon and +/// semicolon), set at the centre of the em box in vertical writing. +fn is_centered_punctuation(c: char) -> bool { + classify(c) == JapaneseClass::MiddleDot +} + +/// Flow-axis shift that centres a glyph's ink band in its em body. +fn centered_flow_shift(ink_top: f32, ink_bottom: f32, em_body: f32) -> f32 { + em_body / 2.0 - (ink_top + ink_bottom) / 2.0 +} + +/// A single-glyph cluster whose font has no vertical alternate for it: the +/// shaped glyph is still the horizontal one. +fn needs_rotated_vertical_fallback(run: &ShapedRun, glyph: usize, count: usize, ch: char) -> bool { + count == 1 + && uses_rotated_vertical_fallback(ch) + && run.glyphs[glyph] == run.font.unichar_to_glyph(ch as i32) +} + +fn is_tcy_digit(c: char) -> bool { + c.is_ascii_digit() || ('0'..='9').contains(&c) +} + +/// Split a TCY `digits` span into pieces. Maximal ASCII or full-width digit +/// runs of 2..=max chars are marked tcy; the rest keep normal vertical +/// layout. Returns (piece text, span-relative UTF-16 start, tcy). +fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> { + let mut pieces: Vec<(String, usize, bool)> = Vec::new(); + let mut utf16 = 0usize; + for c in text.chars() { + let digit = is_tcy_digit(c); + match pieces.last_mut() { + Some((piece, _, piece_digit)) if *piece_digit == digit => piece.push(c), + _ => pieces.push((c.to_string(), utf16, digit)), + } + utf16 += c.len_utf16(); + } + for (piece, _, tcy) in pieces.iter_mut() { + *tcy = *tcy && (2..=max).contains(&piece.chars().count()); + } + pieces +} + +/// UTF-16 offset, within the shaped piece, of a UTF-8 offset in `segment`. +/// A sideways segment split after each run of spaces, so every word is its +/// own cell and a column may break between words. Upright segments stay +/// whole. +fn split_sideways_words(segment: Segment) -> Vec { + if segment.upright { + return vec![segment]; + } + let mut words: Vec = Vec::new(); + let mut utf16 = segment.utf16_start; + let mut previous_space = false; + for ch in segment.text.chars() { + match words.last_mut() { + Some(word) if !previous_space || ch.is_whitespace() => word.text.push(ch), + _ => words.push(Segment { + text: ch.to_string(), + utf16_start: utf16, + upright: false, + }), + } + previous_space = ch.is_whitespace(); + utf16 += ch.len_utf16(); + } + words +} + +fn segment_utf16(segment: &Segment, utf8: usize) -> usize { + Utf16Cursor::new(segment).at(utf8) +} + +/// Paragraph UTF-16 offsets of a segment's UTF-8 offsets, counted from the +/// last lookup so ascending lookups walk the text once. +struct Utf16Cursor<'a> { + segment: &'a Segment, + utf8: usize, + utf16: usize, +} + +impl<'a> Utf16Cursor<'a> { + fn new(segment: &'a Segment) -> Self { + Self { + segment, + utf8: 0, + utf16: segment.utf16_start, + } + } + + fn at(&mut self, utf8: usize) -> usize { + let utf8 = utf8.min(self.segment.text.len()); + if utf8 < self.utf8 { + *self = Self::new(self.segment); + } + self.utf16 += self.segment.text[self.utf8..utf8] + .chars() + .map(char::len_utf16) + .sum::(); + self.utf8 = utf8; + self.utf16 + } +} + +/// Cell builder for one span of a paragraph. +pub(super) struct SpanCells<'a> { + fonts: &'a Fonts<'a>, + span: &'a TextSpan, + paragraph: usize, + span_index: usize, + paint: usize, + /// UTF-16 offset of the span in its paragraph's layout text. + start: usize, + /// Typefaces of the span's own font, then emoji and the registered + /// fallback fonts. + typefaces: Vec, +} + +impl<'a> SpanCells<'a> { + pub(super) fn new( + fonts: &'a Fonts<'a>, + span: &'a TextSpan, + paragraph: usize, + span_index: usize, + paint: usize, + start: usize, + ) -> Self { + Self { + fonts, + span, + paragraph, + span_index, + paint, + start, + typefaces: resolve_typefaces( + &span_font_families(span, fonts.fallback_families), + fonts.provider, + ), + } + } + + fn shape(&self, text: &str, font_size: f32, upright: bool) -> Vec { + shape_with_typefaces( + text, + font_size, + &self.typefaces, + upright, + self.span.font_features, + self.fonts.fallback_mgr, + ) + } + + /// A cell of this span covering `start..end` (paragraph UTF-16), whose + /// ink fills its extent. + fn cell(&self, kind: CellKind, start: usize, end: usize, extent: f32) -> VerticalCell { + VerticalCell { + kind, + paragraph: self.paragraph, + span: self.span_index, + start, + end, + column: 0, + top: 0.0, + extent, + minimum_oikomi_extent: extent, + h_advance: self.span.font_size, + ink_top: 0.0, + ink_bottom: extent - self.span.letter_spacing, + paint: self.paint, + font_size: self.span.font_size, + decoration: self.span.text_decoration, + glyph_flow_shift: 0.0, + } + } + + /// Push the cells of the span's layout `text`. Runs are appended to + /// `runs`, which the cells index. + pub(super) fn push(&self, text: &str, runs: &mut Vec, cells: &mut Vec) { + let combine = self.span.text_combine_upright; + if combine == TextCombineUpright::All + && self.push_tate_chu_yoko(text, 0, MIN_TCY_SCALE, runs, cells) + { + return; + } + // `digits` merges each run of 2..=max digits into one upright cell. + let pieces = match combine.digits_max() { + Some(max) => split_digit_runs(text, max), + None => vec![(text.to_string(), 0, false)], + }; + for (piece, piece_start, tcy) in &pieces { + let min_scale = 1.0 / piece.chars().count().max(1) as f32; + if *tcy && self.push_tate_chu_yoko(piece, *piece_start, min_scale, runs, cells) { + continue; + } + let piece_base = self.start + piece_start; + let segments = segment_by_orientation(piece, self.span.text_orientation) + .into_iter() + .flat_map(split_sideways_words); + for segment in segments { + for mut run in self.shape(&segment.text, self.span.font_size, segment.upright) { + let run_index = runs.len(); + if segment.upright { + self.push_upright_clusters(&segment, piece_base, &run, run_index, cells); + } else { + cells.push(self.rotated_cell(&segment, piece_base, &mut run, run_index)); + } + runs.push(run); + } + } + } + } + + /// Compose `piece` (a whole TCY span or a digit run in one) into one + /// upright composite cell, with fallback runs side by side. Returns false + /// when the composite would squeeze below `min_scale`; the caller then uses + /// normal vertical layout. + fn push_tate_chu_yoko( + &self, + piece: &str, + piece_start: usize, + min_scale: f32, + runs: &mut Vec, + cells: &mut Vec, + ) -> bool { + // A lone upright CJK char (e.g. a ruby base span that inherits `all`) + // skips TCY, which would shrink it below its neighbours. + let mut chars = piece.chars(); + if matches!((chars.next(), chars.next()), (Some(ch), None) if is_upright_char(ch)) { + return false; + } + + let letter_spacing = self.span.letter_spacing; + let mut shaped = self.shape(piece, self.span.font_size, false); + if shaped.is_empty() { + return false; + } + let em = self.span.font_size.max(1.0); + let mut combined_advance = 0.0f32; + for run in &mut shaped { + run.advance = run.spread_glyphs(letter_spacing); + combined_advance += run.advance; + } + // Letter-spacing also separates adjacent fallback runs. + combined_advance += letter_spacing * (shaped.len() - 1) as f32; + // Only a composite wider than the em is squeezed, and only across: + // glyphs keep their full height, as in JLREQ and CSS. + let h_scale = (em / combined_advance.max(1.0)).min(1.0); + if h_scale < min_scale { + return false; + } + + let extent = em + letter_spacing; + let kind = CellKind::TateChuYoko { + run_start: runs.len(), + run_count: shaped.len(), + h_scale, + }; + runs.extend(shaped); + let start = self.start + piece_start; + let end = start + piece.encode_utf16().count(); + let cell = VerticalCell { + h_advance: combined_advance * h_scale, + ink_bottom: em, + ..self.cell(kind, start, end, extent) + }; + cells.push(FlowCell::new( + cell, + None, + FlowScript::Upright, + letter_spacing, + )); + true + } + + /// One upright cell per glyph cluster of `run`, so combining sequences + /// stay in one cell. + fn push_upright_clusters( + &self, + segment: &Segment, + piece_base: usize, + run: &ShapedRun, + run_index: usize, + cells: &mut Vec, + ) { + let font_size = self.span.font_size; + let letter_spacing = self.span.letter_spacing; + let vmetrics = vertical_metrics(&run.font); + // vpal applies here: SkShaper's horizontal shaping skips vertical GPOS. + let vpal = (self.span.font_features == FontFeatures::Vpal) + .then(|| vpal_table(&run.font)) + .flatten(); + + let mut utf16 = Utf16Cursor::new(segment); + for (glyph, count) in run.cluster_spans() { + let glyphs = &run.glyphs[glyph..glyph + count]; + let cluster_utf8 = run.clusters[glyph] as usize; + let next_cluster_utf8 = run + .clusters + .get(glyph + count) + .map_or(run.utf8_range.end, |c| *c as usize); + let ch = segment.text[cluster_utf8..].chars().next(); + let h_advance = match run.cluster_advance(glyph, count) { + advance if advance > 0.0 => advance, + _ => font_size, + }; + // No `vmtx`: h-advance, with forced-upright Latin floored to an em. + let horizontal_fallback = if ch.is_some_and(|c| !is_upright_char(c)) { + h_advance.max(font_size) + } else { + h_advance + }; + let vertical_advance = vmetrics + .as_ref() + .map(|vm| { + glyphs + .iter() + .map(|g| vm.advance(*g, font_size)) + .sum::() + }) + .filter(|advance| *advance > 0.0) + .unwrap_or(horizontal_fallback); + // vpal tightens the cell and lifts the ink by the font's deltas. + let vpal_delta = vpal + .as_ref() + .and_then(|table| table.cluster_delta(glyphs, font_size)); + let (vpal_advance, vpal_flow_shift) = vpal_delta.unwrap_or((0.0, 0.0)); + let extent = vertical_advance + vpal_advance + letter_spacing; + + let synthetic_rotation = + ch.is_some_and(|ch| needs_rotated_vertical_fallback(run, glyph, count, ch)); + let ink = run.cluster_ink_bounds(glyph, count).map(|ink| { + if synthetic_rotation { + (ink.left, ink.right) + } else { + let offset = upright_baseline(&run.font, run.font.size()); + (ink.top + offset, ink.bottom + offset) + } + }); + let (ink_top, ink_bottom) = ink.unwrap_or((0.0, extent - letter_spacing)); + let (mut ink_top, mut ink_bottom) = + (ink_top + vpal_flow_shift, ink_bottom + vpal_flow_shift); + // Centre cl-05 and forced-upright Western ink in the em body, + // unless vpal already centres it. Western glyphs hung from a + // shared baseline leave uneven gaps: a descender meets the next + // cell's ascender (`gf`) while x-height letters float apart. + let glyph_flow_shift = if !synthetic_rotation + && vpal_delta.is_none() + && ink.is_some() + && ch.is_some_and(|c| is_centered_punctuation(c) || !is_upright_char(c)) + { + let shift = centered_flow_shift(ink_top, ink_bottom, extent - letter_spacing); + ink_top += shift; + ink_bottom += shift; + shift + } else { + vpal_flow_shift + }; + + let kind = if synthetic_rotation { + CellKind::SyntheticRotated { + run: run_index, + glyph, + count, + } + } else { + CellKind::Upright { + run: run_index, + glyph, + count, + } + }; + let start = piece_base + utf16.at(cluster_utf8); + let end = piece_base + utf16.at(next_cluster_utf8); + let cell = VerticalCell { + minimum_oikomi_extent: minimum_oikomi_extent(ch, extent, font_size, letter_spacing), + h_advance, + ink_top, + ink_bottom, + glyph_flow_shift, + ..self.cell(kind, start, end, extent) + }; + cells.push(FlowCell::new(cell, ch, FlowScript::Upright, letter_spacing)); + } + } + + /// One cell for a whole sideways Western run. Letter-spacing spreads its + /// glyphs down the column (post-rotation +x). + fn rotated_cell( + &self, + segment: &Segment, + piece_base: usize, + run: &mut ShapedRun, + run_index: usize, + ) -> FlowCell { + let letter_spacing = self.span.letter_spacing; + run.normalize_word_spaces(&segment.text, self.span.font_size); + let extent = run.spread_glyphs(letter_spacing); + let run_text = &segment.text[run.utf8_range.clone()]; + let script = FlowScript::Rotated { + starts_alphanumeric: run_text.chars().next().is_some_and(char::is_alphanumeric), + ends_alphanumeric: run_text + .chars() + .next_back() + .is_some_and(char::is_alphanumeric), + }; + let start = piece_base + segment_utf16(segment, run.utf8_range.start); + let end = piece_base + segment_utf16(segment, run.utf8_range.end); + let (ink_top, ink_bottom) = run + .ink_bounds() + .map_or((0.0, extent - letter_spacing), |ink| (ink.left, ink.right)); + let cell = VerticalCell { + // Unused for centring: rotated runs draw from `run.positions`. + h_advance: run.advance, + ink_top, + ink_bottom, + ..self.cell(CellKind::Rotated { run: run_index }, start, end, extent) + }; + FlowCell::new(cell, None, script, letter_spacing) + } +} + +/// Most characters a warichu piece split moves back to keep kinsoku. +const MAX_WARICHU_KINSOKU_SHIFT: usize = 4; + +/// One character of a warichu note. +#[derive(Debug, Clone, Copy)] +struct NoteChar { + ch: char, + /// Index of the owning span in the note's members. + member: usize, + /// UTF-8 offset in the member's text. + utf8: usize, + /// Paragraph UTF-16 offset. + utf16: usize, +} + +/// One span's share of a shaped warichu piece: its characters on each +/// sub-line and where they start along the piece. +struct PieceSegment { + member: usize, + chars: Range, + first_runs: Vec, + second_runs: Vec, + first_chars: usize, + first_top: f32, + first_extent: f32, + second_top: f32, + second_extent: f32, +} + +/// A shaped warichu piece: the part of a note set in one column. +struct ShapedPiece { + segments: Vec, + extent: f32, + font_size: f32, + letter_spacing: f32, +} + +/// Warichu (割注): consecutive warichu spans of a paragraph set as one note +/// of two half-size sub-lines (the first on the right, jlreq reading order). +/// The note breaks into pieces at paragraph UTF-16 `splits`, one per column. +/// Each piece emits one cell per span it touches; the first carries the +/// piece's flow extent and the rest share its box. +pub(super) struct WarichuNote<'a> { + members: Vec<(SpanCells<'a>, &'a str)>, + chars: Vec, +} + +impl<'a> WarichuNote<'a> { + pub(super) fn new(members: Vec<(SpanCells<'a>, &'a str)>) -> Self { + let mut chars = Vec::new(); + for (member, (span_cells, text)) in members.iter().enumerate() { + let mut utf16 = span_cells.start; + for (utf8, ch) in text.char_indices() { + chars.push(NoteChar { + ch, + member, + utf8, + utf16, + }); + utf16 += ch.len_utf16(); + } + } + Self { members, chars } + } + + /// True when `utf16` (paragraph offset) falls inside the note. + pub(super) fn contains(&self, utf16: usize) -> bool { + match (self.chars.first(), self.chars.last()) { + (Some(first), Some(last)) => { + (first.utf16..last.utf16 + last.ch.len_utf16()).contains(&utf16) + } + _ => false, + } + } + + /// Push the note's cells, or its spans' normal cells when it cannot be + /// set as warichu (fewer than two characters, or a sub-line that shapes + /// empty). + pub(super) fn push( + &self, + splits: &[usize], + runs: &mut Vec, + cells: &mut Vec, + ) { + if self.chars.len() >= 2 { + let shaped: Option> = self + .pieces(splits) + .into_iter() + .map(|piece| self.shape_piece(piece)) + .collect(); + if let Some(pieces) = shaped { + for piece in pieces { + self.push_piece(piece, runs, cells); + } + return; + } + } + for (span_cells, text) in &self.members { + span_cells.push(text, runs, cells); + } + } + + /// Paragraph UTF-16 offset where the piece starting at `piece_start` + /// should break so its first part fits in `room`, or None when no part + /// of at least one character per sub-line fits. The break keeps two + /// characters on each side and moves back to keep kinsoku. + pub(super) fn split_for_room( + &self, + piece_start: usize, + splits: &[usize], + room: f32, + ) -> Option { + let piece = self + .pieces(splits) + .into_iter() + .find(|piece| self.chars[piece.start].utf16 == piece_start)?; + if piece.len() < 4 { + return None; + } + let half_size = self.max_font_size(piece.clone()) * WARICHU_FONT_SCALE; + let estimate = 2 * (room / half_size.max(1.0)).floor().max(0.0) as usize; + let longest = (estimate + 2).min(piece.len() - 2); + let mut cut = longest; + while cut >= 2 { + let mut at = piece.start + cut; + let mut shift = 0; + while at > piece.start + 2 + && shift < MAX_WARICHU_KINSOKU_SHIFT + && (forbidden_at_line_start(self.chars[at].ch) + || forbidden_at_line_end(self.chars[at - 1].ch)) + { + at -= 1; + shift += 1; + } + let fits = self + .shape_piece(piece.start..at) + .is_some_and(|shaped| shaped.extent <= room + FIT_TOLERANCE); + if fits { + return Some(self.chars[at].utf16); + } + cut = (at - piece.start).min(cut) - 1; + } + None + } + + /// Char index ranges of the pieces cut at `splits`. + fn pieces(&self, splits: &[usize]) -> Vec> { + let mut pieces = Vec::new(); + let mut start = 0; + for (index, note_char) in self.chars.iter().enumerate().skip(1) { + if splits.contains(¬e_char.utf16) { + pieces.push(start..index); + start = index; + } + } + pieces.push(start..self.chars.len()); + pieces + } + + fn max_font_size(&self, chars: Range) -> f32 { + self.chars[chars] + .iter() + .map(|c| self.members[c.member].0.span.font_size) + .fold(0.0, f32::max) + } + + /// Member text of the chars `range`, which belong to one member. + fn member_text(&self, range: Range) -> &'a str { + let first = self.chars[range.start]; + let text = self.members[first.member].1; + let end = self + .chars + .get(range.end) + .filter(|next| next.member == first.member) + .map_or(text.len(), |next| next.utf8); + &text[first.utf8..end] + } + + /// Shape one sub-line part of a member; empty parts shape to no runs. + fn shape_part(&self, member: usize, range: Range) -> Option<(Vec, f32)> { + if range.is_empty() { + return Some((Vec::new(), 0.0)); + } + let span_cells = &self.members[member].0; + let half_size = span_cells.span.font_size * WARICHU_FONT_SCALE; + let runs = span_cells.shape(self.member_text(range), half_size, true); + if runs.is_empty() { + return None; + } + let advance = runs.iter().map(|r| r.advance).sum(); + Some((runs, advance)) + } + + fn shape_piece(&self, piece: Range) -> Option { + let text: String = self.chars[piece.clone()].iter().map(|c| c.ch).collect(); + let split = piece.start + warichu_split_chars(&text); + let mut segments = Vec::new(); + let (mut first_cursor, mut second_cursor) = (0.0f32, 0.0f32); + let mut index = piece.start; + while index < piece.end { + let member = self.chars[index].member; + let end = (index..piece.end) + .find(|&i| self.chars[i].member != member) + .unwrap_or(piece.end); + let first = index.min(split)..end.min(split); + let second = index.max(split)..end.max(split); + let first_chars = self.chars[first.clone()] + .iter() + .map(|c| c.ch.len_utf16()) + .sum(); + let (first_runs, first_extent) = self.shape_part(member, first)?; + let (second_runs, second_extent) = self.shape_part(member, second)?; + segments.push(PieceSegment { + member, + chars: index..end, + first_runs, + second_runs, + first_chars, + first_top: first_cursor, + first_extent, + second_top: second_cursor, + second_extent, + }); + first_cursor += first_extent; + second_cursor += second_extent; + index = end; + } + let letter_spacing = self.chars[piece.clone()] + .iter() + .map(|c| self.members[c.member].0.span.letter_spacing) + .fold(0.0, f32::max); + Some(ShapedPiece { + segments, + extent: first_cursor.max(second_cursor) + letter_spacing, + font_size: self.max_font_size(piece), + letter_spacing, + }) + } + + fn push_piece(&self, piece: ShapedPiece, runs: &mut Vec, cells: &mut Vec) { + for (index, segment) in piece.segments.into_iter().enumerate() { + let span_cells = &self.members[segment.member].0; + let kind = CellKind::Warichu { + run_start: runs.len(), + run_count: segment.first_runs.len() + segment.second_runs.len(), + first_count: segment.first_runs.len(), + first_chars: segment.first_chars, + first_top: segment.first_top, + first_extent: segment.first_extent, + second_top: segment.second_top, + second_extent: segment.second_extent, + }; + runs.extend(segment.first_runs); + runs.extend(segment.second_runs); + let last = self.chars[segment.chars.end - 1]; + let start = self.chars[segment.chars.start].utf16; + let end = last.utf16 + last.ch.len_utf16(); + // Two half-em sub-columns fill the em, the default `h_advance`. + let cell = VerticalCell { + font_size: piece.font_size, + ..span_cells.cell(kind, start, end, piece.extent) + }; + let mut flow = FlowCell::new(cell, None, FlowScript::Upright, piece.letter_spacing); + if index > 0 { + flow.shares_previous_box = true; + flow.cell.extent = 0.0; + flow.cell.minimum_oikomi_extent = 0.0; + } + cells.push(flow); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::layout::VerticalCell; + use super::super::shaping::WESTERN_WORD_SPACING_EM; + use super::super::test_support::*; + use super::*; + use crate::shapes::{Fill, SolidColor, TextDecoration, TextOrientation}; + use skia_safe as skia; + + fn vpal_content(text: &str, font_features: FontFeatures) -> crate::shapes::TextContent { + spans_content( + vec![TextSpan { + font_features, + ..make_span(text) + }], + 1000.0, + ) + } + + #[test] + fn missing_vertical_alternate_uses_a_character_granular_rotated_cell() { + let mut content = make_content(&["“"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&provider(TEST_FONT), &content); + assert_eq!(layout.cells.len(), 1); + assert!(matches!( + layout.cells[0].kind, + CellKind::SyntheticRotated { .. } + )); + } + + #[test] + fn upright_narrow_latin_reserves_a_full_em_without_vmtx() { + // Under text-orientation: upright, Latin letters stand upright. The + // test face has no `vmtx`, so each letter advances a full em. + let em = 20.0; + let mut content = make_content(&["ab"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&provider(TEST_FONT), &content); + assert_eq!(layout.cells.len(), 2, "one upright cell per Latin letter"); + for (index, cell) in layout.cells.iter().enumerate() { + assert!( + matches!(cell.kind, CellKind::Upright { .. }), + "cell {index} should be an upright Latin letter" + ); + assert!( + (cell.extent - em).abs() < 0.5, + "upright Latin cell {index} reserves a full em, got {}", + cell.extent + ); + } + // The letters advance a full em down the column, so they never overlap. + assert!( + layout.cells[1].top - layout.cells[0].top >= em - 0.5, + "successive upright letters are one em apart, got {}", + layout.cells[1].top - layout.cells[0].top + ); + } + + #[test] + fn upright_latin_ink_is_centred_in_its_cell() { + // A descender followed by an ascender (`gf`) must not touch: each + // letter's ink sits in the middle of its em cell. + let mut content = make_content(&["gf"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&provider(TEST_FONT), &content); + for cell in &layout.cells { + let ink_middle = (cell.ink_top + cell.ink_bottom) / 2.0; + assert!( + (ink_middle - cell.extent / 2.0).abs() < 0.01, + "ink middle {ink_middle} in a {} cell", + cell.extent + ); + } + let (g, f) = (&layout.cells[0], &layout.cells[1]); + let gap = f.top + f.ink_top - (g.top + g.ink_bottom); + assert!(gap > 0.2 * EM, "g/f ink gap {gap}"); + } + + #[test] + fn warichu_span_composes_two_half_size_sub_lines() { + let content = warichu_content("ABCD", 400.0); + let layout = layout_content(&content, 400.0); + assert_eq!(layout.cells.len(), 1, "the whole span is one composite"); + let cell = &layout.cells[0]; + let CellKind::Warichu { + run_count, + first_count, + .. + } = cell.kind + else { + panic!("expected a warichu cell"); + }; + assert!(first_count >= 1 && run_count > first_count); + // Two half-size chars per sub-line: about one em, not the four em of + // normal layout. + assert!( + cell.extent < 2.0 * 20.0, + "two half-size sub-lines take about one em, got {}", + cell.extent + ); + assert!( + (cell.h_advance - 20.0).abs() < 0.001, + "the composite fills the full em width" + ); + assert_eq!(cell.start, 0); + assert_eq!(cell.end, 4); + } + + #[test] + fn warichu_single_char_keeps_normal_layout() { + let content = warichu_content("A", 400.0); + let layout = layout_content(&content, 400.0); + assert!(layout + .cells + .iter() + .all(|c| !matches!(c.kind, CellKind::Warichu { .. }))); + } + + #[test] + fn tate_chu_yoko_span_becomes_one_upright_cell() { + let mut content = make_content_with_spans(&["20", "年"], 200.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::All; + + let layout = layout_content(&content, 200.0); + + assert_eq!(layout.cells.len(), 2); + assert!(matches!(layout.cells[0].kind, CellKind::TateChuYoko { .. })); + assert_eq!(layout.cells[0].start, 0); + assert_eq!(layout.cells[0].end, 2); + assert_eq!(layout.cells[0].extent, 20.0); + assert_eq!(layout.cells[1].start, 2); + } + + #[test] + fn tate_chu_yoko_compresses_only_the_width() { + // JLREQ Fig 26: two digits sit at full height in one em; only a run + // wider than the em is squeezed, and only horizontally. + let mut content = make_content_with_spans(&["20", "2026"], 200.0); + for span in content.paragraphs_mut()[0].children_mut() { + span.text_combine_upright = TextCombineUpright::All; + } + + let layout = layout_content(&content, 200.0); + let scales: Vec<(f32, f32)> = layout + .cells + .iter() + .filter_map(|cell| match cell.kind { + CellKind::TateChuYoko { h_scale, .. } => Some((h_scale, cell.h_advance)), + _ => None, + }) + .collect(); + + assert_eq!(scales.len(), 2); + assert_eq!(scales[0].0, 1.0, "two digits fit the em unscaled"); + assert!(scales[1].0 < 1.0, "four digits are squeezed"); + assert!((scales[1].1 - EM).abs() < 0.01, "into exactly one em"); + } + + #[test] + fn tate_chu_yoko_all_keeps_single_upright_ruby_base_at_full_size() { + let mut content = make_content_with_spans(&["く", "くくく"], 400.0); + let spans = content.paragraphs_mut()[0].children_mut(); + spans[0].ruby = "あ".to_string(); + for span in spans { + span.text_combine_upright = TextCombineUpright::All; + } + + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let ruby_base = layout + .cells + .iter() + .find(|cell| cell.span == 0) + .expect("ruby base cell"); + let following_base = layout + .cells + .iter() + .find(|cell| cell.span == 1) + .expect("following base cell"); + + assert!(matches!(ruby_base.kind, CellKind::Upright { .. })); + assert!(!layout.ruby_cells.is_empty(), "ruby annotation is present"); + assert_eq!(ruby_base.font_size, following_base.font_size); + assert!(layout + .cells + .iter() + .all(|cell| !matches!(cell.kind, CellKind::TateChuYoko { .. }))); + } + + #[test] + fn tate_chu_yoko_composes_covered_run_to_single_cell() { + // A CJK-covering face keeps the whole marked span in one upright + // composite cell (run_count >= 1); the next span is a separate cell. + let mut content = make_content_with_spans(&["くく", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::All; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let CellKind::TateChuYoko { run_count, .. } = layout.cells[0].kind else { + panic!("expected a Tate-chu-yoko cell"); + }; + assert!(run_count >= 1, "composite references at least one run"); + assert_eq!(layout.cells[0].start, 0); + assert_eq!(layout.cells[0].end, 2); + assert_eq!(layout.cells[1].start, 2); + } + + #[test] + fn tate_chu_yoko_wide_run_falls_back_to_normal_layout() { + // A run far wider than the em would scale below MIN_TCY_SCALE, so it + // gets normal layout. + let mut content = make_content_with_spans(&["123456789"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::All; + let layout = layout_content(&content, 400.0); + assert!( + !layout + .cells + .iter() + .any(|c| matches!(c.kind, CellKind::TateChuYoko { .. })), + "an over-wide run must not become a tate-chu-yoko composite" + ); + } + + #[test] + fn split_digit_runs_honours_the_max_parameter() { + // max 2: only exactly-two-digit runs combine. + let pieces = split_digit_runs("平成31年123日", 2); + let flags: Vec<(&str, bool)> = pieces + .iter() + .map(|(text, _, tcy)| (text.as_str(), *tcy)) + .collect(); + assert_eq!( + flags, + vec![ + ("平成", false), + ("31", true), + ("年", false), + ("123", false), + ("日", false), + ] + ); + // max 3 admits the three-digit run. + let pieces = split_digit_runs("平成31年123日", 3); + assert!(pieces.iter().any(|(text, _, tcy)| text == "123" && *tcy)); + } + + #[test] + fn split_digit_runs_marks_two_to_four_digit_runs() { + let pieces = split_digit_runs("平成31年12345日5", 4); + let flags: Vec<(&str, usize, bool)> = pieces + .iter() + .map(|(text, start, tcy)| (text.as_str(), *start, *tcy)) + .collect(); + assert_eq!( + flags, + vec![ + ("平成", 0, false), + ("31", 2, true), + ("年", 4, false), + ("12345", 5, false), + ("日", 10, false), + ("5", 11, false), + ] + ); + } + + #[test] + fn split_digit_runs_recognizes_full_width_digits() { + let pieces = split_digit_runs("2026夏号", 4); + let flags: Vec<(&str, bool)> = pieces + .iter() + .map(|(text, _, tcy)| (text.as_str(), *tcy)) + .collect(); + assert_eq!(flags, vec![("2026", true), ("夏号", false)]); + } + + #[test] + fn tate_chu_yoko_digits_combines_only_digit_runs() { + let mut content = make_content_with_spans(&["あ31く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|c| matches!(c.kind, CellKind::TateChuYoko { .. })) + .collect(); + assert_eq!(tcy.len(), 1, "exactly the digit run combines"); + assert_eq!(tcy[0].start, 1); + assert_eq!(tcy[0].end, 3); + // The surrounding characters keep their own cells in text order. + let starts: Vec = layout.cells.iter().map(|c| c.start).collect(); + let mut sorted = starts.clone(); + sorted.sort_unstable(); + assert_eq!(starts, sorted, "cells stay in text order"); + assert_eq!(layout.cells.len(), 3); + } + + #[test] + fn tate_chu_yoko_digits_combines_four_ascii_digits() { + let mut content = make_content_with_spans(&["2025年"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("four-digit run combines"); + assert_eq!(tcy.start, 0); + assert_eq!(tcy.end, 4); + } + + #[test] + fn tate_chu_yoko_digits_combines_full_width_unicode_digits() { + let mut content = make_content_with_spans(&["2026年"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy = layout + .cells + .iter() + .find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("full-width digit run combines"); + assert_eq!(tcy.start, 0); + assert_eq!(tcy.end, 4); + } + + #[test] + fn letter_spacing_extends_upright_cells() { + // Each upright cluster gains `letter_spacing` of flow advance; the + // centring width (`h_advance`) stays the same. + let plain = layout_content(&spaced_content("あい", 0.0), 1000.0); + let spaced = layout_content(&spaced_content("あい", 5.0), 1000.0); + assert_eq!(plain.cells.len(), spaced.cells.len()); + for i in 0..plain.cells.len() { + assert!( + (spaced.cells[i].extent - plain.cells[i].extent - 5.0).abs() < 0.01, + "cell {i} extent grows by letter-spacing" + ); + assert!( + (spaced.cells[i].h_advance - plain.cells[i].h_advance).abs() < 0.01, + "cell {i} centring width is unchanged" + ); + } + assert!( + spaced.cells[1].top - plain.cells[1].top - 5.0 > -0.01, + "the second cell is pushed down by the spacing" + ); + } + + #[test] + fn letter_spacing_spreads_rotated_run() { + // A sideways Latin run grows by `letter_spacing` per glyph and its + // glyphs shift apart along the (post-rotation) column axis. + let plain = layout_content(&spaced_content("AB", 0.0), 1000.0); + let spaced = layout_content(&spaced_content("AB", 5.0), 1000.0); + let plain_cell = plain + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .expect("a rotated cell"); + let spaced_cell = spaced + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .expect("a rotated cell"); + let (CellKind::Rotated { run: plain_run }, CellKind::Rotated { run: spaced_run }) = + (plain_cell.kind, spaced_cell.kind) + else { + unreachable!(); + }; + let glyphs = spaced.runs[spaced_run].glyphs.len(); + assert!(glyphs >= 2, "AB shapes to at least two glyphs"); + assert!( + (spaced_cell.extent - plain_cell.extent - 5.0 * glyphs as f32).abs() < 0.01, + "rotated extent grows by letter_spacing * glyph count" + ); + let plain_gap = plain.runs[plain_run].positions[1].x - plain.runs[plain_run].positions[0].x; + let spaced_gap = + spaced.runs[spaced_run].positions[1].x - spaced.runs[spaced_run].positions[0].x; + assert!( + (spaced_gap - plain_gap - 5.0).abs() < 0.01, + "adjacent glyph gap grows by letter-spacing" + ); + } + + // A tiny Noto Sans JP subset with `vmtx`/`vhea`: U+3031 (〱, vertical kana + // repeat mark) advances 2em vertically and 1em horizontally; + // U+3042/U+304F are symmetric controls. + + #[test] + fn vertical_advance_uses_vmtx() { + // 〱 flows down the column by its 2em vertical advance (vmtx), not + // its 1em horizontal width; the glyph still centres on the 1em width. + let content = make_content(&["〱"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let cell = &layout.cells[0]; + let CellKind::Upright { run, glyph, count } = cell.kind else { + panic!("expected an upright cell"); + }; + let horizontal: f32 = layout.runs[run].advances[glyph..glyph + count].iter().sum(); + assert!( + (horizontal - 20.0).abs() < 0.5, + "horizontal advance ~1em, got {horizontal}" + ); + assert!( + (cell.extent - 40.0).abs() < 0.5, + "vertical extent ~2em from vmtx, got {}", + cell.extent + ); + assert!( + (cell.h_advance - 20.0).abs() < 0.5, + "h_advance stays ~1em, got {}", + cell.h_advance + ); + } + + #[test] + fn vertical_advance_symmetric_glyph_unchanged() { + // A glyph whose vmtx equals its hmtx keeps extent == h_advance. + let content = make_content(&["く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let cell = &layout.cells[0]; + assert!( + (cell.extent - 20.0).abs() < 0.5, + "extent ~1em, got {}", + cell.extent + ); + assert!( + (cell.h_advance - cell.extent).abs() < 0.01, + "symmetric glyph: extent == h_advance" + ); + } + + // Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the + // vertical alternates of 、。「」 halve their vertical advances (「 also + // lifts its ink by 481 units) and the あ/く alternates tighten by + // 58/60 units with small placement lifts. + + #[test] + fn native_vertical_alternates_are_not_synthetically_rotated() { + let layout = layout_with( + &provider(VPAL_TEST_FONT), + &vpal_content("「」、。", FontFeatures::None), + ); + assert_eq!(layout.cells.len(), 4); + assert!(layout + .cells + .iter() + .all(|cell| matches!(cell.kind, CellKind::Upright { .. }))); + } + + #[test] + fn vpal_tightens_upright_kana() { + let provider = provider(VPAL_TEST_FONT); + let plain = layout_with(&provider, &vpal_content("あ", FontFeatures::None)); + let tight = layout_with(&provider, &vpal_content("あ", FontFeatures::Vpal)); + assert!( + (plain.cells[0].extent - 20.0).abs() < 0.01, + "without vpal あ keeps its full em, got {}", + plain.cells[0].extent + ); + // あ's vertical alternate carries YAdvance -58, YPlacement 39. + let expected = 20.0 * (1000.0 - 58.0) / 1000.0; + assert!( + (tight.cells[0].extent - expected).abs() < 0.01, + "vpal advance delta tightens the extent, got {}", + tight.cells[0].extent + ); + let expected_shift = -20.0 * 39.0 / 1000.0; + assert!( + (tight.cells[0].glyph_flow_shift - expected_shift).abs() < 0.01, + "vpal placement lifts the drawn ink, got {}", + tight.cells[0].glyph_flow_shift + ); + } + + #[test] + fn vpal_opening_bracket_uses_font_placement() { + // Mid-line, an opening bracket keeps its leading aki. Under vpal its + // placement comes from the font's YPlacement (481 units), not a + // synthetic sequence shed. + let provider = provider(VPAL_TEST_FONT); + let shed = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::None)); + let vpal = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::Vpal)); + assert_eq!( + shed.cells[1].glyph_flow_shift, 0.0, + "without vpal the preferred aki needs no synthetic shift, got {}", + shed.cells[1].glyph_flow_shift + ); + let expected = -20.0 * 481.0 / 1000.0; + assert!( + (vpal.cells[1].glyph_flow_shift - expected).abs() < 0.01, + "with vpal 「 lifts by the font's placement delta, got {}", + vpal.cells[1].glyph_flow_shift + ); + assert!( + (vpal.cells[1].extent - 10.0).abs() < 0.01, + "「 is half-width under vpal, got {}", + vpal.cells[1].extent + ); + } + + #[test] + fn centered_punctuation_shift_centres_ink_in_em_body() { + assert!(is_centered_punctuation('・')); + assert!(is_centered_punctuation(':')); + assert!(is_centered_punctuation(';')); + assert!(!is_centered_punctuation('あ')); + // Ink at 14..18 in a 20 body: midpoint 16 moves to 10, so shift == -6. + let shift = centered_flow_shift(14.0, 18.0, 20.0); + assert!((shift + 6.0).abs() < 1e-4, "expected -6, got {shift}"); + // Already-centred ink needs no shift. + assert!(centered_flow_shift(8.0, 12.0, 20.0).abs() < 1e-4); + } + + #[test] + fn vertical_base_text_switches_font_inside_an_upright_segment() { + let content = make_content(&["あ、く"], 400.0); + let provider = provider_with_fallback(VMTX_TEST_FONT, VPAL_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]); + + assert_eq!(layout.cells.len(), 3); + let run_ids: Vec = layout + .cells + .iter() + .map(|cell| match cell.kind { + CellKind::Upright { run, .. } => layout.runs[run].font.typeface().unique_id(), + _ => panic!("expected upright fallback cells"), + }) + .collect(); + assert_eq!(run_ids[0], run_ids[2]); + assert_ne!(run_ids[0], run_ids[1]); + let CellKind::Upright { run, glyph, .. } = layout.cells[1].kind else { + unreachable!(); + }; + assert_ne!(layout.runs[run].glyphs[glyph], 0); + } + + #[test] + fn layout_mixed_text_has_rotated_run() { + let content = make_content(&["あAB1い"], 1000.0); + let layout = layout_content(&content, 1000.0); + assert!(layout + .cells + .iter() + .any(|c| matches!(c.kind, CellKind::Rotated { .. }))); + // The rotated run covers the Latin range 1..4 (UTF-16). + let rotated = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Rotated { .. })) + .unwrap(); + assert_eq!(rotated.start, 1); + assert_eq!(rotated.end, 4); + } + + #[test] + fn western_word_space_uses_one_third_em() { + let without_space = layout_content(&make_content(&["ab"], 1000.0), 1000.0); + let with_space = layout_content(&make_content(&["a b"], 1000.0), 1000.0); + assert_eq!(without_space.cells.len(), 1); + assert_eq!(with_space.cells.len(), 2, "one sideways cell per word"); + let extent = |layout: &super::super::layout::VerticalLayout| { + layout.cells.iter().map(|c| c.extent).sum::() + }; + let added = extent(&with_space) - extent(&without_space); + assert!( + (added - 20.0 * WESTERN_WORD_SPACING_EM).abs() < 0.01, + "word space should add one third em, got {added}" + ); + } + + #[test] + fn cells_carry_font_size_and_decoration() { + let content = decorated_content("あい", TextDecoration::UNDERLINE); + let layout = layout_content(&content, 1000.0); + assert!(!layout.cells.is_empty()); + for cell in &layout.cells { + assert_eq!(cell.font_size, 20.0); + assert_eq!(cell.decoration, Some(TextDecoration::UNDERLINE)); + } + } + + #[test] + fn mixed_spans_preserve_fill_color_and_opacity() { + let mut content = make_content_with_spans(&["あ", "A", "い"], 1000.0); + let colors = [ + skia::Color::from_argb(255, 255, 0, 0), + skia::Color::from_argb(128, 0, 255, 0), + skia::Color::from_argb(64, 0, 0, 255), + ]; + for (span, color) in content.paragraphs_mut()[0] + .children_mut() + .iter_mut() + .zip(colors) + { + span.fills = vec![Fill::Solid(SolidColor(color))]; + } + + let layout = layout_content(&content, 1000.0); + assert_eq!(layout.paints.len(), colors.len()); + for (paint, color) in layout.paints.iter().zip(colors) { + assert_eq!(paint.color(), color); + } + assert!(layout + .cells + .iter() + .any(|cell| matches!(cell.kind, CellKind::Rotated { .. }))); + } +} diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs new file mode 100644 index 0000000000..4bb61887f6 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -0,0 +1,1729 @@ +// Column planning and JLREQ spacing along the vertical flow. These passes run +// on a paragraph's `FlowCell`s before placement: they adjust cell extents +// (aki, oikomi, inter-script spacing) and pick column breaks (kinsoku, +// burasage, oidashi). + +use crate::shapes::japanese::{ + aki_class, break_allowed_between, classify, pair_rule, punctuation_aki_sheds, AkiShed, + JapaneseClass, +}; +use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; +use crate::shapes::text_japanese::capped_equal_allocations; +use crate::shapes::{LineAdjustment, TextAlign}; + +use super::layout::{CellKind, VerticalCell}; + +const INTER_SCRIPT_SPACING_EM: f32 = 0.25; + +/// Amounts below this are treated as zero by the spacing passes. +const EPSILON: f32 = 0.0001; + +/// Overflow below this still fits a column: shape heights carry float noise +/// (a 19em box can measure 417.99998px for 418px of text). +pub(super) const FIT_TOLERANCE: f32 = 0.01; + +/// An item placed along the column. `ch` is set for single-character cells +/// and drives kinsoku at column breaks; rotated runs have none and never +/// split. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct FlowItem { + pub extent: f32, + pub ch: Option, + /// Same source char as the previous item (text transform); keep together. + pub keep_with_previous: bool, + /// Continues the previous item's word across a style or font run + /// boundary; a break here would split the word. + pub joins_previous: bool, + /// Trailing aki the item sheds when it ends a column: a closing bracket + /// there is set half width (JLREQ §3.1.4), so this part may pass the + /// column end while planning. + pub end_slack: f32, +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum FlowScript { + Upright, + Rotated { + starts_alphanumeric: bool, + ends_alphanumeric: bool, + }, +} + +/// A cell of one paragraph awaiting column placement, with the facts the +/// spacing passes and the column planner need. +#[derive(Clone)] +pub(super) struct FlowCell { + pub cell: VerticalCell, + /// Character of a single-character cell; classifies it for aki and kinsoku. + pub ch: Option, + pub keep_with_previous: bool, + pub script: FlowScript, + /// Letter-spacing included at the end of the cell's extent. + pub trailing_spacing: f32, + /// A later span's cell of the previous cell's warichu piece: it has no + /// flow extent and takes the piece's box after placement. + pub shares_previous_box: bool, +} + +impl FlowCell { + pub(super) fn new( + cell: VerticalCell, + ch: Option, + script: FlowScript, + trailing_spacing: f32, + ) -> Self { + Self { + cell, + ch, + keep_with_previous: false, + script, + trailing_spacing, + shares_previous_box: false, + } + } + + fn item(&self) -> FlowItem { + FlowItem { + extent: self.cell.extent, + ch: self.ch, + keep_with_previous: self.keep_with_previous || self.shares_previous_box, + joins_previous: false, + end_slack: self.end_slack(), + } + } + + /// Trailing aki of a closing bracket, which it sheds at a column end. + fn end_slack(&self) -> f32 { + let closing = self + .ch + .is_some_and(|ch| classify(ch) == JapaneseClass::ClosingBracket); + if closing { + (self.cell.extent - (0.5 * self.cell.font_size + self.trailing_spacing)).max(0.0) + } else { + 0.0 + } + } + + /// Shrink the cell to a half-em frame plus its letter-spacing. Opening + /// punctuation has its ink in the trailing half of the em, so + /// `pull_glyph` lifts it into the shrunk cell. Returns false when the + /// cell is already that narrow. + fn shed_to_half_em(&mut self, pull_glyph: bool) -> bool { + let target = 0.5 * self.cell.font_size + self.trailing_spacing; + if target >= self.cell.extent { + return false; + } + if pull_glyph { + self.cell.glyph_flow_shift -= self.cell.extent - target; + } + self.cell.extent = target; + true + } +} + +pub(super) fn flow_items(cells: &[FlowCell]) -> Vec { + let mut items: Vec = cells.iter().map(FlowCell::item).collect(); + for (index, pair) in cells.windows(2).enumerate() { + items[index + 1].joins_previous = continues_word(&pair[0], &pair[1]); + } + items +} + +/// True when two sideways runs meet inside a word: a letter or digit on +/// both sides of a style or font boundary. +fn continues_word(previous: &FlowCell, next: &FlowCell) -> bool { + matches!( + (previous.script, next.script), + ( + FlowScript::Rotated { + ends_alphanumeric: true, + .. + }, + FlowScript::Rotated { + starts_alphanumeric: true, + .. + } + ) + ) +} + +/// Punctuation that may hang past the column bottom (ぶら下げ / burasage): the +/// ideographic and full-width comma and period. A mark that would overflow +/// the column hangs into the margin. +fn can_hang(c: char) -> bool { + matches!(c, '、' | '。' | ',' | '.') +} + +/// True when `item` would overflow a column already filled to `cursor`. +fn overflows(cursor: f32, item: &FlowItem, max_height: f32) -> bool { + cursor > 0.0 + && cursor + item.extent - item.end_slack > max_height + FIT_TOLERANCE + && !item.ch.is_some_and(can_hang) +} + +/// Why a column may not break before `items[at]`. +#[derive(Debug, Clone, Copy, PartialEq)] +enum BreakBlock { + /// Kinsoku: a forbidden-at-line-start or -end character. + Kinsoku, + /// A word, numeral run, identical inseparable pair or numeral with its + /// unit (JLREQ §3.1.10). + Unbreakable, +} + +fn break_block(items: &[FlowItem], at: usize) -> Option { + let (previous, next) = (&items[at - 1], &items[at]); + if next.joins_previous { + return Some(BreakBlock::Unbreakable); + } + let kinsoku = next.ch.is_some_and(forbidden_at_line_start) + || previous.ch.is_some_and(forbidden_at_line_end); + if kinsoku { + return Some(BreakBlock::Kinsoku); + } + match (previous.ch, next.ch) { + (Some(before), Some(after)) if !break_allowed_between(before, after) => { + Some(BreakBlock::Unbreakable) + } + _ => None, + } +} + +/// First item of the next column when `items[i]` overflows the column +/// starting at `column_start`. Kinsoku: no forbidden-at-line-end char at the +/// column bottom and no forbidden-at-line-start char at the next column top; +/// offending predecessors move to the new column (oidashi), at most +/// `MAX_KINSOKU_SHIFT` of them. Words and other unbreakable sequences move +/// whole when they fit a column. A sequence that cannot move without +/// emptying the column or overflowing the next one breaks where it +/// overflows. `None` +/// keeps `items[i]` in the column when it closes an atomic composite (group +/// ruby or an expanded source scalar) that began at the column head. +fn column_break( + items: &[FlowItem], + column_start: usize, + i: usize, + max_height: f32, +) -> Option { + const MAX_KINSOKU_SHIFT: usize = 4; + + let skip_kept = |mut index: usize| { + while index > column_start && items[index].keep_with_previous { + index -= 1; + } + index + }; + + let natural = skip_kept(i); + if natural == column_start && items[i].keep_with_previous { + return None; + } + let mut break_at = natural; + let mut kinsoku_shifts = 0; + while break_at > column_start { + match break_block(items, break_at) { + None => break, + Some(BreakBlock::Kinsoku) if kinsoku_shifts >= MAX_KINSOKU_SHIFT => break, + Some(block) => { + if block == BreakBlock::Kinsoku { + kinsoku_shifts += 1; + } + break_at = skip_kept(break_at - 1); + } + } + } + // Drop the shift if it would empty this column or overflow the new one: + // a kinsoku violation is better than overflowing the wrap budget. + let shifted_extent: f32 = items[break_at..i].iter().map(|it| it.extent).sum(); + if break_at == column_start + || (break_at < i && shifted_extent + items[i].extent > max_height + FIT_TOLERANCE) + { + break_at = natural; + } + Some(break_at) +} + +/// Assign items to columns, returning (column index, offset from top) per +/// item. An item that overflows starts a new column (see `column_break`); an +/// item taller than the column gets one to itself. Burasage marks never +/// overflow: the next non-hanging item sees the overflowed cursor and wraps, +/// and oidashi never pulls a hung mark since it is forbidden-at-line-start, +/// not -end. +pub(super) fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> { + let mut placements: Vec<(usize, f32)> = Vec::with_capacity(items.len()); + let mut column = 0usize; + let mut cursor = 0.0f32; + let mut column_start = 0usize; + + for (i, item) in items.iter().enumerate() { + if overflows(cursor, item, max_height) { + if let Some(break_at) = column_break(items, column_start, i, max_height) { + column += 1; + cursor = 0.0; + for k in break_at..i { + placements[k] = (column, cursor); + cursor += items[k].extent; + } + column_start = break_at; + } + } + placements.push((column, cursor)); + cursor += item.extent; + } + placements +} + +/// True when `max_height` is a real wrap budget (auto-width columns are +/// unbounded). +pub(super) fn is_bounded(max_height: f32) -> bool { + max_height.is_finite() && max_height > 0.0 && max_height < f32::MAX +} + +/// Offset of a column's content along the inline axis for `text-align`. In +/// `vertical-rl` that axis runs top to bottom: Left/Start anchor to the top, +/// Right/End to the bottom, Center to the middle of `budget` (the column wrap +/// height). An unbounded budget (auto-width) and Justify yield no shift; +/// `ordered_expansion_offsets` spreads justify space between cells. +pub(super) fn align_offset_along_column(align: TextAlign, budget: f32, used: f32) -> f32 { + if !is_bounded(budget) { + return 0.0; + } + let slack = (budget - used).max(0.0); + match align { + TextAlign::Center => slack / 2.0, + TextAlign::Right | TextAlign::End => slack, + _ => 0.0, + } +} + +/// Japanese inter-script spacing (JLREQ §3.2.6) between kana or kanji +/// (cl-15, cl-16, cl-19) and a rotated alphanumeric run. Punctuation keeps +/// its own aki and Latin sets solid against it; whitespace gets none. Scales +/// by the smaller adjacent font size so a large neighbour cannot widen the +/// gap. +fn inter_script_spacing(previous: &FlowCell, next: &FlowCell) -> f32 { + let is_japanese_letter = + |flow: &FlowCell| flow.ch.is_some_and(|ch| classify(ch).is_japanese_letter()); + let boundary = match (previous.script, next.script) { + ( + FlowScript::Upright, + FlowScript::Rotated { + starts_alphanumeric: true, + .. + }, + ) => is_japanese_letter(previous), + ( + FlowScript::Rotated { + ends_alphanumeric: true, + .. + }, + FlowScript::Upright, + ) => is_japanese_letter(next), + _ => false, + }; + if boundary { + previous.cell.font_size.min(next.cell.font_size) * INTER_SCRIPT_SPACING_EM + } else { + 0.0 + } +} + +/// Give every upright <-> Western boundary its inter-script gap, measured +/// between ink edges: side bearings differ across fonts, so advances alone +/// would space `うpenあ` unevenly. Adjusts the preceding cell's extent and +/// keeps its trailing letter-spacing. +pub(super) fn apply_inter_script_spacing(cells: &mut [FlowCell]) { + for i in 1..cells.len() { + let target_gap = inter_script_spacing(&cells[i - 1], &cells[i]); + if target_gap <= 0.0 { + continue; + } + let previous = &cells[i - 1]; + let natural_gap = previous.cell.extent - previous.trailing_spacing + cells[i].cell.ink_top + - previous.cell.ink_bottom; + let corrected = previous.cell.extent + target_gap - natural_gap; + cells[i - 1].cell.extent = corrected.max(0.0); + } +} + +/// JLREQ character class of each cell. Ruby bases count as simple ruby, so +/// `ruby_spans[span]` overrides the cell's own class. +pub(super) fn flow_classes(cells: &[FlowCell], ruby_spans: &[bool]) -> Vec> { + cells + .iter() + .map(|flow| { + if ruby_spans[flow.cell.span] { + return Some(JapaneseClass::SimpleRuby); + } + match flow.cell.kind { + CellKind::TateChuYoko { .. } => Some(JapaneseClass::TateChuYoko), + CellKind::Rotated { .. } => Some(JapaneseClass::Western), + _ => flow.ch.map(classify), + } + }) + .collect() +} + +/// JLREQ punctuation and cl-30 adjacency. Full-width fonts include a half-em +/// aki in punctuation advances. Ordinary text keeps it; at the internal +/// boundaries of §3.1.4 one half-em goes (see `punctuation_aki_sheds`), and +/// middle dots keep their quarter-em sides. +pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], aki_classes: &[Option]) { + for (index, shed) in punctuation_aki_sheds(aki_classes) { + cells[index].shed_to_half_em(shed == AkiShed::Leading); + } +} + +/// Punctuation aki class of each cell (see `aki_class`). A cell classed by +/// its own character takes `aki_class`, which leaves out curly quotes and +/// `vpal` spans; ruby bases, tate-chu-yoko and sideways runs keep their +/// `flow_classes` class. +pub(super) fn aki_classes( + cells: &[FlowCell], + classes: &[Option], + proportional_spans: &[bool], +) -> Vec> { + cells + .iter() + .zip(classes) + .map(|(flow, class)| match flow.ch { + Some(ch) if *class == Some(classify(ch)) => { + aki_class(ch, false, proportional_spans[flow.cell.span]) + } + _ => *class, + }) + .collect() +} + +/// Smallest legal flow extent after oikomi. Full-width punctuation floors at +/// a half-em frame, or at its shaped extent when `vpal` has already removed +/// the aki. +pub(super) fn minimum_oikomi_extent( + ch: Option, + extent: f32, + font_size: f32, + letter_spacing: f32, +) -> f32 { + let half_em_frame = ch.is_some_and(|ch| { + matches!( + classify(ch), + JapaneseClass::OpeningBracket + | JapaneseClass::ClosingBracket + | JapaneseClass::FullStop + | JapaneseClass::Comma + | JapaneseClass::MiddleDot + ) + }); + if half_em_frame { + extent.min(0.5 * font_size + letter_spacing) + } else { + extent + } +} + +/// The em that scales the spacing between `cells[boundary]` and the next +/// cell: the smaller adjacent font size. +fn boundary_em(cells: &[FlowCell], boundary: usize) -> f32 { + cells[boundary] + .cell + .font_size + .min(cells[boundary + 1].cell.font_size) +} + +fn explicit_pair_spacing_em(before: JapaneseClass, after: JapaneseClass) -> f32 { + if matches!(before, JapaneseClass::DividingPunctuation) { + pair_rule(before, after).preferred_em + } else { + 0.0 + } +} + +/// Adjacent classes at `boundary`, when both cells have one. +fn boundary_classes( + classes: &[Option], + boundary: usize, +) -> Option<(JapaneseClass, JapaneseClass)> { + Some((classes[boundary]?, classes[boundary + 1]?)) +} + +/// Preferred pair spacing (in em) at every boundary, per the JLREQ table. +pub(super) fn preferred_pair_spacing(classes: &[Option]) -> Vec { + (0..classes.len().saturating_sub(1)) + .map(|boundary| { + boundary_classes(classes, boundary) + .map_or(0.0, |(before, after)| pair_rule(before, after).preferred_em) + }) + .collect() +} + +/// cl-04 question/exclamation marks take a one-em space after their frame. +/// Fonts leave it out of the advance (unlike bracket aki), so this adds it +/// to the flow extent, where oikomi can reduce it. +pub(super) fn materialize_explicit_pair_spacing( + cells: &mut [FlowCell], + classes: &[Option], +) { + for boundary in 0..cells.len().saturating_sub(1) { + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let spacing_em = explicit_pair_spacing_em(before, after); + if spacing_em > 0.0 { + cells[boundary].cell.extent += spacing_em * boundary_em(cells, boundary); + } + } +} + +/// Cell holding the removable spacing of a boundary, and whether that space +/// leads the cell (its glyph then moves up with the reduction). +fn spacing_owner(before: JapaneseClass, after: JapaneseClass, boundary: usize) -> (usize, bool) { + if explicit_pair_spacing_em(before, after) > 0.0 { + (boundary, false) + } else if matches!( + after, + JapaneseClass::OpeningBracket | JapaneseClass::MiddleDot + ) { + // After sequence shedding, the remaining aki is the next glyph's + // embedded leading space. + (boundary + 1, true) + } else { + (boundary, false) + } +} + +/// Remove up to `amount` of removable spacing from `cells[index]`, never +/// below its oikomi floor. Returns the amount removed. +fn reduce_cell_spacing(cells: &mut [FlowCell], index: usize, amount: f32, leading: bool) -> f32 { + let cell = &mut cells[index].cell; + let removable = (cell.extent - cell.minimum_oikomi_extent).max(0.0); + let amount = amount.min(cell.extent.max(0.0)).min(removable); + cell.extent -= amount; + if leading { + cell.glyph_flow_shift -= amount; + } + amount +} + +/// Discard explicit spacing at column edges (no trailing `!` space at a +/// column bottom). Returns true when it removed any. +fn discard_explicit_spacing_at_column_edges( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + placements: &[(usize, f32)], +) -> bool { + let mut changed = false; + for boundary in 0..cells.len().saturating_sub(1) { + if placements[boundary].0 == placements[boundary + 1].0 { + continue; + } + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + if explicit_pair_spacing_em(before, after) <= 0.0 { + continue; + } + let spacing = pair_spacing_em[boundary] * boundary_em(cells, boundary); + if spacing <= 0.0 { + continue; + } + reduce_cell_spacing(cells, boundary, spacing, false); + pair_spacing_em[boundary] = 0.0; + changed = true; + } + changed +} + +/// True when breaking the column before `cells[i]`, with the column filled +/// to `cursor`, needs line adjustment: the break would violate kinsoku or +/// split a kept group, or would leave the column short of `max_height` (a +/// rotated run or other uneven extent). A column that is already full breaks +/// without it. +fn break_needs_adjustment(cells: &[FlowCell], i: usize, cursor: f32, max_height: f32) -> bool { + let flow = &cells[i]; + let previous_ch = i.checked_sub(1).and_then(|previous| cells[previous].ch); + flow.keep_with_previous + || flow.ch.is_some_and(forbidden_at_line_start) + || previous_ch.is_some_and(forbidden_at_line_end) + || max_height - cursor > FIT_TOLERANCE +} + +/// JLREQ oikomi: before wrapping a non-hanging item whose break needs line +/// adjustment, try to keep it in the column by reducing legal aki in table +/// priority order. When the whole deficit cannot be recovered, leave the +/// line to the oidashi/kinsoku planner. `policy` decides whether squeezing +/// comes first, only when pushing out would break kinsoku, or never. +pub(super) fn apply_ordered_oikomi( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + max_height: f32, + policy: LineAdjustment, +) { + if !is_bounded(max_height) || policy == LineAdjustment::PushOutOnly { + return; + } + let mut column_start = 0usize; + let mut cursor = 0.0f32; + for i in 0..cells.len() { + let item = cells[i].item(); + if overflows(cursor, &item, max_height) { + let needed = item.extent - item.end_slack; + let squeeze = break_needs_adjustment(cells, i, cursor, max_height) + && (policy == LineAdjustment::PushInFirst + || !push_out_resolves(cells, column_start, i, max_height)); + if squeeze { + let deficit = cursor + needed - max_height; + cursor -= compress_line(cells, classes, pair_spacing_em, column_start..i, deficit); + } + if cursor + needed > max_height + FIT_TOLERANCE { + column_start = i; + cursor = 0.0; + } + } + cursor += cells[i].cell.extent; + } +} + +/// True when breaking before `cells[i]` can move characters to the next +/// column (oidashi) without leaving a kinsoku violation at the break. +fn push_out_resolves(cells: &[FlowCell], column_start: usize, i: usize, max_height: f32) -> bool { + let items = flow_items(cells); + match column_break(&items, column_start, i, max_height) { + None => true, + Some(break_at) => { + break_at > column_start && break_block(&items, break_at) != Some(BreakBlock::Kinsoku) + } + } +} + +/// Recover `deficit` from the boundaries in `line` (the cells before the +/// overflowing one), in shrink-priority order, when the whole deficit fits. +/// Returns the extent removed from the line. +fn compress_line( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + line: std::ops::Range, + deficit: f32, +) -> f32 { + let overflowing = line.end; + // (boundary, priority, capacity) + let mut opportunities: Vec<(usize, u8, f32)> = Vec::new(); + for boundary in line { + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let rule = pair_rule(before, after); + if rule.shrink_priority == 0 || pair_spacing_em[boundary] <= rule.minimum_em { + continue; + } + let em = boundary_em(cells, boundary); + let (owner, _) = spacing_owner(before, after, boundary); + let owner = &cells[owner].cell; + let rule_capacity = (pair_spacing_em[boundary] - rule.minimum_em) * em; + let physical_capacity = (owner.extent - owner.minimum_oikomi_extent).max(0.0); + let capacity = rule_capacity.min(physical_capacity); + if capacity > EPSILON { + opportunities.push((boundary, rule.shrink_priority, capacity)); + } + } + let total_capacity: f32 = opportunities.iter().map(|(_, _, cap)| *cap).sum(); + if total_capacity + EPSILON < deficit { + return 0.0; + } + + let mut removed_from_line = 0.0; + let mut remaining = deficit; + for priority in 1..=5 { + if remaining <= EPSILON { + break; + } + let caps: Vec<(usize, f32)> = opportunities + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(boundary, _, cap)| (*boundary, *cap)) + .collect(); + if caps.is_empty() { + continue; + } + let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); + for (boundary, reduction) in capped_equal_allocations(&caps, remaining.min(available)) { + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let em = boundary_em(cells, boundary); + let (owner, leading) = spacing_owner(before, after, boundary); + let reduction = reduce_cell_spacing(cells, owner, reduction, leading); + pair_spacing_em[boundary] -= reduction / em; + if owner < overflowing { + removed_from_line += reduction; + } + remaining -= reduction; + } + } + removed_from_line +} + +/// Plan the columns, then trim spacing a break left at a column edge and +/// re-plan, since a shorter line may pull another cell into the column. Each +/// round shrinks a not-yet-trimmed cell, so the cell count bounds the loops. +/// +/// Opening brackets at a column head follow the JIS X 4051 tentsuki policy +/// and drop their leading half-em; closing brackets at a column end drop +/// their trailing half-em. A trim that pulls the bracket off its edge in the +/// re-plan is undone. +pub(super) fn plan_with_edge_trimming( + cells: &mut [FlowCell], + classes: &[Option], + pair_spacing_em: &mut [f32], + max_height: f32, +) -> Vec<(usize, f32)> { + let mut placements = plan_columns(&flow_items(cells), max_height); + for _ in 0..cells.len() { + if !discard_explicit_spacing_at_column_edges(cells, classes, pair_spacing_em, &placements) { + break; + } + placements = plan_columns(&flow_items(cells), max_height); + } + // Cells whose trim moved them off the edge keep their aki for good, so + // the loop cannot oscillate. + let mut keep_aki = vec![false; cells.len()]; + for _ in 0..cells.len() { + let saved = cells.to_vec(); + // (index, trimmed as a column head) + let mut trimmed: Vec<(usize, bool)> = Vec::new(); + for (index, flow) in cells.iter_mut().enumerate() { + if keep_aki[index] { + continue; + } + let opening = flow + .ch + .is_some_and(|ch| classify(ch) == JapaneseClass::OpeningBracket); + if at_column_head(&placements, index) && opening && flow.shed_to_half_em(true) { + trimmed.push((index, true)); + } + if at_column_end(&placements, index) + && flow.end_slack() > EPSILON + && flow.shed_to_half_em(false) + { + trimmed.push((index, false)); + } + } + if trimmed.is_empty() { + break; + } + let replanned = plan_columns(&flow_items(cells), max_height); + let mut moved = false; + for (index, head) in trimmed { + let still_at_edge = if head { + at_column_head(&replanned, index) + } else { + at_column_end(&replanned, index) + }; + if !still_at_edge { + cells[index] = saved[index].clone(); + keep_aki[index] = true; + moved = true; + } + } + placements = if moved { + plan_columns(&flow_items(cells), max_height) + } else { + replanned + }; + } + placements +} + +fn at_column_head(placements: &[(usize, f32)], index: usize) -> bool { + placements[index].1 == 0.0 +} + +fn at_column_end(placements: &[(usize, f32)], index: usize) -> bool { + placements + .get(index + 1) + .is_none_or(|(next_column, _)| *next_column != placements[index].0) +} + +/// Ordered oidashi expansion for justified columns. Stages 1–3 respect the +/// table caps; stage 4 spreads any remaining slack evenly over every +/// expandable boundary (JLREQ §3.8.4). Returns each cell's extra flow offset. +/// Skips the last column. +pub(super) fn ordered_expansion_offsets( + cells: &[FlowCell], + classes: &[Option], + pair_spacing_em: &[f32], + placements: &[(usize, f32)], + column_used: &[f32], + max_height: f32, +) -> Vec { + let last_column = column_used.len().saturating_sub(1); + let mut boundary_expansion = vec![0.0f32; cells.len().saturating_sub(1)]; + for (column, used) in column_used.iter().enumerate().take(last_column) { + // (boundary, priority, cap) + let mut boundaries: Vec<(usize, u8, f32)> = Vec::new(); + for boundary in 0..cells.len().saturating_sub(1) { + if placements[boundary].0 != column || placements[boundary + 1].0 != column { + continue; + } + let Some((before, after)) = boundary_classes(classes, boundary) else { + continue; + }; + let rule = pair_rule(before, after); + if rule.expand_priority == 0 { + continue; + } + let cap = (rule.maximum_em - pair_spacing_em[boundary]).max(0.0) + * boundary_em(cells, boundary); + boundaries.push((boundary, rule.expand_priority, cap)); + } + let mut remaining = (max_height - used).max(0.0); + for priority in 1..=3 { + let caps: Vec<(usize, f32)> = boundaries + .iter() + .filter(|(_, p, _)| *p == priority) + .map(|(boundary, _, cap)| (*boundary, *cap)) + .collect(); + let available: f32 = caps.iter().map(|(_, cap)| *cap).sum(); + for (boundary, expansion) in capped_equal_allocations(&caps, remaining.min(available)) { + boundary_expansion[boundary] += expansion; + remaining -= expansion; + } + } + if remaining > EPSILON && !boundaries.is_empty() { + let extra = remaining / boundaries.len() as f32; + for (boundary, _, _) in &boundaries { + boundary_expansion[*boundary] += extra; + } + } + } + + let mut offsets = vec![0.0f32; cells.len()]; + for i in 1..cells.len() { + if placements[i].0 == placements[i - 1].0 { + offsets[i] = offsets[i - 1] + boundary_expansion[i - 1]; + } + } + offsets +} + +#[cfg(test)] +mod tests { + use super::super::layout::{wrap_height, CellKind, VerticalCell}; + use super::super::test_support::*; + use super::*; + use crate::shapes::{FontFeatures, GrowType, TextCombineUpright, TextSpan}; + + fn item(extent: f32, ch: char) -> FlowItem { + FlowItem { + extent, + ch: Some(ch), + keep_with_previous: false, + joins_previous: false, + end_slack: 0.0, + } + } + + fn run_item(extent: f32, joins_previous: bool) -> FlowItem { + FlowItem { + extent, + ch: None, + keep_with_previous: false, + joins_previous, + end_slack: 0.0, + } + } + + fn columns(placements: &[(usize, f32)]) -> Vec { + placements.iter().map(|(column, _)| *column).collect() + } + + #[test] + fn plan_columns_keeps_upright_latin_words_whole() { + let items: Vec = "あいPenpot".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 70.0); + assert_eq!(columns(&placements), vec![0, 0, 1, 1, 1, 1, 1, 1]); + } + + #[test] + fn plan_columns_keeps_a_word_split_across_runs_whole() { + let items = vec![ + item(24.0, 'あ'), + run_item(44.0, false), + run_item(38.0, true), + ]; + let placements = plan_columns(&items, 90.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 44.0)]); + } + + #[test] + fn plan_columns_keeps_full_width_numbers_whole() { + let items: Vec = "あいう2026年".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 40.0); + assert_eq!(columns(&placements), vec![0, 0, 0, 1, 1, 1, 1, 2]); + } + + #[test] + fn full_width_number_moves_to_the_next_column_whole() { + let provider = provider_with_fallback(VMTX_TEST_FONT, TEST_FONT); + let wide = layout_with(&provider, &make_content(&["あいう2026年"], 1000.0)); + let height = wide.cells[..4].iter().map(|cell| cell.extent).sum::() + 2.0; + let layout = layout_with_height( + &provider, + &make_content(&["あいう2026年"], height), + height, + ); + let columns: Vec<(usize, Option)> = layout + .cells + .iter() + .map(|cell| { + ( + cell.column, + Some(format!("{:?}", cell.kind).chars().take(10).collect()), + ) + }) + .collect(); + let digit_columns: Vec = layout.cells[3..7].iter().map(|cell| cell.column).collect(); + assert_eq!(digit_columns, vec![1, 1, 1, 1], "cells: {columns:?}"); + } + + #[test] + fn plan_columns_keeps_a_numeral_with_its_unit() { + let items: Vec = "体重60㎏".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 40.0); + assert_eq!(columns(&placements), vec![0, 0, 1, 1, 1]); + } + + #[test] + fn plan_columns_never_leaves_an_empty_column() { + let items: Vec = "「「あ".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 25.0); + assert_eq!(columns(&placements), vec![0, 0, 1]); + } + + #[test] + fn plan_columns_oidashi_ignores_float_noise_in_the_budget() { + let items = vec![item(22.0, 'あ'), item(22.0, 'い'), item(22.0, '」')]; + let placements = plan_columns(&items, 44.0 - 0.00002); + assert_eq!(columns(&placements), vec![0, 1, 1]); + } + + #[test] + fn sideways_words_wrap_at_spaces() { + let provider = provider(TEST_FONT); + let wide = layout_with(&provider, &make_content(&["Penpot Work"], 1000.0)); + let first_word = wide.cells[0].extent; + let layout = layout_with_height( + &provider, + &make_content(&["Penpot Work"], first_word + 1.0), + first_word + 1.0, + ); + assert_eq!(layout.cells.len(), 2, "one sideways cell per word"); + assert_ne!(layout.cells[0].column, layout.cells[1].column); + } + + fn aligned_content(text: &str, height: f32, align: TextAlign) -> crate::shapes::TextContent { + content_of( + vec![vertical_paragraph(vec![make_span(text)], align, 1.0)], + height, + GrowType::Fixed, + ) + } + + fn vpal_content(text: &str) -> crate::shapes::TextContent { + spans_content( + vec![TextSpan { + font_features: FontFeatures::Vpal, + ..make_span(text) + }], + 1000.0, + ) + } + + fn visible_flow_gap(previous: &VerticalCell, next: &VerticalCell) -> f32 { + next.top + next.ink_top - (previous.top + previous.ink_bottom) + } + + #[test] + fn plan_columns_breaks_on_overflow() { + let items: Vec = "あいうえお".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 25.0); + assert_eq!( + placements, + vec![(0, 0.0), (0, 10.0), (1, 0.0), (1, 10.0), (2, 0.0)] + ); + } + + #[test] + fn plan_columns_ignores_float_noise_in_the_budget() { + let items: Vec = "あいう".chars().map(|c| item(22.0, c)).collect(); + let placements = plan_columns(&items, 66.0 - 0.00002); + assert!(placements.iter().all(|(column, _)| *column == 0)); + } + + #[test] + fn plan_columns_oversized_item_gets_own_column() { + let items = vec![item(10.0, 'あ'), item(100.0, 'い'), item(10.0, 'う')]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (2, 0.0)]); + } + + #[test] + fn plan_columns_burasage_hangs_comma_period() { + // 。 overflows the two-cell column; burasage hangs it past the column + // bottom, so no oidashi pushes い down and 。 never starts a column. + let items = vec![ + item(10.0, 'あ'), + item(10.0, 'い'), + item(10.0, '。'), + item(10.0, 'う'), + ]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (0, 10.0), (0, 20.0), (1, 0.0)]); + assert_eq!(placements[2].0, 0, "。 hangs in the current column"); + } + + #[test] + fn plan_columns_kinsoku_no_forbidden_end_at_column_bottom() { + // 「 would be left at the bottom of column 0; it moves down. + let items = vec![ + item(10.0, 'あ'), + item(10.0, '「'), + item(10.0, 'い'), + item(10.0, 'う'), + ]; + let placements = plan_columns(&items, 25.0); + assert_eq!(placements, vec![(0, 0.0), (1, 0.0), (1, 10.0), (2, 0.0)]); + } + + #[test] + fn plan_columns_kinsoku_shift_is_bounded() { + // A column full of open brackets cannot be emptied: oidashi moves at + // most four of them along with the overflowing あ. + let items: Vec = "「「「「「「あ".chars().map(|c| item(10.0, c)).collect(); + let placements = plan_columns(&items, 60.0); + let first_column_count = placements.iter().filter(|(c, _)| *c == 0).count(); + assert_eq!(first_column_count, 2); + } + + #[test] + fn plan_columns_kinsoku_shift_never_overflows_budget() { + // A two-item budget: moving 「「 down with the overflowing い would put + // three items (30.0) in a 20.0 column, so the planner drops the shift. + let items = vec![ + item(10.0, 'あ'), + item(10.0, '「'), + item(10.0, '「'), + item(10.0, 'い'), + ]; + let placements = plan_columns(&items, 20.0); + let mut column_used = std::collections::BTreeMap::new(); + for (it, (column, top)) in items.iter().zip(&placements) { + let used: &mut f32 = column_used.entry(*column).or_default(); + *used = used.max(top + it.extent); + } + for (column, used) in column_used { + assert!(used <= 20.0, "column {column} overflows: {used}"); + } + } + + #[test] + fn capped_adjustment_redistributes_after_a_boundary_saturates() { + let allocations = capped_equal_allocations(&[(0, 1.0), (1, 3.0)], 3.0); + assert_eq!(allocations, vec![(0, 1.0), (1, 2.0)]); + } + + #[test] + fn align_offset_helper_maps_edges() { + // Unbounded budget (auto-width): no shift. + assert_eq!( + align_offset_along_column(TextAlign::Center, f32::MAX, 40.0), + 0.0 + ); + // Left/Start anchors to the top. + assert_eq!(align_offset_along_column(TextAlign::Left, 100.0, 40.0), 0.0); + // Center splits the slack. + assert_eq!( + align_offset_along_column(TextAlign::Center, 100.0, 40.0), + 30.0 + ); + // Right/End pushes to the bottom. + assert_eq!( + align_offset_along_column(TextAlign::Right, 100.0, 40.0), + 60.0 + ); + assert_eq!(align_offset_along_column(TextAlign::End, 100.0, 40.0), 60.0); + // Overfull column: no negative shift. + assert_eq!( + align_offset_along_column(TextAlign::Right, 40.0, 100.0), + 0.0 + ); + } + + /// Text of each column of `text` laid out in `height` under `policy`. + fn columns_under(text: &str, height: f32, policy: LineAdjustment) -> Vec { + let mut content = make_content(&[text], height); + content.set_line_adjustment(policy); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, height); + let chars: Vec = text.chars().collect(); + (0..layout.columns.len()) + .map(|column| { + layout + .cells + .iter() + .filter(|cell| cell.column == column) + .map(|cell| chars[cell.start]) + .collect() + }) + .collect() + } + + #[test] + fn line_adjustment_decides_whether_a_line_squeezes() { + // Fig 78, line 4: keeping う) needs the bracket aki squeezed. + let text = "ささえる「ボディ」(仮想ボディという)の大きさ"; + let height = 17.0 * EM; + let first = |policy| columns_under(text, height, policy)[0].clone(); + assert_eq!( + first(LineAdjustment::PushInFirst), + "ささえる「ボディ」(仮想ボディという)" + ); + assert_eq!( + first(LineAdjustment::PushOutFirst), + "ささえる「ボディ」(仮想ボディとい" + ); + assert_eq!( + first(LineAdjustment::PushOutOnly), + "ささえる「ボディ」(仮想ボディとい" + ); + } + + #[test] + fn push_out_policies_move_a_bracketed_word_instead_of_squeezing() { + // あ「あ」 in 3.25 em: squeezing 「's aki keeps it in one column; + // pushing out moves 「あ」 whole, since 「 cannot end a column. + let height = 3.25 * EM; + let columns = |policy| columns_under("あ「あ」", height, policy); + assert_eq!(columns(LineAdjustment::PushInFirst), vec!["あ「あ」"]); + assert_eq!(columns(LineAdjustment::PushOutFirst), vec!["あ", "「あ」"]); + assert_eq!(columns(LineAdjustment::PushOutOnly), vec!["あ", "「あ」"]); + } + + #[test] + fn closing_bracket_at_column_end_counts_without_its_aki() { + // JLREQ Fig 78: ……(仮想ボディという)at a 17 em column. A closing + // bracket ending a column is half width, so squeezing 「 and ( keeps + // う) in the column instead of pushing it out. + let text = "ささえる「ボディ」(仮想ボディという)の大きさ"; + let height = 17.0 * EM; + let content = make_content(&[text], height); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, height); + let chars: Vec = text.chars().collect(); + let first: String = layout + .cells + .iter() + .filter(|cell| cell.column == 0) + .map(|cell| chars[cell.start]) + .collect(); + assert_eq!(first, "ささえる「ボディ」(仮想ボディという)"); + let bracket = layout + .cells + .iter() + .find(|cell| chars[cell.start] == ')') + .unwrap(); + assert!( + bracket.top + bracket.extent <= height + 0.01, + "the bracket ends inside the column: {}", + bracket.top + bracket.extent + ); + } + + #[test] + fn layout_kinsoku_no_period_at_column_top() { + // Wrap height fits exactly 2 characters per column; the 。 after + // the second character would start column 2 without kinsoku. + let text = "あい。うえお"; + let content = make_content(&[text], 100.0); + let cell_extent = layout_content(&content, 1000.0).cells[0].extent; + let layout = layout_content(&content, cell_extent * 2.0 + 0.1); + + assert!(layout.columns.len() > 1, "the text must wrap"); + let chars: Vec = text.chars().collect(); + for column_index in 0..layout.columns.len() { + let head = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .min_by(|a, b| a.top.total_cmp(&b.top)) + .expect("every column holds a cell"); + let c = chars[head.start]; + assert!( + !forbidden_at_line_start(c), + "column {column_index} starts with forbidden char {c}" + ); + } + } + + #[test] + fn ordinary_closing_punctuation_keeps_preferred_aki() { + // In ordinary text, the comma and closing bracket keep their normal + // half-em glyph body plus half-em trailing aki: one em in total. + let content = make_content(&["く、く」く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells.len(), 5, "one cell per character"); + let em = 20.0; + assert!( + (layout.cells[0].extent - em).abs() < 1.0, + "leading ideograph keeps full advance, got {}", + layout.cells[0].extent + ); + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "、 before an ideograph keeps its aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[3].extent - em).abs() < 1.0, + "」 before an ideograph keeps its aki, got {}", + layout.cells[3].extent + ); + assert!( + (layout.cells[4].extent - em).abs() < 1.0, + "trailing ideograph keeps full advance, got {}", + layout.cells[4].extent + ); + } + + #[test] + fn closing_then_opening_keeps_half_em_aki() { + // く」「く: the closing bracket sheds its trailing half, but the + // opening bracket after it keeps its full em (leading half blank), so + // the pair keeps the half-em aki of JIS X 4051. + let content = make_content(&["く」「く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells.len(), 4, "one cell per character"); + let em = 20.0; + assert!( + layout.cells[1].extent <= 0.5 * em + 0.01, + "」 sheds its trailing aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "「 after a closing mark keeps its full em, got {}", + layout.cells[2].extent + ); + assert_eq!( + layout.cells[2].glyph_flow_shift, 0.0, + "the unshed opening bracket is not shifted" + ); + } + + #[test] + fn dividing_punctuation_materializes_trailing_aki() { + let provider = provider(VMTX_TEST_FONT); + let terminal = layout_with(&provider, &make_content(&["!"], 1000.0)); + let before_ideograph = layout_with(&provider, &make_content(&["!く"], 1000.0)); + let before_dividing = layout_with(&provider, &make_content(&["!?"], 1000.0)); + let before_closing = layout_with(&provider, &make_content(&["!」"], 1000.0)); + let em = 20.0; + let natural_extent = terminal.cells[0].extent; + + assert!( + (before_ideograph.cells[0].extent - natural_extent - em).abs() < 0.01, + "! before ordinary text adds one em, got {} over natural {natural_extent}", + before_ideograph.cells[0].extent + ); + assert!( + (before_dividing.cells[0].extent - natural_extent).abs() < 0.01, + "! before ? adds no aki, got {} over natural {natural_extent}", + before_dividing.cells[0].extent + ); + assert!( + (before_closing.cells[0].extent - natural_extent).abs() < 0.01, + "! before a closing bracket adds no aki, got {} over natural {natural_extent}", + before_closing.cells[0].extent + ); + } + + #[test] + fn dividing_punctuation_oikomi_never_compresses_glyph_frames() { + let provider = provider(VMTX_TEST_FONT); + let terminal_exclamation = layout_with(&provider, &make_content(&["!"], 1000.0)); + let terminal_question = layout_with(&provider, &make_content(&["?"], 1000.0)); + let layout = layout_with_height(&provider, &make_content(&["く!?く"], 60.0), 60.0); + + assert!( + layout.cells[1].extent + 0.01 >= terminal_exclamation.cells[0].extent, + "oikomi compressed the ! glyph frame from {} to {}", + terminal_exclamation.cells[0].extent, + layout.cells[1].extent + ); + assert!( + layout.cells[2].extent + 0.01 >= terminal_question.cells[0].extent, + "oikomi compressed the ? glyph frame from {} to {}", + terminal_question.cells[0].extent, + layout.cells[2].extent + ); + } + + #[test] + fn dividing_punctuation_discards_trailing_aki_at_column_edge() { + let provider = provider(VMTX_TEST_FONT); + let terminal = layout_with(&provider, &make_content(&["!"], 1000.0)); + let layout = layout_with_height(&provider, &make_content(&["!く"], 20.0), 20.0); + + assert_ne!(layout.cells[0].column, layout.cells[1].column); + assert!( + (layout.cells[0].extent - terminal.cells[0].extent).abs() < 0.01, + "column-end ! should discard its trailing aki, got {} over natural {}", + layout.cells[0].extent, + terminal.cells[0].extent + ); + } + + #[test] + fn ordinary_opening_punctuation_keeps_preferred_aki() { + // く「く: the opening bracket keeps its leading half-em aki in the + // middle of a line, so its total advance remains one em. + let content = make_content(&["く「く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells.len(), 3, "one cell per character"); + let em = 20.0; + assert!( + (layout.cells[0].extent - em).abs() < 1.0, + "leading ideograph keeps full advance, got {}", + layout.cells[0].extent + ); + assert_eq!( + layout.cells[0].glyph_flow_shift, 0.0, + "ideograph is not shifted" + ); + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "「 keeps its leading aki, got {}", + layout.cells[1].extent + ); + assert_eq!( + layout.cells[1].glyph_flow_shift, 0.0, + "an uncompressed opening bracket is not shifted, got {}", + layout.cells[1].glyph_flow_shift + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "trailing ideograph keeps full advance, got {}", + layout.cells[2].extent + ); + } + + #[test] + fn consecutive_closing_punctuation_sets_solid_internally() { + let content = make_content(&["く。」く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let em = 20.0; + assert!( + layout.cells[1].extent <= 0.5 * em + 0.01, + "。 sheds its internal trailing aki, got {}", + layout.cells[1].extent + ); + assert!( + (layout.cells[2].extent - em).abs() < 1.0, + "the final 」 keeps the sequence's trailing aki, got {}", + layout.cells[2].extent + ); + } + + #[test] + fn consecutive_opening_punctuation_sets_solid_after_first() { + let content = make_content(&["く「『く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let em = 20.0; + assert!( + (layout.cells[1].extent - em).abs() < 1.0, + "the first opening bracket keeps the sequence's leading aki, got {}", + layout.cells[1].extent + ); + assert!( + layout.cells[2].extent <= 0.5 * em + 0.01, + "the second opening bracket sets solid, got {}", + layout.cells[2].extent + ); + assert!(layout.cells[2].glyph_flow_shift < -0.01); + } + + #[test] + fn opening_bracket_at_column_head_is_tentsuki() { + let content = make_content(&["「く"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let em = 20.0; + assert_eq!(layout.cells[0].top, 0.0); + assert!( + layout.cells[0].extent <= 0.5 * em + 0.01, + "line-head 「 sheds its leading aki, got {}", + layout.cells[0].extent + ); + assert!(layout.cells[0].glyph_flow_shift < -0.01); + } + + #[test] + fn line_end_punctuation_keeps_preferred_half_em_aki() { + let content = make_content(&["く、"], 40.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert_eq!(layout.cells[0].column, layout.cells[1].column); + assert!( + (layout.cells[1].extent - 20.0).abs() < 1.0, + "line-end 、 keeps a half-em after its glyph, got {}", + layout.cells[1].extent + ); + } + + #[test] + fn plain_ideographs_keep_full_advance() { + let content = make_content(&["くくく"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + for cell in &layout.cells { + assert!( + (cell.extent - 20.0).abs() < 1.0, + "no punctuation: full advance kept, got {}", + cell.extent + ); + } + } + + #[test] + fn vpal_punctuation_is_not_double_compressed() { + // 、's vertical alternate already halves its advance under vpal; + // the aki shed must not compress the half-width cell again. + let provider = provider(VPAL_TEST_FONT); + let layout = layout_with(&provider, &vpal_content("あ、あ")); + assert!( + (layout.cells[1].extent - 10.0).abs() < 0.01, + "、 is exactly half-width under vpal, got {}", + layout.cells[1].extent + ); + } + + #[test] + fn vpal_oikomi_preserves_half_em_punctuation_frame() { + // vpal has removed the opening bracket's leading aki, so in a tight + // fixed-height column oikomi cannot shrink the frame and text wraps. + let provider = provider(VPAL_TEST_FONT); + let content = vpal_content("あ「あ"); + let natural = layout_with(&provider, &content); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let bracket_extent = natural.cells[1].extent; + let constrained = layout_with_height(&provider, &content, natural_total - 5.0); + + assert!( + (bracket_extent - 10.0).abs() < 0.01, + "vpal opening bracket starts at half-em, got {bracket_extent}" + ); + assert!( + (constrained.cells[1].extent - bracket_extent).abs() < 0.01, + "oikomi collapsed the vpal bracket frame from {bracket_extent} to {}", + constrained.cells[1].extent + ); + assert_ne!( + constrained.cells[0].column, constrained.cells[2].column, + "insufficient removable aki should wrap the protected bracket and following glyph" + ); + } + + #[test] + fn tate_chu_yoko_all_uses_asymmetric_punctuation_aki() { + // 」→TCY keeps the closing mark's trailing half-em, and TCY→「 keeps + // the opening mark's leading half-em. TCY on the opposite side of + // either bracket sets solid. + let mut content = make_content_with_spans(&["く", "」", "20", "「", "く"], 400.0); + content.paragraphs_mut()[0].children_mut()[2].text_combine_upright = + TextCombineUpright::All; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + assert!(matches!(layout.cells[2].kind, CellKind::TateChuYoko { .. })); + for (index, label) in [(1, "」 before TCY"), (2, "TCY"), (3, "「 after TCY")] { + assert!( + (layout.cells[index].extent - 20.0).abs() < 0.01, + "{label} should occupy its preferred one-em frame, got {}", + layout.cells[index].extent + ); + } + + let mut reverse = make_content_with_spans(&["く", "「", "20", "」", "く"], 400.0); + reverse.paragraphs_mut()[0].children_mut()[2].text_combine_upright = + TextCombineUpright::All; + let reverse = layout_with(&provider(VMTX_TEST_FONT), &reverse); + assert!(matches!( + reverse.cells[2].kind, + CellKind::TateChuYoko { .. } + )); + assert!((reverse.cells[1].extent - 20.0).abs() < 0.01); + assert!((reverse.cells[3].extent - 20.0).abs() < 0.01); + } + + #[test] + fn tate_chu_yoko_digits_uses_the_same_cl30_adjacency() { + let mut content = make_content_with_spans(&["く」31「く"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::Digits; + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let tcy_index = layout + .cells + .iter() + .position(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. })) + .expect("digit TCY cell"); + assert!((layout.cells[tcy_index - 1].extent - 20.0).abs() < 0.01); + assert!((layout.cells[tcy_index].extent - 20.0).abs() < 0.01); + assert!((layout.cells[tcy_index + 1].extent - 20.0).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_separates_cjk_and_latin() { + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; + + let cjk_latin = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + assert_eq!(cjk_latin.cells.len(), 2); + assert!( + (visible_flow_gap(&cjk_latin.cells[0], &cjk_latin.cells[1]) - expected_gap).abs() + < 0.01 + ); + + let latin_cjk = layout_content(&make_content(&["aあ"], 1000.0), 1000.0); + assert_eq!(latin_cjk.cells.len(), 2); + assert!( + (visible_flow_gap(&latin_cjk.cells[0], &latin_cjk.cells[1]) - expected_gap).abs() + < 0.01 + ); + } + + #[test] + fn inter_script_spacing_is_visually_symmetric_around_latin_run() { + let layout = layout_content(&make_content(&["うpenあ"], 1000.0), 1000.0); + assert_eq!(layout.cells.len(), 3); + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM; + let before = visible_flow_gap(&layout.cells[0], &layout.cells[1]); + let after = visible_flow_gap(&layout.cells[1], &layout.cells[2]); + assert!((before - expected_gap).abs() < 0.01); + assert!((after - expected_gap).abs() < 0.01); + assert!((before - after).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_preserves_explicit_letter_spacing() { + let letter_spacing = 3.0; + let layout = layout_content(&spaced_content("うpenあ", letter_spacing), 1000.0); + assert_eq!(layout.cells.len(), 3); + let expected_gap = 20.0 * INTER_SCRIPT_SPACING_EM + letter_spacing; + assert!((visible_flow_gap(&layout.cells[0], &layout.cells[1]) - expected_gap).abs() < 0.01); + assert!((visible_flow_gap(&layout.cells[1], &layout.cells[2]) - expected_gap).abs() < 0.01); + } + + #[test] + fn inter_script_spacing_crosses_span_boundaries() { + let joined = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + let split = layout_content(&make_content_with_spans(&["あ", "a"], 1000.0), 1000.0); + assert_eq!(joined.cells.len(), split.cells.len()); + for (joined, split) in joined.cells.iter().zip(&split.cells) { + assert!((joined.top - split.top).abs() < 0.01); + assert!((joined.extent - split.extent).abs() < 0.01); + } + } + + #[test] + fn inter_script_spacing_excludes_punctuation_and_whitespace() { + let cjk_extent = layout_content(&make_content(&["あ"], 1000.0), 1000.0).cells[0].extent; + for text in ["あ.", "あ a"] { + let layout = layout_content(&make_content(&[text], 1000.0), 1000.0); + assert!( + (layout.cells[0].extent - cjk_extent).abs() < 0.01, + "{text:?} must not add spacing immediately after the CJK cell" + ); + } + } + + #[test] + fn latin_sets_against_japanese_punctuation_without_script_spacing() { + // JLREQ §3.2.6 (Figure 103): after 、。」 Latin keeps only the + // punctuation's half-em aki, and Latin sets solid after 「 and + // before 、」. + let provider = provider_with_fallback(VPAL_TEST_FONT, TEST_FONT); + let fallback = ["fallback".to_string()]; + let layout = |text: &str| { + layout_with_fallback(&provider, &make_content(&[text], 1000.0), 1000.0, &fallback) + }; + let natural = layout("editor").cells[0].extent; + for text in ["あ、editor", "あ。editor", "あ」editor", "あ「editor"] { + let cells = layout(text).cells; + assert!( + (cells[2].top - cells[1].top - EM).abs() < 0.01, + "{text}: punctuation frame is {} em", + (cells[2].top - cells[1].top) / EM + ); + } + for text in ["editor、", "editor」"] { + let cells = layout(text).cells; + assert!( + (cells[1].top - natural).abs() < 0.01, + "{text}: Latin must set solid, gap {}", + cells[1].top - natural + ); + } + } + + #[test] + fn oikomi_reduces_script_gap_before_oidashi() { + let natural = layout_content(&make_content(&["あa"], 1000.0), 1000.0); + assert_eq!(natural.cells.len(), 2); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = natural_total - 2.0; + let adjusted = layout_content(&make_content(&["あa"], budget), budget); + assert_eq!(adjusted.cells[0].column, adjusted.cells[1].column); + let gap = visible_flow_gap(&adjusted.cells[0], &adjusted.cells[1]); + assert!( + (gap - (20.0 * INTER_SCRIPT_SPACING_EM - 2.0)).abs() < 0.01, + "oikomi should recover the two-pixel deficit from the script gap, got {gap}" + ); + } + + #[test] + fn oikomi_keeps_a_full_column_solid() { + // Five cells fill the column exactly; the sixth く may start a column, + // so the commas keep their aki instead of compressing to pull it in. + let budget = 5.0 * EM; + let content = make_content(&["く、く、くく"], budget); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + assert_eq!(layout.cells[4].column, 0); + assert_eq!(layout.cells[5].column, 1); + for comma in [1, 3] { + assert!( + (layout.cells[comma].extent - EM).abs() < 0.01, + "comma {comma} keeps its aki, got {}", + layout.cells[comma].extent + ); + } + } + + #[test] + fn oikomi_fills_a_column_left_short_by_a_rotated_run() { + // The Latin run leaves the column uneven; reducing the comma aki + // pulls the last く in instead of leaving the column short. + let text = "くaく、くく"; + let provider = provider(VMTX_TEST_FONT); + let natural = layout_with_height(&provider, &make_content(&[text], 1000.0), 1000.0); + let total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = total - 2.0; + let layout = layout_with_height(&provider, &make_content(&[text], budget), budget); + let last = layout.cells.last().expect("cells"); + assert_eq!(last.column, 0, "oikomi keeps the last く in the column"); + } + + #[test] + fn oikomi_keeps_a_line_start_forbidden_character_in_the_column() { + let budget = 5.0 * EM; + let content = make_content(&["く、く、く」"], budget); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + assert_eq!( + layout.cells[5].column, 0, + "」 cannot start a column, so oikomi keeps it in place" + ); + } + + #[test] + fn oikomi_preserves_sentence_final_full_stop_aki() { + let natural = layout_content(&make_content(&["あ。あ"], 1000.0), 1000.0); + let natural_total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = natural_total - 2.0; + let adjusted = layout_content(&make_content(&["あ。あ"], budget), budget); + assert_ne!( + adjusted.cells[1].column, adjusted.cells[2].column, + "fixed full-stop aki must not be compressed to retain the next ideograph" + ); + assert!((adjusted.cells[1].extent - natural.cells[1].extent).abs() < 0.01); + } + + #[test] + fn text_align_shifts_column_cells() { + let text = "あいう"; + // Baseline (top-aligned) used length of the single column. + let used = { + let content = aligned_content(text, 1000.0, TextAlign::Left); + let layout = layout_content(&content, 1000.0); + layout + .cells + .iter() + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max) + }; + + let top_of = |align: TextAlign| { + let content = aligned_content(text, 1000.0, align); + let layout = layout_content(&content, 1000.0); + layout.cells[0].top + }; + + assert!(top_of(TextAlign::Left).abs() < 0.01); + assert!((top_of(TextAlign::Center) - (1000.0 - used) / 2.0).abs() < 0.5); + assert!((top_of(TextAlign::Right) - (1000.0 - used)).abs() < 0.5); + // Cells stay in reading order and keep tiling under the shift. + let content = aligned_content(text, 1000.0, TextAlign::Right); + let layout = layout_content(&content, 1000.0); + for pair in layout.cells.windows(2) { + assert!(pair[1].top >= pair[0].top); + } + } + + #[test] + fn text_align_auto_width_stays_top() { + // Auto-width columns are snug: alignment must not shift them. + let content = content_of( + vec![vertical_paragraph( + vec![make_span("あいうえお")], + TextAlign::Right, + 1.0, + )], + 60.0, + GrowType::AutoWidth, + ); + + let layout = layout_content(&content, wrap_height(&content, 60.0)); + assert!(layout.cells[0].top.abs() < 0.01); + } + + #[test] + fn justify_fills_non_last_columns() { + use std::collections::BTreeMap; + let text = "あいうえお"; + // Uniform per-cell extent for the test font. + let e = { + let content = aligned_content(text, 1000.0, TextAlign::Left); + layout_content(&content, 1000.0).cells[0].extent + }; + // A 2.5-cell budget wraps into three columns: {0,1}, {2,3}, {4}. The + // first two break with 0.5e of slack; the last column is one cell. + let budget = e * 2.5; + let content = aligned_content(text, budget, TextAlign::Justify); + let layout = layout_content(&content, budget); + + let mut by_col: BTreeMap> = BTreeMap::new(); + for c in &layout.cells { + by_col.entry(c.column).or_default().push(c); + } + assert!(by_col.len() >= 2, "text must wrap into multiple columns"); + let last = *by_col.keys().max().unwrap(); + + for (&col, col_cells) in &by_col { + if col != last && col_cells.len() > 1 { + assert!(col_cells[0].top.abs() < 0.01, "col {col} top cell at top"); + let bottom = col_cells.last().unwrap().top + col_cells.last().unwrap().extent; + assert!( + (bottom - budget).abs() < 0.5, + "justified col {col} fills the budget, bottom {bottom} vs {budget}" + ); + } + } + // The last column keeps its natural top (start-aligned, not stretched). + assert!( + by_col[&last][0].top.abs() < 0.01, + "last column is not justified" + ); + } +} diff --git a/render-wasm/src/shapes/text_vertical/font_tables.rs b/render-wasm/src/shapes/text_vertical/font_tables.rs new file mode 100644 index 0000000000..f7a6cdf32c --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/font_tables.rs @@ -0,0 +1,226 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::thread::LocalKey; + +use skia_safe::{Font, GlyphId}; + +use crate::shapes::gpos_vpal::{parse_vpal, VpalDelta}; + +/// Parsed font tables keyed by `SkTypefaceID`. `None` is cached too: a face +/// without the table never gains it. +type TableCache = RefCell>>>; + +thread_local! { + // Layout runs on every paint; each face's tables are parsed once. + static VERTICAL_METRICS: TableCache = RefCell::default(); + static VPAL_TABLES: TableCache = RefCell::default(); + static TYPO_METRICS: TableCache = RefCell::default(); +} + +fn cached_table( + cache: &'static LocalKey>, + font: &Font, + parse: impl FnOnce(&Font) -> Option, +) -> Option> { + let id = font.typeface().unique_id(); + cache.with(|cache| { + cache + .borrow_mut() + .entry(id) + .or_insert_with(|| parse(font).map(Rc::new)) + .clone() + }) +} + +fn units_per_em(font: &Font) -> Option { + let units_per_em = font.typeface().units_per_em()? as f32; + (units_per_em > 0.0).then_some(units_per_em) +} + +fn table_data(font: &Font, tag: &[u8; 4]) -> Option { + font.typeface().copy_table_data(u32::from_be_bytes(*tag)) +} + +/// Vertical advances from a font's `vhea`/`vmtx` tables. Upright cells +/// advance by the glyph's vertical advance, not its shaped horizontal one; +/// the two differ for vertical alternates and proportional glyphs (e.g. the +/// vertical kana repeat marks). +pub(super) struct VerticalMetrics { + units_per_em: f32, + /// Advance heights of the first `advances.len()` glyph ids. + advances: Vec, + /// Advance for glyph ids at or beyond the long-metric count. + last: u16, +} + +impl VerticalMetrics { + /// Parses `vhea`/`vmtx`. `None` when the font has no vertical metrics + /// (callers keep horizontal advances). + pub(super) fn from_font(font: &Font) -> Option { + let units_per_em = units_per_em(font)?; + let vhea = table_data(font, b"vhea")?; + let vmtx = table_data(font, b"vmtx")?; + let vhea = vhea.as_bytes(); + let vmtx = vmtx.as_bytes(); + // `numberOfLongVerMetrics` is the trailing u16 of the 36-byte header. + let num_long = u16::from_be_bytes([*vhea.get(34)?, *vhea.get(35)?]) as usize; + if num_long == 0 { + return None; + } + // Each long metric is { advanceHeight: u16, topSideBearing: i16 }. + let mut advances = Vec::with_capacity(num_long); + for i in 0..num_long { + let o = i * 4; + let (Some(&hi), Some(&lo)) = (vmtx.get(o), vmtx.get(o + 1)) else { + break; + }; + advances.push(u16::from_be_bytes([hi, lo])); + } + let last = *advances.last()?; + Some(Self { + units_per_em, + advances, + last, + }) + } + + /// Vertical advance of `glyph` at `font_size`, in pixels (same units as + /// the horizontal advances). + pub(super) fn advance(&self, glyph: GlyphId, font_size: f32) -> f32 { + let raw = self + .advances + .get(glyph as usize) + .copied() + .unwrap_or(self.last); + raw as f32 * font_size / self.units_per_em + } +} + +pub(super) fn vertical_metrics(font: &Font) -> Option> { + cached_table(&VERTICAL_METRICS, font, VerticalMetrics::from_font) +} + +/// GPOS `vpal` deltas for one typeface, in font units. +pub(super) struct VpalTable { + units_per_em: f32, + deltas: HashMap, +} + +impl VpalTable { + fn from_font(font: &Font) -> Option { + let units_per_em = units_per_em(font)?; + let gpos = table_data(font, b"GPOS")?; + let deltas = parse_vpal(gpos.as_bytes())?; + Some(Self { + units_per_em, + deltas, + }) + } + + /// Pixel deltas for a cluster at `font_size`: summed advance delta + /// (negative when the cell tightens) and flow-axis ink shift (positive + /// down the column; GPOS `yPlacement` is y-up, so its sign flips). + /// `None` when no glyph of the cluster is covered. + pub(super) fn cluster_delta(&self, glyphs: &[GlyphId], font_size: f32) -> Option<(f32, f32)> { + let scale = font_size / self.units_per_em; + let mut advance = 0.0f32; + let mut flow_shift = None; + for glyph in glyphs { + if let Some(delta) = self.deltas.get(glyph) { + advance += delta.y_advance as f32 * scale; + flow_shift.get_or_insert(-(delta.y_placement as f32) * scale); + } + } + flow_shift.map(|shift| (advance, shift)) + } +} + +pub(super) fn vpal_table(font: &Font) -> Option> { + cached_table(&VPAL_TABLES, font, VpalTable::from_font) +} + +/// OS/2 typographic ascender/descender over unitsPerEm. They bound the +/// ideographic em box, which the browser uses for the vertical central +/// baseline. `hhea`/`Font::metrics` ascent exceeds the em in CJK faces and +/// would push an upright glyph off the centre of its cell. +struct TypoMetrics { + /// sTypoAscender / unitsPerEm (positive, above the baseline). + ascender: f32, + /// sTypoDescender / unitsPerEm (negative, below the baseline). + descender: f32, +} + +impl TypoMetrics { + fn from_font(font: &Font) -> Option { + let units_per_em = units_per_em(font)?; + let os2 = table_data(font, b"OS/2")?; + let os2 = os2.as_bytes(); + // sTypoAscender @ 68, sTypoDescender @ 70 (i16), in every OS/2 version. + let ascender = i16::from_be_bytes([*os2.get(68)?, *os2.get(69)?]) as f32; + let descender = i16::from_be_bytes([*os2.get(70)?, *os2.get(71)?]) as f32; + Some(Self { + ascender: ascender / units_per_em, + descender: descender / units_per_em, + }) + } +} + +/// Ascent/descent for centring an upright cell, in Skia's sign convention +/// (ascent negative, descent positive) at the font's size. Uses the OS/2 +/// typographic metrics, which match the browser's vertical central baseline +/// in the SVG/foreignObject export, or `Font::metrics` without an OS/2 table. +pub(super) fn upright_centre_metrics(font: &Font) -> (f32, f32) { + if let Some(typo) = cached_table(&TYPO_METRICS, font, TypoMetrics::from_font) { + let size = font.size(); + return (-typo.ascender * size, -typo.descender * size); + } + let (_, metrics) = font.metrics(); + (metrics.ascent, metrics.descent) +} + +/// Offset from an upright cell's top edge to the glyph baseline. Centres the +/// glyph's line box in the em cell, as Tate-chu-yoko does. CJK faces have an +/// ascent larger than the em, so hanging glyphs from the ascent would push +/// them below their cells and overflow the column. +pub(super) fn upright_baseline_offset(ascent: f32, descent: f32, font_size: f32) -> f32 { + font_size / 2.0 - (ascent + descent) / 2.0 +} + +/// Baseline offset for an upright glyph of `font` in an em cell of `font_size`. +pub(super) fn upright_baseline(font: &Font, font_size: f32) -> f32 { + let (ascent, descent) = upright_centre_metrics(font); + upright_baseline_offset(ascent, descent, font_size) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::{TEST_FONT, VMTX_TEST_FONT}; + use super::*; + use skia_safe::FontMgr; + + #[test] + fn vertical_metrics_parse_vmtx() { + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(skia_safe::Data::new_copy(VMTX_TEST_FONT), None) + .unwrap(); + let font = Font::new(typeface, 20.0); + let vm = VerticalMetrics::from_font(&font).expect("vmtx present"); + // gid 1 = 〱 (2000 units), gid 3+ fall back to the last long metric. + assert!((vm.advance(1, 20.0) - 40.0).abs() < 0.01); + assert!((vm.advance(3, 20.0) - 20.0).abs() < 0.01); + assert!((vm.advance(99, 20.0) - 20.0).abs() < 0.01); + } + + #[test] + fn vertical_metrics_absent_without_vmtx() { + // The bundled Source Sans face carries no `vmtx`. + let font_mgr = FontMgr::new(); + let typeface = font_mgr + .new_from_data(skia_safe::Data::new_copy(TEST_FONT), None) + .unwrap(); + let font = Font::new(typeface, 20.0); + assert!(VerticalMetrics::from_font(&font).is_none()); + } +} diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs new file mode 100644 index 0000000000..21623bad87 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -0,0 +1,1016 @@ +use std::cell::OnceCell; + +use skia_safe::{ + self as skia, + textlayout::{TextDecoration, TypefaceFontProvider}, + FontMgr, +}; + +use crate::globals::get_resources; +use crate::math::Rect; +use crate::shapes::japanese::JapaneseClass; +use crate::shapes::text_japanese::EMPHASIS_FONT_SCALE; +use crate::shapes::{ + merge_fills, AppliedTextTransform, FontFeatures, GrowType, LineAdjustment, Paragraph, RubySide, + TextAlign, TextContent, TextSpan, VerticalAlign, +}; +use crate::utils::get_fallback_fonts; + +use super::annotations::{ + grow_ruby_bases, layout_emphasis, layout_ruby, ruby_base_units, ruby_rooms, + set_ruby_overhang_rooms, spread_ruby_base_cells, EmphasisMark, RubyBaseUnit, RubyCell, + RubyRooms, +}; +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_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, FlowCell, FIT_TOLERANCE, +}; +use super::paint::LayoutDraws; +use super::shaping::ShapedRun; + +#[derive(Debug, Clone, Copy)] +pub enum CellKind { + Upright { + run: usize, + glyph: usize, + count: usize, + }, + /// An upright-flow character whose font lacks a `vert`/`vrt2` alternate, + /// rotated in its own cell so wrapping, kinsoku and editor offsets stay + /// per character. + SyntheticRotated { + run: usize, + glyph: usize, + count: usize, + }, + Rotated { + run: usize, + }, + TateChuYoko { + /// Runs `[run_start, run_start + run_count)`, laid side by side. + run_start: usize, + run_count: usize, + /// Horizontal squeeze that fits the runs into one em; glyphs keep + /// their full height. + h_scale: f32, + }, + /// One span's share of a warichu piece. Every cell of a piece has the + /// piece's box; the `*_top`/`*_extent` pairs place this span's glyphs + /// along each sub-line, relative to `top`. + Warichu { + /// First `first_count` runs are the right sub-line, the rest the left. + run_start: usize, + run_count: usize, + first_count: usize, + /// UTF-16 length of the first sub-line part (split point from `start`). + first_chars: usize, + first_top: f32, + first_extent: f32, + second_top: f32, + second_extent: f32, + }, +} + +/// One placed piece of the vertical flow. Offsets are UTF-16, +/// paragraph-relative (all spans concatenated), in original text space. +#[derive(Clone)] +pub struct VerticalCell { + pub kind: CellKind, + pub paragraph: usize, + pub span: usize, + pub start: usize, + pub end: usize, + pub column: usize, + pub top: f32, + /// Flow-axis advance, from `vmtx` when present, else the horizontal one. + pub extent: f32, + /// Oikomi lower bound: the glyph frame before removable pair spacing. + pub minimum_oikomi_extent: f32, + /// Shaped horizontal advance, used to centre the glyph in the column. + pub h_advance: f32, + /// Visible glyph-ink edges along the flow axis, relative to `top`. + pub ink_top: f32, + pub ink_bottom: f32, + pub paint: usize, + /// Span font size, for decoration bar geometry. + pub font_size: f32, + /// Span text decoration, painted as vertical bars along the column. + pub decoration: Option, + /// Draw-only flow shift for half-width opening punctuation (jlreq aki). + pub glyph_flow_shift: f32, +} + +/// A laid-out column, x measured from the *content* left edge (the content +/// block is anchored to the shape's right edge by consumers). +#[derive(Debug, Clone, Copy)] +pub struct VerticalColumn { + pub x: f32, + pub width: f32, + /// Reserved annotation gutter before the base band (left / `under`). + pub base_offset: f32, + /// Line-height column advance that base glyphs centre on. + pub base_width: f32, +} + +pub(super) fn column_base_center(column: &VerticalColumn) -> f32 { + column.x + column.base_offset + column.base_width / 2.0 +} + +pub struct VerticalLayout { + pub runs: Vec, + pub paints: Vec, + pub cells: Vec, + pub columns: Vec, + /// Shaped ruby annotation runs, indexed by `RubyCell::run`. + pub ruby_runs: Vec, + pub ruby_cells: Vec, + /// Single-glyph emphasis runs, indexed by `EmphasisMark::run`. + pub emphasis_runs: Vec, + pub emphasis_marks: Vec, + /// Per paragraph: [start, end) range into `columns`. + pub paragraph_columns: Vec<(usize, usize)>, + /// Text offsets of each paragraph. + pub text_maps: Vec, + pub width: f32, + pub height: f32, + /// Glyph draws, built on the first paint. + pub(super) draws: OnceCell, +} + +/// Text offsets of one laid-out paragraph, which map cells back to the +/// source text. +pub struct ParagraphTextMap { + /// Transformed text of each span, with its source ownership. + pub span_transforms: Vec, + /// UTF-16 start of each span in the paragraph's transformed text. + pub span_starts: Vec, + /// UTF-16 start of each span in the paragraph's source text. + pub span_source_starts: Vec, + /// UTF-16 offset of each scalar boundary of the transformed text, for + /// editor positions. + pub utf16_boundaries: Vec, +} + +impl ParagraphTextMap { + fn new(paragraph: &Paragraph) -> Self { + let span_transforms: Vec = paragraph + .children() + .iter() + .map(TextSpan::apply_text_transform_with_source_ranges) + .collect(); + let span_starts = running_starts( + span_transforms + .iter() + .map(|transform| transform.text.encode_utf16().count()), + ); + let span_source_starts = running_starts( + paragraph + .children() + .iter() + .map(|span| span.text.encode_utf16().count()), + ); + let mut utf16_boundaries = vec![0usize]; + let mut offset = 0usize; + for character in span_transforms.iter().flat_map(|span| span.text.chars()) { + offset += character.len_utf16(); + utf16_boundaries.push(offset); + } + Self { + span_transforms, + span_starts, + span_source_starts, + utf16_boundaries, + } + } +} + +/// Start of each item when items of `lengths` follow one another. +fn running_starts(lengths: impl Iterator) -> Vec { + lengths + .scan(0usize, |offset, length| { + let start = *offset; + *offset += length; + Some(start) + }) + .collect() +} + +impl VerticalLayout { + /// Content origin (top-left of the laid-out block) in the coordinate + /// space of `bounds`. + pub fn origin(&self, bounds: &Rect, align: VerticalAlign) -> (f32, f32) { + ( + bounds.left + block_axis_offset(bounds.width(), self.width, align), + bounds.top, + ) + } +} + +/// Horizontal offset of vertical content within its shape. `VerticalAlign` +/// top/center/bottom mean block start/center/end: right/center/left in +/// vertical-rl. Content wider than the shape keeps that anchor and overflows +/// toward the block end, so a start-anchored block grows leftward. +pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 { + let slack = container_width - content_width; + match align { + VerticalAlign::Top => slack, + VerticalAlign::Center => slack / 2.0, + VerticalAlign::Bottom => 0.0, + } +} + +/// Column-wrap limit: auto-width shapes grow to fit (columns never wrap); +/// all others, auto-height included, wrap at the shape height. +pub fn wrap_height(text_content: &TextContent, height: f32) -> f32 { + match text_content.grow_type() { + GrowType::AutoWidth => f32::MAX, + _ => f32::max(height, 1.0), + } +} + +/// Cross-axis geometry shared by every column of a paragraph. +struct ColumnGeometry { + /// Line-height-controlled column advance the base glyphs centre on. + base_width: f32, + /// Annotation gutter on the left (`under`) side of the base band. + under_gutter: f32, + /// Annotation gutter on the right (`over`) side of the base band. + over_gutter: f32, +} + +impl ColumnGeometry { + fn new(paragraph: &Paragraph) -> Self { + let spans = paragraph.children(); + let line_height = if paragraph.line_height() > 0.0 { + paragraph.line_height() + } else { + 1.2 + }; + let max_font_size = spans.iter().map(|s| s.font_size).fold(12.0, f32::max); + + // Under automatic clearance, ruby reserves its configured-size gutter + // on the logical annotation side. In vertical-rl, `over` is right and + // `under` is left. `None` keeps the column advance, as horizontal + // keeps the line height. + let reserves = |span: &&TextSpan| span.annotation_clearance.is_auto(); + let ruby_gutter = |side| { + spans + .iter() + .filter(reserves) + .filter(|span| span.has_ruby() && span.ruby_side == side) + .map(TextSpan::ruby_font_size) + .fold(0.0, f32::max) + }; + let ruby_over_gutter = ruby_gutter(RubySide::Over); + // Emphasis takes the over (right) side. Auto-clearance spans with both + // annotations stack there; under-side ruby stays separate. + let emphasis_gutter = spans + .iter() + .filter(reserves) + .filter(|span| !span.text_emphasis.is_none()) + .map(|span| span.font_size * EMPHASIS_FONT_SCALE) + .fold(0.0, f32::max); + let stacked = spans + .iter() + .any(|s| s.stacks_emphasis_outside_ruby() && !s.text_emphasis.is_none()); + let over_gutter = if stacked { + ruby_over_gutter + emphasis_gutter + } else { + ruby_over_gutter.max(emphasis_gutter) + }; + + Self { + base_width: max_font_size * line_height, + under_gutter: ruby_gutter(RubySide::Under), + over_gutter, + } + } + + fn column(&self) -> VerticalColumn { + VerticalColumn { + x: 0.0, + width: self.under_gutter + self.base_width + self.over_gutter, + base_offset: self.under_gutter, + base_width: self.base_width, + } + } +} + +/// A run of the paragraph flow: shaped cells, or the warichu note at an +/// index of `ParagraphCells::notes`. +enum FlowSegment { + Shaped(Vec), + Note(usize), +} + +/// A paragraph shaped once for warichu planning. Spans outside warichu are +/// shaped up front; each plan only reshapes the notes. +struct ParagraphCells<'a> { + paragraph: &'a Paragraph, + text_map: &'a ParagraphTextMap, + segments: Vec, + notes: Vec>, +} + +impl<'a> ParagraphCells<'a> { + /// Shapes the spans outside warichu into `runs` and adds one paint per + /// span to `paints`. + fn new( + fonts: &'a Fonts, + paragraph_index: usize, + paragraph: &'a Paragraph, + text_map: &'a ParagraphTextMap, + bounds: Rect, + runs: &mut Vec, + paints: &mut Vec, + ) -> Self { + let mut segments = Vec::new(); + let mut notes: Vec> = Vec::new(); + let mut note_members = Vec::new(); + let mut flush_note = |members: &mut Vec<_>, segments: &mut Vec| { + if !members.is_empty() { + segments.push(FlowSegment::Note(notes.len())); + notes.push(WarichuNote::new(std::mem::take(members))); + } + }; + for (span_index, (span, transform)) in paragraph + .children() + .iter() + .zip(&text_map.span_transforms) + .enumerate() + { + let offset = text_map.span_starts[span_index]; + if transform.text.is_empty() { + continue; + } + paints.push(merge_fills(&span.fills, bounds)); + let span_cells = SpanCells::new( + fonts, + span, + paragraph_index, + span_index, + paints.len() - 1, + offset, + ); + if span.warichu { + note_members.push((span_cells, transform.text.as_str())); + } else { + flush_note(&mut note_members, &mut segments); + let mut cells = Vec::new(); + span_cells.push(&transform.text, runs, &mut cells); + segments.push(FlowSegment::Shaped(cells)); + } + } + flush_note(&mut note_members, &mut segments); + Self { + paragraph, + text_map, + segments, + notes, + } + } + + /// The paragraph's cells in flow order, before spacing and placement, + /// with notes broken into pieces at the paragraph UTF-16 + /// `warichu_splits`. Note runs go to the end of `runs`. + fn flow(&self, warichu_splits: &[usize], runs: &mut Vec) -> Vec { + let mut flow = Vec::new(); + for segment in &self.segments { + match segment { + FlowSegment::Shaped(cells) => flow.extend(cells.iter().cloned()), + FlowSegment::Note(note) => self.notes[*note].push(warichu_splits, runs, &mut flow), + } + } + keep_transform_expansions_together(&mut flow, self.text_map); + keep_ruby_bases_together(&mut flow, self.paragraph); + flow + } +} + +/// Most warichu breaks planned per paragraph. +const MAX_WARICHU_SPLITS: usize = 64; + +/// Next break that lets a warichu piece start where the planner left room +/// for it, as a paragraph UTF-16 offset. +fn next_warichu_split( + notes: &[WarichuNote], + flow: &[FlowCell], + placements: &[(usize, f32)], + splits: &[usize], + max_height: f32, +) -> Option { + warichu_overflows(flow, placements, max_height) + .into_iter() + .find_map(|(i, room)| { + let start = flow[i].cell.start; + let note = notes.iter().find(|note| note.contains(start))?; + note.split_for_room(start, splits, room) + .filter(|split| !splits.contains(split)) + }) +} + +/// Warichu pieces the planner moved whole to a later column, or that overrun +/// their column, with the room left for them where they should start: after +/// the cells kinsoku carried along, at the bottom of the previous column. +fn warichu_overflows( + flow: &[FlowCell], + placements: &[(usize, f32)], + max_height: f32, +) -> Vec<(usize, f32)> { + if !is_bounded(max_height) { + return Vec::new(); + } + let columns = placements.last().map_or(0, |(column, _)| column + 1); + let mut column_used = vec![0.0f32; columns]; + let mut column_first = vec![usize::MAX; columns]; + for (i, (cell, (column, top))) in flow.iter().zip(placements).enumerate() { + column_used[*column] = column_used[*column].max(top + cell.cell.extent); + column_first[*column] = column_first[*column].min(i); + } + let mut overflows = Vec::new(); + for (i, (cell, (column, top))) in flow.iter().zip(placements).enumerate() { + if !matches!(cell.cell.kind, CellKind::Warichu { .. }) || cell.shares_previous_box { + continue; + } + if top + cell.cell.extent > max_height + FIT_TOLERANCE { + overflows.push((i, max_height - top)); + } else if *column > 0 { + let carried: f32 = flow[column_first[*column]..i] + .iter() + .map(|carried| carried.cell.extent) + .sum(); + let room = max_height - column_used[column - 1] - carried; + if room > FIT_TOLERANCE { + overflows.push((i, room)); + } + } + } + overflows +} +/// A CSS transform may expand one source character into several cells +/// (`ß` -> `SS`). Keep them in one column so the SVG fallback renders each +/// source slice once. +fn keep_transform_expansions_together(flow: &mut [FlowCell], text_map: &ParagraphTextMap) { + let source_range = |cell: &VerticalCell| { + let start = text_map.span_starts[cell.span]; + text_map.span_transforms[cell.span].source_utf16_range(cell.start - start..cell.end - start) + }; + for index in 1..flow.len() { + let (previous, current) = (&flow[index - 1].cell, &flow[index].cell); + flow[index].keep_with_previous = + previous.span == current.span && source_range(previous) == source_range(current); + } +} + +/// A span reading is group ruby: its base moves between columns whole, so +/// the reading never splits. +fn keep_ruby_bases_together(flow: &mut [FlowCell], paragraph: &Paragraph) { + let spans = paragraph.children(); + for index in 1..flow.len() { + let span = flow[index].cell.span; + if flow[index - 1].cell.span == span && spans.get(span).is_some_and(TextSpan::has_ruby) { + flow[index].keep_with_previous = true; + } + } +} + +/// Final flow-axis top of each cell: its planned offset plus the text-align +/// shift. Justify stretches every column but the last to fill a bounded wrap +/// height. +fn aligned_tops( + flow: &[FlowCell], + classes: &[Option], + pair_spacing_em: &[f32], + placements: &[(usize, f32)], + align: TextAlign, + max_height: f32, +) -> Vec { + let columns_used = placements.last().map_or(1, |(column, _)| column + 1); + // A column's used length is its farthest cell bottom. + let mut column_used = vec![0.0f32; columns_used]; + for (flow, (column, top)) in flow.iter().zip(placements) { + column_used[*column] = column_used[*column].max(top + flow.cell.extent); + } + let expansion = if matches!(align, TextAlign::Justify) && is_bounded(max_height) { + ordered_expansion_offsets( + flow, + classes, + pair_spacing_em, + placements, + &column_used, + max_height, + ) + } else { + vec![0.0; flow.len()] + }; + placements + .iter() + .zip(expansion) + .map(|((column, top), extra)| { + top + align_offset_along_column(align, max_height, column_used[*column]) + extra + }) + .collect() +} + +/// A paragraph's flow after spacing and column planning. +struct PlannedParagraph { + flow: Vec, + ruby_units: Vec, + classes: Vec>, + pair_spacing_em: Vec, + /// (column, offset from the column top) of each flow cell. + placements: Vec<(usize, f32)>, +} + +impl PlannedParagraph { + /// Plans the flow, then breaks the first warichu piece that does not fit + /// where it starts and plans again, until every piece fits. Note runs go + /// to the end of `runs`. + fn new( + cells: &ParagraphCells, + fonts: &Fonts, + max_height: f32, + line_adjustment: LineAdjustment, + runs: &mut Vec, + ) -> Self { + let spans = cells.paragraph.children(); + let ruby_spans: Vec = spans.iter().map(TextSpan::has_ruby).collect(); + let vpal_spans: Vec = spans + .iter() + .map(|span| span.font_features == FontFeatures::Vpal) + .collect(); + let ruby_units = ruby_base_units( + cells.paragraph, + &cells.text_map.span_transforms, + &cells.text_map.span_starts, + fonts, + ); + let run_mark = runs.len(); + let mut warichu_splits: Vec = Vec::new(); + loop { + runs.truncate(run_mark); + let mut flow = cells.flow(&warichu_splits, runs); + let mut ruby_units = ruby_units.clone(); + + apply_inter_script_spacing(&mut flow); + let classes = flow_classes(&flow, &ruby_spans); + let aki = aki_classes(&flow, &classes, &vpal_spans); + shed_punctuation_aki(&mut flow, &aki); + materialize_explicit_pair_spacing(&mut flow, &classes); + set_ruby_overhang_rooms(&flow, &mut ruby_units); + grow_ruby_bases(&mut flow, &ruby_units); + let mut pair_spacing_em = preferred_pair_spacing(&classes); + apply_ordered_oikomi( + &mut flow, + &classes, + &mut pair_spacing_em, + max_height, + line_adjustment, + ); + let placements = + plan_with_edge_trimming(&mut flow, &classes, &mut pair_spacing_em, max_height); + let split = if warichu_splits.len() < MAX_WARICHU_SPLITS { + next_warichu_split( + &cells.notes, + &flow, + &placements, + &warichu_splits, + max_height, + ) + } else { + None + }; + match split { + Some(split) => warichu_splits.push(split), + None => { + return Self { + flow, + ruby_units, + classes, + pair_spacing_em, + placements, + } + } + } + } + } + + fn columns_used(&self) -> usize { + self.placements.last().map_or(1, |(column, _)| column + 1) + } + + /// 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 { + 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 { + column: column_base + column, + top, + ..flow.cell.clone() + }; + if flow.shares_previous_box { + if let Some(piece) = cells.last() { + cell.top = piece.top; + cell.extent = piece.extent; + cell.minimum_oikomi_extent = piece.minimum_oikomi_extent; + cell.ink_top = piece.ink_top; + cell.ink_bottom = piece.ink_bottom; + } + } + cells.push(cell); + } + cells + } +} + +/// Sets each column's x, right to left from column 0, and returns the total +/// width. +fn place_columns_right_to_left(columns: &mut [VerticalColumn]) -> f32 { + let width: f32 = columns.iter().map(|column| column.width).sum(); + let mut right = width; + for column in columns.iter_mut() { + right -= column.width; + column.x = right; + } + width +} + +/// Lay out the whole content vertically. Pure of global state: fonts come +/// through the provider/fallback arguments so native tests can supply +/// their own. +pub fn layout_vertical( + text_content: &TextContent, + max_height: f32, + font_provider: &TypefaceFontProvider, + fallback_mgr: FontMgr, + fallback_families: &[String], + bounds: Rect, +) -> VerticalLayout { + let fonts = Fonts { + provider: font_provider, + fallback_mgr: &fallback_mgr, + fallback_families, + }; + let paragraphs = text_content.paragraphs(); + let text_maps: Vec = paragraphs.iter().map(ParagraphTextMap::new).collect(); + + let mut runs: Vec = Vec::new(); + let mut paints: Vec = Vec::new(); + let mut cells: Vec = Vec::new(); + let mut columns: Vec = Vec::new(); + let mut paragraph_columns: Vec<(usize, usize)> = Vec::new(); + let mut ruby_rooms_by_span = RubyRooms::new(); + + for (paragraph_index, (paragraph, text_map)) in paragraphs.iter().zip(&text_maps).enumerate() { + let paragraph_cells = ParagraphCells::new( + &fonts, + paragraph_index, + paragraph, + text_map, + bounds, + &mut runs, + &mut paints, + ); + let planned = PlannedParagraph::new( + ¶graph_cells, + &fonts, + max_height, + text_content.line_adjustment(), + &mut runs, + ); + + let column_base = columns.len(); + let columns_used = planned.columns_used(); + columns.extend(std::iter::repeat_n( + ColumnGeometry::new(paragraph).column(), + columns_used, + )); + paragraph_columns.push((column_base, column_base + columns_used)); + + let mut placed = planned.place(paragraph.text_align(), max_height, column_base); + spread_ruby_base_cells(&mut placed, &planned.ruby_units, max_height); + cells.extend(placed); + ruby_rooms_by_span.extend(ruby_rooms(paragraph_index, &planned.ruby_units)); + } + + let width = place_columns_right_to_left(&mut columns); + let (ruby_runs, ruby_cells) = layout_ruby(text_content, &cells, &fonts, &ruby_rooms_by_span); + let (emphasis_runs, emphasis_marks) = + layout_emphasis(text_content, &cells, &runs, &text_maps, &fonts); + let height = cells + .iter() + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max); + + VerticalLayout { + runs, + paints, + cells, + columns, + ruby_runs, + ruby_cells, + emphasis_runs, + emphasis_marks, + paragraph_columns, + text_maps, + width, + height, + draws: OnceCell::new(), + } +} + +/// Lay out with the render state's font store; span paints resolve against +/// `bounds`. +fn layout_from_content( + text_content: &TextContent, + max_height: f32, + bounds: Rect, +) -> 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, + bounds, + ) +} + +/// Lay out the content for a box `height` tall, against its stored bounds. +#[cfg(test)] +pub(super) fn layout_for_box(text_content: &TextContent, height: f32) -> VerticalLayout { + layout_from_content( + text_content, + wrap_height(text_content, height), + text_content.bounds(), + ) +} + +/// Lay out the content for the box `rect`: its height is the column-wrap +/// budget and span paints resolve against it. Production code goes through +/// the cached `TextContent::vertical_layout`. +pub fn layout_for_rect(text_content: &TextContent, rect: &Rect) -> VerticalLayout { + layout_from_content( + text_content, + wrap_height(text_content, rect.height()), + *rect, + ) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use super::*; + use crate::shapes::TextOrientation; + + fn with_headless_fonts(test: impl FnOnce() -> T) -> T { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + test() + } + + #[test] + fn vertical_layout_is_cached_until_the_content_changes() { + with_headless_fonts(|| { + let mut content = make_content(&["あいう"], 100.0); + let rect = Rect::from_xywh(0.0, 0.0, 60.0, 100.0); + let first = content.vertical_layout(&rect); + + assert!(std::rc::Rc::ptr_eq(&first, &content.vertical_layout(&rect))); + assert!( + std::rc::Rc::ptr_eq(&first, &content.clone().vertical_layout(&rect)), + "clones share the cached layout" + ); + let taller = Rect::from_xywh(0.0, 0.0, 60.0, 200.0); + assert!(!std::rc::Rc::ptr_eq( + &first, + &content.vertical_layout(&taller) + )); + + content.paragraphs_mut()[0].children_mut()[0].text = "えお".to_string(); + let edited = content.vertical_layout(&rect); + assert!(!std::rc::Rc::ptr_eq(&first, &edited)); + assert_eq!(edited.cells.len(), 2, "an edit lays out again"); + }); + } + + #[test] + fn vertical_layout_wraps_at_the_given_box_not_the_stored_bounds() { + with_headless_fonts(|| { + // Stored bounds are taller than the shape, as after measuring a fixed text. + let content = make_content(&["あいうえ"], 1000.0); + let wide = content.vertical_layout(&Rect::from_xywh(0.0, 0.0, 60.0, 1000.0)); + let height = wide.cells[0].extent * 2.0 + 1.0; + let layout = content.vertical_layout(&Rect::from_xywh(0.0, 0.0, 60.0, height)); + assert_ne!(layout.cells[0].column, layout.cells[3].column); + }); + } + + #[test] + fn update_layout_of_vertical_text_skips_the_horizontal_layout() { + with_headless_fonts(|| { + let mut content = make_content(&["あいう"], 100.0); + content.update_layout(Rect::from_xywh(0.0, 0.0, 60.0, 100.0)); + assert!(!content.needs_update_layout()); + assert!(content.layout.paragraphs.iter().all(Vec::is_empty)); + }); + } + + fn warichu_span(text: &str) -> TextSpan { + TextSpan { + warichu: true, + text_orientation: TextOrientation::Upright, + ..make_span(text) + } + } + + /// Cells that start a warichu piece, in flow order. + fn warichu_pieces(layout: &VerticalLayout) -> Vec<&VerticalCell> { + let mut pieces: Vec<&VerticalCell> = Vec::new(); + for cell in &layout.cells { + if !matches!(cell.kind, CellKind::Warichu { .. }) { + continue; + } + let same_box = pieces + .last() + .is_some_and(|piece| piece.column == cell.column && piece.top == cell.top); + if !same_box { + pieces.push(cell); + } + } + pieces + } + + #[test] + fn warichu_breaks_at_the_column_end() { + // Four ems of base text leave two ems; ten half-size characters need + // two and a half, so four per sub-line stay and the rest wrap. + let budget = 6.0 * EM; + let content = spans_content( + vec![make_span("くくくく"), warichu_span("あくあくあくあくあく")], + budget, + ); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + let pieces = warichu_pieces(&layout); + assert_eq!(pieces.len(), 2, "the note breaks into two pieces"); + let (first, second) = (pieces[0], pieces[1]); + assert_eq!(first.column, 0); + assert!((first.top - 4.0 * EM).abs() < 0.01); + assert!(first.top + first.extent <= budget + 0.01); + assert_eq!(first.end - first.start, 8, "four characters per sub-line"); + assert_eq!(second.column, 1); + assert_eq!(second.top, 0.0); + assert_eq!((second.start, second.end), (first.end, 14)); + } + + #[test] + fn warichu_span_with_a_reading_breaks_and_shows_no_ruby() { + let budget = 6.0 * EM; + let note = TextSpan { + ruby: "よみ".to_string(), + ..warichu_span("あくあくあくあくあく") + }; + let content = spans_content(vec![make_span("くくくく"), note], budget); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + assert_eq!(warichu_pieces(&layout).len(), 2, "the note still breaks"); + assert!(layout.ruby_cells.is_empty(), "warichu shows no ruby"); + } + + #[test] + fn adjacent_warichu_spans_form_one_note() { + let content = spans_content(vec![warichu_span("くあく"), warichu_span("あくあ")], 400.0); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, 400.0); + assert_eq!(warichu_pieces(&layout).len(), 1, "one note, one piece"); + let [first, second] = &layout.cells[..] else { + panic!("one cell per span, got {}", layout.cells.len()); + }; + assert_eq!((first.span, second.span), (0, 1)); + assert_eq!((first.top, first.extent), (second.top, second.extent)); + let ( + CellKind::Warichu { + first_chars: first_span_chars, + first_extent, + .. + }, + CellKind::Warichu { + first_chars: second_span_chars, + second_top, + second_extent, + .. + }, + ) = (first.kind, second.kind) + else { + panic!("expected warichu cells"); + }; + assert_eq!( + first_span_chars, 3, + "the first span fills the first sub-line" + ); + assert_eq!(second_span_chars, 0, "the second span fills the second"); + assert_eq!( + second_top, 0.0, + "the second sub-line starts at the piece top" + ); + assert!((first_extent - second_extent).abs() < 0.01); + assert!( + (first.extent - 3.0 * EM / 2.0).abs() < 0.01, + "three half-size characters per sub-line, got {}", + first.extent + ); + } + + #[test] + fn layout_cells_tile_the_text() { + let text = "縦書きのAB12テスト。"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + let mut expected = 0usize; + for cell in &layout.cells { + assert_eq!(cell.paragraph, 0); + assert_eq!(cell.start, expected, "cells must tile without gaps"); + assert!(cell.end > cell.start); + expected = cell.end; + } + assert_eq!(expected, text.encode_utf16().count()); + } + + #[test] + fn layout_columns_respect_wrap_height() { + let content = make_content(&["あいうえおかきくけこ"], 100.0); + let layout = layout_content(&content, 60.0); + + assert!(layout.columns.len() > 1, "content must wrap into columns"); + for column_index in 0..layout.columns.len() { + let bottom = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .map(|c| c.top + c.extent) + .fold(0.0f32, f32::max); + assert!(bottom <= 60.0 + 0.01, "column overflows the wrap height"); + } + // Columns advance leftward: column 0 is the rightmost. + assert!(layout.columns[0].x > layout.columns[1].x); + let total: f32 = layout.columns.iter().map(|c| c.width).sum(); + assert!((layout.width - total).abs() < 0.01); + } + + #[test] + fn layout_each_paragraph_starts_a_new_column() { + let content = make_content(&["あい", "うえ"], 1000.0); + let layout = layout_content(&content, 1000.0); + + assert_eq!(layout.paragraph_columns.len(), 2); + let (p0_start, p0_end) = layout.paragraph_columns[0]; + let (p1_start, _) = layout.paragraph_columns[1]; + assert_eq!(p0_start, 0); + assert_eq!(p0_end, p1_start); + assert!(layout + .cells + .iter() + .all(|c| (c.paragraph == 0) == (c.column < p0_end))); + } + + #[test] + fn layout_empty_paragraph_still_takes_a_column() { + let content = make_content(&["あ", "", "い"], 1000.0); + let layout = layout_content(&content, 1000.0); + assert_eq!(layout.columns.len(), 3); + assert_eq!(layout.paragraph_columns[1], (1, 2)); + } + + #[test] + fn block_axis_alignment_maps_start_center_end_to_right_center_left() { + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Top), 160.0); + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Center), 80.0); + assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Bottom), 0.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), -20.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Center), -10.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Bottom), 0.0); + } + + #[test] + fn wrapped_vertical_content_grows_across_columns() { + let content = make_content(&["あいうえおかきくけこ"], 60.0); + let layout = layout_content(&content, 60.0); + assert!(layout.columns.len() > 1); + assert!(layout.width > layout.columns[0].width); + assert!(layout.height <= 60.0 + 0.01); + } +} diff --git a/render-wasm/src/shapes/text_vertical/mod.rs b/render-wasm/src/shapes/text_vertical/mod.rs new file mode 100644 index 0000000000..6417c8535f --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/mod.rs @@ -0,0 +1,55 @@ +// Vertical (tategaki) text layout: columns flow top->bottom and advance +// right->left. Skia's skparagraph has no vertical writing mode, so this +// module owns the whole vertical pipeline: +// +// segment spans by glyph orientation (`orientation`) -> shape each segment +// with SkShaper, OpenType `vert`/`vrt2` for upright runs (`shaping`) -> +// derive cells: one per upright glyph cluster, one per rotated run, one per +// composite (`cells`) -> JLREQ spacing and kinsoku-aware column breaks +// (`flow`) -> place columns and annotations (`layout`, `annotations`) -> +// paint / outline export (`paint`) and position data / hit-testing +// (`positions`) from the cells. +// +// Offsets: cells use UTF-16 offsets in transformed layout text. Position +// data maps those ranges back to the original span text before it crosses +// the WASM boundary; the WORD JOINER OffsetMap applies only to +// skparagraph-driven horizontal breaks. +// +// Layouts are computed on demand, as the horizontal path rebuilds its +// skparagraph objects per paint; only per-typeface font tables are cached. +// +// Text-align aligns each column's glyphs along the vertical (inline) axis: +// Left/Start->top, Center->middle, Right/End->bottom of the wrap budget. +// Justify stretches every column but the last to fill the wrap budget. +// +// Letter-spacing adds advance along the column once per upright cluster +// and once per glyph inside a rotated run, matching the horizontal +// `letter-spacing` that Skia applies to each glyph advance. +// +// Not supported yet: PDF/vector emoji overlays and inner shadows. + +mod annotations; +mod cells; +mod flow; +mod font_tables; +mod layout; +mod orientation; +mod paint; +mod positions; +mod shaping; + +#[cfg(test)] +pub(crate) mod test_support; + +pub use layout::{block_axis_offset, layout_for_rect, VerticalLayout}; +pub(crate) use orientation::{is_emoji_char, is_upright_char}; +pub use paint::{ + paint_drop_shadow, paint_grid, paint_layout, paint_stroke, paint_text_vertical, + paint_text_vertical_with, vertical_text_paths, GlyphOutput, +}; +pub use positions::{ + caret_from_point, caret_rect, intersects, position_data, range_rects, DIRECTION_EMPHASIS_MARK, +}; +pub(crate) use shaping::{ + shape_segment_with_fallbacks, single_glyph_blob, span_font_families, ShapedRun, +}; diff --git a/render-wasm/src/shapes/text_vertical/orientation.rs b/render-wasm/src/shapes/text_vertical/orientation.rs new file mode 100644 index 0000000000..b11731738c --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/orientation.rs @@ -0,0 +1,180 @@ +use crate::shapes::TextOrientation; + +/// Emoji ranges recognized by the frontend font-loader. Emoji stay upright +/// in vertical flow; Latin text rotates under `text-orientation: mixed`. +pub(crate) fn is_emoji_char(c: char) -> bool { + matches!(u32::from(c), + 0x2300..=0x23FF + | 0x2600..=0x27BF + | 0x2B00..=0x2BFF + | 0x1F000..=0x1FAFF + ) +} + +/// Characters that stay upright in vertical flow: kana, kanji, CJK +/// punctuation, full-width forms and emoji. Everything else rotates sideways +/// under `text-orientation: mixed`. +pub(crate) fn is_upright_char(c: char) -> bool { + is_emoji_char(c) + || matches!(u32::from(c), + 0x2E80..=0x2FDF // CJK radicals, Kangxi radicals + | 0x3000..=0x303F // CJK symbols and punctuation + | 0x3040..=0x30FF // hiragana, katakana + | 0x31C0..=0x31EF // CJK strokes + | 0x31F0..=0x31FF // katakana phonetic extensions + | 0x3200..=0x33FF // enclosed CJK, CJK compatibility + | 0x3400..=0x4DBF // CJK unified ideographs extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xAC00..=0xD7AF // hangul syllables + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF00..=0xFF60 // full-width forms + | 0xFFE0..=0xFFE6 // full-width signs + | 0x20000..=0x2FA1F // CJK extensions B..F + ) +} + +/// Characters whose horizontal glyph needs a vertical alternate, normally +/// from `vert` / `vrt2`. When the face has no such substitution, the +/// horizontal glyph rotates clockwise as a fallback. Covers UAX #50 `Tr`, +/// plus comma/full-stop punctuation whose plain glyph sits in the wrong +/// half of the vertical em box. +pub(super) fn uses_rotated_vertical_fallback(c: char) -> bool { + matches!(u32::from(c), + 0x2018..=0x2019 // single quotation marks + | 0x201C..=0x201D // double quotation marks + | 0x2329..=0x232A // angle brackets + | 0x3001..=0x3002 // ideographic comma / full stop + | 0x3008..=0x301F // CJK brackets, wave dash and quotation marks + | 0x3030 // wavy dash + | 0x30A0 // katakana-hiragana double hyphen + | 0x30FC // prolonged sound mark + | 0xFE50..=0xFE52 // small comma / ideographic comma / full stop + | 0xFE59..=0xFE5E // small brackets + | 0xFF08..=0xFF09 // full-width parentheses + | 0xFF0C // full-width comma + | 0xFF0D // full-width hyphen-minus + | 0xFF0E // full-width full stop + | 0xFF1A..=0xFF1B // full-width colon / semicolon + | 0xFF1C..=0xFF1E // full-width comparison signs + | 0xFF3B // full-width left square bracket + | 0xFF3D // full-width right square bracket + | 0xFF3F // full-width low line + | 0xFF5B..=0xFF60 // full-width braces, bars, tilde and white parentheses + | 0xFFE3 // full-width macron + ) +} + +#[derive(Debug, PartialEq)] +pub(super) struct Segment { + pub text: String, + /// UTF-16 offset of the segment start within its span text. + pub utf16_start: usize, + pub upright: bool, +} + +/// Split text into maximal runs of same orientation. Under +/// `TextOrientation::Upright` every character is upright. +pub(super) fn segment_by_orientation(text: &str, orientation: TextOrientation) -> Vec { + let mut segments: Vec = Vec::new(); + let mut utf16_offset = 0; + for c in text.chars() { + let upright = orientation == TextOrientation::Upright || is_upright_char(c); + let emoji = is_emoji_char(c); + // CJK and emoji are both upright, but split them so the segment + // probe can select the emoji family: the provider's generic fallback + // iterator does not reliably switch from a CJK face to a color-emoji + // face. + match segments.last_mut() { + Some(last) + if last.upright == upright + && last.text.chars().next_back().is_some_and(is_emoji_char) == emoji => + { + last.text.push(c) + } + _ => segments.push(Segment { + text: c.to_string(), + utf16_start: utf16_offset, + upright, + }), + } + utf16_offset += c.len_utf16(); + } + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upright_classification() { + assert!(is_upright_char('あ')); + assert!(is_upright_char('ア')); + assert!(is_upright_char('漢')); + assert!(is_upright_char('。')); + assert!(is_upright_char('「')); + assert!(is_upright_char('ー')); + assert!(is_upright_char('!')); + assert!(!is_upright_char('A')); + assert!(!is_upright_char('1')); + assert!(!is_upright_char(' ')); + assert!(!is_upright_char('.')); + } + + #[test] + fn emoji_stay_upright_in_vertical_text() { + for emoji in ['😆', '💦', '🙇'] { + assert!(is_upright_char(emoji)); + } + + let segments = segment_by_orientation("久😆元💦🙇", TextOrientation::Mixed); + assert_eq!(segments.len(), 4); + assert!(segments.iter().all(|segment| segment.upright)); + assert_eq!(segments[1].text, "😆"); + assert_eq!(segments[3].text, "💦🙇"); + } + + #[test] + fn transformed_japanese_punctuation_has_a_rotated_font_fallback() { + for character in [ + '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '、', '。', 'ー', '〜', + ':', ';', + ] { + assert!( + uses_rotated_vertical_fallback(character), + "{character} needs a transformed vertical glyph" + ); + } + assert!(!uses_rotated_vertical_fallback('あ')); + assert!(!uses_rotated_vertical_fallback('漢')); + assert!(!uses_rotated_vertical_fallback('!')); + } + + #[test] + fn segments_mixed_text() { + let segments = segment_by_orientation("縦書きABC123です", TextOrientation::Mixed); + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].text, "縦書き"); + assert!(segments[0].upright); + assert_eq!(segments[0].utf16_start, 0); + assert_eq!(segments[1].text, "ABC123"); + assert!(!segments[1].upright); + assert_eq!(segments[1].utf16_start, 3); + assert_eq!(segments[2].text, "です"); + assert!(segments[2].upright); + assert_eq!(segments[2].utf16_start, 9); + } + + #[test] + fn segments_upright_orientation_keeps_latin_upright() { + let segments = segment_by_orientation("縦ABC", TextOrientation::Upright); + assert_eq!(segments.len(), 1); + assert!(segments[0].upright); + } + + #[test] + fn segments_empty_text() { + assert!(segment_by_orientation("", TextOrientation::Mixed).is_empty()); + } +} diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs new file mode 100644 index 0000000000..3f89863c76 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -0,0 +1,1041 @@ +use skia_safe::{ + self as skia, canvas::SaveLayerRec, textlayout::Paragraph as SkiaParagraph, + textlayout::TextDecoration, BlendMode, Canvas, ImageFilter, Matrix, Paint, Point as SkPoint, + TextBlob, +}; + +use crate::math::Rect; +use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign}; + +use super::annotations::{emphasis_mark_center, ruby_strip_x, EmphasisMark, RubyCell}; +use super::font_tables::upright_baseline; +use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; +use super::shaping::{single_glyph_blob, ShapedRun}; + +/// How glyphs reach the canvas. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GlyphOutput { + /// Text blobs. + Text, + /// Glyph outlines. SVG export needs them: `SkSVGDevice` rebuilds `` + /// from glyph IDs through the font's cmap, which loses vertical + /// alternates. + Outlines, +} + +/// One text blob drawn at `offset` in the local space that `transform` maps +/// to the layout's content space (identity when `None`). Canvas painting and +/// outline export share these draws, so they match for every cell kind. +struct GlyphDraw { + blob: TextBlob, + offset: SkPoint, + transform: Option, +} + +impl GlyphDraw { + fn at(blob: TextBlob, offset: impl Into) -> Self { + Self { + blob, + offset: offset.into(), + transform: None, + } + } + + fn transformed(blob: TextBlob, offset: impl Into, transform: Matrix) -> Self { + Self { + blob, + offset: offset.into(), + transform: Some(transform), + } + } + + /// Draws with the content origin at `origin`. + fn draw(&self, canvas: &Canvas, paint: &Paint, origin: SkPoint, output: GlyphOutput) { + if output == GlyphOutput::Outlines { + canvas.draw_path(&self.path(origin), paint); + return; + } + match &self.transform { + None => { + canvas.draw_text_blob(&self.blob, self.offset + origin, paint); + } + Some(transform) => { + canvas.save(); + canvas.translate(origin); + canvas.concat(transform); + canvas.draw_text_blob(&self.blob, self.offset, paint); + canvas.restore(); + } + } + } + + /// Outline with the content origin at `origin`. + fn path(&self, origin: SkPoint) -> skia::Path { + match &self.transform { + None => text_blob_path(self.blob.clone(), self.offset + origin), + Some(transform) => { + let mut transform = *transform; + transform.post_translate(origin); + text_blob_path(self.blob.clone(), self.offset).make_transform(&transform) + } + } + } +} + +/// Glyph draws of a layout with its content origin at (0, 0), indexed like +/// its cells, ruby cells and emphasis marks. Built on the first paint. +pub(super) struct LayoutDraws { + cells: Vec>, + ruby: Vec>, + emphasis: Vec>, +} + +impl LayoutDraws { + fn new(layout: &VerticalLayout) -> Self { + let origin = (0.0, 0.0); + Self { + cells: layout + .cells + .iter() + .map(|cell| cell_draws(layout, cell, origin)) + .collect(), + ruby: layout + .ruby_cells + .iter() + .map(|ruby| ruby_draws(layout, ruby, origin)) + .collect(), + emphasis: layout + .emphasis_marks + .iter() + .map(|mark| emphasis_draw(layout, mark, origin)) + .collect(), + } + } +} + +fn layout_draws(layout: &VerticalLayout) -> &LayoutDraws { + layout.draws.get_or_init(|| LayoutDraws::new(layout)) +} + +/// Glyphs on their side: +x of the blob runs down the column from `origin`. +fn rotated_at(origin: (f32, f32)) -> Matrix { + let mut matrix = Matrix::translate(origin); + matrix.pre_rotate(90.0, None); + matrix +} + +/// Glyph draws of one cell, for a layout whose content origin is `origin`. +fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32)) -> Vec { + let column = &layout.columns[cell.column]; + let x_center = origin.0 + column_base_center(column); + let y_top = origin.1 + cell.top; + match cell.kind { + CellKind::Upright { run, glyph, count } => { + let run = &layout.runs[run]; + let baseline = + y_top + cell.glyph_flow_shift + upright_baseline(&run.font, cell.font_size); + run.cluster_blob(glyph, count) + .map(|blob| GlyphDraw::at(blob, (x_center - cell.h_advance / 2.0, baseline))) + .into_iter() + .collect() + } + CellKind::SyntheticRotated { run, glyph, count } => { + let run = &layout.runs[run]; + run.cluster_blob(glyph, count) + .map(|blob| { + GlyphDraw::transformed( + blob, + (0.0, run.cluster_rotated_baseline_shift(glyph, count)), + rotated_at((x_center, y_top + cell.glyph_flow_shift)), + ) + }) + .into_iter() + .collect() + } + CellKind::Rotated { run } => { + let run = &layout.runs[run]; + // After rotation +x runs down the column and +y runs across it; + // the shift puts the font's central baseline on the column axis. + run.blob() + .map(|blob| { + GlyphDraw::transformed( + blob, + (0.0, run.rotated_baseline_shift), + rotated_at((x_center, y_top)), + ) + }) + .into_iter() + .collect() + } + CellKind::TateChuYoko { + run_start, + run_count, + h_scale, + } => { + let composite = &layout.runs[run_start..run_start + run_count]; + let combined_advance: f32 = composite.iter().map(|run| run.advance).sum(); + // Vertical centring uses the tallest run's ascent/descent. + let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| { + let (_, metrics) = run.font.metrics(); + (a.min(metrics.ascent), d.max(metrics.descent)) + }); + let x0 = x_center - (combined_advance * h_scale) / 2.0; + let baseline = y_top + cell.font_size / 2.0 - (ascent + descent) / 2.0; + let mut transform = Matrix::translate((x0, baseline)); + transform.pre_scale((h_scale, 1.0), None); + let mut cursor = 0.0f32; + let mut draws = Vec::with_capacity(composite.len()); + for run in composite { + if let Some(blob) = run.blob() { + draws.push(GlyphDraw::transformed(blob, (cursor, 0.0), transform)); + } + cursor += run.advance; + } + draws + } + CellKind::Warichu { + run_start, + run_count, + first_count, + first_top, + second_top, + .. + } => { + let (first, second) = + layout.runs[run_start..run_start + run_count].split_at(first_count); + let quarter = cell.font_size / 4.0; + // vertical-rl: the first sub-line reads first, on the right half. + let mut draws = warichu_line_draws(first, x_center + quarter, y_top + first_top); + draws.extend(warichu_line_draws( + second, + x_center - quarter, + y_top + second_top, + )); + draws + } + } +} + +/// One warichu sub-line: half-size upright glyphs stacked down the +/// sub-column centred on `x_center`, one cluster per cell. +fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec { + let mut draws = Vec::new(); + let mut cursor = y_top; + for run in runs { + let font_size = run.font.size(); + let baseline_offset = upright_baseline(&run.font, font_size); + for (glyph, count) in run.cluster_spans() { + let advance = match run.cluster_advance(glyph, count) { + advance if advance > 0.0 => advance, + _ => font_size, + }; + if let Some(blob) = run.cluster_blob(glyph, count) { + draws.push(GlyphDraw::at( + blob, + (x_center - advance / 2.0, cursor + baseline_offset), + )); + } + cursor += advance; + } + } + draws +} + +/// One column's slice of a ruby annotation in the column's ruby gutter: +/// upright glyphs stacked, Western words on their side. Flow positions come +/// from the whole base span; each glyph is centred across the gutter by its +/// own fallback-font run. +fn ruby_draws(layout: &VerticalLayout, ruby: &RubyCell, origin: (f32, f32)) -> Vec { + let column = &layout.columns[ruby.column]; + let gutter_center = origin.0 + + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side) + + ruby.font_size / 2.0; + ruby.glyphs + .iter() + .zip(&ruby.glyph_tops) + .filter_map(|(ruby_glyph, top)| { + let run = &layout.ruby_runs[ruby_glyph.run]; + let blob = run.glyph_blob(ruby_glyph.glyph)?; + if ruby_glyph.sideways { + return Some(GlyphDraw::transformed( + blob, + (0.0, run.rotated_baseline_shift), + rotated_at((gutter_center, origin.1 + top)), + )); + } + let advance = run + .advances + .get(ruby_glyph.glyph) + .copied() + .unwrap_or(ruby.font_size); + Some(GlyphDraw::at( + blob, + ( + gutter_center - advance / 2.0, + origin.1 + top + upright_baseline(&run.font, ruby.font_size), + ), + )) + }) + .collect() +} + +/// One emphasis mark (圏点) centred on its base cell's flow extent, in the +/// column's right-side gutter. +fn emphasis_draw( + layout: &VerticalLayout, + mark: &EmphasisMark, + origin: (f32, f32), +) -> Option { + let run = &layout.emphasis_runs[mark.run]; + let blob = single_glyph_blob(&run.font, *run.glyphs.first()?)?; + let (center_x, center_y) = emphasis_mark_center(layout, mark); + let gutter_center = origin.0 + center_x; + let (_, metrics) = run.font.metrics(); + let baseline = origin.1 + center_y - (metrics.ascent + metrics.descent) / 2.0; + let advance = run.advances.first().copied().unwrap_or(0.0); + Some(GlyphDraw::at( + blob, + (gutter_center - advance / 2.0, baseline), + )) +} + +/// Underline and line-through bars of a cell. +fn decoration_rects( + layout: &VerticalLayout, + cell: &VerticalCell, + origin: (f32, f32), +) -> Vec { + let Some(decoration) = cell.decoration else { + return Vec::new(); + }; + let mut rects = Vec::new(); + if decoration.contains(TextDecoration::UNDERLINE) { + rects.push(decoration_bar(layout, cell, origin.0, origin.1, false)); + } + if decoration.contains(TextDecoration::LINE_THROUGH) { + rects.push(decoration_bar(layout, cell, origin.0, origin.1, true)); + } + rects +} + +/// A glyph draw or a decoration bar of the layout. +enum LayoutItem<'a> { + Glyphs(&'a GlyphDraw), + Bar(skia::Rect), +} + +/// Every glyph draw and decoration bar of the layout, with its content origin +/// at `origin`, and the fill paint of its span. Paint order: each cell's +/// glyphs and bars, then ruby, then emphasis marks. +fn for_each_item( + layout: &VerticalLayout, + origin: (f32, f32), + mut visit: impl FnMut(LayoutItem, &Paint), +) { + let draws = layout_draws(layout); + for (cell, cell_draws) in layout.cells.iter().zip(&draws.cells) { + let paint = &layout.paints[cell.paint]; + for draw in cell_draws { + visit(LayoutItem::Glyphs(draw), paint); + } + for rect in decoration_rects(layout, cell, origin) { + visit(LayoutItem::Bar(rect), paint); + } + } + for (ruby, ruby_draws) in layout.ruby_cells.iter().zip(&draws.ruby) { + for draw in ruby_draws { + visit(LayoutItem::Glyphs(draw), &layout.paints[ruby.paint]); + } + } + for (mark, draw) in layout.emphasis_marks.iter().zip(&draws.emphasis) { + if let Some(draw) = draw { + visit( + LayoutItem::Glyphs(draw), + &layout.paints[layout.cells[mark.cell].paint], + ); + } + } +} + +/// Paint every item of the layout with its span's fill paint, or with +/// `single_paint` for every item (stroke, shadow and silhouette passes). +fn paint_items( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + single_paint: Option<&Paint>, + output: GlyphOutput, +) { + let origin = layout.origin(bounds, vertical_align); + for_each_item(layout, origin, |item, fill| { + let paint = single_paint.unwrap_or(fill); + match item { + LayoutItem::Glyphs(draw) => draw.draw(canvas, paint, origin.into(), output), + LayoutItem::Bar(rect) => { + let mut bar_paint = paint.clone(); + if single_paint.is_none() { + bar_paint.set_style(skia::PaintStyle::Fill); + } + bar_paint.set_anti_alias(true); + canvas.draw_rect(rect, &bar_paint); + } + } + }); +} + +/// Fill pass: every glyph and decoration with its span's fill paint. +pub fn paint_layout( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + output: GlyphOutput, +) { + paint_items(canvas, layout, bounds, vertical_align, None, output); +} + +/// Paint every glyph and decoration with one `paint` (stroke / shadow +/// silhouette passes). +fn paint_glyphs( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + paint: &Paint, + output: GlyphOutput, +) { + paint_items(canvas, layout, bounds, vertical_align, Some(paint), output); +} + +/// Paint the text content vertically inside its bounds. Returns false +/// when the content is not vertical, so the caller falls back to the +/// horizontal path. +pub fn paint_text_vertical( + canvas: &Canvas, + text_content: &TextContent, + bounds: &Rect, + vertical_align: VerticalAlign, + output: GlyphOutput, +) -> bool { + if !text_content.is_vertical() { + return false; + } + let layout = text_content.vertical_layout(bounds); + paint_layout(canvas, &layout, bounds, vertical_align, output); + true +} + +/// Paint the glyphs of vertical text content with one `paint` (a stroke or +/// an opaque silhouette) instead of the span fills. Returns false when the +/// content is not vertical. +pub fn paint_text_vertical_with( + canvas: &Canvas, + text_content: &TextContent, + bounds: &Rect, + vertical_align: VerticalAlign, + paint: &Paint, + output: GlyphOutput, +) -> bool { + if !text_content.is_vertical() { + return false; + } + let layout = text_content.vertical_layout(bounds); + paint_glyphs(canvas, &layout, bounds, vertical_align, paint, output); + true +} + +/// Glyph-outline paths of the layout, in the same order and with the same +/// paints as the canvas fill pass. +fn paths_from_layout( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + antialias: bool, +) -> Vec<(skia::Path, Paint)> { + let mut paths = Vec::new(); + let origin = layout.origin(bounds, vertical_align); + for_each_item(layout, origin, |item, paint| { + let path = match item { + LayoutItem::Glyphs(draw) => draw.path(origin.into()), + LayoutItem::Bar(rect) => skia::Path::rect(rect, None), + }; + push_text_path(&mut paths, path, paint, antialias); + }); + paths +} + +/// Glyph-outline paths of the vertical layout, matching the canvas pass. +pub fn vertical_text_paths( + text_content: &TextContent, + vertical_align: VerticalAlign, + antialias: bool, +) -> Vec<(skia::Path, Paint)> { + if !text_content.is_vertical() { + return Vec::new(); + } + let bounds = text_content.bounds(); + let layout = text_content.vertical_layout(&bounds); + paths_from_layout(&layout, &bounds, vertical_align, antialias) +} + +/// Developer overlay: a jlreq-style character-frame grid. Outlines each +/// column band and, per cell, the advance box, the virtual body / em square +/// centred on the column axis, and the glyph-ink band, so aki and +/// letter-spacing are visible. Only the on-screen fills pass calls it, behind +/// the `TEXT_GRID_VISIBLE` render flag; it never reaches exported output. +pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align: VerticalAlign) { + let (origin_x, origin_y) = layout.origin(bounds, align); + + let mut column_paint = Paint::default(); + column_paint.set_anti_alias(true); + column_paint.set_style(skia::PaintStyle::Stroke); + column_paint.set_stroke_width(1.0); + column_paint.set_color(skia::Color::from_argb(0x55, 0x88, 0x88, 0x88)); + + let mut advance_paint = Paint::default(); + advance_paint.set_anti_alias(true); + advance_paint.set_style(skia::PaintStyle::Stroke); + advance_paint.set_stroke_width(1.0); + advance_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED)); + + let mut em_paint = Paint::default(); + em_paint.set_anti_alias(true); + em_paint.set_style(skia::PaintStyle::Stroke); + em_paint.set_stroke_width(1.0); + em_paint.set_color(skia::Color::from_argb(0x99, 0xEB, 0x57, 0x57)); + + let mut ink_paint = Paint::default(); + ink_paint.set_anti_alias(true); + ink_paint.set_style(skia::PaintStyle::Stroke); + ink_paint.set_stroke_width(1.0); + ink_paint.set_color(skia::Color::from_argb(0x77, 0x27, 0xAE, 0x60)); + + // Column bands over the full used height. + for column in &layout.columns { + let x = origin_x + column.x; + canvas.draw_rect( + Rect::from_xywh(x, origin_y, column.width, layout.height), + &column_paint, + ); + } + + for cell in &layout.cells { + let column = &layout.columns[cell.column]; + let x_left = origin_x + column.x; + let y_top = origin_y + cell.top; + let x_center = origin_x + column_base_center(column); + + // Advance box: the real layout cell along the column axis. + canvas.draw_rect( + Rect::from_xywh(x_left, y_top, column.base_width, cell.extent), + &advance_paint, + ); + + // Virtual body / em square, centred on the column axis and on the + // cell's advance so aki and letter-spacing are visible. + let em = cell.font_size.max(1.0); + canvas.draw_rect( + Rect::from_xywh( + x_center - em / 2.0, + y_top + (cell.extent - em) / 2.0, + em, + em, + ), + &em_paint, + ); + + // Glyph-ink band along the flow axis. + if cell.ink_bottom > cell.ink_top { + canvas.draw_line( + (x_left, origin_y + cell.top + cell.ink_top), + (x_left + column.width, origin_y + cell.top + cell.ink_top), + &ink_paint, + ); + canvas.draw_line( + (x_left, origin_y + cell.top + cell.ink_bottom), + (x_left + column.width, origin_y + cell.top + cell.ink_bottom), + &ink_paint, + ); + } + } +} + +/// Paint the vertical glyph shadows. The shadow paint carries the +/// blur/offset image filter, so glyphs draw through it with no offscreen +/// layer (a `save_layer` with the filter can exceed GPU limits on tall +/// columns). +pub fn paint_drop_shadow( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + shadow_paint: &Paint, + output: GlyphOutput, +) { + let mut paint = shadow_paint.clone(); + paint.set_color(skia::Color::BLACK); + paint.set_anti_alias(true); + paint_glyphs(canvas, layout, bounds, vertical_align, &paint, output); +} + +/// Paint a stroke on the vertical glyphs. Center strokes draw directly; +/// inner/outer strokes mask with `SrcIn` / `SrcOut` against the glyph +/// silhouette, as the horizontal path does. +#[allow(clippy::too_many_arguments)] +pub fn paint_stroke( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + stroke: &Stroke, + selrect: &Rect, + blur: Option<&ImageFilter>, + output: GlyphOutput, +) { + let (stroke_paints, layer_opacity) = + crate::render::text::get_text_stroke_paints(stroke, selrect, false); + + if let Some(blur_filter) = blur { + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + } + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + } + + for stroke_paint in &stroke_paints { + match stroke.kind { + StrokeKind::Center => { + paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint, output) + } + StrokeKind::Inner => paint_masked_stroke( + canvas, + layout, + bounds, + vertical_align, + stroke_paint, + BlendMode::SrcIn, + output, + ), + StrokeKind::Outer => paint_masked_stroke( + canvas, + layout, + bounds, + vertical_align, + stroke_paint, + BlendMode::SrcOut, + output, + ), + } + } + + if layer_opacity.is_some() { + canvas.restore(); + } + if blur.is_some() { + canvas.restore(); + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_masked_stroke( + canvas: &Canvas, + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + stroke_paint: &Paint, + blend: BlendMode, + output: GlyphOutput, +) { + let mut mask = Paint::default(); + mask.set_color(skia::Color::BLACK); + mask.set_anti_alias(true); + + canvas.save_layer(&SaveLayerRec::default()); + paint_glyphs(canvas, layout, bounds, vertical_align, &mask, output); + + let mut blend_paint = Paint::default(); + blend_paint.set_blend_mode(blend); + canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint, output); + canvas.restore(); + + canvas.restore(); +} + +fn text_blob_path(mut blob: TextBlob, offset: impl Into) -> skia::Path { + // get_path is relative to the blob's ink bounds; add them back to match. + let bounds = *blob.bounds(); + let offset = offset.into(); + SkiaParagraph::get_path(&mut blob).with_offset((offset.x + bounds.left, offset.y + bounds.top)) +} + +fn push_text_path( + paths: &mut Vec<(skia::Path, Paint)>, + path: skia::Path, + paint: &Paint, + antialias: bool, +) { + if path.is_empty() { + return; + } + let mut paint = paint.clone(); + paint.set_anti_alias(antialias); + paths.push((path, paint)); +} + +/// Decoration bar of a cell in absolute coordinates. Underline runs along +/// the *left* side of the column (the under side in `vertical-rl`); +/// line-through runs down the centre. Both span the cell's extent, so +/// adjacent decorated cells form one bar. +fn decoration_bar( + layout: &VerticalLayout, + cell: &VerticalCell, + origin_x: f32, + origin_y: f32, + line_through: bool, +) -> skia::Rect { + let column = &layout.columns[cell.column]; + let x_center = origin_x + column_base_center(column); + let y_top = origin_y + cell.top; + let thickness = (cell.font_size * 0.06).max(1.0); + let bar_x = if line_through { + x_center + } else { + x_center - cell.font_size / 2.0 - cell.font_size / 9.0 + }; + skia::Rect::from_ltrb( + bar_x - thickness / 2.0, + y_top, + bar_x + thickness / 2.0, + y_top + cell.extent, + ) +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use super::*; + use crate::shapes::{StrokeStyle, TextEmphasis, TextOrientation, TextSpan}; + + #[test] + fn glyph_draws_are_built_once_and_follow_the_origin() { + let content = make_content(&["あくABC"], 1000.0); + let provider = provider_with_fallback(TEST_FONT, VMTX_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 1000.0, &["fallback".to_string()]); + let bounds = content.bounds(); + assert!(layout.draws.get().is_none()); + + let paths = paths_from_layout(&layout, &bounds, VerticalAlign::Top, true); + let draws = layout + .draws + .get() + .expect("the first paint builds the draws") as *const _; + let shifted = paths_from_layout( + &layout, + &bounds.with_offset((30.0, 40.0)), + VerticalAlign::Top, + true, + ); + + assert!(std::ptr::eq(draws, layout.draws.get().unwrap())); + assert!(paths.len() >= 3, "upright and sideways glyphs are drawn"); + assert_eq!(paths.len(), shifted.len()); + for ((path, _), (moved, _)) in paths.iter().zip(&shifted) { + let (a, b) = (path.bounds(), moved.bounds()); + assert!((b.left - a.left - 30.0).abs() < 0.01, "{a:?} -> {b:?}"); + assert!((b.top - a.top - 40.0).abs() < 0.01, "{a:?} -> {b:?}"); + } + } + + #[test] + fn shape_to_path_places_vertical_glyphs_down_the_column() { + let content = make_content(&["あく"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + + let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true); + + assert_eq!(paths.len(), 2, "one outline path per upright glyph"); + let first = paths[0].0.bounds(); + let second = paths[1].0.bounds(); + assert!( + second.top > first.top, + "the second glyph outline follows the first down the vertical flow axis" + ); + let horizontal_shift = (second.center_x() - first.center_x()).abs(); + let vertical_shift = second.center_y() - first.center_y(); + assert!( + vertical_shift > horizontal_shift, + "vertical flow dominates the glyphs' optical side-bearing difference" + ); + } + + #[test] + fn shape_to_path_preserves_the_text_blob_draw_origin() { + let content = make_content(&["あ"], 1000.0); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let CellKind::Upright { run, glyph, count } = layout.cells[0].kind else { + panic!("expected an upright cell"); + }; + let blob = layout.runs[run] + .cluster_blob(glyph, count) + .expect("a glyph text blob"); + let blob_bounds = *blob.bounds(); + let draw_origin = SkPoint::new(120.0, 340.0); + let mut normalized_blob = blob.clone(); + let normalized_bounds = *SkiaParagraph::get_path(&mut normalized_blob).bounds(); + + let path_bounds = *text_blob_path(blob, draw_origin).bounds(); + + assert!( + (path_bounds.left - (draw_origin.x + blob_bounds.left + normalized_bounds.left)).abs() + < 0.01 + ); + assert!( + (path_bounds.top - (draw_origin.y + blob_bounds.top + normalized_bounds.top)).abs() + < 0.01 + ); + assert!((path_bounds.width() - normalized_bounds.width()).abs() < 0.01); + assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01); + } + + #[test] + fn decoration_bar_underline_left_of_center_line_through_centered() { + let content = decorated_content("あい", TextDecoration::UNDERLINE); + let layout = layout_content(&content, 1000.0); + let (ox, oy) = layout.origin(&content.bounds(), VerticalAlign::Top); + let cell = &layout.cells[0]; + let column = &layout.columns[cell.column]; + let x_center = ox + column.x + column.width / 2.0; + + let underline = decoration_bar(&layout, cell, ox, oy, false); + let strike = decoration_bar(&layout, cell, ox, oy, true); + + assert!(underline.center_x() < x_center); + assert!((strike.center_x() - x_center).abs() < 0.01); + assert!((underline.height() - cell.extent).abs() < 0.01); + assert!(underline.width() >= 1.0 - 0.01); + } + + #[test] + fn outline_export_includes_every_annotation_glyph() { + let content = spans_content( + vec![TextSpan { + ruby: "aあ".to_string(), + text_emphasis: TextEmphasis::FilledDot, + text_orientation: TextOrientation::Upright, + ..make_span("AB") + }], + 400.0, + ); + let provider = provider_with_fallback(TEST_FONT, VMTX_TEST_FONT); + let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]); + let ruby_glyphs: usize = layout.ruby_cells.iter().map(|ruby| ruby.glyphs.len()).sum(); + assert_eq!(ruby_glyphs, 2, "both fallback runs carry a ruby glyph"); + + let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true); + + assert_eq!( + paths.len(), + layout.cells.len() + ruby_glyphs + layout.emphasis_marks.len(), + "one outline per base cell, ruby glyph and emphasis mark" + ); + let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); + paint_layout( + surface.canvas(), + &layout, + &content.bounds(), + VerticalAlign::Top, + GlyphOutput::Text, + ); + } + + /// Pixels with any ink inside `rect`. + fn inked_pixels(surface: &mut skia::Surface, rect: skia::Rect) -> usize { + let image = surface.image_snapshot(); + let pixmap = image.peek_pixels().expect("raster pixels"); + let mut inked = 0; + for y in rect.top.max(0.0) as i32..rect.bottom.min(image.height() as f32) as i32 { + for x in rect.left.max(0.0) as i32..rect.right.min(image.width() as f32) as i32 { + if pixmap.get_color((x, y)).a() > 0 { + inked += 1; + } + } + } + inked + } + + #[test] + fn upright_ruby_glyphs_sit_in_their_em_slot_like_base_glyphs() { + // JLREQ Fig 122 and 141: hanging a ruby glyph from the font ascent + // put CJK faces (ascent larger than the em) about a quarter em below + // their slot, onto the next kanji and past the column end. + let content = spans_content( + vec![TextSpan { + ruby: "あく".to_string(), + ..make_span("くく") + }], + 400.0, + ); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let ruby = &layout.ruby_cells[0]; + let draws = ruby_draws(&layout, ruby, (0.0, 0.0)); + assert_eq!(draws.len(), 2); + for ((draw, glyph), top) in draws.iter().zip(&ruby.glyphs).zip(&ruby.glyph_tops) { + let run = &layout.ruby_runs[glyph.run]; + let expected = top + upright_baseline(&run.font, ruby.font_size); + assert!( + (draw.offset.y - expected).abs() < 0.01, + "baseline {} instead of {expected}", + draw.offset.y + ); + } + } + + #[test] + fn single_paint_passes_cover_ruby_like_the_fill_pass() { + let content = spans_content( + vec![TextSpan { + ruby: "あ".to_string(), + ..make_span("く") + }], + 400.0, + ); + let layout = layout_with(&provider(VMTX_TEST_FONT), &content); + let bounds = content.bounds(); + let ruby = &layout.ruby_cells[0]; + let column = &layout.columns[ruby.column]; + let (origin_x, origin_y) = layout.origin(&bounds, VerticalAlign::Top); + let strip_x = + origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side); + let strip = skia::Rect::from_xywh(strip_x, origin_y, ruby.font_size, 400.0); + + let mut surface = skia::surfaces::raster_n32_premul((256, 512)).unwrap(); + let mut stroke = Paint::default(); + stroke.set_color(skia::Color::BLACK); + paint_glyphs( + surface.canvas(), + &layout, + &bounds, + VerticalAlign::Top, + &stroke, + GlyphOutput::Text, + ); + + assert!(inked_pixels(&mut surface, strip) > 0, "the ruby is painted"); + } + + #[test] + fn paint_passes_do_not_panic() { + let content = decorated_content("あいAB。", TextDecoration::LINE_THROUGH); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + let selrect = content.bounds(); + + let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap(); + let canvas = surface.canvas(); + + // Callers apply shape transforms to the canvas; run every pass under + // a non-trivial transform with upright and rotated cells. + canvas.translate((12.0, 8.0)); + canvas.rotate(7.0, Some((64.0, 64.0).into())); + + paint_layout( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + GlyphOutput::Text, + ); + paint_drop_shadow( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &Paint::default(), + GlyphOutput::Text, + ); + + // A real drop-shadow image filter, as `drop_shadow_paints` builds. + let mut shadow_paint = Paint::default(); + shadow_paint.set_image_filter(skia::image_filters::drop_shadow( + (12.0, 12.0), + (6.0, 6.0), + skia::Color::from_argb(230, 0, 0, 255), + None, + None, + None, + )); + paint_drop_shadow( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &shadow_paint, + GlyphOutput::Text, + ); + + for kind in [ + Stroke::new_center_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + Stroke::new_inner_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + Stroke::new_outer_stroke(3.0, StrokeStyle::Solid, None, None, None, None), + ] { + paint_stroke( + canvas, + &layout, + &bounds, + VerticalAlign::Top, + &kind, + &selrect, + None, + GlyphOutput::Text, + ); + } + } + + /// Uncompressed PDF of the layout's fill pass, as Latin-1 text. + fn layout_pdf(layout: &VerticalLayout, bounds: &Rect) -> String { + let mut bytes: Vec = Vec::new(); + let metadata = skia::pdf::Metadata { + compression_level: skia::pdf::CompressionLevel::None, + ..Default::default() + }; + { + let document = skia::pdf::new_document(&mut bytes, Some(&metadata)); + let mut page = document.begin_page((400.0, 400.0), None); + paint_layout( + page.canvas(), + layout, + bounds, + VerticalAlign::Top, + GlyphOutput::Text, + ); + page.end_page().close(); + } + bytes.iter().map(|byte| *byte as char).collect() + } + + #[test] + fn pdf_text_of_vertical_forms_is_the_source_text() { + let content = make_content(&["「あ」"], 400.0); + let layout = layout_with(&provider(VPAL_TEST_FONT), &content); + + let pdf = layout_pdf(&layout, &content.bounds()).to_uppercase(); + let to_unicode = pdf + .split_once("BEGINBFCHAR") + .and_then(|(_, rest)| rest.split_once("ENDBFCHAR")) + .map(|(entries, _)| entries) + .expect("a ToUnicode CMap"); + + // The vertical alternates have no cmap entry of their own; the PDF + // text maps them back to the source characters. + for source in ["<300C>", "<3042>", "<300D>"] { + assert!(to_unicode.contains(source), "ToUnicode maps {source}"); + } + assert!(!to_unicode.contains("<0000>"), "no glyph maps to U+0000"); + } +} diff --git a/render-wasm/src/shapes/text_vertical/positions.rs b/render-wasm/src/shapes/text_vertical/positions.rs new file mode 100644 index 0000000000..d48e8c284d --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/positions.rs @@ -0,0 +1,1116 @@ +use std::ops::Range; + +use skia_safe::{Contains, Point as SkPoint}; + +use crate::math::Rect; +use crate::shapes::{PositionData, VerticalAlign}; + +use super::annotations::{emphasis_mark_center, ruby_strip_x}; +use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout}; + +/// Position-data `direction` of a vertical-rl strip; 0/1 are rtl/ltr. +pub const DIRECTION_VERTICAL_RL: u32 = 2; + +/// Position-data `direction` of a ruby strip; offsets index the *ruby* string. +pub const DIRECTION_VERTICAL_RUBY: u32 = 3; + +/// Position-data `direction` of one emphasis mark (圏点) box, in any mode. +pub const DIRECTION_EMPHASIS_MARK: u32 = 4; + +/// Paragraph source UTF-16 range of a `transformed` span-text range. +fn source_utf16_range( + layout: &VerticalLayout, + paragraph: usize, + span: usize, + transformed: Range, +) -> Range { + let text_map = &layout.text_maps[paragraph]; + let transformed_span_start = text_map.span_starts[span]; + let source_span_start = text_map.span_source_starts[span]; + let relative = text_map.span_transforms[span].source_utf16_range( + transformed.start - transformed_span_start..transformed.end - transformed_span_start, + ); + source_span_start + relative.start..source_span_start + relative.end +} + +pub(super) fn cell_source_utf16_range( + layout: &VerticalLayout, + cell: &VerticalCell, +) -> Range { + source_utf16_range(layout, cell.paragraph, cell.span, cell.start..cell.end) +} + +/// Entry of `cell`'s span covering the paragraph source range `source`. +fn span_entry( + layout: &VerticalLayout, + cell: &VerticalCell, + source: Range, + (x, y, width, height): (f32, f32, f32, f32), + direction: u32, +) -> PositionData { + let span_start = layout.text_maps[cell.paragraph].span_source_starts[cell.span]; + PositionData { + paragraph: cell.paragraph as u32, + span: cell.span as u32, + start_pos: (source.start - span_start) as u32, + end_pos: (source.end - span_start) as u32, + x, + y, + width, + height, + direction, + } +} + +/// Flow placement (top relative to the cell, extent) of a warichu cell's +/// part of its first (right) or second (left) sub-line. +fn warichu_line_span(cell: &VerticalCell, first_line: bool) -> (f32, f32) { + match cell.kind { + CellKind::Warichu { + first_top, + first_extent, + second_top, + second_extent, + .. + } => { + if first_line { + (first_top, first_extent) + } else { + (second_top, second_extent) + } + } + _ => (0.0, cell.extent), + } +} + +/// The sub-line strips of a warichu cell: its first sub-line part on the +/// right half of the base band, its second on the left, each holding its own +/// characters. A cell with no characters on a sub-line has no strip there. +fn warichu_entries( + layout: &VerticalLayout, + cell: &VerticalCell, + first_chars: usize, + origin: (f32, f32), +) -> Vec { + let column = &layout.columns[cell.column]; + let center = origin.0 + column_base_center(column); + let half = cell.font_size / 2.0; + let split = cell.start + first_chars; + let line = |range: Range, x: f32, first_line: bool| { + let source = source_utf16_range(layout, cell.paragraph, cell.span, range); + let (top, extent) = warichu_line_span(cell, first_line); + let rect = (x, origin.1 + cell.top + top, half, extent); + span_entry(layout, cell, source, rect, DIRECTION_VERTICAL_RL) + }; + [ + (cell.start..split, center, true), + (split..cell.end, center - half, false), + ] + .into_iter() + .filter(|(range, _, _)| !range.is_empty()) + .map(|(range, x, first_line)| line(range, x, first_line)) + .collect() +} + +/// Position-data entries for the v2 editor / exports: the base text strips, +/// then the ruby strips, then one box per emphasis mark. +pub fn position_data( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, +) -> Vec { + let origin = layout.origin(bounds, vertical_align); + let mut result = base_entries(layout, origin); + result.extend(ruby_entries(layout, origin)); + result.extend(emphasis_entries(layout, origin)); + result +} + +/// A cell that keeps its own strip: a warichu piece, or a tate-chu-yoko +/// composite, which SVG text sets with `text-combine-upright: all` per +/// element. +fn has_own_strip(cell: &VerticalCell) -> bool { + matches!( + cell.kind, + CellKind::Warichu { .. } | CellKind::TateChuYoko { .. } + ) +} + +/// Base text strips: consecutive cells of the same span in the same column +/// merge into one strip over the column's base band, so overlay and +/// selection skip the ruby gutter. +fn base_entries(layout: &VerticalLayout, (origin_x, origin_y): (f32, f32)) -> Vec { + let mut result: Vec = Vec::new(); + let mut i = 0; + while i < layout.cells.len() { + let first = &layout.cells[i]; + if let CellKind::Warichu { first_chars, .. } = first.kind { + result.extend(warichu_entries( + layout, + first, + first_chars, + (origin_x, origin_y), + )); + i += 1; + continue; + } + let mut source_range = cell_source_utf16_range(layout, first); + let mut bottom = first.top + first.extent; + let mut j = i + 1; + while j < layout.cells.len() && !has_own_strip(first) { + let next = &layout.cells[j]; + let same_strip = next.paragraph == first.paragraph + && next.span == first.span + && next.column == first.column + && !has_own_strip(next); + if !same_strip { + break; + } + let next_source = cell_source_utf16_range(layout, next); + source_range.start = source_range.start.min(next_source.start); + source_range.end = source_range.end.max(next_source.end); + bottom = next.top + next.extent; + j += 1; + } + let column = &layout.columns[first.column]; + let rect = ( + origin_x + column.x, + origin_y + first.top, + column.base_width, + bottom - first.top, + ); + result.push(span_entry( + layout, + first, + source_range, + rect, + DIRECTION_VERTICAL_RL, + )); + i = j; + } + result +} + +/// Ruby strips at the flow positions the canvas paints. `start_pos` and +/// `end_pos` are UTF-16 offsets into the span's ruby string, taken from the +/// shaped clusters so surrogate pairs slice correctly. +fn ruby_entries(layout: &VerticalLayout, (origin_x, origin_y): (f32, f32)) -> Vec { + layout + .ruby_cells + .iter() + .filter_map(|ruby| { + let (first, last) = (ruby.glyphs.first()?, ruby.glyphs.last()?); + let column = &layout.columns[ruby.column]; + let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min); + let bottom = ruby + .glyph_tops + .iter() + .zip(&ruby.glyphs) + .map(|(top, glyph)| top + glyph.extent) + .fold(f32::MIN, f32::max); + Some(PositionData { + paragraph: ruby.paragraph as u32, + span: ruby.span as u32, + start_pos: first.utf16_start as u32, + end_pos: last.utf16_end as u32, + x: origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side), + y: origin_y + top, + width: ruby.font_size, + height: bottom - top, + direction: DIRECTION_VERTICAL_RUBY, + }) + }) + .collect() +} + +/// One box per emphasis mark, covering its base cell's source range. +fn emphasis_entries( + layout: &VerticalLayout, + (origin_x, origin_y): (f32, f32), +) -> Vec { + layout + .emphasis_marks + .iter() + .map(|mark| { + let cell = &layout.cells[mark.cell]; + let (center_x, center_y) = emphasis_mark_center(layout, mark); + let size = mark.font_size; + let rect = ( + origin_x + center_x - size / 2.0, + origin_y + center_y - size / 2.0, + size, + size, + ); + let source = cell_source_utf16_range(layout, cell); + span_entry(layout, cell, source, rect, DIRECTION_EMPHASIS_MARK) + }) + .collect() +} + +/// True when the point (in the same space as `bounds`) hits laid-out text. +pub fn intersects( + layout: &VerticalLayout, + bounds: &Rect, + vertical_align: VerticalAlign, + x: f32, + y: f32, +) -> bool { + let (origin_x, origin_y) = layout.origin(bounds, vertical_align); + layout.cells.iter().any(|cell| { + let column = &layout.columns[cell.column]; + let rect = Rect::from_xywh( + origin_x + column.x, + origin_y + cell.top, + column.width, + cell.extent, + ); + rect.contains(&SkPoint::new(x, y)) + }) +} + +fn scalar_offset_from_utf16( + layout: &VerticalLayout, + paragraph: usize, + utf16_offset: usize, +) -> Option { + let boundaries = &layout.text_maps.get(paragraph)?.utf16_boundaries; + Some(match boundaries.binary_search(&utf16_offset) { + Ok(index) => index, + Err(index) => index.saturating_sub(1), + }) +} + +fn cell_scalar_range(layout: &VerticalLayout, cell: &VerticalCell) -> Option<(usize, usize)> { + Some(( + scalar_offset_from_utf16(layout, cell.paragraph, cell.start)?, + scalar_offset_from_utf16(layout, cell.paragraph, cell.end)?, + )) +} + +/// Flow offset from the cell top of each scalar boundary (0..=`chars`) of a +/// sideways run, from its glyph positions; `None` for other cells. +fn rotated_boundaries( + layout: &VerticalLayout, + cell: &VerticalCell, + chars: usize, +) -> Option> { + let CellKind::Rotated { run } = cell.kind else { + return None; + }; + layout + .runs + .get(run)? + .scalar_flow_offsets(cell.extent, chars) +} + +/// Flow offset from the cell top of the scalar boundary `index` (0..=`chars`). +fn boundary_offset( + layout: &VerticalLayout, + cell: &VerticalCell, + index: usize, + chars: usize, +) -> f32 { + rotated_boundaries(layout, cell, chars) + .and_then(|boundaries| boundaries.get(index).copied()) + .unwrap_or(index as f32 / chars as f32 * cell.extent) +} + +/// Scalar length of a warichu cell's first sub-line. `first_chars` is its +/// UTF-16 length; the cell holds `chars` scalars from `cell_start`. +fn warichu_first_line_len( + layout: &VerticalLayout, + cell: &VerticalCell, + first_chars: usize, + cell_start: usize, + chars: usize, +) -> Option { + let first_end = scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)?; + Some(first_end.saturating_sub(cell_start).min(chars)) +} + +/// Index of the column under `x`; points outside every column take the +/// nearest end. Columns are ordered right to left, i.e. descending x. +fn column_at(layout: &VerticalLayout, x: f32) -> usize { + layout + .columns + .iter() + .position(|c| x >= c.x && x < c.x + c.width) + .unwrap_or(if x >= layout.width { + 0 + } else { + layout.columns.len() - 1 + }) +} + +/// True when a point inside a warichu cell's box lies past this cell's part +/// of its sub-line and the next cell shares the box, so the caret belongs to +/// that next span. +fn passes_to_next_warichu_cell( + layout: &VerticalLayout, + cell: &VerticalCell, + next: Option<&&VerticalCell>, + x: f32, + y: f32, +) -> bool { + if !matches!(cell.kind, CellKind::Warichu { .. }) { + return false; + } + let first_line = x >= column_base_center(&layout.columns[cell.column]); + let (top, extent) = warichu_line_span(cell, first_line); + let next_shares_box = next + .is_some_and(|next| matches!(next.kind, CellKind::Warichu { .. }) && next.top == cell.top); + next_shares_box && (extent <= 0.0 || y >= cell.top + top + extent) +} + +/// Paragraph scalar offset of the caret for a point inside `cell`, which +/// holds the scalars `cell_start..cell_end`. +fn caret_offset_in_cell( + layout: &VerticalLayout, + cell: &VerticalCell, + (cell_start, cell_end): (usize, usize), + x: f32, + y: f32, +) -> Option { + let chars = (cell_end - cell_start).max(1); + let column = &layout.columns[cell.column]; + Some(match cell.kind { + CellKind::Rotated { .. } => { + // The glyph boundary nearest the point along the run. + let within = y - cell.top; + let nearest = (0..=chars) + .min_by(|a, b| { + let distance = |index: &usize| { + (boundary_offset(layout, cell, *index, chars) - within).abs() + }; + distance(a).total_cmp(&distance(b)) + }) + .unwrap_or(0); + cell_start + nearest + } + CellKind::TateChuYoko { .. } => { + // The digits run left->right inside the composite, so the + // horizontal position picks the offset within it. + let left = column_base_center(column) - cell.h_advance / 2.0; + let frac = ((x - left) / cell.h_advance.max(1.0)).clamp(0.0, 1.0); + cell_start + ((frac * chars as f32).round() as usize).min(chars) + } + CellKind::Warichu { first_chars, .. } => { + // The right sub-column holds the first sub-line, the left the + // second; y picks the offset within that sub-line. + let first = warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?; + let first_line = x >= column_base_center(column); + let (lo, hi) = if first_line { + (0, first) + } else { + (first, chars) + }; + let (top, extent) = warichu_line_span(cell, first_line); + let line_chars = (hi - lo).max(1); + let frac = ((y - cell.top - top) / extent.max(1.0)).clamp(0.0, 1.0); + cell_start + lo + ((frac * line_chars as f32).round() as usize).min(hi - lo) + } + CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } => { + if y < cell.top + cell.extent / 2.0 { + cell_start + } else { + cell_end + } + } + }) +} + +/// Caret position (paragraph index, paragraph-relative Unicode scalar +/// offset) for a point relative to the content block's top-left origin. +pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usize, usize)> { + if layout.columns.is_empty() { + return None; + } + let column_index = column_at(layout, x); + let paragraph = layout + .paragraph_columns + .iter() + .position(|(start, end)| column_index >= *start && column_index < *end)?; + let column_cells: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|c| c.column == column_index) + .collect(); + let Some(last) = column_cells.last() else { + return Some((paragraph, 0)); + }; + + for (index, cell) in column_cells.iter().enumerate() { + if y >= cell.top + cell.extent + || passes_to_next_warichu_cell(layout, cell, column_cells.get(index + 1), x, y) + { + continue; + } + let range = cell_scalar_range(layout, cell)?; + return Some((paragraph, caret_offset_in_cell(layout, cell, range, x, y)?)); + } + + Some(( + paragraph, + scalar_offset_from_utf16(layout, paragraph, last.end)?, + )) +} + +/// Caret rect for a paragraph-relative Unicode scalar offset, in +/// content-local coordinates. It spans the column width; its height is the +/// extent of the character at the offset (overtype carets cover the glyph), +/// or zero after the last character. +pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> Option { + let (col_start, _) = *layout.paragraph_columns.get(paragraph)?; + + let mut result: Option = None; + for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { + let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; + if cell_start > offset { + continue; + } + let column = &layout.columns[cell.column]; + let chars = (cell_end - cell_start).max(1); + let rect = if offset >= cell_end { + Rect::from_xywh(column.x, cell.top + cell.extent, column.base_width, 0.0) + } else { + match cell.kind { + CellKind::TateChuYoko { .. } => { + // Digits run left->right inside the composite: the offset + // moves the caret along the horizontal axis while the + // rect keeps the composite's flow extent. + let left = column_base_center(column) - cell.h_advance / 2.0; + let width = cell.h_advance / chars as f32; + let x = left + (offset - cell_start) as f32 * width; + Rect::from_xywh(x, cell.top, width, cell.extent) + } + CellKind::Warichu { first_chars, .. } => { + // The right sub-column holds the first sub-line, the + // left the second (jlreq reading order); the caret moves + // down that half-width sub-line. + let first = + warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?; + let within = offset - cell_start; + let centre = column_base_center(column); + let half = cell.font_size / 2.0; + let (band_x, line_start, line_chars) = if within < first { + (centre, 0, first) + } else { + (centre - half, first, chars - first) + }; + let line_chars = line_chars.max(1); + let frac = (within - line_start) as f32 / line_chars as f32; + let (top, extent) = warichu_line_span(cell, within < first); + Rect::from_xywh( + band_x, + cell.top + top + frac * extent, + half, + extent / line_chars as f32, + ) + } + _ => { + let index = offset - cell_start; + let top = boundary_offset(layout, cell, index, chars); + let bottom = boundary_offset(layout, cell, index + 1, chars); + Rect::from_xywh(column.x, cell.top + top, column.base_width, bottom - top) + } + } + }; + result = Some(rect); + if offset < cell_end { + break; + } + } + + result.or_else(|| { + // Empty paragraph: caret at the top of its (empty) first column. + layout + .columns + .get(col_start) + .map(|column| Rect::from_xywh(column.x, 0.0, column.base_width, 0.0)) + }) +} + +/// Selection rects for a paragraph-relative Unicode scalar offset range, in +/// content-local coordinates. +pub fn range_rects( + layout: &VerticalLayout, + paragraph: usize, + start: usize, + end: usize, +) -> Vec { + let mut rects: Vec = Vec::new(); + for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) { + let Some((cell_start, cell_end)) = cell_scalar_range(layout, cell) else { + continue; + }; + if cell_end <= start || cell_start >= end { + continue; + } + let column = &layout.columns[cell.column]; + let chars = (cell_end - cell_start).max(1); + if let CellKind::Warichu { first_chars, .. } = cell.kind { + let Some(first) = warichu_first_line_len(layout, cell, first_chars, cell_start, chars) + else { + continue; + }; + rects.extend(warichu_range_rects( + layout, + cell, + (cell_start, cell_start + first, cell_end), + (start, end), + )); + continue; + } + let sel_top = if start > cell_start { + cell.top + boundary_offset(layout, cell, start - cell_start, chars) + } else { + cell.top + }; + let sel_bottom = if end < cell_end { + cell.top + boundary_offset(layout, cell, end - cell_start, chars) + } else { + cell.top + cell.extent + }; + if sel_bottom <= sel_top { + continue; + } + // Merge with the previous rect when contiguous in the same column. + if let Some(last) = rects.last_mut() { + if (last.left - column.x).abs() < f32::EPSILON && (last.bottom - sel_top).abs() < 0.01 { + last.bottom = sel_bottom; + continue; + } + } + rects.push(Rect::from_ltrb( + column.x, + sel_top, + column.x + column.base_width, + sel_bottom, + )); + } + rects +} + +/// Selection rects of the scalar range `start..end` over a warichu cell's +/// two sub-line parts, given as (cell start, first-part end, cell end). +fn warichu_range_rects( + layout: &VerticalLayout, + cell: &VerticalCell, + (cell_start, split, cell_end): (usize, usize, usize), + (start, end): (usize, usize), +) -> Vec { + let centre = column_base_center(&layout.columns[cell.column]); + let half = cell.font_size / 2.0; + [ + (cell_start, split, centre, true), + (split, cell_end, centre - half, false), + ] + .into_iter() + .filter_map(|(line_start, line_end, left, first_line)| { + let from = start.max(line_start); + let to = end.min(line_end); + if from >= to { + return None; + } + let (top, extent) = warichu_line_span(cell, first_line); + let per_char = extent / (line_end - line_start) as f32; + let sel_top = cell.top + top + (from - line_start) as f32 * per_char; + let sel_bottom = cell.top + top + (to - line_start) as f32 * per_char; + Some(Rect::from_ltrb(left, sel_top, left + half, sel_bottom)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::super::test_support::*; + use super::*; + use crate::shapes::{ + TextCombineUpright, TextEmphasis, TextOrientation, TextPositionWithAffinity, TextSpan, + TextTransform, + }; + use crate::wasm::text::helpers as text_helpers; + + #[test] + fn position_data_gives_each_digit_composite_its_own_strip() { + let content = spans_content( + vec![TextSpan { + text_combine_upright: TextCombineUpright::Digits2, + ..make_span("平成31年") + }], + 1000.0, + ); + let layout = layout_content(&content, 1000.0); + let strips: Vec<(u32, u32)> = position_data(&layout, &content.bounds(), VerticalAlign::Top) + .iter() + .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(strips, vec![(0, 2), (2, 4), (4, 5)]); + } + + #[test] + fn caret_from_shape_coords_wraps_columns_at_the_selrect_height() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + // Stored bounds are taller than the shape, as after measuring a fixed text. + let content = make_content(&["あいうえ"], 1000.0); + let wide = super::super::layout::layout_for_box(&content, 1000.0); + let height = wide.cells[0].extent * 2.0 + 1.0; + let selrect = Rect::from_xywh(0.0, 0.0, 200.0, height); + let layout = super::super::layout::layout_for_box(&content, selrect.height()); + let caret = caret_rect(&layout, 0, 2).unwrap(); + assert_ne!(layout.cells[2].column, layout.cells[0].column); + let left = super::super::layout::block_axis_offset( + selrect.width(), + layout.width, + VerticalAlign::Top, + ); + let point = crate::math::Point::new(left + caret.center_x(), caret.top + 1.0); + + let position = content + .get_caret_position_from_shape_coords(&point, &selrect, VerticalAlign::Top) + .expect("a caret position"); + assert_eq!((position.paragraph, position.offset), (0, 2)); + } + + #[test] + fn caret_rect_on_a_sideways_run_follows_glyph_positions() { + let content = make_content(&["WWWiii"], 1000.0); + let layout = layout_content(&content, 1000.0); + let cell = &layout.cells[0]; + let run = &layout.runs[match cell.kind { + CellKind::Rotated { run } => run, + _ => panic!("expected a sideways run"), + }]; + let boundary = cell.top + run.positions[3].x - run.positions[0].x; + + let caret = caret_rect(&layout, 0, 3).unwrap(); + assert!( + (caret.top - boundary).abs() < 0.01, + "caret at {} but the glyph boundary is at {boundary}", + caret.top + ); + assert_eq!( + caret_from_point(&layout, caret.left + 1.0, boundary + 0.5), + Some((0, 3)) + ); + + let selection = range_rects(&layout, 0, 0, 3); + assert!((selection[0].bottom - boundary).abs() < 0.01); + } + + #[test] + fn caret_from_point_lands_inside_tcy_composite() { + let mut content = make_content_with_spans(&["1234", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::All; + let layout = layout_content(&content, 400.0); + let cell = &layout.cells[0]; + assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); + let column = &layout.columns[cell.column]; + let y_mid = cell.top + cell.extent / 2.0; + let (_, at_left) = caret_from_point(&layout, column.x + 0.5, y_mid).unwrap(); + let (_, at_right) = + caret_from_point(&layout, column.x + column.base_width - 0.5, y_mid).unwrap(); + assert!(at_left <= 1, "left edge maps near the start, got {at_left}"); + assert!( + at_right >= 3, + "right edge maps near the end, got {at_right}" + ); + } + + #[test] + fn caret_rect_tracks_horizontal_axis_inside_tcy_composite() { + let mut content = make_content_with_spans(&["1234", "あ"], 400.0); + content.paragraphs_mut()[0].children_mut()[0].text_combine_upright = + TextCombineUpright::All; + let layout = layout_content(&content, 400.0); + let cell = &layout.cells[0]; + assert!(matches!(cell.kind, CellKind::TateChuYoko { .. })); + let left = column_base_center(&layout.columns[cell.column]) - cell.h_advance / 2.0; + let digit = cell.h_advance / 4.0; + for i in 0..4 { + let rect = caret_rect(&layout, 0, cell.start + i).expect("caret rect"); + assert!( + (rect.left - (left + i as f32 * digit)).abs() < 0.01, + "digit {i} caret sits at its horizontal slot, got {}", + rect.left + ); + assert!( + (rect.width() - digit).abs() < 0.01, + "digit {i} caret is one digit wide, got {}", + rect.width() + ); + assert!( + (rect.top - cell.top).abs() < 0.01 && (rect.height() - cell.extent).abs() < 0.01, + "digit {i} caret spans the composite's flow extent" + ); + } + // After the composite the caret falls back to the flow axis. + let rect = caret_rect(&layout, 0, cell.end).expect("caret rect"); + assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01); + } + + #[test] + fn position_data_emits_one_strip_per_warichu_sub_line() { + let content = warichu_content("あいう、えお", 400.0); + let layout = layout_content(&content, 400.0); + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + + let strips: Vec<&PositionData> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .collect(); + assert_eq!(strips.len(), 2); + assert_eq!((strips[0].start_pos, strips[0].end_pos), (0, 4)); + assert_eq!((strips[1].start_pos, strips[1].end_pos), (4, 6)); + assert!( + strips[0].x > strips[1].x, + "the first sub-line reads first, on the right" + ); + assert!((strips[0].width - EM / 2.0).abs() < 0.01); + } + + #[test] + fn position_data_emits_each_emphasis_mark_box() { + let content = spans_content( + vec![TextSpan { + text_emphasis: TextEmphasis::FilledDot, + text_orientation: TextOrientation::Upright, + ..make_span("A、B") + }], + 400.0, + ); + let layout = layout_content(&content, 400.0); + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + + let marks: Vec<&PositionData> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_EMPHASIS_MARK) + .collect(); + let ranges: Vec<(u32, u32)> = marks.iter().map(|m| (m.start_pos, m.end_pos)).collect(); + assert_eq!( + ranges, + vec![(0, 1), (2, 3)], + "A and B are marked; 、 is not" + ); + let base = data + .iter() + .find(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .expect("base strip"); + for mark in marks { + assert!((mark.width - EM / 2.0).abs() < 0.01); + assert!( + mark.x >= base.x + base.width - 0.01, + "marks sit in the gutter right of the base band" + ); + } + } + + #[test] + fn warichu_cell_carries_kinsoku_split() { + let content = warichu_content("あいう、えお", 400.0); + let layout = layout_content(&content, 400.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Warichu { .. })) + .expect("a warichu cell"); + let CellKind::Warichu { first_chars, .. } = cell.kind else { + unreachable!(); + }; + assert_eq!( + first_chars, 4, + "the comma is pulled up into the first sub-line" + ); + // The caret for the second sub-line's first character restarts at + // the composite top, left of the axis. + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let rect = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); + assert!((rect.top - cell.top).abs() < 0.01); + assert!(rect.left < centre); + } + + #[test] + fn caret_rect_tracks_sub_lines_inside_warichu() { + let content = warichu_content("割注二行説明", 400.0); + let layout = layout_content(&content, 400.0); + let cell = layout + .cells + .iter() + .find(|c| matches!(c.kind, CellKind::Warichu { .. })) + .expect("a warichu cell"); + let column = &layout.columns[cell.column]; + let centre = column_base_center(column); + let half = cell.font_size / 2.0; + // First sub-line (offsets 0..3) sits right of the column axis. + let first = caret_rect(&layout, 0, cell.start).expect("caret rect"); + assert!( + (first.left - centre).abs() < 0.01, + "first sub-line caret starts at the axis, got {}", + first.left + ); + assert!( + (first.width() - half).abs() < 0.01, + "sub-line caret is half-size wide, got {}", + first.width() + ); + assert!((first.top - cell.top).abs() < 0.01); + // Second sub-line (offsets 3..6) sits left of the axis, restarting + // at the composite top. + let second = caret_rect(&layout, 0, cell.start + 3).expect("caret rect"); + assert!( + (second.left - (centre - half)).abs() < 0.01, + "second sub-line caret sits left of the axis, got {}", + second.left + ); + assert!( + (second.top - cell.top).abs() < 0.01, + "second sub-line restarts at the composite top, got {}", + second.top + ); + let deeper = caret_rect(&layout, 0, cell.start + 4).expect("caret rect"); + assert!( + deeper.top > second.top, + "later offsets move down the sub-line" + ); + } + + #[test] + fn caret_round_trip() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + for cell in &layout.cells { + let column = &layout.columns[cell.column]; + // A point in the upper half of the cell resolves to its start. + let (paragraph, offset) = caret_from_point( + &layout, + column.x + column.width / 2.0, + cell.top + cell.extent * 0.25, + ) + .expect("caret"); + assert_eq!(paragraph, 0); + assert_eq!(offset, cell.start); + + // caret_rect for that offset lands inside the same column and + // carries the character extent (for overtype carets). + let rect = caret_rect(&layout, paragraph, offset).expect("caret rect"); + assert!((rect.x() - column.x).abs() < 0.01); + let chars = (cell.end - cell.start).max(1); + assert!((rect.height() - cell.extent / chars as f32).abs() < 0.01); + } + + // After the last character there is no glyph to cover: zero height. + let end_offset = layout.cells.last().unwrap().end; + let rect = caret_rect(&layout, 0, end_offset).expect("caret rect"); + assert_eq!(rect.height(), 0.0); + } + + #[test] + fn non_bmp_caret_offsets_round_trip_through_editor_operations() { + let content = make_content(&["𠀀あ"], 1000.0); + let layout = layout_content(&content, 1000.0); + let first = &layout.cells[0]; + let second = &layout.cells[1]; + let column = &layout.columns[first.column]; + let x = column_base_center(column); + + let before = caret_from_point(&layout, x, first.top + first.extent * 0.25).unwrap(); + let after = caret_from_point(&layout, x, first.top + first.extent * 0.75).unwrap(); + assert_eq!(before, (0, 0)); + assert_eq!(after, (0, 1)); + + let rect = caret_rect(&layout, after.0, after.1).expect("caret rect"); + assert!((rect.top - second.top).abs() < 0.01); + assert!((rect.height() - second.extent).abs() < 0.01); + + let rects = range_rects(&layout, 0, 0, 1); + assert_eq!(rects.len(), 1); + assert!((rects[0].height() - first.extent).abs() < 0.01); + + let start = TextPositionWithAffinity::new_downstream_affinity(0, 0); + let end = TextPositionWithAffinity::new_downstream_affinity(after.0, after.1); + assert_eq!( + text_helpers::move_cursor_forward(&start, content.paragraphs(), false), + end + ); + assert_eq!( + text_helpers::move_cursor_backward(&end, content.paragraphs(), false), + start + ); + + let mut inserted = make_content(&["𠀀あ"], 1000.0); + assert_eq!( + text_helpers::insert_text_at_cursor(&mut inserted, &end, "X"), + Some(2) + ); + assert_eq!(inserted.paragraphs()[0].children()[0].text, "𠀀Xあ"); + + let mut deleted = make_content(&["𠀀あ"], 1000.0); + assert_eq!( + text_helpers::delete_char_before(&mut deleted, &end), + Some(start) + ); + assert_eq!(deleted.paragraphs()[0].children()[0].text, "あ"); + } + + #[test] + fn selection_rects_cover_the_range() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + + let rects = range_rects(&layout, 0, 1, 3); + assert!(!rects.is_empty()); + let covered: f32 = rects.iter().map(|r| r.height()).sum(); + let expected: f32 = layout + .cells + .iter() + .filter(|c| c.start >= 1 && c.end <= 3) + .map(|c| c.extent) + .sum(); + assert!((covered - expected).abs() < 0.01); + } + + #[test] + fn position_data_merges_by_column_and_maps_span_offsets() { + let text = "あいうえお"; + let content = make_content(&[text], 1000.0); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + + let data = position_data(&layout, &bounds, VerticalAlign::Top); + assert_eq!(data.len(), 1, "one column, one span => one entry"); + assert_eq!(data[0].start_pos, 0); + assert_eq!(data[0].end_pos, text.encode_utf16().count() as u32); + // Right-anchored: the strip ends at the bounds right edge. + assert!((data[0].x + data[0].width - bounds.right).abs() < 0.01); + } + + #[test] + fn position_data_keeps_expanded_transform_in_one_source_safe_strip() { + let mut content = make_content(&["AßB"], 1000.0); + let span = &mut content.paragraphs_mut()[0].children_mut()[0]; + span.text_transform = Some(TextTransform::Uppercase); + span.text_orientation = TextOrientation::Upright; + let unwrapped = layout_content(&content, 1000.0); + let expanded_extent: f32 = unwrapped + .cells + .iter() + .filter(|cell| cell_source_utf16_range(&unwrapped, cell) == (1..2)) + .map(|cell| cell.extent) + .sum(); + let layout = layout_content(&content, expanded_extent + 0.01); + + let expanded: Vec<&VerticalCell> = layout + .cells + .iter() + .filter(|cell| cell_source_utf16_range(&layout, cell) == (1..2)) + .collect(); + assert_eq!(expanded.len(), 2, "ß must shape as the two cells in SS"); + assert_eq!( + expanded[0].column, expanded[1].column, + "glyphs from one source character must not split across columns" + ); + + let data = position_data(&layout, &content.bounds(), VerticalAlign::Top); + let ranges: Vec<(u32, u32)> = data + .iter() + .filter(|entry| entry.direction == DIRECTION_VERTICAL_RL) + .map(|entry| (entry.start_pos, entry.end_pos)) + .collect(); + assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]); + } + + #[test] + fn position_data_emits_ruby_annotation_strips() { + let content = ruby_content("漢字", "かんじ", 400.0); + let layout = layout_content(&content, 400.0); + let bounds = content.bounds(); + let data = position_data(&layout, &bounds, VerticalAlign::Top); + + let base: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RL) + .collect(); + let ruby: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) + .collect(); + assert_eq!(base.len(), 1); + assert_eq!(ruby.len(), 1, "one annotated column => one ruby strip"); + assert_eq!(ruby[0].start_pos, 0); + assert_eq!(ruby[0].end_pos, 3, "offsets index the ruby string"); + // The strip sits in the gutter, to the right of the base band. + assert!(ruby[0].x >= base[0].x + base[0].width - 0.001); + assert!(ruby[0].width > 0.0); + assert!(ruby[0].height > 0.0); + } + + #[test] + fn position_data_ruby_offsets_are_utf16_for_non_bmp_readings() { + // Two surrogate-pair reading characters: 2 glyphs but 4 UTF-16 + // units. The strip offsets must slice the ruby string by UTF-16. + let content = ruby_content("\u{6f22}\u{5b57}", "\u{1d4aa}\u{1d4ab}", 400.0); + let layout = layout_content(&content, 400.0); + let bounds = content.bounds(); + let data = position_data(&layout, &bounds, VerticalAlign::Top); + let ruby: Vec<&PositionData> = data + .iter() + .filter(|d| d.direction == DIRECTION_VERTICAL_RUBY) + .collect(); + assert_eq!(ruby.len(), 1); + assert_eq!(ruby[0].start_pos, 0); + assert_eq!( + ruby[0].end_pos, 4, + "surrogate pairs count two UTF-16 units each" + ); + } + + #[test] + fn position_data_follows_block_axis_alignment() { + let content = make_content(&["あいう"], 1000.0); + let layout = layout_content(&content, 1000.0); + let bounds = content.bounds(); + let top = position_data(&layout, &bounds, VerticalAlign::Top); + let center = position_data(&layout, &bounds, VerticalAlign::Center); + let bottom = position_data(&layout, &bounds, VerticalAlign::Bottom); + + assert!(top[0].x > center[0].x); + assert!(center[0].x > bottom[0].x); + assert!((bottom[0].x - bounds.left).abs() < 0.01); + } + + #[test] + fn ruby_selection_geometry_excludes_gutter() { + let mut content = ruby_content("AB", "ab", 400.0); + content.paragraphs_mut()[0].children_mut()[0].annotation_clearance = + crate::shapes::AnnotationClearance::Auto; + let layout = layout_content(&content, 400.0); + let base_width = layout.columns[0].base_width; + assert!(base_width < layout.columns[0].width); + + let pd = position_data(&layout, &content.bounds(), VerticalAlign::Top); + assert!(!pd.is_empty()); + for entry in pd.iter().filter(|e| e.direction == DIRECTION_VERTICAL_RL) { + assert!( + (entry.width - base_width).abs() < 0.01, + "position-data strip must be the base band, not the full column" + ); + } + + let rects = range_rects(&layout, 0, 0, 10); + assert!(!rects.is_empty()); + for rect in &rects { + assert!( + (rect.width() - base_width).abs() < 0.01, + "selection rect must be the base band, not the full column" + ); + } + } +} diff --git a/render-wasm/src/shapes/text_vertical/shaping.rs b/render-wasm/src/shapes/text_vertical/shaping.rs new file mode 100644 index 0000000000..82a957383a --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/shaping.rs @@ -0,0 +1,607 @@ +use std::cell::RefCell; +use std::ops::Range; + +use skia_safe::{ + self as skia, + shaper::{ + run_handler::{Buffer, RunInfo}, + Feature, RunHandler, + }, + textlayout::TypefaceFontProvider, + Font, FontMgr, GlyphId, Point as SkPoint, TextBlob, TextBlobBuilder, Typeface, +}; + +use crate::render::DEFAULT_EMOJI_FONT; +use crate::shapes::{FontFeatures, TextSpan}; + +use super::font_tables::upright_centre_metrics; + +/// JLREQ preferred width of a word space inside sideways Western text. +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 { + let mut families = vec![ + format!("{}", span.font_family), + DEFAULT_EMOJI_FONT.to_string(), + ]; + families.extend(fallback_families.iter().cloned()); + families +} + +/// One shaped run: glyphs from a single font, pen-relative positions, +/// per-glyph horizontal advances and per-glyph UTF-8 cluster starts +/// (relative to the shaped segment text). +pub struct ShapedRun { + pub font: Font, + pub glyphs: Vec, + pub positions: Vec, + pub advances: Vec, + pub clusters: Vec, + pub advance: f32, + pub utf8_range: Range, + /// Source text of `utf8_range`, attached to the run's text blobs. + pub text: String, + /// Local-y offset that puts the font's central baseline on the column + /// axis after 90° rotation. + pub rotated_baseline_shift: f32, +} + +impl ShapedRun { + /// Glyph clusters as (first glyph, glyph count). A combining sequence + /// shapes to one cluster and must stay in one cell. + pub(super) fn cluster_spans(&self) -> impl Iterator + '_ { + let mut glyph = 0usize; + std::iter::from_fn(move || { + let cluster = *self.clusters.get(glyph)?; + let count = self.clusters[glyph..] + .iter() + .take_while(|other| **other == cluster) + .count(); + let span = (glyph, count); + glyph += count; + Some(span) + }) + } + + /// Flow offset from the run start of each scalar boundary (0..=`chars`) + /// of a sideways run, from its glyph positions; the last boundary is + /// `extent`. A scalar inside a ligature takes the next glyph's position. + pub(super) fn scalar_flow_offsets(&self, extent: f32, chars: usize) -> Option> { + let origin = self.positions.first()?.x; + let mut offsets = Vec::with_capacity(chars + 1); + let mut utf8 = self.utf8_range.start; + for ch in self.text.chars().take(chars) { + let flow = self + .clusters + .iter() + .position(|cluster| *cluster as usize >= utf8) + .map_or(extent, |glyph| self.positions[glyph].x - origin); + offsets.push(flow); + utf8 += ch.len_utf8(); + } + offsets.push(extent); + (offsets.len() == chars + 1).then_some(offsets) + } + + /// Summed horizontal advance of a cluster. + pub(super) fn cluster_advance(&self, glyph: usize, count: usize) -> f32 { + self.advances[glyph..glyph + count].iter().sum() + } + + /// Positions of a cluster's glyphs relative to its first glyph's pen x. + fn cluster_positions(&self, glyph: usize, count: usize) -> Vec { + let base_x = self.positions[glyph].x; + self.positions[glyph..glyph + count] + .iter() + .map(|position| SkPoint::new(position.x - base_x, position.y)) + .collect() + } + + /// Ink bounds of a cluster, relative to its first glyph's pen position. + pub(super) fn cluster_ink_bounds(&self, glyph: usize, count: usize) -> Option { + glyph_run_ink_bounds( + &self.font, + &self.glyphs[glyph..glyph + count], + &self.cluster_positions(glyph, count), + ) + } + + /// Ink bounds of the whole run at its shaped positions. + pub(super) fn ink_bounds(&self) -> Option { + glyph_run_ink_bounds(&self.font, &self.glyphs, &self.positions) + } + + /// Byte range in `text` of the clusters covering `glyph..glyph + count`. + fn text_range(&self, glyph: usize, count: usize) -> Range { + let base = self.utf8_range.start as u32; + let clusters = &self.clusters[glyph..glyph + count]; + let first = clusters.iter().copied().min().unwrap_or(base); + let last = clusters.iter().copied().max().unwrap_or(base); + // Clusters ascend along the run: the next larger one ends the text. + let end = self.clusters[glyph + count..] + .iter() + .copied() + .find(|cluster| *cluster > last) + .map_or(self.text.len(), |cluster| { + cluster.saturating_sub(base) as usize + }); + (first.saturating_sub(base) as usize).min(end)..end + } + + /// Text blob of `glyph..glyph + count` at `positions`, carrying the + /// clusters' source text so PDF output maps vertical alternates back to + /// the original characters. + fn text_blob(&self, glyph: usize, count: usize, positions: &[SkPoint]) -> Option { + let range = self.text_range(glyph, count); + let Some(text) = self + .text + .as_bytes() + .get(range.clone()) + .filter(|t| !t.is_empty()) + else { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(&self.font, count, None); + glyphs.copy_from_slice(&self.glyphs[glyph..glyph + count]); + points.copy_from_slice(positions); + return builder.make(); + }; + let text_start = self.utf8_range.start as u32 + range.start as u32; + let mut builder = TextBlobBuilder::new(); + let (glyphs, points, utf8_text, clusters) = + builder.alloc_run_text_pos(&self.font, count, text.len(), None); + glyphs.copy_from_slice(&self.glyphs[glyph..glyph + count]); + points.copy_from_slice(positions); + utf8_text.copy_from_slice(text); + for (cluster, source) in clusters + .iter_mut() + .zip(&self.clusters[glyph..glyph + count]) + { + *cluster = source.saturating_sub(text_start); + } + builder.make() + } + + /// Text blob of one cluster, positioned from its first glyph's pen x. + pub(super) fn cluster_blob(&self, glyph: usize, count: usize) -> Option { + self.text_blob(glyph, count, &self.cluster_positions(glyph, count)) + } + + /// Text blob of the whole run at its shaped positions. + pub(super) fn blob(&self) -> Option { + self.text_blob(0, self.glyphs.len(), &self.positions) + } + + /// Text blob of one glyph at its pen origin. The first glyph of a + /// cluster carries the cluster's source text; the rest are glyph-only. + pub(super) fn glyph_blob(&self, glyph: usize) -> Option { + self.glyphs.get(glyph)?; + let starts_cluster = glyph == 0 || self.clusters.get(glyph) != self.clusters.get(glyph - 1); + if starts_cluster { + self.text_blob(glyph, 1, &[SkPoint::default()]) + } else { + single_glyph_blob(&self.font, self.glyphs[glyph]) + } + } + + /// Local-y shift that centres a cluster's ink after a 90° rotation. + pub(super) fn cluster_rotated_baseline_shift(&self, glyph: usize, count: usize) -> f32 { + rotated_run_baseline_shift( + &self.font, + &self.glyphs[glyph..glyph + count], + &self.cluster_positions(glyph, count), + ) + } + + /// Spreads the glyphs by `letter_spacing` each, like horizontal + /// per-glyph spacing. Returns the spaced run advance. + pub(super) fn spread_glyphs(&mut self, letter_spacing: f32) -> f32 { + if letter_spacing != 0.0 { + for (i, position) in self.positions.iter_mut().enumerate() { + position.x += letter_spacing * i as f32; + } + } + self.advance + letter_spacing * self.glyphs.len() as f32 + } + + /// Sets ASCII word spaces in a sideways Western run to JLREQ's one-third + /// em. The space keeps its scalar and break opportunity; only its advance + /// and the following glyph positions change. + pub(super) fn normalize_word_spaces(&mut self, segment_text: &str, font_size: f32) { + let clusters: Vec<(usize, usize)> = self.cluster_spans().collect(); + let mut accumulated_shift = 0.0f32; + for (glyph, count) in clusters { + for position in &mut self.positions[glyph..glyph + count] { + position.x += accumulated_shift; + } + let is_word_space = segment_text + .get(self.clusters[glyph] as usize..) + .and_then(|text| text.chars().next()) + == Some(' '); + if is_word_space { + let delta = + font_size * WESTERN_WORD_SPACING_EM - self.cluster_advance(glyph, count); + self.advances[glyph + count - 1] += delta; + accumulated_shift += delta; + } + } + self.advance += accumulated_shift; + } +} + +/// Text blob holding a single glyph drawn at its pen origin. +pub(crate) fn single_glyph_blob(font: &Font, glyph: GlyphId) -> Option { + let mut builder = TextBlobBuilder::new(); + let (glyphs, points) = builder.alloc_run_pos(font, 1, None); + glyphs[0] = glyph; + points[0] = SkPoint::default(); + builder.make() +} + +#[derive(Default)] +struct RunCollector { + runs: Vec, + scratch_glyphs: Vec, + scratch_positions: Vec, + scratch_clusters: Vec, +} + +impl RunHandler for RunCollector { + fn begin_line(&mut self) {} + + fn run_info(&mut self, _info: &RunInfo) {} + + fn commit_run_info(&mut self) {} + + fn run_buffer(&mut self, info: &RunInfo) -> Buffer<'_> { + self.scratch_glyphs.resize(info.glyph_count, 0); + self.scratch_positions + .resize(info.glyph_count, SkPoint::default()); + self.scratch_clusters.resize(info.glyph_count, 0); + Buffer { + glyphs: &mut self.scratch_glyphs, + positions: &mut self.scratch_positions, + offsets: None, + clusters: Some(&mut self.scratch_clusters), + point: SkPoint::default(), + } + } + + fn commit_run_buffer(&mut self, info: &RunInfo) { + if info.glyph_count == 0 { + return; + } + let origin = self.scratch_positions[0]; + let positions: Vec = self + .scratch_positions + .iter() + .map(|p| SkPoint::new(p.x - origin.x, p.y)) + .collect(); + let end = positions[0].x + info.advance.x; + let advances: Vec = positions + .iter() + .enumerate() + .map(|(i, position)| positions.get(i + 1).map_or(end, |next| next.x) - position.x) + .collect(); + self.runs.push(ShapedRun { + rotated_baseline_shift: central_baseline_shift(info.font), + font: info.font.clone(), + glyphs: self.scratch_glyphs.clone(), + positions, + advances, + clusters: self.scratch_clusters.clone(), + advance: info.advance.x, + utf8_range: info.utf8_range.clone(), + text: String::new(), + }); + } + + fn commit_line(&mut self) {} +} + +fn feature(tag: &[u8; 4]) -> Feature { + Feature { + tag: u32::from_be_bytes(*tag), + value: 1, + start: 0, + end: usize::MAX, + } +} + +/// Omits `vpal`: SkShaper shapes on a horizontal line, where HarfBuzz would +/// apply its y-placement deltas as glyph offsets without the advance deltas. +/// Vertical layout applies the parsed GPOS `vpal` to upright cells itself. +fn font_feature(font_features: FontFeatures) -> Option { + match font_features { + FontFeatures::None => None, + FontFeatures::Palt => Some(feature(b"palt")), + FontFeatures::Vpal => None, + } +} + +/// Shape one orientation segment on a single unbounded line. Upright +/// segments get the OpenType vertical substitution features so CJK +/// punctuation and brackets take their vertical forms. +pub(super) fn shape_segment( + text: &str, + font: &Font, + upright: bool, + font_features: FontFeatures, + fallback: FontMgr, +) -> Vec { + let mut font_iter = skia::Shaper::new_font_mgr_run_iterator(text, font, Some(fallback.clone())); + let mut bidi_iter = skia::shapers::primitive::trivial_bidi_run_iterator(0, text.len()); + let mut script_iter = skia::Shaper::new_hb_icu_script_run_iterator(text); + let mut lang_iter = skia::Shaper::new_trivial_language_run_iterator("ja", text.len()); + + let mut features = if upright { + vec![feature(b"vert"), feature(b"vrt2")] + } else { + vec![] + }; + if let Some(font_feature) = font_feature(font_features) { + features.push(font_feature); + } + + let mut collector = RunCollector::default(); + with_shaper(&fallback, |shaper| { + shaper.shape_with_iterators_and_features( + text, + &mut font_iter, + &mut bidi_iter, + &mut script_iter, + &mut lang_iter, + &features, + f32::MAX, + &mut collector, + ) + }); + for run in &mut collector.runs { + run.text = text + .get(run.utf8_range.clone()) + .unwrap_or_default() + .to_string(); + } + collector.runs +} + +thread_local! { + static SHAPER: RefCell> = const { RefCell::new(None) }; +} + +/// Runs `f` with this thread's shaper, built on first use. One shaper serves +/// every font manager: shaping with explicit iterators takes the fallback +/// from the font run iterator, never from the shaper. +fn with_shaper(fallback: &FontMgr, f: impl FnOnce(&skia::Shaper) -> R) -> R { + SHAPER.with(|cache| { + let mut cache = cache.borrow_mut(); + f(cache.get_or_insert_with(|| skia::Shaper::new(fallback.clone()))) + }) +} + +/// Combining marks, variation selectors and ZWJ belong to the preceding +/// grapheme. They stay in that font run even without a standalone cmap +/// entry, so HarfBuzz gets the complete shaping sequence. +fn extends_previous_grapheme(ch: char) -> bool { + matches!(u32::from(ch), + 0x0300..=0x036F + | 0x1AB0..=0x1AFF + | 0x1DC0..=0x1DFF + | 0x200D + | 0x20D0..=0x20FF + | 0x3099..=0x309A + | 0xFE00..=0xFE0F + | 0xFE20..=0xFE2F + | 0xE0100..=0xE01EF + ) +} + +/// Split `text` into byte ranges covered by one typeface each: the first of +/// `typefaces` with a glyph for the character, or the primary face. +fn typeface_chunks(text: &str, typefaces: &[Typeface]) -> Vec<(Range, usize)> { + let mut chunks: Vec<(Range, usize)> = Vec::new(); + for (byte, ch) in text.char_indices() { + let current = chunks.last().map(|(_, typeface)| *typeface); + let selected = match current { + Some(typeface) if extends_previous_grapheme(ch) => typeface, + _ => typefaces + .iter() + .position(|typeface| typeface.unichar_to_glyph(ch as i32) != 0) + .unwrap_or(0), + }; + match chunks.last_mut() { + Some((range, typeface)) if *typeface == selected => range.end = byte + ch.len_utf8(), + _ => chunks.push((byte..byte + ch.len_utf8(), selected)), + } + } + chunks +} + +/// Shape a segment with explicit per-character font fallback. +/// `TypefaceFontProvider` resolves named families, but some Skia builds lack +/// its character fallback hook, so glyph coverage is resolved here. +pub(crate) fn shape_segment_with_fallbacks( + text: &str, + font_size: f32, + families: &[String], + font_provider: &TypefaceFontProvider, + upright: bool, + font_features: FontFeatures, + fallback_mgr: &FontMgr, +) -> Vec { + shape_with_typefaces( + text, + font_size, + &resolve_typefaces(families, font_provider), + upright, + font_features, + fallback_mgr, + ) +} + +/// The typefaces of `families` that `font_provider` holds, in order. +pub(crate) fn resolve_typefaces( + families: &[String], + font_provider: &TypefaceFontProvider, +) -> Vec { + families + .iter() + .filter_map(|family| font_provider.match_family_style(family, skia::FontStyle::default())) + .collect() +} + +/// `shape_segment_with_fallbacks` with the families already resolved. +pub(crate) fn shape_with_typefaces( + text: &str, + font_size: f32, + typefaces: &[Typeface], + upright: bool, + font_features: FontFeatures, + fallback_mgr: &FontMgr, +) -> Vec { + if typefaces.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + for (range, typeface_index) in typeface_chunks(text, typefaces) { + let font = Font::new(typefaces[typeface_index].clone(), font_size); + let mut shaped = shape_segment( + &text[range.clone()], + &font, + upright, + font_features, + fallback_mgr.clone(), + ); + for run in &mut shaped { + run.utf8_range = + (run.utf8_range.start + range.start)..(run.utf8_range.end + range.start); + for cluster in &mut run.clusters { + *cluster += range.start as u32; + } + } + result.extend(shaped); + } + result +} + +fn glyph_run_ink_bounds( + font: &Font, + glyphs: &[GlyphId], + positions: &[SkPoint], +) -> Option { + let mut bounds = vec![skia::Rect::default(); glyphs.len()]; + font.get_bounds(glyphs, &mut bounds, None); + + let mut ink = skia::Rect::new(f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for (bound, position) in bounds.iter().zip(positions) { + if bound.right > bound.left && bound.bottom > bound.top { + ink.left = ink.left.min(bound.left + position.x); + ink.top = ink.top.min(bound.top + position.y); + ink.right = ink.right.max(bound.right + position.x); + ink.bottom = ink.bottom.max(bound.bottom + position.y); + } + } + + (ink.right > ink.left && ink.bottom > ink.top).then_some(ink) +} + +/// Offset that centres a local-y band on the column axis after the run is +/// rotated 90°. +pub(super) fn rotated_baseline_shift(top: f32, bottom: f32) -> f32 { + -(top + bottom) / 2.0 +} + +/// Shift that puts the font's central baseline on the column axis: the +/// middle of the em box upright glyphs centre on (`upright_centre_metrics`), +/// scaled to 1em. It depends only on the font, so every sideways run of a +/// face shares one baseline whatever its ink. +fn central_baseline_shift(font: &Font) -> f32 { + let (ascent, descent) = upright_centre_metrics(font); + let band = descent - ascent; + if band <= 0.0 { + return 0.0; + } + let em_over = -ascent * font.size() / band; + em_over - font.size() / 2.0 +} + +/// Centres the glyphs' actual ink; ascent/descent metrics are only the +/// fallback for glyphs without visible ink. +fn rotated_run_baseline_shift(font: &Font, glyphs: &[GlyphId], positions: &[SkPoint]) -> f32 { + if let Some(ink) = glyph_run_ink_bounds(font, glyphs, positions) { + rotated_baseline_shift(ink.top, ink.bottom) + } else { + let (_, metrics) = font.metrics(); + rotated_baseline_shift(metrics.ascent, metrics.descent) + } +} + +#[cfg(test)] +mod tests { + use super::super::layout::CellKind; + use super::super::test_support::*; + use super::*; + + #[test] + fn span_families_try_emoji_after_the_span_font() { + let families = span_font_families(&make_span("😆"), &["fallback".to_string()]); + assert_eq!(families[1], DEFAULT_EMOJI_FONT); + assert_eq!(families[2], "fallback"); + } + + fn rotated_shift(text: &str) -> f32 { + let layout = layout_content(&make_content(&[text], 1000.0), 1000.0); + let CellKind::Rotated { run } = layout.cells[0].kind else { + panic!("expected a rotated run"); + }; + layout.runs[run].rotated_baseline_shift + } + + #[test] + fn rotated_runs_share_a_baseline_whatever_their_ink() { + // Ascenders and descenders must not move the baseline across the + // column. + let plain = rotated_shift("aaa"); + for text in ["aaaf", "aaag", "aaagf", "AAA"] { + assert!( + (rotated_shift(text) - plain).abs() < 0.01, + "{text} shifted from {plain} to {}", + rotated_shift(text) + ); + } + } + + #[test] + fn rotated_runs_centre_on_the_upright_em_box() { + // Noto Sans JP's ascent overshoots the em; sideways runs must centre + // on the same em box as upright glyphs, or full-width marks such as + // `…` sit off the column axis. + let typeface = FontMgr::new() + .new_from_data(skia_safe::Data::new_copy(VMTX_TEST_FONT), None) + .unwrap(); + let font = Font::new(typeface, EM); + let (ascent, descent) = upright_centre_metrics(&font); + let em_box_middle = -(ascent + descent) / 2.0; + let shift = central_baseline_shift(&font); + assert!( + (shift - em_box_middle).abs() < 0.01, + "sideways baseline shift {shift}, upright em box middle {em_box_middle}" + ); + } + + #[test] + fn rotated_lowercase_sits_on_the_column_axis() { + // Source Sans: the em-box middle falls within 2% of an em of the + // x-height middle, so lowercase looks centred in the column. + let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0); + let CellKind::Rotated { run } = layout.cells[0].kind else { + panic!("expected a rotated run"); + }; + let run = &layout.runs[run]; + let ink = run.ink_bounds().expect("visible ink"); + let ink_middle = (ink.top + ink.bottom) / 2.0 + run.rotated_baseline_shift; + assert!(ink_middle.abs() < 0.02 * EM, "ink middle at {ink_middle}"); + } +} diff --git a/render-wasm/src/shapes/text_vertical/test_support.rs b/render-wasm/src/shapes/text_vertical/test_support.rs new file mode 100644 index 0000000000..f6872c9d66 --- /dev/null +++ b/render-wasm/src/shapes/text_vertical/test_support.rs @@ -0,0 +1,189 @@ +// Fixtures for the vertical layout tests: real shaping with bundled test +// fonts. Glyph coverage does not matter for offsets/columns; tofu still +// shapes with real advances. + +use skia_safe::{textlayout::TypefaceFontProvider, FontMgr}; + +use crate::math::Rect; +use crate::shapes::{ + FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextContent, TextDecoration, + TextDirection, TextOrientation, TextSpan, WritingMode, +}; +use crate::Uuid; + +use super::layout::{layout_vertical, VerticalLayout}; + +pub(crate) const TEST_FONT: &[u8] = include_bytes!("../../fonts/sourcesanspro-regular.ttf"); + +/// Noto Sans JP subset with `vmtx`/`vhea`: 〱 advances 2em vertically, あ/く 1em. +pub(super) const VMTX_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vmtx-test.ttf"); + +/// Noto Sans JP subset with GSUB `vert` and GPOS `vpal` for 、。「」あく. +pub(crate) const VPAL_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vpal-test.ttf"); + +/// Font size of every test span. +pub(crate) const EM: f32 = 20.0; + +/// Provider with `font` registered under the family every test span uses +/// (spans serialize their family as "uuid-weight-style"). +pub(crate) fn provider(font: &[u8]) -> TypefaceFontProvider { + let typeface = FontMgr::new() + .new_from_data(skia_safe::Data::new_copy(font), None) + .expect("failed to load test font"); + let mut provider = TypefaceFontProvider::new(); + let family = format!("{}", FontFamily::new(Uuid::nil(), 400, FontStyle::Normal)); + provider.register_typeface(typeface, Some(family.as_str())); + provider +} + +/// `provider(font)` plus `fallback` registered as the "fallback" family. +pub(super) fn provider_with_fallback(font: &[u8], fallback: &[u8]) -> TypefaceFontProvider { + let mut provider = provider(font); + let typeface = FontMgr::new() + .new_from_data(skia_safe::Data::new_copy(fallback), None) + .expect("failed to load fallback font"); + provider.register_typeface(typeface, Some("fallback")); + provider +} + +pub(super) fn make_span(text: &str) -> TextSpan { + TextSpan { + text: text.to_string(), + font_size: EM, + line_height: 1.0, + ..TextSpan::default() + } +} + +pub(super) fn vertical_paragraph( + spans: Vec, + align: TextAlign, + line_height: f32, +) -> Paragraph { + let mut paragraph = Paragraph::new( + align, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + spans, + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + paragraph +} + +/// A 200px wide box `height` tall holding `paragraphs`. +pub(super) fn content_of( + paragraphs: Vec, + height: f32, + grow_type: GrowType, +) -> TextContent { + crate::globals::design_init(); + let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, height), grow_type); + for paragraph in paragraphs { + content.add_paragraph(paragraph); + } + content +} + +/// One left-aligned paragraph holding `spans`. +pub(super) fn spans_content(spans: Vec, height: f32) -> TextContent { + content_of( + vec![vertical_paragraph(spans, TextAlign::Left, 1.0)], + height, + GrowType::Fixed, + ) +} + +/// One paragraph per text, each a single span. +pub(super) fn make_content(texts: &[&str], height: f32) -> TextContent { + let paragraphs = texts + .iter() + .map(|text| vertical_paragraph(vec![make_span(text)], TextAlign::Left, 1.0)) + .collect(); + content_of(paragraphs, height, GrowType::Fixed) +} + +/// One paragraph with a span per text. +pub(super) fn make_content_with_spans(texts: &[&str], height: f32) -> TextContent { + spans_content(texts.iter().map(|text| make_span(text)).collect(), height) +} + +pub(super) fn ruby_content(base: &str, ruby: &str, height: f32) -> TextContent { + spans_content( + vec![TextSpan { + ruby: ruby.to_string(), + ..make_span(base) + }], + height, + ) +} + +pub(super) fn warichu_content(base: &str, height: f32) -> TextContent { + spans_content( + vec![TextSpan { + warichu: true, + text_orientation: TextOrientation::Upright, + ..make_span(base) + }], + height, + ) +} + +pub(super) fn spaced_content(text: &str, letter_spacing: f32) -> TextContent { + spans_content( + vec![TextSpan { + letter_spacing, + ..make_span(text) + }], + 1000.0, + ) +} + +pub(super) fn decorated_content(text: &str, decoration: TextDecoration) -> TextContent { + spans_content( + vec![TextSpan { + text_decoration: Some(decoration), + ..make_span(text) + }], + 1000.0, + ) +} + +pub(super) fn layout_with_fallback( + provider: &TypefaceFontProvider, + content: &TextContent, + max_height: f32, + fallback_families: &[String], +) -> VerticalLayout { + layout_vertical( + content, + max_height, + provider, + FontMgr::from(provider.clone()), + fallback_families, + content.bounds(), + ) +} + +pub(super) fn layout_with_height( + provider: &TypefaceFontProvider, + content: &TextContent, + max_height: f32, +) -> VerticalLayout { + layout_with_fallback(provider, content, max_height, &[]) +} + +/// Layout with `provider` and a 1000px wrap budget. +pub(super) fn layout_with( + provider: &TypefaceFontProvider, + content: &TextContent, +) -> VerticalLayout { + layout_with_height(provider, content, 1000.0) +} + +/// Layout with the Latin test face. +pub(super) fn layout_content(content: &TextContent, max_height: f32) -> VerticalLayout { + layout_with_height(&provider(TEST_FONT), content, max_height) +} diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index aaec3e90fd..4d7fbae9a6 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -281,6 +281,8 @@ pub struct TextComposition { pub previous: String, pub current: String, pub is_composing: bool, + /// Preview start: each update replaces `[start, start + previous]`. + pub start: Option, } impl TextComposition { @@ -289,6 +291,7 @@ impl TextComposition { previous: String::new(), current: String::new(), is_composing: false, + start: None, } } @@ -299,6 +302,7 @@ impl TextComposition { self.is_composing = true; self.previous = String::new(); self.current = String::new(); + self.start = None; true } @@ -316,22 +320,26 @@ impl TextComposition { return false; } self.is_composing = false; + self.start = None; true } + /// Range the next preview replaces: the previous preview, or `selection` + /// (the pre-composition selection) when there is none yet. pub fn get_selection(&self, selection: &TextSelection) -> TextSelection { - if self.previous.is_empty() { + let Some(start) = self.start.filter(|_| !self.previous.is_empty()) else { return *selection; - } + }; - let focus = selection.focus; - let previous_len = self.previous.chars().count(); let anchor = TextPositionWithAffinity::new_downstream_affinity( - focus.paragraph, - focus.offset + previous_len, + start.paragraph, + start.offset + self.previous.chars().count(), ); - TextSelection { anchor, focus } + TextSelection { + anchor, + focus: start, + } } } @@ -916,8 +924,23 @@ impl TextEditorState { TextDirection::LTR }; + // In vertical-rl, Up/Down move by character along the column and + // Left/Right cross columns (columns advance right-to-left). + let is_vertical = text_content.is_vertical(); + let direction = if is_vertical { + match direction { + CursorDirection::Backward => CursorDirection::LineAfter, // Left -> next column + CursorDirection::Forward => CursorDirection::LineBefore, // Right -> prev column + CursorDirection::LineBefore => CursorDirection::Backward, // Up -> prev char + CursorDirection::LineAfter => CursorDirection::Forward, // Down -> next char + other => other, + } + } else { + direction + }; + // For horizontal navigation, swap Backward/Forward when in RTL text - let adjusted_direction = if text_span_text_direction == TextDirection::RTL { + let adjusted_direction = if !is_vertical && text_span_text_direction == TextDirection::RTL { match direction { CursorDirection::Backward => CursorDirection::Forward, CursorDirection::Forward => CursorDirection::Backward, @@ -1190,4 +1213,31 @@ mod tests { assert!(editor.selection.is_collapsed()); assert_eq!(editor.selection.focus.offset, 2); } + + fn caret(paragraph: usize, offset: usize) -> TextSelection { + TextSelection::from_position_with_affinity( + TextPositionWithAffinity::new_downstream_affinity(paragraph, offset), + ) + } + + #[test] + fn composition_replaces_the_previous_preview_wherever_the_caret_is() { + let mut composition = TextComposition::new(); + composition.start(); + + // First update: the pre-composition caret is the replaced range. + composition.update("に"); + let replaced = composition.get_selection(&caret(0, 2)); + assert_eq!((replaced.start().offset, replaced.end().offset), (2, 2)); + composition.start = Some(replaced.start()); + + // The caret sits after the preview; the next update still replaces + // the preview itself. + composition.update("にほ"); + let replaced = composition.get_selection(&caret(0, 3)); + assert_eq!((replaced.start().offset, replaced.end().offset), (2, 3)); + + composition.end(); + assert!(composition.start.is_none()); + } } diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index f3ad1d4189..161dd7453d 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -124,3 +124,11 @@ pub extern "C" fn is_font_uploaded( let family = FontFamily::new(id, weight, font_style.into()); get_resources().fonts.has_family(&family, is_emoji) } + +#[no_mangle] +pub extern "C" fn mark_font_as_fallback(a: u32, b: u32, c: u32, d: u32, weight: u32, style: u8) { + let id = uuid_from_u32_quartet(a, b, c, d); + let font_style = RawFontStyle::from(style); + let family = FontFamily::new(id, weight, font_style.into()); + get_resources().fonts.mark_as_fallback(&family); +} diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index c154afbf89..ec252b87dd 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -12,6 +12,9 @@ use crate::{with_current_shape, with_current_shape_mut, with_state}; use crate::error::Error; pub mod helpers; +mod japanese_layout; + +pub use japanese_layout::*; const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); @@ -103,6 +106,10 @@ pub struct RawParagraphData { text_direction: RawTextDirection, text_decoration: RawTextDecoration, text_transform: RawTextTransform, + writing_mode: u8, + text_orientation: u8, + // Padding for the CLJS writer's 4-byte-aligned layout; always zero. + _padding: [u8; 2], line_height: f32, letter_spacing: f32, } @@ -131,6 +138,18 @@ pub struct RawTextSpan { text_decoration: RawTextDecoration, text_transform: RawTextTransform, text_direction: RawTextDirection, + text_orientation: u8, + text_combine_upright: u8, + text_emphasis: u8, + warichu: u8, + font_features: u8, + annotation_clearance: u8, + ruby_size: u8, + ruby_align: u8, + ruby_overhang: u8, + ruby_side: u8, + // Padding for the CLJS writer's 4-byte-aligned layout; always zero. + _padding: [u8; 2], font_size: f32, line_height: f32, letter_spacing: f32, @@ -139,6 +158,7 @@ pub struct RawTextSpan { font_family: [u8; 4], font_variant_id: [u32; 4], // TODO: maybe add RawUUID type text_length: u32, + ruby_length: u32, fill_count: u32, fills: [RawFillData; MAX_TEXT_FILLS], } @@ -177,7 +197,7 @@ impl From for shapes::TextSpan { .map(|fill| fill.into()) .collect(); - Self::new( + let mut span = Self::new( text, font_family, value.font_size, @@ -189,7 +209,18 @@ impl From for shapes::TextSpan { value.font_weight, uuid_from_u32(value.font_variant_id), fills, - ) + ); + span.text_orientation = RawTextOrientation::from(value.text_orientation).into(); + span.text_combine_upright = RawTextCombineUpright::from(value.text_combine_upright).into(); + span.text_emphasis = RawTextEmphasis::from(value.text_emphasis).into(); + span.warichu = RawWarichu::from(value.warichu).into(); + span.font_features = RawFontFeatures::from(value.font_features).into(); + span.annotation_clearance = RawAnnotationClearance::from(value.annotation_clearance).into(); + span.ruby_size = RawRubySize::from(value.ruby_size).into(); + span.ruby_align = RawRubyAlign::from(value.ruby_align).into(); + span.ruby_overhang = RawRubyOverhang::from(value.ruby_overhang).into(); + span.ruby_side = RawRubySide::from(value.ruby_side).into(); + span } } @@ -230,21 +261,31 @@ impl From for shapes::Paragraph { fn from(value: RawParagraph) -> Self { let mut spans = vec![]; + // Layout: [ ]. Annotation blobs + // begin after all base text. let mut offset = 0; + let mut ruby_offset: usize = value.spans.iter().map(|s| s.text_length as usize).sum(); for raw_span in value.spans.into_iter() { let delta = raw_span.text_length as usize; - let text_buffer = &value.text_buffer[offset..offset + delta]; - + let text_buffer = value.text_buffer.get(offset..offset + delta).unwrap_or(&[]); + let ruby_delta = raw_span.ruby_length as usize; + let ruby_buffer = value + .text_buffer + .get(ruby_offset..ruby_offset + ruby_delta) + .unwrap_or(&[]); let mut span = shapes::TextSpan::from(raw_span); if !text_buffer.is_empty() { span.set_text(String::from_utf8_lossy(text_buffer).to_string()); } - + if !ruby_buffer.is_empty() { + span.ruby = String::from_utf8_lossy(ruby_buffer).to_string(); + } spans.push(span); offset += delta; + ruby_offset += ruby_delta; } - shapes::Paragraph::new( + let mut paragraph = shapes::Paragraph::new( value.attrs.text_align.into(), value.attrs.text_direction.into(), value.attrs.text_decoration.into(), @@ -252,7 +293,11 @@ impl From for shapes::Paragraph { value.attrs.line_height, value.attrs.letter_spacing, spans, - ) + ); + paragraph.set_writing_mode(RawWritingMode::from(value.attrs.writing_mode).into()); + paragraph + .set_text_orientation(RawTextOrientation::from(value.attrs.text_orientation).into()); + paragraph } } @@ -320,6 +365,15 @@ pub extern "C" fn set_shape_grow_type(grow_type: u8) { }); } +#[no_mangle] +pub extern "C" fn set_shape_line_adjustment(line_adjustment: u8) { + with_current_shape_mut!(state, |shape: &mut Shape| { + if let Type::Text(text_content) = &mut shape.shape_type { + text_content.set_line_adjustment(RawLineAdjustment::from(line_adjustment).into()); + } + }); +} + #[no_mangle] pub extern "C" fn get_text_dimensions() -> *mut u8 { let mut ptr = std::ptr::null_mut(); @@ -445,3 +499,118 @@ pub extern "C" fn calculate_position_data() -> *mut u8 { }); mem::write_vec(result) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{ + AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize, + }; + + /// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16) + /// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80) + /// attr bytes before the fills block. These sizes must match the struct + /// layouts. + #[test] + fn raw_struct_sizes_match_cljs_writer() { + const PARAGRAPH_ATTR_U8_SIZE: usize = 16; + const SPAN_ATTR_U8_SIZE: usize = 80; + assert_eq!(RAW_PARAGRAPH_DATA_SIZE, 4 + PARAGRAPH_ATTR_U8_SIZE); + assert_eq!( + RAW_SPAN_DATA_SIZE, + SPAN_ATTR_U8_SIZE + MAX_TEXT_FILLS * std::mem::size_of::() + ); + } + + #[test] + fn raw_text_combine_upright_counts_deserialize() { + // Byte 5 of the span attr block; the counted digits variants map + // onto their max run length. + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[5] = RawTextCombineUpright::Digits2 as u8; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!(span.text_combine_upright.digits_max(), Some(2)); + + bytes[5] = RawTextCombineUpright::Digits3 as u8; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!(span.text_combine_upright.digits_max(), Some(3)); + + bytes[5] = RawTextCombineUpright::Digits as u8; + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + assert_eq!(span.text_combine_upright.digits_max(), Some(4)); + } + + #[test] + fn raw_font_features_deserializes_from_reserved_span_byte() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[8] = RawFontFeatures::Vpal as u8; + + let raw = RawTextSpan::from(bytes); + let span = shapes::TextSpan::from(raw); + + assert_eq!(span.font_features, FontFeatures::Vpal); + } + + #[test] + fn raw_annotation_clearance_deserializes_from_reserved_span_byte() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[9] = RawAnnotationClearance::Auto as u8; + + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + + assert_eq!(span.annotation_clearance, AnnotationClearance::Auto); + } + + #[test] + fn raw_ruby_customization_deserializes_from_span_bytes() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[10] = RawRubySize::Quarter as u8; + bytes[11] = RawRubyAlign::SpaceBetween as u8; + bytes[12] = RawRubyOverhang::None as u8; + bytes[13] = RawRubySide::Under as u8; + + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + + assert_eq!(span.ruby_size, RubySize::Quarter); + assert_eq!(span.ruby_align, RubyAlign::SpaceBetween); + assert_eq!(span.ruby_overhang, RubyOverhang::None); + assert_eq!(span.ruby_side, RubySide::Under); + } + + #[test] + fn out_of_range_japanese_span_bytes_fall_back_to_defaults() { + let mut bytes = [0u8; RAW_SPAN_DATA_SIZE]; + bytes[4..14].fill(0xFF); + + let span = shapes::TextSpan::from(RawTextSpan::from(bytes)); + + assert_eq!(span.text_orientation, shapes::TextOrientation::Mixed); + assert_eq!(span.text_combine_upright, shapes::TextCombineUpright::None); + assert_eq!(span.text_emphasis, shapes::TextEmphasis::None); + assert!(!span.warichu); + assert_eq!(span.font_features, FontFeatures::None); + assert_eq!(span.annotation_clearance, AnnotationClearance::None); + assert_eq!(span.ruby_size, RubySize::Half); + assert_eq!(span.ruby_align, RubyAlign::SpaceAround); + assert_eq!(span.ruby_overhang, RubyOverhang::Auto); + assert_eq!(span.ruby_side, RubySide::Over); + } + + #[test] + fn out_of_range_paragraph_writing_bytes_fall_back_to_defaults() { + let mut bytes = [0u8; RAW_PARAGRAPH_DATA_SIZE]; + bytes[8] = 0xFF; + bytes[9] = 0xFF; + + let attrs = RawParagraphData::from(bytes); + + assert_eq!( + shapes::WritingMode::from(RawWritingMode::from(attrs.writing_mode)), + shapes::WritingMode::HorizontalTb + ); + assert_eq!( + shapes::TextOrientation::from(RawTextOrientation::from(attrs.text_orientation)), + shapes::TextOrientation::Mixed + ); + } +} diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index f9ccfebdd0..a0ae969667 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -823,6 +823,8 @@ pub fn split_paragraph_at_cursor( let text_direction = para.text_direction(); let text_decoration = para.text_decoration(); let text_transform = para.text_transform(); + let writing_mode = para.writing_mode(); + let text_orientation = para.text_orientation(); let line_height = para.line_height(); let letter_spacing = para.letter_spacing(); @@ -838,7 +840,7 @@ pub fn split_paragraph_at_cursor( span.set_text(new_text); } - let new_para = crate::shapes::Paragraph::new( + let mut new_para = crate::shapes::Paragraph::new( text_align, text_direction, text_decoration, @@ -847,6 +849,8 @@ pub fn split_paragraph_at_cursor( letter_spacing, new_para_children, ); + new_para.set_writing_mode(writing_mode); + new_para.set_text_orientation(text_orientation); paragraphs.insert(cursor.paragraph + 1, new_para); diff --git a/render-wasm/src/wasm/text/japanese_layout.rs b/render-wasm/src/wasm/text/japanese_layout.rs new file mode 100644 index 0000000000..efdc9ee58f --- /dev/null +++ b/render-wasm/src/wasm/text/japanese_layout.rs @@ -0,0 +1,286 @@ +use crate::shapes::{ + self, AnnotationClearance, FontFeatures, LineAdjustment, RubyAlign, RubyOverhang, RubySide, + RubySize, TextCombineUpright, TextEmphasis, +}; +use macros::ToJs; + +/// Reads a style byte as `$raw`, falling back to `$default` when the byte +/// names no variant, so a bad byte from the CLJS writer never becomes an +/// invalid enum value. +macro_rules! raw_enum_from_byte { + ($raw:ident, $default:ident, [$($variant:ident),+ $(,)?]) => { + impl From for $raw { + fn from(value: u8) -> Self { + [$($raw::$variant),+] + .into_iter() + .find(|variant| *variant as u8 == value) + .unwrap_or($raw::$default) + } + } + }; +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawWritingMode { + HorizontalTb = 0, + VerticalRl = 1, +} + +raw_enum_from_byte!(RawWritingMode, HorizontalTb, [HorizontalTb, VerticalRl]); + +impl From for shapes::WritingMode { + fn from(value: RawWritingMode) -> Self { + match value { + RawWritingMode::HorizontalTb => shapes::WritingMode::HorizontalTb, + RawWritingMode::VerticalRl => shapes::WritingMode::VerticalRl, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawTextOrientation { + Mixed = 0, + Upright = 1, +} + +raw_enum_from_byte!(RawTextOrientation, Mixed, [Mixed, Upright]); + +impl From for shapes::TextOrientation { + fn from(value: RawTextOrientation) -> Self { + match value { + RawTextOrientation::Mixed => shapes::TextOrientation::Mixed, + RawTextOrientation::Upright => shapes::TextOrientation::Upright, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawTextCombineUpright { + None = 0, + All = 1, + Digits = 2, + Digits2 = 3, + Digits3 = 4, +} + +raw_enum_from_byte!( + RawTextCombineUpright, + None, + [None, All, Digits, Digits2, Digits3] +); + +impl From for TextCombineUpright { + fn from(value: RawTextCombineUpright) -> Self { + match value { + RawTextCombineUpright::None => TextCombineUpright::None, + RawTextCombineUpright::All => TextCombineUpright::All, + RawTextCombineUpright::Digits => TextCombineUpright::Digits, + RawTextCombineUpright::Digits2 => TextCombineUpright::Digits2, + RawTextCombineUpright::Digits3 => TextCombineUpright::Digits3, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawTextEmphasis { + None = 0, + FilledDot = 1, + OpenDot = 2, + FilledCircle = 3, + OpenCircle = 4, + FilledSesame = 5, + OpenSesame = 6, +} + +raw_enum_from_byte!( + RawTextEmphasis, + None, + [ + None, + FilledDot, + OpenDot, + FilledCircle, + OpenCircle, + FilledSesame, + OpenSesame + ] +); + +impl From for TextEmphasis { + fn from(value: RawTextEmphasis) -> Self { + match value { + RawTextEmphasis::None => TextEmphasis::None, + RawTextEmphasis::FilledDot => TextEmphasis::FilledDot, + RawTextEmphasis::OpenDot => TextEmphasis::OpenDot, + RawTextEmphasis::FilledCircle => TextEmphasis::FilledCircle, + RawTextEmphasis::OpenCircle => TextEmphasis::OpenCircle, + RawTextEmphasis::FilledSesame => TextEmphasis::FilledSesame, + RawTextEmphasis::OpenSesame => TextEmphasis::OpenSesame, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawWarichu { + None = 0, + Warichu = 1, +} + +raw_enum_from_byte!(RawWarichu, None, [None, Warichu]); + +impl From for bool { + fn from(value: RawWarichu) -> Self { + value == RawWarichu::Warichu + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawFontFeatures { + None = 0, + Palt = 1, + Vpal = 2, +} + +raw_enum_from_byte!(RawFontFeatures, None, [None, Palt, Vpal]); + +impl From for FontFeatures { + fn from(value: RawFontFeatures) -> Self { + match value { + RawFontFeatures::None => FontFeatures::None, + RawFontFeatures::Palt => FontFeatures::Palt, + RawFontFeatures::Vpal => FontFeatures::Vpal, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawAnnotationClearance { + None = 0, + Auto = 1, +} + +raw_enum_from_byte!(RawAnnotationClearance, None, [None, Auto]); + +impl From for AnnotationClearance { + fn from(value: RawAnnotationClearance) -> Self { + match value { + RawAnnotationClearance::None => AnnotationClearance::None, + RawAnnotationClearance::Auto => AnnotationClearance::Auto, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawRubySize { + Half = 0, + Third = 1, + Quarter = 2, +} + +raw_enum_from_byte!(RawRubySize, Half, [Half, Third, Quarter]); + +impl From for RubySize { + fn from(value: RawRubySize) -> Self { + match value { + RawRubySize::Half => RubySize::Half, + RawRubySize::Third => RubySize::Third, + RawRubySize::Quarter => RubySize::Quarter, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawRubyAlign { + SpaceAround = 0, + Center = 1, + Start = 2, + SpaceBetween = 3, +} + +raw_enum_from_byte!( + RawRubyAlign, + SpaceAround, + [SpaceAround, Center, Start, SpaceBetween] +); + +impl From for RubyAlign { + fn from(value: RawRubyAlign) -> Self { + match value { + RawRubyAlign::SpaceAround => RubyAlign::SpaceAround, + RawRubyAlign::Center => RubyAlign::Center, + RawRubyAlign::Start => RubyAlign::Start, + RawRubyAlign::SpaceBetween => RubyAlign::SpaceBetween, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawRubyOverhang { + Auto = 0, + None = 1, +} + +raw_enum_from_byte!(RawRubyOverhang, Auto, [Auto, None]); + +impl From for RubyOverhang { + fn from(value: RawRubyOverhang) -> Self { + match value { + RawRubyOverhang::Auto => RubyOverhang::Auto, + RawRubyOverhang::None => RubyOverhang::None, + } + } +} + +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawRubySide { + Over = 0, + Under = 1, +} + +raw_enum_from_byte!(RawRubySide, Over, [Over, Under]); + +impl From for RubySide { + fn from(value: RawRubySide) -> Self { + match value { + RawRubySide::Over => RubySide::Over, + RawRubySide::Under => RubySide::Under, + } + } +} + +// The variants are Adobe's Kinsoku Type values and the JS keys. +#[allow(clippy::enum_variant_names)] +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawLineAdjustment { + PushInFirst = 0, + PushOutFirst = 1, + PushOutOnly = 2, +} + +raw_enum_from_byte!( + RawLineAdjustment, + PushInFirst, + [PushInFirst, PushOutFirst, PushOutOnly] +); + +impl From for LineAdjustment { + fn from(value: RawLineAdjustment) -> Self { + match value { + RawLineAdjustment::PushInFirst => LineAdjustment::PushInFirst, + RawLineAdjustment::PushOutFirst => LineAdjustment::PushOutFirst, + RawLineAdjustment::PushOutOnly => LineAdjustment::PushOutOnly, + } + } +} diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 9853876249..34835103d9 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -154,7 +154,11 @@ where }; let point = Point::new(x, y); - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = text_content.get_caret_position_from_shape_coords( + &point, + &shape.selrect(), + shape.vertical_align(), + ) { apply(get_text_editor_state(), text_content, &position); } }) @@ -208,7 +212,11 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { }; let point = Point::new(x, y); get_text_editor_state().start_pointer_selection(); - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = text_content.get_caret_position_from_shape_coords( + &point, + &shape.selrect(), + shape.vertical_align(), + ) { get_text_editor_state().place_caret_from_position(text_content, &position); } }); @@ -233,7 +241,11 @@ pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) { }; let point = Point::new(x, y); get_text_editor_state().start_pointer_selection(); - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = text_content.get_caret_position_from_shape_coords( + &point, + &shape.selrect(), + shape.vertical_align(), + ) { get_text_editor_state().extend_selection_from_position(&position); // The click after pointerup would collapse the caret and drop the // selection we just extended. @@ -267,7 +279,11 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { return; }; - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = text_content.get_caret_position_from_shape_coords( + &point, + &shape.selrect(), + shape.vertical_align(), + ) { get_text_editor_state().extend_selection_from_position(&position); // We need this flag to prevent handling the click behavior // just after a pointerup event. @@ -296,7 +312,11 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = text_content.get_caret_position_from_shape_coords( + &point, + &shape.selrect(), + shape.vertical_align(), + ) { get_text_editor_state().extend_selection_from_position(&position); get_text_editor_state().update_styles(text_content); } @@ -331,7 +351,11 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { return; }; - if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + if let Some(position) = text_content.get_caret_position_from_shape_coords( + &point, + &shape.selrect(), + shape.vertical_align(), + ) { get_text_editor_state().place_caret_from_position(text_content, &position); } }); @@ -356,9 +380,13 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { let Type::Text(text_content) = &shape.shape_type else { return; }; - if let Some(position) = - text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) - { + if let Some(position) = text_content.get_caret_position_from_screen_coords( + &point, + &view_matrix, + &shape_matrix, + &shape.selrect(), + shape.vertical_align(), + ) { get_text_editor_state().place_caret_from_position(text_content, &position); } }); @@ -411,11 +439,16 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { .get_selection(&get_text_editor_state().selection); text_helpers::delete_selection_range(text_content, &selection); - let cursor = get_text_editor_state().selection.focus; + // Insert at the start of the replaced range (the pre-composition + // selection or the previous preview), not at a possibly stale + // selection focus, and advance the caret past the committed text. + let cursor = selection.start(); if let Some(new_cursor) = text_helpers::insert_text_with_newlines(text_content, &cursor, &text) { get_text_editor_state().selection.set_caret(new_cursor); + } else { + get_text_editor_state().selection.set_caret(cursor); } text_content.layout.clear(); @@ -466,8 +499,13 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { .get_selection(&get_text_editor_state().selection); text_helpers::delete_selection_range(text_content, &selection); - let cursor = get_text_editor_state().selection.focus; - text_helpers::insert_text_with_newlines(text_content, &cursor, &text); + // The preview always starts where the replaced range did; the caret + // goes after it, as in a native text field. + let cursor = selection.start(); + get_text_editor_state().composition.start = Some(cursor); + let caret = + text_helpers::insert_text_with_newlines(text_content, &cursor, &text).unwrap_or(cursor); + get_text_editor_state().selection.set_caret(caret); text_content.layout.clear(); @@ -482,6 +520,28 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { Ok(()) } +/// Place the caret `offset` characters into the composition preview, where +/// the IME's own cursor is (clause conversion, cursor moves inside the +/// preedit). Clamped to the preview; ignored outside a composition. +#[no_mangle] +pub extern "C" fn text_editor_set_composition_cursor(offset: u32) { + let editor_state = get_text_editor_state(); + if !editor_state.has_focus || !editor_state.composition.is_composing { + return; + } + let Some(start) = editor_state.composition.start else { + return; + }; + let offset = (offset as usize).min(editor_state.composition.current.chars().count()); + editor_state + .selection + .set_caret(TextPositionWithAffinity::new_downstream_affinity( + start.paragraph, + start.offset + offset, + )); + editor_state.reset_blink(); +} + #[no_mangle] #[wasm_error] pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> { @@ -662,6 +722,48 @@ pub extern "C" fn text_editor_move_cursor( // RENDERING & EXPORT // ============================================================================ +/// Caret rectangle in page coordinates (the unrotated selrect space of the +/// text overlay), regardless of the blink phase. The frontend keeps the IME +/// capture surface on it. +#[no_mangle] +pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { + with_state!(state, { + let editor_state = get_text_editor_state(); + if !editor_state.has_focus { + return std::ptr::null_mut(); + } + + let Some(shape_id) = editor_state.active_shape_id else { + return std::ptr::null_mut(); + }; + + // A preview update clears the layout; rebuild it so the rect is + // current before the next render. + update_text_layout_if_needed(state, shape_id); + + let Some(shape) = state.shapes.get(&shape_id) else { + return std::ptr::null_mut(); + }; + + let Type::Text(text_content) = &shape.shape_type else { + return std::ptr::null_mut(); + }; + + let Some(rect) = crate::render::text_editor::cursor_rect(editor_state, text_content, shape) + else { + return std::ptr::null_mut(); + }; + + let selrect = shape.selrect(); + let mut bytes = vec![0u8; 16]; + bytes[0..4].copy_from_slice(&(selrect.x() + rect.left()).to_le_bytes()); + bytes[4..8].copy_from_slice(&(selrect.y() + rect.top()).to_le_bytes()); + bytes[8..12].copy_from_slice(&rect.width().to_le_bytes()); + bytes[12..16].copy_from_slice(&rect.height().to_le_bytes()); + mem::write_bytes(bytes) + }) +} + #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { with_state!(state, {