diff --git a/docs/context-2d.md b/docs/context-2d.md index 3c0b448..6bde946 100644 --- a/docs/context-2d.md +++ b/docs/context-2d.md @@ -725,6 +725,45 @@ whose shadow can land on the target at all (its own bounds, moved back by the offset and grown by the blur's reach), so a shape mostly off-screen does not allocate a surface the size of its bounding box. +### How strong a shadow gets, and how to test one + +A blurred shadow reaches `shadowColor` only where the shape casting it is +wide compared with the blur. That follows from what a blur *is* — coverage +convolved with a gaussian — but it surprises people looking at pixels, so +here it is in numbers, with σ = `shadowBlur / 2`: + +| what casts it | `shadowBlur` | peak alpha | +| --- | --- | --- | +| a 60×40 rect | 30 (σ 15) | 0.78 | +| a 60×40 rect | 8 (σ 4) | 1.00 | +| 48px glyph stems | 14 (σ 7) | 0.37 | + +A glyph stem five pixels wide against σ 7 keeps about `erf(5 / (2√2 · 7))` +of its coverage — under a third — and that is what a browser draws too. So +**an exact-colour pixel assertion is the wrong test for a shadow**: a +"count the pixels within 90 of `#ff0000`" check finds nothing on a canvas +whose red glyph shadow is plainly visible, because no pixel on it is ever +that red (issue #287). + +What to assert instead: + +- **the shadow's own alpha**, on a transparent target. Draw with + `fillStyle = 'rgba(0, 0, 0, 0)'` so only the shadow paints, and read the + alpha channel out of `getImageData` — it is the coverage, with no + background mixed into it +- **a difference between two places**, rather than a colour: darker (or + more tinted) where the shadow is than where it is not, at an offset the + drawing itself does not reach +- **the profile**, when the blur itself is what is under test: a blurred + straight edge follows the gaussian's CDF, so coverage at ±σ is + 0.841 / 0.159 (this is what `test/shadow.test.js` and + `test/smoke-canvas.test.js` check) + +None of this changes with the server. Shadows render identically on +node-x11's in-process JS X server and on Xorg — same requests, same +pixels — and both suites pin the same numbers; see +[xserver.md](xserver.md). + ## Text Text is fully shaped: OpenType kerning and ligatures, contextual forms for diff --git a/docs/xserver.md b/docs/xserver.md index 470d0ec..8d8b307 100644 --- a/docs/xserver.md +++ b/docs/xserver.md @@ -35,3 +35,10 @@ Rasterization is antialiased but intentionally not pixel-exact with Xorg — assert on regions/tolerances, not exact edge pixels (see `test/xserver.test.js` for the patterns; that suite runs ntk end-to-end against this server with no `$DISPLAY` and no fontconfig). + +Server-side *filtering* is exact, though, and shadows are the case worth +naming: the `convolution` filter behind `shadowBlur` produces the same +pixels here as on Xorg, so a shadow that seems to vanish under a headless +harness is an assertion problem rather than a missing request (issue #287). +[context-2d.md](context-2d.md#how-strong-a-shadow-gets-and-how-to-test-one) +has the numbers and what to assert. diff --git a/test/shadow.test.js b/test/shadow.test.js index 666afb3..735e0ed 100644 --- a/test/shadow.test.js +++ b/test/shadow.test.js @@ -506,3 +506,64 @@ describe('shadows on laid-out text', () => { ctx.destroy(); }); }); + +// ------------------------------------------------------------------ +// how strong a blurred shadow gets (issue #287) + +describe('how strong a blurred shadow gets', () => { + // Issue #287 read a shadow as missing on this very server because nothing + // on the canvas came within a tolerance of `shadowColor` itself. Nothing + // was missing: a blurred shadow only *reaches* its colour where the shape + // casting it is wide compared with the blur, and a glyph stem never is. + // The numbers below are the ones that decided it, and they are the same on + // Xorg (test/smoke-canvas.test.js pins the rect case there too). + + /** the shadow's own alpha, straight from getImageData, over the surface */ + const shadowAlpha = async (ctx, w, h) => { + const img = await ctx.getImageData(0, 0, w, h); + let peak = 0; + let painted = 0; + for (let i = 3; i < img.data.length; i += 4) { + if (img.data[i] > 0) painted++; + if (img.data[i] > peak) peak = img.data[i]; + } + return { peak, painted }; + }; + + test('a shape much wider than the blur reaches the shadow colour', async () => { + const w = 180; + const h = 140; + const ctx = target(w, h); + ctx.shadowColor = '#ff0000'; + ctx.shadowBlur = 30; // sigma 15, so a 60x40 rect is 4 sigma by 2.7 + ctx.fillStyle = 'rgba(0, 0, 0, 0)'; // only the shadow paints + ctx.fillRect(60, 50, 60, 40); + const { peak } = await shadowAlpha(ctx, w, h); + // convolving that rect with the same kernel gives 0.784 of full alpha — + // the interior is not opaque either, because 60x40 is not wide enough + // for one, and the gaussian says exactly how much it keeps + assert.ok(Math.abs(peak - 200) <= 4, `the middle of the shadow is ${peak}, expected ~200`); + ctx.destroy(); + }); + + test('the shadow of 48px glyphs peaks at about a third of it', async () => { + const w = 200; + const h = 120; + const ctx = target(w, h); + ctx.font = '48px sans-serif'; + ctx.shadowColor = '#ff0000'; + ctx.shadowBlur = 14; + ctx.shadowOffsetX = 5; + ctx.shadowOffsetY = 5; + ctx.fillStyle = 'rgba(0, 0, 0, 0)'; + ctx.fillText('AAA', 10, 70); + const { peak, painted } = await shadowAlpha(ctx, w, h); + assert.ok(painted > 2000, `the shadow is there (${painted} painted pixels)`); + // a 48px stem is about 5px wide against sigma 7: erf(5 / (2*sqrt(2)*7)) + // is 0.28, and two neighbouring stems add to a little over a third + assert.ok(peak > 60 && peak < 140, `peak alpha ${peak}, expected ~94`); + // which is why a test that looks for `shadowColor` itself finds nothing + assert.ok(peak < 165, 'nothing on the canvas is within 90 of the colour'); + ctx.destroy(); + }); +}); diff --git a/test/smoke-canvas.test.js b/test/smoke-canvas.test.js index 77a4176..535e8de 100644 --- a/test/smoke-canvas.test.js +++ b/test/smoke-canvas.test.js @@ -370,3 +370,29 @@ test('shadows: offset, blurred and coloured, on a real server', async (t) => { blurred.pixmap.destroy(); pixmap.destroy(); }); + +test('shadows: a wide blur keeps the same fraction of its colour here (#287)', async (t) => { + if (skip) return t.skip(skip); + // The one number issue #287 turned on: how *strong* a blurred shadow gets. + // A shadow only reaches `shadowColor` where the shape casting it is wide + // compared with the blur, so the interior of a 60x40 rect at sigma 15 is + // 0.784 of full alpha and not 1 — and reading that as "the shadow is + // missing" is what the issue did. The hermetic run asserts the same 0.784 + // against node-x11's JS server (test/shadow.test.js); this asserts Xorg + // agrees, which is the claim the issue needed and nothing was checking. + const { pixmap, ctx } = freshCtx(180); + + ctx.shadowColor = 'black'; + ctx.shadowBlur = 30; + ctx.fillStyle = 'rgba(0, 0, 0, 0)'; // only the shadow paints + ctx.fillRect(60, 50, 60, 40); + + const image = await readPixels(ctx, 180, 180); + // black shadow on white: alpha is 1 - grey + const alpha = 1 - px(image, 180, 90, 70)[0] / 255; + assert.ok( + Math.abs(alpha - 0.784) < 0.03, + `the middle of the shadow is ${alpha.toFixed(3)} of the colour, expected ~0.784` + ); + pixmap.destroy(); +});