From 6e2dc0c3dcf52a1856dbc350d429878ba505e295 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 31 Mar 2026 15:44:20 +0200 Subject: [PATCH 1/9] :bug: Fix problem with token performance --- frontend/src/app/render_wasm/shape.cljs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 7809d5ca0f..032f3d7926 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -332,9 +332,14 @@ (defn process-shape-changes! [objects shape-changes] - (->> (rx/from shape-changes) - (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) - (rx/subs! #(api/request-render "set-wasm-attrs")))) + (let [shape-changes + (->> shape-changes + ;; We don't need to update the model for shapes not in the current page + (filter (fn [[shape-id _]] (shape-in-current-page? shape-id))))] + (when (d/not-empty? shape-changes) + (->> (rx/from shape-changes) + (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) + (rx/subs! #(api/request-render "set-wasm-attrs")))))) ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) From 0558bab092f9f27f85de1bf06f5d861145e2e86e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 30 Mar 2026 17:18:29 +0200 Subject: [PATCH 2/9] :tada: Show modal when WebGL is not available --- frontend/src/app/main/ui/workspace.cljs | 1 + .../app/main/ui/workspace/viewport_wasm.cljs | 65 +++++++++++----- .../ui/workspace/webgl_unavailable_modal.cljs | 78 +++++++++++++++++++ .../ui/workspace/webgl_unavailable_modal.scss | 61 +++++++++++++++ frontend/translations/en.po | 21 +++++ frontend/translations/es.po | 21 +++++ 6 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs create mode 100644 frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index d44b82c823..0d5ec76847 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -40,6 +40,7 @@ [app.main.ui.workspace.tokens.settings] [app.main.ui.workspace.tokens.themes.create-modal] [app.main.ui.workspace.viewport :refer [viewport*]] + [app.main.ui.workspace.webgl-unavailable-modal] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.globals :as globals] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index a7cd731d5a..ab06a89709 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -16,7 +16,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] - [app.main.data.common :as dcm] + [app.main.data.modal :as modal] [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.variants :as dwv] [app.main.features :as features] @@ -319,25 +319,50 @@ ;; harder to follow through. (mf/with-effect [page-id] (when-let [canvas (mf/ref-val canvas-ref)] - (->> wasm.api/module - (p/fmap (fn [ready?] - (when ready? - (let [init? (try - (wasm.api/init-canvas-context canvas) - (catch :default e - (js/console.error "Error initializing canvas context:" e) - false))] - (reset! canvas-init? init?) - (when init? - ;; Restore previous canvas pixels immediately after context initialization - ;; This happens before initialize-viewport is called - (wasm.api/apply-canvas-blur) - (wasm.api/restore-previous-canvas-pixels)) - (when-not init? - (js/alert "WebGL not supported") - (st/emit! (dcm/go-to-dashboard-recent)))))))) - (fn [] - (wasm.api/clear-canvas)))) + (let [timeout-id-ref (volatile! nil) + unmounted? (volatile! false) + modal-shown? (volatile! false) + + show-unavailable + (fn [] + (when-not (or @unmounted? @modal-shown?) + (vreset! modal-shown? true) + (reset! canvas-init? false) + (st/emit! (modal/show {:type :webgl-unavailable})))) + + try-init + (fn try-init [retries] + (when-not @unmounted? + (let [init? (try + (wasm.api/init-canvas-context canvas) + (catch :default e + (js/console.error "Error initializing canvas context:" e) + false))] + (cond + init? + (do + (reset! canvas-init? true) + ;; Restore previous canvas pixels immediately after context initialization + ;; This happens before initialize-viewport is called + (wasm.api/apply-canvas-blur) + (wasm.api/restore-previous-canvas-pixels)) + + (pos? retries) + (vreset! timeout-id-ref + (js/setTimeout #(try-init (dec retries)) 200)) + + :else + (show-unavailable)))))] + (reset! canvas-init? false) + (->> wasm.api/module + (p/fmap (fn [ready?] + (when ready? + (try-init 3))))) + (fn [] + (vreset! unmounted? true) + (when-let [timeout-id @timeout-id-ref] + (js/clearTimeout timeout-id)) + (wasm.api/clear-canvas))))) (mf/with-effect [show-text-editor? workspace-editor-state edition] (let [active-editor-state (get workspace-editor-state edition)] diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs new file mode 100644 index 0000000000..f4092a4762 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs @@ -0,0 +1,78 @@ +;; 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) KALEIDOS INC + +(ns app.main.ui.workspace.webgl-unavailable-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.common :as dcm] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as k] + [goog.events :as events] + [rumext.v2 :as mf]) + (:import goog.events.EventType)) + +(defn- close-and-go-dashboard + [] + (st/emit! (modal/hide) + (dcm/go-to-dashboard-recent))) + +(def ^:const webgl-troubleshooting-url "https://help.penpot.app/user-guide/first-steps/troubleshooting-webgl/") + +#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]} +(mf/defc webgl-unavailable-modal* + {::mf/register modal/components + ::mf/register-as :webgl-unavailable} + [_] + + (let [handle-keydown (fn [event] + (when (k/esc? event) + (dom/stop-propagation event) + (close-and-go-dashboard)))] + (mf/with-effect [] + (let [key (events/listen js/document EventType.KEYDOWN handle-keydown)] + (fn [] + (events/unlistenByKey key)))) + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:header {:class (stl/css :modal-header)} + [:> icon-button* {:on-click close-and-go-dashboard + :class (stl/css :modal-close-btn) + :icon i/close + :variant "action" + :size "medium" + :aria-label (tr "labels.close")}] + [:> heading* {:level 2 :typography t/title-medium} + (tr "webgl.modals.webgl-unavailable.title")]] + + [:section {:class (stl/css :modal-content)} + [:> text* {:as "p" :typography t/body-large} + (tr "webgl.modals.webgl-unavailable.message")] + [:hr {:class (stl/css :modal-divider)}] + [:> text* {:as "p" :typography t/body-medium} + (tr "webgl.modals.webgl-unavailable.troubleshooting.before") + [:a {:href webgl-troubleshooting-url + :target "_blank" + :rel "noopener noreferrer" + :class (stl/css :link)} + (tr "webgl.modals.webgl-unavailable.troubleshooting.link")] + (tr "webgl.modals.webgl-unavailable.troubleshooting.after")]] + + [:footer {:class (stl/css :modal-footer)} + [:> button* {:on-click close-and-go-dashboard + :variant "secondary"} + (tr "webgl.modals.webgl-unavailable.cta-dashboard")] + [:> button* {:to webgl-troubleshooting-url :target "_blank" :variant "primary"} + (tr "webgl.modals.webgl-unavailable.cta-troubleshooting")]]]])) diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss new file mode 100644 index 0000000000..0ad86b0c29 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss @@ -0,0 +1,61 @@ +// 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) KALEIDOS INC + +@use "ds/_utils.scss" as *; +@use "ds/_borders.scss" as *; + +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-dialog { + @extend .modal-container-base; + + color: var(--color-foreground-secondary); + display: grid; + gap: var(--sp-s); + padding: px2rem(72); // FIXME: This should be a token + max-width: px2rem(682); // FIXME: This should be a token +} + +.modal-header { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + top: px2rem(38); // FIXME: This should be a token + right: px2rem(38); // FIXME: This should be a token +} + +.modal-content { + display: grid; + grid-template-rows: auto; + gap: var(--sp-s); + + & > * { + margin: 0; // FIXME: This should be in reset styles + } +} + +.modal-divider { + border: $b-1 solid var(--color-background-quaternary); + margin: var(--sp-xs) 0; +} + +.modal-footer { + margin-block-start: var(--sp-l); + justify-self: end; + display: flex; + flex-direction: row; + gap: var(--sp-s); +} + +.link { + color: var(--color-accent-primary); +} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a259a95a05..29efc62a11 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8907,6 +8907,27 @@ msgstr "" msgid "workspace.versions.warning.text" msgstr "Autosaved versions will be kept for %s days." +msgid "webgl.modals.webgl-unavailable.title" +msgstr "Oops! WebGL is not available" + +msgid "webgl.modals.webgl-unavailable.message" +msgstr "WebGL is not available in your browser, which is required for Penpot to work. Please check your browser settings and/or close graphics-heavy tabs." + +msgid "webgl.modals.webgl-unavailable.troubleshooting.before" +msgstr "Follow our " + +msgid "webgl.modals.webgl-unavailable.troubleshooting.link" +msgstr "WebGL troubleshooting guide" + +msgid "webgl.modals.webgl-unavailable.troubleshooting.after" +msgstr " to check browser settings, GPU acceleration, drivers, and common system issues." + +msgid "webgl.modals.webgl-unavailable.cta-dashboard" +msgstr "Go to dashboard" + +msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" +msgstr "Troubleshooting guide" + #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 8dfcbb6c75..9314ec02d2 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -8755,6 +8755,27 @@ msgstr "Si quieres aumentar este límite, contáctanos en [support@penpot.app](% msgid "workspace.versions.warning.text" msgstr "Los autoguardados duran %s días." +msgid "webgl.modals.webgl-unavailable.title" +msgstr "Vaya, WebGL no está disponible" + +msgid "webgl.modals.webgl-unavailable.message" +msgstr "WebGL no está disponible en tu navegador, y es necesario para que Penpot funcione. Revisa la configuración de tu navegador y/o cierra pestañas con uso intensivo de gráficos." + +msgid "webgl.modals.webgl-unavailable.troubleshooting.before" +msgstr "Consulta nuestra " + +msgid "webgl.modals.webgl-unavailable.troubleshooting.link" +msgstr "guía de solución de problemas de WebGL" + +msgid "webgl.modals.webgl-unavailable.troubleshooting.after" +msgstr " para revisar la configuración del navegador, la aceleración por GPU, los drivers y problemas comunes del sistema." + +msgid "webgl.modals.webgl-unavailable.cta-dashboard" +msgstr "Ir al panel" + +msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" +msgstr "Guía de solución de problemas" + #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" From 619bc5833d90135ac72db623bad8f852e50569ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 7 Apr 2026 15:47:27 +0200 Subject: [PATCH 3/9] :wrench: Remove VS Code settings --- .vscode/settings.json | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b57665d559..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "files.exclude": { - "**/.clj-kondo": true, - "**/.cpcache": true, - "**/.lsp": true, - "**/.shadow-cljs": true, - "**/node_modules": true - } -} From 6d5b97a7e9206f101220dd1248e3594b916692ae Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Fri, 27 Mar 2026 15:19:54 +0100 Subject: [PATCH 4/9] :wrench: Fix text bounds --- render-wasm/src/shapes/text.rs | 229 ++++++++++++++++++++++++++------- 1 file changed, 181 insertions(+), 48 deletions(-) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 2bcf2b23f8..40bdb6d2ec 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -18,6 +18,7 @@ use skia_safe::{ Contains, }; +use std::cell::Cell; use std::collections::HashSet; use super::FontFamily; @@ -177,10 +178,25 @@ pub struct TextContentLayoutResult( TextContentSize, ); +/// Cached extrect stored as offsets from the selrect origin, +/// keyed by the selrect dimensions (width, height) and vertical alignment +/// used to compute it. +#[derive(Debug, Clone, Copy)] +struct CachedExtrect { + selrect_width: f32, + selrect_height: f32, + valign: u8, + left: f32, + top: f32, + right: f32, + bottom: f32, +} + #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, pub paragraphs: Vec>, + cached_extrect: Cell>, } impl Clone for TextContentLayout { @@ -188,6 +204,7 @@ impl Clone for TextContentLayout { Self { paragraph_builders: vec![], paragraphs: vec![], + cached_extrect: Cell::new(None), } } } @@ -203,6 +220,7 @@ impl TextContentLayout { Self { paragraph_builders: vec![], paragraphs: vec![], + cached_extrect: Cell::new(None), } } @@ -213,6 +231,7 @@ impl TextContentLayout { ) { self.paragraph_builders = paragraph_builders; self.paragraphs = paragraphs; + self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { @@ -231,11 +250,14 @@ pub struct TextDecorationSegment { pub width: f32, } -/* - * Check if the current x,y (in paragraph relative coordinates) is inside - * the paragraph - */ -#[allow(dead_code)] +fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 { + match valign { + VerticalAlign::Center => (container_h - content_h) / 2.0, + VerticalAlign::Bottom => container_h - content_h, + _ => 0.0, + } +} + fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> bool { if y < 0.0 || y > paragraph.height() { return false; @@ -250,6 +272,20 @@ fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> b rects.iter().any(|r| r.rect.contains(&Point::new(x, y))) } +fn paragraph_intersects<'a>( + paragraphs: impl Iterator, + x_pos: f32, + y_pos: f32, +) -> bool { + paragraphs + .scan(0.0_f32, |height, p| { + let prev_height = *height; + *height += p.height(); + Some((prev_height, p)) + }) + .any(|(height, p)| intersects(p, x_pos, y_pos - height)) +} + // Performs a text auto layout without width limits. // This should be the same as text_auto_layout. pub fn build_paragraphs_from_paragraph_builders( @@ -370,34 +406,133 @@ impl TextContent { self.grow_type = grow_type; } + /// Compute a tight text rect from laid-out Skia paragraphs using glyph + /// metrics (fm.top for overshoot, line descent for bottom, line left/width + /// for horizontal extent). + fn rect_from_paragraphs(&self, selrect: &Rect, valign: VerticalAlign) -> Option { + let paragraphs = &self.layout.paragraphs; + let x = selrect.x(); + let base_y = selrect.y(); + + let total_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + + let vertical_offset = vertical_align_offset(selrect.height(), total_height, valign); + + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + let mut has_lines = false; + let mut y_accum = base_y + vertical_offset; + + for group in paragraphs { + if let Some(paragraph) = group.first() { + let line_metrics = paragraph.get_line_metrics(); + for line in &line_metrics { + let line_baseline = y_accum + line.baseline as f32; + + // Use per-glyph fm.top for tighter vertical bounds when + // available; fall back to line-level ascent for empty lines + // (where get_style_metrics returns nothing). + let style_metrics = line.get_style_metrics(line.start_index..line.end_index); + if style_metrics.is_empty() { + min_y = min_y.min(line_baseline - line.ascent as f32); + } else { + for (_start, style_metric) in &style_metrics { + let fm = &style_metric.font_metrics; + min_y = min_y.min(line_baseline + fm.top); + } + } + + // Bottom uses line-level descent (includes descender space + // for the whole line, not just present glyphs). + max_y = max_y.max(line_baseline + line.descent as f32); + min_x = min_x.min(x + line.left as f32); + max_x = max_x.max(x + line.left as f32 + line.width as f32); + has_lines = true; + } + y_accum += paragraph.height(); + } + } + + if has_lines { + Some(Rect::from_ltrb(min_x, min_y, max_x, max_y)) + } else { + None + } + } + + fn compute_and_cache_extrect( + &self, + shape: &Shape, + selrect: &Rect, + valign: VerticalAlign, + ) -> Rect { + // AutoWidth paragraphs are laid out with f32::MAX, so line metrics + // (line.left) reflect alignment within that huge width and are + // unusable for tight bounds. Fall back to content_rect. + if self.grow_type() == GrowType::AutoWidth { + return self.content_rect(selrect, valign); + } + + let tight = if !self.layout.paragraphs.is_empty() { + self.rect_from_paragraphs(selrect, valign) + } else { + let mut text_content = self.clone(); + text_content.update_layout(shape.selrect); + text_content.rect_from_paragraphs(selrect, valign) + } + .unwrap_or_else(|| self.content_rect(selrect, valign)); + + // Cache as offsets from selrect origin so it's position-independent. + let sx = selrect.x(); + let sy = selrect.y(); + self.layout.cached_extrect.set(Some(CachedExtrect { + selrect_width: selrect.width(), + selrect_height: selrect.height(), + valign: valign as u8, + left: tight.left() - sx, + top: tight.top() - sy, + right: tight.right() - sx, + bottom: tight.bottom() - sy, + })); + + tight + } + pub fn calculate_bounds(&self, shape: &Shape, apply_transform: bool) -> Bounds { - let (x, mut y, transform, center) = ( - shape.selrect.x(), - shape.selrect.y(), - &shape.transform, - &shape.center(), - ); + let transform = &shape.transform; + let center = &shape.center(); + let selrect = shape.selrect(); + let valign = shape.vertical_align(); + let sw = selrect.width(); + let sh = selrect.height(); + let sx = selrect.x(); + let sy = selrect.y(); - let width = if self.grow_type() == GrowType::AutoWidth { - self.size.width + // Try the cache first: if dimensions and valign match, just apply position offset. + let text_rect = if let Some(cached) = self.layout.cached_extrect.get() { + if (cached.selrect_width - sw).abs() < 0.1 + && (cached.selrect_height - sh).abs() < 0.1 + && cached.valign == valign as u8 + { + Rect::from_ltrb( + sx + cached.left, + sy + cached.top, + sx + cached.right, + sy + cached.bottom, + ) + } else { + self.compute_and_cache_extrect(shape, &selrect, valign) + } } else { - shape.selrect().width() + self.compute_and_cache_extrect(shape, &selrect, valign) }; - let height = if self.size.width.round() != width.round() { - self.get_height(width) - } else { - self.size.height - }; - - let offset_y = match shape.vertical_align() { - VerticalAlign::Center => (shape.selrect().height() - height) / 2.0, - VerticalAlign::Bottom => shape.selrect().height() - height, - _ => 0.0, - }; - y += offset_y; - - let text_rect = Rect::from_xywh(x, y, width, height); let mut bounds = Bounds::new( Point::new(text_rect.x(), text_rect.y()), Point::new(text_rect.x() + text_rect.width(), text_rect.y()), @@ -434,11 +569,7 @@ impl TextContent { self.size.height }; - let offset_y = match valign { - VerticalAlign::Center => (selrect.height() - height) / 2.0, - VerticalAlign::Bottom => selrect.height() - height, - _ => 0.0, - }; + let offset_y = vertical_align_offset(selrect.height(), height, valign); y += offset_y; Rect::from_xywh(x, y, width, height) @@ -883,22 +1014,24 @@ impl TextContent { let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); - let width = self.width(); - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); - - paragraphs - .iter() - .flatten() - .scan( - (0 as f32, None::), - |(height, _), p| { - let prev_height = *height; - *height += p.height(); - Some((prev_height, p)) - }, + if !self.layout.paragraphs.is_empty() { + // Reuse stored laid-out paragraphs + paragraph_intersects( + self.layout + .paragraphs + .iter() + .flat_map(|group| group.first()), + x_pos, + y_pos, ) - .any(|(height, p)| intersects(p, x_pos, y_pos - height)) + } else { + let width = self.width(); + let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + let paragraphs = + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + + paragraph_intersects(paragraphs.iter().flatten(), x_pos, y_pos) + } } } From f6b8117fe9a182aed5b17d9a98e651c6ec918ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 8 Apr 2026 12:03:12 +0200 Subject: [PATCH 5/9] :sparkles: Explicitly call free_gpu_resources on RenderState drop --- render-wasm/src/render.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 17e29f08d2..c66bcdf2b3 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -3004,3 +3004,9 @@ impl RenderState { self.viewbox.set_all(zoom, x, y); } } + +impl Drop for RenderState { + fn drop(&mut self) { + self.gpu_state.context.free_gpu_resources(); + } +} From 6ce2aadfaecbbd3c660b96977b318b38353d66cd Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 8 Apr 2026 16:14:51 +0200 Subject: [PATCH 6/9] :sparkles: Improve message from schema errors in plugins (#8865) --- common/src/app/common/i18n.cljc | 7 ++ common/src/app/common/schema/messages.cljc | 105 +++++++++++++++++++++ frontend/src/app/plugins/utils.cljs | 18 +++- frontend/src/app/util/forms.cljs | 97 +------------------ frontend/src/app/util/i18n.cljs | 1 + frontend/translations/en.po | 3 + 6 files changed, 134 insertions(+), 97 deletions(-) create mode 100644 common/src/app/common/schema/messages.cljc diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index bdd80b9741..f363329f2d 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -13,3 +13,10 @@ unit tests or backend code for logs or error messages." [key & _args] key) + +(defn c + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [x] + x) diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc new file mode 100644 index 0000000000..93903c1b9c --- /dev/null +++ b/common/src/app/common/schema/messages.cljc @@ -0,0 +1,105 @@ +;; 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) KALEIDOS INC + +(ns app.common.schema.messages + (:require + [app.common.data :as d] + [app.common.i18n :as i18n :refer [tr]] + [app.common.schema :as sm] + [malli.core :as m])) + +;; --- Handlers Helpers + +(defn- translate-code + [code] + (if (vector? code) + (tr (nth code 0) (i18n/c (nth code 1))) + (tr code))) + +(defn- handle-error-fn + [props problem] + (let [v-fn (:error/fn props) + result (v-fn problem)] + (if (string? result) + {:message result} + {:message (or (some-> (get result :code) + (translate-code)) + (get result :message) + (tr "errors.invalid-data"))}))) + +(defn- handle-error-message + [props] + {:message (get props :error/message)}) + +(defn- handle-error-code + [props] + (let [code (get props :error/code)] + {:message (translate-code code)})) + +(defn interpret-schema-problem + [acc {:keys [schema in value type] :as problem}] + (let [props (m/properties schema) + tprops (m/type-properties schema) + field (or (:error/field props) + in) + field (if (vector? field) + field + [field])] + + (if (and (= 1 (count field)) + (contains? acc (first field))) + acc + (cond + (or (nil? field) + (empty? field)) + acc + + (or (= type :malli.core/missing-key) + (nil? value)) + (assoc-in acc field {:message (tr "errors.field-missing")}) + + ;; --- CHECK on schema props + (contains? props :error/fn) + (assoc-in acc field (handle-error-fn props problem)) + + (contains? props :error/message) + (assoc-in acc field (handle-error-message props)) + + (contains? props :error/code) + (assoc-in acc field (handle-error-code props)) + + ;; --- CHECK on type props + (contains? tprops :error/fn) + (assoc-in acc field (handle-error-fn tprops problem)) + + (contains? tprops :error/message) + (assoc-in acc field (handle-error-message tprops)) + + (contains? tprops :error/code) + (assoc-in acc field (handle-error-code tprops)) + + :else + (assoc-in acc field {:message (tr "errors.invalid-data")}))))) + + + +(defn- apply-validators + [validators state errors] + (reduce (fn [errors validator-fn] + (merge errors (validator-fn errors (:data state)))) + errors + validators)) + +(defn collect-schema-errors + [schema validators state] + (let [explain (sm/explain schema (:data state)) + errors (->> (reduce interpret-schema-problem {} (:errors explain)) + (apply-validators validators state))] + + (-> (:errors state) + (merge errors) + (d/without-nils) + (not-empty)))) diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index cbfc512323..19de73fcde 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,14 +9,17 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.i18n :as i18n :refer [tr]] [app.common.schema :as sm] + [app.common.schema.messages :as csm] [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn locate-file [id] @@ -262,6 +265,15 @@ (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) +(defn error-messages + [explain] + (->> (:errors explain) + (reduce csm/interpret-schema-problem {}) + (mapcat (comp seq val)) + (map (fn [[field {:keys [message]}]] + (tr "plugins.validation.message" (name field) message))) + (str/join ". "))) + (defn handle-error "Function to be used in plugin proxies methods to handle errors and print a readable message to the console." @@ -269,7 +281,9 @@ (fn [cause] (let [message (if-let [explain (-> cause ex-data ::sm/explain)] - (sm/humanize-explain explain) + (do + (js/console.error (sm/humanize-explain explain)) + (error-messages explain)) (ex-data cause))] (js/console.log (.-stack cause)) (not-valid plugin-id :error message)))) diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 253d32470c..f0e7e5466c 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -10,84 +10,10 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.util.i18n :as i18n :refer [tr]] + [app.common.schema.messages :as csm] [cuerdas.core :as str] - [malli.core :as m] [rumext.v2 :as mf])) -;; --- Handlers Helpers - -(defn- translate-code - [code] - (if (vector? code) - (tr (nth code 0) (i18n/c (nth code 1))) - (tr code))) - -(defn- handle-error-fn - [props problem] - (let [v-fn (:error/fn props) - result (v-fn problem)] - (if (string? result) - {:message result} - {:message (or (some-> (get result :code) - (translate-code)) - (get result :message) - (tr "errors.invalid-data"))}))) - -(defn- handle-error-message - [props] - {:message (get props :error/message)}) - -(defn- handle-error-code - [props] - (let [code (get props :error/code)] - {:message (translate-code code)})) - -(defn- interpret-schema-problem - [acc {:keys [schema in value type] :as problem}] - (let [props (m/properties schema) - tprops (m/type-properties schema) - field (or (:error/field props) - in) - field (if (vector? field) - field - [field])] - - (if (and (= 1 (count field)) - (contains? acc (first field))) - acc - (cond - (or (nil? field) - (empty? field)) - acc - - (or (= type :malli.core/missing-key) - (nil? value)) - (assoc-in acc field {:message (tr "errors.field-missing")}) - - ;; --- CHECK on schema props - (contains? props :error/fn) - (assoc-in acc field (handle-error-fn props problem)) - - (contains? props :error/message) - (assoc-in acc field (handle-error-message props)) - - (contains? props :error/code) - (assoc-in acc field (handle-error-code props)) - - ;; --- CHECK on type props - (contains? tprops :error/fn) - (assoc-in acc field (handle-error-fn tprops problem)) - - (contains? tprops :error/message) - (assoc-in acc field (handle-error-message tprops)) - - (contains? tprops :error/code) - (assoc-in acc field (handle-error-code tprops)) - - :else - (assoc-in acc field {:message (tr "errors.invalid-data")}))))) - (defn- use-rerender-fn [] (let [state (mf/useState 0) @@ -97,24 +23,6 @@ (fn [] (render-fn inc))))) -(defn- apply-validators - [validators state errors] - (reduce (fn [errors validator-fn] - (merge errors (validator-fn errors (:data state)))) - errors - validators)) - -(defn- collect-schema-errors - [schema validators state] - (let [explain (sm/explain schema (:data state)) - errors (->> (reduce interpret-schema-problem {} (:errors explain)) - (apply-validators validators state))] - - (-> (:errors state) - (merge errors) - (d/without-nils) - (not-empty)))) - (defn- wrap-update-schema-fn [f {:keys [schema validators]}] (fn [& args] @@ -124,7 +32,7 @@ errors (when-not valid? - (collect-schema-errors schema validators state)) + (csm/collect-schema-errors schema validators state)) extra-errors (not-empty (:extra-errors state))] @@ -136,7 +44,6 @@ (not extra-errors) valid?))))) - (defn- make-initial-state [initial-data] (let [initial (if (fn? initial-data) (initial-data) initial-data) diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 2f93c48129..93282d9c56 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -216,3 +216,4 @@ ;; We set the real translation function in the common i18n namespace, ;; so that when common code calls (tr ...) it uses this function. (set! app.common.i18n/tr tr) +(set! app.common.i18n/c c) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 29efc62a11..6516514d57 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -8931,3 +8931,6 @@ msgstr "Troubleshooting guide" #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" + +msgid "plugins.validation.message" +msgstr "Field %s is invalid: %s" From 0dfa450cc8834ba7a48fa650d6757f5a14135bc1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Apr 2026 12:06:45 +0000 Subject: [PATCH 7/9] :bug: Fix swapped move-to/line-to type codes in PathData binary readers The impl-walk, impl-reduce, and impl-lookup functions had the binary type codes for move-to and line-to swapped (1 mapped to :line-to and 2 to :move-to). This is inconsistent with from-plain, read-segment, to-string-segment*, and the Rust RawSegmentData enum which all use 1=move-to and 2=line-to. The swap caused incorrect command types to be reported to callers like get-handlers and get-points. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/impl.cljc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 959a11bf6f..ecdd7ddb09 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -128,8 +128,8 @@ x (buf/read-float buffer (+ offset 20)) y (buf/read-float buffer (+ offset 24)) type (case type - 1 :line-to - 2 :move-to + 1 :move-to + 2 :line-to 3 :curve-to 4 :close-path) res (f type c1x c1y c2x c2y x y)] @@ -153,8 +153,8 @@ x (buf/read-float buffer (+ offset 20)) y (buf/read-float buffer (+ offset 24)) type (case type - 1 :line-to - 2 :move-to + 1 :move-to + 2 :line-to 3 :curve-to 4 :close-path) result (f result index type c1x c1y c2x c2y x y)] @@ -174,8 +174,8 @@ x (buf/read-float buffer (+ offset 20)) y (buf/read-float buffer (+ offset 24)) type (case type - 1 :line-to - 2 :move-to + 1 :move-to + 2 :line-to 3 :curve-to 4 :close-path)] #?(:clj (f type c1x c1y c2x c2y x y) From 2eaa2dc807733c626f217f8b3254ee456cd3c2b9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Apr 2026 12:07:07 +0000 Subject: [PATCH 8/9] :bug: Handle corrupted PathData segments gracefully instead of crashing Add nil defaults to all case expressions that match binary segment type codes so that corrupted/unknown values are skipped instead of throwing 'No matching clause'. This prevents a React render crash (triggered via shape-with-open-path? -> get-subpaths -> reduce) when a PathData buffer contains invalid bytes, e.g. from a WASM data transfer or deserialization of damaged stored data. Affected functions: read-segment, impl-walk, impl-reduce, impl-lookup, to-string-segment*, and the seq/reduce protocol implementations on both JVM and CLJS PathData types. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/impl.cljc | 61 ++++++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index ecdd7ddb09..f15840719f 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -131,8 +131,10 @@ 1 :move-to 2 :line-to 3 :curve-to - 4 :close-path) - res (f type c1x c1y c2x c2y x y)] + 4 :close-path + nil) + res (when (some? type) + (f type c1x c1y c2x c2y x y))] (recur (inc index) (if (some? res) (conj! result res) @@ -156,8 +158,11 @@ 1 :move-to 2 :line-to 3 :curve-to - 4 :close-path) - result (f result index type c1x c1y c2x c2y x y)] + 4 :close-path + nil) + result (if (some? type) + (f result index type c1x c1y c2x c2y x y) + result)] (if (reduced? result) result (recur (inc index) result))) @@ -177,9 +182,11 @@ 1 :move-to 2 :line-to 3 :curve-to - 4 :close-path)] - #?(:clj (f type c1x c1y c2x c2y x y) - :cljs (^function f type c1x c1y c2x c2y x y)))) + 4 :close-path + nil)] + (when (some? type) + #?(:clj (f type c1x c1y c2x c2y x y) + :cljs (^function f type c1x c1y c2x c2y x y))))) (defn- to-string-segment* [buffer offset type ^StringBuilder builder] @@ -219,7 +226,10 @@ (.append ",") (.append y))) 4 (doto builder - (.append "Z")))) + (.append "Z")) + + ;; Skip corrupted/unknown segment types + nil)) (defn- to-string "Format the path data structure to string" @@ -236,7 +246,8 @@ (.toString builder))) (defn- read-segment - "Read segment from binary buffer at specified index" + "Read segment from binary buffer at specified index. Returns nil for + corrupted/invalid segment types." [buffer index] (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset)] @@ -268,7 +279,10 @@ :c2y (double c2y)}}) 4 {:command :close-path - :params {}}))) + :params {}} + + ;; Return nil for corrupted/unknown segment types + nil))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TYPE: PATH-DATA @@ -320,8 +334,10 @@ (when (pos? size) ((fn next-seq [i] (when (< i size) - (cons (read-segment buffer i) - (lazy-seq (next-seq (inc i)))))) + (let [segment (read-segment buffer i)] + (if (some? segment) + (cons segment (lazy-seq (next-seq (inc i)))) + (next-seq (inc i)))))) 0))) clojure.lang.IReduceInit @@ -329,7 +345,10 @@ (loop [index 0 result start] (if (< index size) - (let [result (f result (read-segment buffer index))] + (let [segment (read-segment buffer index) + result (if (some? segment) + (f result segment) + result)] (if (reduced? result) @result (recur (inc index) result))) @@ -407,7 +426,10 @@ (read-segment buffer 0) nil)] (if (< index size) - (let [result (f result (read-segment buffer index))] + (let [segment (read-segment buffer index) + result (if (some? segment) + (f result segment) + result)] (if (reduced? result) @result (recur (inc index) result))) @@ -417,7 +439,10 @@ (loop [index 0 result start] (if (< index size) - (let [result (f result (read-segment buffer index))] + (let [segment (read-segment buffer index) + result (if (some? segment) + (f result segment) + result)] (if (reduced? result) @result (recur (inc index) result))) @@ -446,8 +471,10 @@ (when (pos? size) ((fn next-seq [i] (when (< i size) - (cons (read-segment buffer i) - (lazy-seq (next-seq (inc i)))))) + (let [segment (read-segment buffer i)] + (if (some? segment) + (cons segment (lazy-seq (next-seq (inc i)))) + (next-seq (inc i)))))) 0))) cljs.core/IPrintWithWriter From 92de9ed258a80abc04e07fad60b8ef7113450041 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 8 Apr 2026 12:38:28 +0000 Subject: [PATCH 9/9] :bug: Fix PathData corruption root causes across WASM and CLJS Replace unsafe std::mem::transmute calls in Rust WASM path code with validated TryFrom conversions to prevent undefined behavior from invalid enum discriminant values. This was the most likely root cause of the "No matching clause: -19772" production crash -- corrupted bytes flowing through transmute could produce arbitrary invalid enum variants. Fix byteOffset handling throughout the CLJS PathData serialization pipeline. DataView instances created via buf/slice carry a non-zero byteOffset, but from-bytes, transit write handler, -write-to, buf/clone, and buf/equals? all operated on the full underlying ArrayBuffer, ignoring offset and length. This could silently produce PathData with incorrect size or content. Rust changes (render-wasm): - RawSegmentData: From<[u8; N]> -> TryFrom<[u8; N]> with discriminant validation (must be 0x01-0x04) before transmuting - RawBoolType: From -> TryFrom with explicit match on 0-3 - Add #[wasm_error] to set_shape_path_content, current_to_path, convert_stroke_to_path, and set_shape_bool_type so panics are caught and routed through the WASM error protocol instead of crashing - set_shape_path_content: replace .expect() with proper Result/? error propagation per segment - Remove unused From bound from SerializableResult trait CLJS changes (common): - from-bytes: use DataView.byteLength instead of ArrayBuffer.byteLength for DataView inputs; preserve byteOffset/byteLength when converting from Uint8Array, Uint32Array, and Int8Array - Transit write handler: construct Uint8Array with byteOffset and byteLength from the DataView, not the full backing ArrayBuffer - -write-to: same byteOffset/byteLength fix - buf/clone: copy only the DataView byte range using Uint8Array with proper offset, not Uint32Array over the full ArrayBuffer - buf/equals?: compare DataView byte ranges using Uint8Array with proper offset, not the full backing ArrayBuffers Frontend changes: - shape-to-path, stroke-to-path, calculate-bool*: wrap WASM call and buffer read in try/catch to ensure mem/free is always called, even when an exception occurs between the WASM call and the free call Signed-off-by: Andrey Antukh --- common/src/app/common/buffer.cljc | 25 +-- common/src/app/common/types/path/impl.cljc | 30 ++-- .../common_tests/types/path_data_test.cljc | 146 ++++++++++++++++++ frontend/src/app/render_wasm/api.cljs | 78 ++++++---- 4 files changed, 226 insertions(+), 53 deletions(-) diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 07cf5e6853..7d23edeafe 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -217,10 +217,12 @@ (let [buffer (ByteBuffer/wrap dst)] (.order buffer ByteOrder/LITTLE_ENDIAN))) :cljs - (let [buffer' (.-buffer ^js/DataView buffer) - src-view (js/Uint32Array. buffer') - dst-buff (js/ArrayBuffer. (.-byteLength buffer')) - dst-view (js/Uint32Array. dst-buff)] + (let [src-off (.-byteOffset ^js/DataView buffer) + src-len (.-byteLength ^js/DataView buffer) + src-buf (.-buffer ^js/DataView buffer) + src-view (js/Uint8Array. src-buf src-off src-len) + dst-buff (js/ArrayBuffer. src-len) + dst-view (js/Uint8Array. dst-buff)] (.set dst-view src-view) (js/DataView. dst-buff)))) @@ -239,12 +241,15 @@ ^ByteBuffer buffer-b) :cljs - (let [buffer-a (.-buffer buffer-a) - buffer-b (.-buffer buffer-b)] - (if (= (.-byteLength buffer-a) - (.-byteLength buffer-b)) - (let [cb (js/Uint32Array. buffer-a) - ob (js/Uint32Array. buffer-b) + (let [len-a (.-byteLength ^js/DataView buffer-a) + len-b (.-byteLength ^js/DataView buffer-b)] + (if (= len-a len-b) + (let [cb (js/Uint8Array. (.-buffer ^js/DataView buffer-a) + (.-byteOffset ^js/DataView buffer-a) + len-a) + ob (js/Uint8Array. (.-buffer ^js/DataView buffer-b) + (.-byteOffset ^js/DataView buffer-b) + len-b) sz (alength cb)] (loop [i 0] (if (< i sz) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index f15840719f..2db3fcb2e9 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -390,10 +390,10 @@ ;; NOTE: we still use u8 because until the heap refactor merge ;; we can't guarrantee the alignment of offset on 4 bytes (assert (instance? js/ArrayBuffer into-buffer)) - (let [buffer' (.-buffer ^js/DataView buffer) - size (.-byteLength buffer') + (let [size (.-byteLength ^js/DataView buffer) + src-off (.-byteOffset ^js/DataView buffer) mem (js/Uint8Array. into-buffer offset size)] - (.set mem (js/Uint8Array. buffer')))) + (.set mem (js/Uint8Array. (.-buffer ^js/DataView buffer) src-off size)))) ITransformable (-transform [this m] @@ -635,19 +635,27 @@ nil)) (instance? js/DataView buffer) - (let [buffer' (.-buffer ^js/DataView buffer) - size (.-byteLength ^js/ArrayBuffer buffer') - count (long (/ size SEGMENT-U8-SIZE))] + (let [size (.-byteLength ^js/DataView buffer) + count (long (/ size SEGMENT-U8-SIZE))] (PathData. count buffer (weak/weak-value-map) nil)) (instance? js/Uint8Array buffer) - (from-bytes (.-buffer buffer)) + (let [ab (.-buffer buffer) + offset (.-byteOffset buffer) + size (.-byteLength buffer)] + (from-bytes (js/DataView. ab offset size))) (instance? js/Uint32Array buffer) - (from-bytes (.-buffer buffer)) + (let [ab (.-buffer buffer) + offset (.-byteOffset buffer) + size (.-byteLength buffer)] + (from-bytes (js/DataView. ab offset size))) (instance? js/Int8Array buffer) - (from-bytes (.-buffer buffer)) + (let [ab (.-buffer buffer) + offset (.-byteOffset buffer) + size (.-byteLength buffer)] + (from-bytes (js/DataView. ab offset size))) :else (throw (js/Error. "invalid data provided"))))) @@ -733,7 +741,9 @@ :class PathData :wfn (fn [^PathData pdata] (let [buffer (.-buffer pdata)] - #?(:cljs (js/Uint8Array. (.-buffer ^js/DataView buffer)) + #?(:cljs (js/Uint8Array. (.-buffer ^js/DataView buffer) + (.-byteOffset ^js/DataView buffer) + (.-byteLength ^js/DataView buffer)) :clj (.array ^ByteBuffer buffer)))) :rfn from-bytes}) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index add7d873ad..252334b459 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1272,3 +1272,149 @@ (let [segs (vec (:content result)) curve-segs (filter #(= :curve-to (:command %)) segs)] (t/is (pos? (count curve-segs)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TYPE CODE CONSISTENCY TESTS (regression for move-to/line-to swap bug) +;; +;; These tests ensure that all binary reader code paths agree on the +;; mapping: 1=move-to, 2=line-to, 3=curve-to, 4=close-path. +;; +;; The bug was that `impl-walk`, `impl-reduce`, and `impl-lookup` had +;; type codes 1 and 2 swapped (1→:line-to, 2→:move-to) while +;; `read-segment`, `from-plain`, and `to-string-segment*` had the +;; correct mapping. This caused subtle mismatches in operations like +;; `get-subpaths`, `get-points`, `get-handlers`, etc. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest type-code-walk-consistency + (t/testing "impl-walk produces same command types as read-segment (via vec)" + (let [pdata (path/content sample-content) + ;; read-segment path: produces {:command :keyword ...} maps + seq-types (mapv :command (vec pdata)) + ;; impl-walk path: collects type keywords + walk-types (path.impl/-walk pdata + (fn [type _ _ _ _ _ _] type) + [])] + ;; Both paths must agree on the command types + (t/is (= seq-types walk-types)) + ;; Verify the actual expected types + (t/is (= [:move-to :line-to :curve-to :close-path] seq-types)) + (t/is (= [:move-to :line-to :curve-to :close-path] walk-types))))) + +(t/deftest type-code-reduce-consistency + (t/testing "impl-reduce produces same command types as read-segment (via vec)" + (let [pdata (path/content sample-content) + ;; read-segment path + seq-types (mapv :command (vec pdata)) + ;; impl-reduce path: collects [index type] pairs + reduce-types (path.impl/-reduce + pdata + (fn [acc index type _ _ _ _ _ _] + (conj acc type)) + [])] + (t/is (= seq-types reduce-types)) + (t/is (= [:move-to :line-to :curve-to :close-path] reduce-types))))) + +(t/deftest type-code-lookup-consistency + (t/testing "impl-lookup produces same command types as read-segment for each index" + (let [pdata (path/content sample-content) + seg-count (count pdata)] + (doseq [i (range seg-count)] + (let [;; read-segment path + seg-type (:command (nth pdata i)) + ;; impl-lookup path + lookup-type (path.impl/-lookup + pdata i + (fn [type _ _ _ _ _ _] type))] + (t/is (= seg-type lookup-type) + (str "Mismatch at index " i + ": read-segment=" seg-type + " lookup=" lookup-type))))))) + +(t/deftest type-code-get-points-uses-walk + (t/testing "get-points (via impl-walk) excludes close-path and includes move-to/line-to/curve-to" + (let [pdata (path/content sample-content) + points (path.segment/get-points pdata) + ;; Manually extract points from read-segment (via vec), + ;; skipping close-path + expected-points (->> (vec pdata) + (remove #(= :close-path (:command %))) + (mapv #(gpt/point + (get-in % [:params :x]) + (get-in % [:params :y]))))] + (t/is (= expected-points points)) + ;; Specifically: 3 points (move-to, line-to, curve-to) + (t/is (= 3 (count points)))))) + +(t/deftest type-code-get-subpaths-uses-reduce + (t/testing "get-subpaths (via reduce) correctly identifies move-to to start subpaths" + (let [;; Content with two subpaths: move-to + line-to + close-path, then move-to + line-to + two-subpath-content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :close-path :params {}} + {:command :move-to :params {:x 20.0 :y 20.0}} + {:command :line-to :params {:x 30.0 :y 30.0}}] + pdata (path/content two-subpath-content) + subpaths (path.subpath/get-subpaths pdata)] + ;; Must produce exactly 2 subpaths (one per move-to) + (t/is (= 2 (count subpaths))) + ;; First subpath starts at (0,0) + (t/is (= (gpt/point 0.0 0.0) (:from (first subpaths)))) + ;; Second subpath starts at (20,20) + (t/is (= (gpt/point 20.0 20.0) (:from (second subpaths))))))) + +(t/deftest type-code-get-handlers-uses-reduce + (t/testing "get-handlers (via impl-reduce) correctly identifies curve-to segments" + (let [pdata (path/content sample-content) + handlers (path.segment/get-handlers pdata)] + ;; sample-content has one curve-to at index 2 + ;; The curve-to's :c1 handler belongs to the previous point (line-to endpoint) + ;; The curve-to's :c2 handler belongs to the curve-to endpoint + (t/is (some? handlers)) + (let [line-to-point (gpt/point 439.0 802.0) + curve-to-point (gpt/point 264.0 634.0)] + ;; line-to endpoint should have [2 :c1] handler + (t/is (some #(= [2 :c1] %) (get handlers line-to-point))) + ;; curve-to endpoint should have [2 :c2] handler + (t/is (some #(= [2 :c2] %) (get handlers curve-to-point))))))) + +(t/deftest type-code-handler-point-uses-lookup + (t/testing "get-handler-point (via impl-lookup) returns correct values" + (let [pdata (path/content sample-content)] + ;; Index 0 is move-to (480, 839) — not a curve, so any prefix + ;; returns the segment point itself + (let [pt (path.segment/get-handler-point pdata 0 :c1)] + (t/is (= (gpt/point 480.0 839.0) pt))) + ;; Index 2 is curve-to with c1=(368,737), c2=(310,681), point=(264,634) + (let [c1-pt (path.segment/get-handler-point pdata 2 :c1) + c2-pt (path.segment/get-handler-point pdata 2 :c2)] + (t/is (= (gpt/point 368.0 737.0) c1-pt)) + (t/is (= (gpt/point 310.0 681.0) c2-pt)))))) + +(t/deftest type-code-all-readers-agree-large-content + (t/testing "all binary readers agree on types for a large multi-segment path" + (let [pdata (path/content sample-content-large) + seg-count (count pdata) + ;; Collect types from all four code paths + seq-types (mapv :command (vec pdata)) + walk-types (path.impl/-walk pdata + (fn [type _ _ _ _ _ _] type) + []) + reduce-types (path.impl/-reduce + pdata + (fn [acc _ type _ _ _ _ _ _] + (conj acc type)) + []) + lookup-types (mapv (fn [i] + (path.impl/-lookup + pdata i + (fn [type _ _ _ _ _ _] type))) + (range seg-count))] + ;; All four must be identical + (t/is (= seq-types walk-types)) + (t/is (= seq-types reduce-types)) + (t/is (= seq-types lookup-types)) + ;; Verify first and last entries specifically + (t/is (= :move-to (first seq-types))) + (t/is (= :close-path (last seq-types)))))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 27dc63e5c6..0d2214acde 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1541,35 +1541,43 @@ (defn shape-to-path [id] (use-shape id) - (let [offset (-> (h/call wasm/internal-module "_current_to_path") - (mem/->offset-32)) - heap (mem/get-heap-u32) - length (aget heap offset) - data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content)) + (try + (let [offset (-> (h/call wasm/internal-module "_current_to_path") + (mem/->offset-32)) + heap (mem/get-heap-u32) + length (aget heap offset) + data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (catch :default cause + (mem/free) + (throw cause)))) (defn stroke-to-path "Converts a shape's stroke at the given index into a filled path. Returns the stroke outline as PathData content." [id stroke-index] (use-shape id) - (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) - (mem/->offset-32)) - heap (mem/get-heap-u32) - length (aget heap offset)] - (if (pos? length) - (let [data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content) - (do (mem/free) - nil)))) + (try + (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) + (mem/->offset-32)) + heap (mem/get-heap-u32) + length (aget heap offset)] + (if (pos? length) + (let [data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (do (mem/free) + nil))) + (catch :default cause + (mem/free) + (throw cause)))) (defn calculate-bool* [bool-type ids] @@ -1582,17 +1590,21 @@ offset (rseq ids)) - (let [offset - (-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type)) - (mem/->offset-32)) + (try + (let [offset + (-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type)) + (mem/->offset-32)) - length (aget heap offset) - data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content))) + length (aget heap offset) + data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (catch :default cause + (mem/free) + (throw cause))))) (defn calculate-bool [shape objects]