This commit is contained in:
alonso.torres 2026-10-01 13:21:13 +02:00
parent d92b5392e6
commit 11d4cc6459
34 changed files with 8106 additions and 8936 deletions

View File

@ -302,78 +302,58 @@
[text-direction]
(untranslate "text-direction" text-direction "ltr"))
(defn- translate-enum
"Wasm discriminant of the `id` enum variant named `value`, falling back to
the `default` variant. 0 when the enum is not exported."
[id value default]
(if-let [values (unchecked-get wasm/serializers id)]
(d/nilv (unchecked-get values (d/name value))
(unchecked-get values default))
0))
(defn translate-writing-mode
[writing-mode]
(let [values (unchecked-get wasm/serializers "writing-mode")
default (unchecked-get values "horizontal-tb")]
(d/nilv (unchecked-get values (d/name writing-mode)) default)))
[value]
(translate-enum "writing-mode" value "horizontal-tb"))
(defn translate-text-orientation
[text-orientation]
(let [values (unchecked-get wasm/serializers "text-orientation")
default (unchecked-get values "mixed")]
(d/nilv (unchecked-get values (d/name text-orientation)) default)))
[value]
(translate-enum "text-orientation" value "mixed"))
(defn translate-text-combine-upright
[text-combine-upright]
(let [values (unchecked-get wasm/serializers "text-combine-upright")
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name text-combine-upright)) default)))
[value]
(translate-enum "text-combine-upright" value "none"))
(defn translate-text-emphasis
[text-emphasis]
(let [values (unchecked-get wasm/serializers "text-emphasis")
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name text-emphasis)) default)))
[value]
(translate-enum "text-emphasis" value "none"))
(defn translate-warichu
[warichu]
(let [values (unchecked-get wasm/serializers "warichu")
default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name warichu)) default)))
[value]
(translate-enum "warichu" value "none"))
(defn translate-font-features
[font-features]
(if-let [values (unchecked-get wasm/serializers "font-features")]
(let [default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name font-features)) default))
0))
[value]
(translate-enum "font-features" value "none"))
(defn translate-annotation-clearance
[annotation-clearance]
(if-let [values (unchecked-get wasm/serializers "annotation-clearance")]
(let [default (unchecked-get values "none")]
(d/nilv (unchecked-get values (d/name annotation-clearance)) default))
0))
[value]
(translate-enum "annotation-clearance" value "none"))
(defn translate-ruby-size
[value]
(if-let [values (unchecked-get wasm/serializers "ruby-size")]
(let [default (unchecked-get values "half")]
(d/nilv (unchecked-get values (d/name value)) default))
0))
(translate-enum "ruby-size" value "half"))
(defn translate-ruby-align
[value]
(if-let [values (unchecked-get wasm/serializers "ruby-align")]
(let [default (unchecked-get values "space-around")]
(d/nilv (unchecked-get values (d/name value)) default))
0))
(translate-enum "ruby-align" value "space-around"))
(defn translate-ruby-overhang
[value]
(if-let [values (unchecked-get wasm/serializers "ruby-overhang")]
(let [default (unchecked-get values "auto")]
(d/nilv (unchecked-get values (d/name value)) default))
0))
(translate-enum "ruby-overhang" value "auto"))
(defn translate-ruby-side
[value]
(if-let [values (unchecked-get wasm/serializers "ruby-side")]
(let [default (unchecked-get values "over")]
(d/nilv (unchecked-get values (d/name value)) default))
0))
(translate-enum "ruby-side" value "over"))
(defn translate-font-style
[font-style]

View File

@ -6,7 +6,8 @@
(ns app.common.types.text.japanese-layout
(:require
[app.common.data.macros :as dm]))
[app.common.data.macros :as dm]
[cuerdas.core :as str]))
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
;; and "mixed", so plain horizontal text never stores these attrs.
@ -50,6 +51,50 @@
(def text-annotation-clearance-attrs
[:annotation-clearance])
;; Values of the span attrs above when a span does not store them.
(def span-attr-defaults
{:text-combine-upright "none"
:text-emphasis "none"
:ruby ""
:ruby-hidden false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
:ruby-side "over"
:warichu "none"
:font-features "none"
:annotation-clearance "none"})
;; Annotation font sizes relative to the base font size.
(def emphasis-font-scale 0.5)
(def warichu-font-scale 0.5)
(defn ruby-font-scale
[ruby-size]
(case ruby-size
"third" (/ 1 3)
"quarter" 0.25
0.5))
(defn visible-ruby
"Ruby annotation text of a text node, or nil when it has none or the
annotation is hidden."
[node]
(let [ruby (:ruby node)]
(when (and (string? ruby)
(not (str/blank? ruby))
(not (true? (:ruby-hidden node))))
ruby)))
(defn warichu-text?
"True when a text node renders as warichu: it needs two characters to fill
its two sub-lines."
[node]
(let [text (:text node)]
(and (= "warichu" (:warichu node))
(string? text)
(>= (count text) 2))))
(defn content-writing-mode
"Writing mode of a text content. Stored per paragraph but treated as a
whole-shape property: the first paragraph decides the flow."

View File

@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as cc]
[app.common.types.text.japanese-layout :as jl]
[app.main.ui.shapes.text.styles :as sts]
[app.util.text.writing-mode :as wm]
[cuerdas.core :as str]
@ -21,11 +22,8 @@
style (if (= text "")
(sts/generate-text-styles shape parent)
(sts/generate-text-styles shape node))
ruby (:ruby node)
ruby? (and (not (true? (:ruby-hidden node)))
(string? ruby)
(not (str/blank? ruby)))]
(if ruby?
ruby (jl/visible-ruby node)]
(if (some? ruby)
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
[:span.text-node {:style style} text]
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]

View File

