mirror of
https://github.com/penpot/penpot.git
synced 2026-10-04 17:56:14 +00:00
refactor
This commit is contained in:
parent
d92b5392e6
commit
11d4cc6459
@ -302,78 +302,58 @@
|
|||||||
[text-direction]
|
[text-direction]
|
||||||
(untranslate "text-direction" text-direction "ltr"))
|
(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
|
(defn translate-writing-mode
|
||||||
[writing-mode]
|
[value]
|
||||||
(let [values (unchecked-get wasm/serializers "writing-mode")
|
(translate-enum "writing-mode" value "horizontal-tb"))
|
||||||
default (unchecked-get values "horizontal-tb")]
|
|
||||||
(d/nilv (unchecked-get values (d/name writing-mode)) default)))
|
|
||||||
|
|
||||||
(defn translate-text-orientation
|
(defn translate-text-orientation
|
||||||
[text-orientation]
|
[value]
|
||||||
(let [values (unchecked-get wasm/serializers "text-orientation")
|
(translate-enum "text-orientation" value "mixed"))
|
||||||
default (unchecked-get values "mixed")]
|
|
||||||
(d/nilv (unchecked-get values (d/name text-orientation)) default)))
|
|
||||||
|
|
||||||
(defn translate-text-combine-upright
|
(defn translate-text-combine-upright
|
||||||
[text-combine-upright]
|
[value]
|
||||||
(let [values (unchecked-get wasm/serializers "text-combine-upright")
|
(translate-enum "text-combine-upright" value "none"))
|
||||||
default (unchecked-get values "none")]
|
|
||||||
(d/nilv (unchecked-get values (d/name text-combine-upright)) default)))
|
|
||||||
|
|
||||||
(defn translate-text-emphasis
|
(defn translate-text-emphasis
|
||||||
[text-emphasis]
|
[value]
|
||||||
(let [values (unchecked-get wasm/serializers "text-emphasis")
|
(translate-enum "text-emphasis" value "none"))
|
||||||
default (unchecked-get values "none")]
|
|
||||||
(d/nilv (unchecked-get values (d/name text-emphasis)) default)))
|
|
||||||
|
|
||||||
(defn translate-warichu
|
(defn translate-warichu
|
||||||
[warichu]
|
[value]
|
||||||
(let [values (unchecked-get wasm/serializers "warichu")
|
(translate-enum "warichu" value "none"))
|
||||||
default (unchecked-get values "none")]
|
|
||||||
(d/nilv (unchecked-get values (d/name warichu)) default)))
|
|
||||||
|
|
||||||
(defn translate-font-features
|
(defn translate-font-features
|
||||||
[font-features]
|
[value]
|
||||||
(if-let [values (unchecked-get wasm/serializers "font-features")]
|
(translate-enum "font-features" value "none"))
|
||||||
(let [default (unchecked-get values "none")]
|
|
||||||
(d/nilv (unchecked-get values (d/name font-features)) default))
|
|
||||||
0))
|
|
||||||
|
|
||||||
(defn translate-annotation-clearance
|
(defn translate-annotation-clearance
|
||||||
[annotation-clearance]
|
[value]
|
||||||
(if-let [values (unchecked-get wasm/serializers "annotation-clearance")]
|
(translate-enum "annotation-clearance" value "none"))
|
||||||
(let [default (unchecked-get values "none")]
|
|
||||||
(d/nilv (unchecked-get values (d/name annotation-clearance)) default))
|
|
||||||
0))
|
|
||||||
|
|
||||||
(defn translate-ruby-size
|
(defn translate-ruby-size
|
||||||
[value]
|
[value]
|
||||||
(if-let [values (unchecked-get wasm/serializers "ruby-size")]
|
(translate-enum "ruby-size" value "half"))
|
||||||
(let [default (unchecked-get values "half")]
|
|
||||||
(d/nilv (unchecked-get values (d/name value)) default))
|
|
||||||
0))
|
|
||||||
|
|
||||||
(defn translate-ruby-align
|
(defn translate-ruby-align
|
||||||
[value]
|
[value]
|
||||||
(if-let [values (unchecked-get wasm/serializers "ruby-align")]
|
(translate-enum "ruby-align" value "space-around"))
|
||||||
(let [default (unchecked-get values "space-around")]
|
|
||||||
(d/nilv (unchecked-get values (d/name value)) default))
|
|
||||||
0))
|
|
||||||
|
|
||||||
(defn translate-ruby-overhang
|
(defn translate-ruby-overhang
|
||||||
[value]
|
[value]
|
||||||
(if-let [values (unchecked-get wasm/serializers "ruby-overhang")]
|
(translate-enum "ruby-overhang" value "auto"))
|
||||||
(let [default (unchecked-get values "auto")]
|
|
||||||
(d/nilv (unchecked-get values (d/name value)) default))
|
|
||||||
0))
|
|
||||||
|
|
||||||
(defn translate-ruby-side
|
(defn translate-ruby-side
|
||||||
[value]
|
[value]
|
||||||
(if-let [values (unchecked-get wasm/serializers "ruby-side")]
|
(translate-enum "ruby-side" value "over"))
|
||||||
(let [default (unchecked-get values "over")]
|
|
||||||
(d/nilv (unchecked-get values (d/name value)) default))
|
|
||||||
0))
|
|
||||||
|
|
||||||
|
|
||||||
(defn translate-font-style
|
(defn translate-font-style
|
||||||
[font-style]
|
[font-style]
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
(ns app.common.types.text.japanese-layout
|
(ns app.common.types.text.japanese-layout
|
||||||
(:require
|
(: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"
|
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
|
||||||
;; and "mixed", so plain horizontal text never stores these attrs.
|
;; and "mixed", so plain horizontal text never stores these attrs.
|
||||||
@ -50,6 +51,50 @@
|
|||||||
(def text-annotation-clearance-attrs
|
(def text-annotation-clearance-attrs
|
||||||
[:annotation-clearance])
|
[: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
|
(defn content-writing-mode
|
||||||
"Writing mode of a text content. Stored per paragraph but treated as a
|
"Writing mode of a text content. Stored per paragraph but treated as a
|
||||||
whole-shape property: the first paragraph decides the flow."
|
whole-shape property: the first paragraph decides the flow."
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.types.color :as cc]
|
[app.common.types.color :as cc]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.main.ui.shapes.text.styles :as sts]
|
[app.main.ui.shapes.text.styles :as sts]
|
||||||
[app.util.text.writing-mode :as wm]
|
[app.util.text.writing-mode :as wm]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
@ -21,11 +22,8 @@
|
|||||||
style (if (= text "")
|
style (if (= text "")
|
||||||
(sts/generate-text-styles shape parent)
|
(sts/generate-text-styles shape parent)
|
||||||
(sts/generate-text-styles shape node))
|
(sts/generate-text-styles shape node))
|
||||||
ruby (:ruby node)
|
ruby (jl/visible-ruby node)]
|
||||||
ruby? (and (not (true? (:ruby-hidden node)))
|
(if (some? ruby)
|
||||||
(string? ruby)
|
|
||||||
(not (str/blank? ruby)))]
|
|
||||||
(if ruby?
|
|
||||||
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
||||||
[:span.text-node {:style style} text]
|
[:span.text-node {:style style} text]
|
||||||
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
||||||
|
|||||||
@ -9,9 +9,9 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.text :as legacy.txt]
|
[app.common.text :as legacy.txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.main.ui.shapes.text.styles :as sts]
|
[app.main.ui.shapes.text.styles :as sts]
|
||||||
[app.util.text.writing-mode :as wm]
|
[app.util.text.writing-mode :as wm]
|
||||||
[cuerdas.core :as str]
|
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(mf/defc render-text*
|
(mf/defc render-text*
|
||||||
@ -21,11 +21,8 @@
|
|||||||
(sts/generate-text-styles shape parent)
|
(sts/generate-text-styles shape parent)
|
||||||
(sts/generate-text-styles shape node))
|
(sts/generate-text-styles shape node))
|
||||||
class (when is-code (:$id node))
|
class (when is-code (:$id node))
|
||||||
ruby (:ruby node)
|
ruby (jl/visible-ruby node)]
|
||||||
ruby? (and (not (true? (:ruby-hidden node)))
|
(if (some? ruby)
|
||||||
(string? ruby)
|
|
||||||
(not (str/blank? ruby)))]
|
|
||||||
(if ruby?
|
|
||||||
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
|
||||||
[:span.text-node {:style style :class class} text]
|
[:span.text-node {:style style :class class} text]
|
||||||
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
[app.common.transit :as transit]
|
[app.common.transit :as transit]
|
||||||
[app.common.types.color :as cc]
|
[app.common.types.color :as cc]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.main.fonts :as fonts]
|
[app.main.fonts :as fonts]
|
||||||
[app.main.ui.formats :as fmt]
|
[app.main.ui.formats :as fmt]
|
||||||
[app.util.color :as uc]
|
[app.util.color :as uc]
|
||||||
@ -87,31 +88,74 @@
|
|||||||
"digits3" "digits 3"
|
"digits3" "digits 3"
|
||||||
value))
|
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
|
(defn generate-text-styles
|
||||||
([shape data]
|
([shape data]
|
||||||
(generate-text-styles shape data nil))
|
(generate-text-styles shape data nil))
|
||||||
|
|
||||||
([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}]
|
([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}]
|
||||||
(let [letter-spacing (:letter-spacing data 0)
|
(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-decoration (:text-decoration data)
|
||||||
text-transform (:text-transform data)
|
text-transform (:text-transform data)
|
||||||
|
|
||||||
@ -186,26 +230,6 @@
|
|||||||
(and (string? letter-spacing) (pos? (alength letter-spacing)))
|
(and (string? letter-spacing) (pos? (alength letter-spacing)))
|
||||||
(obj/set! "letterSpacing" (str letter-spacing "px"))
|
(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)))
|
(and (string? font-size) (pos? (alength font-size)))
|
||||||
(obj/set! "fontSize" (str font-size "px"))
|
(obj/set! "fontSize" (str font-size "px"))
|
||||||
|
|
||||||
@ -217,18 +241,8 @@
|
|||||||
(= grow-type :auto-width)
|
(= grow-type :auto-width)
|
||||||
(obj/set! "whiteSpace" "pre")
|
(obj/set! "whiteSpace" "pre")
|
||||||
|
|
||||||
;; Warichu (割注) CSS emulation: an inline-block at half size whose
|
:always
|
||||||
;; inline-size fits half the characters, so the browser wraps it into
|
(add-japanese-text-styles! data)))))
|
||||||
;; 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")))))))
|
|
||||||
|
|
||||||
(defn generate-ruby-styles
|
(defn generate-ruby-styles
|
||||||
[shape data]
|
[shape data]
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.ui.context :as muc]
|
[app.main.ui.context :as muc]
|
||||||
[app.main.ui.shapes.attrs :as attrs]
|
[app.main.ui.shapes.attrs :as attrs]
|
||||||
@ -21,17 +22,6 @@
|
|||||||
|
|
||||||
(def fill-attrs [:fill-color :fill-color-gradient :fill-opacity])
|
(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
|
;; Kinsoku classes for the warichu sub-line split (same characters the
|
||||||
;; renderer's kinsoku module suppresses at line boundaries).
|
;; renderer's kinsoku module suppresses at line boundaries).
|
||||||
(def ^:private warichu-forbidden-at-start
|
(def ^:private warichu-forbidden-at-start
|
||||||
@ -97,13 +87,168 @@
|
|||||||
(if (emphasis-character? character) mark " ")))
|
(if (emphasis-character? character) mark " ")))
|
||||||
(apply str)))
|
(apply str)))
|
||||||
|
|
||||||
(defn- add-font-features!
|
(defn- px
|
||||||
[style font-features]
|
"CSS pixel size, or nil for a non-positive size."
|
||||||
(cond-> style
|
[size]
|
||||||
(and (string? font-features)
|
(when (pos? size) (dm/str size "px")))
|
||||||
(not= "none" font-features)
|
|
||||||
(pos? (alength font-features)))
|
(defn- with-font-features
|
||||||
(obj/set! "fontFeatureSettings" (dm/str "\"" 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
|
(defn set-white-fill
|
||||||
[shape]
|
[shape]
|
||||||
@ -156,125 +301,24 @@
|
|||||||
|
|
||||||
[:> :g group-props
|
[:> :g group-props
|
||||||
(for [[index data] (d/enumerate position-data)]
|
(for [[index data] (d/enumerate position-data)]
|
||||||
(let [rtl? (= "rtl" (:direction data))
|
(let [vertical? (= "vertical-rl" (:writing-mode data))
|
||||||
vertical? (= "vertical-rl" (:writing-mode data))
|
ruby (jl/visible-ruby data)
|
||||||
ruby (:ruby data)
|
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
|
||||||
ruby? (and (string? ruby) (seq ruby))
|
font-size (js/parseFloat (:font-size data))
|
||||||
ruby-side (:ruby-side data "over")
|
scaled-size (fn [scale] (if (js/isNaN font-size) 0 (* font-size scale)))
|
||||||
ruby-align (:ruby-align data "space-around")
|
ruby-font-size (scaled-size (jl/ruby-font-scale (:ruby-size data)))
|
||||||
ruby-overhang (:ruby-overhang data "auto")
|
;; Annotations take the fills of this strip's own render id.
|
||||||
auto-clearance? (= "auto" (:annotation-clearance data))
|
annotation-fill (str "url(#fill-" index "-" render-id "-" index ")")
|
||||||
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)
|
|
||||||
|
|
||||||
browser-props
|
browser-props
|
||||||
(cond
|
(when (and (not vertical?) (cf/check-browser? :safari))
|
||||||
(and (not vertical?) (cf/check-browser? :safari))
|
|
||||||
#js {:dominantBaseline "hanging"
|
#js {:dominantBaseline "hanging"
|
||||||
:dy "0.2em"
|
:dy "0.2em"
|
||||||
:y (- (:y data) (:height data))})
|
:y (- (:y data) (:height data))})
|
||||||
|
|
||||||
base-style (add-font-features!
|
text-props (strip-text-props shape data index
|
||||||
#js {:fontFamily (:font-family data)
|
(str "url(#fill-" index "-" render-id ")")
|
||||||
:fontSize (:font-size data)
|
browser-props)
|
||||||
: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))))
|
|
||||||
shape (-> shape
|
shape (-> shape
|
||||||
(assoc :fills (:fills data))
|
(assoc :fills (:fills data))
|
||||||
;; The text elements have the shadow and blur already applied in the
|
;; The text elements have the shadow and blur already applied in the
|
||||||
@ -290,106 +334,25 @@
|
|||||||
[:& fills/fills {:shape shape :render-id render-id}]]
|
[:& fills/fills {:shape shape :render-id render-id}]]
|
||||||
|
|
||||||
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
|
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
|
||||||
(if warichu?
|
(if (jl/warichu-text? data)
|
||||||
;; Warichu: two half-size sub-lines within one inline strip.
|
(warichu-lines shape data index annotation-fill)
|
||||||
;; Vertical reading order is right then left; horizontal is
|
[:> :text text-props (:text data)])]
|
||||||
;; top then bottom.
|
(when (some? ruby)
|
||||||
(let [text (:text data)
|
[:> :text (ruby-text-props shape data index ruby-font-size
|
||||||
split-index (warichu-split-index text)
|
(annotation-style data ruby-font-size vertical?
|
||||||
centre (+ (:x data) (/ (:width data) 2))
|
"upright" annotation-fill))
|
||||||
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"))))
|
|
||||||
ruby])
|
ruby])
|
||||||
;; Emphasis marks (圏点): one half-size mark per eligible base
|
;; Emphasis marks (圏点): one half-size mark per eligible base
|
||||||
;; character, right of vertical text or above horizontal text.
|
;; character.
|
||||||
(when emphasis?
|
(when (some? emphasis-mark)
|
||||||
[:> :text (if vertical?
|
(let [ruby-layer? (and (= "auto" (:annotation-clearance data))
|
||||||
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
|
(not= "under" (:ruby-side data "over"))
|
||||||
:x (+ (:x data)
|
(or (some? ruby) (:annotation-has-ruby data)))
|
||||||
(:width data)
|
mark-size (scaled-size jl/emphasis-font-scale)
|
||||||
(/ emphasis-font-size-num 2)
|
style (annotation-style data mark-size vertical?
|
||||||
(if (and auto-clearance? has-ruby-layer?)
|
(when vertical? "upright")
|
||||||
ruby-font-size-num
|
annotation-fill)]
|
||||||
0))
|
[:> :text (emphasis-text-props shape data index mark-size
|
||||||
:y (- (:y data) (:height data))
|
(if ruby-layer? ruby-font-size 0)
|
||||||
:textLength (:height data)
|
style)
|
||||||
:lengthAdjust "spacing"
|
(emphasis-marks-text (:text data) emphasis-mark)]))]))]]))
|
||||||
: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)])]))]]))
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.main.ui.workspace.sidebar.options.common
|
(ns app.main.ui.workspace.sidebar.options.common
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.main.data.workspace.tokens.application :as dwta]
|
[app.main.data.workspace.tokens.application :as dwta]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
@ -26,6 +27,20 @@
|
|||||||
:ref ref}
|
:ref ref}
|
||||||
children])))
|
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]
|
(defn emit-value-or-token [value emit-value-fn ids attrs]
|
||||||
(if (or (string? value)
|
(if (or (string? value)
|
||||||
(number? value)
|
(number? value)
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
[app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]]
|
[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.ds.foundations.assets.icon :as i]
|
||||||
[app.main.ui.hooks :as hooks]
|
[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.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.token-typography-row :refer [token-typography-row*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
|
[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."
|
Evaluated once at module load time; cf/flags is immutable after startup."
|
||||||
(contains? cf/flags :token-typography-row))
|
(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*
|
(mf/defc text-align-options*
|
||||||
[{:keys [values on-change on-blur]}]
|
[{:keys [values on-change on-blur]}]
|
||||||
(let [options
|
(let [options
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout
|
(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
|
||||||
[app.common.types.file :as ctf]
|
[app.common.types.file :as ctf]
|
||||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
[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.select :refer [select*]]
|
||||||
[app.main.ui.ds.controls.switch :refer [switch*]]
|
[app.main.ui.ds.controls.switch :refer [switch*]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
[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.dom :as dom]
|
||||||
[app.util.i18n :refer [tr]]
|
[app.util.i18n :refer [tr]]
|
||||||
[app.util.text.writing-mode :as wm]
|
[app.util.text.writing-mode :as wm]
|
||||||
[rumext.v2 :as mf]))
|
[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
|
(def ^:private mixed-span-values
|
||||||
#{:mixed :multiple "mixed" "multiple"})
|
#{:mixed :multiple "mixed" "multiple"})
|
||||||
|
|
||||||
@ -47,13 +34,13 @@
|
|||||||
"mixed"
|
"mixed"
|
||||||
(radio-selected value)))
|
(radio-selected value)))
|
||||||
|
|
||||||
(defn- span-select-value
|
(defn span-select-value
|
||||||
[value default]
|
[value default]
|
||||||
(if (mixed-span-value? value)
|
(if (mixed-span-value? value)
|
||||||
"mixed"
|
"mixed"
|
||||||
(radio-selected value default)))
|
(radio-selected value default)))
|
||||||
|
|
||||||
(defn- with-mixed-span-option
|
(defn with-mixed-span-option
|
||||||
[options value]
|
[options value]
|
||||||
(cond-> options
|
(cond-> options
|
||||||
(mixed-span-value? value)
|
(mixed-span-value? value)
|
||||||
@ -102,61 +89,57 @@
|
|||||||
;; Sub-components
|
;; Sub-components
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(mf/defc writing-mode-options*
|
(mf/defc attr-radio-options*
|
||||||
[{:keys [values on-change on-blur]}]
|
"Radio group bound to one text attribute."
|
||||||
(let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb")
|
[{:keys [values on-change on-blur attr default-value name class options]}]
|
||||||
options
|
(let [selected (radio-selected (get values attr) default-value)
|
||||||
(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}])
|
|
||||||
|
|
||||||
handle-change
|
handle-change
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps on-change on-blur)
|
(mf/deps attr on-change on-blur)
|
||||||
(fn [value]
|
(fn [value]
|
||||||
(on-change {:writing-mode value})
|
(on-change {attr value})
|
||||||
(when (some? on-blur) (on-blur))))]
|
(when (some? on-blur) (on-blur))))]
|
||||||
|
[:div {:class class}
|
||||||
[:div {:class (stl/css :writing-mode-options)}
|
[:> radio-buttons* {:selected selected
|
||||||
[:> radio-buttons* {:selected writing-mode
|
|
||||||
:on-change handle-change
|
:on-change handle-change
|
||||||
:name "writing-mode-options"
|
:name name
|
||||||
:options options}]]))
|
:options options}]]))
|
||||||
|
|
||||||
(mf/defc text-orientation-options*
|
(defn- writing-mode-options
|
||||||
[{:keys [values on-change on-blur]}]
|
[]
|
||||||
;; The v2 editor can read the paragraph orientation back as an empty
|
[{:value "horizontal-tb"
|
||||||
;; string when it is unset; treat that (and nil) as the "mixed" default.
|
:id "horizontal-tb-writing-mode"
|
||||||
(let [text-orientation (radio-selected (:text-orientation values) "mixed")
|
:label (tr "workspace.options.text-options.writing-mode-horizontal")
|
||||||
options
|
:icon i/writing-mode-horizontal}
|
||||||
(mf/with-memo []
|
{:value "vertical-rl"
|
||||||
[{:value "mixed"
|
:id "vertical-rl-writing-mode"
|
||||||
:id "mixed-text-orientation"
|
:label (tr "workspace.options.text-options.writing-mode-vertical")
|
||||||
:label (tr "workspace.options.text-options.text-orientation-mixed")
|
:icon i/writing-mode-vertical}])
|
||||||
: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}])
|
|
||||||
|
|
||||||
handle-change
|
(defn- text-orientation-options
|
||||||
(mf/use-fn
|
[]
|
||||||
(mf/deps on-change on-blur)
|
[{:value "mixed"
|
||||||
(fn [value]
|
:id "mixed-text-orientation"
|
||||||
(on-change {:text-orientation value})
|
:label (tr "workspace.options.text-options.text-orientation-mixed")
|
||||||
(when (some? on-blur) (on-blur))))]
|
: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)}
|
;; Warichu (割注): a span-scoped toggle that renders the selection as two
|
||||||
[:> radio-buttons* {:selected text-orientation
|
;; half-size lines within one inline position (top/bottom in horizontal
|
||||||
:on-change handle-change
|
;; flow, right/left in vertical flow).
|
||||||
:name "text-orientation-options"
|
(defn- warichu-options
|
||||||
:options 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*
|
(mf/defc text-combine-upright-options*
|
||||||
;; Digit TCY can be applied across a shape because it discovers eligible
|
;; Digit TCY can be applied across a shape because it discovers eligible
|
||||||
@ -268,7 +251,8 @@
|
|||||||
:on-click on-toggle-advanced
|
:on-click on-toggle-advanced
|
||||||
:icon i/menu}]]))
|
: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]}]
|
[{:keys [values on-change on-blur attr default-value label options]}]
|
||||||
(let [value (get values attr)
|
(let [value (get values attr)
|
||||||
selected (span-select-value value default-value)
|
selected (span-select-value value default-value)
|
||||||
@ -329,22 +313,22 @@
|
|||||||
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
|
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
|
||||||
[:div {:class (stl/css :japanese-layout-controls)}
|
[:div {:class (stl/css :japanese-layout-controls)}
|
||||||
[:> ruby-hidden-option* common-props]
|
[:> ruby-hidden-option* common-props]
|
||||||
[:> ruby-select-option* (mf/spread-props common-props
|
[:> span-select-option* (mf/spread-props common-props
|
||||||
{:attr :ruby-size
|
{:attr :ruby-size
|
||||||
:default-value "half"
|
:default-value "half"
|
||||||
:label (tr "workspace.options.text-options.ruby-size")
|
:label (tr "workspace.options.text-options.ruby-size")
|
||||||
:options size-options})]
|
:options size-options})]
|
||||||
[:> ruby-select-option* (mf/spread-props common-props
|
[:> span-select-option* (mf/spread-props common-props
|
||||||
{:attr :ruby-align
|
{:attr :ruby-align
|
||||||
:default-value "space-around"
|
:default-value "space-around"
|
||||||
:label (tr "workspace.options.text-options.ruby-align")
|
:label (tr "workspace.options.text-options.ruby-align")
|
||||||
:options align-options})]
|
:options align-options})]
|
||||||
[:> ruby-select-option* (mf/spread-props common-props
|
[:> span-select-option* (mf/spread-props common-props
|
||||||
{:attr :ruby-overhang
|
{:attr :ruby-overhang
|
||||||
:default-value "auto"
|
:default-value "auto"
|
||||||
:label (tr "workspace.options.text-options.ruby-overhang")
|
:label (tr "workspace.options.text-options.ruby-overhang")
|
||||||
:options overhang-options})]
|
:options overhang-options})]
|
||||||
[:> ruby-select-option* (mf/spread-props common-props
|
[:> span-select-option* (mf/spread-props common-props
|
||||||
{:attr :ruby-side
|
{:attr :ruby-side
|
||||||
:default-value "over"
|
:default-value "over"
|
||||||
:label (tr "workspace.options.text-options.ruby-side")
|
:label (tr "workspace.options.text-options.ruby-side")
|
||||||
@ -381,37 +365,6 @@
|
|||||||
:is-visible open?}
|
:is-visible open?}
|
||||||
[:> ruby-customization-options* common-props]]]))
|
[:> 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*
|
(mf/defc font-features-options*
|
||||||
;; Japanese proportional metric alternates. `palt` is typically used for
|
;; Japanese proportional metric alternates. `palt` is typically used for
|
||||||
;; horizontal composition and `vpal` for vertical composition; the value is
|
;; horizontal composition and `vpal` for vertical composition; the value is
|
||||||
@ -432,7 +385,7 @@
|
|||||||
(on-change {:font-features (if checked? feature "none")})
|
(on-change {:font-features (if checked? feature "none")})
|
||||||
(when (some? on-blur) (on-blur))))]
|
(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)}
|
[:span {:class (stl/css :japanese-option-label)}
|
||||||
(tr "workspace.options.text-options.font-features")]
|
(tr "workspace.options.text-options.font-features")]
|
||||||
[:> switch* {:default-checked enabled?
|
[:> switch* {:default-checked enabled?
|
||||||
@ -458,33 +411,6 @@
|
|||||||
{:id "open-sesame"
|
{:id "open-sesame"
|
||||||
:label (translate "workspace.options.text-options.text-emphasis-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
|
(defn annotation-clearance-options
|
||||||
([]
|
([]
|
||||||
(annotation-clearance-options tr))
|
(annotation-clearance-options tr))
|
||||||
@ -494,26 +420,6 @@
|
|||||||
{:id "auto"
|
{:id "auto"
|
||||||
:label (translate "workspace.options.text-options.annotation-clearance-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*
|
(mf/defc japanese-layout-options*
|
||||||
[{:keys [values ruby-values text-selection-active
|
[{:keys [values ruby-values text-selection-active
|
||||||
on-change on-ruby-presentation-change on-blur]}]
|
on-change on-ruby-presentation-change on-blur]}]
|
||||||
@ -525,26 +431,58 @@
|
|||||||
ruby-presentation-props (mf/props
|
ruby-presentation-props (mf/props
|
||||||
{:values ruby-values
|
{:values ruby-values
|
||||||
:on-change on-ruby-presentation-change
|
: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-options)}
|
||||||
[:div {:class (stl/css :japanese-layout-controls)}
|
[:div {:class (stl/css :japanese-layout-controls)}
|
||||||
[:div {:class (stl/css :japanese-icon-options)}
|
[: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?
|
(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*
|
[:> text-combine-upright-options*
|
||||||
(mf/spread-props common-props
|
(mf/spread-props common-props
|
||||||
{:text-selection-active text-selection-active})]])
|
{:text-selection-active text-selection-active})]])
|
||||||
(when ^boolean 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?
|
(when ^boolean vertical?
|
||||||
[:> text-combine-upright-count-options* common-props])
|
[:> text-combine-upright-count-options* common-props])
|
||||||
[:> font-features-options* common-props]
|
[:> font-features-options* common-props]
|
||||||
|
;; Emphasis marks (圏点 / bouten) apply to the selected characters.
|
||||||
(when ^boolean text-selection-active
|
(when ^boolean text-selection-active
|
||||||
[:> text-emphasis-options* common-props])
|
[:> span-select-option* (mf/spread-props common-props
|
||||||
[:> annotation-clearance-options* 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
|
(if text-selection-active
|
||||||
[:> ruby-advanced-options* common-props]
|
[:> ruby-advanced-options* common-props]
|
||||||
[:> ruby-presentation-options* ruby-presentation-props])]]))
|
[:> ruby-presentation-options* ruby-presentation-props])]]))
|
||||||
|
|||||||
@ -41,12 +41,6 @@
|
|||||||
border-radius: $br-8;
|
border-radius: $br-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-emphasis-options,
|
|
||||||
.font-features-options,
|
|
||||||
.annotation-clearance-options {
|
|
||||||
inline-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.japanese-layout-options,
|
.japanese-layout-options,
|
||||||
.japanese-layout-controls {
|
.japanese-layout-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.types.fills :as types.fills]
|
[app.common.types.fills :as types.fills]
|
||||||
[app.common.types.text :as txt]
|
[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.shapes :as dwsh]
|
||||||
[app.main.data.workspace.texts :as dwt]
|
[app.main.data.workspace.texts :as dwt]
|
||||||
[app.main.data.workspace.wasm-text :as dwwt]
|
[app.main.data.workspace.wasm-text :as dwwt]
|
||||||
@ -102,24 +103,57 @@
|
|||||||
result))))
|
result))))
|
||||||
|
|
||||||
(def ^:private japanese-range-defaults
|
(def ^:private japanese-range-defaults
|
||||||
{:text-combine-upright "none"
|
;; A range without ruby reports null to plugins.
|
||||||
:text-emphasis "none"
|
(assoc jl/span-attr-defaults :ruby nil))
|
||||||
:ruby nil
|
|
||||||
:ruby-size "half"
|
|
||||||
:ruby-align "space-around"
|
|
||||||
:ruby-overhang "auto"
|
|
||||||
:ruby-side "over"
|
|
||||||
:warichu "none"
|
|
||||||
:font-features "none"
|
|
||||||
:annotation-clearance "none"})
|
|
||||||
|
|
||||||
(defn- range-japanese-value
|
(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)]
|
(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))
|
(map #(get % attr default))
|
||||||
(u/mixed-value))))
|
(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?
|
(defn text-range-proxy?
|
||||||
[range]
|
[range]
|
||||||
(obj/type-of? range "TextRange"))
|
(obj/type-of? range "TextRange"))
|
||||||
@ -394,223 +428,53 @@
|
|||||||
|
|
||||||
:fontFeatures
|
:fontFeatures
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :font-features))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :fontFeatures :font-features (enum-value? font-features-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:textCombineUpright
|
:textCombineUpright
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :text-combine-upright))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:textEmphasis
|
:textEmphasis
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :text-emphasis))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :textEmphasis :text-emphasis (enum-value? text-emphasis-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:warichu
|
:warichu
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :warichu))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :warichu :warichu (enum-value? warichu-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:annotationClearance
|
:annotationClearance
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :annotation-clearance))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:ruby
|
:ruby
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :ruby))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :ruby :ruby optional-string?)}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:rubySize
|
:rubySize
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :ruby-size))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :rubySize :ruby-size (enum-value? ruby-size-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:rubyAlign
|
:rubyAlign
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :ruby-align))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :rubyAlign :ruby-align (enum-value? ruby-align-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:rubyOverhang
|
:rubyOverhang
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :ruby-overhang))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:rubySide
|
:rubySide
|
||||||
{:this true
|
{:this true
|
||||||
:get
|
:get (fn [self] (range-japanese-value self start end :ruby-side))
|
||||||
(fn [self]
|
:set (range-attr-setter plugin-id page-id id start end :rubySide :ruby-side (enum-value? ruby-side-re))}
|
||||||
(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}))))}
|
|
||||||
|
|
||||||
:direction
|
:direction
|
||||||
{:this true
|
{:this true
|
||||||
@ -1008,207 +872,51 @@
|
|||||||
|
|
||||||
{:name "writingMode"
|
{:name "writingMode"
|
||||||
:get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :writingMode :writing-mode (enum-value? writing-mode-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "textOrientation"
|
{:name "textOrientation"
|
||||||
:get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :textOrientation :text-orientation (enum-value? text-orientation-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "textCombineUpright"
|
{:name "textCombineUpright"
|
||||||
:get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "textEmphasis"
|
{:name "textEmphasis"
|
||||||
:get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? text-emphasis-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "warichu"
|
{:name "warichu"
|
||||||
:get #(-> % u/proxy->shape text-props :warichu format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :warichu format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :warichu :warichu (enum-value? warichu-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "fontFeatures"
|
{:name "fontFeatures"
|
||||||
:get #(-> % u/proxy->shape text-props :font-features format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :font-features format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :fontFeatures :font-features (enum-value? font-features-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "annotationClearance"
|
{:name "annotationClearance"
|
||||||
:get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "ruby"
|
{:name "ruby"
|
||||||
:get #(-> % u/proxy->shape text-props :ruby format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :ruby format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :ruby :ruby optional-string?)}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "rubySize"
|
{:name "rubySize"
|
||||||
:get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :rubySize :ruby-size (enum-value? ruby-size-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "rubyAlign"
|
{:name "rubyAlign"
|
||||||
:get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :rubyAlign :ruby-align (enum-value? ruby-align-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "rubyOverhang"
|
{:name "rubyOverhang"
|
||||||
:get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "rubySide"
|
{:name "rubySide"
|
||||||
:get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed)
|
:get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed)
|
||||||
:set
|
:set (shape-attr-setter plugin-id page-id :rubySide :ruby-side (enum-value? ruby-side-re))}
|
||||||
(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})))))}
|
|
||||||
|
|
||||||
{:name "textBounds"
|
{:name "textBounds"
|
||||||
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})))
|
: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.path.impl :as path.impl]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
[app.common.types.text :as txt]
|
[app.common.types.text :as txt]
|
||||||
|
[app.common.types.text.japanese-layout :as jl]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
@ -2931,60 +2932,90 @@
|
|||||||
(def POSITION-DATA-U8-SIZE 36)
|
(def POSITION-DATA-U8-SIZE 36)
|
||||||
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
|
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
|
||||||
|
|
||||||
(defn- ruby-font-scale
|
(def ^:private POSITION-DATA-VERTICAL 2)
|
||||||
[ruby-size]
|
(def ^:private POSITION-DATA-RUBY 3)
|
||||||
(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)))
|
|
||||||
|
|
||||||
(defn- ruby-strip-entry
|
(defn- ruby-strip-entry
|
||||||
"Position-data entry for a ruby annotation strip (direction 3): the
|
"Position-data entry for a ruby annotation strip: the offsets index the
|
||||||
offsets index the span's ruby string and the geometry is the exact
|
span's ruby string and the geometry is the exact gutter placement the
|
||||||
gutter placement the canvas paints."
|
canvas paints."
|
||||||
[element {:keys [start-pos end-pos x y width height]}]
|
[element {:keys [start-pos end-pos x y width height]}]
|
||||||
(let [ruby (get element :ruby)]
|
(when-let [ruby (jl/visible-ruby element)]
|
||||||
(when (and (not (true? (:ruby-hidden element)))
|
(let [text (subs ruby
|
||||||
(string? ruby))
|
(min start-pos (count ruby))
|
||||||
(let [text (subs ruby
|
(min end-pos (count ruby)))
|
||||||
(min start-pos (count ruby))
|
font-size (js/parseFloat (get element :font-size))]
|
||||||
(min end-pos (count ruby)))
|
(when (seq text)
|
||||||
font-size (js/parseFloat (get element :font-size))]
|
(d/patch-object
|
||||||
(when (seq text)
|
(txt/get-default-text-attrs)
|
||||||
(d/patch-object
|
(d/without-nils
|
||||||
(txt/get-default-text-attrs)
|
{:x x
|
||||||
(d/without-nils
|
:y (+ y height)
|
||||||
{:x x
|
:width width
|
||||||
:y (+ y height)
|
:height height
|
||||||
:width width
|
:direction "ltr"
|
||||||
:height height
|
:writing-mode "vertical-rl"
|
||||||
:direction "ltr"
|
:text-orientation "upright"
|
||||||
:writing-mode "vertical-rl"
|
:font-id (get element :font-id)
|
||||||
:text-orientation "upright"
|
:font-family (get element :font-family)
|
||||||
:font-id (get element :font-id)
|
:font-size (when-not (js/isNaN font-size)
|
||||||
:font-family (get element :font-family)
|
(dm/str (* (jl/ruby-font-scale (:ruby-size element)) font-size) "px"))
|
||||||
:font-size (when-not (js/isNaN font-size)
|
:font-weight (get element :font-weight)
|
||||||
(dm/str (* (ruby-font-scale (:ruby-size element)) font-size) "px"))
|
:font-style (get element :font-style)
|
||||||
:font-weight (get element :font-weight)
|
:ruby-size (get element :ruby-size)
|
||||||
:font-style (get element :font-style)
|
:ruby-align (get element :ruby-align)
|
||||||
:ruby-size (get element :ruby-size)
|
:ruby-overhang (get element :ruby-overhang)
|
||||||
:ruby-align (get element :ruby-align)
|
:ruby-side (get element :ruby-side)
|
||||||
:ruby-overhang (get element :ruby-overhang)
|
:fills (get element :fills)
|
||||||
:ruby-side (get element :ruby-side)
|
:text text}))))))
|
||||||
: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
|
(defn calculate-position-data
|
||||||
[shape]
|
[shape]
|
||||||
@ -3013,82 +3044,14 @@
|
|||||||
|
|
||||||
(into []
|
(into []
|
||||||
(keep
|
(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
|
(let [paragraph-node (-> content :children
|
||||||
(get 0) :children ;; paragraph-set
|
(get 0) :children ;; paragraph-set
|
||||||
(get paragraph))
|
(get paragraph))
|
||||||
element (-> paragraph-node :children ;; paragraph
|
element (-> paragraph-node :children (get span))]
|
||||||
(get span))
|
(if (= direction POSITION-DATA-RUBY)
|
||||||
element-text (:text element)]
|
|
||||||
(if (= direction 3)
|
|
||||||
(ruby-strip-entry element entry)
|
(ruby-strip-entry element entry)
|
||||||
;; Glyph orientation of a vertical strip; stored on the
|
(text-strip-entry paragraph-node element entry)))))
|
||||||
;; 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})))))))))
|
|
||||||
result))))
|
result))))
|
||||||
|
|
||||||
(defn apply-canvas-blur
|
(defn apply-canvas-blur
|
||||||
|
|||||||
@ -697,29 +697,16 @@
|
|||||||
[para]
|
[para]
|
||||||
(apply + (map (fn [span] (count (:text span))) (:children 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
|
(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]
|
[span]
|
||||||
(reduce-kv
|
(reduce-kv
|
||||||
(fn [styles attr default]
|
(fn [styles attr default]
|
||||||
(assoc styles attr (or (get span attr) default)))
|
(assoc styles attr (or (get span attr) default)))
|
||||||
{}
|
{}
|
||||||
japanese-span-style-defaults))
|
jl/span-attr-defaults))
|
||||||
|
|
||||||
(defn- merge-selection-styles
|
(defn- merge-selection-styles
|
||||||
[result styles]
|
[result styles]
|
||||||
|
|||||||
@ -38,223 +38,44 @@
|
|||||||
[re s]
|
[re s]
|
||||||
(count (re-seq re s)))
|
(count (re-seq re s)))
|
||||||
|
|
||||||
(defn- setup-vertical-text
|
(def ^:private strip-defaults
|
||||||
[]
|
{:fills [{:fill-color "#112233" :fill-opacity 1}]
|
||||||
(let [shape (-> (cts/setup-shape {:type :text
|
:font-family "Noto Sans CJK JP"
|
||||||
:x 10
|
:font-size "20"
|
||||||
:y 20
|
:font-weight "400"})
|
||||||
:width 40
|
|
||||||
:height 100})
|
(defn- setup-text
|
||||||
(assoc :name "Vertical text"
|
"A text shape whose single position-data strip is `strip` (over
|
||||||
:position-data
|
`strip-defaults`), with its objects and selection."
|
||||||
[{:x 20
|
[{:keys [width height]} strip]
|
||||||
:y 100
|
(let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height})
|
||||||
:width 24
|
(assoc :name "Text"
|
||||||
:height 80
|
:position-data [(merge strip-defaults strip)]))
|
||||||
: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"}]))
|
|
||||||
file (cths/add-sample-shape
|
file (cths/add-sample-shape
|
||||||
(cthf/sample-file :file1 :page-label :page1)
|
(cthf/sample-file :file1 :page-label :page1)
|
||||||
:vertical-text
|
:text
|
||||||
shape)
|
shape)
|
||||||
page (cthf/current-page file)
|
objects (:objects (cthf/current-page file))]
|
||||||
objects (:objects page)]
|
|
||||||
{:objects objects
|
{:objects objects
|
||||||
:shapes [(get objects (cthi/id :vertical-text))]}))
|
:shapes [(get objects (cthi/id :text))]}))
|
||||||
|
|
||||||
(defn- setup-vertical-ruby-text
|
(defn- vertical-strip
|
||||||
[]
|
[attrs]
|
||||||
(let [shape (-> (cts/setup-shape {:type :text
|
(merge {:x 20 :y 100 :width 24 :height 80
|
||||||
:x 10
|
:writing-mode "vertical-rl"
|
||||||
:y 20
|
:text-orientation "upright"}
|
||||||
:width 60
|
attrs))
|
||||||
: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- setup-vertical-emphasis-text
|
(defn- horizontal-strip
|
||||||
[]
|
[attrs]
|
||||||
(let [shape (-> (cts/setup-shape {:type :text
|
(merge {:x 20 :y 60 :width 100 :height 20
|
||||||
:x 10
|
:writing-mode "horizontal-tb"}
|
||||||
:y 20
|
attrs))
|
||||||
: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- setup-vertical-warichu-text
|
(defn- setup-vertical-warichu-text
|
||||||
([] (setup-vertical-warichu-text "割注入り"))
|
[text]
|
||||||
([text]
|
(setup-text {:width 60 :height 100}
|
||||||
(let [shape (-> (cts/setup-shape {:type :text
|
(vertical-strip {:height 40 :text text :warichu "warichu"})))
|
||||||
: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))]})))
|
|
||||||
|
|
||||||
(deftest empty-selection-yields-empty-string
|
(deftest empty-selection-yields-empty-string
|
||||||
(is (= "" (svg/generate-markup {} []))))
|
(is (= "" (svg/generate-markup {} []))))
|
||||||
@ -281,7 +102,8 @@
|
|||||||
|
|
||||||
(deftest vertical-text-svg-preserves-browser-layout-properties
|
(deftest vertical-text-svg-preserves-browser-layout-properties
|
||||||
(testing "Static SVG carries the vertical writing properties used by browser exports"
|
(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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"writing-mode:vertical-rl" markup))
|
(is (re-find #"writing-mode:vertical-rl" markup))
|
||||||
(is (re-find #"text-orientation:upright" markup))
|
(is (re-find #"text-orientation:upright" markup))
|
||||||
@ -291,7 +113,8 @@
|
|||||||
|
|
||||||
(deftest vertical-emphasis-svg-emits-static-marks
|
(deftest vertical-emphasis-svg-emits-static-marks
|
||||||
(testing "Static SVG draws emphasis marks and skips whitespace characters"
|
(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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"強調 あ" markup))
|
(is (re-find #"強調 あ" markup))
|
||||||
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
|
;; 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
|
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
|
||||||
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
|
(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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"強調、 あ" markup))
|
(is (re-find #"強調、 あ" markup))
|
||||||
;; Five Unicode base characters: two marks, punctuation and whitespace
|
;; Five Unicode base characters: two marks, punctuation and whitespace
|
||||||
@ -317,7 +141,12 @@
|
|||||||
|
|
||||||
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
|
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
|
||||||
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
|
(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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"かんじ" markup))
|
(is (re-find #"かんじ" markup))
|
||||||
(is (re-find #"••" markup))
|
(is (re-find #"••" markup))
|
||||||
@ -326,7 +155,7 @@
|
|||||||
|
|
||||||
(deftest vertical-warichu-svg-emits-two-sub-columns
|
(deftest vertical-warichu-svg-emits-two-sub-columns
|
||||||
(testing "Static SVG splits a warichu strip into two half-size 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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"割注" markup))
|
(is (re-find #"割注" markup))
|
||||||
(is (re-find #"入り" markup))
|
(is (re-find #"入り" markup))
|
||||||
@ -354,7 +183,8 @@
|
|||||||
|
|
||||||
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
|
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
|
||||||
(testing "Static SVG splits horizontal warichu into top and bottom half-size 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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"割注" markup))
|
(is (re-find #"割注" markup))
|
||||||
(is (re-find #"入り" markup))
|
(is (re-find #"入り" markup))
|
||||||
@ -365,7 +195,8 @@
|
|||||||
|
|
||||||
(deftest vertical-ruby-svg-emits-static-annotation
|
(deftest vertical-ruby-svg-emits-static-annotation
|
||||||
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
|
(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)]
|
markup (svg/generate-markup objects shapes)]
|
||||||
(is (re-find #"漢字" markup))
|
(is (re-find #"漢字" markup))
|
||||||
(is (re-find #"かんじ" markup))
|
(is (re-find #"かんじ" markup))
|
||||||
|
|||||||
@ -26,10 +26,10 @@
|
|||||||
;; so negative values were refused. Pin the accept/reject contract of the
|
;; so negative values were refused. Pin the accept/reject contract of the
|
||||||
;; plugin setter here.
|
;; plugin setter here.
|
||||||
|
|
||||||
(defn- apply-letter-spacing
|
(defn- apply-range-property
|
||||||
"Sets `letterSpacing` on a text range proxy and returns the attributes sent
|
"Sets `property` on a text range proxy and returns the attributes sent to
|
||||||
to the update event, or nil when the plugin rejected the value."
|
the update event, or nil when the plugin rejected the value."
|
||||||
[value]
|
[property value]
|
||||||
(let [captured (atom nil)
|
(let [captured (atom nil)
|
||||||
range (plugins.text/text-range-proxy
|
range (plugins.text/text-range-proxy
|
||||||
plugin-id (random-uuid) (random-uuid) (random-uuid) 0 4)]
|
plugin-id (random-uuid) (random-uuid) (random-uuid) 0 4)]
|
||||||
@ -41,19 +41,12 @@
|
|||||||
(reset! captured attrs)
|
(reset! captured attrs)
|
||||||
:update-text-range)
|
:update-text-range)
|
||||||
st/emit! mock/noop]
|
st/emit! mock/noop]
|
||||||
(set! (.-letterSpacing range) value)
|
(unchecked-set range property value)
|
||||||
@captured)))
|
@captured)))
|
||||||
|
|
||||||
(def ^:private font-features-re @#'plugins.text/font-features-re)
|
(defn- apply-letter-spacing
|
||||||
(def ^:private annotation-clearance-re @#'plugins.text/annotation-clearance-re)
|
[value]
|
||||||
(def ^:private ruby-size-re @#'plugins.text/ruby-size-re)
|
(apply-range-property "letterSpacing" value))
|
||||||
(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)))
|
|
||||||
|
|
||||||
(t/deftest letter-spacing-accepts-negative-values
|
(t/deftest letter-spacing-accepts-negative-values
|
||||||
(t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56")))
|
(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-2")))
|
||||||
(t/is (nil? (apply-letter-spacing "--1"))))
|
(t/is (nil? (apply-letter-spacing "--1"))))
|
||||||
|
|
||||||
(t/deftest font-features-re-accepts-supported-japanese-proportional-features
|
(t/deftest japanese-range-properties-accept-only-supported-values
|
||||||
(t/is (valid-font-features? "none"))
|
(doseq [[property attr accepted rejected]
|
||||||
(t/is (valid-font-features? "palt"))
|
[["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]]
|
||||||
(t/is (valid-font-features? "vpal"))
|
["annotationClearance" :annotation-clearance ["none" "auto"] ["always"]]
|
||||||
(t/is (not (valid-font-features? "liga")))
|
["rubySize" :ruby-size ["half" "third" "quarter"] ["full"]]
|
||||||
(t/is (not (valid-font-features? "palt,vpal"))))
|
["rubyAlign" :ruby-align ["space-around" "center" "start" "space-between"] ["end"]]
|
||||||
|
["rubyOverhang" :ruby-overhang ["auto" "none"] ["always"]]
|
||||||
(t/deftest annotation-clearance-re-accepts-supported-policies
|
["rubySide" :ruby-side ["over" "under"] ["right"]]
|
||||||
(t/is (valid-annotation-clearance? "none"))
|
["ruby" :ruby ["かんじ" nil] [42]]]]
|
||||||
(t/is (valid-annotation-clearance? "auto"))
|
(doseq [value accepted]
|
||||||
(t/is (not (valid-annotation-clearance? "always"))))
|
(t/is (= {attr value} (apply-range-property property value))
|
||||||
|
(str property " accepts " (pr-str value))))
|
||||||
(t/deftest ruby-customization-validates-supported-values
|
(doseq [value rejected]
|
||||||
(t/is (every? #(re-matches ruby-size-re %) ["half" "third" "quarter"]))
|
(t/is (nil? (apply-range-property property value))
|
||||||
(t/is (not (re-matches ruby-size-re "full")))
|
(str property " rejects " (pr-str value))))))
|
||||||
(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 text-range-japanese-properties-read-span-values
|
(t/deftest text-range-japanese-properties-read-span-values
|
||||||
(let [file-id (random-uuid)
|
(let [file-id (random-uuid)
|
||||||
|
|||||||
@ -5,12 +5,12 @@
|
|||||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
(ns frontend-tests.render-wasm.texts-test
|
(ns frontend-tests.render-wasm.texts-test
|
||||||
"Unit tests for CJK script classification and the Han-unification
|
"Japanese text support in the WASM bridge: span serialization, editor
|
||||||
resolution policy used to pick Noto fallback fonts."
|
selection styles, IME composition caret placement, and the CJK script
|
||||||
|
classification used to pick Noto fallback fonts."
|
||||||
(:require
|
(:require
|
||||||
[app.common.fonts :as cfnt]
|
[app.common.fonts :as cfnt]
|
||||||
[app.common.render-wasm.text-content :as tc]
|
[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.main.ui.workspace.shapes.text.v3-editor :as v3-editor]
|
||||||
[app.render-wasm.api :as api]
|
[app.render-wasm.api :as api]
|
||||||
[app.render-wasm.api.texts :as texts]
|
[app.render-wasm.api.texts :as texts]
|
||||||
@ -148,119 +148,66 @@
|
|||||||
(t/is (not-any? #(contains? % :text-orientation) paragraphs))
|
(t/is (not-any? #(contains? % :text-orientation) paragraphs))
|
||||||
(t/is (= [nil "center" nil] (mapv :text-align paragraphs)))))
|
(t/is (= [nil "center" nil] (mapv :text-align paragraphs)))))
|
||||||
|
|
||||||
(t/deftest write-spans-serializes-font-features-in-reserved-byte
|
;; Byte offsets inside one span's attribute block (see `write-spans` and
|
||||||
(let [sentinel (js-obj)
|
;; RawTextSpan in render-wasm).
|
||||||
previous (if (.hasOwnProperty wasm/serializers "font-features")
|
(def ^:private span-attr-offset
|
||||||
(unchecked-get wasm/serializers "font-features")
|
{:text-combine-upright 5
|
||||||
sentinel)]
|
:font-features 8
|
||||||
(try
|
:annotation-clearance 9
|
||||||
(aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2})
|
:ruby-size 10
|
||||||
(let [buffer (js/ArrayBuffer. 256)
|
:ruby-align 11
|
||||||
dview (js/DataView. buffer)
|
:ruby-overhang 12
|
||||||
span {:text "日本語"
|
:ruby-side 13
|
||||||
:font-size "16"
|
:padding [14 15]
|
||||||
:font-weight "400"
|
:ruby-length 72})
|
||||||
: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))))))
|
|
||||||
|
|
||||||
(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte
|
(def ^:private span-paragraph
|
||||||
(let [sentinel (js-obj)
|
{:font-size "16" :font-weight "400" :line-height "1"})
|
||||||
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))))))
|
|
||||||
|
|
||||||
(t/deftest write-spans-serializes-ruby-customization-bytes
|
(defn- serialized-span
|
||||||
(let [buffer (js/ArrayBuffer. 256)
|
"DataView over the attribute block `write-spans` produces for `span`."
|
||||||
dview (js/DataView. buffer)
|
[span]
|
||||||
span {:text "漢字"
|
(let [dview (js/DataView. (js/ArrayBuffer. 256))]
|
||||||
:font-size "16"
|
(write-spans 0 dview [(merge {:font-size "16" :font-weight "400"} span)] span-paragraph)
|
||||||
:font-weight "400"
|
dview))
|
||||||
: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))))))
|
|
||||||
|
|
||||||
(t/deftest write-spans-omits-hidden-ruby-bytes
|
(defn- span-byte
|
||||||
(let [buffer (js/ArrayBuffer. 256)
|
[dview attr]
|
||||||
dview (js/DataView. buffer)
|
(.getUint8 dview (get span-attr-offset attr)))
|
||||||
paragraph {:font-size "16"
|
|
||||||
:font-weight "400"
|
(defn- padding-bytes
|
||||||
:line-height "1"}]
|
[dview]
|
||||||
(write-spans 0 dview [{:text "日"
|
(mapv #(.getUint8 dview %) (:padding span-attr-offset)))
|
||||||
:ruby "にち"
|
|
||||||
:ruby-hidden true
|
(t/deftest write-spans-serializes-font-features
|
||||||
:font-size "16"
|
(let [dview (serialized-span {:text "日本語" :font-features "vpal"})]
|
||||||
:font-weight "400"}]
|
(t/is (= 2 (span-byte dview :font-features)))
|
||||||
paragraph)
|
(t/is (= 0 (span-byte dview :annotation-clearance)))
|
||||||
(t/is (= 0 (.getInt32 dview 72 true)))
|
(t/is (= [0 0] (padding-bytes dview)))))
|
||||||
(write-spans 0 dview [{:text "日"
|
|
||||||
:ruby "にち"
|
(t/deftest write-spans-serializes-annotation-clearance
|
||||||
:ruby-hidden false
|
(let [dview (serialized-span {:text "漢字" :annotation-clearance "auto"})]
|
||||||
:font-size "16"
|
(t/is (= 1 (span-byte dview :annotation-clearance)))
|
||||||
:font-weight "400"}]
|
(t/is (= [0 0] (padding-bytes dview)))))
|
||||||
paragraph)
|
|
||||||
(t/is (pos? (.getInt32 dview 72 true)))))
|
(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
|
(t/deftest write-spans-serializes-counted-digits-tcy
|
||||||
(let [sentinel (js-obj)
|
(let [dview (serialized-span {:text "平成31年" :text-combine-upright "digits2"})]
|
||||||
previous (if (.hasOwnProperty wasm/serializers "text-combine-upright")
|
(t/is (= 3 (span-byte dview :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))))))
|
|
||||||
|
|
||||||
(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery
|
(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery
|
||||||
(let [content {:children
|
(let [content {:children
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
[app.main.ui.ds.controls.select :as select]
|
[app.main.ui.ds.controls.select :as select]
|
||||||
[app.main.ui.shapes.text.html-text :as html-text]
|
[app.main.ui.shapes.text.html-text :as html-text]
|
||||||
[app.main.ui.shapes.text.styles :as text-styles]
|
[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]
|
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
|
||||||
[cljs.test :as t :include-macros true]
|
[cljs.test :as t :include-macros true]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
@ -29,11 +30,6 @@
|
|||||||
:font-features
|
:font-features
|
||||||
:annotation-clearance])
|
: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/deftest japanese-layout-controls-follow-file-or-profile-configuration
|
||||||
(t/is (false? (tjl/japanese-layout-config-enabled? {} {})))
|
(t/is (false? (tjl/japanese-layout-config-enabled? {} {})))
|
||||||
@ -105,13 +101,6 @@
|
|||||||
(t/is (str/includes? markup ">日</span>"))
|
(t/is (str/includes? markup ">日</span>"))
|
||||||
(t/is (not (str/includes? markup "<ruby")))))
|
(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
|
(t/deftest shape-level-furigana-presentation-only-updates-ruby-spans
|
||||||
(let [shape {:content
|
(let [shape {:content
|
||||||
{:type "root"
|
{:type "root"
|
||||||
@ -140,15 +129,19 @@
|
|||||||
(t/is (= "quarter" (:ruby-size (second spans))))
|
(t/is (= "quarter" (:ruby-size (second spans))))
|
||||||
(t/is (false? (:ruby-hidden (second spans))))))
|
(t/is (false? (:ruby-hidden (second spans))))))
|
||||||
|
|
||||||
(t/deftest mixed-japanese-span-values-have-distinct-control-states
|
(t/deftest mixed-span-values-select-a-disabled-mixed-option
|
||||||
(let [options (with-mixed-span-option
|
(let [options (tjl/with-mixed-span-option (tjl/text-emphasis-options identity) :multiple)]
|
||||||
(tjl/text-emphasis-options identity)
|
(t/is (= "mixed" (tjl/span-select-value :multiple "none")))
|
||||||
:multiple)]
|
(t/is (= "none" (tjl/span-select-value nil "none")))
|
||||||
(t/is (= "mixed" (span-select-value :multiple "none")))
|
|
||||||
(t/is (= "mixed" (span-input-value :multiple)))
|
|
||||||
(t/is (= "mixed" (:id (last options))))
|
(t/is (= "mixed" (:id (last options))))
|
||||||
(t/is (true? (:disabled (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
|
(t/deftest whole-text-selection-reports-every-differing-span-value-as-mixed
|
||||||
(let [shape {:content
|
(let [shape {:content
|
||||||
|
|||||||
@ -39,10 +39,8 @@ pub fn render_vertical_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
|
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
|
||||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
|
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
|
||||||
let max_height = text_vertical::wrap_height(text_content, bounds.height());
|
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
|
||||||
text_vertical::layout_from_content(text_content, max_height)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
|
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
|
||||||
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
|
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() {
|
let grid_layout = match layout.as_ref() {
|
||||||
Some(layout) => layout,
|
Some(layout) => layout,
|
||||||
None => {
|
None => {
|
||||||
let max_height = text_vertical::wrap_height(text_content, bounds.height());
|
owned_layout = text_vertical::layout_for_box(text_content, bounds.height());
|
||||||
owned_layout = text_vertical::layout_from_content(text_content, max_height);
|
|
||||||
&owned_layout
|
&owned_layout
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -713,7 +710,7 @@ fn paint_text_with_emoji_overlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ruby_per_paragraph {
|
if ruby_per_paragraph {
|
||||||
crate::shapes::text_vertical::paint_horizontal_ruby(
|
crate::shapes::paint_horizontal_ruby(
|
||||||
canvas,
|
canvas,
|
||||||
text_content,
|
text_content,
|
||||||
para.source_paragraph,
|
para.source_paragraph,
|
||||||
|
|||||||
@ -146,8 +146,7 @@ fn vertical_layout_for_shape(
|
|||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
) -> (text_vertical::VerticalLayout, f32) {
|
) -> (text_vertical::VerticalLayout, f32) {
|
||||||
let selrect = shape.selrect();
|
let selrect = shape.selrect();
|
||||||
let max_height = text_vertical::wrap_height(text_content, selrect.height());
|
let layout = text_vertical::layout_for_box(text_content, selrect.height());
|
||||||
let layout = text_vertical::layout_from_content(text_content, max_height);
|
|
||||||
let origin_x =
|
let origin_x =
|
||||||
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
|
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
|
||||||
(layout, origin_x)
|
(layout, origin_x)
|
||||||
|
|||||||
@ -70,10 +70,8 @@ impl<'a> VectorRenderer<'a> {
|
|||||||
let bounds = text_content.bounds();
|
let bounds = text_content.bounds();
|
||||||
let drop_shadows = shape.drop_shadow_paints();
|
let drop_shadows = shape.drop_shadow_paints();
|
||||||
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
|
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
|
||||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
|
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
|
||||||
let max_height = text_vertical::wrap_height(text_content, bounds.height());
|
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
|
||||||
text_vertical::layout_from_content(text_content, max_height)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(layout) = &layout {
|
if let Some(layout) = &layout {
|
||||||
for shadow in &drop_shadows {
|
for shadow in &drop_shadows {
|
||||||
|
|||||||
@ -575,9 +575,7 @@ impl TextContent {
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|p| p.children())
|
.flat_map(|p| p.children())
|
||||||
.any(|span| {
|
.any(|span| {
|
||||||
span.warichu
|
span.warichu || span.has_ruby() || span.text_emphasis.mark_char().is_some()
|
||||||
|| !span.ruby.trim().is_empty()
|
|
||||||
|| span.text_emphasis.mark_char().is_some()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -798,8 +796,7 @@ impl TextContent {
|
|||||||
// arrives selrect-local; the content block is right-anchored.
|
// arrives selrect-local; the content block is right-anchored.
|
||||||
if self.is_vertical() {
|
if self.is_vertical() {
|
||||||
let bounds = self.bounds();
|
let bounds = self.bounds();
|
||||||
let max_height = super::text_vertical::wrap_height(self, bounds.height());
|
let layout = super::text_vertical::layout_for_box(self, bounds.height());
|
||||||
let layout = super::text_vertical::layout_from_content(self, max_height);
|
|
||||||
let cx = point.x
|
let cx = point.x
|
||||||
- super::text_vertical::block_axis_offset(
|
- super::text_vertical::block_axis_offset(
|
||||||
bounds.width(),
|
bounds.width(),
|
||||||
@ -1284,15 +1281,14 @@ impl TextContent {
|
|||||||
if self.is_vertical() {
|
if self.is_vertical() {
|
||||||
match self.grow_type() {
|
match self.grow_type() {
|
||||||
GrowType::AutoWidth => {
|
GrowType::AutoWidth => {
|
||||||
let max_height = super::text_vertical::wrap_height(self, selrect.height());
|
let (width, height) =
|
||||||
let (width, height) = super::text_vertical::measure_content(self, max_height);
|
super::text_vertical::measure_content(self, selrect.height());
|
||||||
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||||||
self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||||||
self.size.max_width = self.size.width;
|
self.size.max_width = self.size.width;
|
||||||
}
|
}
|
||||||
GrowType::AutoHeight => {
|
GrowType::AutoHeight => {
|
||||||
let max_height = super::text_vertical::wrap_height(self, selrect.height());
|
let (width, _) = super::text_vertical::measure_content(self, selrect.height());
|
||||||
let (width, _) = super::text_vertical::measure_content(self, max_height);
|
|
||||||
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||||||
self.size.height = selrect.height();
|
self.size.height = selrect.height();
|
||||||
self.size.max_width = self.size.width;
|
self.size.max_width = self.size.width;
|
||||||
@ -1414,8 +1410,7 @@ impl TextContent {
|
|||||||
// Vertical writing: hit-test against the laid-out cells directly
|
// Vertical writing: hit-test against the laid-out cells directly
|
||||||
// (absolute coordinates, right-anchored to the selrect).
|
// (absolute coordinates, right-anchored to the selrect).
|
||||||
if self.is_vertical() {
|
if self.is_vertical() {
|
||||||
let max_height = super::text_vertical::wrap_height(self, shape.selrect.height());
|
let layout = super::text_vertical::layout_for_box(self, shape.selrect.height());
|
||||||
let layout = super::text_vertical::layout_from_content(self, max_height);
|
|
||||||
return super::text_vertical::intersects(
|
return super::text_vertical::intersects(
|
||||||
&layout,
|
&layout,
|
||||||
&shape.selrect,
|
&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.
|
/// 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) {
|
pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) {
|
||||||
let tab = PlaceholderStyle::new(
|
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
|
/// Text after browser filtering and CSS text transformation, plus the source
|
||||||
/// UTF-16 range that produced each transformed Unicode scalar. A single source
|
/// UTF-16 range that produced each transformed Unicode scalar. A single source
|
||||||
/// scalar can produce several output scalars (for example `ß` uppercases to
|
/// 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;
|
let source_start = source_utf16;
|
||||||
source_utf16 += source_char.len_utf16();
|
source_utf16 += source_char.len_utf16();
|
||||||
|
|
||||||
let processed = if source_char == '\n'
|
// Control characters below U+0020 are filtered (Firefox drops them,
|
||||||
|| source_char == '\r'
|
// other browsers show a space); tabs and line breaks are kept.
|
||||||
|| source_char == '\u{2028}'
|
let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}')
|
||||||
|| source_char == '\u{2029}'
|
|
||||||
|| source_char >= '\u{0020}'
|
|| source_char >= '\u{0020}'
|
||||||
{
|
{
|
||||||
Some(source_char)
|
Some(source_char)
|
||||||
@ -1923,6 +1872,38 @@ impl TextSpan {
|
|||||||
self.annotation_clearance = clearance;
|
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(
|
pub fn to_style(
|
||||||
&self,
|
&self,
|
||||||
content_bounds: &Rect,
|
content_bounds: &Rect,
|
||||||
@ -1967,7 +1948,7 @@ impl TextSpan {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let annotation_layers = if self.annotation_clearance.is_auto() {
|
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 {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@ -2281,8 +2262,7 @@ pub fn calculate_position_data(
|
|||||||
if skip_position_data {
|
if skip_position_data {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height());
|
let layout = super::text_vertical::layout_for_box(&text_content, shape.selrect.height());
|
||||||
let layout = super::text_vertical::layout_from_content(&text_content, max_height);
|
|
||||||
return super::text_vertical::position_data(
|
return super::text_vertical::position_data(
|
||||||
&layout,
|
&layout,
|
||||||
&shape.selrect,
|
&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]
|
#[test]
|
||||||
fn capitalize_basic_words() {
|
fn capitalize_basic_words() {
|
||||||
assert_eq!(capitalize_words("hello world"), "Hello World");
|
assert_eq!(capitalize("hello world"), "Hello World");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_preserves_leading_whitespace() {
|
fn capitalize_preserves_leading_whitespace() {
|
||||||
assert_eq!(capitalize_words(" hello"), " Hello");
|
assert_eq!(capitalize(" hello"), " Hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_preserves_trailing_whitespace() {
|
fn capitalize_preserves_trailing_whitespace() {
|
||||||
assert_eq!(capitalize_words("hello "), "Hello ");
|
assert_eq!(capitalize("hello "), "Hello ");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_preserves_multiple_spaces() {
|
fn capitalize_preserves_multiple_spaces() {
|
||||||
assert_eq!(capitalize_words("hello world"), "Hello World");
|
assert_eq!(capitalize("hello world"), "Hello World");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_whitespace_only() {
|
fn capitalize_whitespace_only() {
|
||||||
assert_eq!(capitalize_words(" "), " ");
|
assert_eq!(capitalize(" "), " ");
|
||||||
assert_eq!(capitalize_words(" "), " ");
|
assert_eq!(capitalize(" "), " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_empty_string() {
|
fn capitalize_empty_string() {
|
||||||
assert_eq!(capitalize_words(""), "");
|
assert_eq!(capitalize(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_single_char() {
|
fn capitalize_single_char() {
|
||||||
assert_eq!(capitalize_words("a"), "A");
|
assert_eq!(capitalize("a"), "A");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_already_uppercase() {
|
fn capitalize_already_uppercase() {
|
||||||
assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD");
|
assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_preserves_tabs_and_newlines() {
|
fn capitalize_preserves_tabs_and_newlines() {
|
||||||
assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld");
|
assert_eq!(capitalize("hello\tworld"), "Hello\tWorld");
|
||||||
assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld");
|
assert_eq!(capitalize("hello\nworld"), "Hello\nWorld");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_after_punctuation() {
|
fn capitalize_after_punctuation() {
|
||||||
assert_eq!(capitalize_words("(readonly)"), "(Readonly)");
|
assert_eq!(capitalize("(readonly)"), "(Readonly)");
|
||||||
assert_eq!(capitalize_words("hello-world"), "Hello-World");
|
assert_eq!(capitalize("hello-world"), "Hello-World");
|
||||||
assert_eq!(capitalize_words("one/two/three"), "One/Two/Three");
|
assert_eq!(capitalize("one/two/three"), "One/Two/Three");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capitalize_after_digits() {
|
fn capitalize_after_digits() {
|
||||||
assert_eq!(capitalize_words("item1name"), "Item1Name");
|
assert_eq!(capitalize("item1name"), "Item1Name");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_ignored_chars_preserves_spaces() {
|
fn ignored_chars_preserves_spaces() {
|
||||||
assert_eq!(process_ignored_chars("hello world", 0), "hello world");
|
assert_eq!(filter_ignored_chars("hello world", 0), "hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_ignored_chars_preserves_line_breaks() {
|
fn ignored_chars_preserves_line_breaks() {
|
||||||
assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld");
|
assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld");
|
||||||
assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld");
|
assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_ignored_chars_preserves_tabs() {
|
fn ignored_chars_preserves_tabs() {
|
||||||
assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld");
|
assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
process_ignored_chars("hello\tworld", Browser::Firefox as u8),
|
filter_ignored_chars("hello\tworld", Browser::Firefox as u8),
|
||||||
"hello\tworld"
|
"hello\tworld"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// U+0001 (SOH) should become space in non-Firefox
|
||||||
assert_eq!(
|
assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b");
|
||||||
process_ignored_chars("a\x01b", Browser::Chrome as u8),
|
|
||||||
"a b"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_ignored_chars_removes_control_chars_firefox() {
|
fn ignored_chars_removes_control_chars_firefox() {
|
||||||
assert_eq!(
|
assert_eq!(filter_ignored_chars("a\x01b", Browser::Firefox as u8), "ab");
|
||||||
process_ignored_chars("a\x01b", Browser::Firefox as u8),
|
|
||||||
"ab"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_paragraph(texts: &[&str]) -> Paragraph {
|
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(1..3), 1..2);
|
||||||
assert_eq!(transformed.source_utf16_range(3..4), 2..3);
|
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::math::Point;
|
||||||
use crate::shapes::kinsoku;
|
use crate::shapes::{kinsoku, merge_fills};
|
||||||
use crate::utils::get_font_collection;
|
use crate::utils::{get_fallback_fonts, get_font_collection};
|
||||||
use skia_safe::{
|
use skia_safe::{
|
||||||
self as skia,
|
self as skia,
|
||||||
textlayout::{
|
textlayout::{
|
||||||
ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle,
|
ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle,
|
||||||
RectWidthStyle, TextBaseline,
|
RectWidthStyle, TextBaseline,
|
||||||
},
|
},
|
||||||
Contains,
|
Canvas, Contains, Font, FontMgr, GlyphId,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const WARICHU_FONT_SCALE: f32 = 0.5;
|
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
|
let ruby_breaks: Vec<Option<Vec<usize>>> = paragraph
|
||||||
.children()
|
.children()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|span| (!span.ruby.trim().is_empty()).then(Vec::new))
|
.map(|span| span.has_ruby().then(Vec::new))
|
||||||
.collect();
|
.collect();
|
||||||
if let Some((shifted, map)) =
|
if let Some((shifted, map)) =
|
||||||
kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks)
|
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,
|
text_style: &skia::textlayout::TextStyle,
|
||||||
fonts: &skia::textlayout::FontCollection,
|
fonts: &skia::textlayout::FontCollection,
|
||||||
) {
|
) {
|
||||||
if span.warichu && span.text.chars().count() >= 2 {
|
if span.is_warichu() {
|
||||||
let text = span.apply_text_transform();
|
let text = span.apply_text_transform();
|
||||||
let split = super::text_vertical::warichu_split_chars(&text);
|
let (first, second) = warichu_text_lines(&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 mut mini_style = text_style.clone();
|
let mut mini_style = text_style.clone();
|
||||||
mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
|
mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
|
||||||
mini_style.set_height(1.0);
|
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 shifted_end = shifted_cursor;
|
||||||
let source_start = offset_map.to_original(shifted_start);
|
let source_start = offset_map.to_original(shifted_start);
|
||||||
let source_end = offset_map.to_original(shifted_end);
|
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 builder_start = builder_cursor;
|
||||||
let style_anchor_start = if warichu {
|
let style_anchor_start = if warichu {
|
||||||
builder_byte_cursor + '\u{FFFC}'.len_utf8()
|
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 placeholders = laid_out.get_rects_for_placeholders().into_iter();
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for (index, span) in paragraph.children().iter().enumerate() {
|
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() {
|
if let Some(textbox) = placeholders.next() {
|
||||||
result.push((index, textbox.rect));
|
result.push((index, textbox.rect));
|
||||||
}
|
}
|
||||||
@ -295,7 +293,7 @@ pub(crate) fn horizontal_warichu_hit_test(
|
|||||||
if !rect.contains(&point) {
|
if !rect.contains(&point) {
|
||||||
continue;
|
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 total = span.text.chars().count();
|
||||||
let second = point.y >= rect.top() + rect.height() / 2.0;
|
let second = point.y >= rect.top() + rect.height() / 2.0;
|
||||||
let (line_start, line_len) = if second {
|
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 {
|
if source_offset < source_start || source_offset > source_end {
|
||||||
continue;
|
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 local = source_offset - source_start;
|
||||||
let total = source_end - source_start;
|
let total = source_end - source_start;
|
||||||
let (line_start, line_len, top) = if local >= split {
|
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 {
|
if selected_start >= selected_end {
|
||||||
continue;
|
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 [
|
for (line_start, line_end, top) in [
|
||||||
(span_start, span_start + split, rect.top()),
|
(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 span_end = span_start + span.text.chars().count();
|
||||||
let selected_start = source_start.max(span_start);
|
let selected_start = source_start.max(span_start);
|
||||||
let selected_end = source_end.min(span_end);
|
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;
|
span_start = span_end;
|
||||||
if warichu || selected_start >= selected_end {
|
if warichu || selected_start >= selected_end {
|
||||||
return None;
|
return None;
|
||||||
@ -405,8 +403,39 @@ pub(crate) fn horizontal_normal_selection_ranges(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn warichu_text_lines(text: &str) -> (&str, &str) {
|
/// Char index where a warichu run splits into its two sub-lines: the
|
||||||
let split = super::text_vertical::warichu_split_chars(text);
|
/// 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
|
let split_byte = text
|
||||||
.char_indices()
|
.char_indices()
|
||||||
.nth(split)
|
.nth(split)
|
||||||
@ -415,7 +444,7 @@ fn warichu_text_lines(text: &str) -> (&str, &str) {
|
|||||||
text.split_at(split_byte)
|
text.split_at(split_byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn warichu_mini_paragraph(
|
fn mini_paragraph(
|
||||||
text: &str,
|
text: &str,
|
||||||
style: &skia::textlayout::TextStyle,
|
style: &skia::textlayout::TextStyle,
|
||||||
width: f32,
|
width: f32,
|
||||||
@ -448,17 +477,7 @@ pub(crate) fn paint_horizontal_warichu(
|
|||||||
let Some(span) = paragraph.children().get(range.span) else {
|
let Some(span) = paragraph.children().get(range.span) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Indexed style metrics expose builder UTF-8 byte positions even
|
let Some(mut style) = horizontal_span_style(laid_out, range) else {
|
||||||
// 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 {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
|
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);
|
style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
|
||||||
let transformed = span.apply_text_transform();
|
let transformed = span.apply_text_transform();
|
||||||
let (first, second) = warichu_text_lines(&transformed);
|
let (first, second) = warichu_text_lines(&transformed);
|
||||||
let first_para = warichu_mini_paragraph(first, &style, rect.width());
|
let first_para = mini_paragraph(first, &style, rect.width());
|
||||||
let second_para = warichu_mini_paragraph(second, &style, rect.width());
|
let second_para = mini_paragraph(second, &style, rect.width());
|
||||||
let half_height = rect.height() / 2.0;
|
let half_height = rect.height() / 2.0;
|
||||||
first_para.paint(canvas, (x + rect.left(), y + rect.top()));
|
first_para.paint(canvas, (x + rect.left(), y + rect.top()));
|
||||||
second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height));
|
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,
|
laid_out: &skia::textlayout::Paragraph,
|
||||||
range: &HorizontalSpanRange,
|
range: &HorizontalSpanRange,
|
||||||
) -> Option<skia::textlayout::TextStyle> {
|
) -> Option<skia::textlayout::TextStyle> {
|
||||||
// Indexed style metrics use builder UTF-8 byte positions (the same
|
// Indexed style metrics use builder UTF-8 byte positions, unlike the
|
||||||
// convention used by the warichu style anchor above).
|
// UTF-16 offsets of glyph and line ranges.
|
||||||
let anchor = range.style_anchor_start..range.style_anchor_start + 1;
|
let anchor = range.style_anchor_start..range.style_anchor_start + 1;
|
||||||
laid_out.get_line_metrics().iter().find_map(|line| {
|
laid_out.get_line_metrics().iter().find_map(|line| {
|
||||||
line.get_style_metrics(anchor.clone())
|
line.get_style_metrics(anchor.clone())
|
||||||
@ -595,7 +614,7 @@ pub(crate) fn paint_horizontal_emphasis(
|
|||||||
style.set_height(1.0);
|
style.set_height(1.0);
|
||||||
style.set_height_override(true);
|
style.set_height_override(true);
|
||||||
style.set_letter_spacing(0.0);
|
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
|
let mark_ink = mark_paragraph
|
||||||
.get_rects_for_range(
|
.get_rects_for_range(
|
||||||
0..mark.len_utf16(),
|
0..mark.len_utf16(),
|
||||||
@ -621,22 +640,204 @@ pub(crate) fn paint_horizontal_emphasis(
|
|||||||
.filter(|placement| placement.span == range.span && placement.mark == mark)
|
.filter(|placement| placement.span == range.span && placement.mark == mark)
|
||||||
{
|
{
|
||||||
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
|
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)
|
let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size)
|
||||||
- mark_ink_bottom
|
- mark_ink_bottom
|
||||||
- ruby_offset;
|
- span.emphasis_ruby_offset();
|
||||||
mark_paragraph.paint(canvas, (mark_x, mark_y));
|
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
|
/// Block flow direction of a paragraph. Horizontal is the skparagraph
|
||||||
/// path; vertical-rl lays out columns top->bottom advancing right->left
|
/// path; vertical-rl lays out columns top->bottom advancing right->left
|
||||||
/// through the custom vertical pass.
|
/// through the custom vertical pass.
|
||||||
@ -783,3 +984,337 @@ impl AnnotationClearance {
|
|||||||
matches!(self, AnnotationClearance::Auto)
|
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