From 800eb8fc776063f3bcc712d69affdfa530368629 Mon Sep 17 00:00:00 2001 From: Mad Dinh <70377017+dennytosp@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:58:37 +0700 Subject: [PATCH 1/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20swap=20the=20skew=20sh?= =?UTF-8?q?ear=20axes=20in=20the=20native=20recorder=20too=20(#4020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4015 fixed processTransform3d but missed the C++ mirror of it in the native recorder, which builds the same matrix for transform props with the same two axes swapped. The SkM44 constructor reads its arguments row-major, so skewX has to put tan(angle) in row 0 - exactly like the TypeScript version it shadows. Since the snapshots were regenerated for the corrected behaviour, native now disagrees with them and Transforms.spec fails on main for both test-android and build-test-ios-graphite. Co-authored-by: William Candillon --- packages/skia/cpp/api/recorder/Convertor.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/skia/cpp/api/recorder/Convertor.h b/packages/skia/cpp/api/recorder/Convertor.h index f3c9e0c18a..d2fcef4fec 100644 --- a/packages/skia/cpp/api/recorder/Convertor.h +++ b/packages/skia/cpp/api/recorder/Convertor.h @@ -361,13 +361,16 @@ SkM44 getPropertyValue(jsi::Runtime &runtime, const jsi::Value &value) { m4.preScale(1, s); } else if (key == "skewX") { auto angle = value.getProperty(runtime, key.c_str()).asNumber(); - SkM44 skewX(1, 0, 0, 0, std::tan(angle), 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, + // The SkM44 constructor takes its arguments in row-major reading + // order, so the shear factor of a horizontal skew belongs in row 0, + // where it scales y into x. + SkM44 skewX(1, std::tan(angle), 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); m4.preConcat(skewX); } else if (key == "skewY") { auto angle = value.getProperty(runtime, key.c_str()).asNumber(); - SkM44 skewY(1, std::tan(angle), 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, + SkM44 skewY(1, 0, 0, 0, std::tan(angle), 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); m4.preConcat(skewY); } else if (key == "rotate" || key == "rotateZ") { From 701c4b990b0ea44c8414692ff3d1109fbcd94dd2 Mon Sep 17 00:00:00 2001 From: Bao Nguyen <39545125+giaBaoJS@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:02:24 +0700 Subject: [PATCH 2/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20derive=20the=20inner?= =?UTF-8?q?=20shadow=20from=20the=20shape=20outline,=20not=20its=20opacity?= =?UTF-8?q?=20(#4023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(๐Ÿ›): derive the inner shadow from the shape outline, not its opacity An inner shadow is generated outside the shape, offset, blurred, and clipped back into it. "Outside" is produced with `SrcOut` against the source graphic, which yields `shadowColor x (1 - dst.alpha)` - the complement of the source's *alpha*, not of its silhouette. Inside a translucent shape `1 - alpha` is non-zero, so the shadow is generated across the whole interior and then clipped onto it, tinting the shape towards the shadow colour. The extent of the effect is decoupled from the blur radius and the offset: a shadow with `blur={0} dx={0} dy={0}`, which cannot move anything from outside the shape to inside it, still tints a translucent shape uniformly. Saturating alpha before the `SrcOut` makes the complement geometric again: the shadow is generated only outside the outline, and its reach is bounded by the blur and the offset, for any fill opacity. Opaque shapes are unaffected, which is why the existing inner shadow baselines do not move. The same graph is built twice - once in the TypeScript player used on web and in the JS recorder, once in `cpp/api/recorder/ImageFilters.h` for iOS, Android and macOS - so both are updated. --------- Co-authored-by: William Candillon --- packages/skia/cpp/api/recorder/ImageFilters.h | 16 +- .../drawings/inner-shadow-opaque.png | Bin 0 -> 6945 bytes .../inner-shadow-translucent-blur.png | Bin 0 -> 12557 bytes .../inner-shadow-translucent-center.png | Bin 0 -> 4073 bytes .../drawings/inner-shadow-translucent.png | Bin 0 -> 6704 bytes .../__tests__/e2e/InnerShadow.spec.tsx | 155 ++++++++++++++++++ .../sksg/Recorder/commands/ImageFilters.ts | 21 ++- 7 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 packages/skia/src/__tests__/snapshots/drawings/inner-shadow-opaque.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/inner-shadow-translucent-blur.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/inner-shadow-translucent-center.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/inner-shadow-translucent.png create mode 100644 packages/skia/src/renderer/__tests__/e2e/InnerShadow.spec.tsx diff --git a/packages/skia/cpp/api/recorder/ImageFilters.h b/packages/skia/cpp/api/recorder/ImageFilters.h index 1a50a403c0..886f85c821 100644 --- a/packages/skia/cpp/api/recorder/ImageFilters.h +++ b/packages/skia/cpp/api/recorder/ImageFilters.h @@ -135,8 +135,22 @@ class DropShadowImageFilterCmd : public Command { auto sourceAlpha = SkImageFilters::ColorFilter( SkColorFilters::Blend(SK_ColorBLACK, SkBlendMode::kSrcIn), nullptr); + // The shadow is generated outside the shape and then clipped back into it, + // so "outside" has to be the complement of the shape's silhouette. Taking + // SrcOut against the source graphic itself makes it the complement of the + // source's *alpha*: inside a translucent shape 1 - alpha is non-zero, so + // the shadow is generated across the whole interior and tints it, no matter + // how small the blur and the offset are (issue #2990). Saturating alpha + // first keeps the shadow tied to the shape's outline instead of to its + // opacity. 255 is the saturation point of an 8-bit alpha channel, so one + // unit of coverage is enough to reach 1. + static constexpr float kAlphaSaturate[20] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0}; + auto coverage = SkImageFilters::ColorFilter( + SkColorFilters::Matrix(kAlphaSaturate), nullptr); + auto f1 = SkImageFilters::ColorFilter( - SkColorFilters::Blend(color, SkBlendMode::kSrcOut), nullptr); + SkColorFilters::Blend(color, SkBlendMode::kSrcOut), coverage); auto f2 = SkImageFilters::Offset(dx, dy, f1); auto f3 = SkImageFilters::Blur(sigmaX, sigmaY, SkTileMode::kDecal, f2); diff --git a/packages/skia/src/__tests__/snapshots/drawings/inner-shadow-opaque.png b/packages/skia/src/__tests__/snapshots/drawings/inner-shadow-opaque.png new file mode 100644 index 0000000000000000000000000000000000000000..37dc2ea7e9d5585e5092ad94d698f84e3f819c19 GIT binary patch literal 6945 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 HP*43EuyT_$mS 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 literal 0 HcmV?d00001 diff --git a/packages/skia/src/__tests__/snapshots/drawings/inner-shadow-translucent-center.png b/packages/skia/src/__tests__/snapshots/drawings/inner-shadow-translucent-center.png new file mode 100644 index 0000000000000000000000000000000000000000..df1fe0019b0811ca0dce36027769a9a79fbf0998 GIT binary patch literal 4073 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 Qw9K%6jkiaEd+?$E0GM})Y5)KL literal 0 HcmV?d00001 diff --git a/packages/skia/src/renderer/__tests__/e2e/InnerShadow.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/InnerShadow.spec.tsx new file mode 100644 index 0000000000..2a2fd1e1c4 --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/InnerShadow.spec.tsx @@ -0,0 +1,155 @@ +import React from "react"; + +import { checkImage } from "../../../__tests__/setup"; +import { Fill, Group, RoundedRect, Shadow } from "../../components"; +import { importSkia, surface } from "../setup"; + +// An inner shadow is generated outside the shape and clipped back into it, so +// "outside" has to be the complement of the shape's silhouette. Taking SrcOut +// against the source graphic makes it the complement of the source's alpha +// instead: inside a translucent shape 1 - alpha is non-zero, so the shadow is +// generated across the whole interior and tints it - even when the blur and the +// offset are both zero (issue #2990). +// +// The reference results are drawn without a shadow, so they don't encode the +// behaviour under test. + +describe("Inner shadow", () => { + it("Build reference result", async () => { + const image = await surface.draw( + <> + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-translucent.png"); + }); + + it("should be a no-op without blur and offset on a translucent shape", async () => { + // Nothing outside the shape is moved or spread into it, so there is nothing + // to draw. + const image = await surface.draw( + <> + + + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-translucent.png"); + }); + + it("Build opaque reference result", async () => { + const image = await surface.draw( + <> + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-opaque.png"); + }); + + it("should be a no-op without blur and offset on an opaque shape", async () => { + // Regression guard: the opaque case is already a no-op and has to stay one. + const image = await surface.draw( + <> + + + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-opaque.png"); + }); + + it("Build centered reference result", async () => { + const { rect } = importSkia(); + const image = await surface.draw( + <> + + + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-translucent-center.png"); + }); + + it("should stay within reach of the blur on a translucent shape", async () => { + // The center of the shape is 48px away from its closest edge, far outside + // the reach of a 4px blur, so the shadow cannot touch it. + const { rect } = importSkia(); + const image = await surface.draw( + <> + + + + + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-translucent-center.png"); + }); + + it("should draw the blurred shadow along the edges of a translucent shape", async () => { + const image = await surface.draw( + <> + + + + + + ); + checkImage(image, "snapshots/drawings/inner-shadow-translucent-blur.png"); + }); +}); diff --git a/packages/skia/src/sksg/Recorder/commands/ImageFilters.ts b/packages/skia/src/sksg/Recorder/commands/ImageFilters.ts index 6b1726b650..af27c99995 100644 --- a/packages/skia/src/sksg/Recorder/commands/ImageFilters.ts +++ b/packages/skia/src/sksg/Recorder/commands/ImageFilters.ts @@ -31,6 +31,14 @@ export enum MorphologyOperator { const Black = Float32Array.of(0, 0, 0, 1); +// Turns the source graphic into a hard silhouette: alpha is multiplied by 255 +// and clamped, so any pixel the shape covers at all becomes fully opaque, and +// the colour channels are dropped. 255 is the saturation point of an 8-bit +// alpha channel, so one unit of coverage is enough to reach 1. +const AlphaSaturate = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, +]; + const MakeInnerShadow = ( Skia: Skia, shadowOnly: boolean | undefined, @@ -50,9 +58,20 @@ const MakeInnerShadow = ( Skia.ColorFilter.MakeBlend(Black, BlendMode.SrcIn), null ); + // The shadow is generated outside the shape and then clipped back into it, so + // "outside" has to be the complement of the shape's silhouette. Taking SrcOut + // against the source graphic itself makes it the complement of the source's + // *alpha*: inside a translucent shape 1 - alpha is non-zero, so the shadow is + // generated across the whole interior and tints it, no matter how small the + // blur and the offset are (issue #2990). Saturating alpha first keeps the + // shadow tied to the shape's outline instead of to its opacity. + const coverage = Skia.ImageFilter.MakeColorFilter( + Skia.ColorFilter.MakeMatrix(AlphaSaturate), + null + ); const f1 = Skia.ImageFilter.MakeColorFilter( Skia.ColorFilter.MakeBlend(color, BlendMode.SrcOut), - null + coverage ); const f2 = Skia.ImageFilter.MakeOffset(dx, dy, f1); const f3 = Skia.ImageFilter.MakeBlur(sigmaX, sigmaY, TileMode.Decal, f2); From 73b5857d2544315f189e7da12768f22f2f2e06d2 Mon Sep 17 00:00:00 2001 From: Mad Dinh <70377017+dennytosp@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:03:04 +0700 Subject: [PATCH 3/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20scale=20SkMatrix=20uni?= =?UTF-8?q?formly=20when=20y=20is=20omitted=20(#4016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(๐Ÿ›): scale SkMatrix uniformly when y is omitted SkMatrix.scale() and postScale() take an optional y. The CanvasKit backend falls back to x for a uniform scale, but the JSI bindings fell back to 1, so matrix.scale(0.5) halved only the x axis on iOS and Android while halving both on Web. The renderer's own "Scale with origin using a matrix" test pins the uniform result ([0.5, 0, 192, 0, 0.5, 192, 0, 0, 1]), but it runs on CanvasKit only, so the native path was never covered. * test(๐Ÿงช): run the SkMatrix scale spec against native too Move it to renderer/__tests__/e2e and go through surface.eval, so the JSI bindings this fixes are actually exercised. As a plain unit test it only ever ran against CanvasKit, which was already correct. --------- Co-authored-by: William Candillon --- packages/skia/cpp/api/JsiSkMatrix.h | 6 ++-- .../src/renderer/__tests__/e2e/Matrix.spec.ts | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 packages/skia/src/renderer/__tests__/e2e/Matrix.spec.ts diff --git a/packages/skia/cpp/api/JsiSkMatrix.h b/packages/skia/cpp/api/JsiSkMatrix.h index a7c6e531ce..243f80dc8c 100644 --- a/packages/skia/cpp/api/JsiSkMatrix.h +++ b/packages/skia/cpp/api/JsiSkMatrix.h @@ -69,12 +69,14 @@ class JsiSkMatrix void postTranslate(double x, double y) { getObject()->postTranslate(x, y); } + // A missing y means a uniform scale, matching the CanvasKit backend and the + // SkMatrix type, where y is optional rather than defaulted to 1. void scale(double x, JsiOptional y) { - getObject()->preScale(x, y.has_value() ? *y : 1); + getObject()->preScale(x, y.has_value() ? *y : x); } void postScale(double x, JsiOptional y) { - getObject()->postScale(x, y.has_value() ? *y : 1); + getObject()->postScale(x, y.has_value() ? *y : x); } void skew(double x, double y) { getObject()->preSkew(x, y); } diff --git a/packages/skia/src/renderer/__tests__/e2e/Matrix.spec.ts b/packages/skia/src/renderer/__tests__/e2e/Matrix.spec.ts new file mode 100644 index 0000000000..9bd7520adf --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/Matrix.spec.ts @@ -0,0 +1,30 @@ +import { surface } from "../setup"; + +describe("SkMatrix", () => { + it("scales uniformly when y is omitted", async () => { + const result = await surface.eval((Skia) => { + const matrix = Skia.Matrix(); + matrix.scale(2); + return matrix.get(); + }); + expect(result).toEqual([2, 0, 0, 0, 2, 0, 0, 0, 1]); + }); + + it("postScales uniformly when y is omitted", async () => { + const result = await surface.eval((Skia) => { + const matrix = Skia.Matrix(); + matrix.postScale(2); + return matrix.get(); + }); + expect(result).toEqual([2, 0, 0, 0, 2, 0, 0, 0, 1]); + }); + + it("keeps the two axes independent when y is given", async () => { + const result = await surface.eval((Skia) => { + const matrix = Skia.Matrix(); + matrix.scale(2, 3); + return matrix.get(); + }); + expect(result).toEqual([2, 0, 0, 0, 3, 0, 0, 0, 1]); + }); +}); From 942de97771b18f19b970203a8900907746edab7f Mon Sep 17 00:00:00 2001 From: Mad Dinh <70377017+dennytosp@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:03:54 +0700 Subject: [PATCH 4/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20don't=20emit=20a=20CTM?= =?UTF-8?q?=20restore=20for=20a=20CTM=20that=20never=20saved=20(#4013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(๐Ÿ›): don't emit a CTM restore for a CTM that never saved processCTM() records a SaveCTM command whenever any CTM prop is defined, and visitNode() then pairs it with an unconditional restoreCTM(). saveCTM() is narrower: it only saves the canvas when there is a transform/matrix, a clip or a layer. When a group carries a CTM prop that falls outside that set, the restore has no matching save and pops a save belonging to an ancestor, so every later sibling of that group is drawn without the ancestor's transform or clip. Three ways to hit it, all of them ordinary code: - `origin` with no `transform` or `matrix` (an identity transform, so processTransformProps2 returns null and nothing is saved); - `clip={cond && path}` where `cond` is false, which passes `clip={false}`; - `invertClip` on a group that has no clip. Emit the CTM command only when it will actually save. This is the shared visitor, so the fix covers the native recorder, the CanvasKit player and the static container at once, and it leaves the two save conditions expressed in one place instead of two that can drift. Adds a regression test covering each of the three props plus a control and a CTM that does save; the three inert cases fail without this change. * test(๐Ÿงช): assert CTM balance with checkImage Move the spec to renderer/__tests__/e2e so it runs against native as well as CanvasKit, and compare rendered images instead of reading back individual pixels. Every inert CTM case now has to match the reference scene, where the group under test has no props at all. --------- Co-authored-by: William Candillon --- .../snapshots/drawings/ctm-balance-nested.png | Bin 0 -> 4081 bytes .../snapshots/drawings/ctm-balance.png | Bin 0 -> 4078 bytes .../__tests__/e2e/CTMBalance.spec.tsx | 51 ++++++++++++++++++ packages/skia/src/sksg/Recorder/Visitor.ts | 15 +++--- 4 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 packages/skia/src/__tests__/snapshots/drawings/ctm-balance-nested.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/ctm-balance.png create mode 100644 packages/skia/src/renderer/__tests__/e2e/CTMBalance.spec.tsx diff --git a/packages/skia/src/__tests__/snapshots/drawings/ctm-balance-nested.png b/packages/skia/src/__tests__/snapshots/drawings/ctm-balance-nested.png new file mode 100644 index 0000000000000000000000000000000000000000..5d67228c5377c1cdf322e78406a2cdf0d8330c80 GIT binary patch literal 4081 zcmeAS@N?(olHy`uVBq!ia0y~yUYxb!1x)hf`EZQRK;s^ota_B#_GKc4cw!- vd$e^jnsY{*tfLL}(eBA;?`=@GboU7R3jD24x+w#!<{3O){an^LB{Ts5cCA%* literal 0 HcmV?d00001 diff --git a/packages/skia/src/__tests__/snapshots/drawings/ctm-balance.png b/packages/skia/src/__tests__/snapshots/drawings/ctm-balance.png new file mode 100644 index 0000000000000000000000000000000000000000..8687d52ae70443cc372d5ab69f9d6ace49bb316d GIT binary patch literal 4078 zcmeAS@N?(olHy`uVBq!ia0y~yUu4{%LDRyBwV;T=Vb{``no`*&F5+ZxcTt zcVGU{y|;`E32(PC}1Fds%$7$k!m-{xkx@c>j5jaJRTKp1Vg00UvP wY98EG^Jq&Nm`F!E8o)pp?Ou(vPUQ*qJt6#m{Sq$B0!BZBr>mdKI;Vst0BplmZ~y=R literal 0 HcmV?d00001 diff --git a/packages/skia/src/renderer/__tests__/e2e/CTMBalance.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/CTMBalance.spec.tsx new file mode 100644 index 0000000000..26a410d3bd --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/CTMBalance.spec.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +import { checkImage } from "../../../__tests__/setup"; +import { Fill, Group, Rect } from "../../components"; +import { surface } from "../setup"; + +// A CTM that saves nothing must not emit a restore, otherwise it pops the save +// of an enclosing group and every later sibling loses that group's transform. +// Each case wraps a sibling in a group whose only CTM prop is inert: the scene +// has to come out identical to the reference, where that group has no props. + +const SHIFT = 128; +const REF = "snapshots/drawings/ctm-balance.png"; + +const drawScene = (inert: Record) => + surface.draw( + <> + + + + + + + + + ); + +describe("CTM save/restore balance", () => { + it("Build reference result", async () => { + checkImage(await drawScene({}), REF); + }); + + it("origin without transform or matrix", async () => { + checkImage(await drawScene({ origin: { x: 0, y: 0 } }), REF); + }); + + it("clip resolved to false by a conditional", async () => { + checkImage(await drawScene({ clip: false }), REF); + }); + + it("invertClip without a clip", async () => { + checkImage(await drawScene({ invertClip: false }), REF); + }); + + it("still restores for a CTM that does save", async () => { + // The inner group's own translate applies to the red rect only, and is + // restored before the blue sibling is drawn. + const image = await drawScene({ transform: [{ translateX: 32 }] }); + checkImage(image, "snapshots/drawings/ctm-balance-nested.png"); + }); +}); diff --git a/packages/skia/src/sksg/Recorder/Visitor.ts b/packages/skia/src/sksg/Recorder/Visitor.ts index 2842b4b388..4862d0faa9 100644 --- a/packages/skia/src/sksg/Recorder/Visitor.ts +++ b/packages/skia/src/sksg/Recorder/Visitor.ts @@ -104,13 +104,16 @@ const processCTM = ({ if (layer) { ctm.layer = layer; } + // Only record a CTM command when saveCTM() will actually save the canvas. + // saveCTM() saves on transform/matrix, clip or layer; restoreCTM() always + // restores, so recording a CTM that saves nothing - `origin` with no + // transform, `clip={false}` from a conditional, a bare `invertClip` - pops + // an enclosing save that belongs to an ancestor. if ( - clip !== undefined || - invertClip !== undefined || - transform !== undefined || - origin !== undefined || - matrix !== undefined || - layer !== undefined + ctm.clip !== undefined || + ctm.transform !== undefined || + ctm.matrix !== undefined || + ctm.layer !== undefined ) { return ctm; } From b51bbbbee26c55f1ff267c7962aaa7a13798f41c Mon Sep 17 00:00:00 2001 From: Bao Nguyen <39545125+giaBaoJS@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:25:06 +0700 Subject: [PATCH 5/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20apply=20the=20mask=20s?= =?UTF-8?q?rcIn=20composite=20once=20per=20group,=20not=20per=20draw=20(#4?= =?UTF-8?q?022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(๐Ÿ›): apply the mask srcIn composite once per group, not per draw `` finishes by drawing its children onto the mask with `srcIn`, using ``. `blendMode` on a `` is a paint property, so it is applied once per draw call inside the group rather than once to the group as a whole - `Group.tsx` only requests a layer when a `layer` prop is given. `srcIn` replaces the destination inside the coverage of the draw it is attached to. With a single child that coincides with compositing the children once, which is why the existing mask baselines are unaffected. With more than one child, every child after the first is composited against the previous child's result instead of against the mask, so overlapping translucent children lose their mutual blending and their alpha is multiplied a second time. Requesting a layer instead composites the children together first and applies `srcIn` once, to the result. --------- Co-authored-by: William Candillon --- apps/docs/docs/mask.md | 4 +- .../drawings/mask-composite-clipped.png | Bin 0 -> 10246 bytes .../drawings/mask-composite-plain.png | Bin 0 -> 14462 bytes .../drawings/mask-composite-visible.png | Bin 0 -> 10183 bytes .../__tests__/e2e/MaskComposite.spec.tsx | 157 ++++++++++++++++++ .../skia/src/renderer/components/Mask.tsx | 13 +- 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 packages/skia/src/__tests__/snapshots/drawings/mask-composite-clipped.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/mask-composite-plain.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/mask-composite-visible.png create mode 100644 packages/skia/src/renderer/__tests__/e2e/MaskComposite.spec.tsx diff --git a/apps/docs/docs/mask.md b/apps/docs/docs/mask.md index c5944822c3..c2d3e73871 100644 --- a/apps/docs/docs/mask.md +++ b/apps/docs/docs/mask.md @@ -12,12 +12,12 @@ Just like its [CSS counterpart](https://developer.mozilla.org/en-US/docs/Web/CSS The first child of `Mask` is the drawing used as a mask, and the remaining children are the drawings to mask. -By default, the mask is not clipped. If you want to clip the mask with the bounds of the contents, use the `clip` property. +By default, the mask is clipped to the content: only the masked content is drawn. With `clip={false}`, the mask drawing itself stays visible wherever the content doesn't cover it. | Name | Type | Description | |:----------|:--------------------------|:--------------------------------------------------------------| | mode? | `alpha` or `luminance` | Is it a luminance or alpha mask (default is `alpha`) | -| clip? | `boolean` | clip the mask so it doesn't exceed the content | +| clip? | `boolean` | clip the mask so it doesn't exceed the content (default is `true`) | | mask | `ReactNode[] | ReactNode` | Mask definition | | children | `ReactNode[] | ReactNode` | Content to mask | diff --git a/packages/skia/src/__tests__/snapshots/drawings/mask-composite-clipped.png b/packages/skia/src/__tests__/snapshots/drawings/mask-composite-clipped.png new file mode 100644 index 0000000000000000000000000000000000000000..92e65c3f1ee9e4afedaf7ce8130873b1fd4d1a19 GIT binary patch literal 10246 zcmeHtc|4Te`~Nj#L@JUBPnJR`Ssu#L*jglIYfs2jvSiJ^4O2a(Cq-r|p)s^riim6p z(<0AgD^r%NWwLM0kjDBuw?3cm@B8=n@26L<+swJIbFSrF+j*b)+t%8Ag~%Tw0I{s+x>!qa-00f6;jv3tkikb5H? zeA-!8a@UUrgQ?EZfmZW2w;iw7Z@44%>@U(f;KGR<)h2))y=i(C}ow zBbnki{AJp>_d%;`Sm{&>P2NR`@Fnbp>-0ovSM!s78!r$h4_iEl?Cfhzsu-I=oHst- zC5Ha!>*LW=L>xVAF@Y#M3Fx_60X z$3c%@&Y0guaxa+?I$cZKGHqXJ!!V4HOGau3tU-$q7{-E`|plRdH|pmR5vo@yKDy4&IuafVu1d%L3H z@oY?*gz; zQ&tH`g6;h?FF!4K=0{bGU5jd~^#7tXwarWJTt;YKA}1|~y23shr2fQ#;-`(FkDPih zd&e^ZYVHjsD9;K#wD2oR?ft9Lskh2Ip8r8Wp|tR9 ze|%7wj&PE@wZSf6Zcu^lm1Ql#q3Owet==g$ky){?v(i~TBU@~W3hs#ufXq%jh$hC6 z6XXMUhHdJ8&&ICEiO%?@yYqGv>v~QDW!G-B3aPPj`efV%l{P!;YdBbfG+ED^2raVb z5Nqi7bOtoPBPFYq3k*NjYs-dd!7WATrqIU{_&@zp#)5fUuE;IFbYef3<9sKssGz+$ z#0w~wHb6JjvilOfQ^vOR8H{b^`0ZfhAOnc5)Lfg+@{+UMYmfR=o$$No&fD(-C z;ONj_Hd}XEO>?RyEgE0;Gc72|i7vzX@s5axm$!jZe7<~)Css2Ok((@Lw7P}nPQ@5z zu}-ARtrqCEBEUTsAei|qot10d#B(OD3sVJ|Re1209Va6fcv3Us zFtz{Unh8^OuU@+OJuxr^W>rOvlcuhJC(cRamo2f*Rb|Lv;K(UTJ^{|wvHRo`h-h8c z{xs%=nSg>jPKh~UM$2JIcca_BunK#Q&mH@s8QaEzbu8|zVdzeP!%Cp~{Y2MypM!(Kem(p_8i#tYlS%{tV=8j)>ZcLsPIIIH4LzBr2 zO^xD+$&81s+Wbey$df|dt^`mUG4D`tI^lhmeKJa9JGrNq6IHa8|NE7Dim}s4&%Uyg z+()TJ`{lxOSaZj$W^o~EIOcnUE=^w4+T_x2PfP@5JFHVe9AW z>${Dlo?pir#_H$KXoh@0TH$51XgFH*hU5#g`E$<_>1jfE0sX?nh;av6*4#V{(}1)( zWwe+7?X+6O%%P9v$C4wuKR^5%O`A~x{fYTgrSx1)@>E8;xrT1&uls+SKyWN{e;B0B zemZoi}G9W}L+YP?`D51^f zR^4#m=4%Sp-C;#LdFKDAhqh+aEm{st5-t>&}bz97z8ThPcCYlaEEtdEhDPJd#IlS)B(zt+cU-f0^@BlTw!XQ8DCF?q8J zWUij2nlOCOdI#H7Y$`m_&U{@DB}3CnPRBiaA4M8W*pgPtM?7Nvt&Yhmgkw5Ref}Pl zGPHJ=n81=B70ayZ9Wa}WuY~p^D51=PxZ=b6UVjsosE+SgCkM0l0zU>Z~x(V{g)F%9H#%%>>DEgPN5$Sex(GJz*MAkV|e&a?0F46@C~ z=A>**%XyrX38I_LvoGP&^6MSq)J$;4rSX)h=eLcW{JyK0StYPE>1cmRfx^(qKDM^4 zH8#iO(?tJeW%eV0#nI0(bJ{j-)4L9+6nR(tS_V&c!+7iqB`3cjF|%!C?*81QS%JJT z0*v(!p$r{_MvVfpSn;K3`XpMki65Eh6EYriKh|4C%<+$~)!-Y9hVR>OtE!H^z=Ge+ zkJrQ~nnJe!tOOx3V*x~iuH<>lmPs;gx|nQBA+p|(Jb&uckk~e>7o7N*U2%9A)xa?u z_|twW_M@{Inb8{C5=(jVlr<_4@&RG7HW8esVuN07{t+j9h^@npGZt1;PUlBdU{aX!`i@1# zNB*>pl^4S>z#VgdR}yd;4VjqJdc<&QC)3Pv5FHJw-NX)S zhgpjwQ;uGZ6G%bk5Wy7JHPpLH47bF;>vNhpKy(ZoKd%{7r2G^4jzHW|$df{8g~ zm-v)T(_YJ!oa2f?%d!luDrLE82waHy|QfTJI#ycW{(!k?mi%PbQIwSRufZ!VY88LXNhs)dPxsaya76qhW0#k(lEO%jb z5cdj7Bg0qV_y#=Xy>*8a3K}g@JjrbMVS^f34>KlB!=T7$LKR3dXytJvwRMMEbkU@2B1mia`0X64btBl&^uo?Hf?6H6u19=oryEZF6nAwoQ{{(E>2N zmr~Y@Uz|g{-%m17CP6g6+5otM9gL6pRq@|-nWdv{HJ^is1BDjN8!4QmQv!0eK;)=)pQx%)3+ia4=LvuDQSh-`<&mriMb5=T7gEoL4e3XbGMU|yvc&qcq+ zj989D2s#3`Ez=SG;=t5jN)a!U!kIdnqktR!8>F&ECQd?nwnd*H30l-}S_c!*GI=Xd z`Xn_Fn0p;pBUPNMo1=ce6oo^rUru8Sg&;}u@up{s0LSb^pZ%6l-p7U@2N?S*kmp9EiS7ez^-^GNyV|dbE|J}$a0#n$r)~|m9lN4Kvf{`+}5)?U0 z)^GL~K$GLp}WE|5 zfIntF21rTUr)F9HmkZL~6NkrW7V$T!#L0B&Fk!8`kD|_?>dQaBU7MCB1OkH-n!nX@ zucc(}|IcK*bzz{j-kt)ag;{-BX;;WxTo#)oVNX%b=J{+vhB2`=e}_aKn*7fSfoib2 zX#9U(NyBe#8sgaTb_ICTQ|N5}6Z?`Vipd|0q6X8((vqV3KSCq>Eeg%+32?rLOU*yq zCT2tcDI;WpLpak@m;)`E5Gfb7@D5KQV}zc#``Tg`&>Kv?>ZCn*+olNW#CcJ~ZWmmy zJ&`2C`$Nl2z0JA$j1=T)BA>FJZrigcPPL`qU)Fl_MF*ODzhF^)UWX==rh})vE^^rw z2k@>Az0U^Q)~4DG$mPX!SmKx;+hcN6+_i}P@FP8Njq2GEOGiPGMIXwv@O!@*%qHt` zfC$WDZ~4PvmvxwP+i~_92$`NeinJc9RWN@qf}Lofg;?-UJogW++g>)Z$ZyKEV`Mg| z!QtNJ$k(tbvR+Ad1rB_Upl&(w1RzfmMQ%3UHqjq_)^oY37?hs4+=w7`-OChxNAE^4 zGwUrzt2E~--75!dn zIe1`>psdK;47cqB5s4xkWu6%cz#>+|XF2A|lDIs4r>h|4^;6G@fisw0?!a+eb+Yc1 z?)=e%?E0;(4(qU*D(7-T?c1p_oNddIHspjTxn2v&J}*jnq2belCqUUoIQmv`YjPhDTe(^O{ z2{Mv&#Y#kP-7j)sP5v&UjMi`0_@?Oei$Z08^%XfB$VB2vRS`_x^&({Dt<|gz&q1+a(!}ZUM7EE5DDYND#6Nm-o$B z0acFK^J)O&F^FM!37xb1_wIeZ;T*mhNmYm1g{Oo^+R+zq-F-_bc;hcmtNwr^bd!%6 z!BSGtxgJFHJGb&v6E18z=bGeTsEcYPb~G`IIGwMHrQSYkJLDV9ODPKVx)PcKhnfl} zIfLTp20$DNBT!&mE)O_?(0+ocdK`kHyl2%j(re6Vb&2RApy1W%l%$eXw_eoK7i*&- zYOA8#?3$wG2+KKz66CcxM*z-AK$Rt%(^HSPJ=}a;5M6TEemF!c+zXGl2czYoa4F?t z6PvT?39K&kME+=-j5@sE?wG2Lv%Pf$HW!G1=~;ZL))N$%Yk=~YV0!8(F0&R*KC+bZ z4GS*BXY(qk%E@45D5orWkbXA#WklFw>$nOy60|}YpCqhj4dEYVjAS9=!Ij`lK=er@VL$q~;oX@ny7TXwWv4;bUR#6^DhNU+&7N%mrA+w=%FU^Ly((*uz1()iv z19$v9o>Kga-5P5781{ zg7?#_ltSk#FkeQf0qK~WPX_;Pj_K;aug4k3tWO96X`3(vjrb#kD?n|fS-Gzd!%OTh zlr&Jx>+YvD*yv#$^nDMiym9vhEqG^x9nW-W5iZF6P@pt7(Igud518n9<~396Y~;Fb zq2HDr<2vnNUyMu=l2~=VElG*N@OIulVM{820lI4u}YaV22fOh5=A+075;73G}f>= zbnJ}`YrxndG^qVgMoRYgxty9ESp$#QWDVIuB>PttD0yIPVnhv>6-m#OH~!G~E(x{J zzB-3`Vo&U+bq{oWZl`MBd5})bjnq{Bv_&VQg1(| zv*fSBwImzle{aNLc`r()V(I4ohY7YHeonX$hB3z!+7DWpG^^x=?M6w56et&qGFZX# z0|nnvGa|GXw2-}#2@o&7g}=oM+n=+}D?b60aS*jT56QUaKcF6mWi+-V{%?l}#?vqIjXL)Sj2twR&a%n<-q+sK(p zH|NC%=}y>oGXt%-wU@{zjW*z+gh+tsMkQ9%Jvo-Evd1nlK~V=Rek&VdT#?|x2yU{S z38qcXHcjkxShdg0#E9KjIx{mNj)!&wJZ0C|gOM$j#Ez?luz7pb{e~&|;Q}`^H17(+ z_T)S=_&u%9WOyVgK}X{--j!5(7Bc<<0Un&UVfWgkSNm0Z?A+w$ug)KRLw;JOyqZGR zrky?&G>)J^H=@yaUFX9)p9H+#zz6V%whXu%y0Zxpk zm^{Wjl|4gU&pr4LT0hDJYEUkoG-~Y|h@M&8f?{*fI_H{fG2b?EH{sHzfiGcP`c$Za z6xKUPl|Mk9%aE%(B%vvEs3C%C$g^c6*q|K#(GkuT&DE+4GBZ-pfQo}8#a_nq{ya1(LWo^xH_5o&DM ztbRpKM5YAcl?cVEcc?5?AJvgJ(4DvmQWQvi*oT1Gpwipx=6__QvYTmB3?FW(n5;ZP z5qk{!Zj-w#jLt^Cu78okc^EAFubj4LBB zJy*kP+37&ftyacFy9V<%yO&pSrs%$)ym?w9>Yl}xZqNW1HXcd0>_muQH6%`M@EiuM%9&1Bk1`(@@71JUfJJw(w zW*TUnMv&CakqLgMUy4sK(R;f)vxI4luE8Mggx{#hfuOTSEKQpx?hn=pU72CQZjF`T3%vup z0KJCdJ3NQMvb5+o8v^dTbN>!fm#5ciZ~GzCt+5JxnHoPGE1K&tx^$<=M_ya4QaoKz zaj%hXiva!gcv#Z7k$<50pWrh3%Ma>3lBZW#QKyR85zp!0p9Ky6VFRM~(A^oSV2pRk zJ4HSq=4Crk|K&AQ<_tet^U{Lu+<3QE9+&q2}IulN_@%3sn7~1&gIcwAL1^tt4gf+t*B37 zof+90r^}YvHXzh}Qv$ShlBvgL&LYr*+vA;hEG+t}*ghKPf(y0U+iH5uM_FTpKteSp zn?j!mO)HZoXimg9Ow~-sZg1Ke`aH4sqdjeY+o4DdL1l`(Xb(1M?sAaUZ&vrO82d(e zHEKz1Z?^O+^5U85uv%THw9$Sc68bra>bFU$k`KY9w=@!Fp3d%jg)XDA((4AEmu9&8 z7ts-S?TI{uYOg{N#kZz&uO3x|_e|w36qPGvh&0jed4EZkm_y_)+;>qaqDJ~yJRt-$ zXqvI?qF(gt)BUgJF1mBy5f)q}=8`LO+H%D(S voaOE3wg3L!WxMwUJaB}vIrL@87Ae1G>o?%((R`StKHa3Od+`KZ0IV`Ej*z&T`{G4^Cvi5b+}!`0YW6jLfA8h=SF%>AujB~v zGR6|kni7`*k5ATmgO%w7&9LVn=R)gnBGdkYHN7S2=7Q7Y_(!{GKu zeBL&|mys1t_}7q05P0(6xBunCf0f|B#_<2c6I?Y5K{UM^6x|%`i2iN=?Oxb^sU)6O z+}@MAUaC8jXBY}3KL5yRS3w|*I;>4j*e;L*mJ1V)fGwpcE-DtOdsi-beX z;3^Gh{%{fZW=0g^%Nw3g3Wb}8FsnbLYR;_GoPfV=DEfB$Yoc3^(7Nu`m>VwE$K}!1 zE-~Z84il*;$ID)-651MGvExMhKg7-OjRmvUy5&9CIgZBijhf1xJOFnPsN09{RXaZG z8GK`m=p7_nF2ffVKX%czW>6Bx#cAzlqE)$ylb%Oe%!+xokQ*yj^9>hA5^nEw*Z~Yb zuf8S*eUkmTh1;{mA!0m%N+(xDv3$T=CnWTrBDx&uzZ$OLr63!sCcQy<+mN>>CHN5k zY{hMV>h8V(Byh{H2n$wN32eks-rL8oZ&AOVUk`4WlaTj|sqfASEK3=?l5)FzI&!pg zr-i`QmWJ1N`Osq5nU2~i>}=)jYAbs8Mb?zd0K;<^Exa_eh`-6`|1KmpfI4H1rC25; z)H>U^!G-k7a}Qa{FH3B-UPPnM)Glz}J!vV3NAi7avJrYF{KWuZc1_0wE3_@^qgo9% z1B-dRS36{!b&4x=Z001X*Oc4ily-6G50tu1)u1HSx0>%X$~R5jv0#0mXzk^=mkZOm z3)o3`?A*>A5t|6C#*GK`2wf_7+j~Dd| z`imla(*bau{qmhsw|%c+Sd0>1GA(_>P0rKLDzI*##le1ru%CyJrdET0aBkVHpeR1o zlR)(-dnatJiv)#9A{w}b)vwf0RO^cm3Y0`^59^`noIqpYfB-0Ne~S&V!1FKq*G_V# zx_$(Odl8@)lZjqF&&lHHZ&h<}Crwq8F%iVi-r>6Yq3=P^6tUmw+xnJ8!)a8DsU}*+ ztY4z>EUCqcd_CuFL!#+a%-~#%RjX!wci8|ol6;8&8?y8tBuFiH{iKRMkLnm+`d#u*^zCz={#|bF{iSzsnjg?_ZCSxaMVdhy*JSjgTz!Daog7jaOW8JJ()X)C zKFp|3XX$Os9L=&i4evg7!A?m+IpIm;VC$#Ja!zDI?d`KmTVlP502qAeu}}Bxh+5hX z&6)pI=A#37M14V6+vtD98~=ns9zs~FrpJ%nNVw5^fYsm;08+OvFKyFR810!h`hBhL z*x^JsN-NpteafTxH3CoM{WJDK?z>n!rgHyTQl_9-W3RF^c>|c+H&aXPMgXmZ7fY zF(t)kIR|Y7>H1USr`+WpVcakMD%$}Tp9;_mBjeZ*Pf#=J{Ftwjph@G}2EJHdj#HXu zq5Tl3o-J(`h((G8}{9~d}!%wN7D;xNUDyt=$#F@ivO+vfC zhhz%&L)>@_| z(A`24gBt#E%fJ_ni|P3-`Bkry7$4_CPfVk{8Tbb|a)BN-hQj<{1RBn8`1lVkGi4DNY-l%eX@SDgA0j>VV|%Rir)5Wnm5pcDjo=^Thj!25 z?)KQ!IAoqS$cU}ez@d`FCbZulY3RnEw-TTy%rk;Nc$fJGzE7D*DCEVo`&At=%kksR z6hL79*_1#-<;4;C=H@ldxL+Gkwq@-H_TCxxsMN_@If7cD+2@H*8sAF@A_$RhP&24wEN3Wa{;Y4P*9Y-4?+=EInpy^AEQrj{b@_C?0?}8I=Otsn_(iyd+ z0*C6#`c6G>tv#1C2jl|pVQ1elNU}&EGG0Q_rgl;7_fKhR$`5)3uHOH#jeXJ(_4Y_$ z57NHC#iEVX{9qgVF3`t`mtd^Bp-`pYAsOP-77jV$U{GyVY(fI})Lrr`;g#zF$;~a!FO{nX_pHw@WJPd-U*GT$!0^@%5*4sshnMFWdU6B|qM&PJ_P zNR-%k)_A#AvVGGfOO%)kshbjg2>5yQ7ad^dGtZ|%zgva#52r#K&?ol>cP@F;?aph7 z5v${sYm3~9HZR)kS+iXF(fz^x`AbN{pztPbel~Qz#$D#lf^^{4cKjUaz5s*It#WM6 z<5*BHn_*|gk&>%|R$(76!y|qJpb)Ru4^kq?|(Gvrgm9S9R&2kecz3_P6`^bRpCR(|5k zcK%eHh@AYeIpZTE#%5kJkThynP_B}=iyxR_ z^+gumLK_KODNt{1+vh1cF(EQ{T+@T+$no!g-xFf+%~r-&yYIeYdQL8|S?P75SWRqY z=05!!1AM_Nus7CKc~h z72KxjPOGfoy8a&n2h#`!2lR`p9=ecR+CbL~1V>bG5>K1K?8L{v@#BM)+~f|2h1@{tU_Jq&?v zxkv^+Jc40RhuTRk=2tfj*H|j%Z<_iCsO(vmU`zzq{xQ%YN7rvRj53x7t;4{J2eB26 zI}r^a^&bQ#*W>b1IDWfc`Hs(<9A&jTXV4iNj6?q6;;6}@h&OrL;(^0K*e-Ff*u71n zDWF!a<>twUbbC?LM;Z2HsNkRN5T@k07}t4px*A+<`W*D%t+YEm35a8V2GimbJdV=E zdbYMze^zi`>v^G8Ge@UD$^u+Me-M|x;vbpXvKM}S!gRCOL-zb$%?gblxCl%`6b$CA z_?x;8gGG}YVw*I&+i(thY=wVpy>Df<$a6ZXSe&rFX|V<=%oHaC;hT9cEDAuTz6<&k zjaIB%cx-T`KV8m`mb4PVkm`}5dZ72~J&x^`3iMBJdkpl`LDRFdEz!d%Wa9Z;+O_X* z3>r2=TdRyi4ch)h(~7zt=DR4#Uc^lK=qJLNoBsIi;5VKLnL`^H<87}-8E@tW4#c+b zy7RRVb_10Y=rgF6sQY{}F88M#TeG(!7`fxU-zf!i7xJfVaD%7beIe`uD!UeVmuV#0 zT_#`d(GLcWXPMeIPi*O+h@x{(OnluNRAhJRV_-Z{cW#CHh(s@tXSseeD84?hGP`+_ z(WN9orQdaXgmAfEsu=xD;qT0;7}@|SbnQipk)ep-K!`{C9!9Q%k_2ip%ANO$-m(Md zBHPH09YFOJL*d-rn^F$%{oaqyz8+;%koShwE1C;jHQM@=Dax4+cfGachfeiemHeGQ zRdY^Ge(*Z2q=h2{H^VNLH|>ZUyvJDHU z7R)Sb@?*3Qg2nGI703AI)(wovc8v_*(-KsB^#0!&v8QCM9t0x-SdoBlP6V%!d-q!J z+n-i`ju8xBUloZ3b3S#`uT|{hot!{5R%}JlD)d_Kcu?6mg)>-XrN%wvA5-#l%oh7p zfC>`WP9D^t_r>D`PNzwg*c#KKn%B3IL|gJ^wn?KEb{k0M47f#*yK(PZvC69yWRrPY z)}E&yCpx)4YwQ9WH=x{uwP*M=(@^HA8?Endh~1xw%bW4T&DJrf!AQ_(f*XsP+te^} zzih)W{DZoV+UGbSmp}6;Fc`6+3P10*ZR7K9mENcM-UsinZVrUsvT!^$B6UUwkI^X! zyTQiQpJ9RY9}jE=d?yqFrzQ$Y9!jm+pokCb4GU^JZDX=GTvErcEgo59qT22Q!8+UtGTXh^bN>^@9v;P)u`5?~)ZJx>p zaEHL6H)J=ey@GDo^~S)arfmA{$eZm7+`zuH+G66ZD>ws4%^CCw)DJ~n3wg2|shq<2 zl5|9n)%d5XAnP(WD^Trb7asmmRp(!V71No%%^vBSpHtS|aP902vodhuj z^DBf0TR1iZW~UC_C-mKPD<z|a)9pS)m5RDUaKrc#U z6&&sax4N?g#J0T$e{8MC ziX(yxehfWdYLFH~W|~wdH=vlspw<5tubKC;D@f)ch_0hgBh^nZn)4e4)-s3Cq0iPU z5k@2)M8-ZuMzq*Ee(+R_rjgKsRM1x~2Gxz@Q?yljl!@;tiFIENlS4pYX?TTh+!tpR zfmRzf#RC(+PusT7ARfT+Np)eRVX)h0O`)c=ar&(tnG9hOa~>Vj_3-Aa;0Pzsq?x^E z2&|13#S1Ee;Ig;uSMp|al-?a>#UST-G(x`<|JwZker)MIJkzavA(OY>JudfHkF~KWIc-4uFjB zcXwA$(Fc8Y9DA2EBGED~XAO>9&{}WRo>=*_@}{sZLz!FMGz5F@=uLz|a{~dp`9o|yGxcj35VU(BFNpAGJG1m(MmB|Y z(ls3V(E*nKnRNL7Ow#;!k_xhk{XdhE|IegoF_G=uQttIV3{~rDI7~Xf&XC*%l%4{0 z2cd-$cnaw4v&qUVr8O6>n3}&kL&vD@E1~1+7GLh~JC}LgIIfuD?f)FHTY-cuzNDkM>h5eA=qD*1 zywb&9v5PrqEM;=7j|xK;M9<)f-&Y)vOM#j<-gjDFjH`?OseoL~i)m>C&y#_|O@CIvHE_meb&p{h9!R%afWI?&biYfxnE|`z zLPSQz;dMqLT+ui2!;Yrd(^F}nZMr?I@9BfuiD&n|{rLxIP3NqMaSbSL5|dRFxYZpT z5n8%HI9~(_p3EKCyX|y7@mV%BpbA#Zt8$RoQso3K*1VKI@rNwy0Xc--n>JvWBzUm* z$zE_}S@!P;+_l`=36x60H>c#(vyOo0-=oLkA%JO#F&EN?;=aoLoq0+h|H&-+>w==d zlXG%N$MbEz7Yr@Lf_}Y>1qv7apQ{)pu8H-wKX5u3=ghM22*U2M$I}6Usvx%&+^W~e zDT$*{8ahB&{2VY!62+J+ZwVrelVGxlY~my#o-)}-&O=MLXS7Mft!}epS_%<%=PZY8lB9iaRBHpKCV7I&!(i!c&sO*l+2@!dOC15P=UJyLJVyOqhh~ z;+;p!Eqykx+}k~fPimQf>aYuFO?=Z0`o0cLS=PNM~WYp67mQ$Y7a#JtxlyS0r z6Z1*MWx1@ML?!pUBep#=nDawhx`nJ0Gy_`q|OaV9cZU~R#o?wsD9br?Kb(;P@X^qKiJFZ@MjO_eBlm-wC zlR#7LNfiy}UI24zNW;IH5w{usLp4QP8&>=<7n%HZcJE05#}wB;-_+AnP0nwFy9D1p ztZ2d9Wi>*!H#R{gdvEG~QDWh%q%R52Yhj*jN+Df!C?ItHdG(@gtw0`Jzs$`)(=sdj z_qb7Rx1pw;L|}Z607E8|Hoj>&-b_yVc;5gn=A@Wy!G=0(lY979?6Vg31>wKzGuher{9M~lH5XSegI z*VlHeo#w{u@!|zDE%>A#CNa!ud&N^)n6xUM({=D*Fje8pA-khp$j`N2uGf-(-1bq$ zYkk=Vj;8p3Dv(F0#heEJ!}KIBAdtiM)c15HH-B8dr1n>BrIQzx;&{@{3uU4luPn0( ziO@|0;6>iDt~o8E=X~;Py2WAdFQ*irh#FwUdl99fJ39>fySGtFP-|x2I2dqd zQ<2Bl>sQ&MHLh5VV`_zU!E&EsK*ST zJk3Bxx%)apIA210vK$&MhgOQYVqO2q{Hs9tNZc4_5;X5ea!}pm9RZAj{`VM*oO9%yVoYX#RL zz{tvu*e^2!9fJt}%tyhD*a--hUDJFS>kDPUj`6kUICw=-SkbcJ+pCi->JCUS^O09Q z){!HFIjNgXO!SGUKGTErA)fwV#r%D8FF1;4KFN;x&q#MtZ^e*N?;nXIUq^&_k+ZJk z+XhbaxMxSb+Jb!!I|@Soz+%_42CuKAPKJ^3g_nKfjL;)e?C1wASsw()Vn?R#`7Y0 z+lftuA~bE^+0+L;l9HZR^H~rvZRO?Z)MIwRlv2SkBo%L=XQ7F7v0rlV_cse+Gx5E? z#mBr*C_9wA;2yK{F9i=?|DC!f#`Jy$Kp3JvE%QkTJIxNJSm4dwM`hKXzv_D5NMu~>D4D=N|gQ2x3FaT^J@9nGC!*+JEf1av22m|W92)o?@5$+i8qE@`kWdYw) zdTZ5Y@UAQSX*uW-TiJ!p2c*s>Z}{O} zwE2&ngGahGTs8mr-W#vWz2U0Acas%tVF+Na+~I;4oeF?4Y`M1@)HC6DVShZHI zi52v_61uk=^C9cjDxWcPA-~Wbv!=fDuKZ70j!W-SPBQR+^q!=5>j(sVd_2C@!!7^# zY%=l-@!OP~A3sC#Tv+ku#pgQj2J546(^5$LyQI2)Z-Z8Z~?qwkHg(MKL(=|Mb7f#fMY1EJ7 z(sY;?aBakOX!&*f&e5Vj+_XFJVHpWbDD!uD*$a;yBb9J+f_K~q#OM#KrK-}C12 z-sV&phqRg5ZQI8%+wr_wWfrod^rV@5GGgRceILx@N_AU49u5AXdBb_P6pL{$Ic=;1 z;&GI^@0J}diEj!N0CaNtpPN{w7f0T*;>rQr&cnsyigM ztS$nadv)cFmEbo8b5)6mjGYE`H$y{i28#GvzkfG<%+A`{h-REL#PA9pZ4NFPXz3`B zM_wP>q>AG^WTC^wj#=~9Q1*hZX+ePX21(lgg`|B-F^Y7=V# zjw*7iL%){R$$-8v6CNnZ0^D4|O+Tk6Ok~No$%42fw!HR9RQBF&c>^H*e-9A9+NS

cTAX1|71gWEDLV(}pK4Ucc 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 renders its children into a layer and applies the mask once, when the +// mask's own layer is restored. A blend mode applied per draw call instead +// composites every child after the first against the previous child rather +// than against the mask, and the overlap of two translucent children comes out +// wrong (issue #3254). With the default clip the mask is applied with dstIn, +// so only the masked children remain; with clip={false} it is applied with +// dstATop, which also keeps the mask artwork visible wherever the children +// leave it uncovered. +// +// None of the references below is drawn with , so none of them encodes +// the behaviour under test: they are the plain drawing, the same drawing +// behind a geometric clip, and the same drawing over the visible mask artwork. + +const REF_PLAIN = "snapshots/drawings/mask-composite-plain.png"; +const REF_CLIPPED = "snapshots/drawings/mask-composite-clipped.png"; +const REF_VISIBLE = "snapshots/drawings/mask-composite-visible.png"; + +// Two overlapping translucent circles: the overlap is only right if they are +// composited against each other before the mask is applied, and the 0.5 alpha +// is only right if the mask alpha is multiplied in exactly once. +const twoChildren = ( + <> + + + +); + +describe("Mask composition", () => { + it("Build reference result", async () => { + const image = await surface.draw( + <> + + {twoChildren} + + ); + checkImage(image, REF_PLAIN); + }); + + it("should not alter the drawing when the mask is opaque everywhere", async () => { + // An alpha mask that is opaque everywhere selects the whole drawing, so + // the result has to be the drawing itself - under the default clip, and + // with the translucent children rendered at their own alpha (the mask + // alpha must not be multiplied in twice). + const image = await surface.draw( + <> + + }>{twoChildren} + + ); + checkImage(image, REF_PLAIN); + }); + + it("Build clipped reference result", async () => { + const { rect } = importSkia(); + const image = await surface.draw( + <> + + {twoChildren} + + ); + checkImage(image, REF_CLIPPED); + }); + + it("should select the drawing without recompositing it", async () => { + // An alpha mask that is opaque on the left half and empty on the right + // half selects exactly what the matching rectangular clip selects. The + // mask is magenta: an alpha mask only contributes coverage, so none of + // its color may leak into the result - the pixels outside the children + // are load-bearing here. + const image = await surface.draw( + <> + + } + > + {twoChildren} + + + ); + checkImage(image, REF_CLIPPED); + }); + + it("should select the drawing by luminance", async () => { + // White has luminance 1 and black has luminance 0, so a white-on-black + // luminance mask selects exactly what the alpha mask above selects. + const image = await surface.draw( + <> + + + + + + } + > + {twoChildren} + + + ); + checkImage(image, REF_CLIPPED); + }); + + it("Build visible mask reference result", async () => { + const { rect } = importSkia(); + const image = await surface.draw( + <> + + + + {twoChildren} + + + ); + checkImage(image, REF_VISIBLE); + }); + + it("should keep the mask artwork visible with clip={false}", async () => { + // Without clip, the mask artwork itself remains wherever the children + // leave it uncovered, and the children composite over it - but the + // children still only show where the mask has coverage. + const image = await surface.draw( + <> + + } + > + {twoChildren} + + + ); + checkImage(image, REF_VISIBLE); + }); + + it("should composite every draw of a single child correctly", async () => { + // Regression guard: the per-draw compositing bug fired per draw call, not + // per React child - a single child recording several draws was corrupted + // the same way. A child-count fast path must not reintroduce it. + const image = await surface.draw( + <> + + }> + {twoChildren} + + + ); + checkImage(image, REF_PLAIN); + }); +}); diff --git a/packages/skia/src/renderer/components/Mask.tsx b/packages/skia/src/renderer/components/Mask.tsx index f0100bf2e5..21325e229b 100644 --- a/packages/skia/src/renderer/components/Mask.tsx +++ b/packages/skia/src/renderer/components/Mask.tsx @@ -20,17 +20,24 @@ export const Mask = ({ }: MaskProps) => { return ( + {children} + {/* The children composite against each other in the layer above; the + mask is then applied once, when its own layer is restored. A blend + mode attached to a without a layer would instead be applied + per draw call, compositing every child after the first against the + previous child rather than against the mask (issue #3254). + dstIn keeps the children where the mask is opaque and erases them + where it is transparent. Without clip, dstATop additionally keeps + the mask artwork itself wherever the children leave it uncovered. */} + {mode === "luminance" && } } > {mask} - {clip && }>{children}} - {children} ); }; From 42000b9b2176ac2ea8612fa971b9b7a88beb5829 Mon Sep 17 00:00:00 2001 From: Bao Nguyen <39545125+giaBaoJS@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:28:38 +0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20keep=20the=20paint/opa?= =?UTF-8?q?city=20stack=20balanced=20in=20SavePaint=20(#4021)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every SavePaint command must push exactly one frame onto the paint and opacity stacks, because RestorePaint and RestorePaintDeclaration each pop exactly one. Both backends break that invariant, in opposite directions and on opposite branches. The native recorder pushes twice for a standalone paint: ctx->savePaint(); if (standalone) { SkPaint freshPaint; ctx->pushPaint(freshPaint); // second push } while the TypeScript player resets the frame savePaint() already pushed. A declaration is always closed by RestorePaintDeclaration, which pops one frame, so each declaration leaves one stale frame behind. The enclosing group's RestorePaint then pops that stale frame instead of its own, and the group's opacity stays on the stack and is applied to every sibling drawn afterwards โ€” including siblings that are not in the group at all. That is issue #3355: it only shows up with a (or any child) inside the group, because that is what emits a standalone paint. The TypeScript player has the mirror defect on the other branch: the `paint` prop pushes onto `paints` without pushing an opacity, so restorePaint() underflows the opacity stack. getOpacity() then returns undefined and setAlphaf(alpha * undefined) yields NaN, so everything drawn after the group disappears entirely. Fix both so a SavePaint always moves each stack by exactly one frame. Fixes #3355 Co-authored-by: William Candillon --- packages/skia/cpp/api/recorder/Paint.h | 7 ++- .../paint-declaration-opacity-balance.png | Bin 0 -> 3674 bytes .../drawings/paint-prop-opacity-balance.png | Bin 0 -> 3677 bytes .../__tests__/e2e/PaintStackBalance.spec.tsx | 45 ++++++++++++++++++ .../skia/src/sksg/Recorder/DrawingContext.ts | 10 ++++ packages/skia/src/sksg/Recorder/Player.ts | 2 +- 6 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 packages/skia/src/__tests__/snapshots/drawings/paint-declaration-opacity-balance.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/paint-prop-opacity-balance.png create mode 100644 packages/skia/src/renderer/__tests__/e2e/PaintStackBalance.spec.tsx diff --git a/packages/skia/cpp/api/recorder/Paint.h b/packages/skia/cpp/api/recorder/Paint.h index b4b2f1567b..5e3dec216a 100644 --- a/packages/skia/cpp/api/recorder/Paint.h +++ b/packages/skia/cpp/api/recorder/Paint.h @@ -154,8 +154,11 @@ class SavePaintCmd : public Command { } ctx->savePaint(); if (standalone) { - SkPaint freshPaint; - ctx->pushPaint(freshPaint); + // Reset the paint that savePaint() just pushed instead of pushing a + // second one: the matching RestorePaintDeclaration pops a single frame, + // so an extra push would leave the enclosing group's opacity on the + // stack and leak it onto every sibling drawn afterwards. + ctx->getPaint() = SkPaint(); } auto &paint = ctx->getPaint(); if (props.opacity.has_value()) { diff --git a/packages/skia/src/__tests__/snapshots/drawings/paint-declaration-opacity-balance.png b/packages/skia/src/__tests__/snapshots/drawings/paint-declaration-opacity-balance.png new file mode 100644 index 0000000000000000000000000000000000000000..379248dc8dcc4655d0cca68e8cfab710a7fd1ecb GIT binary patch literal 3674 zcmeAS@N?(olHy`uVBq!ia0y~yU6CR{y**emazlfd0r4m2q*yw1s5RU0P=2w03(o8WC9XS zEI^`#14wX;DjW@-(L^zt6-G;n!BIW}%j8jjGX&}~{P}mhpYxK{;p)F;X)cGernW$} z6` is left on +// the stack and applied to every sibling drawn after it (issue #3355). +describe("Paint stack balance", () => { + it("should not leak a group opacity through a paint prop", async () => { + const { Skia } = importSkia(); + const paint = Skia.Paint(); + paint.setColor(Skia.Color("red")); + const image = await surface.draw( + <> + + + + + + + ); + checkImage(image, "snapshots/drawings/paint-prop-opacity-balance.png"); + }); + + it("should not leak a group opacity through a paint declaration", async () => { + const image = await surface.draw( + <> + + + + + + + + + ); + checkImage( + image, + "snapshots/drawings/paint-declaration-opacity-balance.png" + ); + }); +}); diff --git a/packages/skia/src/sksg/Recorder/DrawingContext.ts b/packages/skia/src/sksg/Recorder/DrawingContext.ts index 26faae3101..9d5e2ea9e4 100644 --- a/packages/skia/src/sksg/Recorder/DrawingContext.ts +++ b/packages/skia/src/sksg/Recorder/DrawingContext.ts @@ -56,6 +56,15 @@ export const createDrawingContext = ( nextPaintIndex++; }; + // Pushes an externally owned paint (the `paint` prop) onto the stack. It must + // push an opacity alongside it: restorePaint() pops both, so pushing only the + // paint would underflow the opacity stack and leak the enclosing group's + // opacity onto everything drawn afterwards. + const pushPaint = (paint: SkPaint) => { + paints.push(paint); + opacities.push(opacities[opacities.length - 1]); + }; + const getOpacity = () => { return opacities[opacities.length - 1]; }; @@ -144,6 +153,7 @@ export const createDrawingContext = ( // Public methods savePaint, + pushPaint, saveBackdropFilter, get paint() { return paints[paints.length - 1]; diff --git a/packages/skia/src/sksg/Recorder/Player.ts b/packages/skia/src/sksg/Recorder/Player.ts index 08537c3372..614989eefe 100644 --- a/packages/skia/src/sksg/Recorder/Player.ts +++ b/packages/skia/src/sksg/Recorder/Player.ts @@ -123,7 +123,7 @@ const play = (ctx: DrawingContext, _command: Command) => { ctx.canvas.saveLayer(paint); } else if (isDrawCommand(command, CommandType.SavePaint)) { if (command.props.paint) { - ctx.paints.push(command.props.paint); + ctx.pushPaint(command.props.paint); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { standalone } = command as any; From cb178e44f356d8d3156a9a9dbba290ccb5e55b8e Mon Sep 17 00:00:00 2001 From: Mad Dinh <70377017+dennytosp@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:30:44 +0700 Subject: [PATCH 7/7] =?UTF-8?q?fix(=F0=9F=90=9B):=20trim=20a=20path=20befo?= =?UTF-8?q?re=20stroking=20it=20(#4018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(๐Ÿ›): trim a path before stroking it drawPath applied the stroke prop before start/end, so trimming walked the perimeter of the generated outline rather than the path the caller drew. painted the right half of the line instead of the left. The native recorder already trims first (PathCmd in cpp/api/recorder/Drawings.h), so this only affected the JS player - Web and the static container. * test(๐Ÿงช): compare rendered images for the path trim spec Move it to renderer/__tests__/e2e so it also runs against native, draw through surface.draw and compare with checkImage instead of sampling alpha by hand. An untrimmed baseline sits next to the trimmed one so the two halves are easy to tell apart. --------- Co-authored-by: William Candillon --- packages/skia/cpp/api/recorder/Drawings.h | 16 +++++-- .../drawings/path-trim-stroke-full.png | Bin 0 -> 4073 bytes .../snapshots/drawings/path-trim-stroke.png | Bin 0 -> 4072 bytes .../renderer/__tests__/e2e/PathTrim.spec.tsx | 45 ++++++++++++++++++ .../src/sksg/Recorder/commands/Drawing.ts | 18 +++---- 5 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 packages/skia/src/__tests__/snapshots/drawings/path-trim-stroke-full.png create mode 100644 packages/skia/src/__tests__/snapshots/drawings/path-trim-stroke.png create mode 100644 packages/skia/src/renderer/__tests__/e2e/PathTrim.spec.tsx diff --git a/packages/skia/cpp/api/recorder/Drawings.h b/packages/skia/cpp/api/recorder/Drawings.h index c5ce24b14c..a0398a4244 100644 --- a/packages/skia/cpp/api/recorder/Drawings.h +++ b/packages/skia/cpp/api/recorder/Drawings.h @@ -149,6 +149,9 @@ class PathCmd : public Command { if (hasStrokeOptions) { const auto &stroke = props.stroke.value(); SkPaint strokePaint; + // A default SkPaint is fill-style, and FillPathWithPaint only + // outlines a paint that strokes. + strokePaint.setStyle(SkPaint::kStroke_Style); if (stroke.cap.has_value()) { strokePaint.setStrokeCap(stroke.cap.value()); @@ -166,11 +169,18 @@ class PathCmd : public Command { strokePaint.setStrokeMiter(stroke.miter_limit.value()); } + float precision = stroke.precision.value_or(1.0f); + SkPathBuilder resultBuilder; - if (!skpathutils::FillPathWithPaint(*p, strokePaint, &resultBuilder)) { - throw std::runtime_error("Failed to apply stroke to path"); + auto ctm = SkMatrix::Scale(precision, precision); + if (skpathutils::FillPathWithPaint(*p, strokePaint, &resultBuilder, + nullptr, ctm)) { + pathToUse = std::make_shared(resultBuilder.snapshot()); + } else { + // The JS player keeps the unstroked path when Path.Stroke returns + // null (e.g. a hairline width of 0). + pathToUse = std::const_pointer_cast(p); } - pathToUse = std::make_shared(resultBuilder.snapshot()); } else { pathToUse = std::const_pointer_cast(p); } diff --git a/packages/skia/src/__tests__/snapshots/drawings/path-trim-stroke-full.png b/packages/skia/src/__tests__/snapshots/drawings/path-trim-stroke-full.png new file mode 100644 index 0000000000000000000000000000000000000000..73e89b46dc1baa869533e9053ab5598cd99ac54f GIT binary patch literal 4073 zcmeAS@N?(olHy`uVBq!ia0y~yU5>6~YqJ;xUa0mhk0VN=z-~uEZK;}0Hj4B)rp3y`xniWP%iqZ0Mc-9IN z(yI@YKF`qyR*T+g+r(p@0;{~ydXMO4!MwMO*Uy4NufbFfm_SBz$?(i2qYXh|ypFb$ pfq^jEI~eT~jb;U+`b3qCM`!c?OT4jsA~4@Dc)I$ztaD0e0svoUQeFT6 literal 0 HcmV?d00001 diff --git a/packages/skia/src/__tests__/snapshots/drawings/path-trim-stroke.png b/packages/skia/src/__tests__/snapshots/drawings/path-trim-stroke.png new file mode 100644 index 0000000000000000000000000000000000000000..6f89bdd18d6f0bb47ece4e1466de55aa336426b3 GIT binary patch literal 4072 zcmeAS@N?(olHy`uVBq!ia0y~yU5>6~YqJ;xUa0mhk0VN=z-~uEZK;}0Hj4B)rp3y`xniWP%iqZ0Mc-9IN z(yI@YKF`r-_|Z1=Eh~f0hvDf>TGiy|zcsv0+a}Jya6pP57;>Xc31A?MHlRkEjiXs% lwAlzO3PyWSBe;XP=Z+xnzqfe{)&mm)gQu&X%Q~loCIHT2RG0t& literal 0 HcmV?d00001 diff --git a/packages/skia/src/renderer/__tests__/e2e/PathTrim.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/PathTrim.spec.tsx new file mode 100644 index 0000000000..fe998aa984 --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/PathTrim.spec.tsx @@ -0,0 +1,45 @@ +import React from "react"; + +import { checkImage } from "../../../__tests__/setup"; +import { Fill, Path } from "../../components"; +import { surface } from "../setup"; + +// The stroke prop replaces the path with the outline of the stroke, so the +// trim has to run first - otherwise start/end walk that outline's perimeter +// and end up painting a different part of the line. + +const LINE = "M 20 128 L 236 128"; + +describe("Path trim", () => { + it("trims the path before turning it into a stroke outline", async () => { + const image = await surface.draw( + <> + + + + ); + checkImage(image, "snapshots/drawings/path-trim-stroke.png"); + }); + + it("leaves an untrimmed stroke alone", async () => { + const image = await surface.draw( + <> + + + + ); + checkImage(image, "snapshots/drawings/path-trim-stroke-full.png"); + }); +}); diff --git a/packages/skia/src/sksg/Recorder/commands/Drawing.ts b/packages/skia/src/sksg/Recorder/commands/Drawing.ts index 1cae0dde0f..959ae40274 100644 --- a/packages/skia/src/sksg/Recorder/commands/Drawing.ts +++ b/packages/skia/src/sksg/Recorder/commands/Drawing.ts @@ -217,6 +217,16 @@ export const drawPath = (ctx: DrawingContext, props: PathProps) => { let path = processPath(ctx.Skia, pathProps.path); + // Trim runs first, so start/end address the path the caller drew. Stroking + // first would replace it with its outline and leave the offsets walking that + // outline's perimeter instead. The native recorder orders these the same way. + if (hasStartOffset || hasEndOffset) { + const trimmed = ctx.Skia.Path.Trim(path, start, end, false); + if (trimmed) { + path = trimmed; + } + } + // Apply fill type using PathBuilder if (hasFillType) { const builder = ctx.Skia.PathBuilder.MakeFromPath(path); @@ -232,14 +242,6 @@ export const drawPath = (ctx: DrawingContext, props: PathProps) => { } } - // Apply trim using static Path.Trim - if (hasStartOffset || hasEndOffset) { - const trimmed = ctx.Skia.Path.Trim(path, start, end, false); - if (trimmed) { - path = trimmed; - } - } - ctx.canvas.drawPath(path, ctx.paint); };