@ -9,9 +9,9 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.text :as legacy.txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.ui.shapes.text.styles :as sts]
[app.util.text.writing-mode :as wm]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(mf/defc render-text*
@ -21,11 +21,8 @@
(sts/generate-text-styles shape parent)
(sts/generate-text-styles shape node))
class (when is-code (:$id node))
ruby (:ruby node)
ruby? (and (not (true? (:ruby-hidden node)))
(string? ruby)
(not (str/blank? ruby)))]
(if ruby?
ruby (jl/visible-ruby node)]
(if (some? ruby)
[:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
[:span.text-node {:style style :class class} text]
[:rt {:style (sts/generate-ruby-styles shape node)} ruby]]

View File

@ -10,6 +10,7 @@
[app.common.transit :as transit]
[app.common.types.color :as cc]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.fonts :as fonts]
[app.main.ui.formats :as fmt]
[app.util.color :as uc]
@ -87,31 +88,74 @@
"digits3" "digits 3"
value))
(defn- set-value?
"True for a stored style value other than empty or \"none\"."
[value]
(and (string? value) (pos? (alength value)) (not= "none" value)))
(defn font-feature-settings
"CSS `font-feature-settings` for a persisted font-features value, or nil."
[font-features]
(when (set-value? font-features)
(str/format "\"%s\"" font-features)))
(defn- annotation-line-height
"Line height that reserves a half-em per ruby/emphasis layer under
automatic annotation clearance, or nil."
[data]
(when (= "auto" (:annotation-clearance data))
(let [layers (+ (if (jl/visible-ruby data) 1 0)
(if (set-value? (:text-emphasis data)) 1 0))
line-height (js/parseFloat (or (:line-height data)
(:line-height txt/default-typography)))]
(when (and (pos? layers) (not (js/isNaN line-height)))
(+ line-height (* layers 0.5))))))
(defn- add-japanese-text-styles!
[style data]
(let [text-combine-upright (:text-combine-upright data)
text-emphasis (:text-emphasis data)
font-features (font-feature-settings (:font-features data))
annotation-clearance (:annotation-clearance data)
line-height (annotation-line-height data)
font-size (:font-size data)]
(cond-> style
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
(set-value? text-emphasis)
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
(some? font-features)
(obj/set! "fontFeatureSettings" font-features)
(and (string? annotation-clearance) (pos? (alength annotation-clearance)))
(obj/set! "--annotation-clearance" annotation-clearance)
(some? line-height)
(obj/set! "lineHeight" line-height)
;; Warichu (割注) CSS emulation: an inline-block at half size whose
;; inline-size fits half the characters, so the browser wraps it into
;; two half-size sub-lines within one inline position in either writing
;; mode.
(jl/warichu-text? data)
(-> (obj/set! "display" "inline-block")
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))
(str (* (js/parseFloat font-size) jl/warichu-font-scale) "px")
"50%"))
(obj/set! "lineHeight" "1")
(obj/set! "inlineSize"
(str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em"))))))
(defn generate-text-styles
([shape data]
(generate-text-styles shape data nil))
([{:keys [grow-type] :as shape} data {:keys [show-text?] :or {show-text? true}}]
(let [letter-spacing (:letter-spacing data 0)
text-combine-upright (:text-combine-upright data)
text-emphasis (:text-emphasis data)
font-features (:font-features data)
annotation-clearance (:annotation-clearance data)
annotation-layers (if (= "auto" annotation-clearance)
(+ (if (and (not (true? (:ruby-hidden data)))
(string? (:ruby data))
(seq (:ruby data))) 1 0)
(if (and (string? text-emphasis)
(not= "none" text-emphasis)) 1 0))
0)
line-height-num (js/parseFloat (or (:line-height data)
(:line-height txt/default-typography)))
auto-line-height (when (and (pos? annotation-layers)
(not (js/isNaN line-height-num)))
(+ line-height-num (* annotation-layers 0.5)))
warichu? (and (= "warichu" (:warichu data))
(string? (:text data))
(>= (count (:text data)) 2))
text-decoration (:text-decoration data)
text-transform (:text-transform data)
@ -186,26 +230,6 @@
(and (string? letter-spacing) (pos? (alength letter-spacing)))
(obj/set! "letterSpacing" (str letter-spacing "px"))
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
(and (string? text-emphasis) (pos? (alength text-emphasis))
(not= "none" text-emphasis))
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
(and (string? font-features) (pos? (alength font-features))
(not= "none" font-features))
(obj/set! "fontFeatureSettings" (str/format "\"%s\"" font-features))
(and (string? annotation-clearance)
(pos? (alength annotation-clearance)))
(obj/set! "--annotation-clearance" annotation-clearance)
(some? auto-line-height)
(obj/set! "lineHeight" auto-line-height)
(and (string? font-size) (pos? (alength font-size)))
(obj/set! "fontSize" (str font-size "px"))
@ -217,18 +241,8 @@
(= grow-type :auto-width)
(obj/set! "whiteSpace" "pre")
;; Warichu (割注) CSS emulation: an inline-block at half size whose
;; inline-size fits half the characters, so the browser wraps it into
;; two half-size sub-lines within one inline position in either writing
;; mode.
warichu?
(-> (obj/set! "display" "inline-block")
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))
(str (/ (js/parseFloat font-size) 2) "px")
"50%"))
(obj/set! "lineHeight" "1")
(obj/set! "inlineSize"
(str (js/Math.ceil (/ (alength (js/Array.from (:text data))) 2)) "em")))))))
:always
(add-japanese-text-styles! data)))))
(defn generate-ruby-styles
[shape data]

View File

@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.types.text.japanese-layout :as jl]
[app.config :as cf]
[app.main.ui.context :as muc]
[app.main.ui.shapes.attrs :as attrs]
@ -21,17 +22,6 @@
(def fill-attrs [:fill-color :fill-color-gradient :fill-opacity])
(defn- ruby-font-scale
[ruby-size]
(case ruby-size
"third" (/ 1 3)
"quarter" 0.25
0.5))
(def ^:private emphasis-font-scale 0.5)
(def ^:private warichu-font-scale 0.5)
;; Kinsoku classes for the warichu sub-line split (same characters the
;; renderer's kinsoku module suppresses at line boundaries).
(def ^:private warichu-forbidden-at-start
@ -97,13 +87,168 @@
(if (emphasis-character? character) mark " ")))
(apply str)))
(defn- add-font-features!
[style font-features]
(cond-> style
(and (string? font-features)
(not= "none" font-features)
(pos? (alength font-features)))
(obj/set! "fontFeatureSettings" (dm/str "\"" font-features "\""))))
(defn- px
"CSS pixel size, or nil for a non-positive size."
[size]
(when (pos? size) (dm/str size "px")))
(defn- with-font-features
[style data]
(let [font-features (sts/font-feature-settings (:font-features data))]
(cond-> style
(some? font-features) (obj/set! "fontFeatureSettings" font-features))))
(defn- annotation-style
"Style of a ruby, warichu or emphasis line drawn at `font-size` along a
position-data strip."
[data font-size vertical? text-orientation fill]
(with-font-features
#js {:fontFamily (:font-family data)
:fontSize (px font-size)
:fontWeight (:font-weight data)
:fontStyle (:font-style data)
:direction (:direction data)
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
:textSpacingTrim "normal"
:textOrientation text-orientation
:textAutospace "normal"
:whiteSpace "pre"
:fill fill}
data))
(defn- strip-text-props
"Props of the `text` element drawing a position-data strip's base text."
[shape data index fill browser-props]
(let [style (with-font-features
#js {:fontFamily (:font-family data)
:fontSize (:font-size data)
:fontWeight (:font-weight data)
:textTransform (:text-transform data)
:textDecoration (:text-decoration data)
:textCombineUpright (sts/css-text-combine-upright
(:text-combine-upright data))
:letterSpacing (:letter-spacing data)
:fontStyle (:font-style data)
:direction (:direction data)
:textSpacingTrim "normal"
:whiteSpace "pre"}
data)
key (dm/str "text-" (:id shape) "-" index)]
(if (= "vertical-rl" (:writing-mode data))
;; Vertical strip: glyphs run down the column; x is the column's
;; central axis and y the strip top (stored y is the strip bottom).
#js {:key key
:x (+ (:x data) (/ (:width data) 2))
:y (- (:y data) (:height data))
:textLength (:height data)
:lengthAdjust "spacingAndGlyphs"
:style (-> style
(obj/set! "writingMode" "vertical-rl")
(obj/set! "textOrientation" (or (:text-orientation data) "mixed"))
(obj/set! "textAutospace" "normal")
(obj/set! "fill" fill))}
(cond-> #js {:key key
:x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data))
:y (:y data)
:dominantBaseline "ideographic"
:textLength (:width data)
:lengthAdjust "spacingAndGlyphs"
:style (obj/set! style "fill" fill)}
(some? browser-props)
(obj/merge! browser-props)))))
(defn- warichu-lines
"Warichu: two half-size sub-lines within one inline strip. Vertical
reading order is right then left; horizontal is top then bottom."
[shape data index fill]
(let [vertical? (= "vertical-rl" (:writing-mode data))
font-size (* (js/parseFloat (:font-size data)) jl/warichu-font-scale)
font-size (if (js/isNaN font-size) 0 font-size)
style (annotation-style data font-size vertical? (when vertical? "upright") fill)
text (:text data)
split-index (warichu-split-index text)
lines [(subs text 0 split-index) (subs text split-index)]
top (- (:y data) (:height data))
centre (+ (:x data) (/ (:width data) 2))
quarter (/ font-size 2)]
(mf/html
[:g {:key (dm/str "warichu-" (:id shape) "-" index)}
(if vertical?
[:*
[:> :text {:x (+ centre quarter) :y top :style style} (first lines)]
[:> :text {:x (- centre quarter) :y top :style style} (second lines)]]
[:*
(for [[line-index line] (d/enumerate lines)]
[:> :text {:key line-index
:x (:x data)
:y (+ top (* line-index (/ (:height data) 2)))
:dominantBaseline "hanging"
:textLength (:width data)
:lengthAdjust "spacingAndGlyphs"
:style style}
line])])])))
(defn- ruby-text-props
"Props of the `text` element drawing a strip's ruby annotation beside it
(vertical) or above/below it (horizontal), per its side and alignment."
[shape data index ruby-font-size style]
(let [key (dm/str "ruby-" (:id shape) "-" index)
ruby-side (:ruby-side data "over")]
(if (= "vertical-rl" (:writing-mode data))
#js {:key key
:x (if (= "under" ruby-side)
(- (:x data) (/ ruby-font-size 2))
(+ (:x data) (:width data) (/ ruby-font-size 2)))
:y (- (:y data) (:height data))
:textLength (:height data)
:lengthAdjust "spacingAndGlyphs"
:style style}
(let [rtl? (= "rtl" (:direction data))
ruby-align (:ruby-align data "space-around")
fit? (or (= "none" (:ruby-overhang data "auto"))
(= "space-around" ruby-align))]
(cond-> #js {:key key
:x (if (= "center" ruby-align)
(+ (:x data) (/ (:width data) 2))
(if rtl? (+ (:x data) (:width data)) (:x data)))
:y (if (= "under" ruby-side)
(:y data)
(- (:y data) (:height data)))
:dominantBaseline (if (= "under" ruby-side) "hanging" "text-after-edge")
:style style}
(= "center" ruby-align)
(obj/set! "textAnchor" "middle")
(= "start" ruby-align)
(obj/set! "textAnchor" (if rtl? "end" "start"))
fit?
(-> (obj/set! "textLength" (:width data))
(obj/set! "lengthAdjust" "spacingAndGlyphs"))
(= "space-between" ruby-align)
(-> (obj/set! "textLength" (:width data))
(obj/set! "lengthAdjust" "spacing")))))))
(defn- emphasis-text-props
"Props of the `text` element drawing a strip's emphasis marks: right of
vertical text or above horizontal text, outside a stacked ruby layer."
[shape data index font-size ruby-offset style]
(let [key (dm/str "emphasis-" (:id shape) "-" index)]
(if (= "vertical-rl" (:writing-mode data))
#js {:key key
:x (+ (:x data) (:width data) (/ font-size 2) ruby-offset)
:y (- (:y data) (:height data))
:textLength (:height data)
:lengthAdjust "spacing"
:style style}
#js {:key key
:x (if (= "rtl" (:direction data)) (+ (:x data) (:width data)) (:x data))
:y (- (:y data) (:height data) ruby-offset)
:dominantBaseline "text-after-edge"
:textLength (:width data)
:lengthAdjust "spacing"
:style style})))
(defn set-white-fill
[shape]
@ -156,125 +301,24 @@
[:> :g group-props
(for [[index data] (d/enumerate position-data)]
(let [rtl? (= "rtl" (:direction data))
vertical? (= "vertical-rl" (:writing-mode data))
ruby (:ruby data)
ruby? (and (string? ruby) (seq ruby))
ruby-side (:ruby-side data "over")
ruby-align (:ruby-align data "space-around")
ruby-overhang (:ruby-overhang data "auto")
auto-clearance? (= "auto" (:annotation-clearance data))
has-ruby-layer? (and (not= "under" ruby-side)
(or ruby? (:annotation-has-ruby data)))
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
emphasis? (some? emphasis-mark)
warichu? (and (= "warichu" (:warichu data))
(string? (:text data))
(>= (count (:text data)) 2))
font-features (:font-features data)
(let [vertical? (= "vertical-rl" (:writing-mode data))
ruby (jl/visible-ruby data)
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
font-size (js/parseFloat (:font-size data))
scaled-size (fn [scale] (if (js/isNaN font-size) 0 (* font-size scale)))
ruby-font-size (scaled-size (jl/ruby-font-scale (:ruby-size data)))
;; Annotations take the fills of this strip's own render id.
annotation-fill (str "url(#fill-" index "-" render-id "-" index ")")
browser-props
(cond
(and (not vertical?) (cf/check-browser? :safari))
(when (and (not vertical?) (cf/check-browser? :safari))
#js {:dominantBaseline "hanging"
:dy "0.2em"
:y (- (:y data) (:height data))})
base-style (add-font-features!
#js {:fontFamily (:font-family data)
:fontSize (:font-size data)
:fontWeight (:font-weight data)
:textTransform (:text-transform data)
:textDecoration (:text-decoration data)
:textCombineUpright (sts/css-text-combine-upright
(:text-combine-upright data))
:letterSpacing (:letter-spacing data)
:fontStyle (:font-style data)
:direction (:direction data)
:textSpacingTrim "normal"
:whiteSpace "pre"}
font-features)
font-size (js/parseFloat (:font-size data))
ruby-font-size-num (if (js/isNaN font-size)
0
(* font-size (ruby-font-scale (:ruby-size data))))
ruby-font-size (when (pos? ruby-font-size-num)
(dm/str ruby-font-size-num "px"))
ruby-style (add-font-features!
#js {:fontFamily (:font-family data)
:fontSize ruby-font-size
:fontWeight (:font-weight data)
:fontStyle (:font-style data)
:direction (:direction data)
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
:textSpacingTrim "normal"
:textOrientation "upright"
:textAutospace "normal"
:whiteSpace "pre"
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
font-features)
emphasis-font-size-num (if (js/isNaN font-size)
0
(* font-size emphasis-font-scale))
warichu-font-size-num (if (js/isNaN font-size)
0
(* font-size warichu-font-scale))
warichu-style (add-font-features!
#js {:fontFamily (:font-family data)
:fontSize (when (pos? warichu-font-size-num)
(dm/str warichu-font-size-num "px"))
:fontWeight (:font-weight data)
:fontStyle (:font-style data)
:direction (:direction data)
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
:textSpacingTrim "normal"
:textOrientation (when vertical? "upright")
:textAutospace "normal"
:whiteSpace "pre"
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
font-features)
emphasis-style (add-font-features!
#js {:fontFamily (:font-family data)
:fontSize (when (pos? emphasis-font-size-num)
(dm/str emphasis-font-size-num "px"))
:fontWeight (:font-weight data)
:fontStyle (:font-style data)
:direction (:direction data)
:writingMode (if vertical? "vertical-rl" "horizontal-tb")
:textSpacingTrim "normal"
:textOrientation (when vertical? "upright")
:textAutospace "normal"
:whiteSpace "pre"
:fill (str "url(#fill-" index "-" render-id "-" index ")")}
font-features)
props (if vertical?
;; Vertical strip: glyphs run down the column; x is
;; the column's central axis and y the strip top
;; (stored y is the strip bottom).
#js {:key (dm/str "text-" (:id shape) "-" index)
:x (+ (:x data) (/ (:width data) 2))
:y (- (:y data) (:height data))
:textLength (:height data)
:lengthAdjust "spacingAndGlyphs"
:style (-> base-style
(obj/set! "writingMode" "vertical-rl")
(obj/set! "textSpacingTrim" "normal")
(obj/set! "textOrientation" (or (:text-orientation data) "mixed"))
(obj/set! "textAutospace" "normal")
(obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
(-> #js {:key (dm/str "text-" (:id shape) "-" index)
:x (if rtl? (+ (:x data) (:width data)) (:x data))
:y (:y data)
:dominantBaseline "ideographic"
:textLength (:width data)
:lengthAdjust "spacingAndGlyphs"
:style (obj/set! base-style "fill" (str "url(#fill-" index "-" render-id ")"))}
(cond-> browser-props
(obj/merge! browser-props))))
text-props (strip-text-props shape data index
(str "url(#fill-" index "-" render-id ")")
browser-props)
shape (-> shape
(assoc :fills (:fills data))
;; The text elements have the shadow and blur already applied in the
@ -290,106 +334,25 @@
[:& fills/fills {:shape shape :render-id render-id}]]
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
(if warichu?
;; Warichu: two half-size sub-lines within one inline strip.
;; Vertical reading order is right then left; horizontal is
;; top then bottom.
(let [text (:text data)
split-index (warichu-split-index text)
centre (+ (:x data) (/ (:width data) 2))
quarter (/ warichu-font-size-num 2)
top (- (:y data) (:height data))]
[:g {:key (dm/str "warichu-" (:id shape) "-" index)}
(if vertical?
[:*
[:> :text {:x (+ centre quarter)
:y top
:style warichu-style}
(subs text 0 split-index)]
[:> :text {:x (- centre quarter)
:y top
:style warichu-style}
(subs text split-index)]]
[:*
[:> :text {:x (:x data)
:y top
:dominantBaseline "hanging"
:textLength (:width data)
:lengthAdjust "spacingAndGlyphs"
:style warichu-style}
(subs text 0 split-index)]
[:> :text {:x (:x data)
:y (+ top (/ (:height data) 2))
:dominantBaseline "hanging"
:textLength (:width data)
:lengthAdjust "spacingAndGlyphs"
:style warichu-style}
(subs text split-index)]])])
[:> :text props (:text data)])]
(when ruby?
[:> :text (if vertical?
#js {:key (dm/str "ruby-" (:id shape) "-" index)
:x (if (= "under" ruby-side)
(- (:x data) (/ ruby-font-size-num 2))
(+ (:x data) (:width data) (/ ruby-font-size-num 2)))
:y (- (:y data) (:height data))
:textLength (:height data)
:lengthAdjust "spacingAndGlyphs"
:style ruby-style}
(let [start-x (if rtl? (+ (:x data) (:width data)) (:x data))
center-x (+ (:x data) (/ (:width data) 2))
constrain? (= "none" ruby-overhang)]
(cond-> #js {:key (dm/str "ruby-" (:id shape) "-" index)
:x (if (= "center" ruby-align) center-x start-x)
:y (if (= "under" ruby-side)
(:y data)
(- (:y data) (:height data)))
:dominantBaseline (if (= "under" ruby-side)
"hanging"
"text-after-edge")
:style ruby-style}
(= "center" ruby-align)
(obj/set! "textAnchor" "middle")
(= "start" ruby-align)
(obj/set! "textAnchor" (if rtl? "end" "start"))
(or constrain? (= "space-around" ruby-align))
(obj/set! "textLength" (:width data))
(or constrain? (= "space-around" ruby-align))
(obj/set! "lengthAdjust" "spacingAndGlyphs")
(= "space-between" ruby-align)
(obj/set! "textLength" (:width data))
(= "space-between" ruby-align)
(obj/set! "lengthAdjust" "spacing"))))
(if (jl/warichu-text? data)
(warichu-lines shape data index annotation-fill)
[:> :text text-props (:text data)])]
(when (some? ruby)
[:> :text (ruby-text-props shape data index ruby-font-size
(annotation-style data ruby-font-size vertical?
"upright" annotation-fill))
ruby])
;; Emphasis marks (圏点): one half-size mark per eligible base
;; character, right of vertical text or above horizontal text.
(when emphasis?
[:> :text (if vertical?
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
:x (+ (:x data)
(:width data)
(/ emphasis-font-size-num 2)
(if (and auto-clearance? has-ruby-layer?)
ruby-font-size-num
0))
:y (- (:y data) (:height data))
:textLength (:height data)
:lengthAdjust "spacing"
:style emphasis-style}
#js {:key (dm/str "emphasis-" (:id shape) "-" index)
:x (if rtl? (+ (:x data) (:width data)) (:x data))
:y (- (:y data)
(:height data)
(if (and auto-clearance? has-ruby-layer?)
ruby-font-size-num
0))
:dominantBaseline "text-after-edge"
:textLength (:width data)
:lengthAdjust "spacing"
:style emphasis-style})
(emphasis-marks-text (:text data) emphasis-mark)])]))]]))
;; character.
(when (some? emphasis-mark)
(let [ruby-layer? (and (= "auto" (:annotation-clearance data))
(not= "under" (:ruby-side data "over"))
(or (some? ruby) (:annotation-has-ruby data)))
mark-size (scaled-size jl/emphasis-font-scale)
style (annotation-style data mark-size vertical?
(when vertical? "upright")
annotation-fill)]
[:> :text (emphasis-text-props shape data index mark-size
(if ruby-layer? ruby-font-size 0)
style)
(emphasis-marks-text (:text data) emphasis-mark)]))]))]]))

View File

