From e27c65c77a9430891469b462472483bddc8b62b3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 23 Oct 2026 11:02:23 +0200 Subject: [PATCH] :sparkles: 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. --- .../src/app/main/data/exports/assets.cljs | 377 ++++++++++++------ frontend/src/app/main/data/exports/wasm.cljs | 88 +++- frontend/src/app/main/ui/exports/assets.cljs | 13 +- frontend/src/app/render_wasm/api.cljs | 114 +++++- frontend/src/app/worker.cljs | 1 + frontend/src/app/worker/exports.cljs | 260 ++++++++++++ frontend/src/app/worker/messages.cljs | 70 +++- frontend/src/app/worker/thumbnails.cljs | 44 +- frontend/src/app/worker/wasm.cljs | 87 ++++ .../data/exports_assets_test.cljs | 253 +++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/worker/exports_test.cljs | 70 ++++ render-wasm/src/main.rs | 63 +++ render-wasm/src/render/pdf.rs | 311 +++++++++++++-- render-wasm/src/render/pdf/glyph_text.rs | 19 + render-wasm/src/state.rs | 23 ++ 16 files changed, 1554 insertions(+), 241 deletions(-) create mode 100644 frontend/src/app/worker/exports.cljs create mode 100644 frontend/src/app/worker/wasm.cljs create mode 100644 frontend/test/frontend_tests/worker/exports_test.cljs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 5ca4f3e983..e85c1c3cd3 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -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}] diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 696b8ed716..b23556474a 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index f1be8e32a2..83140cb47c 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b31729caaa..f5af3275dd 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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?) diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 860abcff27..5df69ac365 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -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] diff --git a/frontend/src/app/worker/exports.cljs b/frontend/src/app/worker/exports.cljs new file mode 100644 index 0000000000..fa83b4ef29 --- /dev/null +++ b/frontend/src/app/worker/exports.cljs @@ -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) diff --git a/frontend/src/app/worker/messages.cljs b/frontend/src/app/worker/messages.cljs index 2bb1c6a859..209deb37ef 100644 --- a/frontend/src/app/worker/messages.cljs +++ b/frontend/src/app/worker/messages.cljs @@ -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)))) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index e42a9687b4..074b23c400 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -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))) diff --git a/frontend/src/app/worker/wasm.cljs b/frontend/src/app/worker/wasm.cljs new file mode 100644 index 0000000000..eca6c47071 --- /dev/null +++ b/frontend/src/app/worker/wasm.cljs @@ -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)) diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index a9c9393f25..4673b13411 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -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))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 5dd9553f7d..f2585704e8 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/frontend/test/frontend_tests/worker/exports_test.cljs b/frontend/test/frontend_tests/worker/exports_test.cljs new file mode 100644 index 0000000000..5f1a9f6953 --- /dev/null +++ b/frontend/test/frontend_tests/worker/exports_test.cljs @@ -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))) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 9e590fa86a..0d39d76045 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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 = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + 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> { diff --git a/render-wasm/src/render/pdf.rs b/render-wasm/src/render/pdf.rs index d70f353ef4..746b26242d 100644 --- a/render-wasm/src/render/pdf.rs +++ b/render-wasm/src/render/pdf.rs @@ -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, + metadata: *mut skia::pdf::Metadata<'static>, + document: ManuallyDrop>, + roots: Vec, +} + +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) -> 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`; 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 = &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, + 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, 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, tree: ShapesPoolRef) -> Result> { + 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) { + 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> { - 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 = 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> { + 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); + } } diff --git a/render-wasm/src/render/pdf/glyph_text.rs b/render-wasm/src/render/pdf/glyph_text.rs index d3623bf87f..71ba721343 100644 --- a/render-wasm/src/render/pdf/glyph_text.rs +++ b/render-wasm/src/render/pdf/glyph_text.rs @@ -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::*; diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index a3e3d91296..d3b803b18c 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -28,6 +28,8 @@ pub(crate) struct State { pub saved_shapes: Option, /// 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, } 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> { + 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> { + 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> { crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale) }