mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 15:08:24 +00:00
♻️ Extract GPU-free RenderResources; add headless wasm exports (#10653)
This commit is contained in:
parent
d4e87ec59d
commit
08e42687d1
@ -179,6 +179,15 @@
|
||||
(and (wasm-export-enabled? state)
|
||||
(contains? wasm-export-types (:type export))))
|
||||
|
||||
(defn- request-simple-export-wasm
|
||||
[export]
|
||||
(ptk/reify ::request-simple-export-wasm
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export)))))
|
||||
|
||||
(defn request-simple-export
|
||||
[{:keys [export]}]
|
||||
(ptk/reify ::request-simple-export
|
||||
@ -191,11 +200,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (use-wasm-export? state export)
|
||||
(do
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export))
|
||||
(rx/empty))
|
||||
(rx/of (request-simple-export-wasm export))
|
||||
(let [profile-id (:profile-id state)
|
||||
params {:exports [export]
|
||||
:profile-id profile-id
|
||||
|
||||
@ -665,7 +665,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps frames)
|
||||
(fn [_]
|
||||
(st/emit! (de/show-workspace-export-frames-dialog (reverse frames)))))
|
||||
(st/emit! (de/show-workspace-export-frames-dialog frames))))
|
||||
|
||||
on-export-frames-key-down
|
||||
(mf/use-fn
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.path.impl :as path.impl]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
@ -33,7 +32,7 @@
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.shapes.text]
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.api.shapes :as shapes]
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.api.texts :as t]
|
||||
[app.render-wasm.api.webgl :as webgl]
|
||||
[app.render-wasm.deserializers :as dr]
|
||||
@ -43,6 +42,7 @@
|
||||
[app.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.render-wasm.performance :as perf]
|
||||
[app.render-wasm.rulers-state :as rulers-state]
|
||||
[app.render-wasm.serialize-shape :as serialize-shape]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.svg-filters :as svg-filters]
|
||||
@ -251,8 +251,6 @@
|
||||
(def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8)
|
||||
(def ^:const GRID-LAYOUT-CELL-U8-SIZE 36)
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const DEBOUNCE_DELAY_MS 100)
|
||||
|
||||
(defonce ^:private view-interaction-active? (atom false))
|
||||
@ -634,7 +632,7 @@
|
||||
|
||||
(defn set-masked
|
||||
[masked]
|
||||
(h/call wasm/internal-module "_set_shape_masked_group" masked))
|
||||
(props/set-masked masked))
|
||||
|
||||
(defn set-shape-selrect
|
||||
[selrect]
|
||||
@ -729,10 +727,6 @@
|
||||
(perf/end-measure "set-shape-children")
|
||||
nil)
|
||||
|
||||
(defn- get-string-length
|
||||
[string]
|
||||
(+ (count string) 1))
|
||||
|
||||
|
||||
(defn- get-texture-id-for-gl-object
|
||||
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
|
||||
@ -864,124 +858,49 @@
|
||||
|
||||
(defn set-shape-fills
|
||||
[shape-id fills thumbnail?]
|
||||
(if (empty? fills)
|
||||
(h/call wasm/internal-module "_clear_shape_fills")
|
||||
(let [fills (types.fills/coerce fills)
|
||||
image-ids (types.fills/get-image-ids fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
|
||||
;; write fills to the heap
|
||||
(types.fills/write-to fills heap offset)
|
||||
|
||||
;; send fills to wasm
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
|
||||
;; load images for image fills if not cached
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
|
||||
image-ids))))
|
||||
;; Record write is shared with the headless exporter; the image fetch below is
|
||||
;; browser-only (WebGL textures).
|
||||
(when-let [fills (props/write-shape-fills! fills)]
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(types.fills/get-image-ids fills))))
|
||||
|
||||
(defn set-shape-strokes
|
||||
[shape-id strokes thumbnail?]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(keep (fn [stroke]
|
||||
(when-not (:hidden stroke)
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
;; Sentinel -1 means "unset" on the Rust side — keeps the
|
||||
;; FFI signature flat while letting the renderer fall back
|
||||
;; to its default dash pattern when no override is stored.
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
|
||||
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)
|
||||
|
||||
(some? image)
|
||||
(let [image-id (get image :id)
|
||||
buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0) (aget buffer 1)
|
||||
(aget buffer 2) (aget buffer 3)
|
||||
thumbnail?)]
|
||||
(types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(when (== cached-image? 0)
|
||||
(fetch-image shape-id image-id thumbnail?)))
|
||||
|
||||
(some? color)
|
||||
(do
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)))))
|
||||
|
||||
strokes))
|
||||
;; Record write is shared with the headless exporter; the image fetch below is
|
||||
;; browser-only (WebGL textures).
|
||||
(keep (fn [image-id]
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))
|
||||
(props/write-shape-strokes! strokes)))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
(let [style (:style attrs)
|
||||
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
|
||||
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
|
||||
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
|
||||
fill-none (= "none" (or (:fill style) (:fill attrs)))]
|
||||
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
|
||||
(props/set-shape-svg-attrs attrs))
|
||||
|
||||
(defn set-shape-path-content
|
||||
"Upload path content in chunks to WASM."
|
||||
[content]
|
||||
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
|
||||
buffer-size (path/get-byte-size content)
|
||||
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
|
||||
buffer (js/Uint8Array. padded-size)]
|
||||
(path/write-to content (.-buffer buffer) 0)
|
||||
(h/call wasm/internal-module "_start_shape_path_buffer")
|
||||
(let [heapu32 (mem/get-heap-u32)]
|
||||
(loop [offset 0]
|
||||
(when (< offset padded-size)
|
||||
(let [end (min padded-size (+ offset (* chunk-size 4)))
|
||||
chunk (.subarray buffer offset end)
|
||||
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
|
||||
offset-size (.-length chunk-u32)
|
||||
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
|
||||
(.set heapu32 chunk-u32 heap-offset)
|
||||
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
|
||||
(recur end)))))
|
||||
(h/call wasm/internal-module "_set_shape_path_buffer")))
|
||||
(props/set-shape-path-content content))
|
||||
|
||||
(defn set-shape-svg-raw-content
|
||||
[content]
|
||||
(let [size (get-string-length content)
|
||||
offset (mem/alloc size)]
|
||||
(h/call wasm/internal-module "stringToUTF8" content offset size)
|
||||
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
|
||||
(props/set-shape-svg-raw-content content))
|
||||
|
||||
(defn set-shape-blend-mode
|
||||
[blend-mode]
|
||||
@ -1026,25 +945,15 @@
|
||||
|
||||
(defn set-shape-bool-type
|
||||
[bool-type]
|
||||
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
|
||||
(props/set-shape-bool-type bool-type))
|
||||
|
||||
(defn set-shape-blur
|
||||
[blur]
|
||||
(let [type (sr/translate-blur-type :layer-blur)]
|
||||
(if (some? blur)
|
||||
(let [hidden (:hidden blur)
|
||||
value (:value blur)]
|
||||
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
(props/set-shape-blur blur))
|
||||
|
||||
(defn set-shape-background-blur
|
||||
[background-blur]
|
||||
(let [type (sr/translate-blur-type :background-blur)]
|
||||
(if (some? background-blur)
|
||||
(let [hidden (:hidden background-blur)
|
||||
value (:value background-blur)]
|
||||
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
(props/set-shape-background-blur background-blur))
|
||||
|
||||
(defn set-shape-corners
|
||||
[corners]
|
||||
@ -1261,27 +1170,7 @@
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
(props/set-shape-shadows shadows))
|
||||
|
||||
(defn fonts-from-text-content [content fallback-fonts-only?]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
@ -1320,7 +1209,7 @@
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
|
||||
(props/set-shape-grow-type grow-type))
|
||||
|
||||
(defn get-text-dimensions
|
||||
([id]
|
||||
@ -1431,45 +1320,19 @@
|
||||
id (dm/get-prop shape :id)
|
||||
type (dm/get-prop shape :type)
|
||||
|
||||
masked (get shape :masked-group)
|
||||
|
||||
fills (get shape :fills)
|
||||
strokes (if (= type :group)
|
||||
[] (get shape :strokes))
|
||||
children (get shape :shapes)
|
||||
content (let [content (get shape :content)]
|
||||
(if (= type :text)
|
||||
(ensure-text-content content)
|
||||
content))
|
||||
bool-type (get shape :bool-type)
|
||||
grow-type (get shape :grow-type)
|
||||
blur (get shape :blur)
|
||||
background-blur (get shape :background-blur)
|
||||
svg-attrs (get shape :svg-attrs)
|
||||
shadows (get shape :shadow)]
|
||||
content))]
|
||||
|
||||
(shapes/set-shape-base-props shape)
|
||||
(serialize-shape/serialize-shape! shape)
|
||||
|
||||
;; Remaining properties that need separate calls (variable-length or conditional)
|
||||
(set-shape-children children)
|
||||
(set-shape-blur blur)
|
||||
(set-shape-background-blur background-blur)
|
||||
(when (= type :group)
|
||||
(set-masked (boolean masked)))
|
||||
(when (= type :bool)
|
||||
(set-shape-bool-type bool-type))
|
||||
(when (and (some? content)
|
||||
(or (= type :path)
|
||||
(= type :bool)))
|
||||
(set-shape-path-content content))
|
||||
(when (some? svg-attrs)
|
||||
(set-shape-svg-attrs svg-attrs))
|
||||
;; Browser-only: svg-raw markup (needs React) + workspace layout.
|
||||
(when (and (some? content) (= type :svg-raw))
|
||||
(set-shape-svg-raw-content (get-static-markup shape)))
|
||||
(set-shape-shadows shadows)
|
||||
(when (= type :text)
|
||||
(set-shape-grow-type grow-type))
|
||||
|
||||
(set-shape-layout shape)
|
||||
(set-layout-data shape)
|
||||
(let [is-text? (= type :text)
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
[app.config :as cf]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.http :as http]
|
||||
@ -405,68 +406,6 @@
|
||||
[fonts]
|
||||
(keep (fn [font] (store-font font)) fonts))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
(conj fonts {:font-id "gfont-noto-color-emoji"
|
||||
:font-variant-id "regular"
|
||||
:style 0
|
||||
:weight 400
|
||||
:is-emoji true
|
||||
:is-fallback true}))
|
||||
|
||||
(def noto-fonts
|
||||
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
|
||||
|
||||
(defn add-noto-fonts [fonts languages]
|
||||
(reduce (fn [acc lang]
|
||||
(if-let [font (get noto-fonts lang)]
|
||||
(conj acc font)
|
||||
acc))
|
||||
fonts
|
||||
languages))
|
||||
(def add-emoji-font fbf/add-emoji-font)
|
||||
(def noto-fonts fbf/noto-fonts)
|
||||
(def add-noto-fonts fbf/add-noto-fonts)
|
||||
|
||||
199
frontend/src/app/render_wasm/api/props.cljs
Normal file
199
frontend/src/app/render_wasm/api/props.cljs
Normal file
@ -0,0 +1,199 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.api.props
|
||||
"Browser-free WASM shape property setters, shared by the workspace render
|
||||
orchestrator (`app.render-wasm.api`) and the headless exporter
|
||||
(`app.wasm.serialize`).
|
||||
|
||||
These only touch the WASM FFI (`app.render-wasm.{helpers,mem,serializers,
|
||||
wasm}`) and the shared `common` byte layouts — no store/DOM/React — so they
|
||||
run identically in the browser and under Node. Setters that need host-specific
|
||||
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
|
||||
(:require
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.path :as path]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const UUID-U8-SIZE 16)
|
||||
|
||||
(defn set-shape-children
|
||||
"Uploads the child id list via the dynamic `_set_children` path (handles any
|
||||
count). The browser also has fixed-arity fast paths for the incremental edit
|
||||
path; this dynamic one is the shared/batch version."
|
||||
[children]
|
||||
(let [children (into [] (filter uuid?) children)]
|
||||
(if (empty? children)
|
||||
(h/call wasm/internal-module "_set_children_0")
|
||||
(let [heap (mem/get-heap-u32)
|
||||
size (mem/get-alloc-size children UUID-U8-SIZE)
|
||||
offset (mem/alloc->offset-32 size)]
|
||||
(reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
|
||||
(h/call wasm/internal-module "_set_children")))))
|
||||
|
||||
(defn set-shape-bool-type
|
||||
[bool-type]
|
||||
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
|
||||
|
||||
(defn write-shape-fills!
|
||||
"Serializes a fill vector into WASM using the shared `common` byte layout.
|
||||
Only writes the fill *records*; image fill BYTES are host-specific (the browser
|
||||
fetches WebGL textures, the exporter provisions decoded bytes), so callers load
|
||||
images after. Returns the coerced `Fills` (for image-id extraction) or nil when
|
||||
empty."
|
||||
[fills]
|
||||
(if (empty? fills)
|
||||
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
|
||||
(let [fills (types.fills/coerce fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
(types.fills/write-to fills heap offset)
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
fills)))
|
||||
|
||||
(defn write-shape-strokes!
|
||||
"Serializes the stroke vector (records only, like `write-shape-fills!`);
|
||||
image stroke BYTES are host-specific, so callers load images after. Returns
|
||||
the image ids referenced by the strokes' image fills."
|
||||
[strokes]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(into []
|
||||
(keep
|
||||
(fn [stroke]
|
||||
(when-not (:hidden stroke)
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
;; Sentinel -1 means "unset" on the Rust side — keeps the
|
||||
;; FFI signature flat while letting the renderer fall back
|
||||
;; to its default dash pattern when no override is stored.
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do (types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)
|
||||
|
||||
(some? image)
|
||||
(do (types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(get image :id))
|
||||
|
||||
(some? color)
|
||||
(do (types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil))))))
|
||||
strokes))
|
||||
|
||||
(defn- get-string-length
|
||||
[string]
|
||||
(+ (count string) 1))
|
||||
|
||||
(defn set-shape-blur
|
||||
[blur]
|
||||
(let [type (sr/translate-blur-type :layer-blur)]
|
||||
(if (some? blur)
|
||||
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden blur)) (:value blur))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
|
||||
(defn set-shape-background-blur
|
||||
[background-blur]
|
||||
(let [type (sr/translate-blur-type :background-blur)]
|
||||
(if (some? background-blur)
|
||||
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden background-blur)) (:value background-blur))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
|
||||
(defn set-masked
|
||||
[masked]
|
||||
(h/call wasm/internal-module "_set_shape_masked_group" masked))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
(let [style (:style attrs)
|
||||
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
|
||||
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
|
||||
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
|
||||
fill-none (= "none" (or (:fill style) (:fill attrs)))]
|
||||
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
|
||||
|
||||
(defn set-shape-svg-raw-content
|
||||
[content]
|
||||
(let [size (get-string-length content)
|
||||
offset (mem/alloc size)]
|
||||
(h/call wasm/internal-module "stringToUTF8" content offset size)
|
||||
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
|
||||
|
||||
(defn set-shape-path-content
|
||||
"Upload path content in chunks to WASM."
|
||||
[content]
|
||||
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
|
||||
buffer-size (path/get-byte-size content)
|
||||
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
|
||||
buffer (js/Uint8Array. padded-size)]
|
||||
(path/write-to content (.-buffer buffer) 0)
|
||||
(h/call wasm/internal-module "_start_shape_path_buffer")
|
||||
(let [heapu32 (mem/get-heap-u32)]
|
||||
(loop [offset 0]
|
||||
(when (< offset padded-size)
|
||||
(let [end (min padded-size (+ offset (* chunk-size 4)))
|
||||
chunk (.subarray buffer offset end)
|
||||
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
|
||||
offset-size (.-length chunk-u32)
|
||||
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
|
||||
(.set heapu32 chunk-u32 heap-offset)
|
||||
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
|
||||
(recur end)))))
|
||||
(h/call wasm/internal-module "_set_shape_path_buffer")))
|
||||
@ -6,240 +6,21 @@
|
||||
|
||||
(ns app.render-wasm.api.texts
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance"
|
||||
[text]
|
||||
(let [encoder (js/TextEncoder.)]
|
||||
(.encode encoder text)))
|
||||
|
||||
(defn- write-span-fills
|
||||
[offset dview fills]
|
||||
(let [new-ofset (reduce (fn [offset fill]
|
||||
(let [opacity (get fill :fill-opacity 1.0)
|
||||
color (get fill :fill-color)
|
||||
gradient (get fill :fill-color-gradient)
|
||||
image (get fill :fill-image)]
|
||||
|
||||
(cond
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image))))
|
||||
|
||||
offset
|
||||
fills)
|
||||
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
|
||||
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
|
||||
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (f/serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph]
|
||||
(let [paragraph-font-size (get paragraph :font-size)
|
||||
paragraph-font-weight (-> paragraph :font-weight f/serialize-font-weight)
|
||||
paragraph-line-height (f/serialize-line-height (get paragraph :line-height))]
|
||||
(reduce (fn [offset span]
|
||||
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
|
||||
font-size (get span :font-size paragraph-font-size)
|
||||
font-size (f/serialize-font-size font-size)
|
||||
|
||||
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
|
||||
|
||||
font-weight (get span :font-weight paragraph-font-weight)
|
||||
font-weight (f/serialize-font-weight font-weight)
|
||||
|
||||
font-id (f/normalize-font-id (get span :font-id "sourcesanspro"))
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
|
||||
font-variant-id
|
||||
(get span :font-variant-id)
|
||||
|
||||
font-variant-id
|
||||
(if (uuid? font-variant-id)
|
||||
font-variant-id
|
||||
uuid/zero)
|
||||
|
||||
text-decoration
|
||||
(or (sr/translate-text-decoration (:text-decoration span))
|
||||
(sr/translate-text-decoration (:text-decoration paragraph))
|
||||
(sr/translate-text-decoration "none"))
|
||||
|
||||
text-transform
|
||||
(or (sr/translate-text-transform (:text-transform span))
|
||||
(sr/translate-text-transform (:text-transform paragraph))
|
||||
(sr/translate-text-transform "none"))
|
||||
|
||||
text-direction
|
||||
(or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/write-u32 dview font-weight)
|
||||
|
||||
(mem/write-uuid dview font-id)
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
|
||||
(write-span-fills dview fills))))
|
||||
offset
|
||||
spans)))
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.text-content :as tc]))
|
||||
|
||||
(defn write-shape-text
|
||||
;; buffer has the following format:
|
||||
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
|
||||
"Workspace text serialization: the byte writing is shared via
|
||||
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
|
||||
[spans paragraph text]
|
||||
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
|
||||
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
|
||||
num-spans (count normalized-spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview normalized-paragraph)
|
||||
(write-spans dview normalized-spans normalized-paragraph)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
|
||||
(def ^:private emoji-pattern
|
||||
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
|
||||
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
|
||||
:thai #"[\u0E00-\u0E7F]"
|
||||
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
|
||||
:tamil #"[\u0B80-\u0BFF]"
|
||||
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
|
||||
:vietnamese #"[\u1EA0-\u1EF9]"
|
||||
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
|
||||
:bengali #"[\u0980-\u09FF]"
|
||||
:cherokee #"[\u13A0-\u13FF]"
|
||||
:ethiopic #"[\u1200-\u137F]"
|
||||
:georgian #"[\u10A0-\u10FF]"
|
||||
:gujarati #"[\u0A80-\u0AFF]"
|
||||
:gurmukhi #"[\u0A00-\u0A7F]"
|
||||
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
|
||||
:lao #"[\u0E80-\u0EFF]"
|
||||
:malayalam #"[\u0D00-\u0D7F]"
|
||||
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
|
||||
:sinhala #"[\u0D80-\u0DFF]"
|
||||
:telugu #"[\u0C00-\u0C7F]"
|
||||
:tibetan #"[\u0F00-\u0FFF]"
|
||||
:javanese #"[\uA980-\uA9DF]"
|
||||
:kannada #"[\u0C80-\u0CFF]"
|
||||
:oriya #"[\u0B00-\u0B7F]"
|
||||
:mongolian #"[\u1800-\u18AF]"
|
||||
:syriac #"[\u0700-\u074F]"
|
||||
:tifinagh #"[\u2D30-\u2D7F]"
|
||||
:coptic #"[\u2C80-\u2CFF]"
|
||||
:ol-chiki #"[\u1C50-\u1C7F]"
|
||||
:vai #"[\uA500-\uA63F]"
|
||||
:shavian #"\uD801[\uDC50-\uDC7F]"
|
||||
:osmanya #"\uD801[\uDC80-\uDCAF]"
|
||||
:runic #"[\u16A0-\u16FF]"
|
||||
:old-italic #"\uD800[\uDF00-\uDF2F]"
|
||||
:brahmi #"\uD804[\uDC00-\uDC7F]"
|
||||
:modi #"\uD805[\uDE00-\uDE5F]"
|
||||
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
|
||||
:bamum #"[\uA6A0-\uA6FF]"
|
||||
:meroitic #"\uD802[\uDD80-\uDD9F]"
|
||||
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
|
||||
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
|
||||
;; Additional symbol blocks covered by Noto Sans Symbols 2:
|
||||
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
|
||||
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
|
||||
;; Legacy Computing Symbols (U+1FB00-1FBFF)
|
||||
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
|
||||
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
|
||||
|
||||
(defn contains-emoji? [text]
|
||||
(let [result (re-find emoji-pattern text)]
|
||||
(boolean result)))
|
||||
|
||||
(defn collect-used-languages
|
||||
[used text]
|
||||
(reduce-kv (fn [result lang pattern]
|
||||
(cond
|
||||
;; Skip regex operation if we already know that
|
||||
;; langage is present
|
||||
(contains? result lang)
|
||||
result
|
||||
|
||||
(re-find pattern text)
|
||||
(conj result lang)
|
||||
|
||||
:else
|
||||
result))
|
||||
used
|
||||
unicode-ranges))
|
||||
(tc/write-shape-text! spans paragraph text
|
||||
{:normalize-font-id f/normalize-font-id
|
||||
:normalize-paragraph f/normalize-paragraph-font
|
||||
:normalize-span f/normalize-span-font}))
|
||||
|
||||
;; Emoji/script detection lives in the host-agnostic
|
||||
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
|
||||
;; workspace callers.
|
||||
(def contains-emoji? fbf/contains-emoji?)
|
||||
(def collect-used-languages fbf/collect-used-languages)
|
||||
|
||||
157
frontend/src/app/render_wasm/fallback_fonts.cljs
Normal file
157
frontend/src/app/render_wasm/fallback_fonts.cljs
Normal file
@ -0,0 +1,157 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.fallback-fonts
|
||||
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
|
||||
which (google) fallback fonts cover them. Pure data + pure fns — no browser
|
||||
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
|
||||
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
|
||||
the same source. Anything a host must fetch/upload for text to render
|
||||
belongs here, not in host code.")
|
||||
|
||||
(def ^:private emoji-pattern
|
||||
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
|
||||
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
|
||||
:thai #"[\u0E00-\u0E7F]"
|
||||
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
|
||||
:tamil #"[\u0B80-\u0BFF]"
|
||||
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
|
||||
:vietnamese #"[\u1EA0-\u1EF9]"
|
||||
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
|
||||
:bengali #"[\u0980-\u09FF]"
|
||||
:cherokee #"[\u13A0-\u13FF]"
|
||||
:ethiopic #"[\u1200-\u137F]"
|
||||
:georgian #"[\u10A0-\u10FF]"
|
||||
:gujarati #"[\u0A80-\u0AFF]"
|
||||
:gurmukhi #"[\u0A00-\u0A7F]"
|
||||
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
|
||||
:lao #"[\u0E80-\u0EFF]"
|
||||
:malayalam #"[\u0D00-\u0D7F]"
|
||||
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
|
||||
:sinhala #"[\u0D80-\u0DFF]"
|
||||
:telugu #"[\u0C00-\u0C7F]"
|
||||
:tibetan #"[\u0F00-\u0FFF]"
|
||||
:javanese #"[\uA980-\uA9DF]"
|
||||
:kannada #"[\u0C80-\u0CFF]"
|
||||
:oriya #"[\u0B00-\u0B7F]"
|
||||
:mongolian #"[\u1800-\u18AF]"
|
||||
:syriac #"[\u0700-\u074F]"
|
||||
:tifinagh #"[\u2D30-\u2D7F]"
|
||||
:coptic #"[\u2C80-\u2CFF]"
|
||||
:ol-chiki #"[\u1C50-\u1C7F]"
|
||||
:vai #"[\uA500-\uA63F]"
|
||||
:shavian #"\uD801[\uDC50-\uDC7F]"
|
||||
:osmanya #"\uD801[\uDC80-\uDCAF]"
|
||||
:runic #"[\u16A0-\u16FF]"
|
||||
:old-italic #"\uD800[\uDF00-\uDF2F]"
|
||||
:brahmi #"\uD804[\uDC00-\uDC7F]"
|
||||
:modi #"\uD805[\uDE00-\uDE5F]"
|
||||
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
|
||||
:bamum #"[\uA6A0-\uA6FF]"
|
||||
:meroitic #"\uD802[\uDD80-\uDD9F]"
|
||||
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
|
||||
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
|
||||
;; Additional symbol blocks covered by Noto Sans Symbols 2:
|
||||
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
|
||||
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
|
||||
;; Legacy Computing Symbols (U+1FB00-1FBFF)
|
||||
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
|
||||
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
|
||||
|
||||
(defn contains-emoji? [text]
|
||||
(let [result (re-find emoji-pattern text)]
|
||||
(boolean result)))
|
||||
|
||||
(defn collect-used-languages
|
||||
[used text]
|
||||
(reduce-kv (fn [result lang pattern]
|
||||
(cond
|
||||
;; Skip regex operation if we already know that
|
||||
;; langage is present
|
||||
(contains? result lang)
|
||||
result
|
||||
|
||||
(re-find pattern text)
|
||||
(conj result lang)
|
||||
|
||||
:else
|
||||
result))
|
||||
used
|
||||
unicode-ranges))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
(conj fonts {:font-id "gfont-noto-color-emoji"
|
||||
:font-variant-id "regular"
|
||||
:style 0
|
||||
:weight 400
|
||||
:is-emoji true
|
||||
:is-fallback true}))
|
||||
|
||||
(def noto-fonts
|
||||
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
|
||||
|
||||
(defn add-noto-fonts [fonts languages]
|
||||
(reduce (fn [acc lang]
|
||||
(if-let [font (get noto-fonts lang)]
|
||||
(conj acc font)
|
||||
acc))
|
||||
fonts
|
||||
languages))
|
||||
47
frontend/src/app/render_wasm/resources.cljs
Normal file
47
frontend/src/app/render_wasm/resources.cljs
Normal file
@ -0,0 +1,47 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.resources
|
||||
"Host-agnostic enumeration of the external resources a scene needs to
|
||||
render: which image bytes its shapes reference. Pure data walking — no
|
||||
browser or Node dependencies — so the workspace and the headless exporter
|
||||
derive the same set from the same source (sibling of
|
||||
`app.render-wasm.fallback-fonts`, which does the same for fonts)."
|
||||
(:require
|
||||
[app.common.types.fills :as types.fills]))
|
||||
|
||||
(defn- fill-image-ids
|
||||
[fills]
|
||||
(some-> fills not-empty types.fills/coerce types.fills/get-image-ids))
|
||||
|
||||
(defn- stroke-image-ids
|
||||
[strokes]
|
||||
(keep (comp :id :stroke-image) strokes))
|
||||
|
||||
(defn- text-image-ids
|
||||
"Image-fill ids referenced by a text shape's span fills."
|
||||
[content]
|
||||
(when content
|
||||
(->> (tree-seq :children :children content)
|
||||
(mapcat :fills)
|
||||
(keep (comp :id :fill-image)))))
|
||||
|
||||
(defn shape-image-ids
|
||||
"Distinct image ids referenced by one shape: its fills, its strokes' image
|
||||
fills, and (for texts) its spans' image fills."
|
||||
[shape]
|
||||
(-> #{}
|
||||
(into (fill-image-ids (:fills shape)))
|
||||
(into (stroke-image-ids (:strokes shape)))
|
||||
;; `:content` is only a text tree for text shapes (paths reuse the key
|
||||
;; for geometry).
|
||||
(into (when (= :text (:type shape))
|
||||
(text-image-ids (:content shape))))))
|
||||
|
||||
(defn scene-image-ids
|
||||
"Distinct image ids referenced anywhere in an `objects` map."
|
||||
[scene]
|
||||
(into #{} (mapcat shape-image-ids) (vals scene)))
|
||||
54
frontend/src/app/render_wasm/serialize_shape.cljs
Normal file
54
frontend/src/app/render_wasm/serialize_shape.cljs
Normal file
@ -0,0 +1,54 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.serialize-shape
|
||||
"Single source of truth for the host-independent part of serializing a whole
|
||||
shape into the WASM design state.
|
||||
|
||||
Both batch serializers call this so they can't drift:
|
||||
- the workspace `app.render-wasm.api/set-object` (browser), and
|
||||
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
|
||||
|
||||
It applies only the properties that need no host-specific resources or driver:
|
||||
base props, children, blur, background blur, shadows, svg attrs, group mask,
|
||||
bool type, path/bool geometry and text grow type. The parts that DO differ by
|
||||
host are handled by each caller AFTER this runs:
|
||||
- fills / strokes (image bytes are fetched + uploaded differently),
|
||||
- text content (fonts),
|
||||
- svg-raw markup (browser renders it via React),
|
||||
- layout (grid/flex — workspace only).
|
||||
|
||||
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
|
||||
dispatching per changed key through the same underlying `props` setters."
|
||||
(:require
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.api.shapes :as shapes]))
|
||||
|
||||
(defn serialize-shape!
|
||||
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
|
||||
runs first because it selects the current shape (`use_shape`) the rest mutate."
|
||||
[shape]
|
||||
(let [type (get shape :type)]
|
||||
(shapes/set-shape-base-props shape)
|
||||
(props/set-shape-children (get shape :shapes))
|
||||
(props/set-shape-blur (get shape :blur))
|
||||
(props/set-shape-background-blur (get shape :background-blur))
|
||||
(props/set-shape-shadows (get shape :shadow))
|
||||
|
||||
(when (some? (get shape :svg-attrs))
|
||||
(props/set-shape-svg-attrs (get shape :svg-attrs)))
|
||||
|
||||
(when (= type :group)
|
||||
(props/set-masked (boolean (get shape :masked-group))))
|
||||
|
||||
(when (= type :bool)
|
||||
(props/set-shape-bool-type (get shape :bool-type)))
|
||||
|
||||
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
|
||||
(props/set-shape-path-content (get shape :content)))
|
||||
|
||||
(when (= type :text)
|
||||
(props/set-shape-grow-type (get shape :grow-type)))))
|
||||
198
frontend/src/app/render_wasm/text_content.cljs
Normal file
198
frontend/src/app/render_wasm/text_content.cljs
Normal file
@ -0,0 +1,198 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.text-content
|
||||
"Single source of truth for writing a text shape's content into the WASM design
|
||||
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
|
||||
identical for the workspace and the headless exporter — only *font resolution*
|
||||
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
|
||||
catalog + custom variants). So the byte-writing lives here and font resolution
|
||||
is injected via the `opts` map passed to `write-shape-text!`.
|
||||
|
||||
Fully portable (no store/DOM/React), so it runs under Node too."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(def ^:private default-font-size 14)
|
||||
(def ^:private default-line-height 1.2)
|
||||
(def ^:private default-letter-spacing 0.0)
|
||||
|
||||
;; --- pure attribute serializers -------------------------------------------
|
||||
|
||||
(defn serialize-font-size
|
||||
[font-size]
|
||||
(cond
|
||||
(number? font-size) font-size
|
||||
(string? font-size) (or (d/parse-double font-size) default-font-size)
|
||||
:else default-font-size))
|
||||
|
||||
(defn serialize-font-weight
|
||||
[font-weight]
|
||||
(if (number? font-weight)
|
||||
font-weight
|
||||
(let [font-weight-str (str font-weight)]
|
||||
(cond
|
||||
(re-matches #"\d+" font-weight-str) (js/Number font-weight-str)
|
||||
(str/includes? font-weight-str "bold") 700
|
||||
(str/includes? font-weight-str "black") 900
|
||||
(str/includes? font-weight-str "extrabold") 800
|
||||
(str/includes? font-weight-str "extralight") 200
|
||||
(str/includes? font-weight-str "light") 300
|
||||
(str/includes? font-weight-str "medium") 500
|
||||
(str/includes? font-weight-str "semibold") 600
|
||||
(str/includes? font-weight-str "thin") 100
|
||||
:else 400))))
|
||||
|
||||
(defn serialize-line-height
|
||||
([line-height] (serialize-line-height line-height default-line-height))
|
||||
([line-height default-value]
|
||||
(cond
|
||||
(number? line-height) line-height
|
||||
(string? line-height) (or (d/parse-double line-height) default-value)
|
||||
:else default-value)))
|
||||
|
||||
(defn serialize-letter-spacing
|
||||
[letter-spacing]
|
||||
(cond
|
||||
(number? letter-spacing) letter-spacing
|
||||
(string? letter-spacing) (or (d/parse-double letter-spacing) default-letter-spacing)
|
||||
:else default-letter-spacing))
|
||||
|
||||
;; --- binary writers --------------------------------------------------------
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance."
|
||||
[text]
|
||||
(let [encoder (js/TextEncoder.)]
|
||||
(.encode encoder text)))
|
||||
|
||||
(defn- write-span-fills
|
||||
[offset dview fills]
|
||||
(let [new-ofset (reduce (fn [offset fill]
|
||||
(let [opacity (get fill :fill-opacity 1.0)
|
||||
color (get fill :fill-color)
|
||||
gradient (get fill :fill-color-gradient)
|
||||
image (get fill :fill-image)]
|
||||
(cond
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image))))
|
||||
offset
|
||||
fills)
|
||||
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
|
||||
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph normalize-font-id]
|
||||
(let [paragraph-font-size (get paragraph :font-size)
|
||||
paragraph-font-weight (-> paragraph :font-weight serialize-font-weight)
|
||||
paragraph-line-height (serialize-line-height (get paragraph :line-height))]
|
||||
(reduce
|
||||
(fn [offset span]
|
||||
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
|
||||
font-size (serialize-font-size (get span :font-size paragraph-font-size))
|
||||
line-height (serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (serialize-letter-spacing (get span :letter-spacing))
|
||||
font-weight (serialize-font-weight (get span :font-weight paragraph-font-weight))
|
||||
font-id (normalize-font-id (get span :font-id "sourcesanspro"))
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
font-variant-id (get span :font-variant-id)
|
||||
font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero)
|
||||
text-decoration (or (sr/translate-text-decoration (:text-decoration span))
|
||||
(sr/translate-text-decoration (:text-decoration paragraph))
|
||||
(sr/translate-text-decoration "none"))
|
||||
text-transform (or (sr/translate-text-transform (:text-transform span))
|
||||
(sr/translate-text-transform (:text-transform paragraph))
|
||||
(sr/translate-text-transform "none"))
|
||||
text-direction (or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/write-u32 dview font-weight)
|
||||
(mem/write-uuid dview font-id)
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
(write-span-fills dview fills))))
|
||||
offset
|
||||
spans)))
|
||||
|
||||
(defn write-shape-text!
|
||||
"Writes one paragraph's spans + text into WASM and appends it to the current
|
||||
shape via `_set_shape_text_content`.
|
||||
|
||||
`opts` injects host-specific font resolution:
|
||||
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
|
||||
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
|
||||
fonts DB (workspace); default to identity (the exporter resolves variants
|
||||
differently / not at all)."
|
||||
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
|
||||
:or {normalize-font-id identity
|
||||
normalize-paragraph identity
|
||||
normalize-span (fn [span _paragraph] span)}}]
|
||||
(let [paragraph (normalize-paragraph paragraph)
|
||||
spans (map #(normalize-span % paragraph) spans)
|
||||
num-spans (count spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview paragraph)
|
||||
(write-spans dview spans paragraph normalize-font-id)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
@ -41,7 +41,7 @@ export EMCC_CFLAGS="--no-entry \
|
||||
-sMAX_WEBGL_VERSION=2 \
|
||||
-sEXPORT_NAME=createRustSkiaModule \
|
||||
-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
|
||||
-sENVIRONMENT=web \
|
||||
-sENVIRONMENT=web,worker,node \
|
||||
-sMODULARIZE=1 \
|
||||
-sDISABLE_EXCEPTION_CATCHING=1 \
|
||||
-sFILESYSTEM=0 \
|
||||
|
||||
@ -4,12 +4,16 @@ use macros::wasm_error;
|
||||
use crate::emscripten::init_gl;
|
||||
|
||||
use crate::mem;
|
||||
use crate::render::{gpu_state::GpuState, RenderState};
|
||||
use crate::render::{gpu_state::GpuState, RenderResources, RenderState};
|
||||
use crate::state::{State, TextEditorState, UIState};
|
||||
|
||||
static mut DESIGN_STATE: *mut State = std::ptr::null_mut();
|
||||
static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
|
||||
static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
|
||||
static mut UI_STATE: *mut UIState = std::ptr::null_mut();
|
||||
static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
|
||||
static mut RENDER_RESOURCES: *mut RenderResources = std::ptr::null_mut();
|
||||
|
||||
/// Design State.
|
||||
pub(crate) fn get_design_state() -> &'static mut State {
|
||||
unsafe {
|
||||
debug_assert!(!DESIGN_STATE.is_null(), "Design State is null");
|
||||
@ -17,20 +21,17 @@ pub(crate) fn get_design_state() -> &'static mut State {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU State.
|
||||
static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
|
||||
unsafe {
|
||||
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
|
||||
assert!(
|
||||
!GPU_STATE.is_null(),
|
||||
"GPU State is null (headless instance?)"
|
||||
);
|
||||
&mut *GPU_STATE
|
||||
}
|
||||
}
|
||||
|
||||
/// Render State.
|
||||
static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_render_state() -> &'static mut RenderState {
|
||||
unsafe {
|
||||
@ -39,8 +40,18 @@ pub(crate) fn get_render_state() -> &'static mut RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text Editor State
|
||||
static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
|
||||
#[inline(always)]
|
||||
pub(crate) fn has_render_state() -> bool {
|
||||
unsafe { !RENDER_STATE.is_null() }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_resources() -> &'static mut RenderResources {
|
||||
unsafe {
|
||||
debug_assert!(!RENDER_RESOURCES.is_null(), "Render Resources is null");
|
||||
&mut *RENDER_RESOURCES
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState {
|
||||
@ -50,9 +61,6 @@ pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState {
|
||||
}
|
||||
}
|
||||
|
||||
/// UI State
|
||||
static mut UI_STATE: *mut UIState = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_ui_state() -> &'static mut UIState {
|
||||
unsafe {
|
||||
@ -113,6 +121,23 @@ fn render_init(width: i32, height: i32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the interactive RenderResources (GPU image store).
|
||||
fn resources_init() {
|
||||
unsafe {
|
||||
let resources = RenderResources::try_new().expect("Cannot initialize RenderResources");
|
||||
RENDER_RESOURCES = Box::into_raw(Box::new(resources));
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes GPU-free RenderResources for the headless export path.
|
||||
fn resources_init_headless() {
|
||||
unsafe {
|
||||
let resources = RenderResources::try_new_headless()
|
||||
.expect("Cannot initialize headless RenderResources");
|
||||
RENDER_RESOURCES = Box::into_raw(Box::new(resources));
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes DesignState.
|
||||
fn design_init() {
|
||||
unsafe {
|
||||
@ -143,6 +168,7 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
init_gl!();
|
||||
gpu_init();
|
||||
resources_init();
|
||||
render_init(width, height);
|
||||
text_editor_init();
|
||||
design_init();
|
||||
@ -150,6 +176,19 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Boots the engine for headless export with no GPU/WebGL context: only
|
||||
/// `RenderResources` (GPU-free) + the design state. The export (raster/PDF)
|
||||
/// paths render onto their own surface; the interactive render loop is not
|
||||
/// available on an instance initialized this way. `width`/`height` are kept for
|
||||
/// API symmetry with `init` but unused — the export sizes from shape bounds.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn init_headless(_width: i32, _height: i32) -> Result<()> {
|
||||
resources_init_headless();
|
||||
design_init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn clean_up() -> Result<()> {
|
||||
|
||||
@ -22,7 +22,7 @@ use std::collections::HashMap;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::{FrameType, RenderFlag};
|
||||
|
||||
use globals::{get_design_state, get_gpu_state, get_render_state};
|
||||
use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state};
|
||||
|
||||
use macros::wasm_error;
|
||||
use math::{Bounds, Matrix};
|
||||
@ -720,7 +720,7 @@ pub extern "C" fn is_image_cached(
|
||||
is_thumbnail: bool,
|
||||
) -> Result<bool> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let result = get_render_state().has_image(&id, is_thumbnail);
|
||||
let result = get_resources().images.contains(&id, is_thumbnail);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@ -734,8 +734,7 @@ pub extern "C" fn set_shape_svg_raw_content() -> Result<()> {
|
||||
.trim_end_matches('\0')
|
||||
.to_string();
|
||||
|
||||
let render_state = get_render_state();
|
||||
let font_manager = skia::FontMgr::from(render_state.fonts().font_provider().clone());
|
||||
let font_manager = skia::FontMgr::from(get_resources().fonts.font_provider().clone());
|
||||
shape.set_svg_raw_content(svg_raw_content);
|
||||
shape.update_svg_raw_content(font_manager);
|
||||
});
|
||||
@ -1001,6 +1000,36 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -
|
||||
})
|
||||
}
|
||||
|
||||
/// PNG via CPU raster (no GPU/WebGL). Returns `[len][width][height][png]` (LE),
|
||||
/// same layout as `render_shape_pixels`.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render_shape_raster(
|
||||
a: u32,
|
||||
b: u32,
|
||||
c: u32,
|
||||
d: u32,
|
||||
scale: f32,
|
||||
) -> Result<*mut u8> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
|
||||
if !scale.is_finite() {
|
||||
return Err(Error::CriticalError("Scale is not finite".to_string()));
|
||||
}
|
||||
|
||||
with_state!(state, {
|
||||
let (data, width, height) = state.render_shape_raster(&id, scale)?;
|
||||
|
||||
let len = data.len() as u32;
|
||||
let mut buf = Vec::with_capacity(12 + data.len());
|
||||
buf.extend_from_slice(&len.to_le_bytes());
|
||||
buf.extend_from_slice(&width.to_le_bytes());
|
||||
buf.extend_from_slice(&height.to_le_bytes());
|
||||
buf.extend_from_slice(&data);
|
||||
Ok(mem::write_bytes(buf))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
// Why an empty main?
|
||||
// Right now with the target `wasm32-unknown-emscripten` it is not possible
|
||||
|
||||
@ -7,6 +7,8 @@ pub mod grid_layout;
|
||||
mod images;
|
||||
mod options;
|
||||
pub mod pdf;
|
||||
pub mod raster;
|
||||
mod resources;
|
||||
mod shadows;
|
||||
pub mod shape_renderer;
|
||||
mod strokes;
|
||||
@ -34,10 +36,11 @@ use crate::tiles::{self, PendingTiles, TileRect};
|
||||
use crate::uuid::Uuid;
|
||||
use crate::view::Viewbox;
|
||||
use crate::wapi;
|
||||
use crate::{get_gpu_state, performance};
|
||||
use crate::{get_gpu_state, get_resources, performance};
|
||||
|
||||
pub use fonts::*;
|
||||
pub use images::*;
|
||||
pub(crate) use resources::RenderResources;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
@ -348,15 +351,12 @@ pub(crate) struct RenderState {
|
||||
pub options: RenderOptions,
|
||||
stats: RenderStats,
|
||||
pub surfaces: Surfaces,
|
||||
pub fonts: FontStore,
|
||||
pub viewbox: Viewbox,
|
||||
pub cached_viewbox: Viewbox,
|
||||
pub images: ImageStore,
|
||||
pub background_color: skia::Color,
|
||||
// Stack of nodes pending to be rendered.
|
||||
pending_nodes: Vec<NodeRenderState>,
|
||||
pub current_tile: Option<tiles::Tile>,
|
||||
pub sampling_options: skia::SamplingOptions,
|
||||
pub render_area: Rect,
|
||||
// render_area expanded by surface margins — used for visibility checks so that
|
||||
// shapes in the margin zone are rendered (needed for background blur sampling).
|
||||
@ -546,19 +546,18 @@ impl RenderState {
|
||||
|
||||
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
|
||||
// This needs to be done once per WebGL context.
|
||||
let sampling_options =
|
||||
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
|
||||
let sampling_options = get_resources().sampling_options;
|
||||
|
||||
let fonts = FontStore::try_new()?;
|
||||
let surfaces = Surfaces::try_new(
|
||||
(width, height),
|
||||
sampling_options,
|
||||
tiles::get_tile_dimensions(),
|
||||
)?;
|
||||
|
||||
// This is used multiple times everywhere so instead of creating new instances every
|
||||
// time we reuse this one.
|
||||
Self::assemble(width, height, surfaces)
|
||||
}
|
||||
|
||||
fn assemble(width: i32, height: i32, surfaces: Surfaces) -> Result<RenderState> {
|
||||
let viewbox = Viewbox::new(width as f32, height as f32);
|
||||
let tiles = tiles::TileHashMap::new();
|
||||
let options = RenderOptions::default();
|
||||
@ -567,14 +566,11 @@ impl RenderState {
|
||||
options,
|
||||
stats: RenderStats::new(),
|
||||
surfaces,
|
||||
fonts,
|
||||
viewbox,
|
||||
cached_viewbox: Viewbox::new(0., 0.),
|
||||
images: ImageStore::new(),
|
||||
background_color: skia::Color::TRANSPARENT,
|
||||
pending_nodes: vec![],
|
||||
current_tile: None,
|
||||
sampling_options,
|
||||
render_area: Rect::new_empty(),
|
||||
render_area_with_margins: Rect::new_empty(),
|
||||
tiles,
|
||||
@ -811,35 +807,6 @@ impl RenderState {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn fonts(&self) -> &FontStore {
|
||||
&self.fonts
|
||||
}
|
||||
|
||||
pub fn fonts_mut(&mut self) -> &mut FontStore {
|
||||
&mut self.fonts
|
||||
}
|
||||
|
||||
pub fn add_image(&mut self, id: Uuid, is_thumbnail: bool, image_data: &[u8]) -> Result<()> {
|
||||
self.images.add(id, is_thumbnail, image_data)
|
||||
}
|
||||
|
||||
/// Adds an image from an existing WebGL texture, avoiding re-decoding
|
||||
pub fn add_image_from_gl_texture(
|
||||
&mut self,
|
||||
id: Uuid,
|
||||
is_thumbnail: bool,
|
||||
texture_id: u32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<()> {
|
||||
self.images
|
||||
.add_image_from_gl_texture(id, is_thumbnail, texture_id, width, height)
|
||||
}
|
||||
|
||||
pub fn has_image(&self, id: &Uuid, is_thumbnail: bool) -> bool {
|
||||
self.images.contains(id, is_thumbnail)
|
||||
}
|
||||
|
||||
pub fn set_debug_flags(&mut self, debug: u32) {
|
||||
self.options.flags = debug;
|
||||
}
|
||||
@ -854,7 +821,7 @@ impl RenderState {
|
||||
self.viewbox.width().floor() as i32,
|
||||
self.viewbox.height().floor() as i32,
|
||||
)?;
|
||||
self.fonts.set_scale_debug_font(dpr);
|
||||
get_resources().fonts.set_scale_debug_font(dpr);
|
||||
self.viewbox.set_dpr(dpr);
|
||||
self.surfaces.set_dpr(dpr);
|
||||
}
|
||||
@ -1002,7 +969,7 @@ impl RenderState {
|
||||
text_paint.set_color(skia::Color::GRAY);
|
||||
text_paint.set_anti_alias(true);
|
||||
|
||||
let font = self.fonts.debug_font();
|
||||
let font = get_resources().fonts.debug_font();
|
||||
// FIXME
|
||||
let text = "Loading…";
|
||||
let (text_width, _) = font.measure_str(text, None);
|
||||
@ -1431,7 +1398,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
match &shape.shape_type {
|
||||
Type::SVGRaw(_) => {
|
||||
Type::SVGRaw(sr) => {
|
||||
if let Some(svg_transform) = shape.svg_transform() {
|
||||
matrix.pre_concat(&svg_transform);
|
||||
}
|
||||
@ -1443,7 +1410,18 @@ impl RenderState {
|
||||
if let Some(svg) = shape.svg.as_ref() {
|
||||
svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
|
||||
} else {
|
||||
panic!("SVG should be available");
|
||||
let font_manager =
|
||||
skia::FontMgr::from(get_resources().fonts.font_provider().clone());
|
||||
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
|
||||
match dom_result {
|
||||
Ok(dom) => {
|
||||
dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
|
||||
shape.to_mut().set_svg(dom);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing SVG. Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3020,7 +2998,7 @@ impl RenderState {
|
||||
surface.draw(
|
||||
drop_canvas,
|
||||
(0.0, 0.0),
|
||||
self.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
Some(&drop_paint),
|
||||
);
|
||||
drop_canvas.restore();
|
||||
@ -3030,7 +3008,7 @@ impl RenderState {
|
||||
surface.draw(
|
||||
drop_canvas,
|
||||
(0.0, 0.0),
|
||||
self.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
Some(&drop_paint),
|
||||
);
|
||||
drop_canvas.restore();
|
||||
|
||||
@ -6,6 +6,7 @@ use macros::wasm_error;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
|
||||
use skia_safe::{self as skia, Rect};
|
||||
|
||||
@ -62,16 +63,16 @@ pub fn render_wasm_label(render_state: &mut RenderState) {
|
||||
"WebGL rendering"
|
||||
};
|
||||
let dpr = render_state.options.dpr;
|
||||
let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None);
|
||||
let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None);
|
||||
let mut p = skia::Point::new(canvas_width - (20.0 * dpr) - scalar, 50.0 * dpr);
|
||||
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str, p, debug_font, &paint);
|
||||
|
||||
if render_state.options.is_text_editor_v3() {
|
||||
str = "Text Editor v3";
|
||||
|
||||
let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None);
|
||||
let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None);
|
||||
p.x = canvas_width - (20.0 * dpr) - scalar;
|
||||
p.y += 15.0 * dpr;
|
||||
canvas.draw_str(str, p, debug_font, &paint);
|
||||
@ -85,7 +86,7 @@ pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) {
|
||||
paint.set_color(skia::Color::RED);
|
||||
let str_rect = format!("{} {} {} {}", sx, sy, ex, ey);
|
||||
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str_rect, skia::Point::new(100.0, 150.0), debug_font, &paint);
|
||||
}
|
||||
|
||||
@ -104,7 +105,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
|
||||
|
||||
let str_rect = format!("{} {} {} {}", sx, sy, ex, ey);
|
||||
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
@ -114,7 +115,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
|
||||
let debug_rect = get_debug_rect(rect);
|
||||
let p = skia::Point::new(debug_rect.x(), debug_rect.y() - 1.);
|
||||
let str = format!("{}:{}", x, y);
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
paint.set_style(skia::PaintStyle::Fill);
|
||||
canvas.draw_str(str, p, debug_font, &paint);
|
||||
canvas.draw_rect(debug_rect, &paint);
|
||||
@ -153,7 +154,7 @@ pub fn render_workspace_current_tile(
|
||||
let tile_position_origin = skia::Point::new(rect.x() + 10., rect.y() + 20.);
|
||||
p.set_style(skia::PaintStyle::Fill);
|
||||
let str = format!("{prefix} {}:{}", tile.x(), tile.y());
|
||||
let mut debug_font = render_state.fonts.debug_font().clone();
|
||||
let mut debug_font = get_resources().fonts.debug_font().clone();
|
||||
debug_font.set_size(16.);
|
||||
canvas.draw_str(str, tile_position_origin, &debug_font, &p);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
|
||||
|
||||
use super::{filters, RenderState, SurfaceId};
|
||||
use crate::error::Result;
|
||||
use crate::get_resources;
|
||||
use crate::render::get_source_rect;
|
||||
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
|
||||
|
||||
@ -74,7 +75,7 @@ fn draw_image_fill(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(image) = render_state.images.get(&image_fill.id()) else {
|
||||
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@ -102,7 +103,7 @@ fn draw_image_fill(
|
||||
image,
|
||||
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||
dest_rect,
|
||||
render_state.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
paint,
|
||||
);
|
||||
|
||||
@ -122,7 +123,7 @@ fn draw_svg_image_fill(
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
) -> bool {
|
||||
let Some((dom, size)) = render_state.images.get_svg(&image_fill.id()) else {
|
||||
let Some((dom, size)) = get_resources().images.get_svg(&image_fill.id()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ use skia_safe::{self as skia, ImageFilter, Rect};
|
||||
|
||||
use super::{RenderState, SurfaceId};
|
||||
use crate::error::Result;
|
||||
use crate::get_resources;
|
||||
|
||||
/// Composes two image filters, returning a combined filter if both are present,
|
||||
/// or the individual filter if only one is present, or None if neither is present.
|
||||
@ -51,12 +52,12 @@ where
|
||||
canvas.save();
|
||||
canvas.scale((1.0 / scale, 1.0 / scale));
|
||||
canvas.translate((bounds.left * scale, bounds.top * scale));
|
||||
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
|
||||
surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None);
|
||||
canvas.restore();
|
||||
} else {
|
||||
canvas.save();
|
||||
canvas.translate((bounds.left, bounds.top));
|
||||
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
|
||||
surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None);
|
||||
canvas.restore();
|
||||
}
|
||||
Ok(true)
|
||||
|
||||
@ -68,7 +68,8 @@ enum StoredImage {
|
||||
|
||||
pub struct ImageStore {
|
||||
images: HashMap<(Uuid, bool), StoredImage>,
|
||||
context: Box<DirectContext>,
|
||||
/// gpu-only
|
||||
context: Option<Box<DirectContext>>,
|
||||
}
|
||||
|
||||
/// Creates a Skia image from an existing WebGL texture.
|
||||
@ -211,7 +212,17 @@ impl ImageStore {
|
||||
let context = &gpu_state.context;
|
||||
Self {
|
||||
images: HashMap::with_capacity(2048),
|
||||
context: Box::new(context.clone()),
|
||||
context: Some(Box::new(context.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-free image store for the headless export path: no GPU context, so
|
||||
/// images are kept as encoded bytes and decoded on the CPU at draw time
|
||||
/// (see `get_cpu_image`).
|
||||
pub fn new_without_gpu() -> Self {
|
||||
Self {
|
||||
images: HashMap::with_capacity(16),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,21 +240,41 @@ impl ImageStore {
|
||||
|
||||
let raw_data = image_data.to_vec();
|
||||
|
||||
if let Some(gpu_image) = decode_image(&mut self.context, &raw_data) {
|
||||
self.images.insert(key, StoredImage::Gpu(gpu_image));
|
||||
} else if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
self.images.insert(
|
||||
key,
|
||||
StoredImage::Svg {
|
||||
dom,
|
||||
size,
|
||||
raster: None,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// The lazy re-decode in `get_internal` only retries raster codecs,
|
||||
// so SVGs that fail to parse here stay raw.
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
match self.context.as_mut() {
|
||||
Some(context) => {
|
||||
if let Some(gpu_image) = decode_image(context, &raw_data) {
|
||||
self.images.insert(key, StoredImage::Gpu(gpu_image));
|
||||
} else if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
self.images.insert(
|
||||
key,
|
||||
StoredImage::Svg {
|
||||
dom,
|
||||
size,
|
||||
raster: None,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// The lazy re-decode in `get_internal` only retries raster codecs,
|
||||
// so SVGs that fail to parse here stay raw.
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
}
|
||||
}
|
||||
// GPU-free: keep the encoded bytes; decoded on the CPU at draw time.
|
||||
// SVGs still get parsed up front since that needs no GPU context.
|
||||
None => {
|
||||
if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
self.images.insert(
|
||||
key,
|
||||
StoredImage::Svg {
|
||||
dom,
|
||||
size,
|
||||
raster: None,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -266,7 +297,12 @@ impl ImageStore {
|
||||
}
|
||||
|
||||
// Create a Skia image from the existing GL texture
|
||||
let image = create_image_from_gl_texture(&mut self.context, texture_id, width, height)?;
|
||||
let Some(context) = self.context.as_mut() else {
|
||||
return Err(crate::error::Error::CriticalError(
|
||||
"Cannot register a GL texture without a GPU context".to_string(),
|
||||
));
|
||||
};
|
||||
let image = create_image_from_gl_texture(context, texture_id, width, height)?;
|
||||
self.images.insert(key, StoredImage::Gpu(image));
|
||||
|
||||
Ok(())
|
||||
@ -287,8 +323,35 @@ impl ImageStore {
|
||||
}
|
||||
|
||||
pub fn get_cpu_image(&mut self, id: &Uuid) -> Option<Image> {
|
||||
let gpu_image = self.get(id)?.clone();
|
||||
gpu_image.make_non_texture_image(self.context.as_mut())
|
||||
// GPU path: promote to a texture, then copy to a CPU image.
|
||||
if self.context.is_some() {
|
||||
let gpu_image = self.get(id)?.clone();
|
||||
let context = self.context.as_mut()?;
|
||||
return gpu_image.make_non_texture_image(context.as_mut());
|
||||
}
|
||||
// Headless (no GPU context): decode the stored encoded bytes directly to
|
||||
// a CPU image, which draws fine on a raster/PDF canvas. Try full first,
|
||||
// then thumbnail.
|
||||
self.decode_raw_cpu_image(id, false)
|
||||
.or_else(|| self.decode_raw_cpu_image(id, true))
|
||||
}
|
||||
|
||||
fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option<Image> {
|
||||
match self.images.get(&(*id, is_thumbnail))? {
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||
Image::from_encoded(&data)
|
||||
}
|
||||
StoredImage::Gpu(img) => Some(img.clone()),
|
||||
StoredImage::Svg { dom, size, .. } => {
|
||||
// No GPU context in the headless path: rasterize on a CPU
|
||||
// surface instead of `rasterize_svg` (which needs one).
|
||||
let dimensions = ISize::new(size.width as i32, size.height as i32);
|
||||
let mut surface = skia::surfaces::raster_n32_premul(dimensions)?;
|
||||
dom.render(surface.canvas());
|
||||
Some(surface.image_snapshot())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector access for SVG images: the fill render path draws the DOM
|
||||
@ -311,7 +374,8 @@ impl ImageStore {
|
||||
match entry {
|
||||
StoredImage::Gpu(ref img) => Some(img),
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let gpu_image = decode_image(&mut self.context, raw_data)?;
|
||||
let context = self.context.as_mut()?;
|
||||
let gpu_image = decode_image(context, raw_data)?;
|
||||
*entry = StoredImage::Gpu(gpu_image);
|
||||
|
||||
if let StoredImage::Gpu(ref img) = entry {
|
||||
@ -322,7 +386,8 @@ impl ImageStore {
|
||||
}
|
||||
StoredImage::Svg { dom, size, raster } => {
|
||||
if raster.is_none() {
|
||||
*raster = rasterize_svg(&mut self.context, dom, *size);
|
||||
let context = self.context.as_mut()?;
|
||||
*raster = rasterize_svg(context, dom, *size);
|
||||
}
|
||||
raster.as_ref()
|
||||
}
|
||||
|
||||
@ -69,6 +69,7 @@ impl RenderOptions {
|
||||
self.fast_mode = enabled;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn set_capture_frames(&mut self, capture_frames: i32) {
|
||||
self.capture_frames = capture_frames;
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ use crate::error::Result;
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::vector::{self, VectorTarget};
|
||||
use super::RenderState;
|
||||
use super::vector;
|
||||
use super::RenderResources;
|
||||
|
||||
/// Renders a shape tree to a PDF document and returns the raw PDF bytes.
|
||||
///
|
||||
@ -16,7 +16,7 @@ use super::RenderState;
|
||||
/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's
|
||||
/// PDF backend
|
||||
pub fn render_to_pdf(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
@ -24,7 +24,15 @@ pub fn render_to_pdf(
|
||||
let shape = tree
|
||||
.get(id)
|
||||
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?;
|
||||
let bounds = shape.extrect(tree, scale);
|
||||
// Boards export at their own bounds (like the classic exporter, which
|
||||
// screenshots exactly the board box): content overflowing a board lands
|
||||
// outside the page/surface and is cropped. Other shapes keep the extended
|
||||
// rect so their own shadows/blur are included.
|
||||
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
|
||||
shape.selrect()
|
||||
} else {
|
||||
shape.extrect(tree, scale)
|
||||
};
|
||||
|
||||
let page_w = bounds.width() * scale;
|
||||
let page_h = bounds.height() * scale;
|
||||
@ -45,7 +53,7 @@ pub fn render_to_pdf(
|
||||
let page_canvas = on_page.canvas();
|
||||
page_canvas.scale((scale, scale));
|
||||
page_canvas.translate((-bounds.left(), -bounds.top()));
|
||||
vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?;
|
||||
vector::render_tree_pdf(shared, page_canvas, id, tree, scale, bounds)?;
|
||||
}
|
||||
|
||||
let document = on_page.end_page();
|
||||
|
||||
59
render-wasm/src/render/raster.rs
Normal file
59
render-wasm/src/render/raster.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use skia_safe as skia;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::vector;
|
||||
use super::RenderResources;
|
||||
|
||||
/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL).
|
||||
/// Returns `(png_bytes, width_px, height_px)`.
|
||||
pub fn render_to_raster(
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
let Some(shape) = tree.get(id) else {
|
||||
return Ok((Vec::new(), 0, 0));
|
||||
};
|
||||
// Boards export at their own bounds (like the classic exporter, which
|
||||
// screenshots exactly the board box): content overflowing a board lands
|
||||
// outside the page/surface and is cropped. Other shapes keep the extended
|
||||
// rect so their own shadows/blur are included.
|
||||
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
|
||||
shape.selrect()
|
||||
} else {
|
||||
shape.extrect(tree, scale)
|
||||
};
|
||||
|
||||
let width = (bounds.width() * scale).ceil() as i32;
|
||||
let height = (bounds.height() * scale).ceil() as i32;
|
||||
if width <= 0 || height <= 0 {
|
||||
return Ok((Vec::new(), 0, 0));
|
||||
}
|
||||
|
||||
let mut surface = skia::surfaces::raster_n32_premul((width, height)).ok_or_else(|| {
|
||||
Error::CriticalError("Failed to create raster export surface".to_string())
|
||||
})?;
|
||||
|
||||
{
|
||||
let canvas = surface.canvas();
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
canvas.scale((scale, scale));
|
||||
canvas.translate((-bounds.left(), -bounds.top()));
|
||||
vector::render_tree(shared, canvas, id, tree, scale, bounds)?;
|
||||
}
|
||||
|
||||
let data = surface
|
||||
.image_snapshot()
|
||||
.encode(
|
||||
None::<&mut skia::gpu::DirectContext>,
|
||||
skia::EncodedImageFormat::PNG,
|
||||
100,
|
||||
)
|
||||
.ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?;
|
||||
|
||||
Ok((data.as_bytes().to_vec(), width, height))
|
||||
}
|
||||
43
render-wasm/src/render/resources.rs
Normal file
43
render-wasm/src/render/resources.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use skia_safe as skia;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
use super::{FontStore, ImageStore};
|
||||
|
||||
/// Render dependencies that the export (PDF) path needs independently of the
|
||||
/// surface/tile/GPU machinery in [`RenderState`]: the font store, the
|
||||
/// decoded-image store, and the default sampling options.
|
||||
///
|
||||
/// Splitting these out keeps the vector/PDF export path (which takes
|
||||
/// `&mut RenderResources`) decoupled from the interactive renderer, and gives a
|
||||
/// single source of truth reached through `get_resources()`.
|
||||
pub(crate) struct RenderResources {
|
||||
pub fonts: FontStore,
|
||||
pub images: ImageStore,
|
||||
pub sampling_options: skia::SamplingOptions,
|
||||
}
|
||||
|
||||
impl RenderResources {
|
||||
pub fn try_new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
fonts: FontStore::try_new()?,
|
||||
images: ImageStore::new(),
|
||||
sampling_options: skia::SamplingOptions::new(
|
||||
skia::FilterMode::Linear,
|
||||
skia::MipmapMode::Nearest,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Headless export resources: CPU-only image store, no GPU/WebGL.
|
||||
pub fn try_new_headless() -> Result<Self> {
|
||||
Ok(Self {
|
||||
fonts: FontStore::try_new()?,
|
||||
images: ImageStore::new_without_gpu(),
|
||||
sampling_options: skia::SamplingOptions::new(
|
||||
skia::FilterMode::Linear,
|
||||
skia::MipmapMode::Nearest,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ use skia_safe::{self as skia, ImageFilter, RRect};
|
||||
|
||||
use super::{filters, RenderState, SurfaceId};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::get_resources;
|
||||
use crate::render::filters::compose_filters;
|
||||
use crate::render::{get_dest_rect, get_source_rect};
|
||||
|
||||
@ -467,7 +468,7 @@ fn draw_image_stroke_in_container(
|
||||
surface_id: SurfaceId,
|
||||
) -> Result<()> {
|
||||
let scale = render_state.get_scale();
|
||||
let Some(image) = render_state.images.get(&image_fill.id()) else {
|
||||
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@ -575,7 +576,7 @@ fn draw_image_stroke_in_container(
|
||||
image,
|
||||
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||
dest_rect,
|
||||
render_state.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
&image_paint,
|
||||
);
|
||||
|
||||
|
||||
@ -106,6 +106,7 @@ impl DocAtlas {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: delete one docatlas is optional
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.size.width <= 0 || self.size.height <= 0
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use skia_safe::{self as skia, Color4f};
|
||||
|
||||
use super::{RenderState, ShapesPoolRef, SurfaceId};
|
||||
use crate::get_resources;
|
||||
use crate::globals::get_ui_state;
|
||||
use crate::render::grid_layout;
|
||||
use crate::shapes::{Layout, Type};
|
||||
@ -67,7 +68,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
|
||||
rulers::render(
|
||||
canvas,
|
||||
viewbox,
|
||||
&render_state.fonts,
|
||||
&get_resources().fonts,
|
||||
get_ui_state().ruler_state(),
|
||||
);
|
||||
|
||||
|
||||
@ -9,19 +9,9 @@ use crate::uuid::Uuid;
|
||||
|
||||
use super::shape_renderer::ShapeRenderer;
|
||||
use super::text;
|
||||
use super::RenderState;
|
||||
use super::RenderResources;
|
||||
use super::{get_dest_rect, get_source_rect};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VectorTarget — vector export backend selector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Vector export backend selector (PDF today; SVG could be added as a variant).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VectorTarget {
|
||||
Pdf,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -29,23 +19,16 @@ pub(super) enum VectorTarget {
|
||||
/// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces).
|
||||
pub(super) struct VectorRenderer<'a> {
|
||||
canvas: &'a Canvas,
|
||||
shared: &'a mut RenderState,
|
||||
shared: &'a mut RenderResources,
|
||||
scale: f32,
|
||||
_target: VectorTarget,
|
||||
}
|
||||
|
||||
impl<'a> VectorRenderer<'a> {
|
||||
pub fn new(
|
||||
canvas: &'a Canvas,
|
||||
shared: &'a mut RenderState,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
) -> Self {
|
||||
pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self {
|
||||
Self {
|
||||
canvas,
|
||||
shared,
|
||||
scale,
|
||||
_target: target,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -364,15 +347,88 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
// Tree traversal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Depth-first render of the shape tree rooted at `id`.
|
||||
/// Options threaded through the whole traversal. Carries the export root/page
|
||||
/// (needed to re-render the backdrop for background blur) and the background
|
||||
/// blur strategy for the active canvas.
|
||||
struct TreeOpts<'a> {
|
||||
/// The exported root shape id — the backdrop for any shape is re-rendered
|
||||
/// from here.
|
||||
root: &'a Uuid,
|
||||
/// Root's export bounds (shape space); sizes/positions the offscreen
|
||||
/// backdrop surface to match the page.
|
||||
page: skia::Rect,
|
||||
/// `true` on a PDF canvas (which ignores Skia backdrop filters): background
|
||||
/// blur is produced by rendering the backdrop onto an offscreen raster
|
||||
/// surface and embedding the blurred result as an image. `false` on a raster
|
||||
/// canvas, where a plain Skia backdrop filter works directly.
|
||||
embed_bg_blur: bool,
|
||||
/// When rendering a backdrop, the shape whose own subtree must be omitted
|
||||
/// (so the blur samples only what is *behind* it).
|
||||
skip: Option<&'a Uuid>,
|
||||
}
|
||||
|
||||
/// Depth-first render of the shape tree rooted at `id`. Used for raster export
|
||||
/// (PNG/webp), where background blur is applied with a direct Skia backdrop
|
||||
/// filter (raster surfaces support it). `page` must be the same bounds the
|
||||
/// caller used to size/translate its surface — the embedded background-blur
|
||||
/// backdrop is drawn device-aligned against it.
|
||||
pub(super) fn render_tree(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
page: skia::Rect,
|
||||
) -> Result<()> {
|
||||
render_tree_dispatch(shared, canvas, id, tree, scale, page, false)
|
||||
}
|
||||
|
||||
/// Like [`render_tree`], but for a PDF canvas: background blur is embedded as a
|
||||
/// rasterised image (the PDF backend ignores backdrop filters).
|
||||
pub(super) fn render_tree_pdf(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
page: skia::Rect,
|
||||
) -> Result<()> {
|
||||
render_tree_dispatch(shared, canvas, id, tree, scale, page, true)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tree_dispatch(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
page: skia::Rect,
|
||||
embed_bg_blur: bool,
|
||||
) -> Result<()> {
|
||||
let opts = TreeOpts {
|
||||
root: id,
|
||||
page,
|
||||
embed_bg_blur,
|
||||
skip: None,
|
||||
};
|
||||
render_tree_inner(shared, canvas, id, tree, scale, &opts)
|
||||
}
|
||||
|
||||
fn render_tree_inner(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
// When re-rendering a backdrop, omit the blur shape's own subtree so it only
|
||||
// samples what is behind it.
|
||||
if opts.skip == Some(id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(element) = tree.get(id) else {
|
||||
return Ok(());
|
||||
};
|
||||
@ -381,12 +437,27 @@ pub(super) fn render_tree(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Background blur samples already-drawn content behind the shape, so it must
|
||||
// run before the shape (and its subtree) paints. Text/SVGRaw are excluded,
|
||||
// matching the GPU path.
|
||||
if !matches!(element.shape_type, Type::Text(_) | Type::SVGRaw(_)) {
|
||||
if let Some(blur) = element.visible_background_blur() {
|
||||
if blur.value > 0.0 {
|
||||
if opts.embed_bg_blur {
|
||||
render_background_blur_image(shared, canvas, element, tree, scale, opts)?;
|
||||
} else {
|
||||
render_background_blur_backdrop(canvas, element, blur.value * scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &element.shape_type {
|
||||
Type::Group(group) => {
|
||||
render_group(shared, canvas, element, group.masked, tree, scale, target)?;
|
||||
render_group(shared, canvas, element, group.masked, tree, scale, opts)?;
|
||||
}
|
||||
Type::Frame(_) => {
|
||||
render_frame(shared, canvas, element, tree, scale, target)?;
|
||||
render_frame(shared, canvas, element, tree, scale, opts)?;
|
||||
}
|
||||
// Leaf types listed explicitly (no `_`) so a new Type must be handled.
|
||||
Type::Rect(_)
|
||||
@ -395,25 +466,135 @@ pub(super) fn render_tree(
|
||||
| Type::Bool(_)
|
||||
| Type::Text(_)
|
||||
| Type::SVGRaw(_) => {
|
||||
render_leaf(shared, canvas, element, scale, target)?;
|
||||
render_leaf(shared, canvas, element, scale)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background blur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Background blur on a canvas that supports Skia backdrop filters (raster).
|
||||
/// Blurs the current device contents within the shape silhouette and stamps the
|
||||
/// result back with `Src`. `sigma_radius` is the blur radius already multiplied
|
||||
/// by the export scale.
|
||||
fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) {
|
||||
let sigma = radius_to_sigma(sigma_radius);
|
||||
let Some(blur_filter) =
|
||||
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
clip_to_shape(canvas, shape, true);
|
||||
// Apply the blur in device space (sigma already includes the export scale);
|
||||
// the clip, set with the full transform, survives reset_matrix.
|
||||
canvas.reset_matrix();
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_blend_mode(skia::BlendMode::Src);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.backdrop(&blur_filter)
|
||||
.backdrop_tile_mode(skia::TileMode::Clamp)
|
||||
.paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
canvas.restore(); // composite the blurred-backdrop layer
|
||||
canvas.restore(); // pop the clip + transform
|
||||
}
|
||||
|
||||
/// Background blur for a PDF canvas (backdrop filters unsupported): render the
|
||||
/// backdrop — the whole page minus this shape's own subtree — onto an offscreen
|
||||
/// raster surface, blur it, and embed the result as an image clipped to the
|
||||
/// shape. The rest of the page stays vector.
|
||||
///
|
||||
/// LIMITATION: the backdrop omits only this shape's subtree, not shapes painted
|
||||
/// *after* it. For content stacked on top of the blur shape the foreground would
|
||||
/// bleed into the blur; correct for the common case (nothing above the panel).
|
||||
fn render_background_blur_image(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
let bounds = opts.page;
|
||||
let width = (bounds.width() * scale).ceil() as i32;
|
||||
let height = (bounds.height() * scale).ceil() as i32;
|
||||
if width <= 0 || height <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Render the backdrop into an offscreen raster surface, in the same
|
||||
// coordinate space as the PDF page (see `render/pdf.rs`).
|
||||
let Some(mut surface) = skia::surfaces::raster_n32_premul((width, height)) else {
|
||||
return Ok(());
|
||||
};
|
||||
{
|
||||
let oc = surface.canvas();
|
||||
oc.clear(skia::Color::TRANSPARENT);
|
||||
oc.scale((scale, scale));
|
||||
oc.translate((-bounds.left(), -bounds.top()));
|
||||
let sub = TreeOpts {
|
||||
root: opts.root,
|
||||
page: opts.page,
|
||||
embed_bg_blur: false,
|
||||
skip: Some(&shape.id),
|
||||
};
|
||||
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
|
||||
}
|
||||
let image = surface.image_snapshot();
|
||||
|
||||
// Bake the blur into a raster bitmap. The PDF backend ignores image filters
|
||||
// at draw time (same limitation as backdrop filters), so we must blur on a
|
||||
// raster surface — where filters work — and embed the pre-blurred result.
|
||||
let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale);
|
||||
let blurred = {
|
||||
let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let bc = blur_surface.canvas();
|
||||
bc.clear(skia::Color::TRANSPARENT);
|
||||
let mut paint = Paint::default();
|
||||
if let Some(filter) =
|
||||
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
|
||||
{
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
bc.draw_image(&image, (0.0, 0.0), Some(&paint));
|
||||
blur_surface.image_snapshot()
|
||||
};
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
clip_to_shape(canvas, shape, true);
|
||||
// Draw the pre-blurred full-page bitmap in device space (1 image px per
|
||||
// device unit) so it aligns with the page regardless of the shape transform.
|
||||
canvas.reset_matrix();
|
||||
canvas.draw_image(&blurred, (0.0, 0.0), None);
|
||||
canvas.restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_group(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
masked: bool,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
// A group has no geometry of its own and does NOT propagate a transform to
|
||||
// its children: child shapes are stored in absolute coordinates and each
|
||||
@ -422,7 +603,7 @@ fn render_group(
|
||||
canvas.save();
|
||||
|
||||
// Group drop shadow: subtree silhouette, below the opacity/clip layer.
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?;
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, false, opts)?;
|
||||
|
||||
// Layer for opacity / blend mode (and group-level layer blur)
|
||||
let needs_layer = element.needs_layer();
|
||||
@ -455,21 +636,21 @@ fn render_group(
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
|
||||
if let Some(mask_id) = element.mask_id() {
|
||||
let mut mask_paint = Paint::default();
|
||||
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint));
|
||||
render_tree(shared, canvas, mask_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?;
|
||||
canvas.restore(); // mask layer
|
||||
}
|
||||
|
||||
canvas.restore(); // composition layer
|
||||
} else {
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -485,12 +666,12 @@ fn render_group(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_frame(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
// A frame's own geometry (background, clip, strokes) is placed by its
|
||||
// `centered_transform`, but — like groups — it does NOT propagate that
|
||||
@ -503,7 +684,7 @@ fn render_frame(
|
||||
|
||||
// Frame drop shadow: background + subtree silhouette, below the clip layer
|
||||
// so it extends outside the frame bounds.
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?;
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, true, opts)?;
|
||||
|
||||
let needs_layer = element.needs_layer();
|
||||
|
||||
@ -542,7 +723,7 @@ fn render_frame(
|
||||
if !element.fills.is_empty() {
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
canvas.restore();
|
||||
@ -551,7 +732,7 @@ fn render_frame(
|
||||
// Children (absolute coords, no frame transform).
|
||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
|
||||
// Strokes over children (clipped frames), in the frame's space.
|
||||
@ -559,7 +740,7 @@ fn render_frame(
|
||||
if !visible_strokes.is_empty() {
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
canvas.restore();
|
||||
}
|
||||
@ -575,13 +756,13 @@ fn render_frame(
|
||||
/// layer (its alpha becomes the shadow). `draw_fills` includes the frame
|
||||
/// background in the silhouette.
|
||||
fn render_container_drop_shadows(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
draw_fills: bool,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let Some(filter) = shadow.get_drop_shadow_filter() else {
|
||||
@ -592,13 +773,13 @@ fn render_container_drop_shadows(
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
|
||||
if draw_fills && !element.fills.is_empty() {
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
}
|
||||
|
||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
@ -611,11 +792,10 @@ fn render_container_drop_shadows(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_leaf(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
) -> Result<()> {
|
||||
let needs_layer = element.needs_layer();
|
||||
|
||||
@ -633,7 +813,7 @@ fn render_leaf(
|
||||
canvas.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
|
||||
// Layer blur (non-text shapes)
|
||||
let blur_layer = if !matches!(element.shape_type, Type::Text(_)) {
|
||||
@ -695,7 +875,7 @@ fn render_leaf_content<R: ShapeRenderer + ?Sized>(renderer: &mut R, shape: &Shap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_image_fill(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
image_fill: &crate::shapes::ImageFill,
|
||||
@ -737,7 +917,7 @@ fn draw_image_fill(
|
||||
|
||||
fn draw_single_stroke(
|
||||
canvas: &Canvas,
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
scale: f32,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
@ -848,7 +1028,7 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint
|
||||
/// CPU image over it with `SrcIn` so only the stroke area shows the image.
|
||||
fn draw_image_stroke(
|
||||
canvas: &Canvas,
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
scale: f32,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
|
||||
@ -1289,6 +1289,15 @@ impl Shape {
|
||||
})
|
||||
}
|
||||
|
||||
/// Font families used by this shape (the text spans' families), or an
|
||||
/// empty vec for non-text shapes.
|
||||
pub fn font_families(&self) -> Vec<FontFamily> {
|
||||
match &self.shape_type {
|
||||
Type::Text(content) => content.font_families(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn mask_filter(&self, scale: f32) -> Option<skia::MaskFilter> {
|
||||
self.blur
|
||||
|
||||
@ -391,6 +391,20 @@ impl TextContent {
|
||||
self.bounds = Rect::from_xywh(x, y, w, h);
|
||||
}
|
||||
|
||||
/// Distinct font families referenced by this content's spans, in first-seen
|
||||
/// order.
|
||||
pub fn font_families(&self) -> Vec<FontFamily> {
|
||||
let mut seen: Vec<FontFamily> = Vec::new();
|
||||
for paragraph in &self.paragraphs {
|
||||
for span in paragraph.children() {
|
||||
if !seen.contains(&span.font_family) {
|
||||
seen.push(span.font_family);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
pub fn add_paragraph(&mut self, paragraph: Paragraph) {
|
||||
self.paragraphs.push(paragraph);
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::shapes::text::TextContent;
|
||||
use skia_safe::{
|
||||
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
|
||||
@ -174,7 +174,7 @@ impl TextPaths {
|
||||
) -> Option<(skia::Path, skia::Rect)> {
|
||||
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
|
||||
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
|
||||
let emoji_font = get_render_state().fonts().get_emoji_font(font.size());
|
||||
let emoji_font = get_resources().fonts.get_emoji_font(font.size());
|
||||
let use_font = emoji_font.as_ref().unwrap_or(font);
|
||||
|
||||
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
|
||||
|
||||
@ -11,9 +11,9 @@ pub use ui::UIState;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::FrameType;
|
||||
use crate::shapes::{grid_layout::grid_cell_data, Shape};
|
||||
use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape};
|
||||
use crate::uuid::Uuid;
|
||||
use crate::{get_render_state, tiles};
|
||||
use crate::{get_render_state, get_resources, has_render_state, tiles};
|
||||
|
||||
/// This struct holds the state of the Rust application between JS calls.
|
||||
///
|
||||
@ -96,7 +96,33 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
|
||||
crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale)
|
||||
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale)
|
||||
}
|
||||
|
||||
/// GPU-free counterpart of [`State::render_shape_pixels`]: PNG on a CPU
|
||||
/// raster surface, no GPU/WebGL.
|
||||
pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec<u8>, i32, i32)> {
|
||||
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale)
|
||||
}
|
||||
|
||||
/// Distinct font families used by the (visible) subtree rooted at `id`, in
|
||||
/// first-seen order — the on-demand set the headless exporter provisions.
|
||||
pub fn fonts_used_by_shape(&self, id: &Uuid) -> Vec<FontFamily> {
|
||||
let Some(root) = self.shapes.get(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut result: Vec<FontFamily> = Vec::new();
|
||||
for child_id in root.all_children_iter(&self.shapes, false, true) {
|
||||
if let Some(shape) = self.shapes.get(&child_id) {
|
||||
for family in shape.font_families() {
|
||||
if !result.contains(&family) {
|
||||
result.push(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<FrameType> {
|
||||
@ -150,8 +176,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) {
|
||||
let render_state = get_render_state();
|
||||
|
||||
// We don't really do a self.shapes.remove so that redo/undo keep working
|
||||
let Some(shape) = self.shapes.get(&id) else {
|
||||
return;
|
||||
@ -159,23 +183,28 @@ impl State {
|
||||
|
||||
// Only remove the children when is being deleted from the owner
|
||||
if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) {
|
||||
// IMPORTANT:
|
||||
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape
|
||||
// tiles with the current interest area, which means we'd only invalidate
|
||||
// the subset currently near the viewport. When the user later pans/zooms
|
||||
// to reveal previously cached tiles, stale pixels could reappear.
|
||||
//
|
||||
// Instead, remove the shape from *all* tiles where it was indexed, and
|
||||
// drop cached tiles for those entries.
|
||||
let indexed_tiles: Vec<tiles::Tile> = render_state
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map(|t| t.iter().copied().collect())
|
||||
.unwrap_or_default();
|
||||
// Tile invalidation only applies to the on-screen render state; the
|
||||
// headless export path has none, so skip it there.
|
||||
if has_render_state() {
|
||||
let render_state = get_render_state();
|
||||
// IMPORTANT:
|
||||
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape
|
||||
// tiles with the current interest area, which means we'd only invalidate
|
||||
// the subset currently near the viewport. When the user later pans/zooms
|
||||
// to reveal previously cached tiles, stale pixels could reappear.
|
||||
//
|
||||
// Instead, remove the shape from *all* tiles where it was indexed, and
|
||||
// drop cached tiles for those entries.
|
||||
let indexed_tiles: Vec<tiles::Tile> = render_state
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map(|t| t.iter().copied().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for tile in indexed_tiles {
|
||||
render_state.remove_cached_tile(tile);
|
||||
render_state.tiles.remove_shape_at(tile, shape.id);
|
||||
for tile in indexed_tiles {
|
||||
render_state.remove_cached_tile(tile);
|
||||
render_state.tiles.remove_shape_at(tile, shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(shape_to_delete) = self.shapes.get(&id) {
|
||||
@ -184,8 +213,8 @@ impl State {
|
||||
if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) {
|
||||
shape_to_delete.set_deleted(true);
|
||||
}
|
||||
if render_state.show_grid == Some(shape_id) {
|
||||
render_state.show_grid = None;
|
||||
if has_render_state() && get_render_state().show_grid == Some(shape_id) {
|
||||
get_render_state().show_grid = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -215,7 +244,8 @@ impl State {
|
||||
/// and groups properly encompass their children.
|
||||
pub fn set_parent_for_current_shape(&mut self, id: Uuid) {
|
||||
// Reparent preview during drag is handled by structure modifiers only.
|
||||
if get_render_state().options.is_interactive_transform() {
|
||||
// Headless export has no render state and never runs interactive drags.
|
||||
if has_render_state() && get_render_state().options.is_interactive_transform() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -265,7 +295,7 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn font_collection(&self) -> &FontCollection {
|
||||
get_render_state().fonts().font_collection()
|
||||
get_resources().fonts.font_collection()
|
||||
}
|
||||
|
||||
pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> {
|
||||
@ -298,18 +328,20 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn touch_current(&mut self) {
|
||||
let render_state = get_render_state();
|
||||
if !self.loading {
|
||||
if let Some(current_id) = self.current_id {
|
||||
render_state.mark_touched(current_id);
|
||||
}
|
||||
// `mark_touched` only drives incremental on-screen tile invalidation;
|
||||
// the headless export path has no render state, so skip it there.
|
||||
if self.loading || !has_render_state() {
|
||||
return;
|
||||
}
|
||||
if let Some(current_id) = self.current_id {
|
||||
get_render_state().mark_touched(current_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch_shape(&mut self, id: Uuid) {
|
||||
let render_state = get_render_state();
|
||||
if !self.loading {
|
||||
render_state.mark_touched(id);
|
||||
if self.loading || !has_render_state() {
|
||||
return;
|
||||
}
|
||||
get_render_state().mark_touched(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::skia::textlayout::FontCollection;
|
||||
use crate::skia::Image;
|
||||
use crate::uuid::Uuid;
|
||||
@ -25,12 +25,12 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
|
||||
}
|
||||
|
||||
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
|
||||
get_render_state().images.get(image_id)
|
||||
get_resources().images.get(image_id)
|
||||
}
|
||||
|
||||
// FIXME: move to a different place ?
|
||||
pub fn get_fallback_fonts() -> &'static HashSet<String> {
|
||||
get_render_state().fonts().get_fallback()
|
||||
get_resources().fonts.get_fallback()
|
||||
}
|
||||
|
||||
pub fn get_font_collection() -> &'static FontCollection {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use crate::error::{Error, Result};
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::mem;
|
||||
use crate::shapes::Fill;
|
||||
use crate::state::State;
|
||||
@ -104,7 +104,10 @@ pub extern "C" fn store_image() -> Result<()> {
|
||||
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
|
||||
|
||||
with_state!(state, {
|
||||
if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) {
|
||||
if let Err(msg) = get_resources()
|
||||
.images
|
||||
.add(ids.image_id, is_thumbnail, image_bytes)
|
||||
{
|
||||
eprintln!("{}", msg);
|
||||
}
|
||||
touch_shapes_with_image(state, ids.image_id);
|
||||
@ -174,7 +177,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> {
|
||||
);
|
||||
|
||||
with_state!(state, {
|
||||
if let Err(msg) = get_render_state().add_image_from_gl_texture(
|
||||
if let Err(msg) = get_resources().images.add_image_from_gl_texture(
|
||||
ids.image_id,
|
||||
is_thumbnail,
|
||||
texture_id,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
use macros::{wasm_error, ToJs};
|
||||
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::mem;
|
||||
use crate::render::FontStore;
|
||||
use crate::shapes::{FontFamily, FontStyle};
|
||||
use crate::utils::uuid_from_u32_quartet;
|
||||
use crate::with_state;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
||||
#[repr(u8)]
|
||||
@ -45,14 +47,44 @@ pub extern "C" fn store_font(
|
||||
let font_style = RawFontStyle::from(style);
|
||||
|
||||
let family = FontFamily::new(id, weight, font_style.into());
|
||||
let _ = get_render_state()
|
||||
.fonts_mut()
|
||||
let _ = get_resources()
|
||||
.fonts
|
||||
.add(family, &font_bytes, is_emoji, is_fallback);
|
||||
|
||||
mem::free_bytes()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the font store to its default state, dropping every font uploaded via
|
||||
/// `store_font`. A headless host that reuses a single WASM instance across
|
||||
/// requests must call this per render so fonts don't accumulate unbounded.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn clear_fonts() -> Result<()> {
|
||||
get_resources().fonts = FontStore::try_new()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Distinct font families used by the subtree, for on-demand provisioning by a
|
||||
/// headless host. Buffer (LE): `[count u32]` then `count` ×
|
||||
/// `[uuid 16B][weight u32][style u32]` (0=normal, 1=italic); uuid bytes match
|
||||
/// the quartet `store_font` consumes.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_fonts_for_shape(a: u32, b: u32, c: u32, d: u32) -> *mut u8 {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let families = with_state!(state, { state.fonts_used_by_shape(&id) });
|
||||
|
||||
let mut buf = Vec::with_capacity(4 + families.len() * 24);
|
||||
buf.extend_from_slice(&(families.len() as u32).to_le_bytes());
|
||||
for family in families {
|
||||
let id_bytes: [u8; 16] = family.id().into();
|
||||
buf.extend_from_slice(&id_bytes);
|
||||
buf.extend_from_slice(&family.weight().to_le_bytes());
|
||||
buf.extend_from_slice(&(family.style() as u32).to_le_bytes());
|
||||
}
|
||||
mem::write_bytes(buf)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn is_font_uploaded(
|
||||
a: u32,
|
||||
@ -66,7 +98,5 @@ pub extern "C" fn is_font_uploaded(
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let font_style = RawFontStyle::from(style);
|
||||
let family = FontFamily::new(id, weight, font_style.into());
|
||||
let res = get_render_state().fonts().has_family(&family, is_emoji);
|
||||
|
||||
res
|
||||
get_resources().fonts.has_family(&family, is_emoji)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user