diff --git a/CHANGES.md b/CHANGES.md index a1fc4eb47..6c7ce28b7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,7 @@ - Allow nested asset groups [Taiga #1716](https://tree.taiga.io/project/penpot/us/1716). - Allow to ungroup assets [Taiga #1719](https://tree.taiga.io/project/penpot/us/1719). - Allow to rename assets groups [Taiga #1721](https://tree.taiga.io/project/penpot/us/1721). +- Export elements to PDF [Taiga #519](https://tree.taiga.io/project/penpot/us/519). - Memorize collapse state of assets in panel [Taiga #1718](https://tree.taiga.io/project/penpot/us/1718). - Headers button sets and menus review [Taiga #1663](https://tree.taiga.io/project/penpot/us/1663). - Preserve components if possible, when pasted into a different file [Taiga #1063](https://tree.taiga.io/project/penpot/issue/1063). diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 90f7cd00b..9df73c066 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -71,6 +71,20 @@ :type (name type) :omitBackground omit-background?}))) +(defn pdf + ([page] (pdf page nil)) + ([page {:keys [viewport omit-background? prefer-css-page-size?] + :or {viewport {} + omit-background? true + prefer-css-page-size? true}}] + (let [viewport (d/merge default-viewport viewport)] + (.pdf ^js page #js {:width (:width viewport) + :height (:height viewport) + :scale (:scale viewport) + :omitBackground omit-background? + :printBackground (not omit-background?) + :preferCSSPageSize prefer-css-page-size?})))) + (defn eval! [frame f] (.evaluate ^js frame f)) diff --git a/exporter/src/app/http/export.cljs b/exporter/src/app/http/export.cljs index 8be2f5470..e8b56b666 100644 --- a/exporter/src/app/http/export.cljs +++ b/exporter/src/app/http/export.cljs @@ -10,6 +10,7 @@ [app.common.spec :as us] [app.renderer.bitmap :as rb] [app.renderer.svg :as rs] + [app.renderer.pdf :as rp] [app.zipfile :as zip] [cljs.spec.alpha :as s] [cuerdas.core :as str] @@ -89,7 +90,8 @@ (case (:type params) :png (rb/render params) :jpeg (rb/render params) - :svg (rs/render params))) + :svg (rs/render params) + :pdf (rp/render params))) (defn- find-filename-candidate [params used] @@ -101,7 +103,8 @@ (case (:type params) :png ".png" :jpeg ".jpg" - :svg ".svg"))] + :svg ".svg" + :pdf ".pdf"))] (if (contains? used candidate) (recur (inc index)) candidate)))) diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs new file mode 100644 index 000000000..56efd4e46 --- /dev/null +++ b/exporter/src/app/renderer/pdf.cljs @@ -0,0 +1,79 @@ +;; 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) UXBOX Labs SL + +(ns app.renderer.pdf + "A pdf renderer." + (:require + [app.browser :as bw] + [app.common.exceptions :as ex :include-macros true] + [app.common.spec :as us] + [app.config :as cf] + [cljs.spec.alpha :as s] + [lambdaisland.uri :as u] + [lambdaisland.glogi :as log] + [promesa.core :as p])) + +(defn create-cookie + [uri token] + (let [domain (str (:host uri) + (when (:port uri) + (str ":" (:port uri))))] + {:domain domain + :key "auth-token" + :value token})) + +(defn pdf-from-object + [browser {:keys [file-id page-id object-id token scale type]}] + (letfn [(handle [page] + (let [path (str "/render-object/" file-id "/" page-id "/" object-id) + uri (-> (u/uri (cf/get :public-uri)) + (assoc :path "/") + (assoc :fragment path)) + cookie (create-cookie uri token)] + (pdf-from page (str uri) cookie))) + + (pdf-from [page uri cookie] + (log/info :uri uri) + (let [options {:cookie cookie}] + (p/do! + (bw/configure-page! page options) + (bw/navigate! page uri) + (bw/wait-for page "#screenshot") + (bw/pdf page))))] + + (bw/exec! browser handle))) + +(s/def ::name ::us/string) +(s/def ::suffix ::us/string) +(s/def ::page-id ::us/uuid) +(s/def ::file-id ::us/uuid) +(s/def ::object-id ::us/uuid) +(s/def ::scale ::us/number) +(s/def ::token ::us/string) +(s/def ::filename ::us/string) + +(s/def ::render-params + (s/keys :req-un [::name ::suffix ::object-id ::page-id ::scale ::token ::file-id] + :opt-un [::filename])) + +(defn render + [params] + (us/assert ::render-params params) + (let [browser @bw/instance] + (when-not browser + (ex/raise :type :internal + :code :browser-not-ready + :hint "browser cluster is not initialized yet")) + + (p/let [content (pdf-from-object browser params)] + {:content content + :filename (or (:filename params) + (str (:name params) + (:suffix params "") + ".pdf")) + :length (alength content) + :mime-type "application/pdf"}))) + diff --git a/frontend/resources/styles/main/layouts/main-layout.scss b/frontend/resources/styles/main/layouts/main-layout.scss index cb4b1c7b2..bc4ffcb33 100644 --- a/frontend/resources/styles/main/layouts/main-layout.scss +++ b/frontend/resources/styles/main/layouts/main-layout.scss @@ -44,3 +44,7 @@ } } +#screenshot { + display: flex; + flex-direction: column; +} diff --git a/frontend/src/app/main/ui/render.cljs b/frontend/src/app/main/ui/render.cljs index 64fd56ec2..1a7ca192f 100644 --- a/frontend/src/app/main/ui/render.cljs +++ b/frontend/src/app/main/ui/render.cljs @@ -9,6 +9,7 @@ [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] + [app.common.math :as mth] [app.common.pages :as cp] [app.common.uuid :as uuid] [app.main.data.fonts :as df] @@ -18,6 +19,7 @@ [app.main.ui.shapes.embed :as embed] [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.shape :refer [shape-container]] + [app.util.dom :as dom] [beicon.core :as rx] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -68,6 +70,11 @@ #(exports/shape-wrapper-factory objects)) ] + (mf/use-effect + (mf/deps width height) + #(dom/set-page-style {:size (str (mth/round width) "px " + (mth/round height) "px")})) + [:& (mf/provider embed/context) {:value true} [:svg {:id "screenshot" :view-box vbox diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs index ff24325fc..7e65db893 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs @@ -36,6 +36,11 @@ (not (empty (:suffix (first exports))))) (str (:suffix (first exports)))) + scale-enabled? + (mf/use-callback + (fn [export] + (#{:png :jpeg} (:type export)))) + on-download (mf/use-callback (mf/deps shape) @@ -111,15 +116,16 @@ (for [[index export] (d/enumerate exports)] [:div.element-set-options-group {:key index} - [:select.input-select {:on-change (partial on-scale-change index) - :value (:scale export)} - [:option {:value "0.5"} "0.5x"] - [:option {:value "0.75"} "0.75x"] - [:option {:value "1"} "1x"] - [:option {:value "1.5"} "1.5x"] - [:option {:value "2"} "2x"] - [:option {:value "4"} "4x"] - [:option {:value "6"} "6x"]] + (when (scale-enabled? export) + [:select.input-select {:on-change (partial on-scale-change index) + :value (:scale export)} + [:option {:value "0.5"} "0.5x"] + [:option {:value "0.75"} "0.75x"] + [:option {:value "1"} "1x"] + [:option {:value "1.5"} "1.5x"] + [:option {:value "2"} "2x"] + [:option {:value "4"} "4x"] + [:option {:value "6"} "6x"]]) [:input.input-text {:value (:suffix export) :placeholder (tr "workspace.options.export.suffix") :on-change (partial on-suffix-change index)}] @@ -127,7 +133,8 @@ :on-change (partial on-type-change index)} [:option {:value "png"} "PNG"] [:option {:value "jpeg"} "JPEG"] - [:option {:value "svg"} "SVG"]] + [:option {:value "svg"} "SVG"] + [:option {:value "pdf"} "PDF"]] [:div.delete-icon {:on-click (partial delete-export index)} i/minus]]) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index ccdf6be61..b14de52b3 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -6,13 +6,13 @@ (ns app.util.dom (:require - [app.common.exceptions :as ex] - [app.common.geom.point :as gpt] - [app.util.globals :as globals] - [app.util.object :as obj] - [cuerdas.core :as str] - [goog.dom :as dom] - [promesa.core :as p])) + [app.common.exceptions :as ex] + [app.common.geom.point :as gpt] + [app.util.globals :as globals] + [app.util.object :as obj] + [cuerdas.core :as str] + [goog.dom :as dom] + [promesa.core :as p])) ;; --- Deprecated methods @@ -45,6 +45,18 @@ [title] (set! (.-title globals/document) title)) +(defn set-page-style + [style] + (let [head (first (.getElementsByTagName ^js globals/document "head")) + style-str (str/join "\n" + (map (fn [[k v]] + (str (name k) ": " v ";")) + style))] + (.insertAdjacentHTML head "beforeend" + (str "")))) + (defn get-element-by-class ([classname] (dom/getElementByClass classname))