Merge branch 'staging-render' into develop

This commit is contained in:
Andrey Antukh
2026-02-09 14:14:02 +01:00
13 changed files with 200 additions and 86 deletions

View File

@@ -34,6 +34,12 @@
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - 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) - 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 ## 2.13.0
### :heart: Community contributions (Thank you!) ### :heart: Community contributions (Thank you!)

View File

@@ -35,8 +35,7 @@
javax.xml.parsers.SAXParserFactory javax.xml.parsers.SAXParserFactory
org.apache.commons.io.IOUtils org.apache.commons.io.IOUtils
org.im4java.core.ConvertCmd org.im4java.core.ConvertCmd
org.im4java.core.IMOperation org.im4java.core.IMOperation))
org.im4java.core.Info))
(def default-max-file-size (def default-max-file-size
(* 1024 1024 10)) ; 10 MiB (* 1024 1024 10)) ; 10 MiB
@@ -224,17 +223,18 @@
;; If we are processing an animated gif we use the first frame with -scene 0 ;; 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) (let [dim-result (sh/sh "identify" "-format" "%w %h\n" path)
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)] orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
(if (and (= 0 (:exit dim-result)) (when (= 0 (:exit dim-result))
(= 0 (:exit orient-result)))
(let [[w h] (-> (:out dim-result) (let [[w h] (-> (:out dim-result)
str/trim str/trim
(clojure.string/split #"\s+") (clojure.string/split #"\s+")
(->> (mapv #(Integer/parseInt %)))) (->> (mapv #(Integer/parseInt %))))
orientation-exit (:exit orient-result)
orientation (-> orient-result :out str/trim)] orientation (-> orient-result :out str/trim)]
(if (= 0 orientation-exit)
(case orientation (case orientation
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
{:width w :height h})) ; Normal or unknown orientation {:width w :height h}) ; Normal or unknown orientation
nil))) {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
(defmethod process :info (defmethod process :info
[{:keys [input] :as params}] [{:keys [input] :as params}]
@@ -247,26 +247,37 @@
:hint "uploaded svg does not provides dimensions")) :hint "uploaded svg does not provides dimensions"))
(merge input info {:ts (ct/now) :size (fs/size path)})) (merge input info {:ts (ct/now) :size (fs/size path)}))
(let [instance (Info. (str path)) (let [path-str (str path)
mtype' (.getProperty instance "Mime type")] 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) (when (and (string? mtype)
(not= mtype mtype')) (not= (str/lower mtype) mtype'))
(ex/raise :type :validation (ex/raise :type :validation
:code :media-type-mismatch :code :media-type-mismatch
:hint (str "Seems like you are uploading a file whose content does not match the extension." :hint (str "Seems like you are uploading a file whose content does not match the extension."
"Expected: " mtype ". Got: " mtype'))) "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 (assoc input
:width width :width width
:height height :height height
:size (fs/size path) :size (fs/size path)
:ts (ct/now))))))) :ts (ct/now))))))
(defmethod process-error org.im4java.core.InfoException (defmethod process-error org.im4java.core.InfoException
[error] [error]

View File

@@ -24,6 +24,7 @@ RUN set -e; \
libltdl-dev \ libltdl-dev \
liblzma-dev \ liblzma-dev \
libopenexr-dev \ libopenexr-dev \
libxml2-dev \
libpng-dev \ libpng-dev \
librsvg2-dev \ librsvg2-dev \
libtiff-dev \ libtiff-dev \
@@ -52,6 +53,7 @@ RUN set -e; \
libfftw3-dev \ libfftw3-dev \
libheif-dev \ libheif-dev \
libjpeg-dev \ libjpeg-dev \
libxml2-dev \
liblcms2-dev \ liblcms2-dev \
libltdl-dev \ libltdl-dev \
liblzma-dev \ liblzma-dev \
@@ -77,6 +79,7 @@ RUN set -e; \
libopenjp2-7 \ libopenjp2-7 \
libpng16-16 \ libpng16-16 \
librsvg2-2 \ librsvg2-2 \
libxml2 \
libtiff6 \ libtiff6 \
libwebp7 \ libwebp7 \
libwebpdemux2 \ libwebpdemux2 \

View File

@@ -125,7 +125,7 @@ RUN set -ex; \
COPY --from=build /opt/jre /opt/jre COPY --from=build /opt/jre /opt/jre
COPY --from=build /opt/node /opt/node 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/" ARG BUNDLE_PATH="./bundle-backend/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/backend/ COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/backend/

View File

@@ -107,7 +107,7 @@ RUN set -eux; \
ARG BUNDLE_PATH="./bundle-exporter/" ARG BUNDLE_PATH="./bundle-exporter/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/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 WORKDIR /opt/penpot/exporter
USER penpot:penpot USER penpot:penpot

View File

@@ -38,6 +38,24 @@
(assoc :path "/render.html") (assoc :path "/render.html")
(assoc :query (u/map->query-string params))))) (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}] (render-object [page base-uri {:keys [id] :as object}]
(p/let [uri (prepare-uri base-uri id) (p/let [uri (prepare-uri base-uri id)
path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))] path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))]
@@ -45,6 +63,7 @@
(bw/nav! page uri) (bw/nav! page uri)
(p/let [dom (bw/select page (dm/str "#screenshot-" id))] (p/let [dom (bw/select page (dm/str "#screenshot-" id))]
(bw/wait-for dom) (bw/wait-for dom)
(sync-page-size! dom)
(bw/screenshot dom {:full-page? true}) (bw/screenshot dom {:full-page? true})
(bw/sleep page 2000) ; the good old fix with sleep (bw/sleep page 2000) ; the good old fix with sleep
(bw/pdf page {:path path}) (bw/pdf page {:path path})

View File

@@ -46,6 +46,7 @@
(def ^function create-editor editor.v2/create) (def ^function create-editor editor.v2/create)
(def ^function set-editor-root! editor.v2/setRoot) (def ^function set-editor-root! editor.v2/setRoot)
(def ^function get-editor-root editor.v2/getRoot) (def ^function get-editor-root editor.v2/getRoot)
(def ^function is-empty? editor.v2/isEmpty)
(def ^function dispose! editor.v2/dispose) (def ^function dispose! editor.v2/dispose)
(declare v2-update-text-shape-content) (declare v2-update-text-shape-content)
@@ -901,15 +902,22 @@
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data})))) (update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))
(defn v2-update-text-shape-content (defn v2-update-text-shape-content
[id content & {:keys [update-name? name finalize?] [id content & {:keys [update-name? name finalize? save-undo?]
:or {update-name? false name nil finalize? false}}] :or {update-name? false name nil finalize? false save-undo? true}}]
(ptk/reify ::v2-update-text-shape-content (ptk/reify ::v2-update-text-shape-content
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(if (features/active-feature? state "render-wasm/v1") (if (features/active-feature? state "render-wasm/v1")
(let [objects (dsh/lookup-page-objects state) (let [objects (dsh/lookup-page-objects state)
shape (get objects id) 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/concat
(rx/of (rx/of
(dwsh/update-shapes (dwsh/update-shapes
@@ -917,10 +925,16 @@
(fn [shape] (fn [shape]
(let [new-shape (-> shape (let [new-shape (-> shape
(assoc :content content) (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)) (cond-> (and update-name? (some? name))
(assoc :name name)))] (assoc :name name)))]
new-shape)) 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?) (if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers (dwm/apply-wasm-modifiers
@@ -933,8 +947,16 @@
(when finalize? (when finalize?
(rx/concat (rx/concat
(when (and (not (v2-content-has-text? content)) (some? id)) (when (and (not has-content?) (some? id))
(rx/of (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) (dws/deselect-shape id)
(dwsh/delete-shapes #{id}))) (dwsh/delete-shapes #{id})))
(rx/of (dwt/finish-transform)))))) (rx/of (dwt/finish-transform))))))

View File

@@ -621,7 +621,7 @@
(->> stream (->> stream
(rx/filter (ptk/type? ::dws/duplicate-selected)) (rx/filter (ptk/type? ::dws/duplicate-selected))
(rx/take 1) (rx/take 1)
(rx/map #(start-move from-position)))))) (rx/map #(start-move from-position nil true))))))
(defn get-drop-cell (defn get-drop-cell
[target-frame objects position] [target-frame objects position]
@@ -641,8 +641,9 @@
(dom/set-property! node "transform" (gmt/translate-matrix move-vector)))))) (dom/set-property! node "transform" (gmt/translate-matrix move-vector))))))
(defn start-move (defn start-move
([from-position] (start-move from-position nil)) ([from-position] (start-move from-position nil false))
([from-position ids] ([from-position ids] (start-move from-position ids false))
([from-position ids from-duplicate?]
(ptk/reify ::start-move (ptk/reify ::start-move
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
@@ -750,8 +751,16 @@
(rx/share))] (rx/share))]
(if (features/active-feature? state "render-wasm/v1") (if (features/active-feature? state "render-wasm/v1")
(let [duplicate-stopper
(->> ms/mouse-position-alt
(rx/mapcat
(fn [alt?]
(if (and alt? (not from-duplicate?))
(rx/of true)
(rx/empty)))))]
(rx/merge (rx/merge
(->> modifiers-stream (->> modifiers-stream
(rx/take-until duplicate-stopper)
(rx/map (rx/map
(fn [[modifiers snap-ignore-axis]] (fn [[modifiers snap-ignore-axis]]
(dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis)))) (dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis))))
@@ -762,7 +771,7 @@
(rx/take 1) (rx/take 1)
(rx/mapcat (rx/mapcat
(fn [[_ alt?]] (fn [[_ alt?]]
(if (and (not duplicate-move-started?) alt?) (if (and (not from-duplicate?) alt?)
(rx/of (start-move-duplicate from-position) (rx/of (start-move-duplicate from-position)
(dws/duplicate-selected false true)) (dws/duplicate-selected false true))
(rx/empty))))) (rx/empty)))))
@@ -770,6 +779,7 @@
;; Last event will write the modifiers creating the changes ;; Last event will write the modifiers creating the changes
(->> move-stream (->> move-stream
(rx/last) (rx/last)
(rx/take-until duplicate-stopper)
(rx/with-latest-from modifiers-stream) (rx/with-latest-from modifiers-stream)
(rx/mapcat (rx/mapcat
(fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]] (fn [[[_ target-frame drop-index drop-cell] [modifiers snap-ignore-axis]]]
@@ -781,7 +791,7 @@
:undo-transation? false) :undo-transation? false)
(move-shapes-to-frame ids target-frame drop-index drop-cell) (move-shapes-to-frame ids target-frame drop-index drop-cell)
(finish-transform) (finish-transform)
(dwu/commit-undo-transaction undo-id))))))) (dwu/commit-undo-transaction undo-id))))))))
(rx/merge (rx/merge
(->> modifiers-stream (->> modifiers-stream

View File

@@ -117,7 +117,8 @@
(st/emit! (dwt/v2-update-text-shape-content shape-id content (st/emit! (dwt/v2-update-text-shape-content shape-id content
:update-name? update-name? :update-name? update-name?
:name generated-name :name generated-name
:finalize? true)))) :finalize? true
:save-undo? false))))
(let [container-node (mf/ref-val container-ref)] (let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0))) (dom/set-style! container-node "opacity" 0)))
@@ -135,15 +136,21 @@
on-needs-layout on-needs-layout
(fn [] (fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))] (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. ;; FIXME: We need to find a better way to trigger layout changes.
#_(st/emit! #_(st/emit!
(dwt/v2-update-text-shape-position-data shape-id []))) (dwt/v2-update-text-shape-position-data shape-id [])))
on-change on-change
(fn [] (fn []
(let [is-empty? (dwt/is-empty? instance)
save-undo? (not is-empty?)]
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))] (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? save-undo?)))))
on-clipboard-change on-clipboard-change
(fn [event] (fn [event]
@@ -247,7 +254,7 @@
:ref container-ref :ref container-ref
:data-testid "text-editor-container" :data-testid "text-editor-container"
:style {:width "var(--editor-container-width)" :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 ;; We hide the editor when is blurred because otherwise the
;; selection won't let us see the underlying text. Use opacity ;; selection won't let us see the underlying text. Use opacity
;; because display or visibility won't allow to recover focus ;; because display or visibility won't allow to recover focus
@@ -256,7 +263,7 @@
;; IMPORTANT! This is now done through DOM mutations (see ;; IMPORTANT! This is now done through DOM mutations (see
;; on-blur and on-focus) but I keep this for future references. ;; on-blur and on-focus) but I keep this for future references.
;; :opacity (when @blurred 0)}} ;; :opacity (when @blurred 0)}}
}
[:div [:div
{:class (dm/str {:class (dm/str
"mousetrap " "mousetrap "

View File

@@ -911,17 +911,23 @@
(def render-finish (def render-finish
(letfn [(do-render [ts] (letfn [(do-render [ts]
;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly
(when wasm/context-initialized?
(perf/begin-measure "render-finish") (perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(render ts) (render ts)
(perf/end-measure "render-finish"))] (perf/end-measure "render-finish")))]
(fns/debounce do-render DEBOUNCE_DELAY_MS))) (fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan (def render-pan
(letfn [(do-render-pan [ts] (letfn [(do-render-pan [ts]
;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly
(when wasm/context-initialized?
(perf/begin-measure "render-pan") (perf/begin-measure "render-pan")
(render ts) (render ts)
(perf/end-measure "render-pan"))] (perf/end-measure "render-pan")))]
(fns/throttle do-render-pan THROTTLE_DELAY_MS))) (fns/throttle do-render-pan THROTTLE_DELAY_MS)))
(defn set-view-box (defn set-view-box
@@ -1399,6 +1405,16 @@
[] []
(when wasm/context-initialized? (when wasm/context-initialized?
(try (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 ;; TODO: perform corresponding cleaning
(set! wasm/context-initialized? false) (set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up") (h/call wasm/internal-module "_clean_up")

View File

@@ -32,7 +32,8 @@
"This function adds units to style values" "This function adds units to style values"
[k v] [k v]
(cond (cond
(and (or (= k :font-size) (and (keyword? k)
(or (= k :font-size)
(= k :letter-spacing)) (= k :letter-spacing))
(not= (str/slice v -2) "px")) (not= (str/slice v -2) "px"))
(str v "px") (str v "px")

View File

@@ -326,7 +326,9 @@ export class TextEditor extends EventTarget {
* @param {FocusEvent} e * @param {FocusEvent} e
*/ */
#onBlur = (e) => { #onBlur = (e) => {
if (!this.isEmpty) {
this.#changeController.notifyImmediately(); this.#changeController.notifyImmediately();
}
this.#selectionController.saveSelection(); this.#selectionController.saveSelection();
this.dispatchEvent(new FocusEvent(e.type, e)); this.dispatchEvent(new FocusEvent(e.type, e));
}; };
@@ -683,13 +685,26 @@ export function createRootFromString(string) {
* Returns true if the passed object is a TextEditor * Returns true if the passed object is a TextEditor
* instance. * instance.
* *
* @param {*} instance * @param {TextEditor} instance
* @returns {boolean} * @returns {boolean}
*/ */
export function isTextEditor(instance) { export function isTextEditor(instance) {
return instance instanceof TextEditor; 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 * Returns the root element of a TextEditor
* instance. * instance.
@@ -701,7 +716,7 @@ export function getRoot(instance) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
return instance.root; return instance.root;
} }
return null; throw new TypeError("Instance is not a TextEditor");
} }
/** /**
@@ -714,10 +729,10 @@ export function getRoot(instance) {
export function setRoot(instance, root) { export function setRoot(instance, root) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
instance.root = root; instance.root = root;
}
return instance; return instance;
} }
throw new TypeError("Instance is not a TextEditor");
}
/** /**
* Creates a new TextEditor instance. * Creates a new TextEditor instance.
@@ -741,7 +756,7 @@ export function getCurrentStyle(instance) {
if (isTextEditor(instance)) { if (isTextEditor(instance)) {
return instance.currentStyle; 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)) { if (isTextEditor(instance)) {
return instance.applyStylesToSelection(styles); 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)) { if (isTextEditor(instance)) {
return instance.dispose(); return instance.dispose();
} }
return null; throw new TypeError("Instance is not a TextEditor");
} }
export default TextEditor; export default TextEditor;

View File

@@ -54,8 +54,12 @@ export class ChangeController extends EventTarget {
return this.#hasPendingChanges; return this.#hasPendingChanges;
} }
/**
* Handles timeout.
*/
#onTimeout = () => { #onTimeout = () => {
this.dispatchEvent(new Event("change")); this.dispatchEvent(new Event("change"));
this.#hasPendingChanges = false;
}; };
/** /**