From 38b990ef9091f7f207fcfc611f736aad3d3e2523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 6 Aug 2026 16:13:06 +0200 Subject: [PATCH] :wrench: Add exporter headless backend (#10875) * :sparkles: Add headless wasm render backend to the exporter * :recycle: Move render-wasm bridge to common and split wasm builds * :wrench: Upload builtin font variants in the wasm exporter * :recycle: Move shared font and resources utils out of render_wasm * :zap: Fetch only the exported roots in the wasm exporter * :zap: Bound save_layer rects in the vector export path --- .gitignore | 2 + backend/scripts/_env | 6 +- common/deps.edn | 2 +- .../resources/fonts/gfonts.2025.11.28.json | 0 common/src/app/common/flags.cljc | 3 + .../main => common/src/app/common}/fonts.clj | 6 +- .../src/app/common/fonts.cljs | 160 ++++++- common/src/app/common/render_wasm/README.md | 24 + .../app/common}/render_wasm/api/props.cljs | 16 +- .../app/common}/render_wasm/api/shapes.cljs | 12 +- common/src/app/common/render_wasm/enums.clj | 54 +++ .../src/app/common}/render_wasm/helpers.cljc | 4 +- .../src/app/common}/render_wasm/mem.cljs | 6 +- .../app/common}/render_wasm/mem/heap32.cljs | 2 +- .../common}/render_wasm/serialize_shape.cljs | 6 +- .../app/common}/render_wasm/serializers.cljs | 6 +- .../render_wasm/serializers/color.cljs | 2 +- .../app/common}/render_wasm/text_content.cljs | 27 +- .../src/app/common}/render_wasm/wasm.cljs | 55 +-- .../src/app/common/types/shape/images.cljs | 6 +- exporter/package.json | 1 + exporter/scripts/build | 14 + exporter/src/app/core.cljs | 7 + exporter/src/app/renderer.cljs | 26 +- exporter/src/app/renderer/wasm.cljs | 451 ++++++++++++++++++ exporter/src/app/wasm.cljs | 255 ++++++++++ exporter/src/app/wasm/enums.cljs | 19 + exporter/src/app/wasm/serialize.cljs | 46 ++ exporter/src/app/wasm/text.cljs | 35 ++ frontend/deps.edn | 3 +- frontend/package.json | 2 +- frontend/scripts/build | 2 +- frontend/scripts/build-fonts-preview.js | 2 +- frontend/src/app/main/data/workspace.cljs | 2 +- frontend/src/app/main/fonts.cljs | 70 +-- frontend/src/app/main/render_viewer_wasm.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 27 +- frontend/src/app/render_wasm/api/enums.cljs | 19 + frontend/src/app/render_wasm/api/fonts.cljs | 71 +-- frontend/src/app/render_wasm/api/texts.cljs | 16 +- frontend/src/app/render_wasm/api/webgl.cljs | 2 +- frontend/src/app/render_wasm/text_editor.cljs | 12 +- frontend/src/app/worker/thumbnails.cljs | 2 +- frontend/src/debug.cljs | 6 +- .../render_wasm/process_objects_test.cljs | 4 +- .../text_editor_caret_color_test.cljs | 2 +- render-wasm/Cargo.toml | 4 + render-wasm/README.md | 28 ++ render-wasm/_build_env | 80 +++- render-wasm/build | 23 +- render-wasm/lint | 2 +- render-wasm/src/render/text.rs | 70 ++- render-wasm/src/render/vector.rs | 67 ++- render-wasm/src/shapes.rs | 15 +- render-wasm/test | 2 +- render-wasm/watch | 7 +- 56 files changed, 1475 insertions(+), 320 deletions(-) rename {frontend => common}/resources/fonts/gfonts.2025.11.28.json (100%) rename {frontend/src/app/main => common/src/app/common}/fonts.clj (98%) rename frontend/src/app/render_wasm/fallback_fonts.cljs => common/src/app/common/fonts.cljs (61%) create mode 100644 common/src/app/common/render_wasm/README.md rename {frontend/src/app => common/src/app/common}/render_wasm/api/props.cljs (96%) rename {frontend/src/app => common/src/app/common}/render_wasm/api/shapes.cljs (96%) create mode 100644 common/src/app/common/render_wasm/enums.clj rename {frontend/src/app => common/src/app/common}/render_wasm/helpers.cljc (94%) rename {frontend/src/app => common/src/app/common}/render_wasm/mem.cljs (97%) rename {frontend/src/app => common/src/app/common}/render_wasm/mem/heap32.cljs (97%) rename {frontend/src/app => common/src/app/common}/render_wasm/serialize_shape.cljs (93%) rename {frontend/src/app => common/src/app/common}/render_wasm/serializers.cljs (99%) rename {frontend/src/app => common/src/app/common}/render_wasm/serializers/color.cljs (90%) rename {frontend/src/app => common/src/app/common}/render_wasm/text_content.cljs (90%) rename {frontend/src/app => common/src/app/common}/render_wasm/wasm.cljs (58%) rename frontend/src/app/render_wasm/resources.cljs => common/src/app/common/types/shape/images.cljs (91%) create mode 100644 exporter/src/app/renderer/wasm.cljs create mode 100644 exporter/src/app/wasm.cljs create mode 100644 exporter/src/app/wasm/enums.cljs create mode 100644 exporter/src/app/wasm/serialize.cljs create mode 100644 exporter/src/app/wasm/text.cljs create mode 100644 frontend/src/app/render_wasm/api/enums.cljs diff --git a/.gitignore b/.gitignore index 382b89c92f..9481cf6b87 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ opencode.json /docker/images/bundle* /exporter/target /exporter/.shadow-cljs +/exporter/resources/wasm/ +/exporter/src/app/wasm/shared.js /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ diff --git a/backend/scripts/_env b/backend/scripts/_env index 04dcf1a724..724b55f05b 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -13,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key # PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by # docker/devenv/defaults.env and injected via the main service's env block. +if [ -f /home/selfsigned.crt ]; then + export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt; +fi + # Background worker flag is per-instance. Defaults to enabled (ws0); ws1+ # overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only # run on ws0, keeping notification Pub/Sub bound to a single Valkey. See @@ -101,5 +105,3 @@ function setup_minio() { mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q } - - diff --git a/common/deps.edn b/common/deps.edn index edffb487f0..36f4b63b16 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -55,7 +55,7 @@ io.aviso/pretty {:mvn/version "1.4.4"} environ/environ {:mvn/version "1.2.0"}} - :paths ["src" "vendor" "target/classes"] + :paths ["src" "vendor" "resources" "target/classes"] :aliases {:dev {:extra-deps diff --git a/frontend/resources/fonts/gfonts.2025.11.28.json b/common/resources/fonts/gfonts.2025.11.28.json similarity index 100% rename from frontend/resources/fonts/gfonts.2025.11.28.json rename to common/resources/fonts/gfonts.2025.11.28.json diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 9988c1a9f8..bfccc0afdf 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -178,6 +178,9 @@ :stroke-path :stroke-per-side + ;; Exporter only: uses render-wasm for export instead of browser + ;; renderer. + :wasm-export :custom-shortcuts :remote-media-processing}) diff --git a/frontend/src/app/main/fonts.clj b/common/src/app/common/fonts.clj similarity index 98% rename from frontend/src/app/main/fonts.clj rename to common/src/app/common/fonts.clj index c8abbd0478..9b0be1660f 100644 --- a/frontend/src/app/main/fonts.clj +++ b/common/src/app/common/fonts.clj @@ -4,8 +4,9 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.main.fonts +(ns app.common.fonts "A fonts loading macros." + (:require [app.common.uuid :as uuid] [clojure.data.json :as json] @@ -47,6 +48,3 @@ (let [data (slurp (io/resource path)) data (json/read-str data)] `~(mapv parse-gfont (get data "items")))) - - - diff --git a/frontend/src/app/render_wasm/fallback_fonts.cljs b/common/src/app/common/fonts.cljs similarity index 61% rename from frontend/src/app/render_wasm/fallback_fonts.cljs rename to common/src/app/common/fonts.cljs index 80f52be51c..5366d6c2c4 100644 --- a/frontend/src/app/render_wasm/fallback_fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -4,13 +4,159 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.fallback-fonts - "Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and - which (google) fallback fonts cover them. Pure data + pure fns — no browser - or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the - headless exporter (`app.renderer.wasm`) compute the SAME fallback set from - the same source. Anything a host must fetch/upload for text to render - belongs here, not in host code.") +(ns app.common.fonts + "Host-agnostic font knowledge shared by every renderer: the google catalog + baked at compile time from `common/resources/fonts/gfonts.*.json`, the + font-id/uuid mapping, weight/style variant resolution, and the noto fallback + fonts a text's scripts and emoji need. Also the one family bundled with the + frontend, which is not a google font but resolves by the same rules. + + Pure data + pure fns — no browser or Node dependencies — so the workspace and + the headless exporter resolve the SAME fonts from the same source. Anything a + host must fetch or upload for text to render belongs here, not in host code." + (:require-macros [app.common.fonts :refer [preload-gfonts]]) + (:require + [app.common.data :as d] + [app.common.uuid :as uuid] + [cuerdas.core :as str])) + +;; --- GOOGLE FONTS CATALOG + +(def catalog + (preload-gfonts "fonts/gfonts.2025.11.28.json")) + +(def ^:private by-id + (reduce (fn [m font] (assoc m (:id font) font)) {} catalog)) + +(def ^:private by-uuid + (reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog)) + +(defn gfont-id->uuid + "Maps a `gfont-` id to its (compilation-stable) catalog uuid, or nil." + [gfont-id] + (:uuid (get by-id gfont-id))) + +;; --- font-id -> wasm uuid + +(def ^:private custom-prefix "custom-") +(def ^:private gfont-prefix "gfont-") + +(defn font-id->backend + "Which source a content font-id comes from: `:google` for `gfont-`, + `:custom` for `custom-`, `:builtin` for everything else (bundled + families, but also unknown or malformed ids — the same bucket + `font-id->uuid` maps to `uuid/zero`)." + [font-id] + (cond + (not (string? font-id)) :builtin + (str/starts-with? font-id gfont-prefix) :google + (str/starts-with? font-id custom-prefix) :custom + :else :builtin)) + +(defn font-id->uuid + "Maps a content font-id to the uuid WASM keys fonts by: + + - `gfont-` -> the catalog uuid, + - `custom-` -> that uuid, + - anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM + resolves to the default font." + + [font-id] + (case (font-id->backend font-id) + :google (or (gfont-id->uuid font-id) uuid/zero) + :custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero) + uuid/zero)) + +;; --- proxy urls + +(def ^:private gstatic-prefix + "https://fonts.gstatic.com/s") + +(defn gstatic->proxy-url + [s base] + (let [base (str/rtrim (str base) "/")] + (str/replace (str s) gstatic-prefix base))) + +;; --- variant resolution + +(defn closest-variant + [variants target-weight target-style] + (when-let [target-weight (d/parse-integer target-weight)] + (let [result + (reduce + (fn [closest-match variant] + (let [weight (d/parse-integer (:weight variant)) + distance (abs (- target-weight weight)) + matches-style? (= target-style (:style variant)) + current {:variant variant + :weight weight + :distance distance}] + (cond + ;; Exact match found + (and (zero? distance) + (if target-style matches-style? true)) + (reduced current) + + (nil? closest-match) current + + ;; Update best match if this variant is closer or equal distance but higher weight + (or (< distance (:distance closest-match)) + (and (= distance (:distance closest-match)) + (> weight (:weight closest-match)))) + current + + ;; Same weight as the `closest-match` but the style matches `target-style` + (and (= weight (:weight closest-match)) matches-style?) + current + + :else + closest-match))) + nil + variants)] + (:variant result)))) + +(defn resolve-ttf-url + [font-uuid weight style] + (when-let [font (get by-uuid font-uuid)] + (let [style (if (zero? style) "normal" "italic") + variants (:variants font)] + (:ttf-url (or (closest-variant variants weight style) + (first variants)))))) + +;; --- BUILTIN FONTS +;; +;; Bundled with the frontend, served from `/fonts/`. Shared so the +;; workspace and the exporter upload the same TTF for a given weight/style. + +(def local-fonts + [{:id "sourcesanspro" + :name "Source Sans Pro" + :family "sourcesanspro" + :variants + [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"} + {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"} + {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"} + {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} + {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} + {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} + {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} + {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} + {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} + {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} + {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} + {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}]) + +(defn resolve-ttf-file + "Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the + same nearest-weight rule as the google catalog." + [weight style] + (let [variants (:variants (first local-fonts))] + (:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic")) + (first variants))))) + +;; --- FALLBACK FONTS +;; +;; Which scripts/emoji a text uses and which (google) fallback fonts cover them. (def ^:private emoji-pattern #"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]") diff --git a/common/src/app/common/render_wasm/README.md b/common/src/app/common/render_wasm/README.md new file mode 100644 index 0000000000..b153832e52 --- /dev/null +++ b/common/src/app/common/render_wasm/README.md @@ -0,0 +1,24 @@ +# `app.common.render-wasm.*` + +The host-agnostic ClojureScript side of the render-wasm binary protocol: byte +layouts, memory helpers and serializers that turn Penpot shapes into the buffers +`render-wasm` consumes. + +The workspace drives it from `app.render-wasm.*`, the headless exporter from +`app.wasm.*` — same code underneath, so the two cannot drift. + +Font knowledge is *not* here even though both hosts need it for rendering: it is +not specific to the wasm backend, so the google fonts catalog (baked from +`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the +emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id +enumeration lives in `app.common.types.shape.images`. + +`shared.js` is not here: it is a per-build artifact, so each host compiles +against the copy from its own render-wasm build and passes it to +`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`). + +## Rules for anything added here + +**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on +`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also +has to run under plain Node — a `js/document` here breaks the exporter. diff --git a/frontend/src/app/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs similarity index 96% rename from frontend/src/app/render_wasm/api/props.cljs rename to common/src/app/common/render_wasm/api/props.cljs index 26bbdadc3a..6caaac7de8 100644 --- a/frontend/src/app/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.api.props +(ns app.common.render-wasm.api.props "Browser-free WASM shape property setters, shared by the workspace render orchestrator (`app.render-wasm.api`) and the headless exporter (`app.wasm.serialize`). @@ -15,15 +15,15 @@ data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`." (:require [app.common.math :as mth] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.mem.heap32 :as mem.h32] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills :as types.fills] [app.common.types.fills.impl :as types.fills.impl] - [app.common.types.path :as path] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.mem.heap32 :as mem.h32] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm])) + [app.common.types.path :as path])) (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/common/src/app/common/render_wasm/api/shapes.cljs similarity index 96% rename from frontend/src/app/render_wasm/api/shapes.cljs rename to common/src/app/common/render_wasm/api/shapes.cljs index 31cf032ed3..55f442d0d0 100644 --- a/frontend/src/app/render_wasm/api/shapes.cljs +++ b/common/src/app/common/render_wasm/api/shapes.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.api.shapes +(ns app.common.render-wasm.api.shapes "Batched shape property serialization for improved WASM performance. This module provides a single WASM call to set all base shape properties, @@ -13,11 +13,11 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.uuid :as uuid] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm])) + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] + [app.common.uuid :as uuid])) ;; Binary layout constants matching Rust implementation: ;; diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj new file mode 100644 index 0000000000..3bc382ec8d --- /dev/null +++ b/common/src/app/common/render_wasm/enums.clj @@ -0,0 +1,54 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.render-wasm.enums + "Serializer enum table from `shared.js`") + +(def ^:private serializer-exports + [["raster-format" "RasterFormat"] + ["blur-type" "RawBlurType"] + ["blend-mode" "RawBlendMode"] + ["bool-type" "RawBoolType"] + ["font-style" "RawFontStyle"] + ["flex-direction" "RawFlexDirection"] + ["grid-direction" "RawGridDirection"] + ["grow-type" "RawGrowType"] + ["align-items" "RawAlignItems"] + ["align-self" "RawAlignSelf"] + ["align-content" "RawAlignContent"] + ["justify-items" "RawJustifyItems"] + ["justify-content" "RawJustifyContent"] + ["justify-self" "RawJustifySelf"] + ["wrap-type" "RawWrapType"] + ["grid-track-type" "RawGridTrackType"] + ["shadow-style" "RawShadowStyle"] + ["guide-kind" "RawGuideKind"] + ["stroke-style" "RawStrokeStyle"] + ["stroke-cap" "RawStrokeCap"] + ["shape-type" "RawShapeType"] + ["constraint-h" "RawConstraintH"] + ["constraint-v" "RawConstraintV"] + ["sizing" "RawSizing"] + ["vertical-align" "RawVerticalAlign"] + ["fill-data" "RawFillData"] + ["text-align" "RawTextAlign"] + ["text-direction" "RawTextDirection"] + ["text-decoration" "RawTextDecoration"] + ["text-transform" "RawTextTransform"] + ["multiple-state" "MultipleState"] + ["transform-entry-kind" "RawTransformEntryKind"] + ["segment-data" "RawSegmentData"] + ["stroke-linecap" "RawStrokeLineCap"] + ["stroke-linejoin" "RawStrokeLineJoin"] + ["fill-rule" "RawFillRule"]]) + +(defmacro serializers + [alias] + (let [alias (name alias)] + `(cljs.core/js-obj + ~@(mapcat (fn [[key export]] + [key (symbol alias export)]) + serializer-exports)))) diff --git a/frontend/src/app/render_wasm/helpers.cljc b/common/src/app/common/render_wasm/helpers.cljc similarity index 94% rename from frontend/src/app/render_wasm/helpers.cljc rename to common/src/app/common/render_wasm/helpers.cljc index 452e3f1eb4..236a952248 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/common/src/app/common/render_wasm/helpers.cljc @@ -4,8 +4,8 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.helpers - #?(:cljs (:require-macros [app.render-wasm.helpers])) +(ns app.common.render-wasm.helpers + #?(:cljs (:require-macros [app.common.render-wasm.helpers])) (:require [app.common.data :as d])) (def error-code diff --git a/frontend/src/app/render_wasm/mem.cljs b/common/src/app/common/render_wasm/mem.cljs similarity index 97% rename from frontend/src/app/render_wasm/mem.cljs rename to common/src/app/common/render_wasm/mem.cljs index d90d2f7fa7..3a64315ba0 100644 --- a/frontend/src/app/render_wasm/mem.cljs +++ b/common/src/app/common/render_wasm/mem.cljs @@ -4,11 +4,11 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.mem +(ns app.common.render-wasm.mem (:require [app.common.buffer :as buf] - [app.render-wasm.helpers :as h] - [app.render-wasm.wasm :as wasm])) + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm])) (defn ->offset-32 "Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset" diff --git a/frontend/src/app/render_wasm/mem/heap32.cljs b/common/src/app/common/render_wasm/mem/heap32.cljs similarity index 97% rename from frontend/src/app/render_wasm/mem/heap32.cljs rename to common/src/app/common/render_wasm/mem/heap32.cljs index 07ac6b749f..bfe7c49053 100644 --- a/frontend/src/app/render_wasm/mem/heap32.cljs +++ b/common/src/app/common/render_wasm/mem/heap32.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.mem.heap32 +(ns app.common.render-wasm.mem.heap32 "A memory write helpers that uses 32 bits addressed offsets." (:require [app.common.data.macros :as dm] diff --git a/frontend/src/app/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs similarity index 93% rename from frontend/src/app/render_wasm/serialize_shape.cljs rename to common/src/app/common/render_wasm/serialize_shape.cljs index ffc659757e..a0e776a62f 100644 --- a/frontend/src/app/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.serialize-shape +(ns app.common.render-wasm.serialize-shape "Single source of truth for the host-independent part of serializing a whole shape into the WASM design state. @@ -24,8 +24,8 @@ The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps dispatching per changed key through the same underlying `props` setters." (:require - [app.render-wasm.api.props :as props] - [app.render-wasm.api.shapes :as shapes])) + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.shapes :as shapes])) (defn serialize-shape! "Applies every host-independent WASM property of `shape`. `set-shape-base-props` diff --git a/frontend/src/app/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs similarity index 99% rename from frontend/src/app/render_wasm/serializers.cljs rename to common/src/app/common/render_wasm/serializers.cljs index 6c35d5d455..862eafae7c 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -4,16 +4,16 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL - (ns app.render-wasm.serializers + (ns app.common.render-wasm.serializers (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) (defn u8 diff --git a/frontend/src/app/render_wasm/serializers/color.cljs b/common/src/app/common/render_wasm/serializers/color.cljs similarity index 90% rename from frontend/src/app/render_wasm/serializers/color.cljs rename to common/src/app/common/render_wasm/serializers/color.cljs index 7d5d28b8c7..c3ef27fdfc 100644 --- a/frontend/src/app/render_wasm/serializers/color.cljs +++ b/common/src/app/common/render_wasm/serializers/color.cljs @@ -1,4 +1,4 @@ -(ns app.render-wasm.serializers.color +(ns app.common.render-wasm.serializers.color (:require [app.common.math :as mth])) diff --git a/frontend/src/app/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs similarity index 90% rename from frontend/src/app/render_wasm/text_content.cljs rename to common/src/app/common/render_wasm/text_content.cljs index 457a633066..51ce707fb4 100644 --- a/frontend/src/app/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -4,23 +4,24 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.text-content +(ns app.common.render-wasm.text-content "Single source of truth for writing a text shape's content into the WASM design state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is - identical for the workspace and the headless exporter — only *font resolution* - differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts - catalog + custom variants). So the byte-writing lives here and font resolution - is injected via the `opts` map passed to `write-shape-text!`. + identical for the workspace and the headless exporter, and so is the font-id + -> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs — + the workspace has a loaded fonts DB, the exporter does not — so that part is + injected via the `opts` map passed to `write-shape-text!`. Fully portable (no store/DOM/React), so it runs under Node too." (:require [app.common.data :as d] + [app.common.fonts :as cfnt] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.uuid :as uuid] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) (def ^:const PARAGRAPH-ATTR-U8-SIZE 12) @@ -169,13 +170,15 @@ "Writes one paragraph's spans + text into WASM and appends it to the current shape via `_set_shape_text_content`. - `opts` injects host-specific font resolution: - - `:normalize-font-id` (string font-id -> wasm uuid) — required in practice, + `opts` injects host-specific font handling: + - `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared + `cfnt/font-id->uuid`, which is what both hosts want — a host only + overrides it if it keys its font store some other way, - `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a fonts DB (workspace); default to identity (the exporter resolves variants differently / not at all)." [spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span] - :or {normalize-font-id identity + :or {normalize-font-id cfnt/font-id->uuid normalize-paragraph identity normalize-span (fn [span _paragraph] span)}}] (let [paragraph (normalize-paragraph paragraph) diff --git a/frontend/src/app/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs similarity index 58% rename from frontend/src/app/render_wasm/wasm.cljs rename to common/src/app/common/render_wasm/wasm.cljs index 933b200530..657b44047d 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/common/src/app/common/render_wasm/wasm.cljs @@ -4,8 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.wasm - (:require ["./api/shared.js" :as shared])) +(ns app.common.render-wasm.wasm) (defonce internal-frame-id nil) (defonce internal-frame-type 0) @@ -65,41 +64,19 @@ (set! gl-context nil) (set! context-initialized? false)) -(defonce serializers - #js {:raster-format shared/RasterFormat - :blur-type shared/RawBlurType - :blend-mode shared/RawBlendMode - :bool-type shared/RawBoolType - :font-style shared/RawFontStyle - :flex-direction shared/RawFlexDirection - :grid-direction shared/RawGridDirection - :grow-type shared/RawGrowType - :align-items shared/RawAlignItems - :align-self shared/RawAlignSelf - :align-content shared/RawAlignContent - :justify-items shared/RawJustifyItems - :justify-content shared/RawJustifyContent - :justify-self shared/RawJustifySelf - :wrap-type shared/RawWrapType - :grid-track-type shared/RawGridTrackType - :shadow-style shared/RawShadowStyle - :guide-kind shared/RawGuideKind - :stroke-style shared/RawStrokeStyle - :stroke-cap shared/RawStrokeCap - :shape-type shared/RawShapeType - :constraint-h shared/RawConstraintH - :constraint-v shared/RawConstraintV - :sizing shared/RawSizing - :vertical-align shared/RawVerticalAlign - :fill-data shared/RawFillData - :text-align shared/RawTextAlign - :text-direction shared/RawTextDirection - :text-decoration shared/RawTextDecoration - :text-transform shared/RawTextTransform - :multiple-state shared/MultipleState - :transform-entry-kind shared/RawTransformEntryKind - :segment-data shared/RawSegmentData - :stroke-linecap shared/RawStrokeLineCap - :stroke-linejoin shared/RawStrokeLineJoin - :fill-rule shared/RawFillRule}) +(defonce serializers nil) + +(defn init-serializers! + "Binds the enum table produced by the `enums/serializers` macro." + [table] + (let [missing (array)] + (doseq [key (js/Object.keys table)] + (when (undefined? (unchecked-get table key)) + (.push missing key))) + + (when (pos? (alength missing)) + (throw (ex-info "stale or incomplete render-wasm shared.js" + {:missing (vec missing)}))) + + (set! serializers table))) diff --git a/frontend/src/app/render_wasm/resources.cljs b/common/src/app/common/types/shape/images.cljs similarity index 91% rename from frontend/src/app/render_wasm/resources.cljs rename to common/src/app/common/types/shape/images.cljs index 9f564aa990..54e6fef6c3 100644 --- a/frontend/src/app/render_wasm/resources.cljs +++ b/common/src/app/common/types/shape/images.cljs @@ -4,12 +4,12 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.resources +(ns app.common.types.shape.images "Host-agnostic enumeration of the external resources a scene needs to render: which image bytes its shapes reference. Pure data walking — no browser or Node dependencies — so the workspace and the headless exporter - derive the same set from the same source (sibling of - `app.render-wasm.fallback-fonts`, which does the same for fonts)." + derive the same set from the same source (counterpart of + `app.common.fonts`, which does the same for fonts)." (:require [app.common.types.fills :as types.fills])) diff --git a/exporter/package.json b/exporter/package.json index 83518eabee..ae3eefb04c 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -33,6 +33,7 @@ "watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main", "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", + "build:wasm": "../render-wasm/build export", "build": "pnpm run clear:shadow-cache && pnpm run build:app", "fmt": "cljfmt fix --parallel=true src/", "check-fmt": "cljfmt check --parallel=true src/", diff --git a/exporter/scripts/build b/exporter/scripts/build index 40eba8f44c..a240c17b75 100755 --- a/exporter/scripts/build +++ b/exporter/scripts/build @@ -8,6 +8,17 @@ export NODE_ENV=production; corepack enable; corepack install || exit 1; pnpm install || exit 1; +pnpm run build:wasm; + +WASM_SRC="resources/wasm"; +WASM_SHARED="src/app/wasm/shared.js"; +if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then + echo "ERROR: the render-wasm build did not produce:" >&2; + echo " $WASM_SRC/render-wasm.wasm" >&2; + echo " $WASM_SHARED" >&2; + exit 1; +fi + rm -rf target # Build the application @@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/; cp package.json target/; touch target/pnpm-workspace.yaml; +mkdir -p target/$WASM_SRC; +cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/; + cat < serialize -> provision fonts and + images -> relayout text with the real fonts -> render each object. + + One shared WASM design state, so requests are serialized one at a time. + + Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the + browser path." + (:require + ["node:fs" :as fs] + ["undici" :as http] + [app.common.data :as d] + [app.common.fonts :as cfnt] + ;; Required for side effects: these register the transit read handlers and + ;; deftype impls the `get-page` response is decoded into. + [app.common.geom.matrix] + [app.common.geom.point] + [app.common.geom.rect] + [app.common.logging :as l] + [app.common.transit :as t] + [app.common.types.fills.impl] + [app.common.types.objects-map] + [app.common.types.path.impl] + [app.common.types.shape] + [app.common.types.shape.images :as images] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.util.mime :as mime] + [app.util.shell :as sh] + [app.wasm :as wasm] + [app.wasm.serialize :as serialize] + [cuerdas.core :as str] + [promesa.core :as p])) + +;; --- module lifecycle (one shared, lazily-initialized instance) + +(defonce ^:private module* (atom nil)) + +(defn- ensure-module! + [] + (or @module* + (reset! module* (wasm/init!)))) + +;; --- serialized access to the shared module +;; +;; `handle-multiple-export` fans out partitions concurrently, but there is one +;; design state and one global mem buffer, so their serialize/render/alloc must +;; not interleave. + +(defonce ^:private queue (atom (p/resolved nil))) + +(defn- enqueue! + "Runs `thunk` (0-arg, returns a promise) only after all previously enqueued + work has settled. Returns `thunk`'s promise. A task's failure is isolated: + it doesn't break the chain for the next task." + [thunk] + (let [result (p/handle @queue (fn [_ _] (thunk)))] + (reset! queue (p/handle result (fn [_ _] nil))) + result)) + +;; --- backend endpoints +;; +;; Every fetch targets the internal endpoint (falling back to public-uri), +;; in a deployment the exporter reaches the backend over the container network + +(defn- internal-uri + "Absolute URI for `path` on the internal (backend) endpoint." + [path] + (-> (cf/get-internal-uri) + (u/ensure-path-slash) + (u/join path) + (str))) + +(defn- error-detail + "Node's fetch reports every transport failure as a bare `TypeError: fetch + failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a + nested `cause` chain that the logger does not print. Flattens the chain into + one readable string." + [cause] + (->> (iterate (fn [^js e] (unchecked-get e "cause")) cause) + (take-while some?) + (take 5) + (map (fn [^js e] + (let [code (unchecked-get e "code") + msg (or (unchecked-get e "message") (str e))] + (if code (str code ": " msg) msg)))) + (str/join " <- "))) + +(defn- fetch! + "`undici/fetch` that fails with an ex-info carrying the target uri and the + unwrapped cause chain, so a failed request says what actually went wrong and + against which endpoint." + [uri opts] + (->> (p/do (http/fetch uri opts)) + (p/merr (fn [cause] + (p/rejected (ex-info "http fetch failed" + {:uri uri :detail (error-detail cause)} + cause)))))) + +(defn- explain + "Log-friendly reason for `cause`: the detail `fetch!` already attached, or a + freshly unwrapped chain for anything else (WASM aborts, decode errors)." + [cause] + (or (:detail (ex-data cause)) + (error-detail cause))) + +(defn- rpc-headers + "Auth headers for backend RPC calls (management key + bearer)." + [token] + #js {"Content-Type" "application/transit+json" + "X-Shared-Key" (str "exporter " cf/management-key) + "Authorization" (str "Bearer " token)}) + +(defn- asset-headers + "Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to + a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple + authentication types\")." + [token] + #js {"X-Shared-Key" (str "exporter " cf/management-key) + "Cookie" (str "auth-token=" token)}) + +;; --- shape bundle fetch (backend RPC) + +(defn- fetch-objects + "Fetches the exported roots and their children from the backend via the + `get-page` RPC (`:object-id`, as the browser render path does), using the + same auth the exporter uses elsewhere (management key + bearer)." + [{:keys [file-id page-id share-id token objects]}] + (let [headers (rpc-headers token) + root-ids (into #{} (map :id) objects) + body (t/encode-str (cond-> {:file-id file-id + :page-id page-id} + (seq root-ids) (assoc :object-id root-ids) + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-page")] + (l/dbg :hint "wasm render: get-page" + :uri uri + :file-id (str file-id) + :page-id (str page-id) + :roots (count root-ids)) + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (->> (.text resp) + (p/mcat (fn [resp-body] + (l/error :hint "wasm render: get-page failed" + :uri uri + :status (.-status resp) + :body resp-body) + (p/rejected (ex-info "get-page failed" + {:status (.-status resp) + :body resp-body})))))))) + (p/fmap t/decode-str) + (p/fmap :objects)))) + +;; --- font resolution +;; +;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape` +;; reports it. Custom (team) fonts resolve through the file's font variants, +;; google fonts through the shared `app.common.fonts` catalog; builtin +;; fonts through its bundled family + the frontend's static `/fonts/`. + +(defn- fetch-font-variants + "Team (custom) font variants for the file, or nil — a failure here degrades + to fallback fonts, it does not fail the export." + [{:keys [file-id share-id token]}] + (let [headers (rpc-headers token) + body (t/encode-str (cond-> {:file-id file-id} + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-font-variants")] + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (p/resolved nil)))) + (p/fmap (fn [s] (when s (t/decode-str s)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: get-font-variants failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- fetch-ttf-bytes + "Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure + here degrades to fallback fonts, it does not fail the export." + ([uri] (fetch-ttf-bytes uri #js {:method "GET"})) + ([uri opts] + (->> (fetch! uri opts) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (p/resolved nil)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: font fetch failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +;; TTF bytes cached for the process lifetime, keyed by whatever identifies the +;; variant (a gfont id+weight+style, a builtin file name). +(defonce ^:private font-bytes* (atom {})) + +(defn- cached-ttf-bytes + [cache-key fetch-fn] + (if-let [bytes (get @font-bytes* cache-key)] + (p/resolved bytes) + (->> (fetch-fn) + (p/fmap (fn [buf] + (when buf (swap! font-bytes* assoc cache-key buf)) + buf))))) + +(defn- fetch-asset-bytes + [asset-id {:keys [token]}] + (fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id)) + #js {:method "GET" :headers (asset-headers token)})) + +(defn- fetch-gfont-bytes + [ttf-url] + (fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font")))) + +(defn- fetch-builtin-font-bytes + [ttf-file] + (cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file))))) + +(defn- make-resolve-font + "Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom + variants first, matching uuid+weight+style then degrading to uuid+weight then + uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps + every builtin family to; google catalog otherwise." + [variants params] + (fn [{:keys [id weight style]}] + (let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3)) + style-str (if (zero? style) "normal" "italic") + variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight) + (= (name (:font-style v)) style-str))) + variants) + (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight))) + variants) + (d/seek (fn [v] (= (:font-id v) font-uuid)) variants))] + (cond + (:ttf-file-id variant) + (fetch-asset-bytes (:ttf-file-id variant) params) + + (= uuid/zero font-uuid) + (fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style)) + + :else + (if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)] + (fetch-gfont-bytes gurl) + (p/resolved nil)))))) + +;; --- fallback fonts (emoji + per-script noto fonts) +;; +;; Emoji and non-latin scripts render through fallback families, not through +;; any span's font family, so `wasm/fonts-for-shape` never reports them and the +;; provisioning above never uploads them. Must run per request, since +;; `clear-fonts!` empties the store; the TTF bytes stay cached per process. + +(defn- scene-fallback-fonts + "Fallback font descriptors needed by the scene's text. Deduped because + several languages map to one noto family and provisioning is concurrent — + otherwise they all miss the byte cache at once and refetch the same TTF." + [scene] + (let [texts (for [shape (vals scene) + :when (= :text (:type shape)) + node (or (some->> (:content shape) (tree-seq :children :children)) []) + :let [text (:text node)] + :when (string? text)] + text) + emoji? (boolean (some cfnt/contains-emoji? texts)) + langs (reduce cfnt/collect-used-languages #{} texts)] + (distinct + (cond-> (cfnt/add-noto-fonts [] langs) + emoji? (cfnt/add-emoji-font))))) + +(defn- fetch-fallback-font-bytes + "Downloads one fallback font's TTF. Cached by the whole variant, not just + `font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a + font-id-only key would serve the first downloaded variant for every other one." + [{:keys [font-id weight style]}] + (if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))] + (cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url)) + (p/resolved nil))) + +(defn- provision-fallback-fonts! + [scene] + (->> (scene-fallback-fonts scene) + (map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}] + (if-let [font-uuid (cfnt/gfont-id->uuid font-id)] + (->> (fetch-fallback-font-bytes font) + (p/fmap (fn [buf] + (if buf + (wasm/store-font! {:id (uuid/get-u32 font-uuid) + :weight weight + :style style + :emoji? (boolean is-emoji) + :fallback? (boolean is-fallback)} + buf) + (l/warn :hint "wasm render: fallback font unavailable" + :font-id font-id))))) + (p/resolved nil)))) + (p/all))) + +;; --- image resolution +;; +;; Image fills reference file-media ids; the encoded bytes go straight to +;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens +;; once per request rather than per rendered object. + +(defn- fetch-file-media-bytes + "Downloads an image fill's encoded bytes by file-media id." + [media-id {:keys [token]}] + (let [headers (asset-headers token) + uri (internal-uri (str "assets/by-file-media-id/" media-id))] + (->> (fetch! uri #js {:method "GET" :headers headers}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (do + (l/warn :hint "wasm render: image fetch non-200" + :media-id (str media-id) + :uri uri + :status (.-status resp)) + (p/resolved nil))))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: image fetch failed" + :media-id (str media-id) :uri uri + :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- provision-images! + "Fetches and stores every image the scene references (shape, stroke and + text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, + the image store is not reset per request, so already-held images are skipped + and repeated exports of a file reuse them." + [scene params] + (let [all-ids (images/scene-image-ids scene) + new-ids (remove wasm/image-cached? all-ids)] + (l/dbg :hint "wasm render: provisioning images" + :total (count all-ids) + :cached (- (count all-ids) (count new-ids))) + (->> new-ids + (map (fn [image-id] + (->> (fetch-file-media-bytes image-id params) + (p/fmap (fn [buf] + (if buf + (do + (l/dbg :hint "wasm render: image stored" + :media-id (str image-id) + :bytes (.-byteLength ^js buf)) + (wasm/store-image! image-id buf)) + (l/warn :hint "wasm render: image unavailable" + :media-id (str image-id)))))))) + (p/all)))) + +(defn- relayout-text! + "Recomputes layout for every text shape, once the real fonts are provisioned + (serialize-time layout used the fallback)." + [scene] + (doseq [shape (vals scene) + :when (= :text (:type shape))] + (wasm/update-text-layout! (:id shape)))) + +;; --- render + +(defn- render-object-bytes + [type id scale] + (if (= :pdf type) + (let [bytes (wasm/render-shape-pdf id scale)] + (l/dbg :hint "PDF generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + (wasm/render-shape-raster id scale type))) + +(defn- render* + [{:keys [scale type objects] :as params} on-object] + (l/dbg :hint "wasm render: start" + :type type + :scale scale + :objects (count objects) + :file-id (str (:file-id params)) + :page-id (str (:page-id params))) + (->> (ensure-module!) + (p/mcat (fn [_] (fetch-objects params))) + (p/mcat (fn [scene] + (l/dbg :hint "wasm render: scene fetched" :shapes (count scene)) + (serialize/serialize-scene! scene) + (l/dbg :hint "wasm render: scene serialized") + ;; So fonts from a previous request don't leak into this one. + (wasm/clear-fonts!) + (->> (p/all [(fetch-font-variants params) + (provision-images! scene params) + (provision-fallback-fonts! scene)]) + (p/mcat + (fn [[variants _]] + (let [resolve-font (make-resolve-font (or variants []) params)] + ;; Before rendering, so the relayout below sees real + ;; font metrics. Deduped across objects: a partition + ;; sharing one family downloads its TTF once. + (wasm/provision-fonts! (map :id objects) resolve-font)))) + (p/mcat + (fn [_] + (relayout-text! scene) + (p/run + (fn [{:keys [id] :as object}] + (let [bytes (render-object-bytes type id scale) + path (sh/tempfile :prefix "penpot.tmp.wasm." + :suffix (mime/get-extension type))] + (l/dbg :hint "wasm render: object rendered" + :object-id (str id) :bytes (.-length bytes)) + (fs/writeFileSync path bytes) + ;; `on-object` returns a plain value (zip append) or + ;; a promise (single export's file move); `p/do` + ;; normalizes both to a thenable. + (p/do (on-object (assoc object :path path))))) + objects)))))) + (p/fmap (fn [result] + ;; After the request, never mid-render, so an image can't + ;; disappear under a running export. + (let [evicted (wasm/evict-images! wasm/image-cache-mb)] + (when (pos? evicted) + (l/info :hint "wasm render: evicted cached images" :count evicted))) + result)) + (p/merr (fn [cause] + (l/error :hint "wasm render: failed" + :detail (explain cause) + :internal-uri (str (cf/get-internal-uri)) + :cause cause) + ;; A panic can leave the mem buffer allocated or the instance + ;; aborted; drop it so the next request rebuilds a fresh one. + (reset! module* nil) + (p/rejected cause))))) + +(defn render + "Public entry. `enqueue!` keeps concurrent exports off each other's toes on + the shared WASM instance." + [params on-object] + (enqueue! (fn [] (render* params on-object)))) diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs new file mode 100644 index 0000000000..8a06b9b4f2 --- /dev/null +++ b/exporter/src/app/wasm.cljs @@ -0,0 +1,255 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm + "Headless driver for the render-wasm module under Node: the GPU-free + counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it + via `init_headless`, and exposes font provisioning + shape rendering. + + Serialization is reused from the portable render-wasm leaves, so this + namespace owns only the Node runtime and the headless render calls. + + Requires render-wasm built with `-sENVIRONMENT=web,node`." + (:require + ["node:fs" :as fs] + ["node:path" :as path] + [app.common.data :as d] + [app.common.logging :as l] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] + [app.common.uuid :as uuid] + ;; Required for side effects: binds the generated enums. + [app.wasm.enums] + [promesa.core :as p] + [shadow.esm :refer [dynamic-import]])) + +(def ^:private default-viewport-width 1920) +(def ^:private default-viewport-height 1080) + +;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32]. +(def ^:private RASTER-HEADER-BYTES 12) +;; render_shape_pdf result header: [len u32] only. +(def ^:private PDF-HEADER-BYTES 4) +;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32]. +(def ^:private FONT-ENTRY-BYTES 24) + +(def artifact-dir + "Built render-wasm artifact, relative to the process working directory. Same + path in devenv and inside the bundle, so it is a constant." + "resources/wasm") + +(def image-cache-mb + "Byte budget (MB) the image store is trimmed to between requests." + 256) + +(defn- read-result-bytes + "Reads `len` bytes from the WASM heap starting at `offset`, copying them out + (via `.slice`) before the buffer is freed." + [offset len] + (.slice (mem/get-heap-u8) offset (+ offset len))) + +;; --- MODULE LIFECYCLE + +(defn init! + "Loads the render-wasm artifact under Node and boots it headless. Sets the + shared `wasm/internal-module` so the portable serialization leaves work. + Idempotent-ish: callers should hold the returned module." + ([] (init! default-viewport-width default-viewport-height)) + ([width height] + (let [dir artifact-dir + js-path (path/resolve dir "render-wasm.js") + wasm-path (path/resolve dir "render-wasm.wasm") + wasm-bytes (fs/readFileSync wasm-path)] + (l/info :hint "loading render-wasm (headless)" :js js-path) + ;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import` + ;; compiles to an undefined `import$`). + (->> (dynamic-import (str "file://" js-path)) + (p/mcat + (fn [mod] + (let [factory (unchecked-get mod "default")] + (factory + #js {;; Bypass the web fetch loader: instantiate from local bytes. + :instantiateWasm + (fn [imports success] + (-> (js/WebAssembly.instantiate wasm-bytes imports) + (.then (fn [result] (success (.-instance result))))) + #js {}) + :locateFile (fn [p] (path/resolve dir p)) + :printErr (fn [s] (l/warn :wasm s))})))) + (p/fmap + (fn [module] + (set! wasm/internal-module module) + (h/call module "_init_headless" width height) + (set! wasm/context-initialized? true) + (l/info :hint "render-wasm headless module ready" :width width :height height) + module)))))) + +;; --- FONT PROVISIONING (on demand, mirrors the browser) + +(defn fonts-for-shape + "Returns the distinct font families needed to render the subtree rooted at + `shape-id` as a vector of {:id :weight :style}. Equivalent to + the browser's `get-content-fonts`, but read from the loaded WASM tree." + [shape-id] + (let [module wasm/internal-module + buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves + offset (h/call module "_get_fonts_for_shape" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)) + heap32 (mem/get-heap-u32) + n (aget heap32 (mem/->offset-32 offset)) + ;; `vec` must stay eager: it reads the result buffer, and the + ;; `mem/free` below invalidates these offsets. + entries (vec + (for [i (range n)] + (let [base (+ offset 4 (* i FONT-ENTRY-BYTES)) + u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))] + {:id #js [(u32 0) (u32 4) (u32 8) (u32 12)] + :weight (u32 16) + :style (u32 20)})))] + (mem/free) + entries)) + +(defn- font-key + "Value key for a family map. Its `:id` is a JS array, so the map itself can't + be compared by value." + [{:keys [id weight style]}] + [(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style]) + +(defn fonts-for-shapes + "Distinct font families needed by every subtree in `shape-ids`. Objects in a + partition overwhelmingly share families, so deduping here means one download + and one `_store_font` per family rather than one per object." + [shape-ids] + (into [] (comp (mapcat fonts-for-shape) + (d/distinct-xf font-key)) + shape-ids)) + +(defn store-font! + "Uploads one font's TTF bytes into the WASM font store, keyed by the family + (uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer. + + Does NOT call `mem/free` — `store_font` (and likewise `store_image` below) + releases the global buffer itself on the Rust side. Freeing again here would + drop a buffer a later writer already owns." + [{:keys [id weight style emoji? fallback?]} font-bytes] + (let [module wasm/internal-module + size (.-byteLength font-bytes) + ptr (h/call module "_alloc_bytes" size) + heap (mem/get-heap-u8)] + (.set heap (js/Uint8Array. font-bytes) ptr) + (h/call module "_store_font" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + weight style (boolean emoji?) (boolean fallback?)))) + +(defn clear-fonts! + "Resets the WASM font store. Must be called once per render request because + the shared module would otherwise accumulate fonts across requests." + [] + (h/call wasm/internal-module "_clear_fonts")) + +(defn update-text-layout! + "Recomputes a text shape's layout with the currently provisioned fonts. Text is + laid out at serialize time using the fallback font (real fonts aren't uploaded + yet), so this must run again after `provision-fonts!` or glyph metrics/line + breaks are wrong." + [shape-id] + (let [buf (uuid/get-u32 shape-id)] + (h/call wasm/internal-module "_update_shape_text_layout_for" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)))) + +(defn image-cached? + "True when the module's image store already holds this image (full size). + The store is NOT reset between requests, so previously provisioned images + can be reused instead of refetched." + [image-id] + (let [buf (uuid/get-u32 image-id)] + (not (zero? (h/call wasm/internal-module "_is_image_cached" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + false))))) + +(defn store-image! + "Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into + the WASM image store via `_store_image`. Buffer layout matches the Rust reader: + [shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are + keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an + ArrayBuffer/Buffer/Uint8Array." + [image-id image-bytes] + (let [module wasm/internal-module + img-u8 (js/Uint8Array. image-bytes) + size (.-byteLength img-u8) + total (+ 36 size) + ptr (h/call module "_alloc_bytes" total) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap)) + quart (uuid/get-u32 image-id)] + ;; shape uuid [0..16) = 0 (images are keyed by image uuid only) + (.setUint32 dview (+ ptr 0) 0 true) + (.setUint32 dview (+ ptr 4) 0 true) + (.setUint32 dview (+ ptr 8) 0 true) + (.setUint32 dview (+ ptr 12) 0 true) + ;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which + ;; the fill path uses, so it hashes to the same key the fill references) + (.setUint32 dview (+ ptr 16) (aget quart 0) true) + (.setUint32 dview (+ ptr 20) (aget quart 1) true) + (.setUint32 dview (+ ptr 24) (aget quart 2) true) + (.setUint32 dview (+ ptr 28) (aget quart 3) true) + ;; is_thumbnail [32..36) = 0 + (.setUint32 dview (+ ptr 32) 0 true) + ;; encoded bytes [36..) + (.set heap img-u8 (+ ptr 36)) + (h/call module "_store_image"))) + +(defn evict-images! + "Evicts least-recently-used images until the store retains at most `max-mb` + megabytes. Returns the number evicted." + [max-mb] + (h/call wasm/internal-module "_evict_images_to_budget" max-mb)) + +(defn provision-fonts! + "Resolves and uploads every font needed by `shape-ids`, each family fetched + once. `resolve-font` is an injected fn of the family map -> promise of TTF + bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom + assets / backend) out of the driver." + [shape-ids resolve-font] + (->> (fonts-for-shapes shape-ids) + (map (fn [family] + (->> (resolve-font family) + (p/fmap (fn [bytes] (when bytes (store-font! family bytes))))))) + (p/all))) + +;; --- RENDER + +(defn- read-render-result + "Copies the encoded payload out of a `_render_shape_*` result buffer and frees + it. `header-bytes` is the size of the header preceding the payload." + [offset header-bytes] + (let [heap32 (mem/get-heap-u32) + len (aget heap32 (mem/->offset-32 offset)) + bytes (read-result-bytes (+ offset header-bytes) len)] + (mem/free) + bytes)) + +(defn render-shape-raster + "Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU + 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] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_raster" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale (sr/translate-raster-format format)) + (read-render-result RASTER-HEADER-BYTES)))) + +(defn render-shape-pdf + "Renders the shape subtree to PDF bytes (Uint8Array)." + [shape-id scale] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_pdf" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale) + (read-render-result PDF-HEADER-BYTES)))) diff --git a/exporter/src/app/wasm/enums.cljs b/exporter/src/app/wasm/enums.cljs new file mode 100644 index 0000000000..9c1a429522 --- /dev/null +++ b/exporter/src/app/wasm/enums.cljs @@ -0,0 +1,19 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.enums + "Binds this build's generated enums into the shared bridge. + + `shared.js` is emitted next to this file by `render-wasm/build export` and is + not committed. Requiring this namespace is what makes + `app.common.render-wasm.wasm/serializers` usable." + (:require + ["./shared.js" :as shared] + [app.common.render-wasm.wasm :as wasm]) + (:require-macros + [app.common.render-wasm.enums :as enums])) + +(wasm/init-serializers! (enums/serializers shared)) diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs new file mode 100644 index 0000000000..cbb08c27f3 --- /dev/null +++ b/exporter/src/app/wasm/serialize.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.serialize + "Browser-free shape serialization for the headless exporter: the counterpart + of `app.render-wasm.api/set-object`, which cannot be reused directly because + its namespace pulls React/DOM/store. Only the call sequencing lives here — + every byte layout comes from the shared serializers, so the bytes sent to + WASM are the editor's. + + Covers everything except svg-raw. Image bytes and fonts are provisioned + separately by `app.renderer.wasm`." + (:require + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.wasm :as wasm] + [app.wasm.text :as text])) + +(defn set-shape! + "Serializes a single shape into the WASM design state. The host-independent + properties (base props, children, blur, shadows, svg-attrs, mask, bool-type, + path geometry, grow-type) go through the shared `serialize-shape!` — the same + code the workspace's `set-object` uses, so the two can't drift. Only the + host-specific parts are handled here: fills/strokes (image bytes are provisioned + separately) and text content (fonts provisioned separately)." + [shape] + (let [type (get shape :type)] + (serialize-shape/serialize-shape! shape) + (props/write-shape-fills! (get shape :fills)) + (when-not (= type :group) + (props/write-shape-strokes! (get shape :strokes))) + (when (= type :text) + (text/set-shape-text! (get shape :content))))) + +(defn serialize-scene! + "Loads every shape of an `objects` map into the WASM design state. Resets the + shapes pool first so repeated exports don't accumulate into the shared + state. Order is irrelevant: shapes reference each other by id and the tree + is resolved at render time." + [objects] + (h/call wasm/internal-module "_init_shapes_pool" (count objects)) + (run! set-shape! (vals objects))) diff --git a/exporter/src/app/wasm/text.cljs b/exporter/src/app/wasm/text.cljs new file mode 100644 index 0000000000..714542d7f1 --- /dev/null +++ b/exporter/src/app/wasm/text.cljs @@ -0,0 +1,35 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.text + "Browser-free text-content serialization for the headless exporter. Only the + paragraph walk is local: the binary layout and the font-id -> uuid mapping + both come from `app.common.render-wasm.text-content`." + (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.text-content :as tc] + [app.common.render-wasm.wasm :as wasm])) + +(defn set-shape-text! + "Serializes a text shape's content into the current WASM shape. Mirrors the + editor's sequence: clear -> vertical-align -> append each paragraph -> layout. + Byte writing and font resolution are the shared + `text-content/write-shape-text!` defaults; the exporter has no fonts DB, so + it injects no variant normalization." + [content] + (when content + (h/call wasm/internal-module "_clear_shape_text") + (h/call wasm/internal-module "_set_shape_vertical_align" + (sr/translate-vertical-align (get content :vertical-align))) + (let [paragraph-set (first (get content :children)) + paragraphs (get paragraph-set :children)] + (doseq [paragraph paragraphs] + (let [spans (get paragraph :children)] + (when (seq spans) + (let [text (apply str (map :text spans))] + (tc/write-shape-text! spans paragraph text {})))))) + (h/call wasm/internal-module "_update_shape_text_layout"))) diff --git a/frontend/deps.edn b/frontend/deps.edn index a662b7086d..f060448343 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -1,6 +1,7 @@ {:paths ["src" "vendor" "resources" "test"] :deps - {penpot/common + {;; Carries `app.common.render-wasm.*`, shared with the headless exporter. + penpot/common {:local/root "../common"} org.clojure/clojure {:mvn/version "1.12.2"} diff --git a/frontend/package.json b/frontend/package.json index e61395d4f9..2a257ef13d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,7 @@ "build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", "build:storybook:assets": "node ./scripts/build-storybook-assets.js", "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", - "build:wasm": "../render-wasm/build", + "build:wasm": "../render-wasm/build frontend", "build:app:libs": "node ./scripts/build-libs.js", "build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker", diff --git a/frontend/scripts/build b/frontend/scripts/build index 2cc812e228..fe40d223bc 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -30,7 +30,7 @@ mkdir -p target/dist; # Build render wasm binary pushd ../render-wasm; -./build +./build frontend popd pushd ../mcp; diff --git a/frontend/scripts/build-fonts-preview.js b/frontend/scripts/build-fonts-preview.js index b1aa7c839c..6c609252b4 100644 --- a/frontend/scripts/build-fonts-preview.js +++ b/frontend/scripts/build-fonts-preview.js @@ -68,7 +68,7 @@ function slug(value) { } async function findGfontsJson() { - const dir = "resources/fonts"; + const dir = "../common/resources/fonts"; const entries = await fs.readdir(dir); const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort(); if (matches.length === 0) { diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index cedfad1d96..8917f2e97f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -18,6 +18,7 @@ [app.common.geom.shapes :as gsh] [app.common.logging :as log] [app.common.path-names :as cpn] + [app.common.render-wasm.wasm :as wasm-state] [app.common.transit :as t] [app.common.types.component :as ctc] [app.common.types.components-list :as ctkl] @@ -77,7 +78,6 @@ [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm-state] [app.util.dom :as dom] [app.util.globals :as ug] [app.util.http :as http] diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 677f8aa1fb..b41a9d6135 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -6,10 +6,10 @@ (ns app.main.fonts "Fonts management and loading logic." - (:require-macros [app.main.fonts :refer [preload-gfonts]]) (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.fonts :as cfnt] [app.common.logging :as log] [app.common.types.text :as txt] [app.common.uri :as u] @@ -25,27 +25,6 @@ (log/set-level! :warn) -(def google-fonts - (preload-gfonts "fonts/gfonts.2025.11.28.json")) - -(def local-fonts - [{:id "sourcesanspro" - :name "Source Sans Pro" - :family "sourcesanspro" - :variants - [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"} - {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"} - {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"} - {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} - {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} - {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} - {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} - {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} - {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} - {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} - {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} - {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}]) - (defonce fontsdb (l/atom {})) (defonce fonts (l/atom [])) @@ -65,10 +44,10 @@ fonts (map #(assoc % :backend backend) fonts)] (merge db (d/index-by :id fonts)))))) -(register! :builtin local-fonts) +(register! :builtin cfnt/local-fonts) (when (contains? cf/flags :google-fonts-provider) - (register! :google google-fonts)) + (register! :google cfnt/catalog)) (defn get-font-data [id] (get @fontsdb id)) @@ -266,8 +245,7 @@ (defn- process-gfont-css [css] - (let [base (u/join cf/public-uri "internal/gfonts/font")] - (str/replace css "https://fonts.gstatic.com/s" (dm/str base)))) + (cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font"))) (defn- fetch-gfont-css [url] @@ -397,42 +375,12 @@ (defn find-closest-variant "Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match. - When exactly between two weights, choose the higher one." + When exactly between two weights, choose the higher one. + + The algorithm lives in `app.common.fonts` so the headless exporter resolves the + same variant for the same text." [font target-weight target-style] - (when-let [target-weight (d/parse-integer target-weight)] - (let [variants (:variants font []) - result - (reduce - (fn [closest-match variant] - (let [weight (d/parse-integer (:weight variant)) - distance (abs (- target-weight weight)) - matches-style? (= target-style (:style variant)) - current {:variant variant - :weight weight - :distance distance}] - (cond - ;; Exact match found - (and (zero? distance) - (if target-style matches-style? true)) - (reduced current) - - (nil? closest-match) current - - ;; Update best match if this variant is closer or equal distance but higher weight - (or (< distance (:distance closest-match)) - (and (= distance (:distance closest-match)) - (> weight (:weight closest-match)))) - current - - ;; Same weight as the `closest-match` but the style matches `target-style` - (and (= weight (:weight closest-match)) matches-style?) - current - - :else - closest-match))) - nil - variants)] - (:variant result)))) + (cfnt/closest-variant (:variants font []) target-weight target-style)) ;; Font embedding functions (defn get-node-fonts diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 45f7b83f73..1d4cd89506 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -9,8 +9,8 @@ (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm] [app.util.dom :as dom] [app.util.timers :as ts] [app.util.webapi :as webapi] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 46da6a7792..e7b0b03c0b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -13,8 +13,17 @@ [app.common.exceptions :as ex] [app.common.files.focus :as cpf] [app.common.files.helpers :as cfh] + [app.common.fonts :as cfnt] [app.common.logging :as log] [app.common.math :as mth] + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.mem.heap32 :as mem.h32] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] [app.common.types.path :as path] @@ -31,23 +40,17 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.shapes.text] + ;; Required for side effects: binds the generated enums. + [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as f] - [app.render-wasm.api.props :as props] [app.render-wasm.api.texts :as t] [app.render-wasm.api.webgl :as webgl] [app.render-wasm.deserializers :as dr] [app.render-wasm.gesture :as wasm-gesture] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.mem.heap32 :as mem.h32] [app.render-wasm.performance :as perf] [app.render-wasm.rulers-state :as rulers-state] - [app.render-wasm.serialize-shape :as serialize-shape] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.text-editor :as text-editor] - [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.functions :as fns] @@ -1295,8 +1298,8 @@ langs) (let [text (apply str (map :text spans)) - emoji? (if emoji? emoji? (t/contains-emoji? text)) - langs (t/collect-used-languages langs text)] + emoji? (if emoji? emoji? (cfnt/contains-emoji? text)) + langs (cfnt/collect-used-languages langs text)] ;; FIXME: this should probably be somewhere else (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) @@ -1307,8 +1310,8 @@ (let [updated-fonts (-> #{} - (cond-> ^boolean emoji? (f/add-emoji-font)) - (f/add-noto-fonts langs)) + (cond-> ^boolean emoji? (cfnt/add-emoji-font)) + (cfnt/add-noto-fonts langs)) fallback-fonts (filter #(get % :is-fallback) updated-fonts)] (if fallback-fonts-only? updated-fonts fallback-fonts)))))) diff --git a/frontend/src/app/render_wasm/api/enums.cljs b/frontend/src/app/render_wasm/api/enums.cljs new file mode 100644 index 0000000000..de132c4638 --- /dev/null +++ b/frontend/src/app/render_wasm/api/enums.cljs @@ -0,0 +1,19 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.render-wasm.api.enums + "Binds this build's generated enums into the shared bridge. + + `shared.js` is emitted next to this file by `render-wasm/build frontend` and + is not committed. Requiring this namespace is what makes + `app.common.render-wasm.wasm/serializers` usable." + (:require + ["./shared.js" :as shared] + [app.common.render-wasm.wasm :as wasm]) + (:require-macros + [app.common.render-wasm.enums :as enums])) + +(wasm/init-serializers! (enums/serializers shared)) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c3d5a32a35..f6a9c83b6e 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -8,15 +8,15 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.fonts :as cfnt] [app.common.logging :as log] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.config :as cf] [app.main.fonts :as fonts] [app.main.store :as st] - [app.render-wasm.fallback-fonts :as fbf] - [app.render-wasm.helpers :as h] - [app.render-wasm.wasm :as wasm] [app.util.http :as http] [app.util.timers :as tm] [beicon.v2.core :as rx] @@ -39,33 +39,6 @@ (def ^:private default-line-height 1.2) (def ^:private default-letter-spacing 0.0) -(defn- google-font-id->uuid - "Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the - font is not found in fontsdb. uuid/zero maps to the default font (Source - Sans Pro) in WASM. - A font id may not exist for different reasons: - - the gfonts.json catalog was updated and fonts were renamed or removed, - - the file was imported from another Penpot instance with different fonts, - ..." - [font-id] - (let [font (fonts/get-font-data font-id) - result (:uuid font)] - (or result uuid/zero))) - -(defn- custom-font-id->uuid - [font-id] - (uuid/uuid (subs font-id (inc (str/index-of font-id "-"))))) - -(defn- font-backend - [font-id] - (cond - (str/starts-with? font-id "gfont-") - :google - (str/starts-with? font-id "custom-") - :custom - :else - :builtin)) - (defn- font-db-data [font-id font-variant-id font-weight-fallback font-style-fallback] (let [font (fonts/get-font-data font-id) @@ -75,15 +48,6 @@ variant closest-variant))) -(defn- font-id->uuid [font-id] - (case (font-backend font-id) - :google - (google-font-id->uuid font-id) - :custom - (custom-font-id->uuid font-id) - :builtin - uuid/zero)) - (defn uuid->font-id [font-uuid] (if (= font-uuid uuid/zero) @@ -100,11 +64,11 @@ "regular"))) (defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style] - (case (font-backend font-id) + (case (cfnt/font-id->backend font-id) :google font-id :custom - (let [font-uuid (custom-font-id->uuid font-id) + (let [font-uuid (cfnt/font-id->uuid font-id) matching-font (some (fn [[_ font]] (and (= (:font-id font) font-uuid) (= (str (:font-weight font)) (str font-weight)) @@ -194,13 +158,12 @@ (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] (let [variant (font-db-data font-id font-variant-id font-weight font-style)] - (if-let [ttf-url (:ttf-url variant)] - (str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/")) - nil))) + (when-let [ttf-url (:ttf-url variant)] + (cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font"))))) (defn- font-id->ttf-url [font-id asset-id font-variant-id font-weight font-style] - (case (font-backend font-id) + (case (cfnt/font-id->backend font-id) :google (google-font-ttf-url font-id font-variant-id font-weight font-style) :custom @@ -245,18 +208,6 @@ "italic" 1 0)) -(defn normalize-font-id - [font-id] - (try - (if ^boolean (str/starts-with? font-id "gfont-") - (google-font-id->uuid font-id) - (let [no-prefix (subs font-id (inc (str/index-of font-id "-")))] - (if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix)) - uuid/zero - (uuid/parse no-prefix)))) - (catch :default _e - uuid/zero))) - (defn normalize-span-font [span paragraph] (let [font-id (:font-id span) @@ -358,7 +309,7 @@ emoji? (get font :is-emoji false) fallback? (get font :is-fallback false) font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback) - wasm-id (font-id->uuid font-id) + wasm-id (cfnt/font-id->uuid font-id) raw-weight (or (:weight font-data) font-weight-fallback) weight (serialize-font-weight raw-weight) style (cond @@ -415,7 +366,3 @@ (defn store-fonts [fonts] (keep (fn [font] (store-font font)) fonts)) - -(def add-emoji-font fbf/add-emoji-font) -(def noto-fonts fbf/noto-fonts) -(def add-noto-fonts fbf/add-noto-fonts) diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index 3ea55dfdb3..5a6565bc1e 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -6,21 +6,13 @@ (ns app.render-wasm.api.texts (:require - [app.render-wasm.api.fonts :as f] - [app.render-wasm.fallback-fonts :as fbf] - [app.render-wasm.text-content :as tc])) + [app.common.render-wasm.text-content :as tc] + [app.render-wasm.api.fonts :as f])) (defn write-shape-text "Workspace text serialization: the byte writing is shared via - `app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." + `app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." [spans paragraph text] (tc/write-shape-text! spans paragraph text - {:normalize-font-id f/normalize-font-id - :normalize-paragraph f/normalize-paragraph-font + {:normalize-paragraph f/normalize-paragraph-font :normalize-span f/normalize-span-font})) - -;; Emoji/script detection lives in the host-agnostic -;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing -;; workspace callers. -(def contains-emoji? fbf/contains-emoji?) -(def collect-used-languages fbf/collect-used-languages) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index fc7a3fe37f..268dfe4462 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -8,7 +8,7 @@ "WebGL utilities for pixel capture and rendering" (:require [app.common.logging :as log] - [app.render-wasm.wasm :as wasm] + [app.common.render-wasm.wasm :as wasm] [promesa.core :as p])) (defn get-webgl-context diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 94108ea456..3c26916b2f 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -7,16 +7,18 @@ (ns app.render-wasm.text-editor "Text editor WASM bindings" (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.fonts :as main-fonts] + ;; Required for side effects: binds the generated enums. + [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as fonts] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm] [app.util.color :as uc] [app.util.dom :as dom])) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index 21d4e37190..0dd8916cf7 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -11,6 +11,7 @@ [app.common.geom.rect :as grc] [app.common.geom.shapes.bounds :as gsb] [app.common.logging :as log] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as cc] [app.common.uri :as u] [app.common.uuid :as uuid] @@ -18,7 +19,6 @@ [app.main.fonts :as fonts] [app.main.render :as render] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm] [app.util.http :as http] [app.worker.impl :as impl] [beicon.v2.core :as rx] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fb5cd5b5fe..f2ec88f412 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -15,6 +15,9 @@ [app.common.json :as json] [app.common.logging :as l] [app.common.pprint :as pp] + [app.common.render-wasm.helpers :as wasm.h] + [app.common.render-wasm.mem :as wasm.mem] + [app.common.render-wasm.wasm :as wasm] [app.common.transit :as t] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] @@ -36,9 +39,6 @@ [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] - [app.render-wasm.helpers :as wasm.h] - [app.render-wasm.mem :as wasm.mem] - [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.http :as http] diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index c985b336ae..cf9e68670a 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -15,9 +15,9 @@ font URL get no callback (fetch-font returns nil when the URL is already in :fetching) and are permanently stuck with fallback-font layout metrics." (:require + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.mem :as mem] - [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true])) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs index 2594e84995..09203f4bae 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs @@ -11,7 +11,7 @@ anything else (no fill, gradient, image fills, mixed selection) it falls back to an inverted caret (white painted with a Difference blend)." (:require - [app.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.serializers.color :as sr-clr] [app.render-wasm.text-editor :as text-editor] [cljs.test :as t :include-macros true])) diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 15f8f39335..b17e3aa69f 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -42,3 +42,7 @@ opt-level = 3 lto = "fat" strip = true codegen-units = 1 + +[profile.size] +inherits = "release" +opt-level = "z" diff --git a/render-wasm/README.md b/render-wasm/README.md index b1010bb70c..4ed4830bcd 100644 --- a/render-wasm/README.md +++ b/render-wasm/README.md @@ -29,6 +29,34 @@ You can also use `./watch` to run the build on every change. The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app. +### Render targets + +The same Rust source produces two artifacts, which differ only in compiler +options: + +| Target | Tuned for | Cargo profile | Consumed by | +| ---------- | --------- | ----------------- | ------------------------------ | +| `frontend` | speed | `release` (`-O3`) | `frontend/resources/public/js` | +| `export` | size | `size` (`-Oz`) | `exporter/resources/wasm` | + +```sh +./build # both targets, frontend first +./build frontend # workspace / viewer renderer +./build export # headless exporter renderer +``` + +`./watch` still follows a single target (`frontend` unless you pass one), +since watching both would rebuild twice on every keystroke. + +Each target keeps its own `CARGO_TARGET_DIR` (`target/`), so switching +between them does not invalidate the other's cache. Set `BUILD_MODE=release` +(or `NODE_ENV=production`) for an optimized build; the default is `debug`. + +Each target writes its own generated `shared.js` (the enum discriminants the +CLJS side compiles against) next to the code that imports it — respectively +`frontend/src/app/render_wasm/api/shared.js` and +`exporter/src/app/wasm/shared.js`. Neither build writes to the other's paths. + ![Architecture overview](docs/images/architecture_schema.png) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 0b506e415d..e034580dfd 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -1,15 +1,25 @@ #!/usr/bin/env bash export VERSION_TAG=${VERSION:-develop}; +export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}"; + +case "$RENDER_TARGET" in + frontend|export) ;; + *) + echo "ERROR: unknown render target '$RENDER_TARGET' (expected 'frontend' or 'export')" >&2; + exit 1; + ;; +esac if [ "$NODE_ENV" = "production" ]; then export BUILD_MODE="release"; else - export BUILD_MODE=${1:-debug}; + export BUILD_MODE=${BUILD_MODE:-debug}; fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; +export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} # 256 MB of initial heap to perform less @@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache"; export CARGO_PARAMS="${@:2}"; +export CARGO_PROFILE_DIR="debug"; + if [ "$BUILD_MODE" = "release" ]; then - export CARGO_PARAMS="--release $CARGO_PARAMS" - export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS" + case "$RENDER_TARGET" in + frontend) + export CARGO_PARAMS="--release $CARGO_PARAMS"; + export CARGO_PROFILE_DIR="release"; + export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + export) + export CARGO_PARAMS="--profile size $CARGO_PARAMS"; + export CARGO_PROFILE_DIR="size"; + export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + esac else # TODO: Extra parameters that could be good to look into: # -gseparate-dwarf @@ -62,6 +84,12 @@ else export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS" fi +export FRONTEND_DEST="../frontend/resources/public/js"; +export EXPORT_DEST="../exporter/resources/wasm"; + +export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js"; +export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js"; + function clean { cargo clean; } @@ -78,26 +106,48 @@ function build { function copy_artifacts { DEST=$1; + SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR"; mkdir -p $DEST; - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js; - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm; - if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; + cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js; + cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm; + if [ -f $SRC/render_wasm.wasm.map ]; then + cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; fi sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js; - pnpm exec esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \ - --log-level=error \ - --outfile=$DEST/worker/render.js \ - --platform=neutral \ - --format=iife \ - --global-name=WasmModule; + # The worker bundle is a browser concern; the exporter imports the ESM + # module directly under Node. + if [ "$RENDER_TARGET" = "frontend" ]; then + pnpm exec esbuild $SRC/render_wasm.js \ + --log-level=error \ + --outfile=$DEST/worker/render.js \ + --platform=neutral \ + --format=iife \ + --global-name=WasmModule; + fi } function copy_shared_artifact { - SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1); - cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; + DEST=$1; + SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1); + + cp $SHARED_FILE $DEST; +} + +# Copies whatever the current RENDER_TARGET produced to where that target's +# consumer reads it. +function copy_target_artifacts { + case "$RENDER_TARGET" in + frontend) + copy_artifacts "$FRONTEND_DEST"; + copy_shared_artifact "$FRONTEND_SHARED_DEST"; + ;; + export) + copy_artifacts "$EXPORT_DEST"; + copy_shared_artifact "$EXPORT_SHARED_DEST"; + ;; + esac } diff --git a/render-wasm/build b/render-wasm/build index 2505ae0cf9..2a832fc3f4 100755 --- a/render-wasm/build +++ b/render-wasm/build @@ -1,8 +1,26 @@ #!/usr/bin/env bash +# Usage: ./build [frontend|export] [extra cargo params...] +# +# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production) +# for an optimized build. See `_build_env` for what each target changes. + +_SCRIPT_DIR=$(dirname $0); + +# Each target needs its own `_build_env`, so re-enter per target. +case "${1:-}" in + frontend|export) + ;; + *) + for _target in frontend export; do + "$_SCRIPT_DIR/build" "$_target" "$@" || exit $?; + done + exit 0; + ;; +esac + EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh -_SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; . ./_build_env @@ -11,8 +29,7 @@ set -ex; setup; build; -copy_artifacts "../frontend/resources/public/js"; -copy_shared_artifact; +copy_target_artifacts; exit $?; diff --git a/render-wasm/lint b/render-wasm/lint index e94145189a..4ed7d6826d 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then set -x fi -. ./_build_env +. ./_build_env frontend export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index bf069d2b1c..e027aebd21 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -329,15 +329,21 @@ fn render_text_on_canvas( layer_opacity: Option, overlay_emoji: bool, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - let blur_layer = SaveLayerRec::default().paint(&blur_paint); + let blur_layer = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint); canvas.save_layer(&blur_layer); } if let Some(shadow_paint) = shadow { - let layer_rec = SaveLayerRec::default().paint(shadow_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(shadow_paint); canvas.save_layer(&layer_rec); draw_text( canvas, @@ -351,7 +357,9 @@ fn render_text_on_canvas( if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { let mut layer_paint = Paint::default(); layer_paint.set_image_filter(erode); - let layer_rec = SaveLayerRec::default().paint(&layer_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&layer_paint); canvas.save_layer(&layer_rec); draw_text( canvas, @@ -582,7 +590,10 @@ fn draw_decoration_stroke( skia::BlendMode::SrcOut }; - canvas.save_layer(&SaveLayerRec::default()); + let outset = stroke_paint.stroke_width().max(0.0); + let layer_bounds = bar.with_outset((outset, outset)); + + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); let mut mask_paint = Paint::default(); mask_paint.set_color(skia::Color::BLACK); mask_paint.set_anti_alias(true); @@ -590,7 +601,11 @@ fn draw_decoration_stroke( let mut blend_paint = Paint::default(); blend_paint.set_blend_mode(blend); - canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blend_paint), + ); canvas.draw_rect(bar, stroke_paint); canvas.restore(); canvas.restore(); @@ -705,7 +720,12 @@ pub fn render_emoji_overlay( if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint), + ); } for (emoji_para, deco_para) in emoji_layout @@ -728,13 +748,17 @@ fn draw_text( layer_opacity: Option, overlay_emoji: bool, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); - let layer_rec = SaveLayerRec::default().paint(&opacity_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&opacity_paint); canvas.save_layer(&layer_rec); } else { - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); } paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); @@ -759,27 +783,41 @@ fn render_masked_stroke_on_canvas( blur: Option<&ImageFilter>, layer_opacity: Option, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint), + ); } if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); - canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&opacity_paint), + ); } - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); paint_text(canvas, shape, mask_builders); let mut stroke_paint = Paint::default(); stroke_paint.set_blend_mode(stroke_mask_blend); - canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&stroke_paint), + ); paint_text(canvas, shape, stroke_builders); @@ -789,7 +827,11 @@ fn render_masked_stroke_on_canvas( if let Some(fill_builders) = fill_builders { let mut dst_over_paint = Paint::default(); dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); - canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&dst_over_paint), + ); paint_text(canvas, shape, fill_builders); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 23b797023e..a536955c6d 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -79,11 +79,14 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { + let layer_bounds = shape.layer_bounds(); for shadow in shape.drop_shadows_visible() { if let Some(filter) = shadow.get_drop_shadow_filter() { let mut paint = Paint::default(); paint.set_image_filter(filter); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); self.canvas.save_layer(&layer_rec); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); @@ -99,10 +102,14 @@ impl ShapeRenderer for VectorRenderer<'_> { if !shape.has_fills() { return Ok(()); } + let layer_bounds = shape.layer_bounds(); for shadow in shape.inner_shadows_visible() { let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint), + ); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); @@ -161,9 +168,13 @@ impl ShapeRenderer for VectorRenderer<'_> { }) .collect(); + let layer_bounds = shape.layer_bounds(); for shadow_paint in &drop_shadows { - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint)); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(shadow_paint), + ); text::render_overlay_emoji( self.canvas, @@ -331,7 +342,10 @@ impl ShapeRenderer for VectorRenderer<'_> { if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { let mut paint = Paint::default(); paint.set_image_filter(filter); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = shape.layer_bounds(); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); self.canvas.save_layer(&layer_rec); true } else { @@ -715,7 +729,10 @@ fn render_group( } } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.extrect(tree, scale); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -726,7 +743,12 @@ fn render_group( // as content, then re-draw the mask silhouette (the group's first child) // with DstIn to clip everything to it. let paint = Paint::default(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + let subtree_bounds = element.extrect(tree, scale); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&paint), + ); for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; @@ -735,7 +757,11 @@ fn render_group( if let Some(mask_id) = element.mask_id() { let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&mask_paint), + ); render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?; canvas.restore(); // mask layer } @@ -797,7 +823,10 @@ fn render_frame( } } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.extrect(tree, scale); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -857,13 +886,18 @@ fn render_container_drop_shadows( draw_fills: bool, opts: &TreeOpts, ) -> Result<()> { + let subtree_bounds = element.extrect(tree, scale); for shadow in element.drop_shadows_visible() { let Some(filter) = shadow.get_drop_shadow_filter() else { continue; }; let mut paint = Paint::default(); paint.set_image_filter(filter); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&paint), + ); if draw_fills && !element.fills.is_empty() { let mut renderer = VectorRenderer::new(canvas, shared, scale); @@ -902,7 +936,10 @@ fn render_leaf( let mut paint = Paint::default(); paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.layer_bounds(); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -1101,7 +1138,8 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint } StrokeKind::Outer => { canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); draw_shape_geometry(canvas, shape, paint); let mut clear_paint = Paint::default(); clear_paint.set_blend_mode(skia::BlendMode::Clear); @@ -1134,7 +1172,8 @@ fn draw_image_stroke( let container = shape.selrect; canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); // Opaque stroke silhouette; the SrcIn image draw below fills it. draw_stroke_geometry(canvas, scale, shape, stroke, true); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 47269c5288..dbdeb74be2 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1070,7 +1070,7 @@ impl Shape { extrect } - fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + fn own_extrect_bounds(&self) -> Bounds { let shape = self; let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open()); @@ -1096,6 +1096,19 @@ impl Shape { bounds = self.apply_stroke_bounds(bounds, max_stroke); bounds = self.apply_shadow_bounds(bounds); bounds = self.apply_blur_bounds(bounds); + bounds + } + + /// Bound for a `SaveLayerRec` wrapping this shape's own drawing, in + /// untransformed space (callers concatenate [`Self::centered_transform`] + /// first). Includes shadow/blur margins, so it is also a valid input bound + /// for a layer whose paint carries an image filter. + pub fn layer_bounds(&self) -> math::Rect { + self.own_extrect_bounds().to_rect() + } + + fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + let mut bounds = self.own_extrect_bounds(); bounds = self.apply_children_bounds(bounds, shapes_pool, scale); bounds = self.apply_children_blur(bounds, shapes_pool); diff --git a/render-wasm/test b/render-wasm/test index f416e6c6bb..8019b1fecd 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -7,7 +7,7 @@ export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"}; _SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; -. ./_build_env +. ./_build_env frontend cargo test --bin render_wasm -- --show-output diff --git a/render-wasm/watch b/render-wasm/watch index 90c08c9ffd..1825bf7667 100755 --- a/render-wasm/watch +++ b/render-wasm/watch @@ -1,5 +1,7 @@ #!/usr/bin/env bash +# Usage: ./watch [frontend|export] + _SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; @@ -7,8 +9,7 @@ pushd $_SCRIPT_DIR; set -x build; -copy_artifacts "../frontend/resources/public/js"; -copy_shared_artifact; +copy_target_artifacts; pushd $_SCRIPT_DIR; @@ -16,7 +17,7 @@ cargo watch \ --why \ -i "_tmp*" \ -x "build $CARGO_PARAMS" \ - -s "./build" \ + -s "./build $RENDER_TARGET" \ -s "echo 'DONE\n'"; popd