ntk 8.1.1 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,8 @@ import {
52
52
  compositeTraps,
53
53
  drawGlyphRuns,
54
54
  encodeGlyphItems,
55
+ positionedRunsInk,
56
+ runId,
55
57
  } from "./text/glyphs.js";
56
58
  import { TextLayout } from "./text/layout.js";
57
59
  import { reorderRuns } from "./text/shape.js";
@@ -1099,6 +1101,110 @@ class RenderingContext2d {
1099
1101
  );
1100
1102
  }
1101
1103
 
1104
+ /**
1105
+ * The shadow of positioned glyph runs, cached — what `drawGlyphs`, and
1106
+ * therefore every `TextLayout.draw`, casts (issue #283).
1107
+ *
1108
+ * A paragraph gets **one** coverage surface, not one per line: the runs
1109
+ * already carry their own baselines, so they all go into the same surface
1110
+ * exactly as they all go into the same glyph composite. Nothing is
1111
+ * re-shaped — the caller handed us the runs, which is why this path is
1112
+ * cheaper than `_shadowOfText`, not dearer.
1113
+ *
1114
+ * The cached copy is position-independent, as `fillText`'s is: geometry is
1115
+ * stored relative to the first run's origin and the composite carries it
1116
+ * to wherever the text is drawn. The key is that relative geometry plus
1117
+ * the identity of each run, so the same string laid out to two widths —
1118
+ * same runs, different line origins — is two shadows, and re-drawing one
1119
+ * layout is one lookup no matter how many glyphs are in it.
1120
+ *
1121
+ * @param {Array<{run, x, y, textRendering?}>} positioned device-space runs
1122
+ */
1123
+ _shadowOfGlyphs(positioned) {
1124
+ if (!positioned.length) return;
1125
+ const app = this.window.app;
1126
+ const policy = shadowPolicyOf(app);
1127
+ const sigma = shadowSigma(this._shadowBlur, policy);
1128
+ const reach = shadowReach(sigma);
1129
+
1130
+ // Run origins relative to the first, rounded: whole-pixel offsets are
1131
+ // what the bitmap glyph path draws at anyway, and they keep the key
1132
+ // stable as the paragraph moves — `(x + a) - (x + b)` is not exactly
1133
+ // `a - b` in floating point, and an origin-dependent key would miss the
1134
+ // cache on every scroll.
1135
+ const ax = positioned[0].x;
1136
+ const ay = positioned[0].y;
1137
+ const local = positioned.map((p) => ({
1138
+ run: p.run,
1139
+ x: Math.round(p.x - ax),
1140
+ y: Math.round(p.y - ay),
1141
+ textRendering: p.textRendering,
1142
+ }));
1143
+ const key = `${local
1144
+ .map((p) => `${runId(p.run)},${p.x},${p.y},${p.textRendering ?? ""}`)
1145
+ .join("\u0000")}\u0000${sigma}`;
1146
+
1147
+ let ink = null;
1148
+ const surface = cachedShadow(app, key, () => {
1149
+ ink = positionedRunsInk(local);
1150
+ if (!ink) return null; // a line of spaces inks nothing
1151
+ const box = {
1152
+ x: Math.floor(ink.minX) - 1 - reach,
1153
+ y: Math.floor(ink.minY) - 1 - reach,
1154
+ };
1155
+ box.w = Math.ceil(ink.maxX) + 1 + reach - box.x;
1156
+ box.h = Math.ceil(ink.maxY) + 1 + reach - box.y;
1157
+ if (box.w * box.h > policy.maxPixels) return null;
1158
+ let coverage = new Surface(app, {
1159
+ width: box.w,
1160
+ height: box.h,
1161
+ format: "a8",
1162
+ });
1163
+ coverage.render((sctx) => {
1164
+ this._loadShadowState(sctx, 0, 0);
1165
+ // the origins are already device-space and placed by hand
1166
+ sctx._m = [1, 0, 0, 1, 0, 0];
1167
+ sctx._drawGlyphsDevice(
1168
+ this.Render.PictOp.Over,
1169
+ sctx._backgroundPicture,
1170
+ local.map((p) => ({ ...p, x: p.x - box.x, y: p.y - box.y })),
1171
+ );
1172
+ });
1173
+ if (sigma > 0) coverage = blurCoverage(coverage, sigma);
1174
+ // where the anchor sits inside the surface — whole pixels, so the
1175
+ // composite below can carry it anywhere
1176
+ coverage._shadowOrigin = { x: -box.x, y: -box.y };
1177
+ return coverage;
1178
+ });
1179
+ if (surface) {
1180
+ const origin = surface._shadowOrigin;
1181
+ this._paintShadow(
1182
+ surface,
1183
+ Math.round(ax + this._shadowOffsetX) - origin.x,
1184
+ Math.round(ay + this._shadowOffsetY) - origin.y,
1185
+ );
1186
+ return;
1187
+ }
1188
+ if (!ink) return;
1189
+ // Padded ink larger than a shadow surface may be: fall back to the
1190
+ // clipped, uncached path, which sizes itself to the part of the shadow
1191
+ // that can actually be seen — the same escape `_shadowOfText` takes.
1192
+ this._shadowOfDrawing(
1193
+ {
1194
+ minX: ax + ink.minX,
1195
+ maxX: ax + ink.maxX,
1196
+ minY: ay + ink.minY,
1197
+ maxY: ay + ink.maxY,
1198
+ },
1199
+ (sctx, dx, dy) =>
1200
+ sctx._drawGlyphsDevice(
1201
+ this.Render.PictOp.Over,
1202
+ sctx._backgroundPicture,
1203
+ positioned.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
1204
+ ),
1205
+ );
1206
+ }
1207
+
1102
1208
  /** the shadow of a `fill()`/`stroke()`-shaped call, from its arguments */
