Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/context-2d.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/xserver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
61 changes: 61 additions & 0 deletions test/shadow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
26 changes: 26 additions & 0 deletions test/smoke-canvas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});