🐛 Fix cropped outer stroke of rotated board in view mode

This commit is contained in:
alonso.torres 2026-05-28 08:41:09 +02:00 committed by Alonso Torres
parent 3444c0589f
commit 70e8dbb38a
6 changed files with 361 additions and 9 deletions

View File

@ -89,14 +89,23 @@
([shape]
(get-shape-filter-bounds shape false))
([shape ignore-shadow-margin?]
(if (or (and (cfh/svg-raw-shape? shape)
(not= :svg (dm/get-in shape [:content :tag])))
;; If no shadows or blur, we return the selrect as is
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0))))))
(cond
;; SVG raw elements (non-root) don't have proper rotated points; use selrect
(and (cfh/svg-raw-shape? shape)
(not= :svg (dm/get-in shape [:content :tag])))
(dm/get-prop shape :selrect)
;; No shadows or blur: use the axis-aligned bounding box from the actual
;; (possibly rotated) points. Using selrect here would be wrong for rotated
;; shapes because selrect stores the unrotated rectangle, not the screen-space bbox.
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0)))))
(-> (dm/get-prop shape :points)
(grc/points->rect))
:else
(let [filters (shape->filters shape)
blur-value (case (-> shape :blur :type)
:layer-blur (or (-> shape :blur :value) 0)

View File

@ -0,0 +1,195 @@
{
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c",
"~:file-id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
"~:created-at": "~m1717759268004",
"~:data": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0,
"~:y": 0.01
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~ubc508673-9e3b-80bf-8004-77dfa30a2b13"
]
}
},
"~ubc508673-9e3b-80bf-8004-77dfa30a2b13": {
"~#shape": {
"~:y": 100,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 0.5735764363510460,
"~:b": 0.8191520442889918,
"~:c": -0.8191520442889918,
"~:d": 0.5735764363510460,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 55,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Board",
"~:width": 80,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 166.208,
"~:y": 92.816
}
},
{
"~#point": {
"~:x": 212.096,
"~:y": 158.352
}
},
{
"~#point": {
"~:x": 113.792,
"~:y": 227.184
}
},
{
"~#point": {
"~:x": 67.904,
"~:y": 161.648
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 0.5735764363510460,
"~:b": -0.8191520442889918,
"~:c": 0.8191520442889918,
"~:d": 0.5735764363510460,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ubc508673-9e3b-80bf-8004-77dfa30a2b13",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [
{
"~:stroke-color": "#FF0000",
"~:stroke-opacity": 1,
"~:stroke-style": "~:solid",
"~:stroke-alignment": "~:outer",
"~:stroke-width": 20
}
],
"~:x": 100,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 100,
"~:y": 100,
"~:width": 80,
"~:height": 120,
"~:x1": 100,
"~:y1": 100,
"~:x2": 180,
"~:y2": 220
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 120,
"~:flip-y": null,
"~:shapes": [],
"~:show-content": true
}
}
},
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2",
"~:name": "Page 1"
}
}

View File

@ -0,0 +1,86 @@
{
"~:users": [
{
"~:id": "~u0515a066-e303-8169-8004-73eb4018f4e0",
"~:email": "leia@example.com",
"~:name": "Princesa Leia",
"~:fullname": "Princesa Leia",
"~:is-active": true
}
],
"~:fonts": [],
"~:project": {
"~:id": "~u0515a066-e303-8169-8004-73eb401b5d55",
"~:name": "Drafts",
"~:team-id": "~u0515a066-e303-8169-8004-73eb401977a6"
},
"~:share-links": [],
"~:libraries": [],
"~:file": {
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "Rotated Board Stroke Test",
"~:revn": 1,
"~:modified-at": "~m1717759268010",
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
"~:is-shared": false,
"~:version": 48,
"~:project-id": "~u0515a066-e303-8169-8004-73eb401b5d55",
"~:created-at": "~m1717759250257",
"~:data": {
"~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1",
"~:options": {
"~:components-v2": true
},
"~:pages": [
"~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2"
],
"~:pages-index": {
"~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2": {
"~#penpot/pointer": [
"~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c",
{
"~:created-at": "~m1717759268024"
}
]
}
}
}
},
"~:team": {
"~:id": "~u0515a066-e303-8169-8004-73eb401977a6",
"~:created-at": "~m1717493865581",
"~:modified-at": "~m1717493865581",
"~:name": "Default",
"~:is-default": true,
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
}
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true,
"~:in-team": true
}
}

View File

@ -71,6 +71,21 @@ export class ViewerPage extends BaseWebSocketPage {
);
}
async setupFileWithRotatedBoardStroke() {
await this.mockRPC(
/get\-view\-only\-bundle\?/,
"viewer/get-view-only-bundle-rotated-board-stroke.json",
);
await this.mockRPC(
"get-comment-threads?file-id=*",
"workspace/get-comment-threads-empty.json",
);
await this.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-rotated-board-stroke.json",
);
}
async setupFileWithComments() {
await this.mockRPC(
/get\-view\-only\-bundle\?/,

View File

@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
import { ViewerPage } from "../pages/ViewerPage";
// Issue 8257: outer stroke of a rotated board is cropped in View Mode.
// The SVG viewport must be large enough to contain the stroke of a rotated board.
// A 55° rotated board (80×120) with a 20px outer stroke has a rotated bounding box
// of ~144×134px. The viewport must be at least ~202×192px (bbox + stroke margin).
test.beforeEach(async ({ page }) => {
await ViewerPage.init(page);
});
const rotatedBoardFileId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb1";
const rotatedBoardPageId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb2";
test("Viewer shows full outer stroke of a rotated board without clipping", async ({
page,
}) => {
const viewer = new ViewerPage(page);
await viewer.setupLoggedInUser();
await viewer.setupFileWithRotatedBoardStroke();
await viewer.goToViewer({
fileId: rotatedBoardFileId,
pageId: rotatedBoardPageId,
});
// Wait for the viewer SVG to be rendered
const svg = page.locator("svg[class*='not-fixed']").first();
await expect(svg).toBeVisible();
// The SVG viewBox must be large enough to contain the rotated board plus its
// 20px outer stroke. For a 55° rotated board (80×120):
// - The axis-aligned bounding box of the rotated frame is ~144×134px
// - The outer stroke (20px) adds sqrt(2)*20 ≈ 29px margin on each side
// - So the viewport must be at least ~202×192px
//
// Before the fix, the viewer used the unrotated selrect (80×120) as the viewport,
// causing the stroke to be heavily clipped.
const viewBox = await svg.getAttribute("viewBox");
const [, , vbWidth, vbHeight] = viewBox.split(" ").map(Number);
// The unrotated selrect is 80×120. If the viewport is close to those dimensions,
// the stroke is being clipped (bug). The fixed viewport should be much larger.
expect(vbWidth).toBeGreaterThan(150);
expect(vbHeight).toBeGreaterThan(150);
});

View File

@ -53,9 +53,9 @@
(defn- calculate-size
"Calculate the total size we must reserve for the frame, including possible paddings
added because shadows or blur."
added because shadows, blur, or strokes."
[objects frame zoom]
(let [{:keys [x y width height]} (gsb/get-object-bounds objects frame)]
(let [{:keys [x y width height]} (gsb/get-object-bounds objects frame {:ignore-margin? false})]
{:base-width width
:base-height height
:x x