0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-05 11:31:35 -05:00

Add thumbnail renderer

And integrate the dashboard thumbnails to use that service
This commit is contained in:
Andrey Antukh 2023-06-21 17:14:50 +02:00
parent 64ddfa0c31
commit d11b007795
19 changed files with 579 additions and 183 deletions

View file

@ -38,6 +38,11 @@
;; --- FEATURES
(defn resolve-public-uri
[media-id]
(when media-id
(str (cf/get :public-uri) "/assets/by-id/" media-id)))
(def supported-features
#{"storage/objects-map"
"storage/pointer-map"
@ -413,15 +418,23 @@
f.modified_at,
f.name,
f.revn,
f.is_shared
f.is_shared,
ft.media_id
from file as f
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where f.project_id = ?
and f.deleted_at is null
order by f.modified_at desc")
(defn get-project-files
[conn project-id]
(db/exec! conn [sql:project-files project-id]))
(->> (db/exec! conn [sql:project-files project-id])
(mapv (fn [row]
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(sv/defmethod ::get-project-files
"Get all files for the specified project."
@ -668,9 +681,11 @@
f.modified_at,
f.name,
f.is_shared,
ft.media_id,
row_number() over w as row_num
from file as f
join project as p on (p.id = f.project_id)
inner join project as p on (p.id = f.project_id)
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where p.team_id = ?
and p.deleted_at is null
and f.deleted_at is null
@ -681,7 +696,13 @@
(defn get-team-recent-files
[conn team-id]
(db/exec! conn [sql:team-recent-files team-id]))
(->> (db/exec! conn [sql:team-recent-files team-id])
(mapv (fn [row]
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(s/def ::get-team-recent-files
(s/keys :req [::rpc/profile-id]

View file

@ -14,7 +14,6 @@
[app.common.schema :as sm]
[app.common.spec :as us]
[app.common.types.shape-tree :as ctt]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.loggers.audit :as-alias audit]
@ -39,10 +38,6 @@
;; --- COMMAND QUERY: get-file-object-thumbnails
(defn- get-public-uri
[media-id]
(str (cf/get :public-uri) "/assets/by-id/" media-id))
(defn- get-object-thumbnails
([conn file-id]
(let [sql (str/concat
@ -52,7 +47,7 @@
res (db/exec! conn [sql file-id])]
(->> res
(d/index-by :object-id (fn [row]
(or (some-> row :media-id get-public-uri)
(or (some-> row :media-id files/resolve-public-uri)
(:data row))))
(d/without-nils))))
@ -65,7 +60,7 @@
res (db/exec! conn [sql file-id ids])]
(d/index-by :object-id
(fn [row]
(or (some-> row :media-id get-public-uri)
(or (some-> row :media-id files/resolve-public-uri)
(:data row)))
res))))
@ -85,8 +80,6 @@
;; --- COMMAND QUERY: get-file-thumbnail
;; FIXME: refactor to support uploading data to storage
(defn get-file-thumbnail
[conn file-id revn]
(let [sql (sql/select :file-thumbnail
@ -95,10 +88,15 @@
{:limit 1
:order-by [[:revn :desc]]})
row (db/exec-one! conn sql)]
(when-not row
(ex/raise :type :not-found
:code :file-thumbnail-not-found))
(when-not (:data row)
(ex/raise :type :not-found
:code :file-thumbnail-not-found))
{:data (:data row)
:props (some-> (:props row) db/decode-transit-pgobject)
:revn (:revn row)
@ -113,20 +111,16 @@
:opt-un [::revn]))
(sv/defmethod ::get-file-thumbnail
"Method used in frontend for obtain the file thumbnail (used in the
dashboard)."
{::doc/added "1.17"}
{::doc/added "1.17"
::doc/deprecated "1.19"}
[{:keys [::db/pool]} {:keys [::rpc/profile-id file-id revn]}]
(dm/with-open [conn (db/open pool)]
(files/check-read-permissions! conn profile-id file-id)
(-> (get-file-thumbnail conn file-id revn)
(rph/with-http-cache long-cache-duration))))
;; --- COMMAND QUERY: get-file-data-for-thumbnail
;; FIXME: performance issue, handle new media_id
;;
;; We need to improve how we set frame for thumbnail in order to avoid
;; loading all pages into memory for find the frame set for thumbnail.
@ -427,24 +421,27 @@
:bucket "file-thumbnail"})]
(db/exec-one! conn [sql:create-file-thumbnail file-id revn
(:id media) props
(:id media) props])))
(s/def ::media ::media/upload)
(s/def ::create-file-thumbnail
(s/keys :req [::rpc/profile-id]
:req-un [::file-id ::revn ::props ::media]))
(:id media) props])
media))
(sv/defmethod ::create-file-thumbnail
"Creates or updates the file thumbnail. Mainly used for paint the
grid thumbnails."
{::doc/added "1.19"
::audit/skip true}
::audit/skip true
::sm/params [:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn :int]
[:media ::media/upload]]
}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(-> cfg
(update ::sto/storage media/configure-assets-storage)
(assoc ::db/conn conn)
(create-file-thumbnail! params))
nil)))
(let [media (-> cfg
(update ::sto/storage media/configure-assets-storage)
(assoc ::db/conn conn)
(create-file-thumbnail! params))]
{:uri (files/resolve-public-uri (:id media))}))))

