From c55c23c6dd3a77ee4718086e04f71e75d211b3a8 Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Tue, 10 Feb 2026 23:31:32 +0100 Subject: [PATCH 1/2] :sparkles: Add option to download user uploaded custom fonts Allow users download any of the manually installed fonts. When there is more than one font in the family download as a .zip. Signed-off-by: Dalai Felinto --- CHANGES.md | 2 + backend/src/app/rpc/commands/fonts.clj | 96 ++++++++++++++++++- frontend/src/app/main/ui/dashboard/fonts.cljs | 38 +++++++- frontend/translations/en.po | 3 + frontend/translations/es.po | 3 + 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f4fd816eba..e15dd094f7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,8 @@ ### :heart: Community contributions (Thank you!) +- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320) + ### :sparkles: New features & Enhancements - Access to design tokens in Penpot Plugins [Taiga #8990](https://tree.taiga.io/project/penpot/us/8990) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 03c66a968f..f646342ccc 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -9,6 +9,7 @@ [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.media :as cmedia] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -31,10 +32,13 @@ [app.util.services :as sv] [datoteka.io :as io]) (:import + java.io.ByteArrayOutputStream java.io.InputStream java.io.OutputStream java.io.SequenceInputStream - java.util.Collections)) + java.util.Collections + java.util.zip.ZipEntry + java.util.zip.ZipOutputStream)) (set! *warn-on-reflection* true) @@ -296,3 +300,93 @@ (rph/with-meta (rph/wrap) {::audit/props {:font-family (:font-family variant) :font-id (:font-id variant)}}))) + +;; --- DOWNLOAD FONT + +(def ^:private schema:download-font + [:map {:title "download-font"} + [:id ::sm/uuid]]) + +(sv/defmethod ::download-font + {::doc/added "1.18" + ::sm/params schema:download-font} + [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] + (dm/with-open [conn (db/open pool)] + (let [variant (db/get conn :team-font-variant + {:id id + :deleted-at nil})] + (when-not variant + (ex/raise :type :not-found + :code :object-not-found)) + + (teams/check-read-permissions! conn profile-id (:team-id variant)) + + ;; Try to get the best available font format (prefer TTF for broader compatibility). + (let [file-id (or (:ttf-file-id variant) + (:otf-file-id variant) + (:woff2-file-id variant) + (:woff1-file-id variant))] + (when-not file-id + (ex/raise :type :not-found + :code :font-file-not-found)) + + (let [font-obj (sto/get-object storage file-id) + font-bytes (sto/get-object-bytes storage font-obj)] + (when-not font-obj + (ex/raise :type :not-found + :code :font-file-not-found)) + + ;; Return base64-encoded string and mime-type for transit serialization. + (let [data (.encodeToString (java.util.Base64/getEncoder) font-bytes) + mtype (or (:content-type font-obj) (-> font-obj meta :content-type) "application/octet-stream")] + {:data data :mtype mtype})))))) + +(def ^:private schema:download-font-family + [:map {:title "download-font-family"} + [:font-id ::sm/uuid]]) + +(sv/defmethod ::download-font-family + {::doc/added "1.18" + ::sm/params schema:download-font-family} + [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}] + (dm/with-open [conn (db/open pool)] + (let [variants (db/query conn :team-font-variant + {:font-id font-id + :deleted-at nil})] + (when-not (seq variants) + (ex/raise :type :not-found + :code :object-not-found)) + + (teams/check-read-permissions! conn profile-id (:team-id (first variants))) + + (let [entries + (->> variants + (map (fn [v] + (let [file-id (or (:ttf-file-id v) + (:otf-file-id v) + (:woff2-file-id v) + (:woff1-file-id v))] + (when-not file-id + (ex/raise :type :not-found :code :font-file-not-found)) + + (let [sobj (sto/get-object storage file-id) + bytes (sto/get-object-bytes storage sobj) + mtype (or (:content-type sobj) (-> sobj meta :content-type) "application/octet-stream") + ext (cmedia/mtype->extension mtype) + name (str (:font-family v) "-" (:font-weight v) + (when-not (= "normal" (:font-style v)) (str "-" (:font-style v))) + (or ext ""))] + {:name name :bytes bytes})))))] + + ;; Build zip in memory. + (let [baos (ByteArrayOutputStream.) + zos (ZipOutputStream. baos)] + (doseq [{:keys [name bytes]} entries] + (let [entry (ZipEntry. name)] + (.putNextEntry zos entry) + (.write zos ^bytes bytes) + (.closeEntry zos))) + (.close zos) + (let [zip-bytes (.toByteArray baos) + data (.encodeToString (java.util.Base64/getEncoder) zip-bytes)] + {:data data :mtype "application/zip"})))))) \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index c1aa638671..7b98b810f8 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -24,6 +24,8 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] + [app.util.timers :as tm] + [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -259,11 +261,14 @@ (mf/defc installed-font-context-menu {::mf/props :obj ::mf/private true} - [{:keys [is-open on-close on-edit on-delete]}] - (let [options (mf/with-memo [on-edit on-delete] + [{:keys [is-open on-close on-edit on-download on-delete]}] + (let [options (mf/with-memo [on-edit on-download on-delete] [{:name (tr "labels.edit") :id "font-edit" :handler on-edit} + {:name (tr "labels.download-simple") + :id "font-download" + :handler on-download} {:name (tr "labels.delete") :id "font-delete" :handler on-delete}])] @@ -345,6 +350,34 @@ (st/emit! (df/delete-font font-id)))}] (st/emit! (modal/show options))))) + on-download + (mf/use-fn + (mf/deps variants) + (fn [_event] + (let [variant (first variants) + variant-id (:id variant) + multiple? (> (count variants) 1) + cmd (if multiple? :download-font-family :download-font) + params (if multiple? {:font-id font-id} {:id variant-id})] + (->> (rp/cmd! cmd params) + (rx/subs! (fn [font-data] + ;; font-data is base64-encoded or a map {:data :mtype} + (let [b64 (if (string? font-data) font-data (:data font-data)) + default-mtype "application/octet-stream" + mtype (if (string? font-data) default-mtype (or (:mtype font-data) default-mtype)) + binary-str (js/atob b64) + bytes (js/Uint8Array. + (for [i (range (.-length binary-str))] + (.charCodeAt binary-str i))) + blob (wapi/create-blob bytes mtype) + uri (wapi/create-uri blob) + name (:font-family font)] + (dom/trigger-download-uri name mtype uri) + (tm/schedule-on-idle #(wapi/revoke-uri uri)))) + (fn [error] + (js/console.error "error downloading font" error) + (st/emit! (ntf/error (tr "errors.download-font"))))))))) + on-delete-variant (mf/use-fn (fn [event] @@ -407,6 +440,7 @@ {:on-close on-menu-close :is-open menu-open? :on-delete on-delete-font + :on-download on-download :on-edit on-edit}]]))])) (mf/defc installed-fonts* diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 61a736188f..95e5906dbe 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2354,6 +2354,9 @@ msgstr "Discard" msgid "labels.download" msgstr "Download %s" +msgid "labels.download-simple" +msgstr "Download" + #: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820 msgid "labels.drafts" msgstr "Drafts" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index daa6c2cdae..9e543b7f4f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2325,6 +2325,9 @@ msgstr "Descartar" msgid "labels.download" msgstr "Descargar %s" +msgid "labels.download-simple" +msgstr "Descargar" + #: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820 msgid "labels.drafts" msgstr "Borradores" From fbf1240998f33879737677abd06dbb0528e2acb2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 12 Feb 2026 13:09:52 +0100 Subject: [PATCH 2/2] :zap: Add several optimizations for fonts zip download Mainly prevent hold the whole zip in memory and uses an unified response type, leavin frontend fetching the blob data from the assets/storage subsystem. --- backend/src/app/http/sse.clj | 1 + backend/src/app/media.clj | 2 +- backend/src/app/rpc.clj | 8 +- backend/src/app/rpc/commands/fonts.clj | 139 +++++++++--------- frontend/src/app/main/ui/dashboard/fonts.cljs | 32 ++-- frontend/src/app/util/dom.cljs | 6 +- frontend/src/app/util/http.cljs | 5 + 7 files changed, 102 insertions(+), 91 deletions(-) diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index da5fd4e05a..765f0c894d 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -53,6 +53,7 @@ ::yres/status 200 ::yres/body (yres/stream-body (fn [_ output] + (let [channel (sp/chan :buf buf :xf (keep encode)) listener (events/spawn-listener channel diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index bbb3123e73..7d6bb2a894 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -54,7 +54,7 @@ [:path ::fs/path] [:mtype {:optional true} ::sm/text]]) -(def ^:private check-input +(def check-input (sm/check-fn schema:input)) (defn validate-media-type! diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c5ef53aaf4..487e8eb668 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -73,9 +73,13 @@ (if (nil? result) 204 200)) - headers (cond-> (::http/headers mdata {}) - (yres/stream-body? result) + + headers (::http/headers mdata {}) + headers (cond-> headers + (and (yres/stream-body? result) + (not (contains? headers "content-type"))) (assoc "content-type" "application/octet-stream"))] + {::yres/status status ::yres/headers headers ::yres/body result}))] diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index f646342ccc..b47c6c2e38 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -16,6 +16,7 @@ [app.db :as db] [app.db.sql :as-alias sql] [app.features.logical-deletion :as ldel] + [app.http :as-alias http] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.media :as media] @@ -32,7 +33,6 @@ [app.util.services :as sv] [datoteka.io :as io]) (:import - java.io.ByteArrayOutputStream java.io.InputStream java.io.OutputStream java.io.SequenceInputStream @@ -303,90 +303,95 @@ ;; --- DOWNLOAD FONT +(defn- make-temporal-storage-object + [cfg profile-id content] + (let [storage (sto/resolve cfg) + content (media/check-input content) + hash (sto/calculate-hash (:path content)) + data (-> (sto/content (:path content)) + (sto/wrap-with-hash hash)) + mtype (:mtype content "application/octet-stream") + content {::sto/content data + ::sto/deduplicate? true + ::sto/touched-at (ct/in-future {:minutes 30}) + :profile-id profile-id + :content-type mtype + :bucket "tempfile"}] + + (sto/put-object! storage content))) + +(defn- make-variant-filename + [v mtype] + (str (:font-family v) "-" (:font-weight v) + (when-not (= "normal" (:font-style v)) (str "-" (:font-style v))) + (cmedia/mtype->extension mtype))) + (def ^:private schema:download-font [:map {:title "download-font"} [:id ::sm/uuid]]) (sv/defmethod ::download-font - {::doc/added "1.18" + "Download the font file. Returns a http redirect to the asset resource uri." + {::doc/added "2.15" ::sm/params schema:download-font} [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] - (dm/with-open [conn (db/open pool)] - (let [variant (db/get conn :team-font-variant - {:id id - :deleted-at nil})] - (when-not variant - (ex/raise :type :not-found - :code :object-not-found)) + (let [variant (db/get pool :team-font-variant {:id id})] + (teams/check-read-permissions! pool profile-id (:team-id variant)) - (teams/check-read-permissions! conn profile-id (:team-id variant)) + ;; Try to get the best available font format (prefer TTF for broader compatibility). + (let [media-id (or (:ttf-file-id variant) + (:otf-file-id variant) + (:woff2-file-id variant) + (:woff1-file-id variant)) + sobj (sto/get-object storage media-id) + mtype (-> sobj meta :content-type)] - ;; Try to get the best available font format (prefer TTF for broader compatibility). - (let [file-id (or (:ttf-file-id variant) - (:otf-file-id variant) - (:woff2-file-id variant) - (:woff1-file-id variant))] - (when-not file-id - (ex/raise :type :not-found - :code :font-file-not-found)) - - (let [font-obj (sto/get-object storage file-id) - font-bytes (sto/get-object-bytes storage font-obj)] - (when-not font-obj - (ex/raise :type :not-found - :code :font-file-not-found)) - - ;; Return base64-encoded string and mime-type for transit serialization. - (let [data (.encodeToString (java.util.Base64/getEncoder) font-bytes) - mtype (or (:content-type font-obj) (-> font-obj meta :content-type) "application/octet-stream")] - {:data data :mtype mtype})))))) + {:id (:id sobj) + :uri (files/resolve-public-uri (:id sobj)) + :name (make-variant-filename variant mtype)}))) (def ^:private schema:download-font-family [:map {:title "download-font-family"} [:font-id ::sm/uuid]]) (sv/defmethod ::download-font-family - {::doc/added "1.18" + "Download the entire font family as a zip file. Returns the zip + bytes on the body, without encoding it on transit or json." + {::doc/added "2.15" ::sm/params schema:download-font-family} [{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}] - (dm/with-open [conn (db/open pool)] - (let [variants (db/query conn :team-font-variant - {:font-id font-id - :deleted-at nil})] - (when-not (seq variants) - (ex/raise :type :not-found - :code :object-not-found)) + (let [variants (db/query pool :team-font-variant + {:font-id font-id + :deleted-at nil})] - (teams/check-read-permissions! conn profile-id (:team-id (first variants))) + (when-not (seq variants) + (ex/raise :type :not-found + :code :object-not-found)) - (let [entries - (->> variants - (map (fn [v] - (let [file-id (or (:ttf-file-id v) - (:otf-file-id v) - (:woff2-file-id v) - (:woff1-file-id v))] - (when-not file-id - (ex/raise :type :not-found :code :font-file-not-found)) + (teams/check-read-permissions! pool profile-id (:team-id (first variants))) - (let [sobj (sto/get-object storage file-id) - bytes (sto/get-object-bytes storage sobj) - mtype (or (:content-type sobj) (-> sobj meta :content-type) "application/octet-stream") - ext (cmedia/mtype->extension mtype) - name (str (:font-family v) "-" (:font-weight v) - (when-not (= "normal" (:font-style v)) (str "-" (:font-style v))) - (or ext ""))] - {:name name :bytes bytes})))))] + (let [tempfile (tmp/tempfile :suffix ".zip") + ffamily (-> variants first :font-family)] - ;; Build zip in memory. - (let [baos (ByteArrayOutputStream.) - zos (ZipOutputStream. baos)] - (doseq [{:keys [name bytes]} entries] - (let [entry (ZipEntry. name)] - (.putNextEntry zos entry) - (.write zos ^bytes bytes) - (.closeEntry zos))) - (.close zos) - (let [zip-bytes (.toByteArray baos) - data (.encodeToString (java.util.Base64/getEncoder) zip-bytes)] - {:data data :mtype "application/zip"})))))) \ No newline at end of file + (with-open [^OutputStream output (io/output-stream tempfile) + ^OutputStream output (ZipOutputStream. output)] + (doseq [v variants] + (let [media-id (or (:ttf-file-id v) + (:otf-file-id v) + (:woff2-file-id v) + (:woff1-file-id v)) + sobj (sto/get-object storage media-id) + mtype (-> sobj meta :content-type) + name (make-variant-filename v mtype)] + + (with-open [input (sto/get-object-data storage sobj)] + (.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name)) + (io/copy input output :size (:size sobj)) + (.closeEntry ^ZipOutputStream output))))) + + (let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id + {:mtype "application/zip" + :path tempfile})] + {:id id + :uri (files/resolve-public-uri id) + :name (str ffamily ".zip")})))) diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 7b98b810f8..45c7f101b5 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.dashboard.fonts (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.media :as cm] [app.common.uuid :as uuid] @@ -22,10 +23,9 @@ [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] + [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] - [app.util.timers :as tm] - [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -354,26 +354,18 @@ (mf/use-fn (mf/deps variants) (fn [_event] - (let [variant (first variants) + (let [variant (first variants) variant-id (:id variant) - multiple? (> (count variants) 1) - cmd (if multiple? :download-font-family :download-font) - params (if multiple? {:font-id font-id} {:id variant-id})] + multiple? (> (count variants) 1) + cmd (if multiple? :download-font-family :download-font) + params (if multiple? {:font-id font-id} {:id variant-id})] (->> (rp/cmd! cmd params) - (rx/subs! (fn [font-data] - ;; font-data is base64-encoded or a map {:data :mtype} - (let [b64 (if (string? font-data) font-data (:data font-data)) - default-mtype "application/octet-stream" - mtype (if (string? font-data) default-mtype (or (:mtype font-data) default-mtype)) - binary-str (js/atob b64) - bytes (js/Uint8Array. - (for [i (range (.-length binary-str))] - (.charCodeAt binary-str i))) - blob (wapi/create-blob bytes mtype) - uri (wapi/create-uri blob) - name (:font-family font)] - (dom/trigger-download-uri name mtype uri) - (tm/schedule-on-idle #(wapi/revoke-uri uri)))) + (rx/mapcat (fn [{:keys [name uri]}] + (->> (http/send! {:uri uri :method :get :response-type :blob}) + (rx/map :body) + (rx/map (fn [blob] (d/vec2 name blob)))))) + (rx/subs! (fn [[filename blob]] + (dom/trigger-download filename blob)) (fn [error] (js/console.error "error downloading font" error) (st/emit! (ntf/error (tr "errors.download-font"))))))))) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index ffa2b8f361..5638b7bd94 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -748,7 +748,11 @@ (defn trigger-download [filename blob] - (trigger-download-uri filename (.-type ^js blob) (wapi/create-uri blob))) + (let [uri (wapi/create-uri blob)] + (try + (trigger-download-uri filename (.-type ^js blob) uri) + (finally + (wapi/revoke-uri uri))))) (defn event "Create an instance of DOM Event" diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index bf35ce96fd..3a091e18e1 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -190,6 +190,11 @@ [{:keys [status]}] (<= 400 status 499)) +(defn blob? + [^js v] + (when (some? v) + (instance? js/Blob v))) + (defn as-promise [observable] (p/create