mirror of
https://github.com/penpot/penpot.git
synced 2026-10-03 01:06:14 +00:00
refactor
This commit is contained in:
parent
d92b5392e6
commit
11d4cc6459
@ -302,78 +302,58 @@
|
||||
[text-direction]
|
||||
(untranslate "text-direction" text-direction "ltr"))
|
||||
|
||||
(defn- translate-enum
|
||||
"Wasm discriminant of the `id` enum variant named `value`, falling back to
|
||||
the `default` variant. 0 when the enum is not exported."
|
||||
[id value default]
|
||||
(if-let [values (unchecked-get wasm/serializers id)]
|
||||
(d/nilv (unchecked-get values (d/name value))
|
||||
(unchecked-get values default))
|
||||
0))
|
||||
|
||||
(defn translate-writing-mode
|
||||
[writing-mode]
|
||||
(let [values (unchecked-get wasm/serializers "writing-mode")
|
||||
default (unchecked-get values "horizontal-tb")]
|
||||
(d/nilv (unchecked-get values (d/name writing-mode)) default)))
|
||||
[value]
|
||||
(translate-enum "writing-mode" value "horizontal-tb"))
|
||||
|
||||
(defn translate-text-orientation
|
||||
[text-orientation]
|
||||
(let [values (unchecked-get wasm/serializers "text-orientation")
|
||||
default (unchecked-get values "mixed")]
|
||||
(d/nilv (unchecked-get values (d/name text-orientation)) default)))
|
||||
[value]
|
||||
(translate-enum "text-orientation" value "mixed"))
|
||||
|
||||
(defn translate-text-combine-upright
|
||||
[text-combine-upright]
|
||||
(let [values (unchecked-get wasm/serializers "text-combine-upright")
|
||||
default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name text-combine-upright)) default)))
|
||||
[value]
|
||||
(translate-enum "text-combine-upright" value "none"))
|
||||
|
||||
(defn translate-text-emphasis
|
||||
[text-emphasis]
|
||||
(let [values (unchecked-get wasm/serializers "text-emphasis")
|
||||
default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name text-emphasis)) default)))
|
||||
[value]
|
||||
(translate-enum "text-emphasis" value "none"))
|
||||
|
||||
(defn translate-warichu
|
||||
[warichu]
|
||||
(let [values (unchecked-get wasm/serializers "warichu")
|
||||
default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name warichu)) default)))
|
||||
[value]
|
||||
(translate-enum "warichu" value "none"))
|
||||
|
||||
(defn translate-font-features
|
||||
[font-features]
|
||||
(if-let [values (unchecked-get wasm/serializers "font-features")]
|
||||
(let [default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name font-features)) default))
|
||||
0))
|
||||
[value]
|
||||
(translate-enum "font-features" value "none"))
|
||||
|
||||
(defn translate-annotation-clearance
|
||||
[annotation-clearance]
|
||||
(if-let [values (unchecked-get wasm/serializers "annotation-clearance")]
|
||||
(let [default (unchecked-get values "none")]
|
||||
(d/nilv (unchecked-get values (d/name annotation-clearance)) default))
|
||||
0))
|
||||
[value]
|
||||
(translate-enum "annotation-clearance" value "none"))
|
||||
|
||||
(defn translate-ruby-size
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-size")]
|
||||
(let [default (unchecked-get values "half")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
(translate-enum "ruby-size" value "half"))
|
||||
|
||||
(defn translate-ruby-align
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-align")]
|
||||
(let [default (unchecked-get values "space-around")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
(translate-enum "ruby-align" value "space-around"))
|
||||
|
||||
(defn translate-ruby-overhang
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-overhang")]
|
||||
(let [default (unchecked-get values "auto")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
(translate-enum "ruby-overhang" value "auto"))
|
||||
|
||||
(defn translate-ruby-side
|
||||
[value]
|
||||
(if-let [values (unchecked-get wasm/serializers "ruby-side")]
|
||||
(let [default (unchecked-get values "over")]
|
||||
(d/nilv (unchecked-get values (d/name value)) default))
|
||||
0))
|
||||
|
||||
(translate-enum "ruby-side" value "over"))
|
||||
|
||||
(defn translate-font-style
|
||||
[font-style]
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
|
||||
(ns app.common.types.text.japanese-layout
|
||||
(:require
|
||||
[app.common.data.macros :as dm]))
|
||||
[app.common.data.macros :as dm]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
|
||||
;; and "mixed", so plain horizontal text never stores these attrs.
|
||||
@ -50,6 +51,50 @@
|
||||
(def text-annotation-clearance-attrs
|
||||
[:annotation-clearance])
|
||||
|
||||
;; Values of the span attrs above when a span does not store them.
|
||||
(def span-attr-defaults
|
||||
{:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby ""
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"})
|
||||
|
||||
;; Annotation font sizes relative to the base font size.
|
||||
(def emphasis-font-scale 0.5)
|
||||
(def warichu-font-scale 0.5)
|
||||
|
||||
(defn ruby-font-scale
|
||||
[ruby-size]
|
||||
(case ruby-size
|
||||
"third" (/ 1 3)
|
||||
"quarter" 0.25
|
||||
0.5))
|
||||
|
||||
(defn visible-ruby
|
||||
"Ruby annotation text of a text node, or nil when it has none or the
|
||||
annotation is hidden."
|
||||
[node]
|
||||
(let [ruby (:ruby node)]
|
||||
(when (and (string? ruby)
|
||||
(not (str/blank? ruby))
|
||||
(not (true? (:ruby-hidden node))))
|
||||
ruby)))
|
||||
|
||||
(defn warichu-text?
|
||||
"True when a text node renders as warichu: it needs two characters to fill
|
||||
its two sub-lines."
|
||||
[node]
|
||||
(let [text (:text node)]
|
||||
(and (= "warichu" (:warichu node))
|
||||
(string? text)
|
||||
(>= (count text) 2))))
|
||||
|
||||
(defn content-writing-mode
|
||||
"Writing mode of a text content. Stored per paragraph but treated as a
|
||||
whole-shape property: the first paragraph decides the flow."
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.types.color :as cc]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.main.ui.shapes.text.styles :as sts]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[cuerdas.core :as str]
|
||||
@ -21,11 +22,8 @@
|
||||
style (if (= text "")
|
||||
(sts/generate-text-styles shape parent)
|
||||
(sts/generate-text-styles shape node))
|
||||
ruby (:ruby node)
|
||||
ruby? (and (not (true? (:ruby-hidden node)))
|
||||
(string? ruby)
|
||||
(not (str/blank? ruby)))]
|
||||
(if ruby?
|
||||
ruby (jl/visible-ruby node)]
|
||||
(if (some? ruby)
|
||||
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
||||
[:span.text-node {:style style} text]
|
||||
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.text :as legacy.txt]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.main.ui.shapes.text.styles :as sts]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc render-text*
|
||||
@ -21,11 +21,8 @@
|
||||
(sts/generate-text-styles shape parent)
|
||||
(sts/generate-text-styles shape node))
|
||||
class (when is-code (:$id node))
|
||||
ruby (:ruby node)
|
||||
ruby? (and (not (true? (:ruby-hidden node)))
|
||||
(string? ruby)
|
||||
(not (str/blank? ruby)))]
|
||||
(if ruby?
|
||||
ruby (jl/visible-ruby node)]
|
||||
(if (some? ruby)
|
||||
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
||||
[:span.text-node {:style style :class class} text]
|
||||
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.transit :as transit]
|
||||
[app.common.types.color :as cc]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.ui.formats :as fmt]
|
||||
[app.util.color :as uc]
|
||||
@ -87,31 +88,74 @@
|
||||
"digits3" "digits 3"
|
||||
value))
|
||||
|
||||
(defn- set-value?
|
||||
"True for a stored style value other than empty or \"none\"."
|
||||
[value]
|
||||
(and (string? value) (pos? (alength value)) (not= "none" value)))
|
||||
|
||||
(defn font-feature-settings
|
||||
"CSS `font-feature-settings` for a persisted font-features value, or nil."
|
||||
[font-features]
|
||||
(when (set-value? font-features)
|
||||
(str/format "\"%s\"" font-features)))
|
||||
|
||||
(defn- annotation-line-height
|
||||
"Line height that reserves a half-em per ruby/emphasis layer under
|
||||
automatic annotation clearance, or nil."
|
||||
[data]
|
||||
(when (= "auto" (:annotation-clearance data))
|
||||
(let [layers (+ (if (jl/visible-ruby data) 1 0)
|
||||
(if (set-value? (:text-emphasis data)) 1 0))
|
||||
line-height (js/parseFloat (or (:line-height data)
|
||||
(:line-height txt/default-typography)))]
|
||||
(when (and (pos? layers) (not (js/isNaN line-height)))
|
||||
(+ line-height (* layers 0.5))))))
|
||||
|
||||
(defn- add-japanese-text-styles!
|
||||
[style data]
|
||||
(let [text-combine-upright (:text-combine-upright data)
|
||||
text-emphasis (:text-emphasis data)
|
||||
font-features (font-feature-settings (:font-features data))
|
||||
annotation-clearance (:annotation-clearance data)
|
||||
line-height (annotation-line-height data)
|
||||
font-size (:font-size data)]
|
||||
(cond-> style
|
||||
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
|
||||
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
|
||||
|
||||
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
|
||||
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
|
||||
(set-value? text-emphasis)
|
||||
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
|
||||
|
||||
(some? font-features)
|
||||
(obj/set! "fontFeatureSettings" font-features)
|
||||
|
||||
(and (string? annotation-clearance) (pos? (alength annotation-clearance)))
|
||||
(obj/set! "--annotation-clearance" annotation-clearance)
|
||||
|
||||
(some? line-height)
|
||||
(obj/set! "lineHeight" line-height)
|
||||
|
||||
;; Warichu (割注) CSS emulation: an inline-block at half size whose
|
||||
;; inline-size fits half the characters, so the browser wraps it into
|
||||
;; two half-size sub-lines within one inline position in either writing
|
||||
;; mode.
|
||||
(jl/warichu-text? data)
|
||||
(-> (obj/set! "display" "inline-block")
|
||||
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))
|
||||
(str (* (js/parseFloat font-size) jl/warichu-font-scale) "px")
|
||||
"50%"))
|
||||
(obj/set! "lineHeight" "1")
|
||||
(obj/set! "inlineSize"
|
||||
(str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em"))))))
|
||||
|
||||
(defn generate-text-styles
|
||||
([shape data]
|
||||
(generate-text-styles shape data nil))
|
||||
|
||||
([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}]
|
||||
(let [letter-spacing (:letter-spacing data 0)
|
||||
text-combine-upright (:text-combine-upright data)
|
||||
text-emphasis (:text-emphasis data)
|
||||
font-features (:font-features data)
|
||||
annotation-clearance (:annotation-clearance data)
|
||||
annotation-layers (if (= "auto" annotation-clearance)
|
||||
(+ (if (and (not (true? (:ruby-hidden data)))
|
||||
(string? (:ruby data))
|
||||
(seq (:ruby data))) 1 0)
|
||||
(if (and (string? text-emphasis)
|
||||
(not= "none" text-emphasis)) 1 0))
|
||||
0)
|
||||
line-height-num (js/parseFloat (or (:line-height data)
|
||||
(:line-height txt/default-typography)))
|
||||
auto-line-height (when (and (pos? annotation-layers)
|
||||
(not (js/isNaN line-height-num)))
|
||||
(+ line-height-num (* annotation-layers 0.5)))
|
||||
warichu? (and (= "warichu" (:warichu data))
|
||||
(string? (:text data))
|
||||
(>= (count (:text data)) 2))
|
||||
text-decoration (:text-decoration data)
|
||||
text-transform (:text-transform data)
|
||||
|
||||
@ -186,26 +230,6 @@
|
||||
(and (string? letter-spacing) (pos? (alength letter-spacing)))
|
||||
(obj/set! "letterSpacing" (str letter-spacing "px"))
|
||||
|
||||
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
|
||||
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
|
||||
|
||||
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
|
||||
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
|
||||
(and (string? text-emphasis) (pos? (alength text-emphasis))
|
||||
(not= "none" text-emphasis))
|
||||
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
|
||||
|
||||
(and (string? font-features) (pos? (alength font-features))
|
||||
(not= "none" font-features))
|
||||
(obj/set! "fontFeatureSettings" (str/format "\"%s\"" font-features))
|
||||
|
||||
(and (string? annotation-clearance)
|
||||
(pos? (alength annotation-clearance)))
|
||||
(obj/set! "--annotation-clearance" annotation-clearance)
|
||||
|
||||
(some? auto-line-height)
|
||||
(obj/set! "lineHeight" auto-line-height)
|
||||
|
||||
(and (string? font-size) (pos? (alength font-size)))
|
||||
(obj/set! "fontSize" (str font-size "px"))
|
||||
|
||||
@ -217,18 +241,8 @@
|
||||
(= grow-type :auto-width)
|
||||
(obj/set! "whiteSpace" "pre")
|
||||
|
||||
;; Warichu (割注) CSS emulation: an inline-block at half size whose
|
||||
;; inline-size fits half the characters, so the browser wraps it into
|
||||
;; two half-size sub-lines within one inline position in either writing
|
||||
;; mode.
|
||||
warichu?
|
||||
(-> (obj/set! "display" "inline-block")
|
||||
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))
|
||||
(str (/ (js/parseFloat font-size) 2) "px")
|
||||
"50%"))
|
||||
(obj/set! "lineHeight" "1")
|
||||
(obj/set! "inlineSize"
|
||||
(str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em")))))))
|
||||
:always
|
||||
(add-japanese-text-styles! data)))))
|
||||
|
||||
(defn generate-ruby-styles
|
||||
[shape data]
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.config :as cf]
|
||||
[app.main.ui.context :as muc]
|
||||
[app.main.ui.shapes.attrs :as attrs]
|
||||
@ -21,17 +22,6 @@
|
||||
|
||||
(def fill-attrs [:fill-color :fill-color-gradient :fill-opacity])
|
||||
|
||||
(defn- ruby-font-scale
|
||||
[ruby-size]
|
||||
(case ruby-size
|
||||
"third" (/ 1 3)
|
||||
"quarter" 0.25
|
||||
0.5))
|
||||
|
||||
(def ^:private emphasis-font-scale 0.5)
|
||||
|
||||
(def ^:private warichu-font-scale 0.5)
|
||||
|
||||
;; Kinsoku classes for the warichu sub-line split (same characters the
|
||||
;; renderer's kinsoku module suppresses at line boundaries).
|
||||
(def ^:private warichu-forbidden-at-start
|
||||
@ -97,13 +87,168 @@
|
||||
(if (emphasis-character? character) mark " ")))
|
||||
(apply str)))
|
||||
|
||||
(defn- add-font-features!
|
||||
[style font-features]
|
||||
(cond-> style
|
||||
(and (string? font-features)
|
||||
(not= "none" font-features)
|
||||
(pos? (alength font-features)))
|
||||
(obj/set! "fontFeatureSettings" (dm/str "\"" font-features "\""))))
|
||||
(defn- px
|
||||
"CSS pixel size, or nil for a non-positive size."
|
||||
[size]
|
||||
(when (pos? size) (dm/str size "px")))
|
||||
|
||||
(defn- with-font-features
|
||||
[style data]
|
||||
(let [font-features (sts/font-feature-settings (:font-features data))]
|
||||
(cond-> style
|
||||
(some? font-features) (obj/set! "fontFeatureSettings" font-features))))
|
||||
|
||||
(defn- annotation-style
|
||||
"Style of a ruby, warichu or emphasis line drawn at `font-size` along a
|
||||
position-data strip."
|
||||
[data font-size vertical? text-orientation fill]
|
||||
(with-font-features
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (px font-size)
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation text-orientation
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill fill}
|
||||
data))
|
||||
|
||||
(defn- strip-text-props
|
||||
"Props of the `text` element drawing a position-data strip's base text."
|
||||
[shape data index fill browser-props]
|
||||
(let [style (with-font-features
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (:font-size data)
|
||||
:fontWeight (:font-weight data)
|
||||
:textTransform (:text-transform data)
|
||||
:textDecoration (:text-decoration data)
|
||||
:textCombineUpright (sts/css-text-combine-upright
|
||||
(:text-combine-upright data))
|
||||
:letterSpacing (:letter-spacing data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:textSpacingTrim "normal"
|
||||
:whiteSpace "pre"}
|
||||
data)
|
||||
key (dm/str "text-" (:id shape) "-" index)]
|
||||
(if (= "vertical-rl" (:writing-mode data))
|
||||
;; Vertical strip: glyphs run down the column; x is the column's
|
||||
;; central axis and y the strip top (stored y is the strip bottom).
|
||||
#js {:key key
|
||||
:x (+ (:x data) (/ (:width data) 2))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (-> style
|
||||
(obj/set! "writingMode" "vertical-rl")
|
||||
(obj/set! "textOrientation" (or (:text-orientation data) "mixed"))
|
||||
(obj/set! "textAutospace" "normal")
|
||||
(obj/set! "fill" fill))}
|
||||
(cond-> #js {:key key
|
||||
:x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data))
|
||||
:y (:y data)
|
||||
:dominantBaseline "ideographic"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (obj/set! style "fill" fill)}
|
||||
(some? browser-props)
|
||||
(obj/merge! browser-props)))))
|
||||
|
||||
(defn- warichu-lines
|
||||
"Warichu: two half-size sub-lines within one inline strip. Vertical
|
||||
reading order is right then left; horizontal is top then bottom."
|
||||
[shape data index fill]
|
||||
(let [vertical? (= "vertical-rl" (:writing-mode data))
|
||||
font-size (* (js/parseFloat (:font-size data)) jl/warichu-font-scale)
|
||||
font-size (if (js/isNaN font-size) 0 font-size)
|
||||
style (annotation-style data font-size vertical? (when vertical? "upright") fill)
|
||||
text (:text data)
|
||||
split-index (warichu-split-index text)
|
||||
lines [(subs text 0 split-index) (subs text split-index)]
|
||||
top (- (:y data) (:height data))
|
||||
centre (+ (:x data) (/ (:width data) 2))
|
||||
quarter (/ font-size 2)]
|
||||
(mf/html
|
||||
[:g {:key (dm/str "warichu-" (:id shape) "-" index)}
|
||||
(if vertical?
|
||||
[:*
|
||||
[:> :text {:x (+ centre quarter) :y top :style style} (first lines)]
|
||||
[:> :text {:x (- centre quarter) :y top :style style} (second lines)]]
|
||||
[:*
|
||||
(for [[line-index line] (d/enumerate lines)]
|
||||
[:> :text {:key line-index
|
||||
:x (:x data)
|
||||
:y (+ top (* line-index (/ (:height data) 2)))
|
||||
:dominantBaseline "hanging"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style style}
|
||||
line])])])))
|
||||
|
||||
(defn- ruby-text-props
|
||||
"Props of the `text` element drawing a strip's ruby annotation beside it
|
||||
(vertical) or above/below it (horizontal), per its side and alignment."
|
||||
[shape data index ruby-font-size style]
|
||||
(let [key (dm/str "ruby-" (:id shape) "-" index)
|
||||
ruby-side (:ruby-side data "over")]
|
||||
(if (= "vertical-rl" (:writing-mode data))
|
||||
#js {:key key
|
||||
:x (if (= "under" ruby-side)
|
||||
(- (:x data) (/ ruby-font-size 2))
|
||||
(+ (:x data) (:width data) (/ ruby-font-size 2)))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style style}
|
||||
(let [rtl? (= "rtl" (:direction data))
|
||||
ruby-align (:ruby-align data "space-around")
|
||||
fit? (or (= "none" (:ruby-overhang data "auto"))
|
||||
(= "space-around" ruby-align))]
|
||||
(cond-> #js {:key key
|
||||
:x (if (= "center" ruby-align)
|
||||
(+ (:x data) (/ (:width data) 2))
|
||||
(if rtl? (+ (:x data) (:width data)) (:x data)))
|
||||
:y (if (= "under" ruby-side)
|
||||
(:y data)
|
||||
(- (:y data) (:height data)))
|
||||
:dominantBaseline (if (= "under" ruby-side) "hanging" "text-after-edge")
|
||||
:style style}
|
||||
(= "center" ruby-align)
|
||||
(obj/set! "textAnchor" "middle")
|
||||
|
||||
(= "start" ruby-align)
|
||||
(obj/set! "textAnchor" (if rtl? "end" "start"))
|
||||
|
||||
fit?
|
||||
(-> (obj/set! "textLength" (:width data))
|
||||
(obj/set! "lengthAdjust" "spacingAndGlyphs"))
|
||||
|
||||
(= "space-between" ruby-align)
|
||||
(-> (obj/set! "textLength" (:width data))
|
||||
(obj/set! "lengthAdjust" "spacing")))))))
|
||||
|
||||
(defn- emphasis-text-props
|
||||
"Props of the `text` element drawing a strip's emphasis marks: right of
|
||||
vertical text or above horizontal text, outside a stacked ruby layer."
|
||||
[shape data index font-size ruby-offset style]
|
||||
(let [key (dm/str "emphasis-" (:id shape) "-" index)]
|
||||
(if (= "vertical-rl" (:writing-mode data))
|
||||
#js {:key key
|
||||
:x (+ (:x data) (:width data) (/ font-size 2) ruby-offset)
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacing"
|
||||
:style style}
|
||||
#js {:key key
|
||||
:x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data))
|
||||
:y (- (:y data) (:height data) ruby-offset)
|
||||
:dominantBaseline "text-after-edge"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacing"
|
||||
:style style})))
|
||||
|
||||
(defn set-white-fill
|
||||
[shape]
|
||||
@ -156,125 +301,24 @@
|
||||
|
||||
[:> :g group-props
|
||||
(for [[index data] (d/enumerate position-data)]
|
||||
(let [rtl? (= "rtl" (:direction data))
|
||||
vertical? (= "vertical-rl" (:writing-mode data))
|
||||
ruby (:ruby data)
|
||||
ruby? (and (string? ruby) (seq ruby))
|
||||
ruby-side (:ruby-side data "over")
|
||||
ruby-align (:ruby-align data "space-around")
|
||||
ruby-overhang (:ruby-overhang data "auto")
|
||||
auto-clearance? (= "auto" (:annotation-clearance data))
|
||||
has-ruby-layer? (and (not= "under" ruby-side)
|
||||
(or ruby? (:annotation-has-ruby data)))
|
||||
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
|
||||
emphasis? (some? emphasis-mark)
|
||||
warichu? (and (= "warichu" (:warichu data))
|
||||
(string? (:text data))
|
||||
(>= (count (:text data)) 2))
|
||||
font-features (:font-features data)
|
||||
(let [vertical? (= "vertical-rl" (:writing-mode data))
|
||||
ruby (jl/visible-ruby data)
|
||||
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
|
||||
font-size (js/parseFloat (:font-size data))
|
||||
scaled-size (fn [scale] (if (js/isNaN font-size) 0 (* font-size scale)))
|
||||
ruby-font-size (scaled-size (jl/ruby-font-scale (:ruby-size data)))
|
||||
;; Annotations take the fills of this strip's own render id.
|
||||
annotation-fill (str "url(#fill-" index "-" render-id "-" index ")")
|
||||
|
||||
browser-props
|
||||
(cond
|
||||
(and (not vertical?) (cf/check-browser? :safari))
|
||||
(when (and (not vertical?) (cf/check-browser? :safari))
|
||||
#js {:dominantBaseline "hanging"
|
||||
:dy "0.2em"
|
||||
:y (- (:y data) (:height data))})
|
||||
|
||||
base-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (:font-size data)
|
||||
:fontWeight (:font-weight data)
|
||||
:textTransform (:text-transform data)
|
||||
:textDecoration (:text-decoration data)
|
||||
:textCombineUpright (sts/css-text-combine-upright
|
||||
(:text-combine-upright data))
|
||||
:letterSpacing (:letter-spacing data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:textSpacingTrim "normal"
|
||||
:whiteSpace "pre"}
|
||||
font-features)
|
||||
|
||||
font-size (js/parseFloat (:font-size data))
|
||||
ruby-font-size-num (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size (ruby-font-scale (:ruby-size data))))
|
||||
ruby-font-size (when (pos? ruby-font-size-num)
|
||||
(dm/str ruby-font-size-num "px"))
|
||||
ruby-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize ruby-font-size
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation "upright"
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
|
||||
font-features)
|
||||
|
||||
emphasis-font-size-num (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size emphasis-font-scale))
|
||||
|
||||
warichu-font-size-num (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size warichu-font-scale))
|
||||
warichu-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (when (pos? warichu-font-size-num)
|
||||
(dm/str warichu-font-size-num "px"))
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation (when vertical? "upright")
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
|
||||
font-features)
|
||||
emphasis-style (add-font-features!
|
||||
#js {:fontFamily (:font-family data)
|
||||
:fontSize (when (pos? emphasis-font-size-num)
|
||||
(dm/str emphasis-font-size-num "px"))
|
||||
:fontWeight (:font-weight data)
|
||||
:fontStyle (:font-style data)
|
||||
:direction (:direction data)
|
||||
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
|
||||
:textSpacingTrim "normal"
|
||||
:textOrientation (when vertical? "upright")
|
||||
:textAutospace "normal"
|
||||
:whiteSpace "pre"
|
||||
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
|
||||
font-features)
|
||||
|
||||
props (if vertical?
|
||||
;; Vertical strip: glyphs run down the column; x is
|
||||
;; the column's central axis and y the strip top
|
||||
;; (stored y is the strip bottom).
|
||||
#js {:key (dm/str "text-" (:id shape) "-" index)
|
||||
:x (+ (:x data) (/ (:width data) 2))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (-> base-style
|
||||
(obj/set! "writingMode" "vertical-rl")
|
||||
(obj/set! "textSpacingTrim" "normal")
|
||||
(obj/set! "textOrientation" (or (:text-orientation data) "mixed"))
|
||||
(obj/set! "textAutospace" "normal")
|
||||
(obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
|
||||
(-> #js {:key (dm/str "text-" (:id shape) "-" index)
|
||||
:x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
:y (:y data)
|
||||
:dominantBaseline "ideographic"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style (obj/set! base-style "fill" (str "url(#fill-" index "-" render-id ")"))}
|
||||
(cond-> browser-props
|
||||
(obj/merge! browser-props))))
|
||||
text-props (strip-text-props shape data index
|
||||
(str "url(#fill-" index "-" render-id ")")
|
||||
browser-props)
|
||||
shape (-> shape
|
||||
(assoc :fills (:fills data))
|
||||
;; The text elements have the shadow and blur already applied in the
|
||||
@ -290,106 +334,25 @@
|
||||
[:& fills/fills {:shape shape :render-id render-id}]]
|
||||
|
||||
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
|
||||
(if warichu?
|
||||
;; Warichu: two half-size sub-lines within one inline strip.
|
||||
;; Vertical reading order is right then left; horizontal is
|
||||
;; top then bottom.
|
||||
(let [text (:text data)
|
||||
split-index (warichu-split-index text)
|
||||
centre (+ (:x data) (/ (:width data) 2))
|
||||
quarter (/ warichu-font-size-num 2)
|
||||
top (- (:y data) (:height data))]
|
||||
[:g {:key (dm/str "warichu-" (:id shape) "-" index)}
|
||||
(if vertical?
|
||||
[:*
|
||||
[:> :text {:x (+ centre quarter)
|
||||
:y top
|
||||
:style warichu-style}
|
||||
(subs text 0 split-index)]
|
||||
[:> :text {:x (- centre quarter)
|
||||
:y top
|
||||
:style warichu-style}
|
||||
(subs text split-index)]]
|
||||
[:*
|
||||
[:> :text {:x (:x data)
|
||||
:y top
|
||||
:dominantBaseline "hanging"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style warichu-style}
|
||||
(subs text 0 split-index)]
|
||||
[:> :text {:x (:x data)
|
||||
:y (+ top (/ (:height data) 2))
|
||||
:dominantBaseline "hanging"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style warichu-style}
|
||||
(subs text split-index)]])])
|
||||
[:> :text props (:text data)])]
|
||||
(when ruby?
|
||||
[:> :text (if vertical?
|
||||
#js {:key (dm/str "ruby-" (:id shape) "-" index)
|
||||
:x (if (= "under" ruby-side)
|
||||
(- (:x data) (/ ruby-font-size-num 2))
|
||||
(+ (:x data) (:width data) (/ ruby-font-size-num 2)))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style ruby-style}
|
||||
(let [start-x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
center-x (+ (:x data) (/ (:width data) 2))
|
||||
constrain? (= "none" ruby-overhang)]
|
||||
(cond-> #js {:key (dm/str "ruby-" (:id shape) "-" index)
|
||||
:x (if (= "center" ruby-align) center-x start-x)
|
||||
:y (if (= "under" ruby-side)
|
||||
(:y data)
|
||||
(- (:y data) (:height data)))
|
||||
:dominantBaseline (if (= "under" ruby-side)
|
||||
"hanging"
|
||||
"text-after-edge")
|
||||
:style ruby-style}
|
||||
(= "center" ruby-align)
|
||||
(obj/set! "textAnchor" "middle")
|
||||
|
||||
(= "start" ruby-align)
|
||||
(obj/set! "textAnchor" (if rtl? "end" "start"))
|
||||
|
||||
(or constrain? (= "space-around" ruby-align))
|
||||
(obj/set! "textLength" (:width data))
|
||||
|
||||
(or constrain? (= "space-around" ruby-align))
|
||||
(obj/set! "lengthAdjust" "spacingAndGlyphs")
|
||||
|
||||
(= "space-between" ruby-align)
|
||||
(obj/set! "textLength" (:width data))
|
||||
|
||||
(= "space-between" ruby-align)
|
||||
(obj/set! "lengthAdjust" "spacing"))))
|
||||
(if (jl/warichu-text? data)
|
||||
(warichu-lines shape data index annotation-fill)
|
||||
[:> :text text-props (:text data)])]
|
||||
(when (some? ruby)
|
||||
[:> :text (ruby-text-props shape data index ruby-font-size
|
||||
(annotation-style data ruby-font-size vertical?
|
||||
"upright" annotation-fill))
|
||||
ruby])
|
||||
;; Emphasis marks (圏点): one half-size mark per eligible base
|
||||
;; character, right of vertical text or above horizontal text.
|
||||
(when emphasis?
|
||||
[:> :text (if vertical?
|
||||
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
|
||||
:x (+ (:x data)
|
||||
(:width data)
|
||||
(/ emphasis-font-size-num 2)
|
||||
(if (and auto-clearance? has-ruby-layer?)
|
||||
ruby-font-size-num
|
||||
0))
|
||||
:y (- (:y data) (:height data))
|
||||
:textLength (:height data)
|
||||
:lengthAdjust "spacing"
|
||||
:style emphasis-style}
|
||||
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
|
||||
:x (if rtl? (+ (:x data) (:width data)) (:x data))
|
||||
:y (- (:y data)
|
||||
(:height data)
|
||||
(if (and auto-clearance? has-ruby-layer?)
|
||||
ruby-font-size-num
|
||||
0))
|
||||
:dominantBaseline "text-after-edge"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacing"
|
||||
:style emphasis-style})
|
||||
(emphasis-marks-text (:text data) emphasis-mark)])]))]]))
|
||||
;; character.
|
||||
(when (some? emphasis-mark)
|
||||
(let [ruby-layer? (and (= "auto" (:annotation-clearance data))
|
||||
(not= "under" (:ruby-side data "over"))
|
||||
(or (some? ruby) (:annotation-has-ruby data)))
|
||||
mark-size (scaled-size jl/emphasis-font-scale)
|
||||
style (annotation-style data mark-size vertical?
|
||||
(when vertical? "upright")
|
||||
annotation-fill)]
|
||||
[:> :text (emphasis-text-props shape data index mark-size
|
||||
(if ruby-layer? ruby-font-size 0)
|
||||
style)
|
||||
(emphasis-marks-text (:text data) emphasis-mark)]))]))]]))
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.workspace.sidebar.options.common
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.store :as st]
|
||||
[app.util.dom :as dom]
|
||||
@ -26,6 +27,20 @@
|
||||
:ref ref}
|
||||
children])))
|
||||
|
||||
(defn radio-selected
|
||||
"Value to select in a radio group for an attribute value: its name, or
|
||||
`default` when unset. Mixed (`:multiple`) values select nothing."
|
||||
([value]
|
||||
(radio-selected value ""))
|
||||
([value default]
|
||||
(cond
|
||||
(= value :multiple) ""
|
||||
(or (nil? value)
|
||||
(and (string? value) (empty? value))) default
|
||||
(keyword? value) (d/name value)
|
||||
(string? value) value
|
||||
:else (str value))))
|
||||
|
||||
(defn emit-value-or-token [value emit-value-fn ids attrs]
|
||||
(if (or (string? value)
|
||||
(number? value)
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
[app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.workspace.sidebar.options.common :refer [radio-selected]]
|
||||
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
|
||||
[app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]]
|
||||
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
|
||||
@ -53,18 +54,6 @@
|
||||
Evaluated once at module load time; cf/flags is immutable after startup."
|
||||
(contains? cf/flags :token-typography-row))
|
||||
|
||||
(defn- radio-selected
|
||||
([value]
|
||||
(radio-selected value ""))
|
||||
([value default]
|
||||
(cond
|
||||
(= value :multiple) ""
|
||||
(or (nil? value)
|
||||
(and (string? value) (empty? value))) default
|
||||
(keyword? value) (d/name value)
|
||||
(string? value) value
|
||||
:else (str value))))
|
||||
|
||||
(mf/defc text-align-options*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [options
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
@ -16,24 +15,12 @@
|
||||
[app.main.ui.ds.controls.select :refer [select*]]
|
||||
[app.main.ui.ds.controls.switch :refer [switch*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
|
||||
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options* radio-selected]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.text.writing-mode :as wm]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- radio-selected
|
||||
([value]
|
||||
(radio-selected value ""))
|
||||
([value default]
|
||||
(cond
|
||||
(= value :multiple) ""
|
||||
(or (nil? value)
|
||||
(and (string? value) (empty? value))) default
|
||||
(keyword? value) (d/name value)
|
||||
(string? value) value
|
||||
:else (str value))))
|
||||
|
||||
(def ^:private mixed-span-values
|
||||
#{:mixed :multiple "mixed" "multiple"})
|
||||
|
||||
@ -47,13 +34,13 @@
|
||||
"mixed"
|
||||
(radio-selected value)))
|
||||
|
||||
(defn- span-select-value
|
||||
(defn span-select-value
|
||||
[value default]
|
||||
(if (mixed-span-value? value)
|
||||
"mixed"
|
||||
(radio-selected value default)))
|
||||
|
||||
(defn- with-mixed-span-option
|
||||
(defn with-mixed-span-option
|
||||
[options value]
|
||||
(cond-> options
|
||||
(mixed-span-value? value)
|
||||
@ -102,61 +89,57 @@
|
||||
;; Sub-components
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(mf/defc writing-mode-options*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb")
|
||||
options
|
||||
(mf/with-memo []
|
||||
[{:value "horizontal-tb"
|
||||
:id "horizontal-tb-writing-mode"
|
||||
:label (tr "workspace.options.text-options.writing-mode-horizontal")
|
||||
:icon i/writing-mode-horizontal}
|
||||
{:value "vertical-rl"
|
||||
:id "vertical-rl-writing-mode"
|
||||
:label (tr "workspace.options.text-options.writing-mode-vertical")
|
||||
:icon i/writing-mode-vertical}])
|
||||
|
||||
(mf/defc attr-radio-options*
|
||||
"Radio group bound to one text attribute."
|
||||
[{:keys [values on-change on-blur attr default-value name class options]}]
|
||||
(let [selected (radio-selected (get values attr) default-value)
|
||||
handle-change
|
||||
(mf/use-fn
|
||||
(mf/deps on-change on-blur)
|
||||
(mf/deps attr on-change on-blur)
|
||||
(fn [value]
|
||||
(on-change {:writing-mode value})
|
||||
(on-change {attr value})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
|
||||
[:div {:class (stl/css :writing-mode-options)}
|
||||
[:> radio-buttons* {:selected writing-mode
|
||||
[:div {:class class}
|
||||
[:> radio-buttons* {:selected selected
|
||||
:on-change handle-change
|
||||
:name "writing-mode-options"
|
||||
:name name
|
||||
:options options}]]))
|
||||
|
||||
(mf/defc text-orientation-options*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
;; The v2 editor can read the paragraph orientation back as an empty
|
||||
;; string when it is unset; treat that (and nil) as the "mixed" default.
|
||||
(let [text-orientation (radio-selected (:text-orientation values) "mixed")
|
||||
options
|
||||
(mf/with-memo []
|
||||
[{:value "mixed"
|
||||
:id "mixed-text-orientation"
|
||||
:label (tr "workspace.options.text-options.text-orientation-mixed")
|
||||
:icon i/text-orientation-mixed}
|
||||
{:value "upright"
|
||||
:id "upright-text-orientation"
|
||||
:label (tr "workspace.options.text-options.text-orientation-upright")
|
||||
:icon i/text-orientation-upright}])
|
||||
(defn- writing-mode-options
|
||||
[]
|
||||
[{:value "horizontal-tb"
|
||||
:id "horizontal-tb-writing-mode"
|
||||
:label (tr "workspace.options.text-options.writing-mode-horizontal")
|
||||
:icon i/writing-mode-horizontal}
|
||||
{:value "vertical-rl"
|
||||
:id "vertical-rl-writing-mode"
|
||||
:label (tr "workspace.options.text-options.writing-mode-vertical")
|
||||
:icon i/writing-mode-vertical}])
|
||||
|
||||
handle-change
|
||||
(mf/use-fn
|
||||
(mf/deps on-change on-blur)
|
||||
(fn [value]
|
||||
(on-change {:text-orientation value})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
(defn- text-orientation-options
|
||||
[]
|
||||
[{:value "mixed"
|
||||
:id "mixed-text-orientation"
|
||||
:label (tr "workspace.options.text-options.text-orientation-mixed")
|
||||
:icon i/text-orientation-mixed}
|
||||
{:value "upright"
|
||||
:id "upright-text-orientation"
|
||||
:label (tr "workspace.options.text-options.text-orientation-upright")
|
||||
:icon i/text-orientation-upright}])
|
||||
|
||||
[:div {:class (stl/css :text-orientation-options)}
|
||||
[:> radio-buttons* {:selected text-orientation
|
||||
:on-change handle-change
|
||||
:name "text-orientation-options"
|
||||
:options options}]]))
|
||||
;; Warichu (割注): a span-scoped toggle that renders the selection as two
|
||||
;; half-size lines within one inline position (top/bottom in horizontal
|
||||
;; flow, right/left in vertical flow).
|
||||
(defn- warichu-options
|
||||
[]
|
||||
[{:value "none"
|
||||
:id "none-warichu"
|
||||
:label (tr "workspace.options.text-options.warichu-none")
|
||||
:icon i/warichu-none}
|
||||
{:value "warichu"
|
||||
:id "warichu-warichu"
|
||||
:label (tr "workspace.options.text-options.warichu")
|
||||
:icon i/warichu}])
|
||||
|
||||
(mf/defc text-combine-upright-options*
|
||||
;; Digit TCY can be applied across a shape because it discovers eligible
|
||||
@ -268,7 +251,8 @@
|
||||
:on-click on-toggle-advanced
|
||||
:icon i/menu}]]))
|
||||
|
||||
(mf/defc ruby-select-option*
|
||||
(mf/defc span-select-option*
|
||||
"Select bound to one span attribute, with a disabled entry for mixed values."
|
||||
[{:keys [values on-change on-blur attr default-value label options]}]
|
||||
(let [value (get values attr)
|
||||
selected (span-select-value value default-value)
|
||||
@ -329,22 +313,22 @@
|
||||
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
|
||||
[:div {:class (stl/css :japanese-layout-controls)}
|
||||
[:> ruby-hidden-option* common-props]
|
||||
[:> ruby-select-option* (mf/spread-props common-props
|
||||
[:> span-select-option* (mf/spread-props common-props
|
||||
{:attr :ruby-size
|
||||
:default-value "half"
|
||||
:label (tr "workspace.options.text-options.ruby-size")
|
||||
:options size-options})]
|
||||
[:> ruby-select-option* (mf/spread-props common-props
|
||||
[:> span-select-option* (mf/spread-props common-props
|
||||
{:attr :ruby-align
|
||||
:default-value "space-around"
|
||||
:label (tr "workspace.options.text-options.ruby-align")
|
||||
:options align-options})]
|
||||
[:> ruby-select-option* (mf/spread-props common-props
|
||||
[:> span-select-option* (mf/spread-props common-props
|
||||
{:attr :ruby-overhang
|
||||
:default-value "auto"
|
||||
:label (tr "workspace.options.text-options.ruby-overhang")
|
||||
:options overhang-options})]
|
||||
[:> ruby-select-option* (mf/spread-props common-props
|
||||
[:> span-select-option* (mf/spread-props common-props
|
||||
{:attr :ruby-side
|
||||
:default-value "over"
|
||||
:label (tr "workspace.options.text-options.ruby-side")
|
||||
@ -381,37 +365,6 @@
|
||||
:is-visible open?}
|
||||
[:> ruby-customization-options* common-props]]]))
|
||||
|
||||
(mf/defc warichu-options*
|
||||
;; Warichu (割注): a span-scoped toggle that renders the selection as two
|
||||
;; half-size lines within one inline position (top/bottom in horizontal
|
||||
;; flow, right/left in vertical flow). Applies to the current selection
|
||||
;; through the shared node-attr on-change; "none" is the default.
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [warichu (radio-selected (:warichu values) "none")
|
||||
options
|
||||
(mf/with-memo []
|
||||
[{:value "none"
|
||||
:id "none-warichu"
|
||||
:label (tr "workspace.options.text-options.warichu-none")
|
||||
:icon i/warichu-none}
|
||||
{:value "warichu"
|
||||
:id "warichu-warichu"
|
||||
:label (tr "workspace.options.text-options.warichu")
|
||||
:icon i/warichu}])
|
||||
|
||||
handle-change
|
||||
(mf/use-fn
|
||||
(mf/deps on-change on-blur)
|
||||
(fn [value]
|
||||
(on-change {:warichu value})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
|
||||
[:div {:class (stl/css :warichu-options)}
|
||||
[:> radio-buttons* {:selected warichu
|
||||
:on-change handle-change
|
||||
:name "warichu-options"
|
||||
:options options}]]))
|
||||
|
||||
(mf/defc font-features-options*
|
||||
;; Japanese proportional metric alternates. `palt` is typically used for
|
||||
;; horizontal composition and `vpal` for vertical composition; the value is
|
||||
@ -432,7 +385,7 @@
|
||||
(on-change {:font-features (if checked? feature "none")})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
|
||||
[:div {:class (stl/css :font-features-options :japanese-select-option)}
|
||||
[:div {:class (stl/css :japanese-select-option)}
|
||||
[:span {:class (stl/css :japanese-option-label)}
|
||||
(tr "workspace.options.text-options.font-features")]
|
||||
[:> switch* {:default-checked enabled?
|
||||
@ -458,33 +411,6 @@
|
||||
{:id "open-sesame"
|
||||
:label (translate "workspace.options.text-options.text-emphasis-open-sesame")}]))
|
||||
|
||||
(mf/defc text-emphasis-options*
|
||||
;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base
|
||||
;; character in vertical writing. Applies to the current selection through the
|
||||
;; shared node-attr on-change; "none" is the default.
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [value (:text-emphasis values)
|
||||
text-emphasis (span-select-value value "none")
|
||||
options
|
||||
(mf/with-memo []
|
||||
(text-emphasis-options))
|
||||
options (with-mixed-span-option options value)
|
||||
|
||||
handle-change
|
||||
(mf/use-fn
|
||||
(mf/deps on-change on-blur)
|
||||
(fn [value]
|
||||
(on-change {:text-emphasis value})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
|
||||
[:div {:class (stl/css :text-emphasis-options :japanese-select-option)}
|
||||
[:span {:class (stl/css :japanese-option-label)}
|
||||
(tr "workspace.options.text-options.text-emphasis")]
|
||||
[:> select* {:default-selected text-emphasis
|
||||
:aria-label (tr "workspace.options.text-options.text-emphasis")
|
||||
:options options
|
||||
:on-change handle-change}]]))
|
||||
|
||||
(defn annotation-clearance-options
|
||||
([]
|
||||
(annotation-clearance-options tr))
|
||||
@ -494,26 +420,6 @@
|
||||
{:id "auto"
|
||||
:label (translate "workspace.options.text-options.annotation-clearance-auto")}]))
|
||||
|
||||
(mf/defc annotation-clearance-options*
|
||||
[{:keys [values on-change on-blur]}]
|
||||
(let [value (:annotation-clearance values)
|
||||
annotation-clearance (span-select-value value "none")
|
||||
options (mf/with-memo [] (annotation-clearance-options))
|
||||
options (with-mixed-span-option options value)
|
||||
handle-change
|
||||
(mf/use-fn
|
||||
(mf/deps on-change on-blur)
|
||||
(fn [value]
|
||||
(on-change {:annotation-clearance value})
|
||||
(when (some? on-blur) (on-blur))))]
|
||||
[:div {:class (stl/css :annotation-clearance-options :japanese-select-option)}
|
||||
[:span {:class (stl/css :japanese-option-label)}
|
||||
(tr "workspace.options.text-options.annotation-clearance")]
|
||||
[:> select* {:default-selected annotation-clearance
|
||||
:aria-label (tr "workspace.options.text-options.annotation-clearance")
|
||||
:options options
|
||||
:on-change handle-change}]]))
|
||||
|
||||
(mf/defc japanese-layout-options*
|
||||
[{:keys [values ruby-values text-selection-active
|
||||
on-change on-ruby-presentation-change on-blur]}]
|
||||
@ -525,26 +431,58 @@
|
||||
ruby-presentation-props (mf/props
|
||||
{:values ruby-values
|
||||
:on-change on-ruby-presentation-change
|
||||
:on-blur on-blur})]
|
||||
:on-blur on-blur})
|
||||
options (mf/with-memo []
|
||||
{:writing-mode (writing-mode-options)
|
||||
:text-orientation (text-orientation-options)
|
||||
:warichu (warichu-options)
|
||||
:text-emphasis (text-emphasis-options)
|
||||
:annotation-clearance (annotation-clearance-options)})]
|
||||
|
||||
[:div {:class (stl/css :japanese-layout-options)}
|
||||
[:div {:class (stl/css :japanese-layout-controls)}
|
||||
[:div {:class (stl/css :japanese-icon-options)}
|
||||
[:> writing-mode-options* common-props]
|
||||
[:> attr-radio-options* (mf/spread-props common-props
|
||||
{:attr :writing-mode
|
||||
:default-value "horizontal-tb"
|
||||
:name "writing-mode-options"
|
||||
:class (stl/css :writing-mode-options)
|
||||
:options (:writing-mode options)})]
|
||||
(when ^boolean vertical?
|
||||
[:*
|
||||
[:> text-orientation-options* common-props]
|
||||
;; The v2 editor can read the paragraph orientation back as an empty
|
||||
;; string when it is unset; that (and nil) selects "mixed".
|
||||
[:> attr-radio-options* (mf/spread-props common-props
|
||||
{:attr :text-orientation
|
||||
:default-value "mixed"
|
||||
:name "text-orientation-options"
|
||||
:class (stl/css :text-orientation-options)
|
||||
:options (:text-orientation options)})]
|
||||
[:> text-combine-upright-options*
|
||||
(mf/spread-props common-props
|
||||
{:text-selection-active text-selection-active})]])
|
||||
(when ^boolean text-selection-active
|
||||
[:> warichu-options* common-props])]
|
||||
[:> attr-radio-options* (mf/spread-props common-props
|
||||
{:attr :warichu
|
||||
:default-value "none"
|
||||
:name "warichu-options"
|
||||
:class (stl/css :warichu-options)
|
||||
:options (:warichu options)})])]
|
||||
(when ^boolean vertical?
|
||||
[:> text-combine-upright-count-options* common-props])
|
||||
[:> font-features-options* common-props]
|
||||
;; Emphasis marks (圏点 / bouten) apply to the selected characters.
|
||||
(when ^boolean text-selection-active
|
||||
[:> text-emphasis-options* common-props])
|
||||
[:> annotation-clearance-options* common-props]
|
||||
[:> span-select-option* (mf/spread-props common-props
|
||||
{:attr :text-emphasis
|
||||
:default-value "none"
|
||||
:label (tr "workspace.options.text-options.text-emphasis")
|
||||
:options (:text-emphasis options)})])
|
||||
[:> span-select-option* (mf/spread-props common-props
|
||||
{:attr :annotation-clearance
|
||||
:default-value "none"
|
||||
:label (tr "workspace.options.text-options.annotation-clearance")
|
||||
:options (:annotation-clearance options)})]
|
||||
(if text-selection-active
|
||||
[:> ruby-advanced-options* common-props]
|
||||
[:> ruby-presentation-options* ruby-presentation-props])]]))
|
||||
|
||||
@ -41,12 +41,6 @@
|
||||
border-radius: $br-8;
|
||||
}
|
||||
|
||||
.text-emphasis-options,
|
||||
.font-features-options,
|
||||
.annotation-clearance-options {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.japanese-layout-options,
|
||||
.japanese-layout-controls {
|
||||
display: flex;
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.text.japanese-layout :as jl]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.data.workspace.wasm-text :as dwwt]
|
||||
@ -102,24 +103,57 @@
|
||||
result))))
|
||||
|
||||
(def ^:private japanese-range-defaults
|
||||
{:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby nil
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"})
|
||||
;; A range without ruby reports null to plugins.
|
||||
(assoc jl/span-attr-defaults :ruby nil))
|
||||
|
||||
(defn- range-japanese-value
|
||||
[range-data attr]
|
||||
"Value of a Japanese span `attr` over a text range proxy's characters."
|
||||
[range-proxy start end attr]
|
||||
(let [default (get japanese-range-defaults attr)]
|
||||
(->> range-data
|
||||
(->> (-> range-proxy u/proxy->shape :content (content-range->text+styles start end))
|
||||
(map #(get % attr default))
|
||||
(u/mixed-value))))
|
||||
|
||||
(defn- enum-value?
|
||||
"Validator accepting the strings fully matched by `re`."
|
||||
[re]
|
||||
(fn [value]
|
||||
(and (string? value) (some? (re-matches re value)))))
|
||||
|
||||
(defn- optional-string?
|
||||
[value]
|
||||
(or (nil? value) (string? value)))
|
||||
|
||||
(defn- attr-setter
|
||||
"Property setter that checks `valid?` and the plugin's write access before
|
||||
emitting `(update-event self attrs)` with `{attr value}`."
|
||||
[plugin-id page-id prop attr valid? update-event]
|
||||
(fn [self value]
|
||||
(cond
|
||||
(not (valid? value))
|
||||
(u/not-valid plugin-id prop value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id prop "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id prop "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (update-event self {attr value})))))
|
||||
|
||||
(defn- range-attr-setter
|
||||
"Setter of a span attribute over the characters [start, end) of shape `id`."
|
||||
[plugin-id page-id id start end prop attr valid?]
|
||||
(attr-setter plugin-id page-id prop attr valid?
|
||||
(fn [_ attrs] (dwt/update-text-range id start end attrs))))
|
||||
|
||||
(defn- shape-attr-setter
|
||||
"Setter of a text attribute over the whole text shape."
|
||||
[plugin-id page-id prop attr valid?]
|
||||
(attr-setter plugin-id page-id prop attr valid?
|
||||
(fn [self attrs] (dwt/update-attrs (obj/get self "$id") attrs))))
|
||||
|
||||
(defn text-range-proxy?
|
||||
[range]
|
||||
(obj/type-of? range "TextRange"))
|
||||
@ -394,223 +428,53 @@
|
||||
|
||||
:fontFeatures
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :font-features)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches font-features-re value)))
|
||||
(u/not-valid plugin-id :fontFeatures value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:font-features value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :font-features))
|
||||
:set (range-attr-setter plugin-id page-id id start end :fontFeatures :font-features (enum-value? font-features-re))}
|
||||
|
||||
:textCombineUpright
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :text-combine-upright)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-combine-upright-re value)))
|
||||
(u/not-valid plugin-id :textCombineUpright value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:text-combine-upright value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :text-combine-upright))
|
||||
:set (range-attr-setter plugin-id page-id id start end :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))}
|
||||
|
||||
:textEmphasis
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :text-emphasis)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-emphasis-re value)))
|
||||
(u/not-valid plugin-id :textEmphasis value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:text-emphasis value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :text-emphasis))
|
||||
:set (range-attr-setter plugin-id page-id id start end :textEmphasis :text-emphasis (enum-value? text-emphasis-re))}
|
||||
|
||||
:warichu
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :warichu)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches warichu-re value)))
|
||||
(u/not-valid plugin-id :warichu value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:warichu value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :warichu))
|
||||
:set (range-attr-setter plugin-id page-id id start end :warichu :warichu (enum-value? warichu-re))}
|
||||
|
||||
:annotationClearance
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :annotation-clearance)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches annotation-clearance-re value)))
|
||||
(u/not-valid plugin-id :annotationClearance value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:annotation-clearance value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :annotation-clearance))
|
||||
:set (range-attr-setter plugin-id page-id id start end :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))}
|
||||
|
||||
:ruby
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(and (some? value) (not (string? value)))
|
||||
(u/not-valid plugin-id :ruby value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :ruby))
|
||||
:set (range-attr-setter plugin-id page-id id start end :ruby :ruby optional-string?)}
|
||||
|
||||
:rubySize
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-size)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-size-re value)))
|
||||
(u/not-valid plugin-id :rubySize value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-size value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :ruby-size))
|
||||
:set (range-attr-setter plugin-id page-id id start end :rubySize :ruby-size (enum-value? ruby-size-re))}
|
||||
|
||||
:rubyAlign
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-align)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-align-re value)))
|
||||
(u/not-valid plugin-id :rubyAlign value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-align value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :ruby-align))
|
||||
:set (range-attr-setter plugin-id page-id id start end :rubyAlign :ruby-align (enum-value? ruby-align-re))}
|
||||
|
||||
:rubyOverhang
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-overhang)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-overhang-re value)))
|
||||
(u/not-valid plugin-id :rubyOverhang value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-overhang value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :ruby-overhang))
|
||||
:set (range-attr-setter plugin-id page-id id start end :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))}
|
||||
|
||||
:rubySide
|
||||
{:this true
|
||||
:get
|
||||
(fn [self]
|
||||
(let [range-data
|
||||
(-> self u/proxy->shape :content (content-range->text+styles start end))]
|
||||
(range-japanese-value range-data :ruby-side)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-side-re value)))
|
||||
(u/not-valid plugin-id :rubySide value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-text-range id start end {:ruby-side value}))))}
|
||||
:get (fn [self] (range-japanese-value self start end :ruby-side))
|
||||
:set (range-attr-setter plugin-id page-id id start end :rubySide :ruby-side (enum-value? ruby-side-re))}
|
||||
|
||||
:direction
|
||||
{:this true
|
||||
@ -1008,207 +872,51 @@
|
||||
|
||||
{:name "writingMode"
|
||||
:get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches writing-mode-re value)))
|
||||
(u/not-valid plugin-id :writingMode value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :writingMode "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :writingMode "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:writing-mode value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :writingMode :writing-mode (enum-value? writing-mode-re))}
|
||||
|
||||
{:name "textOrientation"
|
||||
:get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-orientation-re value)))
|
||||
(u/not-valid plugin-id :textOrientation value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textOrientation "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textOrientation "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:text-orientation value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :textOrientation :text-orientation (enum-value? text-orientation-re))}
|
||||
|
||||
{:name "textCombineUpright"
|
||||
:get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-combine-upright-re value)))
|
||||
(u/not-valid plugin-id :textCombineUpright value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:text-combine-upright value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))}
|
||||
|
||||
{:name "textEmphasis"
|
||||
:get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches text-emphasis-re value)))
|
||||
(u/not-valid plugin-id :textEmphasis value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:text-emphasis value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? text-emphasis-re))}
|
||||
|
||||
{:name "warichu"
|
||||
:get #(-> % u/proxy->shape text-props :warichu format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches warichu-re value)))
|
||||
(u/not-valid plugin-id :warichu value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:warichu value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :warichu :warichu (enum-value? warichu-re))}
|
||||
|
||||
{:name "fontFeatures"
|
||||
:get #(-> % u/proxy->shape text-props :font-features format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches font-features-re value)))
|
||||
(u/not-valid plugin-id :fontFeatures value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:font-features value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :fontFeatures :font-features (enum-value? font-features-re))}
|
||||
|
||||
{:name "annotationClearance"
|
||||
:get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches annotation-clearance-re value)))
|
||||
(u/not-valid plugin-id :annotationClearance value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:annotation-clearance value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))}
|
||||
|
||||
{:name "ruby"
|
||||
:get #(-> % u/proxy->shape text-props :ruby format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(and (some? value) (not (string? value)))
|
||||
(u/not-valid plugin-id :ruby value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :ruby :ruby optional-string?)}
|
||||
|
||||
{:name "rubySize"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-size-re value)))
|
||||
(u/not-valid plugin-id :rubySize value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-size value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :rubySize :ruby-size (enum-value? ruby-size-re))}
|
||||
|
||||
{:name "rubyAlign"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-align-re value)))
|
||||
(u/not-valid plugin-id :rubyAlign value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-align value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :rubyAlign :ruby-align (enum-value? ruby-align-re))}
|
||||
|
||||
{:name "rubyOverhang"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-overhang-re value)))
|
||||
(u/not-valid plugin-id :rubyOverhang value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-overhang value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))}
|
||||
|
||||
{:name "rubySide"
|
||||
:get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed)
|
||||
:set
|
||||
(fn [self value]
|
||||
(let [id (obj/get self "$id")]
|
||||
(cond
|
||||
(or (not (string? value)) (not (re-matches ruby-side-re value)))
|
||||
(u/not-valid plugin-id :rubySide value)
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission")
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active")
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:ruby-side value})))))}
|
||||
:set (shape-attr-setter plugin-id page-id :rubySide :ruby-side (enum-value? ruby-side-re))}
|
||||
|
||||
{:name "textBounds"
|
||||
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})))
|
||||
|
||||
@ -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]
|
||||
@ -2931,60 +2932,90 @@
|
||||
(def POSITION-DATA-U8-SIZE 36)
|
||||
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
|
||||
|
||||
(defn- ruby-font-scale
|
||||
[ruby-size]
|
||||
(case ruby-size
|
||||
"third" (/ 1 3)
|
||||
"quarter" 0.25
|
||||
0.5))
|
||||
|
||||
(defn- horizontal-ruby-slice
|
||||
"Returns the whole-span ruby annotation for a horizontal base strip."
|
||||
[element _start-pos _end-pos]
|
||||
(let [text (:text element)
|
||||
ruby (:ruby element)]
|
||||
(when (and (not (true? (:ruby-hidden element)))
|
||||
(string? text)
|
||||
(seq text)
|
||||
(string? ruby)
|
||||
(seq ruby))
|
||||
ruby)))
|
||||
(def ^:private POSITION-DATA-VERTICAL 2)
|
||||
(def ^:private POSITION-DATA-RUBY 3)
|
||||
|
||||
(defn- ruby-strip-entry
|
||||
"Position-data entry for a ruby annotation strip (direction 3): the
|
||||
offsets index the span's ruby string and the geometry is the exact
|
||||
gutter placement the canvas paints."
|
||||
"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]}]
|
||||
(let [ruby (get element :ruby)]
|
||||
(when (and (not (true? (:ruby-hidden element)))
|
||||
(string? ruby))
|
||||
(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 (* (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})))))))
|
||||
(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)
|
||||
;; Emphasis marks (圏点) and warichu are drawn by the static SVG
|
||||
;; renderer; "none" carries no information.
|
||||
:text-emphasis (let [emphasis (get element :text-emphasis)]
|
||||
(when (and (seq emphasis) (not= "none" emphasis))
|
||||
emphasis))
|
||||
:warichu (when (= "warichu" (get element :warichu)) "warichu")
|
||||
:annotation-clearance (when (= "auto" (get element :annotation-clearance)) "auto")
|
||||
:annotation-has-ruby (when (some? ruby) true)
|
||||
;; Horizontal position data has no separate ruby strip, so the base
|
||||
;; entry carries the annotation for static 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]
|
||||
@ -3013,82 +3044,14 @@
|
||||
|
||||
(into []
|
||||
(keep
|
||||
(fn [{:keys [paragraph span start-pos end-pos direction x y width height] :as entry}]
|
||||
(fn [{:keys [paragraph span direction] :as entry}]
|
||||
(let [paragraph-node (-> content :children
|
||||
(get 0) :children ;; paragraph-set
|
||||
(get paragraph))
|
||||
element (-> paragraph-node :children ;; paragraph
|
||||
(get span))
|
||||
element-text (:text element)]
|
||||
(if (= direction 3)
|
||||
element (-> paragraph-node :children (get span))]
|
||||
(if (= direction POSITION-DATA-RUBY)
|
||||
(ruby-strip-entry element entry)
|
||||
;; Glyph orientation of a vertical strip; stored on the
|
||||
;; span or its paragraph. Empty reads normalize to nil
|
||||
;; (the SVG renderer then defaults to "mixed").
|
||||
(let [text-orientation
|
||||
(when (= direction 2)
|
||||
(let [orientation (or (get element :text-orientation)
|
||||
(get paragraph-node :text-orientation))]
|
||||
(when (seq orientation) orientation)))]
|
||||
|
||||
;; 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)
|
||||
;; Direction 2 marks a vertical-rl column strip;
|
||||
;; the SVG renderer draws it with CSS writing-mode.
|
||||
:writing-mode (when (= direction 2) "vertical-rl")
|
||||
:text-orientation 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)
|
||||
;; Emphasis marks (圏点) are drawn by the static SVG
|
||||
;; renderer; "none" carries no information.
|
||||
:text-emphasis (let [emphasis (get element :text-emphasis)]
|
||||
(when (and (string? emphasis)
|
||||
(seq emphasis)
|
||||
(not= "none" emphasis))
|
||||
emphasis))
|
||||
:annotation-clearance
|
||||
(let [clearance (get element :annotation-clearance)]
|
||||
(when (= "auto" clearance) clearance))
|
||||
:annotation-has-ruby
|
||||
(let [ruby (get element :ruby)]
|
||||
(when (and (not (true? (:ruby-hidden element)))
|
||||
(string? ruby)
|
||||
(seq ruby))
|
||||
true))
|
||||
;; Horizontal position data has no separate ruby
|
||||
;; strip, so carry the annotation on the base entry
|
||||
;; for static SVG export. Vertical ruby has its own
|
||||
;; direction-3 position entry.
|
||||
:ruby (when (not= direction 2)
|
||||
(horizontal-ruby-slice
|
||||
element start-pos end-pos))
|
||||
:ruby-size (get element :ruby-size)
|
||||
:ruby-align (get element :ruby-align)
|
||||
:ruby-overhang (get element :ruby-overhang)
|
||||
:ruby-side (get element :ruby-side)
|
||||
;; Warichu spans render as two half-size sub-columns
|
||||
;; in the static SVG; "none" carries no information.
|
||||
:warichu (let [warichu (get element :warichu)]
|
||||
(when (= "warichu" warichu) warichu))
|
||||
:letter-spacing (dm/str (get element :letter-spacing) "px")
|
||||
:font-style (get element :font-style)
|
||||
:fills (get element :fills)
|
||||
:text text})))))))))
|
||||
(text-strip-entry paragraph-node element entry)))))
|
||||
result))))
|
||||
|
||||
(defn apply-canvas-blur
|
||||
|
||||
@ -697,29 +697,16 @@
|
||||
[para]
|
||||
(apply + (map (fn [span] (count (:text span))) (:children para))))
|
||||
|
||||
(def ^:private japanese-span-style-defaults
|
||||
"Explicit UI defaults for Japanese span attributes. These values must be
|
||||
present in every selection snapshot so moving onto an unstyled span clears
|
||||
the previous span's controls instead of leaving stale values behind."
|
||||
{:text-combine-upright "none"
|
||||
:text-emphasis "none"
|
||||
:ruby ""
|
||||
:ruby-hidden false
|
||||
:ruby-size "half"
|
||||
:ruby-align "space-around"
|
||||
:ruby-overhang "auto"
|
||||
:ruby-side "over"
|
||||
:warichu "none"
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"})
|
||||
|
||||
(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 clears the
|
||||
previous span's controls instead of leaving stale values behind."
|
||||
[span]
|
||||
(reduce-kv
|
||||
(fn [styles attr default]
|
||||
(assoc styles attr (or (get span attr) default)))
|
||||
{}
|
||||
japanese-span-style-defaults))
|
||||
jl/span-attr-defaults))
|
||||
|
||||
(defn- merge-selection-styles
|
||||
[result styles]
|
||||
|
||||
@ -38,223 +38,44 @@
|
||||
[re s]
|
||||
(count (re-seq re s)))
|
||||
|
||||
(defn- setup-vertical-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 40
|
||||
:height 100})
|
||||
(assoc :name "Vertical text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:font-features "vpal"
|
||||
:text "うA"}]))
|
||||
(def ^:private strip-defaults
|
||||
{:fills [{:fill-color "#112233" :fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"})
|
||||
|
||||
(defn- setup-text
|
||||
"A text shape whose single position-data strip is `strip` (over
|
||||
`strip-defaults`), with its objects and selection."
|
||||
[{:keys [width height]} strip]
|
||||
(let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height})
|
||||
(assoc :name "Text"
|
||||
:position-data [(merge strip-defaults strip)]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-text
|
||||
:text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
objects (:objects (cthf/current-page file))]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-text))]}))
|
||||
:shapes [(get objects (cthi/id :text))]}))
|
||||
|
||||
(defn- setup-vertical-ruby-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical ruby text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text "漢字"
|
||||
:ruby "かんじ"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-ruby-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-ruby-text))]}))
|
||||
(defn- vertical-strip
|
||||
[attrs]
|
||||
(merge {:x 20 :y 100 :width 24 :height 80
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"}
|
||||
attrs))
|
||||
|
||||
(defn- setup-vertical-emphasis-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical emphasis text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 80
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text "強調 あ"
|
||||
:text-emphasis "filled-dot"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-emphasis-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-emphasis-text))]}))
|
||||
|
||||
(defn- setup-horizontal-emphasis-text
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal emphasis text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 100
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text "強調、 あ"
|
||||
:text-emphasis "filled-dot"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-emphasis-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-emphasis-text))]}))
|
||||
|
||||
(defn- setup-horizontal-stacked-annotations
|
||||
[]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal stacked annotations"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 100
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233" :fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:text-emphasis "filled-dot"
|
||||
:annotation-clearance "auto"
|
||||
:annotation-has-ruby true}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-stacked-annotations
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-stacked-annotations))]}))
|
||||
(defn- horizontal-strip
|
||||
[attrs]
|
||||
(merge {:x 20 :y 60 :width 100 :height 20
|
||||
:writing-mode "horizontal-tb"}
|
||||
attrs))
|
||||
|
||||
(defn- setup-vertical-warichu-text
|
||||
([] (setup-vertical-warichu-text "割注入り"))
|
||||
([text]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 60
|
||||
:height 100})
|
||||
(assoc :name "Vertical warichu text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 100
|
||||
:width 24
|
||||
:height 40
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "vertical-rl"
|
||||
:text-orientation "upright"
|
||||
:text text
|
||||
:warichu "warichu"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:vertical-warichu-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :vertical-warichu-text))]})))
|
||||
|
||||
(defn- setup-horizontal-warichu-text
|
||||
([] (setup-horizontal-warichu-text "割注入り"))
|
||||
([text]
|
||||
(let [shape (-> (cts/setup-shape {:type :text
|
||||
:x 10
|
||||
:y 20
|
||||
:width 120
|
||||
:height 40})
|
||||
(assoc :name "Horizontal warichu text"
|
||||
:position-data
|
||||
[{:x 20
|
||||
:y 60
|
||||
:width 40
|
||||
:height 20
|
||||
:fills [{:fill-color "#112233"
|
||||
:fill-opacity 1}]
|
||||
:font-family "Noto Sans CJK JP"
|
||||
:font-size "20"
|
||||
:font-weight "400"
|
||||
:writing-mode "horizontal-tb"
|
||||
:text text
|
||||
:warichu "warichu"}]))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:horizontal-warichu-text
|
||||
shape)
|
||||
page (cthf/current-page file)
|
||||
objects (:objects page)]
|
||||
{:objects objects
|
||||
:shapes [(get objects (cthi/id :horizontal-warichu-text))]})))
|
||||
[text]
|
||||
(setup-text {:width 60 :height 100}
|
||||
(vertical-strip {:height 40 :text text :warichu "warichu"})))
|
||||
|
||||
(deftest empty-selection-yields-empty-string
|
||||
(is (= "" (svg/generate-markup {} []))))
|
||||
@ -281,7 +102,8 @@
|
||||
|
||||
(deftest vertical-text-svg-preserves-browser-layout-properties
|
||||
(testing "Static SVG carries the vertical writing properties used by browser exports"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-text)
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 40 :height 100}
|
||||
(vertical-strip {:font-features "vpal" :text "うA"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"writing-mode:vertical-rl" markup))
|
||||
(is (re-find #"text-orientation:upright" markup))
|
||||
@ -291,7 +113,8 @@
|
||||
|
||||
(deftest vertical-emphasis-svg-emits-static-marks
|
||||
(testing "Static SVG draws emphasis marks and skips whitespace characters"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-emphasis-text)
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 60 :height 100}
|
||||
(vertical-strip {:text "強調 あ" :text-emphasis "filled-dot"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調 あ" markup))
|
||||
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
|
||||
@ -301,7 +124,8 @@
|
||||
|
||||
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
|
||||
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-emphasis-text)
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
|
||||
(horizontal-strip {:text "強調、 あ" :text-emphasis "filled-dot"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調、 あ" markup))
|
||||
;; Five Unicode base characters: two marks, punctuation and whitespace
|
||||
@ -317,7 +141,12 @@
|
||||
|
||||
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
|
||||
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-stacked-annotations)
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
|
||||
(horizontal-strip {:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:text-emphasis "filled-dot"
|
||||
:annotation-clearance "auto"
|
||||
:annotation-has-ruby true}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"かんじ" markup))
|
||||
(is (re-find #"••" markup))
|
||||
@ -326,7 +155,7 @@
|
||||
|
||||
(deftest vertical-warichu-svg-emits-two-sub-columns
|
||||
(testing "Static SVG splits a warichu strip into two half-size sub-columns"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text)
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割注入り")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
@ -354,7 +183,8 @@
|
||||
|
||||
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
|
||||
(testing "Static SVG splits horizontal warichu into top and bottom half-size lines"
|
||||
(let [{:keys [objects shapes]} (setup-horizontal-warichu-text)
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
|
||||
(horizontal-strip {:width 40 :text "割注入り" :warichu "warichu"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
@ -365,7 +195,8 @@
|
||||
|
||||
(deftest vertical-ruby-svg-emits-static-annotation
|
||||
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-ruby-text)
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 60 :height 100}
|
||||
(vertical-strip {:text "漢字" :ruby "かんじ"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"漢字" markup))
|
||||
(is (re-find #"かんじ" markup))
|
||||
|
||||
@ -26,10 +26,10 @@
|
||||
;; so negative values were refused. Pin the accept/reject contract of the
|
||||
;; plugin setter here.
|
||||
|
||||
(defn- apply-letter-spacing
|
||||
"Sets `letterSpacing` on a text range proxy and returns the attributes sent
|
||||
to the update event, or nil when the plugin rejected the value."
|
||||
[value]
|
||||
(defn- apply-range-property
|
||||
"Sets `property` on a text range proxy and returns the attributes sent to
|
||||
the update event, or nil when the plugin rejected the value."
|
||||
[property value]
|
||||
(let [captured (atom nil)
|
||||
range (plugins.text/text-range-proxy
|
||||
plugin-id (random-uuid) (random-uuid) (random-uuid) 0 4)]
|
||||
@ -41,19 +41,12 @@
|
||||
(reset! captured attrs)
|
||||
:update-text-range)
|
||||
st/emit! mock/noop]
|
||||
(set! (.-letterSpacing range) value)
|
||||
(unchecked-set range property value)
|
||||
@captured)))
|
||||
|
||||
(def ^:private font-features-re @#'plugins.text/font-features-re)
|
||||
(def ^:private annotation-clearance-re @#'plugins.text/annotation-clearance-re)
|
||||
(def ^:private ruby-size-re @#'plugins.text/ruby-size-re)
|
||||
(def ^:private ruby-align-re @#'plugins.text/ruby-align-re)
|
||||
(def ^:private ruby-overhang-re @#'plugins.text/ruby-overhang-re)
|
||||
(def ^:private ruby-side-re @#'plugins.text/ruby-side-re)
|
||||
|
||||
(defn- valid-font-features? [s] (boolean (re-matches font-features-re s)))
|
||||
(defn- valid-annotation-clearance? [s]
|
||||
(boolean (re-matches annotation-clearance-re s)))
|
||||
(defn- apply-letter-spacing
|
||||
[value]
|
||||
(apply-range-property "letterSpacing" value))
|
||||
|
||||
(t/deftest letter-spacing-accepts-negative-values
|
||||
(t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56")))
|
||||
@ -70,28 +63,21 @@
|
||||
(t/is (nil? (apply-letter-spacing "1-2")))
|
||||
(t/is (nil? (apply-letter-spacing "--1"))))
|
||||
|
||||
(t/deftest font-features-re-accepts-supported-japanese-proportional-features
|
||||
(t/is (valid-font-features? "none"))
|
||||
(t/is (valid-font-features? "palt"))
|
||||
(t/is (valid-font-features? "vpal"))
|
||||
(t/is (not (valid-font-features? "liga")))
|
||||
(t/is (not (valid-font-features? "palt,vpal"))))
|
||||
|
||||
(t/deftest annotation-clearance-re-accepts-supported-policies
|
||||
(t/is (valid-annotation-clearance? "none"))
|
||||
(t/is (valid-annotation-clearance? "auto"))
|
||||
(t/is (not (valid-annotation-clearance? "always"))))
|
||||
|
||||
(t/deftest ruby-customization-validates-supported-values
|
||||
(t/is (every? #(re-matches ruby-size-re %) ["half" "third" "quarter"]))
|
||||
(t/is (not (re-matches ruby-size-re "full")))
|
||||
(t/is (every? #(re-matches ruby-align-re %)
|
||||
["space-around" "center" "start" "space-between"]))
|
||||
(t/is (not (re-matches ruby-align-re "end")))
|
||||
(t/is (every? #(re-matches ruby-overhang-re %) ["auto" "none"]))
|
||||
(t/is (not (re-matches ruby-overhang-re "always")))
|
||||
(t/is (every? #(re-matches ruby-side-re %) ["over" "under"]))
|
||||
(t/is (not (re-matches ruby-side-re "right"))))
|
||||
(t/deftest japanese-range-properties-accept-only-supported-values
|
||||
(doseq [[property attr accepted rejected]
|
||||
[["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]]
|
||||
["annotationClearance" :annotation-clearance ["none" "auto"] ["always"]]
|
||||
["rubySize" :ruby-size ["half" "third" "quarter"] ["full"]]
|
||||
["rubyAlign" :ruby-align ["space-around" "center" "start" "space-between"] ["end"]]
|
||||
["rubyOverhang" :ruby-overhang ["auto" "none"] ["always"]]
|
||||
["rubySide" :ruby-side ["over" "under"] ["right"]]
|
||||
["ruby" :ruby ["かんじ" nil] [42]]]]
|
||||
(doseq [value accepted]
|
||||
(t/is (= {attr value} (apply-range-property property value))
|
||||
(str property " accepts " (pr-str value))))
|
||||
(doseq [value rejected]
|
||||
(t/is (nil? (apply-range-property property value))
|
||||
(str property " rejects " (pr-str value))))))
|
||||
|
||||
(t/deftest text-range-japanese-properties-read-span-values
|
||||
(let [file-id (random-uuid)
|
||||
|
||||
@ -5,12 +5,12 @@
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.render-wasm.texts-test
|
||||
"Unit tests for CJK script classification and the Han-unification
|
||||
resolution policy used to pick Noto fallback fonts."
|
||||
"Japanese text support in the WASM bridge: span serialization, editor
|
||||
selection styles, IME composition caret placement, and the CJK script
|
||||
classification used to pick Noto fallback fonts."
|
||||
(:require
|
||||
[app.common.fonts :as cfnt]
|
||||
[app.common.render-wasm.text-content :as tc]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.main.ui.workspace.shapes.text.v3-editor :as v3-editor]
|
||||
[app.render-wasm.api :as api]
|
||||
[app.render-wasm.api.texts :as texts]
|
||||
@ -148,119 +148,66 @@
|
||||
(t/is (not-any? #(contains? % :text-orientation) paragraphs))
|
||||
(t/is (= [nil "center" nil] (mapv :text-align paragraphs)))))
|
||||
|
||||
(t/deftest write-spans-serializes-font-features-in-reserved-byte
|
||||
(let [sentinel (js-obj)
|
||||
previous (if (.hasOwnProperty wasm/serializers "font-features")
|
||||
(unchecked-get wasm/serializers "font-features")
|
||||
sentinel)]
|
||||
(try
|
||||
(aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2})
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "日本語"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:font-features "vpal"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
(t/is (= 2 (.getUint8 dview 8)))
|
||||
(t/is (= 0 (.getUint8 dview 9)))
|
||||
(t/is (= 0 (.getUint8 dview 14)))
|
||||
(t/is (= 0 (.getUint8 dview 15))))
|
||||
(finally
|
||||
(if (identical? sentinel previous)
|
||||
(js-delete wasm/serializers "font-features")
|
||||
(aset wasm/serializers "font-features" previous))))))
|
||||
;; Byte offsets inside one span's attribute block (see `write-spans` and
|
||||
;; RawTextSpan in render-wasm).
|
||||
(def ^:private span-attr-offset
|
||||
{:text-combine-upright 5
|
||||
:font-features 8
|
||||
:annotation-clearance 9
|
||||
:ruby-size 10
|
||||
:ruby-align 11
|
||||
:ruby-overhang 12
|
||||
:ruby-side 13
|
||||
:padding [14 15]
|
||||
:ruby-length 72})
|
||||
|
||||
(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte
|
||||
(let [sentinel (js-obj)
|
||||
previous (if (.hasOwnProperty wasm/serializers "annotation-clearance")
|
||||
(unchecked-get wasm/serializers "annotation-clearance")
|
||||
sentinel)]
|
||||
(try
|
||||
(aset wasm/serializers "annotation-clearance" #js {"none" 0 "auto" 1})
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "漢字"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:annotation-clearance "auto"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
(t/is (= 1 (.getUint8 dview 9)))
|
||||
(t/is (= 0 (.getUint8 dview 14)))
|
||||
(t/is (= 0 (.getUint8 dview 15))))
|
||||
(finally
|
||||
(if (identical? sentinel previous)
|
||||
(js-delete wasm/serializers "annotation-clearance")
|
||||
(aset wasm/serializers "annotation-clearance" previous))))))
|
||||
(def ^:private span-paragraph
|
||||
{:font-size "16" :font-weight "400" :line-height "1"})
|
||||
|
||||
(t/deftest write-spans-serializes-ruby-customization-bytes
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "漢字"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:ruby-size "quarter"
|
||||
:ruby-align "space-between"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
(t/is (= [2 3 1 1 0 0]
|
||||
(mapv #(.getUint8 dview %) (range 10 16))))))
|
||||
(defn- serialized-span
|
||||
"DataView over the attribute block `write-spans` produces for `span`."
|
||||
[span]
|
||||
(let [dview (js/DataView. (js/ArrayBuffer. 256))]
|
||||
(write-spans 0 dview [(merge {:font-size "16" :font-weight "400"} span)] span-paragraph)
|
||||
dview))
|
||||
|
||||
(t/deftest write-spans-omits-hidden-ruby-bytes
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [{:text "日"
|
||||
:ruby "にち"
|
||||
:ruby-hidden true
|
||||
:font-size "16"
|
||||
:font-weight "400"}]
|
||||
paragraph)
|
||||
(t/is (= 0 (.getInt32 dview 72 true)))
|
||||
(write-spans 0 dview [{:text "日"
|
||||
:ruby "にち"
|
||||
:ruby-hidden false
|
||||
:font-size "16"
|
||||
:font-weight "400"}]
|
||||
paragraph)
|
||||
(t/is (pos? (.getInt32 dview 72 true)))))
|
||||
(defn- span-byte
|
||||
[dview attr]
|
||||
(.getUint8 dview (get span-attr-offset attr)))
|
||||
|
||||
(defn- padding-bytes
|
||||
[dview]
|
||||
(mapv #(.getUint8 dview %) (:padding span-attr-offset)))
|
||||
|
||||
(t/deftest write-spans-serializes-font-features
|
||||
(let [dview (serialized-span {:text "日本語" :font-features "vpal"})]
|
||||
(t/is (= 2 (span-byte dview :font-features)))
|
||||
(t/is (= 0 (span-byte dview :annotation-clearance)))
|
||||
(t/is (= [0 0] (padding-bytes dview)))))
|
||||
|
||||
(t/deftest write-spans-serializes-annotation-clearance
|
||||
(let [dview (serialized-span {:text "漢字" :annotation-clearance "auto"})]
|
||||
(t/is (= 1 (span-byte dview :annotation-clearance)))
|
||||
(t/is (= [0 0] (padding-bytes dview)))))
|
||||
|
||||
(t/deftest write-spans-serializes-ruby-customization
|
||||
(let [dview (serialized-span {:text "漢字"
|
||||
:ruby-size "quarter"
|
||||
:ruby-align "space-between"
|
||||
:ruby-overhang "none"
|
||||
:ruby-side "under"})]
|
||||
(t/is (= [2 3 1 1]
|
||||
(mapv #(span-byte dview %) [:ruby-size :ruby-align :ruby-overhang :ruby-side])))
|
||||
(t/is (= [0 0] (padding-bytes dview)))))
|
||||
|
||||
(t/deftest write-spans-omits-hidden-ruby
|
||||
(let [ruby-length #(.getInt32 % (:ruby-length span-attr-offset) true)]
|
||||
(t/is (= 0 (ruby-length (serialized-span {:text "日" :ruby "にち" :ruby-hidden true}))))
|
||||
(t/is (pos? (ruby-length (serialized-span {:text "日" :ruby "にち" :ruby-hidden false}))))))
|
||||
|
||||
(t/deftest write-spans-serializes-counted-digits-tcy
|
||||
(let [sentinel (js-obj)
|
||||
previous (if (.hasOwnProperty wasm/serializers "text-combine-upright")
|
||||
(unchecked-get wasm/serializers "text-combine-upright")
|
||||
sentinel)]
|
||||
(try
|
||||
(aset wasm/serializers "text-combine-upright"
|
||||
#js {"none" 0 "all" 1 "digits" 2 "digits2" 3 "digits3" 4})
|
||||
(let [buffer (js/ArrayBuffer. 256)
|
||||
dview (js/DataView. buffer)
|
||||
span {:text "平成31年"
|
||||
:font-size "16"
|
||||
:font-weight "400"
|
||||
:text-combine-upright "digits2"}
|
||||
paragraph {:font-size "16"
|
||||
:font-weight "400"
|
||||
:line-height "1"}]
|
||||
(write-spans 0 dview [span] paragraph)
|
||||
;; Byte 5 of the span attr block carries text-combine-upright.
|
||||
(t/is (= 3 (.getUint8 dview 5))))
|
||||
(finally
|
||||
(if (identical? sentinel previous)
|
||||
(js-delete wasm/serializers "text-combine-upright")
|
||||
(aset wasm/serializers "text-combine-upright" previous))))))
|
||||
(let [dview (serialized-span {:text "平成31年" :text-combine-upright "digits2"})]
|
||||
(t/is (= 3 (span-byte dview :text-combine-upright)))))
|
||||
|
||||
(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery
|
||||
(let [content {:children
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.main.ui.ds.controls.select :as select]
|
||||
[app.main.ui.shapes.text.html-text :as html-text]
|
||||
[app.main.ui.shapes.text.styles :as text-styles]
|
||||
[app.main.ui.workspace.sidebar.options.common :refer [radio-selected]]
|
||||
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[cuerdas.core :as str]
|
||||
@ -29,11 +30,6 @@
|
||||
:font-features
|
||||
:annotation-clearance])
|
||||
|
||||
(def ^:private radio-selected @#'tjl/radio-selected)
|
||||
(def ^:private span-input-value @#'tjl/span-input-value)
|
||||
(def ^:private span-select-value @#'tjl/span-select-value)
|
||||
(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option)
|
||||
(def ^:private ruby-common-props @#'tjl/ruby-common-props)
|
||||
|
||||
(t/deftest japanese-layout-controls-follow-file-or-profile-configuration
|
||||
(t/is (false? (tjl/japanese-layout-config-enabled? {} {})))
|
||||
@ -105,13 +101,6 @@
|
||||
(t/is (str/includes? markup ">日</span>"))
|
||||
(t/is (not (str/includes? markup "<ruby")))))
|
||||
|
||||
(t/deftest advanced-furigana-options-preserve-change-callbacks
|
||||
(let [on-change identity
|
||||
on-blur identity
|
||||
props (ruby-common-props {} on-change on-blur)]
|
||||
(t/is (identical? on-change (unchecked-get props "onChange")))
|
||||
(t/is (identical? on-blur (unchecked-get props "onBlur")))))
|
||||
|
||||
(t/deftest shape-level-furigana-presentation-only-updates-ruby-spans
|
||||
(let [shape {:content
|
||||
{:type "root"
|
||||
@ -140,15 +129,19 @@
|
||||
(t/is (= "quarter" (:ruby-size (second spans))))
|
||||
(t/is (false? (:ruby-hidden (second spans))))))
|
||||
|
||||
(t/deftest mixed-japanese-span-values-have-distinct-control-states
|
||||
(let [options (with-mixed-span-option
|
||||
(tjl/text-emphasis-options identity)
|
||||
:multiple)]
|
||||
(t/is (= "mixed" (span-select-value :multiple "none")))
|
||||
(t/is (= "mixed" (span-input-value :multiple)))
|
||||
(t/deftest mixed-span-values-select-a-disabled-mixed-option
|
||||
(let [options (tjl/with-mixed-span-option (tjl/text-emphasis-options identity) :multiple)]
|
||||
(t/is (= "mixed" (tjl/span-select-value :multiple "none")))
|
||||
(t/is (= "none" (tjl/span-select-value nil "none")))
|
||||
(t/is (= "mixed" (:id (last options))))
|
||||
(t/is (true? (:disabled (last options))))
|
||||
(t/is (= "" (radio-selected :multiple "none")))))
|
||||
(t/is (= options (tjl/with-mixed-span-option options "none"))
|
||||
"an unmixed value adds no entry")))
|
||||
|
||||
(t/deftest mixed-radio-values-select-nothing
|
||||
(t/is (= "" (radio-selected :multiple "none")))
|
||||
(t/is (= "none" (radio-selected nil "none")))
|
||||
(t/is (= "vertical-rl" (radio-selected :vertical-rl))))
|
||||
|
||||
(t/deftest whole-text-selection-reports-every-differing-span-value-as-mixed
|
||||
(let [shape {:content
|
||||
|
||||
@ -39,10 +39,8 @@ pub fn render_vertical_text(
|
||||
}
|
||||
}
|
||||
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
|
||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
|
||||
let max_height = text_vertical::wrap_height(text_content, bounds.height());
|
||||
text_vertical::layout_from_content(text_content, max_height)
|
||||
});
|
||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
|
||||
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
|
||||
|
||||
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
|
||||
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
|
||||
@ -90,8 +88,7 @@ pub fn render_vertical_text(
|
||||
let grid_layout = match layout.as_ref() {
|
||||
Some(layout) => layout,
|
||||
None => {
|
||||
let max_height = text_vertical::wrap_height(text_content, bounds.height());
|
||||
owned_layout = text_vertical::layout_from_content(text_content, max_height);
|
||||
owned_layout = text_vertical::layout_for_box(text_content, bounds.height());
|
||||
&owned_layout
|
||||
}
|
||||
};
|
||||
@ -713,7 +710,7 @@ fn paint_text_with_emoji_overlay(
|
||||
}
|
||||
|
||||
if ruby_per_paragraph {
|
||||
crate::shapes::text_vertical::paint_horizontal_ruby(
|
||||
crate::shapes::paint_horizontal_ruby(
|
||||
canvas,
|
||||
text_content,
|
||||
para.source_paragraph,
|
||||
|
||||
@ -146,8 +146,7 @@ fn vertical_layout_for_shape(
|
||||
shape: &Shape,
|
||||
) -> (text_vertical::VerticalLayout, f32) {
|
||||
let selrect = shape.selrect();
|
||||
let max_height = text_vertical::wrap_height(text_content, selrect.height());
|
||||
let layout = text_vertical::layout_from_content(text_content, max_height);
|
||||
let layout = text_vertical::layout_for_box(text_content, selrect.height());
|
||||
let origin_x =
|
||||
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
|
||||
(layout, origin_x)
|
||||
|
||||
@ -70,10 +70,8 @@ impl<'a> VectorRenderer<'a> {
|
||||
let bounds = text_content.bounds();
|
||||
let drop_shadows = shape.drop_shadow_paints();
|
||||
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
|
||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
|
||||
let max_height = text_vertical::wrap_height(text_content, bounds.height());
|
||||
text_vertical::layout_from_content(text_content, max_height)
|
||||
});
|
||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
|
||||
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
|
||||
|
||||
if let Some(layout) = &layout {
|
||||
for shadow in &drop_shadows {
|
||||
|
||||
@ -575,9 +575,7 @@ impl TextContent {
|
||||
.iter()
|
||||
.flat_map(|p| p.children())
|
||||
.any(|span| {
|
||||
span.warichu
|
||||
|| !span.ruby.trim().is_empty()
|
||||
|| span.text_emphasis.mark_char().is_some()
|
||||
span.warichu || span.has_ruby() || span.text_emphasis.mark_char().is_some()
|
||||
})
|
||||
}
|
||||
|
||||
@ -798,8 +796,7 @@ impl TextContent {
|
||||
// arrives selrect-local; the content block is right-anchored.
|
||||
if self.is_vertical() {
|
||||
let bounds = self.bounds();
|
||||
let max_height = super::text_vertical::wrap_height(self, bounds.height());
|
||||
let layout = super::text_vertical::layout_from_content(self, max_height);
|
||||
let layout = super::text_vertical::layout_for_box(self, bounds.height());
|
||||
let cx = point.x
|
||||
- super::text_vertical::block_axis_offset(
|
||||
bounds.width(),
|
||||
@ -1284,15 +1281,14 @@ impl TextContent {
|
||||
if self.is_vertical() {
|
||||
match self.grow_type() {
|
||||
GrowType::AutoWidth => {
|
||||
let max_height = super::text_vertical::wrap_height(self, selrect.height());
|
||||
let (width, height) = super::text_vertical::measure_content(self, max_height);
|
||||
let (width, height) =
|
||||
super::text_vertical::measure_content(self, selrect.height());
|
||||
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||||
self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||||
self.size.max_width = self.size.width;
|
||||
}
|
||||
GrowType::AutoHeight => {
|
||||
let max_height = super::text_vertical::wrap_height(self, selrect.height());
|
||||
let (width, _) = super::text_vertical::measure_content(self, max_height);
|
||||
let (width, _) = super::text_vertical::measure_content(self, selrect.height());
|
||||
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||||
self.size.height = selrect.height();
|
||||
self.size.max_width = self.size.width;
|
||||
@ -1414,8 +1410,7 @@ impl TextContent {
|
||||
// Vertical writing: hit-test against the laid-out cells directly
|
||||
// (absolute coordinates, right-anchored to the selrect).
|
||||
if self.is_vertical() {
|
||||
let max_height = super::text_vertical::wrap_height(self, shape.selrect.height());
|
||||
let layout = super::text_vertical::layout_from_content(self, max_height);
|
||||
let layout = super::text_vertical::layout_for_box(self, shape.selrect.height());
|
||||
return super::text_vertical::intersects(
|
||||
&layout,
|
||||
&shape.selrect,
|
||||
@ -1635,29 +1630,6 @@ impl Paragraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capitalize the first letter of each word, preserving all original whitespace.
|
||||
/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after
|
||||
/// any non-letter character (whitespace, punctuation, digits, symbols).
|
||||
#[cfg(test)]
|
||||
fn capitalize_words(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut capitalize_next = true;
|
||||
for c in text.chars() {
|
||||
if c.is_alphabetic() {
|
||||
if capitalize_next {
|
||||
result.extend(c.to_uppercase());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
capitalize_next = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
capitalize_next = true;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Add `text`, pushing every '\t' as a one em wide placeholder.
|
||||
pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) {
|
||||
let tab = PlaceholderStyle::new(
|
||||
@ -1676,28 +1648,6 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size:
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter control characters below U+0020, preserving tabs and line breaks.
|
||||
/// Browser-dependent: Firefox drops them, others replace with space.
|
||||
#[cfg(test)]
|
||||
fn process_ignored_chars(text: &str, browser: u8) -> String {
|
||||
text.chars()
|
||||
.filter_map(|c| {
|
||||
if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' {
|
||||
return Some(c);
|
||||
}
|
||||
if c < '\u{0020}' {
|
||||
if browser == Browser::Firefox as u8 {
|
||||
None
|
||||
} else {
|
||||
Some(' ')
|
||||
}
|
||||
} else {
|
||||
Some(c)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Text after browser filtering and CSS text transformation, plus the source
|
||||
/// UTF-16 range that produced each transformed Unicode scalar. A single source
|
||||
/// scalar can produce several output scalars (for example `ß` uppercases to
|
||||
@ -1742,10 +1692,9 @@ fn apply_text_transform_with_source_ranges(
|
||||
let source_start = source_utf16;
|
||||
source_utf16 += source_char.len_utf16();
|
||||
|
||||
let processed = if source_char == '\n'
|
||||
|| source_char == '\r'
|
||||
|| source_char == '\u{2028}'
|
||||
|| source_char == '\u{2029}'
|
||||
// Control characters below U+0020 are filtered (Firefox drops them,
|
||||
// other browsers show a space); tabs and line breaks are kept.
|
||||
let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}')
|
||||
|| source_char >= '\u{0020}'
|
||||
{
|
||||
Some(source_char)
|
||||
@ -1923,6 +1872,38 @@ impl TextSpan {
|
||||
self.annotation_clearance = clearance;
|
||||
}
|
||||
|
||||
/// Ruby annotation text, without surrounding whitespace.
|
||||
pub fn ruby_text(&self) -> &str {
|
||||
self.ruby.trim()
|
||||
}
|
||||
|
||||
pub fn has_ruby(&self) -> bool {
|
||||
!self.ruby_text().is_empty()
|
||||
}
|
||||
|
||||
pub fn ruby_font_size(&self) -> f32 {
|
||||
self.font_size * self.ruby_size.scale()
|
||||
}
|
||||
|
||||
/// Warichu needs at least two characters to fill its two sub-lines.
|
||||
pub fn is_warichu(&self) -> bool {
|
||||
self.warichu && self.text.chars().count() >= 2
|
||||
}
|
||||
|
||||
/// Automatic clearance stacks emphasis marks outside an over-side ruby.
|
||||
pub fn stacks_emphasis_outside_ruby(&self) -> bool {
|
||||
self.annotation_clearance.is_auto() && self.has_ruby() && self.ruby_side == RubySide::Over
|
||||
}
|
||||
|
||||
/// Cross-axis offset of the emphasis marks past a stacked ruby layer.
|
||||
pub fn emphasis_ruby_offset(&self) -> f32 {
|
||||
if self.stacks_emphasis_outside_ruby() {
|
||||
self.ruby_font_size()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_style(
|
||||
&self,
|
||||
content_bounds: &Rect,
|
||||
@ -1967,7 +1948,7 @@ impl TextSpan {
|
||||
};
|
||||
|
||||
let annotation_layers = if self.annotation_clearance.is_auto() {
|
||||
usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none())
|
||||
usize::from(self.has_ruby()) + usize::from(!self.text_emphasis.is_none())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@ -2281,8 +2262,7 @@ pub fn calculate_position_data(
|
||||
if skip_position_data {
|
||||
return Vec::new();
|
||||
}
|
||||
let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height());
|
||||
let layout = super::text_vertical::layout_from_content(&text_content, max_height);
|
||||
let layout = super::text_vertical::layout_for_box(&text_content, shape.selrect.height());
|
||||
return super::text_vertical::position_data(
|
||||
&layout,
|
||||
&shape.selrect,
|
||||
@ -2338,100 +2318,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn capitalize(text: &str) -> String {
|
||||
apply_text_transform_with_source_ranges(
|
||||
text,
|
||||
Browser::Chrome as u8,
|
||||
Some(TextTransform::Capitalize),
|
||||
)
|
||||
.text
|
||||
}
|
||||
|
||||
fn filter_ignored_chars(text: &str, browser: u8) -> String {
|
||||
apply_text_transform_with_source_ranges(text, browser, None).text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_basic_words() {
|
||||
assert_eq!(capitalize_words("hello world"), "Hello World");
|
||||
assert_eq!(capitalize("hello world"), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_preserves_leading_whitespace() {
|
||||
assert_eq!(capitalize_words(" hello"), " Hello");
|
||||
assert_eq!(capitalize(" hello"), " Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_preserves_trailing_whitespace() {
|
||||
assert_eq!(capitalize_words("hello "), "Hello ");
|
||||
assert_eq!(capitalize("hello "), "Hello ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_preserves_multiple_spaces() {
|
||||
assert_eq!(capitalize_words("hello world"), "Hello World");
|
||||
assert_eq!(capitalize("hello world"), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_whitespace_only() {
|
||||
assert_eq!(capitalize_words(" "), " ");
|
||||
assert_eq!(capitalize_words(" "), " ");
|
||||
assert_eq!(capitalize(" "), " ");
|
||||
assert_eq!(capitalize(" "), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_empty_string() {
|
||||
assert_eq!(capitalize_words(""), "");
|
||||
assert_eq!(capitalize(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_single_char() {
|
||||
assert_eq!(capitalize_words("a"), "A");
|
||||
assert_eq!(capitalize("a"), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_already_uppercase() {
|
||||
assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD");
|
||||
assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_preserves_tabs_and_newlines() {
|
||||
assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld");
|
||||
assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld");
|
||||
assert_eq!(capitalize("hello\tworld"), "Hello\tWorld");
|
||||
assert_eq!(capitalize("hello\nworld"), "Hello\nWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_after_punctuation() {
|
||||
assert_eq!(capitalize_words("(readonly)"), "(Readonly)");
|
||||
assert_eq!(capitalize_words("hello-world"), "Hello-World");
|
||||
assert_eq!(capitalize_words("one/two/three"), "One/Two/Three");
|
||||
assert_eq!(capitalize("(readonly)"), "(Readonly)");
|
||||
assert_eq!(capitalize("hello-world"), "Hello-World");
|
||||
assert_eq!(capitalize("one/two/three"), "One/Two/Three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalize_after_digits() {
|
||||
assert_eq!(capitalize_words("item1name"), "Item1Name");
|
||||
assert_eq!(capitalize("item1name"), "Item1Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_ignored_chars_preserves_spaces() {
|
||||
assert_eq!(process_ignored_chars("hello world", 0), "hello world");
|
||||
fn ignored_chars_preserves_spaces() {
|
||||
assert_eq!(filter_ignored_chars("hello world", 0), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_ignored_chars_preserves_line_breaks() {
|
||||
assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld");
|
||||
assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld");
|
||||
fn ignored_chars_preserves_line_breaks() {
|
||||
assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld");
|
||||
assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_ignored_chars_preserves_tabs() {
|
||||
assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld");
|
||||
fn ignored_chars_preserves_tabs() {
|
||||
assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld");
|
||||
assert_eq!(
|
||||
process_ignored_chars("hello\tworld", Browser::Firefox as u8),
|
||||
filter_ignored_chars("hello\tworld", Browser::Firefox as u8),
|
||||
"hello\tworld"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_ignored_chars_replaces_control_chars_chrome() {
|
||||
fn ignored_chars_replaces_control_chars_chrome() {
|
||||
// U+0001 (SOH) should become space in non-Firefox
|
||||
assert_eq!(
|
||||
process_ignored_chars("a\x01b", Browser::Chrome as u8),
|
||||
"a b"
|
||||
);
|
||||
assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_ignored_chars_removes_control_chars_firefox() {
|
||||
assert_eq!(
|
||||
process_ignored_chars("a\x01b", Browser::Firefox as u8),
|
||||
"ab"
|
||||
);
|
||||
fn ignored_chars_removes_control_chars_firefox() {
|
||||
assert_eq!(filter_ignored_chars("a\x01b", Browser::Firefox as u8), "ab");
|
||||
}
|
||||
|
||||
fn test_paragraph(texts: &[&str]) -> Paragraph {
|
||||
@ -2743,290 +2730,4 @@ mod tests {
|
||||
assert_eq!(transformed.source_utf16_range(1..3), 1..2);
|
||||
assert_eq!(transformed.source_utf16_range(3..4), 2..3);
|
||||
}
|
||||
|
||||
// apply_text_transform reads the browser from the design state.
|
||||
fn init_state() {
|
||||
crate::globals::design_init();
|
||||
}
|
||||
|
||||
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
|
||||
TextSpan {
|
||||
text: text.to_string(),
|
||||
font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal),
|
||||
font_size: 16.0,
|
||||
line_height: 1.0,
|
||||
letter_spacing,
|
||||
font_weight: 400,
|
||||
font_variant_id: Uuid::nil(),
|
||||
text_decoration: None,
|
||||
text_transform: None,
|
||||
text_direction: TextDirection::LTR,
|
||||
text_orientation: TextOrientation::default(),
|
||||
text_combine_upright: TextCombineUpright::default(),
|
||||
text_emphasis: TextEmphasis::default(),
|
||||
ruby: String::default(),
|
||||
warichu: false,
|
||||
font_features: FontFeatures::default(),
|
||||
annotation_clearance: AnnotationClearance::default(),
|
||||
ruby_size: RubySize::default(),
|
||||
ruby_align: RubyAlign::default(),
|
||||
ruby_overhang: RubyOverhang::default(),
|
||||
ruby_side: RubySide::default(),
|
||||
paragraph_position: u32::MAX,
|
||||
span_position: u32::MAX,
|
||||
fills: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
|
||||
Paragraph::new(
|
||||
TextAlign::default(),
|
||||
TextDirection::LTR,
|
||||
None,
|
||||
None,
|
||||
1.0,
|
||||
letter_spacing,
|
||||
spans,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_applies_kinsoku() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
|
||||
);
|
||||
assert!(!map.is_empty());
|
||||
assert_eq!(map.to_original(3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["雪国。".to_string()]);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["雪国。".to_string()]);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_respects_text_transform() {
|
||||
init_state();
|
||||
let mut span = make_span("hello。", 0.0);
|
||||
span.text_transform = Some(TextTransform::Uppercase);
|
||||
let paragraph = make_paragraph(vec![span], 0.0);
|
||||
let (texts, _) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_identity_map_for_plain_text() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["helloworld".to_string()]);
|
||||
assert!(map.is_empty());
|
||||
assert_eq!(map.to_original(5), 5);
|
||||
assert_eq!(map.to_shifted(5), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_ruby_is_atomic() {
|
||||
init_state();
|
||||
let mut group = make_span("日本", 0.0);
|
||||
group.ruby = "にほん".to_string();
|
||||
let paragraph = make_paragraph(vec![group], 0.0);
|
||||
assert_eq!(
|
||||
paragraph.layout_span_texts().0,
|
||||
vec!["日\u{2060}本".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
|
||||
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
|
||||
|
||||
assert_eq!(
|
||||
crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0),
|
||||
88.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_warichu_collapses_to_one_builder_position() {
|
||||
init_state();
|
||||
let mut warichu = make_span("割注入り", 0.0);
|
||||
warichu.warichu = true;
|
||||
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
|
||||
|
||||
let ranges = horizontal_span_ranges(¶graph);
|
||||
assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3);
|
||||
assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 0);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 4), 3);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 5), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 1), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 4), 5);
|
||||
assert_eq!(
|
||||
horizontal_normal_selection_ranges(¶graph, 1, 5),
|
||||
vec![3..4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_builder_mapping_preserves_non_bmp_boundaries() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0);
|
||||
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 1), 2);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 1);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 3);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_warichu_builder_emits_one_styled_placeholder() {
|
||||
init_state();
|
||||
let mut span = make_span("割注入り", 0.0);
|
||||
span.warichu = true;
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(span.font_size);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let placeholders = laid_out.get_rects_for_placeholders();
|
||||
assert_eq!(placeholders.len(), 1);
|
||||
assert!(placeholders[0].rect.width() > 0.0);
|
||||
assert!(placeholders[0].rect.height() > 0.0);
|
||||
let has_style = laid_out
|
||||
.get_line_metrics()
|
||||
.iter()
|
||||
.any(|line| !line.get_style_metrics(3..5).is_empty());
|
||||
assert!(
|
||||
has_style,
|
||||
"the paint pass must recover the placeholder style"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_warichu_allows_wrapping_after_the_atomic_box() {
|
||||
init_state();
|
||||
let mut span = make_span("割注入り", 0.0);
|
||||
span.warichu = true;
|
||||
let following = make_span("A", 0.0);
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(span.font_size);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(&following.text);
|
||||
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(16.1);
|
||||
|
||||
assert_eq!(laid_out.get_rects_for_placeholders().len(), 1);
|
||||
assert_eq!(laid_out.get_line_metrics().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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 horizontal_emphasis_tracks_eligible_unicode_characters() {
|
||||
init_state();
|
||||
let mut span = make_span("A😀。 B", 0.0);
|
||||
span.text_emphasis = TextEmphasis::FilledDot;
|
||||
let paragraph = make_paragraph(vec![span], 0.0);
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(16.0);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
let (texts, _) = paragraph.layout_span_texts();
|
||||
for (span, text) in paragraph.children().iter().zip(texts) {
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, span, &text, &style, &fonts);
|
||||
}
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let placements = horizontal_emphasis_placements(¶graph, &laid_out);
|
||||
assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each");
|
||||
assert!(placements
|
||||
.iter()
|
||||
.all(|placement| placement.rect.width() > 0.0));
|
||||
assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_emphasis_recovers_each_non_ascii_span_style() {
|
||||
init_state();
|
||||
let mut first = make_span("漢", 0.0);
|
||||
first.text_emphasis = TextEmphasis::FilledDot;
|
||||
let mut second = make_span("字", 0.0);
|
||||
second.text_emphasis = TextEmphasis::OpenCircle;
|
||||
let paragraph = make_paragraph(vec![first, second], 0.0);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
let (texts, _) = paragraph.layout_span_texts();
|
||||
for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() {
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(if index == 0 { 16.0 } else { 24.0 });
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, span, &text, &style, &fonts);
|
||||
}
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let ranges = horizontal_span_ranges(¶graph);
|
||||
assert_eq!(
|
||||
horizontal_span_style(&laid_out, &ranges[0])
|
||||
.unwrap()
|
||||
.font_size(),
|
||||
16.0
|
||||
);
|
||||
assert_eq!(
|
||||
horizontal_span_style(&laid_out, &ranges[1])
|
||||
.unwrap()
|
||||
.font_size(),
|
||||
24.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
use super::text::{add_text_with_tabs, Paragraph, TextSpan};
|
||||
use super::text::{add_text_with_tabs, Paragraph, TextContent, TextSpan};
|
||||
use super::text_vertical::{
|
||||
distribute_ruby_tops, shape_segment_with_fallbacks, single_glyph_blob, span_font_families,
|
||||
};
|
||||
use crate::globals::get_resources;
|
||||
use crate::math::Point;
|
||||
use crate::shapes::kinsoku;
|
||||
use crate::utils::get_font_collection;
|
||||
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,
|
||||
},
|
||||
Contains,
|
||||
Canvas, Contains, Font, FontMgr, GlyphId,
|
||||
};
|
||||
|
||||
pub const WARICHU_FONT_SCALE: f32 = 0.5;
|
||||
@ -32,7 +36,7 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec<String>, kinsoku:
|
||||
let ruby_breaks: Vec<Option<Vec<usize>>> = paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| (!span.ruby.trim().is_empty()).then(Vec::new))
|
||||
.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)
|
||||
@ -53,15 +57,9 @@ pub(crate) fn add_horizontal_span(
|
||||
text_style: &skia::textlayout::TextStyle,
|
||||
fonts: &skia::textlayout::FontCollection,
|
||||
) {
|
||||
if span.warichu && span.text.chars().count() >= 2 {
|
||||
if span.is_warichu() {
|
||||
let text = span.apply_text_transform();
|
||||
let split = super::text_vertical::warichu_split_chars(&text);
|
||||
let split_byte = text
|
||||
.char_indices()
|
||||
.nth(split)
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(text.len());
|
||||
let (first, second) = text.split_at(split_byte);
|
||||
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);
|
||||
@ -133,7 +131,7 @@ pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpa
|
||||
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.warichu && span.text.chars().count() >= 2;
|
||||
let warichu = span.is_warichu();
|
||||
let builder_start = builder_cursor;
|
||||
let style_anchor_start = if warichu {
|
||||
builder_byte_cursor + '\u{FFFC}'.len_utf8()
|
||||
@ -254,7 +252,7 @@ fn horizontal_warichu_placeholders(
|
||||
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.warichu && span.text.chars().count() >= 2 {
|
||||
if span.is_warichu() {
|
||||
if let Some(textbox) = placeholders.next() {
|
||||
result.push((index, textbox.rect));
|
||||
}
|
||||
@ -295,7 +293,7 @@ pub(crate) fn horizontal_warichu_hit_test(
|
||||
if !rect.contains(&point) {
|
||||
continue;
|
||||
}
|
||||
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
|
||||
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 {
|
||||
@ -319,7 +317,7 @@ pub(crate) fn horizontal_warichu_caret_rect(
|
||||
if source_offset < source_start || source_offset > source_end {
|
||||
continue;
|
||||
}
|
||||
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
|
||||
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 {
|
||||
@ -352,7 +350,7 @@ pub(crate) fn horizontal_warichu_range_rects(
|
||||
if selected_start >= selected_end {
|
||||
continue;
|
||||
}
|
||||
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
|
||||
let split = warichu_split_chars(&span.apply_text_transform());
|
||||
for (line_start, line_end, top) in [
|
||||
(span_start, span_start + split, rect.top()),
|
||||
(
|
||||
@ -392,7 +390,7 @@ pub(crate) fn horizontal_normal_selection_ranges(
|
||||
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);
|
||||
let warichu = span.warichu && span.text.chars().count() >= 2;
|
||||
let warichu = span.is_warichu();
|
||||
span_start = span_end;
|
||||
if warichu || selected_start >= selected_end {
|
||||
return None;
|
||||
@ -405,8 +403,39 @@ pub(crate) fn horizontal_normal_selection_ranges(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn warichu_text_lines(text: &str) -> (&str, &str) {
|
||||
let split = super::text_vertical::warichu_split_chars(text);
|
||||
/// Char index where a warichu run splits into its two sub-lines: the
|
||||
/// balanced midpoint (first line longer), nudged so the second sub-line
|
||||
/// does not start with a line-start-prohibited character and the first
|
||||
/// does not end with a line-end-prohibited one. Nudging forward pulls the
|
||||
/// offending mark up into the first sub-line (jlreq); backward is the
|
||||
/// fallback, and the midpoint stands when no split satisfies kinsoku.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@ -415,7 +444,7 @@ fn warichu_text_lines(text: &str) -> (&str, &str) {
|
||||
text.split_at(split_byte)
|
||||
}
|
||||
|
||||
fn warichu_mini_paragraph(
|
||||
fn mini_paragraph(
|
||||
text: &str,
|
||||
style: &skia::textlayout::TextStyle,
|
||||
width: f32,
|
||||
@ -448,17 +477,7 @@ pub(crate) fn paint_horizontal_warichu(
|
||||
let Some(span) = paragraph.children().get(range.span) else {
|
||||
continue;
|
||||
};
|
||||
// Indexed style metrics expose builder UTF-8 byte positions even
|
||||
// though glyph/line ranges use UTF-16 offsets.
|
||||
let style_anchor = range.style_anchor_start
|
||||
..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8();
|
||||
let style = laid_out.get_line_metrics().iter().find_map(|line| {
|
||||
line.get_style_metrics(style_anchor.clone())
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, metric)| metric.text_style.clone())
|
||||
});
|
||||
let Some(mut style) = style else {
|
||||
let Some(mut style) = horizontal_span_style(laid_out, range) else {
|
||||
continue;
|
||||
};
|
||||
style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
|
||||
@ -467,8 +486,8 @@ pub(crate) fn paint_horizontal_warichu(
|
||||
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 = warichu_mini_paragraph(first, &style, rect.width());
|
||||
let second_para = warichu_mini_paragraph(second, &style, rect.width());
|
||||
let first_para = mini_paragraph(first, &style, rect.width());
|
||||
let second_para = mini_paragraph(second, &style, rect.width());
|
||||
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));
|
||||
@ -546,8 +565,8 @@ 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 (the same
|
||||
// convention used by the warichu style anchor above).
|
||||
// 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())
|
||||
@ -595,7 +614,7 @@ pub(crate) fn paint_horizontal_emphasis(
|
||||
style.set_height(1.0);
|
||||
style.set_height_override(true);
|
||||
style.set_letter_spacing(0.0);
|
||||
let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX);
|
||||
let mark_paragraph = mini_paragraph(&mark.to_string(), &style, f32::MAX);
|
||||
let mark_ink = mark_paragraph
|
||||
.get_rects_for_range(
|
||||
0..mark.len_utf16(),
|
||||
@ -621,22 +640,204 @@ pub(crate) fn paint_horizontal_emphasis(
|
||||
.filter(|placement| placement.span == range.span && placement.mark == mark)
|
||||
{
|
||||
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
|
||||
let ruby_offset = if span.annotation_clearance.is_auto()
|
||||
&& !span.ruby.trim().is_empty()
|
||||
&& span.ruby_side == RubySide::Over
|
||||
{
|
||||
span.font_size * span.ruby_size.scale()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size)
|
||||
- mark_ink_bottom
|
||||
- ruby_offset;
|
||||
- span.emphasis_ruby_offset();
|
||||
mark_paragraph.paint(canvas, (mark_x, mark_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `total` glyphs across segments proportionally to their extents
|
||||
/// (rounded per segment, remainder to the last) so no glyph is dropped.
|
||||
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
|
||||
}
|
||||
|
||||
fn next_horizontal_ruby_range(
|
||||
offset_map: &kinsoku::OffsetMap,
|
||||
utf16_cursor: &mut usize,
|
||||
text: &str,
|
||||
) -> std::ops::Range<usize> {
|
||||
let start = *utf16_cursor;
|
||||
*utf16_cursor += text.encode_utf16().count();
|
||||
offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor)
|
||||
}
|
||||
|
||||
/// Baseline adjustment that attaches a glyph's visible ink edge to its base
|
||||
/// strip. Font-wide ascender/descender metrics include leading which makes
|
||||
/// horizontal ruby visibly detached for 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only:
|
||||
/// base rects come from the already laid-out skparagraph
|
||||
/// (`get_rects_for_range`), the annotation is shaped at half the span size
|
||||
/// and distributed over each line's base rect with the same jlreq
|
||||
/// distribution the vertical path uses (`distribute_ruby_tops` along the
|
||||
/// horizontal flow). Lines are not reflowed to reserve an annotation band;
|
||||
/// the ruby draws in the natural leading above the base line.
|
||||
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 (_, offset_map) = paragraph.layout_span_texts();
|
||||
|
||||
let mut utf16_cursor = 0usize;
|
||||
for span in paragraph.children() {
|
||||
let span_text = span.apply_text_transform();
|
||||
let span_range = next_horizontal_ruby_range(&offset_map, &mut utf16_cursor, &span_text);
|
||||
let ruby_text = span.ruby_text();
|
||||
if ruby_text.is_empty() || span_range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ruby_font_size = span.ruby_font_size();
|
||||
let families = span_font_families(span, &fallback_families);
|
||||
let paint = 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(
|
||||
ruby_text,
|
||||
ruby_font_size,
|
||||
&families,
|
||||
font_provider,
|
||||
false,
|
||||
span.font_features,
|
||||
&fallback_mgr,
|
||||
);
|
||||
let glyphs: Vec<(usize, usize, f32)> = shaped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(run_index, run)| {
|
||||
(0..run.glyphs.len()).map(move |glyph| {
|
||||
(
|
||||
run_index,
|
||||
glyph,
|
||||
run.advances.get(glyph).copied().unwrap_or(ruby_font_size),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
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) {
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let slice = &glyphs[assigned..assigned + count];
|
||||
assigned += count;
|
||||
let advance = slice
|
||||
.iter()
|
||||
.map(|(_, _, advance)| *advance)
|
||||
.fold(0.0f32, f32::max)
|
||||
.max(1.0);
|
||||
// Horizontal SkParagraph has already fixed the base geometry.
|
||||
// When overhang is prohibited, fit the annotation strip to
|
||||
// that geometry instead of allowing either end to escape it.
|
||||
let glyph_scale = if span.ruby_overhang == RubyOverhang::None {
|
||||
(rect_box.rect.width() / (advance * count as f32)).min(1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let layout_advance = advance * glyph_scale;
|
||||
let lefts = distribute_ruby_tops(
|
||||
rect_box.rect.left(),
|
||||
rect_box.rect.width(),
|
||||
count,
|
||||
layout_advance,
|
||||
span.ruby_align,
|
||||
span.ruby_overhang,
|
||||
);
|
||||
for ((run_index, glyph, glyph_advance), left) in slice.iter().zip(lefts) {
|
||||
let run = &shaped[*run_index];
|
||||
let (_, metrics) = run.font.metrics();
|
||||
let baseline = match span.ruby_side {
|
||||
RubySide::Over => {
|
||||
y + horizontal_annotation_over_top(rect_box.rect, span.font_size)
|
||||
- horizontal_ruby_ink_edge(
|
||||
&run.font,
|
||||
run.glyphs[*glyph],
|
||||
metrics.descent,
|
||||
true,
|
||||
)
|
||||
}
|
||||
RubySide::Under => {
|
||||
y + rect_box.rect.bottom()
|
||||
- horizontal_ruby_ink_edge(
|
||||
&run.font,
|
||||
run.glyphs[*glyph],
|
||||
metrics.ascent,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some(blob) = single_glyph_blob(&run.font, run.glyphs[*glyph]) {
|
||||
let gx = x + left + (layout_advance - glyph_advance * glyph_scale) / 2.0;
|
||||
if glyph_scale < 1.0 {
|
||||
canvas.save();
|
||||
canvas.translate((gx, baseline));
|
||||
canvas.scale((glyph_scale, 1.0));
|
||||
canvas.draw_text_blob(&blob, (0.0, 0.0), &paint);
|
||||
canvas.restore();
|
||||
} else {
|
||||
canvas.draw_text_blob(&blob, (gx, baseline), &paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block flow direction of a paragraph. Horizontal is the skparagraph
|
||||
/// path; vertical-rl lays out columns top->bottom advancing right->left
|
||||
/// through the custom vertical pass.
|
||||
@ -783,3 +984,337 @@ impl AnnotationClearance {
|
||||
matches!(self, AnnotationClearance::Auto)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextTransform};
|
||||
use crate::Uuid;
|
||||
|
||||
#[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 horizontal_ruby_ranges_use_utf16_across_spans() {
|
||||
let offset_map = crate::shapes::kinsoku::OffsetMap::default();
|
||||
let mut cursor = 0;
|
||||
|
||||
assert_eq!(
|
||||
next_horizontal_ruby_range(&offset_map, &mut cursor, "𠀀"),
|
||||
0..2
|
||||
);
|
||||
assert_eq!(
|
||||
next_horizontal_ruby_range(&offset_map, &mut cursor, "漢"),
|
||||
2..3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_counts_by_extent_drops_no_glyph() {
|
||||
assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]);
|
||||
assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]);
|
||||
assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]);
|
||||
assert_eq!(
|
||||
split_counts_by_extent(&[1.0, 1.0, 1.0], 2)
|
||||
.iter()
|
||||
.sum::<usize>(),
|
||||
2
|
||||
);
|
||||
assert!(split_counts_by_extent(&[], 3).is_empty());
|
||||
}
|
||||
|
||||
// apply_text_transform reads the browser from the design state.
|
||||
fn init_state() {
|
||||
crate::globals::design_init();
|
||||
}
|
||||
|
||||
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
|
||||
TextSpan {
|
||||
text: text.to_string(),
|
||||
font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
|
||||
font_size: 16.0,
|
||||
line_height: 1.0,
|
||||
letter_spacing,
|
||||
font_weight: 400,
|
||||
font_variant_id: Uuid::nil(),
|
||||
text_decoration: None,
|
||||
text_transform: None,
|
||||
text_direction: TextDirection::LTR,
|
||||
text_orientation: TextOrientation::default(),
|
||||
text_combine_upright: TextCombineUpright::default(),
|
||||
text_emphasis: TextEmphasis::default(),
|
||||
ruby: String::default(),
|
||||
warichu: false,
|
||||
font_features: FontFeatures::default(),
|
||||
annotation_clearance: AnnotationClearance::default(),
|
||||
ruby_size: RubySize::default(),
|
||||
ruby_align: RubyAlign::default(),
|
||||
ruby_overhang: RubyOverhang::default(),
|
||||
ruby_side: RubySide::default(),
|
||||
paragraph_position: u32::MAX,
|
||||
span_position: u32::MAX,
|
||||
fills: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
|
||||
Paragraph::new(
|
||||
TextAlign::default(),
|
||||
TextDirection::LTR,
|
||||
None,
|
||||
None,
|
||||
1.0,
|
||||
letter_spacing,
|
||||
spans,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_applies_kinsoku() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
|
||||
);
|
||||
assert!(!map.is_empty());
|
||||
assert_eq!(map.to_original(3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["雪国。".to_string()]);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["雪国。".to_string()]);
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_respects_text_transform() {
|
||||
init_state();
|
||||
let mut span = make_span("hello。", 0.0);
|
||||
span.text_transform = Some(TextTransform::Uppercase);
|
||||
let paragraph = make_paragraph(vec![span], 0.0);
|
||||
let (texts, _) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_span_texts_identity_map_for_plain_text() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
|
||||
let (texts, map) = paragraph.layout_span_texts();
|
||||
assert_eq!(texts, vec!["helloworld".to_string()]);
|
||||
assert!(map.is_empty());
|
||||
assert_eq!(map.to_original(5), 5);
|
||||
assert_eq!(map.to_shifted(5), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_ruby_is_atomic() {
|
||||
init_state();
|
||||
let mut group = make_span("日本", 0.0);
|
||||
group.ruby = "にほん".to_string();
|
||||
let paragraph = make_paragraph(vec![group], 0.0);
|
||||
assert_eq!(
|
||||
paragraph.layout_span_texts().0,
|
||||
vec!["日\u{2060}本".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
|
||||
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
|
||||
|
||||
assert_eq!(horizontal_annotation_over_top(rect, 56.0), 88.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_warichu_collapses_to_one_builder_position() {
|
||||
init_state();
|
||||
let mut warichu = make_span("割注入り", 0.0);
|
||||
warichu.warichu = true;
|
||||
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
|
||||
|
||||
let ranges = horizontal_span_ranges(¶graph);
|
||||
assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3);
|
||||
assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 0);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 4), 3);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 5), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 1), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 4), 5);
|
||||
assert_eq!(
|
||||
horizontal_normal_selection_ranges(¶graph, 1, 5),
|
||||
vec![3..4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_builder_mapping_preserves_non_bmp_boundaries() {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0);
|
||||
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 1), 2);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 1);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 3);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_warichu_builder_emits_one_styled_placeholder() {
|
||||
init_state();
|
||||
let mut span = make_span("割注入り", 0.0);
|
||||
span.warichu = true;
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(span.font_size);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let placeholders = laid_out.get_rects_for_placeholders();
|
||||
assert_eq!(placeholders.len(), 1);
|
||||
assert!(placeholders[0].rect.width() > 0.0);
|
||||
assert!(placeholders[0].rect.height() > 0.0);
|
||||
let has_style = laid_out
|
||||
.get_line_metrics()
|
||||
.iter()
|
||||
.any(|line| !line.get_style_metrics(3..5).is_empty());
|
||||
assert!(
|
||||
has_style,
|
||||
"the paint pass must recover the placeholder style"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_warichu_allows_wrapping_after_the_atomic_box() {
|
||||
init_state();
|
||||
let mut span = make_span("割注入り", 0.0);
|
||||
span.warichu = true;
|
||||
let following = make_span("A", 0.0);
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(span.font_size);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
|
||||
builder.push_style(&style);
|
||||
builder.add_text(&following.text);
|
||||
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(16.1);
|
||||
|
||||
assert_eq!(laid_out.get_rects_for_placeholders().len(), 1);
|
||||
assert_eq!(laid_out.get_line_metrics().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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 horizontal_emphasis_tracks_eligible_unicode_characters() {
|
||||
init_state();
|
||||
let mut span = make_span("A😀。 B", 0.0);
|
||||
span.text_emphasis = TextEmphasis::FilledDot;
|
||||
let paragraph = make_paragraph(vec![span], 0.0);
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(16.0);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
let (texts, _) = paragraph.layout_span_texts();
|
||||
for (span, text) in paragraph.children().iter().zip(texts) {
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, span, &text, &style, &fonts);
|
||||
}
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let placements = horizontal_emphasis_placements(¶graph, &laid_out);
|
||||
assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each");
|
||||
assert!(placements
|
||||
.iter()
|
||||
.all(|placement| placement.rect.width() > 0.0));
|
||||
assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_emphasis_recovers_each_non_ascii_span_style() {
|
||||
init_state();
|
||||
let mut first = make_span("漢", 0.0);
|
||||
first.text_emphasis = TextEmphasis::FilledDot;
|
||||
let mut second = make_span("字", 0.0);
|
||||
second.text_emphasis = TextEmphasis::OpenCircle;
|
||||
let paragraph = make_paragraph(vec![first, second], 0.0);
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||||
let (texts, _) = paragraph.layout_span_texts();
|
||||
for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() {
|
||||
let mut style = skia::textlayout::TextStyle::default();
|
||||
style.set_font_size(if index == 0 { 16.0 } else { 24.0 });
|
||||
builder.push_style(&style);
|
||||
add_horizontal_span(&mut builder, span, &text, &style, &fonts);
|
||||
}
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let ranges = horizontal_span_ranges(¶graph);
|
||||
assert_eq!(
|
||||
horizontal_span_style(&laid_out, &ranges[0])
|
||||
.unwrap()
|
||||
.font_size(),
|
||||
16.0
|
||||
);
|
||||
assert_eq!(
|
||||
horizontal_span_style(&laid_out, &ranges[1])
|
||||
.unwrap()
|
||||
.font_size(),
|
||||
24.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1026
render-wasm/src/shapes/text_vertical/annotations.rs
Normal file
1026
render-wasm/src/shapes/text_vertical/annotations.rs
Normal file
File diff suppressed because it is too large
Load Diff
1032
render-wasm/src/shapes/text_vertical/cells.rs
Normal file
1032
render-wasm/src/shapes/text_vertical/cells.rs
Normal file
File diff suppressed because it is too large
Load Diff
1374
render-wasm/src/shapes/text_vertical/flow.rs
Normal file
1374
render-wasm/src/shapes/text_vertical/flow.rs
Normal file
File diff suppressed because it is too large
Load Diff
230
render-wasm/src/shapes/text_vertical/font_tables.rs
Normal file
230
render-wasm/src/shapes/text_vertical/font_tables.rs
Normal file
@ -0,0 +1,230 @@
|
||||
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! {
|
||||
// Layouts are recomputed on every paint but a face's tables never
|
||||
// change, so each table (whose parse copies it) is read once per face.
|
||||
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 down the column by the glyph's true vertical advance rather
|
||||
/// than its shaped horizontal advance: identical for full-width CJK, but
|
||||
/// correct for vertical alternates and proportional glyphs whose `vmtx`
|
||||
/// differs from `hmtx` (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 {
|
||||
/// Parse `vhea`/`vmtx` off the font's typeface. Returns `None` when the
|
||||
/// font carries no vertical metrics (the caller then keeps 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 at the shaped
|
||||
/// size (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 the flow-axis shift of the
|
||||
/// drawn ink (positive down the column). GPOS `yPlacement` is y-up,
|
||||
/// so its sign flips into flow space. `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, normalised to the em (design units /
|
||||
/// unitsPerEm). These bound the ideographic em box and are what the browser
|
||||
/// uses for the vertical central baseline; `hhea`/`Font::metrics` are oversized
|
||||
/// for CJK faces (their ascent exceeds the em) and would push an upright glyph
|
||||
/// off the ideographic 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 (i16), sTypoDescender @ 70 (i16); present in every
|
||||
// OS/2 table 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 current size. Prefers the
|
||||
/// OS/2 typographic metrics (the ideographic em box, matching the browser's
|
||||
/// vertical central baseline used by the SVG/foreignObject export); falls back
|
||||
/// to `Font::metrics` when the face carries no 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)
|
||||
}
|
||||
|
||||
/// Vertical offset from a cell's top edge to the glyph baseline for an upright
|
||||
/// cell. Centres the glyph's line box within the em cell (as the Tate-chu-yoko
|
||||
/// path does) instead of hanging it from the horizontal ascent: CJK faces have
|
||||
/// an ascent larger than the em, so hanging from it pushes every glyph below
|
||||
/// its cell and the whole column overflows its bounds.
|
||||
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(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(TEST_FONT, None).unwrap();
|
||||
let font = Font::new(typeface, 20.0);
|
||||
assert!(VerticalMetrics::from_font(&font).is_none());
|
||||
}
|
||||
}
|
||||
602
render-wasm/src/shapes/text_vertical/layout.rs
Normal file
602
render-wasm/src/shapes/text_vertical/layout.rs
Normal file
@ -0,0 +1,602 @@
|
||||
use skia_safe::{
|
||||
self as skia,
|
||||
textlayout::{TextDecoration, TypefaceFontProvider},
|
||||
FontMgr,
|
||||
};
|
||||
|
||||
use crate::globals::get_resources;
|
||||
use crate::math::Rect;
|
||||
use crate::shapes::japanese::JapaneseClass;
|
||||
use crate::shapes::text_japanese::EMPHASIS_FONT_SCALE;
|
||||
use crate::shapes::{
|
||||
merge_fills, AppliedTextTransform, GrowType, Paragraph, RubySide, TextAlign, TextContent,
|
||||
TextSpan, VerticalAlign,
|
||||
};
|
||||
use crate::utils::get_fallback_fonts;
|
||||
|
||||
use super::annotations::{
|
||||
grow_ruby_bases, layout_emphasis, layout_ruby, ruby_base_units, spread_ruby_base_cells,
|
||||
EmphasisMark, RubyCell,
|
||||
};
|
||||
use super::cells::{Fonts, SpanCells};
|
||||
use super::flow::{
|
||||
align_offset_along_column, apply_inter_script_spacing, apply_ordered_oikomi, flow_classes,
|
||||
is_bounded, materialize_explicit_pair_spacing, ordered_expansion_offsets,
|
||||
plan_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, FlowCell,
|
||||
};
|
||||
use super::shaping::ShapedRun;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CellKind {
|
||||
Upright {
|
||||
run: usize,
|
||||
glyph: usize,
|
||||
count: usize,
|
||||
},
|
||||
/// A character that participates in upright Japanese flow and kinsoku,
|
||||
/// but whose font has no `vert`/`vrt2` alternate. It is rotated per cell
|
||||
/// instead of becoming a sideways run so wrapping and editor offsets stay
|
||||
/// character-granular.
|
||||
SyntheticRotated {
|
||||
run: usize,
|
||||
glyph: usize,
|
||||
count: usize,
|
||||
},
|
||||
Rotated {
|
||||
run: usize,
|
||||
},
|
||||
TateChuYoko {
|
||||
/// Composite of one or more shaped runs (fallback fonts each add a
|
||||
/// run) laid side by side; `[run_start, run_start + run_count)`.
|
||||
run_start: usize,
|
||||
run_count: usize,
|
||||
scale: f32,
|
||||
},
|
||||
Warichu {
|
||||
/// Two half-size sub-lines stacked side by side within the column:
|
||||
/// runs `[run_start, run_start + first_count)` are the first (right)
|
||||
/// sub-line, the rest up to `run_start + run_count` the second (left).
|
||||
run_start: usize,
|
||||
run_count: usize,
|
||||
first_count: usize,
|
||||
/// UTF-16 length of the first sub-line's text (the split point,
|
||||
/// relative to the cell's `start`).
|
||||
first_chars: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// One placed piece of the vertical flow. Offsets are UTF-16,
|
||||
/// paragraph-relative (all spans concatenated), in original text space.
|
||||
pub struct VerticalCell {
|
||||
pub kind: CellKind,
|
||||
pub paragraph: usize,
|
||||
pub span: usize,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub column: usize,
|
||||
pub top: f32,
|
||||
/// Advance along the column (vertical/flow axis) — from `vmtx` when
|
||||
/// available, else the shaped horizontal advance.
|
||||
pub extent: f32,
|
||||
/// Lower bound for oikomi reductions. This is the glyph frame after font
|
||||
/// features such as `vpal`, before any removable pair spacing is added.
|
||||
pub minimum_oikomi_extent: f32,
|
||||
/// Shaped horizontal glyph advance, used to centre the glyph on the
|
||||
/// column axis (independent of the vertical flow `extent`).
|
||||
pub h_advance: f32,
|
||||
/// Visible glyph-ink edges along the flow axis, relative to `top`.
|
||||
pub ink_top: f32,
|
||||
pub ink_bottom: f32,
|
||||
pub paint: usize,
|
||||
/// Span font size, for decoration bar geometry.
|
||||
pub font_size: f32,
|
||||
/// Span text decoration, painted as vertical bars along the column.
|
||||
pub decoration: Option<TextDecoration>,
|
||||
/// Extra flow-axis (vertical) shift applied to the drawn glyph only, used to
|
||||
/// pull half-width opening punctuation up into its compressed cell so its
|
||||
/// ink hugs the preceding character (jlreq leading aki removal). Zero for
|
||||
/// every other cell; never affects extent, caret, or position-data.
|
||||
pub glyph_flow_shift: f32,
|
||||
}
|
||||
|
||||
/// A laid-out column, x measured from the *content* left edge (the content
|
||||
/// block is anchored to the shape's right edge by consumers).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VerticalColumn {
|
||||
pub x: f32,
|
||||
pub width: f32,
|
||||
/// Reserved annotation gutter before the base band (left / `under`).
|
||||
pub base_offset: f32,
|
||||
/// Line-height-controlled column advance. Base glyphs centre on this band.
|
||||
/// Ruby reserves additional column width but attaches to the centred base
|
||||
/// em, so extra leading does not become base-to-ruby spacing.
|
||||
pub base_width: f32,
|
||||
}
|
||||
|
||||
pub(super) fn column_base_center(column: &VerticalColumn) -> f32 {
|
||||
column.x + column.base_offset + column.base_width / 2.0
|
||||
}
|
||||
|
||||
pub struct VerticalLayout {
|
||||
pub runs: Vec<ShapedRun>,
|
||||
pub paints: Vec<skia::Paint>,
|
||||
pub cells: Vec<VerticalCell>,
|
||||
pub columns: Vec<VerticalColumn>,
|
||||
/// Shaped ruby annotation runs, indexed by `RubyCell::run`.
|
||||
pub ruby_runs: Vec<ShapedRun>,
|
||||
pub ruby_cells: Vec<RubyCell>,
|
||||
/// Shaped emphasis-mark runs (single glyph each), indexed by
|
||||
/// `EmphasisMark::run`.
|
||||
pub emphasis_runs: Vec<ShapedRun>,
|
||||
pub emphasis_marks: Vec<EmphasisMark>,
|
||||
/// Per paragraph: [start, end) range into `columns`.
|
||||
pub paragraph_columns: Vec<(usize, usize)>,
|
||||
/// Per paragraph: UTF-16 start offset of each span (paragraph-relative).
|
||||
pub span_utf16_starts: Vec<Vec<usize>>,
|
||||
/// Per paragraph: source UTF-16 start offset of each span.
|
||||
pub span_source_utf16_starts: Vec<Vec<usize>>,
|
||||
/// Per paragraph and span: transformed scalar ownership in source text.
|
||||
pub span_transforms: Vec<Vec<AppliedTextTransform>>,
|
||||
/// Per paragraph: UTF-16 offset of every Unicode scalar boundary. Editor
|
||||
/// positions use indices into this table, while cells and position data
|
||||
/// keep their browser-facing UTF-16 offsets.
|
||||
pub paragraph_utf16_boundaries: Vec<Vec<usize>>,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl VerticalLayout {
|
||||
/// Content origin (top-left of the laid-out block) in the same
|
||||
/// coordinate space as `bounds`. In vertical-rl, block-start is the
|
||||
/// right edge, block-center is the horizontal center and block-end is
|
||||
/// the left edge.
|
||||
pub fn origin(&self, bounds: &Rect, align: VerticalAlign) -> (f32, f32) {
|
||||
(
|
||||
bounds.left + block_axis_offset(bounds.width(), self.width, align),
|
||||
bounds.top,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal offset of vertical content within its shape. The existing
|
||||
/// top/center/bottom values describe block-start/center/end; for vertical-rl
|
||||
/// those positions map to right/center/left respectively.
|
||||
pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 {
|
||||
let slack = (container_width - content_width).max(0.0);
|
||||
match align {
|
||||
VerticalAlign::Top => slack,
|
||||
VerticalAlign::Center => slack / 2.0,
|
||||
VerticalAlign::Bottom => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The column-wrap limit for a vertical text content: auto-width shapes
|
||||
/// grow to fit (columns never wrap), everything else wraps at the shape
|
||||
/// height. This is the phase's explicit auto-size decision: auto-height
|
||||
/// behaves like fixed under vertical writing for now.
|
||||
pub fn wrap_height(text_content: &TextContent, height: f32) -> f32 {
|
||||
match text_content.grow_type() {
|
||||
GrowType::AutoWidth => f32::MAX,
|
||||
_ => f32::max(height, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-axis geometry shared by every column of a paragraph.
|
||||
struct ColumnGeometry {
|
||||
/// Line-height-controlled column advance the base glyphs centre on.
|
||||
base_width: f32,
|
||||
/// Annotation gutter on the left (`under`) side of the base band.
|
||||
under_gutter: f32,
|
||||
/// Annotation gutter on the right (`over`) side of the base band.
|
||||
over_gutter: f32,
|
||||
}
|
||||
|
||||
impl ColumnGeometry {
|
||||
fn new(paragraph: &Paragraph) -> Self {
|
||||
let spans = paragraph.children();
|
||||
let line_height = if paragraph.line_height() > 0.0 {
|
||||
paragraph.line_height()
|
||||
} else {
|
||||
1.2
|
||||
};
|
||||
let max_font_size = spans.iter().map(|s| s.font_size).fold(12.0, f32::max);
|
||||
|
||||
// Ruby reserves its configured-size gutter on the logical annotation
|
||||
// side. In vertical-rl, `over` is right and `under` is left.
|
||||
let ruby_gutter = |side| {
|
||||
spans
|
||||
.iter()
|
||||
.filter(|span| span.has_ruby() && span.ruby_side == side)
|
||||
.map(TextSpan::ruby_font_size)
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
let ruby_over_gutter = ruby_gutter(RubySide::Over);
|
||||
// Emphasis occupies the over/right side. Auto-clearance spans carrying
|
||||
// both annotation types stack there; opposite-side ruby stays separate.
|
||||
let emphasis_gutter = if spans.iter().any(|s| !s.text_emphasis.is_none()) {
|
||||
max_font_size * EMPHASIS_FONT_SCALE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let stacked = spans
|
||||
.iter()
|
||||
.any(|s| s.stacks_emphasis_outside_ruby() && !s.text_emphasis.is_none());
|
||||
let over_gutter = if stacked {
|
||||
ruby_over_gutter + emphasis_gutter
|
||||
} else {
|
||||
ruby_over_gutter.max(emphasis_gutter)
|
||||
};
|
||||
|
||||
Self {
|
||||
base_width: max_font_size * line_height,
|
||||
under_gutter: ruby_gutter(RubySide::Under),
|
||||
over_gutter,
|
||||
}
|
||||
}
|
||||
|
||||
fn column(&self) -> VerticalColumn {
|
||||
VerticalColumn {
|
||||
x: 0.0,
|
||||
width: self.under_gutter + self.base_width + self.over_gutter,
|
||||
base_offset: self.under_gutter,
|
||||
base_width: self.base_width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph's cells in flow order, before spacing and placement, plus
|
||||
/// the UTF-16 start of every span in the paragraph's layout text.
|
||||
fn build_paragraph_flow(
|
||||
fonts: &Fonts,
|
||||
paragraph_index: usize,
|
||||
paragraph: &Paragraph,
|
||||
transforms: &[AppliedTextTransform],
|
||||
bounds: Rect,
|
||||
runs: &mut Vec<ShapedRun>,
|
||||
paints: &mut Vec<skia::Paint>,
|
||||
) -> (Vec<FlowCell>, Vec<usize>) {
|
||||
let mut flow = Vec::new();
|
||||
let mut span_starts = Vec::with_capacity(transforms.len());
|
||||
let mut offset = 0usize;
|
||||
for (span_index, (span, transform)) in paragraph.children().iter().zip(transforms).enumerate() {
|
||||
span_starts.push(offset);
|
||||
if transform.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
paints.push(merge_fills(&span.fills, bounds));
|
||||
let span_cells = SpanCells::new(
|
||||
fonts,
|
||||
span,
|
||||
paragraph_index,
|
||||
span_index,
|
||||
paints.len() - 1,
|
||||
offset,
|
||||
);
|
||||
span_cells.push(&transform.text, runs, &mut flow);
|
||||
offset += transform.text.encode_utf16().count();
|
||||
}
|
||||
keep_transform_expansions_together(&mut flow, transforms, &span_starts);
|
||||
(flow, span_starts)
|
||||
}
|
||||
|
||||
/// A CSS transform may expand one source character into several shaped
|
||||
/// cells. Keep those cells in one column so a source slice is rendered
|
||||
/// exactly once by the SVG fallback (for example `ß` -> `SS`).
|
||||
fn keep_transform_expansions_together(
|
||||
flow: &mut [FlowCell],
|
||||
transforms: &[AppliedTextTransform],
|
||||
span_starts: &[usize],
|
||||
) {
|
||||
let source_range = |cell: &VerticalCell| {
|
||||
let start = span_starts[cell.span];
|
||||
transforms[cell.span].source_utf16_range(cell.start - start..cell.end - start)
|
||||
};
|
||||
for index in 1..flow.len() {
|
||||
let (previous, current) = (&flow[index - 1].cell, &flow[index].cell);
|
||||
flow[index].keep_with_previous =
|
||||
previous.span == current.span && source_range(previous) == source_range(current);
|
||||
}
|
||||
}
|
||||
|
||||
/// Final flow-axis top of every cell: its planned offset, shifted by the
|
||||
/// paragraph's text-align. Justify stretches every column but the last (the
|
||||
/// last "line") to fill the wrap budget; a snug auto-width budget has no
|
||||
/// slack.
|
||||
fn aligned_tops(
|
||||
flow: &[FlowCell],
|
||||
classes: &[Option<JapaneseClass>],
|
||||
pair_spacing_em: &[f32],
|
||||
placements: &[(usize, f32)],
|
||||
align: TextAlign,
|
||||
max_height: f32,
|
||||
) -> Vec<f32> {
|
||||
let columns_used = placements.last().map_or(1, |(column, _)| column + 1);
|
||||
// A column's used length is its farthest cell bottom.
|
||||
let mut column_used = vec![0.0f32; columns_used];
|
||||
for (flow, (column, top)) in flow.iter().zip(placements) {
|
||||
column_used[*column] = column_used[*column].max(top + flow.cell.extent);
|
||||
}
|
||||
let expansion = if matches!(align, TextAlign::Justify) && is_bounded(max_height) {
|
||||
ordered_expansion_offsets(
|
||||
flow,
|
||||
classes,
|
||||
pair_spacing_em,
|
||||
placements,
|
||||
&column_used,
|
||||
max_height,
|
||||
)
|
||||
} else {
|
||||
vec![0.0; flow.len()]
|
||||
};
|
||||
placements
|
||||
.iter()
|
||||
.zip(expansion)
|
||||
.map(|((column, top), extra)| {
|
||||
top + align_offset_along_column(align, max_height, column_used[*column]) + extra
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lay out the whole content vertically. Pure of global state: fonts come
|
||||
/// through the provider/fallback arguments so native tests can supply
|
||||
/// their own.
|
||||
pub fn layout_vertical(
|
||||
text_content: &TextContent,
|
||||
max_height: f32,
|
||||
font_provider: &TypefaceFontProvider,
|
||||
fallback_mgr: FontMgr,
|
||||
fallback_families: &[String],
|
||||
bounds: Rect,
|
||||
) -> VerticalLayout {
|
||||
let fonts = Fonts {
|
||||
provider: font_provider,
|
||||
fallback_mgr: &fallback_mgr,
|
||||
fallback_families,
|
||||
};
|
||||
let paragraphs = text_content.paragraphs();
|
||||
let span_transforms: Vec<Vec<AppliedTextTransform>> = paragraphs
|
||||
.iter()
|
||||
.map(|paragraph| {
|
||||
paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.map(TextSpan::apply_text_transform_with_source_ranges)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let paragraph_utf16_boundaries: Vec<Vec<usize>> = span_transforms
|
||||
.iter()
|
||||
.map(|paragraph| {
|
||||
let mut boundaries = vec![0usize];
|
||||
for character in paragraph.iter().flat_map(|span| span.text.chars()) {
|
||||
let next = boundaries.last().copied().unwrap_or(0) + character.len_utf16();
|
||||
boundaries.push(next);
|
||||
}
|
||||
boundaries
|
||||
})
|
||||
.collect();
|
||||
let span_source_utf16_starts: Vec<Vec<usize>> = paragraphs
|
||||
.iter()
|
||||
.map(|paragraph| {
|
||||
let mut offset = 0usize;
|
||||
paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| {
|
||||
let start = offset;
|
||||
offset += span.text.encode_utf16().count();
|
||||
start
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut runs: Vec<ShapedRun> = Vec::new();
|
||||
let mut paints: Vec<skia::Paint> = Vec::new();
|
||||
let mut cells: Vec<VerticalCell> = Vec::new();
|
||||
let mut columns: Vec<VerticalColumn> = Vec::new();
|
||||
let mut paragraph_columns: Vec<(usize, usize)> = Vec::new();
|
||||
let mut span_utf16_starts: Vec<Vec<usize>> = Vec::new();
|
||||
|
||||
for (paragraph_index, paragraph) in paragraphs.iter().enumerate() {
|
||||
let transforms = &span_transforms[paragraph_index];
|
||||
let (mut flow, span_starts) = build_paragraph_flow(
|
||||
&fonts,
|
||||
paragraph_index,
|
||||
paragraph,
|
||||
transforms,
|
||||
bounds,
|
||||
&mut runs,
|
||||
&mut paints,
|
||||
);
|
||||
let ruby_units = ruby_base_units(paragraph, transforms, &span_starts);
|
||||
let ruby_spans: Vec<bool> = paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.map(TextSpan::has_ruby)
|
||||
.collect();
|
||||
|
||||
apply_inter_script_spacing(&mut flow);
|
||||
let classes = flow_classes(&flow, &ruby_spans);
|
||||
shed_punctuation_aki(&mut flow, &classes);
|
||||
materialize_explicit_pair_spacing(&mut flow, &classes);
|
||||
grow_ruby_bases(&mut flow, &ruby_units);
|
||||
let mut pair_spacing_em = preferred_pair_spacing(&classes);
|
||||
apply_ordered_oikomi(&mut flow, &classes, &mut pair_spacing_em, max_height);
|
||||
let placements =
|
||||
plan_with_edge_trimming(&mut flow, &classes, &mut pair_spacing_em, max_height);
|
||||
let tops = aligned_tops(
|
||||
&flow,
|
||||
&classes,
|
||||
&pair_spacing_em,
|
||||
&placements,
|
||||
paragraph.text_align(),
|
||||
max_height,
|
||||
);
|
||||
|
||||
let columns_used = placements.last().map_or(1, |(column, _)| column + 1);
|
||||
let column_base = columns.len();
|
||||
columns.extend(std::iter::repeat_n(
|
||||
ColumnGeometry::new(paragraph).column(),
|
||||
columns_used,
|
||||
));
|
||||
paragraph_columns.push((column_base, column_base + columns_used));
|
||||
|
||||
let paragraph_cell_start = cells.len();
|
||||
for ((flow, (column, _)), top) in flow.into_iter().zip(placements).zip(tops) {
|
||||
cells.push(VerticalCell {
|
||||
column: column_base + column,
|
||||
top,
|
||||
..flow.cell
|
||||
});
|
||||
}
|
||||
spread_ruby_base_cells(&mut cells[paragraph_cell_start..], &ruby_units, max_height);
|
||||
span_utf16_starts.push(span_starts);
|
||||
}
|
||||
|
||||
// Columns advance right->left: column 0 is the rightmost.
|
||||
let width: f32 = columns.iter().map(|c| c.width).sum();
|
||||
let mut right = width;
|
||||
for column in columns.iter_mut() {
|
||||
right -= column.width;
|
||||
column.x = right;
|
||||
}
|
||||
|
||||
let (ruby_runs, ruby_cells) = layout_ruby(text_content, &cells, &fonts);
|
||||
let (emphasis_runs, emphasis_marks) =
|
||||
layout_emphasis(text_content, &cells, &span_utf16_starts, &fonts);
|
||||
|
||||
let height = cells
|
||||
.iter()
|
||||
.map(|c| c.top + c.extent)
|
||||
.fold(0.0f32, f32::max);
|
||||
|
||||
VerticalLayout {
|
||||
runs,
|
||||
paints,
|
||||
cells,
|
||||
columns,
|
||||
ruby_runs,
|
||||
ruby_cells,
|
||||
emphasis_runs,
|
||||
emphasis_marks,
|
||||
paragraph_columns,
|
||||
span_utf16_starts,
|
||||
span_source_utf16_starts,
|
||||
span_transforms,
|
||||
paragraph_utf16_boundaries,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out with the render state's font store.
|
||||
fn layout_from_content(text_content: &TextContent, max_height: f32) -> VerticalLayout {
|
||||
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();
|
||||
layout_vertical(
|
||||
text_content,
|
||||
max_height,
|
||||
font_provider,
|
||||
fallback_mgr,
|
||||
&fallback_families,
|
||||
text_content.bounds(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Production entry point: lay out the content for a box `height` tall.
|
||||
pub fn layout_for_box(text_content: &TextContent, height: f32) -> VerticalLayout {
|
||||
layout_from_content(text_content, wrap_height(text_content, height))
|
||||
}
|
||||
|
||||
/// Content size (width, height) of the vertical layout for a box `height`
|
||||
/// tall, for auto-sizing.
|
||||
pub fn measure_content(text_content: &TextContent, height: f32) -> (f32, f32) {
|
||||
let layout = layout_for_box(text_content, height);
|
||||
(layout.width, layout.height)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_cells_tile_the_text() {
|
||||
let text = "縦書きのAB12テスト。";
|
||||
let content = make_content(&[text], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
|
||||
let mut expected = 0usize;
|
||||
for cell in &layout.cells {
|
||||
assert_eq!(cell.paragraph, 0);
|
||||
assert_eq!(cell.start, expected, "cells must tile without gaps");
|
||||
assert!(cell.end > cell.start);
|
||||
expected = cell.end;
|
||||
}
|
||||
assert_eq!(expected, text.encode_utf16().count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_columns_respect_wrap_height() {
|
||||
let content = make_content(&["あいうえおかきくけこ"], 100.0);
|
||||
let layout = layout_content(&content, 60.0);
|
||||
|
||||
assert!(layout.columns.len() > 1, "content must wrap into columns");
|
||||
for column_index in 0..layout.columns.len() {
|
||||
let bottom = layout
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|c| c.column == column_index)
|
||||
.map(|c| c.top + c.extent)
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(bottom <= 60.0 + 0.01, "column overflows the wrap height");
|
||||
}
|
||||
// Columns advance leftward: column 0 is the rightmost.
|
||||
assert!(layout.columns[0].x > layout.columns[1].x);
|
||||
let total: f32 = layout.columns.iter().map(|c| c.width).sum();
|
||||
assert!((layout.width - total).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_each_paragraph_starts_a_new_column() {
|
||||
let content = make_content(&["あい", "うえ"], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
|
||||
assert_eq!(layout.paragraph_columns.len(), 2);
|
||||
let (p0_start, p0_end) = layout.paragraph_columns[0];
|
||||
let (p1_start, _) = layout.paragraph_columns[1];
|
||||
assert_eq!(p0_start, 0);
|
||||
assert_eq!(p0_end, p1_start);
|
||||
assert!(layout
|
||||
.cells
|
||||
.iter()
|
||||
.all(|c| (c.paragraph == 0) == (c.column < p0_end)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_empty_paragraph_still_takes_a_column() {
|
||||
let content = make_content(&["あ", "", "い"], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
assert_eq!(layout.columns.len(), 3);
|
||||
assert_eq!(layout.paragraph_columns[1], (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_axis_alignment_maps_start_center_end_to_right_center_left() {
|
||||
assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Top), 160.0);
|
||||
assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Center), 80.0);
|
||||
assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Bottom), 0.0);
|
||||
assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_vertical_content_grows_across_columns() {
|
||||
let content = make_content(&["あいうえおかきくけこ"], 60.0);
|
||||
let layout = layout_content(&content, 60.0);
|
||||
assert!(layout.columns.len() > 1);
|
||||
assert!(layout.width > layout.columns[0].width);
|
||||
assert!(layout.height <= 60.0 + 0.01);
|
||||
}
|
||||
}
|
||||
52
render-wasm/src/shapes/text_vertical/mod.rs
Normal file
52
render-wasm/src/shapes/text_vertical/mod.rs
Normal file
@ -0,0 +1,52 @@
|
||||
// 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.
|
||||
//
|
||||
// Offset discipline: 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 remains exclusive to
|
||||
// skparagraph-driven horizontal breaks.
|
||||
//
|
||||
// Layouts are computed on demand (like the horizontal path, which 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 inter-glyph advance along the column — once per
|
||||
// upright cluster and once per glyph inside a rotated run — mirroring the
|
||||
// horizontal `letter-spacing` that Skia applies to each glyph advance.
|
||||
//
|
||||
// Deferred to a later phase: PDF/vector emoji overlays, inner shadows and
|
||||
// block-axis vertical-align.
|
||||
|
||||
mod annotations;
|
||||
mod cells;
|
||||
mod flow;
|
||||
mod font_tables;
|
||||
mod layout;
|
||||
mod orientation;
|
||||
mod paint;
|
||||
mod positions;
|
||||
mod shaping;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
pub(crate) use annotations::distribute_ruby_tops;
|
||||
pub use layout::{block_axis_offset, layout_for_box, measure_content, VerticalLayout};
|
||||
pub use paint::{
|
||||
paint_drop_shadow, paint_grid, paint_stroke, paint_text_vertical, vertical_text_paths,
|
||||
};
|
||||
pub use positions::{caret_from_point, caret_rect, intersects, position_data, range_rects};
|
||||
pub(crate) use shaping::{shape_segment_with_fallbacks, single_glyph_blob, span_font_families};
|
||||
182
render-wasm/src/shapes/text_vertical/orientation.rs
Normal file
182
render-wasm/src/shapes/text_vertical/orientation.rs
Normal file
@ -0,0 +1,182 @@
|
||||
use crate::shapes::TextOrientation;
|
||||
|
||||
/// Emoji ranges recognized by the frontend font-loader. Emoji use their
|
||||
/// intrinsic upright presentation in vertical flow instead of rotating like
|
||||
/// Latin text under `text-orientation: mixed`.
|
||||
pub(super) 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(super) 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. `vert` /
|
||||
/// `vrt2` normally supplies that alternate; when the selected face has no
|
||||
/// such substitution, rotate the horizontal glyph clockwise as a legible
|
||||
/// fallback. The set covers UAX #50 `Tr`, plus comma/full-stop punctuation
|
||||
/// whose untransformed glyph otherwise occupies 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 keep a shaping boundary between
|
||||
// them so the segment probe can explicitly select the emoji family.
|
||||
// The provider's generic fallback iterator does not reliably switch
|
||||
// from a registered CJK face to a registered 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());
|
||||
}
|
||||
}
|
||||
779
render-wasm/src/shapes/text_vertical/paint.rs
Normal file
779
render-wasm/src/shapes/text_vertical/paint.rs
Normal file
@ -0,0 +1,779 @@
|
||||
use skia_safe::{
|
||||
self as skia, canvas::SaveLayerRec, textlayout::Paragraph as SkiaParagraph,
|
||||
textlayout::TextDecoration, BlendMode, Canvas, ImageFilter, Matrix, Paint, Point as SkPoint,
|
||||
TextBlob,
|
||||
};
|
||||
|
||||
use crate::math::Rect;
|
||||
use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign};
|
||||
|
||||
use super::annotations::{emphasis_gutter_center, ruby_strip_x, EmphasisMark, RubyCell};
|
||||
use super::font_tables::upright_baseline;
|
||||
use super::layout::{column_base_center, layout_for_box, CellKind, VerticalCell, VerticalLayout};
|
||||
use super::shaping::{single_glyph_blob, ShapedRun};
|
||||
|
||||
/// One text blob placed in document space: drawn at `offset` in the local
|
||||
/// space that `transform` maps to the document (identity when `None`).
|
||||
/// Canvas painting and outline export consume the same draws, so both stay
|
||||
/// in sync for every cell kind.
|
||||
struct GlyphDraw {
|
||||
blob: TextBlob,
|
||||
offset: SkPoint,
|
||||
transform: Option<Matrix>,
|
||||
}
|
||||
|
||||
impl GlyphDraw {
|
||||
fn at(blob: TextBlob, offset: impl Into<SkPoint>) -> Self {
|
||||
Self {
|
||||
blob,
|
||||
offset: offset.into(),
|
||||
transform: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transformed(blob: TextBlob, offset: impl Into<SkPoint>, transform: Matrix) -> Self {
|
||||
Self {
|
||||
blob,
|
||||
offset: offset.into(),
|
||||
transform: Some(transform),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&self, canvas: &Canvas, paint: &Paint) {
|
||||
match &self.transform {
|
||||
None => {
|
||||
canvas.draw_text_blob(&self.blob, self.offset, paint);
|
||||
}
|
||||
Some(transform) => {
|
||||
canvas.save();
|
||||
canvas.concat(transform);
|
||||
canvas.draw_text_blob(&self.blob, self.offset, paint);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_path(self) -> skia::Path {
|
||||
let path = text_blob_path(self.blob, self.offset);
|
||||
match self.transform {
|
||||
None => path,
|
||||
Some(transform) => path.make_transform(&transform),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyphs on their side: +x of the blob runs down the column from `origin`.
|
||||
fn rotated_at(origin: (f32, f32)) -> Matrix {
|
||||
let mut matrix = Matrix::translate(origin);
|
||||
matrix.pre_rotate(90.0, None);
|
||||
matrix
|
||||
}
|
||||
|
||||
/// Glyph draws of one cell, for a layout whose content origin is `origin`.
|
||||
fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32)) -> Vec<GlyphDraw> {
|
||||
let column = &layout.columns[cell.column];
|
||||
let x_center = origin.0 + column_base_center(column);
|
||||
let y_top = origin.1 + cell.top;
|
||||
match cell.kind {
|
||||
CellKind::Upright { run, glyph, count } => {
|
||||
let run = &layout.runs[run];
|
||||
let baseline =
|
||||
y_top + cell.glyph_flow_shift + upright_baseline(&run.font, cell.font_size);
|
||||
run.cluster_blob(glyph, count)
|
||||
.map(|blob| GlyphDraw::at(blob, (x_center - cell.h_advance / 2.0, baseline)))
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
CellKind::SyntheticRotated { run, glyph, count } => {
|
||||
let run = &layout.runs[run];
|
||||
run.cluster_blob(glyph, count)
|
||||
.map(|blob| {
|
||||
GlyphDraw::transformed(
|
||||
blob,
|
||||
(0.0, run.cluster_rotated_baseline_shift(glyph, count)),
|
||||
rotated_at((x_center, y_top + cell.glyph_flow_shift)),
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
CellKind::Rotated { run } => {
|
||||
let run = &layout.runs[run];
|
||||
// After rotation +x runs down the column and +y runs across it;
|
||||
// the shift centres the run's actual ink band on the column axis.
|
||||
run.blob()
|
||||
.map(|blob| {
|
||||
GlyphDraw::transformed(
|
||||
blob,
|
||||
(0.0, run.rotated_baseline_shift),
|
||||
rotated_at((x_center, y_top)),
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
CellKind::TateChuYoko {
|
||||
run_start,
|
||||
run_count,
|
||||
scale,
|
||||
} => {
|
||||
let composite = &layout.runs[run_start..run_start + run_count];
|
||||
let combined_advance: f32 = composite.iter().map(|run| run.advance).sum();
|
||||
// Vertical centring uses the tallest run's ascent/descent.
|
||||
let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| {
|
||||
let (_, metrics) = run.font.metrics();
|
||||
(a.min(metrics.ascent), d.max(metrics.descent))
|
||||
});
|
||||
let x0 = x_center - (combined_advance * scale) / 2.0;
|
||||
let baseline = y_top + cell.font_size / 2.0 - ((ascent + descent) * scale) / 2.0;
|
||||
let mut transform = Matrix::translate((x0, baseline));
|
||||
transform.pre_scale((scale, scale), None);
|
||||
let mut cursor = 0.0f32;
|
||||
let mut draws = Vec::with_capacity(composite.len());
|
||||
for run in composite {
|
||||
if let Some(blob) = run.blob() {
|
||||
draws.push(GlyphDraw::transformed(blob, (cursor, 0.0), transform));
|
||||
}
|
||||
cursor += run.advance;
|
||||
}
|
||||
draws
|
||||
}
|
||||
CellKind::Warichu {
|
||||
run_start,
|
||||
run_count,
|
||||
first_count,
|
||||
..
|
||||
} => {
|
||||
let (first, second) =
|
||||
layout.runs[run_start..run_start + run_count].split_at(first_count);
|
||||
let quarter = cell.font_size / 4.0;
|
||||
// vertical-rl: the first sub-line reads first, on the right half.
|
||||
let mut draws = warichu_line_draws(first, x_center + quarter, y_top);
|
||||
draws.extend(warichu_line_draws(second, x_center - quarter, y_top));
|
||||
draws
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One warichu sub-line: upright half-size glyphs stacked down the
|
||||
/// sub-column centred on `x_center`, clusters kept together like the normal
|
||||
/// upright path.
|
||||
fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec<GlyphDraw> {
|
||||
let mut draws = Vec::new();
|
||||
let mut cursor = y_top;
|
||||
for run in runs {
|
||||
let font_size = run.font.size();
|
||||
let baseline_offset = upright_baseline(&run.font, font_size);
|
||||
for (glyph, count) in run.cluster_spans() {
|
||||
let advance = match run.cluster_advance(glyph, count) {
|
||||
advance if advance > 0.0 => advance,
|
||||
_ => font_size,
|
||||
};
|
||||
if let Some(blob) = run.cluster_blob(glyph, count) {
|
||||
draws.push(GlyphDraw::at(
|
||||
blob,
|
||||
(x_center - advance / 2.0, cursor + baseline_offset),
|
||||
));
|
||||
}
|
||||
cursor += advance;
|
||||
}
|
||||
}
|
||||
draws
|
||||
}
|
||||
|
||||
/// One column's slice of a ruby annotation stacked upright in its column's
|
||||
/// ruby gutter. Each glyph's flow-axis position comes from the whole base
|
||||
/// span; each glyph is centred across the gutter using its own
|
||||
/// fallback-font run.
|
||||
fn ruby_draws(layout: &VerticalLayout, ruby: &RubyCell, origin: (f32, f32)) -> Vec<GlyphDraw> {
|
||||
let column = &layout.columns[ruby.column];
|
||||
let gutter_center = origin.0
|
||||
+ ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side)
|
||||
+ ruby.font_size / 2.0;
|
||||
ruby.glyphs
|
||||
.iter()
|
||||
.zip(&ruby.glyph_tops)
|
||||
.filter_map(|(ruby_glyph, top)| {
|
||||
let run = &layout.ruby_runs[ruby_glyph.run];
|
||||
let blob = single_glyph_blob(&run.font, *run.glyphs.get(ruby_glyph.glyph)?)?;
|
||||
let (_, metrics) = run.font.metrics();
|
||||
let advance = run
|
||||
.advances
|
||||
.get(ruby_glyph.glyph)
|
||||
.copied()
|
||||
.unwrap_or(ruby.font_size);
|
||||
Some(GlyphDraw::at(
|
||||
blob,
|
||||
(
|
||||
gutter_center - advance / 2.0,
|
||||
origin.1 + top - metrics.ascent,
|
||||
),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One emphasis mark (圏点) centred on its base cell's flow extent, in the
|
||||
/// column's right-side gutter.
|
||||
fn emphasis_draw(
|
||||
layout: &VerticalLayout,
|
||||
mark: &EmphasisMark,
|
||||
origin: (f32, f32),
|
||||
) -> Option<GlyphDraw> {
|
||||
let run = &layout.emphasis_runs[mark.run];
|
||||
let blob = single_glyph_blob(&run.font, *run.glyphs.first()?)?;
|
||||
let gutter_center = origin.0 + emphasis_gutter_center(&layout.columns[mark.column], mark);
|
||||
let (_, metrics) = run.font.metrics();
|
||||
let cell_center = origin.1 + mark.top + mark.extent / 2.0;
|
||||
let baseline = cell_center - (metrics.ascent + metrics.descent) / 2.0;
|
||||
let advance = run.advances.first().copied().unwrap_or(0.0);
|
||||
Some(GlyphDraw::at(
|
||||
blob,
|
||||
(gutter_center - advance / 2.0, baseline),
|
||||
))
|
||||
}
|
||||
|
||||
/// Underline and line-through bars of a cell.
|
||||
fn decoration_rects(
|
||||
layout: &VerticalLayout,
|
||||
cell: &VerticalCell,
|
||||
origin: (f32, f32),
|
||||
) -> Vec<skia::Rect> {
|
||||
let Some(decoration) = cell.decoration else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rects = Vec::new();
|
||||
if decoration.contains(TextDecoration::UNDERLINE) {
|
||||
rects.push(decoration_bar(layout, cell, origin.0, origin.1, false));
|
||||
}
|
||||
if decoration.contains(TextDecoration::LINE_THROUGH) {
|
||||
rects.push(decoration_bar(layout, cell, origin.0, origin.1, true));
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
/// Fill pass: each cell drawn with its own fill paint and its decorations,
|
||||
/// then ruby and emphasis marks with their base cell's paint.
|
||||
fn paint_layout(
|
||||
canvas: &Canvas,
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
) {
|
||||
let origin = layout.origin(bounds, vertical_align);
|
||||
for cell in &layout.cells {
|
||||
let paint = &layout.paints[cell.paint];
|
||||
for draw in cell_draws(layout, cell, origin) {
|
||||
draw.draw(canvas, paint);
|
||||
}
|
||||
let mut decoration_paint = paint.clone();
|
||||
decoration_paint.set_style(skia::PaintStyle::Fill);
|
||||
decoration_paint.set_anti_alias(true);
|
||||
for rect in decoration_rects(layout, cell, origin) {
|
||||
canvas.draw_rect(rect, &decoration_paint);
|
||||
}
|
||||
}
|
||||
for ruby in &layout.ruby_cells {
|
||||
for draw in ruby_draws(layout, ruby, origin) {
|
||||
draw.draw(canvas, &layout.paints[ruby.paint]);
|
||||
}
|
||||
}
|
||||
for mark in &layout.emphasis_marks {
|
||||
if let Some(draw) = emphasis_draw(layout, mark, origin) {
|
||||
draw.draw(canvas, &layout.paints[mark.paint]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint every cell's glyphs with a single overriding paint (stroke /
|
||||
/// shadow silhouette passes).
|
||||
fn paint_glyphs(
|
||||
canvas: &Canvas,
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
paint: &Paint,
|
||||
) {
|
||||
let origin = layout.origin(bounds, vertical_align);
|
||||
for cell in &layout.cells {
|
||||
for draw in cell_draws(layout, cell, origin) {
|
||||
draw.draw(canvas, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the text content vertically inside its bounds. Returns false
|
||||
/// when the content is not vertical, so the caller falls back to the
|
||||
/// horizontal path.
|
||||
pub fn paint_text_vertical(
|
||||
canvas: &Canvas,
|
||||
text_content: &TextContent,
|
||||
vertical_align: VerticalAlign,
|
||||
) -> bool {
|
||||
if !text_content.is_vertical() {
|
||||
return false;
|
||||
}
|
||||
let bounds = text_content.bounds();
|
||||
let layout = layout_for_box(text_content, bounds.height());
|
||||
paint_layout(canvas, &layout, &bounds, vertical_align);
|
||||
true
|
||||
}
|
||||
|
||||
/// Glyph-outline paths of the layout, in the same order and with the same
|
||||
/// paints as the canvas fill pass.
|
||||
fn paths_from_layout(
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
antialias: bool,
|
||||
) -> Vec<(skia::Path, Paint)> {
|
||||
let mut paths = Vec::new();
|
||||
let origin = layout.origin(bounds, vertical_align);
|
||||
for cell in &layout.cells {
|
||||
let paint = &layout.paints[cell.paint];
|
||||
for draw in cell_draws(layout, cell, origin) {
|
||||
push_text_path(&mut paths, draw.into_path(), paint, antialias);
|
||||
}
|
||||
for rect in decoration_rects(layout, cell, origin) {
|
||||
push_text_path(&mut paths, skia::Path::rect(rect, None), paint, antialias);
|
||||
}
|
||||
}
|
||||
for ruby in &layout.ruby_cells {
|
||||
for draw in ruby_draws(layout, ruby, origin) {
|
||||
push_text_path(
|
||||
&mut paths,
|
||||
draw.into_path(),
|
||||
&layout.paints[ruby.paint],
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
}
|
||||
for mark in &layout.emphasis_marks {
|
||||
if let Some(draw) = emphasis_draw(layout, mark, origin) {
|
||||
push_text_path(
|
||||
&mut paths,
|
||||
draw.into_path(),
|
||||
&layout.paints[mark.paint],
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Convert the custom vertical layout to glyph-outline paths using the same
|
||||
/// cells, composite transforms, annotations and alignment as the canvas pass.
|
||||
pub fn vertical_text_paths(
|
||||
text_content: &TextContent,
|
||||
vertical_align: VerticalAlign,
|
||||
antialias: bool,
|
||||
) -> Vec<(skia::Path, Paint)> {
|
||||
if !text_content.is_vertical() {
|
||||
return Vec::new();
|
||||
}
|
||||
let bounds = text_content.bounds();
|
||||
let layout = layout_for_box(text_content, bounds.height());
|
||||
paths_from_layout(&layout, &bounds, vertical_align, antialias)
|
||||
}
|
||||
|
||||
/// Developer overlay: draw a jlreq-style character-frame grid over the
|
||||
/// laid-out vertical cells. For every column it outlines the column band;
|
||||
/// for every cell it draws the advance box (the real layout cell), the
|
||||
/// virtual body / em square centred on the column axis, and the glyph-ink
|
||||
/// band. This exposes solid-setting, aki, letter-spacing and column
|
||||
/// planning visually, mirroring the grids in the jlreq figures. It is never
|
||||
/// emitted to exported or persisted output — only the on-screen fills pass
|
||||
/// calls it, gated by the `TEXT_GRID_VISIBLE` render flag.
|
||||
pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align: VerticalAlign) {
|
||||
let (origin_x, origin_y) = layout.origin(bounds, align);
|
||||
|
||||
let mut column_paint = Paint::default();
|
||||
column_paint.set_anti_alias(true);
|
||||
column_paint.set_style(skia::PaintStyle::Stroke);
|
||||
column_paint.set_stroke_width(1.0);
|
||||
column_paint.set_color(skia::Color::from_argb(0x55, 0x88, 0x88, 0x88));
|
||||
|
||||
let mut advance_paint = Paint::default();
|
||||
advance_paint.set_anti_alias(true);
|
||||
advance_paint.set_style(skia::PaintStyle::Stroke);
|
||||
advance_paint.set_stroke_width(1.0);
|
||||
advance_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED));
|
||||
|
||||
let mut em_paint = Paint::default();
|
||||
em_paint.set_anti_alias(true);
|
||||
em_paint.set_style(skia::PaintStyle::Stroke);
|
||||
em_paint.set_stroke_width(1.0);
|
||||
em_paint.set_color(skia::Color::from_argb(0x99, 0xEB, 0x57, 0x57));
|
||||
|
||||
let mut ink_paint = Paint::default();
|
||||
ink_paint.set_anti_alias(true);
|
||||
ink_paint.set_style(skia::PaintStyle::Stroke);
|
||||
ink_paint.set_stroke_width(1.0);
|
||||
ink_paint.set_color(skia::Color::from_argb(0x77, 0x27, 0xAE, 0x60));
|
||||
|
||||
// Column bands over the full used height.
|
||||
for column in &layout.columns {
|
||||
let x = origin_x + column.x;
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(x, origin_y, column.width, layout.height),
|
||||
&column_paint,
|
||||
);
|
||||
}
|
||||
|
||||
for cell in &layout.cells {
|
||||
let column = &layout.columns[cell.column];
|
||||
let x_left = origin_x + column.x;
|
||||
let y_top = origin_y + cell.top;
|
||||
let x_center = origin_x + column_base_center(column);
|
||||
|
||||
// Advance box: the real layout cell along the column axis.
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(x_left, y_top, column.base_width, cell.extent),
|
||||
&advance_paint,
|
||||
);
|
||||
|
||||
// Virtual body / em square, centred on the column axis and on the
|
||||
// cell's advance so aki and letter-spacing are visible.
|
||||
let em = cell.font_size.max(1.0);
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(
|
||||
x_center - em / 2.0,
|
||||
y_top + (cell.extent - em) / 2.0,
|
||||
em,
|
||||
em,
|
||||
),
|
||||
&em_paint,
|
||||
);
|
||||
|
||||
// Glyph-ink band along the flow axis.
|
||||
if cell.ink_bottom > cell.ink_top {
|
||||
canvas.draw_line(
|
||||
(x_left, origin_y + cell.top + cell.ink_top),
|
||||
(x_left + column.width, origin_y + cell.top + cell.ink_top),
|
||||
&ink_paint,
|
||||
);
|
||||
canvas.draw_line(
|
||||
(x_left, origin_y + cell.top + cell.ink_bottom),
|
||||
(x_left + column.width, origin_y + cell.top + cell.ink_bottom),
|
||||
&ink_paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the vertical glyph shadows. The shadow paint carries the
|
||||
/// blur/offset image filter; drawing the glyphs directly through it renders
|
||||
/// each glyph's shadow without allocating a full-content offscreen layer
|
||||
/// (a `save_layer` with the filter can exceed GPU limits on tall columns).
|
||||
pub fn paint_drop_shadow(
|
||||
canvas: &Canvas,
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
shadow_paint: &Paint,
|
||||
) {
|
||||
let mut paint = shadow_paint.clone();
|
||||
paint.set_color(skia::Color::BLACK);
|
||||
paint.set_anti_alias(true);
|
||||
paint_glyphs(canvas, layout, bounds, vertical_align, &paint);
|
||||
}
|
||||
|
||||
/// Paint a stroke masked to the vertical glyph silhouettes. Center strokes
|
||||
/// draw the stroked outline directly; inner/outer strokes are masked with
|
||||
/// `SrcIn` / `SrcOut` against the glyph silhouette (mirrors the horizontal
|
||||
/// masked-stroke path).
|
||||
pub fn paint_stroke(
|
||||
canvas: &Canvas,
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
stroke: &Stroke,
|
||||
selrect: &Rect,
|
||||
blur: Option<&ImageFilter>,
|
||||
) {
|
||||
let (stroke_paints, layer_opacity) =
|
||||
crate::render::text::get_text_stroke_paints(stroke, selrect, false);
|
||||
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut blur_paint = Paint::default();
|
||||
blur_paint.set_image_filter(blur_filter.clone());
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint));
|
||||
}
|
||||
if let Some(opacity) = layer_opacity {
|
||||
let mut opacity_paint = Paint::default();
|
||||
opacity_paint.set_alpha_f(opacity);
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint));
|
||||
}
|
||||
|
||||
for stroke_paint in &stroke_paints {
|
||||
match stroke.kind {
|
||||
StrokeKind::Center => {
|
||||
paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint)
|
||||
}
|
||||
StrokeKind::Inner => paint_masked_stroke(
|
||||
canvas,
|
||||
layout,
|
||||
bounds,
|
||||
vertical_align,
|
||||
stroke_paint,
|
||||
BlendMode::SrcIn,
|
||||
),
|
||||
StrokeKind::Outer => paint_masked_stroke(
|
||||
canvas,
|
||||
layout,
|
||||
bounds,
|
||||
vertical_align,
|
||||
stroke_paint,
|
||||
BlendMode::SrcOut,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if layer_opacity.is_some() {
|
||||
canvas.restore();
|
||||
}
|
||||
if blur.is_some() {
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_masked_stroke(
|
||||
canvas: &Canvas,
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
stroke_paint: &Paint,
|
||||
blend: BlendMode,
|
||||
) {
|
||||
let mut mask = Paint::default();
|
||||
mask.set_color(skia::Color::BLACK);
|
||||
mask.set_anti_alias(true);
|
||||
|
||||
canvas.save_layer(&SaveLayerRec::default());
|
||||
paint_glyphs(canvas, layout, bounds, vertical_align, &mask);
|
||||
|
||||
let mut blend_paint = Paint::default();
|
||||
blend_paint.set_blend_mode(blend);
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint));
|
||||
paint_glyphs(canvas, layout, bounds, vertical_align, stroke_paint);
|
||||
canvas.restore();
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
fn text_blob_path(mut blob: TextBlob, offset: impl Into<SkPoint>) -> skia::Path {
|
||||
// SkParagraph normalizes extracted glyph outlines against the blob's ink
|
||||
// bounds. Restore that origin before applying the draw offset so the path
|
||||
// occupies the same document coordinates as Canvas::draw_text_blob.
|
||||
let bounds = *blob.bounds();
|
||||
let offset = offset.into();
|
||||
SkiaParagraph::get_path(&mut blob).with_offset((offset.x + bounds.left, offset.y + bounds.top))
|
||||
}
|
||||
|
||||
fn push_text_path(
|
||||
paths: &mut Vec<(skia::Path, Paint)>,
|
||||
path: skia::Path,
|
||||
paint: &Paint,
|
||||
antialias: bool,
|
||||
) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut paint = paint.clone();
|
||||
paint.set_anti_alias(antialias);
|
||||
paths.push((path, paint));
|
||||
}
|
||||
|
||||
/// Decoration bar geometry for a cell in absolute coordinates. Underline
|
||||
/// runs along the *left* side of the column (the under side in
|
||||
/// `vertical-rl`); line-through runs down the column centre. Both span the
|
||||
/// cell's vertical extent so consecutive decorated cells tile a continuous
|
||||
/// bar.
|
||||
fn decoration_bar(
|
||||
layout: &VerticalLayout,
|
||||
cell: &VerticalCell,
|
||||
origin_x: f32,
|
||||
origin_y: f32,
|
||||
line_through: bool,
|
||||
) -> skia::Rect {
|
||||
let column = &layout.columns[cell.column];
|
||||
let x_center = origin_x + column_base_center(column);
|
||||
let y_top = origin_y + cell.top;
|
||||
let thickness = (cell.font_size * 0.06).max(1.0);
|
||||
let bar_x = if line_through {
|
||||
x_center
|
||||
} else {
|
||||
x_center - cell.font_size / 2.0 - cell.font_size / 9.0
|
||||
};
|
||||
skia::Rect::from_ltrb(
|
||||
bar_x - thickness / 2.0,
|
||||
y_top,
|
||||
bar_x + thickness / 2.0,
|
||||
y_top + cell.extent,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
use crate::shapes::{StrokeStyle, TextEmphasis, TextOrientation, TextSpan};
|
||||
|
||||
#[test]
|
||||
fn shape_to_path_places_vertical_glyphs_down_the_column() {
|
||||
let content = make_content(&["あく"], 1000.0);
|
||||
let layout = layout_with(&provider(VMTX_TEST_FONT), &content);
|
||||
|
||||
let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true);
|
||||
|
||||
assert_eq!(paths.len(), 2, "one outline path per upright glyph");
|
||||
let first = paths[0].0.bounds();
|
||||
let second = paths[1].0.bounds();
|
||||
assert!(
|
||||
second.top > first.top,
|
||||
"the second glyph outline follows the first down the vertical flow axis"
|
||||
);
|
||||
let horizontal_shift = (second.center_x() - first.center_x()).abs();
|
||||
let vertical_shift = second.center_y() - first.center_y();
|
||||
assert!(
|
||||
vertical_shift > horizontal_shift,
|
||||
"vertical flow dominates the glyphs' optical side-bearing difference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_to_path_preserves_the_text_blob_draw_origin() {
|
||||
let content = make_content(&["あ"], 1000.0);
|
||||
let layout = layout_with(&provider(VMTX_TEST_FONT), &content);
|
||||
let CellKind::Upright { run, glyph, count } = layout.cells[0].kind else {
|
||||
panic!("expected an upright cell");
|
||||
};
|
||||
let blob = layout.runs[run]
|
||||
.cluster_blob(glyph, count)
|
||||
.expect("a glyph text blob");
|
||||
let blob_bounds = *blob.bounds();
|
||||
let draw_origin = SkPoint::new(120.0, 340.0);
|
||||
let mut normalized_blob = blob.clone();
|
||||
let normalized_bounds = *SkiaParagraph::get_path(&mut normalized_blob).bounds();
|
||||
|
||||
let path_bounds = *text_blob_path(blob, draw_origin).bounds();
|
||||
|
||||
assert!(
|
||||
(path_bounds.left - (draw_origin.x + blob_bounds.left + normalized_bounds.left)).abs()
|
||||
< 0.01
|
||||
);
|
||||
assert!(
|
||||
(path_bounds.top - (draw_origin.y + blob_bounds.top + normalized_bounds.top)).abs()
|
||||
< 0.01
|
||||
);
|
||||
assert!((path_bounds.width() - normalized_bounds.width()).abs() < 0.01);
|
||||
assert!((path_bounds.height() - normalized_bounds.height()).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoration_bar_underline_left_of_center_line_through_centered() {
|
||||
let content = decorated_content("あい", TextDecoration::UNDERLINE);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
let (ox, oy) = layout.origin(&content.bounds(), VerticalAlign::Top);
|
||||
let cell = &layout.cells[0];
|
||||
let column = &layout.columns[cell.column];
|
||||
let x_center = ox + column.x + column.width / 2.0;
|
||||
|
||||
let underline = decoration_bar(&layout, cell, ox, oy, false);
|
||||
let strike = decoration_bar(&layout, cell, ox, oy, true);
|
||||
|
||||
// Underline sits left of the column axis; line-through is centered.
|
||||
assert!(underline.center_x() < x_center);
|
||||
assert!((strike.center_x() - x_center).abs() < 0.01);
|
||||
// Both bars span the cell's vertical extent.
|
||||
assert!((underline.height() - cell.extent).abs() < 0.01);
|
||||
// Bar thickness is at least 1px.
|
||||
assert!(underline.width() >= 1.0 - 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_export_includes_every_annotation_glyph() {
|
||||
let content = spans_content(
|
||||
vec![TextSpan {
|
||||
ruby: "aあ".to_string(),
|
||||
text_emphasis: TextEmphasis::FilledDot,
|
||||
text_orientation: TextOrientation::Upright,
|
||||
..make_span("AB")
|
||||
}],
|
||||
400.0,
|
||||
);
|
||||
let provider = provider_with_fallback(TEST_FONT, VMTX_TEST_FONT);
|
||||
let layout = layout_with_fallback(&provider, &content, 400.0, &["fallback".to_string()]);
|
||||
let ruby_glyphs: usize = layout.ruby_cells.iter().map(|ruby| ruby.glyphs.len()).sum();
|
||||
assert_eq!(ruby_glyphs, 2, "both fallback runs carry a ruby glyph");
|
||||
|
||||
let paths = paths_from_layout(&layout, &content.bounds(), VerticalAlign::Top, true);
|
||||
|
||||
assert_eq!(
|
||||
paths.len(),
|
||||
layout.cells.len() + ruby_glyphs + layout.emphasis_marks.len(),
|
||||
"one outline per base cell, ruby glyph and emphasis mark"
|
||||
);
|
||||
let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap();
|
||||
paint_layout(
|
||||
surface.canvas(),
|
||||
&layout,
|
||||
&content.bounds(),
|
||||
VerticalAlign::Top,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_passes_do_not_panic() {
|
||||
let content = decorated_content("あいAB。", TextDecoration::LINE_THROUGH);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
let bounds = content.bounds();
|
||||
let selrect = content.bounds();
|
||||
|
||||
let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap();
|
||||
let canvas = surface.canvas();
|
||||
|
||||
// Shape transforms are applied on the caller's canvas. Exercise a
|
||||
// non-trivial transform with mixed upright/rotated cells so every
|
||||
// vertical paint pass remains transform-safe.
|
||||
canvas.translate((12.0, 8.0));
|
||||
canvas.rotate(7.0, Some((64.0, 64.0).into()));
|
||||
|
||||
paint_layout(canvas, &layout, &bounds, VerticalAlign::Top);
|
||||
paint_drop_shadow(
|
||||
canvas,
|
||||
&layout,
|
||||
&bounds,
|
||||
VerticalAlign::Top,
|
||||
&Paint::default(),
|
||||
);
|
||||
|
||||
// With a real drop-shadow image filter (as `drop_shadow_paints` builds).
|
||||
let mut shadow_paint = Paint::default();
|
||||
shadow_paint.set_image_filter(skia::image_filters::drop_shadow(
|
||||
(12.0, 12.0),
|
||||
(6.0, 6.0),
|
||||
skia::Color::from_argb(230, 0, 0, 255),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
paint_drop_shadow(canvas, &layout, &bounds, VerticalAlign::Top, &shadow_paint);
|
||||
|
||||
for kind in [
|
||||
Stroke::new_center_stroke(3.0, StrokeStyle::Solid, None, None, None, None),
|
||||
Stroke::new_inner_stroke(3.0, StrokeStyle::Solid, None, None, None, None),
|
||||
Stroke::new_outer_stroke(3.0, StrokeStyle::Solid, None, None, None, None),
|
||||
] {
|
||||
paint_stroke(
|
||||
canvas,
|
||||
&layout,
|
||||
&bounds,
|
||||
VerticalAlign::Top,
|
||||
&kind,
|
||||
&selrect,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
742
render-wasm/src/shapes/text_vertical/positions.rs
Normal file
742
render-wasm/src/shapes/text_vertical/positions.rs
Normal file
@ -0,0 +1,742 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use skia_safe::{Contains, Point as SkPoint};
|
||||
|
||||
use crate::math::Rect;
|
||||
use crate::shapes::{PositionData, VerticalAlign};
|
||||
|
||||
use super::annotations::ruby_strip_x;
|
||||
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout};
|
||||
|
||||
/// Position-data `direction` value marking a vertical (vertical-rl)
|
||||
/// strip; 0/1 are the horizontal rtl/ltr values. The CLJS deserializer
|
||||
/// turns it into `:writing-mode "vertical-rl"` on the entry so the
|
||||
/// legacy SVG renderer can draw the strip vertically.
|
||||
pub const DIRECTION_VERTICAL_RL: u32 = 2;
|
||||
|
||||
/// Position-data `direction` value marking a ruby annotation strip: the
|
||||
/// entry's offsets index the span's *ruby* string and the geometry is the
|
||||
/// exact gutter placement the canvas paints.
|
||||
pub const DIRECTION_VERTICAL_RUBY: u32 = 3;
|
||||
|
||||
pub(super) fn cell_source_utf16_range(
|
||||
layout: &VerticalLayout,
|
||||
cell: &VerticalCell,
|
||||
) -> Range<usize> {
|
||||
let transformed_span_start = layout.span_utf16_starts[cell.paragraph][cell.span];
|
||||
let source_span_start = layout.span_source_utf16_starts[cell.paragraph][cell.span];
|
||||
let relative = layout.span_transforms[cell.paragraph][cell.span]
|
||||
.source_utf16_range(cell.start - transformed_span_start..cell.end - transformed_span_start);
|
||||
source_span_start + relative.start..source_span_start + relative.end
|
||||
}
|
||||
|
||||
/// Position-data entries for the v2 editor / exports: consecutive cells of
|
||||
/// the same span in the same column merge into one vertical strip.
|
||||
pub fn position_data(
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
) -> Vec<PositionData> {
|
||||
let (origin_x, origin_y) = layout.origin(bounds, vertical_align);
|
||||
let mut result: Vec<PositionData> = Vec::new();
|
||||
|
||||
let mut i = 0;
|
||||
while i < layout.cells.len() {
|
||||
let first = &layout.cells[i];
|
||||
let mut source_range = cell_source_utf16_range(layout, first);
|
||||
let mut bottom = first.top + first.extent;
|
||||
let mut j = i + 1;
|
||||
while j < layout.cells.len() {
|
||||
let next = &layout.cells[j];
|
||||
if next.paragraph == first.paragraph
|
||||
&& next.span == first.span
|
||||
&& next.column == first.column
|
||||
{
|
||||
let next_source = cell_source_utf16_range(layout, next);
|
||||
source_range.start = source_range.start.min(next_source.start);
|
||||
source_range.end = source_range.end.max(next_source.end);
|
||||
bottom = next.top + next.extent;
|
||||
j += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let column = &layout.columns[first.column];
|
||||
let span_start = layout.span_source_utf16_starts[first.paragraph][first.span];
|
||||
result.push(PositionData {
|
||||
paragraph: first.paragraph as u32,
|
||||
span: first.span as u32,
|
||||
start_pos: (source_range.start - span_start) as u32,
|
||||
end_pos: (source_range.end - span_start) as u32,
|
||||
x: origin_x + column.x,
|
||||
y: origin_y + first.top,
|
||||
// Base text occupies the base sub-band; any ruby gutter is
|
||||
// excluded so the editor overlay and selection track the glyphs.
|
||||
width: column.base_width,
|
||||
height: bottom - first.top,
|
||||
direction: DIRECTION_VERTICAL_RL,
|
||||
});
|
||||
i = j;
|
||||
}
|
||||
|
||||
// Ruby annotation strips, with the exact flow-axis placement the canvas
|
||||
// paints. `start_pos`/`end_pos` are UTF-16 offsets
|
||||
// into the span's ruby string, taken from the shaped clusters so
|
||||
// surrogate-pair readings slice correctly. The consumer renders them as
|
||||
// their own half-size vertical strips in the column's right-side gutter.
|
||||
for ruby in &layout.ruby_cells {
|
||||
let (Some(first), Some(last)) = (ruby.glyphs.first(), ruby.glyphs.last()) else {
|
||||
continue;
|
||||
};
|
||||
if ruby.base_segments.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let column = &layout.columns[ruby.column];
|
||||
let top = ruby.glyph_tops.iter().copied().fold(f32::MAX, f32::min);
|
||||
let bottom = ruby.glyph_tops.iter().copied().fold(f32::MIN, f32::max) + ruby.font_size;
|
||||
result.push(PositionData {
|
||||
paragraph: ruby.paragraph as u32,
|
||||
span: ruby.span as u32,
|
||||
start_pos: first.utf16_start as u32,
|
||||
end_pos: last.utf16_end as u32,
|
||||
x: origin_x + ruby_strip_x(column, ruby.font_size, ruby.base_font_size, ruby.side),
|
||||
y: origin_y + top,
|
||||
width: ruby.font_size,
|
||||
height: bottom - top,
|
||||
direction: DIRECTION_VERTICAL_RUBY,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// True when the point (in the same space as `bounds`) hits laid-out text.
|
||||
pub fn intersects(
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
vertical_align: VerticalAlign,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> bool {
|
||||
let (origin_x, origin_y) = layout.origin(bounds, vertical_align);
|
||||
layout.cells.iter().any(|cell| {
|
||||
let column = &layout.columns[cell.column];
|
||||
let rect = Rect::from_xywh(
|
||||
origin_x + column.x,
|
||||
origin_y + cell.top,
|
||||
column.width,
|
||||
cell.extent,
|
||||
);
|
||||
rect.contains(&SkPoint::new(x, y))
|
||||
})
|
||||
}
|
||||
|
||||
fn scalar_offset_from_utf16(
|
||||
layout: &VerticalLayout,
|
||||
paragraph: usize,
|
||||
utf16_offset: usize,
|
||||
) -> Option<usize> {
|
||||
let boundaries = layout.paragraph_utf16_boundaries.get(paragraph)?;
|
||||
Some(match boundaries.binary_search(&utf16_offset) {
|
||||
Ok(index) => index,
|
||||
Err(index) => index.saturating_sub(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_scalar_range(layout: &VerticalLayout, cell: &VerticalCell) -> Option<(usize, usize)> {
|
||||
Some((
|
||||
scalar_offset_from_utf16(layout, cell.paragraph, cell.start)?,
|
||||
scalar_offset_from_utf16(layout, cell.paragraph, cell.end)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Scalar length of a warichu cell's first sub-line. `first_chars` is its
|
||||
/// UTF-16 length; the cell holds `chars` scalars from `cell_start`.
|
||||
fn warichu_first_line_len(
|
||||
layout: &VerticalLayout,
|
||||
cell: &VerticalCell,
|
||||
first_chars: usize,
|
||||
cell_start: usize,
|
||||
chars: usize,
|
||||
) -> Option<usize> {
|
||||
let first_end = scalar_offset_from_utf16(layout, cell.paragraph, cell.start + first_chars)?;
|
||||
Some(first_end.saturating_sub(cell_start).min(chars))
|
||||
}
|
||||
|
||||
/// Caret position (paragraph index, paragraph-relative Unicode scalar offset) for
|
||||
/// a point given relative to the content block's top-left origin.
|
||||
pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usize, usize)> {
|
||||
if layout.columns.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Columns are ordered right->left, i.e. descending x.
|
||||
let column_index = layout
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| x >= c.x && x < c.x + c.width)
|
||||
.unwrap_or(if x >= layout.width {
|
||||
0
|
||||
} else {
|
||||
layout.columns.len() - 1
|
||||
});
|
||||
|
||||
let paragraph = layout
|
||||
.paragraph_columns
|
||||
.iter()
|
||||
.position(|(start, end)| column_index >= *start && column_index < *end)?;
|
||||
|
||||
let column_cells: Vec<&VerticalCell> = layout
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|c| c.column == column_index)
|
||||
.collect();
|
||||
|
||||
if column_cells.is_empty() {
|
||||
return Some((paragraph, 0));
|
||||
}
|
||||
|
||||
for cell in &column_cells {
|
||||
if y < cell.top + cell.extent {
|
||||
let (cell_start, cell_end) = cell_scalar_range(layout, cell)?;
|
||||
let chars = (cell_end - cell_start).max(1);
|
||||
let offset = match cell.kind {
|
||||
CellKind::Rotated { .. } => {
|
||||
// Proportional position along the rotated run.
|
||||
let frac = ((y - cell.top) / cell.extent).clamp(0.0, 1.0);
|
||||
cell_start + ((frac * chars as f32).round() as usize).min(chars)
|
||||
}
|
||||
CellKind::TateChuYoko { .. } => {
|
||||
// The digits run left->right inside the composite, so the
|
||||
// horizontal position picks the offset within it.
|
||||
let column = &layout.columns[cell.column];
|
||||
let left = column_base_center(column) - cell.h_advance / 2.0;
|
||||
let frac = ((x - left) / cell.h_advance.max(1.0)).clamp(0.0, 1.0);
|
||||
cell_start + ((frac * chars as f32).round() as usize).min(chars)
|
||||
}
|
||||
CellKind::Warichu { first_chars, .. } => {
|
||||
// Right sub-column holds the first sub-line's characters,
|
||||
// left sub-column the second; the vertical position picks
|
||||
// the offset within the chosen sub-line.
|
||||
let column = &layout.columns[cell.column];
|
||||
let centre = column_base_center(column);
|
||||
let first =
|
||||
warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?;
|
||||
let (lo, hi) = if x >= centre {
|
||||
(0, first)
|
||||
} else {
|
||||
(first, chars)
|
||||
};
|
||||
let line_chars = (hi - lo).max(1);
|
||||
let frac = ((y - cell.top) / cell.extent.max(1.0)).clamp(0.0, 1.0);
|
||||
cell_start + lo + ((frac * line_chars as f32).round() as usize).min(hi - lo)
|
||||
}
|
||||
CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } => {
|
||||
if y < cell.top + cell.extent / 2.0 {
|
||||
cell_start
|
||||
} else {
|
||||
cell_end
|
||||
}
|
||||
}
|
||||
};
|
||||
return Some((paragraph, offset));
|
||||
}
|
||||
}
|
||||
|
||||
Some((
|
||||
paragraph,
|
||||
scalar_offset_from_utf16(layout, paragraph, column_cells.last().unwrap().end)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Caret rectangle for a paragraph-relative Unicode scalar offset, in
|
||||
/// content-local coordinates. The rect spans the column width; its height
|
||||
/// is the extent of the character at the offset (used by overtype-mode
|
||||
/// carets to cover the glyph) or zero when the caret sits after the last
|
||||
/// character, where a thin bar is drawn instead.
|
||||
pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> Option<Rect> {
|
||||
let (col_start, _) = *layout.paragraph_columns.get(paragraph)?;
|
||||
|
||||
let mut result: Option<Rect> = None;
|
||||
for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) {
|
||||
let (cell_start, cell_end) = cell_scalar_range(layout, cell)?;
|
||||
if cell_start > offset {
|
||||
continue;
|
||||
}
|
||||
let column = &layout.columns[cell.column];
|
||||
let chars = (cell_end - cell_start).max(1);
|
||||
let rect = if offset >= cell_end {
|
||||
Rect::from_xywh(column.x, cell.top + cell.extent, column.base_width, 0.0)
|
||||
} else {
|
||||
match cell.kind {
|
||||
CellKind::TateChuYoko { .. } => {
|
||||
// Digits run left->right inside the composite: the offset
|
||||
// moves the caret along the horizontal axis while the
|
||||
// rect keeps the composite's flow extent.
|
||||
let left = column_base_center(column) - cell.h_advance / 2.0;
|
||||
let width = cell.h_advance / chars as f32;
|
||||
let x = left + (offset - cell_start) as f32 * width;
|
||||
Rect::from_xywh(x, cell.top, width, cell.extent)
|
||||
}
|
||||
CellKind::Warichu { first_chars, .. } => {
|
||||
// Right sub-column holds the first sub-line's
|
||||
// characters, left sub-column the second (jlreq reading
|
||||
// order); the caret tracks the offset down the chosen
|
||||
// half-width sub-line.
|
||||
let first =
|
||||
warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?;
|
||||
let within = offset - cell_start;
|
||||
let centre = column_base_center(column);
|
||||
let half = cell.font_size / 2.0;
|
||||
let (band_x, line_start, line_chars) = if within < first {
|
||||
(centre, 0, first)
|
||||
} else {
|
||||
(centre - half, first, chars - first)
|
||||
};
|
||||
let line_chars = line_chars.max(1);
|
||||
let frac = (within - line_start) as f32 / line_chars as f32;
|
||||
Rect::from_xywh(
|
||||
band_x,
|
||||
cell.top + frac * cell.extent,
|
||||
half,
|
||||
cell.extent / line_chars as f32,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let frac = (offset - cell_start) as f32 / chars as f32;
|
||||
Rect::from_xywh(
|
||||
column.x,
|
||||
cell.top + frac * cell.extent,
|
||||
column.base_width,
|
||||
cell.extent / chars as f32,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
result = Some(rect);
|
||||
if offset < cell_end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.or_else(|| {
|
||||
// Empty paragraph: caret at the top of its (empty) first column.
|
||||
layout
|
||||
.columns
|
||||
.get(col_start)
|
||||
.map(|column| Rect::from_xywh(column.x, 0.0, column.base_width, 0.0))
|
||||
})
|
||||
}
|
||||
|
||||
/// Selection rectangles for a paragraph-relative Unicode scalar offset range, in
|
||||
/// content-local coordinates.
|
||||
pub fn range_rects(
|
||||
layout: &VerticalLayout,
|
||||
paragraph: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects: Vec<Rect> = Vec::new();
|
||||
for cell in layout.cells.iter().filter(|c| c.paragraph == paragraph) {
|
||||
let Some((cell_start, cell_end)) = cell_scalar_range(layout, cell) else {
|
||||
continue;
|
||||
};
|
||||
if cell_end <= start || cell_start >= end {
|
||||
continue;
|
||||
}
|
||||
let column = &layout.columns[cell.column];
|
||||
let chars = (cell_end - cell_start).max(1);
|
||||
let sel_top = if start > cell_start {
|
||||
cell.top + ((start - cell_start) as f32 / chars as f32) * cell.extent
|
||||
} else {
|
||||
cell.top
|
||||
};
|
||||
let sel_bottom = if end < cell_end {
|
||||
cell.top + ((end - cell_start) as f32 / chars as f32) * cell.extent
|
||||
} else {
|
||||
cell.top + cell.extent
|
||||
};
|
||||
if sel_bottom <= sel_top {
|
||||
continue;
|
||||
}
|
||||
// Merge with the previous rect when contiguous in the same column.
|
||||
if let Some(last) = rects.last_mut() {
|
||||
if (last.left - column.x).abs() < f32::EPSILON && (last.bottom - sel_top).abs() < 0.01 {
|
||||
last.bottom = sel_bottom;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rects.push(Rect::from_ltrb(
|
||||
column.x,
|
||||
sel_top,
|
||||
column.x + column.base_width,
|
||||
sel_bottom,
|
||||
));
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
use crate::shapes::{
|
||||
TextCombineUpright, TextOrientation, TextPositionWithAffinity, TextTransform,
|
||||
};
|
||||
use crate::wasm::text::helpers as text_helpers;
|
||||
|
||||
#[test]
|
||||
fn caret_from_point_lands_inside_tcy_composite() {
|
||||
let mut content = make_content_with_spans(&["1234", "あ"], 400.0);
|
||||
content.paragraphs_mut()[0].children_mut()[0]
|
||||
.set_text_combine_upright(TextCombineUpright::All);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let cell = &layout.cells[0];
|
||||
assert!(matches!(cell.kind, CellKind::TateChuYoko { .. }));
|
||||
let column = &layout.columns[cell.column];
|
||||
let y_mid = cell.top + cell.extent / 2.0;
|
||||
let (_, at_left) = caret_from_point(&layout, column.x + 0.5, y_mid).unwrap();
|
||||
let (_, at_right) =
|
||||
caret_from_point(&layout, column.x + column.base_width - 0.5, y_mid).unwrap();
|
||||
assert!(at_left <= 1, "left edge maps near the start, got {at_left}");
|
||||
assert!(
|
||||
at_right >= 3,
|
||||
"right edge maps near the end, got {at_right}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_rect_tracks_horizontal_axis_inside_tcy_composite() {
|
||||
let mut content = make_content_with_spans(&["1234", "あ"], 400.0);
|
||||
content.paragraphs_mut()[0].children_mut()[0]
|
||||
.set_text_combine_upright(TextCombineUpright::All);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let cell = &layout.cells[0];
|
||||
assert!(matches!(cell.kind, CellKind::TateChuYoko { .. }));
|
||||
let left = column_base_center(&layout.columns[cell.column]) - cell.h_advance / 2.0;
|
||||
let digit = cell.h_advance / 4.0;
|
||||
for i in 0..4 {
|
||||
let rect = caret_rect(&layout, 0, cell.start + i).expect("caret rect");
|
||||
assert!(
|
||||
(rect.left - (left + i as f32 * digit)).abs() < 0.01,
|
||||
"digit {i} caret sits at its horizontal slot, got {}",
|
||||
rect.left
|
||||
);
|
||||
assert!(
|
||||
(rect.width() - digit).abs() < 0.01,
|
||||
"digit {i} caret is one digit wide, got {}",
|
||||
rect.width()
|
||||
);
|
||||
assert!(
|
||||
(rect.top - cell.top).abs() < 0.01 && (rect.height() - cell.extent).abs() < 0.01,
|
||||
"digit {i} caret spans the composite's flow extent"
|
||||
);
|
||||
}
|
||||
// After the composite the caret falls back to the flow axis.
|
||||
let rect = caret_rect(&layout, 0, cell.end).expect("caret rect");
|
||||
assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_cell_carries_kinsoku_split() {
|
||||
let content = warichu_content("あいう、えお", 400.0);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let cell = layout
|
||||
.cells
|
||||
.iter()
|
||||
.find(|c| matches!(c.kind, CellKind::Warichu { .. }))
|
||||
.expect("a warichu cell");
|
||||
let CellKind::Warichu { first_chars, .. } = cell.kind else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(
|
||||
first_chars, 4,
|
||||
"the comma is pulled up into the first sub-line"
|
||||
);
|
||||
// The caret for the second sub-line's first character restarts at
|
||||
// the composite top, left of the axis.
|
||||
let column = &layout.columns[cell.column];
|
||||
let centre = column_base_center(column);
|
||||
let rect = caret_rect(&layout, 0, cell.start + 4).expect("caret rect");
|
||||
assert!((rect.top - cell.top).abs() < 0.01);
|
||||
assert!(rect.left < centre);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_rect_tracks_sub_lines_inside_warichu() {
|
||||
let content = warichu_content("割注二行説明", 400.0);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let cell = layout
|
||||
.cells
|
||||
.iter()
|
||||
.find(|c| matches!(c.kind, CellKind::Warichu { .. }))
|
||||
.expect("a warichu cell");
|
||||
let column = &layout.columns[cell.column];
|
||||
let centre = column_base_center(column);
|
||||
let half = cell.font_size / 2.0;
|
||||
// First sub-line (offsets 0..3) sits right of the column axis.
|
||||
let first = caret_rect(&layout, 0, cell.start).expect("caret rect");
|
||||
assert!(
|
||||
(first.left - centre).abs() < 0.01,
|
||||
"first sub-line caret starts at the axis, got {}",
|
||||
first.left
|
||||
);
|
||||
assert!(
|
||||
(first.width() - half).abs() < 0.01,
|
||||
"sub-line caret is half-size wide, got {}",
|
||||
first.width()
|
||||
);
|
||||
assert!((first.top - cell.top).abs() < 0.01);
|
||||
// Second sub-line (offsets 3..6) sits left of the axis, restarting
|
||||
// at the composite top.
|
||||
let second = caret_rect(&layout, 0, cell.start + 3).expect("caret rect");
|
||||
assert!(
|
||||
(second.left - (centre - half)).abs() < 0.01,
|
||||
"second sub-line caret sits left of the axis, got {}",
|
||||
second.left
|
||||
);
|
||||
assert!(
|
||||
(second.top - cell.top).abs() < 0.01,
|
||||
"second sub-line restarts at the composite top, got {}",
|
||||
second.top
|
||||
);
|
||||
// Offsets advance down the sub-line.
|
||||
let deeper = caret_rect(&layout, 0, cell.start + 4).expect("caret rect");
|
||||
assert!(
|
||||
deeper.top > second.top,
|
||||
"later offsets move down the sub-line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_round_trip() {
|
||||
let text = "あいうえお";
|
||||
let content = make_content(&[text], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
|
||||
for cell in &layout.cells {
|
||||
let column = &layout.columns[cell.column];
|
||||
// A point in the upper half of the cell resolves to its start.
|
||||
let (paragraph, offset) = caret_from_point(
|
||||
&layout,
|
||||
column.x + column.width / 2.0,
|
||||
cell.top + cell.extent * 0.25,
|
||||
)
|
||||
.expect("caret");
|
||||
assert_eq!(paragraph, 0);
|
||||
assert_eq!(offset, cell.start);
|
||||
|
||||
// caret_rect for that offset lands inside the same column and
|
||||
// carries the character extent (for overtype carets).
|
||||
let rect = caret_rect(&layout, paragraph, offset).expect("caret rect");
|
||||
assert!((rect.x() - column.x).abs() < 0.01);
|
||||
let chars = (cell.end - cell.start).max(1);
|
||||
assert!((rect.height() - cell.extent / chars as f32).abs() < 0.01);
|
||||
}
|
||||
|
||||
// After the last character there is no glyph to cover: zero height.
|
||||
let end_offset = layout.cells.last().unwrap().end;
|
||||
let rect = caret_rect(&layout, 0, end_offset).expect("caret rect");
|
||||
assert_eq!(rect.height(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bmp_caret_offsets_round_trip_through_editor_operations() {
|
||||
let content = make_content(&["𠀀あ"], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
let first = &layout.cells[0];
|
||||
let second = &layout.cells[1];
|
||||
let column = &layout.columns[first.column];
|
||||
let x = column_base_center(column);
|
||||
|
||||
let before = caret_from_point(&layout, x, first.top + first.extent * 0.25).unwrap();
|
||||
let after = caret_from_point(&layout, x, first.top + first.extent * 0.75).unwrap();
|
||||
assert_eq!(before, (0, 0));
|
||||
assert_eq!(after, (0, 1));
|
||||
|
||||
let rect = caret_rect(&layout, after.0, after.1).expect("caret rect");
|
||||
assert!((rect.top - second.top).abs() < 0.01);
|
||||
assert!((rect.height() - second.extent).abs() < 0.01);
|
||||
|
||||
let rects = range_rects(&layout, 0, 0, 1);
|
||||
assert_eq!(rects.len(), 1);
|
||||
assert!((rects[0].height() - first.extent).abs() < 0.01);
|
||||
|
||||
let start = TextPositionWithAffinity::new_downstream_affinity(0, 0);
|
||||
let end = TextPositionWithAffinity::new_downstream_affinity(after.0, after.1);
|
||||
assert_eq!(
|
||||
text_helpers::move_cursor_forward(&start, content.paragraphs(), false),
|
||||
end
|
||||
);
|
||||
assert_eq!(
|
||||
text_helpers::move_cursor_backward(&end, content.paragraphs(), false),
|
||||
start
|
||||
);
|
||||
|
||||
let mut inserted = make_content(&["𠀀あ"], 1000.0);
|
||||
assert_eq!(
|
||||
text_helpers::insert_text_at_cursor(&mut inserted, &end, "X"),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(inserted.paragraphs()[0].children()[0].text, "𠀀Xあ");
|
||||
|
||||
let mut deleted = make_content(&["𠀀あ"], 1000.0);
|
||||
assert_eq!(
|
||||
text_helpers::delete_char_before(&mut deleted, &end),
|
||||
Some(start)
|
||||
);
|
||||
assert_eq!(deleted.paragraphs()[0].children()[0].text, "あ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_rects_cover_the_range() {
|
||||
let text = "あいうえお";
|
||||
let content = make_content(&[text], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
|
||||
let rects = range_rects(&layout, 0, 1, 3);
|
||||
assert!(!rects.is_empty());
|
||||
let covered: f32 = rects.iter().map(|r| r.height()).sum();
|
||||
let expected: f32 = layout
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|c| c.start >= 1 && c.end <= 3)
|
||||
.map(|c| c.extent)
|
||||
.sum();
|
||||
assert!((covered - expected).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_merges_by_column_and_maps_span_offsets() {
|
||||
let text = "あいうえお";
|
||||
let content = make_content(&[text], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
let bounds = content.bounds();
|
||||
|
||||
let data = position_data(&layout, &bounds, VerticalAlign::Top);
|
||||
assert_eq!(data.len(), 1, "one column, one span => one entry");
|
||||
assert_eq!(data[0].start_pos, 0);
|
||||
assert_eq!(data[0].end_pos, text.encode_utf16().count() as u32);
|
||||
// Right-anchored: the strip ends at the bounds right edge.
|
||||
assert!((data[0].x + data[0].width - bounds.right).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_keeps_expanded_transform_in_one_source_safe_strip() {
|
||||
let mut content = make_content(&["AßB"], 1000.0);
|
||||
let span = &mut content.paragraphs_mut()[0].children_mut()[0];
|
||||
span.text_transform = Some(TextTransform::Uppercase);
|
||||
span.text_orientation = TextOrientation::Upright;
|
||||
let unwrapped = layout_content(&content, 1000.0);
|
||||
let expanded_extent: f32 = unwrapped
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|cell| cell_source_utf16_range(&unwrapped, cell) == (1..2))
|
||||
.map(|cell| cell.extent)
|
||||
.sum();
|
||||
let layout = layout_content(&content, expanded_extent + 0.01);
|
||||
|
||||
let expanded: Vec<&VerticalCell> = layout
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|cell| cell_source_utf16_range(&layout, cell) == (1..2))
|
||||
.collect();
|
||||
assert_eq!(expanded.len(), 2, "ß must shape as the two cells in SS");
|
||||
assert_eq!(
|
||||
expanded[0].column, expanded[1].column,
|
||||
"glyphs from one source character must not split across columns"
|
||||
);
|
||||
|
||||
let data = position_data(&layout, &content.bounds(), VerticalAlign::Top);
|
||||
let ranges: Vec<(u32, u32)> = data
|
||||
.iter()
|
||||
.filter(|entry| entry.direction == DIRECTION_VERTICAL_RL)
|
||||
.map(|entry| (entry.start_pos, entry.end_pos))
|
||||
.collect();
|
||||
assert_eq!(ranges, vec![(0, 1), (1, 2), (2, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_emits_ruby_annotation_strips() {
|
||||
let content = ruby_content("漢字", "かんじ", 400.0);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let bounds = content.bounds();
|
||||
let data = position_data(&layout, &bounds, VerticalAlign::Top);
|
||||
|
||||
let base: Vec<&PositionData> = data
|
||||
.iter()
|
||||
.filter(|d| d.direction == DIRECTION_VERTICAL_RL)
|
||||
.collect();
|
||||
let ruby: Vec<&PositionData> = data
|
||||
.iter()
|
||||
.filter(|d| d.direction == DIRECTION_VERTICAL_RUBY)
|
||||
.collect();
|
||||
assert_eq!(base.len(), 1);
|
||||
assert_eq!(ruby.len(), 1, "one annotated column => one ruby strip");
|
||||
assert_eq!(ruby[0].start_pos, 0);
|
||||
assert_eq!(ruby[0].end_pos, 3, "offsets index the ruby string");
|
||||
// The strip sits in the gutter, to the right of the base band.
|
||||
assert!(ruby[0].x >= base[0].x + base[0].width - 0.001);
|
||||
assert!(ruby[0].width > 0.0);
|
||||
assert!(ruby[0].height > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_ruby_offsets_are_utf16_for_non_bmp_readings() {
|
||||
// Two surrogate-pair reading characters: 2 glyphs but 4 UTF-16
|
||||
// units. The strip offsets must slice the ruby string by UTF-16.
|
||||
let content = ruby_content("\u{6f22}\u{5b57}", "\u{1d4aa}\u{1d4ab}", 400.0);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let bounds = content.bounds();
|
||||
let data = position_data(&layout, &bounds, VerticalAlign::Top);
|
||||
let ruby: Vec<&PositionData> = data
|
||||
.iter()
|
||||
.filter(|d| d.direction == DIRECTION_VERTICAL_RUBY)
|
||||
.collect();
|
||||
assert_eq!(ruby.len(), 1);
|
||||
assert_eq!(ruby[0].start_pos, 0);
|
||||
assert_eq!(
|
||||
ruby[0].end_pos, 4,
|
||||
"surrogate pairs count two UTF-16 units each"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_follows_block_axis_alignment() {
|
||||
let content = make_content(&["あいう"], 1000.0);
|
||||
let layout = layout_content(&content, 1000.0);
|
||||
let bounds = content.bounds();
|
||||
let top = position_data(&layout, &bounds, VerticalAlign::Top);
|
||||
let center = position_data(&layout, &bounds, VerticalAlign::Center);
|
||||
let bottom = position_data(&layout, &bounds, VerticalAlign::Bottom);
|
||||
|
||||
assert!(top[0].x > center[0].x);
|
||||
assert!(center[0].x > bottom[0].x);
|
||||
assert!((bottom[0].x - bounds.left).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_selection_geometry_excludes_gutter() {
|
||||
let content = ruby_content("AB", "ab", 400.0);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let base_width = layout.columns[0].base_width;
|
||||
assert!(base_width < layout.columns[0].width);
|
||||
|
||||
let pd = position_data(&layout, &content.bounds(), VerticalAlign::Top);
|
||||
assert!(!pd.is_empty());
|
||||
for entry in pd.iter().filter(|e| e.direction == DIRECTION_VERTICAL_RL) {
|
||||
assert!(
|
||||
(entry.width - base_width).abs() < 0.01,
|
||||
"position-data strip must be the base band, not the full column"
|
||||
);
|
||||
}
|
||||
|
||||
let rects = range_rects(&layout, 0, 0, 10);
|
||||
assert!(!rects.is_empty());
|
||||
for rect in &rects {
|
||||
assert!(
|
||||
(rect.width() - base_width).abs() < 0.01,
|
||||
"selection rect must be the base band, not the full column"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
447
render-wasm/src/shapes/text_vertical/shaping.rs
Normal file
447
render-wasm/src/shapes/text_vertical/shaping.rs
Normal file
@ -0,0 +1,447 @@
|
||||
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};
|
||||
|
||||
/// 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>,
|
||||
/// Local-y offset that centres the run's ink 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)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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(&self.cluster_positions(glyph, count));
|
||||
builder.make()
|
||||
}
|
||||
|
||||
/// Text blob of the whole run at its shaped positions.
|
||||
pub(super) fn blob(&self) -> Option<TextBlob> {
|
||||
let mut builder = TextBlobBuilder::new();
|
||||
let (glyphs, points) = builder.alloc_run_pos(&self.font, self.glyphs.len(), None);
|
||||
glyphs.copy_from_slice(&self.glyphs);
|
||||
points.copy_from_slice(&self.positions);
|
||||
builder.make()
|
||||
}
|
||||
|
||||
/// 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),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spread the glyphs apart by `letter_spacing` per glyph, mirroring the
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Normalize ASCII word spaces inside a sideways Western run to JLREQ's
|
||||
/// preferred one-third em. Shaping retains the source scalar and break
|
||||
/// opportunity; only its advance and 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: rotated_run_baseline_shift(
|
||||
info.font,
|
||||
&self.scratch_glyphs,
|
||||
&positions,
|
||||
),
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// `vpal` is deliberately absent: SkShaper shapes on a horizontal line,
|
||||
/// where HarfBuzz would apply the feature's y-placement deltas as glyph
|
||||
/// offsets without its advance deltas. Vertical layout applies the parsed
|
||||
/// GPOS `vpal` metrics 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 shaper = skia::Shaper::new(fallback.clone());
|
||||
let mut font_iter = skia::Shaper::new_font_mgr_run_iterator(text, font, Some(fallback));
|
||||
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();
|
||||
shaper.shape_with_iterators_and_features(
|
||||
text,
|
||||
&mut font_iter,
|
||||
&mut bidi_iter,
|
||||
&mut script_iter,
|
||||
&mut lang_iter,
|
||||
&features,
|
||||
f32::MAX,
|
||||
&mut collector,
|
||||
);
|
||||
collector.runs
|
||||
}
|
||||
|
||||
/// 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. Penpot's
|
||||
/// `TypefaceFontProvider` can resolve named families, but its character
|
||||
/// fallback hook is not available consistently in every Skia build. Resolve
|
||||
/// coverage here so missing glyphs never depend on that hook.
|
||||
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> {
|
||||
let typefaces: Vec<Typeface> = families
|
||||
.iter()
|
||||
.filter_map(|family| font_provider.match_family_style(family, skia::FontStyle::default()))
|
||||
.collect();
|
||||
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
|
||||
}
|
||||
|
||||
/// Centres the glyphs' actual ink; ascent/descent metrics are only the
|
||||
/// fallback for runs 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_run_centres_actual_lowercase_ink() {
|
||||
let layout = layout_content(&make_content(&["a"], 1000.0), 1000.0);
|
||||
let cell = &layout.cells[0];
|
||||
let CellKind::Rotated { run } = cell.kind else {
|
||||
panic!("expected a rotated run");
|
||||
};
|
||||
let run = &layout.runs[run];
|
||||
let mut bounds = vec![skia::Rect::default(); run.glyphs.len()];
|
||||
run.font.get_bounds(&run.glyphs, &mut bounds, None);
|
||||
let top = bounds
|
||||
.iter()
|
||||
.zip(&run.positions)
|
||||
.map(|(bound, position)| bound.top + position.y)
|
||||
.fold(f32::MAX, f32::min);
|
||||
let bottom = bounds
|
||||
.iter()
|
||||
.zip(&run.positions)
|
||||
.map(|(bound, position)| bound.bottom + position.y)
|
||||
.fold(f32::MIN, f32::max);
|
||||
let shift = run.rotated_baseline_shift;
|
||||
assert!(((top + bottom) / 2.0 + shift).abs() < 0.01);
|
||||
|
||||
// Lowercase ink does not occupy the face's full ascent/descent band;
|
||||
// this guards against regressing to font-wide metric centring.
|
||||
let (_, metrics) = run.font.metrics();
|
||||
let metrics_shift = rotated_baseline_shift(metrics.ascent, metrics.descent);
|
||||
assert!((shift - metrics_shift).abs() > 0.1);
|
||||
}
|
||||
}
|
||||
215
render-wasm/src/shapes/text_vertical/test_support.rs
Normal file
215
render-wasm/src/shapes/text_vertical/test_support.rs
Normal file
@ -0,0 +1,215 @@
|
||||
// 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::{
|
||||
AnnotationClearance, FontFamily, FontFeatures, FontStyle, GrowType, Paragraph, RubyAlign,
|
||||
RubyOverhang, RubySide, RubySize, TextAlign, TextCombineUpright, TextContent, TextDecoration,
|
||||
TextDirection, TextEmphasis, TextOrientation, TextSpan, WritingMode,
|
||||
};
|
||||
use crate::Uuid;
|
||||
|
||||
use super::layout::{layout_vertical, VerticalLayout};
|
||||
|
||||
pub(super) const TEST_FONT: &[u8] = include_bytes!("../../fonts/sourcesanspro-regular.ttf");
|
||||
|
||||
/// A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the
|
||||
/// vertical kana repeat mark) has a 2em vertical advance vs a 1em
|
||||
/// horizontal advance; U+3042/U+304F are symmetric controls.
|
||||
pub(super) const VMTX_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vmtx-test.ttf");
|
||||
|
||||
/// Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the
|
||||
/// vertical alternates of 、。「」 halve their vertical advances (「 also
|
||||
/// lifts its ink by 481 units) and the あ/く alternates tighten by
|
||||
/// 58/60 units with small placement lifts.
|
||||
pub(super) const VPAL_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vpal-test.ttf");
|
||||
|
||||
/// Font size of every test span.
|
||||
pub(super) 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(super) fn provider(font: &[u8]) -> TypefaceFontProvider {
|
||||
let typeface = FontMgr::new()
|
||||
.new_from_data(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(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_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
|
||||
font_size: EM,
|
||||
line_height: 1.0,
|
||||
letter_spacing: 0.0,
|
||||
font_weight: 400,
|
||||
font_variant_id: Uuid::nil(),
|
||||
text_decoration: None,
|
||||
text_transform: None,
|
||||
text_direction: TextDirection::LTR,
|
||||
text_orientation: TextOrientation::Mixed,
|
||||
text_combine_upright: TextCombineUpright::None,
|
||||
text_emphasis: TextEmphasis::None,
|
||||
ruby: String::default(),
|
||||
warichu: false,
|
||||
font_features: FontFeatures::None,
|
||||
annotation_clearance: AnnotationClearance::None,
|
||||
ruby_size: RubySize::Half,
|
||||
ruby_align: RubyAlign::SpaceAround,
|
||||
ruby_overhang: RubyOverhang::Auto,
|
||||
ruby_side: RubySide::Over,
|
||||
paragraph_position: u32::MAX,
|
||||
span_position: u32::MAX,
|
||||
fills: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user