Feature improvements

This commit is contained in:
alonso.torres 2026-10-01 13:45:05 +02:00
parent 11d4cc6459
commit c6ce618bab
16 changed files with 1012 additions and 785 deletions

View File

@ -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]]])

View File

@ -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

View File

@ -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])])]))]]))

View File

@ -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))))

View File

@ -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:&quot;vpal&quot;" 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"

View File

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

View File

@ -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 {

View File

@ -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,

View File

@ -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();

View File

@ -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, &para_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(&para, offset), offset);
assert_eq!(
HorizontalOffsets::new(&para).source_to_builder(offset),
offset
);
assert_eq!(horizontal_builder_to_source(&para, 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(&para, 4), 4);
assert_eq!(horizontal_source_to_builder(&para, 5), 6);
assert_eq!(horizontal_source_to_builder(&para, 6), 7);
assert_eq!(HorizontalOffsets::new(&para).source_to_builder(4), 4);
assert_eq!(HorizontalOffsets::new(&para).source_to_builder(5), 6);
assert_eq!(HorizontalOffsets::new(&para).source_to_builder(6), 7);
assert_eq!(horizontal_builder_to_source(&para, 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(&para, offset + 1)
- horizontal_source_to_builder(&para, offset)
HorizontalOffsets::new(&para).source_to_builder(offset + 1)
- HorizontalOffsets::new(&para).source_to_builder(offset)
};
assert_eq!(len_at(0), 1);
assert_eq!(len_at(1), 2);

View File

@ -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(&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!(HorizontalOffsets::new(&paragraph).source_to_builder(2), 0);
assert_eq!(HorizontalOffsets::new(&paragraph).source_to_builder(4), 3);
assert_eq!(HorizontalOffsets::new(&paragraph).source_to_builder(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),
HorizontalOffsets::new(&paragraph).normal_selection_ranges(&paragraph, 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(&paragraph, 1), 2);
assert_eq!(HorizontalOffsets::new(&paragraph).source_to_builder(1), 2);
assert_eq!(horizontal_builder_to_source(&paragraph, 2), 1);
assert_eq!(horizontal_source_to_builder(&paragraph, 2), 3);
assert_eq!(HorizontalOffsets::new(&paragraph).source_to_builder(2), 3);
assert_eq!(horizontal_builder_to_source(&paragraph, 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(&paragraph, &laid_out);
let placements = horizontal_emphasis_placements(
&paragraph,
&HorizontalOffsets::new(&paragraph),
&laid_out,
);
assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each");
assert!(placements
.iter()

View File

@ -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);

View File

@ -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()

View File

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

View File

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

View File

@ -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);