@ -7,6 +7,7 @@
(ns app.main.ui.workspace.sidebar.options.common
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.store :as st]
[app.util.dom :as dom]
@ -26,6 +27,20 @@
:ref ref}
children])))
(defn radio-selected
"Value to select in a radio group for an attribute value: its name, or
`default` when unset. Mixed (`:multiple`) values select nothing."
([value]
(radio-selected value ""))
([value default]
(cond
(= value :multiple) ""
(or (nil? value)
(and (string? value) (empty? value))) default
(keyword? value) (d/name value)
(string? value) value
:else (str value))))
(defn emit-value-or-token [value emit-value-fn ids attrs]
(if (or (string? value)
(number? value)

View File

@ -30,6 +30,7 @@
[app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.sidebar.options.common :refer [radio-selected]]
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
[app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]]
[app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry*]]
@ -53,18 +54,6 @@
Evaluated once at module load time; cf/flags is immutable after startup."
(contains? cf/flags :token-typography-row))
(defn- radio-selected
([value]
(radio-selected value ""))
([value default]
(cond
(= value :multiple) ""
(or (nil? value)
(and (string? value) (empty? value))) default
(keyword? value) (d/name value)
(string? value) value
:else (str value))))
(mf/defc text-align-options*
[{:keys [values on-change on-blur]}]
(let [options

View File

@ -7,7 +7,6 @@
(ns app.main.ui.workspace.sidebar.options.menus.text-japanese-layout
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.types.file :as ctf]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
@ -16,24 +15,12 @@
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options*]]
[app.main.ui.workspace.sidebar.options.common :refer [advanced-options* radio-selected]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.text.writing-mode :as wm]
[rumext.v2 :as mf]))
(defn- radio-selected
([value]
(radio-selected value ""))
([value default]
(cond
(= value :multiple) ""
(or (nil? value)
(and (string? value) (empty? value))) default
(keyword? value) (d/name value)
(string? value) value
:else (str value))))
(def ^:private mixed-span-values
#{:mixed :multiple "mixed" "multiple"})
@ -47,13 +34,13 @@
"mixed"
(radio-selected value)))
(defn- span-select-value
(defn span-select-value
[value default]
(if (mixed-span-value? value)
"mixed"
(radio-selected value default)))
(defn- with-mixed-span-option
(defn with-mixed-span-option
[options value]
(cond-> options
(mixed-span-value? value)
@ -102,61 +89,57 @@
;; Sub-components
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc writing-mode-options*
[{:keys [values on-change on-blur]}]
(let [writing-mode (radio-selected (:writing-mode values) "horizontal-tb")
options
(mf/with-memo []
[{:value "horizontal-tb"
:id "horizontal-tb-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-horizontal")
:icon i/writing-mode-horizontal}
{:value "vertical-rl"
:id "vertical-rl-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-vertical")
:icon i/writing-mode-vertical}])
(mf/defc attr-radio-options*
"Radio group bound to one text attribute."
[{:keys [values on-change on-blur attr default-value name class options]}]
(let [selected (radio-selected (get values attr) default-value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(mf/deps attr on-change on-blur)
(fn [value]
(on-change {:writing-mode value})
(on-change {attr value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :writing-mode-options)}
[:> radio-buttons* {:selected writing-mode
[:div {:class class}
[:> radio-buttons* {:selected selected
:on-change handle-change
:name "writing-mode-options"
:name name
:options options}]]))
(mf/defc text-orientation-options*
[{:keys [values on-change on-blur]}]
;; The v2 editor can read the paragraph orientation back as an empty
;; string when it is unset; treat that (and nil) as the "mixed" default.
(let [text-orientation (radio-selected (:text-orientation values) "mixed")
options
(mf/with-memo []
[{:value "mixed"
:id "mixed-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-mixed")
:icon i/text-orientation-mixed}
{:value "upright"
:id "upright-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
(defn- writing-mode-options
[]
[{:value "horizontal-tb"
:id "horizontal-tb-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-horizontal")
:icon i/writing-mode-horizontal}
{:value "vertical-rl"
:id "vertical-rl-writing-mode"
:label (tr "workspace.options.text-options.writing-mode-vertical")
:icon i/writing-mode-vertical}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-orientation value})
(when (some? on-blur) (on-blur))))]
(defn- text-orientation-options
[]
[{:value "mixed"
:id "mixed-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-mixed")
:icon i/text-orientation-mixed}
{:value "upright"
:id "upright-text-orientation"
:label (tr "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
[:div {:class (stl/css :text-orientation-options)}
[:> radio-buttons* {:selected text-orientation
:on-change handle-change
:name "text-orientation-options"
:options options}]]))
;; Warichu (割注): a span-scoped toggle that renders the selection as two
;; half-size lines within one inline position (top/bottom in horizontal
;; flow, right/left in vertical flow).
(defn- warichu-options
[]
[{:value "none"
:id "none-warichu"
:label (tr "workspace.options.text-options.warichu-none")
:icon i/warichu-none}
{:value "warichu"
:id "warichu-warichu"
:label (tr "workspace.options.text-options.warichu")
:icon i/warichu}])
(mf/defc text-combine-upright-options*
;; Digit TCY can be applied across a shape because it discovers eligible
@ -268,7 +251,8 @@
:on-click on-toggle-advanced
:icon i/menu}]]))
(mf/defc ruby-select-option*
(mf/defc span-select-option*
"Select bound to one span attribute, with a disabled entry for mixed values."
[{:keys [values on-change on-blur attr default-value label options]}]
(let [value (get values attr)
selected (span-select-value value default-value)
@ -329,22 +313,22 @@
{:id "under" :label (tr "workspace.options.text-options.ruby-side-under")}]]
[:div {:class (stl/css :japanese-layout-controls)}
[:> ruby-hidden-option* common-props]
[:> ruby-select-option* (mf/spread-props common-props
[:> span-select-option* (mf/spread-props common-props
{:attr :ruby-size
:default-value "half"
:label (tr "workspace.options.text-options.ruby-size")
:options size-options})]
[:> ruby-select-option* (mf/spread-props common-props
[:> span-select-option* (mf/spread-props common-props
{:attr :ruby-align
:default-value "space-around"
:label (tr "workspace.options.text-options.ruby-align")
:options align-options})]
[:> ruby-select-option* (mf/spread-props common-props
[:> span-select-option* (mf/spread-props common-props
{:attr :ruby-overhang
:default-value "auto"
:label (tr "workspace.options.text-options.ruby-overhang")
:options overhang-options})]
[:> ruby-select-option* (mf/spread-props common-props
[:> span-select-option* (mf/spread-props common-props
{:attr :ruby-side
:default-value "over"
:label (tr "workspace.options.text-options.ruby-side")
@ -381,37 +365,6 @@
:is-visible open?}
[:> ruby-customization-options* common-props]]]))
(mf/defc warichu-options*
;; Warichu (割注): a span-scoped toggle that renders the selection as two
;; half-size lines within one inline position (top/bottom in horizontal
;; flow, right/left in vertical flow). Applies to the current selection
;; through the shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [warichu (radio-selected (:warichu values) "none")
options
(mf/with-memo []
[{:value "none"
:id "none-warichu"
:label (tr "workspace.options.text-options.warichu-none")
:icon i/warichu-none}
{:value "warichu"
:id "warichu-warichu"
:label (tr "workspace.options.text-options.warichu")
:icon i/warichu}])
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:warichu value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :warichu-options)}
[:> radio-buttons* {:selected warichu
:on-change handle-change
:name "warichu-options"
:options options}]]))
(mf/defc font-features-options*
;; Japanese proportional metric alternates. `palt` is typically used for
;; horizontal composition and `vpal` for vertical composition; the value is
@ -432,7 +385,7 @@
(on-change {:font-features (if checked? feature "none")})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :font-features-options :japanese-select-option)}
[:div {:class (stl/css :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.font-features")]
[:> switch* {:default-checked enabled?
@ -458,33 +411,6 @@
{:id "open-sesame"
:label (translate "workspace.options.text-options.text-emphasis-open-sesame")}]))
(mf/defc text-emphasis-options*
;; Emphasis marks (圏点 / bouten): a span-scoped style drawn beside each base
;; character in vertical writing. Applies to the current selection through the
;; shared node-attr on-change; "none" is the default.
[{:keys [values on-change on-blur]}]
(let [value (:text-emphasis values)
text-emphasis (span-select-value value "none")
options
(mf/with-memo []
(text-emphasis-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-emphasis value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :text-emphasis-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.text-emphasis")]
[:> select* {:default-selected text-emphasis
:aria-label (tr "workspace.options.text-options.text-emphasis")
:options options
:on-change handle-change}]]))
(defn annotation-clearance-options
([]
(annotation-clearance-options tr))
@ -494,26 +420,6 @@
{:id "auto"
:label (translate "workspace.options.text-options.annotation-clearance-auto")}]))
(mf/defc annotation-clearance-options*
[{:keys [values on-change on-blur]}]
(let [value (:annotation-clearance values)
annotation-clearance (span-select-value value "none")
options (mf/with-memo [] (annotation-clearance-options))
options (with-mixed-span-option options value)
handle-change
(mf/use-fn
(mf/deps on-change on-blur)
(fn [value]
(on-change {:annotation-clearance value})
(when (some? on-blur) (on-blur))))]
[:div {:class (stl/css :annotation-clearance-options :japanese-select-option)}
[:span {:class (stl/css :japanese-option-label)}
(tr "workspace.options.text-options.annotation-clearance")]
[:> select* {:default-selected annotation-clearance
:aria-label (tr "workspace.options.text-options.annotation-clearance")
:options options
:on-change handle-change}]]))
(mf/defc japanese-layout-options*
[{:keys [values ruby-values text-selection-active
on-change on-ruby-presentation-change on-blur]}]
@ -525,26 +431,58 @@
ruby-presentation-props (mf/props
{:values ruby-values
:on-change on-ruby-presentation-change
:on-blur on-blur})]
:on-blur on-blur})
options (mf/with-memo []
{:writing-mode (writing-mode-options)
:text-orientation (text-orientation-options)
:warichu (warichu-options)
:text-emphasis (text-emphasis-options)
:annotation-clearance (annotation-clearance-options)})]
[:div {:class (stl/css :japanese-layout-options)}
[:div {:class (stl/css :japanese-layout-controls)}
[:div {:class (stl/css :japanese-icon-options)}
[:> writing-mode-options* common-props]
[:> attr-radio-options* (mf/spread-props common-props
{:attr :writing-mode
:default-value "horizontal-tb"
:name "writing-mode-options"
:class (stl/css :writing-mode-options)
:options (:writing-mode options)})]
(when ^boolean vertical?
[:*
[:> text-orientation-options* common-props]
;; The v2 editor can read the paragraph orientation back as an empty
;; string when it is unset; that (and nil) selects "mixed".
[:> attr-radio-options* (mf/spread-props common-props
{:attr :text-orientation
:default-value "mixed"
:name "text-orientation-options"
:class (stl/css :text-orientation-options)
:options (:text-orientation options)})]
[:> text-combine-upright-options*
(mf/spread-props common-props
{:text-selection-active text-selection-active})]])
(when ^boolean text-selection-active
[:> warichu-options* common-props])]
[:> attr-radio-options* (mf/spread-props common-props
{:attr :warichu
:default-value "none"
:name "warichu-options"
:class (stl/css :warichu-options)
:options (:warichu options)})])]
(when ^boolean vertical?
[:> text-combine-upright-count-options* common-props])
[:> font-features-options* common-props]
;; Emphasis marks (圏点 / bouten) apply to the selected characters.
(when ^boolean text-selection-active
[:> text-emphasis-options* common-props])
[:> annotation-clearance-options* common-props]
[:> span-select-option* (mf/spread-props common-props
{:attr :text-emphasis
:default-value "none"
:label (tr "workspace.options.text-options.text-emphasis")
:options (:text-emphasis options)})])
[:> span-select-option* (mf/spread-props common-props
{:attr :annotation-clearance
:default-value "none"
:label (tr "workspace.options.text-options.annotation-clearance")
:options (:annotation-clearance options)})]
(if text-selection-active
[:> ruby-advanced-options* common-props]
[:> ruby-presentation-options* ruby-presentation-props])]]))

View File

@ -41,12 +41,6 @@
border-radius: $br-8;
}
.text-emphasis-options,
.font-features-options,
.annotation-clearance-options {
inline-size: 100%;
}
.japanese-layout-options,
.japanese-layout-controls {
display: flex;

View File

@ -13,6 +13,7 @@
[app.common.schema :as sm]
[app.common.types.fills :as types.fills]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.wasm-text :as dwwt]
@ -102,24 +103,57 @@
result))))
(def ^:private japanese-range-defaults
{:text-combine-upright "none"
:text-emphasis "none"
:ruby nil
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
:ruby-side "over"
:warichu "none"
:font-features "none"
:annotation-clearance "none"})
;; A range without ruby reports null to plugins.
(assoc jl/span-attr-defaults :ruby nil))
(defn- range-japanese-value
[range-data attr]
"Value of a Japanese span `attr` over a text range proxy's characters."
[range-proxy start end attr]
(let [default (get japanese-range-defaults attr)]
(->> range-data
(->> (-> range-proxy u/proxy->shape :content (content-range->text+styles start end))
(map #(get % attr default))
(u/mixed-value))))
(defn- enum-value?
"Validator accepting the strings fully matched by `re`."
[re]
(fn [value]
(and (string? value) (some? (re-matches re value)))))
(defn- optional-string?
[value]
(or (nil? value) (string? value)))
(defn- attr-setter
"Property setter that checks `valid?` and the plugin's write access before
emitting `(update-event self attrs)` with `{attr value}`."
[plugin-id page-id prop attr valid? update-event]
(fn [self value]
(cond
(not (valid? value))
(u/not-valid plugin-id prop value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id prop "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id prop "Cannot modify a page that is not currently active")
:else
(st/emit! (update-event self {attr value})))))
(defn- range-attr-setter
"Setter of a span attribute over the characters [start, end) of shape `id`."
[plugin-id page-id id start end prop attr valid?]
(attr-setter plugin-id page-id prop attr valid?
(fn [_ attrs] (dwt/update-text-range id start end attrs))))
(defn- shape-attr-setter
"Setter of a text attribute over the whole text shape."
[plugin-id page-id prop attr valid?]
(attr-setter plugin-id page-id prop attr valid?
(fn [self attrs] (dwt/update-attrs (obj/get self "$id") attrs))))
(defn text-range-proxy?
[range]
(obj/type-of? range "TextRange"))
@ -394,223 +428,53 @@
:fontFeatures
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :font-features)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches font-features-re value)))
(u/not-valid plugin-id :fontFeatures value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:font-features value}))))}
:get (fn [self] (range-japanese-value self start end :font-features))
:set (range-attr-setter plugin-id page-id id start end :fontFeatures :font-features (enum-value? font-features-re))}
:textCombineUpright
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :text-combine-upright)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches text-combine-upright-re value)))
(u/not-valid plugin-id :textCombineUpright value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:text-combine-upright value}))))}
:get (fn [self] (range-japanese-value self start end :text-combine-upright))
:set (range-attr-setter plugin-id page-id id start end :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))}
:textEmphasis
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :text-emphasis)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches text-emphasis-re value)))
(u/not-valid plugin-id :textEmphasis value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:text-emphasis value}))))}
:get (fn [self] (range-japanese-value self start end :text-emphasis))
:set (range-attr-setter plugin-id page-id id start end :textEmphasis :text-emphasis (enum-value? text-emphasis-re))}
:warichu
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :warichu)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches warichu-re value)))
(u/not-valid plugin-id :warichu value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:warichu value}))))}
:get (fn [self] (range-japanese-value self start end :warichu))
:set (range-attr-setter plugin-id page-id id start end :warichu :warichu (enum-value? warichu-re))}
:annotationClearance
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :annotation-clearance)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches annotation-clearance-re value)))
(u/not-valid plugin-id :annotationClearance value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:annotation-clearance value}))))}
:get (fn [self] (range-japanese-value self start end :annotation-clearance))
:set (range-attr-setter plugin-id page-id id start end :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))}
:ruby
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :ruby)))
:set
(fn [_ value]
(cond
(and (some? value) (not (string? value)))
(u/not-valid plugin-id :ruby value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:ruby value}))))}
:get (fn [self] (range-japanese-value self start end :ruby))
:set (range-attr-setter plugin-id page-id id start end :ruby :ruby optional-string?)}
:rubySize
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :ruby-size)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches ruby-size-re value)))
(u/not-valid plugin-id :rubySize value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:ruby-size value}))))}
:get (fn [self] (range-japanese-value self start end :ruby-size))
:set (range-attr-setter plugin-id page-id id start end :rubySize :ruby-size (enum-value? ruby-size-re))}
:rubyAlign
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :ruby-align)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches ruby-align-re value)))
(u/not-valid plugin-id :rubyAlign value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:ruby-align value}))))}
:get (fn [self] (range-japanese-value self start end :ruby-align))
:set (range-attr-setter plugin-id page-id id start end :rubyAlign :ruby-align (enum-value? ruby-align-re))}
:rubyOverhang
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :ruby-overhang)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches ruby-overhang-re value)))
(u/not-valid plugin-id :rubyOverhang value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:ruby-overhang value}))))}
:get (fn [self] (range-japanese-value self start end :ruby-overhang))
:set (range-attr-setter plugin-id page-id id start end :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))}
:rubySide
{:this true
:get
(fn [self]
(let [range-data
(-> self u/proxy->shape :content (content-range->text+styles start end))]
(range-japanese-value range-data :ruby-side)))
:set
(fn [_ value]
(cond
(or (not (string? value)) (not (re-matches ruby-side-re value)))
(u/not-valid plugin-id :rubySide value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-text-range id start end {:ruby-side value}))))}
:get (fn [self] (range-japanese-value self start end :ruby-side))
:set (range-attr-setter plugin-id page-id id start end :rubySide :ruby-side (enum-value? ruby-side-re))}
:direction
{:this true
@ -1008,207 +872,51 @@
{:name "writingMode"
:get #(-> % u/proxy->shape text-props :writing-mode format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches writing-mode-re value)))
(u/not-valid plugin-id :writingMode value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :writingMode "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :writingMode "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:writing-mode value})))))}
:set (shape-attr-setter plugin-id page-id :writingMode :writing-mode (enum-value? writing-mode-re))}
{:name "textOrientation"
:get #(-> % u/proxy->shape text-props :text-orientation format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches text-orientation-re value)))
(u/not-valid plugin-id :textOrientation value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :textOrientation "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :textOrientation "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:text-orientation value})))))}
:set (shape-attr-setter plugin-id page-id :textOrientation :text-orientation (enum-value? text-orientation-re))}
{:name "textCombineUpright"
:get #(-> % u/proxy->shape text-props :text-combine-upright format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches text-combine-upright-re value)))
(u/not-valid plugin-id :textCombineUpright value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :textCombineUpright "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :textCombineUpright "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:text-combine-upright value})))))}
:set (shape-attr-setter plugin-id page-id :textCombineUpright :text-combine-upright (enum-value? text-combine-upright-re))}
{:name "textEmphasis"
:get #(-> % u/proxy->shape text-props :text-emphasis format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches text-emphasis-re value)))
(u/not-valid plugin-id :textEmphasis value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :textEmphasis "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :textEmphasis "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:text-emphasis value})))))}
:set (shape-attr-setter plugin-id page-id :textEmphasis :text-emphasis (enum-value? text-emphasis-re))}
{:name "warichu"
:get #(-> % u/proxy->shape text-props :warichu format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches warichu-re value)))
(u/not-valid plugin-id :warichu value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :warichu "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :warichu "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:warichu value})))))}
:set (shape-attr-setter plugin-id page-id :warichu :warichu (enum-value? warichu-re))}
{:name "fontFeatures"
:get #(-> % u/proxy->shape text-props :font-features format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches font-features-re value)))
(u/not-valid plugin-id :fontFeatures value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :fontFeatures "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :fontFeatures "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:font-features value})))))}
:set (shape-attr-setter plugin-id page-id :fontFeatures :font-features (enum-value? font-features-re))}
{:name "annotationClearance"
:get #(-> % u/proxy->shape text-props :annotation-clearance format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches annotation-clearance-re value)))
(u/not-valid plugin-id :annotationClearance value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :annotationClearance "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :annotationClearance "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:annotation-clearance value})))))}
:set (shape-attr-setter plugin-id page-id :annotationClearance :annotation-clearance (enum-value? annotation-clearance-re))}
{:name "ruby"
:get #(-> % u/proxy->shape text-props :ruby format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(and (some? value) (not (string? value)))
(u/not-valid plugin-id :ruby value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :ruby "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :ruby "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:ruby value})))))}
:set (shape-attr-setter plugin-id page-id :ruby :ruby optional-string?)}
{:name "rubySize"
:get #(-> % u/proxy->shape text-props :ruby-size format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches ruby-size-re value)))
(u/not-valid plugin-id :rubySize value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubySize "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubySize "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:ruby-size value})))))}
:set (shape-attr-setter plugin-id page-id :rubySize :ruby-size (enum-value? ruby-size-re))}
{:name "rubyAlign"
:get #(-> % u/proxy->shape text-props :ruby-align format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches ruby-align-re value)))
(u/not-valid plugin-id :rubyAlign value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubyAlign "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubyAlign "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:ruby-align value})))))}
:set (shape-attr-setter plugin-id page-id :rubyAlign :ruby-align (enum-value? ruby-align-re))}
{:name "rubyOverhang"
:get #(-> % u/proxy->shape text-props :ruby-overhang format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches ruby-overhang-re value)))
(u/not-valid plugin-id :rubyOverhang value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubyOverhang "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubyOverhang "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:ruby-overhang value})))))}
:set (shape-attr-setter plugin-id page-id :rubyOverhang :ruby-overhang (enum-value? ruby-overhang-re))}
{:name "rubySide"
:get #(-> % u/proxy->shape text-props :ruby-side format/format-mixed)
:set
(fn [self value]
(let [id (obj/get self "$id")]
(cond
(or (not (string? value)) (not (re-matches ruby-side-re value)))
(u/not-valid plugin-id :rubySide value)
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :rubySide "Plugin doesn't have 'content:write' permission")
(not (u/page-active? page-id))
(u/not-valid plugin-id :rubySide "Cannot modify a page that is not currently active")
:else
(st/emit! (dwt/update-attrs id {:ruby-side value})))))}
:set (shape-attr-setter plugin-id page-id :rubySide :ruby-side (enum-value? ruby-side-re))}
{:name "textBounds"
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})))

