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
18 changes: 16 additions & 2 deletions docs/context-2d.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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):

Expand Down
10 changes: 9 additions & 1 deletion docs/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions lib/renderingcontext_2d.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -2161,6 +2273,7 @@ class RenderingContext2d {
return { ...p, x, y };
});
}
if (this._shadowed()) this._shadowOfGlyphs(positioned);
this._drawGlyphsDevice(op, src, positioned);
}

Expand Down
61 changes: 61 additions & 0 deletions lib/text/glyphs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading