diff --git a/docs/context-2d.md b/docs/context-2d.md index bc29562..3c0b448 100644 --- a/docs/context-2d.md +++ b/docs/context-2d.md @@ -637,8 +637,9 @@ like any other style. ## Shadows The four canvas shadow properties, applied to every drawing operation — -`fill`, `stroke`, `fillText`, `fillRect`, `strokeRect`, `fillRects` and -`drawImage`: +`fill`, `stroke`, `fillText`, `fillRect`, `strokeRect`, `fillRects`, +`drawImage` and `drawGlyphs` (so a `TextLayout` shadows exactly as a +`fillText` does): ```js ctx.shadowColor = '#05070a'; @@ -694,6 +695,19 @@ key and rebuild their coverage per draw, so a large blurred path shadow in a render loop is the shape to watch for; draw it into a `Surface` yourself and `drawImage` that instead. +**Laid-out text is cached too**, on the identity of the runs it is made of +rather than on a string: a whole paragraph is one coverage surface, whatever +its line count, keyed by those runs and their positions relative to each +other. So the same words wrapped to two different widths are two shadows — +they are two drawings — while re-drawing one `TextLayout`, anywhere on the +target, is a lookup and a composite. A caller that hand-builds fresh runs +every frame (a terminal grid, say) has nothing stable to key on and pays for +its coverage each time. + +A shadow belongs to a drawing *call*, here as in a browser: a paragraph +whose spans change colour is drawn as several glyph composites, and each +casts its own shadow, exactly as consecutive `fillText`s would. + `app.shadowPolicy` tunes the ceilings (partial objects merge over the defaults): diff --git a/docs/text.md b/docs/text.md index 3fddb17..e6c7cfc 100644 --- a/docs/text.md +++ b/docs/text.md @@ -228,6 +228,12 @@ clip, and takes the server-side fast path under a rectangular clip. Glyph origins round to whole pixels on the bitmap path — a grid renderer positions on integers anyway, so nothing moves. +It also honours the [shadow](context-2d.md#shadows) state: the runs of one +call become one blurred coverage surface, painted under the glyphs. That is +what gives a `TextLayout` a shadow, and it is cached on the identity of the +runs rather than on a string — so a paragraph redrawn from the same layout +is a cache hit, while runs rebuilt every frame are not. + The advance is what makes a grid cheap: the server moves its pen by each glyph's stored rounded advance, and position bytes are emitted only where the requested position deviates from that pen. A run whose `ax` is the @@ -326,7 +332,9 @@ start, end }` in visual order (`start`/`end` are the logical UTF-16 ranges the line/run covers). `layout.draw(ctx, x, y)` draws at (x, y) in the context's user space — the transform applies to the origin, so a paragraph in a translated context lands with the rest of the drawing — batching -consecutive same-color runs into single requests. Geometry and hit testing +consecutive same-color runs into single requests. The context's shadow +properties apply, one coverage surface per batch, so text that wraps casts a +shadow the same way `fillText` does. Geometry and hit testing (`caretPosition`, `indexAt`, `lines[]`) are relative to that same origin. Line breaking is UAX#14 (`linebreak` package); `\n` forces breaks; a word diff --git a/lib/renderingcontext_2d.js b/lib/renderingcontext_2d.js index 396c4c1..dab33bf 100755 --- a/lib/renderingcontext_2d.js +++ b/lib/renderingcontext_2d.js @@ -52,6 +52,8 @@ import { compositeTraps, drawGlyphRuns, encodeGlyphItems, + positionedRunsInk, + runId, } from "./text/glyphs.js"; import { TextLayout } from "./text/layout.js"; import { reorderRuns } from "./text/shape.js"; @@ -1099,6 +1101,110 @@ class RenderingContext2d { ); } + /** + * The shadow of positioned glyph runs, cached — what `drawGlyphs`, and + * therefore every `TextLayout.draw`, casts (issue #283). + * + * A paragraph gets **one** coverage surface, not one per line: the runs + * already carry their own baselines, so they all go into the same surface + * exactly as they all go into the same glyph composite. Nothing is + * re-shaped — the caller handed us the runs, which is why this path is + * cheaper than `_shadowOfText`, not dearer. + * + * The cached copy is position-independent, as `fillText`'s is: geometry is + * stored relative to the first run's origin and the composite carries it + * to wherever the text is drawn. The key is that relative geometry plus + * the identity of each run, so the same string laid out to two widths — + * same runs, different line origins — is two shadows, and re-drawing one + * layout is one lookup no matter how many glyphs are in it. + * + * @param {Array<{run, x, y, textRendering?}>} positioned device-space runs + */ + _shadowOfGlyphs(positioned) { + if (!positioned.length) return; + const app = this.window.app; + const policy = shadowPolicyOf(app); + const sigma = shadowSigma(this._shadowBlur, policy); + const reach = shadowReach(sigma); + + // Run origins relative to the first, rounded: whole-pixel offsets are + // what the bitmap glyph path draws at anyway, and they keep the key + // stable as the paragraph moves — `(x + a) - (x + b)` is not exactly + // `a - b` in floating point, and an origin-dependent key would miss the + // cache on every scroll. + const ax = positioned[0].x; + const ay = positioned[0].y; + const local = positioned.map((p) => ({ + run: p.run, + x: Math.round(p.x - ax), + y: Math.round(p.y - ay), + textRendering: p.textRendering, + })); + const key = `${local + .map((p) => `${runId(p.run)},${p.x},${p.y},${p.textRendering ?? ""}`) + .join("\u0000")}\u0000${sigma}`; + + let ink = null; + const surface = cachedShadow(app, key, () => { + ink = positionedRunsInk(local); + if (!ink) return null; // a line of spaces inks nothing + const box = { + x: Math.floor(ink.minX) - 1 - reach, + y: Math.floor(ink.minY) - 1 - reach, + }; + box.w = Math.ceil(ink.maxX) + 1 + reach - box.x; + box.h = Math.ceil(ink.maxY) + 1 + reach - box.y; + if (box.w * box.h > policy.maxPixels) return null; + let coverage = new Surface(app, { + width: box.w, + height: box.h, + format: "a8", + }); + coverage.render((sctx) => { + this._loadShadowState(sctx, 0, 0); + // the origins are already device-space and placed by hand + sctx._m = [1, 0, 0, 1, 0, 0]; + sctx._drawGlyphsDevice( + this.Render.PictOp.Over, + sctx._backgroundPicture, + local.map((p) => ({ ...p, x: p.x - box.x, y: p.y - box.y })), + ); + }); + if (sigma > 0) coverage = blurCoverage(coverage, sigma); + // where the anchor sits inside the surface — whole pixels, so the + // composite below can carry it anywhere + coverage._shadowOrigin = { x: -box.x, y: -box.y }; + return coverage; + }); + if (surface) { + const origin = surface._shadowOrigin; + this._paintShadow( + surface, + Math.round(ax + this._shadowOffsetX) - origin.x, + Math.round(ay + this._shadowOffsetY) - origin.y, + ); + return; + } + if (!ink) return; + // Padded ink larger than a shadow surface may be: fall back to the + // clipped, uncached path, which sizes itself to the part of the shadow + // that can actually be seen — the same escape `_shadowOfText` takes. + this._shadowOfDrawing( + { + minX: ax + ink.minX, + maxX: ax + ink.maxX, + minY: ay + ink.minY, + maxY: ay + ink.maxY, + }, + (sctx, dx, dy) => + sctx._drawGlyphsDevice( + this.Render.PictOp.Over, + sctx._backgroundPicture, + positioned.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })), + ), + ); + } + /** the shadow of a `fill()`/`stroke()`-shaped call, from its arguments */ _shadowOfPath(args, stroke) { if (stroke) { @@ -2152,6 +2258,12 @@ class RenderingContext2d { * is not at the window's origin — landed at the untransformed coordinates * and was then cut by the clip, while the neighbouring `fillRect` and * `drawImage` moved (issue #280). + * + * The shadow state applies too, as it does to `fillText`: one blurred + * coverage surface for the whole call, cached on the runs' identity and + * relative positions, painted under the glyphs (issue #283). A paragraph + * whose spans change colour draws as several calls, and — as several + * `fillText`s would — casts a shadow per call. */ drawGlyphs(op, src, positioned) { const m = this._m; @@ -2161,6 +2273,7 @@ class RenderingContext2d { return { ...p, x, y }; }); } + if (this._shadowed()) this._shadowOfGlyphs(positioned); this._drawGlyphsDevice(op, src, positioned); } diff --git a/lib/text/glyphs.js b/lib/text/glyphs.js index d95e38a..79478e7 100644 --- a/lib/text/glyphs.js +++ b/lib/text/glyphs.js @@ -225,6 +225,67 @@ export function positionGlyphs(positioned) { return out; } +/** + * Ink extents of positioned runs, in whatever coordinates their origins are + * given in — the union of every glyph's bounding box, laid out exactly as + * `positionGlyphs` lays it out (pen at `x`, glyph at `pen + dx`, `y - dy`, + * pen advanced by `ax`). + * + * Returns `null` when nothing inks: a run of spaces has extents but no + * bounding box, and neither does an empty array. Blank glyphs report an + * empty `cbox` (`minX` infinite), which the comparisons below drop on their + * own. + * + * This is what sizes a glyph shadow's coverage surface — the run-shaped + * counterpart of the context's `_shapedInk`, which measures one shaped + * string from its own origin. + * + * @param {Array<{run, x, y}>} positioned + * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} + */ +export function positionedRunsInk(positioned) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const { run, x, y } of positioned) { + let cursor = x; + for (const g of run.glyphs) { + const e = run.font.glyphExtents(g.id, run.size); + const gx = cursor + g.dx; + const gy = y - g.dy; + cursor += g.ax; + if (gx + e.minX < minX) minX = gx + e.minX; + if (gx + e.maxX > maxX) maxX = gx + e.maxX; + if (gy + e.minY < minY) minY = gy + e.minY; + if (gy + e.maxY > maxY) maxY = gy + e.maxY; + } + } + return minX <= maxX && minY <= maxY ? { minX, minY, maxX, maxY } : null; +} + +// Identity, not content, for anything that wants to name a run cheaply. +// A shaped run is immutable and shared — the shaping memo hands the same +// object back, and a TextLayout holds on to the ones its lines are made of +// — so a small integer per object is a complete name for the glyphs in it, +// bought at O(1) instead of O(glyphs). Weak, so naming a run keeps nothing +// alive. +const runIds = new WeakMap(); +let nextRunId = 0; + +/** + * A stable small integer for one run object, for cache keys that would + * otherwise have to serialize its glyphs. Runs built fresh on every draw + * (rather than kept, as a `TextLayout` keeps them) get a fresh id each time + * and so never hit such a cache — which is the honest answer, since nothing + * cheap can tell them apart. + */ +export function runId(run) { + let id = runIds.get(run); + if (id === undefined) runIds.set(run, (id = ++nextRunId)); + return id; +} + /** * Decide how a (face, size) renders: cached bitmap glyphs or per-draw * trapezoids. See DEFAULT_TEXT_POLICY for the reasoning; the middle band diff --git a/test/shadow.test.js b/test/shadow.test.js index fff22a5..666afb3 100644 --- a/test/shadow.test.js +++ b/test/shadow.test.js @@ -370,3 +370,139 @@ describe('text shadows', () => { ctx.destroy(); }); }); + +// ------------------------------------------------------------------ +// text that went through a layout, which is a different drawing path +// (issue #283): TextLayout.draw composites through drawGlyphs, which used +// to be the one text call that ignored the shadow state + +describe('shadows on laid-out text', () => { + /** how many pixels are mostly-blue (the shadow) / mostly-red (the text) */ + const tally = (img) => { + let red = 0; + let blue = 0; + for (let i = 0; i < img.data.length; i += 4) { + if (img.data[i] > 128 && img.data[i + 2] < 128) red++; + if (img.data[i + 2] > 128 && img.data[i] < 128) blue++; + } + return { red, blue }; + }; + + const shadowedText = (ctx) => { + ctx.font = '20px sans-serif'; + ctx.shadowColor = '#0000ff'; + ctx.shadowOffsetX = 4; + ctx.shadowOffsetY = 4; + ctx.fillStyle = '#ff0000'; + }; + + test('the same string shadows the same drawn either way', async () => { + const direct = target(); + shadowedText(direct); + direct.fillText('Hi there', 10, 40); + const one = tally(await direct.getImageData(0, 0, W, H)); + + const laid = target(); + shadowedText(laid); + const layout = laid.layoutText('Hi there'); + // layout.draw takes the box's top-left; fillText takes the baseline + layout.draw(laid, 10, 40 - layout.lines[0].baseline); + const two = tally(await laid.getImageData(0, 0, W, H)); + + assert.ok(one.blue > 20, `fillText is shadowed (${one.blue} blue pixels)`); + assert.ok(two.blue > 20, `and so is the layout (${two.blue} blue pixels)`); + assert.ok(two.red > 20, 'the glyphs themselves are still drawn'); + assert.ok( + Math.abs(one.blue - two.blue) <= one.blue * 0.15, + `the two shadows are the same size (${one.blue} vs ${two.blue})` + ); + direct.destroy(); + laid.destroy(); + }); + + test('every line of a wrapped paragraph casts one', async () => { + const ctx = target(); + shadowedText(ctx); + ctx.font = '16px sans-serif'; + const layout = ctx.layoutText('one two three four', { maxWidth: 60 }); + assert.ok(layout.lines.length > 1, `the text wrapped (${layout.lines.length} lines)`); + layout.draw(ctx, 8, 8); + const img = await ctx.getImageData(0, 0, W, H); + const blueIn = (y0, y1) => { + let n = 0; + for (let y = y0; y < y1; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (img.data[i + 2] > 128 && img.data[i] < 128) n++; + } + } + return n; + }; + const mid = Math.round(8 + layout.height / 2); + assert.ok(blueIn(0, mid) > 10, 'the first line is shadowed'); + assert.ok(blueIn(mid, H) > 10, 'and so is the last'); + ctx.destroy(); + }); + + test('the transform carries the shadow with the text', async () => { + const ctx = target(); + shadowedText(ctx); + ctx.font = '16px sans-serif'; + ctx.translate(40, 30); + ctx.layoutText('Hi').draw(ctx, 0, 0); + const img = await ctx.getImageData(0, 0, W, H); + let blue = 0; + let minX = W; + let minY = H; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (img.data[i + 2] > 128 && img.data[i] < 128) { + blue++; + if (x < minX) minX = x; + if (y < minY) minY = y; + } + } + } + assert.ok(blue > 10, `the layout is shadowed (${blue} blue pixels)`); + // the shadow of a translated drawing is translated too, not left at the + // untransformed origin (the bug #280 fixed for the glyphs themselves) + assert.ok(minX >= 38, `shadow starts at x=${minX}, past the translation`); + assert.ok(minY >= 28, `shadow starts at y=${minY}, past the translation`); + ctx.destroy(); + }); + + test('one coverage surface for the whole paragraph, kept across draws', async () => { + app._shadowSurfaces?.clear(); + const ctx = target(); + ctx.font = '16px sans-serif'; + ctx.shadowColor = '#000000'; + ctx.shadowBlur = 4; + const text = 'one two three four'; + const layout = ctx.layoutText(text, { maxWidth: 60 }); + assert.ok(layout.lines.length > 1); + layout.draw(ctx, 8, 8); + assert.equal(app._shadowSurfaces.size, 1, 'one surface, not one per line'); + const [surface] = [...app._shadowSurfaces.values()]; + + layout.draw(ctx, 30, 20); // the same paragraph elsewhere + assert.equal(app._shadowSurfaces.size, 1, 'the second draw reuses it'); + assert.equal([...app._shadowSurfaces.values()][0], surface, 'the same one'); + + // the same text at another width is another shadow: same runs, but the + // lines they sit on are not the same lines + ctx.layoutText(text, { maxWidth: 110 }).draw(ctx, 8, 8); + assert.equal(app._shadowSurfaces.size, 2); + app._shadowSurfaces.clear(); + ctx.destroy(); + }); + + test('no shadow colour, no shadow work', async () => { + app._shadowSurfaces?.clear(); + const ctx = target(); + ctx.font = '16px sans-serif'; + ctx.layoutText('one two three', {}).draw(ctx, 8, 20); + assert.equal(app._shadowSurfaces?.size ?? 0, 0); + ctx.destroy(); + }); +});