mirror of
https://github.com/penpot/penpot.git
synced 2026-10-02 16:56:16 +00:00
Feature improvements
This commit is contained in:
parent
11d4cc6459
commit
c6ce618bab
@ -89,7 +89,7 @@
|
||||
[:writing-mode {:optional true} ::sm/text]
|
||||
[:text-orientation {:optional true} ::sm/text]
|
||||
[:text-combine-upright {:optional true} ::sm/text]
|
||||
[:text-emphasis {:optional true} ::sm/text]
|
||||
[:emphasis-mark {:optional true} :boolean]
|
||||
[:ruby {:optional true} :string]
|
||||
[:ruby-size {:optional true} ::sm/text]
|
||||
[:ruby-align {:optional true} ::sm/text]
|
||||
@ -97,8 +97,6 @@
|
||||
[:ruby-side {:optional true} ::sm/text]
|
||||
[:warichu {:optional true} ::sm/text]
|
||||
[:font-features {:optional true} ::sm/text]
|
||||
[:annotation-clearance {:optional true} ::sm/text]
|
||||
[:annotation-has-ruby {:optional true} :boolean]
|
||||
[:text {:optional true} :string]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]]])
|
||||
|
||||
@ -65,8 +65,21 @@
|
||||
:font-features "none"
|
||||
:annotation-clearance "none"})
|
||||
|
||||
;; Annotation font sizes relative to the base font size.
|
||||
(def emphasis-font-scale 0.5)
|
||||
;; Glyph of each emphasis style, per CSS `text-emphasis-style`.
|
||||
(def ^:private emphasis-mark-chars
|
||||
{"filled-dot" "•"
|
||||
"open-dot" "◦"
|
||||
"filled-circle" "●"
|
||||
"open-circle" "○"
|
||||
"filled-sesame" "﹅"
|
||||
"open-sesame" "﹆"})
|
||||
|
||||
(defn emphasis-mark-char
|
||||
"Mark glyph of a text-emphasis value, or nil for none."
|
||||
[text-emphasis]
|
||||
(get emphasis-mark-chars text-emphasis))
|
||||
|
||||
;; Warichu sub-line font size relative to the base font size.
|
||||
(def warichu-font-scale 0.5)
|
||||
|
||||
(defn ruby-font-scale
|
||||
|
||||
@ -22,71 +22,6 @@
|
||||
|
||||
(def fill-attrs [:fill-color :fill-color-gradient :fill-opacity])
|
||||
|
||||
;; Kinsoku classes for the warichu sub-line split (same characters the
|
||||
;; renderer's kinsoku module suppresses at line boundaries).
|
||||
(def ^:private warichu-forbidden-at-start
|
||||
(str "、。,.)」』]】〕〉》’”!?;:ー"
|
||||
"ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ"
|
||||
"々ゝゞヽヾ・"))
|
||||
|
||||
(def ^:private warichu-forbidden-at-end "(「『[【〔〈《‘“")
|
||||
|
||||
(defn- warichu-split-index
|
||||
"Safe JavaScript string index where a warichu run splits into its two
|
||||
sub-lines. The split is chosen in Unicode code-point space, then translated
|
||||
back to a UTF-16 boundary for `subs`: the
|
||||
balanced midpoint (first sub-line longer), nudged forward then backward
|
||||
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.
|
||||
Mirrors the renderer's `warichu_split_chars`."
|
||||
[text]
|
||||
(let [characters (vec (js/Array.from text))
|
||||
n (count characters)
|
||||
mid (js/Math.ceil (/ n 2))
|
||||
valid? (fn [split]
|
||||
(and (>= split 1)
|
||||
(< split n)
|
||||
(not (.includes warichu-forbidden-at-start (nth characters split)))
|
||||
(not (.includes warichu-forbidden-at-end (nth characters (dec split))))))
|
||||
split (if (valid? mid)
|
||||
mid
|
||||
(or (->> (range 1 n)
|
||||
(some (fn [distance]
|
||||
(cond
|
||||
(valid? (+ mid distance)) (+ mid distance)
|
||||
(and (> mid distance) (valid? (- mid distance))) (- mid distance)))))
|
||||
mid))]
|
||||
(->> (take split characters)
|
||||
(reduce (fn [index character] (+ index (.-length character))) 0))))
|
||||
|
||||
;; CSS `text-emphasis-style` character mapping (same glyphs the canvas
|
||||
;; renderer shapes for each mark style).
|
||||
(def ^:private emphasis-mark-chars
|
||||
{"filled-dot" "•"
|
||||
"open-dot" "◦"
|
||||
"filled-circle" "●"
|
||||
"open-circle" "○"
|
||||
"filled-sesame" "﹅"
|
||||
"open-sesame" "﹆"})
|
||||
|
||||
(def ^:private emphasis-prohibited-chars
|
||||
"、。,.「」『』()[]【】〔〕〈〉《》‘’“”")
|
||||
|
||||
(defn- emphasis-character?
|
||||
[character]
|
||||
(and (not (re-matches #"\s" character))
|
||||
(not (.includes emphasis-prohibited-chars character))))
|
||||
|
||||
(defn- emphasis-marks-text
|
||||
"One mark per eligible Unicode base character. Spaces replace whitespace
|
||||
and Japanese punctuation that does not normally carry emphasis so marks
|
||||
stay aligned with the base slots."
|
||||
[text mark]
|
||||
(->> (js/Array.from text)
|
||||
(map (fn [character]
|
||||
(if (emphasis-character? character) mark " ")))
|
||||
(apply str)))
|
||||
|
||||
(defn- px
|
||||
"CSS pixel size, or nil for a non-positive size."
|
||||
[size]
|
||||
@ -157,36 +92,25 @@
|
||||
(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- warichu-line-props
|
||||
"Props of the `text` element drawing one warichu sub-line at half size in
|
||||
its own strip: the renderer splits the span and places each sub-line."
|
||||
[data 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)
|
||||
top (- (:y data) (:height data))]
|
||||
(if vertical?
|
||||
#js {:x (+ (:x data) (/ (:width data) 2))
|
||||
:y top
|
||||
:style style}
|
||||
#js {:x (:x data)
|
||||
:y top
|
||||
:dominantBaseline "hanging"
|
||||
:textLength (:width data)
|
||||
:lengthAdjust "spacingAndGlyphs"
|
||||
:style style})))
|
||||
|
||||
(defn- ruby-text-props
|
||||
"Props of the `text` element drawing a strip's ruby annotation beside it
|
||||
@ -230,25 +154,16 @@
|
||||
(-> (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- emphasis-mark-props
|
||||
"Props of the `text` element drawing one emphasis mark centred in its em
|
||||
box, as placed by the renderer."
|
||||
[data fill]
|
||||
(let [size (:width data)]
|
||||
#js {:x (+ (:x data) (/ size 2))
|
||||
:y (- (:y data) (/ (:height data) 2))
|
||||
:textAnchor "middle"
|
||||
:dominantBaseline "central"
|
||||
:style (annotation-style data size false nil fill)}))
|
||||
|
||||
(defn set-white-fill
|
||||
[shape]
|
||||
@ -303,10 +218,10 @@
|
||||
(for [[index data] (d/enumerate position-data)]
|
||||
(let [vertical? (= "vertical-rl" (:writing-mode data))
|
||||
ruby (jl/visible-ruby data)
|
||||
emphasis-mark (get emphasis-mark-chars (:text-emphasis data))
|
||||
font-size (js/parseFloat (:font-size data))
|
||||
scaled-size (fn [scale] (if (js/isNaN font-size) 0 (* font-size scale)))
|
||||
ruby-font-size (scaled-size (jl/ruby-font-scale (:ruby-size data)))
|
||||
ruby-font-size (if (js/isNaN font-size)
|
||||
0
|
||||
(* font-size (jl/ruby-font-scale (:ruby-size data))))
|
||||
;; Annotations take the fills of this strip's own render id.
|
||||
annotation-fill (str "url(#fill-" index "-" render-id "-" index ")")
|
||||
|
||||
@ -333,26 +248,16 @@
|
||||
[:defs
|
||||
[:& fills/fills {:shape shape :render-id render-id}]]
|
||||
|
||||
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
|
||||
(if (jl/warichu-text? data)
|
||||
(warichu-lines shape data index annotation-fill)
|
||||
[:> :text text-props (:text data)])]
|
||||
(when (some? ruby)
|
||||
[:> :text (ruby-text-props shape data index ruby-font-size
|
||||
(annotation-style data ruby-font-size vertical?
|
||||
"upright" annotation-fill))
|
||||
ruby])
|
||||
;; Emphasis marks (圏点): one half-size mark per eligible base
|
||||
;; character.
|
||||
(when (some? emphasis-mark)
|
||||
(let [ruby-layer? (and (= "auto" (:annotation-clearance data))
|
||||
(not= "under" (:ruby-side data "over"))
|
||||
(or (some? ruby) (:annotation-has-ruby data)))
|
||||
mark-size (scaled-size jl/emphasis-font-scale)
|
||||
style (annotation-style data mark-size vertical?
|
||||
(when vertical? "upright")
|
||||
annotation-fill)]
|
||||
[:> :text (emphasis-text-props shape data index mark-size
|
||||
(if ruby-layer? ruby-font-size 0)
|
||||
style)
|
||||
(emphasis-marks-text (:text data) emphasis-mark)]))]))]]))
|
||||
(if (:emphasis-mark data)
|
||||
;; Emphasis marks (圏点) are not stroked.
|
||||
[:> :text (emphasis-mark-props data annotation-fill) (:text data)]
|
||||
[:*
|
||||
[:& shape-custom-strokes {:shape shape :position index :render-id render-id}
|
||||
(if (= "warichu" (:warichu data))
|
||||
[:> :text (warichu-line-props data annotation-fill) (:text data)]
|
||||
[:> :text text-props (:text data)])]
|
||||
(when (some? ruby)
|
||||
[:> :text (ruby-text-props shape data index ruby-font-size
|
||||
(annotation-style data ruby-font-size vertical?
|
||||
"upright" annotation-fill))
|
||||
ruby])])]))]]))
|
||||
|
||||
@ -2934,6 +2934,29 @@
|
||||
|
||||
(def ^:private POSITION-DATA-VERTICAL 2)
|
||||
(def ^:private POSITION-DATA-RUBY 3)
|
||||
(def ^:private POSITION-DATA-EMPHASIS-MARK 4)
|
||||
|
||||
(defn- emphasis-mark-entry
|
||||
"Position-data entry for one emphasis mark (圏点): the geometry is the
|
||||
mark's em box, centred where the canvas paints it."
|
||||
[element {:keys [x y width height]}]
|
||||
(when-let [mark (jl/emphasis-mark-char (get element :text-emphasis))]
|
||||
(d/patch-object
|
||||
(txt/get-default-text-attrs)
|
||||
(d/without-nils
|
||||
{:x x
|
||||
:y (+ y height)
|
||||
:width width
|
||||
:height height
|
||||
:direction "ltr"
|
||||
:emphasis-mark true
|
||||
:font-id (get element :font-id)
|
||||
:font-family (get element :font-family)
|
||||
:font-size (dm/str width "px")
|
||||
:font-weight (get element :font-weight)
|
||||
:font-style (get element :font-style)
|
||||
:fills (get element :fills)
|
||||
:text mark}))))
|
||||
|
||||
(defn- ruby-strip-entry
|
||||
"Position-data entry for a ruby annotation strip: the offsets index the
|
||||
@ -2997,14 +3020,11 @@
|
||||
: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))
|
||||
:font-features (let [features (get element :font-features)]
|
||||
(when (and (seq features) (not= "none" features))
|
||||
features))
|
||||
;; Each warichu sub-line has its own entry, drawn at half size.
|
||||
:warichu (when (= "warichu" (get element :warichu)) "warichu")
|
||||
: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)
|
||||
@ -3049,8 +3069,9 @@
|
||||
(get 0) :children ;; paragraph-set
|
||||
(get paragraph))
|
||||
element (-> paragraph-node :children (get span))]
|
||||
(if (= direction POSITION-DATA-RUBY)
|
||||
(ruby-strip-entry element entry)
|
||||
(condp = direction
|
||||
POSITION-DATA-RUBY (ruby-strip-entry element entry)
|
||||
POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry)
|
||||
(text-strip-entry paragraph-node element entry)))))
|
||||
result))))
|
||||
|
||||
|
||||
@ -45,12 +45,12 @@
|
||||
:font-weight "400"})
|
||||
|
||||
(defn- setup-text
|
||||
"A text shape whose single position-data strip is `strip` (over
|
||||
"A text shape with the given position-data `strips` (over
|
||||
`strip-defaults`), with its objects and selection."
|
||||
[{:keys [width height]} strip]
|
||||
[{:keys [width height]} & strips]
|
||||
(let [shape (-> (cts/setup-shape {:type :text :x 10 :y 20 :width width :height height})
|
||||
(assoc :name "Text"
|
||||
:position-data [(merge strip-defaults strip)]))
|
||||
:position-data (mapv #(merge strip-defaults %) strips)))
|
||||
file (cths/add-sample-shape
|
||||
(cthf/sample-file :file1 :page-label :page1)
|
||||
:text
|
||||
@ -72,10 +72,10 @@
|
||||
:writing-mode "horizontal-tb"}
|
||||
attrs))
|
||||
|
||||
(defn- setup-vertical-warichu-text
|
||||
[text]
|
||||
(setup-text {:width 60 :height 100}
|
||||
(vertical-strip {:height 40 :text text :warichu "warichu"})))
|
||||
(defn- emphasis-mark
|
||||
"A mark entry as the renderer emits it: the mark glyph in its em box."
|
||||
[x y]
|
||||
{:x x :y y :width 10 :height 10 :font-size "10px" :emphasis-mark true :text "•"})
|
||||
|
||||
(deftest empty-selection-yields-empty-string
|
||||
(is (= "" (svg/generate-markup {} []))))
|
||||
@ -111,87 +111,48 @@
|
||||
(is (re-find #"font-feature-settings:"vpal"" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest vertical-emphasis-svg-emits-static-marks
|
||||
(testing "Static SVG draws emphasis marks and skips whitespace characters"
|
||||
(deftest emphasis-svg-draws-each-mark-in-its-box
|
||||
(testing "Static SVG draws the marks the renderer placed, centred in their boxes"
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 60 :height 100}
|
||||
(vertical-strip {:text "強調 あ" :text-emphasis "filled-dot"}))
|
||||
(vertical-strip {:text "強調"})
|
||||
(emphasis-mark 44 40)
|
||||
(emphasis-mark 44 60))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調 あ" markup))
|
||||
;; 4 base chars: mark, mark, space (whitespace keeps its slot), mark.
|
||||
(is (re-find #"•• •" markup))
|
||||
(is (re-find #"強調" markup))
|
||||
(is (= 2 (count-matches #">•<" markup)) "one text element per mark")
|
||||
(is (re-find #"x=\"49\"" markup) "marks centre on their box")
|
||||
(is (re-find #"text-anchor=\"middle\"" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (re-find #"fill:url\(#fill-1-[^)]+-1\)" markup)
|
||||
"a mark references its generated per-strip fill")
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest horizontal-emphasis-svg-emits-static-marks-above-the-text
|
||||
(testing "Static SVG draws horizontal emphasis and excludes punctuation and whitespace"
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
|
||||
(horizontal-strip {:text "強調、 あ" :text-emphasis "filled-dot"}))
|
||||
(deftest vertical-warichu-svg-draws-each-sub-line-at-half-size
|
||||
(testing "Static SVG draws each renderer-split warichu sub-line in its own strip"
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 60 :height 100}
|
||||
(vertical-strip {:x 30 :width 10 :height 40
|
||||
:text "割注" :warichu "warichu"})
|
||||
(vertical-strip {:x 20 :width 10 :height 40
|
||||
:text "入り" :warichu "warichu"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"強調、 あ" markup))
|
||||
;; Five Unicode base characters: two marks, punctuation and whitespace
|
||||
;; slots, then one mark.
|
||||
(is (re-find #"•• •" markup))
|
||||
(is (re-find #"writing-mode:horizontal-tb" markup))
|
||||
(is (re-find #"dominant-baseline=\"text-after-edge\"" markup))
|
||||
(is (re-find #"lengthAdjust=\"spacing\"" markup))
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (re-find #"fill:url\(#fill-0-[^)]+-0\)" markup)
|
||||
"the emphasis mark references its generated per-strip fill")
|
||||
(is (re-find #"x=\"35\"[^>]*>割注<" markup) "first sub-line on the right half")
|
||||
(is (re-find #"x=\"25\"[^>]*>入り<" markup) "second sub-line on the left half")
|
||||
(is (= 2 (count-matches #"font-size:10px" markup)))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest horizontal-auto-clearance-stacks-emphasis-outside-ruby
|
||||
(testing "Static SVG keeps ruby nearest the base and offsets emphasis by another half-em"
|
||||
(deftest horizontal-warichu-svg-draws-each-sub-line-at-half-size
|
||||
(testing "Static SVG draws horizontal warichu sub-lines from their top edges"
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
|
||||
(horizontal-strip {:text "漢字"
|
||||
:ruby "かんじ"
|
||||
:text-emphasis "filled-dot"
|
||||
:annotation-clearance "auto"
|
||||
:annotation-has-ruby true}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"かんじ" markup))
|
||||
(is (re-find #"••" markup))
|
||||
(is (re-find #"y=\"40\"" markup) "ruby occupies the first half-em layer")
|
||||
(is (re-find #"y=\"30\"" markup) "emphasis occupies the outer half-em layer"))))
|
||||
|
||||
(deftest vertical-warichu-svg-emits-two-sub-columns
|
||||
(testing "Static SVG splits a warichu strip into two half-size sub-columns"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割注入り")
|
||||
(horizontal-strip {:width 40 :height 10 :y 50
|
||||
:text "割注" :warichu "warichu"})
|
||||
(horizontal-strip {:width 40 :height 10 :y 60
|
||||
:text "入り" :warichu "warichu"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
(is (not (re-find #"割注入り" markup))
|
||||
"the base text must be split, not drawn as one strip")
|
||||
(is (re-find #"font-size:10px" markup))
|
||||
(is (not (re-find #"<foreignObject\b" markup))))))
|
||||
|
||||
(deftest vertical-warichu-svg-split-respects-kinsoku
|
||||
(testing "The second sub-line must not start with a line-start-prohibited mark"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割り注、と")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割り注、" markup)
|
||||
"the comma is pulled up into the first sub-line")
|
||||
(is (not (re-find #"割り注、と" markup))
|
||||
"the strip is still split into two sub-lines"))))
|
||||
|
||||
(deftest vertical-warichu-svg-preserves-non-bmp-characters
|
||||
(testing "Static SVG never splits a surrogate pair between warichu sub-lines"
|
||||
(let [{:keys [objects shapes]} (setup-vertical-warichu-text "割𠀀注😀")
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割𠀀" markup))
|
||||
(is (re-find #"注😀" markup))
|
||||
(is (not (re-find #"<22>" markup))))))
|
||||
|
||||
(deftest horizontal-warichu-svg-emits-two-stacked-sub-lines
|
||||
(testing "Static SVG splits horizontal warichu into top and bottom half-size lines"
|
||||
(let [{:keys [objects shapes]} (setup-text {:width 120 :height 40}
|
||||
(horizontal-strip {:width 40 :text "割注入り" :warichu "warichu"}))
|
||||
markup (svg/generate-markup objects shapes)]
|
||||
(is (re-find #"割注" markup))
|
||||
(is (re-find #"入り" markup))
|
||||
(is (not (re-find #"割注入り" markup)))
|
||||
(is (re-find #"writing-mode:horizontal-tb" markup))
|
||||
(is (= 2 (count-matches #"dominant-baseline=.?hanging" markup)))
|
||||
(is (re-find #"font-size:10px" markup)))))
|
||||
(is (= 2 (count-matches #"font-size:10px" markup))))))
|
||||
|
||||
(deftest vertical-ruby-svg-emits-static-annotation
|
||||
(testing "Static SVG keeps ruby visible without falling back to foreignObject"
|
||||
|
||||
@ -1750,201 +1750,41 @@ impl RenderState {
|
||||
let rebound_text_content =
|
||||
stored_text_content.paint_content_for_selrect(shape.selrect());
|
||||
let text_content = rebound_text_content.as_ref();
|
||||
let count_inner_strokes = shape.count_visible_inner_strokes();
|
||||
// Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge.
|
||||
let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale());
|
||||
let text_stroke_blur_outset =
|
||||
Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
let mut paragraph_builders =
|
||||
text_content.paragraph_builder_group_from_text(None);
|
||||
let stroke_kinds: Vec<StrokeKind> =
|
||||
shape.visible_strokes().rev().map(|s| s.kind).collect();
|
||||
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
if text_content.is_vertical() {
|
||||
text::render_vertical_text(
|
||||
self,
|
||||
&shape,
|
||||
text_content,
|
||||
&mut paragraph_builders,
|
||||
fills_surface_id,
|
||||
strokes_surface_id,
|
||||
skip_effects,
|
||||
)?;
|
||||
} else if skip_effects {
|
||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraph_builders,
|
||||
Some(fills_surface_id),
|
||||
None,
|
||||
None,
|
||||
text_fill_inset,
|
||||
None,
|
||||
)?;
|
||||
|
||||
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
|
||||
.iter_mut()
|
||||
.zip(stroke_opacities.iter())
|
||||
.enumerate()
|
||||
{
|
||||
if stroke_kinds[i] == StrokeKind::Inner {
|
||||
let mut fill_builders =
|
||||
text_content.paragraph_builder_group_from_text(None);
|
||||
text::render_inner_stroke(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
&mut fill_builders,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
} else if stroke_kinds[i] == StrokeKind::Outer {
|
||||
text::render_outer_stroke(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
} else {
|
||||
text::render_with_bounds_outset(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
None,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if shape.has_visible_strokes() && text_content.has_non_ascii() {
|
||||
let mut emoji_builders = text_content.paragraph_builder_group_opaque();
|
||||
let mut deco_builders =
|
||||
text_content.paragraph_builder_group_from_text(None);
|
||||
text::render_emoji_overlay(
|
||||
self,
|
||||
&shape,
|
||||
&mut emoji_builders,
|
||||
&mut deco_builders,
|
||||
strokes_surface_id,
|
||||
None,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let shape_scale = self.get_scale();
|
||||
let mut drop_shadows = if skip_drop_shadows {
|
||||
Vec::new()
|
||||
} else {
|
||||
let count_inner_strokes = shape.count_visible_inner_strokes();
|
||||
// Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge.
|
||||
let text_fill_inset =
|
||||
(count_inner_strokes > 0).then(|| 1.0 / self.get_scale());
|
||||
let text_stroke_blur_outset =
|
||||
Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
let mut paragraph_builders =
|
||||
text_content.paragraph_builder_group_from_text(None);
|
||||
let stroke_kinds: Vec<StrokeKind> =
|
||||
shape.visible_strokes().rev().map(|s| s.kind).collect();
|
||||
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) =
|
||||
shape
|
||||
.drop_shadows_visible()
|
||||
.filter(|s| s.is_perceptible_at_scale(shape_scale))
|
||||
.map(|shadow| {
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
paint.set_image_filter(shadow.get_drop_shadow_filter());
|
||||
paint
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if !skip_drop_shadows {
|
||||
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
|
||||
drop_shadows.extend(inherited_shadows);
|
||||
}
|
||||
}
|
||||
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): (
|
||||
Vec<_>,
|
||||
Vec<_>,
|
||||
) = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
if let Some(parent_shadows) = parent_shadows {
|
||||
if !skip_drop_shadows {
|
||||
if !shape.has_visible_strokes() {
|
||||
for shadow in parent_shadows {
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
Some(&shadow),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
shadows::render_text_shadows(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
&mut stroke_paragraphs_with_shadows_list,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
&parent_shadows,
|
||||
&blur_filter,
|
||||
&stroke_kinds,
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 1. Text drop shadows
|
||||
if !shape.has_visible_strokes() {
|
||||
for shadow in &drop_shadows {
|
||||
text::render(
|
||||
Some(self),
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
Some(shadow),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Text fills
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
if skip_effects {
|
||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
@ -1952,25 +1792,11 @@ impl RenderState {
|
||||
&mut paragraph_builders,
|
||||
Some(fills_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_fill_inset,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 3. Stroke drop shadows
|
||||
shadows::render_text_shadows(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
&mut stroke_paragraphs_with_shadows_list,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
&drop_shadows,
|
||||
&blur_filter,
|
||||
&stroke_kinds,
|
||||
text_content,
|
||||
)?;
|
||||
|
||||
// 4. Stroke fills
|
||||
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
|
||||
.iter_mut()
|
||||
.zip(stroke_opacities.iter())
|
||||
@ -1986,7 +1812,7 @@ impl RenderState {
|
||||
stroke_paragraphs,
|
||||
&mut fill_builders,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
@ -1997,7 +1823,7 @@ impl RenderState {
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
@ -2009,7 +1835,7 @@ impl RenderState {
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
None,
|
||||
*layer_opacity,
|
||||
@ -2028,37 +1854,216 @@ impl RenderState {
|
||||
&mut emoji_builders,
|
||||
&mut deco_builders,
|
||||
strokes_surface_id,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let shape_scale = self.get_scale();
|
||||
let mut drop_shadows = if skip_drop_shadows {
|
||||
Vec::new()
|
||||
} else {
|
||||
shape
|
||||
.drop_shadows_visible()
|
||||
.filter(|s| s.is_perceptible_at_scale(shape_scale))
|
||||
.map(|shadow| {
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
paint.set_image_filter(shadow.get_drop_shadow_filter());
|
||||
paint
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 5. Stroke inner shadows
|
||||
shadows::render_text_shadows(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
&mut stroke_paragraphs_with_shadows_list,
|
||||
Some(innershadows_surface_id),
|
||||
&inner_shadows,
|
||||
&blur_filter,
|
||||
&stroke_kinds,
|
||||
text_content,
|
||||
)?;
|
||||
if !skip_drop_shadows {
|
||||
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
|
||||
drop_shadows.extend(inherited_shadows);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Fill Inner shadows
|
||||
if !shape.has_visible_strokes() {
|
||||
for shadow in &inner_shadows {
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): (
|
||||
Vec<_>,
|
||||
Vec<_>,
|
||||
) = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
if let Some(parent_shadows) = parent_shadows {
|
||||
if !skip_drop_shadows {
|
||||
if !shape.has_visible_strokes() {
|
||||
for shadow in parent_shadows {
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
Some(&shadow),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
shadows::render_text_shadows(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
&mut stroke_paragraphs_with_shadows_list,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
&parent_shadows,
|
||||
&blur_filter,
|
||||
&stroke_kinds,
|
||||
text_content,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 1. Text drop shadows
|
||||
if !shape.has_visible_strokes() {
|
||||
for shadow in &drop_shadows {
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
Some(shadow),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Text fills
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraph_builders,
|
||||
Some(fills_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
text_fill_inset,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 3. Stroke drop shadows
|
||||
shadows::render_text_shadows(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
&mut stroke_paragraphs_with_shadows_list,
|
||||
text_drop_shadows_surface_id.into(),
|
||||
&drop_shadows,
|
||||
&blur_filter,
|
||||
&stroke_kinds,
|
||||
text_content,
|
||||
)?;
|
||||
|
||||
// 4. Stroke fills
|
||||
for (i, (stroke_paragraphs, layer_opacity)) in
|
||||
stroke_paragraphs_list
|
||||
.iter_mut()
|
||||
.zip(stroke_opacities.iter())
|
||||
.enumerate()
|
||||
{
|
||||
if stroke_kinds[i] == StrokeKind::Inner {
|
||||
let mut fill_builders =
|
||||
text_content.paragraph_builder_group_from_text(None);
|
||||
text::render_inner_stroke(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
&mut fill_builders,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
} else if stroke_kinds[i] == StrokeKind::Outer {
|
||||
text::render_outer_stroke(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
} else {
|
||||
text::render_with_bounds_outset(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
text_stroke_blur_outset,
|
||||
None,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if shape.has_visible_strokes() && text_content.has_non_ascii() {
|
||||
let mut emoji_builders =
|
||||
text_content.paragraph_builder_group_opaque();
|
||||
let mut deco_builders =
|
||||
text_content.paragraph_builder_group_from_text(None);
|
||||
text::render_emoji_overlay(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
Some(innershadows_surface_id),
|
||||
Some(shadow),
|
||||
&mut emoji_builders,
|
||||
&mut deco_builders,
|
||||
strokes_surface_id,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Stroke inner shadows
|
||||
shadows::render_text_shadows(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
&mut stroke_paragraphs_with_shadows_list,
|
||||
Some(innershadows_surface_id),
|
||||
&inner_shadows,
|
||||
&blur_filter,
|
||||
&stroke_kinds,
|
||||
text_content,
|
||||
)?;
|
||||
|
||||
// 6. Fill Inner shadows
|
||||
if !shape.has_visible_strokes() {
|
||||
for shadow in &inner_shadows {
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
&shape,
|
||||
&mut paragraphs_with_shadows,
|
||||
Some(innershadows_surface_id),
|
||||
Some(shadow),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,66 +16,57 @@ use skia_safe::{
|
||||
Canvas, ImageFilter, Paint,
|
||||
};
|
||||
|
||||
/// Vertical text: shadows, fill, strokes and the debug grid, all painted from
|
||||
/// one layout. Like every vertical pass, the content is rebound to the
|
||||
/// selrect: stored text bounds describe the measured content and can be
|
||||
/// taller than a fixed shape, whose height is the column-wrap budget.
|
||||
pub fn render_vertical_text(
|
||||
state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
text_content: &TextContent,
|
||||
paragraph_builders: &mut [ParagraphBuilderGroup],
|
||||
fills_surface_id: SurfaceId,
|
||||
strokes_surface_id: SurfaceId,
|
||||
skip_effects: bool,
|
||||
) -> Result<()> {
|
||||
let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten();
|
||||
let text_content = text_content.new_bounds(shape.selrect());
|
||||
let bounds = text_content.bounds();
|
||||
let vertical_align = shape.vertical_align();
|
||||
let layout = text_vertical::layout_for_box(&text_content, bounds.height());
|
||||
|
||||
let skip_shadows = skip_effects || state.should_skip_drop_shadows();
|
||||
let mut drop_shadows = if skip_shadows {
|
||||
Vec::new()
|
||||
} else {
|
||||
shape.drop_shadow_paints()
|
||||
};
|
||||
if !skip_shadows {
|
||||
let mut drop_shadows = shape.drop_shadow_paints();
|
||||
if let Some(inherited_shadows) = state.get_inherited_drop_shadows() {
|
||||
drop_shadows.extend(inherited_shadows);
|
||||
}
|
||||
}
|
||||
let strokes: Vec<Stroke> = shape.visible_strokes().rev().cloned().collect();
|
||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
|
||||
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
|
||||
|
||||
if let Some(layout) = layout.as_ref().filter(|_| !drop_shadows.is_empty()) {
|
||||
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
|
||||
for shadow in &drop_shadows {
|
||||
text_vertical::paint_drop_shadow(
|
||||
canvas,
|
||||
layout,
|
||||
&bounds,
|
||||
shape.vertical_align(),
|
||||
shadow,
|
||||
);
|
||||
if !drop_shadows.is_empty() {
|
||||
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
|
||||
for shadow in &drop_shadows {
|
||||
text_vertical::paint_drop_shadow(canvas, &layout, &bounds, vertical_align, shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
Some(state),
|
||||
None,
|
||||
render_to_surface(
|
||||
state,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
Some(fills_surface_id),
|
||||
None,
|
||||
fills_surface_id,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
0.0,
|
||||
|canvas| paint_vertical_fill(canvas, shape, &layout, &bounds, blur_filter.as_ref()),
|
||||
)?;
|
||||
|
||||
if let Some(layout) = layout.as_ref().filter(|_| !strokes.is_empty()) {
|
||||
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
|
||||
if !strokes.is_empty() {
|
||||
let selrect = shape.selrect();
|
||||
let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id);
|
||||
for stroke in &strokes {
|
||||
for stroke in strokes {
|
||||
text_vertical::paint_stroke(
|
||||
canvas,
|
||||
layout,
|
||||
&layout,
|
||||
&bounds,
|
||||
shape.vertical_align(),
|
||||
vertical_align,
|
||||
stroke,
|
||||
&selrect,
|
||||
blur_filter.as_ref(),
|
||||
@ -84,16 +75,8 @@ pub fn render_vertical_text(
|
||||
}
|
||||
|
||||
if state.options.is_text_grid_visible() {
|
||||
let owned_layout;
|
||||
let grid_layout = match layout.as_ref() {
|
||||
Some(layout) => layout,
|
||||
None => {
|
||||
owned_layout = text_vertical::layout_for_box(text_content, bounds.height());
|
||||
&owned_layout
|
||||
}
|
||||
};
|
||||
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
|
||||
text_vertical::paint_grid(canvas, grid_layout, &bounds, shape.vertical_align());
|
||||
text_vertical::paint_grid(canvas, &layout, &bounds, vertical_align);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -327,6 +310,70 @@ pub fn render_with_bounds_outset_overlay_emoji(
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `paint` on `target_surface`. With a blur, it paints into a filter
|
||||
/// surface sized to the blurred text bounds (grown by `stroke_bounds_outset`)
|
||||
/// when those bounds are usable.
|
||||
fn render_to_surface(
|
||||
render_state: &mut RenderState,
|
||||
shape: &Shape,
|
||||
target_surface: SurfaceId,
|
||||
blur: Option<&ImageFilter>,
|
||||
stroke_bounds_outset: f32,
|
||||
mut paint: impl FnMut(&Canvas),
|
||||
) -> Result<()> {
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut text_bounds = shape
|
||||
.get_text_content()
|
||||
.calculate_bounds(shape, false)
|
||||
.to_rect();
|
||||
if stroke_bounds_outset > 0.0 {
|
||||
text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset));
|
||||
}
|
||||
let bounds = blur_filter.compute_fast_bounds(text_bounds);
|
||||
if bounds.is_finite()
|
||||
&& bounds.width() > 0.0
|
||||
&& bounds.height() > 0.0
|
||||
&& filters::render_with_filter_surface(
|
||||
render_state,
|
||||
bounds,
|
||||
target_surface,
|
||||
|state, temp_surface| {
|
||||
paint(state.surfaces.canvas(temp_surface));
|
||||
Ok(())
|
||||
},
|
||||
)?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
paint(render_state.surfaces.canvas_and_mark_dirty(target_surface));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill pass of vertical text: `layout` painted inside the shape's layer
|
||||
/// blur.
|
||||
pub fn paint_vertical_fill(
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
layout: &text_vertical::VerticalLayout,
|
||||
bounds: &Rect,
|
||||
blur: Option<&ImageFilter>,
|
||||
) {
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut blur_paint = Paint::default();
|
||||
blur_paint.set_image_filter(blur_filter.clone());
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&shape.layer_bounds())
|
||||
.paint(&blur_paint),
|
||||
);
|
||||
}
|
||||
text_vertical::paint_layout(canvas, layout, bounds, shape.vertical_align());
|
||||
if blur.is_some() {
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_with_bounds_outset_inner(
|
||||
render_state: Option<&mut RenderState>,
|
||||
@ -342,55 +389,25 @@ fn render_with_bounds_outset_inner(
|
||||
overlay_emoji: bool,
|
||||
) -> Result<()> {
|
||||
if let Some(render_state) = render_state {
|
||||
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
|
||||
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut text_bounds = shape
|
||||
.get_text_content()
|
||||
.calculate_bounds(shape, false)
|
||||
.to_rect();
|
||||
if stroke_bounds_outset > 0.0 {
|
||||
text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset));
|
||||
}
|
||||
let bounds = blur_filter.compute_fast_bounds(text_bounds);
|
||||
if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 {
|
||||
let blur_filter_clone = blur_filter.clone();
|
||||
if filters::render_with_filter_surface(
|
||||
render_state,
|
||||
bounds,
|
||||
target_surface,
|
||||
|state, temp_surface| {
|
||||
let temp_canvas = state.surfaces.canvas(temp_surface);
|
||||
render_text_on_canvas(
|
||||
temp_canvas,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
shadow,
|
||||
Some(&blur_filter_clone),
|
||||
fill_inset,
|
||||
layer_opacity,
|
||||
false,
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
|
||||
render_text_on_canvas(
|
||||
canvas,
|
||||
return render_to_surface(
|
||||
render_state,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
shadow,
|
||||
surface_id.unwrap_or(SurfaceId::Fills),
|
||||
blur,
|
||||
fill_inset,
|
||||
layer_opacity,
|
||||
false,
|
||||
stroke_bounds_outset,
|
||||
|canvas| {
|
||||
render_text_on_canvas(
|
||||
canvas,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
shadow,
|
||||
blur,
|
||||
fill_inset,
|
||||
layer_opacity,
|
||||
false,
|
||||
)
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(canvas) = canvas {
|
||||
|
||||
@ -274,12 +274,13 @@ fn calculate_cursor_rect(
|
||||
|
||||
// Cursor offsets count source characters; the laid-out paragraph
|
||||
// indexes the transformed, kinsoku-shifted builder text.
|
||||
let offsets = crate::shapes::HorizontalOffsets::new(para);
|
||||
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
|
||||
// Empty paragraph - use default height
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
} else if char_pos == 0 {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
0..crate::shapes::horizontal_source_to_builder(para, 1),
|
||||
0..offsets.source_to_builder(1),
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
@ -290,11 +291,8 @@ fn calculate_cursor_rect(
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
let last_start = crate::shapes::horizontal_source_to_builder(
|
||||
para,
|
||||
para_char_count.saturating_sub(1),
|
||||
);
|
||||
let last_end = crate::shapes::horizontal_source_to_builder(para, para_char_count);
|
||||
let last_start = offsets.source_to_builder(para_char_count.saturating_sub(1));
|
||||
let last_end = offsets.source_to_builder(para_char_count);
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
last_start..last_end,
|
||||
RectHeightStyle::Max,
|
||||
@ -314,8 +312,8 @@ fn calculate_cursor_rect(
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else {
|
||||
let start = crate::shapes::horizontal_source_to_builder(para, char_pos);
|
||||
let end = crate::shapes::horizontal_source_to_builder(para, char_pos + 1);
|
||||
let start = offsets.source_to_builder(char_pos);
|
||||
let end = offsets.source_to_builder(char_pos + 1);
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
start..end,
|
||||
RectHeightStyle::Max,
|
||||
@ -411,8 +409,8 @@ fn calculate_selection_rects(
|
||||
));
|
||||
}
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
for builder_range in
|
||||
crate::shapes::horizontal_normal_selection_ranges(para, range_start, range_end)
|
||||
for builder_range in crate::shapes::HorizontalOffsets::new(para)
|
||||
.normal_selection_ranges(para, range_start, range_end)
|
||||
{
|
||||
for text_box in laid_out_para.get_rects_for_range(
|
||||
builder_range,
|
||||
|
||||
@ -3,8 +3,7 @@ use skia_safe::{self as skia, Canvas, Paint, RRect};
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{
|
||||
circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path,
|
||||
BlurType, Fill, Frame, ParagraphBuilderGroup, Path, Rect, Shape, Stroke, StrokeKind,
|
||||
StrokeStyle, TextContent, Type,
|
||||
BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, TextContent, Type,
|
||||
};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
@ -58,58 +57,35 @@ impl<'a> VectorRenderer<'a> {
|
||||
shape.image_filter(1.)
|
||||
}
|
||||
|
||||
/// Vertical text: shadows, fill and strokes painted from one layout.
|
||||
fn draw_vertical_text(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
text_content: &TextContent,
|
||||
paragraph_builders: &mut [ParagraphBuilderGroup],
|
||||
blur_filter: Option<&skia::ImageFilter>,
|
||||
) -> Result<()> {
|
||||
) {
|
||||
use crate::shapes::text_vertical;
|
||||
|
||||
let bounds = text_content.bounds();
|
||||
let drop_shadows = shape.drop_shadow_paints();
|
||||
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
|
||||
let layout = (!drop_shadows.is_empty() || !strokes.is_empty())
|
||||
.then(|| text_vertical::layout_for_box(text_content, bounds.height()));
|
||||
let vertical_align = shape.vertical_align();
|
||||
let layout = text_vertical::layout_for_box(text_content, bounds.height());
|
||||
|
||||
if let Some(layout) = &layout {
|
||||
for shadow in &drop_shadows {
|
||||
text_vertical::paint_drop_shadow(
|
||||
self.canvas,
|
||||
layout,
|
||||
&bounds,
|
||||
shape.vertical_align(),
|
||||
shadow,
|
||||
);
|
||||
}
|
||||
for shadow in &shape.drop_shadow_paints() {
|
||||
text_vertical::paint_drop_shadow(self.canvas, &layout, &bounds, vertical_align, shadow);
|
||||
}
|
||||
|
||||
text::render_overlay_emoji(
|
||||
self.canvas,
|
||||
shape,
|
||||
paragraph_builders,
|
||||
None,
|
||||
blur_filter,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
if let Some(layout) = &layout {
|
||||
let selrect = shape.selrect();
|
||||
for stroke in strokes {
|
||||
text_vertical::paint_stroke(
|
||||
self.canvas,
|
||||
layout,
|
||||
&bounds,
|
||||
shape.vertical_align(),
|
||||
stroke,
|
||||
&selrect,
|
||||
blur_filter,
|
||||
);
|
||||
}
|
||||
text::paint_vertical_fill(self.canvas, shape, &layout, &bounds, blur_filter);
|
||||
let selrect = shape.selrect();
|
||||
for stroke in shape.visible_strokes().rev() {
|
||||
text_vertical::paint_stroke(
|
||||
self.canvas,
|
||||
&layout,
|
||||
&bounds,
|
||||
vertical_align,
|
||||
stroke,
|
||||
&selrect,
|
||||
blur_filter,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,18 +212,15 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
};
|
||||
|
||||
let text_content = text_content.new_bounds(shape.selrect());
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||
let blur_filter = self.layer_blur_filter(shape);
|
||||
|
||||
if text_content.is_vertical() {
|
||||
return self.draw_vertical_text(
|
||||
shape,
|
||||
&text_content,
|
||||
&mut paragraph_builders,
|
||||
blur_filter.as_ref(),
|
||||
);
|
||||
self.draw_vertical_text(shape, &text_content, blur_filter.as_ref());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||
|
||||
// Text drop shadows: one filter layer per shadow over fill + stroke
|
||||
// silhouettes (mirrors GPU `render_text_shadows`).
|
||||
let drop_shadows = shape.drop_shadow_paints();
|
||||
|
||||
@ -2168,26 +2168,44 @@ pub fn calculate_text_layout_data(
|
||||
// Ranges are in the builder-text (kinsoku-shifted)
|
||||
// space; exported positions are translated back to
|
||||
// original span-relative offsets through the map.
|
||||
let (_, offset_map) = text_para.layout_span_texts();
|
||||
let span_ranges = horizontal_span_ranges(text_para);
|
||||
let offsets = HorizontalOffsets::new(text_para);
|
||||
let offset_map = &offsets.offset_map;
|
||||
let entry =
|
||||
|span: usize, range: std::ops::Range<usize>, mut rect: Rect, direction| {
|
||||
rect.offset((x, current_y));
|
||||
PositionData {
|
||||
paragraph: paragraph_index as u32,
|
||||
span: span as u32,
|
||||
start_pos: range.start as u32,
|
||||
end_pos: range.end as u32,
|
||||
x: rect.x(),
|
||||
y: rect.y(),
|
||||
width: rect.width(),
|
||||
height: rect.height(),
|
||||
direction,
|
||||
}
|
||||
};
|
||||
let placeholder_rects = para_layout.paragraph.get_rects_for_placeholders();
|
||||
let mut placeholder_index = 0usize;
|
||||
for range in span_ranges {
|
||||
for range in &offsets.ranges {
|
||||
if range.warichu {
|
||||
if let Some(textbox) = placeholder_rects.get(placeholder_index) {
|
||||
let mut rect = textbox.rect;
|
||||
rect.offset((x, current_y));
|
||||
position_data.push(PositionData {
|
||||
paragraph: paragraph_index as u32,
|
||||
span: range.span as u32,
|
||||
start_pos: 0,
|
||||
end_pos: (range.source_end - range.source_start) as u32,
|
||||
x: rect.x(),
|
||||
y: rect.y(),
|
||||
width: rect.width(),
|
||||
height: rect.height(),
|
||||
direction: direction_to_int(TextDirection::LTR),
|
||||
});
|
||||
// One strip per sub-line: the top half holds the
|
||||
// first line, the bottom half the second.
|
||||
if let (Some(textbox), Some(span)) = (
|
||||
placeholder_rects.get(placeholder_index),
|
||||
text_para.children().get(range.span),
|
||||
) {
|
||||
let rect = textbox.rect;
|
||||
let text = span.apply_text_transform();
|
||||
let split = warichu_text_lines(&text).0.encode_utf16().count();
|
||||
let end = range.source_end - range.source_start;
|
||||
let half = rect.height() / 2.0;
|
||||
let ltr = direction_to_int(TextDirection::LTR);
|
||||
let top = Rect::from_xywh(rect.x(), rect.y(), rect.width(), half);
|
||||
let bottom =
|
||||
Rect::from_xywh(rect.x(), rect.y() + half, rect.width(), half);
|
||||
position_data.push(entry(range.span, 0..split, top, ltr));
|
||||
position_data.push(entry(range.span, split..end, bottom, ltr));
|
||||
}
|
||||
placeholder_index += 1;
|
||||
continue;
|
||||
@ -2201,7 +2219,7 @@ pub fn calculate_text_layout_data(
|
||||
|
||||
for textbox in rects {
|
||||
let direction = textbox.direct;
|
||||
let mut rect = textbox.rect;
|
||||
let rect = textbox.rect;
|
||||
let cy = rect.top + rect.height() / 2.0;
|
||||
|
||||
// Get byte positions from Skia's transformed text layout
|
||||
@ -2225,18 +2243,24 @@ pub fn calculate_text_layout_data(
|
||||
.position as usize,
|
||||
) - orig_span_start;
|
||||
|
||||
rect.offset((x, current_y));
|
||||
position_data.push(PositionData {
|
||||
paragraph: paragraph_index as u32,
|
||||
span: range.span as u32,
|
||||
start_pos: start_pos as u32,
|
||||
end_pos: end_pos as u32,
|
||||
x: rect.x(),
|
||||
y: rect.y(),
|
||||
width: rect.width(),
|
||||
height: rect.height(),
|
||||
direction: direction_to_int(direction),
|
||||
});
|
||||
position_data.push(entry(
|
||||
range.span,
|
||||
start_pos..end_pos,
|
||||
rect,
|
||||
direction_to_int(direction),
|
||||
));
|
||||
}
|
||||
}
|
||||
for placement in
|
||||
horizontal_emphasis_placements(text_para, &offsets, ¶_layout.paragraph)
|
||||
{
|
||||
if let Some(span) = text_para.children().get(placement.span) {
|
||||
position_data.push(entry(
|
||||
placement.span,
|
||||
placement.range.clone(),
|
||||
horizontal_emphasis_mark_box(span, &placement),
|
||||
super::text_vertical::DIRECTION_EMPHASIS_MARK,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2457,7 +2481,10 @@ mod tests {
|
||||
let para = test_paragraph(&["Añadir"]);
|
||||
for offset in 0..=6 {
|
||||
assert_eq!(para.char_offset_to_utf16(offset), offset);
|
||||
assert_eq!(horizontal_source_to_builder(¶, offset), offset);
|
||||
assert_eq!(
|
||||
HorizontalOffsets::new(¶).source_to_builder(offset),
|
||||
offset
|
||||
);
|
||||
assert_eq!(horizontal_builder_to_source(¶, offset), offset);
|
||||
}
|
||||
}
|
||||
@ -2491,9 +2518,9 @@ mod tests {
|
||||
|
||||
assert_eq!(para.char_offset_to_utf16(4), 4);
|
||||
assert_eq!(para.char_offset_to_utf16(6), 7);
|
||||
assert_eq!(horizontal_source_to_builder(¶, 4), 4);
|
||||
assert_eq!(horizontal_source_to_builder(¶, 5), 6);
|
||||
assert_eq!(horizontal_source_to_builder(¶, 6), 7);
|
||||
assert_eq!(HorizontalOffsets::new(¶).source_to_builder(4), 4);
|
||||
assert_eq!(HorizontalOffsets::new(¶).source_to_builder(5), 6);
|
||||
assert_eq!(HorizontalOffsets::new(¶).source_to_builder(6), 7);
|
||||
assert_eq!(horizontal_builder_to_source(¶, 7), 6);
|
||||
}
|
||||
|
||||
@ -2501,8 +2528,8 @@ mod tests {
|
||||
fn builder_range_covers_the_whole_glyph() {
|
||||
let para = test_paragraph(&["a😀b"]);
|
||||
let len_at = |offset| {
|
||||
horizontal_source_to_builder(¶, offset + 1)
|
||||
- horizontal_source_to_builder(¶, offset)
|
||||
HorizontalOffsets::new(¶).source_to_builder(offset + 1)
|
||||
- HorizontalOffsets::new(¶).source_to_builder(offset)
|
||||
};
|
||||
assert_eq!(len_at(0), 1);
|
||||
assert_eq!(len_at(1), 2);
|
||||
|
||||
@ -112,11 +112,134 @@ pub(crate) struct HorizontalSpanRange {
|
||||
pub style_anchor_start: usize,
|
||||
}
|
||||
|
||||
/// Offset mapping of one horizontally laid-out paragraph between source
|
||||
/// characters, the kinsoku-adjusted layout text and the paragraph builder
|
||||
/// text. Building it runs the kinsoku pass once; reuse it for every lookup
|
||||
/// into the same paragraph.
|
||||
pub(crate) struct HorizontalOffsets {
|
||||
/// Map between the transformed text and the kinsoku-adjusted text.
|
||||
pub(crate) offset_map: kinsoku::OffsetMap,
|
||||
pub(crate) ranges: Vec<HorizontalSpanRange>,
|
||||
/// Transformed-text UTF-16 offset of every source character boundary.
|
||||
boundaries: Vec<usize>,
|
||||
}
|
||||
|
||||
impl HorizontalOffsets {
|
||||
pub(crate) fn new(paragraph: &Paragraph) -> Self {
|
||||
let (span_texts, offset_map) = paragraph.layout_span_texts();
|
||||
let ranges = span_ranges(paragraph, span_texts, &offset_map);
|
||||
Self {
|
||||
offset_map,
|
||||
ranges,
|
||||
boundaries: source_char_boundaries(paragraph),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder-text UTF-16 offset of a paragraph source character offset.
|
||||
pub(crate) fn source_to_builder(&self, source_char_offset: usize) -> usize {
|
||||
let boundaries = &self.boundaries;
|
||||
let source_utf16 = boundaries
|
||||
.get(source_char_offset)
|
||||
.copied()
|
||||
.unwrap_or_else(|| boundaries.last().copied().unwrap_or(0));
|
||||
let Some(range) = self
|
||||
.ranges
|
||||
.iter()
|
||||
.find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end)
|
||||
else {
|
||||
return self
|
||||
.ranges
|
||||
.last()
|
||||
.map(|range| range.builder_end)
|
||||
.unwrap_or(0);
|
||||
};
|
||||
if range.warichu {
|
||||
return if source_utf16 >= range.source_end {
|
||||
range.builder_end
|
||||
} else {
|
||||
range.builder_start
|
||||
};
|
||||
}
|
||||
let shifted = self.offset_map.to_shifted(source_utf16);
|
||||
range.builder_start + shifted.saturating_sub(range.shifted_start)
|
||||
}
|
||||
|
||||
/// Paragraph source character offset of a builder-text UTF-16 offset.
|
||||
pub(crate) fn builder_to_source(&self, builder_offset: usize) -> usize {
|
||||
let source_utf16 = self
|
||||
.ranges
|
||||
.iter()
|
||||
.find(|range| {
|
||||
builder_offset >= range.builder_start && builder_offset <= range.builder_end
|
||||
})
|
||||
.map(|range| {
|
||||
if range.warichu {
|
||||
if builder_offset > range.builder_start {
|
||||
range.source_end
|
||||
} else {
|
||||
range.source_start
|
||||
}
|
||||
} else {
|
||||
let within = builder_offset
|
||||
.saturating_sub(range.builder_start)
|
||||
.min(range.builder_end - range.builder_start);
|
||||
self.offset_map.to_original(range.shifted_start + within)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.ranges
|
||||
.last()
|
||||
.map(|range| range.source_end)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
let boundaries = &self.boundaries;
|
||||
boundaries
|
||||
.partition_point(|boundary| *boundary < source_utf16)
|
||||
.min(boundaries.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Builder-text ranges of the selected source characters
|
||||
/// `[source_start, source_end)` outside warichu spans.
|
||||
pub(crate) fn normal_selection_ranges(
|
||||
&self,
|
||||
paragraph: &Paragraph,
|
||||
source_start: usize,
|
||||
source_end: usize,
|
||||
) -> Vec<std::ops::Range<usize>> {
|
||||
let mut span_start = 0usize;
|
||||
paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.filter_map(|span| {
|
||||
let span_end = span_start + span.text.chars().count();
|
||||
let selected_start = source_start.max(span_start);
|
||||
let selected_end = source_end.min(span_end);
|
||||
span_start = span_end;
|
||||
if span.is_warichu() || selected_start >= selected_end {
|
||||
return None;
|
||||
}
|
||||
Some(self.source_to_builder(selected_start)..self.source_to_builder(selected_end))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpanRange> {
|
||||
HorizontalOffsets::new(paragraph).ranges
|
||||
}
|
||||
|
||||
pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize {
|
||||
HorizontalOffsets::new(paragraph).builder_to_source(builder_offset)
|
||||
}
|
||||
|
||||
/// Ranges shared by layout, position-data and editor mapping. `shifted_*`
|
||||
/// addresses the normal kinsoku-adjusted paragraph text, while `builder_*`
|
||||
/// addresses the paragraph where a whole warichu span occupies one U+FFFC.
|
||||
pub(crate) fn horizontal_span_ranges(paragraph: &Paragraph) -> Vec<HorizontalSpanRange> {
|
||||
let (span_texts, offset_map) = paragraph.layout_span_texts();
|
||||
fn span_ranges(
|
||||
paragraph: &Paragraph,
|
||||
span_texts: Vec<String>,
|
||||
offset_map: &kinsoku::OffsetMap,
|
||||
) -> Vec<HorizontalSpanRange> {
|
||||
let mut builder_cursor = 0usize;
|
||||
let mut builder_byte_cursor = 0usize;
|
||||
let mut shifted_cursor = 0usize;
|
||||
@ -188,61 +311,6 @@ fn source_char_boundaries(paragraph: &Paragraph) -> Vec<usize> {
|
||||
boundaries
|
||||
}
|
||||
|
||||
pub(crate) fn horizontal_source_to_builder(
|
||||
paragraph: &Paragraph,
|
||||
source_char_offset: usize,
|
||||
) -> usize {
|
||||
let boundaries = source_char_boundaries(paragraph);
|
||||
let source_utf16 = boundaries
|
||||
.get(source_char_offset)
|
||||
.copied()
|
||||
.unwrap_or_else(|| boundaries.last().copied().unwrap_or(0));
|
||||
let (_, offset_map) = paragraph.layout_span_texts();
|
||||
let ranges = horizontal_span_ranges(paragraph);
|
||||
let Some(range) = ranges
|
||||
.iter()
|
||||
.find(|range| source_utf16 >= range.source_start && source_utf16 <= range.source_end)
|
||||
else {
|
||||
return ranges.last().map(|range| range.builder_end).unwrap_or(0);
|
||||
};
|
||||
if range.warichu {
|
||||
return if source_utf16 >= range.source_end {
|
||||
range.builder_end
|
||||
} else {
|
||||
range.builder_start
|
||||
};
|
||||
}
|
||||
let shifted = offset_map.to_shifted(source_utf16);
|
||||
range.builder_start + shifted.saturating_sub(range.shifted_start)
|
||||
}
|
||||
|
||||
pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset: usize) -> usize {
|
||||
let (_, offset_map) = paragraph.layout_span_texts();
|
||||
let ranges = horizontal_span_ranges(paragraph);
|
||||
let source_utf16 = ranges
|
||||
.iter()
|
||||
.find(|range| builder_offset >= range.builder_start && builder_offset <= range.builder_end)
|
||||
.map(|range| {
|
||||
if range.warichu {
|
||||
if builder_offset > range.builder_start {
|
||||
range.source_end
|
||||
} else {
|
||||
range.source_start
|
||||
}
|
||||
} else {
|
||||
let within = builder_offset
|
||||
.saturating_sub(range.builder_start)
|
||||
.min(range.builder_end - range.builder_start);
|
||||
offset_map.to_original(range.shifted_start + within)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| ranges.last().map(|range| range.source_end).unwrap_or(0));
|
||||
let boundaries = source_char_boundaries(paragraph);
|
||||
boundaries
|
||||
.partition_point(|boundary| *boundary < source_utf16)
|
||||
.min(boundaries.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Placeholder rect of each horizontal warichu span, keyed by span index.
|
||||
/// Tabs in normal spans are placeholders too, so they are skipped.
|
||||
fn horizontal_warichu_placeholders(
|
||||
@ -377,32 +445,6 @@ pub(crate) fn horizontal_warichu_range_rects(
|
||||
rects
|
||||
}
|
||||
|
||||
pub(crate) fn horizontal_normal_selection_ranges(
|
||||
paragraph: &Paragraph,
|
||||
source_start: usize,
|
||||
source_end: usize,
|
||||
) -> Vec<std::ops::Range<usize>> {
|
||||
let mut span_start = 0usize;
|
||||
paragraph
|
||||
.children()
|
||||
.iter()
|
||||
.filter_map(|span| {
|
||||
let span_end = span_start + span.text.chars().count();
|
||||
let selected_start = source_start.max(span_start);
|
||||
let selected_end = source_end.min(span_end);
|
||||
let warichu = span.is_warichu();
|
||||
span_start = span_end;
|
||||
if warichu || selected_start >= selected_end {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
horizontal_source_to_builder(paragraph, selected_start)
|
||||
..horizontal_source_to_builder(paragraph, selected_end),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Char index where a warichu run splits into its two sub-lines: the
|
||||
/// balanced midpoint (first line longer), nudged so the second sub-line
|
||||
/// does not start with a line-start-prohibited character and the first
|
||||
@ -499,10 +541,13 @@ pub(crate) fn emphasis_char_allowed(character: char) -> bool {
|
||||
&& !crate::shapes::japanese::classify(character).is_emphasis_prohibited()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct HorizontalEmphasisPlacement {
|
||||
pub(crate) span: usize,
|
||||
/// UTF-16 range of the base character in the span's transformed text.
|
||||
pub(crate) range: std::ops::Range<usize>,
|
||||
pub(crate) mark: char,
|
||||
/// Base character rect in the laid-out paragraph.
|
||||
pub(crate) rect: skia::Rect,
|
||||
}
|
||||
|
||||
@ -511,13 +556,13 @@ pub(crate) struct HorizontalEmphasisPlacement {
|
||||
/// character range keeps the marks attached to the actual laid-out glyphs.
|
||||
pub(crate) fn horizontal_emphasis_placements(
|
||||
paragraph: &Paragraph,
|
||||
offsets: &HorizontalOffsets,
|
||||
laid_out: &skia::textlayout::Paragraph,
|
||||
) -> Vec<HorizontalEmphasisPlacement> {
|
||||
let (_, offset_map) = paragraph.layout_span_texts();
|
||||
let ranges = horizontal_span_ranges(paragraph);
|
||||
let offset_map = &offsets.offset_map;
|
||||
let mut placements = Vec::new();
|
||||
|
||||
for range in ranges.iter().filter(|range| !range.warichu) {
|
||||
for range in offsets.ranges.iter().filter(|range| !range.warichu) {
|
||||
let Some(span) = paragraph.children().get(range.span) else {
|
||||
continue;
|
||||
};
|
||||
@ -550,6 +595,7 @@ pub(crate) fn horizontal_emphasis_placements(
|
||||
if let Some(rect) = scalar_rect {
|
||||
placements.push(HorizontalEmphasisPlacement {
|
||||
span: range.span,
|
||||
range: local_utf16..next_utf16,
|
||||
mark,
|
||||
rect,
|
||||
});
|
||||
@ -588,6 +634,23 @@ pub(crate) fn horizontal_annotation_over_top(rect: skia::Rect, font_size: f32) -
|
||||
rect.top.max(rect.bottom - font_size) - font_size * (3.0 / 14.0)
|
||||
}
|
||||
|
||||
/// Em box of a horizontal emphasis mark in the laid-out paragraph: centred
|
||||
/// over its base character, outside any stacked ruby layer.
|
||||
pub(crate) fn horizontal_emphasis_mark_box(
|
||||
span: &TextSpan,
|
||||
placement: &HorizontalEmphasisPlacement,
|
||||
) -> skia::Rect {
|
||||
let size = span.font_size * EMPHASIS_FONT_SCALE;
|
||||
let bottom = horizontal_annotation_over_top(placement.rect, span.font_size)
|
||||
- span.emphasis_ruby_offset();
|
||||
skia::Rect::from_xywh(
|
||||
placement.rect.center_x() - size / 2.0,
|
||||
bottom - size,
|
||||
size,
|
||||
size,
|
||||
)
|
||||
}
|
||||
|
||||
/// Paint horizontal emphasis marks (圏点 / bouten) above their base glyphs.
|
||||
/// The base paragraph retains its normal metrics; interlinear collision and
|
||||
/// automatic line-gap expansion remain a separate layout policy.
|
||||
@ -598,9 +661,9 @@ pub(crate) fn paint_horizontal_emphasis(
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let ranges = horizontal_span_ranges(paragraph);
|
||||
let placements = horizontal_emphasis_placements(paragraph, laid_out);
|
||||
for range in ranges.iter().filter(|range| !range.warichu) {
|
||||
let offsets = HorizontalOffsets::new(paragraph);
|
||||
let placements = horizontal_emphasis_placements(paragraph, &offsets, laid_out);
|
||||
for range in offsets.ranges.iter().filter(|range| !range.warichu) {
|
||||
let Some(span) = paragraph.children().get(range.span) else {
|
||||
continue;
|
||||
};
|
||||
@ -1162,15 +1225,15 @@ mod tests {
|
||||
let ranges = horizontal_span_ranges(¶graph);
|
||||
assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3);
|
||||
assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 0);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 4), 3);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 5), 4);
|
||||
assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(2), 0);
|
||||
assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(4), 3);
|
||||
assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(5), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 1), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 4);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 4), 5);
|
||||
assert_eq!(
|
||||
horizontal_normal_selection_ranges(¶graph, 1, 5),
|
||||
HorizontalOffsets::new(¶graph).normal_selection_ranges(¶graph, 1, 5),
|
||||
vec![3..4]
|
||||
);
|
||||
}
|
||||
@ -1180,9 +1243,9 @@ mod tests {
|
||||
init_state();
|
||||
let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0);
|
||||
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 1), 2);
|
||||
assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(1), 2);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 1);
|
||||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 3);
|
||||
assert_eq!(HorizontalOffsets::new(¶graph).source_to_builder(2), 3);
|
||||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 2);
|
||||
}
|
||||
|
||||
@ -1255,6 +1318,65 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_position_data_carries_warichu_lines_and_emphasis_marks() {
|
||||
init_state();
|
||||
let mut emphasized = make_span("A、B", 0.0);
|
||||
emphasized.text_emphasis = TextEmphasis::FilledDot;
|
||||
let mut warichu = make_span("割注入り", 0.0);
|
||||
warichu.warichu = true;
|
||||
let mut content = super::super::text::TextContent::new(
|
||||
crate::math::Rect::from_xywh(0.0, 0.0, 400.0, 100.0),
|
||||
crate::shapes::GrowType::Fixed,
|
||||
);
|
||||
content.add_paragraph(make_paragraph(vec![emphasized, warichu], 0.0));
|
||||
let mut shape = crate::shapes::Shape::new(Uuid::nil());
|
||||
shape.set_selrect(0.0, 0.0, 400.0, 100.0);
|
||||
let mut resources =
|
||||
crate::render::RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
||||
|
||||
let data = super::super::text::calculate_position_data(&shape, &content, false);
|
||||
|
||||
let warichu_lines: Vec<(u32, u32)> = data
|
||||
.iter()
|
||||
.filter(|entry| entry.span == 1)
|
||||
.map(|entry| (entry.start_pos, entry.end_pos))
|
||||
.collect();
|
||||
assert_eq!(warichu_lines, vec![(0, 2), (2, 4)]);
|
||||
let marks: Vec<(u32, u32)> = data
|
||||
.iter()
|
||||
.filter(|entry| entry.direction == super::super::text_vertical::DIRECTION_EMPHASIS_MARK)
|
||||
.map(|entry| (entry.start_pos, entry.end_pos))
|
||||
.collect();
|
||||
assert_eq!(marks, vec![(0, 1), (2, 3)], "A and B are marked; 、 is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_emphasis_mark_stacks_outside_auto_clearance_ruby() {
|
||||
let placement = HorizontalEmphasisPlacement {
|
||||
span: 0,
|
||||
range: 0..1,
|
||||
mark: '•',
|
||||
rect: skia::Rect::from_xywh(10.0, 40.0, 16.0, 20.0),
|
||||
};
|
||||
let plain = make_span("漢", 0.0);
|
||||
let mut stacked = make_span("漢", 0.0);
|
||||
stacked.ruby = "かん".to_string();
|
||||
stacked.annotation_clearance = AnnotationClearance::Auto;
|
||||
|
||||
let plain_box = horizontal_emphasis_mark_box(&plain, &placement);
|
||||
let stacked_box = horizontal_emphasis_mark_box(&stacked, &placement);
|
||||
|
||||
assert_eq!(plain_box.width(), plain.font_size * EMPHASIS_FONT_SCALE);
|
||||
assert_eq!(plain_box.center_x(), placement.rect.center_x());
|
||||
assert_eq!(
|
||||
stacked_box.bottom,
|
||||
plain_box.bottom - stacked.ruby_font_size(),
|
||||
"the mark sits outside the ruby layer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_emphasis_tracks_eligible_unicode_characters() {
|
||||
init_state();
|
||||
@ -1274,7 +1396,11 @@ mod tests {
|
||||
let mut laid_out = builder.build();
|
||||
laid_out.layout(200.0);
|
||||
|
||||
let placements = horizontal_emphasis_placements(¶graph, &laid_out);
|
||||
let placements = horizontal_emphasis_placements(
|
||||
¶graph,
|
||||
&HorizontalOffsets::new(¶graph),
|
||||
&laid_out,
|
||||
);
|
||||
assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each");
|
||||
assert!(placements
|
||||
.iter()
|
||||
|
||||
@ -12,7 +12,7 @@ use crate::shapes::{
|
||||
|
||||
use super::cells::Fonts;
|
||||
use super::flow::FlowCell;
|
||||
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalColumn};
|
||||
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalColumn, VerticalLayout};
|
||||
use super::shaping::{shape_segment, shape_segment_with_fallbacks, span_font_families, ShapedRun};
|
||||
|
||||
/// One shaped ruby glyph, retaining its fallback-font run and source range.
|
||||
@ -49,17 +49,15 @@ pub struct RubyCell {
|
||||
}
|
||||
|
||||
/// One emphasis mark (圏点 / bouten) drawn beside a base character. Kept out of
|
||||
/// `cells` like ruby, so base metrics, caret and position-data never see it.
|
||||
/// The mark glyph is the single-glyph `run`; it is centred on the base cell's
|
||||
/// flow extent and drawn in the column's right-side gutter.
|
||||
/// `cells` like ruby, so base metrics and caret geometry never see it. The
|
||||
/// mark glyph is the single-glyph `run`; it is centred on its base cell's flow
|
||||
/// extent and drawn in the column's right-side gutter with the cell's paint.
|
||||
pub struct EmphasisMark {
|
||||
pub run: usize,
|
||||
pub column: usize,
|
||||
/// Flow-axis top and extent of the annotated base cell.
|
||||
pub top: f32,
|
||||
pub extent: f32,
|
||||
pub paint: usize,
|
||||
/// Index of the annotated base cell in `VerticalLayout::cells`.
|
||||
pub cell: usize,
|
||||
pub font_size: f32,
|
||||
/// Cross-axis offset past a stacked ruby layer.
|
||||
pub outside_offset: f32,
|
||||
}
|
||||
|
||||
@ -488,6 +486,7 @@ pub(super) fn layout_emphasis(
|
||||
text_content: &TextContent,
|
||||
cells: &[VerticalCell],
|
||||
span_utf16_starts: &[Vec<usize>],
|
||||
span_transforms: &[Vec<AppliedTextTransform>],
|
||||
fonts: &Fonts,
|
||||
) -> (Vec<ShapedRun>, Vec<EmphasisMark>) {
|
||||
let mut emphasis_runs: Vec<ShapedRun> = Vec::new();
|
||||
@ -525,7 +524,9 @@ pub(super) fn layout_emphasis(
|
||||
let run_index = emphasis_runs.len();
|
||||
emphasis_runs.push(shaped.remove(0));
|
||||
let span_start = span_utf16_starts[paragraph_index][span_index];
|
||||
for cell in cells {
|
||||
// Cell offsets index the span's transformed text.
|
||||
let span_text = &span_transforms[paragraph_index][span_index].text;
|
||||
for (cell_index, cell) in cells.iter().enumerate() {
|
||||
if cell.paragraph != paragraph_index
|
||||
|| cell.span != span_index
|
||||
|| !matches!(
|
||||
@ -533,7 +534,7 @@ pub(super) fn layout_emphasis(
|
||||
CellKind::Upright { .. } | CellKind::SyntheticRotated { .. }
|
||||
)
|
||||
|| !utf16_range_allows_emphasis(
|
||||
&span.text,
|
||||
span_text,
|
||||
cell.start - span_start,
|
||||
cell.end - span_start,
|
||||
)
|
||||
@ -542,10 +543,7 @@ pub(super) fn layout_emphasis(
|
||||
}
|
||||
emphasis_marks.push(EmphasisMark {
|
||||
run: run_index,
|
||||
column: cell.column,
|
||||
top: cell.top,
|
||||
extent: cell.extent,
|
||||
paint: cell.paint,
|
||||
cell: cell_index,
|
||||
font_size: mark_font_size,
|
||||
outside_offset: span.emphasis_ruby_offset(),
|
||||
});
|
||||
@ -555,10 +553,16 @@ pub(super) fn layout_emphasis(
|
||||
(emphasis_runs, emphasis_marks)
|
||||
}
|
||||
|
||||
/// Cross-axis centre of an emphasis mark's gutter, past any stacked ruby.
|
||||
pub(super) fn emphasis_gutter_center(column: &VerticalColumn, mark: &EmphasisMark) -> f32 {
|
||||
/// Centre of an emphasis mark, relative to the layout's content origin: in
|
||||
/// its column's gutter past any stacked ruby, at the middle of its base cell.
|
||||
pub(super) fn emphasis_mark_center(layout: &VerticalLayout, mark: &EmphasisMark) -> (f32, f32) {
|
||||
let cell = &layout.cells[mark.cell];
|
||||
let base_font_size = mark.font_size / EMPHASIS_FONT_SCALE;
|
||||
column_base_center(column) + base_font_size / 2.0 + mark.outside_offset + mark.font_size / 2.0
|
||||
let x = column_base_center(&layout.columns[cell.column])
|
||||
+ base_font_size / 2.0
|
||||
+ mark.outside_offset
|
||||
+ mark.font_size / 2.0;
|
||||
(x, cell.top + cell.extent / 2.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -568,7 +572,9 @@ mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
use crate::shapes::{AnnotationClearance, GrowType, RubySize};
|
||||
use crate::shapes::{TextAlign, TextEmphasis, TextOrientation, TextSpan, VerticalAlign};
|
||||
use crate::shapes::{
|
||||
TextAlign, TextEmphasis, TextOrientation, TextSpan, TextTransform, VerticalAlign,
|
||||
};
|
||||
|
||||
fn emphasis_content(base: &str, emphasis: TextEmphasis) -> TextContent {
|
||||
spans_content(
|
||||
@ -650,6 +656,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_follows_the_transformed_text() {
|
||||
// Uppercase expands ß into SS, so the transformed cells S S 、 A are
|
||||
// offset from the source text ß、A.
|
||||
let content = spans_content(
|
||||
vec![TextSpan {
|
||||
text_emphasis: TextEmphasis::FilledDot,
|
||||
text_orientation: TextOrientation::Upright,
|
||||
text_transform: Some(TextTransform::Uppercase),
|
||||
..make_span("ß、A")
|
||||
}],
|
||||
400.0,
|
||||
);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
assert_eq!(layout.cells.len(), 4);
|
||||
let marked: Vec<usize> = layout.emphasis_marks.iter().map(|mark| mark.cell).collect();
|
||||
assert_eq!(marked, vec![0, 1, 3], "S, S and A are marked; 、 is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_emphasis_emits_no_marks() {
|
||||
let content = make_content(&["AB"], 400.0);
|
||||
|
||||
@ -463,8 +463,13 @@ pub fn layout_vertical(
|
||||
}
|
||||
|
||||
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 (emphasis_runs, emphasis_marks) = layout_emphasis(
|
||||
text_content,
|
||||
&cells,
|
||||
&span_utf16_starts,
|
||||
&span_transforms,
|
||||
&fonts,
|
||||
);
|
||||
|
||||
let height = cells
|
||||
.iter()
|
||||
|
||||
@ -46,7 +46,10 @@ 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,
|
||||
paint_drop_shadow, paint_grid, paint_layout, paint_stroke, paint_text_vertical,
|
||||
vertical_text_paths,
|
||||
};
|
||||
pub use positions::{
|
||||
caret_from_point, caret_rect, intersects, position_data, range_rects, DIRECTION_EMPHASIS_MARK,
|
||||
};
|
||||
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};
|
||||
|
||||
@ -7,7 +7,7 @@ use skia_safe::{
|
||||
use crate::math::Rect;
|
||||
use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign};
|
||||
|
||||
use super::annotations::{emphasis_gutter_center, ruby_strip_x, EmphasisMark, RubyCell};
|
||||
use super::annotations::{emphasis_mark_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};
|
||||
@ -222,10 +222,10 @@ fn emphasis_draw(
|
||||
) -> 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 (center_x, center_y) = emphasis_mark_center(layout, mark);
|
||||
let gutter_center = origin.0 + center_x;
|
||||
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 baseline = origin.1 + center_y - (metrics.ascent + metrics.descent) / 2.0;
|
||||
let advance = run.advances.first().copied().unwrap_or(0.0);
|
||||
Some(GlyphDraw::at(
|
||||
blob,
|
||||
@ -254,7 +254,7 @@ fn decoration_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(
|
||||
pub fn paint_layout(
|
||||
canvas: &Canvas,
|
||||
layout: &VerticalLayout,
|
||||
bounds: &Rect,
|
||||
@ -280,7 +280,7 @@ fn paint_layout(
|
||||
}
|
||||
for mark in &layout.emphasis_marks {
|
||||
if let Some(draw) = emphasis_draw(layout, mark, origin) {
|
||||
draw.draw(canvas, &layout.paints[mark.paint]);
|
||||
draw.draw(canvas, &layout.paints[layout.cells[mark.cell].paint]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -353,7 +353,7 @@ fn paths_from_layout(
|
||||
push_text_path(
|
||||
&mut paths,
|
||||
draw.into_path(),
|
||||
&layout.paints[mark.paint],
|
||||
&layout.paints[layout.cells[mark.cell].paint],
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ use skia_safe::{Contains, Point as SkPoint};
|
||||
use crate::math::Rect;
|
||||
use crate::shapes::{PositionData, VerticalAlign};
|
||||
|
||||
use super::annotations::ruby_strip_x;
|
||||
use super::annotations::{emphasis_mark_center, ruby_strip_x};
|
||||
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout};
|
||||
|
||||
/// Position-data `direction` value marking a vertical (vertical-rl)
|
||||
@ -19,15 +19,76 @@ pub const DIRECTION_VERTICAL_RL: u32 = 2;
|
||||
/// exact gutter placement the canvas paints.
|
||||
pub const DIRECTION_VERTICAL_RUBY: u32 = 3;
|
||||
|
||||
/// Position-data `direction` value marking one emphasis mark (圏点): the
|
||||
/// entry's offsets are its base character's and the geometry is the mark's
|
||||
/// em box, centred where the canvas paints it. Horizontal text uses it too.
|
||||
pub const DIRECTION_EMPHASIS_MARK: u32 = 4;
|
||||
|
||||
/// Paragraph source UTF-16 range of a `transformed` span-text range.
|
||||
fn source_utf16_range(
|
||||
layout: &VerticalLayout,
|
||||
paragraph: usize,
|
||||
span: usize,
|
||||
transformed: Range<usize>,
|
||||
) -> Range<usize> {
|
||||
let transformed_span_start = layout.span_utf16_starts[paragraph][span];
|
||||
let source_span_start = layout.span_source_utf16_starts[paragraph][span];
|
||||
let relative = layout.span_transforms[paragraph][span].source_utf16_range(
|
||||
transformed.start - transformed_span_start..transformed.end - transformed_span_start,
|
||||
);
|
||||
source_span_start + relative.start..source_span_start + relative.end
|
||||
}
|
||||
|
||||
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
|
||||
source_utf16_range(layout, cell.paragraph, cell.span, cell.start..cell.end)
|
||||
}
|
||||
|
||||
/// Entry of `cell`'s span covering the paragraph source range `source`.
|
||||
fn span_entry(
|
||||
layout: &VerticalLayout,
|
||||
cell: &VerticalCell,
|
||||
source: Range<usize>,
|
||||
(x, y, width, height): (f32, f32, f32, f32),
|
||||
direction: u32,
|
||||
) -> PositionData {
|
||||
let span_start = layout.span_source_utf16_starts[cell.paragraph][cell.span];
|
||||
PositionData {
|
||||
paragraph: cell.paragraph as u32,
|
||||
span: cell.span as u32,
|
||||
start_pos: (source.start - span_start) as u32,
|
||||
end_pos: (source.end - span_start) as u32,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
|
||||
/// The two sub-line strips of a warichu cell: the first on the right half of
|
||||
/// the base band, the second on the left, each holding its own characters.
|
||||
fn warichu_entries(
|
||||
layout: &VerticalLayout,
|
||||
cell: &VerticalCell,
|
||||
first_chars: usize,
|
||||
origin: (f32, f32),
|
||||
) -> [PositionData; 2] {
|
||||
let column = &layout.columns[cell.column];
|
||||
let center = origin.0 + column_base_center(column);
|
||||
let half = cell.font_size / 2.0;
|
||||
let split = cell.start + first_chars;
|
||||
let line = |range: Range<usize>, x: f32| {
|
||||
let source = source_utf16_range(layout, cell.paragraph, cell.span, range);
|
||||
let rect = (x, origin.1 + cell.top, half, cell.extent);
|
||||
span_entry(layout, cell, source, rect, DIRECTION_VERTICAL_RL)
|
||||
};
|
||||
[
|
||||
line(cell.start..split, center),
|
||||
line(split..cell.end, center - half),
|
||||
]
|
||||
}
|
||||
|
||||
/// Position-data entries for the v2 editor / exports: consecutive cells of
|
||||
@ -43,6 +104,16 @@ pub fn position_data(
|
||||
let mut i = 0;
|
||||
while i < layout.cells.len() {
|
||||
let first = &layout.cells[i];
|
||||
if let CellKind::Warichu { first_chars, .. } = first.kind {
|
||||
result.extend(warichu_entries(
|
||||
layout,
|
||||
first,
|
||||
first_chars,
|
||||
(origin_x, origin_y),
|
||||
));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut source_range = cell_source_utf16_range(layout, first);
|
||||
let mut bottom = first.top + first.extent;
|
||||
let mut j = i + 1;
|
||||
@ -51,6 +122,7 @@ pub fn position_data(
|
||||
if next.paragraph == first.paragraph
|
||||
&& next.span == first.span
|
||||
&& next.column == first.column
|
||||
&& !matches!(next.kind, CellKind::Warichu { .. })
|
||||
{
|
||||
let next_source = cell_source_utf16_range(layout, next);
|
||||
source_range.start = source_range.start.min(next_source.start);
|
||||
@ -62,20 +134,21 @@ pub fn position_data(
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
// Base text occupies the base sub-band; any ruby gutter is excluded
|
||||
// so the editor overlay and selection track the glyphs.
|
||||
let rect = (
|
||||
origin_x + column.x,
|
||||
origin_y + first.top,
|
||||
column.base_width,
|
||||
bottom - first.top,
|
||||
);
|
||||
result.push(span_entry(
|
||||
layout,
|
||||
first,
|
||||
source_range,
|
||||
rect,
|
||||
DIRECTION_VERTICAL_RL,
|
||||
));
|
||||
i = j;
|
||||
}
|
||||
|
||||
@ -106,6 +179,26 @@ pub fn position_data(
|
||||
direction: DIRECTION_VERTICAL_RUBY,
|
||||
});
|
||||
}
|
||||
|
||||
for mark in &layout.emphasis_marks {
|
||||
let cell = &layout.cells[mark.cell];
|
||||
let (center_x, center_y) = emphasis_mark_center(layout, mark);
|
||||
let size = mark.font_size;
|
||||
let rect = (
|
||||
origin_x + center_x - size / 2.0,
|
||||
origin_y + center_y - size / 2.0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
let source = cell_source_utf16_range(layout, cell);
|
||||
result.push(span_entry(
|
||||
layout,
|
||||
cell,
|
||||
source,
|
||||
rect,
|
||||
DIRECTION_EMPHASIS_MARK,
|
||||
));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@ -380,7 +473,8 @@ mod tests {
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
use crate::shapes::{
|
||||
TextCombineUpright, TextOrientation, TextPositionWithAffinity, TextTransform,
|
||||
TextCombineUpright, TextEmphasis, TextOrientation, TextPositionWithAffinity, TextSpan,
|
||||
TextTransform,
|
||||
};
|
||||
use crate::wasm::text::helpers as text_helpers;
|
||||
|
||||
@ -436,6 +530,62 @@ mod tests {
|
||||
assert!(rect.height() < 0.01 || rect.top >= cell.top + cell.extent - 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_emits_one_strip_per_warichu_sub_line() {
|
||||
let content = warichu_content("あいう、えお", 400.0);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let data = position_data(&layout, &content.bounds(), VerticalAlign::Top);
|
||||
|
||||
let strips: Vec<&PositionData> = data
|
||||
.iter()
|
||||
.filter(|entry| entry.direction == DIRECTION_VERTICAL_RL)
|
||||
.collect();
|
||||
assert_eq!(strips.len(), 2);
|
||||
assert_eq!((strips[0].start_pos, strips[0].end_pos), (0, 4));
|
||||
assert_eq!((strips[1].start_pos, strips[1].end_pos), (4, 6));
|
||||
assert!(
|
||||
strips[0].x > strips[1].x,
|
||||
"the first sub-line reads first, on the right"
|
||||
);
|
||||
assert!((strips[0].width - EM / 2.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_data_emits_each_emphasis_mark_box() {
|
||||
let content = spans_content(
|
||||
vec![TextSpan {
|
||||
text_emphasis: TextEmphasis::FilledDot,
|
||||
text_orientation: TextOrientation::Upright,
|
||||
..make_span("A、B")
|
||||
}],
|
||||
400.0,
|
||||
);
|
||||
let layout = layout_content(&content, 400.0);
|
||||
let data = position_data(&layout, &content.bounds(), VerticalAlign::Top);
|
||||
|
||||
let marks: Vec<&PositionData> = data
|
||||
.iter()
|
||||
.filter(|entry| entry.direction == DIRECTION_EMPHASIS_MARK)
|
||||
.collect();
|
||||
let ranges: Vec<(u32, u32)> = marks.iter().map(|m| (m.start_pos, m.end_pos)).collect();
|
||||
assert_eq!(
|
||||
ranges,
|
||||
vec![(0, 1), (2, 3)],
|
||||
"A and B are marked; 、 is not"
|
||||
);
|
||||
let base = data
|
||||
.iter()
|
||||
.find(|entry| entry.direction == DIRECTION_VERTICAL_RL)
|
||||
.expect("base strip");
|
||||
for mark in marks {
|
||||
assert!((mark.width - EM / 2.0).abs() < 0.01);
|
||||
assert!(
|
||||
mark.x >= base.x + base.width - 0.01,
|
||||
"marks sit in the gutter right of the base band"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_cell_carries_kinsoku_split() {
|
||||
let content = warichu_content("あいう、えお", 400.0);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user