From e2b5f936f592d5ac626b45b224f54014c8dca82d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 23 Feb 2026 07:23:12 +0100 Subject: [PATCH] :bug: Fix stroke artifacts --- .../get-file-inner-strokes-artifacts.json | 814 ++++++++++++++++++ .../ui/render-wasm-specs/shapes.spec.js | 24 + .../Check-inner-stroke-artifacts-1.png | Bin 0 -> 31969 bytes render-wasm/src/render.rs | 23 +- render-wasm/src/render/fills.rs | 62 +- render-wasm/src/render/shadows.rs | 8 +- render-wasm/src/render/strokes.rs | 46 +- render-wasm/src/render/surfaces.rs | 40 +- render-wasm/src/render/text.rs | 21 +- 9 files changed, 981 insertions(+), 57 deletions(-) create mode 100644 frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json create mode 100644 frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png diff --git a/frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json b/frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json new file mode 100644 index 0000000000..5987b72225 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json @@ -0,0 +1,814 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "gaps", + "~:revn": 79, + "~:modified-at": "~m1771855365377", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c863f", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node" + ] + }, + "~:version": 67, + "~:project-id": "~ueba8fa2e-4140-8084-8005-448635da32b4", + "~:created-at": "~m1771591980210", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640" + ], + "~:pages-index": { + "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 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 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 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, + "~:r4": 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": [ + "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e", + "~ufbc43ead-a2ce-8058-8007-9a0daf843e09", + "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8", + "~u5bebb998-d617-801b-8007-9a3fbd5cc804", + "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40" + ] + } + }, + "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD/f5dEM2EsRAIAAAAAAAAAAAAAAAAAAAAAAAAAUhmnRABACkQCAAAAAAAAAAAAAAAAAAAAAAAAAP8/vET//01EAgAAAAAAAAAAAAAAAAAAAAAAAAD/f5dEM2EsRA==" + }, + "~:name": "Path", + "~:width": null, + "~:type": "~:path", + "~:points": [ + { + "~#point": { + "~:x": 1212.00003372852, + "~:y": 553.000012923003 + } + }, + { + "~#point": { + "~:x": 1506.00004755679, + "~:y": 553.000012923003 + } + }, + { + "~#point": { + "~:x": 1506.00004755679, + "~:y": 823.999993849517 + } + }, + { + "~#point": { + "~:x": 1212.00003372852, + "~:y": 823.999993849517 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": null, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1212.00003372852, + "~:y": 553.000012923003, + "~:width": 294.000013828278, + "~:height": 270.999980926514, + "~:x1": 1212.00003372852, + "~:y1": 553.000012923003, + "~:x2": 1506.00004755679, + "~:y2": 823.999993849517 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e": { + "~#shape": { + "~:y": 122.000001761754, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 463.999987447937, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 694.000014750112, + "~:y": 122.000001761754 + } + }, + { + "~#point": { + "~:x": 1158.00000219805, + "~:y": 122.000001761754 + } + }, + { + "~#point": { + "~:x": 1158.00000219805, + "~:y": 499.999980116278 + } + }, + { + "~#point": { + "~:x": 694.000014750112, + "~:y": 499.999980116278 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + }, + { + "~:stroke-alignment": "~:outer", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + } + ], + "~:x": 694.000014750113, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 694.000014750113, + "~:y": 122.000001761754, + "~:width": 463.999987447937, + "~:height": 377.999978354524, + "~:x1": 694.000014750113, + "~:y1": 122.000001761754, + "~:x2": 1158.00000219805, + "~:y2": 499.999980116278 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 377.999978354524, + "~:flip-y": null + } + }, + "~ufbc43ead-a2ce-8058-8007-9a0daf843e09": { + "~#shape": { + "~:y": 262.999997589325, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 266.000036716461, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1271.00000137752, + "~:y": 262.999997589325 + } + }, + { + "~#point": { + "~:x": 1537.00003809398, + "~:y": 262.999997589325 + } + }, + { + "~#point": { + "~:x": 1537.00003809398, + "~:y": 483.000033828949 + } + }, + { + "~#point": { + "~:x": 1271.00000137752, + "~:y": 483.000033828949 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ufbc43ead-a2ce-8058-8007-9a0daf843e09", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": 1271.00000137752, + "~:proportion": 1, + "~:shadow": [ + { + "~:id": "~u9c6321b5-aeab-809f-8007-971f9e232191", + "~:style": "~:drop-shadow", + "~:color": { + "~:color": "#000000", + "~:opacity": 1 + }, + "~:offset-x": 4, + "~:offset-y": 4, + "~:blur": 0, + "~:spread": 0, + "~:hidden": true + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1271.00000137752, + "~:y": 262.999997589325, + "~:width": 266.000036716461, + "~:height": 220.000036239624, + "~:x1": 1271.00000137752, + "~:y1": 262.999997589325, + "~:x2": 1537.00003809398, + "~:y2": 483.000033828949 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 220.000036239624, + "~:flip-y": null + } + }, + "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40": { + "~#shape": { + "~:y": -286.999972473494, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "1srkh8oc2vd", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "170uyffw5ph", + "~:font-size": "400", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "psg8ayj675", + "~:font-size": "400", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO", + "~:width": 1116.00003953244, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 545.000013504691, + "~:y": -286.999972473494 + } + }, + { + "~#point": { + "~:x": 1661.00005303713, + "~:y": -286.999972473494 + } + }, + { + "~#point": { + "~:x": 1661.00005303713, + "~:y": 193.000017549648 + } + }, + { + "~#point": { + "~:x": 545.000013504691, + "~:y": 193.000017549648 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 211.980041503906, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "400", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1115.22998046875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 545, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 517.960021972656, + "~:text": "HELLO" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 5, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 545.000013504691, + "~:selrect": { + "~#rect": { + "~:x": 545.000013504691, + "~:y": -286.999972473494, + "~:width": 1116.00003953244, + "~:height": 479.999990023141, + "~:x1": 545.000013504691, + "~:y1": -286.999972473494, + "~:x2": 1661.00005303713, + "~:y2": 193.000017549648 + } + }, + "~:flip-x": null, + "~:height": 479.999990023141, + "~:flip-y": null + } + }, + "~u5bebb998-d617-801b-8007-9a3fbd5cc804": { + "~#shape": { + "~:y": 543.00001095581, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 463.999987447937, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 693.999990768432, + "~:y": 543.00001095581 + } + }, + { + "~#point": { + "~:x": 1157.99997821637, + "~:y": 543.00001095581 + } + }, + { + "~#point": { + "~:x": 1157.99997821637, + "~:y": 920.999989310334 + } + }, + { + "~#point": { + "~:x": 693.999990768432, + "~:y": 920.999989310334 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u5bebb998-d617-801b-8007-9a3fbd5cc804", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + } + ], + "~:x": 693.999990768432, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 693.999990768432, + "~:y": 543.00001095581, + "~:width": 463.999987447937, + "~:height": 377.999978354524, + "~:x1": 693.999990768432, + "~:y1": 543.00001095581, + "~:x2": 1157.99997821637, + "~:y2": 920.999989310334 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 377.999978354524, + "~:flip-y": null + } + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640", + "~:name": "Page 1", + "~:background": "#000000" + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c863f", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } + } \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 9a4b26809b..242f0bf6d2 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -432,3 +432,27 @@ test("Keeps component visible when focusing after creating it", async ({ await workspace.hideUI(); await expect(workspace.canvas).toHaveScreenshot(); }); + +test("Check inner stroke artifacts", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-inner-strokes-artifacts.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-9a0b2e2c863f", + pageId: "effcbebc-b8c8-802f-8007-9a0b2e2c8640", + }); + await workspace.waitForFirstRenderWithoutUI(); + + const previousRenderCount = await workspace.getRenderCount(); + await page.keyboard.press("ControlOrMeta++"); + await workspace.waitForNextRender(previousRenderCount); + + // Stricter comparison: artifacts are very subtle + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.1, + }); +}); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png new file mode 100644 index 0000000000000000000000000000000000000000..8b0fdc9c619dd7bbf4dcdd86b2739feb000e2d8a GIT binary patch literal 31969 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 aTV?Icl%!?jQzGTJzCUtoz}^uvzWg8A;;`NT literal 0 HcmV?d00001 diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 2265ef4de7..1493b9851e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -642,7 +642,7 @@ impl RenderState { apply_to_current_surface: bool, offset: Option<(f32, f32)>, parent_shadows: Option>, - spread: Option, + outset: Option, ) { let surface_ids = fills_surface_id as u32 | strokes_surface_id as u32 @@ -718,7 +718,7 @@ impl RenderState { &visible_strokes, Some(SurfaceId::Current), antialias, - spread, + outset, ); self.surfaces.apply_mut(SurfaceId::Current as u32, |s| { @@ -860,6 +860,8 @@ impl RenderState { let text_content = text_content.new_bounds(shape.selrect()); let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); @@ -886,6 +888,7 @@ impl RenderState { Some(fills_surface_id), None, None, + text_fill_inset, ); for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { @@ -898,6 +901,7 @@ impl RenderState { None, None, text_stroke_blur_outset, + None, ); } } else { @@ -936,6 +940,7 @@ impl RenderState { text_drop_shadows_surface_id.into(), Some(&shadow), blur_filter.as_ref(), + None, ); } } else { @@ -961,6 +966,7 @@ impl RenderState { text_drop_shadows_surface_id.into(), Some(shadow), blur_filter.as_ref(), + None, ); } } @@ -974,6 +980,7 @@ impl RenderState { Some(fills_surface_id), None, blur_filter.as_ref(), + text_fill_inset, ); // 3. Stroke drop shadows @@ -998,6 +1005,7 @@ impl RenderState { None, blur_filter.as_ref(), text_stroke_blur_outset, + None, ); } @@ -1023,6 +1031,7 @@ impl RenderState { Some(innershadows_surface_id), Some(shadow), blur_filter.as_ref(), + None, ); } } @@ -1070,7 +1079,7 @@ impl RenderState { &fills_to_render, antialias, fills_surface_id, - spread, + outset, ); } } else { @@ -1080,7 +1089,7 @@ impl RenderState { &shape.fills, antialias, fills_surface_id, - spread, + outset, ); } @@ -1096,7 +1105,7 @@ impl RenderState { &visible_strokes, Some(strokes_surface_id), antialias, - spread, + outset, ); if !fast_mode { for stroke in &visible_strokes { @@ -1715,7 +1724,7 @@ impl RenderState { false, Some(shadow.offset), // Offset is geometric None, - Some(shadow.spread), // Spread is geometric + Some(shadow.spread), ); }); @@ -1756,7 +1765,7 @@ impl RenderState { false, Some(shadow.offset), // Offset is geometric None, - Some(shadow.spread), // Spread is geometric + Some(shadow.spread), ); }); diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 399f6bbf19..6e098c9752 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -2,7 +2,15 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::render::get_source_rect; -use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; +use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, StrokeKind, Type}; + +/// True when the shape has at least one visible inner stroke. +fn has_inner_stroke(shape: &Shape) -> bool { + let is_open = shape.is_open(); + shape + .visible_strokes() + .any(|s| s.render_kind(is_open) == StrokeKind::Inner) +} fn draw_image_fill( render_state: &mut RenderState, @@ -100,18 +108,33 @@ pub fn render( fills: &[Fill], antialias: bool, surface_id: SurfaceId, - spread: Option, + outset: Option, ) { if fills.is_empty() { return; } + let scale = render_state.get_scale().max(1e-6); + let inset = if has_inner_stroke(shape) { + Some(1.0 / scale) + } else { + None + }; + // Image fills use draw_image_fill which needs render_state for GPU images // and sampling options that get_fill_shader (used by merge_fills) lacks. let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); if has_image_fills { for fill in fills.iter().rev() { - render_single_fill(render_state, shape, fill, antialias, surface_id, spread); + render_single_fill( + render_state, + shape, + fill, + antialias, + surface_id, + outset, + inset, + ); } return; } @@ -128,7 +151,7 @@ pub fn render( |state, temp_surface| { let mut filtered_paint = paint.clone(); filtered_paint.set_image_filter(image_filter.clone()); - draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, spread); + draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, outset, inset); }, ) { return; @@ -137,33 +160,35 @@ pub fn render( } } - draw_fill_to_surface(render_state, shape, surface_id, &paint, spread); + draw_fill_to_surface(render_state, shape, surface_id, &paint, outset, inset); } /// Draws a single paint (with a merged shader) to the appropriate surface /// based on the shape type. +/// When `inset` is Some(eps), the fill is inset by eps (e.g. to avoid seam with inner strokes). fn draw_fill_to_surface( render_state: &mut RenderState, shape: &Shape, surface_id: SurfaceId, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { match &shape.shape_type { Type::Rect(_) | Type::Frame(_) => { render_state .surfaces - .draw_rect_to(surface_id, shape, paint, spread); + .draw_rect_to(surface_id, shape, paint, outset, inset); } Type::Circle => { render_state .surfaces - .draw_circle_to(surface_id, shape, paint, spread); + .draw_circle_to(surface_id, shape, paint, outset, inset); } Type::Path(_) | Type::Bool(_) => { render_state .surfaces - .draw_path_to(surface_id, shape, paint, spread); + .draw_path_to(surface_id, shape, paint, outset, inset); } Type::Group(_) => {} _ => unreachable!("This shape should not have fills"), @@ -176,7 +201,8 @@ fn render_single_fill( fill: &Fill, antialias: bool, surface_id: SurfaceId, - spread: Option, + outset: Option, + inset: Option, ) { let mut paint = fill.to_paint(&shape.selrect, antialias); if let Some(image_filter) = shape.image_filter(1.) { @@ -195,7 +221,8 @@ fn render_single_fill( antialias, temp_surface, &filtered_paint, - spread, + outset, + inset, ); }, ) { @@ -212,10 +239,12 @@ fn render_single_fill( antialias, surface_id, &paint, - spread, + outset, + inset, ); } +#[allow(clippy::too_many_arguments)] fn draw_single_fill_to_surface( render_state: &mut RenderState, shape: &Shape, @@ -223,7 +252,8 @@ fn draw_single_fill_to_surface( antialias: bool, surface_id: SurfaceId, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { match (fill, &shape.shape_type) { (Fill::Image(image_fill), _) => { @@ -239,17 +269,17 @@ fn draw_single_fill_to_surface( (_, Type::Rect(_) | Type::Frame(_)) => { render_state .surfaces - .draw_rect_to(surface_id, shape, paint, spread); + .draw_rect_to(surface_id, shape, paint, outset, inset); } (_, Type::Circle) => { render_state .surfaces - .draw_circle_to(surface_id, shape, paint, spread); + .draw_circle_to(surface_id, shape, paint, outset, inset); } (_, Type::Path(_)) | (_, Type::Bool(_)) => { render_state .surfaces - .draw_path_to(surface_id, shape, paint, spread); + .draw_path_to(surface_id, shape, paint, outset, inset); } (_, Type::Group(_)) => { // Groups can have fills but they propagate them to their children diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index 4906714389..b5077f0688 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -109,17 +109,17 @@ fn render_shadow_paint( Type::Rect(_) | Type::Frame(_) => { render_state .surfaces - .draw_rect_to(surface_id, shape, paint, None); + .draw_rect_to(surface_id, shape, paint, None, None); } Type::Circle => { render_state .surfaces - .draw_circle_to(surface_id, shape, paint, None); + .draw_circle_to(surface_id, shape, paint, None, None); } Type::Path(_) | Type::Bool(_) => { render_state .surfaces - .draw_path_to(surface_id, shape, paint, None); + .draw_path_to(surface_id, shape, paint, None, None); } _ => {} } @@ -154,6 +154,7 @@ pub fn render_text_shadows( surface_id, None, blur_filter.as_ref(), + None, ); for stroke_paragraphs in stroke_paragraphs_group.iter_mut() { @@ -165,6 +166,7 @@ pub fn render_text_shadows( surface_id, None, blur_filter.as_ref(), + None, ); } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 38228f32d4..e18b5f5e63 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -526,7 +526,7 @@ pub fn render( strokes: &[&Stroke], surface_id: Option, antialias: bool, - spread: Option, + outset: Option, ) { if strokes.is_empty() { return; @@ -541,8 +541,8 @@ pub fn render( // edges semi-transparent and revealing strokes underneath. if let Some(image_filter) = shape.image_filter(1.) { let mut content_bounds = shape.selrect; - // Expand for spread if provided - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand for outset if provided + if let Some(s) = outset.filter(|&s| s > 0.0) { content_bounds.outset((s, s)); } let max_margin = strokes @@ -588,7 +588,7 @@ pub fn render( antialias, true, true, - spread, + outset, ); } @@ -608,7 +608,7 @@ pub fn render( surface_id, None, antialias, - spread, + outset, ); } return; @@ -621,7 +621,7 @@ pub fn render( surface_id, antialias, false, - spread, + outset, ); } @@ -642,7 +642,7 @@ fn render_merged( surface_id: Option, antialias: bool, bypass_filter: bool, - spread: Option, + outset: Option, ) { let representative = *strokes .last() @@ -658,8 +658,8 @@ fn render_merged( if !bypass_filter { if let Some(image_filter) = blur_filter.clone() { let mut content_bounds = shape.selrect; - // Expand for spread if provided - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand for outset if provided + if let Some(s) = outset.filter(|&s| s > 0.0) { content_bounds.outset((s, s)); } let stroke_margin = representative.bounds_width(shape.is_open()); @@ -694,7 +694,7 @@ fn render_merged( Some(temp_surface), antialias, true, - spread, + outset, ); state.surfaces.apply_mut(temp_surface as u32, |surface| { @@ -711,8 +711,8 @@ fn render_merged( // via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top). let fills: Vec = strokes.iter().map(|s| s.fill.clone()).collect(); - // Expand selrect if spread is provided - let selrect = if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand selrect if outset is provided + let selrect = if let Some(s) = outset.filter(|&s| s > 0.0) { let mut r = shape.selrect; r.outset((s, s)); r @@ -790,7 +790,7 @@ pub fn render_single( surface_id: Option, shadow: Option<&ImageFilter>, antialias: bool, - spread: Option, + outset: Option, ) { render_single_internal( render_state, @@ -801,7 +801,7 @@ pub fn render_single( antialias, false, false, - spread, + outset, ); } @@ -815,13 +815,13 @@ fn render_single_internal( antialias: bool, bypass_filter: bool, skip_blur: bool, - spread: Option, + outset: Option, ) { if !bypass_filter { if let Some(image_filter) = shape.image_filter(1.) { let mut content_bounds = shape.selrect; - // Expand for spread if provided - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand for outset if provided + if let Some(s) = outset.filter(|&s| s > 0.0) { content_bounds.outset((s, s)); } let stroke_margin = stroke.bounds_width(shape.is_open()); @@ -849,7 +849,7 @@ fn render_single_internal( antialias, true, true, - spread, + outset, ); }, ) { @@ -920,18 +920,18 @@ fn render_single_internal( let is_open = path.is_open(); let mut paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); - // Apply spread by increasing stroke width - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Apply outset by increasing stroke width + if let Some(s) = outset.filter(|&s| s > 0.0) { let current_width = paint.stroke_width(); // Path stroke kinds are built differently: // - Center uses the stroke width directly. // - Inner/Outer use a doubled width plus clipping/clearing logic. - // Compensate spread so visual growth is comparable across kinds. - let spread_growth = match stroke.render_kind(is_open) { + // Compensate outset so visual growth is comparable across kinds. + let outset_growth = match stroke.render_kind(is_open) { StrokeKind::Center => s * 2.0, StrokeKind::Inner | StrokeKind::Outer => s * 4.0, }; - paint.set_stroke_width(current_width + spread_growth); + paint.set_stroke_width(current_width + outset_growth); } draw_stroke_on_path( canvas, diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 8c6780cd52..5bb227ab04 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -360,16 +360,30 @@ impl Surfaces { id: SurfaceId, shape: &Shape, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { - let rect = if let Some(s) = spread.filter(|&s| s > 0.0) { + let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) { let mut r = shape.selrect; r.outset((s, s)); r } else { shape.selrect }; + if let Some(eps) = inset.filter(|&e| e > 0.0) { + rect.inset((eps, eps)); + } if let Some(corners) = shape.shape_type.corners() { + let corners = if let Some(eps) = inset.filter(|&e| e > 0.0) { + let mut c = corners; + for r in c.iter_mut() { + r.x = (r.x - eps).max(0.0); + r.y = (r.y - eps).max(0.0); + } + c + } else { + corners + }; let rrect = RRect::new_rect_radii(rect, &corners); self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint); } else { @@ -382,15 +396,19 @@ impl Surfaces { id: SurfaceId, shape: &Shape, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { - let rect = if let Some(s) = spread.filter(|&s| s > 0.0) { + let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) { let mut r = shape.selrect; r.outset((s, s)); r } else { shape.selrect }; + if let Some(eps) = inset.filter(|&e| e > 0.0) { + rect.inset((eps, eps)); + } self.canvas_and_mark_dirty(id).draw_oval(rect, paint); } @@ -399,17 +417,27 @@ impl Surfaces { id: SurfaceId, shape: &Shape, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { if let Some(path) = shape.get_skia_path() { let canvas = self.canvas_and_mark_dirty(id); - if let Some(s) = spread.filter(|&s| s > 0.0) { + if let Some(s) = outset.filter(|&s| s > 0.0) { // Draw path as a thick stroke to get outset (expanded) silhouette let mut stroke_paint = paint.clone(); stroke_paint.set_stroke_width(s * 2.0); canvas.draw_path(&path, &stroke_paint); } else { canvas.draw_path(&path, paint); + // Inset: avoid seam with inner strokes by clearing a thin border from the fill + if let Some(eps) = inset.filter(|&e| e > 0.0) { + let mut clear_paint = skia::Paint::default(); + clear_paint.set_style(skia::PaintStyle::Stroke); + clear_paint.set_stroke_width(eps * 2.0); + clear_paint.set_blend_mode(skia::BlendMode::Clear); + clear_paint.set_anti_alias(paint.is_anti_alias()); + canvas.draw_path(&path, &clear_paint); + } } } } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 2761962071..42c35b450f 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -166,6 +166,7 @@ pub fn render_with_bounds_outset( shadow: Option<&Paint>, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, + fill_inset: Option, ) { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -193,6 +194,7 @@ pub fn render_with_bounds_outset( paragraph_builders, shadow, Some(&blur_filter_clone), + fill_inset, ); }, ) { @@ -202,15 +204,16 @@ pub fn render_with_bounds_outset( } let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur); + render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); return; } if let Some(canvas) = canvas { - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur); + render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); } } +#[allow(clippy::too_many_arguments)] pub fn render( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, @@ -219,6 +222,7 @@ pub fn render( surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, + fill_inset: Option, ) { render_with_bounds_outset( render_state, @@ -229,6 +233,7 @@ pub fn render( shadow, blur, 0.0, + fill_inset, ); } @@ -238,6 +243,7 @@ fn render_text_on_canvas( paragraph_builders: &mut [Vec], shadow: Option<&Paint>, blur: Option<&ImageFilter>, + fill_inset: Option, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -251,6 +257,17 @@ fn render_text_on_canvas( canvas.save_layer(&layer_rec); draw_text(canvas, shape, paragraph_builders); canvas.restore(); + } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { + if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { + let mut layer_paint = Paint::default(); + layer_paint.set_image_filter(erode); + let layer_rec = SaveLayerRec::default().paint(&layer_paint); + canvas.save_layer(&layer_rec); + draw_text(canvas, shape, paragraph_builders); + canvas.restore(); + } else { + draw_text(canvas, shape, paragraph_builders); + } } else { draw_text(canvas, shape, paragraph_builders); }