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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -575,9 +575,7 @@ impl TextContent {
.iter() .iter()
.flat_map(|p| p.children()) .flat_map(|p| p.children())
.any(|span| { .any(|span| {
span.warichu span.warichu || span.has_ruby() || span.text_emphasis.mark_char().is_some()
|| !span.ruby.trim().is_empty()
|| span.text_emphasis.mark_char().is_some()
}) })
} }
@ -798,8 +796,7 @@ impl TextContent {
// arrives selrect-local; the content block is right-anchored. // arrives selrect-local; the content block is right-anchored.
if self.is_vertical() { if self.is_vertical() {
let bounds = self.bounds(); let bounds = self.bounds();
let max_height = super::text_vertical::wrap_height(self, bounds.height()); let layout = super::text_vertical::layout_for_box(self, bounds.height());
let layout = super::text_vertical::layout_from_content(self, max_height);
let cx = point.x let cx = point.x
- super::text_vertical::block_axis_offset( - super::text_vertical::block_axis_offset(
bounds.width(), bounds.width(),
@ -1284,15 +1281,14 @@ impl TextContent {
if self.is_vertical() { if self.is_vertical() {
match self.grow_type() { match self.grow_type() {
GrowType::AutoWidth => { GrowType::AutoWidth => {
let max_height = super::text_vertical::wrap_height(self, selrect.height()); let (width, height) =
let (width, height) = super::text_vertical::measure_content(self, max_height); super::text_vertical::measure_content(self, selrect.height());
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
self.size.max_width = self.size.width; self.size.max_width = self.size.width;
} }
GrowType::AutoHeight => { GrowType::AutoHeight => {
let max_height = super::text_vertical::wrap_height(self, selrect.height()); let (width, _) = super::text_vertical::measure_content(self, selrect.height());
let (width, _) = super::text_vertical::measure_content(self, max_height);
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
self.size.height = selrect.height(); self.size.height = selrect.height();
self.size.max_width = self.size.width; self.size.max_width = self.size.width;
@ -1414,8 +1410,7 @@ impl TextContent {
// Vertical writing: hit-test against the laid-out cells directly // Vertical writing: hit-test against the laid-out cells directly
// (absolute coordinates, right-anchored to the selrect). // (absolute coordinates, right-anchored to the selrect).
if self.is_vertical() { if self.is_vertical() {
let max_height = super::text_vertical::wrap_height(self, shape.selrect.height()); let layout = super::text_vertical::layout_for_box(self, shape.selrect.height());
let layout = super::text_vertical::layout_from_content(self, max_height);
return super::text_vertical::intersects( return super::text_vertical::intersects(
&layout, &layout,
&shape.selrect, &shape.selrect,
@ -1635,29 +1630,6 @@ impl Paragraph {
} }
} }
/// Capitalize the first letter of each word, preserving all original whitespace.
/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after
/// any non-letter character (whitespace, punctuation, digits, symbols).
#[cfg(test)]
fn capitalize_words(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut capitalize_next = true;
for c in text.chars() {
if c.is_alphabetic() {
if capitalize_next {
result.extend(c.to_uppercase());
} else {
result.push(c);
}
capitalize_next = false;
} else {
result.push(c);
capitalize_next = true;
}
}
result
}
/// Add `text`, pushing every '\t' as a one em wide placeholder. /// Add `text`, pushing every '\t' as a one em wide placeholder.
pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) {
let tab = PlaceholderStyle::new( let tab = PlaceholderStyle::new(
@ -1676,28 +1648,6 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size:
} }
} }
/// Filter control characters below U+0020, preserving tabs and line breaks.
/// Browser-dependent: Firefox drops them, others replace with space.
#[cfg(test)]
fn process_ignored_chars(text: &str, browser: u8) -> String {
text.chars()
.filter_map(|c| {
if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' {
return Some(c);
}
if c < '\u{0020}' {
if browser == Browser::Firefox as u8 {
None
} else {
Some(' ')
}
} else {
Some(c)
}
})
.collect()
}
/// Text after browser filtering and CSS text transformation, plus the source /// Text after browser filtering and CSS text transformation, plus the source
/// UTF-16 range that produced each transformed Unicode scalar. A single source /// UTF-16 range that produced each transformed Unicode scalar. A single source
/// scalar can produce several output scalars (for example `ß` uppercases to /// scalar can produce several output scalars (for example `ß` uppercases to
@ -1742,10 +1692,9 @@ fn apply_text_transform_with_source_ranges(
let source_start = source_utf16; let source_start = source_utf16;
source_utf16 += source_char.len_utf16(); source_utf16 += source_char.len_utf16();
let processed = if source_char == '\n' // Control characters below U+0020 are filtered (Firefox drops them,
|| source_char == '\r' // other browsers show a space); tabs and line breaks are kept.
|| source_char == '\u{2028}' let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}')
|| source_char == '\u{2029}'
|| source_char >= '\u{0020}' || source_char >= '\u{0020}'
{ {
Some(source_char) Some(source_char)
@ -1923,6 +1872,38 @@ impl TextSpan {
self.annotation_clearance = clearance; self.annotation_clearance = clearance;
} }
/// Ruby annotation text, without surrounding whitespace.
pub fn ruby_text(&self) -> &str {
self.ruby.trim()
}
pub fn has_ruby(&self) -> bool {
!self.ruby_text().is_empty()
}
pub fn ruby_font_size(&self) -> f32 {
self.font_size * self.ruby_size.scale()
}
/// Warichu needs at least two characters to fill its two sub-lines.
pub fn is_warichu(&self) -> bool {
self.warichu && self.text.chars().count() >= 2
}
/// Automatic clearance stacks emphasis marks outside an over-side ruby.
pub fn stacks_emphasis_outside_ruby(&self) -> bool {
self.annotation_clearance.is_auto() && self.has_ruby() && self.ruby_side == RubySide::Over
}
/// Cross-axis offset of the emphasis marks past a stacked ruby layer.
pub fn emphasis_ruby_offset(&self) -> f32 {
if self.stacks_emphasis_outside_ruby() {
self.ruby_font_size()
} else {
0.0
}
}
pub fn to_style( pub fn to_style(
&self, &self,
content_bounds: &Rect, content_bounds: &Rect,
@ -1967,7 +1948,7 @@ impl TextSpan {
}; };
let annotation_layers = if self.annotation_clearance.is_auto() { let annotation_layers = if self.annotation_clearance.is_auto() {
usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none()) usize::from(self.has_ruby()) + usize::from(!self.text_emphasis.is_none())
} else { } else {
0 0
}; };
@ -2281,8 +2262,7 @@ pub fn calculate_position_data(
if skip_position_data { if skip_position_data {
return Vec::new(); return Vec::new();
} }
let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height()); let layout = super::text_vertical::layout_for_box(&text_content, shape.selrect.height());
let layout = super::text_vertical::layout_from_content(&text_content, max_height);
return super::text_vertical::position_data( return super::text_vertical::position_data(
&layout, &layout,
&shape.selrect, &shape.selrect,
@ -2338,100 +2318,107 @@ mod tests {
); );
} }
fn capitalize(text: &str) -> String {
apply_text_transform_with_source_ranges(
text,
Browser::Chrome as u8,
Some(TextTransform::Capitalize),
)
.text
}
fn filter_ignored_chars(text: &str, browser: u8) -> String {
apply_text_transform_with_source_ranges(text, browser, None).text
}
#[test] #[test]
fn capitalize_basic_words() { fn capitalize_basic_words() {
assert_eq!(capitalize_words("hello world"), "Hello World"); assert_eq!(capitalize("hello world"), "Hello World");
} }
#[test] #[test]
fn capitalize_preserves_leading_whitespace() { fn capitalize_preserves_leading_whitespace() {
assert_eq!(capitalize_words(" hello"), " Hello"); assert_eq!(capitalize(" hello"), " Hello");
} }
#[test] #[test]
fn capitalize_preserves_trailing_whitespace() { fn capitalize_preserves_trailing_whitespace() {
assert_eq!(capitalize_words("hello "), "Hello "); assert_eq!(capitalize("hello "), "Hello ");
} }
#[test] #[test]
fn capitalize_preserves_multiple_spaces() { fn capitalize_preserves_multiple_spaces() {
assert_eq!(capitalize_words("hello world"), "Hello World"); assert_eq!(capitalize("hello world"), "Hello World");
} }
#[test] #[test]
fn capitalize_whitespace_only() { fn capitalize_whitespace_only() {
assert_eq!(capitalize_words(" "), " "); assert_eq!(capitalize(" "), " ");
assert_eq!(capitalize_words(" "), " "); assert_eq!(capitalize(" "), " ");
} }
#[test] #[test]
fn capitalize_empty_string() { fn capitalize_empty_string() {
assert_eq!(capitalize_words(""), ""); assert_eq!(capitalize(""), "");
} }
#[test] #[test]
fn capitalize_single_char() { fn capitalize_single_char() {
assert_eq!(capitalize_words("a"), "A"); assert_eq!(capitalize("a"), "A");
} }
#[test] #[test]
fn capitalize_already_uppercase() { fn capitalize_already_uppercase() {
assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD"); assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD");
} }
#[test] #[test]
fn capitalize_preserves_tabs_and_newlines() { fn capitalize_preserves_tabs_and_newlines() {
assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld"); assert_eq!(capitalize("hello\tworld"), "Hello\tWorld");
assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld"); assert_eq!(capitalize("hello\nworld"), "Hello\nWorld");
} }
#[test] #[test]
fn capitalize_after_punctuation() { fn capitalize_after_punctuation() {
assert_eq!(capitalize_words("(readonly)"), "(Readonly)"); assert_eq!(capitalize("(readonly)"), "(Readonly)");
assert_eq!(capitalize_words("hello-world"), "Hello-World"); assert_eq!(capitalize("hello-world"), "Hello-World");
assert_eq!(capitalize_words("one/two/three"), "One/Two/Three"); assert_eq!(capitalize("one/two/three"), "One/Two/Three");
} }
#[test] #[test]
fn capitalize_after_digits() { fn capitalize_after_digits() {
assert_eq!(capitalize_words("item1name"), "Item1Name"); assert_eq!(capitalize("item1name"), "Item1Name");
} }
#[test] #[test]
fn process_ignored_chars_preserves_spaces() { fn ignored_chars_preserves_spaces() {
assert_eq!(process_ignored_chars("hello world", 0), "hello world"); assert_eq!(filter_ignored_chars("hello world", 0), "hello world");
} }
#[test] #[test]
fn process_ignored_chars_preserves_line_breaks() { fn ignored_chars_preserves_line_breaks() {
assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld"); assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld");
assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld");
} }
#[test] #[test]
fn process_ignored_chars_preserves_tabs() { fn ignored_chars_preserves_tabs() {
assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld");
assert_eq!( assert_eq!(
process_ignored_chars("hello\tworld", Browser::Firefox as u8), filter_ignored_chars("hello\tworld", Browser::Firefox as u8),
"hello\tworld" "hello\tworld"
); );
} }
#[test] #[test]
fn process_ignored_chars_replaces_control_chars_chrome() { fn ignored_chars_replaces_control_chars_chrome() {
// U+0001 (SOH) should become space in non-Firefox // U+0001 (SOH) should become space in non-Firefox
assert_eq!( assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b");
process_ignored_chars("a\x01b", Browser::Chrome as u8),
"a b"
);
} }
#[test] #[test]
fn process_ignored_chars_removes_control_chars_firefox() { fn ignored_chars_removes_control_chars_firefox() {
assert_eq!( assert_eq!(filter_ignored_chars("a\x01b", Browser::Firefox as u8), "ab");
process_ignored_chars("a\x01b", Browser::Firefox as u8),
"ab"
);
} }
fn test_paragraph(texts: &[&str]) -> Paragraph { fn test_paragraph(texts: &[&str]) -> Paragraph {
@ -2743,290 +2730,4 @@ mod tests {
assert_eq!(transformed.source_utf16_range(1..3), 1..2); assert_eq!(transformed.source_utf16_range(1..3), 1..2);
assert_eq!(transformed.source_utf16_range(3..4), 2..3); assert_eq!(transformed.source_utf16_range(3..4), 2..3);
} }
// apply_text_transform reads the browser from the design state.
fn init_state() {
crate::globals::design_init();
}
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal),
font_size: 16.0,
line_height: 1.0,
letter_spacing,
font_weight: 400,
font_variant_id: Uuid::nil(),
text_decoration: None,
text_transform: None,
text_direction: TextDirection::LTR,
text_orientation: TextOrientation::default(),
text_combine_upright: TextCombineUpright::default(),
text_emphasis: TextEmphasis::default(),
ruby: String::default(),
warichu: false,
font_features: FontFeatures::default(),
annotation_clearance: AnnotationClearance::default(),
ruby_size: RubySize::default(),
ruby_align: RubyAlign::default(),
ruby_overhang: RubyOverhang::default(),
ruby_side: RubySide::default(),
paragraph_position: u32::MAX,
span_position: u32::MAX,
fills: vec![],
}
}
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
Paragraph::new(
TextAlign::default(),
TextDirection::LTR,
None,
None,
1.0,
letter_spacing,
spans,
)
}
#[test]
fn layout_span_texts_applies_kinsoku() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(
texts,
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
);
assert!(!map.is_empty());
assert_eq!(map.to_original(3), 2);
}
#[test]
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_respects_text_transform() {
init_state();
let mut span = make_span("hello。", 0.0);
span.text_transform = Some(TextTransform::Uppercase);
let paragraph = make_paragraph(vec![span], 0.0);
let (texts, _) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
}
#[test]
fn layout_span_texts_identity_map_for_plain_text() {
init_state();
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["helloworld".to_string()]);
assert!(map.is_empty());
assert_eq!(map.to_original(5), 5);
assert_eq!(map.to_shifted(5), 5);
}
#[test]
fn horizontal_ruby_is_atomic() {
init_state();
let mut group = make_span("日本", 0.0);
group.ruby = "にほん".to_string();
let paragraph = make_paragraph(vec![group], 0.0);
assert_eq!(
paragraph.layout_span_texts().0,
vec!["日\u{2060}本".to_string()]
);
}
#[test]
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
assert_eq!(
crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0),
88.0
);
}
#[test]
fn horizontal_warichu_collapses_to_one_builder_position() {
init_state();
let mut warichu = make_span("割注入り", 0.0);
warichu.warichu = true;
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
let ranges = horizontal_span_ranges(&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::math::Point;
use crate::shapes::kinsoku; use crate::shapes::{kinsoku, merge_fills};
use crate::utils::get_font_collection; use crate::utils::{get_fallback_fonts, get_font_collection};
use skia_safe::{ use skia_safe::{
self as skia, self as skia,
textlayout::{ textlayout::{
ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, ParagraphBuilder, ParagraphStyle, PlaceholderAlignment, PlaceholderStyle, RectHeightStyle,
RectWidthStyle, TextBaseline, RectWidthStyle, TextBaseline,
}, },
Contains, Canvas, Contains, Font, FontMgr, GlyphId,
}; };
pub const WARICHU_FONT_SCALE: f32 = 0.5; pub const WARICHU_FONT_SCALE: f32 = 0.5;
@ -32,7 +36,7 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec<String>, kinsoku:
let ruby_breaks: Vec<Option<Vec<usize>>> = paragraph let ruby_breaks: Vec<Option<Vec<usize>>> = paragraph
.children() .children()
.iter() .iter()
.map(|span| (!span.ruby.trim().is_empty()).then(Vec::new)) .map(|span| span.has_ruby().then(Vec::new))
.collect(); .collect();
if let Some((shifted, map)) = if let Some((shifted, map)) =
kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks) kinsoku::apply_to_span_texts_with_ruby_breaks(&texts, &ruby_breaks)
@ -53,15 +57,9 @@ pub(crate) fn add_horizontal_span(
text_style: &skia::textlayout::TextStyle, text_style: &skia::textlayout::TextStyle,
fonts: &skia::textlayout::FontCollection, fonts: &skia::textlayout::FontCollection,
) { ) {
if span.warichu && span.text.chars().count() >= 2 { if span.is_warichu() {
let text = span.apply_text_transform(); let text = span.apply_text_transform();
let split = super::text_vertical::warichu_split_chars(&text); let (first, second) = warichu_text_lines(&text);
let split_byte = text
.char_indices()
.nth(split)
.map(|(index, _)| index)
.unwrap_or(text.len());
let (first, second) = text.split_at(split_byte);
let mut mini_style = text_style.clone(); let mut mini_style = text_style.clone();
mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE); mini_style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
mini_style.set_height(1.0); mini_style.set_height(1.0);
@ -133,7 +131,7 @@ pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpa
let shifted_end = shifted_cursor; let shifted_end = shifted_cursor;
let source_start = offset_map.to_original(shifted_start); let source_start = offset_map.to_original(shifted_start);
let source_end = offset_map.to_original(shifted_end); let source_end = offset_map.to_original(shifted_end);
let warichu = span.warichu && span.text.chars().count() >= 2; let warichu = span.is_warichu();
let builder_start = builder_cursor; let builder_start = builder_cursor;
let style_anchor_start = if warichu { let style_anchor_start = if warichu {
builder_byte_cursor + '\u{FFFC}'.len_utf8() builder_byte_cursor + '\u{FFFC}'.len_utf8()
@ -254,7 +252,7 @@ fn horizontal_warichu_placeholders(
let mut placeholders = laid_out.get_rects_for_placeholders().into_iter(); let mut placeholders = laid_out.get_rects_for_placeholders().into_iter();
let mut result = Vec::new(); let mut result = Vec::new();
for (index, span) in paragraph.children().iter().enumerate() { for (index, span) in paragraph.children().iter().enumerate() {
if span.warichu && span.text.chars().count() >= 2 { if span.is_warichu() {
if let Some(textbox) = placeholders.next() { if let Some(textbox) = placeholders.next() {
result.push((index, textbox.rect)); result.push((index, textbox.rect));
} }
@ -295,7 +293,7 @@ pub(crate) fn horizontal_warichu_hit_test(
if !rect.contains(&point) { if !rect.contains(&point) {
continue; continue;
} }
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); let split = warichu_split_chars(&span.apply_text_transform());
let total = span.text.chars().count(); let total = span.text.chars().count();
let second = point.y >= rect.top() + rect.height() / 2.0; let second = point.y >= rect.top() + rect.height() / 2.0;
let (line_start, line_len) = if second { let (line_start, line_len) = if second {
@ -319,7 +317,7 @@ pub(crate) fn horizontal_warichu_caret_rect(
if source_offset < source_start || source_offset > source_end { if source_offset < source_start || source_offset > source_end {
continue; continue;
} }
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); let split = warichu_split_chars(&span.apply_text_transform());
let local = source_offset - source_start; let local = source_offset - source_start;
let total = source_end - source_start; let total = source_end - source_start;
let (line_start, line_len, top) = if local >= split { let (line_start, line_len, top) = if local >= split {
@ -352,7 +350,7 @@ pub(crate) fn horizontal_warichu_range_rects(
if selected_start >= selected_end { if selected_start >= selected_end {
continue; continue;
} }
let split = super::text_vertical::warichu_split_chars(&span.apply_text_transform()); let split = warichu_split_chars(&span.apply_text_transform());
for (line_start, line_end, top) in [ for (line_start, line_end, top) in [
(span_start, span_start + split, rect.top()), (span_start, span_start + split, rect.top()),
( (
@ -392,7 +390,7 @@ pub(crate) fn horizontal_normal_selection_ranges(
let span_end = span_start + span.text.chars().count(); let span_end = span_start + span.text.chars().count();
let selected_start = source_start.max(span_start); let selected_start = source_start.max(span_start);
let selected_end = source_end.min(span_end); let selected_end = source_end.min(span_end);
let warichu = span.warichu && span.text.chars().count() >= 2; let warichu = span.is_warichu();
span_start = span_end; span_start = span_end;
if warichu || selected_start >= selected_end { if warichu || selected_start >= selected_end {
return None; return None;
@ -405,8 +403,39 @@ pub(crate) fn horizontal_normal_selection_ranges(
.collect() .collect()
} }
fn warichu_text_lines(text: &str) -> (&str, &str) { /// Char index where a warichu run splits into its two sub-lines: the
let split = super::text_vertical::warichu_split_chars(text); /// balanced midpoint (first line longer), nudged so the second sub-line
/// does not start with a line-start-prohibited character and the first
/// does not end with a line-end-prohibited one. Nudging forward pulls the
/// offending mark up into the first sub-line (jlreq); backward is the
/// fallback, and the midpoint stands when no split satisfies kinsoku.
pub(crate) fn warichu_split_chars(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mid = n.div_ceil(2);
let valid = |split: usize| {
split >= 1
&& split < n
&& !kinsoku::forbidden_at_line_start(chars[split])
&& !kinsoku::forbidden_at_line_end(chars[split - 1])
};
if valid(mid) {
return mid;
}
for distance in 1..n {
if valid(mid + distance) {
return mid + distance;
}
if mid > distance && valid(mid - distance) {
return mid - distance;
}
}
mid
}
/// The two warichu sub-lines of `text`, split at `warichu_split_chars`.
pub(crate) fn warichu_text_lines(text: &str) -> (&str, &str) {
let split = warichu_split_chars(text);
let split_byte = text let split_byte = text
.char_indices() .char_indices()
.nth(split) .nth(split)
@ -415,7 +444,7 @@ fn warichu_text_lines(text: &str) -> (&str, &str) {
text.split_at(split_byte) text.split_at(split_byte)
} }
fn warichu_mini_paragraph( fn mini_paragraph(
text: &str, text: &str,
style: &skia::textlayout::TextStyle, style: &skia::textlayout::TextStyle,
width: f32, width: f32,
@ -448,17 +477,7 @@ pub(crate) fn paint_horizontal_warichu(
let Some(span) = paragraph.children().get(range.span) else { let Some(span) = paragraph.children().get(range.span) else {
continue; continue;
}; };
// Indexed style metrics expose builder UTF-8 byte positions even let Some(mut style) = horizontal_span_style(laid_out, range) else {
// though glyph/line ranges use UTF-16 offsets.
let style_anchor = range.style_anchor_start
..range.style_anchor_start + HORIZONTAL_WARICHU_STYLE_ANCHOR.len_utf8();
let style = laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(style_anchor.clone())
.into_iter()
.next()
.map(|(_, metric)| metric.text_style.clone())
});
let Some(mut style) = style else {
continue; continue;
}; };
style.set_font_size(span.font_size * WARICHU_FONT_SCALE); style.set_font_size(span.font_size * WARICHU_FONT_SCALE);
@ -467,8 +486,8 @@ pub(crate) fn paint_horizontal_warichu(
style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE); style.set_letter_spacing(span.letter_spacing * WARICHU_FONT_SCALE);
let transformed = span.apply_text_transform(); let transformed = span.apply_text_transform();
let (first, second) = warichu_text_lines(&transformed); let (first, second) = warichu_text_lines(&transformed);
let first_para = warichu_mini_paragraph(first, &style, rect.width()); let first_para = mini_paragraph(first, &style, rect.width());
let second_para = warichu_mini_paragraph(second, &style, rect.width()); let second_para = mini_paragraph(second, &style, rect.width());
let half_height = rect.height() / 2.0; let half_height = rect.height() / 2.0;
first_para.paint(canvas, (x + rect.left(), y + rect.top())); first_para.paint(canvas, (x + rect.left(), y + rect.top()));
second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height)); second_para.paint(canvas, (x + rect.left(), y + rect.top() + half_height));
@ -546,8 +565,8 @@ pub(crate) fn horizontal_span_style(
laid_out: &skia::textlayout::Paragraph, laid_out: &skia::textlayout::Paragraph,
range: &HorizontalSpanRange, range: &HorizontalSpanRange,
) -> Option<skia::textlayout::TextStyle> { ) -> Option<skia::textlayout::TextStyle> {
// Indexed style metrics use builder UTF-8 byte positions (the same // Indexed style metrics use builder UTF-8 byte positions, unlike the
// convention used by the warichu style anchor above). // UTF-16 offsets of glyph and line ranges.
let anchor = range.style_anchor_start..range.style_anchor_start + 1; let anchor = range.style_anchor_start..range.style_anchor_start + 1;
laid_out.get_line_metrics().iter().find_map(|line| { laid_out.get_line_metrics().iter().find_map(|line| {
line.get_style_metrics(anchor.clone()) line.get_style_metrics(anchor.clone())
@ -595,7 +614,7 @@ pub(crate) fn paint_horizontal_emphasis(
style.set_height(1.0); style.set_height(1.0);
style.set_height_override(true); style.set_height_override(true);
style.set_letter_spacing(0.0); style.set_letter_spacing(0.0);
let mark_paragraph = warichu_mini_paragraph(&mark.to_string(), &style, f32::MAX); let mark_paragraph = mini_paragraph(&mark.to_string(), &style, f32::MAX);
let mark_ink = mark_paragraph let mark_ink = mark_paragraph
.get_rects_for_range( .get_rects_for_range(
0..mark.len_utf16(), 0..mark.len_utf16(),
@ -621,22 +640,204 @@ pub(crate) fn paint_horizontal_emphasis(
.filter(|placement| placement.span == range.span && placement.mark == mark) .filter(|placement| placement.span == range.span && placement.mark == mark)
{ {
let mark_x = x + placement.rect.center_x() - mark_width / 2.0; let mark_x = x + placement.rect.center_x() - mark_width / 2.0;
let ruby_offset = if span.annotation_clearance.is_auto()
&& !span.ruby.trim().is_empty()
&& span.ruby_side == RubySide::Over
{
span.font_size * span.ruby_size.scale()
} else {
0.0
};
let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size) let mark_y = y + horizontal_annotation_over_top(placement.rect, span.font_size)
- mark_ink_bottom - mark_ink_bottom
- ruby_offset; - span.emphasis_ruby_offset();
mark_paragraph.paint(canvas, (mark_x, mark_y)); mark_paragraph.paint(canvas, (mark_x, mark_y));
} }
} }
} }
/// Split `total` glyphs across segments proportionally to their extents
/// (rounded per segment, remainder to the last) so no glyph is dropped.
fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec<usize> {
let mut counts = vec![0usize; extents.len()];
if extents.is_empty() || total == 0 {
return counts;
}
let sum: f32 = extents.iter().sum();
if sum <= 0.0 {
counts[extents.len() - 1] = total;
return counts;
}
let mut assigned = 0usize;
let last = extents.len() - 1;
for (index, extent) in extents.iter().enumerate() {
let count = if index == last {
total - assigned
} else {
(((extent / sum) * total as f32).round() as usize).min(total - assigned)
};
counts[index] = count;
assigned += count;
}
counts
}
fn next_horizontal_ruby_range(
offset_map: &kinsoku::OffsetMap,
utf16_cursor: &mut usize,
text: &str,
) -> std::ops::Range<usize> {
let start = *utf16_cursor;
*utf16_cursor += text.encode_utf16().count();
offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor)
}
/// Baseline adjustment that attaches a glyph's visible ink edge to its base
/// strip. Font-wide ascender/descender metrics include leading which makes
/// horizontal ruby visibly detached for many Japanese faces.
fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 {
let mut bounds = [skia::Rect::default()];
font.get_bounds(&[glyph], &mut bounds, None);
let bound = bounds[0];
if bound.right > bound.left && bound.bottom > bound.top {
if over {
bound.bottom
} else {
bound.top
}
} else {
fallback
}
}
/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only:
/// base rects come from the already laid-out skparagraph
/// (`get_rects_for_range`), the annotation is shaped at half the span size
/// and distributed over each line's base rect with the same jlreq
/// distribution the vertical path uses (`distribute_ruby_tops` along the
/// horizontal flow). Lines are not reflowed to reserve an annotation band;
/// the ruby draws in the natural leading above the base line.
pub(crate) fn paint_horizontal_ruby(
canvas: &Canvas,
text_content: &TextContent,
paragraph_index: usize,
laid_out: &skia::textlayout::Paragraph,
x: f32,
y: f32,
) {
let Some(paragraph) = text_content.paragraphs().get(paragraph_index) else {
return;
};
if !paragraph.children().iter().any(TextSpan::has_ruby) {
return;
}
let font_provider = get_resources().fonts.font_provider();
let fallback_mgr = FontMgr::from(font_provider.clone());
let fallback_families: Vec<String> = get_fallback_fonts().iter().cloned().collect();
let bounds = text_content.bounds();
let (_, offset_map) = paragraph.layout_span_texts();
let mut utf16_cursor = 0usize;
for span in paragraph.children() {
let span_text = span.apply_text_transform();
let span_range = next_horizontal_ruby_range(&offset_map, &mut utf16_cursor, &span_text);
let ruby_text = span.ruby_text();
if ruby_text.is_empty() || span_range.is_empty() {
continue;
}
let ruby_font_size = span.ruby_font_size();
let families = span_font_families(span, &fallback_families);
let paint = merge_fills(&span.fills, bounds);
let rects = laid_out.get_rects_for_range(
span_range,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let shaped = shape_segment_with_fallbacks(
ruby_text,
ruby_font_size,
&families,
font_provider,
false,
span.font_features,
&fallback_mgr,
);
let glyphs: Vec<(usize, usize, f32)> = shaped
.iter()
.enumerate()
.flat_map(|(run_index, run)| {
(0..run.glyphs.len()).map(move |glyph| {
(
run_index,
glyph,
run.advances.get(glyph).copied().unwrap_or(ruby_font_size),
)
})
})
.collect();
let extents: Vec<f32> = rects.iter().map(|rect| rect.rect.width()).collect();
let counts = split_counts_by_extent(&extents, glyphs.len());
let mut assigned = 0usize;
for (rect_box, count) in rects.iter().zip(counts) {
if count == 0 {
continue;
}
let slice = &glyphs[assigned..assigned + count];
assigned += count;
let advance = slice
.iter()
.map(|(_, _, advance)| *advance)
.fold(0.0f32, f32::max)
.max(1.0);
// Horizontal SkParagraph has already fixed the base geometry.
// When overhang is prohibited, fit the annotation strip to
// that geometry instead of allowing either end to escape it.
let glyph_scale = if span.ruby_overhang == RubyOverhang::None {
(rect_box.rect.width() / (advance * count as f32)).min(1.0)
} else {
1.0
};
let layout_advance = advance * glyph_scale;
let lefts = distribute_ruby_tops(
rect_box.rect.left(),
rect_box.rect.width(),
count,
layout_advance,
span.ruby_align,
span.ruby_overhang,
);
for ((run_index, glyph, glyph_advance), left) in slice.iter().zip(lefts) {
let run = &shaped[*run_index];
let (_, metrics) = run.font.metrics();
let baseline = match span.ruby_side {
RubySide::Over => {
y + horizontal_annotation_over_top(rect_box.rect, span.font_size)
- horizontal_ruby_ink_edge(
&run.font,
run.glyphs[*glyph],
metrics.descent,
true,
)
}
RubySide::Under => {
y + rect_box.rect.bottom()
- horizontal_ruby_ink_edge(
&run.font,
run.glyphs[*glyph],
metrics.ascent,
false,
)
}
};
if let Some(blob) = single_glyph_blob(&run.font, run.glyphs[*glyph]) {
let gx = x + left + (layout_advance - glyph_advance * glyph_scale) / 2.0;
if glyph_scale < 1.0 {
canvas.save();
canvas.translate((gx, baseline));
canvas.scale((glyph_scale, 1.0));
canvas.draw_text_blob(&blob, (0.0, 0.0), &paint);
canvas.restore();
} else {
canvas.draw_text_blob(&blob, (gx, baseline), &paint);
}
}
}
}
}
}
/// Block flow direction of a paragraph. Horizontal is the skparagraph /// Block flow direction of a paragraph. Horizontal is the skparagraph
/// path; vertical-rl lays out columns top->bottom advancing right->left /// path; vertical-rl lays out columns top->bottom advancing right->left
/// through the custom vertical pass. /// through the custom vertical pass.
@ -783,3 +984,337 @@ impl AnnotationClearance {
matches!(self, AnnotationClearance::Auto) matches!(self, AnnotationClearance::Auto)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextTransform};
use crate::Uuid;
#[test]
fn warichu_split_balances_and_respects_kinsoku() {
// Balanced midpoint when nothing forbids it, first line longer.
assert_eq!(warichu_split_chars("あいうえおか"), 3);
assert_eq!(warichu_split_chars("あいうえお"), 3);
// A comma at the midpoint may end the first sub-line...
assert_eq!(warichu_split_chars("あい、うえ"), 3);
// ...but must not start the second one: the split moves forward.
assert_eq!(warichu_split_chars("あいう、えお"), 4);
// An opening bracket must not end the first sub-line.
assert_eq!(warichu_split_chars("あい「うえお"), 4);
// Pathological all-forbidden text keeps the midpoint.
assert_eq!(warichu_split_chars("、、、、"), 2);
}
#[test]
fn horizontal_ruby_ranges_use_utf16_across_spans() {
let offset_map = crate::shapes::kinsoku::OffsetMap::default();
let mut cursor = 0;
assert_eq!(
next_horizontal_ruby_range(&offset_map, &mut cursor, "𠀀"),
0..2
);
assert_eq!(
next_horizontal_ruby_range(&offset_map, &mut cursor, "漢"),
2..3
);
}
#[test]
fn split_counts_by_extent_drops_no_glyph() {
assert_eq!(split_counts_by_extent(&[60.0, 40.0], 5), vec![3, 2]);
assert_eq!(split_counts_by_extent(&[100.0], 4), vec![4]);
assert_eq!(split_counts_by_extent(&[0.0, 0.0], 3), vec![0, 3]);
assert_eq!(
split_counts_by_extent(&[1.0, 1.0, 1.0], 2)
.iter()
.sum::<usize>(),
2
);
assert!(split_counts_by_extent(&[], 3).is_empty());
}
// apply_text_transform reads the browser from the design state.
fn init_state() {
crate::globals::design_init();
}
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
TextSpan {
text: text.to_string(),
font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
font_size: 16.0,
line_height: 1.0,
letter_spacing,
font_weight: 400,
font_variant_id: Uuid::nil(),
text_decoration: None,
text_transform: None,
text_direction: TextDirection::LTR,
text_orientation: TextOrientation::default(),
text_combine_upright: TextCombineUpright::default(),
text_emphasis: TextEmphasis::default(),
ruby: String::default(),
warichu: false,
font_features: FontFeatures::default(),
annotation_clearance: AnnotationClearance::default(),
ruby_size: RubySize::default(),
ruby_align: RubyAlign::default(),
ruby_overhang: RubyOverhang::default(),
ruby_side: RubySide::default(),
paragraph_position: u32::MAX,
span_position: u32::MAX,
fills: vec![],
}
}
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
Paragraph::new(
TextAlign::default(),
TextDirection::LTR,
None,
None,
1.0,
letter_spacing,
spans,
)
}
#[test]
fn layout_span_texts_applies_kinsoku() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(
texts,
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
);
assert!(!map.is_empty());
assert_eq!(map.to_original(3), 2);
}
#[test]
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
init_state();
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["雪国。".to_string()]);
assert!(map.is_empty());
}
#[test]
fn layout_span_texts_respects_text_transform() {
init_state();
let mut span = make_span("hello。", 0.0);
span.text_transform = Some(TextTransform::Uppercase);
let paragraph = make_paragraph(vec![span], 0.0);
let (texts, _) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
}
#[test]
fn layout_span_texts_identity_map_for_plain_text() {
init_state();
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
let (texts, map) = paragraph.layout_span_texts();
assert_eq!(texts, vec!["helloworld".to_string()]);
assert!(map.is_empty());
assert_eq!(map.to_original(5), 5);
assert_eq!(map.to_shifted(5), 5);
}
#[test]
fn horizontal_ruby_is_atomic() {
init_state();
let mut group = make_span("日本", 0.0);
group.ruby = "にほん".to_string();
let paragraph = make_paragraph(vec![group], 0.0);
assert_eq!(
paragraph.layout_span_texts().0,
vec!["日\u{2060}本".to_string()]
);
}
#[test]
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
assert_eq!(horizontal_annotation_over_top(rect, 56.0), 88.0);
}
#[test]
fn horizontal_warichu_collapses_to_one_builder_position() {
init_state();
let mut warichu = make_span("割注入り", 0.0);
warichu.warichu = true;
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
let ranges = horizontal_span_ranges(&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)
}