diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 24d72e5e92..83a7cbb5fa 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -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 diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 394864c22a..7a33b98024 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 765bc14eb8..82cf07cc8f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 86946db5ba..5937ebc014 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api/props.cljs b/frontend/src/app/render_wasm/api/props.cljs new file mode 100644 index 0000000000..add4496c66 --- /dev/null +++ b/frontend/src/app/render_wasm/api/props.cljs @@ -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"))) diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index afe26e2726..3ea55dfdb3 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -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: - ;; [ ] + "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) diff --git a/frontend/src/app/render_wasm/fallback_fonts.cljs b/frontend/src/app/render_wasm/fallback_fonts.cljs new file mode 100644 index 0000000000..80f52be51c --- /dev/null +++ b/frontend/src/app/render_wasm/fallback_fonts.cljs @@ -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)) diff --git a/frontend/src/app/render_wasm/resources.cljs b/frontend/src/app/render_wasm/resources.cljs new file mode 100644 index 0000000000..9f564aa990 --- /dev/null +++ b/frontend/src/app/render_wasm/resources.cljs @@ -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))) diff --git a/frontend/src/app/render_wasm/serialize_shape.cljs b/frontend/src/app/render_wasm/serialize_shape.cljs new file mode 100644 index 0000000000..ffc659757e --- /dev/null +++ b/frontend/src/app/render_wasm/serialize_shape.cljs @@ -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))))) diff --git a/frontend/src/app/render_wasm/text_content.cljs b/frontend/src/app/render_wasm/text_content.cljs new file mode 100644 index 0000000000..457a633066 --- /dev/null +++ b/frontend/src/app/render_wasm/text_content.cljs @@ -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"))) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 70887662df..0b506e415d 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -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 \ diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 64e7d8c94f..cd0fcf0bbf 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -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<()> { diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ffa8d34ca3..06ad3caaad 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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 { 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 diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 3800b7dc81..047aaaf393 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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, 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, pub current_tile: Option, - 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 { // 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 { 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(); diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 0574e3ab28..d661fd2bd1 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -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); } diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index a34956be4a..d7010cc2e5 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -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; }; diff --git a/render-wasm/src/render/filters.rs b/render-wasm/src/render/filters.rs index 34b33f403d..b4313a043d 100644 --- a/render-wasm/src/render/filters.rs +++ b/render-wasm/src/render/filters.rs @@ -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) diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 9473573cfe..a431ae5e4b 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -68,7 +68,8 @@ enum StoredImage { pub struct ImageStore { images: HashMap<(Uuid, bool), StoredImage>, - context: Box, + /// gpu-only + context: Option>, } /// 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 { - 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 { + 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() } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 7a6a3a3d39..fed66505fe 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -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; } diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index 551a451c92..c61fef322b 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -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(); diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs new file mode 100644 index 0000000000..34692d6ab4 --- /dev/null +++ b/render-wasm/src/render/raster.rs @@ -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, 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)) +} diff --git a/render-wasm/src/render/resources.rs b/render-wasm/src/render/resources.rs new file mode 100644 index 0000000000..4ca9761809 --- /dev/null +++ b/render-wasm/src/render/resources.rs @@ -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 { + 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 { + Ok(Self { + fonts: FontStore::try_new()?, + images: ImageStore::new_without_gpu(), + sampling_options: skia::SamplingOptions::new( + skia::FilterMode::Linear, + skia::MipmapMode::Nearest, + ), + }) + } +} diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 8221cad4b6..b5a5a93518 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -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, ); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index dc91011abc..e68c0df0c3 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -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 } diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 19854df768..4ba350ba1a 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -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(), ); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 04d91552d9..cf17a55b8e 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -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 = 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 = 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(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, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 5dfbe9e384..7ae9fd8cef 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -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 { + match &self.shape_type { + Type::Text(content) => content.font_families(), + _ => Vec::new(), + } + } + #[allow(dead_code)] pub fn mask_filter(&self, scale: f32) -> Option { self.blur diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1ba1381172..f5f58f5a80 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -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 { + let mut seen: Vec = 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); diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 38cf30226f..c0d48c2e9e 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -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::>(); 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) { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c38b604592..136f5b52ba 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -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> { - 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, 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 { + let Some(root) = self.shapes.get(id) else { + return Vec::new(); + }; + + let mut result: Vec = 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 { @@ -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 = 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 = 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); } } diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 206b91dbb0..2301846c10 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -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 { - get_render_state().fonts().get_fallback() + get_resources().fonts.get_fallback() } pub fn get_font_collection() -> &'static FontCollection { diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 65a4697181..778b80bf92 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -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, diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 21c4e6a797..112d523035 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -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) }