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 0000000000..8b0fdc9c61 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png differ 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); }