View File

@ -31,6 +31,7 @@
[app.common.types.path.impl :as path.impl]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.types.text.japanese-layout :as jl]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.helpers :as dsh]
@ -2931,60 +2932,90 @@
(def POSITION-DATA-U8-SIZE 36)
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
(defn- ruby-font-scale
[ruby-size]
(case ruby-size
"third" (/ 1 3)
"quarter" 0.25
0.5))
(defn- horizontal-ruby-slice
"Returns the whole-span ruby annotation for a horizontal base strip."
[element _start-pos _end-pos]
(let [text (:text element)
ruby (:ruby element)]
(when (and (not (true? (:ruby-hidden element)))
(string? text)
(seq text)
(string? ruby)
(seq ruby))
ruby)))
(def ^:private POSITION-DATA-VERTICAL 2)
(def ^:private POSITION-DATA-RUBY 3)
(defn- ruby-strip-entry
"Position-data entry for a ruby annotation strip (direction 3): the
offsets index the span's ruby string and the geometry is the exact
gutter placement the canvas paints."
"Position-data entry for a ruby annotation strip: the offsets index the
span's ruby string and the geometry is the exact gutter placement the
canvas paints."
[element {:keys [start-pos end-pos x y width height]}]
(let [ruby (get element :ruby)]
(when (and (not (true? (:ruby-hidden element)))
(string? ruby))
(let [text (subs ruby
(min start-pos (count ruby))
(min end-pos (count ruby)))
font-size (js/parseFloat (get element :font-size))]
(when (seq text)
(d/patch-object
(txt/get-default-text-attrs)
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction "ltr"
:writing-mode "vertical-rl"
:text-orientation "upright"
:font-id (get element :font-id)
:font-family (get element :font-family)
:font-size (when-not (js/isNaN font-size)
(dm/str (* (ruby-font-scale (:ruby-size element)) font-size) "px"))
:font-weight (get element :font-weight)
:font-style (get element :font-style)
:ruby-size (get element :ruby-size)
:ruby-align (get element :ruby-align)
:ruby-overhang (get element :ruby-overhang)
:ruby-side (get element :ruby-side)
:fills (get element :fills)
:text text})))))))
(when-let [ruby (jl/visible-ruby element)]
(let [text (subs ruby
(min start-pos (count ruby))
(min end-pos (count ruby)))
font-size (js/parseFloat (get element :font-size))]
(when (seq text)
(d/patch-object
(txt/get-default-text-attrs)
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction "ltr"
:writing-mode "vertical-rl"
:text-orientation "upright"
:font-id (get element :font-id)
:font-family (get element :font-family)
:font-size (when-not (js/isNaN font-size)
(dm/str (* (jl/ruby-font-scale (:ruby-size element)) font-size) "px"))
:font-weight (get element :font-weight)
:font-style (get element :font-style)
:ruby-size (get element :ruby-size)
:ruby-align (get element :ruby-align)
:ruby-overhang (get element :ruby-overhang)
:ruby-side (get element :ruby-side)
:fills (get element :fills)
:text text}))))))
(defn- text-strip-entry
"Position-data entry for a strip of base text. Be aware that for RTL texts
`start-pos` can be greater than `end-pos`."
[paragraph element {:keys [start-pos end-pos direction x y width height]}]
(when-let [element-text (:text element)]
(let [vertical? (= direction POSITION-DATA-VERTICAL)
ruby (jl/visible-ruby element)]
(d/patch-object
(txt/get-default-text-attrs)
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
;; The SVG renderer draws vertical strips with CSS writing-mode.
:writing-mode (when vertical? "vertical-rl")
;; Glyph orientation is stored on the span or its paragraph.
:text-orientation (when vertical?
(not-empty (or (get element :text-orientation)
(get paragraph :text-orientation))))
:font-id (get element :font-id)
:font-family (get element :font-family)
:font-size (dm/str (get element :font-size) "px")
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:text-combine-upright (get element :text-combine-upright)
;; Emphasis marks (圏点) and warichu are drawn by the static SVG
;; renderer; "none" carries no information.
:text-emphasis (let [emphasis (get element :text-emphasis)]
(when (and (seq emphasis) (not= "none" emphasis))
emphasis))
:warichu (when (= "warichu" (get element :warichu)) "warichu")
:annotation-clearance (when (= "auto" (get element :annotation-clearance)) "auto")
:annotation-has-ruby (when (some? ruby) true)
;; Horizontal position data has no separate ruby strip, so the base
;; entry carries the annotation for static SVG export.
:ruby (when (and (not vertical?) (seq element-text)) ruby)
:ruby-size (get element :ruby-size)
:ruby-align (get element :ruby-align)
:ruby-overhang (get element :ruby-overhang)
:ruby-side (get element :ruby-side)
:letter-spacing (dm/str (get element :letter-spacing) "px")
:font-style (get element :font-style)
:fills (get element :fills)
:text (subs element-text start-pos end-pos)})))))
(defn calculate-position-data
[shape]
@ -3013,82 +3044,14 @@
(into []
(keep
(fn [{:keys [paragraph span start-pos end-pos direction x y width height] :as entry}]
(fn [{:keys [paragraph span direction] :as entry}]
(let [paragraph-node (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph))
element (-> paragraph-node :children ;; paragraph
(get span))
element-text (:text element)]
(if (= direction 3)
element (-> paragraph-node :children (get span))]
(if (= direction POSITION-DATA-RUBY)
(ruby-strip-entry element entry)
;; Glyph orientation of a vertical strip; stored on the
;; span or its paragraph. Empty reads normalize to nil
;; (the SVG renderer then defaults to "mixed").
(let [text-orientation
(when (= direction 2)
(let [orientation (or (get element :text-orientation)
(get paragraph-node :text-orientation))]
(when (seq orientation) orientation)))]
;; Add comprehensive nil-safety checks
;; Be aware that for RTL texts `start-pos` can be greatert han `end-pos`
(when (and element element-text)
(let [text (subs element-text start-pos end-pos)]
(d/patch-object
(txt/get-default-text-attrs)
(d/without-nils
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
;; Direction 2 marks a vertical-rl column strip;
;; the SVG renderer draws it with CSS writing-mode.
:writing-mode (when (= direction 2) "vertical-rl")
:text-orientation text-orientation
:font-id (get element :font-id)
:font-family (get element :font-family)
:font-size (dm/str (get element :font-size) "px")
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:text-combine-upright (get element :text-combine-upright)
;; Emphasis marks (圏点) are drawn by the static SVG
;; renderer; "none" carries no information.
:text-emphasis (let [emphasis (get element :text-emphasis)]
(when (and (string? emphasis)
(seq emphasis)
(not= "none" emphasis))
emphasis))
:annotation-clearance
(let [clearance (get element :annotation-clearance)]
(when (= "auto" clearance) clearance))
:annotation-has-ruby
(let [ruby (get element :ruby)]
(when (and (not (true? (:ruby-hidden element)))
(string? ruby)
(seq ruby))
true))
;; Horizontal position data has no separate ruby
;; strip, so carry the annotation on the base entry
;; for static SVG export. Vertical ruby has its own
;; direction-3 position entry.
:ruby (when (not= direction 2)
(horizontal-ruby-slice
element start-pos end-pos))
:ruby-size (get element :ruby-size)
:ruby-align (get element :ruby-align)
:ruby-overhang (get element :ruby-overhang)
:ruby-side (get element :ruby-side)
;; Warichu spans render as two half-size sub-columns
;; in the static SVG; "none" carries no information.
:warichu (let [warichu (get element :warichu)]
(when (= "warichu" warichu) warichu))
:letter-spacing (dm/str (get element :letter-spacing) "px")
:font-style (get element :font-style)
:fills (get element :fills)
:text text})))))))))
(text-strip-entry paragraph-node element entry)))))
result))))
(defn apply-canvas-blur

View File

@ -697,29 +697,16 @@
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(def ^:private japanese-span-style-defaults
"Explicit UI defaults for Japanese span attributes. These values must be
present in every selection snapshot so moving onto an unstyled span clears
the previous span's controls instead of leaving stale values behind."
{:text-combine-upright "none"
:text-emphasis "none"
:ruby ""
:ruby-hidden false
:ruby-size "half"
:ruby-align "space-around"
:ruby-overhang "auto"
:ruby-side "over"
:warichu "none"
:font-features "none"
:annotation-clearance "none"})
(defn- span-japanese-styles
"Japanese span attributes with their defaults filled in. Every selection
snapshot carries all of them, so moving onto an unstyled span clears the
previous span's controls instead of leaving stale values behind."
[span]
(reduce-kv
(fn [styles attr default]
(assoc styles attr (or (get span attr) default)))
{}
japanese-span-style-defaults))
jl/span-attr-defaults))
(defn- merge-selection-styles
[result styles]

View File

@ -38,223 +38,44 @@
[re s]
(count (re-seq re s)))
(defn- setup-vertical-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 40
:height 100})
(assoc :name "Vertical text"
:position-data
[{:x 20
:y 100
:width 24
:height 80
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:font-features "vpal"
:text "うA"}]))
(def ^:private strip-defaults
{:fills [{:fill-color "#112233" :fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"})
(defn- setup-text
"A text shape whose single position-data strip is `strip` (over
`strip-defaults`), with its objects and selection."
[{:keys [width height]} strip]
(let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height})
(assoc :name "Text"
:position-data [(merge strip-defaults strip)]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-text
:text
shape)
page (cthf/current-page file)
objects (:objects page)]
objects (:objects (cthf/current-page file))]
{:objects objects
:shapes [(get objects (cthi/id :vertical-text))]}))
:shapes [(get objects (cthi/id :text))]}))
(defn- setup-vertical-ruby-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 60
:height 100})
(assoc :name "Vertical ruby text"
:position-data
[{:x 20
:y 100
:width 24
:height 80
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:text "漢字"
:ruby "かんじ"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-ruby-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-ruby-text))]}))
(defn- vertical-strip
[attrs]
(merge {:x 20 :y 100 :width 24 :height 80
:writing-mode "vertical-rl"
:text-orientation "upright"}
attrs))
(defn- setup-vertical-emphasis-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 60
:height 100})
(assoc :name "Vertical emphasis text"
:position-data
[{:x 20
:y 100
:width 24
:height 80
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:text "強調 あ"
:text-emphasis "filled-dot"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-emphasis-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-emphasis-text))]}))
(defn- setup-horizontal-emphasis-text
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 120
:height 40})
(assoc :name "Horizontal emphasis text"
:position-data
[{:x 20
:y 60
:width 100
:height 20
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "horizontal-tb"
:text "強調、 あ"
:text-emphasis "filled-dot"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:horizontal-emphasis-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :horizontal-emphasis-text))]}))
(defn- setup-horizontal-stacked-annotations
[]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 120
:height 40})
(assoc :name "Horizontal stacked annotations"
:position-data
[{:x 20
:y 60
:width 100
:height 20
:fills [{:fill-color "#112233" :fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "horizontal-tb"
:text "漢字"
:ruby "かんじ"
:text-emphasis "filled-dot"
:annotation-clearance "auto"
:annotation-has-ruby true}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:horizontal-stacked-annotations
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :horizontal-stacked-annotations))]}))
(defn- horizontal-strip
[attrs]
(merge {:x 20 :y 60 :width 100 :height 20
:writing-mode "horizontal-tb"}
attrs))
(defn- setup-vertical-warichu-text
([] (setup-vertical-warichu-text "割注入り"))
([text]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 60
:height 100})
(assoc :name "Vertical warichu text"
:position-data
[{:x 20
:y 100
:width 24
:height 40
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "vertical-rl"
:text-orientation "upright"
:text text
:warichu "warichu"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:vertical-warichu-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :vertical-warichu-text))]})))
(defn- setup-horizontal-warichu-text
([] (setup-horizontal-warichu-text "割注入り"))
([text]
(let [shape (-> (cts/setup-shape {:type :text
:x 10
:y 20
:width 120
:height 40})
(assoc :name "Horizontal warichu text"
:position-data
[{:x 20
:y 60
:width 40
:height 20
:fills [{:fill-color "#112233"
:fill-opacity 1}]
:font-family "Noto Sans CJK JP"
:font-size "20"
:font-weight "400"
:writing-mode "horizontal-tb"
:text text
:warichu "warichu"}]))
file (cths/add-sample-shape
(cthf/sample-file :file1 :page-label :page1)
:horizontal-warichu-text
shape)
page (cthf/current-page file)
objects (:objects page)]
{:objects objects
:shapes [(get objects (cthi/id :horizontal-warichu-text))]})))
[text]
(setup-text {:width 60 :height 100}
(vertical-strip {:height 40 :text text :warichu "warichu"})))
(deftest empty-selection-yields-empty-string
(is (= "" (svg/generate-markup {} []))))
@ -281,7 +102,8 @@
(deftest vertical-text-svg-preserves-browser-layout-properties
(testing "Static SVG carries the vertical writing properties used by browser exports"
(let [{:keys [objects shapes]} (setup-vertical-text)
(let [{:keys [objects shapes]} (setup-text {:width 40 :height 100}
(vertical-strip {:font-features "vpal" :text "うA"}))
markup (svg/generate-markup objects shapes)]
(is (re-find #"writing-mode:vertical-rl" markup))
(is (re-find #"text-orientation:upright" markup))
@ -291,7 +113,8 @@
(deftest vertical-emphasis-svg-emits-static-marks
(testing "Static SVG draws emphasis marks and skips whitespace characters"
(let [{:keys [objects shapes]} (setup-vertical-emphasis-text)
(let [{:keys [objects shapes]} (setup-text {:width 60 :height 100}
(vertical-strip {:text "強調 あ" :text-emphasis "filled-dot"}))
markup (svg/generate-markup objects shapes)]
(is (re-find #"強調 あ" markup))
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
@ -301,7 +124,8 @@
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
(let [{:keys [objects shapes]} (setup-horizontal-emphasis-text)
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
(horizontal-strip {:text "強調、 あ" :text-emphasis "filled-dot"}))
markup (svg/generate-markup objects shapes)]
(is (re-find #"強調、 あ" markup))
;; Five Unicode base characters: two marks, punctuation and whitespace
@ -317,7 +141,12 @@
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
(let [{:keys [objects shapes]} (setup-horizontal-stacked-annotations)
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
(horizontal-strip {:text "漢字"
:ruby "かんじ"
:text-emphasis "filled-dot"
:annotation-clearance "auto"
:annotation-has-ruby true}))
markup (svg/generate-markup objects shapes)]
(is (re-find #"かんじ" markup))
(is (re-find #"••" markup))
@ -326,7 +155,7 @@
(deftest vertical-warichu-svg-emits-two-sub-columns
(testing "Static SVG splits a warichu strip into two half-size sub-columns"
(let [{:keys [objects shapes]} (setup-vertical-warichu-text)
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割注入り")
markup (svg/generate-markup objects shapes)]
(is (re-find #"割注" markup))
(is (re-find #"入り" markup))
@ -354,7 +183,8 @@
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
(testing "Static SVG splits horizontal warichu into top and bottom half-size lines"
(let [{:keys [objects shapes]} (setup-horizontal-warichu-text)
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
(horizontal-strip {:width 40 :text "割注入り" :warichu "warichu"}))
markup (svg/generate-markup objects shapes)]
(is (re-find #"割注" markup))
(is (re-find #"入り" markup))
@ -365,7 +195,8 @@
(deftest vertical-ruby-svg-emits-static-annotation
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
(let [{:keys [objects shapes]} (setup-vertical-ruby-text)
(let [{:keys [objects shapes]} (setup-text {:width 60 :height 100}
(vertical-strip {:text "漢字" :ruby "かんじ"}))
markup (svg/generate-markup objects shapes)]
(is (re-find #"漢字" markup))
(is (re-find #"かんじ" markup))

View File

@ -26,10 +26,10 @@
;; so negative values were refused. Pin the accept/reject contract of the
;; plugin setter here.
(defn- apply-letter-spacing
"Sets `letterSpacing` on a text range proxy and returns the attributes sent
to the update event, or nil when the plugin rejected the value."
[value]
(defn- apply-range-property
"Sets `property` on a text range proxy and returns the attributes sent to
the update event, or nil when the plugin rejected the value."
[property value]
(let [captured (atom nil)
range (plugins.text/text-range-proxy
plugin-id (random-uuid) (random-uuid) (random-uuid) 0 4)]
@ -41,19 +41,12 @@
(reset! captured attrs)
:update-text-range)
st/emit! mock/noop]
(set! (.-letterSpacing range) value)
(unchecked-set range property value)
@captured)))
(def ^:private font-features-re @#'plugins.text/font-features-re)
(def ^:private annotation-clearance-re @#'plugins.text/annotation-clearance-re)
(def ^:private ruby-size-re @#'plugins.text/ruby-size-re)
(def ^:private ruby-align-re @#'plugins.text/ruby-align-re)
(def ^:private ruby-overhang-re @#'plugins.text/ruby-overhang-re)
(def ^:private ruby-side-re @#'plugins.text/ruby-side-re)
(defn- valid-font-features? [s] (boolean (re-matches font-features-re s)))
(defn- valid-annotation-clearance? [s]
(boolean (re-matches annotation-clearance-re s)))
(defn- apply-letter-spacing
[value]
(apply-range-property "letterSpacing" value))
(t/deftest letter-spacing-accepts-negative-values
(t/is (= {:letter-spacing "-0.56"} (apply-letter-spacing "-0.56")))
@ -70,28 +63,21 @@
(t/is (nil? (apply-letter-spacing "1-2")))
(t/is (nil? (apply-letter-spacing "--1"))))
(t/deftest font-features-re-accepts-supported-japanese-proportional-features
(t/is (valid-font-features? "none"))
(t/is (valid-font-features? "palt"))
(t/is (valid-font-features? "vpal"))
(t/is (not (valid-font-features? "liga")))
(t/is (not (valid-font-features? "palt,vpal"))))
(t/deftest annotation-clearance-re-accepts-supported-policies
(t/is (valid-annotation-clearance? "none"))
(t/is (valid-annotation-clearance? "auto"))
(t/is (not (valid-annotation-clearance? "always"))))
(t/deftest ruby-customization-validates-supported-values
(t/is (every? #(re-matches ruby-size-re %) ["half" "third" "quarter"]))
(t/is (not (re-matches ruby-size-re "full")))
(t/is (every? #(re-matches ruby-align-re %)
["space-around" "center" "start" "space-between"]))
(t/is (not (re-matches ruby-align-re "end")))
(t/is (every? #(re-matches ruby-overhang-re %) ["auto" "none"]))
(t/is (not (re-matches ruby-overhang-re "always")))
(t/is (every? #(re-matches ruby-side-re %) ["over" "under"]))
(t/is (not (re-matches ruby-side-re "right"))))
(t/deftest japanese-range-properties-accept-only-supported-values
(doseq [[property attr accepted rejected]
[["fontFeatures" :font-features ["none" "palt" "vpal"] ["liga" "palt,vpal"]]
["annotationClearance" :annotation-clearance ["none" "auto"] ["always"]]
["rubySize" :ruby-size ["half" "third" "quarter"] ["full"]]
["rubyAlign" :ruby-align ["space-around" "center" "start" "space-between"] ["end"]]
["rubyOverhang" :ruby-overhang ["auto" "none"] ["always"]]
["rubySide" :ruby-side ["over" "under"] ["right"]]
["ruby" :ruby ["かんじ" nil] [42]]]]
(doseq [value accepted]
(t/is (= {attr value} (apply-range-property property value))
(str property " accepts " (pr-str value))))
(doseq [value rejected]
(t/is (nil? (apply-range-property property value))
(str property " rejects " (pr-str value))))))
(t/deftest text-range-japanese-properties-read-span-values
(let [file-id (random-uuid)

View File

@ -5,12 +5,12 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.render-wasm.texts-test
"Unit tests for CJK script classification and the Han-unification
resolution policy used to pick Noto fallback fonts."
"Japanese text support in the WASM bridge: span serialization, editor
selection styles, IME composition caret placement, and the CJK script
classification used to pick Noto fallback fonts."
(:require
[app.common.fonts :as cfnt]
[app.common.render-wasm.text-content :as tc]
[app.common.render-wasm.wasm :as wasm]
[app.main.ui.workspace.shapes.text.v3-editor :as v3-editor]
[app.render-wasm.api :as api]
[app.render-wasm.api.texts :as texts]
@ -148,119 +148,66 @@
(t/is (not-any? #(contains? % :text-orientation) paragraphs))
(t/is (= [nil "center" nil] (mapv :text-align paragraphs)))))
(t/deftest write-spans-serializes-font-features-in-reserved-byte
(let [sentinel (js-obj)
previous (if (.hasOwnProperty wasm/serializers "font-features")
(unchecked-get wasm/serializers "font-features")
sentinel)]
(try
(aset wasm/serializers "font-features" #js {"none" 0 "palt" 1 "vpal" 2})
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "日本語"
:font-size "16"
:font-weight "400"
:font-features "vpal"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
(t/is (= 2 (.getUint8 dview 8)))
(t/is (= 0 (.getUint8 dview 9)))
(t/is (= 0 (.getUint8 dview 14)))
(t/is (= 0 (.getUint8 dview 15))))
(finally
(if (identical? sentinel previous)
(js-delete wasm/serializers "font-features")
(aset wasm/serializers "font-features" previous))))))
;; Byte offsets inside one span's attribute block (see `write-spans` and
;; RawTextSpan in render-wasm).
(def ^:private span-attr-offset
{:text-combine-upright 5
:font-features 8
:annotation-clearance 9
:ruby-size 10
:ruby-align 11
:ruby-overhang 12
:ruby-side 13
:padding [14 15]
:ruby-length 72})
(t/deftest write-spans-serializes-annotation-clearance-in-reserved-byte
(let [sentinel (js-obj)
previous (if (.hasOwnProperty wasm/serializers "annotation-clearance")
(unchecked-get wasm/serializers "annotation-clearance")
sentinel)]
(try
(aset wasm/serializers "annotation-clearance" #js {"none" 0 "auto" 1})
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "漢字"
:font-size "16"
:font-weight "400"
:annotation-clearance "auto"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
(t/is (= 1 (.getUint8 dview 9)))
(t/is (= 0 (.getUint8 dview 14)))
(t/is (= 0 (.getUint8 dview 15))))
(finally
(if (identical? sentinel previous)
(js-delete wasm/serializers "annotation-clearance")
(aset wasm/serializers "annotation-clearance" previous))))))
(def ^:private span-paragraph
{:font-size "16" :font-weight "400" :line-height "1"})
(t/deftest write-spans-serializes-ruby-customization-bytes
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "漢字"
:font-size "16"
:font-weight "400"
:ruby-size "quarter"
:ruby-align "space-between"
:ruby-overhang "none"
:ruby-side "under"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
(t/is (= [2 3 1 1 0 0]
(mapv #(.getUint8 dview %) (range 10 16))))))
(defn- serialized-span
"DataView over the attribute block `write-spans` produces for `span`."
[span]
(let [dview (js/DataView. (js/ArrayBuffer. 256))]
(write-spans 0 dview [(merge {:font-size "16" :font-weight "400"} span)] span-paragraph)
dview))
(t/deftest write-spans-omits-hidden-ruby-bytes
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [{:text "日"
:ruby "にち"
:ruby-hidden true
:font-size "16"
:font-weight "400"}]
paragraph)
(t/is (= 0 (.getInt32 dview 72 true)))
(write-spans 0 dview [{:text "日"
:ruby "にち"
:ruby-hidden false
:font-size "16"
:font-weight "400"}]
paragraph)
(t/is (pos? (.getInt32 dview 72 true)))))
(defn- span-byte
[dview attr]
(.getUint8 dview (get span-attr-offset attr)))
(defn- padding-bytes
[dview]
(mapv #(.getUint8 dview %) (:padding span-attr-offset)))
(t/deftest write-spans-serializes-font-features
(let [dview (serialized-span {:text "日本語" :font-features "vpal"})]
(t/is (= 2 (span-byte dview :font-features)))
(t/is (= 0 (span-byte dview :annotation-clearance)))
(t/is (= [0 0] (padding-bytes dview)))))
(t/deftest write-spans-serializes-annotation-clearance
(let [dview (serialized-span {:text "漢字" :annotation-clearance "auto"})]
(t/is (= 1 (span-byte dview :annotation-clearance)))
(t/is (= [0 0] (padding-bytes dview)))))
(t/deftest write-spans-serializes-ruby-customization
(let [dview (serialized-span {:text "漢字"
:ruby-size "quarter"
:ruby-align "space-between"
:ruby-overhang "none"
:ruby-side "under"})]
(t/is (= [2 3 1 1]
(mapv #(span-byte dview %) [:ruby-size :ruby-align :ruby-overhang :ruby-side])))
(t/is (= [0 0] (padding-bytes dview)))))
(t/deftest write-spans-omits-hidden-ruby
(let [ruby-length #(.getInt32 % (:ruby-length span-attr-offset) true)]
(t/is (= 0 (ruby-length (serialized-span {:text "日" :ruby "にち" :ruby-hidden true}))))
(t/is (pos? (ruby-length (serialized-span {:text "日" :ruby "にち" :ruby-hidden false}))))))
(t/deftest write-spans-serializes-counted-digits-tcy
(let [sentinel (js-obj)
previous (if (.hasOwnProperty wasm/serializers "text-combine-upright")
(unchecked-get wasm/serializers "text-combine-upright")
sentinel)]
(try
(aset wasm/serializers "text-combine-upright"
#js {"none" 0 "all" 1 "digits" 2 "digits2" 3 "digits3" 4})
(let [buffer (js/ArrayBuffer. 256)
dview (js/DataView. buffer)
span {:text "平成31年"
:font-size "16"
:font-weight "400"
:text-combine-upright "digits2"}
paragraph {:font-size "16"
:font-weight "400"
:line-height "1"}]
(write-spans 0 dview [span] paragraph)
;; Byte 5 of the span attr block carries text-combine-upright.
(t/is (= 3 (.getUint8 dview 5))))
(finally
(if (identical? sentinel previous)
(js-delete wasm/serializers "text-combine-upright")
(aset wasm/serializers "text-combine-upright" previous))))))
(let [dview (serialized-span {:text "平成31年" :text-combine-upright "digits2"})]
(t/is (= 3 (span-byte dview :text-combine-upright)))))
(t/deftest ruby-text-participates-in-live-and-reload-fallback-discovery
(let [content {:children

View File

@ -11,6 +11,7 @@
[app.main.ui.ds.controls.select :as select]
[app.main.ui.shapes.text.html-text :as html-text]
[app.main.ui.shapes.text.styles :as text-styles]
[app.main.ui.workspace.sidebar.options.common :refer [radio-selected]]
[app.main.ui.workspace.sidebar.options.menus.text-japanese-layout :as tjl]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
@ -29,11 +30,6 @@
:font-features
:annotation-clearance])
(def ^:private radio-selected @#'tjl/radio-selected)
(def ^:private span-input-value @#'tjl/span-input-value)
(def ^:private span-select-value @#'tjl/span-select-value)
(def ^:private with-mixed-span-option @#'tjl/with-mixed-span-option)
(def ^:private ruby-common-props @#'tjl/ruby-common-props)
(t/deftest japanese-layout-controls-follow-file-or-profile-configuration
(t/is (false? (tjl/japanese-layout-config-enabled? {} {})))
@ -105,13 +101,6 @@
(t/is (str/includes? markup ">日</span>"))
(t/is (not (str/includes? markup "<ruby")))))
(t/deftest advanced-furigana-options-preserve-change-callbacks
(let [on-change identity
on-blur identity
props (ruby-common-props {} on-change on-blur)]
(t/is (identical? on-change (unchecked-get props "onChange")))
(t/is (identical? on-blur (unchecked-get props "onBlur")))))
(t/deftest shape-level-furigana-presentation-only-updates-ruby-spans
(let [shape {:content
{:type "root"
@ -140,15 +129,19 @@
(t/is (= "quarter" (:ruby-size (second spans))))
(t/is (false? (:ruby-hidden (second spans))))))
(t/deftest mixed-japanese-span-values-have-distinct-control-states
(let [options (with-mixed-span-option
(tjl/text-emphasis-options identity)
:multiple)]
(t/is (= "mixed" (span-select-value :multiple "none")))
(t/is (= "mixed" (span-input-value :multiple)))
(t/deftest mixed-span-values-select-a-disabled-mixed-option
(let [options (tjl/with-mixed-span-option (tjl/text-emphasis-options identity) :multiple)]
(t/is (= "mixed" (tjl/span-select-value :multiple "none")))
(t/is (= "none" (tjl/span-select-value nil "none")))
(t/is (= "mixed" (:id (last options))))
(t/is (true? (:disabled (last options))))
(t/is (= "" (radio-selected :multiple "none")))))
(t/is (= options (tjl/with-mixed-span-option options "none"))
"an unmixed value adds no entry")))
(t/deftest mixed-radio-values-select-nothing
(t/is (= "" (radio-selected :multiple "none")))
(t/is (= "none" (radio-selected nil "none")))
(t/is (= "vertical-rl" (radio-selected :vertical-rl))))
(t/deftest whole-text-selection-reports-every-differing-span-value-as-mixed
(let [shape {:content

View File

@ -39,10 +39,8 @@ pub fn render_vertical_text(
}
}
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
text_vertical::layout_from_content(text_content, max_height)
});
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
@ -90,8 +88,7 @@ pub fn render_vertical_text(
let grid_layout = match layout.as_ref() {
Some(layout) => layout,
None => {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
owned_layout = text_vertical::layout_from_content(text_content, max_height);
owned_layout = text_vertical::layout_for_box(text_content, bounds.height());
&owned_layout
}
};
@ -713,7 +710,7 @@ fn paint_text_with_emoji_overlay(
}
if ruby_per_paragraph {
crate::shapes::text_vertical::paint_horizontal_ruby(
crate::shapes::paint_horizontal_ruby(
canvas,
text_content,
para.source_paragraph,

View File

@ -146,8 +146,7 @@ fn vertical_layout_for_shape(
shape: &Shape,
) -> (text_vertical::VerticalLayout, f32) {
let selrect = shape.selrect();
let max_height = text_vertical::wrap_height(text_content, selrect.height());
let layout = text_vertical::layout_from_content(text_content, max_height);
let layout = text_vertical::layout_for_box(text_content, selrect.height());
let origin_x =
text_vertical::block_axis_offset(selrect.width(), layout.width, shape.vertical_align());
(layout, origin_x)

View File

@ -70,10 +70,8 @@ impl<'a> VectorRenderer<'a> {
let bounds = text_content.bounds();
let drop_shadows = shape.drop_shadow_paints();
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
let layout = (!drop_shadows.is_empty() || !strokes.is_empty()).then(|| {
let max_height = text_vertical::wrap_height(text_content, bounds.height());
text_vertical::layout_from_content(text_content, max_height)
});
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
if let Some(layout) = &layout {
for shadow in &drop_shadows {

View File

@ -575,9 +575,7 @@ impl TextContent {
.iter()
.flat_map(|p| p.children())
.any(|span| {
span.warichu
|| !span.ruby.trim().is_empty()
|| span.text_emphasis.mark_char().is_some()
span.warichu || span.has_ruby() || span.text_emphasis.mark_char().is_some()
})
}
@ -798,8 +796,7 @@ impl TextContent {
// arrives selrect-local; the content block is right-anchored.
if self.is_vertical() {
let bounds = self.bounds();
let max_height = super::text_vertical::wrap_height(self, bounds.height());
let layout = super::text_vertical::layout_from_content(self, max_height);
let layout = super::text_vertical::layout_for_box(self, bounds.height());
let cx = point.x
- super::text_vertical::block_axis_offset(
bounds.width(),
@ -1284,15 +1281,14 @@ impl TextContent {
if self.is_vertical() {
match self.grow_type() {
GrowType::AutoWidth => {
let max_height = super::text_vertical::wrap_height(self, selrect.height());
let (width, height) = super::text_vertical::measure_content(self, max_height);
let (width, height) =
super::text_vertical::measure_content(self, selrect.height());
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
self.size.max_width = self.size.width;
}
GrowType::AutoHeight => {
let max_height = super::text_vertical::wrap_height(self, selrect.height());
let (width, _) = super::text_vertical::measure_content(self, max_height);
let (width, _) = super::text_vertical::measure_content(self, selrect.height());
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
self.size.height = selrect.height();
self.size.max_width = self.size.width;
@ -1414,8 +1410,7 @@ impl TextContent {
// Vertical writing: hit-test against the laid-out cells directly
// (absolute coordinates, right-anchored to the selrect).
if self.is_vertical() {
let max_height = super::text_vertical::wrap_height(self, shape.selrect.height());
let layout = super::text_vertical::layout_from_content(self, max_height);
let layout = super::text_vertical::layout_for_box(self, shape.selrect.height());
return super::text_vertical::intersects(
&layout,
&shape.selrect,
@ -1635,29 +1630,6 @@ impl Paragraph {
}
}
/// Capitalize the first letter of each word, preserving all original whitespace.
/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after
/// any non-letter character (whitespace, punctuation, digits, symbols).
#[cfg(test)]
fn capitalize_words(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut capitalize_next = true;
for c in text.chars() {
if c.is_alphabetic() {
if capitalize_next {
result.extend(c.to_uppercase());
} else {
result.push(c);
}
capitalize_next = false;
} else {
result.push(c);
capitalize_next = true;
}
}
result
}
/// Add `text`, pushing every '\t' as a one em wide placeholder.
pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) {
let tab = PlaceholderStyle::new(
@ -1676,28 +1648,6 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size:
}
}
/// Filter control characters below U+0020, preserving tabs and line breaks.
/// Browser-dependent: Firefox drops them, others replace with space.
#[cfg(test)]
fn process_ignored_chars(text: &str, browser: u8) -> String {
text.chars()
.filter_map(|c| {
if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' {
return Some(c);
}
if c < '\u{0020}' {
if browser == Browser::Firefox as u8 {
None
} else {
Some(' ')
}
} else {
Some(c)
}
})
.collect()
}
/// Text after browser filtering and CSS text transformation, plus the source
/// UTF-16 range that produced each transformed Unicode scalar. A single source
/// scalar can produce several output scalars (for example `ß` uppercases to
@ -1742,10 +1692,9 @@ fn apply_text_transform_with_source_ranges(
let source_start = source_utf16;
source_utf16 += source_char.len_utf16();
let processed = if source_char == '\n'
|| source_char == '\r'
|| source_char == '\u{2028}'
|| source_char == '\u{2029}'
// Control characters below U+0020 are filtered (Firefox drops them,
// other browsers show a space); tabs and line breaks are kept.
let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}')
|| source_char >= '\u{0020}'
{
Some(source_char)
@ -1923,6 +1872,38 @@ impl TextSpan {
self.annotation_clearance = clearance;
}
/// Ruby annotation text, without surrounding whitespace.
pub fn ruby_text(&self) -> &str {
self.ruby.trim()
}
pub fn has_ruby(&self) -> bool {
!self.ruby_text().is_empty()
}
pub fn ruby_font_size(&self) -> f32 {
self.font_size * self.ruby_size.scale()
}
/// Warichu needs at least two characters to fill its two sub-lines.
pub fn is_warichu(&self) -> bool {
self.warichu && self.text.chars().count() >= 2
}
/// Automatic clearance stacks emphasis marks outside an over-side ruby.
pub fn stacks_emphasis_outside_ruby(&self) -> bool {
self.annotation_clearance.is_auto() && self.has_ruby() && self.ruby_side == RubySide::Over
}
/// Cross-axis offset of the emphasis marks past a stacked ruby layer.
pub fn emphasis_ruby_offset(&self) -> f32 {
if self.stacks_emphasis_outside_ruby() {
self.ruby_font_size()
} else {
0.0
}
}
pub fn to_style(
&self,
content_bounds: &Rect,
@ -1967,7 +1948,7 @@ impl TextSpan {
};
let annotation_layers = if self.annotation_clearance.is_auto() {
usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none())
usize::from(self.has_ruby()) + usize::from(!self.text_emphasis.is_none())
} else {
0
};
@ -2281,8 +2262,7 @@ pub fn calculate_position_data(
if skip_position_data {
return Vec::new();
}
let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height());
let layout = super::text_vertical::layout_from_content(&text_content, max_height);
let layout = super::text_vertical::layout_for_box(&text_content, shape.selrect.height());
return super::text_vertical::position_data(
&layout,
&shape.selrect,
@ -2338,100 +2318,107 @@ mod tests {
);
}
fn capitalize(text: &str) -> String {
apply_text_transform_with_source_ranges(
text,
Browser::Chrome as u8,
Some(TextTransform::Capitalize),
)
.text
}
fn filter_ignored_chars(text: &str, browser: u8) -> String {
apply_text_transform_with_source_ranges(text, browser, None).text
}
#[test]
fn capitalize_basic_words() {
assert_eq!(capitalize_words("hello world"), "Hello World");
assert_eq!(capitalize("hello world"), "Hello World");
}
#[test]
fn capitalize_preserves_leading_whitespace() {
assert_eq!(capitalize_words(" hello"), " Hello");
assert_eq!(capitalize(" hello"), " Hello");
}
#[test]
fn capitalize_preserves_trailing_whitespace() {
assert_eq!(capitalize_words("hello "), "Hello ");
assert_eq!(capitalize("hello "), "Hello ");
}
#[test]
fn capitalize_preserves_multiple_spaces() {
assert_eq!(capitalize_words("hello world"), "Hello World");
assert_eq!(capitalize("hello world"), "Hello World");
}
#[test]
fn capitalize_whitespace_only() {
assert_eq!(capitalize_words(" "), " ");
assert_eq!(capitalize_words(" "), " ");
assert_eq!(capitalize(" "), " ");
assert_eq!(capitalize(" "), " ");
}
#[test]
fn capitalize_empty_string() {
assert_eq!(capitalize_words(""), "");
assert_eq!(capitalize(""), "");
}
#[test]
fn capitalize_single_char() {
assert_eq!(capitalize_words("a"), "A");
assert_eq!(capitalize("a"), "A");
}
#[test]
fn capitalize_already_uppercase() {
assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD");
assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD");
}
#[test]
fn capitalize_preserves_tabs_and_newlines() {
assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld");
assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld");
assert_eq!(capitalize("hello\tworld"), "Hello\tWorld");
assert_eq!(capitalize("hello\nworld"), "Hello\nWorld");
}
#[test]
fn capitalize_after_punctuation() {
assert_eq!(capitalize_words("(readonly)"), "(Readonly)");
assert_eq!(capitalize_words("hello-world"), "Hello-World");
assert_eq!(capitalize_words("one/two/three"), "One/Two/Three");
assert_eq!(capitalize("(readonly)"), "(Readonly)");
assert_eq!(capitalize("hello-world"), "Hello-World");
assert_eq!(capitalize("one/two/three"), "One/Two/Three");
}
#[test]
fn capitalize_after_digits() {
assert_eq!(capitalize_words("item1name"), "Item1Name");
assert_eq!(capitalize("item1name"), "Item1Name");
}
#[test]
fn process_ignored_chars_preserves_spaces() {
assert_eq!(process_ignored_chars("hello world", 0), "hello world");
fn ignored_chars_preserves_spaces() {
assert_eq!(filter_ignored_chars("hello world", 0), "hello world");
}
#[test]
fn process_ignored_chars_preserves_line_breaks() {
assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld");
assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld");
fn ignored_chars_preserves_line_breaks() {
assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld");
assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld");
}
#[test]
fn process_ignored_chars_preserves_tabs() {
assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld");
fn ignored_chars_preserves_tabs() {
assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld");
assert_eq!(
process_ignored_chars("hello\tworld", Browser::Firefox as u8),
filter_ignored_chars("hello\tworld", Browser::Firefox as u8),
"hello\tworld"
);
}
#[test]
fn process_ignored_chars_replaces_control_chars_chrome() {
fn ignored_chars_replaces_control_chars_chrome() {
// U+0001 (SOH) should become space in non-Firefox
assert_eq!(
process_ignored_chars("a\x01b", Browser::Chrome as u8),
"a b"
);
assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b");
}
#[test]
fn process_ignored_chars_removes_control_chars_firefox() {
assert_eq!(
process_ignored_chars("a\x01b", Browser::Firefox as u8),
"ab"
);
fn ignored_chars_removes_control_chars_firefox() {
assert_eq!(filter_ignored_chars("a\x01b", Browser::Firefox as u8), "ab");
}
fn test_paragraph(texts: &[&str]) -> Paragraph {
@ -2743,290 +2730,4 @@ mod tests {
assert_eq!(transformed.source_utf16_range(1..3), 1..2);
assert_eq!(transformed.source_utf16_range(3..4), 2..3);
}
// apply_text_transform reads the browser from the design state.
fn init_state() {
crate::globals::design_init();
}
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal),
font_size: 16.0,
line_height: 1.0,
letter_spacing,
font_weight: 400,
font_variant_id: Uuid::nil(),
text_decoration: None,
text_transform: None,
text_direction: TextDirection::LTR,
text_orientation: TextOrientation::default(),
text_combine_upright: TextCombineUpright::default(),
text_emphasis: TextEmphasis::default(),
ruby: String::default(),
warichu: false,
font_features: FontFeatures::default(),
annotation_clearance: AnnotationClearance::default(),
ruby_size: RubySize::default(),
ruby_align: RubyAlign::default(),
ruby_overhang: RubyOverhang::default(),
ruby_side: RubySide::default(),
paragraph_position: u32::MAX,
span_position: u32::MAX,
fills: vec![],
}
}
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
Paragraph::new(
TextAlign::default(),
TextDirection::LTR,
None,
None,
1.0,
letter_spacing,
spans,
)
}
#[test]
fn layout_span_texts_applies_kinsoku() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(
texts,
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
);
assert!(!map.is_empty());
assert_eq!(map.to_original(3), 2);
}
#[test]
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_respects_text_transform() {
init_state();
let mut span = make_span("hello。", 0.0);
span.text_transform = Some(TextTransform::Uppercase);
let paragraph = make_paragraph(vec![span], 0.0);
let (texts, _) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
}
#[test]
fn layout_span_texts_identity_map_for_plain_text() {
init_state();
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["helloworld".to_string()]);
assert!(map.is_empty());
assert_eq!(map.to_original(5), 5);
assert_eq!(map.to_shifted(5), 5);
}
#[test]
fn horizontal_ruby_is_atomic() {
init_state();
let mut group = make_span("日本", 0.0);
group.ruby = "にほん".to_string();
let paragraph = make_paragraph(vec![group], 0.0);
assert_eq!(
paragraph.layout_span_texts().0,
vec!["日\u{2060}本".to_string()]
);
}
#[test]
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
assert_eq!(
crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0),
88.0
);
}
#[test]
fn horizontal_warichu_collapses_to_one_builder_position() {
init_state();
let mut warichu = make_span("割注入り", 0.0);
warichu.warichu = true;
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
let ranges = horizontal_span_ranges(&paragraph);
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(&paragraph, 2), 0);
assert_eq!(horizontal_source_to_builder(&paragraph, 4), 3);
assert_eq!(horizontal_source_to_builder(&paragraph, 5), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 1), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 2), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 3), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 4), 5);
assert_eq!(
horizontal_normal_selection_ranges(&paragraph, 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(&paragraph, 1), 2);
assert_eq!(horizontal_builder_to_source(&paragraph, 2), 1);
assert_eq!(horizontal_source_to_builder(&paragraph, 2), 3);
assert_eq!(horizontal_builder_to_source(&paragraph, 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(&paragraph, &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(&paragraph)[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(&paragraph);
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
);
}
}

View File

@ -1,14 +1,18 @@
use super::text::{add_text_with_tabs, Paragraph, TextSpan};
use super::text::{add_text_with_tabs, Paragraph, TextContent, TextSpan};
use super::text_vertical::{
distribute_ruby_tops, shape_segment_with_fallbacks, single_glyph_blob, span_font_families,
};
use crate::globals::get_resources;
use crate::math::Point;
use crate::shapes::kinsoku;
use crate::utils::get_font_collection;
use crate::shapes::{kinsoku, merge_fills};
use crate::utils::{get_fallback_fonts, get_font_collection};
use skia_safe::{
self as skia,
textlayout::{
ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle,
RectWidthStyle, TextBaseline,
},
Contains,
Canvas, Contains, Font, FontMgr, GlyphId,
};
pub const WARICHU_FONT_SCALE: f32 = 0.5;
@ -32,7 +36,7 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec<String>, kinsoku:
let ruby_breaks: Vec<Option<Vec<usize>>> = paragraph
.children()
.iter()
.map(|span| (!span.ruby.trim().is_empty()).then(Vec::new))
.map(|span| span.has_ruby().then(Vec::new))
.collect();
if let Some((shifted, map)) =
kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks)
@ -53,15 +57,9 @@ pub(crate) fn add_horizontal_span(
text_style: &skia::textlayout::TextStyle,
fonts: &skia::textlayout::FontCollection,
) {
if span.warichu && span.text.chars().count() >= 2 {
if span.is_warichu() {
let text = span.apply_text_transform();
let split = super::text_vertical::warichu_split_chars(&text);
let split_byte = text
.char_indices()
.nth(split)
.map(|(index, _)| index)
.unwrap_or(text.len());
let (first, second) = text.split_at(split_byte);
let (first, second) = warichu_text_lines(&text);
let mut mini_style = text_style.clone();
mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
mini_style.set_height(1.0);
@ -133,7 +131,7 @@ pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpa
let shifted_end = shifted_cursor;
let source_start = offset_map.to_original(shifted_start);
let source_end = offset_map.to_original(shifted_end);
let warichu = span.warichu && span.text.chars().count() >= 2;
let warichu = span.is_warichu();
let builder_start = builder_cursor;
let style_anchor_start = if warichu {
builder_byte_cursor + '\u{FFFC}'.len_utf8()
@ -254,7 +252,7 @@ fn horizontal_warichu_placeholders(
let mut placeholders = laid_out.get_rects_for_placeholders().into_iter();
let mut result = Vec::new();
for (index, span) in paragraph.children().iter().enumerate() {
if span.warichu && span.text.chars().count() >= 2 {
if span.is_warichu() {
if let Some(textbox) = placeholders.next() {
result.push((index, textbox.rect));
}
@ -295,7 +293,7 @@ pub(crate) fn horizontal_warichu_hit_test(
if !rect.contains(&point) {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let split = warichu_split_chars(&span.apply_text_transform());
let total = span.text.chars().count();
let second = point.y >= rect.top() + rect.height() / 2.0;
let (line_start, line_len) = if second {
@ -319,7 +317,7 @@ pub(crate) fn horizontal_warichu_caret_rect(
if source_offset < source_start || source_offset > source_end {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let split = warichu_split_chars(&span.apply_text_transform());
let local = source_offset - source_start;
let total = source_end - source_start;
let (line_start, line_len, top) = if local >= split {
@ -352,7 +350,7 @@ pub(crate) fn horizontal_warichu_range_rects(
if selected_start >= selected_end {
continue;
}
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform());
let split = warichu_split_chars(&span.apply_text_transform());
for (line_start, line_end, top) in [
(span_start, span_start + split, rect.top()),
(
@ -392,7 +390,7 @@ pub(crate) fn horizontal_normal_selection_ranges(
let span_end = span_start + span.text.chars().count();
let selected_start = source_start.max(span_start);
let selected_end = source_end.min(span_end);
let warichu = span.warichu && span.text.chars().count() >= 2;
let warichu = span.is_warichu();
span_start = span_end;
if warichu || selected_start >= selected_end {
return None;
@ -405,8 +403,39 @@ pub(crate) fn horizontal_normal_selection_ranges(
.collect()
}
fn warichu_text_lines(text: &str) -> (&str, &str) {
let split = super::text_vertical::warichu_split_chars(text);
/// Char index where a warichu run splits into its two sub-lines: the
/// balanced midpoint (first line longer), nudged so the second sub-line
/// does not start with a line-start-prohibited character and the first
/// does not end with a line-end-prohibited one. Nudging forward pulls the
/// offending mark up into the first sub-line (jlreq); backward is the
/// fallback, and the midpoint stands when no split satisfies kinsoku.
pub(crate) fn warichu_split_chars(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mid = n.div_ceil(2);
let valid = |split: usize| {
split >= 1
&& split < n
&& !kinsoku::forbidden_at_line_start(chars[split])
&& !kinsoku::forbidden_at_line_end(chars[split - 1])
};
if valid(mid) {
return mid;
}
for distance in 1..n {
if valid(mid + distance) {
return mid + distance;
}
if mid > distance && valid(mid - distance) {
return mid - distance;
}
}
mid
}
/// The two warichu sub-lines of `text`, split at `warichu_split_chars`.
pub(crate) fn warichu_text_lines(text: &str) -> (&str, &str) {
let split = warichu_split_chars(text);
let split_byte = text
.char_indices()
.nth(split)
@ -415,7 +444,7 @@ fn warichu_text_lines(text: &str) -> (&str, &str) {
text.split_at(split_byte)
}
fn warichu_mini_paragraph(
fn mini_paragraph(
text: &str,
style: &skia::textlayout::TextStyle,
width: f32,
@ -448,17 +477,7 @@ pub(crate) fn paint_horizontal_warichu(
let Some(span) = paragraph.children().get(range.span) else {
continue;
};
// Indexed style metrics expose builder UTF-8 byte positions even
// though glyph/line ranges use UTF-16 offsets.
let style_anchor = range.style_anchor_start
..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8();
let style = laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(style_anchor.clone())
.into_iter()
.next()
.map(|(_, metric)| metric.text_style.clone())
});
let Some(mut style) = style else {
let Some(mut style) = horizontal_span_style(laid_out, range) else {
continue;
};
style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
@ -467,8 +486,8 @@ pub(crate) fn paint_horizontal_warichu(
style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
let transformed = span.apply_text_transform();
let (first, second) = warichu_text_lines(&transformed);
let first_para = warichu_mini_paragraph(first, &style, rect.width());
let second_para = warichu_mini_paragraph(second, &style, rect.width());
let first_para = mini_paragraph(first, &style, rect.width());
let second_para = mini_paragraph(second, &style, rect.width());
let half_height = rect.height() / 2.0;
first_para.paint(canvas, (x + rect.left(), y + rect.top()));
second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height));
@ -546,8 +565,8 @@ pub(crate) fn horizontal_span_style(
laid_out: &skia::textlayout::Paragraph,
range: &HorizontalSpanRange,
) -> Option<skia::textlayout::TextStyle> {
// Indexed style metrics use builder UTF-8 byte positions (the same
// convention used by the warichu style anchor above).
// Indexed style metrics use builder UTF-8 byte positions, unlike the
// UTF-16 offsets of glyph and line ranges.
let anchor = range.style_anchor_start..range.style_anchor_start + 1;
laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(anchor.clone())
@ -595,7 +614,7 @@ pub(crate) fn paint_horizontal_emphasis(
style.set_height(1.0);
style.set_height_override(true);
style.set_letter_spacing(0.0);
let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX);
let mark_paragraph = mini_paragraph(&mark.to_string(), &style, f32::MAX);
let mark_ink = mark_paragraph
.get_rects_for_range(
0..mark.len_utf16(),
@ -621,22 +640,204 @@ pub(crate) fn paint_horizontal_emphasis(
.filter(|placement| placement.span == range.span && placement.mark == mark)
{
let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
let ruby_offset = if span.annotation_clearance.is_auto()
&& !span.ruby.trim().is_empty()
&& span.ruby_side == RubySide::Over
{
span.font_size * span.ruby_size.scale()
} else {
0.0
};
let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size)
- mark_ink_bottom
- ruby_offset;
- span.emphasis_ruby_offset();
mark_paragraph.paint(canvas, (mark_x, mark_y));
}
}
}
/// Split `total` glyphs across segments proportionally to their extents
/// (rounded per segment, remainder to the last) so no glyph is dropped.
fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec<usize> {
let mut counts = vec![0usize; extents.len()];
if extents.is_empty() || total == 0 {
return counts;
}
let sum: f32 = extents.iter().sum();
if sum <= 0.0 {
counts[extents.len() - 1] = total;
return counts;
}
let mut assigned = 0usize;
let last = extents.len() - 1;
for (index, extent) in extents.iter().enumerate() {
let count = if index == last {
total - assigned
} else {
(((extent / sum) * total as f32).round() as usize).min(total - assigned)
};
counts[index] = count;
assigned += count;
}
counts
}
fn next_horizontal_ruby_range(
offset_map: &kinsoku::OffsetMap,
utf16_cursor: &mut usize,
text: &str,
) -> std::ops::Range<usize> {
let start = *utf16_cursor;
*utf16_cursor += text.encode_utf16().count();
offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor)
}
/// Baseline adjustment that attaches a glyph's visible ink edge to its base
/// strip. Font-wide ascender/descender metrics include leading which makes
/// horizontal ruby visibly detached for many Japanese faces.
fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 {
let mut bounds = [skia::Rect::default()];
font.get_bounds(&[glyph], &mut bounds, None);
let bound = bounds[0];
if bound.right > bound.left && bound.bottom > bound.top {
if over {
bound.bottom
} else {
bound.top
}
} else {
fallback
}
}
/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only:
/// base rects come from the already laid-out skparagraph
/// (`get_rects_for_range`), the annotation is shaped at half the span size
/// and distributed over each line's base rect with the same jlreq
/// distribution the vertical path uses (`distribute_ruby_tops` along the
/// horizontal flow). Lines are not reflowed to reserve an annotation band;
/// the ruby draws in the natural leading above the base line.
pub(crate) fn paint_horizontal_ruby(
canvas: &Canvas,
text_content: &TextContent,
paragraph_index: usize,
laid_out: &skia::textlayout::Paragraph,
x: f32,
y: f32,
) {
let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else {
return;
};
if !paragraph.children().iter().any(TextSpan::has_ruby) {
return;
}
let font_provider = get_resources().fonts.font_provider();
let fallback_mgr = FontMgr::from(font_provider.clone());
let fallback_families: Vec<String> = get_fallback_fonts().iter().cloned().collect();
let bounds = text_content.bounds();
let (_, offset_map) = paragraph.layout_span_texts();
let mut utf16_cursor = 0usize;
for span in paragraph.children() {
let span_text = span.apply_text_transform();
let span_range = next_horizontal_ruby_range(&offset_map, &mut utf16_cursor, &span_text);
let ruby_text = span.ruby_text();
if ruby_text.is_empty() || span_range.is_empty() {
continue;
}
let ruby_font_size = span.ruby_font_size();
let families = span_font_families(span, &fallback_families);
let paint = merge_fills(&span.fills, bounds);
let rects = laid_out.get_rects_for_range(
span_range,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let shaped = shape_segment_with_fallbacks(
ruby_text,
ruby_font_size,
&families,
font_provider,
false,
span.font_features,
&fallback_mgr,
);
let glyphs: Vec<(usize, usize, f32)> = shaped
.iter()
.enumerate()
.flat_map(|(run_index, run)| {
(0..run.glyphs.len()).map(move |glyph| {
(
run_index,
glyph,
run.advances.get(glyph).copied().unwrap_or(ruby_font_size),
)
})
})
.collect();
let extents: Vec<f32> = rects.iter().map(|rect| rect.rect.width()).collect();
let counts = split_counts_by_extent(&extents, glyphs.len());
let mut assigned = 0usize;
for (rect_box, count) in rects.iter().zip(counts) {
if count == 0 {
continue;
}
let slice = &glyphs[assigned..assigned + count];
assigned += count;
let advance = slice
.iter()
.map(|(_, _, advance)| *advance)
.fold(0.0f32, f32::max)
.max(1.0);
// Horizontal SkParagraph has already fixed the base geometry.
// When overhang is prohibited, fit the annotation strip to
// that geometry instead of allowing either end to escape it.
let glyph_scale = if span.ruby_overhang == RubyOverhang::None {
(rect_box.rect.width() / (advance * count as f32)).min(1.0)
} else {
1.0
};
let layout_advance = advance * glyph_scale;
let lefts = distribute_ruby_tops(
rect_box.rect.left(),
rect_box.rect.width(),
count,
layout_advance,
span.ruby_align,
span.ruby_overhang,
);
for ((run_index, glyph, glyph_advance), left) in slice.iter().zip(lefts) {
let run = &shaped[*run_index];
let (_, metrics) = run.font.metrics();
let baseline = match span.ruby_side {
RubySide::Over => {
y + horizontal_annotation_over_top(rect_box.rect, span.font_size)
- horizontal_ruby_ink_edge(
&run.font,
run.glyphs[*glyph],
metrics.descent,
true,
)
}
RubySide::Under => {
y + rect_box.rect.bottom()
- horizontal_ruby_ink_edge(
&run.font,
run.glyphs[*glyph],
metrics.ascent,
false,
)
}
};
if let Some(blob) = single_glyph_blob(&run.font, run.glyphs[*glyph]) {
let gx = x + left + (layout_advance - glyph_advance * glyph_scale) / 2.0;
if glyph_scale < 1.0 {
canvas.save();
canvas.translate((gx, baseline));
canvas.scale((glyph_scale, 1.0));
canvas.draw_text_blob(&blob, (0.0, 0.0), &paint);
canvas.restore();
} else {
canvas.draw_text_blob(&blob, (gx, baseline), &paint);
}
}
}
}
}
}
/// Block flow direction of a paragraph. Horizontal is the skparagraph
/// path; vertical-rl lays out columns top->bottom advancing right->left
/// through the custom vertical pass.
@ -783,3 +984,337 @@ impl AnnotationClearance {
matches!(self, AnnotationClearance::Auto)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextTransform};
use crate::Uuid;
#[test]
fn warichu_split_balances_and_respects_kinsoku() {
// Balanced midpoint when nothing forbids it, first line longer.
assert_eq!(warichu_split_chars("あいうえおか"), 3);
assert_eq!(warichu_split_chars("あいうえお"), 3);
// A comma at the midpoint may end the first sub-line...
assert_eq!(warichu_split_chars("あい、うえ"), 3);
// ...but must not start the second one: the split moves forward.
assert_eq!(warichu_split_chars("あいう、えお"), 4);
// An opening bracket must not end the first sub-line.
assert_eq!(warichu_split_chars("あい「うえお"), 4);
// Pathological all-forbidden text keeps the midpoint.
assert_eq!(warichu_split_chars("、、、、"), 2);
}
#[test]
fn horizontal_ruby_ranges_use_utf16_across_spans() {
let offset_map = crate::shapes::kinsoku::OffsetMap::default();
let mut cursor = 0;
assert_eq!(
next_horizontal_ruby_range(&offset_map, &mut cursor, "𠀀"),
0..2
);
assert_eq!(
next_horizontal_ruby_range(&offset_map, &mut cursor, "漢"),
2..3
);
}
#[test]
fn split_counts_by_extent_drops_no_glyph() {
assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]);
assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]);
assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]);
assert_eq!(
split_counts_by_extent(&[1.0, 1.0, 1.0], 2)
.iter()
.sum::<usize>(),
2
);
assert!(split_counts_by_extent(&[], 3).is_empty());
}
// apply_text_transform reads the browser from the design state.
fn init_state() {
crate::globals::design_init();
}
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
font_size: 16.0,
line_height: 1.0,
letter_spacing,
font_weight: 400,
font_variant_id: Uuid::nil(),
text_decoration: None,
text_transform: None,
text_direction: TextDirection::LTR,
text_orientation: TextOrientation::default(),
text_combine_upright: TextCombineUpright::default(),
text_emphasis: TextEmphasis::default(),
ruby: String::default(),
warichu: false,
font_features: FontFeatures::default(),
annotation_clearance: AnnotationClearance::default(),
ruby_size: RubySize::default(),
ruby_align: RubyAlign::default(),
ruby_overhang: RubyOverhang::default(),
ruby_side: RubySide::default(),
paragraph_position: u32::MAX,
span_position: u32::MAX,
fills: vec![],
}
}
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
Paragraph::new(
TextAlign::default(),
TextDirection::LTR,
None,
None,
1.0,
letter_spacing,
spans,
)
}
#[test]
fn layout_span_texts_applies_kinsoku() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(
texts,
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
);
assert!(!map.is_empty());
assert_eq!(map.to_original(3), 2);
}
#[test]
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_respects_text_transform() {
init_state();
let mut span = make_span("hello。", 0.0);
span.text_transform = Some(TextTransform::Uppercase);
let paragraph = make_paragraph(vec![span], 0.0);
let (texts, _) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
}
#[test]
fn layout_span_texts_identity_map_for_plain_text() {
init_state();
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["helloworld".to_string()]);
assert!(map.is_empty());
assert_eq!(map.to_original(5), 5);
assert_eq!(map.to_shifted(5), 5);
}
#[test]
fn horizontal_ruby_is_atomic() {
init_state();
let mut group = make_span("日本", 0.0);
group.ruby = "にほん".to_string();
let paragraph = make_paragraph(vec![group], 0.0);
assert_eq!(
paragraph.layout_span_texts().0,
vec!["日\u{2060}本".to_string()]
);
}
#[test]
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
assert_eq!(horizontal_annotation_over_top(rect, 56.0), 88.0);
}
#[test]
fn horizontal_warichu_collapses_to_one_builder_position() {
init_state();
let mut warichu = make_span("割注入り", 0.0);
warichu.warichu = true;
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
let ranges = horizontal_span_ranges(&paragraph);
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(&paragraph, 2), 0);
assert_eq!(horizontal_source_to_builder(&paragraph, 4), 3);
assert_eq!(horizontal_source_to_builder(&paragraph, 5), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 1), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 2), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 3), 4);
assert_eq!(horizontal_builder_to_source(&paragraph, 4), 5);
assert_eq!(
horizontal_normal_selection_ranges(&paragraph, 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(&paragraph, 1), 2);
assert_eq!(horizontal_builder_to_source(&paragraph, 2), 1);
assert_eq!(horizontal_source_to_builder(&paragraph, 2), 3);
assert_eq!(horizontal_builder_to_source(&paragraph, 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(&paragraph, &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(&paragraph)[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(&paragraph);
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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());
}
}

View 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);
}
}

View 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};

View 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());
}
}

View 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,
);
}
}
}

View 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"
);
}
}
}

View 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);
}
}

View 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)
}