From 0f4c8a7da80ae1bbd3ba5b571ccb71696598c2f6 Mon Sep 17 00:00:00 2001 From: Dalai Felinto Date: Tue, 10 Feb 2026 23:31:32 +0100 Subject: [PATCH] :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 fc36ed5c03..7fc560c16f 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 fea252fdc4..743fffbc8c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2343,6 +2343,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:726 msgid "labels.drafts" msgstr "Drafts" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index a06b5ee301..96f6e2afae 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2314,6 +2314,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:726 msgid "labels.drafts" msgstr "Borradores"