✨ Run WASM asset exports locally in the browser (#12515)

When render-wasm is active, shape exports (single file, ZIP, and Export
boards multi-page PDF) run on the shared worker WASM module so the UI
stays responsive. A progressive PDF session (begin/add-page/finish)
updates progress per page; cancel settles on the client. The server
exporter path remains for the SVG renderer.
This commit is contained in:
Alejandro Alonso 2026-10-23 11:02:23 +02:00 committed by GitHub
parent 9aa78a536b
commit e27c65c77a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1554 additions and 241 deletions

View File

@ -6,10 +6,10 @@
(ns app.main.data.exports.assets
(:require
[app.common.files.helpers :as cfh]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
@ -18,9 +18,11 @@
[app.main.features :as features]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.webapi :as wapi]
[app.util.websocket :as ws]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@ -236,17 +238,22 @@
(update [_ state]
(let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now))
healthy? (< time-diff 6000)
;; The legacy path has no server-side figures to track; it keeps
;; reporting progress over the client's own list.
job? (some? (get-in state [:export :job-id]))]
;; Local WASM jobs also store a client job-id so the widget can
;; show cancelling/cancelled (it keys those labels on job-id).
job? (some? (get-in state [:export :job-id]))
current (get-in state [:export :status])
;; Cancel settles the widget immediately; a late worker
;; `:progress` must not flip status back to "running".
terminal? (contains? #{"cancelled" "cancelling" "ended" "error"} current)
apply? (or (not= status "running") (not terminal?))]
(cond-> state
job?
(and job? apply?)
(update :export assoc :status status)
(and job? (some? total))
(and job? apply? (some? total))
(update :export assoc :total total)
(= status "running")
(and apply? (= status "running"))
(update :export assoc :progress done :last-update (ct/now) :healthy? healthy?)
(= status "error")
@ -266,24 +273,37 @@
(when (= status "ended")
(dom/trigger-download-uri filename mtype resource-uri)))))
(defn cancel-export
"Stops the running export. Only reachable on the job API path, where the
exporter can actually abort the work.
(def ^:private local-export-job-id*
"Job id of the in-flight worker multi-export, if any."
(atom nil))
The widget settles from here rather than from the job's `cancelled` message:
the outcome is known once the request returns, and waiting on a round trip
through redis and the websocket would leave it stuck whenever that message is
missed."
(defn cancel-export
"Stops the running export. On the job API path the exporter aborts; on the
local WASM path the worker is told to stop and the widget settles here.
Local cancel settles on the client: if the worker is stuck before the first
cancel check, waiting for a `:cancelled` reply would leave the UI hung."
[]
(ptk/reify ::cancel-export
ptk/WatchEvent
(watch [_ state _]
(when-let [job-id (get-in state [:export :job-id])]
(let [resource-id (get-in state [:export :resource-id])
settle (rx/concat
(rx/of (update-export-status {:status "cancelled"}))
(->> (rx/of (clear-export-state resource-id))
(rx/delay default-timeout)))]
(let [backend (get-in state [:export :backend])
job-id (get-in state [:export :job-id])
resource-id (get-in state [:export :resource-id])
settle (rx/concat
(rx/of (update-export-status {:status "cancelled"}))
(->> (rx/of (clear-export-state resource-id))
(rx/delay default-timeout)))]
(cond
(= "local" backend)
(do
(when-let [local-job-id @local-export-job-id*]
(mw/emit! {:cmd :exports/cancel :job-id local-job-id}))
;; Mark cancelled so a late `:done` from the worker is ignored.
(reset! local-export-job-id* nil)
settle)
(some? job-id)
(rx/concat
;; Stopping is not instantaneous: the request has to reach the
;; exporter and the work has to unwind.
@ -302,6 +322,13 @@
(features/active-feature? state "render-wasm/v1"))
(defn- export-target
"Where a single-shape export runs.
With render-wasm off → `:exporter` (server).
With render-wasm on → never the server API: always `:client` when the
format is supported, except true `:too-large` (beyond raster limits —
the exporter would fail too). Oversized-for-GPU rasters still run
locally via the CPU path in `export-bytes`."
[state {:keys [type object-id scale]}]
(cond
(not (and (wasm-export-enabled? state)
@ -311,18 +338,14 @@
(contains? #{:pdf :svg} type)
:client
;; Raster: only refuse when WASM reports true `:too-large`. The legacy
;; `:exporter` code means “use CPU raster” — we do that locally.
:else
(or (wasm.api/export-target object-id (or scale 1) type) :exporter)))
(if (= :too-large (wasm.api/export-target object-id (or scale 1) type))
:too-large
:client)))
(defn- request-simple-export-wasm
[export]
(ptk/reify ::request-simple-export-wasm
ptk/EffectEvent
(effect [_ _ _]
(case (:type export)
:pdf (wasm.exports/export-pdf export)
:svg (wasm.exports/export-svg export)
(wasm.exports/export-image export)))))
(declare request-local-export-wasm)
(defn request-simple-export
[{:keys [export]}]
@ -340,7 +363,10 @@
(watch [_ state _]
(case @target
:client
(rx/of (request-simple-export-wasm export))
(rx/of (request-local-export-wasm {:exports [export]
:cmd :export-shapes
:name (:name export)
:kind :single}))
:too-large
(rx/of (ntf/error (tr "errors.export-too-large")))
@ -363,95 +389,218 @@
(rx/of (clear-export-state uuid/zero)
(export-failed cause))))))))))))
(defn request-multiple-export
(defn- export-file-page-ids
[state exports]
(let [file-id (or (:file-id (first exports)) (:current-file-id state))
page-id (or (:page-id (first exports)) (:current-page-id state))]
[file-id page-id]))
(defn collect-export-objects
"Live page objects needed for `exports`: each root plus its descendants.
Falls back to the full page when the closure is empty (missing ids)."
[state exports]
(let [[file-id page-id] (export-file-page-ids state exports)
objects (or (dsh/lookup-page-objects state file-id page-id) {})
root-ids (into #{} (map :object-id) exports)
ids (into #{}
(mapcat #(cfh/get-children-ids-with-self objects %))
root-ids)
selected (select-keys objects ids)]
(if (seq selected)
selected
objects)))
(defn- export-page-background
[state exports]
(let [[file-id page-id] (export-file-page-ids state exports)
page (dsh/lookup-page state file-id page-id)]
(:background page)))
(defn- worker-export-entries
"Payload for the worker: drop `:shape` and other UI-only keys."
[exports]
(mapv (fn [{:keys [object-id type scale name suffix]}]
{:object-id object-id
:type type
:scale (or scale 1)
:name name
:suffix suffix})
exports))
(defn- request-local-export-wasm
"Client-side export on the shared worker WASM module: one file (`:single`),
a ZIP of shapes (`:zip`), or a multi-page boards PDF (`:pdf-frames`)."
[{:keys [exports cmd name kind]
:or {cmd :export-shapes}}]
(ptk/reify ::request-local-export-wasm
ptk/WatchEvent
(watch [_ state _]
(let [resource-id (uuid/next)
job-id (uuid/next)
total (count exports)
kind (or kind
(if (= cmd :export-frames) :pdf-frames :zip))
objects (collect-export-objects state exports)
background (export-page-background state exports)
entries (worker-export-entries exports)
clear-later (->> (rx/of (clear-export-state resource-id))
(rx/delay default-timeout))]
(reset! local-export-job-id* job-id)
(swap! st/ongoing-tasks conj :export)
(rx/concat
(rx/of (initialize-export-status exports cmd
{:id resource-id}
{:job-id job-id
:total total
:status "running"
:backend "local"}))
(->> (mw/ask-many!
{:cmd :exports/run-local
:job-id job-id
:kind kind
:exports entries
:objects objects
:background background
:name name})
(rx/mapcat
(fn [{:keys [type] :as message}]
;; Cancel clears `local-export-job-id*` and settles the
;; widget locally. Ignore every later worker message so a
;; stray `:progress` cannot flip the UI back to "running".
(if (nil? @local-export-job-id*)
(rx/empty)
(case type
:progress
(rx/of (update-export-status {:status "running"
:done (:done message)
:total (:total message)}))
:done
(let [uri (wapi/create-uri (:blob message))]
(rx/concat
(rx/of (update-export-status
{:status "ended"
:done total
:filename (:filename message)
:mtype (:mtype message)
:resource-uri uri}))
clear-later))
:cancelled
(rx/concat
(rx/of (update-export-status {:status "cancelled"}))
clear-later)
:error
(rx/of (export-widget-failed
exports cmd
(ex-info (or (:hint message) "worker export failed")
(or (:data message)
{:type :internal
:code :worker-export-error}))))
(rx/empty)))))
(rx/catch
(fn [cause]
(if (= :export-cancelled (:type (ex-data cause)))
(rx/concat
(rx/of (update-export-status {:status "cancelled"}))
clear-later)
(rx/of (export-widget-failed exports cmd cause)))))
(rx/finalize
(fn []
(reset! local-export-job-id* nil)
(swap! st/ongoing-tasks disj :export)))))))))
(defn- request-multiple-export-server
[{:keys [exports cmd name]
:or {cmd :export-shapes}}]
(ptk/reify ::request-multiple-export-server
ptk/WatchEvent
(watch [_ state _]
(let [resource-id (volatile! nil)
;; Emits when the export request fails.
failed-s (rx/subject)
profile-id (:profile-id state)
ws-conn (:ws-conn state)
params (cond->
{:exports exports
:cmd cmd
:profile-id profile-id
:force-multiple true
:is-wasm (wasm-export-enabled? state)}
(some? name)
(assoc :name name))
progress-stream
(->> (ws/get-rcv-stream ws-conn)
(rx/filter ws/message-event?)
(rx/map :payload)
(rx/filter #(= :export-update (:type %)))
(rx/filter #(= @resource-id (:resource-id %)))
(rx/take-until failed-s)
(rx/share))
stopper
(rx/filter #(or (= "ended" (:status %))
(= "error" (:status %))
(= "cancelled" (:status %)))
progress-stream)
file-ids
(export-file-ids exports)]
(swap! st/ongoing-tasks conj :export)
(rx/concat
(dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms)
(rx/of (warn-unsaved-changes file-ids))
(rx/merge
;; Updates the export state with each progress message, until
;; shortly after the export ends or fails.
(->> progress-stream
(rx/map update-export-status)
(rx/take-until (rx/delay 500 stopper))
(rx/finalize (fn []
(swap! st/ongoing-tasks disj :export))))
;; Clears the export state a while after the export ends or is
;; cancelled.
(->> progress-stream
(rx/filter #(or (= "ended" (:status %))
(= "cancelled" (:status %))))
(rx/take 1)
(rx/delay default-timeout)
(rx/map #(clear-export-state @resource-id))
(rx/take-until (rx/delay 6000 stopper)))
;; Launches the exportation process and stores the resource id
;; locally. Goes last so a failure reaches the progress listeners
;; above: the push to `failed-s` releases the leave-page warning
;; even though no progress message will ever arrive, and nothing
;; is raised to the global error handler, which would replace the
;; workspace with an error page.
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id)
(initialize-export-status exports cmd resource nil)))
(rx/catch (fn [cause]
(rx/push! failed-s cause)
(rx/of (export-widget-failed exports cmd cause)))))))))))
(defn request-multiple-export
[{:keys [exports cmd]
:or {cmd :export-shapes}
:as params}]
(let [exports (normalize-exports exports)]
(let [exports (normalize-exports exports)
params (assoc params :exports exports :cmd cmd)]
(ptk/reify ::request-multiple-export
ptk/WatchEvent
(watch [_ state _]
(let [resource-id (volatile! nil)
;; Emits when the export request fails.
failed-s (rx/subject)
profile-id (:profile-id state)
ws-conn (:ws-conn state)
params (cond->
{:exports exports
:cmd cmd
:profile-id profile-id
:force-multiple true
:is-wasm (wasm-export-enabled? state)}
(some? name)
(assoc :name name))
progress-stream
(->> (ws/get-rcv-stream ws-conn)
(rx/filter ws/message-event?)
(rx/map :payload)
(rx/filter #(= :export-update (:type %)))
(rx/filter #(= @resource-id (:resource-id %)))
(rx/take-until failed-s)
(rx/share))
stopper
(rx/filter #(or (= "ended" (:status %))
(= "error" (:status %))
(= "cancelled" (:status %)))
progress-stream)
file-ids
(export-file-ids exports)]
(swap! st/ongoing-tasks conj :export)
(rx/concat
(dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms)
(rx/of (warn-unsaved-changes file-ids))
(rx/merge
;; Updates the export state with each progress message, until
;; shortly after the export ends or fails.
(->> progress-stream
(rx/map update-export-status)
(rx/take-until (rx/delay 500 stopper))
(rx/finalize (fn []
(swap! st/ongoing-tasks disj :export))))
;; Clears the export state a while after the export ends or is
;; cancelled.
(->> progress-stream
(rx/filter #(or (= "ended" (:status %))
(= "cancelled" (:status %))))
(rx/take 1)
(rx/delay default-timeout)
(rx/map #(clear-export-state @resource-id))
(rx/take-until (rx/delay 6000 stopper)))
;; Launches the exportation process and stores the resource id
;; locally. With wasm export active the job API is used instead:
;; it answers with the exporter's own object count and gives a
;; handle to cancel. It goes last so a failure reaches the
;; progress listeners above: the push to `failed-s` releases the
;; leave-page warning even though no progress message will ever
;; arrive, and nothing is raised to the global error handler,
;; which would replace the workspace with an error page.
(->> (if (wasm-export-enabled? state)
(->> (rp/cmd! :create-export-job params)
(rx/map (fn [{job-id :id :keys [total] :as job}]
(vreset! resource-id (:resource-id job))
(initialize-export-status exports cmd
{:id (:resource-id job)}
{:job-id job-id
:total total
:status (:state job)
:backend (:backend job)}))))
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id)
(initialize-export-status exports cmd resource nil)))))
(rx/catch (fn [cause]
(rx/push! failed-s cause)
(rx/of (export-widget-failed exports cmd cause))))))))))))
(if (wasm-export-enabled? state)
(rx/of (request-local-export-wasm params))
(rx/of (request-multiple-export-server params)))))))
(defn request-export
[{:keys [exports] :as params}]

View File

@ -9,51 +9,113 @@
[app.common.media :refer [format->mtype]]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.webapi :as wapi]))
[app.util.webapi :as wapi]
[cuerdas.core :as str]))
;; Regex shared with the exporter so ZIP entry names stay compatible.
(def ^:private sanitize-file-regex #"[\\/:*?\"<>|]")
(defn export-extension
[type]
(case type
:png ".png"
:jpeg ".jpg"
:webp ".webp"
:svg ".svg"
:pdf ".pdf"
:zip ".zip"
""))
(defn sanitize-filename
"Exporter-compatible filename: strip reserved chars, then name+suffix+ext
with numeric de-dupe against `used`."
[{:keys [name suffix type]} used]
(let [base (-> (str (or name "") (or suffix ""))
(str/replace sanitize-file-regex "_"))]
(loop [index 0]
(let [candidate (str base
(when (pos? index)
(str "-" (inc index)))
(export-extension type))]
(if (contains? used candidate)
(recur (inc index))
candidate)))))
(defn export-bytes
"Encoded bytes for one export entry. Returns nil when WASM is not ready.
Used by the worker local export path (single / ZIP) and by main-thread
helpers (plugins, clipboard) that still need a sync URI.
Raster formats use the GPU surface when it fits; otherwise the CPU
raster path (same as the headless exporter) so oversized shapes still
export locally without calling the server API."
[{:keys [type scale object-id] :as _export}]
(let [scale (or scale 1)]
(case type
:pdf (wasm.api/render-shape-pdf object-id)
:svg (wasm.api/render-shape-svg object-id scale)
(if (= :client (wasm.api/export-target object-id scale type))
(wasm.api/render-shape-pixels object-id scale type)
(wasm.api/render-shape-raster object-id scale type)))))
(defn export-image-uri
[{:keys [type scale object-id]}]
[{:keys [type] :as params}]
;; The export type doubles as the encoder format, so the bytes always match
;; the mtype we wrap them in. `:svg` never reaches here — it needs vector
;; markup and `exports.assets/wasm-export-types` keeps it off this path.
(let [bytes (wasm.api/render-shape-pixels object-id scale type)
(let [bytes (export-bytes params)
mtype (format->mtype type)
blob (wapi/create-blob bytes mtype)]
blob (wapi/create-blob bytes mtype)]
(wapi/create-uri blob)))
(defn export-image
[{:keys [type name] :as params}]
(let [url (export-image-uri params)
(let [url (export-image-uri params)
mtype (format->mtype type)]
(dom/trigger-download-uri name mtype url)
(wapi/revoke-uri url)
nil))
(defn export-pdf-uri
[{:keys [object-id]}]
(let [bytes (wasm.api/render-shape-pdf object-id)
blob (wapi/create-blob bytes "application/pdf")]
[params]
(let [bytes (export-bytes (assoc params :type :pdf))
blob (wapi/create-blob bytes "application/pdf")]
(wapi/create-uri blob)))
(defn export-pdf
[{:keys [name] :as params}]
(let [url (export-pdf-uri params)
(let [url (export-pdf-uri params)
filename (str name ".pdf")]
(dom/trigger-download-uri filename "application/pdf" url)
(js/queueMicrotask #(wapi/revoke-uri url))
nil))
(defn export-svg-uri
[{:keys [scale object-id]}]
(let [bytes (wasm.api/render-shape-svg object-id (or scale 1))
blob (wapi/create-blob bytes "image/svg+xml")]
[params]
(let [bytes (export-bytes (assoc params :type :svg))
blob (wapi/create-blob bytes "image/svg+xml")]
(wapi/create-uri blob)))
(defn export-svg
[{:keys [name] :as params}]
(let [url (export-svg-uri params)
(let [url (export-svg-uri params)
filename (str name ".svg")]
(dom/trigger-download-uri filename "image/svg+xml" url)
(js/queueMicrotask #(wapi/revoke-uri url))
nil))
(defn export-frames-pdf-uri
"One multi-page PDF for the given object ids (page order = id order).
Synchronous main-thread helper; multi-export prefers the worker session."
[{:keys [object-ids]}]
(let [bytes (wasm.api/render-shapes-pdf object-ids)
blob (wapi/create-blob bytes "application/pdf")]
(wapi/create-uri blob)))
(defn export-frames-pdf
[{:keys [name] :as params}]
(let [url (export-frames-pdf-uri params)
filename (str (or name "export") ".pdf")]
(dom/trigger-download-uri filename "application/pdf" url)
(js/queueMicrotask #(wapi/revoke-uri url))
nil))

View File

@ -231,12 +231,13 @@
queued? (and (some? job-id) (= "queued" status))
cancelling? (and (some? job-id) (= "cancelling" status))
cancelled? (and (some? job-id) (= "cancelled" status))
;; Only the wasm backend can actually stop: a browser render holds its
;; pool slot until playwright gives up.
cancellable? (and (some? job-id)
(= "wasm" (:backend state))
(:in-progress state)
(not cancelling?))
;; Server wasm jobs and local client-side exports can stop; a non-wasm
;; browser render holds its pool slot until playwright gives up.
cancellable? (and (:in-progress state)
(not cancelling?)
(or (= "local" (:backend state))
(and (some? job-id)
(= "wasm" (:backend state)))))
total (or (:total state) (count items))
complete? (= progress total)
circ (* 2 Math/PI 12)

View File

@ -2899,29 +2899,47 @@
(when-let [snapshot wasm/canvas-snapshot]
(reset! transition-image* snapshot)))))
(defn- read-raster-result
"Decode `[len][width][height][bytes]` from a `_render_shape_*` raster call."
[offset]
(let [heap (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 offset))
result (dr/read-image-bytes heap (+ offset 12) length)]
(mem/free)
result))
(defn render-shape-pixels
"Renders a shape subtree to encoded image bytes. `format` is :png, :jpeg or
:webp; jpeg is flattened onto white on the Rust side, since it has no alpha
channel."
"Renders a shape subtree to encoded image bytes on the GPU export surface.
`format` is :png, :jpeg or :webp; jpeg is flattened onto white on the Rust
side, since it has no alpha channel."
[shape-id scale format]
(when (initialized?)
(let [buffer (uuid/get-u32 shape-id)
offset (h/call wasm/internal-module "_render_shape_pixels"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
scale
(sr/translate-raster-format format))]
(read-raster-result offset))))
offset
(h/call wasm/internal-module "_render_shape_pixels"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
scale
(sr/translate-raster-format format))
heap (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 offset))
result (dr/read-image-bytes heap (+ offset 12) length)]
(mem/free)
result)))
(defn render-shape-raster
"Same output as `render-shape-pixels`, but on a CPU Skia surface (no
GPU/WebGL). Used when the export exceeds the GPU max texture size — the
same path the headless exporter uses for wasm rasters."
[shape-id scale format]
(when (initialized?)
(let [buffer (uuid/get-u32 shape-id)
offset (h/call wasm/internal-module "_render_shape_raster"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
scale
(sr/translate-raster-format format))]
(read-raster-result offset))))
(defn get-shape-extrect
[shape-id]
@ -2976,6 +2994,66 @@
(mem/free)
result)))
(defn render-shapes-pdf
"Renders each shape id as a page of one PDF. IDs are written to the
transfer buffer (same layout as `set-focus-mode`) before the call."
[shape-ids]
(when (and (initialized?) (seq shape-ids))
(let [ids (vec shape-ids)
size (mem/get-alloc-size ids UUID-U8-SIZE)
heap (mem/get-heap-u32)
offset (mem/alloc->offset-32 size)
_ (reduce (fn [offset id]
(mem.h32/write-uuid offset heap id))
offset
ids)
result-offset
(h/call wasm/internal-module "_render_shapes_pdf")
heapu8 (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 result-offset))
result (dr/read-image-bytes heapu8 (+ result-offset 4) length)]
(mem/free)
result)))
(defn pdf-export-begin!
"Opens a progressive multi-page PDF session."
[]
(when (initialized?)
(h/call wasm/internal-module "_pdf_export_begin")))
(defn pdf-export-add-page!
"Appends `shape-id` as the next page of the current PDF session."
[shape-id]
(when (initialized?)
(let [buffer (uuid/get-u32 shape-id)]
(h/call wasm/internal-module "_pdf_export_add_page"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn pdf-export-finish
"Closes the PDF session and returns the encoded PDF bytes."
[]
(when (initialized?)
(let [offset (h/call wasm/internal-module "_pdf_export_finish")
heapu8 (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 offset))
result (dr/read-image-bytes heapu8 (+ offset 4) length)]
(mem/free)
result)))
(defn pdf-export-abort!
"Drops an in-flight PDF session without returning bytes."
[]
(when (initialized?)
(h/call wasm/internal-module "_pdf_export_abort")))
(defn render-shape-svg
[shape-id scale]
(when (initialized?)

View File

@ -11,6 +11,7 @@
[app.common.schema :as sm]
[app.common.types.objects-map]
[app.util.object :as obj]
[app.worker.exports]
[app.worker.impl :as impl]
[app.worker.index]
[app.worker.messages :as wm]

View File

@ -0,0 +1,260 @@
;; 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 SUBSIDIARY SL
(ns app.worker.exports
"Local WASM export (single file, ZIP, multi-page PDF) on the worker module.
Runs through `app.worker.wasm/submit` so it never interleaves with
thumbnail WASM jobs on the same module."
(:require
[app.common.logging :as log]
[app.common.media :as media]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as cc]
[app.common.uuid :as uuid]
[app.main.data.exports.wasm :as wasm.exports]
[app.render-wasm.api :as wasm.api]
[app.util.webapi :as wapi]
[app.util.zip :as uz]
[app.worker.impl :as impl]
[app.worker.wasm :as wwasm]
[beicon.v2.core :as rx]
[promesa.core :as p]))
(log/set-level! :info)
(def ^:private ^:const canvas-size 100)
(defn- export-cancelled
[]
(ex-info "export cancelled" {:type :export-cancelled}))
(defn- export-mtype
[type]
(case type
:pdf "application/pdf"
(media/format->mtype type)))
(defn- build-single
"One shape → one downloadable blob (PNG/JPEG/WebP/SVG/PDF)."
[exports job-id on-progress]
(let [export (first exports)]
(when-not export
(throw (ex-info "no export entry"
{:type :validation
:code :empty-export})))
(when (wwasm/cancelled? job-id)
(throw (export-cancelled)))
(let [bytes (wasm.exports/export-bytes export)]
(when-not bytes
(throw (ex-info "wasm export returned no bytes"
{:type :internal
:code :wasm-export-empty
:object-id (:object-id export)})))
(on-progress 1 1)
(p/resolved (wapi/create-blob bytes (export-mtype (:type export)))))))
(defn- build-zip
[exports job-id on-progress]
(let [writer (uz/writer (uz/blob-writer :mtype "application/zip"))
used (volatile! #{})
entries
(mapv (fn [export]
(let [filename (wasm.exports/sanitize-filename export @used)]
(vswap! used conj filename)
[filename export]))
exports)
total (count entries)]
(->> (p/reduce
(fn [writer [index [filename export]]]
(when (wwasm/cancelled? job-id)
(throw (export-cancelled)))
(let [bytes (wasm.exports/export-bytes export)]
(when-not bytes
(throw (ex-info "wasm export returned no bytes"
{:type :internal
:code :wasm-export-empty
:object-id (:object-id export)})))
(->> (uz/add writer filename bytes)
(p/fmap (fn [_]
(on-progress (inc index) total)
writer)))))
writer
(map-indexed vector entries))
(p/mcat (fn [writer]
(uz/close writer)))
(p/merr (fn [cause]
(try (uz/close writer) (catch :default _))
(p/rejected cause))))))
(defn- build-frames-pdf
[exports job-id on-progress]
(let [ids (mapv :object-id exports)
total (count ids)]
(wasm.api/pdf-export-begin!)
(->> (p/reduce
(fn [_ [index id]]
(when (wwasm/cancelled? job-id)
(throw (export-cancelled)))
(wasm.api/pdf-export-add-page! id)
(on-progress (inc index) total)
;; Yield so a cancel that arrived during add-page can stop
;; before the next page (nil would run every page in one turn).
(p/resolved nil))
nil
(map-indexed vector ids))
(p/mcat
(fn [_]
(let [bytes (wasm.api/pdf-export-finish)]
(when-not bytes
(throw (ex-info "wasm pdf export returned no bytes"
{:type :internal
:code :wasm-export-empty})))
(p/resolved (wapi/create-blob bytes "application/pdf")))))
(p/merr
(fn [cause]
(wasm.api/pdf-export-abort!)
(p/rejected cause))))))
(defn- export-entry
"Keeps only the fields the worker needs to render and name files."
[{:keys [object-id type scale name suffix]}]
{:object-id object-id
:type type
:scale (or scale 1)
:name name
:suffix suffix})
(defn- run-local-export
[{:keys [job-id kind exports objects background] export-name :name}]
(let [exports (mapv export-entry exports)]
(->> (rx/from @wwasm/module)
(rx/mapcat
(fn [_]
(rx/create
(fn [subs]
(let [done? (volatile! false)
;; Never `rx/error!` here: the main `ask-many!` stream would
;; hang because worker error replies used to drop `:error`
;; and never send `completed`. Emit a terminal message instead.
finish!
(fn [result cause]
(when-not @done?
(vreset! done? true)
(try (wasm.api/clear-canvas) (catch :default _))
(wwasm/clear-cancel job-id)
(cond
(or (wwasm/cancelled? job-id)
(= :export-cancelled (:type (ex-data cause))))
(do
(rx/push! subs {:type :cancelled})
(rx/end! subs))
cause
(do
(rx/push! subs {:type :error
:hint (ex-message cause)
:data (ex-data cause)})
(rx/end! subs))
:else
(let [entry (first exports)
filename (case kind
:pdf-frames
(str (or export-name "export") ".pdf")
:single
(wasm.exports/sanitize-filename entry #{})
(str (or export-name "export") ".zip"))
mtype (case kind
:pdf-frames "application/pdf"
:single (export-mtype (:type entry))
"application/zip")]
(rx/push! subs {:type :done
:filename filename
:mtype mtype
:blob result})
(rx/end! subs)))))
on-progress
(fn [done total]
(rx/push! subs {:type :progress
:done done
:total total}))
start-export!
(fn []
;; `:on-render` runs after process-pending (images +
;; fonts). `:on-shapes-ready` fires earlier and would
;; export empty image fills / missing typefaces.
(when (wwasm/cancelled? job-id)
(finish! nil (export-cancelled)))
(when-not @done?
(try
(let [work (case kind
:pdf-frames
(build-frames-pdf exports job-id on-progress)
:single
(build-single exports job-id on-progress)
(build-zip exports job-id on-progress))]
(->> work
(p/fnly (fn [blob cause]
(finish! blob cause)))))
(catch :default cause
(finish! nil cause)))))
canvas (js/OffscreenCanvas. canvas-size canvas-size)
init? (wasm.api/init-canvas-context canvas)]
(cond
(not init?)
(finish! nil (ex-info "failed to init worker wasm canvas"
{:type :internal
:code :wasm-canvas-init-failed}))
(not (wasm/live?))
(finish! nil (ex-info "worker wasm context is not live"
{:type :internal
:code :wasm-not-live}))
(empty? objects)
(finish! nil (ex-info "no shapes to export"
{:type :validation
:code :empty-export-objects}))
(wwasm/cancelled? job-id)
(finish! nil (export-cancelled))
:else
(try
(let [bgcolor (or background cc/canvas)
vbox {:x 0 :y 0 :width canvas-size :height canvas-size}]
(reset! wasm/disable-request-render? true)
(wasm.api/initialize-viewport
objects 1 vbox
:background bgcolor
:force-sync true
:on-render start-export!))
(catch :default cause
(finish! nil cause))))
(fn []
(wwasm/cancel-job job-id)
(try (wasm.api/pdf-export-abort!) (catch :default _))
(try (wasm.api/clear-canvas) (catch :default _)))))))))))
(defmethod impl/handler :exports/run-local
[message _]
(let [job-id (or (:job-id message) (uuid/next))
msg (assoc message :job-id job-id)]
(wwasm/submit #(run-local-export msg))))
(defmethod impl/handler :exports/cancel
[{:keys [job-id]} _]
(when job-id
(wwasm/cancel-job job-id))
nil)

View File

@ -11,26 +11,62 @@
[app.common.transit :as t]
[app.util.object :as obj]))
(defn encode [{:keys [sender-id reply-to payload buffer?] :as message}]
#js {:cmd (d/name (:cmd payload))
:senderId (when sender-id (str sender-id))
:replyTo (when reply-to (str reply-to))
:payload (if (= :initialize-indices (:cmd payload))
(:file-raw payload)
(when (some? payload) (t/encode-str payload)))
:buffer (when (some? buffer?) buffer?)})
(defn encode
"Serializes a worker message for `postMessage`.
Transit cannot carry `js/Blob`. If the payload map has `:blob`, or the
message already has a top-level `:blob`, that value is sent as a
structured-clone field beside the Transit string — not inside it."
[{:keys [sender-id reply-to payload buffer? blob completed error]}]
(let [;; Peel `:blob` out of map payloads before Transit (selection/snap
;; replies are vectors and other non-maps; leave them untouched).
inline-blob (when (map? payload) (:blob payload))
blob (or blob inline-blob)
payload (cond-> payload
(some? inline-blob)
(dissoc :blob))
obj #js {:cmd (d/name (:cmd payload))
:senderId (when sender-id (str sender-id))
:replyTo (when reply-to (str reply-to))
:payload (if (= :initialize-indices (:cmd payload))
(:file-raw payload)
(when (some? payload) (t/encode-str payload)))
:buffer (when (some? buffer?) buffer?)
:completed (when completed true)}]
(when (some? blob)
(obj/set! obj "blob" blob))
(when (some? error)
(obj/set! obj "error" (clj->js error)))
obj))
(defn- js->error-map
[^js err]
(when err
(let [m (js->clj err :keywordize-keys true)]
(cond-> m
(string? (:data m))
(assoc :data nil)))))
(defn decode [^js data]
(let [cmd (obj/get data "cmd")
sender-id (obj/get data "senderId")
reply-to (obj/get data "replyTo")
payload (obj/get data "payload")
buffer (obj/get data "buffer")]
(d/without-nils
{:sender-id (when sender-id (uuid sender-id))
:reply-to (when reply-to (uuid reply-to))
:payload (if (= cmd "initialize-indices")
{:cmd :initialize-indices
:file-raw payload}
(when (some? payload) (t/decode-str payload)))
:buffer? buffer})))
buffer (obj/get data "buffer")
blob (obj/get data "blob")
completed (obj/get data "completed")
error (obj/get data "error")
decoded
(d/without-nils
{:sender-id (when sender-id (uuid sender-id))
:reply-to (when reply-to (uuid reply-to))
:payload (if (= cmd "initialize-indices")
{:cmd :initialize-indices
:file-raw payload}
(when (some? payload) (t/decode-str payload)))
:buffer? buffer
:completed completed
:error (js->error-map error)})]
(cond-> decoded
(some? blob)
(update :payload assoc :blob blob))))

View File

@ -14,13 +14,13 @@
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as cc]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
[app.main.render :as render]
[app.render-wasm.api :as wasm.api]
[app.util.http :as http]
[app.worker.impl :as impl]
[app.worker.wasm :as wwasm]
[beicon.v2.core :as rx]
[okulary.core :as l]
[promesa.core :as p]
@ -138,23 +138,12 @@
:height height
:background background}))))))
(defonce ^:private wasm-module
(delay
(let [module (unchecked-get js/globalThis "WasmModule")
init-fn (unchecked-get module "default")
href (cf/resolve-href "js/render-wasm.wasm")]
(->> (init-fn #js {:locateFile (constantly href)})
(p/fnly (fn [module cause]
(if cause
(js/console.error cause)
(set! wasm/internal-module module))))))))
(defn- render-thumbnail-with-wasm
[{:keys [id file-id revn width] :as message}]
(->> (rx/from @wasm-module)
[{:keys [file-id revn width] :as _message}]
(->> (rx/from @wwasm/module)
(rx/mapcat #(request-data-for-thumbnail file-id revn false))
(rx/mapcat
(fn [{:keys [page] :as file}]
(fn [{:keys [page] :as _file}]
(rx/create
(fn [subs]
(let [bgcolor (or (:background page) cc/canvas)
@ -187,34 +176,13 @@
(if cause
(rx/error! subs cause)
(rx/push! subs
{:id id
:data data
{:data data
:file-id file-id
:revn revn}))
(rx/end! subs)))))))
(rx/end! subs))
nil)))))))
(defonce ^:private
thumbnails-queue
(rx/subject))
(defonce ^:private
thumbnails-stream
(->> thumbnails-queue
(rx/mapcat render-thumbnail-with-wasm)
(rx/share)))
(defmethod impl/handler :thumbnails/generate-for-file-wasm
[message _]
(rx/create
(fn [subs]
(let [id (uuid/next)
sid (->> thumbnails-stream
(rx/filter #(= id (:id %)))
(rx/subs!
(fn [result]
(rx/push! subs result)
(rx/end! subs))))]
(rx/push! thumbnails-queue (assoc message :id id))
#(rx/dispose! sid)))))
(wwasm/submit #(render-thumbnail-with-wasm message)))

View File

@ -0,0 +1,87 @@
;; 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 SUBSIDIARY SL
(ns app.worker.wasm
"Shared render-wasm loader and serial work queue for the web worker.
Thumbnails and local multi-export share one WASM module instance. Jobs that
touch that module (initialize-viewport, render, clear-canvas) must run through
`submit` so they never interleave."
(:require
[app.common.render-wasm.wasm :as wasm]
[app.config :as cf]
[beicon.v2.core :as rx]
[promesa.core :as p]))
;; Delay that resolves to the worker's WasmModule instance.
(defonce module
(delay
(let [module (unchecked-get js/globalThis "WasmModule")
init-fn (unchecked-get module "default")
href (cf/resolve-href "js/render-wasm.wasm")]
(->> (init-fn #js {:locateFile (constantly href)})
(p/fnly (fn [module cause]
(if cause
(js/console.error "worker wasm module init failed" cause)
(set! wasm/internal-module module))))))))
(defonce ^:private work-queue
(rx/subject))
(defonce ^:private cancelled-jobs*
(atom #{}))
(defonce ^:private executor-sub*
(atom nil))
(defn- ensure-executor!
"Starts the serial drain once. Must be reachable from `submit` so release
builds cannot DCE the subscription (a lone unused `defonce` is removed)."
[]
(when (nil? @executor-sub*)
(reset! executor-sub*
(->> work-queue
(rx/mapcat
(fn [{:keys [bus work]}]
(->> (work)
(rx/tap (fn [value]
(rx/push! bus value)))
(rx/catch (fn [cause]
(rx/error! bus cause)
(rx/empty)))
(rx/finalize
(fn []
(rx/end! bus))))))
(rx/subs!
(fn [_])
(fn [cause]
(js/console.error "worker wasm queue error" cause)))))))
(defn cancel-job
"Marks `job-id` so an in-flight export stops between items."
[job-id]
(swap! cancelled-jobs* conj job-id)
nil)
(defn cancelled?
[job-id]
(contains? @cancelled-jobs* job-id))
(defn clear-cancel
[job-id]
(swap! cancelled-jobs* disj job-id))
(defn submit
"Runs `work` (zero-arg → observable) on the serial WASM queue.
Returns a subject that mirrors `work` emissions and errors.
Pushes onto the queue immediately (does not wait for a subscriber) so the
job starts even if the caller is slow to subscribe."
[work]
(ensure-executor!)
(let [bus (rx/subject)]
(rx/push! work-queue {:bus bus :work work})
bus))

View File

@ -8,12 +8,16 @@
(:require
[app.common.uuid :as uuid]
[app.main.data.exports.assets :as de]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.persistence :as dwp]
[app.main.errors :as errors]
[app.main.repo :as repo]
[app.main.store :as st]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.i18n :as i18n]
[app.util.webapi :as wapi]
[app.util.websocket :as ws]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
@ -21,7 +25,6 @@
[frontend-tests.helpers.events :as the]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(def ^:private export {:id (uuid/next)
:object-id (uuid/next)
:type :png
@ -37,6 +40,17 @@
{:profile-id (:id export)
:ws-conn nil})
(defn- wasm-state
[]
(assoc (test-state) :features #{"render-wasm/v1"}))
(t/deftest sanitize-filename-strips-reserved-chars-and-dedupes
(t/is (= "a_b.png"
(wasm.exports/sanitize-filename {:name "a/b" :suffix "" :type :png} #{})))
(t/is (= "board-2.pdf"
(wasm.exports/sanitize-filename {:name "board" :suffix "" :type :pdf}
#{"board.pdf"}))))
(t/deftest normalize-export-preserves-existing-name
(t/is (= (export-with-name "Layer 1")
(de/normalize-export (export-with-name "Layer 1")))))
@ -203,3 +217,240 @@
(await (async/wait-for #(true? @downloaded) "the export runs"))
(await (async/settle))
(t/is (nil? (:notification @store)))))))
;; Scenario: WASM is on and the user exports one shape. Proves: the worker
;; builds a single blob (no ZIP); the exporter is not called.
;; Stub `export-target`: without a module it is nil; routing still stays
;; local (never the server API under wasm).
(t/deftest ^:async wasm-simple-export-stays-local
(let [cmd-calls (atom [])
worker-kind (atom nil)
downloaded (atom nil)
store (export-store (wasm-state) (atom []))
page-id (uuid/next)
file-id (uuid/next)
one (assoc export :enabled true :name "layer"
:page-id page-id :file-id file-id)]
(await
(mock/with-mocks*
{repo/cmd! (mock/stub (fn [cmd params]
(swap! cmd-calls conj [cmd params])
(rx/throw (ex-info "exporter must not run" {}))))
wasm.api/export-target (mock/stub (constantly :client))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [msg]
(reset! worker-kind (:kind msg))
(rx/of {:type :done
:filename "layer.png"
:mtype "image/png"
:blob (js/Blob.)})))
wapi/create-uri (mock/stub (constantly "blob:png"))
dom/trigger-download-uri (mock/stub (fn [filename mtype uri]
(reset! downloaded [filename mtype uri])))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-simple-export {:export one}))
(await (async/wait-for #(some? @downloaded) "the file downloads"))
(t/is (empty? @cmd-calls) "no exporter RPC")
(t/is (= :single @worker-kind))
(t/is (= ["layer.png" "image/png" "blob:png"] @downloaded))))))
;; Scenario: WASM says the raster is too big for the GPU (legacy `:exporter`
;; code). Proves: still local worker download, no server RPC — CPU raster
;; runs inside the worker via `export-bytes`.
(t/deftest ^:async wasm-oversized-for-gpu-stays-local
(let [cmd-calls (atom [])
worker-kind (atom nil)
downloaded (atom nil)
store (export-store (wasm-state) (atom []))
one (assoc export :enabled true :name "huge")]
(await
(mock/with-mocks*
{repo/cmd! (mock/stub (fn [cmd params]
(swap! cmd-calls conj [cmd params])
(rx/throw (ex-info "exporter must not run" {}))))
wasm.api/export-target (mock/stub (constantly :exporter))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [msg]
(reset! worker-kind (:kind msg))
(rx/of {:type :done
:filename "huge.png"
:mtype "image/png"
:blob (js/Blob.)})))
wapi/create-uri (mock/stub (constantly "blob:png"))
dom/trigger-download-uri (mock/stub (fn [filename mtype uri]
(reset! downloaded [filename mtype uri])))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-simple-export {:export one}))
(await (async/wait-for #(some? @downloaded) "the file downloads"))
(t/is (empty? @cmd-calls) "no exporter RPC")
(t/is (= :single @worker-kind))
(t/is (= ["huge.png" "image/png" "blob:png"] @downloaded))))))
;; Scenario: WASM reports true `:too-large` (beyond raster limits). Proves:
;; toast and no RPC — the exporter would fail the same way.
(t/deftest ^:async wasm-true-too-large-shows-toast
(let [cmd-calls (atom [])
store (export-store (wasm-state) (atom []))
one (assoc export :enabled true :name "impossible")]
(await
(mock/with-mocks*
{repo/cmd! (mock/stub (fn [cmd params]
(swap! cmd-calls conj [cmd params])
(rx/throw (ex-info "exporter must not run" {}))))
wasm.api/export-target (mock/stub (constantly :too-large))
i18n/tr (mock/stub #(str "translated:" %))}
(ptk/emit! store (de/request-simple-export {:export one}))
(await (async/wait-for #(some? (:notification @store)) "the toast shows"))
(t/is (empty? @cmd-calls) "no exporter RPC")
(t/is (= "translated:errors.export-too-large"
(get-in @store [:notification :content])))
(t/is (= :error (get-in @store [:notification :level])))))))
;; Scenario: WASM is on and the user exports several shapes. Proves: nothing
;; hits the exporter; the worker builds a ZIP and the client downloads it.
(t/deftest ^:async wasm-multiple-export-stays-local
(let [cmd-calls (atom [])
worker-cmd (atom nil)
downloaded (atom nil)
store (export-store (wasm-state) (atom []))
page-id (uuid/next)
file-id (uuid/next)
exports [(assoc export :enabled true :name "a" :page-id page-id :file-id file-id)
(assoc export :id (uuid/next)
:object-id (uuid/next)
:page-id page-id
:file-id file-id
:enabled true
:name "b")]]
(await
(mock/with-mocks*
{repo/cmd! (mock/stub (fn [cmd params]
(swap! cmd-calls conj [cmd params])
(rx/throw (ex-info "exporter must not run" {}))))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [msg]
(reset! worker-cmd (:cmd msg))
(rx/of {:type :done
:filename "page.zip"
:mtype "application/zip"
:blob (js/Blob.)})))
wapi/create-uri (mock/stub (constantly "blob:zip"))
dom/trigger-download-uri (mock/stub (fn [filename mtype uri]
(reset! downloaded [filename mtype uri])))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-multiple-export {:exports exports :name "page"}))
(await (async/wait-for #(some? @downloaded) "the zip downloads"))
(t/is (empty? @cmd-calls) "no exporter RPC")
(t/is (= :exports/run-local @worker-cmd))
(t/is (= ["page.zip" "application/zip" "blob:zip"] @downloaded))))))
;; Scenario: WASM is on and the user exports boards as PDF. Proves: worker
;; multi-page PDF, no exporter call.
(t/deftest ^:async wasm-export-frames-stays-local
(let [cmd-calls (atom [])
worker-kind (atom nil)
downloaded (atom nil)
store (export-store (wasm-state) (atom []))
page-id (uuid/next)
file-id (uuid/next)
exports [(assoc export :enabled true :name "board-1"
:page-id page-id :file-id file-id)
(assoc export :id (uuid/next)
:object-id (uuid/next)
:page-id page-id
:file-id file-id
:enabled true
:name "board-2")]]
(await
(mock/with-mocks*
{repo/cmd! (mock/stub (fn [cmd params]
(swap! cmd-calls conj [cmd params])
(rx/throw (ex-info "exporter must not run" {}))))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [msg]
(reset! worker-kind (:kind msg))
(rx/of {:type :progress :done 1 :total 2}
{:type :progress :done 2 :total 2}
{:type :done
:filename "page.pdf"
:mtype "application/pdf"
:blob (js/Blob.)})))
wapi/create-uri (mock/stub (constantly "blob:pdf"))
dom/trigger-download-uri (mock/stub (fn [filename mtype uri]
(reset! downloaded [filename mtype uri])))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-multiple-export {:exports exports
:cmd :export-frames
:name "page"}))
(await (async/wait-for #(some? @downloaded) "the pdf downloads"))
(t/is (empty? @cmd-calls) "no exporter RPC")
(t/is (= :pdf-frames @worker-kind))
(t/is (= ["page.pdf" "application/pdf" "blob:pdf"] @downloaded))))))
;; Scenario: the user cancels a local WASM multi-export. Proves: the widget
;; stays cancelled and late worker :progress/:done do not download or flip
;; status back to running.
(t/deftest ^:async wasm-local-cancel-ignores-late-worker-messages
(let [downloaded (atom nil)
cancel-cmds (atom [])
worker-bus (rx/subject)
store (export-store (wasm-state) (atom []))
page-id (uuid/next)
file-id (uuid/next)
exports [(assoc export :enabled true :name "a"
:page-id page-id :file-id file-id)
(assoc export :id (uuid/next)
:object-id (uuid/next)
:page-id page-id
:file-id file-id
:enabled true
:name "b")]]
(await
(mock/with-mocks*
{repo/cmd! (mock/stub (fn [_ _]
(rx/throw (ex-info "exporter must not run" {}))))
de/collect-export-objects (mock/stub (fn [_ _] {}))
mw/ask-many! (mock/stub (fn [_] worker-bus))
mw/emit! (mock/stub (fn [msg]
(swap! cancel-cmds conj msg)))
wapi/create-uri (mock/stub (constantly "blob:zip"))
dom/trigger-download-uri (mock/stub (fn [filename mtype uri]
(reset! downloaded [filename mtype uri])))
st/ongoing-tasks (atom #{})}
(ptk/emit! store (de/request-multiple-export {:exports exports :name "page"}))
(await (async/wait-for #(= "running" (get-in @store [:export :status]))
"export is running"))
(rx/push! worker-bus {:type :progress :done 1 :total 2})
(await (async/wait-for #(= 1 (get-in @store [:export :progress]))
"first progress lands"))
(ptk/emit! store (de/cancel-export))
(await (async/wait-for #(= "cancelled" (get-in @store [:export :status]))
"export is cancelled"))
(rx/push! worker-bus {:type :progress :done 2 :total 2})
(rx/push! worker-bus {:type :done
:filename "page.zip"
:mtype "application/zip"
:blob (js/Blob.)})
(await (async/settle))
(t/is (= "cancelled" (get-in @store [:export :status])))
(t/is (nil? @downloaded) "late :done does not download")
(t/is (= :exports/cancel (:cmd (first @cancel-cmds))))
(rx/end! worker-bus)))))
(t/deftest collect-export-objects-keeps-only-roots-and-descendants
(let [page-id (uuid/next)
file-id (uuid/next)
root (uuid/next)
child (uuid/next)
other (uuid/next)
objects {root {:id root :parent-id uuid/zero :shapes [child]}
child {:id child :parent-id root :shapes []}
other {:id other :parent-id uuid/zero :shapes []}}
state {:files {file-id {:id file-id
:data {:pages-index
{page-id {:id page-id
:objects objects}}}}}}
exports [{:object-id root :page-id page-id :file-id file-id}]
selected (de/collect-export-objects state exports)]
(t/is (= #{root child} (set (keys selected))))
(t/is (not (contains? selected other)))))

View File

@ -153,6 +153,7 @@
[frontend-tests.util-zip-test]
[frontend-tests.util.dom.dnd-test]
[frontend-tests.worker-snap-test]
[frontend-tests.worker.exports-test]
[goog.object :as gobj]))
(enable-console-print!)
@ -186,6 +187,7 @@
'frontend-tests.data.exports-files-test
'frontend-tests.data.imports-test
'frontend-tests.data.jobs-test
'frontend-tests.worker.exports-test
'frontend-tests.data.svg-upload-test
'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test

View File

@ -0,0 +1,70 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.worker.exports-test
(:require
[app.common.uuid :as uuid]
[app.worker.wasm :as wwasm]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]))
;; Record order inside `work`: `submit` starts before any subscriber, so
;; tapping the returned bus can miss values. Plain Promise (not promesa):
;; `await` + promesa crashed the full suite in Node.
(t/deftest ^:async submit-runs-jobs-serially
(let [order (atom [])]
(await
(js/Promise.
(fn [resolve reject]
(let [timer (js/setTimeout
#(reject (js/Error. "submit queue timed out"))
2000)]
(wwasm/submit
(fn []
(swap! order conj :a-start)
(rx/concat
(rx/of :a-start)
(->> (rx/of :a-end)
(rx/delay 20)
(rx/tap #(swap! order conj :a-end))))))
(wwasm/submit
(fn []
(swap! order conj
{:b-sees-a-start (boolean (some #{:a-start} @order))
:b-sees-a-end (boolean (some #{:a-end} @order))})
(swap! order conj :b-end)
(->> (rx/of :b-end)
(rx/finalize
(fn []
(js/clearTimeout timer)
(resolve nil))))))))))
(t/is (= [:a-start :a-end
{:b-sees-a-start true :b-sees-a-end true}
:b-end]
@order)
"job B starts only after job A has finished")))
(t/deftest cancel-job-is-visible-to-cancelled
(let [job-id (uuid/next)]
(t/is (false? (wwasm/cancelled? job-id)))
(wwasm/cancel-job job-id)
(t/is (true? (wwasm/cancelled? job-id)))
(wwasm/clear-cancel job-id)
(t/is (false? (wwasm/cancelled? job-id)))))
;; After a macrotask yield (as between PDF pages), `cancelled?` still holds.
(t/deftest ^:async cancel-is-honored-after-async-yield
(let [job-id (uuid/next)
steps (atom [])]
(swap! steps conj :page-1)
(wwasm/cancel-job job-id)
(await (async/settle))
(if (wwasm/cancelled? job-id)
(swap! steps conj :stopped)
(swap! steps conj :page-2))
(t/is (= [:page-1 :stopped] @steps))
(wwasm/clear-cancel job-id)))

View File

@ -859,6 +859,69 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32) -> Result<*mu
})
}
/// Multi-page PDF: shape UUIDs are read from the transfer buffer (16 bytes
/// each, same layout as `set_focus_mode`). Returns `[len u32 LE][pdf bytes]`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_shapes_pdf() -> Result<*mut u8> {
let bytes = mem::bytes();
let ids: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string())))
.collect::<Result<Vec<Uuid>>>()?;
with_state!(state, {
let data = state.render_shapes_pdf(&ids)?;
let len = data.len() as u32;
let mut buf = Vec::with_capacity(4 + data.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&data);
Ok(mem::write_bytes(buf))
})
}
/// Starts a progressive multi-page PDF session (see `pdf_export_add_page` /
/// `pdf_export_finish`). Replaces any previous session.
#[no_mangle]
#[wasm_error]
pub extern "C" fn pdf_export_begin() -> Result<()> {
with_state!(state, { state.pdf_export_begin() })
}
/// Appends one shape as a page of the current PDF export session.
#[no_mangle]
#[wasm_error]
pub extern "C" fn pdf_export_add_page(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
let id = uuid_from_u32_quartet(a, b, c, d);
with_state!(state, { state.pdf_export_add_page(&id) })
}
/// Finishes the PDF export session and returns `[len u32 LE][pdf bytes]`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn pdf_export_finish() -> Result<*mut u8> {
with_state!(state, {
let data = state.pdf_export_finish()?;
let len = data.len() as u32;
let mut buf = Vec::with_capacity(4 + data.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&data);
Ok(mem::write_bytes(buf))
})
}
/// Aborts the current PDF export session, if any.
#[no_mangle]
#[wasm_error]
pub extern "C" fn pdf_export_abort() -> Result<()> {
with_state!(state, {
state.pdf_export_abort();
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> {

View File

@ -1,6 +1,8 @@
use std::mem::ManuallyDrop;
use skia_safe as skia;
use crate::error::Result;
use crate::error::{Error, Result};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
@ -10,50 +12,291 @@ use super::RenderResources;
mod glyph_text;
mod to_unicode;
/// Renders a shape tree to a PDF document and returns the raw PDF bytes.
///
/// This is a dedicated vector-PDF render path that draws directly to a Skia
/// PDF canvas, bypassing the GPU surface system entirely. The result is a
/// true vector PDF — paths, text and fills are represented as PDF drawing
/// operations rather than rasterised bitmaps. Effects that are inherently
/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's
/// PDF backend
/// In-flight multi-page PDF. The byte buffer and metadata are heap-pinned so
/// Skia's `Document` can hold `'static` write/metadata references across FFI
/// calls (skia-safe ties the document lifetime to both). Stored on
/// [`crate::state::State`], not a process-wide static.
pub(crate) struct PdfExportSession {
bytes: *mut Vec<u8>,
metadata: *mut skia::pdf::Metadata<'static>,
document: ManuallyDrop<skia::Document<'static>>,
roots: Vec<Uuid>,
}
fn pdf_metadata() -> skia::pdf::Metadata<'static> {
skia::pdf::Metadata {
creator: "Penpot".to_string(),
producer: "Penpot (Skia PDF)".to_string(),
raster_dpi: Some(300.0),
..Default::default()
}
}
/// Starts a multi-page PDF session. Aborts any previous session in `slot` first.
pub fn export_begin(slot: &mut Option<PdfExportSession>) -> Result<()> {
export_abort(slot);
let bytes = Box::into_raw(Box::new(Vec::new()));
let metadata = Box::into_raw(Box::new(pdf_metadata()));
// SAFETY: `bytes` and `metadata` stay pinned until finish/abort. Document
// writes through `&mut Vec<u8>`; reallocating the Vec's heap buffer is
// fine because the reference targets the Vec, not its buffer.
let document = unsafe {
let bytes_ref: &'static mut Vec<u8> = &mut *bytes;
let metadata_ref: &'static skia::pdf::Metadata<'static> = &*metadata;
skia::pdf::new_document(bytes_ref, Some(metadata_ref))
};
*slot = Some(PdfExportSession {
bytes,
metadata,
document: ManuallyDrop::new(document),
roots: Vec::new(),
});
Ok(())
}
/// Appends one shape as a new page. Call between `export_begin` and
/// `export_finish`.
pub fn export_add_page(
slot: &mut Option<PdfExportSession>,
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
) -> Result<()> {
let session = slot
.as_mut()
.ok_or_else(|| Error::CriticalError("PDF export session not started".to_string()))?;
let shape = tree
.get(id)
.ok_or_else(|| Error::CriticalError(format!("Shape not found for PDF: {id}")))?;
// Boards crop overflowing children (classic board box) but keep their own
// strokes/shadows/blur. Other shapes use full extrect.
let bounds = shape.export_root_bounds(tree, 1.0);
// SAFETY: document was created against session.bytes; we put it back.
let document = unsafe { ManuallyDrop::take(&mut session.document) };
let mut on_page = document.begin_page((bounds.width(), bounds.height()), None);
{
let page_canvas = on_page.canvas();
page_canvas.translate((-bounds.left(), -bounds.top()));
if let Err(err) = vector::render_tree_pdf(shared, page_canvas, id, tree, bounds) {
// Restore the document so abort/finish can still run; do not
// record this root — the page content failed.
let document = on_page.end_page();
session.document = ManuallyDrop::new(document);
return Err(err);
}
}
let document = on_page.end_page();
session.document = ManuallyDrop::new(document);
session.roots.push(*id);
Ok(())
}
fn drop_session_buffers(bytes: *mut Vec<u8>, metadata: *mut skia::pdf::Metadata<'static>) {
unsafe {
drop(Box::from_raw(metadata));
drop(Box::from_raw(bytes));
}
}
/// Closes the session and returns the PDF bytes (with ToUnicode fixup).
pub fn export_finish(slot: &mut Option<PdfExportSession>, tree: ShapesPoolRef) -> Result<Vec<u8>> {
let session = slot
.take()
.ok_or_else(|| Error::CriticalError("PDF export session not started".to_string()))?;
if session.roots.is_empty() {
let document = ManuallyDrop::into_inner(session.document);
document.close();
drop_session_buffers(session.bytes, session.metadata);
return Err(Error::CriticalError(
"No shapes for multi-page PDF".to_string(),
));
}
let roots = session.roots;
let document = ManuallyDrop::into_inner(session.document);
document.close();
let pdf_bytes = unsafe { *Box::from_raw(session.bytes) };
unsafe {
drop(Box::from_raw(session.metadata));
}
// Skia maps ligatures and other substituted glyphs to U+0000; give them
// back their text so it can be copied from the PDF.
let glyph_texts = glyph_text::collect_many(tree, &roots);
Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts))
}
/// Drops an in-flight session without returning bytes.
pub fn export_abort(slot: &mut Option<PdfExportSession>) {
let Some(session) = slot.take() else {
return;
};
let document = ManuallyDrop::into_inner(session.document);
document.close();
drop_session_buffers(session.bytes, session.metadata);
}
/// Renders a shape tree to a single-page PDF and returns the raw PDF bytes.
pub fn render_to_pdf(
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
) -> Result<Vec<u8>> {
let shape = tree
.get(id)
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?;
// Boards crop overflowing children (classic board box) but keep their own
// strokes/shadows/blur. Other shapes use full extrect.
let bounds = shape.export_root_bounds(tree, 1.0);
render_to_pdf_multi(shared, &[*id], tree)
}
let mut pdf_bytes: Vec<u8> = Vec::new();
/// Renders each shape root as a page of one PDF document, in `ids` order.
/// Uses a local session so it does not disturb a progressive export in
/// [`crate::state::State`].
pub fn render_to_pdf_multi(
shared: &mut RenderResources,
ids: &[Uuid],
tree: ShapesPoolRef,
) -> Result<Vec<u8>> {
if ids.is_empty() {
return Err(Error::CriticalError(
"No shapes for multi-page PDF".to_string(),
));
}
let mut session = None;
export_begin(&mut session)?;
for id in ids {
if let Err(err) = export_add_page(&mut session, shared, id, tree) {
export_abort(&mut session);
return Err(err);
}
}
match export_finish(&mut session, tree) {
Ok(bytes) => Ok(bytes),
Err(err) => {
export_abort(&mut session);
Err(err)
}
}
}
let metadata = skia::pdf::Metadata {
creator: "Penpot".to_string(),
producer: "Penpot (Skia PDF)".to_string(),
raster_dpi: Some(300.0),
..Default::default()
};
#[cfg(test)]
mod tests {
use super::*;
let document = skia::pdf::new_document(&mut pdf_bytes, Some(&metadata));
use crate::shapes::{Fill, Rect, SolidColor, Type};
use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
let mut on_page = document.begin_page((bounds.width(), bounds.height()), None);
use skia_safe as skia;
{
let page_canvas = on_page.canvas();
page_canvas.translate((-bounds.left(), -bounds.top()));
vector::render_tree_pdf(shared, page_canvas, id, tree, bounds)?;
fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
let document = on_page.end_page();
document.close();
fn add_solid_rect(
pool: &mut ShapesPool,
id: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
) {
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
}
// Skia maps ligatures and other substituted glyphs to U+0000; give them
// back their text so it can be copied from the PDF.
let glyph_texts = glyph_text::collect(tree, id);
Ok(to_unicode::fix_to_unicode(pdf_bytes, &glyph_texts))
/// Counts page objects: `/Type /Page` entries that are not `/Pages`.
fn page_count(pdf: &[u8]) -> usize {
let text = String::from_utf8_lossy(pdf);
text.match_indices("/Type /Page")
.filter(|(i, _)| !text[*i..].starts_with("/Type /Pages"))
.count()
}
#[test]
fn multi_page_pdf_has_one_page_per_shape() {
let mut pool = ShapesPool::new();
let a = uid(1);
let b = uid(2);
add_solid_rect(
&mut pool,
a,
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
add_solid_rect(
&mut pool,
b,
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(0, 0, 255),
);
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
let pdf = render_to_pdf_multi(&mut resources, &[a, b], &pool).expect("pdf");
assert!(pdf.starts_with(b"%PDF"), "missing PDF header");
assert_eq!(page_count(&pdf), 2, "expected two pages");
}
#[test]
fn session_api_reports_progress_sized_pages() {
let mut pool = ShapesPool::new();
let a = uid(1);
let b = uid(2);
add_solid_rect(
&mut pool,
a,
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
add_solid_rect(
&mut pool,
b,
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(0, 0, 255),
);
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
let mut session = None;
export_begin(&mut session).expect("begin");
export_add_page(&mut session, &mut resources, &a, &pool).expect("page a");
export_add_page(&mut session, &mut resources, &b, &pool).expect("page b");
let pdf = export_finish(&mut session, &pool).expect("finish");
assert_eq!(page_count(&pdf), 2);
}
#[test]
fn empty_id_list_is_an_error() {
let pool = ShapesPool::new();
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
let err = render_to_pdf_multi(&mut resources, &[], &pool).expect_err("empty");
assert!(
matches!(err, Error::CriticalError(_)),
"empty ids must fail critically: {err:?}"
);
}
#[test]
fn single_page_wrapper_still_works() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
(0.0, 0.0, 50.0, 50.0),
skia::Color::from_rgb(0, 128, 0),
);
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
let pdf = render_to_pdf(&mut resources, &id, &pool).expect("pdf");
assert!(pdf.starts_with(b"%PDF"));
assert_eq!(page_count(&pdf), 1);
}
}

View File

@ -197,6 +197,25 @@ pub fn collect(tree: ShapesPoolRef, root: &Uuid) -> GlyphTexts {
glyph_texts
}
/// Merges glyph texts from every root in `ids` (export-frames / multi PDF).
pub fn collect_many(tree: ShapesPoolRef, ids: &[Uuid]) -> GlyphTexts {
let mut result = GlyphTexts::default();
for id in ids {
result.merge(collect(tree, id));
}
result
}
impl GlyphTexts {
fn merge(&mut self, other: GlyphTexts) {
for (font, glyphs) in other.fonts {
for (glyph, value) in glyphs {
self.insert(&font, glyph, value);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -28,6 +28,8 @@ pub(crate) struct State {
pub saved_shapes: Option<ShapesPool>,
/// True while the first bulk load of shapes is in progress.
pub loading: bool,
/// Progressive multi-page PDF export (begin / add-page / finish).
pub pdf_export: Option<crate::render::pdf::PdfExportSession>,
}
impl State {
@ -38,6 +40,7 @@ impl State {
shapes: ShapesPool::new(),
saved_shapes: None,
loading: false,
pdf_export: None,
}
}
@ -103,6 +106,26 @@ impl State {
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes)
}
pub fn render_shapes_pdf(&mut self, ids: &[Uuid]) -> Result<Vec<u8>> {
crate::render::pdf::render_to_pdf_multi(get_resources(), ids, &self.shapes)
}
pub fn pdf_export_begin(&mut self) -> Result<()> {
crate::render::pdf::export_begin(&mut self.pdf_export)
}
pub fn pdf_export_add_page(&mut self, id: &Uuid) -> Result<()> {
crate::render::pdf::export_add_page(&mut self.pdf_export, get_resources(), id, &self.shapes)
}
pub fn pdf_export_finish(&mut self) -> Result<Vec<u8>> {
crate::render::pdf::export_finish(&mut self.pdf_export, &self.shapes)
}
pub fn pdf_export_abort(&mut self) {
crate::render::pdf::export_abort(&mut self.pdf_export)
}
pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale)
}