From a1606c42ff52e95ef9f2c1c01fad96402b80186b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 20 Oct 2026 15:37:07 +0200 Subject: [PATCH] :bug: Export bitmaps over the GPU surface limit through the exporter (#12455) --- .../src/app/main/data/exports/assets.cljs | 31 ++++++++++++++----- frontend/src/app/render_wasm/api.cljs | 15 +++++++++ frontend/translations/en.po | 4 +++ frontend/translations/es.po | 4 +++ render-wasm/src/main.rs | 25 +++++++++++++++ render-wasm/src/render/raster.rs | 21 +++++++++++++ 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index fd6d98e010..5ca4f3e983 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -18,6 +18,7 @@ [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] + [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.websocket :as ws] @@ -300,11 +301,18 @@ [state] (features/active-feature? state "render-wasm/v1")) -(defn- use-wasm-export? - "Whether to take the client-side WASM export path for `export`." - [state export] - (and (wasm-export-enabled? state) - (contains? wasm-export-types (:type export)))) +(defn- export-target + [state {:keys [type object-id scale]}] + (cond + (not (and (wasm-export-enabled? state) + (contains? wasm-export-types type))) + :exporter + + (contains? #{:pdf :svg} type) + :client + + :else + (or (wasm.api/export-target object-id (or scale 1) type) :exporter))) (defn- request-simple-export-wasm [export] @@ -318,18 +326,25 @@ (defn request-simple-export [{:keys [export]}] - (let [export (normalize-export export)] + (let [export (normalize-export export) + target (volatile! nil)] (ptk/reify ::request-simple-export ptk/UpdateEvent (update [_ state] + (vreset! target (export-target state export)) (cond-> state - (not (use-wasm-export? state export)) + (= :exporter @target) (update :export assoc :in-progress true :id uuid/zero))) ptk/WatchEvent (watch [_ state _] - (if (use-wasm-export? state export) + (case @target + :client (rx/of (request-simple-export-wasm export)) + + :too-large + (rx/of (ntf/error (tr "errors.export-too-large"))) + (let [profile-id (:profile-id state) params (normalize-export-shapes-params {:exports [export] :profile-id profile-id diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 2227b4155e..7bed759057 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -3009,6 +3009,21 @@ (mem/free) {:x x :y y :width w :height h}))))) +(defn export-target + [shape-id scale format] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id)] + (case (h/call wasm/internal-module "_export_target" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format)) + 0 :client + 2 :too-large + :exporter)))) + (defn render-shape-pdf [shape-id] (when (initialized?) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fe435da917..014f5b622b 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1586,6 +1586,10 @@ msgstr "The email «%s» has been reported as spam or permanently bounce." msgid "errors.export-failed" msgstr "The export failed. Try again in a moment." +#: src/app/main/data/exports/assets.cljs:346 +msgid "errors.export-too-large" +msgstr "Too large to export, try a smaller scale" + #: src/app/main/errors.cljs:974 msgid "errors.feature-mismatch" msgstr "" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index df0f23c90c..6eaf06da2a 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1596,6 +1596,10 @@ msgstr "El email «%s» tiene reportes de spam o de rebote permanente." msgid "errors.export-failed" msgstr "La exportación ha fallado. Inténtalo de nuevo en un momento." +#: src/app/main/data/exports/assets.cljs:346 +msgid "errors.export-too-large" +msgstr "El archivo es demasiado grande, prueba con otra escala" + #: src/app/main/errors.cljs:974 msgid "errors.feature-mismatch" msgstr "" diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 11a8256842..34f3aa8dad 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -806,6 +806,31 @@ pub extern "C" fn render_shape_pixels( }) } +#[no_mangle] +pub extern "C" fn export_target(a: u32, b: u32, c: u32, d: u32, scale: f32, format: u32) -> u32 { + let id = uuid_from_u32_quartet(a, b, c, d); + let Ok(format) = RasterFormat::from_u32(format) else { + return 1; + }; + + with_state!(state, { + let Some(shape) = state.shapes.get(&id) else { + return 1; + }; + let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, scale); + let width = (scale * extrect.width()).ceil() as i32; + let height = (scale * extrect.height()).ceil() as i32; + let max_size = get_gpu_state().max_surface_size(); + if width <= max_size && height <= max_size { + 0 + } else if format.fits_raster(width, height) { + 1 + } else { + 2 + } + }) +} + #[no_mangle] pub extern "C" fn render_stats() { get_render_state().print_stats(); diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 52dd798006..5bd001ac32 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -8,6 +8,16 @@ use macros::ToJs; use super::vector; use super::RenderResources; +/// Half the default 2 GiB emscripten heap (a 16384² RGBA surface), leaving +/// room for the encoded copies and the scene. Estimated, not measured. +const MAX_RASTER_BYTES: i64 = 1 << 30; +/// PNG stores each side in 31 bits; the memory budget limits it first. +const MAX_PNG_SIDE: i32 = i32::MAX; +/// libjpeg-turbo's `JPEG_MAX_DIMENSION`, below the 16-bit JPEG header limit. +const MAX_JPEG_SIDE: i32 = 65500; +/// libwebp's `WEBP_MAX_DIMENSION`: WebP stores each side in 14 bits. +const MAX_WEBP_SIDE: i32 = 16383; + /// Encoded output format for [`render_to_raster`] and `render_shape_pixels`. /// `ToJs` publishes the discriminants to `api/shared.js`, so the CLJS side /// reads them from here instead of hardcoding the codes. @@ -65,6 +75,17 @@ impl RasterFormat { pub fn clear_color(self) -> skia::Color { self.opaque_background().unwrap_or(skia::Color::TRANSPARENT) } + + pub fn fits_raster(self, width: i32, height: i32) -> bool { + let max_side = match self { + Self::Png => MAX_PNG_SIDE, + Self::Jpeg => MAX_JPEG_SIDE, + Self::Webp => MAX_WEBP_SIDE, + }; + width <= max_side + && height <= max_side + && width as i64 * height as i64 * 4 <= MAX_RASTER_BYTES + } } /// Renders a shape tree to encoded image bytes on a CPU raster surface (no