mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ Render Japanese text layouts in WASM
Lay out horizontal and vertical Japanese text with kinsoku, ruby, emphasis, warichu, tate-chu-yoko and proportional font features. Carry the layout through glyph paths, SVG export and text selection. Update the Rust and ClojureScript binary formats together, including the editor bridge and headless exporter. Resolve CJK fallback fonts and add renderer tests and font fixtures.
This commit is contained in:
parent
b714a1d872
commit
bed26fdd24
@ -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]"
|
||||
@ -236,6 +239,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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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.japanese-layout :as jl]
|
||||
[app.common.uuid :as uuid]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
@ -302,6 +304,21 @@
|
||||
[text-direction]
|
||||
(untranslate "text-direction" text-direction "ltr"))
|
||||
|
||||
(defn translate-japanese-enum
|
||||
"Wasm discriminant of the value of the Japanese 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 (jl/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 japanese layout value" :attr attr :value value)
|
||||
(unchecked-get values default)))
|
||||
0)))
|
||||
|
||||
(defn translate-font-style
|
||||
[font-style]
|
||||
|
||||
@ -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))
|
||||
""
|
||||
(get span :ruby "")))
|
||||
|
||||
(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-japanese-enum :writing-mode (get paragraph :writing-mode))
|
||||
text-orientation (sr/translate-japanese-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-japanese-enum
|
||||
:text-orientation
|
||||
(get span :text-orientation (get paragraph :text-orientation)))
|
||||
text-combine-upright (sr/translate-japanese-enum :text-combine-upright (get span :text-combine-upright))
|
||||
text-emphasis (sr/translate-japanese-enum :text-emphasis (get span :text-emphasis))
|
||||
warichu (sr/translate-japanese-enum :warichu (get span :warichu))
|
||||
font-features (sr/translate-japanese-enum :font-features (get span :font-features))
|
||||
annotation-clearance (sr/translate-japanese-enum :annotation-clearance (get span :annotation-clearance))
|
||||
ruby-size (sr/translate-japanese-enum :ruby-size (get span :ruby-size))
|
||||
ruby-align (sr/translate-japanese-enum :ruby-align (get span :ruby-align))
|
||||
ruby-overhang (sr/translate-japanese-enum :ruby-overhang (get span :ruby-overhang))
|
||||
ruby-side (sr/translate-japanese-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")))
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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-japanese-enum :line-adjustment (get content :line-adjustment)))
|
||||
(let [paragraph-set (first (get content :children))
|
||||
paragraphs (get paragraph-set :children)]
|
||||
(doseq [paragraph paragraphs]
|
||||
|
||||
@ -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.japanese-layout :as jl]
|
||||
[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 (`jl/enum-values`)."
|
||||
[line-adjustment]
|
||||
(h/call wasm/internal-module "_set_shape_line_adjustment"
|
||||
(sr/translate-japanese-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 (jl/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 (jl/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 (* (jl/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 (jl/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
|
||||
@ -3008,5 +3140,3 @@
|
||||
viewport mount. Idempotent: the `delay` caches its in-flight promise."
|
||||
[]
|
||||
@module)
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.japanese-layout :as jl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.fonts :as main-fonts]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
@ -253,7 +254,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
|
||||
@ -372,6 +373,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))
|
||||
@ -401,6 +421,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?)
|
||||
@ -689,6 +715,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)))
|
||||
{}
|
||||
jl/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."
|
||||
@ -726,37 +842,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)
|
||||
@ -898,26 +988,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 jl/whole-shape-paragraph-attrs)
|
||||
range-attrs (apply dissoc attrs jl/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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
BIN
render-wasm/src/fonts/notosansjp-vmtx-test.ttf
Normal file
BIN
render-wasm/src/fonts/notosansjp-vmtx-test.ttf
Normal file
Binary file not shown.
BIN
render-wasm/src/fonts/notosansjp-vpal-test.ttf
Normal file
BIN
render-wasm/src/fonts/notosansjp-vpal-test.ttf
Normal file
Binary file not shown.
@ -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));
|
||||
|
||||
@ -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<StrokeKind> =
|
||||
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<StrokeKind> =
|
||||
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
|
||||
}
|
||||
_ => {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// True while an SVG export draws. `SkSVGDevice` rebuilds `<text>` 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);
|
||||
|
||||
@ -3714,3 +3714,75 @@ fn raster_export_of_child_paints_inherited_group_fill() {
|
||||
assert_eq!(inherited_png, raster_png(&own, uid(2)));
|
||||
assert_ne!(inherited_png, raster_png(&bare, uid(2)));
|
||||
}
|
||||
|
||||
#[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("<text"),
|
||||
"vertical glyphs must not go through cmap-rebuilt <text>: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("<path"),
|
||||
"vertical glyphs export as outlines: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.to_ascii_lowercase().contains("fill=\"#003fff\""),
|
||||
"outlines keep the text fill: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!super::writing_svg(),
|
||||
"the export flag resets once the document is written"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_the_stroke_of_vertical_text_as_stroked_outlines() {
|
||||
for kind in [StrokeKind::Center, StrokeKind::Inner, StrokeKind::Outer] {
|
||||
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);
|
||||
}
|
||||
}
|
||||
shape.add_stroke(solid_stroke(kind, 4.0, skia::Color::from_rgb(200, 0, 0)));
|
||||
|
||||
let svg = render(&pool, id).to_ascii_lowercase();
|
||||
assert!(
|
||||
svg.contains("stroke=\"#c80000\""),
|
||||
"{kind:?}: the stroke must stay a stroke, not a second fill: {svg}"
|
||||
);
|
||||
assert_eq!(
|
||||
svg.matches("fill=\"#c80000\"").count(),
|
||||
0,
|
||||
"{kind:?}: the stroke colour must not fill the glyphs: {svg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ use skia_safe::Canvas;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::render::text;
|
||||
use crate::shapes::{Fill, ImageFill, Shape, SolidColor, Stroke, StrokeKind, Type};
|
||||
use crate::shapes::{text_vertical, Fill, ImageFill, Shape, SolidColor, Stroke, StrokeKind, Type};
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::document::SvgLayerCanvas;
|
||||
@ -325,6 +325,21 @@ fn paint_text_glyph_silhouette(canvas: &Canvas, shape: &Shape) -> 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)?;
|
||||
@ -339,6 +354,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(
|
||||
|
||||
@ -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<f32> = 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<usize, ParagraphBuilder> =
|
||||
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<bool> {
|
||||
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<TextDecorationSegment> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,79 @@ fn paragraphs_vertical_offset(
|
||||
)
|
||||
}
|
||||
|
||||
fn calculate_cursor_rect(
|
||||
fn vertical_layout_for_shape(
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> (std::rc::Rc<text_vertical::VerticalLayout>, 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<Rect> {
|
||||
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(),
|
||||
))
|
||||
}
|
||||
|
||||
fn calculate_vertical_selection_rects(
|
||||
selection: &TextSelection,
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let start = selection.start();
|
||||
let end = selection.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 +224,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::<f32>();
|
||||
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(
|
||||
@ -228,12 +310,17 @@ fn calculate_selection_rects(
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let start = selection.start();
|
||||
let end = selection.end();
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
|
||||
if text_content.is_vertical() {
|
||||
return calculate_vertical_selection_rects(selection, text_content, shape);
|
||||
}
|
||||
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
|
||||
@ -268,15 +355,15 @@ fn calculate_selection_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(),
|
||||
@ -284,6 +371,24 @@ fn calculate_selection_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;
|
||||
@ -291,3 +396,81 @@ fn calculate_selection_rects(
|
||||
|
||||
rects
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes::{Paragraph, TextAlign, TextDirection, TextSpan};
|
||||
use skia_safe::textlayout::{
|
||||
FontCollection, Paragraph as SkiaParagraph, ParagraphBuilder, ParagraphStyle, TextStyle,
|
||||
TypefaceFontProvider,
|
||||
};
|
||||
use skia_safe::FontMgr;
|
||||
|
||||
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<usize>) -> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
@ -1527,7 +1536,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);
|
||||
}
|
||||
}
|
||||
|
||||
287
render-wasm/src/shapes/gpos_vpal.rs
Normal file
287
render-wasm/src/shapes/gpos_vpal.rs
Normal file
@ -0,0 +1,287 @@
|
||||
//! 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<u16> {
|
||||
Some(u16::from_be_bytes([
|
||||
*data.get(offset)?,
|
||||
*data.get(offset + 1)?,
|
||||
]))
|
||||
}
|
||||
|
||||
fn read_i16(data: &[u8], offset: usize) -> Option<i16> {
|
||||
read_u16(data, offset).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
|
||||
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<Vec<u16>> {
|
||||
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<VpalDelta> {
|
||||
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<u16, VpalDelta>,
|
||||
) -> 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<HashMap<u16, VpalDelta>> {
|
||||
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)?;
|
||||
}
|
||||
9 => {
|
||||
// ExtensionPos: { format: u16, extensionLookupType: u16,
|
||||
// extensionOffset: u32 (from the subtable start) }.
|
||||
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<u8> {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
885
render-wasm/src/shapes/japanese.rs
Normal file
885
render-wasm/src/shapes/japanese.rs
Normal file
@ -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<JapaneseClass> {
|
||||
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<JapaneseClass>]) -> 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'));
|
||||
}
|
||||
}
|
||||
734
render-wasm/src/shapes/kinsoku.rs
Normal file
734
render-wasm/src/shapes/kinsoku.rs
Normal file
@ -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<usize>,
|
||||
}
|
||||
|
||||
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<Vec<usize>>],
|
||||
) -> Option<(Vec<String>, OffsetMap)> {
|
||||
let mut inserted: Vec<usize> = Vec::new();
|
||||
let mut out: Vec<String> = 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<String> {
|
||||
texts.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
fn apply(texts: &[&str]) -> (Vec<String>, 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<u16> = shifted.encode_utf16().collect();
|
||||
let original_units: Vec<u16> = 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<char> {
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
143
render-wasm/src/shapes/text_horizontal/emphasis.rs
Normal file
143
render-wasm/src/shapes/text_horizontal/emphasis.rs
Normal file
@ -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<usize>,
|
||||
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<HorizontalEmphasisPlacement> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
244
render-wasm/src/shapes/text_horizontal/justify.rs
Normal file
244
render-wasm/src/shapes/text_horizontal/justify.rs
Normal file
@ -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<Vec<(usize, f32)>> {
|
||||
let mut spacing: Vec<Vec<(usize, f32)>> = 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<JapaneseClass>,
|
||||
font_size: f32,
|
||||
/// A WORD JOINER before it forbids a break.
|
||||
joined: bool,
|
||||
}
|
||||
|
||||
fn line_chars(paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> Vec<LineChar> {
|
||||
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<usize> {
|
||||
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<usize>) -> 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<Vec<(usize, f32)>>, skia::textlayout::Paragraph) {
|
||||
let chars = line_chars(paragraph, plan);
|
||||
let mut sheds: Vec<Vec<(usize, f32)>> = 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::<f32>();
|
||||
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)
|
||||
}
|
||||
265
render-wasm/src/shapes/text_horizontal/mod.rs
Normal file
265
render-wasm/src/shapes/text_horizontal/mod.rs
Normal file
@ -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<String>, kinsoku::OffsetMap) {
|
||||
let texts: Vec<String> = 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<Option<Vec<usize>>> = 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<Vec<(usize, f32)>> {
|
||||
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<Option<JapaneseClass>> = 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<(usize, f32)>> = 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<String>,
|
||||
pub(crate) offsets: HorizontalOffsets,
|
||||
pub(crate) sheds: Vec<Vec<(usize, f32)>>,
|
||||
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<skia::textlayout::TextStyle> {
|
||||
// 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)
|
||||
}
|
||||
222
render-wasm/src/shapes/text_horizontal/offsets.rs
Normal file
222
render-wasm/src/shapes/text_horizontal/offsets.rs
Normal file
@ -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<HorizontalSpanRange>,
|
||||
/// Transformed-text UTF-16 offset of every source character boundary.
|
||||
boundaries: Vec<usize>,
|
||||
}
|
||||
|
||||
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<std::ops::Range<usize>> {
|
||||
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<HorizontalSpanRange> {
|
||||
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<HorizontalSpanRange> {
|
||||
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<usize> {
|
||||
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
|
||||
}
|
||||
517
render-wasm/src/shapes/text_horizontal/ruby.rs
Normal file
517
render-wasm/src/shapes/text_horizontal/ruby.rs
Normal file
@ -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<Vec<(usize, f32)>>,
|
||||
/// Gap added between the base characters of each ruby span.
|
||||
pub base_gaps: Vec<f32>,
|
||||
/// 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<f32>,
|
||||
/// 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<char> = 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<String> = 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<f32> {
|
||||
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<usize> = 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::<f32>()
|
||||
+ 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<usize> {
|
||||
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<usize>)> {
|
||||
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<RubyGlyphRef> {
|
||||
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<f32>, 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<String> = 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<f32> = 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<f32> {
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
1020
render-wasm/src/shapes/text_horizontal/tests.rs
Normal file
1020
render-wasm/src/shapes/text_horizontal/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
188
render-wasm/src/shapes/text_horizontal/warichu.rs
Normal file
188
render-wasm/src/shapes/text_horizontal/warichu.rs
Normal file
@ -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<usize> {
|
||||
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<skia::Rect> {
|
||||
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<skia::Rect> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
584
render-wasm/src/shapes/text_japanese.rs
Normal file
584
render-wasm/src/shapes/text_japanese.rs
Normal file
@ -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<char> = 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<char>,
|
||||
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<f32>, Vec<(usize, f32)>) {
|
||||
let mut extents: Vec<f32> = 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<f32> {
|
||||
let count = extents.len();
|
||||
if count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let line: f32 = extents.iter().sum();
|
||||
let packed = |start: f32, gap: f32| -> Vec<f32> {
|
||||
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<usize> = 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<usize> {
|
||||
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<char> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,17 @@ impl TextPaths {
|
||||
}
|
||||
|
||||
pub fn get_paths(&self, vertical_align: VerticalAlign) -> Vec<skia::Path> {
|
||||
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<SkiaParagraph> = paragraph_builders
|
||||
@ -48,7 +59,8 @@ impl TextPaths {
|
||||
origin: Point,
|
||||
paths: &mut Vec<skia::Path>,
|
||||
) {
|
||||
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());
|
||||
|
||||
1345
render-wasm/src/shapes/text_vertical/annotations.rs
Normal file
1345
render-wasm/src/shapes/text_vertical/annotations.rs
Normal file
File diff suppressed because it is too large
Load Diff
1340
render-wasm/src/shapes/text_vertical/cells.rs
Normal file
1340
render-wasm/src/shapes/text_vertical/cells.rs
Normal file
File diff suppressed because it is too large
Load Diff
1729
render-wasm/src/shapes/text_vertical/flow.rs
Normal file
1729
render-wasm/src/shapes/text_vertical/flow.rs
Normal file
File diff suppressed because it is too large
Load Diff
226
render-wasm/src/shapes/text_vertical/font_tables.rs
Normal file
226
render-wasm/src/shapes/text_vertical/font_tables.rs
Normal file
@ -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<T> = RefCell<HashMap<u32, Option<Rc<T>>>>;
|
||||
|
||||
thread_local! {
|
||||
// Layout runs on every paint; each face's tables are parsed once.
|
||||
static VERTICAL_METRICS: TableCache<VerticalMetrics> = RefCell::default();
|
||||
static VPAL_TABLES: TableCache<VpalTable> = RefCell::default();
|
||||
static TYPO_METRICS: TableCache<TypoMetrics> = RefCell::default();
|
||||
}
|
||||
|
||||
fn cached_table<T: 'static>(
|
||||
cache: &'static LocalKey<TableCache<T>>,
|
||||
font: &Font,
|
||||
parse: impl FnOnce(&Font) -> Option<T>,
|
||||
) -> Option<Rc<T>> {
|
||||
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<f32> {
|
||||
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<skia_safe::Data> {
|
||||
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<u16>,
|
||||
/// 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<Self> {
|
||||
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<Rc<VerticalMetrics>> {
|
||||
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<u16, VpalDelta>,
|
||||
}
|
||||
|
||||
impl VpalTable {
|
||||
fn from_font(font: &Font) -> Option<Self> {
|
||||
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<Rc<VpalTable>> {
|
||||
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<Self> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
1016
render-wasm/src/shapes/text_vertical/layout.rs
Normal file
1016
render-wasm/src/shapes/text_vertical/layout.rs
Normal file
File diff suppressed because it is too large
Load Diff
55
render-wasm/src/shapes/text_vertical/mod.rs
Normal file
55
render-wasm/src/shapes/text_vertical/mod.rs
Normal file
@ -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,
|
||||
};
|
||||
180
render-wasm/src/shapes/text_vertical/orientation.rs
Normal file
180
render-wasm/src/shapes/text_vertical/orientation.rs
Normal file
@ -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<Segment> {
|
||||
let mut segments: Vec<Segment> = 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());
|
||||
}
|
||||
}
|
||||
1041
render-wasm/src/shapes/text_vertical/paint.rs
Normal file
1041
render-wasm/src/shapes/text_vertical/paint.rs
Normal file
File diff suppressed because it is too large
Load Diff
1116
render-wasm/src/shapes/text_vertical/positions.rs
Normal file
1116
render-wasm/src/shapes/text_vertical/positions.rs
Normal file
File diff suppressed because it is too large
Load Diff
607
render-wasm/src/shapes/text_vertical/shaping.rs
Normal file
607
render-wasm/src/shapes/text_vertical/shaping.rs
Normal file
@ -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<String> {
|
||||
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<GlyphId>,
|
||||
pub positions: Vec<SkPoint>,
|
||||
pub advances: Vec<f32>,
|
||||
pub clusters: Vec<u32>,
|
||||
pub advance: f32,
|
||||
pub utf8_range: Range<usize>,
|
||||
/// 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<Item = (usize, usize)> + '_ {
|
||||
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<Vec<f32>> {
|
||||
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<SkPoint> {
|
||||
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<skia::Rect> {
|
||||
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<skia::Rect> {
|
||||
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<usize> {
|
||||
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<TextBlob> {
|
||||
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<TextBlob> {
|
||||
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<TextBlob> {
|
||||
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<TextBlob> {
|
||||
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<TextBlob> {
|
||||
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<ShapedRun>,
|
||||
scratch_glyphs: Vec<GlyphId>,
|
||||
scratch_positions: Vec<SkPoint>,
|
||||
scratch_clusters: Vec<u32>,
|
||||
}
|
||||
|
||||
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<SkPoint> = 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<f32> = 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<Feature> {
|
||||
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<ShapedRun> {
|
||||
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<Option<skia::Shaper>> = 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<R>(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>, usize)> {
|
||||
let mut chunks: Vec<(Range<usize>, 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<ShapedRun> {
|
||||
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<Typeface> {
|
||||
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<ShapedRun> {
|
||||
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<skia::Rect> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
189
render-wasm/src/shapes/text_vertical/test_support.rs
Normal file
189
render-wasm/src/shapes/text_vertical/test_support.rs
Normal file
@ -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<TextSpan>,
|
||||
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<Paragraph>,
|
||||
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<TextSpan>, 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)
|
||||
}
|
||||
@ -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<TextPositionWithAffinity>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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::<RawTextSpan>();
|
||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||
@ -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<RawTextSpan> 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<RawTextSpan> 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<RawParagraph> for shapes::Paragraph {
|
||||
fn from(value: RawParagraph) -> Self {
|
||||
let mut spans = vec![];
|
||||
|
||||
// Layout: [<all span texts> <all span ruby texts>]. 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<RawParagraph> 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::<RawFillData>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
286
render-wasm/src/wasm/text/japanese_layout.rs
Normal file
286
render-wasm/src/wasm/text/japanese_layout.rs
Normal file
@ -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<u8> 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<RawWritingMode> 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<RawTextOrientation> 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<RawTextCombineUpright> 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<RawTextEmphasis> 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<RawWarichu> 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<RawFontFeatures> 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<RawAnnotationClearance> 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<RawRubySize> 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<RawRubyAlign> 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<RawRubyOverhang> 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<RawRubySide> 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<RawLineAdjustment> for LineAdjustment {
|
||||
fn from(value: RawLineAdjustment) -> Self {
|
||||
match value {
|
||||
RawLineAdjustment::PushInFirst => LineAdjustment::PushInFirst,
|
||||
RawLineAdjustment::PushOutFirst => LineAdjustment::PushOutFirst,
|
||||
RawLineAdjustment::PushOutOnly => LineAdjustment::PushOutOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -153,7 +153,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);
|
||||
}
|
||||
})
|
||||
@ -207,7 +211,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);
|
||||
}
|
||||
});
|
||||
@ -232,7 +240,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.
|
||||
@ -266,7 +278,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.
|
||||
@ -295,7 +311,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);
|
||||
}
|
||||
@ -330,7 +350,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);
|
||||
}
|
||||
});
|
||||
@ -355,9 +379,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);
|
||||
}
|
||||
});
|
||||
@ -410,11 +438,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();
|
||||
@ -465,8 +498,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();
|
||||
|
||||
@ -481,6 +519,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<()> {
|
||||
@ -661,6 +721,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, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user