diff --git a/CHANGES.md b/CHANGES.md index af52bb58de..b7ea2e3d6d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -34,6 +34,12 @@ - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) +## 2.13.1 + +### :bug: Bugs fixed + +- Fix PDF Exporter outputs empty page when board has A4 format [Taiga #13181](https://tree.taiga.io/project/penpot/issue/13181) + ## 2.13.0 ### :heart: Community contributions (Thank you!) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index de1a90fe20..bbb3123e73 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -35,8 +35,7 @@ javax.xml.parsers.SAXParserFactory org.apache.commons.io.IOUtils org.im4java.core.ConvertCmd - org.im4java.core.IMOperation - org.im4java.core.Info)) + org.im4java.core.IMOperation)) (def default-max-file-size (* 1024 1024 10)) ; 10 MiB @@ -224,17 +223,18 @@ ;; If we are processing an animated gif we use the first frame with -scene 0 (let [dim-result (sh/sh "identify" "-format" "%w %h\n" path) orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)] - (if (and (= 0 (:exit dim-result)) - (= 0 (:exit orient-result))) + (when (= 0 (:exit dim-result)) (let [[w h] (-> (:out dim-result) str/trim (clojure.string/split #"\s+") (->> (mapv #(Integer/parseInt %)))) - orientation (-> orient-result :out str/trim)] - (case orientation - ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees - {:width w :height h})) ; Normal or unknown orientation - nil))) + orientation-exit (:exit orient-result) + orientation (-> orient-result :out str/trim)] + (if (= 0 orientation-exit) + (case orientation + ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees + {:width w :height h}) ; Normal or unknown orientation + {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is (defmethod process :info [{:keys [input] :as params}] @@ -247,26 +247,37 @@ :hint "uploaded svg does not provides dimensions")) (merge input info {:ts (ct/now) :size (fs/size path)})) - (let [instance (Info. (str path)) - mtype' (.getProperty instance "Mime type")] + (let [path-str (str path) + identify-res (sh/sh "identify" "-format" "image/%[magick]\n" path-str) + ;; identify prints one line per frame (animated GIFs, etc.); we take the first one + mtype' (if (zero? (:exit identify-res)) + (-> identify-res + :out + str/trim + (str/split #"\s+" 2) + first + str/lower) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")) + {:keys [width height]} + (or (get-dimensions-with-orientation path-str) + (do + (l/warn "Failed to read image dimensions with orientation" {:path path}) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")))] (when (and (string? mtype) - (not= mtype mtype')) + (not= (str/lower mtype) mtype')) (ex/raise :type :validation :code :media-type-mismatch :hint (str "Seems like you are uploading a file whose content does not match the extension." "Expected: " mtype ". Got: " mtype'))) - (let [{:keys [width height]} - (or (get-dimensions-with-orientation (str path)) - (do - (l/warn "Failed to read image dimensions with orientation; falling back to im4java" - {:path path}) - {:width (.getPageWidth instance) - :height (.getPageHeight instance)}))] - (assoc input - :width width - :height height - :size (fs/size path) - :ts (ct/now))))))) + (assoc input + :width width + :height height + :size (fs/size path) + :ts (ct/now)))))) (defmethod process-error org.im4java.core.InfoException [error] diff --git a/docker/imagemagick/Dockerfile b/docker/imagemagick/Dockerfile index c13221d244..d06b0d2d3a 100644 --- a/docker/imagemagick/Dockerfile +++ b/docker/imagemagick/Dockerfile @@ -24,6 +24,7 @@ RUN set -e; \ libltdl-dev \ liblzma-dev \ libopenexr-dev \ + libxml2-dev \ libpng-dev \ librsvg2-dev \ libtiff-dev \ @@ -52,6 +53,7 @@ RUN set -e; \ libfftw3-dev \ libheif-dev \ libjpeg-dev \ + libxml2-dev \ liblcms2-dev \ libltdl-dev \ liblzma-dev \ @@ -77,6 +79,7 @@ RUN set -e; \ libopenjp2-7 \ libpng16-16 \ librsvg2-2 \ + libxml2 \ libtiff6 \ libwebp7 \ libwebpdemux2 \ diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index a651415f4e..9d5500ecc8 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -125,7 +125,7 @@ RUN set -ex; \ COPY --from=build /opt/jre /opt/jre COPY --from=build /opt/node /opt/node -COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick ARG BUNDLE_PATH="./bundle-backend/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/backend/ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index dbc2b3b8b6..28cc80c807 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -107,7 +107,7 @@ RUN set -eux; \ ARG BUNDLE_PATH="./bundle-exporter/" COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/ -COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick +COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick WORKDIR /opt/penpot/exporter USER penpot:penpot diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index c7558184c4..25bcfc036b 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -38,6 +38,24 @@ (assoc :path "/render.html") (assoc :query (u/map->query-string params))))) + (sync-page-size! [dom] + (bw/eval! dom + (fn [elem] + ;; IMPORTANT: No CLJS runtime allowed. Use only JS + ;; primitives. This runs in a context without access to + ;; cljs.core. Avoid any functions that transpile to + ;; cljs.core/* calls, as they will break in the browser + ;; runtime. + + (let [width (.getAttribute ^js elem "width") + height (.getAttribute ^js elem "height") + style-node (let [node (.createElement js/document "style")] + (.appendChild (.-head js/document) node) + node)] + (set! (.-textContent style-node) + (dm/str "@page { size: " width "px " height "px; margin: 0; }\n" + "html, body, #app { margin: 0; padding: 0; width: " width "px; height: " height "px; overflow: visible; }")))))) + (render-object [page base-uri {:keys [id] :as object}] (p/let [uri (prepare-uri base-uri id) path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))] @@ -45,6 +63,7 @@ (bw/nav! page uri) (p/let [dom (bw/select page (dm/str "#screenshot-" id))] (bw/wait-for dom) + (sync-page-size! dom) (bw/screenshot dom {:full-page? true}) (bw/sleep page 2000) ; the good old fix with sleep (bw/pdf page {:path path}) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 76b888e238..518d7f0b60 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -46,6 +46,7 @@ (def ^function create-editor editor.v2/create) (def ^function set-editor-root! editor.v2/setRoot) (def ^function get-editor-root editor.v2/getRoot) +(def ^function is-empty? editor.v2/isEmpty) (def ^function dispose! editor.v2/dispose) (declare v2-update-text-shape-content) @@ -901,15 +902,22 @@ (update-in state [:workspace-text-modifier shape-id] {:position-data position-data})))) (defn v2-update-text-shape-content - [id content & {:keys [update-name? name finalize?] - :or {update-name? false name nil finalize? false}}] + [id content & {:keys [update-name? name finalize? save-undo?] + :or {update-name? false name nil finalize? false save-undo? true}}] (ptk/reify ::v2-update-text-shape-content ptk/WatchEvent (watch [_ state _] (if (features/active-feature? state "render-wasm/v1") (let [objects (dsh/lookup-page-objects state) shape (get objects id) - new-shape? (nil? (:content shape))] + new-shape? (nil? (:content shape)) + prev-content (:content shape) + has-prev-content? (not (nil? (:prev-content shape))) + has-content? (when-not new-shape? + (v2-content-has-text? content)) + did-has-content? (when-not new-shape? + (v2-content-has-text? prev-content))] + (rx/concat (rx/of (dwsh/update-shapes @@ -917,10 +925,16 @@ (fn [shape] (let [new-shape (-> shape (assoc :content content) + (cond-> (and has-content? + has-prev-content?) + (dissoc :prev-content)) + (cond-> (and did-has-content? + (not has-content?)) + (assoc :prev-content prev-content)) (cond-> (and update-name? (some? name)) (assoc :name name)))] new-shape)) - {:undo-group (when new-shape? id)}) + {:save-undo? save-undo? :undo-group (when new-shape? id)}) (if (and (not= :fixed (:grow-type shape)) finalize?) (dwm/apply-wasm-modifiers @@ -933,8 +947,16 @@ (when finalize? (rx/concat - (when (and (not (v2-content-has-text? content)) (some? id)) + (when (and (not has-content?) (some? id)) (rx/of + (when has-prev-content? + (dwsh/update-shapes + [id] + (fn [shape] + (let [new-shape (-> shape + (assoc :content (:prev-content shape)))] + new-shape)) + {:save-undo? false})) (dws/deselect-shape id) (dwsh/delete-shapes #{id}))) (rx/of (dwt/finish-transform)))))) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 589bd35130..1e0331ccd8 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -621,7 +621,7 @@ (->> stream (rx/filter (ptk/type? ::dws/duplicate-selected)) (rx/take 1) - (rx/map #(start-move from-position)))))) + (rx/map #(start-move from-position nil true)))))) (defn get-drop-cell [target-frame objects position] @@ -641,8 +641,9 @@ (dom/set-property! node "transform" (gmt/translate-matrix move-vector)))))) (defn start-move - ([from-position] (start-move from-position nil)) - ([from-position ids] + ([from-position] (start-move from-position nil false)) + ([from-position ids] (start-move from-position ids false)) + ([from-position ids from-duplicate?] (ptk/reify ::start-move ptk/UpdateEvent (update [_ state] @@ -750,38 +751,47 @@ (rx/share))] (if (features/active-feature? state "render-wasm/v1") - (rx/merge - (->> modifiers-stream - (rx/map - (fn [[modifiers snap-ignore-axis]] - (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) + (let [duplicate-stopper + (->> ms/mouse-position-alt + (rx/mapcat + (fn [alt?] + (if (and alt? (not from-duplicate?)) + (rx/of true) + (rx/empty)))))] + (rx/merge + (->> modifiers-stream + (rx/take-until duplicate-stopper) + (rx/map + (fn [[modifiers snap-ignore-axis]] + (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) - (->> move-stream - (rx/with-latest-from ms/mouse-position-alt) - (rx/filter (fn [[_ alt?]] alt?)) - (rx/take 1) - (rx/mapcat - (fn [[_ alt?]] - (if (and (not duplicate-move-started?) alt?) - (rx/of (start-move-duplicate from-position) - (dws/duplicate-selected false true)) - (rx/empty))))) + (->> move-stream + (rx/with-latest-from ms/mouse-position-alt) + (rx/filter (fn [[_ alt?]] alt?)) + (rx/take 1) + (rx/mapcat + (fn [[_ alt?]] + (if (and (not from-duplicate?) alt?) + (rx/of (start-move-duplicate from-position) + (dws/duplicate-selected false true)) + (rx/empty))))) - ;; Last event will write the modifiers creating the changes - (->> move-stream - (rx/last) - (rx/with-latest-from modifiers-stream) - (rx/mapcat - (fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]] - (let [undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dwm/apply-wasm-modifiers modifiers - :snap-ignore-axis snap-ignore-axis - :undo-transation? false) - (move-shapes-to-frame ids target-frame drop-index drop-cell) - (finish-transform) - (dwu/commit-undo-transaction undo-id))))))) + ;; Last event will write the modifiers creating the changes + (->> move-stream + (rx/last) + (rx/take-until duplicate-stopper) + (rx/with-latest-from modifiers-stream) + (rx/mapcat + (fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]] + (let [undo-id (js/Symbol)] + (rx/of + (dwu/start-undo-transaction undo-id) + (dwm/apply-wasm-modifiers modifiers + :snap-ignore-axis snap-ignore-axis + :undo-transation? false) + (move-shapes-to-frame ids target-frame drop-index drop-cell) + (finish-transform) + (dwu/commit-undo-transaction undo-id)))))))) (rx/merge (->> modifiers-stream diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 98e82162e2..d23b376844 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -117,7 +117,8 @@ (st/emit! (dwt/v2-update-text-shape-content shape-id content :update-name? update-name? :name generated-name - :finalize? true)))) + :finalize? true + :save-undo? false)))) (let [container-node (mf/ref-val container-ref)] (dom/set-style! container-node "opacity" 0))) @@ -135,15 +136,21 @@ on-needs-layout (fn [] (when-let [content (content/dom->cljs (dwt/get-editor-root instance))] - (st/emit! (dwt/v2-update-text-shape-content shape-id content :update-name? true))) + (st/emit! (dwt/v2-update-text-shape-content shape-id content + :update-name? true + :save-undo? false))) ;; FIXME: We need to find a better way to trigger layout changes. #_(st/emit! (dwt/v2-update-text-shape-position-data shape-id []))) on-change (fn [] - (when-let [content (content/dom->cljs (dwt/get-editor-root instance))] - (st/emit! (dwt/v2-update-text-shape-content shape-id content :update-name? true)))) + (let [is-empty? (dwt/is-empty? instance) + save-undo? (not is-empty?)] + (when-let [content (content/dom->cljs (dwt/get-editor-root instance))] + (st/emit! (dwt/v2-update-text-shape-content shape-id content + :update-name? true + :save-undo? save-undo?))))) on-clipboard-change (fn [event] @@ -247,7 +254,7 @@ :ref container-ref :data-testid "text-editor-container" :style {:width "var(--editor-container-width)" - :height "var(--editor-container-height)"} + :height "var(--editor-container-height)"}} ;; We hide the editor when is blurred because otherwise the ;; selection won't let us see the underlying text. Use opacity ;; because display or visibility won't allow to recover focus @@ -256,7 +263,7 @@ ;; IMPORTANT! This is now done through DOM mutations (see ;; on-blur and on-focus) but I keep this for future references. ;; :opacity (when @blurred 0)}} - } + [:div {:class (dm/str "mousetrap " diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 64e509e7f5..6e2373b519 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -911,17 +911,23 @@ (def render-finish (letfn [(do-render [ts] - (perf/begin-measure "render-finish") - (h/call wasm/internal-module "_set_view_end") - (render ts) - (perf/end-measure "render-finish"))] + ;; Check if context is still initialized before executing + ;; to prevent errors when navigating quickly + (when wasm/context-initialized? + (perf/begin-measure "render-finish") + (h/call wasm/internal-module "_set_view_end") + (render ts) + (perf/end-measure "render-finish")))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (def render-pan (letfn [(do-render-pan [ts] - (perf/begin-measure "render-pan") - (render ts) - (perf/end-measure "render-pan"))] + ;; Check if context is still initialized before executing + ;; to prevent errors when navigating quickly + (when wasm/context-initialized? + (perf/begin-measure "render-pan") + (render ts) + (perf/end-measure "render-pan")))] (fns/throttle do-render-pan THROTTLE_DELAY_MS))) (defn set-view-box @@ -1399,6 +1405,16 @@ [] (when wasm/context-initialized? (try + ;; Cancel any pending animation frame to prevent race conditions + (when wasm/internal-frame-id + (js/cancelAnimationFrame wasm/internal-frame-id) + (set! wasm/internal-frame-id nil)) + + ;; Reset render flags to prevent new renders from being scheduled + (reset! pending-render false) + (reset! shapes-loading? false) + (reset! deferred-render? false) + ;; TODO: perform corresponding cleaning (set! wasm/context-initialized? false) (h/call wasm/internal-module "_clean_up") diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index 4b801ee864..c67ca4d629 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -32,7 +32,8 @@ "This function adds units to style values" [k v] (cond - (and (or (= k :font-size) + (and (keyword? k) + (or (= k :font-size) (= k :letter-spacing)) (not= (str/slice v -2) "px")) (str v "px") diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index 20828c1264..e7f82739b1 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -326,7 +326,9 @@ export class TextEditor extends EventTarget { * @param {FocusEvent} e */ #onBlur = (e) => { - this.#changeController.notifyImmediately(); + if (!this.isEmpty) { + this.#changeController.notifyImmediately(); + } this.#selectionController.saveSelection(); this.dispatchEvent(new FocusEvent(e.type, e)); }; @@ -683,13 +685,26 @@ export function createRootFromString(string) { * Returns true if the passed object is a TextEditor * instance. * - * @param {*} instance + * @param {TextEditor} instance * @returns {boolean} */ export function isTextEditor(instance) { return instance instanceof TextEditor; } +/** + * Returns true if the TextEditor is empty. + * + * @param {TextEditor} instance + * @returns {boolean} + */ +export function isEmpty(instance) { + if (isTextEditor(instance)) { + return instance.isEmpty; + } + throw new TypeError('Instance is not a TextEditor'); +} + /** * Returns the root element of a TextEditor * instance. @@ -701,7 +716,7 @@ export function getRoot(instance) { if (isTextEditor(instance)) { return instance.root; } - return null; + throw new TypeError("Instance is not a TextEditor"); } /** @@ -714,9 +729,9 @@ export function getRoot(instance) { export function setRoot(instance, root) { if (isTextEditor(instance)) { instance.root = root; + return instance; } - - return instance; + throw new TypeError("Instance is not a TextEditor"); } /** @@ -741,7 +756,7 @@ export function getCurrentStyle(instance) { if (isTextEditor(instance)) { return instance.currentStyle; } - return null; + throw new TypeError("Instance is not a TextEditor"); } /** @@ -756,7 +771,7 @@ export function applyStylesToSelection(instance, styles) { if (isTextEditor(instance)) { return instance.applyStylesToSelection(styles); } - return null; + throw new TypeError("Instance is not a TextEditor"); } /** @@ -770,7 +785,7 @@ export function dispose(instance) { if (isTextEditor(instance)) { return instance.dispose(); } - return null; + throw new TypeError("Instance is not a TextEditor"); } export default TextEditor; diff --git a/frontend/text-editor/src/editor/controllers/ChangeController.js b/frontend/text-editor/src/editor/controllers/ChangeController.js index 166f89a598..f83e697414 100644 --- a/frontend/text-editor/src/editor/controllers/ChangeController.js +++ b/frontend/text-editor/src/editor/controllers/ChangeController.js @@ -54,8 +54,12 @@ export class ChangeController extends EventTarget { return this.#hasPendingChanges; } + /** + * Handles timeout. + */ #onTimeout = () => { this.dispatchEvent(new Event("change")); + this.#hasPendingChanges = false; }; /**