1103
1209
  _shadowOfPath(args, stroke) {
1104
1210
  if (stroke) {
@@ -2152,6 +2258,12 @@ class RenderingContext2d {
2152
2258
  * is not at the window's origin — landed at the untransformed coordinates
2153
2259
  * and was then cut by the clip, while the neighbouring `fillRect` and
2154
2260
  * `drawImage` moved (issue #280).
2261
+ *
2262
+ * The shadow state applies too, as it does to `fillText`: one blurred
2263
+ * coverage surface for the whole call, cached on the runs' identity and
2264
+ * relative positions, painted under the glyphs (issue #283). A paragraph
2265
+ * whose spans change colour draws as several calls, and — as several
2266
+ * `fillText`s would — casts a shadow per call.
2155
2267
  */
2156
2268
  drawGlyphs(op, src, positioned) {
2157
2269
  const m = this._m;
@@ -2161,6 +2273,7 @@ class RenderingContext2d {
2161
2273
  return { ...p, x, y };
2162
2274
  });
2163
2275
  }
2276
+ if (this._shadowed()) this._shadowOfGlyphs(positioned);
2164
2277
  this._drawGlyphsDevice(op, src, positioned);
2165
2278
  }
2166
2279
 
@@ -225,6 +225,67 @@ export function positionGlyphs(positioned) {
225
225
  return out;
226
226
  }
227
227
 
228
+ /**
229
+ * Ink extents of positioned runs, in whatever coordinates their origins are
230
+ * given in — the union of every glyph's bounding box, laid out exactly as
231
+ * `positionGlyphs` lays it out (pen at `x`, glyph at `pen + dx`, `y - dy`,
232
+ * pen advanced by `ax`).
233
+ *
234
+ * Returns `null` when nothing inks: a run of spaces has extents but no
235
+ * bounding box, and neither does an empty array. Blank glyphs report an
236
+ * empty `cbox` (`minX` infinite), which the comparisons below drop on their
237
+ * own.
238
+ *
239
+ * This is what sizes a glyph shadow's coverage surface — the run-shaped
240
+ * counterpart of the context's `_shapedInk`, which measures one shaped
241
+ * string from its own origin.
242
+ *
243
+ * @param {Array<{run, x, y}>} positioned
244
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null}
245
+ */
246
+ export function positionedRunsInk(positioned) {
247
+ let minX = Infinity;
248
+ let minY = Infinity;
249
+ let maxX = -Infinity;
250
+ let maxY = -Infinity;
251
+ for (const { run, x, y } of positioned) {
252
+ let cursor = x;
253
+ for (const g of run.glyphs) {
254
+ const e = run.font.glyphExtents(g.id, run.size);
255
+ const gx = cursor + g.dx;
256
+ const gy = y - g.dy;
257
+ cursor += g.ax;
258
+ if (gx + e.minX < minX) minX = gx + e.minX;
259
+ if (gx + e.maxX > maxX) maxX = gx + e.maxX;
260
+ if (gy + e.minY < minY) minY = gy + e.minY;
261
+ if (gy + e.maxY > maxY) maxY = gy + e.maxY;
262
+ }
263
+ }
264
+ return minX <= maxX && minY <= maxY ? { minX, minY, maxX, maxY } : null;
265
+ }
266
+
267
+ // Identity, not content, for anything that wants to name a run cheaply.
268
+ // A shaped run is immutable and shared — the shaping memo hands the same
269
+ // object back, and a TextLayout holds on to the ones its lines are made of
270
+ // — so a small integer per object is a complete name for the glyphs in it,
271
+ // bought at O(1) instead of O(glyphs). Weak, so naming a run keeps nothing
272
+ // alive.
273
+ const runIds = new WeakMap();
274
+ let nextRunId = 0;
275
+
276
+ /**
277
+ * A stable small integer for one run object, for cache keys that would
278
+ * otherwise have to serialize its glyphs. Runs built fresh on every draw
279
+ * (rather than kept, as a `TextLayout` keeps them) get a fresh id each time
280
+ * and so never hit such a cache — which is the honest answer, since nothing
281
+ * cheap can tell them apart.
282
+ */
283
+ export function runId(run) {
284
+ let id = runIds.get(run);
285
+ if (id === undefined) runIds.set(run, (id = ++nextRunId));
286
+ return id;
287
+ }
288
+
228
289
  /**
229
290
  * Decide how a (face, size) renders: cached bitmap glyphs or per-draw
230
291
  * trapezoids. See DEFAULT_TEXT_POLICY for the reasoning; the middle band
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",