mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 04:48:39 +00:00
✨ Support multiple page PDF export over headless exporter
This commit is contained in:
parent
54bef496f1
commit
2878d8d0f3
@ -72,6 +72,12 @@ export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
|
||||
|
||||
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
|
||||
|
||||
# Headless WASM export: render `:is-wasm` exports via the in-process Skia/WASM
|
||||
# pipeline (no browser). Reads the render-wasm artifact from PENPOT_WASM_DIR,
|
||||
# defaulting to ../frontend/resources/public/js (render-wasm.js/.wasm).
|
||||
export PENPOT_WASM_HEADLESS=true
|
||||
# export PENPOT_WASM_DIR=../frontend/resources/public/js
|
||||
|
||||
export JAVA_OPTS="\
|
||||
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
|
||||
-Djdk.attach.allowAttachSelf \
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
{:paths ["src" "vendor" "resources" "test"]
|
||||
{:paths ["src" "vendor" "resources" "test" "../frontend/src" "../frontend/resources"]
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
binaryage/devtools {:mvn/version "1.0.7"}
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
;; Needed by the shared render-wasm font namespaces reused from ../frontend/src
|
||||
;; (the font db uses an okulary atom so the editor keeps its reactivity).
|
||||
funcool/okulary {:mvn/version "2022.04.11-16"}
|
||||
}
|
||||
:aliases
|
||||
{:outdated
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
{:deps {:aliases [:dev]}
|
||||
:source-paths ["src" "vendor" "../common"]
|
||||
;; ../frontend/src is on the path so the exporter can reuse the *portable*
|
||||
;; render-wasm serialization leaves (app.render-wasm.{wasm,mem,helpers,
|
||||
;; serializers,api.shapes,...}). shadow-cljs compiles only the required
|
||||
;; dependency graph, so the browser-coupled namespaces (api, api.webgl,
|
||||
;; api.fonts) are never pulled in — the workspace build is unaffected.
|
||||
:source-paths ["src" "vendor" "../common" "../frontend/src"]
|
||||
|
||||
:builds
|
||||
{:main
|
||||
|
||||
@ -40,7 +40,12 @@
|
||||
[:redis-uri {:optional true} :string]
|
||||
[:tempdir {:optional true} :string]
|
||||
[:browser-pool-max {:optional true} ::sm/int]
|
||||
[:browser-pool-min {:optional true} ::sm/int]])
|
||||
[:browser-pool-min {:optional true} ::sm/int]
|
||||
;; Headless WASM export (no browser). When true, `:is-wasm` exports render
|
||||
;; via the in-process Skia/WASM pipeline. `:wasm-dir` points at the built
|
||||
;; render-wasm artifact (render-wasm.js/.wasm).
|
||||
[:wasm-headless {:optional true} :boolean]
|
||||
[:wasm-dir {:optional true} :string]])
|
||||
|
||||
(def ^:private decode-config
|
||||
(sm/decoder schema:config sm/string-transformer))
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
(handle-export exchange (assoc params :exports exports))))
|
||||
|
||||
(defn handle-export
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id is-wasm] :as params}]
|
||||
[{:keys [:request/auth-token :request/auth-scheme] :as exchange} {:keys [exports name profile-id is-wasm] :as params}]
|
||||
(let [topic (str profile-id)
|
||||
file-id (-> exports first :file-id)
|
||||
|
||||
@ -95,14 +95,14 @@
|
||||
|
||||
procs
|
||||
(->> (seq exports)
|
||||
(map #(rd/render (assoc % :is-wasm is-wasm) on-object)))]
|
||||
(map #(rd/render (assoc % :is-wasm is-wasm :token-scheme auth-scheme) on-object)))]
|
||||
|
||||
(->> (p/all procs)
|
||||
(p/fmap (fn [] @result-cache))
|
||||
(p/mcat (partial join-pdf file-id))
|
||||
(p/mcat (partial move-file resource))
|
||||
(p/fmap (constantly resource))
|
||||
(p/mcat (partial rsc/upload-resource auth-token))
|
||||
(p/mcat (partial rsc/upload-resource auth-token auth-scheme))
|
||||
(p/mcat (fn [resource]
|
||||
(->> (sh/stat (:path resource))
|
||||
(p/fmap #(merge resource %)))))
|
||||
|
||||
@ -62,15 +62,18 @@
|
||||
(handle-multiple-export exchange (assoc params :exports exports)))))
|
||||
|
||||
(defn- handle-single-export
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}]
|
||||
[{:keys [:request/auth-token :request/auth-scheme] :as exchange} {:keys [export name skip-children is-wasm] :as params}]
|
||||
(let [resource (rsc/create (:type export) (or name (:name export)))
|
||||
export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))]
|
||||
export (assoc export
|
||||
:skip-children skip-children
|
||||
:is-wasm (boolean is-wasm)
|
||||
:token-scheme auth-scheme)]
|
||||
|
||||
(->> (rd/render export
|
||||
(fn [{:keys [path] :as object}]
|
||||
(sh/move! path (:path resource))))
|
||||
(p/fmap (constantly resource))
|
||||
(p/mcat (partial rsc/upload-resource auth-token))
|
||||
(p/mcat (partial rsc/upload-resource auth-token auth-scheme))
|
||||
(p/fmap (fn [resource]
|
||||
(dissoc resource :path)))
|
||||
(p/fmap (fn [resource]
|
||||
@ -81,7 +84,7 @@
|
||||
(p/rejected cause))))))
|
||||
|
||||
(defn- handle-multiple-export
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}]
|
||||
[{:keys [:request/auth-token :request/auth-scheme] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}]
|
||||
(let [resource (rsc/create :zip (or name (-> exports first :name)))
|
||||
total (count exports)
|
||||
topic (str profile-id)
|
||||
@ -112,11 +115,11 @@
|
||||
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
|
||||
|
||||
proc (->> exports
|
||||
(map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append)))
|
||||
(map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm) :token-scheme auth-scheme) append)))
|
||||
(p/all)
|
||||
(p/mcat (fn [_] (rsc/close-zip zip)))
|
||||
(p/fmap (constantly resource))
|
||||
(p/mcat (partial rsc/upload-resource auth-token))
|
||||
(p/mcat (partial rsc/upload-resource auth-token auth-scheme))
|
||||
(p/fmap (fn [resource]
|
||||
(let [data {:type :export-update
|
||||
:name (:name resource)
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
(ns app.handlers.resources
|
||||
"Temporal resources management."
|
||||
(:require
|
||||
["archiver" :as arc]
|
||||
["archiver" :refer [ZipArchive]]
|
||||
["node:fs" :as fs]
|
||||
["node:fs/promises" :as fsp]
|
||||
["node:path" :as path]
|
||||
@ -17,6 +17,7 @@
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.util.auth :as auth]
|
||||
[app.util.mime :as mime]
|
||||
[app.util.shell :as sh]
|
||||
[cljs.core :as c]
|
||||
@ -41,7 +42,7 @@
|
||||
|
||||
(defn create-zip
|
||||
[& {:keys [resource on-complete on-progress on-error]}]
|
||||
(let [^js zip (new arc/ZipArchive)
|
||||
(let [^js zip (new ZipArchive #js {})
|
||||
^js out (fs/createWriteStream (:path resource))
|
||||
on-complete (or on-complete (constantly nil))
|
||||
progress (atom 0)]
|
||||
@ -65,15 +66,17 @@
|
||||
(.finalize ^js zip))))
|
||||
|
||||
(defn upload-resource
|
||||
[auth-token resource]
|
||||
(->> (fsp/readFile (:path resource))
|
||||
([auth-token resource]
|
||||
(upload-resource auth-token :bearer resource))
|
||||
([auth-token auth-scheme resource]
|
||||
(->> (fsp/readFile (:path resource))
|
||||
(p/fmap (fn [buffer]
|
||||
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
|
||||
(p/mcat (fn [blob]
|
||||
(let [fdata (new http/FormData)
|
||||
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
|
||||
"Authorization" (str "Bearer " auth-token)}
|
||||
"Authorization" (auth/header-value auth-token auth-scheme)}
|
||||
|
||||
request #js {:headers headers
|
||||
:method "POST"
|
||||
@ -95,4 +98,4 @@
|
||||
(.text response))))
|
||||
(p/fmap t/decode-str)
|
||||
(p/fmap (fn [result]
|
||||
(merge resource (dissoc result :id))))))
|
||||
(merge resource (dissoc result :id)))))))
|
||||
|
||||
@ -120,10 +120,17 @@
|
||||
(p/catch (fn [cause] (on-error cause exchange))))))
|
||||
|
||||
(defn- wrap-auth
|
||||
"Resolves the auth token from the session cookie and records its scheme
|
||||
(`:request/auth-scheme` is always `:cookie` here) so downstream backend
|
||||
calls can forward it appropriately. Header-based API authentication is
|
||||
deliberately NOT accepted on this branch — external access to /api/export
|
||||
ships separately (export-api branch)."
|
||||
[handler cookie-name]
|
||||
(fn [{:keys [:request/cookies] :as exchange}]
|
||||
(let [token (.get ^js cookies cookie-name)]
|
||||
(handler (cond-> exchange token (assoc :request/auth-token token))))))
|
||||
(handler (cond-> exchange
|
||||
(not (str/blank? token)) (-> (assoc :request/auth-token token)
|
||||
(assoc :request/auth-scheme :cookie)))))))
|
||||
|
||||
(defn- wrap-health
|
||||
"Add /healthz entry point intercept."
|
||||
|
||||
@ -7,10 +7,13 @@
|
||||
(ns app.renderer
|
||||
"Common renderer interface."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.renderer.bitmap :as rb]
|
||||
[app.renderer.pdf :as rp]
|
||||
[app.renderer.svg :as rs]
|
||||
[app.renderer.wasm :as rw]
|
||||
[cljs.spec.alpha :as s]))
|
||||
|
||||
(s/def ::name ::us/string)
|
||||
@ -36,13 +39,24 @@
|
||||
:opt-un [::is-wasm]))
|
||||
|
||||
(defn render
|
||||
[{:keys [type] :as params} on-object]
|
||||
[{:keys [type is-wasm] :as params} on-object]
|
||||
(us/verify ::render-params params)
|
||||
(us/verify fn? on-object)
|
||||
(case type
|
||||
:png (rb/render params on-object)
|
||||
:jpeg (rb/render params on-object)
|
||||
:webp (rb/render params on-object)
|
||||
:pdf (rp/render params on-object)
|
||||
:svg (rs/render params on-object)))
|
||||
;; Opt-in headless path: when an export is flagged `:is-wasm` AND the
|
||||
;; `:wasm-headless` config is enabled, render with the in-process Skia/WASM
|
||||
;; pipeline (no browser). Off by default, so existing behavior is unchanged.
|
||||
(let [headless? (and is-wasm (cf/get :wasm-headless))]
|
||||
(l/info :hint "render"
|
||||
:type type
|
||||
:is-wasm (boolean is-wasm)
|
||||
:wasm-headless (boolean (cf/get :wasm-headless))
|
||||
:backend (if headless? "wasm" "browser"))
|
||||
(if headless?
|
||||
(rw/render params on-object)
|
||||
(case type
|
||||
:png (rb/render params on-object)
|
||||
:jpeg (rb/render params on-object)
|
||||
:webp (rb/render params on-object)
|
||||
:pdf (rp/render params on-object)
|
||||
:svg (rs/render params on-object)))))
|
||||
|
||||
|
||||
438
exporter/src/app/renderer/wasm.cljs
Normal file
438
exporter/src/app/renderer/wasm.cljs
Normal file
@ -0,0 +1,438 @@
|
||||
;; 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.renderer.wasm
|
||||
"Headless renderer backend: renders exports with the render-wasm Skia
|
||||
pipeline running in this Node process, with no browser and no WebGL.
|
||||
|
||||
Drop-in alternative to `app.renderer.bitmap`/`pdf` (the Playwright
|
||||
backends), selected from `app.renderer` when an export is flagged
|
||||
headless. Pipeline per request:
|
||||
|
||||
init module (once) -> fetch shape bundle (backend RPC)
|
||||
-> serialize scene
|
||||
-> provision resources (team/google fonts + fallback fonts + images,
|
||||
enumerated by the host-agnostic app.render-wasm.{resources,fallback-fonts})
|
||||
-> relayout text (real font metrics) -> render each object -> tempfile
|
||||
|
||||
NOTE (current slice): single shared WASM design state, so requests are
|
||||
serialized one at a time (no pooling yet). PNG + PDF are wired; jpeg/webp
|
||||
still need the PNG->format conversion that `bitmap` does."
|
||||
(:require
|
||||
["node:fs" :as fs]
|
||||
["undici" :as http]
|
||||
;; Loaded for side effects: these namespaces register the transit
|
||||
;; read-handlers for penpot file-data types (#penpot/shape, objects-map,
|
||||
;; point, matrix, rect, path, fills). Without them, `get-page` responses
|
||||
;; decode shapes into opaque TaggedValues with nil :id.
|
||||
[app.common.geom.matrix]
|
||||
[app.common.geom.point]
|
||||
[app.common.geom.rect]
|
||||
[app.common.types.fills.impl]
|
||||
[app.common.types.objects-map]
|
||||
[app.common.types.path.impl]
|
||||
[app.common.types.shape]
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.resources :as resources]
|
||||
[app.util.auth :as auth]
|
||||
[app.util.mime :as mime]
|
||||
[app.util.shell :as sh]
|
||||
[app.wasm :as wasm]
|
||||
[app.wasm.gfonts :as gfonts]
|
||||
[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!))))
|
||||
|
||||
;; --- serialize access to the single shared WASM module ---
|
||||
;;
|
||||
;; There is one shared WASM instance (one design state, one global mem buffer),
|
||||
;; but `handle-multiple-export` fans out partitions concurrently (`p/all`). This
|
||||
;; promise queue runs module-touching work one task at a time so concurrent
|
||||
;; exports can't interleave their serialize/render/alloc on the shared state.
|
||||
|
||||
(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))
|
||||
|
||||
;; --- shape bundle fetch (backend RPC)
|
||||
|
||||
(defn- fetch-objects
|
||||
"Fetches the page's `objects` map from the backend via the `get-page` RPC,
|
||||
using the same auth the exporter uses elsewhere (management key + bearer)."
|
||||
[{:keys [file-id page-id share-id token token-scheme]}]
|
||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
headers #js {"Content-Type" "application/transit+json"
|
||||
"X-Shared-Key" (str "exporter " cf/management-key)
|
||||
"Authorization" (auth/header-value token token-scheme)}
|
||||
;; share-id is an OPTIONAL uuid on the backend; it must be omitted when
|
||||
;; absent, not sent as nil (nil fails the uuid schema).
|
||||
body (t/encode-str (cond-> {:file-id file-id
|
||||
:page-id page-id}
|
||||
share-id (assoc :share-id share-id)))
|
||||
uri (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join "api/rpc/command/get-page")
|
||||
(str))]
|
||||
(l/info :hint "wasm render: get-page"
|
||||
:uri uri
|
||||
:file-id (str file-id)
|
||||
:page-id (str page-id)
|
||||
:auth-scheme (or token-scheme :bearer)
|
||||
:token-len (count (str token)))
|
||||
(->> (http/fetch uri #js {:method "POST" :headers headers :body body :dispatcher agent})
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.text resp)
|
||||
;; Surface the backend's actual error body so we can tell
|
||||
;; auth (401) from bad params (400/404).
|
||||
(->> (.text resp)
|
||||
(p/mcat (fn [resp-body]
|
||||
(l/error :hint "wasm render: get-page failed"
|
||||
: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
|
||||
;;
|
||||
;; Custom (team) fonts: the exporter's text serializer keeps their real font
|
||||
;; uuid, so `wasm/fonts-for-shape` reports it. We fetch the file's team font
|
||||
;; variants once (get-font-variants RPC), map uuid+weight+style -> ttf asset id,
|
||||
;; and download the TTF from `assets/by-id/<id>`. Google/builtin fonts still fall
|
||||
;; back to the bundled default (they need the gfonts catalog — a later slice).
|
||||
|
||||
(defn- fetch-font-variants
|
||||
"Fetches the file's team (custom) font variants via the get-font-variants RPC.
|
||||
Returns a promise of the variant vector (or nil on failure — fonts then just
|
||||
fall back, they don't fail the export)."
|
||||
[{:keys [file-id share-id token token-scheme]}]
|
||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
headers #js {"Content-Type" "application/transit+json"
|
||||
"X-Shared-Key" (str "exporter " cf/management-key)
|
||||
"Authorization" (auth/header-value token token-scheme)}
|
||||
body (t/encode-str (cond-> {:file-id file-id}
|
||||
share-id (assoc :share-id share-id)))
|
||||
uri (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join "api/rpc/command/get-font-variants")
|
||||
(str))]
|
||||
(->> (http/fetch uri #js {:method "POST" :headers headers :body body :dispatcher agent})
|
||||
(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" :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
(defn- fetch-asset-bytes
|
||||
"Downloads a stored asset (font TTF) by id, returning a promise of an
|
||||
ArrayBuffer (or nil)."
|
||||
[asset-id {:keys [token token-scheme]}]
|
||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
headers (clj->js (merge {"X-Shared-Key" (str "exporter " cf/management-key)}
|
||||
(auth/asset-headers token token-scheme)))
|
||||
uri (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join (str "assets/by-id/" asset-id))
|
||||
(str))]
|
||||
(->> (http/fetch uri #js {:method "GET" :headers headers :dispatcher agent})
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.arrayBuffer resp)
|
||||
(p/resolved nil))))
|
||||
(p/merr (fn [cause]
|
||||
(l/warn :hint "wasm render: font asset fetch failed"
|
||||
:asset-id (str asset-id) :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
(defn- gfont-proxy-url
|
||||
"Rewrites a gstatic ttf url to the local gfonts proxy (mirrors the browser's
|
||||
`google-font-ttf-url`)."
|
||||
[ttf-url]
|
||||
(let [proxy (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join "internal/gfonts/font/")
|
||||
(str))]
|
||||
(str/replace ttf-url "https://fonts.gstatic.com/s/" proxy)))
|
||||
|
||||
(defn- fetch-gfont-bytes
|
||||
"Downloads a google font TTF through the local gfonts proxy."
|
||||
[ttf-url]
|
||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
uri (gfont-proxy-url ttf-url)]
|
||||
(->> (http/fetch uri #js {:method "GET" :dispatcher agent})
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.arrayBuffer resp)
|
||||
(p/resolved nil))))
|
||||
(p/merr (fn [cause]
|
||||
(l/warn :hint "wasm render: gfont fetch failed" :url uri :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
(defn- make-resolve-font
|
||||
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Tries the
|
||||
file's custom (team) font variants first — matching uuid + weight + style,
|
||||
degrading to uuid+weight then uuid — then the shared google catalog. Returns
|
||||
nil for fonts not found (builtin falls back to the bundled default)."
|
||||
[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))]
|
||||
(if-let [ttf-id (:ttf-file-id variant)]
|
||||
(fetch-asset-bytes ttf-id params)
|
||||
(if-let [gurl (gfonts/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 in the wasm
|
||||
;; font store, not through any span's font family — so `wasm/fonts-for-shape`
|
||||
;; never reports them and the per-object provisioning above never uploads
|
||||
;; them. The browser workspace uploads them as a side effect of serializing
|
||||
;; text (`add-emoji-font` / `add-noto-fonts`), which is why client-side single
|
||||
;; exports show emoji/CJK while headless exports dropped them. Which fonts a
|
||||
;; scene needs is computed by the host-agnostic
|
||||
;; `app.render-wasm.fallback-fonts` (same data the browser uses).
|
||||
;; `clear-fonts!` resets the store every request, so this must run per
|
||||
;; request; the TTF bytes are cached per font for the process lifetime.
|
||||
|
||||
(defn- scene-fallback-fonts
|
||||
"Fallback font descriptors needed by the scene's text content."
|
||||
[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 fbf/contains-emoji? texts))
|
||||
langs (reduce fbf/collect-used-languages #{} texts)]
|
||||
(cond-> (fbf/add-noto-fonts [] langs)
|
||||
emoji? (fbf/add-emoji-font))))
|
||||
|
||||
(defonce ^:private fallback-font-bytes* (atom {}))
|
||||
|
||||
(defn- fetch-fallback-font-bytes
|
||||
"Downloads (and caches for the process lifetime) one fallback font's TTF."
|
||||
[{:keys [font-id weight style]}]
|
||||
(if-let [bytes (get @fallback-font-bytes* font-id)]
|
||||
(p/resolved bytes)
|
||||
(let [font-uuid (gfonts/gfont-id->uuid font-id)
|
||||
ttf-url (some-> font-uuid (gfonts/resolve-ttf-url weight style))]
|
||||
(if ttf-url
|
||||
(->> (fetch-gfont-bytes ttf-url)
|
||||
(p/fmap (fn [buf]
|
||||
(when buf (swap! fallback-font-bytes* assoc font-id buf))
|
||||
buf)))
|
||||
(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 (gfonts/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. We collect them from the scene, fetch
|
||||
;; the encoded bytes from `assets/by-file-media-id/<id>`, and hand them to
|
||||
;; `_store_image` (Skia decodes them; no WebGL). Keyed by media uuid, so this is
|
||||
;; done 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 token-scheme]}]
|
||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
||||
headers (clj->js (merge {"X-Shared-Key" (str "exporter " cf/management-key)}
|
||||
(auth/asset-headers token token-scheme)))
|
||||
uri (-> (cf/get :public-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join (str "assets/by-file-media-id/" media-id))
|
||||
(str))]
|
||||
(->> (http/fetch uri #js {:method "GET" :headers headers :dispatcher agent})
|
||||
(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 :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
(defn- provision-images!
|
||||
"Fetches and stores every image the scene references — shape fills, stroke
|
||||
image fills, and text-span image fills (enumerated by the host-agnostic
|
||||
`app.render-wasm.resources`, same source the workspace uses). Images the
|
||||
module already holds are skipped: unlike fonts, the image store is not
|
||||
reset per request, so repeated exports of the same file reuse them.
|
||||
NOTE: that also means the store grows with every distinct image the
|
||||
process ever exports; if exporter memory becomes a problem, add an
|
||||
eviction policy on the Rust side rather than clearing per request."
|
||||
[scene params]
|
||||
(let [all-ids (resources/scene-image-ids scene)
|
||||
new-ids (remove wasm/image-cached? all-ids)]
|
||||
(l/info :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/info :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 in the scene. Called after fonts are
|
||||
provisioned so metrics use the real fonts (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]
|
||||
(case type
|
||||
:pdf (let [bytes (wasm/render-shape-pdf id scale)]
|
||||
(l/info :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)))
|
||||
|
||||
(defn- render*
|
||||
[{:keys [scale type objects] :as params} on-object]
|
||||
(l/info :hint "wasm render: start" :type type :scale scale :objects (count objects))
|
||||
(->> (ensure-module!)
|
||||
(p/mcat (fn [_]
|
||||
(l/info :hint "wasm render: module ready, fetching scene"
|
||||
:file-id (str (:file-id params)) :page-id (str (:page-id params)))
|
||||
(fetch-objects params)))
|
||||
(p/mcat (fn [scene]
|
||||
(let [sample (first (vals scene))]
|
||||
(l/info :hint "wasm render: scene fetched"
|
||||
:shapes (count scene)
|
||||
:map? (map? scene)
|
||||
:first-key (str (first (keys scene)))
|
||||
:sample-id (str (:id sample))
|
||||
:sample-type (str (:type sample))
|
||||
:sample-keys (pr-str (when (map? sample) (vec (keys sample))))))
|
||||
(serialize/serialize-scene! scene)
|
||||
(l/info :hint "wasm render: scene serialized")
|
||||
;; Reset the shared module's font store so fonts from a previous
|
||||
;; request don't accumulate / leak into this one.
|
||||
(wasm/clear-fonts!)
|
||||
;; Fetch the file's custom (team) font variants once, provision
|
||||
;; every referenced image once, then resolve/provision fonts per
|
||||
;; rendered object.
|
||||
(->> (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)]
|
||||
;; Provision every object's fonts BEFORE rendering, so
|
||||
;; the text relayout below sees real font metrics.
|
||||
(p/all (map (fn [{:keys [id]}]
|
||||
(wasm/provision-fonts! id resolve-font))
|
||||
objects)))))
|
||||
(p/mcat
|
||||
(fn [_]
|
||||
;; Serialize-time layout used the fallback font (fonts
|
||||
;; weren't uploaded yet); recompute now that they are, or
|
||||
;; text metrics/line breaks are wrong.
|
||||
(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/info :hint "wasm render: object rendered"
|
||||
:object-id (str id) :bytes (.-length bytes))
|
||||
(fs/writeFileSync path bytes)
|
||||
;; `on-object` may return a plain value (zip append
|
||||
;; returns the archiver instance) or a promise (single
|
||||
;; export's file move); `p/do` normalizes both to a
|
||||
;; thenable so `p/mcat` doesn't throw "expected thenable".
|
||||
(p/do (on-object (assoc object :path path)))))
|
||||
objects))))))
|
||||
(p/merr (fn [cause]
|
||||
(l/error :hint "wasm render: failed" :cause cause)
|
||||
;; A panic/abort can leave the shared module's buffer allocated
|
||||
;; or the wasm instance aborted; drop it so the next request
|
||||
;; rebuilds a fresh module instead of inheriting the bad state.
|
||||
(reset! module* nil)
|
||||
(p/rejected cause)))))
|
||||
|
||||
(defn render
|
||||
"Public entry. Serializes module access through `enqueue!` so concurrent
|
||||
exports (multi-partition zip) run one at a time on the shared WASM instance."
|
||||
[params on-object]
|
||||
(enqueue! (fn [] (render* params on-object))))
|
||||
30
exporter/src/app/util/auth.cljs
Normal file
30
exporter/src/app/util/auth.cljs
Normal file
@ -0,0 +1,30 @@
|
||||
;; 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.util.auth
|
||||
"Helpers for forwarding the caller's auth to the backend.")
|
||||
|
||||
(defn header-value
|
||||
"Builds the `Authorization` header value to forward to the backend for a
|
||||
given token + scheme. Penpot access tokens use the `Token` scheme; session
|
||||
tokens (incl. those resolved from a cookie) use `Bearer`. Defaults to
|
||||
`Bearer` so existing callers are unaffected."
|
||||
([token] (header-value token :bearer))
|
||||
([token scheme]
|
||||
(str (if (= scheme :token) "Token " "Bearer ") token)))
|
||||
|
||||
(defn asset-headers
|
||||
"Auth headers for fetching `/assets/*` URLs. Those are served through the
|
||||
storage backend: nginx follows an internal redirect to a *presigned* S3/minio
|
||||
URL and forwards the client's headers, so a `Bearer` Authorization header
|
||||
makes S3 reject the request with 'multiple authentication types' (400).
|
||||
Session tokens are therefore forwarded the way the browser sends them — as
|
||||
the auth cookie — and access tokens keep the `Token` scheme, which S3 does
|
||||
not parse as an authentication type."
|
||||
[token scheme]
|
||||
(if (= scheme :token)
|
||||
{"Authorization" (str "Token " token)}
|
||||
{"Cookie" (str "auth-token=" token)}))
|
||||
229
exporter/src/app/wasm.cljs
Normal file
229
exporter/src/app/wasm.cljs
Normal file
@ -0,0 +1,229 @@
|
||||
;; 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.
|
||||
|
||||
This is the GPU-free counterpart of the browser's
|
||||
`app.render-wasm.api`: it loads the emscripten artifact in the Node
|
||||
process (no WebGL), boots it via `init_headless`, and exposes the
|
||||
minimal surface the exporter needs — font provisioning and shape
|
||||
rendering to PNG/PDF bytes.
|
||||
|
||||
The shape *serialization* is reused from the portable render-wasm leaves
|
||||
(`app.render-wasm.{wasm,mem,helpers,serializers,api.shapes,...}`); this
|
||||
namespace only owns the Node runtime + the headless render/export calls.
|
||||
The CLJS↔WASM binary protocol therefore stays identical to the editor —
|
||||
no second implementation, no drift.
|
||||
|
||||
Requires render-wasm built with `-sENVIRONMENT=web,node`."
|
||||
(:require
|
||||
["node:fs" :as fs]
|
||||
["node:path" :as path]
|
||||
[app.common.logging :as l]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[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)
|
||||
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
|
||||
(def ^:private FONT-ENTRY-BYTES 24)
|
||||
|
||||
(defn- artifact-dir
|
||||
[]
|
||||
(cf/get :wasm-dir "../frontend/resources/public/js"))
|
||||
|
||||
(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 <uuid-u32x4> :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))]
|
||||
(let [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 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."
|
||||
[{: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 provision-fonts!
|
||||
"Resolves and uploads every font needed by `shape-id`. `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-id resolve-font]
|
||||
(->> (fonts-for-shape shape-id)
|
||||
(map (fn [family]
|
||||
(->> (resolve-font family)
|
||||
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
|
||||
(p/all)))
|
||||
|
||||
;; --- RENDER
|
||||
|
||||
(defn- render-call
|
||||
[fn-name shape-id scale]
|
||||
(let [module wasm/internal-module
|
||||
buf (uuid/get-u32 shape-id)
|
||||
offset (h/call module fn-name
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
scale)
|
||||
heap32 (mem/get-heap-u32)
|
||||
len (aget heap32 (mem/->offset-32 offset))
|
||||
bytes (read-result-bytes (+ offset RASTER-HEADER-BYTES) len)]
|
||||
(mem/free)
|
||||
bytes))
|
||||
|
||||
(defn render-shape-raster
|
||||
"Renders the shape subtree to PNG bytes (Uint8Array) on a CPU surface."
|
||||
[shape-id scale]
|
||||
(render-call "_render_shape_raster" shape-id scale))
|
||||
|
||||
(defn render-shape-pdf
|
||||
"Renders the shape subtree to PDF bytes (Uint8Array)."
|
||||
[shape-id scale]
|
||||
;; PDF result header is [len u32] only (no w/h); handled separately.
|
||||
(let [module wasm/internal-module
|
||||
buf (uuid/get-u32 shape-id)
|
||||
offset (h/call module "_render_shape_pdf"
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
scale)
|
||||
heap32 (mem/get-heap-u32)
|
||||
len (aget heap32 (mem/->offset-32 offset))
|
||||
bytes (read-result-bytes (+ offset 4) len)]
|
||||
(mem/free)
|
||||
bytes))
|
||||
45
exporter/src/app/wasm/gfonts.cljs
Normal file
45
exporter/src/app/wasm/gfonts.cljs
Normal file
@ -0,0 +1,45 @@
|
||||
;; 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.gfonts
|
||||
"Compile-time Google Fonts catalog for the headless exporter.
|
||||
|
||||
Reuses the same `gfonts.json` + parser the browser uses, via the portable
|
||||
`preload-gfonts` macro (a `.clj` macro — it does NOT pull the browser-coupled
|
||||
`app.main.fonts` cljs runtime). `parse-gfont` assigns each font a `uuid/random`
|
||||
at macro-expansion time, so the catalog MUST be defined once here and shared:
|
||||
text serialization (`app.wasm.text`) maps `gfont-<slug>` -> this uuid, and font
|
||||
provisioning (`app.renderer.wasm`) maps the same uuid back to the variant's
|
||||
ttf url. Consistency only holds because both read this single instance."
|
||||
(:require-macros [app.main.fonts :refer [preload-gfonts]]))
|
||||
|
||||
(def ^:private 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-<slug>` id to its (build-stable) catalog uuid, or nil."
|
||||
[gfont-id]
|
||||
(:uuid (get by-id gfont-id)))
|
||||
|
||||
(defn resolve-ttf-url
|
||||
"Given a google font uuid + numeric weight + style int (0 = normal, else
|
||||
italic), returns the variant's gstatic ttf url (or nil if not a google font /
|
||||
no variants). Degrades weight+style -> weight -> first variant."
|
||||
[font-uuid weight style]
|
||||
(when-let [font (get by-uuid font-uuid)]
|
||||
(let [w (str weight)
|
||||
s (if (zero? style) "normal" "italic")
|
||||
variants (:variants font)
|
||||
variant (or (some (fn [v] (when (and (= (:weight v) w) (= (:style v) s)) v)) variants)
|
||||
(some (fn [v] (when (= (:weight v) w) v)) variants)
|
||||
(first variants))]
|
||||
(:ttf-url variant))))
|
||||
57
exporter/src/app/wasm/serialize.cljs
Normal file
57
exporter/src/app/wasm/serialize.cljs
Normal file
@ -0,0 +1,57 @@
|
||||
;; 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.
|
||||
|
||||
This is the headless counterpart of the browser orchestrator
|
||||
`app.render-wasm.api/set-object`. It cannot reuse that fn directly (its
|
||||
namespace pulls React/DOM/store), so it owns the *call sequencing* here —
|
||||
but every byte layout is reused from the shared, identical sources:
|
||||
|
||||
- `app.render-wasm.api.shapes/set-shape-base-props` (the scalar struct),
|
||||
- `app.common.types.fills` / `…fills.impl` (fills & stroke fills),
|
||||
- `app.render-wasm.{mem,serializers,helpers}` leaves.
|
||||
|
||||
So the bytes sent to WASM are the same as the editor's; only the (simple)
|
||||
orchestration lives here.
|
||||
|
||||
COVERAGE: base props (incl. corners/opacity/transform/clip/constraints),
|
||||
children, fills (solid/gradient/image), strokes (solid/gradient/image),
|
||||
layer + background blur, shadows, masks, path/bool geometry, text. Image
|
||||
BYTES and fonts are provisioned separately by `app.renderer.wasm`.
|
||||
NOT YET: svg-raw (needs faithful static markup — see step 3d notes)."
|
||||
(:require
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.serialize-shape :as serialize-shape]
|
||||
[app.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)))
|
||||
58
exporter/src/app/wasm/text.cljs
Normal file
58
exporter/src/app/wasm/text.cljs
Normal file
@ -0,0 +1,58 @@
|
||||
;; 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.
|
||||
|
||||
The binary text layout is shared with the workspace via
|
||||
`app.render-wasm.text-content`; only *font-id resolution* is local here,
|
||||
because the exporter has no fonts DB: custom fonts keep their uuid
|
||||
(`custom-<uuid>`), google fonts (`gfont-<slug>`) map via the compile-time
|
||||
`app.wasm.gfonts` catalog, and builtin falls back to the default (uuid/zero)."
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.text-content :as tc]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.wasm.gfonts :as gfonts]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn- normalize-font-id
|
||||
"Maps a content font-id to its wasm uuid. The provisioning side keys on the
|
||||
same uuid."
|
||||
[font-id]
|
||||
(try
|
||||
(cond
|
||||
(str/starts-with? font-id "gfont-")
|
||||
(or (gfonts/gfont-id->uuid font-id) uuid/zero)
|
||||
|
||||
(str/includes? font-id "-")
|
||||
(let [no-prefix (subs font-id (inc (str/index-of font-id "-")))]
|
||||
(if (str/blank? no-prefix) uuid/zero (uuid/parse no-prefix)))
|
||||
|
||||
:else uuid/zero)
|
||||
(catch :default _ uuid/zero)))
|
||||
|
||||
(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 is the shared `text-content/write-shape-text!`; only font-id
|
||||
resolution is injected."
|
||||
[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
|
||||
{:normalize-font-id normalize-font-id}))))))
|
||||
(h/call wasm/internal-module "_update_shape_text_layout")))
|
||||
@ -179,6 +179,15 @@
|
||||
(and (wasm-export-enabled? state)
|
||||
(contains? wasm-export-types (:type export))))
|
||||
|
||||
(defn- request-simple-export-wasm
|
||||
[export]
|
||||
(ptk/reify ::request-simple-export-wasm
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export)))))
|
||||
|
||||
(defn request-simple-export
|
||||
[{:keys [export]}]
|
||||
(ptk/reify ::request-simple-export
|
||||
@ -191,11 +200,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (use-wasm-export? state export)
|
||||
(do
|
||||
(case (:type export)
|
||||
:pdf (wasm.exports/export-pdf export)
|
||||
(wasm.exports/export-image export))
|
||||
(rx/empty))
|
||||
(rx/of (request-simple-export-wasm export))
|
||||
(let [profile-id (:profile-id state)
|
||||
params {:exports [export]
|
||||
:profile-id profile-id
|
||||
|
||||
@ -665,7 +665,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps frames)
|
||||
(fn [_]
|
||||
(st/emit! (de/show-workspace-export-frames-dialog (reverse frames)))))
|
||||
(st/emit! (de/show-workspace-export-frames-dialog frames))))
|
||||
|
||||
on-export-frames-key-down
|
||||
(mf/use-fn
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
[app.common.files.focus :as cpf]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.logging :as log]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
@ -33,6 +32,7 @@
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.shapes.text]
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.api.shapes :as shapes]
|
||||
[app.render-wasm.api.texts :as t]
|
||||
[app.render-wasm.api.webgl :as webgl]
|
||||
@ -43,6 +43,7 @@
|
||||
[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]
|
||||
@ -251,8 +252,6 @@
|
||||
(def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8)
|
||||
(def ^:const GRID-LAYOUT-CELL-U8-SIZE 36)
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const DEBOUNCE_DELAY_MS 100)
|
||||
|
||||
(defonce ^:private view-interaction-active? (atom false))
|
||||
@ -633,7 +632,7 @@
|
||||
|
||||
(defn set-masked
|
||||
[masked]
|
||||
(h/call wasm/internal-module "_set_shape_masked_group" masked))
|
||||
(props/set-masked masked))
|
||||
|
||||
(defn set-shape-selrect
|
||||
[selrect]
|
||||
@ -728,10 +727,6 @@
|
||||
(perf/end-measure "set-shape-children")
|
||||
nil)
|
||||
|
||||
(defn- get-string-length
|
||||
[string]
|
||||
(+ (count string) 1))
|
||||
|
||||
|
||||
(defn- get-texture-id-for-gl-object
|
||||
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
|
||||
@ -832,124 +827,49 @@
|
||||
|
||||
(defn set-shape-fills
|
||||
[shape-id fills thumbnail?]
|
||||
(if (empty? fills)
|
||||
(h/call wasm/internal-module "_clear_shape_fills")
|
||||
(let [fills (types.fills/coerce fills)
|
||||
image-ids (types.fills/get-image-ids fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
|
||||
;; write fills to the heap
|
||||
(types.fills/write-to fills heap offset)
|
||||
|
||||
;; send fills to wasm
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
|
||||
;; load images for image fills if not cached
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
|
||||
image-ids))))
|
||||
;; Record write is shared with the headless exporter; the image fetch below is
|
||||
;; browser-only (WebGL textures).
|
||||
(when-let [fills (props/write-shape-fills! fills)]
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(types.fills/get-image-ids fills))))
|
||||
|
||||
(defn set-shape-strokes
|
||||
[shape-id strokes thumbnail?]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(keep (fn [stroke]
|
||||
(when-not (:hidden stroke)
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
;; Sentinel -1 means "unset" on the Rust side — keeps the
|
||||
;; FFI signature flat while letting the renderer fall back
|
||||
;; to its default dash pattern when no override is stored.
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
|
||||
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)
|
||||
|
||||
(some? image)
|
||||
(let [image-id (get image :id)
|
||||
buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0) (aget buffer 1)
|
||||
(aget buffer 2) (aget buffer 3)
|
||||
thumbnail?)]
|
||||
(types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(when (== cached-image? 0)
|
||||
(fetch-image shape-id image-id thumbnail?)))
|
||||
|
||||
(some? color)
|
||||
(do
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)))))
|
||||
|
||||
strokes))
|
||||
;; Record write is shared with the headless exporter; the image fetch below is
|
||||
;; browser-only (WebGL textures).
|
||||
(keep (fn [image-id]
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))
|
||||
(props/write-shape-strokes! strokes)))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
(let [style (:style attrs)
|
||||
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
|
||||
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
|
||||
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
|
||||
fill-none (= "none" (or (:fill style) (:fill attrs)))]
|
||||
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
|
||||
(props/set-shape-svg-attrs attrs))
|
||||
|
||||
(defn set-shape-path-content
|
||||
"Upload path content in chunks to WASM."
|
||||
[content]
|
||||
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
|
||||
buffer-size (path/get-byte-size content)
|
||||
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
|
||||
buffer (js/Uint8Array. padded-size)]
|
||||
(path/write-to content (.-buffer buffer) 0)
|
||||
(h/call wasm/internal-module "_start_shape_path_buffer")
|
||||
(let [heapu32 (mem/get-heap-u32)]
|
||||
(loop [offset 0]
|
||||
(when (< offset padded-size)
|
||||
(let [end (min padded-size (+ offset (* chunk-size 4)))
|
||||
chunk (.subarray buffer offset end)
|
||||
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
|
||||
offset-size (.-length chunk-u32)
|
||||
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
|
||||
(.set heapu32 chunk-u32 heap-offset)
|
||||
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
|
||||
(recur end)))))
|
||||
(h/call wasm/internal-module "_set_shape_path_buffer")))
|
||||
(props/set-shape-path-content content))
|
||||
|
||||
(defn set-shape-svg-raw-content
|
||||
[content]
|
||||
(let [size (get-string-length content)
|
||||
offset (mem/alloc size)]
|
||||
(h/call wasm/internal-module "stringToUTF8" content offset size)
|
||||
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
|
||||
(props/set-shape-svg-raw-content content))
|
||||
|
||||
(defn set-shape-blend-mode
|
||||
[blend-mode]
|
||||
@ -994,25 +914,15 @@
|
||||
|
||||
(defn set-shape-bool-type
|
||||
[bool-type]
|
||||
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
|
||||
(props/set-shape-bool-type bool-type))
|
||||
|
||||
(defn set-shape-blur
|
||||
[blur]
|
||||
(let [type (sr/translate-blur-type :layer-blur)]
|
||||
(if (some? blur)
|
||||
(let [hidden (:hidden blur)
|
||||
value (:value blur)]
|
||||
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
(props/set-shape-blur blur))
|
||||
|
||||
(defn set-shape-background-blur
|
||||
[background-blur]
|
||||
(let [type (sr/translate-blur-type :background-blur)]
|
||||
(if (some? background-blur)
|
||||
(let [hidden (:hidden background-blur)
|
||||
value (:value background-blur)]
|
||||
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
(props/set-shape-background-blur background-blur))
|
||||
|
||||
(defn set-shape-corners
|
||||
[corners]
|
||||
@ -1229,27 +1139,7 @@
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
(props/set-shape-shadows shadows))
|
||||
|
||||
(defn fonts-from-text-content [content fallback-fonts-only?]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
@ -1288,7 +1178,7 @@
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
|
||||
(props/set-shape-grow-type grow-type))
|
||||
|
||||
(defn get-text-dimensions
|
||||
([id]
|
||||
@ -1399,45 +1289,22 @@
|
||||
id (dm/get-prop shape :id)
|
||||
type (dm/get-prop shape :type)
|
||||
|
||||
masked (get shape :masked-group)
|
||||
|
||||
fills (get shape :fills)
|
||||
strokes (if (= type :group)
|
||||
[] (get shape :strokes))
|
||||
children (get shape :shapes)
|
||||
content (let [content (get shape :content)]
|
||||
(if (= type :text)
|
||||
(ensure-text-content content)
|
||||
content))
|
||||
bool-type (get shape :bool-type)
|
||||
grow-type (get shape :grow-type)
|
||||
blur (get shape :blur)
|
||||
background-blur (get shape :background-blur)
|
||||
svg-attrs (get shape :svg-attrs)
|
||||
shadows (get shape :shadow)]
|
||||
content))]
|
||||
|
||||
(shapes/set-shape-base-props shape)
|
||||
;; Host-independent properties (base props, children, blur, background
|
||||
;; blur, shadows, svg attrs, mask, bool type, path geometry, grow type),
|
||||
;; shared with the headless exporter so the two can't drift.
|
||||
(serialize-shape/serialize-shape! shape)
|
||||
|
||||
;; Remaining properties that need separate calls (variable-length or conditional)
|
||||
(set-shape-children children)
|
||||
(set-shape-blur blur)
|
||||
(set-shape-background-blur background-blur)
|
||||
(when (= type :group)
|
||||
(set-masked (boolean masked)))
|
||||
(when (= type :bool)
|
||||
(set-shape-bool-type bool-type))
|
||||
(when (and (some? content)
|
||||
(or (= type :path)
|
||||
(= type :bool)))
|
||||
(set-shape-path-content content))
|
||||
(when (some? svg-attrs)
|
||||
(set-shape-svg-attrs svg-attrs))
|
||||
;; Browser-only: svg-raw markup (needs React) + workspace layout.
|
||||
(when (and (some? content) (= type :svg-raw))
|
||||
(set-shape-svg-raw-content (get-static-markup shape)))
|
||||
(set-shape-shadows shadows)
|
||||
(when (= type :text)
|
||||
(set-shape-grow-type grow-type))
|
||||
|
||||
(set-shape-layout shape)
|
||||
(set-layout-data shape)
|
||||
(let [is-text? (= type :text)
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
[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]
|
||||
@ -405,68 +406,9 @@
|
||||
[fonts]
|
||||
(keep (fn [font] (store-font font)) fonts))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
(conj fonts {:font-id "gfont-noto-color-emoji"
|
||||
:font-variant-id "regular"
|
||||
:style 0
|
||||
:weight 400
|
||||
:is-emoji true
|
||||
:is-fallback true}))
|
||||
|
||||
(def noto-fonts
|
||||
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
|
||||
|
||||
(defn add-noto-fonts [fonts languages]
|
||||
(reduce (fn [acc lang]
|
||||
(if-let [font (get noto-fonts lang)]
|
||||
(conj acc font)
|
||||
acc))
|
||||
fonts
|
||||
languages))
|
||||
;; Fallback-font data (emoji + per-script noto fonts) lives in the
|
||||
;; host-agnostic `app.render-wasm.fallback-fonts`, shared with the headless
|
||||
;; exporter; kept re-exported here for existing workspace callers.
|
||||
(def add-emoji-font fbf/add-emoji-font)
|
||||
(def noto-fonts fbf/noto-fonts)
|
||||
(def add-noto-fonts fbf/add-noto-fonts)
|
||||
|
||||
199
frontend/src/app/render_wasm/api/props.cljs
Normal file
199
frontend/src/app/render_wasm/api/props.cljs
Normal file
@ -0,0 +1,199 @@
|
||||
;; 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.props
|
||||
"Browser-free WASM shape property setters, shared by the workspace render
|
||||
orchestrator (`app.render-wasm.api`) and the headless exporter
|
||||
(`app.wasm.serialize`).
|
||||
|
||||
These only touch the WASM FFI (`app.render-wasm.{helpers,mem,serializers,
|
||||
wasm}`) and the shared `common` byte layouts — no store/DOM/React — so they
|
||||
run identically in the browser and under Node. Setters that need host-specific
|
||||
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
|
||||
(:require
|
||||
[app.common.math :as mth]
|
||||
[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]))
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const UUID-U8-SIZE 16)
|
||||
|
||||
(defn set-shape-children
|
||||
"Uploads the child id list via the dynamic `_set_children` path (handles any
|
||||
count). The browser also has fixed-arity fast paths for the incremental edit
|
||||
path; this dynamic one is the shared/batch version."
|
||||
[children]
|
||||
(let [children (into [] (filter uuid?) children)]
|
||||
(if (empty? children)
|
||||
(h/call wasm/internal-module "_set_children_0")
|
||||
(let [heap (mem/get-heap-u32)
|
||||
size (mem/get-alloc-size children UUID-U8-SIZE)
|
||||
offset (mem/alloc->offset-32 size)]
|
||||
(reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
|
||||
(h/call wasm/internal-module "_set_children")))))
|
||||
|
||||
(defn set-shape-bool-type
|
||||
[bool-type]
|
||||
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
|
||||
|
||||
(defn write-shape-fills!
|
||||
"Serializes a fill vector into WASM using the shared `common` byte layout.
|
||||
Only writes the fill *records*; image fill BYTES are host-specific (the browser
|
||||
fetches WebGL textures, the exporter provisions decoded bytes), so callers load
|
||||
images after. Returns the coerced `Fills` (for image-id extraction) or nil when
|
||||
empty."
|
||||
[fills]
|
||||
(if (empty? fills)
|
||||
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
|
||||
(let [fills (types.fills/coerce fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
(types.fills/write-to fills heap offset)
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
fills)))
|
||||
|
||||
(defn write-shape-strokes!
|
||||
"Serializes the stroke vector (records only, like `write-shape-fills!`);
|
||||
image stroke BYTES are host-specific, so callers load images after. Returns
|
||||
the image ids referenced by the strokes' image fills."
|
||||
[strokes]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(into []
|
||||
(keep
|
||||
(fn [stroke]
|
||||
(when-not (:hidden stroke)
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
;; Sentinel -1 means "unset" on the Rust side — keeps the
|
||||
;; FFI signature flat while letting the renderer fall back
|
||||
;; to its default dash pattern when no override is stored.
|
||||
dash (or (:stroke-dash stroke) -1)
|
||||
gap (or (:stroke-gap stroke) -1)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do (types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil)
|
||||
|
||||
(some? image)
|
||||
(do (types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(get image :id))
|
||||
|
||||
(some? color)
|
||||
(do (types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
nil))))))
|
||||
strokes))
|
||||
|
||||
(defn- get-string-length
|
||||
[string]
|
||||
(+ (count string) 1))
|
||||
|
||||
(defn set-shape-blur
|
||||
[blur]
|
||||
(let [type (sr/translate-blur-type :layer-blur)]
|
||||
(if (some? blur)
|
||||
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden blur)) (:value blur))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
|
||||
(defn set-shape-background-blur
|
||||
[background-blur]
|
||||
(let [type (sr/translate-blur-type :background-blur)]
|
||||
(if (some? background-blur)
|
||||
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden background-blur)) (:value background-blur))
|
||||
(h/call wasm/internal-module "_clear_shape_blur" type))))
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
|
||||
(defn set-masked
|
||||
[masked]
|
||||
(h/call wasm/internal-module "_set_shape_masked_group" masked))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
(let [style (:style attrs)
|
||||
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
|
||||
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
|
||||
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
|
||||
fill-none (= "none" (or (:fill style) (:fill attrs)))]
|
||||
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
|
||||
|
||||
(defn set-shape-svg-raw-content
|
||||
[content]
|
||||
(let [size (get-string-length content)
|
||||
offset (mem/alloc size)]
|
||||
(h/call wasm/internal-module "stringToUTF8" content offset size)
|
||||
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
|
||||
|
||||
(defn set-shape-path-content
|
||||
"Upload path content in chunks to WASM."
|
||||
[content]
|
||||
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
|
||||
buffer-size (path/get-byte-size content)
|
||||
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
|
||||
buffer (js/Uint8Array. padded-size)]
|
||||
(path/write-to content (.-buffer buffer) 0)
|
||||
(h/call wasm/internal-module "_start_shape_path_buffer")
|
||||
(let [heapu32 (mem/get-heap-u32)]
|
||||
(loop [offset 0]
|
||||
(when (< offset padded-size)
|
||||
(let [end (min padded-size (+ offset (* chunk-size 4)))
|
||||
chunk (.subarray buffer offset end)
|
||||
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
|
||||
offset-size (.-length chunk-u32)
|
||||
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
|
||||
(.set heapu32 chunk-u32 heap-offset)
|
||||
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
|
||||
(recur end)))))
|
||||
(h/call wasm/internal-module "_set_shape_path_buffer")))
|
||||
@ -6,240 +6,21 @@
|
||||
|
||||
(ns app.render-wasm.api.texts
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance"
|
||||
[text]
|
||||
(let [encoder (js/TextEncoder.)]
|
||||
(.encode encoder text)))
|
||||
|
||||
(defn- write-span-fills
|
||||
[offset dview fills]
|
||||
(let [new-ofset (reduce (fn [offset fill]
|
||||
(let [opacity (get fill :fill-opacity 1.0)
|
||||
color (get fill :fill-color)
|
||||
gradient (get fill :fill-color-gradient)
|
||||
image (get fill :fill-image)]
|
||||
|
||||
(cond
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image))))
|
||||
|
||||
offset
|
||||
fills)
|
||||
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
|
||||
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
|
||||
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (f/serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph]
|
||||
(let [paragraph-font-size (get paragraph :font-size)
|
||||
paragraph-font-weight (-> paragraph :font-weight f/serialize-font-weight)
|
||||
paragraph-line-height (f/serialize-line-height (get paragraph :line-height))]
|
||||
(reduce (fn [offset span]
|
||||
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
|
||||
font-size (get span :font-size paragraph-font-size)
|
||||
font-size (f/serialize-font-size font-size)
|
||||
|
||||
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
|
||||
|
||||
font-weight (get span :font-weight paragraph-font-weight)
|
||||
font-weight (f/serialize-font-weight font-weight)
|
||||
|
||||
font-id (f/normalize-font-id (get span :font-id "sourcesanspro"))
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
|
||||
font-variant-id
|
||||
(get span :font-variant-id)
|
||||
|
||||
font-variant-id
|
||||
(if (uuid? font-variant-id)
|
||||
font-variant-id
|
||||
uuid/zero)
|
||||
|
||||
text-decoration
|
||||
(or (sr/translate-text-decoration (:text-decoration span))
|
||||
(sr/translate-text-decoration (:text-decoration paragraph))
|
||||
(sr/translate-text-decoration "none"))
|
||||
|
||||
text-transform
|
||||
(or (sr/translate-text-transform (:text-transform span))
|
||||
(sr/translate-text-transform (:text-transform paragraph))
|
||||
(sr/translate-text-transform "none"))
|
||||
|
||||
text-direction
|
||||
(or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/write-u32 dview font-weight)
|
||||
|
||||
(mem/write-uuid dview font-id)
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
|
||||
(write-span-fills dview fills))))
|
||||
offset
|
||||
spans)))
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.text-content :as tc]))
|
||||
|
||||
(defn write-shape-text
|
||||
;; buffer has the following format:
|
||||
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
|
||||
"Workspace text serialization: the byte writing is shared via
|
||||
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
|
||||
[spans paragraph text]
|
||||
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
|
||||
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
|
||||
num-spans (count normalized-spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview normalized-paragraph)
|
||||
(write-spans dview normalized-spans normalized-paragraph)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
|
||||
(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]")
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
|
||||
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
|
||||
:thai #"[\u0E00-\u0E7F]"
|
||||
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
|
||||
:tamil #"[\u0B80-\u0BFF]"
|
||||
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
|
||||
:vietnamese #"[\u1EA0-\u1EF9]"
|
||||
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
|
||||
:bengali #"[\u0980-\u09FF]"
|
||||
:cherokee #"[\u13A0-\u13FF]"
|
||||
:ethiopic #"[\u1200-\u137F]"
|
||||
:georgian #"[\u10A0-\u10FF]"
|
||||
:gujarati #"[\u0A80-\u0AFF]"
|
||||
:gurmukhi #"[\u0A00-\u0A7F]"
|
||||
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
|
||||
:lao #"[\u0E80-\u0EFF]"
|
||||
:malayalam #"[\u0D00-\u0D7F]"
|
||||
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
|
||||
:sinhala #"[\u0D80-\u0DFF]"
|
||||
:telugu #"[\u0C00-\u0C7F]"
|
||||
:tibetan #"[\u0F00-\u0FFF]"
|
||||
:javanese #"[\uA980-\uA9DF]"
|
||||
:kannada #"[\u0C80-\u0CFF]"
|
||||
:oriya #"[\u0B00-\u0B7F]"
|
||||
:mongolian #"[\u1800-\u18AF]"
|
||||
:syriac #"[\u0700-\u074F]"
|
||||
:tifinagh #"[\u2D30-\u2D7F]"
|
||||
:coptic #"[\u2C80-\u2CFF]"
|
||||
:ol-chiki #"[\u1C50-\u1C7F]"
|
||||
:vai #"[\uA500-\uA63F]"
|
||||
:shavian #"\uD801[\uDC50-\uDC7F]"
|
||||
:osmanya #"\uD801[\uDC80-\uDCAF]"
|
||||
:runic #"[\u16A0-\u16FF]"
|
||||
:old-italic #"\uD800[\uDF00-\uDF2F]"
|
||||
:brahmi #"\uD804[\uDC00-\uDC7F]"
|
||||
:modi #"\uD805[\uDE00-\uDE5F]"
|
||||
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
|
||||
:bamum #"[\uA6A0-\uA6FF]"
|
||||
:meroitic #"\uD802[\uDD80-\uDD9F]"
|
||||
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
|
||||
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
|
||||
;; Additional symbol blocks covered by Noto Sans Symbols 2:
|
||||
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
|
||||
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
|
||||
;; Legacy Computing Symbols (U+1FB00-1FBFF)
|
||||
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
|
||||
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
|
||||
|
||||
(defn contains-emoji? [text]
|
||||
(let [result (re-find emoji-pattern text)]
|
||||
(boolean result)))
|
||||
|
||||
(defn collect-used-languages
|
||||
[used text]
|
||||
(reduce-kv (fn [result lang pattern]
|
||||
(cond
|
||||
;; Skip regex operation if we already know that
|
||||
;; langage is present
|
||||
(contains? result lang)
|
||||
result
|
||||
|
||||
(re-find pattern text)
|
||||
(conj result lang)
|
||||
|
||||
:else
|
||||
result))
|
||||
used
|
||||
unicode-ranges))
|
||||
(tc/write-shape-text! spans paragraph text
|
||||
{:normalize-font-id f/normalize-font-id
|
||||
: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)
|
||||
|
||||
157
frontend/src/app/render_wasm/fallback_fonts.cljs
Normal file
157
frontend/src/app/render_wasm/fallback_fonts.cljs
Normal file
@ -0,0 +1,157 @@
|
||||
;; 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.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.")
|
||||
|
||||
(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]")
|
||||
|
||||
(def ^:private unicode-ranges
|
||||
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
|
||||
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
|
||||
:korean #"[\uAC00-\uD7AF]"
|
||||
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
|
||||
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
|
||||
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
|
||||
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
|
||||
:thai #"[\u0E00-\u0E7F]"
|
||||
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
|
||||
:tamil #"[\u0B80-\u0BFF]"
|
||||
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
|
||||
:vietnamese #"[\u1EA0-\u1EF9]"
|
||||
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
|
||||
:bengali #"[\u0980-\u09FF]"
|
||||
:cherokee #"[\u13A0-\u13FF]"
|
||||
:ethiopic #"[\u1200-\u137F]"
|
||||
:georgian #"[\u10A0-\u10FF]"
|
||||
:gujarati #"[\u0A80-\u0AFF]"
|
||||
:gurmukhi #"[\u0A00-\u0A7F]"
|
||||
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
|
||||
:lao #"[\u0E80-\u0EFF]"
|
||||
:malayalam #"[\u0D00-\u0D7F]"
|
||||
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
|
||||
:sinhala #"[\u0D80-\u0DFF]"
|
||||
:telugu #"[\u0C00-\u0C7F]"
|
||||
:tibetan #"[\u0F00-\u0FFF]"
|
||||
:javanese #"[\uA980-\uA9DF]"
|
||||
:kannada #"[\u0C80-\u0CFF]"
|
||||
:oriya #"[\u0B00-\u0B7F]"
|
||||
:mongolian #"[\u1800-\u18AF]"
|
||||
:syriac #"[\u0700-\u074F]"
|
||||
:tifinagh #"[\u2D30-\u2D7F]"
|
||||
:coptic #"[\u2C80-\u2CFF]"
|
||||
:ol-chiki #"[\u1C50-\u1C7F]"
|
||||
:vai #"[\uA500-\uA63F]"
|
||||
:shavian #"\uD801[\uDC50-\uDC7F]"
|
||||
:osmanya #"\uD801[\uDC80-\uDCAF]"
|
||||
:runic #"[\u16A0-\u16FF]"
|
||||
:old-italic #"\uD800[\uDF00-\uDF2F]"
|
||||
:brahmi #"\uD804[\uDC00-\uDC7F]"
|
||||
:modi #"\uD805[\uDE00-\uDE5F]"
|
||||
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
|
||||
:bamum #"[\uA6A0-\uA6FF]"
|
||||
:meroitic #"\uD802[\uDD80-\uDD9F]"
|
||||
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
|
||||
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
|
||||
;; Additional symbol blocks covered by Noto Sans Symbols 2:
|
||||
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
|
||||
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
|
||||
;; Legacy Computing Symbols (U+1FB00-1FBFF)
|
||||
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
|
||||
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
|
||||
|
||||
(defn contains-emoji? [text]
|
||||
(let [result (re-find emoji-pattern text)]
|
||||
(boolean result)))
|
||||
|
||||
(defn collect-used-languages
|
||||
[used text]
|
||||
(reduce-kv (fn [result lang pattern]
|
||||
(cond
|
||||
;; Skip regex operation if we already know that
|
||||
;; langage is present
|
||||
(contains? result lang)
|
||||
result
|
||||
|
||||
(re-find pattern text)
|
||||
(conj result lang)
|
||||
|
||||
:else
|
||||
result))
|
||||
used
|
||||
unicode-ranges))
|
||||
|
||||
(defn add-emoji-font
|
||||
[fonts]
|
||||
(conj fonts {:font-id "gfont-noto-color-emoji"
|
||||
:font-variant-id "regular"
|
||||
:style 0
|
||||
:weight 400
|
||||
:is-emoji true
|
||||
:is-fallback true}))
|
||||
|
||||
(def noto-fonts
|
||||
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
|
||||
|
||||
(defn add-noto-fonts [fonts languages]
|
||||
(reduce (fn [acc lang]
|
||||
(if-let [font (get noto-fonts lang)]
|
||||
(conj acc font)
|
||||
acc))
|
||||
fonts
|
||||
languages))
|
||||
47
frontend/src/app/render_wasm/resources.cljs
Normal file
47
frontend/src/app/render_wasm/resources.cljs
Normal file
@ -0,0 +1,47 @@
|
||||
;; 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.resources
|
||||
"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)."
|
||||
(:require
|
||||
[app.common.types.fills :as types.fills]))
|
||||
|
||||
(defn- fill-image-ids
|
||||
[fills]
|
||||
(some-> fills not-empty types.fills/coerce types.fills/get-image-ids))
|
||||
|
||||
(defn- stroke-image-ids
|
||||
[strokes]
|
||||
(keep (comp :id :stroke-image) strokes))
|
||||
|
||||
(defn- text-image-ids
|
||||
"Image-fill ids referenced by a text shape's span fills."
|
||||
[content]
|
||||
(when content
|
||||
(->> (tree-seq :children :children content)
|
||||
(mapcat :fills)
|
||||
(keep (comp :id :fill-image)))))
|
||||
|
||||
(defn shape-image-ids
|
||||
"Distinct image ids referenced by one shape: its fills, its strokes' image
|
||||
fills, and (for texts) its spans' image fills."
|
||||
[shape]
|
||||
(-> #{}
|
||||
(into (fill-image-ids (:fills shape)))
|
||||
(into (stroke-image-ids (:strokes shape)))
|
||||
;; `:content` is only a text tree for text shapes (paths reuse the key
|
||||
;; for geometry).
|
||||
(into (when (= :text (:type shape))
|
||||
(text-image-ids (:content shape))))))
|
||||
|
||||
(defn scene-image-ids
|
||||
"Distinct image ids referenced anywhere in an `objects` map."
|
||||
[scene]
|
||||
(into #{} (mapcat shape-image-ids) (vals scene)))
|
||||
54
frontend/src/app/render_wasm/serialize_shape.cljs
Normal file
54
frontend/src/app/render_wasm/serialize_shape.cljs
Normal file
@ -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.render-wasm.serialize-shape
|
||||
"Single source of truth for the host-independent part of serializing a whole
|
||||
shape into the WASM design state.
|
||||
|
||||
Both batch serializers call this so they can't drift:
|
||||
- the workspace `app.render-wasm.api/set-object` (browser), and
|
||||
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
|
||||
|
||||
It applies only the properties that need no host-specific resources or driver:
|
||||
base props, children, blur, background blur, shadows, svg attrs, group mask,
|
||||
bool type, path/bool geometry and text grow type. The parts that DO differ by
|
||||
host are handled by each caller AFTER this runs:
|
||||
- fills / strokes (image bytes are fetched + uploaded differently),
|
||||
- text content (fonts),
|
||||
- svg-raw markup (browser renders it via React),
|
||||
- layout (grid/flex — workspace only).
|
||||
|
||||
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]))
|
||||
|
||||
(defn serialize-shape!
|
||||
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
|
||||
runs first because it selects the current shape (`use_shape`) the rest mutate."
|
||||
[shape]
|
||||
(let [type (get shape :type)]
|
||||
(shapes/set-shape-base-props shape)
|
||||
(props/set-shape-children (get shape :shapes))
|
||||
(props/set-shape-blur (get shape :blur))
|
||||
(props/set-shape-background-blur (get shape :background-blur))
|
||||
(props/set-shape-shadows (get shape :shadow))
|
||||
|
||||
(when (some? (get shape :svg-attrs))
|
||||
(props/set-shape-svg-attrs (get shape :svg-attrs)))
|
||||
|
||||
(when (= type :group)
|
||||
(props/set-masked (boolean (get shape :masked-group))))
|
||||
|
||||
(when (= type :bool)
|
||||
(props/set-shape-bool-type (get shape :bool-type)))
|
||||
|
||||
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
|
||||
(props/set-shape-path-content (get shape :content)))
|
||||
|
||||
(when (= type :text)
|
||||
(props/set-shape-grow-type (get shape :grow-type)))))
|
||||
198
frontend/src/app/render_wasm/text_content.cljs
Normal file
198
frontend/src/app/render_wasm/text_content.cljs
Normal file
@ -0,0 +1,198 @@
|
||||
;; 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.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!`.
|
||||
|
||||
Fully portable (no store/DOM/React), so it runs under Node too."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[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)
|
||||
(def ^:const SPAN-ATTR-U8-SIZE 64)
|
||||
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
|
||||
|
||||
(def ^:private default-font-size 14)
|
||||
(def ^:private default-line-height 1.2)
|
||||
(def ^:private default-letter-spacing 0.0)
|
||||
|
||||
;; --- pure attribute serializers -------------------------------------------
|
||||
|
||||
(defn serialize-font-size
|
||||
[font-size]
|
||||
(cond
|
||||
(number? font-size) font-size
|
||||
(string? font-size) (or (d/parse-double font-size) default-font-size)
|
||||
:else default-font-size))
|
||||
|
||||
(defn serialize-font-weight
|
||||
[font-weight]
|
||||
(if (number? font-weight)
|
||||
font-weight
|
||||
(let [font-weight-str (str font-weight)]
|
||||
(cond
|
||||
(re-matches #"\d+" font-weight-str) (js/Number font-weight-str)
|
||||
(str/includes? font-weight-str "bold") 700
|
||||
(str/includes? font-weight-str "black") 900
|
||||
(str/includes? font-weight-str "extrabold") 800
|
||||
(str/includes? font-weight-str "extralight") 200
|
||||
(str/includes? font-weight-str "light") 300
|
||||
(str/includes? font-weight-str "medium") 500
|
||||
(str/includes? font-weight-str "semibold") 600
|
||||
(str/includes? font-weight-str "thin") 100
|
||||
:else 400))))
|
||||
|
||||
(defn serialize-line-height
|
||||
([line-height] (serialize-line-height line-height default-line-height))
|
||||
([line-height default-value]
|
||||
(cond
|
||||
(number? line-height) line-height
|
||||
(string? line-height) (or (d/parse-double line-height) default-value)
|
||||
:else default-value)))
|
||||
|
||||
(defn serialize-letter-spacing
|
||||
[letter-spacing]
|
||||
(cond
|
||||
(number? letter-spacing) letter-spacing
|
||||
(string? letter-spacing) (or (d/parse-double letter-spacing) default-letter-spacing)
|
||||
:else default-letter-spacing))
|
||||
|
||||
;; --- binary writers --------------------------------------------------------
|
||||
|
||||
(defn- encode-text
|
||||
"Into an UTF8 buffer. Returns an ArrayBuffer instance."
|
||||
[text]
|
||||
(let [encoder (js/TextEncoder.)]
|
||||
(.encode encoder text)))
|
||||
|
||||
(defn- write-span-fills
|
||||
[offset dview fills]
|
||||
(let [new-ofset (reduce (fn [offset fill]
|
||||
(let [opacity (get fill :fill-opacity 1.0)
|
||||
color (get fill :fill-color)
|
||||
gradient (get fill :fill-color-gradient)
|
||||
image (get fill :fill-image)]
|
||||
(cond
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill offset dview opacity image))))
|
||||
offset
|
||||
fills)
|
||||
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
|
||||
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
|
||||
|
||||
(defn- write-paragraph
|
||||
[offset dview paragraph]
|
||||
(let [text-align (sr/translate-text-align (get paragraph :text-align))
|
||||
text-direction (sr/translate-text-direction (get paragraph :text-direction))
|
||||
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
|
||||
text-transform (sr/translate-text-transform (get paragraph :text-transform))
|
||||
line-height (serialize-line-height (get paragraph :line-height))
|
||||
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview text-align)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
|
||||
|
||||
(defn- write-spans
|
||||
[offset dview spans paragraph normalize-font-id]
|
||||
(let [paragraph-font-size (get paragraph :font-size)
|
||||
paragraph-font-weight (-> paragraph :font-weight serialize-font-weight)
|
||||
paragraph-line-height (serialize-line-height (get paragraph :line-height))]
|
||||
(reduce
|
||||
(fn [offset span]
|
||||
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
|
||||
font-size (serialize-font-size (get span :font-size paragraph-font-size))
|
||||
line-height (serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (serialize-letter-spacing (get span :letter-spacing))
|
||||
font-weight (serialize-font-weight (get span :font-weight paragraph-font-weight))
|
||||
font-id (normalize-font-id (get span :font-id "sourcesanspro"))
|
||||
font-family (hash (get span :font-family "sourcesanspro"))
|
||||
text-buffer (encode-text (get span :text ""))
|
||||
text-length (mem/size text-buffer)
|
||||
fills (take MAX-TEXT-FILLS (get span :fills []))
|
||||
font-variant-id (get span :font-variant-id)
|
||||
font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero)
|
||||
text-decoration (or (sr/translate-text-decoration (:text-decoration span))
|
||||
(sr/translate-text-decoration (:text-decoration paragraph))
|
||||
(sr/translate-text-decoration "none"))
|
||||
text-transform (or (sr/translate-text-transform (:text-transform span))
|
||||
(sr/translate-text-transform (:text-transform paragraph))
|
||||
(sr/translate-text-transform "none"))
|
||||
text-direction (or (sr/translate-text-direction (:text-direction span))
|
||||
(sr/translate-text-direction (:text-direction paragraph))
|
||||
(sr/translate-text-direction "ltr"))]
|
||||
(-> offset
|
||||
(mem/write-u8 dview font-style)
|
||||
(mem/write-u8 dview text-decoration)
|
||||
(mem/write-u8 dview text-transform)
|
||||
(mem/write-u8 dview text-direction)
|
||||
(mem/write-f32 dview font-size)
|
||||
(mem/write-f32 dview line-height)
|
||||
(mem/write-f32 dview letter-spacing)
|
||||
(mem/write-u32 dview font-weight)
|
||||
(mem/write-uuid dview font-id)
|
||||
(mem/write-i32 dview font-family)
|
||||
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
|
||||
(mem/write-i32 dview text-length)
|
||||
(mem/write-i32 dview (count fills))
|
||||
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
|
||||
(write-span-fills dview fills))))
|
||||
offset
|
||||
spans)))
|
||||
|
||||
(defn write-shape-text!
|
||||
"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,
|
||||
- `: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
|
||||
normalize-paragraph identity
|
||||
normalize-span (fn [span _paragraph] span)}}]
|
||||
(let [paragraph (normalize-paragraph paragraph)
|
||||
spans (map #(normalize-span % paragraph) spans)
|
||||
num-spans (count spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
text-buffer (encode-text text)
|
||||
text-size (mem/size text-buffer)
|
||||
total-size (+ 4 metadata-size text-size)
|
||||
heapu8 (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)
|
||||
offset (mem/alloc total-size)]
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview paragraph)
|
||||
(write-spans dview spans paragraph normalize-font-id)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
@ -41,7 +41,7 @@ export EMCC_CFLAGS="--no-entry \
|
||||
-sMAX_WEBGL_VERSION=2 \
|
||||
-sEXPORT_NAME=createRustSkiaModule \
|
||||
-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
|
||||
-sENVIRONMENT=web \
|
||||
-sENVIRONMENT=web,worker,node \
|
||||
-sMODULARIZE=1 \
|
||||
-sDISABLE_EXCEPTION_CATCHING=1 \
|
||||
-sFILESYSTEM=0 \
|
||||
|
||||
@ -4,7 +4,7 @@ use macros::wasm_error;
|
||||
use crate::emscripten::init_gl;
|
||||
|
||||
use crate::mem;
|
||||
use crate::render::{gpu_state::GpuState, RenderState};
|
||||
use crate::render::{gpu_state::GpuState, RenderResources, RenderState};
|
||||
use crate::state::{State, TextEditorState, UIState};
|
||||
|
||||
static mut DESIGN_STATE: *mut State = std::ptr::null_mut();
|
||||
@ -23,7 +23,12 @@ static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
|
||||
unsafe {
|
||||
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
|
||||
// `assert!` (not `debug_assert!`): a headless instance never inits GPU
|
||||
// state, so an interactive call must fail-fast rather than deref null.
|
||||
assert!(
|
||||
!GPU_STATE.is_null(),
|
||||
"GPU State is null (headless instance?)"
|
||||
);
|
||||
&mut *GPU_STATE
|
||||
}
|
||||
}
|
||||
@ -39,6 +44,26 @@ pub(crate) fn get_render_state() -> &'static mut RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the global render state has been initialized. The headless export
|
||||
/// path (`init_headless`) boots without a GPU render state, so tile and
|
||||
/// interaction bookkeeping (which only exists to drive incremental on-screen
|
||||
/// rendering) must be skipped when this is false.
|
||||
#[inline(always)]
|
||||
pub(crate) fn has_render_state() -> bool {
|
||||
unsafe { !RENDER_STATE.is_null() }
|
||||
}
|
||||
|
||||
/// GPU-free resources for the headless export path
|
||||
static mut RENDER_RESOURCES: *mut RenderResources = std::ptr::null_mut();
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn get_resources() -> &'static mut RenderResources {
|
||||
unsafe {
|
||||
debug_assert!(!RENDER_RESOURCES.is_null(), "Render Resources is null");
|
||||
&mut *RENDER_RESOURCES
|
||||
}
|
||||
}
|
||||
|
||||
/// Text Editor State
|
||||
static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
|
||||
|
||||
@ -113,6 +138,23 @@ fn render_init(width: i32, height: i32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the interactive RenderResources (GPU image store).
|
||||
fn resources_init() {
|
||||
unsafe {
|
||||
let resources = RenderResources::try_new().expect("Cannot initialize RenderResources");
|
||||
RENDER_RESOURCES = Box::into_raw(Box::new(resources));
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes GPU-free RenderResources for the headless export path.
|
||||
fn resources_init_headless() {
|
||||
unsafe {
|
||||
let resources = RenderResources::try_new_headless()
|
||||
.expect("Cannot initialize headless RenderResources");
|
||||
RENDER_RESOURCES = Box::into_raw(Box::new(resources));
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes DesignState.
|
||||
fn design_init() {
|
||||
unsafe {
|
||||
@ -143,6 +185,7 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
init_gl!();
|
||||
gpu_init();
|
||||
resources_init();
|
||||
render_init(width, height);
|
||||
text_editor_init();
|
||||
design_init();
|
||||
@ -150,6 +193,19 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Boots the engine for headless export with no GPU/WebGL context: only
|
||||
/// `RenderResources` (GPU-free) + the design state. The export (raster/PDF)
|
||||
/// paths render onto their own surface; the interactive render loop is not
|
||||
/// available on an instance initialized this way. `width`/`height` are kept for
|
||||
/// API symmetry with `init` but unused — the export sizes from shape bounds.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn init_headless(_width: i32, _height: i32) -> Result<()> {
|
||||
resources_init_headless();
|
||||
design_init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn clean_up() -> Result<()> {
|
||||
|
||||
81
render-wasm/src/js/render-headless.mjs
Normal file
81
render-wasm/src/js/render-headless.mjs
Normal file
@ -0,0 +1,81 @@
|
||||
// Headless render-wasm smoke test: loads the artifact in Node (no browser/
|
||||
// WebGL), boots via init_headless, renders a red rect to PNG, validates it.
|
||||
// Requires render-wasm built with -sENVIRONMENT=web,worker,node.
|
||||
// node render-wasm/src/js/render-headless.mjs
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const ARTIFACT_DIR = resolve(here, "../../../frontend/resources/public/js");
|
||||
const JS_PATH = resolve(ARTIFACT_DIR, "render-wasm.js");
|
||||
const WASM_PATH = resolve(ARTIFACT_DIR, "render-wasm.wasm");
|
||||
|
||||
// 4 + max(gradient 156, image 36, solid 4); solid fill = tag u8 @0, ARGB u32 @4.
|
||||
const FILL_U8_SIZE = 160;
|
||||
|
||||
async function main() {
|
||||
const wasmBytes = readFileSync(WASM_PATH);
|
||||
const factory = (await import(pathToFileURL(JS_PATH).href)).default;
|
||||
|
||||
const Module = await factory({
|
||||
instantiateWasm(imports, success) {
|
||||
WebAssembly.instantiate(wasmBytes, imports).then(({ instance }) => success(instance));
|
||||
return {};
|
||||
},
|
||||
locateFile: (p) => resolve(ARTIFACT_DIR, p),
|
||||
printErr: (s) => console.error("[wasm:err]", s),
|
||||
});
|
||||
|
||||
const call = (name, ...args) => {
|
||||
const fn = Module["_" + name];
|
||||
if (typeof fn !== "function") throw new Error(`export _${name} missing`);
|
||||
return fn(...args);
|
||||
};
|
||||
|
||||
call("init_headless", 800, 600);
|
||||
|
||||
// 200x120 rect; any fixed non-nil uuid, as long as use/render agree.
|
||||
const [a, b, c, d] = [1, 2, 3, 4];
|
||||
call("init_shapes_pool", 1);
|
||||
call("use_shape", a, b, c, d);
|
||||
call("set_shape_selrect", 0, 0, 200, 120);
|
||||
|
||||
// Solid opaque-red fill: [num_fills u8][3 pad][160-byte record].
|
||||
const ptr = call("alloc_bytes", 4 + FILL_U8_SIZE);
|
||||
const buf = Module.HEAPU8.subarray(ptr, ptr + 4 + FILL_U8_SIZE);
|
||||
buf.fill(0);
|
||||
buf[0] = 1;
|
||||
const fill = new DataView(buf.buffer, buf.byteOffset + 4, FILL_U8_SIZE);
|
||||
fill.setUint8(0, 0x00);
|
||||
fill.setUint32(4, 0xffff0000, true);
|
||||
call("set_shape_fills");
|
||||
|
||||
// Result layout: [len u32][w u32][h u32][png...].
|
||||
const resPtr = call("render_shape_raster", a, b, c, d, 1.0);
|
||||
const u32 = Module.HEAPU32;
|
||||
const base = resPtr >>> 2;
|
||||
const [len, width, height] = [u32[base], u32[base + 1], u32[base + 2]];
|
||||
const png = Module.HEAPU8.slice(resPtr + 12, resPtr + 12 + len);
|
||||
call("free_bytes");
|
||||
|
||||
const out = "/tmp/headless-render.png";
|
||||
writeFileSync(out, png);
|
||||
|
||||
const MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const okMagic = MAGIC.every((v, i) => png[i] === v);
|
||||
const ihdrW = (png[16] << 24) | (png[17] << 16) | (png[18] << 8) | png[19];
|
||||
const ihdrH = (png[20] << 24) | (png[21] << 16) | (png[22] << 8) | png[23];
|
||||
console.log(`render_shape_raster -> ${len} bytes (${width}x${height}); PNG ${ihdrW}x${ihdrH} -> ${out}`);
|
||||
|
||||
if (!okMagic || ihdrW !== 200 || ihdrH !== 120 || len < 100) {
|
||||
throw new Error("unexpected PNG output");
|
||||
}
|
||||
console.log("OK: headless render in Node works.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("FAILED:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -22,7 +22,7 @@ use std::collections::HashMap;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::{FrameType, RenderFlag};
|
||||
|
||||
use globals::{get_design_state, get_gpu_state, get_render_state};
|
||||
use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state};
|
||||
|
||||
use macros::wasm_error;
|
||||
use math::{Bounds, Matrix};
|
||||
@ -720,7 +720,7 @@ pub extern "C" fn is_image_cached(
|
||||
is_thumbnail: bool,
|
||||
) -> Result<bool> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let result = get_render_state().has_image(&id, is_thumbnail);
|
||||
let result = get_resources().images.contains(&id, is_thumbnail);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@ -734,8 +734,7 @@ pub extern "C" fn set_shape_svg_raw_content() -> Result<()> {
|
||||
.trim_end_matches('\0')
|
||||
.to_string();
|
||||
|
||||
let render_state = get_render_state();
|
||||
let font_manager = skia::FontMgr::from(render_state.fonts().font_provider().clone());
|
||||
let font_manager = skia::FontMgr::from(get_resources().fonts.font_provider().clone());
|
||||
shape.set_svg_raw_content(svg_raw_content);
|
||||
shape.update_svg_raw_content(font_manager);
|
||||
});
|
||||
@ -1001,6 +1000,36 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -
|
||||
})
|
||||
}
|
||||
|
||||
/// PNG via CPU raster (no GPU/WebGL). Returns `[len][width][height][png]` (LE),
|
||||
/// same layout as `render_shape_pixels`.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render_shape_raster(
|
||||
a: u32,
|
||||
b: u32,
|
||||
c: u32,
|
||||
d: u32,
|
||||
scale: f32,
|
||||
) -> Result<*mut u8> {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
|
||||
if !scale.is_finite() {
|
||||
return Err(Error::CriticalError("Scale is not finite".to_string()));
|
||||
}
|
||||
|
||||
with_state!(state, {
|
||||
let (data, width, height) = state.render_shape_raster(&id, scale)?;
|
||||
|
||||
let len = data.len() as u32;
|
||||
let mut buf = Vec::with_capacity(12 + data.len());
|
||||
buf.extend_from_slice(&len.to_le_bytes());
|
||||
buf.extend_from_slice(&width.to_le_bytes());
|
||||
buf.extend_from_slice(&height.to_le_bytes());
|
||||
buf.extend_from_slice(&data);
|
||||
Ok(mem::write_bytes(buf))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
// Why an empty main?
|
||||
// Right now with the target `wasm32-unknown-emscripten` it is not possible
|
||||
|
||||
@ -7,6 +7,8 @@ pub mod grid_layout;
|
||||
mod images;
|
||||
mod options;
|
||||
pub mod pdf;
|
||||
pub mod raster;
|
||||
mod resources;
|
||||
mod shadows;
|
||||
pub mod shape_renderer;
|
||||
mod strokes;
|
||||
@ -34,10 +36,11 @@ use crate::tiles::{self, PendingTiles, TileRect};
|
||||
use crate::uuid::Uuid;
|
||||
use crate::view::Viewbox;
|
||||
use crate::wapi;
|
||||
use crate::{get_gpu_state, performance};
|
||||
use crate::{get_gpu_state, get_resources, performance};
|
||||
|
||||
pub use fonts::*;
|
||||
pub use images::*;
|
||||
pub(crate) use resources::RenderResources;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
@ -348,15 +351,12 @@ pub(crate) struct RenderState {
|
||||
pub options: RenderOptions,
|
||||
stats: RenderStats,
|
||||
pub surfaces: Surfaces,
|
||||
pub fonts: FontStore,
|
||||
pub viewbox: Viewbox,
|
||||
pub cached_viewbox: Viewbox,
|
||||
pub images: ImageStore,
|
||||
pub background_color: skia::Color,
|
||||
// Stack of nodes pending to be rendered.
|
||||
pending_nodes: Vec<NodeRenderState>,
|
||||
pub current_tile: Option<tiles::Tile>,
|
||||
pub sampling_options: skia::SamplingOptions,
|
||||
pub render_area: Rect,
|
||||
// render_area expanded by surface margins — used for visibility checks so that
|
||||
// shapes in the margin zone are rendered (needed for background blur sampling).
|
||||
@ -546,19 +546,18 @@ impl RenderState {
|
||||
|
||||
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
|
||||
// This needs to be done once per WebGL context.
|
||||
let sampling_options =
|
||||
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
|
||||
let sampling_options = get_resources().sampling_options;
|
||||
|
||||
let fonts = FontStore::try_new()?;
|
||||
let surfaces = Surfaces::try_new(
|
||||
(width, height),
|
||||
sampling_options,
|
||||
tiles::get_tile_dimensions(),
|
||||
)?;
|
||||
|
||||
// This is used multiple times everywhere so instead of creating new instances every
|
||||
// time we reuse this one.
|
||||
Self::assemble(width, height, surfaces)
|
||||
}
|
||||
|
||||
fn assemble(width: i32, height: i32, surfaces: Surfaces) -> Result<RenderState> {
|
||||
let viewbox = Viewbox::new(width as f32, height as f32);
|
||||
let tiles = tiles::TileHashMap::new();
|
||||
let options = RenderOptions::default();
|
||||
@ -567,14 +566,11 @@ impl RenderState {
|
||||
options,
|
||||
stats: RenderStats::new(),
|
||||
surfaces,
|
||||
fonts,
|
||||
viewbox,
|
||||
cached_viewbox: Viewbox::new(0., 0.),
|
||||
images: ImageStore::new(),
|
||||
background_color: skia::Color::TRANSPARENT,
|
||||
pending_nodes: vec![],
|
||||
current_tile: None,
|
||||
sampling_options,
|
||||
render_area: Rect::new_empty(),
|
||||
render_area_with_margins: Rect::new_empty(),
|
||||
tiles,
|
||||
@ -778,35 +774,6 @@ impl RenderState {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn fonts(&self) -> &FontStore {
|
||||
&self.fonts
|
||||
}
|
||||
|
||||
pub fn fonts_mut(&mut self) -> &mut FontStore {
|
||||
&mut self.fonts
|
||||
}
|
||||
|
||||
pub fn add_image(&mut self, id: Uuid, is_thumbnail: bool, image_data: &[u8]) -> Result<()> {
|
||||
self.images.add(id, is_thumbnail, image_data)
|
||||
}
|
||||
|
||||
/// Adds an image from an existing WebGL texture, avoiding re-decoding
|
||||
pub fn add_image_from_gl_texture(
|
||||
&mut self,
|
||||
id: Uuid,
|
||||
is_thumbnail: bool,
|
||||
texture_id: u32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<()> {
|
||||
self.images
|
||||
.add_image_from_gl_texture(id, is_thumbnail, texture_id, width, height)
|
||||
}
|
||||
|
||||
pub fn has_image(&self, id: &Uuid, is_thumbnail: bool) -> bool {
|
||||
self.images.contains(id, is_thumbnail)
|
||||
}
|
||||
|
||||
pub fn set_debug_flags(&mut self, debug: u32) {
|
||||
self.options.flags = debug;
|
||||
}
|
||||
@ -821,7 +788,7 @@ impl RenderState {
|
||||
self.viewbox.width().floor() as i32,
|
||||
self.viewbox.height().floor() as i32,
|
||||
)?;
|
||||
self.fonts.set_scale_debug_font(dpr);
|
||||
get_resources().fonts.set_scale_debug_font(dpr);
|
||||
self.viewbox.set_dpr(dpr);
|
||||
self.surfaces.set_dpr(dpr);
|
||||
}
|
||||
@ -969,7 +936,7 @@ impl RenderState {
|
||||
text_paint.set_color(skia::Color::GRAY);
|
||||
text_paint.set_anti_alias(true);
|
||||
|
||||
let font = self.fonts.debug_font();
|
||||
let font = get_resources().fonts.debug_font();
|
||||
// FIXME
|
||||
let text = "Loading…";
|
||||
let (text_width, _) = font.measure_str(text, None);
|
||||
@ -1398,7 +1365,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
match &shape.shape_type {
|
||||
Type::SVGRaw(_) => {
|
||||
Type::SVGRaw(sr) => {
|
||||
if let Some(svg_transform) = shape.svg_transform() {
|
||||
matrix.pre_concat(&svg_transform);
|
||||
}
|
||||
@ -1410,7 +1377,18 @@ impl RenderState {
|
||||
if let Some(svg) = shape.svg.as_ref() {
|
||||
svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
|
||||
} else {
|
||||
panic!("SVG should be available");
|
||||
let font_manager =
|
||||
skia::FontMgr::from(get_resources().fonts.font_provider().clone());
|
||||
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
|
||||
match dom_result {
|
||||
Ok(dom) => {
|
||||
dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
|
||||
shape.to_mut().set_svg(dom);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing SVG. Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2987,7 +2965,7 @@ impl RenderState {
|
||||
surface.draw(
|
||||
drop_canvas,
|
||||
(0.0, 0.0),
|
||||
self.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
Some(&drop_paint),
|
||||
);
|
||||
drop_canvas.restore();
|
||||
@ -2997,7 +2975,7 @@ impl RenderState {
|
||||
surface.draw(
|
||||
drop_canvas,
|
||||
(0.0, 0.0),
|
||||
self.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
Some(&drop_paint),
|
||||
);
|
||||
drop_canvas.restore();
|
||||
|
||||
@ -6,6 +6,7 @@ use macros::wasm_error;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
|
||||
use skia_safe::{self as skia, Rect};
|
||||
|
||||
@ -62,16 +63,16 @@ pub fn render_wasm_label(render_state: &mut RenderState) {
|
||||
"WebGL rendering"
|
||||
};
|
||||
let dpr = render_state.options.dpr;
|
||||
let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None);
|
||||
let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None);
|
||||
let mut p = skia::Point::new(canvas_width - (20.0 * dpr) - scalar, 50.0 * dpr);
|
||||
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str, p, debug_font, &paint);
|
||||
|
||||
if render_state.options.is_text_editor_v3() {
|
||||
str = "Text Editor v3";
|
||||
|
||||
let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None);
|
||||
let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None);
|
||||
p.x = canvas_width - (20.0 * dpr) - scalar;
|
||||
p.y += 15.0 * dpr;
|
||||
canvas.draw_str(str, p, debug_font, &paint);
|
||||
@ -85,7 +86,7 @@ pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) {
|
||||
paint.set_color(skia::Color::RED);
|
||||
let str_rect = format!("{} {} {} {}", sx, sy, ex, ey);
|
||||
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str_rect, skia::Point::new(100.0, 150.0), debug_font, &paint);
|
||||
}
|
||||
|
||||
@ -104,7 +105,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
|
||||
|
||||
let str_rect = format!("{} {} {} {}", sx, sy, ex, ey);
|
||||
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
@ -114,7 +115,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
|
||||
let debug_rect = get_debug_rect(rect);
|
||||
let p = skia::Point::new(debug_rect.x(), debug_rect.y() - 1.);
|
||||
let str = format!("{}:{}", x, y);
|
||||
let debug_font = render_state.fonts.debug_font();
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
paint.set_style(skia::PaintStyle::Fill);
|
||||
canvas.draw_str(str, p, debug_font, &paint);
|
||||
canvas.draw_rect(debug_rect, &paint);
|
||||
@ -153,7 +154,7 @@ pub fn render_workspace_current_tile(
|
||||
let tile_position_origin = skia::Point::new(rect.x() + 10., rect.y() + 20.);
|
||||
p.set_style(skia::PaintStyle::Fill);
|
||||
let str = format!("{prefix} {}:{}", tile.x(), tile.y());
|
||||
let mut debug_font = render_state.fonts.debug_font().clone();
|
||||
let mut debug_font = get_resources().fonts.debug_font().clone();
|
||||
debug_font.set_size(16.);
|
||||
canvas.draw_str(str, tile_position_origin, &debug_font, &p);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
|
||||
|
||||
use super::{filters, RenderState, SurfaceId};
|
||||
use crate::error::Result;
|
||||
use crate::get_resources;
|
||||
use crate::render::get_source_rect;
|
||||
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
|
||||
|
||||
@ -13,7 +14,7 @@ fn draw_image_fill(
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
) {
|
||||
let Some(image) = render_state.images.get(&image_fill.id()) else {
|
||||
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@ -83,7 +84,7 @@ fn draw_image_fill(
|
||||
image,
|
||||
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||
dest_rect,
|
||||
render_state.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
paint,
|
||||
);
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ use skia_safe::{self as skia, ImageFilter, Rect};
|
||||
|
||||
use super::{RenderState, SurfaceId};
|
||||
use crate::error::Result;
|
||||
use crate::get_resources;
|
||||
|
||||
/// Composes two image filters, returning a combined filter if both are present,
|
||||
/// or the individual filter if only one is present, or None if neither is present.
|
||||
@ -51,12 +52,12 @@ where
|
||||
canvas.save();
|
||||
canvas.scale((1.0 / scale, 1.0 / scale));
|
||||
canvas.translate((bounds.left * scale, bounds.top * scale));
|
||||
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
|
||||
surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None);
|
||||
canvas.restore();
|
||||
} else {
|
||||
canvas.save();
|
||||
canvas.translate((bounds.left, bounds.top));
|
||||
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
|
||||
surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None);
|
||||
canvas.restore();
|
||||
}
|
||||
Ok(true)
|
||||
|
||||
@ -61,7 +61,8 @@ enum StoredImage {
|
||||
|
||||
pub struct ImageStore {
|
||||
images: HashMap<(Uuid, bool), StoredImage>,
|
||||
context: Box<DirectContext>,
|
||||
/// gpu-only
|
||||
context: Option<Box<DirectContext>>,
|
||||
}
|
||||
|
||||
/// Creates a Skia image from an existing WebGL texture.
|
||||
@ -149,7 +150,17 @@ impl ImageStore {
|
||||
let context = &gpu_state.context;
|
||||
Self {
|
||||
images: HashMap::with_capacity(2048),
|
||||
context: Box::new(context.clone()),
|
||||
context: Some(Box::new(context.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-free image store for the headless export path: no GPU context, so
|
||||
/// images are kept as encoded bytes and decoded on the CPU at draw time
|
||||
/// (see `get_cpu_image`).
|
||||
pub fn new_without_gpu() -> Self {
|
||||
Self {
|
||||
images: HashMap::with_capacity(16),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,10 +178,18 @@ impl ImageStore {
|
||||
|
||||
let raw_data = image_data.to_vec();
|
||||
|
||||
if let Some(gpu_image) = decode_image(&mut self.context, &raw_data) {
|
||||
self.images.insert(key, StoredImage::Gpu(gpu_image));
|
||||
} else {
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
match self.context.as_mut() {
|
||||
Some(context) => {
|
||||
if let Some(gpu_image) = decode_image(context, &raw_data) {
|
||||
self.images.insert(key, StoredImage::Gpu(gpu_image));
|
||||
} else {
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
}
|
||||
}
|
||||
// GPU-free: keep the encoded bytes; decoded on the CPU at draw time.
|
||||
None => {
|
||||
self.images.insert(key, StoredImage::Raw(raw_data));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -193,7 +212,12 @@ impl ImageStore {
|
||||
}
|
||||
|
||||
// Create a Skia image from the existing GL texture
|
||||
let image = create_image_from_gl_texture(&mut self.context, texture_id, width, height)?;
|
||||
let Some(context) = self.context.as_mut() else {
|
||||
return Err(crate::error::Error::CriticalError(
|
||||
"Cannot register a GL texture without a GPU context".to_string(),
|
||||
));
|
||||
};
|
||||
let image = create_image_from_gl_texture(context, texture_id, width, height)?;
|
||||
self.images.insert(key, StoredImage::Gpu(image));
|
||||
|
||||
Ok(())
|
||||
@ -214,8 +238,27 @@ impl ImageStore {
|
||||
}
|
||||
|
||||
pub fn get_cpu_image(&mut self, id: &Uuid) -> Option<Image> {
|
||||
let gpu_image = self.get(id)?.clone();
|
||||
gpu_image.make_non_texture_image(self.context.as_mut())
|
||||
// GPU path: promote to a texture, then copy to a CPU image.
|
||||
if self.context.is_some() {
|
||||
let gpu_image = self.get(id)?.clone();
|
||||
let context = self.context.as_mut()?;
|
||||
return gpu_image.make_non_texture_image(context.as_mut());
|
||||
}
|
||||
// Headless (no GPU context): decode the stored encoded bytes directly to
|
||||
// a CPU image, which draws fine on a raster/PDF canvas. Try full first,
|
||||
// then thumbnail.
|
||||
self.decode_raw_cpu_image(id, false)
|
||||
.or_else(|| self.decode_raw_cpu_image(id, true))
|
||||
}
|
||||
|
||||
fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option<Image> {
|
||||
match self.images.get(&(*id, is_thumbnail))? {
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||
Image::from_encoded(&data)
|
||||
}
|
||||
StoredImage::Gpu(img) => Some(img.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
|
||||
@ -225,7 +268,8 @@ impl ImageStore {
|
||||
match entry {
|
||||
StoredImage::Gpu(ref img) => Some(img),
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let gpu_image = decode_image(&mut self.context, raw_data)?;
|
||||
let context = self.context.as_mut()?;
|
||||
let gpu_image = decode_image(context, raw_data)?;
|
||||
*entry = StoredImage::Gpu(gpu_image);
|
||||
|
||||
if let StoredImage::Gpu(ref img) = entry {
|
||||
|
||||
@ -69,6 +69,7 @@ impl RenderOptions {
|
||||
self.fast_mode = enabled;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn set_capture_frames(&mut self, capture_frames: i32) {
|
||||
self.capture_frames = capture_frames;
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ use crate::error::Result;
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::vector::{self, VectorTarget};
|
||||
use super::RenderState;
|
||||
use super::vector;
|
||||
use super::RenderResources;
|
||||
|
||||
/// Renders a shape tree to a PDF document and returns the raw PDF bytes.
|
||||
///
|
||||
@ -16,7 +16,7 @@ use super::RenderState;
|
||||
/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's
|
||||
/// PDF backend
|
||||
pub fn render_to_pdf(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
@ -24,7 +24,15 @@ pub fn render_to_pdf(
|
||||
let shape = tree
|
||||
.get(id)
|
||||
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?;
|
||||
let bounds = shape.extrect(tree, scale);
|
||||
// Boards export at their own bounds (like the classic exporter, which
|
||||
// screenshots exactly the board box): content overflowing a board lands
|
||||
// outside the page/surface and is cropped. Other shapes keep the extended
|
||||
// rect so their own shadows/blur are included.
|
||||
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
|
||||
shape.selrect()
|
||||
} else {
|
||||
shape.extrect(tree, scale)
|
||||
};
|
||||
|
||||
let page_w = bounds.width() * scale;
|
||||
let page_h = bounds.height() * scale;
|
||||
@ -45,7 +53,7 @@ pub fn render_to_pdf(
|
||||
let page_canvas = on_page.canvas();
|
||||
page_canvas.scale((scale, scale));
|
||||
page_canvas.translate((-bounds.left(), -bounds.top()));
|
||||
vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?;
|
||||
vector::render_tree_pdf(shared, page_canvas, id, tree, scale, bounds)?;
|
||||
}
|
||||
|
||||
let document = on_page.end_page();
|
||||
|
||||
59
render-wasm/src/render/raster.rs
Normal file
59
render-wasm/src/render/raster.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use skia_safe as skia;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
use super::vector;
|
||||
use super::RenderResources;
|
||||
|
||||
/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL).
|
||||
/// Returns `(png_bytes, width_px, height_px)`.
|
||||
pub fn render_to_raster(
|
||||
shared: &mut RenderResources,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<(Vec<u8>, i32, i32)> {
|
||||
let Some(shape) = tree.get(id) else {
|
||||
return Ok((Vec::new(), 0, 0));
|
||||
};
|
||||
// Boards export at their own bounds (like the classic exporter, which
|
||||
// screenshots exactly the board box): content overflowing a board lands
|
||||
// outside the page/surface and is cropped. Other shapes keep the extended
|
||||
// rect so their own shadows/blur are included.
|
||||
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
|
||||
shape.selrect()
|
||||
} else {
|
||||
shape.extrect(tree, scale)
|
||||
};
|
||||
|
||||
let width = (bounds.width() * scale).ceil() as i32;
|
||||
let height = (bounds.height() * scale).ceil() as i32;
|
||||
if width <= 0 || height <= 0 {
|
||||
return Ok((Vec::new(), 0, 0));
|
||||
}
|
||||
|
||||
let mut surface = skia::surfaces::raster_n32_premul((width, height)).ok_or_else(|| {
|
||||
Error::CriticalError("Failed to create raster export surface".to_string())
|
||||
})?;
|
||||
|
||||
{
|
||||
let canvas = surface.canvas();
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
canvas.scale((scale, scale));
|
||||
canvas.translate((-bounds.left(), -bounds.top()));
|
||||
vector::render_tree(shared, canvas, id, tree, scale, bounds)?;
|
||||
}
|
||||
|
||||
let data = surface
|
||||
.image_snapshot()
|
||||
.encode(
|
||||
None::<&mut skia::gpu::DirectContext>,
|
||||
skia::EncodedImageFormat::PNG,
|
||||
100,
|
||||
)
|
||||
.ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?;
|
||||
|
||||
Ok((data.as_bytes().to_vec(), width, height))
|
||||
}
|
||||
43
render-wasm/src/render/resources.rs
Normal file
43
render-wasm/src/render/resources.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use skia_safe as skia;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
use super::{FontStore, ImageStore};
|
||||
|
||||
/// Render dependencies that the export (PDF) path needs independently of the
|
||||
/// surface/tile/GPU machinery in [`RenderState`]: the font store, the
|
||||
/// decoded-image store, and the default sampling options.
|
||||
///
|
||||
/// Splitting these out keeps the vector/PDF export path (which takes
|
||||
/// `&mut RenderResources`) decoupled from the interactive renderer, and gives a
|
||||
/// single source of truth reached through `get_resources()`.
|
||||
pub(crate) struct RenderResources {
|
||||
pub fonts: FontStore,
|
||||
pub images: ImageStore,
|
||||
pub sampling_options: skia::SamplingOptions,
|
||||
}
|
||||
|
||||
impl RenderResources {
|
||||
pub fn try_new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
fonts: FontStore::try_new()?,
|
||||
images: ImageStore::new(),
|
||||
sampling_options: skia::SamplingOptions::new(
|
||||
skia::FilterMode::Linear,
|
||||
skia::MipmapMode::Nearest,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Headless export resources: CPU-only image store, no GPU/WebGL.
|
||||
pub fn try_new_headless() -> Result<Self> {
|
||||
Ok(Self {
|
||||
fonts: FontStore::try_new()?,
|
||||
images: ImageStore::new_without_gpu(),
|
||||
sampling_options: skia::SamplingOptions::new(
|
||||
skia::FilterMode::Linear,
|
||||
skia::MipmapMode::Nearest,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ use skia_safe::{self as skia, ImageFilter, RRect};
|
||||
|
||||
use super::{filters, RenderState, SurfaceId};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::get_resources;
|
||||
use crate::render::filters::compose_filters;
|
||||
use crate::render::{get_dest_rect, get_source_rect};
|
||||
|
||||
@ -467,7 +468,7 @@ fn draw_image_stroke_in_container(
|
||||
surface_id: SurfaceId,
|
||||
) -> Result<()> {
|
||||
let scale = render_state.get_scale();
|
||||
let Some(image) = render_state.images.get(&image_fill.id()) else {
|
||||
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@ -575,7 +576,7 @@ fn draw_image_stroke_in_container(
|
||||
image,
|
||||
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||
dest_rect,
|
||||
render_state.sampling_options,
|
||||
get_resources().sampling_options,
|
||||
&image_paint,
|
||||
);
|
||||
|
||||
|
||||
@ -106,6 +106,7 @@ impl DocAtlas {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: delete one docatlas is optional
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.size.width <= 0 || self.size.height <= 0
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use skia_safe::{self as skia, Color4f};
|
||||
|
||||
use super::{RenderState, ShapesPoolRef, SurfaceId};
|
||||
use crate::get_resources;
|
||||
use crate::globals::get_ui_state;
|
||||
use crate::render::grid_layout;
|
||||
use crate::shapes::{Layout, Type};
|
||||
@ -67,7 +68,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
|
||||
rulers::render(
|
||||
canvas,
|
||||
viewbox,
|
||||
&render_state.fonts,
|
||||
&get_resources().fonts,
|
||||
get_ui_state().ruler_state(),
|
||||
);
|
||||
|
||||
|
||||
@ -9,19 +9,9 @@ use crate::uuid::Uuid;
|
||||
|
||||
use super::shape_renderer::ShapeRenderer;
|
||||
use super::text;
|
||||
use super::RenderState;
|
||||
use super::RenderResources;
|
||||
use super::{get_dest_rect, get_source_rect};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VectorTarget — vector export backend selector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Vector export backend selector (PDF today; SVG could be added as a variant).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VectorTarget {
|
||||
Pdf,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -29,23 +19,16 @@ pub(super) enum VectorTarget {
|
||||
/// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces).
|
||||
pub(super) struct VectorRenderer<'a> {
|
||||
canvas: &'a Canvas,
|
||||
shared: &'a mut RenderState,
|
||||
shared: &'a mut RenderResources,
|
||||
scale: f32,
|
||||
_target: VectorTarget,
|
||||
}
|
||||
|
||||
impl<'a> VectorRenderer<'a> {
|
||||
pub fn new(
|
||||
canvas: &'a Canvas,
|
||||
shared: &'a mut RenderState,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
) -> Self {
|
||||
pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self {
|
||||
Self {
|
||||
canvas,
|
||||
shared,
|
||||
scale,
|
||||
_target: target,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -364,15 +347,88 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
// Tree traversal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Depth-first render of the shape tree rooted at `id`.
|
||||
/// Options threaded through the whole traversal. Carries the export root/page
|
||||
/// (needed to re-render the backdrop for background blur) and the background
|
||||
/// blur strategy for the active canvas.
|
||||
struct TreeOpts<'a> {
|
||||
/// The exported root shape id — the backdrop for any shape is re-rendered
|
||||
/// from here.
|
||||
root: &'a Uuid,
|
||||
/// Root's export bounds (shape space); sizes/positions the offscreen
|
||||
/// backdrop surface to match the page.
|
||||
page: skia::Rect,
|
||||
/// `true` on a PDF canvas (which ignores Skia backdrop filters): background
|
||||
/// blur is produced by rendering the backdrop onto an offscreen raster
|
||||
/// surface and embedding the blurred result as an image. `false` on a raster
|
||||
/// canvas, where a plain Skia backdrop filter works directly.
|
||||
embed_bg_blur: bool,
|
||||
/// When rendering a backdrop, the shape whose own subtree must be omitted
|
||||
/// (so the blur samples only what is *behind* it).
|
||||
skip: Option<&'a Uuid>,
|
||||
}
|
||||
|
||||
/// Depth-first render of the shape tree rooted at `id`. Used for raster export
|
||||
/// (PNG/webp), where background blur is applied with a direct Skia backdrop
|
||||
/// filter (raster surfaces support it). `page` must be the same bounds the
|
||||
/// caller used to size/translate its surface — the embedded background-blur
|
||||
/// backdrop is drawn device-aligned against it.
|
||||
pub(super) fn render_tree(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
page: skia::Rect,
|
||||
) -> Result<()> {
|
||||
render_tree_dispatch(shared, canvas, id, tree, scale, page, false)
|
||||
}
|
||||
|
||||
/// Like [`render_tree`], but for a PDF canvas: background blur is embedded as a
|
||||
/// rasterised image (the PDF backend ignores backdrop filters).
|
||||
pub(super) fn render_tree_pdf(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
page: skia::Rect,
|
||||
) -> Result<()> {
|
||||
render_tree_dispatch(shared, canvas, id, tree, scale, page, true)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tree_dispatch(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
page: skia::Rect,
|
||||
embed_bg_blur: bool,
|
||||
) -> Result<()> {
|
||||
let opts = TreeOpts {
|
||||
root: id,
|
||||
page,
|
||||
embed_bg_blur,
|
||||
skip: None,
|
||||
};
|
||||
render_tree_inner(shared, canvas, id, tree, scale, &opts)
|
||||
}
|
||||
|
||||
fn render_tree_inner(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
id: &Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
// When re-rendering a backdrop, omit the blur shape's own subtree so it only
|
||||
// samples what is behind it.
|
||||
if opts.skip == Some(id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(element) = tree.get(id) else {
|
||||
return Ok(());
|
||||
};
|
||||
@ -381,12 +437,27 @@ pub(super) fn render_tree(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Background blur samples already-drawn content behind the shape, so it must
|
||||
// run before the shape (and its subtree) paints. Text/SVGRaw are excluded,
|
||||
// matching the GPU path.
|
||||
if !matches!(element.shape_type, Type::Text(_) | Type::SVGRaw(_)) {
|
||||
if let Some(blur) = element.visible_background_blur() {
|
||||
if blur.value > 0.0 {
|
||||
if opts.embed_bg_blur {
|
||||
render_background_blur_image(shared, canvas, element, tree, scale, opts)?;
|
||||
} else {
|
||||
render_background_blur_backdrop(canvas, element, blur.value * scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &element.shape_type {
|
||||
Type::Group(group) => {
|
||||
render_group(shared, canvas, element, group.masked, tree, scale, target)?;
|
||||
render_group(shared, canvas, element, group.masked, tree, scale, opts)?;
|
||||
}
|
||||
Type::Frame(_) => {
|
||||
render_frame(shared, canvas, element, tree, scale, target)?;
|
||||
render_frame(shared, canvas, element, tree, scale, opts)?;
|
||||
}
|
||||
// Leaf types listed explicitly (no `_`) so a new Type must be handled.
|
||||
Type::Rect(_)
|
||||
@ -395,25 +466,135 @@ pub(super) fn render_tree(
|
||||
| Type::Bool(_)
|
||||
| Type::Text(_)
|
||||
| Type::SVGRaw(_) => {
|
||||
render_leaf(shared, canvas, element, scale, target)?;
|
||||
render_leaf(shared, canvas, element, scale)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background blur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Background blur on a canvas that supports Skia backdrop filters (raster).
|
||||
/// Blurs the current device contents within the shape silhouette and stamps the
|
||||
/// result back with `Src`. `sigma_radius` is the blur radius already multiplied
|
||||
/// by the export scale.
|
||||
fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) {
|
||||
let sigma = radius_to_sigma(sigma_radius);
|
||||
let Some(blur_filter) =
|
||||
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
clip_to_shape(canvas, shape, true);
|
||||
// Apply the blur in device space (sigma already includes the export scale);
|
||||
// the clip, set with the full transform, survives reset_matrix.
|
||||
canvas.reset_matrix();
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_blend_mode(skia::BlendMode::Src);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.backdrop(&blur_filter)
|
||||
.backdrop_tile_mode(skia::TileMode::Clamp)
|
||||
.paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
canvas.restore(); // composite the blurred-backdrop layer
|
||||
canvas.restore(); // pop the clip + transform
|
||||
}
|
||||
|
||||
/// Background blur for a PDF canvas (backdrop filters unsupported): render the
|
||||
/// backdrop — the whole page minus this shape's own subtree — onto an offscreen
|
||||
/// raster surface, blur it, and embed the result as an image clipped to the
|
||||
/// shape. The rest of the page stays vector.
|
||||
///
|
||||
/// LIMITATION: the backdrop omits only this shape's subtree, not shapes painted
|
||||
/// *after* it. For content stacked on top of the blur shape the foreground would
|
||||
/// bleed into the blur; correct for the common case (nothing above the panel).
|
||||
fn render_background_blur_image(
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
let bounds = opts.page;
|
||||
let width = (bounds.width() * scale).ceil() as i32;
|
||||
let height = (bounds.height() * scale).ceil() as i32;
|
||||
if width <= 0 || height <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Render the backdrop into an offscreen raster surface, in the same
|
||||
// coordinate space as the PDF page (see `render/pdf.rs`).
|
||||
let Some(mut surface) = skia::surfaces::raster_n32_premul((width, height)) else {
|
||||
return Ok(());
|
||||
};
|
||||
{
|
||||
let oc = surface.canvas();
|
||||
oc.clear(skia::Color::TRANSPARENT);
|
||||
oc.scale((scale, scale));
|
||||
oc.translate((-bounds.left(), -bounds.top()));
|
||||
let sub = TreeOpts {
|
||||
root: opts.root,
|
||||
page: opts.page,
|
||||
embed_bg_blur: false,
|
||||
skip: Some(&shape.id),
|
||||
};
|
||||
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
|
||||
}
|
||||
let image = surface.image_snapshot();
|
||||
|
||||
// Bake the blur into a raster bitmap. The PDF backend ignores image filters
|
||||
// at draw time (same limitation as backdrop filters), so we must blur on a
|
||||
// raster surface — where filters work — and embed the pre-blurred result.
|
||||
let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale);
|
||||
let blurred = {
|
||||
let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let bc = blur_surface.canvas();
|
||||
bc.clear(skia::Color::TRANSPARENT);
|
||||
let mut paint = Paint::default();
|
||||
if let Some(filter) =
|
||||
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
|
||||
{
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
bc.draw_image(&image, (0.0, 0.0), Some(&paint));
|
||||
blur_surface.image_snapshot()
|
||||
};
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
clip_to_shape(canvas, shape, true);
|
||||
// Draw the pre-blurred full-page bitmap in device space (1 image px per
|
||||
// device unit) so it aligns with the page regardless of the shape transform.
|
||||
canvas.reset_matrix();
|
||||
canvas.draw_image(&blurred, (0.0, 0.0), None);
|
||||
canvas.restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_group(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
masked: bool,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
// A group has no geometry of its own and does NOT propagate a transform to
|
||||
// its children: child shapes are stored in absolute coordinates and each
|
||||
@ -422,7 +603,7 @@ fn render_group(
|
||||
canvas.save();
|
||||
|
||||
// Group drop shadow: subtree silhouette, below the opacity/clip layer.
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?;
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, false, opts)?;
|
||||
|
||||
// Layer for opacity / blend mode (and group-level layer blur)
|
||||
let needs_layer = element.needs_layer();
|
||||
@ -455,21 +636,21 @@ fn render_group(
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
|
||||
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));
|
||||
render_tree(shared, canvas, mask_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?;
|
||||
canvas.restore(); // mask layer
|
||||
}
|
||||
|
||||
canvas.restore(); // composition layer
|
||||
} else {
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -485,12 +666,12 @@ fn render_group(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_frame(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
// A frame's own geometry (background, clip, strokes) is placed by its
|
||||
// `centered_transform`, but — like groups — it does NOT propagate that
|
||||
@ -503,7 +684,7 @@ fn render_frame(
|
||||
|
||||
// Frame drop shadow: background + subtree silhouette, below the clip layer
|
||||
// so it extends outside the frame bounds.
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?;
|
||||
render_container_drop_shadows(shared, canvas, element, tree, scale, true, opts)?;
|
||||
|
||||
let needs_layer = element.needs_layer();
|
||||
|
||||
@ -542,7 +723,7 @@ fn render_frame(
|
||||
if !element.fills.is_empty() {
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
canvas.restore();
|
||||
@ -551,7 +732,7 @@ fn render_frame(
|
||||
// Children (absolute coords, no frame transform).
|
||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
|
||||
// Strokes over children (clipped frames), in the frame's space.
|
||||
@ -559,7 +740,7 @@ fn render_frame(
|
||||
if !visible_strokes.is_empty() {
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
renderer.draw_strokes(element, &visible_strokes)?;
|
||||
canvas.restore();
|
||||
}
|
||||
@ -575,13 +756,13 @@ fn render_frame(
|
||||
/// layer (its alpha becomes the shadow). `draw_fills` includes the frame
|
||||
/// background in the silhouette.
|
||||
fn render_container_drop_shadows(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
draw_fills: bool,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let Some(filter) = shadow.get_drop_shadow_filter() else {
|
||||
@ -592,13 +773,13 @@ fn render_container_drop_shadows(
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
|
||||
if draw_fills && !element.fills.is_empty() {
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
}
|
||||
|
||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(shared, canvas, child_id, tree, scale, target)?;
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
@ -611,11 +792,10 @@ fn render_container_drop_shadows(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_leaf(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
element: &Shape,
|
||||
scale: f32,
|
||||
target: VectorTarget,
|
||||
) -> Result<()> {
|
||||
let needs_layer = element.needs_layer();
|
||||
|
||||
@ -633,7 +813,7 @@ fn render_leaf(
|
||||
canvas.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
|
||||
// Layer blur (non-text shapes)
|
||||
let blur_layer = if !matches!(element.shape_type, Type::Text(_)) {
|
||||
@ -695,7 +875,7 @@ fn render_leaf_content<R: ShapeRenderer + ?Sized>(renderer: &mut R, shape: &Shap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw_image_fill(
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
canvas: &Canvas,
|
||||
shape: &Shape,
|
||||
image_fill: &crate::shapes::ImageFill,
|
||||
@ -737,7 +917,7 @@ fn draw_image_fill(
|
||||
|
||||
fn draw_single_stroke(
|
||||
canvas: &Canvas,
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
scale: f32,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
@ -848,7 +1028,7 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint
|
||||
/// CPU image over it with `SrcIn` so only the stroke area shows the image.
|
||||
fn draw_image_stroke(
|
||||
canvas: &Canvas,
|
||||
shared: &mut RenderState,
|
||||
shared: &mut RenderResources,
|
||||
scale: f32,
|
||||
shape: &Shape,
|
||||
stroke: &Stroke,
|
||||
|
||||
@ -1285,6 +1285,15 @@ impl Shape {
|
||||
})
|
||||
}
|
||||
|
||||
/// Font families used by this shape (the text spans' families), or an
|
||||
/// empty vec for non-text shapes.
|
||||
pub fn font_families(&self) -> Vec<FontFamily> {
|
||||
match &self.shape_type {
|
||||
Type::Text(content) => content.font_families(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn mask_filter(&self, scale: f32) -> Option<skia::MaskFilter> {
|
||||
self.blur
|
||||
|
||||
@ -391,6 +391,20 @@ impl TextContent {
|
||||
self.bounds = Rect::from_xywh(x, y, w, h);
|
||||
}
|
||||
|
||||
/// Distinct font families referenced by this content's spans, in first-seen
|
||||
/// order.
|
||||
pub fn font_families(&self) -> Vec<FontFamily> {
|
||||
let mut seen: Vec<FontFamily> = Vec::new();
|
||||
for paragraph in &self.paragraphs {
|
||||
for span in paragraph.children() {
|
||||
if !seen.contains(&span.font_family) {
|
||||
seen.push(span.font_family);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
pub fn add_paragraph(&mut self, paragraph: Paragraph) {
|
||||
self.paragraphs.push(paragraph);
|
||||
self.content_version = self.content_version.wrapping_add(1);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::shapes::text::TextContent;
|
||||
use skia_safe::{
|
||||
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
|
||||
@ -174,7 +174,7 @@ impl TextPaths {
|
||||
) -> Option<(skia::Path, skia::Rect)> {
|
||||
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
|
||||
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
|
||||
let emoji_font = get_render_state().fonts().get_emoji_font(font.size());
|
||||
let emoji_font = get_resources().fonts.get_emoji_font(font.size());
|
||||
let use_font = emoji_font.as_ref().unwrap_or(font);
|
||||
|
||||
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
|
||||
|
||||
@ -11,9 +11,9 @@ pub use ui::UIState;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::render::FrameType;
|
||||
use crate::shapes::{grid_layout::grid_cell_data, Shape};
|
||||
use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape};
|
||||
use crate::uuid::Uuid;
|
||||
use crate::{get_render_state, tiles};
|
||||
use crate::{get_render_state, get_resources, has_render_state, tiles};
|
||||
|
||||
/// This struct holds the state of the Rust application between JS calls.
|
||||
///
|
||||
@ -96,7 +96,33 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
|
||||
crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale)
|
||||
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale)
|
||||
}
|
||||
|
||||
/// GPU-free counterpart of [`State::render_shape_pixels`]: PNG on a CPU
|
||||
/// raster surface, no GPU/WebGL.
|
||||
pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec<u8>, i32, i32)> {
|
||||
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale)
|
||||
}
|
||||
|
||||
/// Distinct font families used by the (visible) subtree rooted at `id`, in
|
||||
/// first-seen order — the on-demand set the headless exporter provisions.
|
||||
pub fn fonts_used_by_shape(&self, id: &Uuid) -> Vec<FontFamily> {
|
||||
let Some(root) = self.shapes.get(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut result: Vec<FontFamily> = Vec::new();
|
||||
for child_id in root.all_children_iter(&self.shapes, false, true) {
|
||||
if let Some(shape) = self.shapes.get(&child_id) {
|
||||
for family in shape.font_families() {
|
||||
if !result.contains(&family) {
|
||||
result.push(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<FrameType> {
|
||||
@ -150,8 +176,6 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) {
|
||||
let render_state = get_render_state();
|
||||
|
||||
// We don't really do a self.shapes.remove so that redo/undo keep working
|
||||
let Some(shape) = self.shapes.get(&id) else {
|
||||
return;
|
||||
@ -159,23 +183,28 @@ impl State {
|
||||
|
||||
// Only remove the children when is being deleted from the owner
|
||||
if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) {
|
||||
// IMPORTANT:
|
||||
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape
|
||||
// tiles with the current interest area, which means we'd only invalidate
|
||||
// the subset currently near the viewport. When the user later pans/zooms
|
||||
// to reveal previously cached tiles, stale pixels could reappear.
|
||||
//
|
||||
// Instead, remove the shape from *all* tiles where it was indexed, and
|
||||
// drop cached tiles for those entries.
|
||||
let indexed_tiles: Vec<tiles::Tile> = render_state
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map(|t| t.iter().copied().collect())
|
||||
.unwrap_or_default();
|
||||
// Tile invalidation only applies to the on-screen render state; the
|
||||
// headless export path has none, so skip it there.
|
||||
if has_render_state() {
|
||||
let render_state = get_render_state();
|
||||
// IMPORTANT:
|
||||
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape
|
||||
// tiles with the current interest area, which means we'd only invalidate
|
||||
// the subset currently near the viewport. When the user later pans/zooms
|
||||
// to reveal previously cached tiles, stale pixels could reappear.
|
||||
//
|
||||
// Instead, remove the shape from *all* tiles where it was indexed, and
|
||||
// drop cached tiles for those entries.
|
||||
let indexed_tiles: Vec<tiles::Tile> = render_state
|
||||
.tiles
|
||||
.get_tiles_of(shape.id)
|
||||
.map(|t| t.iter().copied().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for tile in indexed_tiles {
|
||||
render_state.remove_cached_tile(tile);
|
||||
render_state.tiles.remove_shape_at(tile, shape.id);
|
||||
for tile in indexed_tiles {
|
||||
render_state.remove_cached_tile(tile);
|
||||
render_state.tiles.remove_shape_at(tile, shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(shape_to_delete) = self.shapes.get(&id) {
|
||||
@ -184,8 +213,8 @@ impl State {
|
||||
if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) {
|
||||
shape_to_delete.set_deleted(true);
|
||||
}
|
||||
if render_state.show_grid == Some(shape_id) {
|
||||
render_state.show_grid = None;
|
||||
if has_render_state() && get_render_state().show_grid == Some(shape_id) {
|
||||
get_render_state().show_grid = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -215,7 +244,8 @@ impl State {
|
||||
/// and groups properly encompass their children.
|
||||
pub fn set_parent_for_current_shape(&mut self, id: Uuid) {
|
||||
// Reparent preview during drag is handled by structure modifiers only.
|
||||
if get_render_state().options.is_interactive_transform() {
|
||||
// Headless export has no render state and never runs interactive drags.
|
||||
if has_render_state() && get_render_state().options.is_interactive_transform() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -265,7 +295,7 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn font_collection(&self) -> &FontCollection {
|
||||
get_render_state().fonts().font_collection()
|
||||
get_resources().fonts.font_collection()
|
||||
}
|
||||
|
||||
pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> {
|
||||
@ -298,18 +328,20 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn touch_current(&mut self) {
|
||||
let render_state = get_render_state();
|
||||
if !self.loading {
|
||||
if let Some(current_id) = self.current_id {
|
||||
render_state.mark_touched(current_id);
|
||||
}
|
||||
// `mark_touched` only drives incremental on-screen tile invalidation;
|
||||
// the headless export path has no render state, so skip it there.
|
||||
if self.loading || !has_render_state() {
|
||||
return;
|
||||
}
|
||||
if let Some(current_id) = self.current_id {
|
||||
get_render_state().mark_touched(current_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch_shape(&mut self, id: Uuid) {
|
||||
let render_state = get_render_state();
|
||||
if !self.loading {
|
||||
render_state.mark_touched(id);
|
||||
if self.loading || !has_render_state() {
|
||||
return;
|
||||
}
|
||||
get_render_state().mark_touched(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::skia::textlayout::FontCollection;
|
||||
use crate::skia::Image;
|
||||
use crate::uuid::Uuid;
|
||||
@ -25,12 +25,12 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
|
||||
}
|
||||
|
||||
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
|
||||
get_render_state().images.get(image_id)
|
||||
get_resources().images.get(image_id)
|
||||
}
|
||||
|
||||
// FIXME: move to a different place ?
|
||||
pub fn get_fallback_fonts() -> &'static HashSet<String> {
|
||||
get_render_state().fonts().get_fallback()
|
||||
get_resources().fonts.get_fallback()
|
||||
}
|
||||
|
||||
pub fn get_font_collection() -> &'static FontCollection {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use crate::error::{Error, Result};
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::mem;
|
||||
use crate::shapes::Fill;
|
||||
use crate::state::State;
|
||||
@ -104,7 +104,10 @@ pub extern "C" fn store_image() -> Result<()> {
|
||||
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
|
||||
|
||||
with_state!(state, {
|
||||
if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) {
|
||||
if let Err(msg) = get_resources()
|
||||
.images
|
||||
.add(ids.image_id, is_thumbnail, image_bytes)
|
||||
{
|
||||
eprintln!("{}", msg);
|
||||
}
|
||||
touch_shapes_with_image(state, ids.image_id);
|
||||
@ -174,7 +177,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> {
|
||||
);
|
||||
|
||||
with_state!(state, {
|
||||
if let Err(msg) = get_render_state().add_image_from_gl_texture(
|
||||
if let Err(msg) = get_resources().images.add_image_from_gl_texture(
|
||||
ids.image_id,
|
||||
is_thumbnail,
|
||||
texture_id,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
use macros::{wasm_error, ToJs};
|
||||
|
||||
use crate::get_render_state;
|
||||
use crate::get_resources;
|
||||
use crate::mem;
|
||||
use crate::render::FontStore;
|
||||
use crate::shapes::{FontFamily, FontStyle};
|
||||
use crate::utils::uuid_from_u32_quartet;
|
||||
use crate::with_state;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
||||
#[repr(u8)]
|
||||
@ -45,14 +47,44 @@ pub extern "C" fn store_font(
|
||||
let font_style = RawFontStyle::from(style);
|
||||
|
||||
let family = FontFamily::new(id, weight, font_style.into());
|
||||
let _ = get_render_state()
|
||||
.fonts_mut()
|
||||
let _ = get_resources()
|
||||
.fonts
|
||||
.add(family, &font_bytes, is_emoji, is_fallback);
|
||||
|
||||
mem::free_bytes()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the font store to its default state, dropping every font uploaded via
|
||||
/// `store_font`. A headless host that reuses a single WASM instance across
|
||||
/// requests must call this per render so fonts don't accumulate unbounded.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn clear_fonts() -> Result<()> {
|
||||
get_resources().fonts = FontStore::try_new()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Distinct font families used by the subtree, for on-demand provisioning by a
|
||||
/// headless host. Buffer (LE): `[count u32]` then `count` ×
|
||||
/// `[uuid 16B][weight u32][style u32]` (0=normal, 1=italic); uuid bytes match
|
||||
/// the quartet `store_font` consumes.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_fonts_for_shape(a: u32, b: u32, c: u32, d: u32) -> *mut u8 {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let families = with_state!(state, { state.fonts_used_by_shape(&id) });
|
||||
|
||||
let mut buf = Vec::with_capacity(4 + families.len() * 24);
|
||||
buf.extend_from_slice(&(families.len() as u32).to_le_bytes());
|
||||
for family in families {
|
||||
let id_bytes: [u8; 16] = family.id().into();
|
||||
buf.extend_from_slice(&id_bytes);
|
||||
buf.extend_from_slice(&family.weight().to_le_bytes());
|
||||
buf.extend_from_slice(&(family.style() as u32).to_le_bytes());
|
||||
}
|
||||
mem::write_bytes(buf)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn is_font_uploaded(
|
||||
a: u32,
|
||||
@ -66,7 +98,5 @@ pub extern "C" fn is_font_uploaded(
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let font_style = RawFontStyle::from(style);
|
||||
let family = FontFamily::new(id, weight, font_style.into());
|
||||
let res = get_render_state().fonts().has_family(&family, is_emoji);
|
||||
|
||||
res
|
||||
get_resources().fonts.has_family(&family, is_emoji)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user