mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ 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:
parent
9aa78a536b
commit
e27c65c77a
@ -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}]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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?)
|
||||
|
||||
@ -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]
|
||||
|
||||
260
frontend/src/app/worker/exports.cljs
Normal file
260
frontend/src/app/worker/exports.cljs
Normal 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)
|
||||
@ -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))))
|
||||
|
||||
@ -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)))
|
||||
|
||||
87
frontend/src/app/worker/wasm.cljs
Normal file
87
frontend/src/app/worker/wasm.cljs
Normal 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))
|
||||
@ -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)))))
|
||||
|
||||
@ -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
|
||||
|
||||
70
frontend/test/frontend_tests/worker/exports_test.cljs
Normal file
70
frontend/test/frontend_tests/worker/exports_test.cljs
Normal 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)))
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user