fix comments

This commit is contained in:
alonso.torres 2026-10-02 09:39:09 +02:00
parent c6ce618bab
commit 116c82572f
37 changed files with 599 additions and 839 deletions

View File

@ -163,13 +163,9 @@
(def ^:private unicode-ranges
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
;; Han ideographs are shared by Japanese/Chinese/Korean (Han
;; unification) and cannot identify a language by themselves; they
;; are resolved to a concrete language by `resolve-ambiguous-cjk`.
;; Han ideographs, shared by all CJK languages; see `resolve-ambiguous-cjk`.
:han #"[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]"
;; CJK symbols/punctuation (U+3000-303F) and half/full-width forms
;; (U+FF01-FF65, U+FFE0-FFEE) are likewise shared across
;; Japanese/Chinese/Korean; resolved by `resolve-ambiguous-cjk`.
;; CJK punctuation and half/full-width forms, shared; see `resolve-ambiguous-cjk`.
:cjk-punctuation #"[\u3000-\u303F\uFF01-\uFF65\uFFE0-\uFFEE]"
:korean #"[\uAC00-\uD7AF]"
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"

View File

@ -9,16 +9,14 @@
[app.common.data.macros :as dm]
[cuerdas.core :as str]))
;; Vertical writing (tategaki). Absent values behave as "horizontal-tb"
;; and "mixed", so plain horizontal text never stores these attrs.
;; Vertical writing (tategaki); absent means "horizontal-tb" and "mixed".
(def text-writing-mode-attrs
[:writing-mode])
(def text-orientation-attrs
[:text-orientation])
;; Paragraph attrs that act as whole-shape properties: every paragraph must
;; carry the first paragraph's value, since that one decides the flow.
;; Whole-shape paragraph attrs: every paragraph carries the first one's value.
(def whole-shape-paragraph-attrs
(into text-writing-mode-attrs text-orientation-attrs))
@ -38,16 +36,14 @@
:ruby-overhang
:ruby-side])
;; Warichu (割注): the span renders as two half-size lines stacked inline
;; within one column position. Values "warichu" / "none"; absent means off.
;; Warichu (割注): two half-size lines in one line position; "warichu" or "none".
(def text-warichu-attrs
[:warichu])
(def text-font-features-attrs
[:font-features])
;; Annotation collision policy. "none" preserves the explicit line height;
;; "auto" reserves an additional half-em layer for ruby and emphasis.
;; "auto" adds a half-em layer per ruby/emphasis; "none" keeps the line height.
(def text-annotation-clearance-attrs
[:annotation-clearance])
@ -90,8 +86,7 @@
0.5))
(defn visible-ruby
"Ruby annotation text of a text node, or nil when it has none or the
annotation is hidden."
"Ruby annotation text of a text node, or nil when absent or hidden."
[node]
(let [ruby (:ruby node)]
(when (and (string? ruby)
@ -100,8 +95,8 @@
ruby)))
(defn warichu-text?
"True when a text node renders as warichu: it needs two characters to fill
its two sub-lines."
"True when a text node renders as warichu, which needs at least two
characters for its two sub-lines."
[node]
(let [text (:text node)]
(and (= "warichu" (:warichu node))

View File

@ -173,9 +173,7 @@
height (dm/get-prop shape :height)
content (get shape :content)
;; Vertical writing anchors columns to the box edges, so the oversized
;; auto-grow box used to avoid horizontal wrapping/clipping would push
;; the content off-position. Use the real selrect size instead.
;; Vertical text anchors columns to the box edges, so it skips the oversized auto-grow box.
vertical? (wm/vertical-text-content? content)
[colors _color-mapping color-mapping-inverse] (retrieve-colors shape)]

View File

@ -82,9 +82,7 @@
content (if is-code (legacy.txt/index-content content) content)
;; Vertical writing anchors columns to the box edges; the oversized
;; auto-grow box (used to avoid horizontal wrapping) would push content
;; off-position, so use the real selrect size instead.
;; Vertical text anchors columns to the box edges, so it skips the oversized auto-grow box.
vertical? (wm/vertical-text-content? content)
style

View File

@ -23,8 +23,7 @@
(generate-root-styles props node false))
([{:keys [width height]} node code?]
(let [valign (:vertical-align node "top")
;; Mirroring the shape's writing mode on the root makes paragraph
;; blocks stack right-to-left.
;; The root writing mode makes paragraph blocks stack right-to-left.
writing-mode (wm/content-writing-mode node)
base #js {:height (when-not code? (fmt/format-pixels height))
:width (when-not code? (fmt/format-pixels width))
@ -123,8 +122,8 @@
(and (string? text-combine-upright) (pos? (alength text-combine-upright)))
(obj/set! "textCombineUpright" (css-text-combine-upright text-combine-upright))
;; Emphasis marks map to CSS text-emphasis-style: our kebab values
;; ("filled-dot") become the CSS "<fill> <shape>" pair ("filled dot").
;; Stored kebab values ("filled-dot") become the CSS "<fill> <shape>"
;; pair ("filled dot").
(set-value? text-emphasis)
(obj/set! "textEmphasis" (str/replace text-emphasis "-" " "))
@ -137,10 +136,8 @@
(some? line-height)
(obj/set! "lineHeight" line-height)
;; Warichu (割注) CSS emulation: an inline-block at half size whose
;; inline-size fits half the characters, so the browser wraps it into
;; two half-size sub-lines within one inline position in either writing
;; mode.
;; Warichu (割注): a half-size inline-block as wide as half the
;; characters, so the browser wraps it into two sub-lines.
(jl/warichu-text? data)
(-> (obj/set! "display" "inline-block")
(obj/set! "fontSize" (if (and (string? font-size) (pos? (alength font-size)))

View File

@ -232,38 +232,30 @@
(dm/str "mousetrap " (stl/css :text-editor-container)))
(def ^:private ime-surface-collapse
"Scale applied along the inline axis of the capture surface, so its text
(retained input or an in-flight composition) never moves the DOM caret."
"Inline-axis scale of the capture surface, so its text never moves the DOM caret."
0.001)
(defn- update-ime-caret!
"Move the hidden contenteditable capture surface onto the WASM caret so the
browser's IME candidate window opens next to the edited text. Returns the
caret rectangle it anchored to, or nil when there is none yet.
"Move the hidden capture surface onto the WASM caret so the IME candidate
window opens next to the edited text. Returns the caret rect, or nil when
there is none yet.
Must only be called while the editor is idle, never during or immediately
before an IME composition: real IMEs (ibus-mozc) abort and commit the
pending text on any mutation of the composing element, including style
writes on the keydown-229 that precedes compositionstart. The surface is
therefore kept on the caret at all times, updated after every
caret-affecting operation (click, arrows, typing, commit). During a
composition the window follows the WASM caret by moving the surface's
wrapper instead (see `follow-ime-caret!`).
Call only while idle, never during or right before a composition: real
IMEs (ibus-mozc) abort and commit on any mutation of the composing
element, including style writes on the keydown-229 before
compositionstart. So the surface follows the caret after every
caret-affecting operation, and during a composition only its wrapper
moves (see `follow-ime-caret!`).
The browser anchors the candidate window to the DOM caret inside the
surface. The surface is collapsed along the inline axis, so neither the text
it retains between keystrokes (see `keep-input-alive`) nor the composition
moves that caret. The block axis keeps the caret cell size (font-size = line
height horizontally, column width vertically).
The IME anchors its window to the DOM caret inside the surface. The
surface is collapsed along the inline axis, so neither its retained text
(see `keep-input-alive`) nor the composition moves that caret; the block
axis keeps the caret cell size. The window opens below the caret, so in
vertical text the surface (`vertical-rl`, `line-height: 1`) sits right of
the caret column and the window opens beside the composition.
The IME opens the window below the caret. Horizontally that clears the
current line. Vertically the text itself flows down, so the surface (with
`writing-mode: vertical-rl` and `line-height: 1`, making its column exactly
one caret column wide) sits just right of the caret column: the window opens
beside the composition instead of over it.
The caret rectangle is in the same page space as the overlay foreignObject,
whose top-left and width are passed as `origin`."
`origin` holds the overlay foreignObject's top-left and width, in the
same page space as the caret rect."
[^js node ^js wrapper origin]
(when (and (some? node) (some? origin))
(when-let [{:keys [x y width height] :as rect} (text-editor/text-editor-get-cursor-rect)]
@ -275,8 +267,8 @@
(set! (.-lineHeight style) "1")
(set! (.-transformOrigin style) "100% 0")
(set! (.-transform style) (dm/str "scaleY(" ime-surface-collapse ")"))
;; Surface column starts past the caret column's right edge, plus a
;; small gap so the window border stays clear of the glyphs.
;; Start past the caret column's right edge, plus a small gap so
;; the window border clears the glyphs.
(set! (.-left style) (dm/str (- (+ x (* 2.15 width)) (:x origin) (:width origin)) "px")))
(do
(set! (.-writingMode style) "")
@ -324,9 +316,9 @@
(defn- follow-ime-caret!
"Translate the surface wrapper by how far the WASM caret moved from `anchor`,
the caret rectangle the surface was placed on. Only the wrapper is written,
never the composing element, so it is safe while a composition is in flight.
Returns nil when the caret rectangle is not available yet."
the caret rect the surface was placed on. Writes only the wrapper, never the
composing element, so it is safe mid-composition. Returns nil when the caret
rect is not available yet."
[^js wrapper anchor]
(when (and (some? wrapper) (some? anchor))
(when-let [{:keys [x y]} (text-editor/text-editor-get-cursor-rect)]
@ -356,28 +348,23 @@
deferred-press-ref (mf/use-ref nil)
;; Overlay foreignObject top-left and width in page space, used to
;; translate the WASM caret rectangle into an offset inside it.
;; Overlay foreignObject top-left and width in page space (see `update-ime-caret!`).
origin-ref (mf/use-ref nil)
;; True between compositionstart and compositionend; guards the surface
;; against any repositioning while the IME owns it.
;; True between compositionstart and compositionend; the surface must not move then.
composing-ref (mf/use-ref false)
;; Positioned wrapper of the surface, moved during a composition so the
;; IME window follows the WASM caret (see `follow-ime-caret!`).
;; Surface wrapper, moved during a composition (see `follow-ime-caret!`).
wrapper-ref (mf/use-ref nil)
;; Caret rectangle the surface was last placed on while idle.
anchor-ref (mf/use-ref nil)
;; Surface text length (UTF-16) before the composition, and the current
;; composition text: locate the IME cursor within the preview.
;; Surface UTF-16 length before the composition, and the composition text.
composition-base-ref (mf/use-ref 0)
composition-text-ref (mf/use-ref "")
;; IME cursor offset last read from the DOM selection, nil right after a
;; text change until the next read.
;; IME cursor offset last read from the DOM selection; nil after a text change.
synced-offset-ref (mf/use-ref nil)
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
@ -409,10 +396,9 @@
"center" (+ y (/ (- selrect-height height) 2))
y)]
;; Vertical text anchors the IME window right of the caret column,
;; which for the rightmost column lies past the overlay. The
;; foreignObject is widened (not the clip) so the anchor stays inside
;; it; otherwise the browser scrolls it to reveal the caret and
;; cancels the offset.
;; past the overlay for the rightmost column. Widen the foreignObject
;; (not the clip) to keep the anchor inside it, or the browser scrolls
;; to reveal the caret and cancels the offset.
[(assoc selrect
:y y :width overlay-width :height max-height
:ime-width (cond-> overlay-width vertical? (+ viewport-width)))
@ -421,11 +407,9 @@
schedule-ime-caret!
(mf/use-fn
(fn []
;; Wait two frames so the pending WASM render rebuilds the text
;; layout the caret rect reads, then retry for a while: right after
;; mount the rect can stay unavailable until the first full layout.
;; Skipped when a composition began in the meantime — the surface
;; must not move while the IME owns it.
;; Wait two frames for the pending WASM render to rebuild the layout
;; the caret rect reads, then retry: after mount the rect can stay
;; unavailable until the first full layout. Skip while composing.
(letfn [(attempt [tries]
(when-not (mf/ref-val composing-ref)
(if-let [rect (update-ime-caret!
@ -442,9 +426,9 @@
schedule-ime-follow!
(mf/use-fn
(fn []
;; Same frame wait and retries as `schedule-ime-caret!`: the preview
;; update clears the text layout the caret rect reads until the
;; pending render rebuilds it. Only applies mid-composition.
;; Same wait and retries as `schedule-ime-caret!`: a preview update
;; clears the layout the caret rect reads until the next render.
;; Runs only mid-composition.
(letfn [(attempt [tries]
(when (and (mf/ref-val composing-ref)
(nil? (follow-ime-caret!
@ -456,10 +440,9 @@
(fn []
(js/requestAnimationFrame #(attempt 30)))))))
;; Moves the WASM caret inside the preview and the IME window with it.
;; Synchronous: the browser reports the caret bounds to the IME right
;; after the composition/selection update that triggered this, so a
;; deferred move would only reach the IME on its next update.
;; Moves the WASM caret inside the preview, and the IME window with it.
;; Synchronous: the browser reports caret bounds to the IME right after
;; the update that triggered this.
place-composition-caret!
(mf/use-fn
(fn [offset]
@ -467,10 +450,9 @@
(when (nil? (follow-ime-caret! (mf/ref-val wrapper-ref) (mf/ref-val anchor-ref)))
(schedule-ime-follow!))))
;; When only the IME cursor moves (switching clauses), the browser keeps
;; the DOM selection on it: mirror it on the WASM caret. The first read
;; after a text change is just the baseline; text changes place the
;; caret themselves (see on-composition-update).
;; Mirrors the IME cursor (the DOM selection) on the WASM caret when only
;; the cursor moves, e.g. switching clauses. The first read after a text
;; change only sets the baseline (see on-composition-update).
sync-composition-cursor!
(mf/use-fn
(fn []
@ -487,13 +469,9 @@
(place-composition-caret! cursor)
(wasm.api/render-text-editor-overlay!)))))))
;; Programmatic .focus() on the surface does not reliably fire
;; focus/focusin in Chromium (contenteditable inside an SVG
;; foreignObject): the node becomes document.activeElement but React's
;; on-focus never dispatches, leaving the WASM editor unfocused and
;; every caret-rect read null. So wherever we focus programmatically,
;; the WASM focus is established explicitly instead of relying on the
;; focus event.
;; In Chromium, .focus() on a contenteditable inside an SVG foreignObject
;; does not reliably fire focus/focusin, so React's on-focus never runs
;; and every caret-rect read is null. This sets the WASM focus itself.
focus-editor!
(mf/use-fn
(mf/deps shape-id)
@ -526,22 +504,19 @@
(mf/use-fn
(mf/deps shape-id)
(fn [event]
;; IME cancel (e.g. Escape on Linux ibus-mozc) fires compositionupdate
;; with an empty string; that must reach WASM to clear the preview text.
;; IME cancel (e.g. Escape in ibus-mozc) fires compositionupdate with
;; an empty string; WASM needs it to clear the preview.
;;
;; While the composition is in flight the browser owns the capture
;; surface, so this handler must not touch it: no clearing, no style
;; writes, and no store dispatch. A store sync re-renders this
;; component (shape content/name/dimensions, foreignObject
;; attributes, CSS vars), and any such churn makes a real IME commit
;; the pending kana and restart (typing "ni" commits ん then い
;; instead of composing に). The WASM editor keeps the preview in its
;; own state and paints it via the render request; the store is
;; synced once on compositionend. Only the surface wrapper moves, so
;; the IME window follows the WASM caret (wrapping, candidates).
;; The browser owns the capture surface mid-composition, so this
;; handler must not touch it: no clearing, no style writes, no store
;; dispatch. A store sync re-renders this component, and that makes
;; a real IME commit the pending kana and restart (typing "ni"
;; commits ん then い, not に). WASM keeps the preview in its own
;; state; the store syncs on compositionend. Only the surface wrapper
;; moves, so the IME window follows the WASM caret.
;;
;; The caret goes to the end of the part that changed: a typed kana,
;; a converted word, a picked candidate (even in a middle clause).
;; The caret goes to the end of the changed part: a typed kana, a
;; converted word, or a candidate picked in any clause.
(let [data (.-data event)]
(when (some? data)
(let [previous (mf/ref-val composition-text-ref)]
@ -615,11 +590,10 @@
on-key-down
(mf/use-fn
(fn [^js event]
;; IME keydowns (keyCode 229) must not touch the surface at all: by
;; the time the 229 reaches the DOM the IME already owns the surface,
;; and even a pre-compositionstart style write makes ibus abort and
;; commit. The surface is already sitting on the caret from the last
;; idle repositioning (see `update-ime-caret!`).
;; IME keydowns (keyCode 229) must not touch the surface: the IME
;; already owns it, and even a style write before compositionstart
;; makes ibus abort and commit. The surface already sits on the
;; caret (see `update-ime-caret!`).
(when (and (text-editor/text-editor-has-focus?)
(not (composing-event? event)))
(let [key (.-key event)
@ -718,9 +692,9 @@
;; Let contenteditable handle text input via on-input
:else nil)
;; Any handled key may have moved the caret; keep the surface on
;; it so the next IME sequence anchors correctly. Plain character
;; keys reschedule again from on-input after the insert.
;; A handled key may move the caret; keep the surface on it for
;; the next IME sequence. Character keys also reschedule from
;; on-input after the insert.
(schedule-ime-caret!)))))
;; Native `beforeinput` listener (see the use-effect that registers it).
@ -786,10 +760,9 @@
on-pointer-down
(mf/use-fn
(fn [^js event]
;; The capture surface is pointer-events:none (it sits on the caret,
;; not under the pointer), so it must be focused programmatically.
;; preventDefault stops the browser from moving focus to the body on
;; mousedown, which would blur the surface right back.
;; The capture surface is pointer-events:none, so focus it here.
;; preventDefault stops mousedown from moving focus to the body,
;; which would blur the surface.
(dom/prevent-default event)
(focus-editor!)
(when-not (secondary-button? event)
@ -1019,8 +992,7 @@
:on-pointer-move on-pointer-move
:on-pointer-up on-pointer-up
:on-context-menu on-context-menu
;; The hover cursor lives here: the capture surface below is
;; pointer-events:none so it never receives hover itself.
;; The hover cursor lives here: the capture surface is pointer-events:none.
:class (dm/str (cur/get-text (:rotation shape) vertical?)
" "
(stl/css :text-editor))
@ -1031,10 +1003,7 @@
{:ref contenteditable-ref
:contentEditable true
:suppressContentEditableWarning true
;; The surface retains typed text between keystrokes (see
;; keep-input-alive), so disable text assistance that would otherwise
;; rewrite that retained text and desync the WASM editor.
;; NOTE: this was already not working in v1/v2
;; Text assistance would rewrite the retained text (see keep-input-alive).
:spellCheck false
:autoCorrect "off"
:autoCapitalize "off"

View File

@ -50,8 +50,8 @@
:dimmed true})))
(defn japanese-layout-config-enabled?
"Japanese layout controls are available under the WASM renderer, when
enabled for the current file or globally in the user's profile."
"True under the WASM renderer when Japanese layout is enabled for the file
or in the user's profile."
[file-data profile]
(and ^boolean (wm/vertical-layout-active?)
(or (ctf/japanese-layout-enabled? file-data)
@ -63,7 +63,7 @@
(= "vertical-rl" (:writing-mode values)))
(defn proportional-metrics-feature
"Return the proportional metric feature relevant to the writing mode."
"Proportional metrics feature for the writing mode: `vpal` or `palt`."
[writing-mode]
(if (= writing-mode "vertical-rl") "vpal" "palt"))
@ -127,9 +127,8 @@
:label (tr "workspace.options.text-options.text-orientation-upright")
:icon i/text-orientation-upright}])
;; Warichu (割注): a span-scoped toggle that renders the selection as two
;; half-size lines within one inline position (top/bottom in horizontal
;; flow, right/left in vertical flow).
;; Warichu (割注): renders the selection as two half-size lines in one inline
;; position (top/bottom horizontally, right/left vertically).
(defn- warichu-options
[]
[{:value "none"
@ -142,9 +141,8 @@
:icon i/warichu}])
(mf/defc text-combine-upright-options*
;; Digit TCY can be applied across a shape because it discovers eligible
;; runs automatically. The unrestricted `all` value is only offered for an
;; explicit text range.
;; Digit TCY finds eligible runs itself, so it applies to a whole shape;
;; `all` is offered only for a text selection.
[{:keys [values on-change on-blur text-selection-active]}]
(let [text-combine-upright (radio-selected (:text-combine-upright values) "none")
digits? (case text-combine-upright
@ -176,8 +174,7 @@
false)
options
(mf/with-memo []
;; select* matches and reports options by :id, so the id is the
;; persisted attr value.
;; select* matches options by :id, so the id is the stored value.
[{:id "digits2"
:label (tr "workspace.options.text-options.text-combine-upright-digits-2")}
{:id "digits3"
@ -366,9 +363,8 @@
[:> ruby-customization-options* common-props]]]))
(mf/defc font-features-options*
;; Japanese proportional metric alternates. `palt` is typically used for
;; horizontal composition and `vpal` for vertical composition; the value is
;; span-scoped and passed through browser, export, and render-wasm paths.
;; Japanese proportional metric alternates, per span: `palt` for
;; horizontal text, `vpal` for vertical.
[{:keys [values on-change on-blur]}]
(let [writing-mode (:writing-mode values)
feature (proportional-metrics-feature writing-mode)
@ -450,8 +446,8 @@
:options (:writing-mode options)})]
(when ^boolean vertical?
[:*
;; The v2 editor can read the paragraph orientation back as an empty
;; string when it is unset; that (and nil) selects "mixed".
;; An unset orientation reads as nil, or "" in the v2 editor; both
;; select "mixed".
[:> attr-radio-options* (mf/spread-props common-props
{:attr :text-orientation
:default-value "mixed"

View File

@ -573,9 +573,8 @@
horizontal-resize?)
resize-direction (if horizontal-resize? :horizontal :vertical)]
(cond
;; Resizing auto-width on the wrap axis switches to auto-height.
;; The physical wrap axis is horizontal normally and vertical
;; under vertical writing.
;; Resizing auto-width along the wrap axis (vertical under
;; vertical writing) switches to auto-height.
(and (= shape-type :text)
(= grow-type :auto-width)
wrap-axis-resize?)

View File

@ -1407,14 +1407,13 @@
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
;; Fallback faces a composition is already waiting on, so each preview update
;; does not queue another relayout for them.
;; Fallback faces a composition waits on, so preview updates do not queue more relayouts.
(defonce ^:private composition-pending-faces (atom #{}))
(defn load-composition-fonts!
"Fetch the fallback faces (emoji, Noto, ...) that `text` needs and relayout
`shape-id` once they are stored. For text that only lives in WASM, such as an
IME composition preview; committed content loads its faces through
`shape-id` once they are stored. For text that lives only in WASM, such as
an IME composition preview; committed content loads its faces through
`set-shape-text-content`."
[shape-id text]
(let [emoji? (cfnt/contains-emoji? text)
@ -3025,8 +3024,7 @@
features))
;; Each warichu sub-line has its own entry, drawn at half size.
:warichu (when (= "warichu" (get element :warichu)) "warichu")
;; Horizontal position data has no separate ruby strip, so the base
;; entry carries the annotation for static SVG export.
;; Horizontal data has no ruby strip; the base entry carries ruby for SVG export.
:ruby (when (and (not vertical?) (seq element-text)) ruby)
:ruby-size (get element :ruby-size)
:ruby-align (get element :ruby-align)

View File

@ -699,8 +699,8 @@
(defn- span-japanese-styles
"Japanese span attributes with their defaults filled in. Every selection
snapshot carries all of them, so moving onto an unstyled span clears the
previous span's controls instead of leaving stale values behind."
snapshot carries all of them, so moving onto an unstyled span resets the
controls."
[span]
(reduce-kv
(fn [styles attr default]
@ -717,9 +717,8 @@
styles))
(defn- span-at-offset
"Return the span used by the WASM editor for a collapsed caret. At a span
boundary the preceding span wins, matching find_text_span_at_offset in
render-wasm."
"Span the WASM editor uses for a collapsed caret. At a span boundary the
preceding span wins, as in find_text_span_at_offset in render-wasm."
[paragraph offset]
(let [spans (:children paragraph)]
(loop [remaining-spans spans
@ -747,9 +746,9 @@
selected)))
(defn selection-japanese-styles
"Read Japanese span styles for a normalized WASM selection from Penpot's
cached content tree. A range spanning different values reports :multiple;
a caret reports the style of its current span."
"Japanese span styles of a WASM selection, read from the cached content.
A range with differing values reports :multiple; a caret reports its
span's style."
[content selection]
(when (and content selection)
(let [{:keys [start-para start-offset end-para end-offset]}
@ -782,7 +781,7 @@
selected-spans)))))
(defn text-editor-get-current-japanese-styles
"Return Japanese span styles for the active WASM editor selection."
"Japanese span styles of the active WASM editor selection."
[]
(when (wasm/ready?)
(let [shape-id (text-editor-get-active-shape-id)
@ -942,9 +941,9 @@
:content new-content})))))
(defn apply-paragraph-attrs-to-range
"Apply paragraph level attrs to the paragraphs `selection` touches; a
collapsed caret means just the one it sits in. Whole-shape attrs (writing
mode, orientation) go to every paragraph instead, a nil value removing them."
"Apply paragraph attrs to the paragraphs `selection` touches (a caret
touches one). Whole-shape attrs (writing mode, orientation) go to every
paragraph; a nil value removes them."
[content selection attrs]
(let [{:keys [start-para end-para]} (normalize-selection selection)
whole-attrs (select-keys attrs jl/whole-shape-paragraph-attrs)

View File

@ -6,8 +6,8 @@
(ns app.util.text.writing-mode
"Writing mode of text content as the active renderer lays it out. Only the
WASM renderer supports vertical writing; under the SVG renderer every text
flows horizontally, while the stored attrs are kept untouched."
WASM renderer supports vertical writing; the SVG renderer lays every text
out horizontally and leaves the stored attrs as they are."
(:require
[app.common.types.shape :as cts]
[app.common.types.text.japanese-layout :as jl]))
@ -31,8 +31,8 @@
(defn stale-vertical-layout?
"True when the content is stored as vertical but the active renderer lays
it out horizontally, so a layout computed by the WASM renderer (such as
`:position-data`) no longer matches."
it out horizontally, so a WASM-computed layout (such as `:position-data`)
does not match."
[content]
(and (not ^boolean (vertical-layout-active?))
(jl/vertical-text-content? content)))

View File

@ -124,8 +124,8 @@
content (selection 0 2 0 3)))))))
(t/deftest composition-caret-goes-to-the-end-of-the-changed-part
;; Typing appends, converting replaces everything, and picking a candidate
;; for a middle clause changes only that clause.
;; Typing appends, converting replaces all, and a candidate for a middle
;; clause changes only that clause.
(t/is (= 1 (v3-editor/changed-span-end "" "に")))
(t/is (= 2 (v3-editor/changed-span-end "に" "にほ")))
(t/is (= 3 (v3-editor/changed-span-end "にほんご" "日本語")))
@ -227,13 +227,11 @@
(t/is (= #{:japanese} (langs "デザイン"))))
(t/deftest classification-han-is-ambiguous
;; Kanji-only text (han-unification fixture) must NOT classify as a
;; concrete language; it is ambiguous Han.
;; Kanji-only text is ambiguous Han, not a concrete language.
(t/is (= #{:han} (langs "東京都渋谷区神南一丁目"))))
(t/deftest classification-cjk-punctuation
;; CJK punctuation and full-width forms match the shared class
;; (previously they matched no range at all).
;; CJK punctuation and full-width forms match the shared class.
(t/is (= #{:cjk-punctuation} (langs "、。「」『』()")))
(t/is (= #{:cjk-punctuation} (langs "!?:;123ABC"))))
@ -262,8 +260,7 @@
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "zh_cn"))))
(t/deftest resolve-han-only-defaults-to-chinese
;; Without kana/hangul and without a CJK locale, keep the previous
;; behavior (Noto Sans SC).
;; Without kana, hangul or a CJK locale, Han resolves to Chinese (Noto Sans SC).
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} "en")))
(t/is (= #{:chinese} (cfnt/resolve-ambiguous-cjk #{:han} nil))))

View File

@ -4327,32 +4327,31 @@ export interface Text extends ShapeBase {
verticalAlign: 'top' | 'center' | 'bottom' | null;
/**
* The writing mode of the text shape. `horizontal-tb` lays text out in
* horizontal lines; `vertical-rl` in vertical columns advancing right-to-left.
* Returns 'mixed' if paragraphs use different modes.
* The writing mode of the text shape: `horizontal-tb` (horizontal lines) or
* `vertical-rl` (vertical columns, right to left). `null` when unset, which
* means `horizontal-tb`. Returns 'mixed' if paragraphs use different modes.
*/
writingMode: 'horizontal-tb' | 'vertical-rl' | 'mixed' | null;
/**
* The orientation of characters in vertical writing. `mixed` rotates
* non-CJK runs sideways; `upright` keeps every character upright.
* Returns 'mixed' if paragraphs use different orientations.
* Character orientation in vertical writing: `mixed` turns non-CJK runs
* sideways; `upright` keeps every character upright. `null` when unset,
* which means `mixed`. Returns 'mixed' if paragraphs use different values.
*/
textOrientation: 'mixed' | 'upright' | null;
/**
* Combines the text shape upright in vertical writing. `all` draws the text
* as one upright composite; `digits` combines runs of 2-4 consecutive
* digits (`digits2`/`digits3` cap the run length at 2/3); `none` uses the
* normal vertical layout.
* Returns 'mixed' if text spans use different values.
* Tate-chu-yoko: sets text upright as one block in vertical writing. `all`
* combines the whole span; `digits` combines runs of 2-4 digits
* (`digits2`/`digits3` cap the run at 2/3); `none` (the default) turns it
* off. Returns 'mixed' if text spans use different values.
*/
textCombineUpright:
'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed' | null;
/**
* Emphasis marks (圏点 / bouten) drawn beside each base character, mirroring
* CSS `text-emphasis-style`. `none` removes them.
* Emphasis marks (圏点 / bouten) beside each base character, as in CSS
* `text-emphasis-style`. `none` (the default) removes them.
* Returns 'mixed' if text spans use different values.
*/
textEmphasis:
@ -4367,45 +4366,44 @@ export interface Text extends ShapeBase {
| null;
/**
* Warichu (割注): renders the span as two half-size lines stacked inline
* within one column position of the vertical flow. `none` disables it.
* Warichu (割注): sets the text as two half-size lines in one line position.
* `none` (the default) turns it off.
* Returns 'mixed' if text spans use different values.
*/
warichu: 'none' | 'warichu' | 'mixed' | null;
/**
* OpenType proportional alternate metrics for Japanese text. `palt` applies
* proportional alternate widths in horizontal writing; `vpal` applies
* proportional alternate widths in vertical writing. `none` disables them.
* OpenType proportional metrics for Japanese text: `palt` for horizontal
* writing, `vpal` for vertical. `none` (the default) turns them off.
* Returns 'mixed' if text spans use different values.
*/
fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null;
/**
* Controls annotation collision handling. `none` preserves the explicit
* line gap; `auto` reserves separate half-em layers for ruby and emphasis.
* Room for annotations: `auto` adds a half-em to the line height for each
* ruby or emphasis layer; `none` (the default) keeps the set line height.
* Returns 'mixed' if text spans use different values.
*/
annotationClearance: 'none' | 'auto' | 'mixed' | null;
/**
* Ruby (furigana) annotation shown over the base text in vertical writing.
* Set a string to annotate the selected span(s), or `null` to remove it.
* Ruby (furigana) annotation text for the base text. Set a string to
* annotate the whole text, or `null` to remove it.
* Returns 'mixed' if text spans carry different ruby values.
*/
ruby: string | null;
/** Ruby annotation size relative to its base text. */
/** Ruby size relative to the base text: `half` (default), `third` or `quarter`. */
rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null;
/** Distribution of ruby glyphs across the corresponding base text. */
/** How ruby glyphs spread over the base text; defaults to `space-around`. */
rubyAlign:
'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null;
/** Whether ruby may extend beyond the corresponding base text. */
/** Whether ruby may extend past its base text: `auto` (default) or `none`. */
rubyOverhang: 'auto' | 'none' | 'mixed' | null;
/** Annotation side: above/right (`over`) or below/left (`under`). */
/** Ruby side: `over` (above or right, the default) or `under` (below or left). */
rubySide: 'over' | 'under' | 'mixed' | null;
/**
@ -4534,8 +4532,8 @@ export interface TextRange {
warichu: 'none' | 'warichu' | 'mixed' | null;
/**
* OpenType proportional alternate metrics for Japanese text. It can be a
* specific feature or 'mixed' if multiple text spans are used.
* OpenType proportional metrics for the range: `palt` (horizontal), `vpal`
* (vertical) or `none`. Returns 'mixed' for different span values.
*/
fontFeatures: 'none' | 'palt' | 'vpal' | 'mixed' | null;
@ -4551,17 +4549,17 @@ export interface TextRange {
*/
ruby: string | 'mixed' | null;
/** Ruby annotation size relative to its base text. */
/** Ruby size relative to the base text: `half` (default), `third` or `quarter`. */
rubySize: 'half' | 'third' | 'quarter' | 'mixed' | null;
/** Distribution of ruby glyphs across the corresponding base text. */
/** How ruby glyphs spread over the base text; defaults to `space-around`. */
rubyAlign:
'space-around' | 'center' | 'start' | 'space-between' | 'mixed' | null;
/** Whether ruby may extend beyond the corresponding base text. */
/** Whether ruby may extend past its base text: `auto` (default) or `none`. */
rubyOverhang: 'auto' | 'none' | 'mixed' | null;
/** Annotation side: above/right (`over`) or below/left (`under`). */
/** Ruby side: `over` (above or right, the default) or `under` (below or left). */
rubySide: 'over' | 'under' | 'mixed' | null;
/**

View File

@ -1728,7 +1728,7 @@ impl RenderState {
// Plain fill (no strokes / parent shadows): reuse cached layout
// paragraphs when valid. Skip builder rebuild + Skia layout.
// The developer text grid is painted by the full text pass.
// The full text pass paints the developer text grid.
let can_use_layout_cache = !self.options.is_text_grid_visible()
&& !shape.has_visible_strokes()
&& parent_shadows.is_none()

View File

@ -121,10 +121,9 @@ impl FontStore {
Ok(())
}
/// Upgrade an already-uploaded family to participate in character
/// fallback. Font bytes are cached independently from the role in which a
/// face is used, so a family may first arrive as a document font and only
/// later be requested as a fallback.
/// Upgrade an already-uploaded family to take part in character fallback.
/// Font bytes are cached apart from a face's role, so a family may arrive
/// as a document font and later be requested as a fallback.
pub fn mark_as_fallback(&mut self, family: &FontFamily) {
if self.has_family(family, false) {
self.fallback_fonts.insert(format!("{}", family));

View File

@ -124,7 +124,7 @@ impl RenderOptions {
}
/// jlreq-style character-frame grid overlay for Japanese vertical
/// text: a developer aid, never part of exported output.
/// text: a developer aid, never exported.
pub fn is_text_grid_visible(&self) -> bool {
self.flags & TEXT_GRID_VISIBLE == TEXT_GRID_VISIBLE
}

View File

@ -17,9 +17,9 @@ use skia_safe::{
};
/// 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.
/// one layout. Rebinds the content 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,
@ -82,10 +82,10 @@ pub fn render_vertical_text(
Ok(())
}
/// Paint a viewport-only grid over SkParagraph horizontal text. Blue outlines
/// show line boxes, green boxes show the per-scalar tight rectangles returned
/// by SkParagraph, and amber rules show baselines. This mirrors the vertical
/// text grid while making horizontal annotation anchors inspectable.
/// Paint a viewport-only debug grid over SkParagraph horizontal text: blue
/// line boxes, green per-scalar tight rects from SkParagraph, amber
/// baselines. Matches the vertical text grid and shows horizontal annotation
/// anchors.
pub fn paint_horizontal_grid(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
let mut builders = text_content.paragraph_builder_group_from_text(None);
let layout = calculate_text_layout_data(shape, text_content, &mut builders, true);
@ -682,10 +682,9 @@ fn paint_text_with_emoji_overlay(
) {
let text_content = shape.get_text_content();
// Vertical writing renders through the custom vertical pass.
// Stored text bounds describe the measured content and can be taller than
// a fixed shape. Rebind to the selrect so the shape height remains the
// column-wrap budget used by the vertical painter.
// Vertical writing paints through the vertical pass. Stored text bounds
// describe the measured content and can be taller than a fixed shape, so
// rebind to the selrect to keep the shape height as the column-wrap budget.
let vertical_text_content = text_content
.is_vertical()
.then(|| text_content.new_bounds(shape.selrect()));

View File

@ -1,11 +1,11 @@
//! Minimal `GPOS` parser for the `vpal` (proportional vertical alternate
//! metrics) feature. Vertical layout applies these deltas itself: cells
//! flow by `vmtx` advances, so HarfBuzz never gets a chance to apply
//! vertical GPOS positioning (SkShaper shapes on a horizontal line).
//! metrics) feature. Vertical layout places cells by `vmtx` advances and
//! applies these deltas itself: SkShaper shapes on a horizontal line, so
//! HarfBuzz never applies vertical GPOS positioning.
//!
//! Only single-adjustment lookups are read (SinglePos, directly or behind
//! an Extension lookup) — `vpal` is metrics-only by design and real fonts
//! (Noto CJK, Source Han) encode it exactly this way.
//! Reads only single-adjustment lookups (SinglePos, direct or behind an
//! Extension lookup): `vpal` is metrics-only, and real fonts (Noto CJK,
//! Source Han) encode it this way.
use std::collections::HashMap;
@ -67,7 +67,7 @@ fn parse_coverage(data: &[u8], offset: usize) -> Option<Vec<u16>> {
/// A ValueRecord holds one i16 per set low bit of `value_format`, in bit
/// order (xPlacement, yPlacement, xAdvance, yAdvance, then four device
/// offsets). Returns the y deltas and consumes nothing else.
/// offsets). Returns only the y deltas.
fn parse_value_record(data: &[u8], offset: usize, value_format: u16) -> Option<VpalDelta> {
let mut delta = VpalDelta::default();
let mut o = offset;

View File

@ -1,10 +1,9 @@
//! Shared JLREQ character classes and pair-rule tables.
//!
//! JLREQ defines thirty layout classes. Classes 20–24 and 28–30 are
//! contextual/virtual classes produced by higher-level inline composites;
//! [`classify`] handles scalar characters and callers assign those virtual
//! classes when constructing reference marks, ruby, grouped numerals,
//! warichu, or tate-chu-yoko.
//! virtual classes for inline composites: [`classify`] handles single
//! characters, and callers assign the virtual classes when building
//! reference marks, ruby, grouped numerals, warichu, or tate-chu-yoko.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
@ -124,9 +123,9 @@ impl JapaneseClass {
)
}
/// Half-width punctuation whose normal character frame is completed by
/// half an em after the glyph. Consecutive punctuation may suppress that
/// appended spacing, but the glyph body itself remains half-width.
/// Half-width punctuation followed by a half-em of aki that completes
/// its character frame. Consecutive punctuation may drop that aki; the
/// glyph body stays half-width.
pub const fn is_trailing_aki_punctuation(self) -> bool {
matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma)
}
@ -141,9 +140,9 @@ pub struct PairRule {
/// Largest spacing allowed during oidashi/justification, in em.
pub maximum_em: f32,
pub break_allowed: bool,
/// Whether horizontal SkParagraph needs an inserted WORD JOINER for this
/// pair. Atomic Western/numeral runs are already protected by its Unicode
/// breaker, so they remain non-breakable without synthetic characters.
/// Whether horizontal SkParagraph needs a WORD JOINER to block a break
/// in this pair. Its Unicode breaker already keeps Western/numeral runs
/// whole.
pub suppress_break_with_joiner: bool,
/// Lower values are adjusted first; zero means not adjustable.
pub shrink_priority: u8,
@ -215,15 +214,15 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl
(before, after),
(JapaneseClass::TateChuYoko, JapaneseClass::TateChuYoko)
) {
// Two adjacent cl-30 entries are necessarily separate TCY
// composites (characters inside one composite are atomic).
// Adjacent cl-30 entries are always separate TCY composites
// (characters inside one composite are atomic).
rule.maximum_em = 0.25;
rule.expand_priority = 3;
} else if matches!(before, JapaneseClass::DividingPunctuation)
&& !matches!(after, JapaneseClass::ClosingBracket)
{
// A sentence-ending question/exclamation mark carries one em
// after it. Line planning may discard this at the line edge.
// after it. Line planning may drop it at the line edge.
rule.preferred_em = 1.0;
rule.minimum_em = 0.0;
rule.maximum_em = 1.0;
@ -231,8 +230,8 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl
} else if before.is_trailing_aki_punctuation()
&& matches!(after, JapaneseClass::OpeningBracket)
{
// Only one half-em is retained between the two half-width
// glyph bodies, not the sum of both characters' normal aki.
// Keep one half-em between the two half-width glyph bodies,
// not the sum of both characters' aki.
rule.preferred_em = 0.5;
rule.minimum_em = 0.0;
rule.maximum_em = 0.5;
@ -281,8 +280,8 @@ const fn generated_pair_rules() -> [[PairRule; JapaneseClass::COUNT]; JapaneseCl
rule.shrink_priority = 3;
} else if before.is_japanese_letter() && after.is_japanese_letter() {
// Solid Japanese text is the general third-stage expansion
// opportunity. The planner may continue past this quarter-em
// cap only in JLREQ's final equal-expansion fallback.
// point. The planner passes this quarter-em cap only in
// JLREQ's final equal-expansion fallback.
rule.maximum_em = 0.25;
rule.expand_priority = 3;
}

View File

@ -1,26 +1,22 @@
//! Japanese line-breaking rules (kinsoku shori).
//!
//! Skia's default break iterator allows closing punctuation, the
//! prolonged sound mark or small kana at a line start, and opening
//! brackets at a line end. skia-safe exposes no ICU BreakIterator, so
//! the forbidden break opportunities are suppressed by inserting U+2060 WORD
//! JOINER into the text handed to skparagraph. The same lossless layout-text
//! transform inserts JLREQ quarter-em Japanese/Western boundary space and
//! normalizes Western word spaces to one third em.
//! Skia's default break iterator allows closing punctuation, the prolonged
//! sound mark or small kana at a line start, and opening brackets at a line
//! end. skia-safe exposes no ICU BreakIterator, so this module suppresses
//! those breaks by inserting U+2060 WORD JOINER into the text handed to
//! skparagraph. The same transform inserts the JLREQ quarter-em space at
//! Japanese/Western boundaries and sets Western word spaces to one third em.
//!
//! The inserted joiners shift every UTF-16 offset reported by the laid
//! out paragraph (position-data, caret mapping, selection rects). The
//! [`OffsetMap`] returned along with the modified texts translates
//! between original and joiner-shifted offsets. It is a pure function
//! of the span texts, so any consumer can recompute it and stay
//! consistent with the builders by construction.
//! Inserted characters shift every UTF-16 offset the laid-out paragraph
//! reports (position-data, carets, selection rects). [`OffsetMap`]
//! translates between original and shifted offsets. It depends only on the
//! span texts, so any consumer can recompute it and match the builders.
/// Zero-width character whose UAX #14 class forbids breaking on either
/// side of it.
/// Zero-width character; its UAX #14 class forbids a break on either side.
pub const WORD_JOINER: char = '\u{2060}';
/// Unicode FOUR-PER-EM SPACE, used for the preferred Japanese/Western gap.
pub const JAPANESE_WESTERN_SPACE: char = '\u{2005}';
/// Unicode THREE-PER-EM SPACE, used for Western word spacing in Japanese text.
/// Unicode THREE-PER-EM SPACE, used for Western word spaces.
pub const WESTERN_WORD_SPACE: char = '\u{2004}';
use super::japanese::{classify, pair_rule};
@ -37,9 +33,7 @@ pub fn forbidden_at_line_end(c: char) -> bool {
/// and layout-text UTF-16 offsets (the text handed to skparagraph).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct OffsetMap {
/// UTF-16 indices of inserted joiners or boundary spaces, ascending and
/// expressed in shifted coordinates. Same-length substitutions need no
/// entry.
/// Ascending shifted UTF-16 indices of inserted (not substituted) chars.
inserted: Vec<usize>,
}
@ -49,14 +43,14 @@ impl OffsetMap {
self.inserted.is_empty()
}
/// Original offset for a shifted offset. An offset pointing at an inserted
/// character resolves to the source boundary where it was inserted.
/// Original offset for a shifted offset. An offset on an inserted
/// character resolves to the boundary where it was inserted.
pub fn to_original(&self, shifted: usize) -> usize {
shifted - self.inserted.iter().take_while(|&&p| p < shifted).count()
}
/// Shifted offset for an original offset. A boundary that received a
/// layout character resolves after it, so carets avoid synthetic spacing.
/// Shifted offset for an original offset. A boundary that received an
/// inserted character resolves after it, so carets skip synthetic spacing.
pub fn to_shifted(&self, original: usize) -> usize {
let mut shifted = original;
for &p in &self.inserted {
@ -70,14 +64,12 @@ impl OffsetMap {
}
}
/// Applies the horizontal Japanese layout-text transform. It inserts WORD
/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space at
/// Japanese↔Western boundaries, and normalizes breakable ASCII word spaces to
/// one third em. Span boundaries are transparent. Returns `None` when the
/// paragraph needs no transformation.
/// Apply the normal Japanese layout transform while additionally protecting
/// annotated base units. `ruby_breaks[span] == Some(boundaries)` means that
/// every internal scalar boundary except those UTF-16 offsets is atomic.
/// Applies the horizontal Japanese layout-text transform: inserts WORD
/// JOINER wherever a break would violate kinsoku, inserts a quarter-em space
/// at Japanese↔Western boundaries, and sets breakable ASCII spaces to one
/// third em. Span boundaries are transparent. `ruby_breaks[span] ==
/// Some(boundaries)` forbids breaks at every internal scalar boundary of that
/// span except those UTF-16 offsets. Returns `None` when nothing changes.
pub fn apply_to_span_texts_with_ruby_breaks(
span_texts: &[String],
ruby_breaks: &[Option<Vec<usize>>],
@ -218,8 +210,7 @@ mod tests {
#[test]
fn no_insertion_at_paragraph_start() {
// A leading forbidden-at-start char has no break opportunity
// before it; nothing to suppress.
// A leading start-forbidden char has no break before it to suppress.
let (texts, _) = apply(&["。あ。"]);
assert_eq!(texts, vec!["。あ\u{2060}。".to_string()]);
}
@ -458,10 +449,9 @@ mod tests {
// long-paragraph-wrap
"国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。向側の座席から娘が立って来て、島村の前のガラス窓を落した。雪の冷気が流れこんだ。",
];
// Several widths to move the break positions around. Widths
// must exceed the longest unbreakable (joined) run, otherwise
// skparagraph rightfully falls back to an emergency mid-run
// break.
// Vary the width to move the breaks. Each width must exceed the
// longest joined run, or skparagraph falls back to an emergency
// mid-run break.
let char_width = measure_width("国");
for width in [9.0, 12.0, 16.5, 24.0].map(|n: f32| n * char_width) {
for original in fixtures {
@ -488,9 +478,9 @@ mod tests {
fn joiner_becomes_visible_under_letter_spacing() {
// skparagraph applies letter-spacing per cluster, INCLUDING the
// zero-width joiner, which would double the tracking at every
// suppressed break. This is why callers disable kinsoku for
// paragraphs with a non-zero letter-spacing. If this test ever
// fails (Skia stops spacing ignorables), that gate can go.
// suppressed break, so callers disable kinsoku when letter-spacing
// is non-zero. If this test fails (Skia stops spacing ignorables),
// drop that gate.
let collection = font_collection();
let measure = |text: &str| {
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), collection.clone());

View File

@ -271,7 +271,7 @@ fn propagate_transform(
let width_before = text_content.size.width;
let height_before = text_content.size.height;
let (new_width, new_height) = if text_content.is_vertical() {
// Vertical auto-height fixes the physical height (the
// Vertical auto-height keeps the physical height (the
// column wrap axis) and grows width as columns are added.
if height_changed {
let mut clone = text_content.clone();
@ -288,8 +288,8 @@ fn propagate_transform(
(shape_bounds_after.width(), height_before)
};
// Reflow only when the grow axis (the WASM-computed
// dimension) changes; the wrap axis is driven by the
// resize itself.
// dimension) changes; the resize itself sets the wrap
// axis.
let grow_axis_changed = if text_content.is_vertical() {
!is_close_to(width_before, new_width)
} else {

View File

@ -558,16 +558,15 @@ impl TextContent {
self.size.normalized_line_height
}
/// Writing mode is a whole-shape property: the first paragraph
/// decides the flow for all of them.
/// Writing mode applies to the whole shape: the first paragraph sets it.
pub fn is_vertical(&self) -> bool {
self.paragraphs
.first()
.is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl)
}
/// Vertical writing and horizontal ruby, warichu and emphasis marks are
/// painted by the full text pass, not from the cached paint layout.
/// Vertical writing and horizontal ruby, warichu and emphasis marks need
/// the full text pass, not the cached paint layout.
pub fn can_paint_from_layout_cache(&self) -> bool {
!self.is_vertical()
&& !self
@ -659,9 +658,8 @@ impl TextContent {
// AutoWidth paragraphs are laid out with f32::MAX, so line metrics
// (line.left) reflect alignment within that huge width and are
// unusable for tight bounds. Fall back to content_rect.
// Vertical writing bounds come from the vertical pass through
// content_rect; the skparagraph line metrics below describe the
// unused horizontal layout.
// Vertical writing takes its bounds from content_rect; the
// skparagraph line metrics below describe an unused horizontal layout.
if self.grow_type() == GrowType::AutoWidth || self.is_vertical() {
return self.content_rect(selrect, valign);
}
@ -746,7 +744,7 @@ impl TextContent {
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
// Vertical content anchors to the shape's right edge and always
// aligns to the top (vertical-align along columns is deferred).
// aligns to the top; vertical-align does not apply.
if self.is_vertical() {
let (width, height) = if self.grow_type() == GrowType::AutoWidth {
(self.size.width, self.size.height)
@ -792,8 +790,8 @@ impl TextContent {
point: &Point,
vertical_align: VerticalAlign,
) -> Option<TextPositionWithAffinity> {
// Vertical writing: resolve through the vertical pass. The point
// arrives selrect-local; the content block is right-anchored.
// Vertical writing: resolve through the vertical pass. The point is
// selrect-local; the content block is right-anchored.
if self.is_vertical() {
let bounds = self.bounds();
let layout = super::text_vertical::layout_for_box(self, bounds.height());
@ -1274,10 +1272,10 @@ impl TextContent {
}
}
// Vertical writing sizes come from the vertical pass. Auto-width
// fits both axes without wrapping. Auto-height keeps the shape height
// as its wrap budget and grows width as columns advance right-to-left.
// Fixed keeps both shape dimensions.
// Vertical writing takes sizes from the vertical pass. Auto-width
// fits both axes without wrapping. Auto-height wraps at the shape
// height and grows width as columns advance right-to-left. Fixed
// keeps both dimensions.
if self.is_vertical() {
match self.grow_type() {
GrowType::AutoWidth => {
@ -1407,8 +1405,8 @@ impl TextContent {
let result = matrix.map_point((x_pos, y_pos));
// Vertical writing: hit-test against the laid-out cells directly
// (absolute coordinates, right-anchored to the selrect).
// Vertical writing: hit-test against the laid-out cells (absolute
// coordinates, right-anchored to the selrect).
if self.is_vertical() {
let layout = super::text_vertical::layout_for_box(self, shape.selrect.height());
return super::text_vertical::intersects(
@ -1600,12 +1598,12 @@ impl Paragraph {
self.text_transform
}
/// Span texts as fed to the paragraph builders: text-transform applied,
/// Japanese spacing normalized, and kinsoku break suppressions inserted,
/// plus the map between original and builder-text UTF-16 offsets. Every
/// consumer of laid-out offsets must translate through the map. The layout
/// transform is skipped under letter-spacing, where skparagraph would add
/// letter spacing to synthetic layout characters.
/// Span texts as fed to the paragraph builders (text-transform applied,
/// Japanese spacing normalized, kinsoku break suppressions inserted),
/// plus the map from original to builder-text UTF-16 offsets. Consumers
/// of laid-out offsets must translate through the map. Skips the layout
/// transform under letter-spacing, where skparagraph would also space
/// the synthetic characters.
pub fn layout_span_texts(&self) -> (Vec<String>, kinsoku::OffsetMap) {
layout_span_texts(self)
}
@ -1648,11 +1646,10 @@ pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size:
}
}
/// Text after browser filtering and CSS text transformation, plus the source
/// UTF-16 range that produced each transformed Unicode scalar. A single source
/// scalar can produce several output scalars (for example `ß` uppercases to
/// `SS`); keeping that ownership lets vertical layout wrap and export the
/// transformed glyphs as one source-text unit.
/// Text after browser filtering and CSS text-transform, plus the source
/// UTF-16 range behind each output scalar. One source scalar can yield
/// several (`ß` uppercases to `SS`); the ranges let vertical layout wrap and
/// export those glyphs as one source unit.
#[derive(Debug, Clone, PartialEq)]
pub struct AppliedTextTransform {
pub text: String,
@ -1757,8 +1754,7 @@ pub struct TextSpan {
pub ruby_align: RubyAlign,
pub ruby_overhang: RubyOverhang,
pub ruby_side: RubySide,
/// Warichu (割注): render the span as two half-size lines stacked inline
/// within one column position of the vertical flow.
/// Warichu (割注): two half-size lines stacked in one column position.
pub warichu: bool,
pub font_features: FontFeatures,
pub annotation_clearance: AnnotationClearance,
@ -2165,9 +2161,8 @@ pub fn calculate_text_layout_data(
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
// Ranges are in the builder-text (kinsoku-shifted)
// space; exported positions are translated back to
// original span-relative offsets through the map.
// Ranges are in builder-text (kinsoku-shifted) space; the
// map translates exported positions back to span offsets.
let offsets = HorizontalOffsets::new(text_para);
let offset_map = &offsets.offset_map;
let entry =

View File

@ -47,9 +47,9 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec<String>, kinsoku:
(texts, kinsoku::OffsetMap::default())
}
/// Add a span to a horizontal paragraph builder. Warichu is represented by a
/// single inline placeholder so the two annotation lines wrap as one unit.
/// The actual glyphs are painted after SkParagraph has positioned the box.
/// Add a span to a horizontal paragraph builder. A warichu span becomes one
/// inline placeholder so its two lines wrap as a unit; its glyphs are painted
/// after layout.
pub(crate) fn add_horizontal_span(
builder: &mut ParagraphBuilder,
span: &TextSpan,
@ -83,10 +83,9 @@ pub(crate) fn add_horizontal_span(
height,
));
// SkParagraph does not expose a placeholder's TextStyle through line
// metrics. A near-zero, inkless NBSP preserves the exact
// fill/stroke/shadow style for the custom paint pass; the following
// zero-width space restores a legal wrapping boundary after the
// atomic placeholder.
// metrics. A near-zero, inkless NBSP carries the span style for the
// paint pass; the zero-width space after it allows a line break after
// the atomic placeholder.
let mut anchor_style = text_style.clone();
anchor_style.set_font_size(0.01);
anchor_style.set_height(0.01);
@ -112,10 +111,9 @@ 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.
/// Offset maps of one horizontal paragraph between source characters, the
/// kinsoku-adjusted layout text and the builder text. Building it runs the
/// kinsoku pass; reuse it for every lookup in the paragraph.
pub(crate) struct HorizontalOffsets {
/// Map between the transformed text and the kinsoku-adjusted text.
pub(crate) offset_map: kinsoku::OffsetMap,
@ -232,9 +230,9 @@ pub(crate) fn horizontal_builder_to_source(paragraph: &Paragraph, builder_offset
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.
/// Ranges shared by layout, position data and editor mapping. `shifted_*`
/// addresses the kinsoku-adjusted text; `builder_*` addresses the builder
/// text, where each warichu span collapses to one placeholder.
fn span_ranges(
paragraph: &Paragraph,
span_texts: Vec<String>,
@ -446,11 +444,9 @@ pub(crate) fn horizontal_warichu_range_rects(
}
/// Char index where a warichu run splits into its two sub-lines: the
/// balanced midpoint (first line longer), nudged so the second sub-line
/// does not start with a line-start-prohibited character and the first
/// does not end with a line-end-prohibited one. Nudging forward pulls the
/// offending mark up into the first sub-line (jlreq); backward is the
/// fallback, and the midpoint stands when no split satisfies kinsoku.
/// midpoint (first line longer), moved to the nearest split that keeps
/// kinsoku. At equal distance a forward move wins, pulling the mark up into
/// the first sub-line (jlreq). Falls back to the midpoint.
pub(crate) fn warichu_split_chars(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
@ -551,9 +547,8 @@ pub(crate) struct HorizontalEmphasisPlacement {
pub(crate) rect: skia::Rect,
}
/// Locate one horizontal emphasis mark above each eligible source character.
/// SkParagraph owns wrapping and bidi placement; querying each transformed
/// character range keeps the marks attached to the actual laid-out glyphs.
/// Place one emphasis mark above each eligible character, from the laid-out
/// rect of each character so marks follow SkParagraph's wrapping and bidi.
pub(crate) fn horizontal_emphasis_placements(
paragraph: &Paragraph,
offsets: &HorizontalOffsets,
@ -622,13 +617,10 @@ pub(crate) fn horizontal_span_style(
})
}
/// Top of the horizontal base em inside SkParagraph's typographic rectangle.
/// Its tight rect can include substantial ascender-side padding; anchoring an
/// over annotation to that rect's top therefore leaves a visible gap above CJK
/// ink. The em is bottom-aligned to the rect, matching the baseline model used
/// by the horizontal painter. The ruby showcase needs a 12 px clearance at
/// 56 px: the original 6 px adjustment still left it 6 px too close to the
/// kanji. Keep the 3/14-em value proportional at other text sizes.
/// Top of the horizontal base em inside SkParagraph's typographic rect, less
/// a 3/14-em clearance (12 px at 56 px). The tight rect includes ascender-side
/// padding, so anchoring an over annotation to its top leaves a gap above CJK
/// ink; the em is bottom-aligned to the rect.
pub(crate) fn horizontal_annotation_over_top(rect: skia::Rect, font_size: f32) -> f32 {
let font_size = font_size.max(0.0);
rect.top.max(rect.bottom - font_size) - font_size * (3.0 / 14.0)
@ -652,8 +644,7 @@ pub(crate) fn horizontal_emphasis_mark_box(
}
/// 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.
/// Draw-only: the base paragraph keeps its own metrics.
pub(crate) fn paint_horizontal_emphasis(
canvas: &skia::Canvas,
paragraph: &Paragraph,
@ -747,9 +738,9 @@ fn next_horizontal_ruby_range(
offset_map.to_shifted(start)..offset_map.to_shifted(*utf16_cursor)
}
/// Baseline adjustment that attaches a glyph's visible ink edge to its base
/// strip. Font-wide ascender/descender metrics include leading which makes
/// horizontal ruby visibly detached for many Japanese faces.
/// Ink edge of `glyph` (bottom when `over`, else top), used to attach ruby
/// ink to its base. Font-wide ascent/descent include leading, which leaves
/// ruby detached in many Japanese faces.
fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bool) -> f32 {
let mut bounds = [skia::Rect::default()];
font.get_bounds(&[glyph], &mut bounds, None);
@ -765,13 +756,11 @@ fn horizontal_ruby_ink_edge(font: &Font, glyph: GlyphId, fallback: f32, over: bo
}
}
/// Paint ruby annotations for one horizontally laid-out paragraph. Draw-only:
/// base rects come from the already laid-out skparagraph
/// (`get_rects_for_range`), the annotation is shaped at half the span size
/// and distributed over each line's base rect with the same jlreq
/// distribution the vertical path uses (`distribute_ruby_tops` along the
/// horizontal flow). Lines are not reflowed to reserve an annotation band;
/// the ruby draws in the natural leading above the base line.
/// Paint ruby for one laid-out horizontal paragraph. Draw-only: base rects
/// come from `get_rects_for_range`, and the ruby is shaped at
/// `ruby_font_size` and spread over each line's rect with the vertical path's
/// jlreq distribution (`distribute_ruby_tops`). Lines are not reflowed; ruby
/// draws in the line's leading.
pub(crate) fn paint_horizontal_ruby(
canvas: &Canvas,
text_content: &TextContent,
@ -844,9 +833,8 @@ pub(crate) fn paint_horizontal_ruby(
.map(|(_, _, advance)| *advance)
.fold(0.0f32, f32::max)
.max(1.0);
// Horizontal SkParagraph has already fixed the base geometry.
// When overhang is prohibited, fit the annotation strip to
// that geometry instead of allowing either end to escape it.
// With overhang prohibited, squeeze the ruby to fit the base
// rect that SkParagraph has fixed.
let glyph_scale = if span.ruby_overhang == RubyOverhang::None {
(rect_box.rect.width() / (advance * count as f32)).min(1.0)
} else {
@ -901,9 +889,9 @@ pub(crate) fn paint_horizontal_ruby(
}
}
/// Block flow direction of a paragraph. Horizontal is the skparagraph
/// path; vertical-rl lays out columns top->bottom advancing right->left
/// through the custom vertical pass.
/// Block flow direction of a paragraph. Horizontal uses skparagraph;
/// vertical-rl uses the custom vertical pass: columns top to bottom,
/// advancing right to left.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum WritingMode {
#[default]
@ -926,13 +914,11 @@ pub enum TextCombineUpright {
#[default]
None,
All,
/// Combine runs of 2-4 consecutive ASCII or full-width digits into one upright
/// composite; other characters keep the normal vertical layout.
/// Runs of 2-4 ASCII or full-width digits combine into one upright cell.
Digits,
/// Like `Digits` but only runs of exactly 2 digits combine
/// (CSS `text-combine-upright: digits 2`).
/// Like `Digits`, for runs of exactly 2 (CSS `digits 2`).
Digits2,
/// Like `Digits` but runs of 2-3 digits combine.
/// Like `Digits`, for runs of 2-3.
Digits3,
}
@ -991,9 +977,9 @@ pub enum FontFeatures {
Vpal,
}
/// Controls whether annotation layers participate in line/column spacing.
/// The default preserves legacy documents; `Auto` reserves one half-em for
/// each active ruby or emphasis layer.
/// Whether ruby and emphasis on the same side stack. `Auto` places emphasis
/// outside over-side ruby and reserves room for both; `None` lets them share
/// one layer.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum AnnotationClearance {
#[default]

View File

@ -1,7 +1,7 @@
// Ruby (furigana) and emphasis marks (圏点) in vertical flow. Annotations
// stay out of `cells`, so base metrics, caret geometry and position data of
// the base text never see them; they are placed from the base cells' final
// columns and flow extents.
// stay out of `cells`, so base metrics, caret geometry and position data
// ignore them; they are placed from the base cells' final columns and flow
// extents.
use skia_safe::{self as skia, Font};
@ -24,23 +24,18 @@ pub struct RubyGlyph {
pub utf16_end: usize,
}
/// A ruby annotation placed alongside one column of base characters.
/// Painted from `ruby_runs`; kept out of `cells` so base metrics and caret
/// geometry are unaffected.
/// A ruby annotation beside one column of base characters, painted from
/// `ruby_runs`.
#[derive(Debug, Clone)]
pub struct RubyCell {
/// Ordered glyphs for this base column. Each retains the shaped run that
/// supplied its font so fallback boundaries do not drop ruby content.
/// Glyphs in order; each keeps its shaped run so fallback fonts survive.
pub glyphs: Vec<RubyGlyph>,
pub paragraph: usize,
pub span: usize,
pub column: usize,
/// Flow-axis (top, extent) of each base character this ruby annotates,
/// in flow order, restricted to the annotated column. Group ruby spreads
/// the annotation over the union; mono ruby maps ruby glyphs onto the
/// individual segments.
/// Flow-axis (top, extent) of each annotated base character in the column.
pub base_segments: Vec<(f32, f32)>,
/// Final flow-axis positions computed from the explicit ruby mapping.
/// Flow-axis top of each glyph.
pub glyph_tops: Vec<f32>,
pub font_size: f32,
pub base_font_size: f32,
@ -48,10 +43,9 @@ pub struct RubyCell {
pub paint: usize,
}
/// One emphasis mark (圏点 / bouten) drawn beside a base character. Kept out of
/// `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.
/// One emphasis mark (圏点 / bouten) beside a base character: the
/// single-glyph `run`, centred on the base cell's flow extent in the column's
/// right-side gutter.
pub struct EmphasisMark {
pub run: usize,
/// Index of the annotated base cell in `VerticalLayout::cells`.
@ -61,9 +55,9 @@ pub struct EmphasisMark {
pub outside_offset: f32,
}
/// Return the proportional item range belonging to a contiguous slice of the
/// base text. This keeps a ruby reading monotonic when its base wraps across
/// columns while ensuring the final column receives any rounding remainder.
/// Item range proportional to a contiguous slice of the base text. Keeps a
/// reading monotonic when its base wraps across columns; the final slice gets
/// the rounding remainder.
fn proportional_range(
item_count: usize,
base_start: usize,
@ -83,18 +77,14 @@ fn proportional_range(
start.min(item_count)..end.min(item_count)
}
/// Distribute `count` ruby glyphs of the given `advance` along a single base
/// segment `[seg_top, seg_top + seg_extent)`, returning each glyph's flow-axis
/// top. Two jlreq regimes:
/// Flow-axis top of each of `count` ruby glyphs of `advance` along the base
/// segment `[seg_top, seg_top + seg_extent)`. Per jlreq:
///
/// - Ruby no longer than the base (`Lr <= seg_extent`): even distribution
/// (均等割り付け). Each glyph gets an equal slot `seg_extent / count` and is
/// centred in its slot, which yields equal inter-glyph gaps and half gaps at
/// both ends.
/// - Ruby longer than the base (`Lr > seg_extent`): the glyphs are packed at
/// their own advance and the block is centred on the base, overhanging both
/// ends symmetrically (オーバーハング). The per-end overhang is capped at one
/// ruby em so a long annotation cannot swallow its neighbours' cells.
/// - Ruby that fits the base is placed by `align`; `SpaceAround` is even
/// distribution (均等割り付け): equal slots, each glyph centred in its slot.
/// - Longer ruby packs at its own advance and overhangs the base start
/// (オーバーハング) by half the overflow, capped at one ruby em so it cannot
/// cover its neighbours. `RubyOverhang::None` starts it at the base top.
pub(crate) fn distribute_ruby_tops(
seg_top: f32,
seg_extent: f32,
@ -140,10 +130,9 @@ pub(crate) fn distribute_ruby_tops(
}
}
/// Cross-axis start of a vertical ruby strip. Line height controls the column
/// advance (`base_width`), but must not become spacing between an annotation
/// and its base glyph. Centre the base em in that advance and attach ruby to
/// the em edge; any extra leading remains outside the base+ruby group.
/// Cross-axis start of a vertical ruby strip. Ruby attaches to the edge of the
/// base em, centred in the column advance (`base_width`), so line height adds
/// no gap between ruby and base.
pub(super) fn ruby_strip_x(
column: &VerticalColumn,
ruby_font_size: f32,
@ -219,11 +208,10 @@ pub(super) fn ruby_base_units(
.collect()
}
/// Long ruby with no slack: grow the base span's flow extent *before*
/// column planning so the wrap itself makes room (forced spreading). The
/// growth becomes inter-character gaps, so only the cells before the last
/// one grow. Single-character bases keep the capped-overhang behaviour of
/// `spread_ruby_base_cells`, unless overhang is prohibited.
/// Grow the flow extent of base cells under long ruby before column planning,
/// so wrapping makes room (forced spreading). The growth goes into gaps
/// between characters, so the last cell keeps its extent. A single-character
/// base grows only when overhang is prohibited; otherwise the ruby overhangs.
pub(super) fn grow_ruby_bases(flow: &mut [FlowCell], ruby_units: &[RubyBaseUnit]) {
for unit in ruby_units {
let indices: Vec<usize> = (0..flow.len())
@ -250,12 +238,9 @@ pub(super) fn grow_ruby_bases(flow: &mut [FlowCell], ruby_units: &[RubyBaseUnit]
}
}
/// Expand gaps between already-placed base cells for long ruby annotations.
///
/// This is deliberately post-placement and bounded: it only shifts later base
/// cells in the same span/column, and only into slack before the next cell (or
/// the column bottom). It avoids re-wrapping columns while making the base span
/// long enough for common long compound-word readings when there is room.
/// Widen gaps between placed base cells under long ruby. Shifts only later
/// cells of the same span and column, and only into slack before the next
/// cell or the column bottom, so columns never re-wrap.
pub(super) fn spread_ruby_base_cells(
cells: &mut [VerticalCell],
ruby_units: &[RubyBaseUnit],
@ -391,10 +376,9 @@ fn ruby_glyphs(
glyphs
}
/// Ruby (furigana) placement. Runs after column placement because a ruby
/// annotation's strip is positioned from its base characters' final column
/// and flow extent. A reading whose base wraps is partitioned across the
/// columns in proportion to their base characters.
/// Ruby (furigana) placement. Runs after column placement, since ruby follows
/// its base's final column and flow extent. A reading whose base wraps is
/// split across columns in proportion to their base characters.
pub(super) fn layout_ruby(
text_content: &TextContent,
cells: &[VerticalCell],
@ -478,10 +462,9 @@ pub(super) fn layout_ruby(
(ruby_runs, ruby_cells)
}
/// Emphasis marks (圏点 / bouten): one mark glyph per upright base cell of
/// every span that carries `text_emphasis`, drawn beside the cell in the
/// right-side gutter. The mark is shaped once per span. Whitespace and
/// Japanese punctuation cells get no mark (CSS `text-emphasis` behaviour).
/// Emphasis marks (圏点 / bouten): one mark per upright base cell of each
/// span with `text_emphasis`, shaped once per span. Whitespace and Japanese
/// punctuation get no mark, as in CSS `text-emphasis`.
pub(super) fn layout_emphasis(
text_content: &TextContent,
cells: &[VerticalCell],
@ -822,9 +805,8 @@ mod tests {
#[test]
fn ruby_shorter_than_base_distributes_evenly() {
// One base char (extent 100) with 2 ruby glyphs at advance 50: the
// combined ruby line (100) equals the base, so glyphs sit in equal
// slots of 50, each centred (slot/2 - advance/2 = 0 offset).
// A ruby line of 2 x 50 equals the base extent (100): one glyph per
// slot, with no offset.
let tops = distribute_ruby_tops(
0.0,
100.0,
@ -843,8 +825,8 @@ mod tests {
"second ruby glyph one slot down"
);
// A wide base (extent 200) with 2 glyphs of advance 50 must spread out
// (slot 100) rather than pack tight at advance 50.
// A wide base (extent 200) spreads 2 glyphs of advance 50 into slots
// of 100.
let spread = distribute_ruby_tops(
0.0,
200.0,
@ -865,10 +847,8 @@ mod tests {
#[test]
fn ruby_longer_than_base_overhangs_symmetrically() {
// Base extent 40, four ruby glyphs of advance 20 (line 80 > 40): the
// block centres on the base and overhangs both ends. Overflow is 40,
// per-end overhang 20 (capped at one ruby em = 20), so it starts 20
// above the base top.
// A ruby line of 80 over a 40 base centres on the base, overhanging
// each end by 20 (the one-em cap).
let tops = distribute_ruby_tops(
0.0,
40.0,
@ -993,10 +973,9 @@ mod tests {
#[test]
fn ruby_base_spreading_forces_room_when_no_slack() {
// Base 日本 (2 em = 40) with a 6-glyph half-em reading (60) followed
// by 語 in a 60 budget: there is no post-placement slack, so the base
// extents must grow before planning and push the follower to the next
// column instead of falling back to overhang.
// Base 日本 (40) with a 60-long reading, then 語, in a 60 column: with
// no slack after placement, the base grows before planning and pushes
// 語 to the next column.
let mut content = make_content_with_spans(&["日本", "語"], 60.0);
content.paragraphs_mut()[0].children_mut()[0].ruby = "にほんごです".to_string();
let layout = layout_content(&content, 60.0);

View File

@ -16,8 +16,7 @@ use super::orientation::{
};
use super::shaping::{shape_segment_with_fallbacks, span_font_families, ShapedRun};
/// Minimum scale for unconstrained `all` tate-chu-yoko. Counted digit modes
/// derive their limit from the eligible run length instead.
/// Smallest scale for `all` tate-chu-yoko; digit modes use 1 / run length.
const MIN_TCY_SCALE: f32 = 0.5;
/// Fonts available to the layout: the provider's registered faces and the
@ -28,15 +27,13 @@ pub(super) struct Fonts<'a> {
pub fallback_families: &'a [String],
}
/// Centered punctuation (jlreq class cl-05 中点類): the middle dot, the
/// full-width colon and the full-width semicolon are placed at the centre of
/// the em box in vertical writing rather than on the horizontal baseline.
/// Centered punctuation (jlreq cl-05 中点類: middle dot, full-width colon and
/// semicolon), set at the centre of the em box in vertical writing.
fn is_centered_punctuation(c: char) -> bool {
classify(c) == JapaneseClass::MiddleDot
}
/// Flow-axis shift that centres a glyph's ink band within its em body: moves
/// the ink midpoint (`(ink_top + ink_bottom) / 2`) to the body midpoint.
/// Flow-axis shift that centres a glyph's ink band in its em body.
fn centered_flow_shift(ink_top: f32, ink_bottom: f32, em_body: f32) -> f32 {
em_body / 2.0 - (ink_top + ink_bottom) / 2.0
}
@ -53,10 +50,9 @@ fn is_tcy_digit(c: char) -> bool {
c.is_ascii_digit() || ('0'..='9').contains(&c)
}
/// Split a TCY `digits` span into pieces: maximal ASCII or full-width digit
/// runs of 2..=max characters become upright composites, everything else
/// keeps the normal vertical layout. Returns (piece text, span-relative
/// UTF-16 start, tcy).
/// Split a TCY `digits` span into pieces. Maximal ASCII or full-width digit
/// runs of 2..=max chars are marked tcy; the rest keep normal vertical
/// layout. Returns (piece text, span-relative UTF-16 start, tcy).
fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> {
let mut pieces: Vec<(String, usize, bool)> = Vec::new();
let mut utf16 = 0usize;
@ -92,9 +88,7 @@ pub(super) struct SpanCells<'a> {
paint: usize,
/// UTF-16 offset of the span in its paragraph's layout text.
start: usize,
/// The span's own font first, then emoji and the registered fallback
/// fonts. Shaping resolves glyph coverage explicitly and splits runs at
/// typeface boundaries.
/// The span's own font, then emoji and the registered fallback fonts.
families: Vec<String>,
}
@ -165,10 +159,7 @@ impl<'a> SpanCells<'a> {
if self.span.is_warichu() && self.push_warichu(text, runs, cells) {
return;
}
// `digits` combines runs of 2..=max consecutive ASCII or full-width
// digits (max 4, or 2/3 for the counted variants) into one upright
// composite; the rest of the span flows through the normal
// orientation segmentation.
// `digits` merges each run of 2..=max digits into one upright cell.
let pieces = match combine.digits_max() {
Some(max) => split_digit_runs(text, max),
None => vec![(text.to_string(), 0, false)],
@ -193,11 +184,10 @@ impl<'a> SpanCells<'a> {
}
}
/// Compose `piece` (a whole TCY span or a digit run inside one) into one
/// upright composite cell, shaping with the first family that covers the
/// piece and composing any fallback runs side by side. Returns false when
/// the composite would compress below `min_scale`; the caller then falls
/// back to the normal vertical segmentation.
/// Compose `piece` (a whole TCY span or a digit run in one) into one
/// upright composite cell, with fallback runs side by side. Returns false
/// when the composite would scale below `min_scale`; the caller then uses
/// normal vertical layout.
fn push_tate_chu_yoko(
&self,
piece: &str,
@ -206,12 +196,8 @@ impl<'a> SpanCells<'a> {
runs: &mut Vec<ShapedRun>,
cells: &mut Vec<FlowCell>,
) -> bool {
// Ruby and other span-level formatting can split an otherwise
// continuous vertical run into a one-character span while preserving
// an inherited `text-combine-upright: all`. A single CJK character is
// already upright; routing it through the horizontal TCY path can
// scale it down to fit the font's line metrics and make it smaller
// than adjacent base characters.
// A lone upright CJK char (e.g. a ruby base span that inherits `all`)
// skips TCY, which would shrink it below its neighbours.
let mut chars = piece.chars();
if matches!((chars.next(), chars.next()), (Some(ch), None) if is_upright_char(ch)) {
return false;
@ -264,9 +250,9 @@ impl<'a> SpanCells<'a> {
}
/// Warichu (割注): the span becomes one composite cell holding two
/// half-size sub-lines laid side by side within the column (the first
/// sub-line on the right, jlreq reading order), split by
/// `warichu_split_chars`. Returns false when a sub-line shapes empty.
/// half-size sub-lines side by side in the column (the first on the
/// right, jlreq reading order), split by `warichu_split_chars`. Returns
/// false when a sub-line shapes empty.
fn push_warichu(
&self,
text: &str,
@ -291,8 +277,7 @@ impl<'a> SpanCells<'a> {
};
runs.extend(first_runs);
runs.extend(second_runs);
// Two half-em sub-columns side by side fill the em, the default
// `h_advance`.
// Two half-em sub-columns fill the em, the default `h_advance`.
let end = self.start + text.encode_utf16().count();
let cell = self.cell(kind, self.start, end, extent);
cells.push(FlowCell::new(
@ -317,9 +302,7 @@ impl<'a> SpanCells<'a> {
let font_size = self.span.font_size;
let letter_spacing = self.span.letter_spacing;
let vmetrics = vertical_metrics(&run.font);
// vpal deltas apply here in layout, not in the shaper: SkShaper
// shapes on a horizontal line, where vertical GPOS positioning never
// fires.
// vpal applies here: SkShaper's horizontal shaping skips vertical GPOS.
let vpal = (self.span.font_features == FontFeatures::Vpal)
.then(|| vpal_table(&run.font))
.flatten();
@ -336,13 +319,7 @@ impl<'a> SpanCells<'a> {
advance if advance > 0.0 => advance,
_ => font_size,
};
// Flow down the column by the true vertical advance when the font
// has `vmtx`. Without it, fall back to the horizontal advance,
// which is exact for full-width CJK. A character that is upright
// only because `text-orientation: upright` forced it (e.g. a Latin
// letter) is far narrower than an em and would collide with the
// following character, so floor it to a full em, matching the
// browser's upright behavior.
// No `vmtx`: h-advance, with forced-upright Latin floored to an em.
let horizontal_fallback = if ch.is_some_and(|c| !is_upright_char(c)) {
h_advance.max(font_size)
} else {
@ -358,10 +335,7 @@ impl<'a> SpanCells<'a> {
})
.filter(|advance| *advance > 0.0)
.unwrap_or(horizontal_fallback);
// vpal: the font's advance delta tightens the cell and its
// placement delta lifts the drawn ink to keep it inside. The
// tightened extent flows into caret, position-data and the aki
// sheds (which skip already-half-width cells).
// vpal tightens the cell and lifts the ink by the font's deltas.
let vpal_delta = vpal
.as_ref()
.and_then(|table| table.cluster_delta(glyphs, font_size));
@ -381,9 +355,7 @@ impl<'a> SpanCells<'a> {
let (ink_top, ink_bottom) = ink.unwrap_or((0.0, extent - letter_spacing));
let (mut ink_top, mut ink_bottom) =
(ink_top + vpal_flow_shift, ink_bottom + vpal_flow_shift);
// Centre middle-dot / colon / semicolon ink in the em body; the
// shift moves the drawn glyph only. A vpal-covered glyph keeps the
// font's own centring instead.
// Centre cl-05 ink in the em body, unless vpal already centres it.
let glyph_flow_shift = if !synthetic_rotation
&& vpal_delta.is_none()
&& ch.is_some_and(is_centered_punctuation)
@ -423,8 +395,8 @@ impl<'a> SpanCells<'a> {
}
}
/// One cell for a whole sideways Western run. Its glyphs spread down the
/// column by letter-spacing (post-rotation +x runs down the column).
/// One cell for a whole sideways Western run. Letter-spacing spreads its
/// glyphs down the column (post-rotation +x).
fn rotated_cell(
&self,
segment: &Segment,
@ -449,8 +421,7 @@ impl<'a> SpanCells<'a> {
.ink_bounds()
.map_or((0.0, extent - letter_spacing), |ink| (ink.left, ink.right));
let cell = VerticalCell {
// Rotated runs draw from `run.positions`; centring doesn't read
// `h_advance`.
// Unused for centring: rotated runs draw from `run.positions`.
h_advance: run.advance,
ink_top,
ink_bottom,
@ -493,11 +464,8 @@ mod tests {
#[test]
fn upright_narrow_latin_reserves_a_full_em_without_vmtx() {
// Under text-orientation: upright, Latin letters are set upright. The
// Latin test face carries no `vmtx`, so the flow advance must fall back
// to a synthesized em instead of the (much narrower) horizontal advance;
// otherwise the letters pack together and collide with the following
// character.
// Under text-orientation: upright, Latin letters stand upright. The
// test face has no `vmtx`, so each letter advances a full em.
let em = 20.0;
let mut content = make_content(&["ab"], 1000.0);
content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright;
@ -537,8 +505,8 @@ mod tests {
panic!("expected a warichu cell");
};
assert!(first_count >= 1 && run_count > first_count);
// Two chars per sub-line at half size: the block's flow extent is
// roughly one em, far below the four em of normal layout.
// Two half-size chars per sub-line: about one em, not the four em of
// normal layout.
assert!(
cell.extent < 2.0 * 20.0,
"two half-size sub-lines take about one em, got {}",
@ -627,8 +595,8 @@ mod tests {
#[test]
fn tate_chu_yoko_wide_run_falls_back_to_normal_layout() {
// A run far wider than the em would compress below MIN_TCY_SCALE; it is
// not combined into a squished composite but laid out normally.
// A run far wider than the em would scale below MIN_TCY_SCALE, so it
// gets normal layout.
let mut content = make_content_with_spans(&["123456789"], 400.0);
content.paragraphs_mut()[0].children_mut()[0]
.set_text_combine_upright(TextCombineUpright::All);
@ -749,9 +717,8 @@ mod tests {
#[test]
fn letter_spacing_extends_upright_cells() {
// Each upright cluster gains `letter_spacing` of flow advance, so
// cells stack further apart down the column; the centring width
// (`h_advance`) is unaffected.
// Each upright cluster gains `letter_spacing` of flow advance; the
// centring width (`h_advance`) stays the same.
let plain = layout_content(&spaced_content("あい", 0.0), 1000.0);
let spaced = layout_content(&spaced_content("あい", 5.0), 1000.0);
assert_eq!(plain.cells.len(), spaced.cells.len());
@ -807,9 +774,9 @@ mod tests {
);
}
// A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the
// vertical kana repeat mark) has a 2em vertical advance vs a 1em
// horizontal advance; U+3042/U+304F are symmetric controls.
// A tiny Noto Sans JP subset with `vmtx`/`vhea`: U+3031 (〱, vertical kana
// repeat mark) advances 2em vertically and 1em horizontally;
// U+3042/U+304F are symmetric controls.
#[test]
fn vertical_advance_uses_vmtx() {
@ -900,9 +867,9 @@ mod tests {
#[test]
fn vpal_opening_bracket_uses_font_placement() {
// In the middle of a line a normal opening bracket keeps its leading
// aki. Under vpal its placement still comes from the font's own
// YPlacement (481 units), not a synthetic sequence shed.
// Mid-line, an opening bracket keeps its leading aki. Under vpal its
// placement comes from the font's YPlacement (481 units), not a
// synthetic sequence shed.
let provider = provider(VPAL_TEST_FONT);
let shed = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::None));
let vpal = layout_with(&provider, &vpal_content("あ「あ", FontFeatures::Vpal));
@ -930,8 +897,7 @@ mod tests {
assert!(is_centered_punctuation(':'));
assert!(is_centered_punctuation(';'));
assert!(!is_centered_punctuation('あ'));
// Ink hugging the bottom of the body (14..18 in a 20 body) shifts up so
// its midpoint (16) lands on the body midpoint (10): shift == -6.
// Ink at 14..18 in a 20 body: midpoint 16 moves to 10, so shift == -6.
let shift = centered_flow_shift(14.0, 18.0, 20.0);
assert!((shift + 6.0).abs() < 1e-4, "expected -6, got {shift}");
// Already-centred ink needs no shift.

View File

@ -1,7 +1,7 @@
// Column planning and JLREQ spacing along the vertical flow. The passes run
// on a paragraph's `FlowCell`s before they are placed: they adjust cell
// extents (aki, oikomi, inter-script spacing) and pick the column breaks
// (kinsoku, burasage, oidashi).
// Column planning and JLREQ spacing along the vertical flow. These passes run
// on a paragraph's `FlowCell`s before placement: they adjust cell extents
// (aki, oikomi, inter-script spacing) and pick column breaks (kinsoku,
// burasage, oidashi).
use crate::shapes::japanese::{classify, pair_rule, JapaneseClass};
use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start};
@ -14,16 +14,14 @@ const INTER_SCRIPT_SPACING_EM: f32 = 0.25;
/// Amounts below this are treated as zero by the spacing passes.
const EPSILON: f32 = 0.0001;
/// A placeable item in the vertical flow: its extent along the column and,
/// for single-character cells, the character (used for kinsoku decisions
/// at column breaks). Rotated runs carry no character and are unsplittable.
/// An item placed along the column. `ch` is set for single-character cells
/// and drives kinsoku at column breaks; rotated runs have none and never
/// split.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct FlowItem {
pub extent: f32,
pub ch: Option<char>,
/// This item and its predecessor came from the same source character
/// after a length-expanding text transform, so a column break must not
/// split them.
/// Same source char as the previous item (text transform); keep together.
pub keep_with_previous: bool,
}
@ -40,8 +38,7 @@ pub(super) enum FlowScript {
/// spacing passes and the column planner need.
pub(super) struct FlowCell {
pub cell: VerticalCell,
/// The character of single-character cells; classifies the cell for aki
/// and kinsoku.
/// Character of a single-character cell; classifies it for aki and kinsoku.
pub ch: Option<char>,
pub keep_with_previous: bool,
pub script: FlowScript,
@ -73,10 +70,10 @@ impl FlowCell {
}
}
/// Reduce the cell to a half-em frame (plus its letter-spacing). Opening
/// punctuation keeps its ink in the trailing half of the em, so
/// `pull_glyph` lifts it into the compressed cell, against the preceding
/// character. Returns false when the cell is already that narrow.
/// Shrink the cell to a half-em frame plus its letter-spacing. Opening
/// punctuation has its ink in the trailing half of the em, so
/// `pull_glyph` lifts it into the shrunk cell. Returns false when the
/// cell is already that narrow.
fn shed_to_half_em(&mut self, pull_glyph: bool) -> bool {
let target = 0.5 * self.cell.font_size + self.trailing_spacing;
if target >= self.cell.extent {
@ -94,10 +91,9 @@ pub(super) fn flow_items(cells: &[FlowCell]) -> Vec<FlowItem> {
cells.iter().map(FlowCell::item).collect()
}
/// Punctuation allowed to hang past the column bottom (ぶら下げ / burasage): the
/// ideographic and full-width comma and period. When such a mark is the item
/// that would overflow the column, it protrudes into the margin instead of
/// wrapping (itself and its predecessor) to the next column.
/// Punctuation that may hang past the column bottom (ぶら下げ / burasage): the
/// ideographic and full-width comma and period. A mark that would overflow
/// the column hangs into the margin.
fn can_hang(c: char) -> bool {
matches!(c, '、' | '。' | ',' | '.')
}
@ -107,14 +103,13 @@ fn overflows(cursor: f32, item: &FlowItem, max_height: f32) -> bool {
cursor > 0.0 && cursor + item.extent > max_height && !item.ch.is_some_and(can_hang)
}
/// First item of the next column when `items[i]` overflows the column that
/// starts at `column_start`. Kinsoku: a break may not leave a
/// forbidden-at-line-end character at the column bottom nor put a
/// forbidden-at-line-start character at the next column top; offending
/// predecessors move to the new column (oidashi), bounded so a pathological
/// run cannot empty its column. `None` keeps `items[i]` in the column: it
/// First item of the next column when `items[i]` overflows the column
/// starting at `column_start`. Kinsoku: no forbidden-at-line-end char at the
/// column bottom and no forbidden-at-line-start char at the next column top;
/// offending predecessors move to the new column (oidashi), at most
/// `MAX_KINSOKU_SHIFT` of them. `None` keeps `items[i]` in the column when it
/// closes an atomic composite (group ruby or an expanded source scalar) that
/// began at the column head, which has no legal internal break.
/// began at the column head.
fn column_break(
items: &[FlowItem],
column_start: usize,
@ -143,9 +138,8 @@ fn column_break(
break_at = skip_kept(break_at - 1);
shifted += 1;
}
// Give up on the shift when the moved items plus the current one would
// overflow the new column too: overflowing the wrap budget is worse than
// the kinsoku violation.
// Drop the shift if it would overflow the new column too: a kinsoku
// violation is better than overflowing the wrap budget.
let shifted_extent: f32 = items[break_at..i].iter().map(|it| it.extent).sum();
if break_at < i && shifted_extent + items[i].extent > max_height {
break_at = i;
@ -153,12 +147,12 @@ fn column_break(
Some(break_at)
}
/// Assign items to columns: returns (column-index, offset-from-top) per
/// item. An item that overflows the column height starts a new column (see
/// `column_break`); items taller than the column occupy one on their own.
/// Burasage marks never overflow: the next non-hanging item still sees the
/// overflowed cursor and wraps normally, and oidashi never pulls the hung
/// marks because they are forbidden-at-line-start, not -end.
/// Assign items to columns, returning (column index, offset from top) per
/// item. An item that overflows starts a new column (see `column_break`); an
/// item taller than the column gets one to itself. Burasage marks never
/// overflow: the next non-hanging item sees the overflowed cursor and wraps,
/// and oidashi never pulls a hung mark since it is forbidden-at-line-start,
/// not -end.
pub(super) fn plan_columns(items: &[FlowItem], max_height: f32) -> Vec<(usize, f32)> {
let mut placements: Vec<(usize, f32)> = Vec::with_capacity(items.len());
let mut column = 0usize;
@ -189,13 +183,11 @@ pub(super) fn is_bounded(max_height: f32) -> bool {
max_height.is_finite() && max_height > 0.0 && max_height < f32::MAX
}
/// Offset of a column's content along the column (vertical/inline) axis for
/// a given `text-align`. In `vertical-rl` the inline axis runs top->bottom, so
/// Left/Start anchor to the top, Right/End to the bottom and Center to the
/// middle of the wrap budget. `budget` is the column wrap height (the box
/// height); an unbounded budget (auto-width, columns are snug) yields no shift.
/// Justify yields no uniform shift here — its space is distributed between
/// cells by `ordered_expansion_offsets`.
/// Offset of a column's content along the inline axis for `text-align`. In
/// `vertical-rl` that axis runs top to bottom: Left/Start anchor to the top,
/// Right/End to the bottom, Center to the middle of `budget` (the column wrap
/// height). An unbounded budget (auto-width) and Justify yield no shift;
/// `ordered_expansion_offsets` spreads justify space between cells.
pub(super) fn align_offset_along_column(align: TextAlign, budget: f32, used: f32) -> f32 {
if !is_bounded(budget) {
return 0.0;
@ -208,10 +200,9 @@ pub(super) fn align_offset_along_column(align: TextAlign, budget: f32, used: f32
}
}
/// Japanese inter-script spacing at an upright CJK <-> rotated alphabetic or
/// numeric boundary. Punctuation and explicit whitespace do not create an
/// automatic gap. Use the smaller adjacent font size so a large neighboring
/// run cannot create a disproportionate gap.
/// Japanese inter-script spacing at an upright CJK <-> rotated alphanumeric
/// boundary; punctuation and whitespace get none. Scales by the smaller
/// adjacent font size so a large neighbour cannot widen the gap.
fn inter_script_spacing(
previous: FlowScript,
previous_font_size: f32,
@ -241,11 +232,10 @@ fn inter_script_spacing(
}
}
/// Give every upright <-> Western boundary its inter-script gap. The
/// preceding cell's flow advance is adjusted so the *visible ink* edges have
/// the target gap: logical advances alone are asymmetric around mixed
/// fonts/glyphs (e.g. `うpenあ`) because their side bearings differ. The
/// preceding cell's explicit trailing letter-spacing is preserved.
/// Give every upright <-> Western boundary its inter-script gap, measured
/// between ink edges: side bearings differ across fonts, so advances alone
/// would space `うpenあ` unevenly. Adjusts the preceding cell's extent and
/// keeps its trailing letter-spacing.
pub(super) fn apply_inter_script_spacing(cells: &mut [FlowCell]) {
for i in 1..cells.len() {
let target_gap = inter_script_spacing(
@ -299,12 +289,11 @@ fn embedded_trailing_aki(class: JapaneseClass) -> f32 {
}
}
/// JLREQ punctuation and cl-30 adjacency. Full-width fonts bake the half-em
/// aki into punctuation advances. That preferred aki stays in ordinary text,
/// but one half-em goes at the internal boundaries defined by §3.1.4:
/// closing punctuation sequences set solid, closing→opening retains a single
/// half-em, opening sequences set solid after the first bracket, and
/// middle-dot adjacency retains its own quarter-em side spacing.
/// JLREQ punctuation and cl-30 adjacency. Full-width fonts include a half-em
/// aki in punctuation advances. Ordinary text keeps it; at the internal
/// boundaries of §3.1.4 one half-em goes: closing sequences set solid,
/// closing→opening keeps one half-em, opening sequences set solid after the
/// first bracket, and middle dots keep their quarter-em sides.
pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], classes: &[Option<JapaneseClass>]) {
for (i, flow) in cells.iter_mut().enumerate() {
let Some(ch) = flow.ch else {
@ -337,10 +326,9 @@ pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], classes: &[Option<Jap
}
}
/// Smallest legal flow extent after oikomi. Full-width Japanese punctuation
/// normally carries removable aki inside its one-em advance, but `vpal` may
/// have removed that aki already. Clamp the floor to the actual shaped extent
/// so a proportional half-em glyph frame can never be compressed again.
/// Smallest legal flow extent after oikomi. Full-width punctuation floors at
/// a half-em frame, or at its shaped extent when `vpal` has already removed
/// the aki.
pub(super) fn minimum_oikomi_extent(
ch: Option<char>,
extent: f32,
@ -429,10 +417,9 @@ pub(super) fn preferred_pair_spacing(classes: &[Option<JapaneseClass>]) -> Vec<f
.collect()
}
/// cl-04 question/exclamation marks carry an explicit one-em space after
/// their character frame. Unlike bracket/comma/full-stop aki, this space is
/// not embedded in the font's full-width advance, so it must exist in the
/// flow extent before oikomi is allowed to reduce it.
/// cl-04 question/exclamation marks take a one-em space after their frame.
/// Fonts leave it out of the advance (unlike bracket aki), so this adds it
/// to the flow extent, where oikomi can reduce it.
pub(super) fn materialize_explicit_pair_spacing(
cells: &mut [FlowCell],
classes: &[Option<JapaneseClass>],
@ -457,8 +444,8 @@ fn spacing_owner(before: JapaneseClass, after: JapaneseClass, boundary: usize) -
after,
JapaneseClass::OpeningBracket | JapaneseClass::MiddleDot
) {
// Sequence layout removes any redundant preceding trailing half; the
// remaining aki is the next glyph's embedded leading space.
// After sequence shedding, the remaining aki is the next glyph's
// embedded leading space.
(boundary + 1, true)
} else {
(boundary, false)
@ -478,8 +465,8 @@ fn reduce_cell_spacing(cells: &mut [FlowCell], index: usize, amount: f32, leadin
amount
}
/// Explicit spacing at a column edge is discarded (no trailing `!` space at
/// the column bottom). Returns true when any spacing was removed.
/// Discard explicit spacing at column edges (no trailing `!` space at a
/// column bottom). Returns true when it removed any.
fn discard_explicit_spacing_at_column_edges(
cells: &mut [FlowCell],
classes: &[Option<JapaneseClass>],
@ -509,9 +496,9 @@ fn discard_explicit_spacing_at_column_edges(
}
/// JLREQ oikomi: before wrapping a non-hanging item, try to keep it in the
/// current column by reducing only legal aki, in table priority order. If the
/// complete deficit cannot be recovered, leave the line untouched for the
/// subsequent oidashi/kinsoku planner.
/// column by reducing legal aki in table priority order. When the whole
/// deficit cannot be recovered, leave the line to the oidashi/kinsoku
/// planner.
pub(super) fn apply_ordered_oikomi(
cells: &mut [FlowCell],
classes: &[Option<JapaneseClass>],
@ -604,13 +591,12 @@ fn compress_line(
removed_from_line
}
/// Plan the columns, then trim spacing that a break left at a column edge
/// and re-plan, since a shorter line may pull one more cell into the
/// preceding column. Each round only shrinks a previously untrimmed cell, so
/// the loops are bounded by the cell count.
/// Plan the columns, then trim spacing a break left at a column edge and
/// re-plan, since a shorter line may pull another cell into the column. Each
/// round shrinks a not-yet-trimmed cell, so the cell count bounds the loops.
///
/// Wrapped opening brackets use the JIS X 4051 tentsuki policy: their leading
/// half-em is discarded at a column head.
/// Opening brackets at a column head follow the JIS X 4051 tentsuki policy
/// and drop their leading half-em.
pub(super) fn plan_with_edge_trimming(
cells: &mut [FlowCell],
classes: &[Option<JapaneseClass>],
@ -643,9 +629,9 @@ pub(super) fn plan_with_edge_trimming(
}
/// Ordered oidashi expansion for justified columns. Stages 1–3 respect the
/// table caps; if slack remains, stage 4 distributes it equally across every
/// otherwise-expandable boundary, as required by JLREQ §3.8.4. Returns the
/// extra flow offset of every cell. The last column is not justified.
/// table caps; stage 4 spreads any remaining slack evenly over every
/// expandable boundary (JLREQ §3.8.4). Returns each cell's extra flow offset.
/// Skips the last column.
pub(super) fn ordered_expansion_offsets(
cells: &[FlowCell],
classes: &[Option<JapaneseClass>],
@ -760,10 +746,8 @@ mod tests {
#[test]
fn plan_columns_burasage_hangs_comma_period() {
// 。 overflows the two-cell column; instead of oidashi (pushing い down
// with it), burasage lets it hang past the column bottom. The invariant
// "no forbidden-at-line-start char at a column top" still holds — 。
// never reaches the next column's top.
// 。 overflows the two-cell column; burasage hangs it past the column
// bottom, so no oidashi pushes い down and 。 never starts a column.
let items = vec![
item(10.0, 'あ'),
item(10.0, 'い'),
@ -800,10 +784,8 @@ mod tests {
#[test]
fn plan_columns_kinsoku_shift_never_overflows_budget() {
// A two-item budget: moving the trailing 「「 down with the
// overflowing い would put three items (30.0) in a 20.0 column;
// the shift is dropped instead, keeping every column within the
// wrap budget.
// A two-item budget: moving 「「 down with the overflowing い would put
// three items (30.0) in a 20.0 column, so the planner drops the shift.
let items = vec![
item(10.0, 'あ'),
item(10.0, '「'),
@ -914,8 +896,7 @@ mod tests {
fn closing_then_opening_keeps_half_em_aki() {
// く」「く: the closing bracket sheds its trailing half, but the
// opening bracket after it keeps its full em (leading half blank), so
// the pair keeps the half-em aki JIS X 4051 asks for instead of
// setting solid.
// the pair keeps the half-em aki of JIS X 4051.
let content = make_content(&["く」「く"], 1000.0);
let layout = layout_with(&provider(VMTX_TEST_FONT), &content);
assert_eq!(layout.cells.len(), 4, "one cell per character");
@ -1122,9 +1103,8 @@ mod tests {
#[test]
fn vpal_oikomi_preserves_half_em_punctuation_frame() {
// vpal has already removed the opening bracket's leading aki. A tight
// fixed-height column must wrap instead of offering that same half-em
// to oikomi and collapsing the glyph frame a second time.
// vpal has removed the opening bracket's leading aki, so in a tight
// fixed-height column oikomi cannot shrink the frame and text wraps.
let provider = provider(VPAL_TEST_FONT);
let content = vpal_content("あ「あ");
let natural = layout_with(&provider, &content);

View File

@ -12,8 +12,7 @@ use crate::shapes::gpos_vpal::{parse_vpal, VpalDelta};
type TableCache<T> = RefCell<HashMap<u32, Option<Rc<T>>>>;
thread_local! {
// Layouts are recomputed on every paint but a face's tables never
// change, so each table (whose parse copies it) is read once per face.
// Layout runs on every paint; each face's tables are parsed once.
static VERTICAL_METRICS: TableCache<VerticalMetrics> = RefCell::default();
static VPAL_TABLES: TableCache<VpalTable> = RefCell::default();
static TYPO_METRICS: TableCache<TypoMetrics> = RefCell::default();
@ -44,10 +43,9 @@ fn table_data(font: &Font, tag: &[u8; 4]) -> Option<skia_safe::Data> {
}
/// Vertical advances from a font's `vhea`/`vmtx` tables. Upright cells
/// advance down the column by the glyph's true vertical advance rather
/// than its shaped horizontal advance: identical for full-width CJK, but
/// correct for vertical alternates and proportional glyphs whose `vmtx`
/// differs from `hmtx` (e.g. the vertical kana repeat marks).
/// advance by the glyph's vertical advance, not its shaped horizontal one;
/// the two differ for vertical alternates and proportional glyphs (e.g. the
/// vertical kana repeat marks).
pub(super) struct VerticalMetrics {
units_per_em: f32,
/// Advance heights of the first `advances.len()` glyph ids.
@ -57,9 +55,8 @@ pub(super) struct VerticalMetrics {
}
impl VerticalMetrics {
/// Parse `vhea`/`vmtx` off the font's typeface. Returns `None` when the
/// font carries no vertical metrics (the caller then keeps horizontal
/// advances).
/// Parses `vhea`/`vmtx`. `None` when the font has no vertical metrics
/// (callers keep horizontal advances).
pub(super) fn from_font(font: &Font) -> Option<Self> {
let units_per_em = units_per_em(font)?;
let vhea = table_data(font, b"vhea")?;
@ -88,8 +85,8 @@ impl VerticalMetrics {
})
}
/// Vertical advance of `glyph` at `font_size`, in pixels at the shaped
/// size (same units as the horizontal advances).
/// Vertical advance of `glyph` at `font_size`, in pixels (same units as
/// the horizontal advances).
pub(super) fn advance(&self, glyph: GlyphId, font_size: f32) -> f32 {
let raw = self
.advances
@ -122,10 +119,9 @@ impl VpalTable {
}
/// Pixel deltas for a cluster at `font_size`: summed advance delta
/// (negative when the cell tightens) and the flow-axis shift of the
/// drawn ink (positive down the column). GPOS `yPlacement` is y-up,
/// so its sign flips into flow space. `None` when no glyph of the
/// cluster is covered.
/// (negative when the cell tightens) and flow-axis ink shift (positive
/// down the column; GPOS `yPlacement` is y-up, so its sign flips).
/// `None` when no glyph of the cluster is covered.
pub(super) fn cluster_delta(&self, glyphs: &[GlyphId], font_size: f32) -> Option<(f32, f32)> {
let scale = font_size / self.units_per_em;
let mut advance = 0.0f32;
@ -144,11 +140,10 @@ pub(super) fn vpal_table(font: &Font) -> Option<Rc<VpalTable>> {
cached_table(&VPAL_TABLES, font, VpalTable::from_font)
}
/// OS/2 typographic ascender/descender, normalised to the em (design units /
/// unitsPerEm). These bound the ideographic em box and are what the browser
/// uses for the vertical central baseline; `hhea`/`Font::metrics` are oversized
/// for CJK faces (their ascent exceeds the em) and would push an upright glyph
/// off the ideographic centre of its cell.
/// OS/2 typographic ascender/descender over unitsPerEm. They bound the
/// ideographic em box, which the browser uses for the vertical central
/// baseline. `hhea`/`Font::metrics` ascent exceeds the em in CJK faces and
/// would push an upright glyph off the centre of its cell.
struct TypoMetrics {
/// sTypoAscender / unitsPerEm (positive, above the baseline).
ascender: f32,
@ -161,8 +156,7 @@ impl TypoMetrics {
let units_per_em = units_per_em(font)?;
let os2 = table_data(font, b"OS/2")?;
let os2 = os2.as_bytes();
// sTypoAscender @ 68 (i16), sTypoDescender @ 70 (i16); present in every
// OS/2 table version.
// sTypoAscender @ 68, sTypoDescender @ 70 (i16), in every OS/2 version.
let ascender = i16::from_be_bytes([*os2.get(68)?, *os2.get(69)?]) as f32;
let descender = i16::from_be_bytes([*os2.get(70)?, *os2.get(71)?]) as f32;
Some(Self {
@ -173,10 +167,9 @@ impl TypoMetrics {
}
/// Ascent/descent for centring an upright cell, in Skia's sign convention
/// (ascent negative, descent positive) at the font's current size. Prefers the
/// OS/2 typographic metrics (the ideographic em box, matching the browser's
/// vertical central baseline used by the SVG/foreignObject export); falls back
/// to `Font::metrics` when the face carries no OS/2 table.
/// (ascent negative, descent positive) at the font's size. Uses the OS/2
/// typographic metrics, which match the browser's vertical central baseline
/// in the SVG/foreignObject export, or `Font::metrics` without an OS/2 table.
pub(super) fn upright_centre_metrics(font: &Font) -> (f32, f32) {
if let Some(typo) = cached_table(&TYPO_METRICS, font, TypoMetrics::from_font) {
let size = font.size();
@ -186,11 +179,10 @@ pub(super) fn upright_centre_metrics(font: &Font) -> (f32, f32) {
(metrics.ascent, metrics.descent)
}
/// Vertical offset from a cell's top edge to the glyph baseline for an upright
/// cell. Centres the glyph's line box within the em cell (as the Tate-chu-yoko
/// path does) instead of hanging it from the horizontal ascent: CJK faces have
/// an ascent larger than the em, so hanging from it pushes every glyph below
/// its cell and the whole column overflows its bounds.
/// Offset from an upright cell's top edge to the glyph baseline. Centres the
/// glyph's line box in the em cell, as Tate-chu-yoko does. CJK faces have an
/// ascent larger than the em, so hanging glyphs from the ascent would push
/// them below their cells and overflow the column.
pub(super) fn upright_baseline_offset(ascent: f32, descent: f32, font_size: f32) -> f32 {
font_size / 2.0 - (ascent + descent) / 2.0
}

View File

@ -33,10 +33,9 @@ pub enum CellKind {
glyph: usize,
count: usize,
},
/// A character that participates in upright Japanese flow and kinsoku,
/// but whose font has no `vert`/`vrt2` alternate. It is rotated per cell
/// instead of becoming a sideways run so wrapping and editor offsets stay
/// character-granular.
/// An upright-flow character whose font lacks a `vert`/`vrt2` alternate,
/// rotated in its own cell so wrapping, kinsoku and editor offsets stay
/// per character.
SyntheticRotated {
run: usize,
glyph: usize,
@ -46,21 +45,17 @@ pub enum CellKind {
run: usize,
},
TateChuYoko {
/// Composite of one or more shaped runs (fallback fonts each add a
/// run) laid side by side; `[run_start, run_start + run_count)`.
/// Runs `[run_start, run_start + run_count)`, laid side by side.
run_start: usize,
run_count: usize,
scale: f32,
},
Warichu {
/// Two half-size sub-lines stacked side by side within the column:
/// runs `[run_start, run_start + first_count)` are the first (right)
/// sub-line, the rest up to `run_start + run_count` the second (left).
/// First `first_count` runs are the right sub-line, the rest the left.
run_start: usize,
run_count: usize,
first_count: usize,
/// UTF-16 length of the first sub-line's text (the split point,
/// relative to the cell's `start`).
/// UTF-16 length of the first sub-line (split point from `start`).
first_chars: usize,
},
}
@ -75,14 +70,11 @@ pub struct VerticalCell {
pub end: usize,
pub column: usize,
pub top: f32,
/// Advance along the column (vertical/flow axis) — from `vmtx` when
/// available, else the shaped horizontal advance.
/// Flow-axis advance, from `vmtx` when present, else the horizontal one.
pub extent: f32,
/// Lower bound for oikomi reductions. This is the glyph frame after font
/// features such as `vpal`, before any removable pair spacing is added.
/// Oikomi lower bound: the glyph frame before removable pair spacing.
pub minimum_oikomi_extent: f32,
/// Shaped horizontal glyph advance, used to centre the glyph on the
/// column axis (independent of the vertical flow `extent`).
/// Shaped horizontal advance, used to centre the glyph in the column.
pub h_advance: f32,
/// Visible glyph-ink edges along the flow axis, relative to `top`.
pub ink_top: f32,
@ -92,10 +84,7 @@ pub struct VerticalCell {
pub font_size: f32,
/// Span text decoration, painted as vertical bars along the column.
pub decoration: Option<TextDecoration>,
/// Extra flow-axis (vertical) shift applied to the drawn glyph only, used to
/// pull half-width opening punctuation up into its compressed cell so its
/// ink hugs the preceding character (jlreq leading aki removal). Zero for
/// every other cell; never affects extent, caret, or position-data.
/// Draw-only flow shift for half-width opening punctuation (jlreq aki).
pub glyph_flow_shift: f32,
}
@ -107,9 +96,7 @@ pub struct VerticalColumn {
pub width: f32,
/// Reserved annotation gutter before the base band (left / `under`).
pub base_offset: f32,
/// Line-height-controlled column advance. Base glyphs centre on this band.
/// Ruby reserves additional column width but attaches to the centred base
/// em, so extra leading does not become base-to-ruby spacing.
/// Line-height column advance that base glyphs centre on.
pub base_width: f32,
}
@ -125,8 +112,7 @@ pub struct VerticalLayout {
/// Shaped ruby annotation runs, indexed by `RubyCell::run`.
pub ruby_runs: Vec<ShapedRun>,
pub ruby_cells: Vec<RubyCell>,
/// Shaped emphasis-mark runs (single glyph each), indexed by
/// `EmphasisMark::run`.
/// Single-glyph emphasis runs, indexed by `EmphasisMark::run`.
pub emphasis_runs: Vec<ShapedRun>,
pub emphasis_marks: Vec<EmphasisMark>,
/// Per paragraph: [start, end) range into `columns`.
@ -137,19 +123,15 @@ pub struct VerticalLayout {
pub span_source_utf16_starts: Vec<Vec<usize>>,
/// Per paragraph and span: transformed scalar ownership in source text.
pub span_transforms: Vec<Vec<AppliedTextTransform>>,
/// Per paragraph: UTF-16 offset of every Unicode scalar boundary. Editor
/// positions use indices into this table, while cells and position data
/// keep their browser-facing UTF-16 offsets.
/// Per paragraph: UTF-16 offset of each scalar boundary, for editor positions.
pub paragraph_utf16_boundaries: Vec<Vec<usize>>,
pub width: f32,
pub height: f32,
}
impl VerticalLayout {
/// Content origin (top-left of the laid-out block) in the same
/// coordinate space as `bounds`. In vertical-rl, block-start is the
/// right edge, block-center is the horizontal center and block-end is
/// the left edge.
/// Content origin (top-left of the laid-out block) in the coordinate
/// space of `bounds`.
pub fn origin(&self, bounds: &Rect, align: VerticalAlign) -> (f32, f32) {
(
bounds.left + block_axis_offset(bounds.width(), self.width, align),
@ -158,9 +140,9 @@ impl VerticalLayout {
}
}
/// Horizontal offset of vertical content within its shape. The existing
/// top/center/bottom values describe block-start/center/end; for vertical-rl
/// those positions map to right/center/left respectively.
/// Horizontal offset of vertical content within its shape. `VerticalAlign`
/// top/center/bottom mean block start/center/end: right/center/left in
/// vertical-rl.
pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 {
let slack = (container_width - content_width).max(0.0);
match align {
@ -170,10 +152,8 @@ pub fn block_axis_offset(container_width: f32, content_width: f32, align: Vertic
}
}
/// The column-wrap limit for a vertical text content: auto-width shapes
/// grow to fit (columns never wrap), everything else wraps at the shape
/// height. This is the phase's explicit auto-size decision: auto-height
/// behaves like fixed under vertical writing for now.
/// Column-wrap limit: auto-width shapes grow to fit (columns never wrap);
/// all others, auto-height included, wrap at the shape height.
pub fn wrap_height(text_content: &TextContent, height: f32) -> f32 {
match text_content.grow_type() {
GrowType::AutoWidth => f32::MAX,
@ -211,8 +191,8 @@ impl ColumnGeometry {
.fold(0.0, f32::max)
};
let ruby_over_gutter = ruby_gutter(RubySide::Over);
// Emphasis occupies the over/right side. Auto-clearance spans carrying
// both annotation types stack there; opposite-side ruby stays separate.
// Emphasis takes the over (right) side. Auto-clearance spans with both
// annotations stack there; under-side ruby stays separate.
let emphasis_gutter = if spans.iter().any(|s| !s.text_emphasis.is_none()) {
max_font_size * EMPHASIS_FONT_SCALE
} else {
@ -279,9 +259,9 @@ fn build_paragraph_flow(
(flow, span_starts)
}
/// A CSS transform may expand one source character into several shaped
/// cells. Keep those cells in one column so a source slice is rendered
/// exactly once by the SVG fallback (for example `ß` -> `SS`).
/// A CSS transform may expand one source character into several cells
/// (`ß` -> `SS`). Keep them in one column so the SVG fallback renders each
/// source slice once.
fn keep_transform_expansions_together(
flow: &mut [FlowCell],
transforms: &[AppliedTextTransform],
@ -298,10 +278,9 @@ fn keep_transform_expansions_together(
}
}
/// Final flow-axis top of every cell: its planned offset, shifted by the
/// paragraph's text-align. Justify stretches every column but the last (the
/// last "line") to fill the wrap budget; a snug auto-width budget has no
/// slack.
/// Final flow-axis top of each cell: its planned offset plus the text-align
/// shift. Justify stretches every column but the last to fill a bounded wrap
/// height.
fn aligned_tops(
flow: &[FlowCell],
classes: &[Option<JapaneseClass>],

View File

@ -10,25 +10,23 @@
// paint / outline export (`paint`) and position data / hit-testing
// (`positions`) from the cells.
//
// Offset discipline: cells use UTF-16 offsets in transformed layout text.
// Position data maps those ranges back to the original span text before it
// crosses the WASM boundary; the WORD JOINER OffsetMap remains exclusive to
// Offsets: cells use UTF-16 offsets in transformed layout text. Position
// data maps those ranges back to the original span text before it crosses
// the WASM boundary; the WORD JOINER OffsetMap applies only to
// skparagraph-driven horizontal breaks.
//
// Layouts are computed on demand (like the horizontal path, which rebuilds
// its skparagraph objects per paint); only per-typeface font tables are
// cached.
// Layouts are computed on demand, as the horizontal path rebuilds its
// skparagraph objects per paint; only per-typeface font tables are cached.
//
// Text-align aligns each column's glyphs along the vertical (inline) axis:
// Left/Start->top, Center->middle, Right/End->bottom of the wrap budget.
// Justify stretches every column but the last to fill the wrap budget.
//
// Letter-spacing adds inter-glyph advance along the column — once per
// upright cluster and once per glyph inside a rotated run — mirroring the
// horizontal `letter-spacing` that Skia applies to each glyph advance.
// Letter-spacing adds advance along the column once per upright cluster
// and once per glyph inside a rotated run, matching the horizontal
// `letter-spacing` that Skia applies to each glyph advance.
//
// Deferred to a later phase: PDF/vector emoji overlays, inner shadows and
// block-axis vertical-align.
// Not supported yet: PDF/vector emoji overlays and inner shadows.
mod annotations;
mod cells;

View File

@ -1,8 +1,7 @@
use crate::shapes::TextOrientation;
/// Emoji ranges recognized by the frontend font-loader. Emoji use their
/// intrinsic upright presentation in vertical flow instead of rotating like
/// Latin text under `text-orientation: mixed`.
/// Emoji ranges recognized by the frontend font-loader. Emoji stay upright
/// in vertical flow; Latin text rotates under `text-orientation: mixed`.
pub(super) fn is_emoji_char(c: char) -> bool {
matches!(u32::from(c),
0x2300..=0x23FF
@ -35,12 +34,11 @@ pub(super) fn is_upright_char(c: char) -> bool {
)
}
/// Characters whose horizontal glyph needs a vertical alternate. `vert` /
/// `vrt2` normally supplies that alternate; when the selected face has no
/// such substitution, rotate the horizontal glyph clockwise as a legible
/// fallback. The set covers UAX #50 `Tr`, plus comma/full-stop punctuation
/// whose untransformed glyph otherwise occupies the wrong half of the
/// vertical em box.
/// Characters whose horizontal glyph needs a vertical alternate, normally
/// from `vert` / `vrt2`. When the face has no such substitution, the
/// horizontal glyph rotates clockwise as a fallback. Covers UAX #50 `Tr`,
/// plus comma/full-stop punctuation whose plain glyph sits in the wrong
/// half of the vertical em box.
pub(super) fn uses_rotated_vertical_fallback(c: char) -> bool {
matches!(u32::from(c),
0x2018..=0x2019 // single quotation marks
@ -83,10 +81,10 @@ pub(super) fn segment_by_orientation(text: &str, orientation: TextOrientation) -
for c in text.chars() {
let upright = orientation == TextOrientation::Upright || is_upright_char(c);
let emoji = is_emoji_char(c);
// CJK and emoji are both upright, but keep a shaping boundary between
// them so the segment probe can explicitly select the emoji family.
// The provider's generic fallback iterator does not reliably switch
// from a registered CJK face to a registered color-emoji face.
// CJK and emoji are both upright, but split them so the segment
// probe can select the emoji family: the provider's generic fallback
// iterator does not reliably switch from a CJK face to a color-emoji
// face.
match segments.last_mut() {
Some(last)
if last.upright == upright

View File

@ -12,10 +12,9 @@ use super::font_tables::upright_baseline;
use super::layout::{column_base_center, layout_for_box, CellKind, VerticalCell, VerticalLayout};
use super::shaping::{single_glyph_blob, ShapedRun};
/// One text blob placed in document space: drawn at `offset` in the local
/// space that `transform` maps to the document (identity when `None`).
/// Canvas painting and outline export consume the same draws, so both stay
/// in sync for every cell kind.
/// One text blob drawn at `offset` in the local space that `transform` maps
/// to the document (identity when `None`). Canvas painting and outline export
/// share these draws, so they match for every cell kind.
struct GlyphDraw {
blob: TextBlob,
offset: SkPoint,
@ -155,9 +154,8 @@ fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32))
}
}
/// One warichu sub-line: upright half-size glyphs stacked down the
/// sub-column centred on `x_center`, clusters kept together like the normal
/// upright path.
/// One warichu sub-line: half-size upright glyphs stacked down the
/// sub-column centred on `x_center`, one cluster per cell.
fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec<GlyphDraw> {
let mut draws = Vec::new();
let mut cursor = y_top;
@ -181,10 +179,9 @@ fn warichu_line_draws(runs: &[ShapedRun], x_center: f32, y_top: f32) -> Vec<Glyp
draws
}
/// One column's slice of a ruby annotation stacked upright in its column's
/// ruby gutter. Each glyph's flow-axis position comes from the whole base
/// span; each glyph is centred across the gutter using its own
/// fallback-font run.
/// One column's slice of a ruby annotation, stacked upright in the column's
/// ruby gutter. Flow positions come from the whole base span; each glyph is
/// centred across the gutter by its own fallback-font run.
fn ruby_draws(layout: &VerticalLayout, ruby: &RubyCell, origin: (f32, f32)) -> Vec<GlyphDraw> {
let column = &layout.columns[ruby.column];
let gutter_center = origin.0
@ -361,8 +358,7 @@ fn paths_from_layout(
paths
}
/// Convert the custom vertical layout to glyph-outline paths using the same
/// cells, composite transforms, annotations and alignment as the canvas pass.
/// Glyph-outline paths of the vertical layout, matching the canvas pass.
pub fn vertical_text_paths(
text_content: &TextContent,
vertical_align: VerticalAlign,
@ -376,14 +372,11 @@ pub fn vertical_text_paths(
paths_from_layout(&layout, &bounds, vertical_align, antialias)
}
/// Developer overlay: draw a jlreq-style character-frame grid over the
/// laid-out vertical cells. For every column it outlines the column band;
/// for every cell it draws the advance box (the real layout cell), the
/// virtual body / em square centred on the column axis, and the glyph-ink
/// band. This exposes solid-setting, aki, letter-spacing and column
/// planning visually, mirroring the grids in the jlreq figures. It is never
/// emitted to exported or persisted output — only the on-screen fills pass
/// calls it, gated by the `TEXT_GRID_VISIBLE` render flag.
/// Developer overlay: a jlreq-style character-frame grid. Outlines each
/// column band and, per cell, the advance box, the virtual body / em square
/// centred on the column axis, and the glyph-ink band, so aki and
/// letter-spacing are visible. Only the on-screen fills pass calls it, behind
/// the `TEXT_GRID_VISIBLE` render flag; it never reaches exported output.
pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align: VerticalAlign) {
let (origin_x, origin_y) = layout.origin(bounds, align);
@ -462,9 +455,9 @@ pub fn paint_grid(canvas: &Canvas, layout: &VerticalLayout, bounds: &Rect, align
}
/// Paint the vertical glyph shadows. The shadow paint carries the
/// blur/offset image filter; drawing the glyphs directly through it renders
/// each glyph's shadow without allocating a full-content offscreen layer
/// (a `save_layer` with the filter can exceed GPU limits on tall columns).
/// blur/offset image filter, so glyphs draw through it with no offscreen
/// layer (a `save_layer` with the filter can exceed GPU limits on tall
/// columns).
pub fn paint_drop_shadow(
canvas: &Canvas,
layout: &VerticalLayout,
@ -478,10 +471,9 @@ pub fn paint_drop_shadow(
paint_glyphs(canvas, layout, bounds, vertical_align, &paint);
}
/// Paint a stroke masked to the vertical glyph silhouettes. Center strokes
/// draw the stroked outline directly; inner/outer strokes are masked with
/// `SrcIn` / `SrcOut` against the glyph silhouette (mirrors the horizontal
/// masked-stroke path).
/// Paint a stroke on the vertical glyphs. Center strokes draw directly;
/// inner/outer strokes mask with `SrcIn` / `SrcOut` against the glyph
/// silhouette, as the horizontal path does.
pub fn paint_stroke(
canvas: &Canvas,
layout: &VerticalLayout,
@ -562,9 +554,7 @@ fn paint_masked_stroke(
}
fn text_blob_path(mut blob: TextBlob, offset: impl Into<SkPoint>) -> skia::Path {
// SkParagraph normalizes extracted glyph outlines against the blob's ink
// bounds. Restore that origin before applying the draw offset so the path
// occupies the same document coordinates as Canvas::draw_text_blob.
// get_path is relative to the blob's ink bounds; add them back to match.
let bounds = *blob.bounds();
let offset = offset.into();
SkiaParagraph::get_path(&mut blob).with_offset((offset.x + bounds.left, offset.y + bounds.top))
@ -584,11 +574,10 @@ fn push_text_path(
paths.push((path, paint));
}
/// Decoration bar geometry for a cell in absolute coordinates. Underline
/// runs along the *left* side of the column (the under side in
/// `vertical-rl`); line-through runs down the column centre. Both span the
/// cell's vertical extent so consecutive decorated cells tile a continuous
/// bar.
/// Decoration bar of a cell in absolute coordinates. Underline runs along
/// the *left* side of the column (the under side in `vertical-rl`);
/// line-through runs down the centre. Both span the cell's extent, so
/// adjacent decorated cells form one bar.
fn decoration_bar(
layout: &VerticalLayout,
cell: &VerticalCell,
@ -682,12 +671,9 @@ mod tests {
let underline = decoration_bar(&layout, cell, ox, oy, false);
let strike = decoration_bar(&layout, cell, ox, oy, true);
// Underline sits left of the column axis; line-through is centered.
assert!(underline.center_x() < x_center);
assert!((strike.center_x() - x_center).abs() < 0.01);
// Both bars span the cell's vertical extent.
assert!((underline.height() - cell.extent).abs() < 0.01);
// Bar thickness is at least 1px.
assert!(underline.width() >= 1.0 - 0.01);
}
@ -733,9 +719,8 @@ mod tests {
let mut surface = skia::surfaces::raster_n32_premul((256, 256)).unwrap();
let canvas = surface.canvas();
// Shape transforms are applied on the caller's canvas. Exercise a
// non-trivial transform with mixed upright/rotated cells so every
// vertical paint pass remains transform-safe.
// Callers apply shape transforms to the canvas; run every pass under
// a non-trivial transform with upright and rotated cells.
canvas.translate((12.0, 8.0));
canvas.rotate(7.0, Some((64.0, 64.0).into()));
@ -748,7 +733,7 @@ mod tests {
&Paint::default(),
);
// With a real drop-shadow image filter (as `drop_shadow_paints` builds).
// A real drop-shadow image filter, as `drop_shadow_paints` builds.
let mut shadow_paint = Paint::default();
shadow_paint.set_image_filter(skia::image_filters::drop_shadow(
(12.0, 12.0),

View File

@ -8,20 +8,13 @@ use crate::shapes::{PositionData, VerticalAlign};
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)
/// strip; 0/1 are the horizontal rtl/ltr values. The CLJS deserializer
/// turns it into `:writing-mode "vertical-rl"` on the entry so the
/// legacy SVG renderer can draw the strip vertically.
/// Position-data `direction` of a vertical-rl strip; 0/1 are rtl/ltr.
pub const DIRECTION_VERTICAL_RL: u32 = 2;
/// Position-data `direction` value marking a ruby annotation strip: the
/// entry's offsets index the span's *ruby* string and the geometry is the
/// exact gutter placement the canvas paints.
/// Position-data `direction` of a ruby strip; offsets index the *ruby* string.
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.
/// Position-data `direction` of one emphasis mark (圏点) box, in any mode.
pub const DIRECTION_EMPHASIS_MARK: u32 = 4;
/// Paragraph source UTF-16 range of a `transformed` span-text range.
@ -134,8 +127,7 @@ pub fn position_data(
}
}
let column = &layout.columns[first.column];
// Base text occupies the base sub-band; any ruby gutter is excluded
// so the editor overlay and selection track the glyphs.
// Base sub-band only, so overlay and selection skip the ruby gutter.
let rect = (
origin_x + column.x,
origin_y + first.top,
@ -152,11 +144,9 @@ pub fn position_data(
i = j;
}
// Ruby annotation strips, with the exact flow-axis placement the canvas
// paints. `start_pos`/`end_pos` are UTF-16 offsets
// into the span's ruby string, taken from the shaped clusters so
// surrogate-pair readings slice correctly. The consumer renders them as
// their own half-size vertical strips in the column's right-side gutter.
// Ruby strips at the flow positions the canvas paints. `start_pos` and
// `end_pos` are UTF-16 offsets into the span's ruby string, taken from the
// shaped clusters so surrogate pairs slice correctly.
for ruby in &layout.ruby_cells {
let (Some(first), Some(last)) = (ruby.glyphs.first(), ruby.glyphs.last()) else {
continue;
@ -255,8 +245,8 @@ fn warichu_first_line_len(
Some(first_end.saturating_sub(cell_start).min(chars))
}
/// Caret position (paragraph index, paragraph-relative Unicode scalar offset) for
/// a point given relative to the content block's top-left origin.
/// Caret position (paragraph index, paragraph-relative Unicode scalar
/// offset) for a point relative to the content block's top-left origin.
pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usize, usize)> {
if layout.columns.is_empty() {
return None;
@ -307,9 +297,8 @@ pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usiz
cell_start + ((frac * chars as f32).round() as usize).min(chars)
}
CellKind::Warichu { first_chars, .. } => {
// Right sub-column holds the first sub-line's characters,
// left sub-column the second; the vertical position picks
// the offset within the chosen sub-line.
// The right sub-column holds the first sub-line, the left
// the second; y picks the offset within that sub-line.
let column = &layout.columns[cell.column];
let centre = column_base_center(column);
let first =
@ -341,11 +330,10 @@ pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usiz
))
}
/// Caret rectangle for a paragraph-relative Unicode scalar offset, in
/// content-local coordinates. The rect spans the column width; its height
/// is the extent of the character at the offset (used by overtype-mode
/// carets to cover the glyph) or zero when the caret sits after the last
/// character, where a thin bar is drawn instead.
/// Caret rect for a paragraph-relative Unicode scalar offset, in
/// content-local coordinates. It spans the column width; its height is the
/// extent of the character at the offset (overtype carets cover the glyph),
/// or zero after the last character.
pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> Option<Rect> {
let (col_start, _) = *layout.paragraph_columns.get(paragraph)?;
@ -371,10 +359,9 @@ pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> O
Rect::from_xywh(x, cell.top, width, cell.extent)
}
CellKind::Warichu { first_chars, .. } => {
// Right sub-column holds the first sub-line's
// characters, left sub-column the second (jlreq reading
// order); the caret tracks the offset down the chosen
// half-width sub-line.
// The right sub-column holds the first sub-line, the
// left the second (jlreq reading order); the caret moves
// down that half-width sub-line.
let first =
warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?;
let within = offset - cell_start;
@ -420,7 +407,7 @@ pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> O
})
}
/// Selection rectangles for a paragraph-relative Unicode scalar offset range, in
/// Selection rects for a paragraph-relative Unicode scalar offset range, in
/// content-local coordinates.
pub fn range_rects(
layout: &VerticalLayout,
@ -649,7 +636,6 @@ mod tests {
"second sub-line restarts at the composite top, got {}",
second.top
);
// Offsets advance down the sub-line.
let deeper = caret_rect(&layout, 0, cell.start + 4).expect("caret rect");
assert!(
deeper.top > second.top,

View File

@ -114,8 +114,8 @@ impl ShapedRun {
)
}
/// Spread the glyphs apart by `letter_spacing` per glyph, mirroring the
/// horizontal per-glyph spacing. Returns the spaced run advance.
/// Spreads the glyphs by `letter_spacing` each, like horizontal
/// per-glyph spacing. Returns the spaced run advance.
pub(super) fn spread_glyphs(&mut self, letter_spacing: f32) -> f32 {
if letter_spacing != 0.0 {
for (i, position) in self.positions.iter_mut().enumerate() {
@ -125,9 +125,9 @@ impl ShapedRun {
self.advance + letter_spacing * self.glyphs.len() as f32
}
/// Normalize ASCII word spaces inside a sideways Western run to JLREQ's
/// preferred one-third em. Shaping retains the source scalar and break
/// opportunity; only its advance and following glyph positions change.
/// Sets ASCII word spaces in a sideways Western run to JLREQ's one-third
/// em. The space keeps its scalar and break opportunity; only its advance
/// and the following glyph positions change.
pub(super) fn normalize_word_spaces(&mut self, segment_text: &str, font_size: f32) {
let clusters: Vec<(usize, usize)> = self.cluster_spans().collect();
let mut accumulated_shift = 0.0f32;
@ -232,10 +232,9 @@ fn feature(tag: &[u8; 4]) -> Feature {
}
}
/// `vpal` is deliberately absent: SkShaper shapes on a horizontal line,
/// where HarfBuzz would apply the feature's y-placement deltas as glyph
/// offsets without its advance deltas. Vertical layout applies the parsed
/// GPOS `vpal` metrics to upright cells itself.
/// Omits `vpal`: SkShaper shapes on a horizontal line, where HarfBuzz would
/// apply its y-placement deltas as glyph offsets without the advance deltas.
/// Vertical layout applies the parsed GPOS `vpal` to upright cells itself.
fn font_feature(font_features: FontFeatures) -> Option<Feature> {
match font_features {
FontFeatures::None => None,
@ -321,10 +320,9 @@ fn typeface_chunks(text: &str, typefaces: &[Typeface]) -> Vec<(Range<usize>, usi
chunks
}
/// Shape a segment with explicit per-character font fallback. Penpot's
/// `TypefaceFontProvider` can resolve named families, but its character
/// fallback hook is not available consistently in every Skia build. Resolve
/// coverage here so missing glyphs never depend on that hook.
/// Shape a segment with explicit per-character font fallback.
/// `TypefaceFontProvider` resolves named families, but some Skia builds lack
/// its character fallback hook, so glyph coverage is resolved here.
pub(crate) fn shape_segment_with_fallbacks(
text: &str,
font_size: f32,
@ -438,8 +436,8 @@ mod tests {
let shift = run.rotated_baseline_shift;
assert!(((top + bottom) / 2.0 + shift).abs() < 0.01);
// Lowercase ink does not occupy the face's full ascent/descent band;
// this guards against regressing to font-wide metric centring.
// Lowercase ink does not fill the face's ascent/descent band, so ink
// centring differs from font-wide metric centring.
let (_, metrics) = run.font.metrics();
let metrics_shift = rotated_baseline_shift(metrics.ascent, metrics.descent);
assert!((shift - metrics_shift).abs() > 0.1);

View File

@ -16,15 +16,10 @@ use super::layout::{layout_vertical, VerticalLayout};
pub(super) const TEST_FONT: &[u8] = include_bytes!("../../fonts/sourcesanspro-regular.ttf");
/// A tiny Noto Sans JP subset carrying `vmtx`/`vhea`: U+3031 (〱, the
/// vertical kana repeat mark) has a 2em vertical advance vs a 1em
/// horizontal advance; U+3042/U+304F are symmetric controls.
/// Noto Sans JP subset with `vmtx`/`vhea`: 〱 advances 2em vertically, あ/く 1em.
pub(super) const VMTX_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vmtx-test.ttf");
/// Subset of Noto Sans JP carrying GSUB `vert` and GPOS `vpal`: the
/// vertical alternates of 、。「」 halve their vertical advances (「 also
/// lifts its ink by 481 units) and the あ/く alternates tighten by
/// 58/60 units with small placement lifts.
/// Noto Sans JP subset with GSUB `vert` and GPOS `vpal` for 、。「」あく.
pub(super) const VPAL_TEST_FONT: &[u8] = include_bytes!("../../fonts/notosansjp-vpal-test.ttf");
/// Font size of every test span.

View File

@ -281,8 +281,7 @@ pub struct TextComposition {
pub previous: String,
pub current: String,
pub is_composing: bool,
/// Where the preview text starts; the preview replaced by each update is
/// `[start, start + previous]`, independent of where the caret is.
/// Preview start: each update replaces `[start, start + previous]`.
pub start: Option<TextPositionWithAffinity>,
}
@ -898,9 +897,8 @@ impl TextEditorState {
TextDirection::LTR
};
// In vertical-rl the physical arrow keys map onto logical navigation
// differently: Up/Down walk characters along the column, and Left/Right
// cross columns (columns advance right-to-left).
// In vertical-rl, Up/Down move by character along the column and
// Left/Right cross columns (columns advance right-to-left).
let is_vertical = text_content.is_vertical();
let direction = if is_vertical {
match direction {
@ -987,8 +985,8 @@ mod tests {
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 2));
composition.start = Some(replaced.start());
// The caret now sits after the preview; the next update still
// replaces the preview itself.
// The caret sits after the preview; the next update still replaces
// the preview itself.
composition.update("にほ");
let replaced = composition.get_selection(&caret(0, 3));
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 3));

View File

@ -108,8 +108,7 @@ pub struct RawParagraphData {
text_transform: RawTextTransform,
writing_mode: RawWritingMode,
text_orientation: RawTextOrientation,
// Explicit padding so the CLJS writer and this struct agree on a
// 4-byte-aligned layout; always written as zero.
// Padding for the CLJS writer's 4-byte-aligned layout; always zero.
_padding: [u8; 2],
line_height: f32,
letter_spacing: f32,
@ -149,8 +148,7 @@ pub struct RawTextSpan {
ruby_align: RawRubyAlign,
ruby_overhang: RawRubyOverhang,
ruby_side: RawRubySide,
// Explicit padding so the CLJS writer and this struct agree on a
// 4-byte-aligned layout; always written as zero.
// Padding for the CLJS writer's 4-byte-aligned layout; always zero.
_padding: [u8; 2],
font_size: f32,
line_height: f32,
@ -501,8 +499,8 @@ mod tests {
/// The CLJS writer (texts.cljs) writes PARAGRAPH-ATTR-U8-SIZE (16)
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)
/// attr bytes before the fills block. These sizes must move in
/// lockstep with the struct layouts.
/// attr bytes before the fills block. These sizes must match the struct
/// layouts.
#[test]
fn raw_struct_sizes_match_cljs_writer() {
const PARAGRAPH_ATTR_U8_SIZE: usize = 16;

View File

@ -710,8 +710,8 @@ pub extern "C" fn text_editor_move_cursor(
// ============================================================================
/// Caret rectangle in page coordinates (the unrotated selrect space of the
/// text overlay), reported independently of the blink phase. The frontend
/// keeps the IME capture surface on it.
/// text overlay), regardless of the blink phase. The frontend keeps the IME
/// capture surface on it.
#[no_mangle]
pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
with_state!(state, {
@ -725,7 +725,7 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
};
// A preview update clears the layout; rebuild it so the rect is
// current right away instead of after the next render.
// current before the next render.
update_text_layout_if_needed(state, shape_id);
let Some(shape) = state.shapes.get(&shape_id) else {