diff --git a/packages/skia/cpp/api/JsiSkCanvas.h b/packages/skia/cpp/api/JsiSkCanvas.h index 19be01b60a..bd4e3b8d45 100644 --- a/packages/skia/cpp/api/JsiSkCanvas.h +++ b/packages/skia/cpp/api/JsiSkCanvas.h @@ -75,7 +75,7 @@ class JsiSkCanvas : public JsiSkNativeObject { auto x = arguments[1].asNumber(); auto y = arguments[2].asNumber(); std::shared_ptr paint; - if (count == 4) { + if (count >= 4 && !arguments[3].isNull() && !arguments[3].isUndefined()) { paint = JsiSkPaint::fromValue(runtime, arguments[3]); } _canvas->drawImage(image, x, y, SkSamplingOptions(), paint.get()); @@ -86,8 +86,12 @@ class JsiSkCanvas : public JsiSkNativeObject { auto image = JsiSkImage::fromValue(runtime, arguments[0]); auto src = JsiSkRect::fromValue(runtime, arguments[1]); auto dest = JsiSkRect::fromValue(runtime, arguments[2]); - auto paint = JsiSkPaint::fromValue(runtime, arguments[3]); - auto fastSample = count >= 5 && arguments[4].getBool(); + std::shared_ptr paint; + if (count >= 4 && !arguments[3].isNull() && !arguments[3].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[3]); + } + auto fastSample = count >= 5 && !arguments[4].isNull() && + !arguments[4].isUndefined() && arguments[4].getBool(); _canvas->drawImageRect(image, *src, *dest, SkSamplingOptions(), paint.get(), fastSample ? SkCanvas::kFast_SrcRectConstraint : SkCanvas::kStrict_SrcRectConstraint); @@ -101,10 +105,8 @@ class JsiSkCanvas : public JsiSkNativeObject { float B = arguments[3].asNumber(); float C = arguments[4].asNumber(); std::shared_ptr paint; - if (count == 6) { - if (!arguments[5].isNull()) { - paint = JsiSkPaint::fromValue(runtime, arguments[5]); - } + if (count >= 6 && !arguments[5].isNull() && !arguments[5].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[5]); } _canvas->drawImage(image, x, y, SkSamplingOptions({B, C}), paint.get()); return jsi::Value::undefined(); @@ -117,10 +119,8 @@ class JsiSkCanvas : public JsiSkNativeObject { auto fm = (SkFilterMode)arguments[3].asNumber(); auto mm = (SkMipmapMode)arguments[4].asNumber(); std::shared_ptr paint; - if (count == 6) { - if (!arguments[5].isNull()) { - paint = JsiSkPaint::fromValue(runtime, arguments[5]); - } + if (count >= 6 && !arguments[5].isNull() && !arguments[5].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[5]); } _canvas->drawImage(image, x, y, SkSamplingOptions(fm, mm), paint.get()); return jsi::Value::undefined(); @@ -132,10 +132,8 @@ class JsiSkCanvas : public JsiSkNativeObject { auto dest = JsiSkRect::fromValue(runtime, arguments[2]); auto fm = (SkFilterMode)arguments[3].asNumber(); std::shared_ptr paint; - if (count == 5) { - if (!arguments[4].isNull()) { - paint = JsiSkPaint::fromValue(runtime, arguments[4]); - } + if (count >= 5 && !arguments[4].isNull() && !arguments[4].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[4]); } _canvas->drawImageNine(image.get(), center->round(), *dest, fm, paint.get()); @@ -149,10 +147,8 @@ class JsiSkCanvas : public JsiSkNativeObject { float B = arguments[3].asNumber(); float C = arguments[4].asNumber(); std::shared_ptr paint; - if (count == 6) { - if (!arguments[5].isNull()) { - paint = JsiSkPaint::fromValue(runtime, arguments[5]); - } + if (count >= 6 && !arguments[5].isNull() && !arguments[5].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[5]); } auto constraint = SkCanvas::kStrict_SrcRectConstraint; // TODO: get from caller @@ -168,10 +164,8 @@ class JsiSkCanvas : public JsiSkNativeObject { auto filter = (SkFilterMode)arguments[3].asNumber(); auto mipmap = (SkMipmapMode)arguments[4].asNumber(); std::shared_ptr paint; - if (count == 6) { - if (!arguments[5].isNull()) { - paint = JsiSkPaint::fromValue(runtime, arguments[5]); - } + if (count >= 6 && !arguments[5].isNull() && !arguments[5].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[5]); } auto constraint = SkCanvas::kStrict_SrcRectConstraint; _canvas->drawImageRect(image.get(), *src, *dest, {filter, mipmap}, @@ -216,9 +210,8 @@ class JsiSkCanvas : public JsiSkNativeObject { void drawPoints(double pointMode, std::vector points, std::shared_ptr paint) { - // Check if we have at least one point if (points.empty()) { - throw std::invalid_argument("Points array must not be empty"); + return; } auto p = SkSpan(points.data(), points.size()); _canvas->drawPoints(static_cast(pointMode), p, *paint); @@ -287,11 +280,22 @@ class JsiSkCanvas : public JsiSkNativeObject { } } - auto paint = - count >= 4 ? JsiSkPaint::fromValue(runtime, arguments[4]) : nullptr; - auto blendMode = static_cast(arguments[3].asNumber()); + // Matches CanvasKit's web default (see drawPatch in interface.js), which + // always falls back to kModulate regardless of whether colors are given. + SkBlendMode blendMode = SkBlendMode::kModulate; + if (count >= 4 && !arguments[3].isNull() && !arguments[3].isUndefined()) { + blendMode = static_cast(arguments[3].asNumber()); + } + + std::shared_ptr paint; + if (count >= 5 && !arguments[4].isNull() && !arguments[4].isUndefined()) { + paint = JsiSkPaint::fromValue(runtime, arguments[4]); + } + SkPaint defaultPaint; + const auto &paintRef = paint != nullptr ? *paint : defaultPaint; + _canvas->drawPatch(cubics.data(), colors.empty() ? nullptr : colors.data(), - texs.empty() ? nullptr : texs.data(), blendMode, *paint); + texs.empty() ? nullptr : texs.data(), blendMode, paintRef); return jsi::Value::undefined(); } @@ -362,20 +366,29 @@ class JsiSkCanvas : public JsiSkNativeObject { int save() { return _canvas->save(); } JSI_HOST_FUNCTION(saveLayer) { - SkPaint *paint = (count >= 1 && !arguments[0].isUndefined()) - ? JsiSkPaint::fromValue(runtime, arguments[0]).get() - : nullptr; - SkRect *bounds = - count >= 2 && !arguments[1].isNull() && !arguments[1].isUndefined() - ? JsiSkRect::fromValue(runtime, arguments[1]).get() + // Keep the wrapper smart pointers alive for the duration of the call: + // JsiSkRect::fromValue() (and, in principle, the others) may return a + // freshly allocated temporary when the argument is a plain JS object + // rather than a host object, so taking .get() from a temporary would + // leave a dangling pointer once the expression finishes evaluating. + std::shared_ptr paintPtr = + (count >= 1 && !arguments[0].isNull() && !arguments[0].isUndefined()) + ? JsiSkPaint::fromValue(runtime, arguments[0]) + : nullptr; + std::shared_ptr boundsPtr = + (count >= 2 && !arguments[1].isNull() && !arguments[1].isUndefined()) + ? JsiSkRect::fromValue(runtime, arguments[1]) : nullptr; - SkImageFilter *backdrop = - count >= 3 && !arguments[2].isNull() && !arguments[2].isUndefined() - ? JsiSkImageFilter::fromValue(runtime, arguments[2]).get() + sk_sp backdropPtr = + (count >= 3 && !arguments[2].isNull() && !arguments[2].isUndefined()) + ? JsiSkImageFilter::fromValue(runtime, arguments[2]) : nullptr; - SkCanvas::SaveLayerFlags flags = count >= 4 ? arguments[3].asNumber() : 0; - return jsi::Value(_canvas->saveLayer( - SkCanvas::SaveLayerRec(bounds, paint, backdrop, flags))); + SkCanvas::SaveLayerFlags flags = + count >= 4 && !arguments[3].isNull() && !arguments[3].isUndefined() + ? static_cast(arguments[3].asNumber()) + : 0; + return jsi::Value(_canvas->saveLayer(SkCanvas::SaveLayerRec( + boundsPtr.get(), paintPtr.get(), backdropPtr.get(), flags))); } void restore() { _canvas->restore(); } @@ -411,7 +424,8 @@ class JsiSkCanvas : public JsiSkNativeObject { auto rects = arguments[1].asObject(runtime).asArray(runtime); auto transforms = arguments[2].asObject(runtime).asArray(runtime); auto paint = JsiSkPaint::fromValue(runtime, arguments[3]); - auto blendMode = count > 5 && !arguments[4].isUndefined() + auto blendMode = count >= 5 && !arguments[4].isNull() && + !arguments[4].isUndefined() ? static_cast(arguments[4].asNumber()) : SkBlendMode::kDstOver; @@ -440,7 +454,7 @@ class JsiSkCanvas : public JsiSkNativeObject { } std::vector colors; - if (count > 5 && !arguments[5].isUndefined()) { + if (count >= 6 && !arguments[5].isNull() && !arguments[5].isUndefined()) { auto colorsArray = arguments[5].asObject(runtime).asArray(runtime); int colorsSize = static_cast(colorsArray.size(runtime)); @@ -470,7 +484,7 @@ class JsiSkCanvas : public JsiSkNativeObject { } } SkSamplingOptions sampling(SkFilterMode::kLinear); - if (count > 6) { + if (count >= 7 && !arguments[6].isNull() && !arguments[6].isUndefined()) { sampling = SamplingOptionsFromValue(runtime, arguments[6]); } auto x = SkSpan(xforms.data(), xforms.size()); diff --git a/packages/skia/src/__tests__/snapshots/canvas/drawatlas-no-colors.png b/packages/skia/src/__tests__/snapshots/canvas/drawatlas-no-colors.png new file mode 100644 index 0000000000..c845acdab8 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/drawatlas-no-colors.png differ diff --git a/packages/skia/src/__tests__/snapshots/canvas/drawimage-optional-paint.png b/packages/skia/src/__tests__/snapshots/canvas/drawimage-optional-paint.png new file mode 100644 index 0000000000..aaab19f49a Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/drawimage-optional-paint.png differ diff --git a/packages/skia/src/__tests__/snapshots/canvas/drawpatch-default-blendmode.png b/packages/skia/src/__tests__/snapshots/canvas/drawpatch-default-blendmode.png new file mode 100644 index 0000000000..ad23538afd Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/drawpatch-default-blendmode.png differ diff --git a/packages/skia/src/__tests__/snapshots/canvas/drawpatch-default-blendmode.png.test.png b/packages/skia/src/__tests__/snapshots/canvas/drawpatch-default-blendmode.png.test.png new file mode 100644 index 0000000000..e7789beddb Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/drawpatch-default-blendmode.png.test.png differ diff --git a/packages/skia/src/__tests__/snapshots/canvas/drawpatch-optional-args.png b/packages/skia/src/__tests__/snapshots/canvas/drawpatch-optional-args.png new file mode 100644 index 0000000000..230703e64e Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/drawpatch-optional-args.png differ diff --git a/packages/skia/src/__tests__/snapshots/canvas/drawpoints-empty-array.png b/packages/skia/src/__tests__/snapshots/canvas/drawpoints-empty-array.png new file mode 100644 index 0000000000..5ad3f22eb7 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/drawpoints-empty-array.png differ diff --git a/packages/skia/src/__tests__/snapshots/canvas/savelayer-plain-rect-bounds.png b/packages/skia/src/__tests__/snapshots/canvas/savelayer-plain-rect-bounds.png new file mode 100644 index 0000000000..27b0ab81f4 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/canvas/savelayer-plain-rect-bounds.png differ diff --git a/packages/skia/src/renderer/__tests__/e2e/Canvas.spec.ts b/packages/skia/src/renderer/__tests__/e2e/Canvas.spec.ts new file mode 100644 index 0000000000..cb5ebdd972 --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/Canvas.spec.ts @@ -0,0 +1,339 @@ +import { checkImage } from "../../../__tests__/setup"; +import { BlendMode, PointMode } from "../../../skia/types"; +import { surface } from "../setup"; + +describe("SkCanvas", () => { + it("drawPatch works with omitted and nullable optional arguments", async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, ctx) => { + const flatPatch = (x: number, y: number, size: number) => { + const tl = { x, y }; + const tr = { x: x + size, y }; + const br = { x: x + size, y: y + size }; + const bl = { x, y: y + size }; + const lerp = ( + a: { x: number; y: number }, + b: { x: number; y: number }, + t: number + ) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); + return [ + tl, + lerp(tl, tr, 1 / 3), + lerp(tl, tr, 2 / 3), + tr, + lerp(tr, br, 1 / 3), + lerp(tr, br, 2 / 3), + br, + lerp(br, bl, 1 / 3), + lerp(br, bl, 2 / 3), + bl, + lerp(bl, tl, 1 / 3), + lerp(bl, tl, 2 / 3), + ]; + }; + + canvas.drawColor(Skia.Color("white")); + const size = 50; + const gap = 10; + const colors = [ + Skia.Color("red"), + Skia.Color("green"), + Skia.Color("blue"), + Skia.Color("black"), + ]; + // Calls that omit the paint argument fall back to CanvasKit's own + // opaque-black default paint, so - like every mode but DstOver - they + // render solid black: that is a faithful (and, for these argument-count + // variants, expected) result, not a rendering bug. Only the 5-argument + // call below supplies an explicit paint, which is what actually lets a + // distinct color show through. + const paint = Skia.Paint(); + paint.setColor(Skia.Color("cyan")); + + // 1 argument (only cubics) + canvas.drawPatch(flatPatch(gap, gap, size)); + + // 2 arguments (cubics and colors) + canvas.drawPatch(flatPatch(gap * 2 + size, gap, size), colors); + + // 3 arguments (cubics, colors, texs) + canvas.drawPatch( + flatPatch(gap * 3 + size * 2, gap, size), + colors, + null + ); + + // 4 arguments (cubics, colors, texs, mode) + canvas.drawPatch( + flatPatch(gap, gap * 2 + size, size), + colors, + null, + ctx.BlendMode.DstOver + ); + + // 5 arguments (cubics, colors, texs, mode, paint) + canvas.drawPatch( + flatPatch(gap * 2 + size, gap * 2 + size, size), + colors, + null, + ctx.BlendMode.SrcOver, + paint + ); + + // Nullable arguments + canvas.drawPatch( + flatPatch(gap * 3 + size * 2, gap * 2 + size, size), + null, + null, + null, + null + ); + }, + { BlendMode } + ); + checkImage(img, "snapshots/canvas/drawpatch-optional-args.png"); + }); + + it("drawAtlas works when colors are omitted", async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, ctx) => { + canvas.drawColor(Skia.Color("white")); + const size = 40; + const texSurface = Skia.Surface.MakeOffscreen(size, size)!; + const texCanvas = texSurface.getCanvas(); + texCanvas.drawColor(Skia.Color("orange")); + const image = texSurface.makeImageSnapshot(); + const paint = Skia.Paint(); + + // 5 arguments: atlas, srcs, dsts, paint, blendMode (without colors) + canvas.drawAtlas( + image, + [Skia.XYWHRect(0, 0, size, size)], + [Skia.RSXform(1, 0, 10, 10)], + paint, + ctx.BlendMode.SrcOver + ); + }, + { BlendMode } + ); + checkImage(img, "snapshots/canvas/drawatlas-no-colors.png"); + }); + + it("drawImage and drawImageRect accept null or omitted paint", async () => { + const img = await surface.drawOffscreen((Skia, canvas) => { + canvas.drawColor(Skia.Color("white")); + const size = 20; + const texSurface = Skia.Surface.MakeOffscreen(size, size)!; + const texCanvas = texSurface.getCanvas(); + texCanvas.drawColor(Skia.Color("purple")); + const image = texSurface.makeImageSnapshot(); + const rect = Skia.XYWHRect(0, 0, size, size); + + // drawImage without paint + canvas.drawImage(image, 10, 10); + // drawImage with null paint + canvas.drawImage(image, 40, 10, null); + + // drawImageRect without paint + canvas.drawImageRect(image, rect, Skia.XYWHRect(10, 40, size, size)); + // drawImageRect with null paint + canvas.drawImageRect( + image, + rect, + Skia.XYWHRect(40, 40, size, size), + null + ); + }); + checkImage(img, "snapshots/canvas/drawimage-optional-paint.png"); + }); + + it("saveLayer accepts null paint and a plain-object rect for bounds", async () => { + const img = await surface.drawOffscreen((Skia, canvas) => { + canvas.drawColor(Skia.Color("white")); + // A plain {x,y,width,height} object is a valid SkRect (see the public + // SkRect type) and must survive being passed as saveLayer's bounds + // without becoming a dangling native pointer. + const bounds = { x: 5, y: 5, width: 50, height: 50 }; + const count = canvas.saveLayer(null, bounds); + const paint = Skia.Paint(); + paint.setColor(Skia.Color("teal")); + canvas.drawRect(Skia.XYWHRect(10, 10, 30, 30), paint); + canvas.restoreToCount(count); + }); + checkImage(img, "snapshots/canvas/savelayer-plain-rect-bounds.png"); + }); + + it("drawPoints does not throw when the points array is empty", async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, ctx) => { + canvas.drawColor(Skia.Color("white")); + const paint = Skia.Paint(); + paint.setColor(Skia.Color("black")); + paint.setStrokeWidth(4); + + canvas.drawPoints(ctx.PointMode.Points, [], paint); + canvas.drawPoints(ctx.PointMode.Lines, [], paint); + canvas.drawPoints(ctx.PointMode.Polygon, [], paint); + + canvas.drawPoints( + ctx.PointMode.Polygon, + [ + { x: 10, y: 10 }, + { x: 50, y: 10 }, + { x: 50, y: 50 }, + { x: 10, y: 50 }, + ], + paint + ); + }, + { PointMode } + ); + checkImage(img, "snapshots/canvas/drawpoints-empty-array.png"); + }); + + it("drawPatch defaults to Modulate blend mode when omitted, matching the web/CanvasKit default", async () => { + const size = 60; + const gap = 10; + const img = await surface.drawOffscreen( + ( + Skia, + canvas, + ctx: { BlendMode: typeof BlendMode; size: number; gap: number } + ) => { + const flatPatch = (x: number, y: number, s: number) => { + const tl = { x, y }; + const tr = { x: x + s, y }; + const br = { x: x + s, y: y + s }; + const bl = { x, y: y + s }; + const lerp = ( + a: { x: number; y: number }, + b: { x: number; y: number }, + t: number + ) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); + return [ + tl, + lerp(tl, tr, 1 / 3), + lerp(tl, tr, 2 / 3), + tr, + lerp(tr, br, 1 / 3), + lerp(tr, br, 2 / 3), + br, + lerp(br, bl, 1 / 3), + lerp(br, bl, 2 / 3), + bl, + lerp(bl, tl, 1 / 3), + lerp(bl, tl, 2 / 3), + ]; + }; + + canvas.drawColor(Skia.Color("white")); + const colors = [ + Skia.Color("red"), + Skia.Color("lime"), + Skia.Color("blue"), + Skia.Color("yellow"), + ]; + // A translucent, tinted paint makes Modulate visibly differ from + // Src-over/Dst-over, so a regression to the old colors-dependent + // default would show up as a pixel mismatch below. + const paint = Skia.Paint(); + paint.setColor(Skia.Color("orange")); + paint.setAlphaf(0.5); + + // Left: mode omitted, must resolve to the same default as CanvasKit. + canvas.drawPatch( + flatPatch(ctx.gap, ctx.gap, ctx.size), + colors, + null, + null, + paint + ); + // Middle: explicit Modulate, the expected default. + canvas.drawPatch( + flatPatch(ctx.gap * 2 + ctx.size, ctx.gap, ctx.size), + colors, + null, + ctx.BlendMode.Modulate, + paint + ); + // Right: explicit DstOver, the old (incorrect) native default used + // when colors were present. Kept for visual contrast against the two + // patches above, which must look identical to each other. + canvas.drawPatch( + flatPatch(ctx.gap * 3 + ctx.size * 2, ctx.gap, ctx.size), + colors, + null, + ctx.BlendMode.DstOver, + paint + ); + }, + { BlendMode, size, gap } + ); + checkImage(img, "snapshots/canvas/drawpatch-default-blendmode.png"); + + // Pin down the regression precisely: render the *same* patch, at the + // *same* absolute position, once with mode omitted and once with mode + // explicitly set to Modulate, each in its own offscreen surface (side by + // side rendering isn't reliable for a pixel-exact comparison since patch + // tessellation isn't perfectly translation-invariant). The two renders + // must be byte-identical if the default truly resolves to Modulate. + const renderSinglePatch = (mode: BlendMode | null) => + surface.drawOffscreen( + ( + Skia, + canvas, + ctx: { size: number; gap: number; mode: number | null } + ) => { + const flatPatch = (x: number, y: number, s: number) => { + const tl = { x, y }; + const tr = { x: x + s, y }; + const br = { x: x + s, y: y + s }; + const bl = { x, y: y + s }; + const lerp = ( + a: { x: number; y: number }, + b: { x: number; y: number }, + t: number + ) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); + return [ + tl, + lerp(tl, tr, 1 / 3), + lerp(tl, tr, 2 / 3), + tr, + lerp(tr, br, 1 / 3), + lerp(tr, br, 2 / 3), + br, + lerp(br, bl, 1 / 3), + lerp(br, bl, 2 / 3), + bl, + lerp(bl, tl, 1 / 3), + lerp(bl, tl, 2 / 3), + ]; + }; + canvas.drawColor(Skia.Color("white")); + const colors = [ + Skia.Color("red"), + Skia.Color("lime"), + Skia.Color("blue"), + Skia.Color("yellow"), + ]; + const paint = Skia.Paint(); + paint.setColor(Skia.Color("orange")); + paint.setAlphaf(0.5); + canvas.drawPatch( + flatPatch(ctx.gap, ctx.gap, ctx.size), + colors, + null, + ctx.mode as BlendMode | null, + paint + ); + }, + { size, gap, mode } + ); + + const omitted = await renderSinglePatch(null); + const explicitModulate = await renderSinglePatch(BlendMode.Modulate); + expect(Array.from(omitted.encodeToBytes()!)).toEqual( + Array.from(explicitModulate.encodeToBytes()!) + ); + }); +}); diff --git a/packages/skia/src/skia/__tests__/ZeroValues.spec.ts b/packages/skia/src/skia/__tests__/ZeroValues.spec.ts index 473242ed5e..7ee7f189f0 100644 --- a/packages/skia/src/skia/__tests__/ZeroValues.spec.ts +++ b/packages/skia/src/skia/__tests__/ZeroValues.spec.ts @@ -1,7 +1,7 @@ import fs from "fs"; import path from "path"; -import { BlendMode, FontWeight, ImageFormat } from "../types"; +import { BlendMode, FontWeight, ImageFormat, PointMode } from "../types"; import type { SkCanvas, Skia, SkSurface } from "../types"; import type { JsiSkCanvas } from "../web/JsiSkCanvas"; import { JsiSkTextStyle } from "../web/JsiSkTextStyle"; @@ -90,6 +90,45 @@ describe("Zero is a valid value", () => { expect(spy.mock.calls[0][4]).toBe(CanvasKit.BlendMode.Clear); spy.mockRestore(); }); + + it("drawAtlas forwards BlendMode.Clear when colors are omitted", () => { + const { Skia, canvas, CanvasKit } = setupSkia(4, 4); + const spy = jest.spyOn(ckRef(canvas), "drawAtlas"); + const image = makeGradientImage(Skia, 8); + canvas.drawAtlas( + image, + [Skia.XYWHRect(0, 0, 8, 8)], + [Skia.RSXform(1, 0, 0, 0)], + Skia.Paint(), + BlendMode.Clear + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][4]).toBe(CanvasKit.BlendMode.Clear); + spy.mockRestore(); + }); + + it("drawPatch works with omitted optional arguments", () => { + const { Skia, canvas } = setupSkia(4, 4); + const cubics = Array.from({ length: 12 }, (_, i) => ({ x: i, y: i })); + expect(() => { + canvas.drawPatch(cubics); + canvas.drawPatch(cubics, [ + Skia.Color("red"), + Skia.Color("green"), + Skia.Color("blue"), + Skia.Color("white"), + ]); + canvas.drawPatch(cubics, null, null, BlendMode.Clear); + }).not.toThrow(); + }); + + it("drawPoints does not throw on empty points array", () => { + const { Skia, canvas } = setupSkia(4, 4); + const paint = Skia.Paint(); + expect(() => { + canvas.drawPoints(PointMode.Points, [], paint); + }).not.toThrow(); + }); }); describe("Image", () => { diff --git a/packages/skia/src/skia/types/Canvas.ts b/packages/skia/src/skia/types/Canvas.ts index 3c15acad6b..e6930ccc94 100644 --- a/packages/skia/src/skia/types/Canvas.ts +++ b/packages/skia/src/skia/types/Canvas.ts @@ -40,7 +40,12 @@ export interface SkCanvas { * @param top * @param paint */ - drawImage: (image: SkImage, x: number, y: number, paint?: SkPaint) => void; + drawImage: ( + image: SkImage, + x: number, + y: number, + paint?: SkPaint | null + ) => void; /** * Draws sub-rectangle src from provided image, scaled and translated to fill dst rectangle. @@ -54,7 +59,7 @@ export interface SkCanvas { img: SkImage, src: SkRect, dest: SkRect, - paint: SkPaint, + paint?: SkPaint | null, fastSample?: boolean ): void; @@ -232,7 +237,7 @@ export interface SkCanvas { colors?: readonly SkColor[] | null, texs?: readonly SkPoint[] | null, mode?: BlendMode | null, - paint?: SkPaint + paint?: SkPaint | null ): void; /** @@ -405,7 +410,7 @@ export interface SkCanvas { * @param flags */ saveLayer( - paint?: SkPaint, + paint?: SkPaint | null, bounds?: SkRect | null, backdrop?: SkImageFilter | null, flags?: SaveLayerFlag diff --git a/packages/skia/src/skia/web/JsiSkCanvas.ts b/packages/skia/src/skia/web/JsiSkCanvas.ts index 540b04d8a2..a0cc690b11 100644 --- a/packages/skia/src/skia/web/JsiSkCanvas.ts +++ b/packages/skia/src/skia/web/JsiSkCanvas.ts @@ -3,6 +3,7 @@ import type { CanvasKit, CubicResampler as CKCubicResampler, FilterOptions as CKFilterOptions, + Paint, } from "canvaskit-wasm"; import { @@ -54,10 +55,20 @@ export class JsiSkCanvas extends HostObject implements SkCanvas { + private defaultPaint?: Paint; + constructor(CanvasKit: CanvasKit, ref: Canvas) { super(CanvasKit, ref, "Canvas"); } + override [Symbol.dispose](): void { + if (this.defaultPaint && !this.defaultPaint.isDeleted()) { + this.defaultPaint.delete(); + } + this.defaultPaint = undefined; + super[Symbol.dispose](); + } + drawRect(rect: SkRect, paint: SkPaint) { this.ref.drawRect( JsiSkRect.fromValue(this.CanvasKit, rect), @@ -65,12 +76,12 @@ export class JsiSkCanvas ); } - drawImage(image: SkImage, x: number, y: number, paint?: SkPaint) { + drawImage(image: SkImage, x: number, y: number, paint?: SkPaint | null) { this.ref.drawImage( JsiSkImage.fromValue(image), x, y, - paint ? JsiSkPaint.fromValue(paint) : paint + paint ? JsiSkPaint.fromValue(paint) : undefined ); } @@ -78,16 +89,18 @@ export class JsiSkCanvas img: SkImage, src: SkRect, dest: SkRect, - paint: SkPaint, + paint?: SkPaint | null, fastSample?: boolean ) { - this.ref.drawImageRect( - JsiSkImage.fromValue(img), - JsiSkRect.fromValue(this.CanvasKit, src), - JsiSkRect.fromValue(this.CanvasKit, dest), - JsiSkPaint.fromValue(paint), - fastSample - ); + this.withPaint(paint, (p) => { + this.ref.drawImageRect( + JsiSkImage.fromValue(img), + JsiSkRect.fromValue(this.CanvasKit, src), + JsiSkRect.fromValue(this.CanvasKit, dest), + p, + fastSample + ); + }); } drawImageCubic( @@ -203,17 +216,39 @@ export class JsiSkCanvas colors?: SkColor[] | null, texs?: SkPoint[] | null, mode?: BlendMode | null, - paint?: SkPaint + paint?: SkPaint | null ) { - this.ref.drawPatch( - cubics.map(({ x, y }) => [x, y]).flat(), - colors, - texs ? texs.flatMap((p) => Array.from(JsiSkPoint.fromValue(p))) : texs, - mode !== undefined && mode !== null - ? getEnum(this.CanvasKit, "BlendMode", mode) - : null, - paint ? JsiSkPaint.fromValue(paint) : undefined - ); + this.withPaint(paint, (p) => { + this.ref.drawPatch( + cubics.map(({ x, y }) => [x, y]).flat(), + colors ?? null, + texs + ? texs.flatMap((pt) => Array.from(JsiSkPoint.fromValue(pt))) + : null, + mode !== undefined && mode !== null + ? getEnum(this.CanvasKit, "BlendMode", mode) + : null, + p + ); + }); + } + + // Draws with the caller's paint if given, otherwise a default CanvasKit + // paint that is cached on this canvas and reused (deleted only when the + // canvas itself is disposed) so it doesn't allocate/free on the WASM + // heap on every call. + private withPaint( + paint: SkPaint | null | undefined, + draw: (p: Paint) => void + ) { + if (paint) { + draw(JsiSkPaint.fromValue(paint)); + return; + } + if (!this.defaultPaint) { + this.defaultPaint = new this.CanvasKit.Paint(); + } + draw(this.defaultPaint); } restoreToCount(saveCount: number) { @@ -228,6 +263,9 @@ export class JsiSkCanvas } drawPoints(mode: PointMode, points: SkPoint[], paint: SkPaint) { + if (points.length === 0) { + return; + } this.ref.drawPoints( getEnum(this.CanvasKit, "PointMode", mode), points.map(({ x, y }) => [x, y]).flat(), @@ -328,7 +366,7 @@ export class JsiSkCanvas } saveLayer( - paint?: SkPaint, + paint?: SkPaint | null, bounds?: SkRect | null, backdrop?: SkImageFilter | null, flags?: SaveLayerFlag