♻️ Extract GPU-free RenderResources; add headless wasm exports (#10653)

This commit is contained in:
Elena Torró 2026-07-22 09:56:21 +02:00 committed by GitHub
parent d4e87ec59d
commit 08e42687d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1423 additions and 684 deletions

View File

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

View File

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

View File

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

View File

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

View 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")))

View File

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

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

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

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

View 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")))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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))
}

View 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,
),
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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