View file

@ -184,7 +184,7 @@
(when (seq res)
(doseq [media-id (into #{} (keep :media-id) res)]
;; Mark as deleted the storage object related with the
;; photo-id field.
;; media-id field.
(l/trace :hint "mark storage object as deleted" :id media-id)
(sto/del-object! storage media-id))

View file

@ -141,7 +141,7 @@
)))
(t/deftest upsert-file-thumbnail
(t/deftest create-file-thumbnail
(let [storage (::sto/storage th/*system*)
profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
@ -159,7 +159,6 @@
data2 {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:props {}
:revn 2
:media {:filename "sample.jpg"
:size 7923
@ -169,7 +168,6 @@
data3 {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile)
:file-id (:id file)
:props {}
:revn 3
:media {:filename "sample.jpg"
:size 312043
@ -183,11 +181,11 @@
(let [out (th/command! data2)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(t/is (contains? (:result out) :uri)))
(let [out (th/command! data3)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(t/is (contains? (:result out) :uri)))
(let [[row1 row2 row3 :as rows] (th/db-query :file-thumbnail
{:file-id (:id file)}

View file

@ -131,7 +131,8 @@ function readManifest() {
"polyfills": "js/polyfills.js",
"main": "js/main.js",
"shared": "js/shared.js",
"worker": "js/worker.js"
"worker": "js/worker.js",
"thumbnail-renderer": "js/thumbnail-renderer.js"
};
}
}
@ -242,7 +243,17 @@ gulp.task("template:render", templatePipeline({
output: paths.output
}));
gulp.task("templates", gulp.series("svg:sprite:icons", "svg:sprite:cursors", "template:main", "template:render"));
gulp.task("template:thumbnail-renderer", templatePipeline({
name: "thumbnail-renderer.html",
input: paths.resources + "templates/thumbnail-renderer.mustache",
output: paths.output
}));
gulp.task("templates", gulp.series("svg:sprite:icons",
"svg:sprite:cursors",
"template:main",
"template:render",
"template:thumbnail-renderer"));
gulp.task("polyfills", function() {
return gulp.src(paths.resources + "polyfills/*.js")

View file

@ -51,6 +51,10 @@
border-radius: $br3;
border: 2px solid lighten($color-gray-20, 15%);
text-align: initial;
img {
object-fit: contain;
}
}
&.dragged {

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Penpot - Thumbnail Renderer</title>
<link rel="icon" href="images/favicon.png" />
<script>
window.penpotVersion = "%version%";
window.penpotBuildDate = "%buildDate%";
</script>
{{# manifest}}
<script>window.penpotWorkerURI="{{& worker}}"</script>
<script src="{{& config}}"></script>
<script src="{{& polyfills}}"></script>
{{/manifest}}
</head>
<body>
{{# manifest}}
<script src="{{& shared}}"></script>
<script src="{{& thumbnail-renderer}}"></script>
{{/manifest}}
</body>
</html>

View file

@ -16,17 +16,25 @@
:modules
{:shared {:entries []}
:main {:entries [app.main]
:depends-on #{:shared}
:init-fn app.main/init}
:main
{:entries [app.main]
:depends-on #{:shared}
:init-fn app.main/init}
:render {:entries [app.render]
:depends-on #{:shared}
:init-fn app.render/init}
:render
{:entries [app.render]
:depends-on #{:shared}
:init-fn app.render/init}
:worker {:entries [app.worker]
:web-worker true
:depends-on #{:shared}}}
:worker
{:entries [app.worker]
:web-worker true
:depends-on #{:shared}}
:thumbnail-renderer
{:entries [app.thumbnail-renderer]
:depends-on #{:shared}
:init-fn app.thumbnail-renderer/init}}
:compiler-options
{:output-feature-set :es2020

View file

@ -84,7 +84,6 @@
(def default-theme "default")
(def default-language "en")
(def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js"))
(def translations (obj/get global "penpotTranslations"))
(def themes (obj/get global "penpotThemes"))
@ -110,7 +109,14 @@
(def public-uri
(atom
(normalize-uri (or (obj/get global "penpotPublicURI")
(.-origin ^js location)))))
(obj/get location "origin")))))
(def thumbnail-renderer-uri
(or (some-> (obj/get global "penpotThumbnailRendererURI") normalize-uri)
(deref public-uri)))
(def worker-uri
(obj/get global "penpotWorkerURI" "/js/worker.js"))
;; --- Helper Functions

View file

@ -15,6 +15,7 @@
[app.main.errors]
[app.main.features :as feat]
[app.main.store :as st]
[app.main.thumbnail-renderer :as tr]
[app.main.ui :as ui]
[app.main.ui.alert]
[app.main.ui.confirm]
@ -80,6 +81,7 @@
(i18n/init! cf/translations)
(theme/init! cf/themes)
(cur/init-styles)
(tr/init!)
(init-ui)
(st/emit! (initialize)))

View file

@ -782,6 +782,15 @@
(->> (rp/cmd! :set-file-shared params)
(rx/ignore))))))
(defn set-file-thumbnail
[file-id thumbnail-uri]
(ptk/reify ::set-file-thumbnail
ptk/UpdateEvent
(update [_ state]
(-> state
(d/update-in-when [:dashboard-files file-id] assoc :thumbnail-uri thumbnail-uri)
(d/update-in-when [:dashboard-recent-files file-id] assoc :thumbnail-uri thumbnail-uri)))))
;; --- EVENT: create-file
(declare file-created)

View file

@ -9,6 +9,7 @@
(:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.text :as txt]
[app.config :as cf]
@ -148,15 +149,13 @@
;; --- LOADER: CUSTOM
(def font-css-template
(def font-face-template
"@font-face {
font-family: '%(family)s';
font-style: %(style)s;
font-weight: %(weight)s;
font-display: block;
src: url(%(woff1-uri)s) format('woff'),
url(%(ttf-uri)s) format('ttf'),
url(%(otf-uri)s) format('otf');
src: url(%(uri)s) format('woff');
}")
(defn- asset-id->uri
@ -165,14 +164,11 @@
(defn generate-custom-font-variant-css
[family variant]
(str/fmt font-css-template
(str/fmt font-face-template
{:family family
:style (:style variant)
:weight (:weight variant)
:woff2-uri (asset-id->uri (::woff2-file-id variant))
:woff1-uri (asset-id->uri (::woff1-file-id variant))
:ttf-uri (asset-id->uri (::ttf-file-id variant))
:otf-uri (asset-id->uri (::otf-file-id variant))}))
:uri (asset-id->uri (::woff1-file-id variant))}))
(defn- generate-custom-font-css
[{:keys [family variants] :as font}]
@ -237,26 +233,19 @@
(-> (obj/get-in js/document ["fonts" "ready"])
(p/then cb)))
(defn get-default-variant [{:keys [variants]}]
(or
(d/seek #(or (= (:id %) "regular") (= (:name %) "regular")) variants)
(first variants)))
(defn get-default-variant
[{:keys [variants]}]
(or (d/seek #(or (= (:id %) "regular")
(= (:name %) "regular")) variants)
(first variants)))
(defn get-variant
[{:keys [variants] :as font} font-variant-id]
(or (d/seek #(= (:id %) font-variant-id) variants)
(get-default-variant font)))
;; Font embedding functions
;; Template for a CSS font face
(def font-face-template "
/* latin */
@font-face {
font-family: '%(family)s';
font-style: %(style)s;
font-weight: %(weight)s;
font-display: block;
src: url(%(baseurl)sfonts/%(family)s-%(suffix)s.woff) format('woff');
}
")
(defn get-content-fonts
"Extracts the fonts used by the content of a text shape"
[{font-id :font-id children :children :as content}]
@ -267,38 +256,52 @@
children-font (->> children (mapv get-content-fonts))]
(reduce set/union (conj children-font current-font))))
(defn fetch-font-css
"Given a font and the variant-id, retrieves the fontface CSS"
[{:keys [font-id font-variant-id]
:or {font-variant-id "regular"}}]
(let [{:keys [backend family variants]} (get @fontsdb font-id)]
(let [{:keys [backend family] :as font} (get @fontsdb font-id)]
(cond
(nil? font)
(rx/empty)
(= :google backend)
(let [variant (d/seek #(= (:id %) font-variant-id) variants)]
(let [variant (get-variant font font-variant-id)]
(-> (generate-gfonts-url
{:family family
:variants [variant]})
(http/fetch-text)))
(= :custom backend)
(let [variant (d/seek #(= (:id %) font-variant-id) variants)
(let [variant (get-variant font font-variant-id)
result (generate-custom-font-variant-css family variant)]
(p/resolved result))
(rx/of result))
:else
(let [{:keys [weight style suffix] :as variant}
(d/seek #(= (:id %) font-variant-id) variants)
font-data {:baseurl (str @cf/public-uri)
:family family
:style style
:suffix (or suffix font-variant-id)
:weight weight}]
(rx/of (str/fmt font-face-template font-data))))))
(let [{:keys [weight style suffix]} (get-variant font font-variant-id)
suffix (or suffix font-variant-id)
params {:uri (dm/str @cf/public-uri "fonts/" family "-" suffix ".woff")
:family family
:style style
:weight weight}]
(rx/of (str/fmt font-face-template params))))))
(defn extract-fontface-urls
"Parses the CSS and retrieves the font urls"
[^string css]
(->> (re-seq #"url\(([^)]+)\)" css)
(mapv second)))
(defn render-font-styles
[ids]
(->> (rx/from ids)
(rx/mapcat (fn [font-id]
(let [font (get @fontsdb font-id)]
(->> (:variants font [])
(map :id)
(map (fn [variant-id]
{:font-id font-id
:font-variant-id variant-id}))))))
(rx/mapcat fetch-font-css)
(rx/reduce (fn [acc css] (dm/str acc "\n" css)) "")))

View file

@ -50,6 +50,11 @@
:upsert-file-object-thumbnail {:query-params [:file-id :object-id]}
:create-file-object-thumbnail {:query-params [:file-id :object-id]
:form-data? true}
:create-file-thumbnail
{:query-params [:file-id :revn]
:form-data? true}
:export-binfile {:response-type :blob}
:import-binfile {:form-data? true}
:retrieve-list-of-builtin-templates {:query-params :all}

View file

@ -0,0 +1,93 @@
;; 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
(ns app.main.thumbnail-renderer
"A main entry point for the thumbnail renderer API interface.
This ns is responsible to provide an API for create thumbnail
renderer iframes and interact with them using asyncrhonous
messages."
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.dom :as dom]
[beicon.core :as rx]
[cuerdas.core :as str]))
(defonce ready? false)
(defonce queue #js [])
(defonce instance nil)
(defonce msgbus (rx/subject))
(defonce origin
(dm/str (assoc cf/thumbnail-renderer-uri :path "/thumbnail-renderer.html")))
(declare send-message!)
(defn- process-queued-messages!
[]
(loop [message (.shift ^js queue)]
(when (some? message)
(send-message! message)
(recur (.shift ^js queue)))))
(defn- on-message
"Handles a message from the thumbnail renderer."
[event]
(let [evorigin (unchecked-get event "origin")
evdata (unchecked-get event "data")]
(when (and (object? evdata) (str/starts-with? origin evorigin))
(let [scope (unchecked-get evdata "scope")
type (unchecked-get evdata "type")]
(when (= "penpot/thumbnail-renderer" scope)
(when (= type "ready")
(set! ready? true)
(process-queued-messages!))
(rx/push! msgbus evdata))))))
(defn- send-message!
"Sends a message to the thumbnail renderer."
[message]
(let [window (.-contentWindow ^js instance)]
(.postMessage ^js window message origin)))
(defn- queue-message!
"Queues a message to be sent to the thumbnail renderer when it's ready."
[message]
(.push ^js queue message))
(defn render
"Renders a thumbnail."
[{:keys [data styles] :as params}]
(let [id (dm/str (uuid/next))
payload #js {:data data :styles styles}
message #js {:id id
:scope "penpot/thumbnail-renderer"
:payload payload}]
(if ^boolean ready?
(send-message! message)
(queue-message! message))
(->> msgbus
(rx/filter #(= id (unchecked-get % "id")))
(rx/mapcat (fn [msg]
(case (unchecked-get msg "type")
"success" (rx/of (unchecked-get msg "payload"))
"failure" (rx/throw (unchecked-get msg "payload")))))
(rx/take 1))))
(defn init!
"Initializes the thumbnail renderer."
[]
(let [iframe (dom/create-element "iframe")]
(dom/set-attribute! iframe "src" origin)
(dom/set-attribute! iframe "hidden" true)
(dom/append-child! js/document.body iframe)
(set! instance iframe)
(.addEventListener js/window "message" on-message)))

View file

@ -16,7 +16,9 @@
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.render :refer [component-svg]]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.thumbnail-renderer :as thr]
[app.main.ui.components.color-bullet :as bc]
[app.main.ui.dashboard.file-menu :refer [file-menu]]
[app.main.ui.dashboard.import :refer [use-import-file]]
@ -30,7 +32,6 @@
[app.util.dom.dnd :as dnd]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.perf :as perf]
[app.util.time :as dt]
[app.util.timers :as ts]
[beicon.core :as rx]
@ -41,44 +42,49 @@
;; --- Grid Item Thumbnail
(defn ask-for-thumbnail
(defn- persist-thumbnail
[file-id revn blob]
(let [params {:file-id file-id :revn revn :media blob}]
(->> (rp/cmd! :create-file-thumbnail params)
(rx/map :uri))))
(defn- ask-for-thumbnail
"Creates some hooks to handle the files thumbnails cache"
[file]
[file-id revn]
(let [features (cond-> ffeat/enabled
(features/active-feature? :components-v2)
(conj "components/v2"))]
(wrk/ask! {:cmd :thumbnails/generate-for-file
:revn (:revn file)
:file-id (:id file)
:file-name (:name file)
:features features})))
(->> (wrk/ask! {:cmd :thumbnails/generate-for-file
:revn revn
:file-id file-id
:features features})
(rx/mapcat (fn [{:keys [fonts] :as result}]
(->> (fonts/render-font-styles fonts)
(rx/map (fn [styles]
(assoc result :styles styles))))))
(rx/mapcat thr/render)
(rx/mapcat (partial persist-thumbnail file-id revn)))))
(mf/defc grid-item-thumbnail
{::mf/wrap [mf/memo]}
[{:keys [file] :as props}]
{::mf/wrap-props false}
[{:keys [file-id revn thumbnail-uri background-color]}]
(let [container (mf/use-ref)
bgcolor (dm/get-in file [:data :options :background])
visible? (h/use-visible container :once? true)]
(mf/with-effect [file visible?]
(when visible?
(let [tp (perf/tpoint)]
(->> (ask-for-thumbnail file)
(rx/subscribe-on :af)
(rx/subs (fn [{:keys [data fonts] :as params}]
(run! fonts/ensure-loaded! fonts)
(log/debug :hint "loaded thumbnail"
:file-id (dm/str (:id file))
:file-name (:name file)
:elapsed (str/ffmt "%ms" (tp)))
(when-let [node (mf/ref-val container)]
(dom/set-html! node data))))))))
(mf/with-effect [file-id revn visible? thumbnail-uri]
(when (and visible? (not thumbnail-uri))
(->> (ask-for-thumbnail file-id revn)
(rx/subs (fn [url]
(st/emit! (dd/set-file-thumbnail file-id url)))))))
[:div.grid-item-th
{:style {:background-color bgcolor}
{:style {:background-color background-color}
:ref container}
i/loader-pencil]))
(when visible?
(if thumbnail-uri
[:img.grid-item-thumbnail-image {:src thumbnail-uri}]
i/loader-pencil))]))
;; --- Grid Item Library
@ -312,7 +318,12 @@
[:div.overlay]
(if library-view?
[:& grid-item-library {:file file}]
[:& grid-item-thumbnail {:file file}])
[:& grid-item-thumbnail
{:file-id (:id file)
:revn (:revn file)
:thumbnail-uri (:thumbnail-uri file)
:background-color (dm/get-in file [:data :options :background])}])
(when (and (:is-shared file) (not library-view?))
[:div.item-badge i/library])
[:div.info-wrapper

View file

@ -0,0 +1,245 @@
;; 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
(ns app.thumbnail-renderer
"A main entry point for the thumbnail renderer process that is
executed on a separated iframe."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.config :as cf]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.object :as obj]
[app.util.webapi :as wapi]
[beicon.core :as rx]
[cuerdas.core :as str]))
(log/set-level! :trace)
(declare send-success!)
(declare send-failure!)
(defonce parent-origin
(dm/str @cf/public-uri))
(defn- get-document-element
[^js svg]
(.-documentElement svg))
(defn- create-image
[uri]
(rx/create
(fn [subs]
(let [image (js/Image.)]
(obj/set! image "onload" #(do
(rx/push! subs image)
(rx/end! subs)))
(obj/set! image "crossOrigin" "anonymous")
(obj/set! image "onerror" #(rx/error! subs %))
(obj/set! image "onabort" #(rx/error! subs (ex/error :type :internal
:code :abort
:hint "operation aborted")))
(obj/set! image "src" uri)
(fn []
(obj/set! image "src" "")
(obj/set! image "onload" nil)
(obj/set! image "onerror" nil)
(obj/set! image "onabort" nil))))))
(defn- svg-get-size
[svg max]
(let [doc (get-document-element svg)
vbox (dom/get-attribute doc "viewBox")]
(when (string? vbox)
(let [[_ _ width height] (str/split vbox #"\s+")
width (d/parse-integer width 0)
height (d/parse-integer height 0)
ratio (/ width height)]
(if (> width height)
[max (* max (/ 1 ratio))]
[(* max ratio) max])))))
(defn- svg-has-intrinsic-size?
"Returns true if the SVG has an intrinsic size."
[svg]
(let [doc (get-document-element svg)
width (dom/get-attribute doc "width")
height (dom/get-attribute doc "height")]
(d/num? width height)))
(defn- svg-set-intrinsic-size!
"Sets the intrinsic size of an SVG to the given max size."
[^js svg max]
(when-not (svg-has-intrinsic-size? svg)
(let [doc (get-document-element svg)
[w h] (svg-get-size svg max)]
(dom/set-attribute! doc "width" (dm/str w))
(dom/set-attribute! doc "height" (dm/str h))))
svg)
(defn- fetch-as-data-uri
"Fetches a URL as a Data URI."
[uri]
(->> (http/send! {:uri uri
:response-type :blob
:method :get
:mode :cors
:omit-default-headers true})
(rx/map :body)
(rx/mapcat wapi/read-file-as-data-url)))
(defn- svg-update-image!
"Updates an image in an SVG to a Data URI."
[image]
(when-let [href (dom/get-attribute image "href")]
(->> (fetch-as-data-uri href)
(rx/map (fn [url]
(dom/set-attribute! image "href" url)
image)))))
(defn- svg-resolve-images!
"Resolves all images in an SVG to Data URIs."
[svg]
(->> (rx/from (dom/query-all svg "image"))
(rx/mapcat svg-update-image!)
(rx/ignore)))
(defn- svg-add-style!
"Adds a <style> node to an SVG."
[svg styles]
(let [doc (get-document-element svg)
style (dom/create-element svg "http://www.w3.org/2000/svg" "style")]
(dom/append-child! style (dom/create-text svg styles))
(dom/append-child! doc style)))
(defn- svg-resolve-styles!
"Resolves all fonts in an SVG to Data URIs."
[svg styles]
(->> (rx/from (re-seq #"url\((https?://[^)]+)\)" styles))
(rx/map second)
(rx/mapcat (fn [url]
(->> (fetch-as-data-uri url)
(rx/map (fn [uri] [url uri])))))
(rx/reduce (fn [styles [url uri]]
(str/replace styles url uri))
styles)
(rx/tap (partial svg-add-style! svg))
(rx/ignore)))
(defn- svg-resolve-all!
"Resolves all images and fonts in an SVG to Data URIs."
[svg styles]
(rx/concat
(svg-resolve-images! svg)
(svg-resolve-styles! svg styles)
(rx/of svg)))
(defn- svg-parse
"Parses an SVG string into an SVG DOM."
[data]
(let [parser (js/DOMParser.)]
(.parseFromString ^js parser data "image/svg+xml")))
(defn- svg-stringify
"Converts an SVG to a string."
[svg]
(let [doc (get-document-element svg)
serializer (js/XMLSerializer.)]
(.serializeToString ^js serializer doc)))
(defn- svg-prepare
"Prepares an SVG for rendering (resolves images to Data URIs and adds intrinsic size)."
[data styles]
(let [svg (svg-parse data)]
(->> (svg-resolve-all! svg styles)
(rx/map #(svg-set-intrinsic-size! % 300))
(rx/map svg-stringify))))
(defn- bitmap->blob
"Converts an ImageBitmap to a Blob."
[bitmap]
(rx/create
(fn [subs]
(let [canvas (dom/create-element "canvas")]
(set! (.-width ^js canvas) (.-width ^js bitmap))
(set! (.-height ^js canvas) (.-height ^js bitmap))
(let [context (.getContext ^js canvas "bitmaprenderer")]
(.transferFromImageBitmap ^js context bitmap)
(.toBlob canvas #(do (rx/push! subs %)
(rx/end! subs))))
(constantly nil)))))
(defn- render
"Renders a thumbnail using it's SVG and returns an ArrayBuffer of the image."
[payload]
(let [data (unchecked-get payload "data")
styles (unchecked-get payload "styles")]
(->> (svg-prepare data styles)
(rx/map #(wapi/create-blob % "image/svg+xml"))
(rx/map wapi/create-uri)
(rx/mapcat (fn [uri]
(->> (create-image uri)
(rx/mapcat wapi/create-image-bitmap)
(rx/tap #(wapi/revoke-uri uri)))))
(rx/mapcat bitmap->blob))))
(defn- on-message
"Handles messages from the main thread."
[event]
(let [evdata (unchecked-get event "data")
evorigin (unchecked-get event "origin")]
(when (str/starts-with? parent-origin evorigin)
(let [id (unchecked-get evdata "id")
payload (unchecked-get evdata "payload")
scope (unchecked-get evdata "scope")]
(when (and (some? payload)
(= scope "penpot/thumbnail-renderer"))
(->> (render payload)
(rx/subs (partial send-success! id)
(partial send-failure! id))))))))
(defn- listen
"Initializes the listener for messages from the main thread."
[]
(.addEventListener js/window "message" on-message))
(defn- send-answer!
"Sends an answer message."
[id type payload]
(let [message #js {:id id
:type type
:scope "penpot/thumbnail-renderer"
:payload payload}]
(when-not (identical? js/window js/parent)
(.postMessage js/parent message parent-origin))))
(defn- send-success!
"Sends a success message."
[id payload]
(send-answer! id "success" payload))
(defn- send-failure!
"Sends a failure message."
[id payload]
(send-answer! id "failure" payload))
(defn- send-ready!
"Sends a ready message."
[]
(send-answer! nil "ready" nil))
;; Initializes worker
(defn ^:export init
[]
(listen)
(send-ready!)
(log/info :hint "initialized" :public-uri @cf/public-uri))

View file

@ -254,7 +254,15 @@
([tag]
(.createElement globals/document tag))
([ns tag]
(.createElementNS globals/document ns tag)))
(.createElementNS globals/document ns tag))
([document ns tag]
(.createElementNS document ns tag)))
(defn create-text
([^js text]
(create-text globals/document text))
([document ^js text]
(.createTextNode document text)))
(defn set-html!
[^js el html]

View file

@ -130,6 +130,10 @@
(map #(.item file-list %))
(filter #(str/starts-with? (.-type %) "image/"))))))
(defn create-image-bitmap
[image]
(js/createImageBitmap image))
(defn request-fullscreen
[el]
(cond

View file

@ -18,7 +18,6 @@
[app.util.webapi :as wapi]
[app.worker.impl :as impl]
[beicon.core :as rx]
[debug :refer [debug?]]
[promesa.core :as p]
[rumext.v2 :as mf]))
@ -45,15 +44,6 @@
:http-status status
:http-body body})))
(defn- not-found?
[{:keys [type]}]
(= :not-found type))
(defn- body-too-large?
[{:keys [type code]}]
(and (= :validation type)
(= :request-body-too-large code)))
(defn- request-data-for-thumbnail
[file-id revn features]
(let [path "api/rpc/command/get-file-data-for-thumbnail"
@ -69,70 +59,25 @@
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response))))
(defn- request-thumbnail
[file-id revn]
(let [path "api/rpc/command/get-file-thumbnail"
params {:file-id file-id
:revn revn}
request {:method :get
:uri (u/join @cf/public-uri path)
:credentials "include"
:query params}]
(->> (http/send! request)
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response))))
(defn- render-thumbnail
[{:keys [page file-id revn] :as params}]
(let [objects (:objects page)
frame (some->> page :thumbnail-frame-id (get objects))
element (if frame
(mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true})
(mf/element render/page-svg #js {:data page :thumbnails? true}))
data (rds/renderToStaticMarkup element)]
(let [objects (:objects page)
frame (some->> page :thumbnail-frame-id (get objects))
element (if frame
(mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true})
(mf/element render/page-svg #js {:data page :thumbnails? true :render-embed? true}))
data (rds/renderToStaticMarkup element)
font-ids (into @fonts/loaded (map first) @fonts/loading)]
{:data data
:fonts (into @fonts/loaded (map first) @fonts/loading)
:fonts font-ids
:file-id file-id
:revn revn}))
(defn- persist-thumbnail
[{:keys [file-id data revn fonts]}]
(let [path "api/rpc/command/upsert-file-thumbnail"
params {:file-id file-id
:revn revn
:props {:fonts fonts}
:data data}
request {:method :post
:uri (u/join @cf/public-uri path)
:credentials "include"
:body (http/transit-data params)}]
(->> (http/send! request)
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response)
(rx/catch body-too-large? (constantly (rx/of nil)))
(rx/map (constantly params)))))
(defmethod impl/handler :thumbnails/generate-for-file
[{:keys [file-id revn features] :as message} _]
(letfn [(on-result [{:keys [data props]}]
{:data data
:fonts (:fonts props)})
(on-cache-miss [_]
(log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "miss")
(->> (request-data-for-thumbnail file-id revn features)
(rx/map render-thumbnail)
(rx/mapcat persist-thumbnail)))]
(if (debug? :disable-thumbnail-cache)
(->> (request-data-for-thumbnail file-id revn features)
(rx/map render-thumbnail))
(->> (request-thumbnail file-id revn)
(rx/tap (fn [_]
(log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "hit")))
(rx/catch not-found? on-cache-miss)
(rx/map on-result)))))
(->> (request-data-for-thumbnail file-id revn features)
(rx/map render-thumbnail)))
(defmethod impl/handler :thumbnails/render-offscreen-canvas
[_ ibpm]