diff --git a/package.json b/package.json index bfd7c68..82179ce 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "build": "node-gyp rebuild", "build:prebuild": "node scripts/prebuild.js", "test": "npm run test:unit && npm run test:window && npm run test:shell && npm run test:a11y && npm run test:identity && npm run test:pane && npm run test:gpu", - "test:unit": "node test/smoke.js && node test/text.js && node test/bezels.js && node test/primitives.js && node test/path-stream.js && node test/dashes.js", + "test:unit": "node test/smoke.js && node test/text.js && node test/bezels.js && node test/primitives.js && node test/clip-rule.js && node test/path-stream.js && node test/dashes.js", "test:window": "node test/clips.js && node test/states.js && node test/dnd.js && node test/ime.js && node test/popup.js", "test:shell": "node test/shell.js", "test:a11y": "node test/uia.js", diff --git a/src/surface.cc b/src/surface.cc index 1baf3bf..467940b 100644 --- a/src/surface.cc +++ b/src/surface.cc @@ -901,15 +901,21 @@ Napi::Value CtxStroke(const Napi::CallbackInfo& info) { return info.Env().Undefined(); } +// ctxClip(surface, evenOdd): the flag ctxFill takes, so canvas's +// clip(path, 'evenodd') — an SVG with clip-rule="evenodd" — cuts +// a ring rather than the square around it. Absent is nonzero, as before. Napi::Value CtxClip(const Napi::CallbackInfo& info) { Surface* s = Arg(info); if (!s || !s->dc) return info.Env().Undefined(); - ID2D1PathGeometry* geometry = BuildPath(s, false); + const bool evenOdd = info.Length() > 1 && info[1].ToBoolean().Value(); + ID2D1PathGeometry* geometry = BuildPath(s, evenOdd); if (!geometry) return info.Env().Undefined(); // An axis-aligned rectangle under an axis-aligned transform is the clip // every paint pass sets, and PushAxisAlignedClip is far cheaper than a - // layer with a geometric mask. Anything else takes the layer. + // layer with a geometric mask. Anything else takes the layer. The rule + // does not enter into it: one rect is one figure wound once, which either + // fill mode cuts alike. D2D1_RECT_F bounds = {}; bool rectangular = false; if (s->path.size() == 1 && s->path[0].op == PathOp::Rect) { diff --git a/test/clip-rule.js b/test/clip-rule.js new file mode 100644 index 0000000..c4ecb35 --- /dev/null +++ b/test/clip-rule.js @@ -0,0 +1,75 @@ +'use strict'; + +// ctxClip(surface, evenOdd): the fill rule of a clip, as ctxFill takes it. +// canvas's clip(path, 'evenodd') is how ntk's SvgView cuts an element to a +// with clip-rule="evenodd" (sidorares/ntk#529), and react-x11's +// context had nothing to hand it to — CtxClip always built its geometry with +// the winding fill mode, so a ring clipped a fill to the whole square around +// it. +// +// Checked: a ring of two rects wound the same way clips evenodd to the ring, +// its hole left alone; without the flag, or with it false, the same path +// clips nonzero, hole and all; and one rect — which takes +// PushAxisAlignedClip rather than a layer — clips the same under either rule. + +const assert = require('node:assert'); +const win32 = require('../index.js'); + +win32.start(() => {}); + +const W = 60; + +/** Fill the whole surface white through a clip to `path`, over black. */ +function clipped(path, ...args) { + const s = win32.createSurface(W, W, 1); + win32.ctxSetFillColor(s, 0, 0, 0, 1); + win32.ctxFillRect(s, 0, 0, W, W); + win32.ctxSave(s); + win32.ctxBeginPath(s); + path(s); + win32.ctxClip(s, ...args); + win32.ctxSetFillColor(s, 1, 1, 1, 1); + win32.ctxFillRect(s, 0, 0, W, W); + win32.ctxRestore(s); + const pixels = win32.ctxGetImageData(s, 0, 0, W, W); + win32.releaseSurface(s); + return pixels; +} +const red = (pixels, x, y) => pixels[(y * W + x) * 4]; + +// a 40px square with a 20px hole, both wound the same way: nonzero fills the +// hole, evenodd leaves it +const ring = (s) => { + win32.ctxRect(s, 10, 10, 40, 40); + win32.ctxRect(s, 20, 20, 20, 20); +}; +const square = (s) => win32.ctxRect(s, 10, 10, 40, 40); + +{ + const p = clipped(ring, true); + assert.equal(red(p, 15, 15), 255, 'evenodd: the ring is drawn'); + assert.equal(red(p, 30, 30), 0, 'evenodd: the hole is filled — the clip was cut nonzero'); + assert.equal(red(p, 5, 5), 0, 'evenodd: something outside the ring was drawn'); +} + +for (const [name, args] of [ + ['no flag', []], + ['false', [false]], +]) { + const p = clipped(ring, ...args); + assert.equal(red(p, 15, 15), 255, `${name}: the ring is drawn`); + assert.equal(red(p, 30, 30), 255, `${name}: nonzero leaves the hole filled`); + assert.equal(red(p, 5, 5), 0, `${name}: something outside the ring was drawn`); +} + +{ + // one rect is one figure wound once: either rule cuts the same pixels + const a = clipped(square, true); + const b = clipped(square, false); + let differ = 0; + for (let i = 0; i < a.length; i += 4) if (a[i] !== b[i]) differ++; + assert.equal(differ, 0, `one rect clips differently under the two rules, ${differ} pixels apart`); +} + +win32.stop(); +console.log('clip-rule: ok');