mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 05:18:36 +00:00
🔧 Add exporter headless backend (#10875)
* ✨ Add headless wasm render backend to the exporter * ♻️ Move render-wasm bridge to common and split wasm builds * 🔧 Upload builtin font variants in the wasm exporter * ♻️ Move shared font and resources utils out of render_wasm * ⚡ Fetch only the exported roots in the wasm exporter * ⚡ Bound save_layer rects in the vector export path
This commit is contained in:
parent
a76401596e
commit
38b990ef90
2
.gitignore
vendored
2
.gitignore
vendored
@ -58,6 +58,8 @@ opencode.json
|
||||
/docker/images/bundle*
|
||||
/exporter/target
|
||||
/exporter/.shadow-cljs
|
||||
/exporter/resources/wasm/
|
||||
/exporter/src/app/wasm/shared.js
|
||||
/frontend/.storybook/preview-body.html
|
||||
/frontend/.storybook/preview-head.html
|
||||
/frontend/playwright-report/
|
||||
|
||||
@ -13,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
|
||||
# docker/devenv/defaults.env and injected via the main service's env block.
|
||||
|
||||
if [ -f /home/selfsigned.crt ]; then
|
||||
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
|
||||
fi
|
||||
|
||||
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
|
||||
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
|
||||
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
|
||||
@ -101,5 +105,3 @@ function setup_minio() {
|
||||
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
||||
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
|
||||
io.aviso/pretty {:mvn/version "1.4.4"}
|
||||
environ/environ {:mvn/version "1.2.0"}}
|
||||
:paths ["src" "vendor" "target/classes"]
|
||||
:paths ["src" "vendor" "resources" "target/classes"]
|
||||
:aliases
|
||||
{:dev
|
||||
{:extra-deps
|
||||
|
||||
@ -178,6 +178,9 @@
|
||||
:stroke-path
|
||||
:stroke-per-side
|
||||
|
||||
;; Exporter only: uses render-wasm for export instead of browser
|
||||
;; renderer.
|
||||
:wasm-export
|
||||
:custom-shortcuts
|
||||
:remote-media-processing})
|
||||
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.fonts
|
||||
(ns app.common.fonts
|
||||
"A fonts loading macros."
|
||||
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.data.json :as json]
|
||||
@ -47,6 +48,3 @@
|
||||
(let [data (slurp (io/resource path))
|
||||
data (json/read-str data)]
|
||||
`~(mapv parse-gfont (get data "items"))))
|
||||
|
||||
|
||||
|
||||
@ -4,13 +4,159 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.fallback-fonts
|
||||
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
|
||||
which (google) fallback fonts cover them. Pure data + pure fns — no browser
|
||||
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
|
||||
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
|
||||
the same source. Anything a host must fetch/upload for text to render
|
||||
belongs here, not in host code.")
|
||||
(ns app.common.fonts
|
||||
"Host-agnostic font knowledge shared by every renderer: the google catalog
|
||||
baked at compile time from `common/resources/fonts/gfonts.*.json`, the
|
||||
font-id/uuid mapping, weight/style variant resolution, and the noto fallback
|
||||
fonts a text's scripts and emoji need. Also the one family bundled with the
|
||||
frontend, which is not a google font but resolves by the same rules.
|
||||
|
||||
Pure data + pure fns — no browser or Node dependencies — so the workspace and
|
||||
the headless exporter resolve the SAME fonts from the same source. Anything a
|
||||
host must fetch or upload for text to render belongs here, not in host code."
|
||||
(:require-macros [app.common.fonts :refer [preload-gfonts]])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.uuid :as uuid]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
;; --- GOOGLE FONTS CATALOG
|
||||
|
||||
(def catalog
|
||||
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
|
||||
|
||||
(def ^:private by-id
|
||||
(reduce (fn [m font] (assoc m (:id font) font)) {} catalog))
|
||||
|
||||
(def ^:private by-uuid
|
||||
(reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog))
|
||||
|
||||
(defn gfont-id->uuid
|
||||
"Maps a `gfont-<slug>` id to its (compilation-stable) catalog uuid, or nil."
|
||||
[gfont-id]
|
||||
(:uuid (get by-id gfont-id)))
|
||||
|
||||
;; --- font-id -> wasm uuid
|
||||
|
||||
(def ^:private custom-prefix "custom-")
|
||||
(def ^:private gfont-prefix "gfont-")
|
||||
|
||||
(defn font-id->backend
|
||||
"Which source a content font-id comes from: `:google` for `gfont-<slug>`,
|
||||
`:custom` for `custom-<uuid>`, `:builtin` for everything else (bundled
|
||||
families, but also unknown or malformed ids — the same bucket
|
||||
`font-id->uuid` maps to `uuid/zero`)."
|
||||
[font-id]
|
||||
(cond
|
||||
(not (string? font-id)) :builtin
|
||||
(str/starts-with? font-id gfont-prefix) :google
|
||||
(str/starts-with? font-id custom-prefix) :custom
|
||||
:else :builtin))
|
||||
|
||||
(defn font-id->uuid
|
||||
"Maps a content font-id to the uuid WASM keys fonts by:
|
||||
|
||||
- `gfont-<slug>` -> the catalog uuid,
|
||||
- `custom-<uuid>` -> that uuid,
|
||||
- anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM
|
||||
resolves to the default font."
|
||||
|
||||
[font-id]
|
||||
(case (font-id->backend font-id)
|
||||
:google (or (gfont-id->uuid font-id) uuid/zero)
|
||||
:custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero)
|
||||
uuid/zero))
|
||||
|
||||
;; --- proxy urls
|
||||
|
||||
(def ^:private gstatic-prefix
|
||||
"https://fonts.gstatic.com/s")
|
||||
|
||||
(defn gstatic->proxy-url
|
||||
[s base]
|
||||
(let [base (str/rtrim (str base) "/")]
|
||||
(str/replace (str s) gstatic-prefix base)))
|
||||
|
||||
;; --- variant resolution
|
||||
|
||||
(defn closest-variant
|
||||
[variants target-weight target-style]
|
||||
(when-let [target-weight (d/parse-integer target-weight)]
|
||||
(let [result
|
||||
(reduce
|
||||
(fn [closest-match variant]
|
||||
(let [weight (d/parse-integer (:weight variant))
|
||||
distance (abs (- target-weight weight))
|
||||
matches-style? (= target-style (:style variant))
|
||||
current {:variant variant
|
||||
:weight weight
|
||||
:distance distance}]
|
||||
(cond
|
||||
;; Exact match found
|
||||
(and (zero? distance)
|
||||
(if target-style matches-style? true))
|
||||
(reduced current)
|
||||
|
||||
(nil? closest-match) current
|
||||
|
||||
;; Update best match if this variant is closer or equal distance but higher weight
|
||||
(or (< distance (:distance closest-match))
|
||||
(and (= distance (:distance closest-match))
|
||||
(> weight (:weight closest-match))))
|
||||
current
|
||||
|
||||
;; Same weight as the `closest-match` but the style matches `target-style`
|
||||
(and (= weight (:weight closest-match)) matches-style?)
|
||||
current
|
||||
|
||||
:else
|
||||
closest-match)))
|
||||
nil
|
||||
variants)]
|
||||
(:variant result))))
|
||||
|
||||
(defn resolve-ttf-url
|
||||
[font-uuid weight style]
|
||||
(when-let [font (get by-uuid font-uuid)]
|
||||
(let [style (if (zero? style) "normal" "italic")
|
||||
variants (:variants font)]
|
||||
(:ttf-url (or (closest-variant variants weight style)
|
||||
(first variants))))))
|
||||
|
||||
;; --- BUILTIN FONTS
|
||||
;;
|
||||
;; Bundled with the frontend, served from `<public-uri>/fonts/`. Shared so the
|
||||
;; workspace and the exporter upload the same TTF for a given weight/style.
|
||||
|
||||
(def local-fonts
|
||||
[{:id "sourcesanspro"
|
||||
:name "Source Sans Pro"
|
||||
:family "sourcesanspro"
|
||||
:variants
|
||||
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
|
||||
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
|
||||
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
|
||||
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
|
||||
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
|
||||
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
|
||||
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
|
||||
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
|
||||
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
|
||||
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
|
||||
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
|
||||
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
|
||||
|
||||
(defn resolve-ttf-file
|
||||
"Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the
|
||||
same nearest-weight rule as the google catalog."
|
||||
[weight style]
|
||||
(let [variants (:variants (first local-fonts))]
|
||||
(:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic"))
|
||||
(first variants)))))
|
||||
|
||||
;; --- FALLBACK FONTS
|
||||
;;
|
||||
;; Which scripts/emoji a text uses and which (google) fallback fonts cover them.
|
||||
|
||||
(def ^:private emoji-pattern
|
||||
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
|
||||
24
common/src/app/common/render_wasm/README.md
Normal file
24
common/src/app/common/render_wasm/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
# `app.common.render-wasm.*`
|
||||
|
||||
The host-agnostic ClojureScript side of the render-wasm binary protocol: byte
|
||||
layouts, memory helpers and serializers that turn Penpot shapes into the buffers
|
||||
`render-wasm` consumes.
|
||||
|
||||
The workspace drives it from `app.render-wasm.*`, the headless exporter from
|
||||
`app.wasm.*` — same code underneath, so the two cannot drift.
|
||||
|
||||
Font knowledge is *not* here even though both hosts need it for rendering: it is
|
||||
not specific to the wasm backend, so the google fonts catalog (baked from
|
||||
`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the
|
||||
emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id
|
||||
enumeration lives in `app.common.types.shape.images`.
|
||||
|
||||
`shared.js` is not here: it is a per-build artifact, so each host compiles
|
||||
against the copy from its own render-wasm build and passes it to
|
||||
`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`).
|
||||
|
||||
## Rules for anything added here
|
||||
|
||||
**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on
|
||||
`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also
|
||||
has to run under plain Node — a `js/document` here breaks the exporter.
|
||||
@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.api.props
|
||||
(ns app.common.render-wasm.api.props
|
||||
"Browser-free WASM shape property setters, shared by the workspace render
|
||||
orchestrator (`app.render-wasm.api`) and the headless exporter
|
||||
(`app.wasm.serialize`).
|
||||
@ -15,15 +15,15 @@
|
||||
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
|
||||
(:require
|
||||
[app.common.math :as mth]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.path :as path]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
[app.common.types.path :as path]))
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.api.shapes
|
||||
(ns app.common.render-wasm.api.shapes
|
||||
"Batched shape property serialization for improved WASM performance.
|
||||
|
||||
This module provides a single WASM call to set all base shape properties,
|
||||
@ -13,11 +13,11 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
;; Binary layout constants matching Rust implementation:
|
||||
;;
|
||||
54
common/src/app/common/render_wasm/enums.clj
Normal file
54
common/src/app/common/render_wasm/enums.clj
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.common.render-wasm.enums
|
||||
"Serializer enum table from `shared.js`")
|
||||
|
||||
(def ^:private serializer-exports
|
||||
[["raster-format" "RasterFormat"]
|
||||
["blur-type" "RawBlurType"]
|
||||
["blend-mode" "RawBlendMode"]
|
||||
["bool-type" "RawBoolType"]
|
||||
["font-style" "RawFontStyle"]
|
||||
["flex-direction" "RawFlexDirection"]
|
||||
["grid-direction" "RawGridDirection"]
|
||||
["grow-type" "RawGrowType"]
|
||||
["align-items" "RawAlignItems"]
|
||||
["align-self" "RawAlignSelf"]
|
||||
["align-content" "RawAlignContent"]
|
||||
["justify-items" "RawJustifyItems"]
|
||||
["justify-content" "RawJustifyContent"]
|
||||
["justify-self" "RawJustifySelf"]
|
||||
["wrap-type" "RawWrapType"]
|
||||
["grid-track-type" "RawGridTrackType"]
|
||||
["shadow-style" "RawShadowStyle"]
|
||||
["guide-kind" "RawGuideKind"]
|
||||
["stroke-style" "RawStrokeStyle"]
|
||||
["stroke-cap" "RawStrokeCap"]
|
||||
["shape-type" "RawShapeType"]
|
||||
["constraint-h" "RawConstraintH"]
|
||||
["constraint-v" "RawConstraintV"]
|
||||
["sizing" "RawSizing"]
|
||||
["vertical-align" "RawVerticalAlign"]
|
||||
["fill-data" "RawFillData"]
|
||||
["text-align" "RawTextAlign"]
|
||||
["text-direction" "RawTextDirection"]
|
||||
["text-decoration" "RawTextDecoration"]
|
||||
["text-transform" "RawTextTransform"]
|
||||
["multiple-state" "MultipleState"]
|
||||
["transform-entry-kind" "RawTransformEntryKind"]
|
||||
["segment-data" "RawSegmentData"]
|
||||
["stroke-linecap" "RawStrokeLineCap"]
|
||||
["stroke-linejoin" "RawStrokeLineJoin"]
|
||||
["fill-rule" "RawFillRule"]])
|
||||
|
||||
(defmacro serializers
|
||||
[alias]
|
||||
(let [alias (name alias)]
|
||||
`(cljs.core/js-obj
|
||||
~@(mapcat (fn [[key export]]
|
||||
[key (symbol alias export)])
|
||||
serializer-exports))))
|
||||
@ -4,8 +4,8 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.helpers
|
||||
#?(:cljs (:require-macros [app.render-wasm.helpers]))
|
||||
(ns app.common.render-wasm.helpers
|
||||
#?(:cljs (:require-macros [app.common.render-wasm.helpers]))
|
||||
(:require [app.common.data :as d]))
|
||||
|
||||
(def error-code
|
||||
@ -4,11 +4,11 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.mem
|
||||
(ns app.common.render-wasm.mem
|
||||
(:require
|
||||
[app.common.buffer :as buf]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.wasm :as wasm]))
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.wasm :as wasm]))
|
||||
|
||||
(defn ->offset-32
|
||||
"Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset"
|
||||
@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.mem.heap32
|
||||
(ns app.common.render-wasm.mem.heap32
|
||||
"A memory write helpers that uses 32 bits addressed offsets."
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.serialize-shape
|
||||
(ns app.common.render-wasm.serialize-shape
|
||||
"Single source of truth for the host-independent part of serializing a whole
|
||||
shape into the WASM design state.
|
||||
|
||||
@ -24,8 +24,8 @@
|
||||
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
|
||||
dispatching per changed key through the same underlying `props` setters."
|
||||
(:require
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.api.shapes :as shapes]))
|
||||
[app.common.render-wasm.api.props :as props]
|
||||
[app.common.render-wasm.api.shapes :as shapes]))
|
||||
|
||||
(defn serialize-shape!
|
||||
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
|
||||
@ -4,16 +4,16 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.serializers
|
||||
(ns app.common.render-wasm.serializers
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.shape-tree :as ctst]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn u8
|
||||
@ -1,4 +1,4 @@
|
||||
(ns app.render-wasm.serializers.color
|
||||
(ns app.common.render-wasm.serializers.color
|
||||
(:require
|
||||
[app.common.math :as mth]))
|
||||
|
||||
@ -4,23 +4,24 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.text-content
|
||||
(ns app.common.render-wasm.text-content
|
||||
"Single source of truth for writing a text shape's content into the WASM design
|
||||
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
|
||||
identical for the workspace and the headless exporter — only *font resolution*
|
||||
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
|
||||
catalog + custom variants). So the byte-writing lives here and font resolution
|
||||
is injected via the `opts` map passed to `write-shape-text!`.
|
||||
identical for the workspace and the headless exporter, and so is the font-id
|
||||
-> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs —
|
||||
the workspace has a loaded fonts DB, the exporter does not — so that part is
|
||||
injected via the `opts` map passed to `write-shape-text!`.
|
||||
|
||||
Fully portable (no store/DOM/React), so it runs under Node too."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.fonts :as cfnt]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
|
||||
@ -169,13 +170,15 @@
|
||||
"Writes one paragraph's spans + text into WASM and appends it to the current
|
||||
shape via `_set_shape_text_content`.
|
||||
|
||||
`opts` injects host-specific font resolution:
|
||||
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
|
||||
`opts` injects host-specific font handling:
|
||||
- `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
|
||||
`cfnt/font-id->uuid`, which is what both hosts want — a host only
|
||||
overrides it if it keys its font store some other way,
|
||||
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
|
||||
fonts DB (workspace); default to identity (the exporter resolves variants
|
||||
differently / not at all)."
|
||||
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
|
||||
:or {normalize-font-id identity
|
||||
:or {normalize-font-id cfnt/font-id->uuid
|
||||
normalize-paragraph identity
|
||||
normalize-span (fn [span _paragraph] span)}}]
|
||||
(let [paragraph (normalize-paragraph paragraph)
|
||||
@ -4,8 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.wasm
|
||||
(:require ["./api/shared.js" :as shared]))
|
||||
(ns app.common.render-wasm.wasm)
|
||||
|
||||
(defonce internal-frame-id nil)
|
||||
(defonce internal-frame-type 0)
|
||||
@ -65,41 +64,19 @@
|
||||
(set! gl-context nil)
|
||||
(set! context-initialized? false))
|
||||
|
||||
(defonce serializers
|
||||
#js {:raster-format shared/RasterFormat
|
||||
:blur-type shared/RawBlurType
|
||||
:blend-mode shared/RawBlendMode
|
||||
:bool-type shared/RawBoolType
|
||||
:font-style shared/RawFontStyle
|
||||
:flex-direction shared/RawFlexDirection
|
||||
:grid-direction shared/RawGridDirection
|
||||
:grow-type shared/RawGrowType
|
||||
:align-items shared/RawAlignItems
|
||||
:align-self shared/RawAlignSelf
|
||||
:align-content shared/RawAlignContent
|
||||
:justify-items shared/RawJustifyItems
|
||||
:justify-content shared/RawJustifyContent
|
||||
:justify-self shared/RawJustifySelf
|
||||
:wrap-type shared/RawWrapType
|
||||
:grid-track-type shared/RawGridTrackType
|
||||
:shadow-style shared/RawShadowStyle
|
||||
:guide-kind shared/RawGuideKind
|
||||
:stroke-style shared/RawStrokeStyle
|
||||
:stroke-cap shared/RawStrokeCap
|
||||
:shape-type shared/RawShapeType
|
||||
:constraint-h shared/RawConstraintH
|
||||
:constraint-v shared/RawConstraintV
|
||||
:sizing shared/RawSizing
|
||||
:vertical-align shared/RawVerticalAlign
|
||||
:fill-data shared/RawFillData
|
||||
:text-align shared/RawTextAlign
|
||||
:text-direction shared/RawTextDirection
|
||||
:text-decoration shared/RawTextDecoration
|
||||
:text-transform shared/RawTextTransform
|
||||
:multiple-state shared/MultipleState
|
||||
:transform-entry-kind shared/RawTransformEntryKind
|
||||
:segment-data shared/RawSegmentData
|
||||
:stroke-linecap shared/RawStrokeLineCap
|
||||
:stroke-linejoin shared/RawStrokeLineJoin
|
||||
:fill-rule shared/RawFillRule})
|
||||
(defonce serializers nil)
|
||||
|
||||
(defn init-serializers!
|
||||
"Binds the enum table produced by the `enums/serializers` macro."
|
||||
[table]
|
||||
(let [missing (array)]
|
||||
(doseq [key (js/Object.keys table)]
|
||||
(when (undefined? (unchecked-get table key))
|
||||
(.push missing key)))
|
||||
|
||||
(when (pos? (alength missing))
|
||||
(throw (ex-info "stale or incomplete render-wasm shared.js"
|
||||
{:missing (vec missing)})))
|
||||
|
||||
(set! serializers table)))
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.resources
|
||||
(ns app.common.types.shape.images
|
||||
"Host-agnostic enumeration of the external resources a scene needs to
|
||||
render: which image bytes its shapes reference. Pure data walking — no
|
||||
browser or Node dependencies — so the workspace and the headless exporter
|
||||
derive the same set from the same source (sibling of
|
||||
`app.render-wasm.fallback-fonts`, which does the same for fonts)."
|
||||
derive the same set from the same source (counterpart of
|
||||
`app.common.fonts`, which does the same for fonts)."
|
||||
(:require
|
||||
[app.common.types.fills :as types.fills]))
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main",
|
||||
"watch": "pnpm run watch:app",
|
||||
"build:app": "clojure -M:dev:shadow-cljs release main",
|
||||
"build:wasm": "../render-wasm/build export",
|
||||
"build": "pnpm run clear:shadow-cache && pnpm run build:app",
|
||||
"fmt": "cljfmt fix --parallel=true src/",
|
||||
"check-fmt": "cljfmt check --parallel=true src/",
|
||||
|
||||
@ -8,6 +8,17 @@ export NODE_ENV=production;
|
||||
corepack enable;
|
||||
corepack install || exit 1;
|
||||
pnpm install || exit 1;
|
||||
pnpm run build:wasm;
|
||||
|
||||
WASM_SRC="resources/wasm";
|
||||
WASM_SHARED="src/app/wasm/shared.js";
|
||||
if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then
|
||||
echo "ERROR: the render-wasm build did not produce:" >&2;
|
||||
echo " $WASM_SRC/render-wasm.wasm" >&2;
|
||||
echo " $WASM_SHARED" >&2;
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
rm -rf target
|
||||
|
||||
# Build the application
|
||||
@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/;
|
||||
cp package.json target/;
|
||||
touch target/pnpm-workspace.yaml;
|
||||
|
||||
mkdir -p target/$WASM_SRC;
|
||||
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/;
|
||||
|
||||
cat <<EOF | tee target/setup
|
||||
#/usr/bin/env bash
|
||||
set -e;
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.config :as cf]
|
||||
[app.http :as http]
|
||||
[app.redis :as redis]
|
||||
[app.wasm :as wasm]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(enable-console-print!)
|
||||
@ -23,6 +24,12 @@
|
||||
:public-uri (str (cf/get :public-uri))
|
||||
:internal-uri (str (cf/get-internal-uri))
|
||||
:version (:full cf/version))
|
||||
(when (contains? cf/flags :wasm-export)
|
||||
(l/warn :msg "headless wasm export enabled (experimental)"
|
||||
:hint (str "renders run in-process on a single shared wasm module, "
|
||||
"one at a time; not recommended for busy instances")
|
||||
:wasm-dir wasm/artifact-dir
|
||||
:image-cache-mb wasm/image-cache-mb))
|
||||
(p/do!
|
||||
(bwr/init)
|
||||
(redis/init)
|
||||
|
||||
@ -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,22 @@
|
||||
: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)))
|
||||
(let [wasm-export? (contains? cf/flags :wasm-export)
|
||||
headless? (and is-wasm wasm-export? (not= :svg type))]
|
||||
(when is-wasm
|
||||
(l/info :hint "render"
|
||||
:type type
|
||||
:wasm-export wasm-export?
|
||||
: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)))))
|
||||
|
||||
|
||||
451
exporter/src/app/renderer/wasm.cljs
Normal file
451
exporter/src/app/renderer/wasm.cljs
Normal file
@ -0,0 +1,451 @@
|
||||
;; 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 in this Node process, with no browser and no WebGL.
|
||||
|
||||
Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and
|
||||
images -> relayout text with the real fonts -> render each object.
|
||||
|
||||
One shared WASM design state, so requests are serialized one at a time.
|
||||
|
||||
Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the
|
||||
browser path."
|
||||
(:require
|
||||
["node:fs" :as fs]
|
||||
["undici" :as http]
|
||||
[app.common.data :as d]
|
||||
[app.common.fonts :as cfnt]
|
||||
;; Required for side effects: these register the transit read handlers and
|
||||
;; deftype impls the `get-page` response is decoded into.
|
||||
[app.common.geom.matrix]
|
||||
[app.common.geom.point]
|
||||
[app.common.geom.rect]
|
||||
[app.common.logging :as l]
|
||||
[app.common.transit :as t]
|
||||
[app.common.types.fills.impl]
|
||||
[app.common.types.objects-map]
|
||||
[app.common.types.path.impl]
|
||||
[app.common.types.shape]
|
||||
[app.common.types.shape.images :as images]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.util.mime :as mime]
|
||||
[app.util.shell :as sh]
|
||||
[app.wasm :as wasm]
|
||||
[app.wasm.serialize :as serialize]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; --- module lifecycle (one shared, lazily-initialized instance)
|
||||
|
||||
(defonce ^:private module* (atom nil))
|
||||
|
||||
(defn- ensure-module!
|
||||
[]
|
||||
(or @module*
|
||||
(reset! module* (wasm/init!))))
|
||||
|
||||
;; --- serialized access to the shared module
|
||||
;;
|
||||
;; `handle-multiple-export` fans out partitions concurrently, but there is one
|
||||
;; design state and one global mem buffer, so their serialize/render/alloc must
|
||||
;; not interleave.
|
||||
|
||||
(defonce ^:private queue (atom (p/resolved nil)))
|
||||
|
||||
(defn- enqueue!
|
||||
"Runs `thunk` (0-arg, returns a promise) only after all previously enqueued
|
||||
work has settled. Returns `thunk`'s promise. A task's failure is isolated:
|
||||
it doesn't break the chain for the next task."
|
||||
[thunk]
|
||||
(let [result (p/handle @queue (fn [_ _] (thunk)))]
|
||||
(reset! queue (p/handle result (fn [_ _] nil)))
|
||||
result))
|
||||
|
||||
;; --- backend endpoints
|
||||
;;
|
||||
;; Every fetch targets the internal endpoint (falling back to public-uri),
|
||||
;; in a deployment the exporter reaches the backend over the container network
|
||||
|
||||
(defn- internal-uri
|
||||
"Absolute URI for `path` on the internal (backend) endpoint."
|
||||
[path]
|
||||
(-> (cf/get-internal-uri)
|
||||
(u/ensure-path-slash)
|
||||
(u/join path)
|
||||
(str)))
|
||||
|
||||
(defn- error-detail
|
||||
"Node's fetch reports every transport failure as a bare `TypeError: fetch
|
||||
failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a
|
||||
nested `cause` chain that the logger does not print. Flattens the chain into
|
||||
one readable string."
|
||||
[cause]
|
||||
(->> (iterate (fn [^js e] (unchecked-get e "cause")) cause)
|
||||
(take-while some?)
|
||||
(take 5)
|
||||
(map (fn [^js e]
|
||||
(let [code (unchecked-get e "code")
|
||||
msg (or (unchecked-get e "message") (str e))]
|
||||
(if code (str code ": " msg) msg))))
|
||||
(str/join " <- ")))
|
||||
|
||||
(defn- fetch!
|
||||
"`undici/fetch` that fails with an ex-info carrying the target uri and the
|
||||
unwrapped cause chain, so a failed request says what actually went wrong and
|
||||
against which endpoint."
|
||||
[uri opts]
|
||||
(->> (p/do (http/fetch uri opts))
|
||||
(p/merr (fn [cause]
|
||||
(p/rejected (ex-info "http fetch failed"
|
||||
{:uri uri :detail (error-detail cause)}
|
||||
cause))))))
|
||||
|
||||
(defn- explain
|
||||
"Log-friendly reason for `cause`: the detail `fetch!` already attached, or a
|
||||
freshly unwrapped chain for anything else (WASM aborts, decode errors)."
|
||||
[cause]
|
||||
(or (:detail (ex-data cause))
|
||||
(error-detail cause)))
|
||||
|
||||
(defn- rpc-headers
|
||||
"Auth headers for backend RPC calls (management key + bearer)."
|
||||
[token]
|
||||
#js {"Content-Type" "application/transit+json"
|
||||
"X-Shared-Key" (str "exporter " cf/management-key)
|
||||
"Authorization" (str "Bearer " token)})
|
||||
|
||||
(defn- asset-headers
|
||||
"Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to
|
||||
a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple
|
||||
authentication types\")."
|
||||
[token]
|
||||
#js {"X-Shared-Key" (str "exporter " cf/management-key)
|
||||
"Cookie" (str "auth-token=" token)})
|
||||
|
||||
;; --- shape bundle fetch (backend RPC)
|
||||
|
||||
(defn- fetch-objects
|
||||
"Fetches the exported roots and their children from the backend via the
|
||||
`get-page` RPC (`:object-id`, as the browser render path does), using the
|
||||
same auth the exporter uses elsewhere (management key + bearer)."
|
||||
[{:keys [file-id page-id share-id token objects]}]
|
||||
(let [headers (rpc-headers token)
|
||||
root-ids (into #{} (map :id) objects)
|
||||
body (t/encode-str (cond-> {:file-id file-id
|
||||
:page-id page-id}
|
||||
(seq root-ids) (assoc :object-id root-ids)
|
||||
share-id (assoc :share-id share-id)))
|
||||
uri (internal-uri "api/rpc/command/get-page")]
|
||||
(l/dbg :hint "wasm render: get-page"
|
||||
:uri uri
|
||||
:file-id (str file-id)
|
||||
:page-id (str page-id)
|
||||
:roots (count root-ids))
|
||||
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.text resp)
|
||||
(->> (.text resp)
|
||||
(p/mcat (fn [resp-body]
|
||||
(l/error :hint "wasm render: get-page failed"
|
||||
:uri uri
|
||||
:status (.-status resp)
|
||||
:body resp-body)
|
||||
(p/rejected (ex-info "get-page failed"
|
||||
{:status (.-status resp)
|
||||
:body resp-body}))))))))
|
||||
(p/fmap t/decode-str)
|
||||
(p/fmap :objects))))
|
||||
|
||||
;; --- font resolution
|
||||
;;
|
||||
;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape`
|
||||
;; reports it. Custom (team) fonts resolve through the file's font variants,
|
||||
;; google fonts through the shared `app.common.fonts` catalog; builtin
|
||||
;; fonts through its bundled family + the frontend's static `/fonts/`.
|
||||
|
||||
(defn- fetch-font-variants
|
||||
"Team (custom) font variants for the file, or nil — a failure here degrades
|
||||
to fallback fonts, it does not fail the export."
|
||||
[{:keys [file-id share-id token]}]
|
||||
(let [headers (rpc-headers token)
|
||||
body (t/encode-str (cond-> {:file-id file-id}
|
||||
share-id (assoc :share-id share-id)))
|
||||
uri (internal-uri "api/rpc/command/get-font-variants")]
|
||||
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.text resp)
|
||||
(p/resolved nil))))
|
||||
(p/fmap (fn [s] (when s (t/decode-str s))))
|
||||
(p/merr (fn [cause]
|
||||
(l/warn :hint "wasm render: get-font-variants failed"
|
||||
:uri uri :detail (explain cause) :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
(defn- fetch-ttf-bytes
|
||||
"Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure
|
||||
here degrades to fallback fonts, it does not fail the export."
|
||||
([uri] (fetch-ttf-bytes uri #js {:method "GET"}))
|
||||
([uri opts]
|
||||
(->> (fetch! uri opts)
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.arrayBuffer resp)
|
||||
(p/resolved nil))))
|
||||
(p/merr (fn [cause]
|
||||
(l/warn :hint "wasm render: font fetch failed"
|
||||
:uri uri :detail (explain cause) :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
;; TTF bytes cached for the process lifetime, keyed by whatever identifies the
|
||||
;; variant (a gfont id+weight+style, a builtin file name).
|
||||
(defonce ^:private font-bytes* (atom {}))
|
||||
|
||||
(defn- cached-ttf-bytes
|
||||
[cache-key fetch-fn]
|
||||
(if-let [bytes (get @font-bytes* cache-key)]
|
||||
(p/resolved bytes)
|
||||
(->> (fetch-fn)
|
||||
(p/fmap (fn [buf]
|
||||
(when buf (swap! font-bytes* assoc cache-key buf))
|
||||
buf)))))
|
||||
|
||||
(defn- fetch-asset-bytes
|
||||
[asset-id {:keys [token]}]
|
||||
(fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id))
|
||||
#js {:method "GET" :headers (asset-headers token)}))
|
||||
|
||||
(defn- fetch-gfont-bytes
|
||||
[ttf-url]
|
||||
(fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font"))))
|
||||
|
||||
(defn- fetch-builtin-font-bytes
|
||||
[ttf-file]
|
||||
(cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file)))))
|
||||
|
||||
(defn- make-resolve-font
|
||||
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom
|
||||
variants first, matching uuid+weight+style then degrading to uuid+weight then
|
||||
uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps
|
||||
every builtin family to; google catalog otherwise."
|
||||
[variants params]
|
||||
(fn [{:keys [id weight style]}]
|
||||
(let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))
|
||||
style-str (if (zero? style) "normal" "italic")
|
||||
variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid)
|
||||
(= (:font-weight v) weight)
|
||||
(= (name (:font-style v)) style-str)))
|
||||
variants)
|
||||
(d/seek (fn [v] (and (= (:font-id v) font-uuid)
|
||||
(= (:font-weight v) weight)))
|
||||
variants)
|
||||
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
|
||||
(cond
|
||||
(:ttf-file-id variant)
|
||||
(fetch-asset-bytes (:ttf-file-id variant) params)
|
||||
|
||||
(= uuid/zero font-uuid)
|
||||
(fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style))
|
||||
|
||||
:else
|
||||
(if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)]
|
||||
(fetch-gfont-bytes gurl)
|
||||
(p/resolved nil))))))
|
||||
|
||||
;; --- fallback fonts (emoji + per-script noto fonts)
|
||||
;;
|
||||
;; Emoji and non-latin scripts render through fallback families, not through
|
||||
;; any span's font family, so `wasm/fonts-for-shape` never reports them and the
|
||||
;; provisioning above never uploads them. Must run per request, since
|
||||
;; `clear-fonts!` empties the store; the TTF bytes stay cached per process.
|
||||
|
||||
(defn- scene-fallback-fonts
|
||||
"Fallback font descriptors needed by the scene's text. Deduped because
|
||||
several languages map to one noto family and provisioning is concurrent —
|
||||
otherwise they all miss the byte cache at once and refetch the same TTF."
|
||||
[scene]
|
||||
(let [texts (for [shape (vals scene)
|
||||
:when (= :text (:type shape))
|
||||
node (or (some->> (:content shape) (tree-seq :children :children)) [])
|
||||
:let [text (:text node)]
|
||||
:when (string? text)]
|
||||
text)
|
||||
emoji? (boolean (some cfnt/contains-emoji? texts))
|
||||
langs (reduce cfnt/collect-used-languages #{} texts)]
|
||||
(distinct
|
||||
(cond-> (cfnt/add-noto-fonts [] langs)
|
||||
emoji? (cfnt/add-emoji-font)))))
|
||||
|
||||
(defn- fetch-fallback-font-bytes
|
||||
"Downloads one fallback font's TTF. Cached by the whole variant, not just
|
||||
`font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a
|
||||
font-id-only key would serve the first downloaded variant for every other one."
|
||||
[{:keys [font-id weight style]}]
|
||||
(if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))]
|
||||
(cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url))
|
||||
(p/resolved nil)))
|
||||
|
||||
(defn- provision-fallback-fonts!
|
||||
[scene]
|
||||
(->> (scene-fallback-fonts scene)
|
||||
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
|
||||
(if-let [font-uuid (cfnt/gfont-id->uuid font-id)]
|
||||
(->> (fetch-fallback-font-bytes font)
|
||||
(p/fmap (fn [buf]
|
||||
(if buf
|
||||
(wasm/store-font! {:id (uuid/get-u32 font-uuid)
|
||||
:weight weight
|
||||
:style style
|
||||
:emoji? (boolean is-emoji)
|
||||
:fallback? (boolean is-fallback)}
|
||||
buf)
|
||||
(l/warn :hint "wasm render: fallback font unavailable"
|
||||
:font-id font-id)))))
|
||||
(p/resolved nil))))
|
||||
(p/all)))
|
||||
|
||||
;; --- image resolution
|
||||
;;
|
||||
;; Image fills reference file-media ids; the encoded bytes go straight to
|
||||
;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens
|
||||
;; once per request rather than per rendered object.
|
||||
|
||||
(defn- fetch-file-media-bytes
|
||||
"Downloads an image fill's encoded bytes by file-media id."
|
||||
[media-id {:keys [token]}]
|
||||
(let [headers (asset-headers token)
|
||||
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
|
||||
(->> (fetch! uri #js {:method "GET" :headers headers})
|
||||
(p/mcat (fn [^js resp]
|
||||
(if (= 200 (.-status resp))
|
||||
(.arrayBuffer resp)
|
||||
(do
|
||||
(l/warn :hint "wasm render: image fetch non-200"
|
||||
:media-id (str media-id)
|
||||
:uri uri
|
||||
:status (.-status resp))
|
||||
(p/resolved nil)))))
|
||||
(p/merr (fn [cause]
|
||||
(l/warn :hint "wasm render: image fetch failed"
|
||||
:media-id (str media-id) :uri uri
|
||||
:detail (explain cause) :cause cause)
|
||||
(p/resolved nil))))))
|
||||
|
||||
(defn- provision-images!
|
||||
"Fetches and stores every image the scene references (shape, stroke and
|
||||
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
|
||||
the image store is not reset per request, so already-held images are skipped
|
||||
and repeated exports of a file reuse them."
|
||||
[scene params]
|
||||
(let [all-ids (images/scene-image-ids scene)
|
||||
new-ids (remove wasm/image-cached? all-ids)]
|
||||
(l/dbg :hint "wasm render: provisioning images"
|
||||
:total (count all-ids)
|
||||
:cached (- (count all-ids) (count new-ids)))
|
||||
(->> new-ids
|
||||
(map (fn [image-id]
|
||||
(->> (fetch-file-media-bytes image-id params)
|
||||
(p/fmap (fn [buf]
|
||||
(if buf
|
||||
(do
|
||||
(l/dbg :hint "wasm render: image stored"
|
||||
:media-id (str image-id)
|
||||
:bytes (.-byteLength ^js buf))
|
||||
(wasm/store-image! image-id buf))
|
||||
(l/warn :hint "wasm render: image unavailable"
|
||||
:media-id (str image-id))))))))
|
||||
(p/all))))
|
||||
|
||||
(defn- relayout-text!
|
||||
"Recomputes layout for every text shape, once the real fonts are provisioned
|
||||
(serialize-time layout used the fallback)."
|
||||
[scene]
|
||||
(doseq [shape (vals scene)
|
||||
:when (= :text (:type shape))]
|
||||
(wasm/update-text-layout! (:id shape))))
|
||||
|
||||
;; --- render
|
||||
|
||||
(defn- render-object-bytes
|
||||
[type id scale]
|
||||
(if (= :pdf type)
|
||||
(let [bytes (wasm/render-shape-pdf id scale)]
|
||||
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
|
||||
:object-id (str id)
|
||||
:backend "skia-wasm"
|
||||
:bytes (.-length bytes))
|
||||
bytes)
|
||||
(wasm/render-shape-raster id scale type)))
|
||||
|
||||
(defn- render*
|
||||
[{:keys [scale type objects] :as params} on-object]
|
||||
(l/dbg :hint "wasm render: start"
|
||||
:type type
|
||||
:scale scale
|
||||
:objects (count objects)
|
||||
:file-id (str (:file-id params))
|
||||
:page-id (str (:page-id params)))
|
||||
(->> (ensure-module!)
|
||||
(p/mcat (fn [_] (fetch-objects params)))
|
||||
(p/mcat (fn [scene]
|
||||
(l/dbg :hint "wasm render: scene fetched" :shapes (count scene))
|
||||
(serialize/serialize-scene! scene)
|
||||
(l/dbg :hint "wasm render: scene serialized")
|
||||
;; So fonts from a previous request don't leak into this one.
|
||||
(wasm/clear-fonts!)
|
||||
(->> (p/all [(fetch-font-variants params)
|
||||
(provision-images! scene params)
|
||||
(provision-fallback-fonts! scene)])
|
||||
(p/mcat
|
||||
(fn [[variants _]]
|
||||
(let [resolve-font (make-resolve-font (or variants []) params)]
|
||||
;; Before rendering, so the relayout below sees real
|
||||
;; font metrics. Deduped across objects: a partition
|
||||
;; sharing one family downloads its TTF once.
|
||||
(wasm/provision-fonts! (map :id objects) resolve-font))))
|
||||
(p/mcat
|
||||
(fn [_]
|
||||
(relayout-text! scene)
|
||||
(p/run
|
||||
(fn [{:keys [id] :as object}]
|
||||
(let [bytes (render-object-bytes type id scale)
|
||||
path (sh/tempfile :prefix "penpot.tmp.wasm."
|
||||
:suffix (mime/get-extension type))]
|
||||
(l/dbg :hint "wasm render: object rendered"
|
||||
:object-id (str id) :bytes (.-length bytes))
|
||||
(fs/writeFileSync path bytes)
|
||||
;; `on-object` returns a plain value (zip append) or
|
||||
;; a promise (single export's file move); `p/do`
|
||||
;; normalizes both to a thenable.
|
||||
(p/do (on-object (assoc object :path path)))))
|
||||
objects))))))
|
||||
(p/fmap (fn [result]
|
||||
;; After the request, never mid-render, so an image can't
|
||||
;; disappear under a running export.
|
||||
(let [evicted (wasm/evict-images! wasm/image-cache-mb)]
|
||||
(when (pos? evicted)
|
||||
(l/info :hint "wasm render: evicted cached images" :count evicted)))
|
||||
result))
|
||||
(p/merr (fn [cause]
|
||||
(l/error :hint "wasm render: failed"
|
||||
:detail (explain cause)
|
||||
:internal-uri (str (cf/get-internal-uri))
|
||||
:cause cause)
|
||||
;; A panic can leave the mem buffer allocated or the instance
|
||||
;; aborted; drop it so the next request rebuilds a fresh one.
|
||||
(reset! module* nil)
|
||||
(p/rejected cause)))))
|
||||
|
||||
(defn render
|
||||
"Public entry. `enqueue!` keeps concurrent exports off each other's toes on
|
||||
the shared WASM instance."
|
||||
[params on-object]
|
||||
(enqueue! (fn [] (render* params on-object))))
|
||||
255
exporter/src/app/wasm.cljs
Normal file
255
exporter/src/app/wasm.cljs
Normal file
@ -0,0 +1,255 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.wasm
|
||||
"Headless driver for the render-wasm module under Node: the GPU-free
|
||||
counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it
|
||||
via `init_headless`, and exposes font provisioning + shape rendering.
|
||||
|
||||
Serialization is reused from the portable render-wasm leaves, so this
|
||||
namespace owns only the Node runtime and the headless render calls.
|
||||
|
||||
Requires render-wasm built with `-sENVIRONMENT=web,node`."
|
||||
(:require
|
||||
["node:fs" :as fs]
|
||||
["node:path" :as path]
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.uuid :as uuid]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.wasm.enums]
|
||||
[promesa.core :as p]
|
||||
[shadow.esm :refer [dynamic-import]]))
|
||||
|
||||
(def ^:private default-viewport-width 1920)
|
||||
(def ^:private default-viewport-height 1080)
|
||||
|
||||
;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32].
|
||||
(def ^:private RASTER-HEADER-BYTES 12)
|
||||
;; render_shape_pdf result header: [len u32] only.
|
||||
(def ^:private PDF-HEADER-BYTES 4)
|
||||
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
|
||||
(def ^:private FONT-ENTRY-BYTES 24)
|
||||
|
||||
(def artifact-dir
|
||||
"Built render-wasm artifact, relative to the process working directory. Same
|
||||
path in devenv and inside the bundle, so it is a constant."
|
||||
"resources/wasm")
|
||||
|
||||
(def image-cache-mb
|
||||
"Byte budget (MB) the image store is trimmed to between requests."
|
||||
256)
|
||||
|
||||
(defn- read-result-bytes
|
||||
"Reads `len` bytes from the WASM heap starting at `offset`, copying them out
|
||||
(via `.slice`) before the buffer is freed."
|
||||
[offset len]
|
||||
(.slice (mem/get-heap-u8) offset (+ offset len)))
|
||||
|
||||
;; --- MODULE LIFECYCLE
|
||||
|
||||
(defn init!
|
||||
"Loads the render-wasm artifact under Node and boots it headless. Sets the
|
||||
shared `wasm/internal-module` so the portable serialization leaves work.
|
||||
Idempotent-ish: callers should hold the returned module."
|
||||
([] (init! default-viewport-width default-viewport-height))
|
||||
([width height]
|
||||
(let [dir artifact-dir
|
||||
js-path (path/resolve dir "render-wasm.js")
|
||||
wasm-path (path/resolve dir "render-wasm.wasm")
|
||||
wasm-bytes (fs/readFileSync wasm-path)]
|
||||
(l/info :hint "loading render-wasm (headless)" :js js-path)
|
||||
;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import`
|
||||
;; compiles to an undefined `import$`).
|
||||
(->> (dynamic-import (str "file://" js-path))
|
||||
(p/mcat
|
||||
(fn [mod]
|
||||
(let [factory (unchecked-get mod "default")]
|
||||
(factory
|
||||
#js {;; Bypass the web fetch loader: instantiate from local bytes.
|
||||
:instantiateWasm
|
||||
(fn [imports success]
|
||||
(-> (js/WebAssembly.instantiate wasm-bytes imports)
|
||||
(.then (fn [result] (success (.-instance result)))))
|
||||
#js {})
|
||||
:locateFile (fn [p] (path/resolve dir p))
|
||||
:printErr (fn [s] (l/warn :wasm s))}))))
|
||||
(p/fmap
|
||||
(fn [module]
|
||||
(set! wasm/internal-module module)
|
||||
(h/call module "_init_headless" width height)
|
||||
(set! wasm/context-initialized? true)
|
||||
(l/info :hint "render-wasm headless module ready" :width width :height height)
|
||||
module))))))
|
||||
|
||||
;; --- FONT PROVISIONING (on demand, mirrors the browser)
|
||||
|
||||
(defn fonts-for-shape
|
||||
"Returns the distinct font families needed to render the subtree rooted at
|
||||
`shape-id` as a vector of {:id <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))
|
||||
;; `vec` must stay eager: it reads the result buffer, and the
|
||||
;; `mem/free` below invalidates these offsets.
|
||||
entries (vec
|
||||
(for [i (range n)]
|
||||
(let [base (+ offset 4 (* i FONT-ENTRY-BYTES))
|
||||
u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))]
|
||||
{:id #js [(u32 0) (u32 4) (u32 8) (u32 12)]
|
||||
:weight (u32 16)
|
||||
:style (u32 20)})))]
|
||||
(mem/free)
|
||||
entries))
|
||||
|
||||
(defn- font-key
|
||||
"Value key for a family map. Its `:id` is a JS array, so the map itself can't
|
||||
be compared by value."
|
||||
[{:keys [id weight style]}]
|
||||
[(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style])
|
||||
|
||||
(defn fonts-for-shapes
|
||||
"Distinct font families needed by every subtree in `shape-ids`. Objects in a
|
||||
partition overwhelmingly share families, so deduping here means one download
|
||||
and one `_store_font` per family rather than one per object."
|
||||
[shape-ids]
|
||||
(into [] (comp (mapcat fonts-for-shape)
|
||||
(d/distinct-xf font-key))
|
||||
shape-ids))
|
||||
|
||||
(defn store-font!
|
||||
"Uploads one font's TTF bytes into the WASM font store, keyed by the family
|
||||
(uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer.
|
||||
|
||||
Does NOT call `mem/free` — `store_font` (and likewise `store_image` below)
|
||||
releases the global buffer itself on the Rust side. Freeing again here would
|
||||
drop a buffer a later writer already owns."
|
||||
[{:keys [id weight style emoji? fallback?]} font-bytes]
|
||||
(let [module wasm/internal-module
|
||||
size (.-byteLength font-bytes)
|
||||
ptr (h/call module "_alloc_bytes" size)
|
||||
heap (mem/get-heap-u8)]
|
||||
(.set heap (js/Uint8Array. font-bytes) ptr)
|
||||
(h/call module "_store_font"
|
||||
(aget id 0) (aget id 1) (aget id 2) (aget id 3)
|
||||
weight style (boolean emoji?) (boolean fallback?))))
|
||||
|
||||
(defn clear-fonts!
|
||||
"Resets the WASM font store. Must be called once per render request because
|
||||
the shared module would otherwise accumulate fonts across requests."
|
||||
[]
|
||||
(h/call wasm/internal-module "_clear_fonts"))
|
||||
|
||||
(defn update-text-layout!
|
||||
"Recomputes a text shape's layout with the currently provisioned fonts. Text is
|
||||
laid out at serialize time using the fallback font (real fonts aren't uploaded
|
||||
yet), so this must run again after `provision-fonts!` or glyph metrics/line
|
||||
breaks are wrong."
|
||||
[shape-id]
|
||||
(let [buf (uuid/get-u32 shape-id)]
|
||||
(h/call wasm/internal-module "_update_shape_text_layout_for"
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))))
|
||||
|
||||
(defn image-cached?
|
||||
"True when the module's image store already holds this image (full size).
|
||||
The store is NOT reset between requests, so previously provisioned images
|
||||
can be reused instead of refetched."
|
||||
[image-id]
|
||||
(let [buf (uuid/get-u32 image-id)]
|
||||
(not (zero? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
false)))))
|
||||
|
||||
(defn store-image!
|
||||
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
|
||||
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
|
||||
[shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are
|
||||
keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an
|
||||
ArrayBuffer/Buffer/Uint8Array."
|
||||
[image-id image-bytes]
|
||||
(let [module wasm/internal-module
|
||||
img-u8 (js/Uint8Array. image-bytes)
|
||||
size (.-byteLength img-u8)
|
||||
total (+ 36 size)
|
||||
ptr (h/call module "_alloc_bytes" total)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))
|
||||
quart (uuid/get-u32 image-id)]
|
||||
;; shape uuid [0..16) = 0 (images are keyed by image uuid only)
|
||||
(.setUint32 dview (+ ptr 0) 0 true)
|
||||
(.setUint32 dview (+ ptr 4) 0 true)
|
||||
(.setUint32 dview (+ ptr 8) 0 true)
|
||||
(.setUint32 dview (+ ptr 12) 0 true)
|
||||
;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which
|
||||
;; the fill path uses, so it hashes to the same key the fill references)
|
||||
(.setUint32 dview (+ ptr 16) (aget quart 0) true)
|
||||
(.setUint32 dview (+ ptr 20) (aget quart 1) true)
|
||||
(.setUint32 dview (+ ptr 24) (aget quart 2) true)
|
||||
(.setUint32 dview (+ ptr 28) (aget quart 3) true)
|
||||
;; is_thumbnail [32..36) = 0
|
||||
(.setUint32 dview (+ ptr 32) 0 true)
|
||||
;; encoded bytes [36..)
|
||||
(.set heap img-u8 (+ ptr 36))
|
||||
(h/call module "_store_image")))
|
||||
|
||||
(defn evict-images!
|
||||
"Evicts least-recently-used images until the store retains at most `max-mb`
|
||||
megabytes. Returns the number evicted."
|
||||
[max-mb]
|
||||
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
|
||||
|
||||
(defn provision-fonts!
|
||||
"Resolves and uploads every font needed by `shape-ids`, each family fetched
|
||||
once. `resolve-font` is an injected fn of the family map -> promise of TTF
|
||||
bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom
|
||||
assets / backend) out of the driver."
|
||||
[shape-ids resolve-font]
|
||||
(->> (fonts-for-shapes shape-ids)
|
||||
(map (fn [family]
|
||||
(->> (resolve-font family)
|
||||
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
|
||||
(p/all)))
|
||||
|
||||
;; --- RENDER
|
||||
|
||||
(defn- read-render-result
|
||||
"Copies the encoded payload out of a `_render_shape_*` result buffer and frees
|
||||
it. `header-bytes` is the size of the header preceding the payload."
|
||||
[offset header-bytes]
|
||||
(let [heap32 (mem/get-heap-u32)
|
||||
len (aget heap32 (mem/->offset-32 offset))
|
||||
bytes (read-result-bytes (+ offset header-bytes) len)]
|
||||
(mem/free)
|
||||
bytes))
|
||||
|
||||
(defn render-shape-raster
|
||||
"Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU
|
||||
surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on
|
||||
the Rust side, since it has no alpha channel."
|
||||
[shape-id scale format]
|
||||
(let [buf (uuid/get-u32 shape-id)]
|
||||
(-> (h/call wasm/internal-module "_render_shape_raster"
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
scale (sr/translate-raster-format format))
|
||||
(read-render-result RASTER-HEADER-BYTES))))
|
||||
|
||||
(defn render-shape-pdf
|
||||
"Renders the shape subtree to PDF bytes (Uint8Array)."
|
||||
[shape-id scale]
|
||||
(let [buf (uuid/get-u32 shape-id)]
|
||||
(-> (h/call wasm/internal-module "_render_shape_pdf"
|
||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||
scale)
|
||||
(read-render-result PDF-HEADER-BYTES))))
|
||||
19
exporter/src/app/wasm/enums.cljs
Normal file
19
exporter/src/app/wasm/enums.cljs
Normal file
@ -0,0 +1,19 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.wasm.enums
|
||||
"Binds this build's generated enums into the shared bridge.
|
||||
|
||||
`shared.js` is emitted next to this file by `render-wasm/build export` and is
|
||||
not committed. Requiring this namespace is what makes
|
||||
`app.common.render-wasm.wasm/serializers` usable."
|
||||
(:require
|
||||
["./shared.js" :as shared]
|
||||
[app.common.render-wasm.wasm :as wasm])
|
||||
(:require-macros
|
||||
[app.common.render-wasm.enums :as enums]))
|
||||
|
||||
(wasm/init-serializers! (enums/serializers shared))
|
||||
46
exporter/src/app/wasm/serialize.cljs
Normal file
46
exporter/src/app/wasm/serialize.cljs
Normal file
@ -0,0 +1,46 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.wasm.serialize
|
||||
"Browser-free shape serialization for the headless exporter: the counterpart
|
||||
of `app.render-wasm.api/set-object`, which cannot be reused directly because
|
||||
its namespace pulls React/DOM/store. Only the call sequencing lives here —
|
||||
every byte layout comes from the shared serializers, so the bytes sent to
|
||||
WASM are the editor's.
|
||||
|
||||
Covers everything except svg-raw. Image bytes and fonts are provisioned
|
||||
separately by `app.renderer.wasm`."
|
||||
(:require
|
||||
[app.common.render-wasm.api.props :as props]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.serialize-shape :as serialize-shape]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.wasm.text :as text]))
|
||||
|
||||
(defn set-shape!
|
||||
"Serializes a single shape into the WASM design state. The host-independent
|
||||
properties (base props, children, blur, shadows, svg-attrs, mask, bool-type,
|
||||
path geometry, grow-type) go through the shared `serialize-shape!` — the same
|
||||
code the workspace's `set-object` uses, so the two can't drift. Only the
|
||||
host-specific parts are handled here: fills/strokes (image bytes are provisioned
|
||||
separately) and text content (fonts provisioned separately)."
|
||||
[shape]
|
||||
(let [type (get shape :type)]
|
||||
(serialize-shape/serialize-shape! shape)
|
||||
(props/write-shape-fills! (get shape :fills))
|
||||
(when-not (= type :group)
|
||||
(props/write-shape-strokes! (get shape :strokes)))
|
||||
(when (= type :text)
|
||||
(text/set-shape-text! (get shape :content)))))
|
||||
|
||||
(defn serialize-scene!
|
||||
"Loads every shape of an `objects` map into the WASM design state. Resets the
|
||||
shapes pool first so repeated exports don't accumulate into the shared
|
||||
state. Order is irrelevant: shapes reference each other by id and the tree
|
||||
is resolved at render time."
|
||||
[objects]
|
||||
(h/call wasm/internal-module "_init_shapes_pool" (count objects))
|
||||
(run! set-shape! (vals objects)))
|
||||
35
exporter/src/app/wasm/text.cljs
Normal file
35
exporter/src/app/wasm/text.cljs
Normal file
@ -0,0 +1,35 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.wasm.text
|
||||
"Browser-free text-content serialization for the headless exporter. Only the
|
||||
paragraph walk is local: the binary layout and the font-id -> uuid mapping
|
||||
both come from `app.common.render-wasm.text-content`."
|
||||
(:require
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.text-content :as tc]
|
||||
[app.common.render-wasm.wasm :as wasm]))
|
||||
|
||||
(defn set-shape-text!
|
||||
"Serializes a text shape's content into the current WASM shape. Mirrors the
|
||||
editor's sequence: clear -> vertical-align -> append each paragraph -> layout.
|
||||
Byte writing and font resolution are the shared
|
||||
`text-content/write-shape-text!` defaults; the exporter has no fonts DB, so
|
||||
it injects no variant normalization."
|
||||
[content]
|
||||
(when content
|
||||
(h/call wasm/internal-module "_clear_shape_text")
|
||||
(h/call wasm/internal-module "_set_shape_vertical_align"
|
||||
(sr/translate-vertical-align (get content :vertical-align)))
|
||||
(let [paragraph-set (first (get content :children))
|
||||
paragraphs (get paragraph-set :children)]
|
||||
(doseq [paragraph paragraphs]
|
||||
(let [spans (get paragraph :children)]
|
||||
(when (seq spans)
|
||||
(let [text (apply str (map :text spans))]
|
||||
(tc/write-shape-text! spans paragraph text {}))))))
|
||||
(h/call wasm/internal-module "_update_shape_text_layout")))
|
||||
@ -1,6 +1,7 @@
|
||||
{:paths ["src" "vendor" "resources" "test"]
|
||||
:deps
|
||||
{penpot/common
|
||||
{;; Carries `app.common.render-wasm.*`, shared with the headless exporter.
|
||||
penpot/common
|
||||
{:local/root "../common"}
|
||||
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
|
||||
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
|
||||
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
|
||||
"build:wasm": "../render-wasm/build",
|
||||
"build:wasm": "../render-wasm/build frontend",
|
||||
"build:app:libs": "node ./scripts/build-libs.js",
|
||||
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
|
||||
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
|
||||
|
||||
@ -30,7 +30,7 @@ mkdir -p target/dist;
|
||||
|
||||
# Build render wasm binary
|
||||
pushd ../render-wasm;
|
||||
./build
|
||||
./build frontend
|
||||
popd
|
||||
|
||||
pushd ../mcp;
|
||||
|
||||
@ -68,7 +68,7 @@ function slug(value) {
|
||||
}
|
||||
|
||||
async function findGfontsJson() {
|
||||
const dir = "resources/fonts";
|
||||
const dir = "../common/resources/fonts";
|
||||
const entries = await fs.readdir(dir);
|
||||
const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort();
|
||||
if (matches.length === 0) {
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.logging :as log]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.render-wasm.wasm :as wasm-state]
|
||||
[app.common.transit :as t]
|
||||
[app.common.types.component :as ctc]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
@ -77,7 +78,6 @@
|
||||
[app.plugins.register :as preg]
|
||||
[app.render-wasm :as wasm]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm-state]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.globals :as ug]
|
||||
[app.util.http :as http]
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
|
||||
(ns app.main.fonts
|
||||
"Fonts management and loading logic."
|
||||
(:require-macros [app.main.fonts :refer [preload-gfonts]])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.fonts :as cfnt]
|
||||
[app.common.logging :as log]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uri :as u]
|
||||
@ -25,27 +25,6 @@
|
||||
|
||||
(log/set-level! :warn)
|
||||
|
||||
(def google-fonts
|
||||
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
|
||||
|
||||
(def local-fonts
|
||||
[{:id "sourcesanspro"
|
||||
:name "Source Sans Pro"
|
||||
:family "sourcesanspro"
|
||||
:variants
|
||||
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
|
||||
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
|
||||
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
|
||||
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
|
||||
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
|
||||
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
|
||||
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
|
||||
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
|
||||
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
|
||||
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
|
||||
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
|
||||
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
|
||||
|
||||
(defonce fontsdb (l/atom {}))
|
||||
(defonce fonts (l/atom []))
|
||||
|
||||
@ -65,10 +44,10 @@
|
||||
fonts (map #(assoc % :backend backend) fonts)]
|
||||
(merge db (d/index-by :id fonts))))))
|
||||
|
||||
(register! :builtin local-fonts)
|
||||
(register! :builtin cfnt/local-fonts)
|
||||
|
||||
(when (contains? cf/flags :google-fonts-provider)
|
||||
(register! :google google-fonts))
|
||||
(register! :google cfnt/catalog))
|
||||
|
||||
(defn get-font-data [id]
|
||||
(get @fontsdb id))
|
||||
@ -266,8 +245,7 @@
|
||||
|
||||
(defn- process-gfont-css
|
||||
[css]
|
||||
(let [base (u/join cf/public-uri "internal/gfonts/font")]
|
||||
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
|
||||
(cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
|
||||
|
||||
(defn- fetch-gfont-css
|
||||
[url]
|
||||
@ -397,42 +375,12 @@
|
||||
|
||||
(defn find-closest-variant
|
||||
"Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match.
|
||||
When exactly between two weights, choose the higher one."
|
||||
When exactly between two weights, choose the higher one.
|
||||
|
||||
The algorithm lives in `app.common.fonts` so the headless exporter resolves the
|
||||
same variant for the same text."
|
||||
[font target-weight target-style]
|
||||
(when-let [target-weight (d/parse-integer target-weight)]
|
||||
(let [variants (:variants font [])
|
||||
result
|
||||
(reduce
|
||||
(fn [closest-match variant]
|
||||
(let [weight (d/parse-integer (:weight variant))
|
||||
distance (abs (- target-weight weight))
|
||||
matches-style? (= target-style (:style variant))
|
||||
current {:variant variant
|
||||
:weight weight
|
||||
:distance distance}]
|
||||
(cond
|
||||
;; Exact match found
|
||||
(and (zero? distance)
|
||||
(if target-style matches-style? true))
|
||||
(reduced current)
|
||||
|
||||
(nil? closest-match) current
|
||||
|
||||
;; Update best match if this variant is closer or equal distance but higher weight
|
||||
(or (< distance (:distance closest-match))
|
||||
(and (= distance (:distance closest-match))
|
||||
(> weight (:weight closest-match))))
|
||||
current
|
||||
|
||||
;; Same weight as the `closest-match` but the style matches `target-style`
|
||||
(and (= weight (:weight closest-match)) matches-style?)
|
||||
current
|
||||
|
||||
:else
|
||||
closest-match)))
|
||||
nil
|
||||
variants)]
|
||||
(:variant result))))
|
||||
(cfnt/closest-variant (:variants font []) target-weight target-style))
|
||||
|
||||
;; Font embedding functions
|
||||
(defn get-node-fonts
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.timers :as ts]
|
||||
[app.util.webapi :as webapi]
|
||||
|
||||
@ -13,8 +13,17 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.focus :as cpf]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.fonts :as cfnt]
|
||||
[app.common.logging :as log]
|
||||
[app.common.math :as mth]
|
||||
[app.common.render-wasm.api.props :as props]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.common.render-wasm.serialize-shape :as serialize-shape]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.path :as path]
|
||||
@ -31,23 +40,17 @@
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.shapes.text]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.render-wasm.api.enums]
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.api.props :as props]
|
||||
[app.render-wasm.api.texts :as t]
|
||||
[app.render-wasm.api.webgl :as webgl]
|
||||
[app.render-wasm.deserializers :as dr]
|
||||
[app.render-wasm.gesture :as wasm-gesture]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.mem.heap32 :as mem.h32]
|
||||
[app.render-wasm.performance :as perf]
|
||||
[app.render-wasm.rulers-state :as rulers-state]
|
||||
[app.render-wasm.serialize-shape :as serialize-shape]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.svg-filters :as svg-filters]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.functions :as fns]
|
||||
@ -1295,8 +1298,8 @@
|
||||
langs)
|
||||
|
||||
(let [text (apply str (map :text spans))
|
||||
emoji? (if emoji? emoji? (t/contains-emoji? text))
|
||||
langs (t/collect-used-languages langs text)]
|
||||
emoji? (if emoji? emoji? (cfnt/contains-emoji? text))
|
||||
langs (cfnt/collect-used-languages langs text)]
|
||||
|
||||
;; FIXME: this should probably be somewhere else
|
||||
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
|
||||
@ -1307,8 +1310,8 @@
|
||||
|
||||
(let [updated-fonts
|
||||
(-> #{}
|
||||
(cond-> ^boolean emoji? (f/add-emoji-font))
|
||||
(f/add-noto-fonts langs))
|
||||
(cond-> ^boolean emoji? (cfnt/add-emoji-font))
|
||||
(cfnt/add-noto-fonts langs))
|
||||
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
|
||||
|
||||
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
|
||||
|
||||
19
frontend/src/app/render_wasm/api/enums.cljs
Normal file
19
frontend/src/app/render_wasm/api/enums.cljs
Normal file
@ -0,0 +1,19 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.render-wasm.api.enums
|
||||
"Binds this build's generated enums into the shared bridge.
|
||||
|
||||
`shared.js` is emitted next to this file by `render-wasm/build frontend` and
|
||||
is not committed. Requiring this namespace is what makes
|
||||
`app.common.render-wasm.wasm/serializers` usable."
|
||||
(:require
|
||||
["./shared.js" :as shared]
|
||||
[app.common.render-wasm.wasm :as wasm])
|
||||
(:require-macros
|
||||
[app.common.render-wasm.enums :as enums]))
|
||||
|
||||
(wasm/init-serializers! (enums/serializers shared))
|
||||
@ -8,15 +8,15 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.fonts :as cfnt]
|
||||
[app.common.logging :as log]
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.http :as http]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
@ -39,33 +39,6 @@
|
||||
(def ^:private default-line-height 1.2)
|
||||
(def ^:private default-letter-spacing 0.0)
|
||||
|
||||
(defn- google-font-id->uuid
|
||||
"Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the
|
||||
font is not found in fontsdb. uuid/zero maps to the default font (Source
|
||||
Sans Pro) in WASM.
|
||||
A font id may not exist for different reasons:
|
||||
- the gfonts.json catalog was updated and fonts were renamed or removed,
|
||||
- the file was imported from another Penpot instance with different fonts,
|
||||
..."
|
||||
[font-id]
|
||||
(let [font (fonts/get-font-data font-id)
|
||||
result (:uuid font)]
|
||||
(or result uuid/zero)))
|
||||
|
||||
(defn- custom-font-id->uuid
|
||||
[font-id]
|
||||
(uuid/uuid (subs font-id (inc (str/index-of font-id "-")))))
|
||||
|
||||
(defn- font-backend
|
||||
[font-id]
|
||||
(cond
|
||||
(str/starts-with? font-id "gfont-")
|
||||
:google
|
||||
(str/starts-with? font-id "custom-")
|
||||
:custom
|
||||
:else
|
||||
:builtin))
|
||||
|
||||
(defn- font-db-data
|
||||
[font-id font-variant-id font-weight-fallback font-style-fallback]
|
||||
(let [font (fonts/get-font-data font-id)
|
||||
@ -75,15 +48,6 @@
|
||||
variant
|
||||
closest-variant)))
|
||||
|
||||
(defn- font-id->uuid [font-id]
|
||||
(case (font-backend font-id)
|
||||
:google
|
||||
(google-font-id->uuid font-id)
|
||||
:custom
|
||||
(custom-font-id->uuid font-id)
|
||||
:builtin
|
||||
uuid/zero))
|
||||
|
||||
(defn uuid->font-id
|
||||
[font-uuid]
|
||||
(if (= font-uuid uuid/zero)
|
||||
@ -100,11 +64,11 @@
|
||||
"regular")))
|
||||
|
||||
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
|
||||
(case (font-backend font-id)
|
||||
(case (cfnt/font-id->backend font-id)
|
||||
:google
|
||||
font-id
|
||||
:custom
|
||||
(let [font-uuid (custom-font-id->uuid font-id)
|
||||
(let [font-uuid (cfnt/font-id->uuid font-id)
|
||||
matching-font (some (fn [[_ font]]
|
||||
(and (= (:font-id font) font-uuid)
|
||||
(= (str (:font-weight font)) (str font-weight))
|
||||
@ -194,13 +158,12 @@
|
||||
(defn- google-font-ttf-url
|
||||
[font-id font-variant-id font-weight font-style]
|
||||
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
|
||||
(if-let [ttf-url (:ttf-url variant)]
|
||||
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/"))
|
||||
nil)))
|
||||
(when-let [ttf-url (:ttf-url variant)]
|
||||
(cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font")))))
|
||||
|
||||
(defn- font-id->ttf-url
|
||||
[font-id asset-id font-variant-id font-weight font-style]
|
||||
(case (font-backend font-id)
|
||||
(case (cfnt/font-id->backend font-id)
|
||||
:google
|
||||
(google-font-ttf-url font-id font-variant-id font-weight font-style)
|
||||
:custom
|
||||
@ -245,18 +208,6 @@
|
||||
"italic" 1
|
||||
0))
|
||||
|
||||
(defn normalize-font-id
|
||||
[font-id]
|
||||
(try
|
||||
(if ^boolean (str/starts-with? font-id "gfont-")
|
||||
(google-font-id->uuid font-id)
|
||||
(let [no-prefix (subs font-id (inc (str/index-of font-id "-")))]
|
||||
(if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix))
|
||||
uuid/zero
|
||||
(uuid/parse no-prefix))))
|
||||
(catch :default _e
|
||||
uuid/zero)))
|
||||
|
||||
(defn normalize-span-font
|
||||
[span paragraph]
|
||||
(let [font-id (:font-id span)
|
||||
@ -358,7 +309,7 @@
|
||||
emoji? (get font :is-emoji false)
|
||||
fallback? (get font :is-fallback false)
|
||||
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
|
||||
wasm-id (font-id->uuid font-id)
|
||||
wasm-id (cfnt/font-id->uuid font-id)
|
||||
raw-weight (or (:weight font-data) font-weight-fallback)
|
||||
weight (serialize-font-weight raw-weight)
|
||||
style (cond
|
||||
@ -415,7 +366,3 @@
|
||||
(defn store-fonts
|
||||
[fonts]
|
||||
(keep (fn [font] (store-font font)) fonts))
|
||||
|
||||
(def add-emoji-font fbf/add-emoji-font)
|
||||
(def noto-fonts fbf/noto-fonts)
|
||||
(def add-noto-fonts fbf/add-noto-fonts)
|
||||
|
||||
@ -6,21 +6,13 @@
|
||||
|
||||
(ns app.render-wasm.api.texts
|
||||
(:require
|
||||
[app.render-wasm.api.fonts :as f]
|
||||
[app.render-wasm.fallback-fonts :as fbf]
|
||||
[app.render-wasm.text-content :as tc]))
|
||||
[app.common.render-wasm.text-content :as tc]
|
||||
[app.render-wasm.api.fonts :as f]))
|
||||
|
||||
(defn write-shape-text
|
||||
"Workspace text serialization: the byte writing is shared via
|
||||
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
|
||||
`app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
|
||||
[spans paragraph text]
|
||||
(tc/write-shape-text! spans paragraph text
|
||||
{:normalize-font-id f/normalize-font-id
|
||||
:normalize-paragraph f/normalize-paragraph-font
|
||||
{:normalize-paragraph f/normalize-paragraph-font
|
||||
:normalize-span f/normalize-span-font}))
|
||||
|
||||
;; Emoji/script detection lives in the host-agnostic
|
||||
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
|
||||
;; workspace callers.
|
||||
(def contains-emoji? fbf/contains-emoji?)
|
||||
(def collect-used-languages fbf/collect-used-languages)
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"WebGL utilities for pixel capture and rendering"
|
||||
(:require
|
||||
[app.common.logging :as log]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[promesa.core :as p]))
|
||||
|
||||
(defn get-webgl-context
|
||||
|
||||
@ -7,16 +7,18 @@
|
||||
(ns app.render-wasm.text-editor
|
||||
"Text editor WASM bindings"
|
||||
(:require
|
||||
[app.common.render-wasm.helpers :as h]
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.fills.impl :as types.fills.impl]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.fonts :as main-fonts]
|
||||
;; Required for side effects: binds the generated enums.
|
||||
[app.render-wasm.api.enums]
|
||||
[app.render-wasm.api.fonts :as fonts]
|
||||
[app.render-wasm.helpers :as h]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.serializers :as sr]
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.color :as uc]
|
||||
[app.util.dom :as dom]))
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes.bounds :as gsb]
|
||||
[app.common.logging :as log]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.types.color :as cc]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
@ -18,7 +19,6 @@
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.render :as render]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.http :as http]
|
||||
[app.worker.impl :as impl]
|
||||
[beicon.v2.core :as rx]
|
||||
|
||||
@ -15,6 +15,9 @@
|
||||
[app.common.json :as json]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.render-wasm.helpers :as wasm.h]
|
||||
[app.common.render-wasm.mem :as wasm.mem]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.transit :as t]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
@ -36,9 +39,6 @@
|
||||
[app.main.errors :as errors]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.helpers :as wasm.h]
|
||||
[app.render-wasm.mem :as wasm.mem]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.http :as http]
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
font URL get no callback (fetch-font returns nil when the URL is already
|
||||
in :fetching) and are permanently stuck with fallback-font layout metrics."
|
||||
(:require
|
||||
[app.common.render-wasm.mem :as mem]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.mem :as mem]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
anything else (no fill, gradient, image fills, mixed selection) it falls back
|
||||
to an inverted caret (white painted with a Difference blend)."
|
||||
(:require
|
||||
[app.render-wasm.serializers.color :as sr-clr]
|
||||
[app.common.render-wasm.serializers.color :as sr-clr]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
|
||||
@ -42,3 +42,7 @@ opt-level = 3
|
||||
lto = "fat"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[profile.size]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
|
||||
@ -29,6 +29,34 @@ You can also use `./watch` to run the build on every change.
|
||||
|
||||
The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app.
|
||||
|
||||
### Render targets
|
||||
|
||||
The same Rust source produces two artifacts, which differ only in compiler
|
||||
options:
|
||||
|
||||
| Target | Tuned for | Cargo profile | Consumed by |
|
||||
| ---------- | --------- | ----------------- | ------------------------------ |
|
||||
| `frontend` | speed | `release` (`-O3`) | `frontend/resources/public/js` |
|
||||
| `export` | size | `size` (`-Oz`) | `exporter/resources/wasm` |
|
||||
|
||||
```sh
|
||||
./build # both targets, frontend first
|
||||
./build frontend # workspace / viewer renderer
|
||||
./build export # headless exporter renderer
|
||||
```
|
||||
|
||||
`./watch` still follows a single target (`frontend` unless you pass one),
|
||||
since watching both would rebuild twice on every keystroke.
|
||||
|
||||
Each target keeps its own `CARGO_TARGET_DIR` (`target/<target>`), so switching
|
||||
between them does not invalidate the other's cache. Set `BUILD_MODE=release`
|
||||
(or `NODE_ENV=production`) for an optimized build; the default is `debug`.
|
||||
|
||||
Each target writes its own generated `shared.js` (the enum discriminants the
|
||||
CLJS side compiles against) next to the code that imports it — respectively
|
||||
`frontend/src/app/render_wasm/api/shared.js` and
|
||||
`exporter/src/app/wasm/shared.js`. Neither build writes to the other's paths.
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export VERSION_TAG=${VERSION:-develop};
|
||||
export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}";
|
||||
|
||||
case "$RENDER_TARGET" in
|
||||
frontend|export) ;;
|
||||
*)
|
||||
echo "ERROR: unknown render target '$RENDER_TARGET' (expected 'frontend' or 'export')" >&2;
|
||||
exit 1;
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
export BUILD_MODE="release";
|
||||
else
|
||||
export BUILD_MODE=${1:-debug};
|
||||
export BUILD_MODE=${BUILD_MODE:-debug};
|
||||
fi
|
||||
|
||||
export BUILD_NAME="${BUILD_NAME:-render-wasm}"
|
||||
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
|
||||
export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"};
|
||||
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
|
||||
|
||||
# 256 MB of initial heap to perform less
|
||||
@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache";
|
||||
|
||||
export CARGO_PARAMS="${@:2}";
|
||||
|
||||
export CARGO_PROFILE_DIR="debug";
|
||||
|
||||
if [ "$BUILD_MODE" = "release" ]; then
|
||||
export CARGO_PARAMS="--release $CARGO_PARAMS"
|
||||
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"
|
||||
case "$RENDER_TARGET" in
|
||||
frontend)
|
||||
export CARGO_PARAMS="--release $CARGO_PARAMS";
|
||||
export CARGO_PROFILE_DIR="release";
|
||||
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS";
|
||||
;;
|
||||
export)
|
||||
export CARGO_PARAMS="--profile size $CARGO_PARAMS";
|
||||
export CARGO_PROFILE_DIR="size";
|
||||
export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS";
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# TODO: Extra parameters that could be good to look into:
|
||||
# -gseparate-dwarf
|
||||
@ -62,6 +84,12 @@ else
|
||||
export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"
|
||||
fi
|
||||
|
||||
export FRONTEND_DEST="../frontend/resources/public/js";
|
||||
export EXPORT_DEST="../exporter/resources/wasm";
|
||||
|
||||
export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js";
|
||||
export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js";
|
||||
|
||||
function clean {
|
||||
cargo clean;
|
||||
}
|
||||
@ -78,26 +106,48 @@ function build {
|
||||
|
||||
function copy_artifacts {
|
||||
DEST=$1;
|
||||
SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR";
|
||||
|
||||
mkdir -p $DEST;
|
||||
|
||||
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
|
||||
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
|
||||
if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then
|
||||
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
|
||||
cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js;
|
||||
cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
|
||||
if [ -f $SRC/render_wasm.wasm.map ]; then
|
||||
cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
|
||||
fi
|
||||
|
||||
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js;
|
||||
|
||||
pnpm exec esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \
|
||||
--log-level=error \
|
||||
--outfile=$DEST/worker/render.js \
|
||||
--platform=neutral \
|
||||
--format=iife \
|
||||
--global-name=WasmModule;
|
||||
# The worker bundle is a browser concern; the exporter imports the ESM
|
||||
# module directly under Node.
|
||||
if [ "$RENDER_TARGET" = "frontend" ]; then
|
||||
pnpm exec esbuild $SRC/render_wasm.js \
|
||||
--log-level=error \
|
||||
--outfile=$DEST/worker/render.js \
|
||||
--platform=neutral \
|
||||
--format=iife \
|
||||
--global-name=WasmModule;
|
||||
fi
|
||||
}
|
||||
|
||||
function copy_shared_artifact {
|
||||
SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1);
|
||||
cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js;
|
||||
DEST=$1;
|
||||
SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1);
|
||||
|
||||
cp $SHARED_FILE $DEST;
|
||||
}
|
||||
|
||||
# Copies whatever the current RENDER_TARGET produced to where that target's
|
||||
# consumer reads it.
|
||||
function copy_target_artifacts {
|
||||
case "$RENDER_TARGET" in
|
||||
frontend)
|
||||
copy_artifacts "$FRONTEND_DEST";
|
||||
copy_shared_artifact "$FRONTEND_SHARED_DEST";
|
||||
;;
|
||||
export)
|
||||
copy_artifacts "$EXPORT_DEST";
|
||||
copy_shared_artifact "$EXPORT_SHARED_DEST";
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@ -1,8 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Usage: ./build [frontend|export] [extra cargo params...]
|
||||
#
|
||||
# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production)
|
||||
# for an optimized build. See `_build_env` for what each target changes.
|
||||
|
||||
_SCRIPT_DIR=$(dirname $0);
|
||||
|
||||
# Each target needs its own `_build_env`, so re-enter per target.
|
||||
case "${1:-}" in
|
||||
frontend|export)
|
||||
;;
|
||||
*)
|
||||
for _target in frontend export; do
|
||||
"$_SCRIPT_DIR/build" "$_target" "$@" || exit $?;
|
||||
done
|
||||
exit 0;
|
||||
;;
|
||||
esac
|
||||
|
||||
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
|
||||
|
||||
_SCRIPT_DIR=$(dirname $0);
|
||||
pushd $_SCRIPT_DIR;
|
||||
|
||||
. ./_build_env
|
||||
@ -11,8 +29,7 @@ set -ex;
|
||||
|
||||
setup;
|
||||
build;
|
||||
copy_artifacts "../frontend/resources/public/js";
|
||||
copy_shared_artifact;
|
||||
copy_target_artifacts;
|
||||
|
||||
exit $?;
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
. ./_build_env
|
||||
. ./_build_env frontend
|
||||
|
||||
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
|
||||
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
|
||||
|
||||
@ -329,15 +329,21 @@ fn render_text_on_canvas(
|
||||
layer_opacity: Option<f32>,
|
||||
overlay_emoji: bool,
|
||||
) {
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut blur_paint = Paint::default();
|
||||
blur_paint.set_image_filter(blur_filter.clone());
|
||||
let blur_layer = SaveLayerRec::default().paint(&blur_paint);
|
||||
let blur_layer = SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&blur_paint);
|
||||
canvas.save_layer(&blur_layer);
|
||||
}
|
||||
|
||||
if let Some(shadow_paint) = shadow {
|
||||
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
|
||||
let layer_rec = SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(shadow_paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
draw_text(
|
||||
canvas,
|
||||
@ -351,7 +357,9 @@ fn render_text_on_canvas(
|
||||
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
|
||||
let mut layer_paint = Paint::default();
|
||||
layer_paint.set_image_filter(erode);
|
||||
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
|
||||
let layer_rec = SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&layer_paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
draw_text(
|
||||
canvas,
|
||||
@ -582,7 +590,10 @@ fn draw_decoration_stroke(
|
||||
skia::BlendMode::SrcOut
|
||||
};
|
||||
|
||||
canvas.save_layer(&SaveLayerRec::default());
|
||||
let outset = stroke_paint.stroke_width().max(0.0);
|
||||
let layer_bounds = bar.with_outset((outset, outset));
|
||||
|
||||
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
|
||||
let mut mask_paint = Paint::default();
|
||||
mask_paint.set_color(skia::Color::BLACK);
|
||||
mask_paint.set_anti_alias(true);
|
||||
@ -590,7 +601,11 @@ fn draw_decoration_stroke(
|
||||
|
||||
let mut blend_paint = Paint::default();
|
||||
blend_paint.set_blend_mode(blend);
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint));
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&blend_paint),
|
||||
);
|
||||
canvas.draw_rect(bar, stroke_paint);
|
||||
canvas.restore();
|
||||
canvas.restore();
|
||||
@ -705,7 +720,12 @@ pub fn render_emoji_overlay(
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut blur_paint = Paint::default();
|
||||
blur_paint.set_image_filter(blur_filter.clone());
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint));
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&blur_paint),
|
||||
);
|
||||
}
|
||||
|
||||
for (emoji_para, deco_para) in emoji_layout
|
||||
@ -728,13 +748,17 @@ fn draw_text(
|
||||
layer_opacity: Option<f32>,
|
||||
overlay_emoji: bool,
|
||||
) {
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
|
||||
if let Some(opacity) = layer_opacity {
|
||||
let mut opacity_paint = Paint::default();
|
||||
opacity_paint.set_alpha_f(opacity);
|
||||
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
|
||||
let layer_rec = SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&opacity_paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
} else {
|
||||
canvas.save_layer(&SaveLayerRec::default());
|
||||
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
|
||||
}
|
||||
|
||||
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji);
|
||||
@ -759,27 +783,41 @@ fn render_masked_stroke_on_canvas(
|
||||
blur: Option<&ImageFilter>,
|
||||
layer_opacity: Option<f32>,
|
||||
) {
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
|
||||
if let Some(blur_filter) = blur {
|
||||
let mut blur_paint = Paint::default();
|
||||
blur_paint.set_image_filter(blur_filter.clone());
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint));
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&blur_paint),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(opacity) = layer_opacity {
|
||||
let mut opacity_paint = Paint::default();
|
||||
opacity_paint.set_alpha_f(opacity);
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint));
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&opacity_paint),
|
||||
);
|
||||
}
|
||||
|
||||
canvas.save_layer(&SaveLayerRec::default());
|
||||
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
|
||||
|
||||
canvas.save_layer(&SaveLayerRec::default());
|
||||
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
|
||||
|
||||
paint_text(canvas, shape, mask_builders);
|
||||
|
||||
let mut stroke_paint = Paint::default();
|
||||
stroke_paint.set_blend_mode(stroke_mask_blend);
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint));
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&stroke_paint),
|
||||
);
|
||||
|
||||
paint_text(canvas, shape, stroke_builders);
|
||||
|
||||
@ -789,7 +827,11 @@ fn render_masked_stroke_on_canvas(
|
||||
if let Some(fill_builders) = fill_builders {
|
||||
let mut dst_over_paint = Paint::default();
|
||||
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
|
||||
canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint));
|
||||
canvas.save_layer(
|
||||
&SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&dst_over_paint),
|
||||
);
|
||||
|
||||
paint_text(canvas, shape, fill_builders);
|
||||
|
||||
|
||||
@ -79,11 +79,14 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
}
|
||||
|
||||
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
for shadow in shape.drop_shadows_visible() {
|
||||
if let Some(filter) = shadow.get_drop_shadow_filter() {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_image_filter(filter);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&paint);
|
||||
self.canvas.save_layer(&layer_rec);
|
||||
let mut fill_paint = Paint::default();
|
||||
fill_paint.set_anti_alias(true);
|
||||
@ -99,10 +102,14 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
if !shape.has_fills() {
|
||||
return Ok(());
|
||||
}
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
for shadow in shape.inner_shadows_visible() {
|
||||
let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
|
||||
self.canvas
|
||||
.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
self.canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&paint),
|
||||
);
|
||||
let mut fill_paint = Paint::default();
|
||||
fill_paint.set_anti_alias(true);
|
||||
fill_paint.set_color(skia::Color::BLACK);
|
||||
@ -161,9 +168,13 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
for shadow_paint in &drop_shadows {
|
||||
self.canvas
|
||||
.save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint));
|
||||
self.canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(shadow_paint),
|
||||
);
|
||||
|
||||
text::render_overlay_emoji(
|
||||
self.canvas,
|
||||
@ -331,7 +342,10 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_image_filter(filter);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&paint);
|
||||
self.canvas.save_layer(&layer_rec);
|
||||
true
|
||||
} else {
|
||||
@ -715,7 +729,10 @@ fn render_group(
|
||||
}
|
||||
}
|
||||
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
let layer_bounds = element.extrect(tree, scale);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
@ -726,7 +743,12 @@ fn render_group(
|
||||
// as content, then re-draw the mask silhouette (the group's first child)
|
||||
// with DstIn to clip everything to it.
|
||||
let paint = Paint::default();
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
let subtree_bounds = element.extrect(tree, scale);
|
||||
canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&subtree_bounds)
|
||||
.paint(&paint),
|
||||
);
|
||||
|
||||
for child_id in &children {
|
||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
||||
@ -735,7 +757,11 @@ fn render_group(
|
||||
if let Some(mask_id) = element.mask_id() {
|
||||
let mut mask_paint = Paint::default();
|
||||
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint));
|
||||
canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&subtree_bounds)
|
||||
.paint(&mask_paint),
|
||||
);
|
||||
render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?;
|
||||
canvas.restore(); // mask layer
|
||||
}
|
||||
@ -797,7 +823,10 @@ fn render_frame(
|
||||
}
|
||||
}
|
||||
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
let layer_bounds = element.extrect(tree, scale);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
@ -857,13 +886,18 @@ fn render_container_drop_shadows(
|
||||
draw_fills: bool,
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
let subtree_bounds = element.extrect(tree, scale);
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let Some(filter) = shadow.get_drop_shadow_filter() else {
|
||||
continue;
|
||||
};
|
||||
let mut paint = Paint::default();
|
||||
paint.set_image_filter(filter);
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
|
||||
canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&subtree_bounds)
|
||||
.paint(&paint),
|
||||
);
|
||||
|
||||
if draw_fills && !element.fills.is_empty() {
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale);
|
||||
@ -902,7 +936,10 @@ fn render_leaf(
|
||||
let mut paint = Paint::default();
|
||||
paint.set_blend_mode(element.blend_mode().into());
|
||||
paint.set_alpha_f(element.opacity());
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
let layer_bounds = element.layer_bounds();
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&paint);
|
||||
canvas.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
@ -1101,7 +1138,8 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint
|
||||
}
|
||||
StrokeKind::Outer => {
|
||||
canvas.save();
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default());
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds));
|
||||
draw_shape_geometry(canvas, shape, paint);
|
||||
let mut clear_paint = Paint::default();
|
||||
clear_paint.set_blend_mode(skia::BlendMode::Clear);
|
||||
@ -1134,7 +1172,8 @@ fn draw_image_stroke(
|
||||
let container = shape.selrect;
|
||||
|
||||
canvas.save();
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default());
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds));
|
||||
|
||||
// Opaque stroke silhouette; the SrcIn image draw below fills it.
|
||||
draw_stroke_geometry(canvas, scale, shape, stroke, true);
|
||||
|
||||
@ -1070,7 +1070,7 @@ impl Shape {
|
||||
extrect
|
||||
}
|
||||
|
||||
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
|
||||
fn own_extrect_bounds(&self) -> Bounds {
|
||||
let shape = self;
|
||||
let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open());
|
||||
|
||||
@ -1096,6 +1096,19 @@ impl Shape {
|
||||
bounds = self.apply_stroke_bounds(bounds, max_stroke);
|
||||
bounds = self.apply_shadow_bounds(bounds);
|
||||
bounds = self.apply_blur_bounds(bounds);
|
||||
bounds
|
||||
}
|
||||
|
||||
/// Bound for a `SaveLayerRec` wrapping this shape's own drawing, in
|
||||
/// untransformed space (callers concatenate [`Self::centered_transform`]
|
||||
/// first). Includes shadow/blur margins, so it is also a valid input bound
|
||||
/// for a layer whose paint carries an image filter.
|
||||
pub fn layer_bounds(&self) -> math::Rect {
|
||||
self.own_extrect_bounds().to_rect()
|
||||
}
|
||||
|
||||
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
|
||||
let mut bounds = self.own_extrect_bounds();
|
||||
bounds = self.apply_children_bounds(bounds, shapes_pool, scale);
|
||||
bounds = self.apply_children_blur(bounds, shapes_pool);
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"};
|
||||
_SCRIPT_DIR=$(dirname $0);
|
||||
pushd $_SCRIPT_DIR;
|
||||
|
||||
. ./_build_env
|
||||
. ./_build_env frontend
|
||||
|
||||
cargo test --bin render_wasm -- --show-output
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Usage: ./watch [frontend|export]
|
||||
|
||||
_SCRIPT_DIR=$(dirname $0);
|
||||
pushd $_SCRIPT_DIR;
|
||||
|
||||
@ -7,8 +9,7 @@ pushd $_SCRIPT_DIR;
|
||||
|
||||
set -x
|
||||
build;
|
||||
copy_artifacts "../frontend/resources/public/js";
|
||||
copy_shared_artifact;
|
||||
copy_target_artifacts;
|
||||
|
||||
pushd $_SCRIPT_DIR;
|
||||
|
||||
@ -16,7 +17,7 @@ cargo watch \
|
||||
--why \
|
||||
-i "_tmp*" \
|
||||
-x "build $CARGO_PARAMS" \
|
||||
-s "./build" \
|
||||
-s "./build $RENDER_TARGET" \
|
||||
-s "echo 'DONE\n'";
|
||||
|
||||
popd
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user