🐛 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] ([shape]
(get-shape-filter-bounds shape false)) (get-shape-filter-bounds shape false))
([shape ignore-shadow-margin?] ([shape ignore-shadow-margin?]
(if (or (and (cfh/svg-raw-shape? shape) (cond
(not= :svg (dm/get-in shape [:content :tag]))) ;; SVG raw elements (non-root) don't have proper rotated points; use selrect
;; If no shadows or blur, we return the selrect as is (and (cfh/svg-raw-shape? shape)
(and (empty? (-> shape :shadow)) (not= :svg (dm/get-in shape [:content :tag])))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0))))))
(dm/get-prop shape :selrect) (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) (let [filters (shape->filters shape)
blur-value (case (-> shape :blur :type) blur-value (case (-> shape :blur :type)
:layer-blur (or (-> shape :blur :value) 0) :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() { async setupFileWithComments() {
await this.mockRPC( await this.mockRPC(
/get\-view\-only\-bundle\?/, /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 (defn- calculate-size
"Calculate the total size we must reserve for the frame, including possible paddings "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] [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-width width
:base-height height :base-height height
:x x :x x