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 0000000000..5b36929f63 Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vmtx-test.ttf differ 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 0000000000..e1fd58e08f Binary files /dev/null and b/render-wasm/src/fonts/notosansjp-vpal-test.ttf differ diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 26e149d97c..b9045f1d89 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -184,7 +184,7 @@ fn resources_init_headless() { } /// Initializes DesignState. -fn design_init() { +pub(crate) fn design_init() { unsafe { let design_state = State::new(); DESIGN_STATE = Box::into_raw(Box::new(design_state)); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5b47c8bce2..4dfb4e6ba7 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1589,8 +1589,12 @@ impl RenderState { let rebound_text_content = stored_text_content.paint_content_for_selrect(shape.selrect()); let text_content = rebound_text_content.as_ref(); - let mut paragraph_builders = - text_content.paragraph_builder_group_from_text(None); + // Vertical text paints from its cached vertical layout. + let mut paragraph_builders = if text_content.is_vertical() { + Vec::new() + } else { + text_content.paragraph_builder_group_from_text(None) + }; text::render( Some(self), None, @@ -1742,7 +1746,9 @@ impl RenderState { // Plain fill (no strokes / parent shadows): reuse cached layout // paragraphs when valid. Skip builder rebuild + Skia layout. - let can_use_layout_cache = !shape.has_visible_strokes() + // The full text pass paints the developer text grid. + let can_use_layout_cache = !self.options.is_text_grid_visible() + && !shape.has_visible_strokes() && parent_shadows.is_none() && (skip_effects || (shape.blur.is_none() @@ -1762,191 +1768,41 @@ impl RenderState { let rebound_text_content = stored_text_content.paint_content_for_selrect(shape.selrect()); let text_content = rebound_text_content.as_ref(); - 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 - .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, {