ntk 8.18.1 → 8.19.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.
@@ -100,6 +100,25 @@ function isIntegral(v) {
100
100
  return Math.abs(v - Math.round(v)) < 1e-6;
101
101
  }
102
102
 
103
+ /**
104
+ * The extent of AddTraps data — `[top-left x, top-right x, top, bottom-left
105
+ * x, bottom-right x, bottom, …]`, as `trapezoidize` makes it — or null for
106
+ * none.
107
+ */
108
+ function trapsInk(traps) {
109
+ let minX = Infinity;
110
+ let minY = Infinity;
111
+ let maxX = -Infinity;
112
+ let maxY = -Infinity;
113
+ for (let i = 0; i < traps.length; i += 6) {
114
+ minX = Math.min(minX, traps[i], traps[i + 3]);
115
+ maxX = Math.max(maxX, traps[i + 1], traps[i + 4]);
116
+ if (traps[i + 2] < minY) minY = traps[i + 2];
117
+ if (traps[i + 5] > maxY) maxY = traps[i + 5];
118
+ }
119
+ return maxX >= minX && maxY >= minY ? { minX, minY, maxX, maxY } : null;
120
+ }
121
+
103
122
  /** The overlap of two {x, y, w, h} boxes, or null when they have none. */
104
123
  function intersectBox(a, b) {
105
124
  const x = Math.max(a.x, b.x);
@@ -236,16 +255,6 @@ const gcRegistry = new FinalizationRegistry(({ X, gcs, regions }) => {
236
255
  });
237
256
  });
238
257
 
239
- /**
240
- * What `drawImage` takes as a server-side source: anything that knows its own
241
- * size and can hand over a `Picture` for this connection.
242
- *
243
- * `Image` is client pixels uploaded once; `Surface` is pixels the server drew
244
- * itself. Neither is special — the contract is the two members, so a caller
245
- * with its own cache of rendered things can satisfy it without ntk knowing
246
- * the type. A `RenderingContext2d` deliberately does not match: its `picture`
247
- * is a property rather than a method, and it keeps its own branch below.
248
- */
249
258
  /** a fillStyle with a single colour behind it, as opposed to a gradient, a
250
259
  * pattern or a caller-supplied Picture */
251
260
  function isPlainColor(style) {
@@ -445,6 +454,54 @@ function fitsFixed(values) {
445
454
  return values.every((v) => Math.abs(v) < 32768);
446
455
  }
447
456
 
457
+ /** How far from a whole pixel a coordinate may be and still count as on it:
458
+ * the rounding a transform adds, as `isIntegral` allows. */
459
+ const SNAP = 1e-6;
460
+
461
+ /**
462
+ * The rectangle (x, y, w, h) under `m`, as the whole-pixel device box a
463
+ * transformed `drawImage` composites over: `x0..x1` by `y0..y1`, unclamped.
464
+ *
465
+ * A corner a rounding error past a whole pixel stays on it. `rotate(Math.PI /
466
+ * 2)` leaves cos(π/2) at 6e-17 rather than 0, so a corner meant for y 30
467
+ * lands on 30.000000000000004, and rounding that out gave the box a row the
468
+ * rectangle does not reach.
469
+ *
470
+ * `fills` is whether the rectangle is that box exactly — an axis-aligned
471
+ * transform that puts every corner on a whole pixel — so that every pixel the
472
+ * composite touches has its centre inside the rectangle. Turned, skewed or
473
+ * off the pixel grid, the box holds pixels whose centres are outside it.
474
+ */
475
+ function turnedBox(m, x, y, w, h) {
476
+ const corners = [
477
+ matApply(m, x, y),
478
+ matApply(m, x + w, y),
479
+ matApply(m, x, y + h),
480
+ matApply(m, x + w, y + h),
481
+ ];
482
+ const xs = corners.map((c) => c[0]);
483
+ const ys = corners.map((c) => c[1]);
484
+ const x0 = Math.floor(Math.min(...xs) + SNAP);
485
+ const y0 = Math.floor(Math.min(...ys) + SNAP);
486
+ const x1 = Math.ceil(Math.max(...xs) - SNAP);
487
+ const y1 = Math.ceil(Math.max(...ys) - SNAP);
488
+ const on = (v, a, b) => Math.abs(v - a) < SNAP || Math.abs(v - b) < SNAP;
489
+ const fills = corners.every(([cx, cy]) => on(cx, x0, x1) && on(cy, y0, y1));
490
+ return { x0, y0, x1, y1, fills };
491
+ }
492
+
493
+ /**
494
+ * What `drawImage` takes as a server-side source: anything that knows its own
495
+ * size and can hand over a `Picture` for this connection.
496
+ *
497
+ * `Image` is client pixels uploaded once; `Surface` is pixels the server drew
498
+ * itself. Neither is special — the contract is the two members, so a caller
499
+ * with its own cache of rendered things can satisfy it without ntk knowing
500
+ * the type. A `RenderingContext2d` does not match — its `picture` is a
501
+ * property rather than a method — and neither does a node-canvas; `drawImage`
502
+ * wraps both in one of these (`contextSource`, `_canvasSource`), so every
503
+ * form of the call treats them as it treats a Surface.
504
+ */
448
505
  function isPictureSource(image) {
449
506
  return (
450
507
  image instanceof Image ||
@@ -455,9 +512,83 @@ function isPictureSource(image) {
455
512
  );
456
513
  }
457
514
 
515
+ /** a node-canvas, or anything else whose pixels come from
516
+ * `image.context.getImageData()` */
517
+ function isCanvasLike(image) {
518
+ return typeof image?.context?.getImageData === "function";
519
+ }
520
+
521
+ /**
522
+ * `drawImage`'s arguments as the eight numbers of its longest form — the
523
+ * source rectangle, then the destination one — whichever form they came in.
524
+ */
525
+ function imageRects(image, args) {
526
+ let sx = 0;
527
+ let sy = 0;
528
+ let sw = image.width;
529
+ let sh = image.height;
530
+ let dx = 0;
531
+ let dy = 0;
532
+ let dw;
533
+ let dh;
534
+ if (args.length >= 8) {
535
+ [sx, sy, sw, sh, dx, dy, dw, dh] = args;
536
+ } else if (args.length >= 4) {
537
+ [dx, dy, dw, dh] = args;
538
+ } else {
539
+ [dx = 0, dy = 0] = args;
540
+ dw = sw;
541
+ dh = sh;
542
+ }
543
+ return [sx, sy, sw, sh, dx, dy, dw, dh];
544
+ }
545
+
546
+ /**
547
+ * A 2d context as a `drawImage` source: the picture-source contract over the
548
+ * pixels it has drawn, so that it is drawn exactly as a Surface is.
549
+ *
550
+ * Its picture comes with nothing in the clip slot (`_sourcePicture`). A
551
+ * context drawing into an a8 target is coverage, painted in the current
552
+ * `fillStyle`, as the a8 Surface it is drawing into would be. `_drawable` is
553
+ * for `_drawsFromCopy`.
554
+ */
555
+ function contextSource(ctx, app) {
556
+ if (!ctx._picture) throw destroyedSourceError();
557
+ if (ctx.window.app !== app) throw otherConnectionError();
558
+ return {
559
+ width: ctx.width,
560
+ height: ctx.height,
561
+ format: ctx._picture.format === app.display.Render.a8 ? "a8" : undefined,
562
+ picture: () => ctx._sourcePicture(),
563
+ _drawable: ctx._target,
564
+ };
565
+ }
566
+
567
+ function destroyedSourceError() {
568
+ return new Error(
569
+ "drawImage: the 2d context passed as the image has been destroyed — by\n" +
570
+ "ctx.destroy(), along with its window, or at the end of the\n" +
571
+ "Surface.render() call that lent it — so there is nothing to read.\n" +
572
+ "\n" +
573
+ "Draw the Surface itself, or keep a context from getContext('2d') for\n" +
574
+ "as long as you draw from it.",
575
+ );
576
+ }
577
+
578
+ function otherConnectionError() {
579
+ return new Error(
580
+ "drawImage: the 2d context passed as the image draws on another X\n" +
581
+ "connection, and a picture means nothing on a connection that did not\n" +
582
+ "make it. Bring the pixels over through the client:\n" +
583
+ "\n" +
584
+ " const pixels = await source.getImageData(0, 0, source.width, source.height);\n" +
585
+ " ctx.drawImage(new Image(pixels), x, y);",
586
+ );
587
+ }
588
+
458
589
  // canvas globalCompositeOperation -> XRender PictOp name. Porter-Duff ops
459
- // map directly; with a clip/shape mask active the op only applies inside
460
- // the mask coverage (outside pixels are left untouched).
590
+ // map directly, and a clip bounds every one of them: nothing outside it
591
+ // changes, whatever the op (`_clipAfter`).
461
592
  const GCO_TO_PICTOP = {
462
593
  "source-over": "Over",
463
594
  copy: "Src",
@@ -477,7 +608,9 @@ const GCO_TO_PICTOP = {
477
608
  // what the split gives up is the gaps between the boxes, and for these ops
478
609
  // the single-box version would not have changed those pixels either. `copy`,
479
610
  // `source-in`, `destination-in`, `source-out` and `destination-atop` write
480
- // the source — or nothing — across the whole box, so they keep one.
611
+ // the source — or nothing — across the whole box, so they keep one. Nor can
612
+ // a path clip be folded into their mask: it is applied after them instead
613
+ // (`_clipAfter`).
481
614
  const MASK_BOUNDED_OPS = [
482
615
  "Over",
483
616
  "OverReverse",
@@ -856,6 +989,11 @@ class RenderingContext2d {
856
989
  return;
857
990
  }
858
991
  if (!this._pictureClipped) return; // nothing of ours is in the slot
992
+ this._clearPictureClip();
993
+ }
994
+
995
+ /** Empty the picture's clip slot. */
996
+ _clearPictureClip() {
859
997
  if (this._fixes) {
860
998
  // None is the real "no clip"; a rectangle would only be a wider one
861
999
  this._fixes.SetPictureClipRegion(this._picture.id, 0, 0, 0);
@@ -898,6 +1036,26 @@ class RenderingContext2d {
898
1036
  return this._picture;
899
1037
  }
900
1038
 
1039
+ /**
1040
+ * The picture for a composite that reads this context's pixels — a
1041
+ * `drawImage` of the context — with nothing in its clip slot.
1042
+ *
1043
+ * A clip is drawing state, not part of the pixels: a canvas drawn
1044
+ * somewhere gives up all of them, whatever its own context's clip. But
1045
+ * glamor — Xorg's modesetting driver, Xwayland — cuts a composite down to
1046
+ * its source's clip, where fb (Xvfb, XQuartz) reads past it, and `picture`
1047
+ * leaves the slot holding what the clip stack says, a region clip included.
1048
+ * So the slot is emptied here, and what it held is owed back: it goes back
1049
+ * on before this context next draws, as any reset does.
1050
+ */
1051
+ _sourcePicture() {
1052
+ if (this._pictureClipped) {
1053
+ this._clearPictureClip();
1054
+ this._clipStale = true;
1055
+ }
1056
+ return this._picture;
1057
+ }
1058
+
901
1059
  /**
902
1060
  * The region the clip stack currently means, or null when it holds no
903
1061
  * region entry. One entry is used as it is; several are intersected into a
@@ -946,9 +1104,10 @@ class RenderingContext2d {
946
1104
  * one per offscreen `Surface`, say — because without it each one
947
1105
  * permanently costs a GC and a Picture.
948
1106
  *
949
- * `_backgroundPicture` and `_glyphSource` are deliberately not freed here:
950
- * both are either solids owned by the app (`App#solidPicture` — shared
951
- * with every other context, freed with the connection) or a
1107
+ * What fills, strokes and glyphs paint with is deliberately not freed
1108
+ * here: either solids owned by the app (`App#styleSolid`,
1109
+ * `App#solidPicture` — shared with every other context, and freed by the
1110
+ * app, as it lets them go or with the connection) or a
952
1111
  * `Picture`/`CanvasGradient` the caller passed in through `fillStyle`,
953
1112
  * which is not ours to free.
954
1113
  */
@@ -984,7 +1143,7 @@ class RenderingContext2d {
984
1143
  this._gc = this._fillMaskGC = this._clipMaskGC = null;
985
1144
  this._gcs.length = 0;
986
1145
 
987
- this._backgroundPicture = this._glyphSource = null;
1146
+ this._fillSource = this._strokeSource = this._glyphSource = null;
988
1147
 
989
1148
  if (this._picture) {
990
1149
  // a picture on a *window* is freed by the server with the window;
@@ -1042,6 +1201,34 @@ class RenderingContext2d {
1042
1201
  return this.window.height;
1043
1202
  }
1044
1203
 
1204
+ /**
1205
+ * Whether text drawn here — `fillText`, `drawGlyphs` and so
1206
+ * `TextLayout.draw` — honours `globalAlpha`. Always true: it is here to
1207
+ * be asked. ntk used to draw glyphs at full opacity whatever the alpha,
1208
+ * and a renderer handed contexts it did not make (another backend's, an
1209
+ * older ntk's) has to fade their text some dearer way, such as drawing it
1210
+ * on a surface of its own and fading that. Undefined on a context that
1211
+ * has never heard of it, which is the answer that keeps it safe.
1212
+ */
1213
+ get fadesGlyphs() {
1214
+ return true;
1215
+ }
1216
+
1217
+ /**
1218
+ * Whether `drawGlyphs` and `drawTraps` take a colour for their source as
1219
+ * well as a picture: a CSS colour string or a premultiplied
1220
+ * `[r, g, b, a]`, the two forms `fillStyle` takes, or `null` for the fill
1221
+ * style itself (`_sourceOf`). Always true, and here to be asked, as
1222
+ * `fadesGlyphs` is. Older ntk took a picture only, and a colour drew
1223
+ * nothing: the server refused it as a picture after the call had
1224
+ * returned, so nothing a caller could catch said so. Code handed a
1225
+ * context it did not make asks this first, and makes its solids with
1226
+ * `createSolidPicture` where it is not true.
1227
+ */
1228
+ get takesColorSources() {
1229
+ return true;
1230
+ }
1231
+
1045
1232
  _op() {
1046
1233
  return this.Render.PictOp[GCO_TO_PICTOP[this._gco] || "Over"];
1047
1234
  }
@@ -1060,15 +1247,28 @@ class RenderingContext2d {
1060
1247
  return this.window.app.solidPicture(r, g, b, a);
1061
1248
  }
1062
1249
 
1250
+ /**
1251
+ * A solid for this call's own requests: a colour with `globalAlpha`
1252
+ * folded into it, or the alpha alone in a mask slot. A fade makes a new
1253
+ * one every frame, so the app keeps only the most recent and frees the
1254
+ * rest (App#transientSolid) — use it in the requests of the call that
1255
+ * asked, and never keep it or hand it out. An app that only answers
1256
+ * solidPicture — a host's own, a test's stand-in — keeps them all.
1257
+ */
1258
+ _transientSolid(r, g, b, a) {
1259
+ const app = this.window.app;
1260
+ return app.transientSolid
1261
+ ? app.transientSolid(r, g, b, a)
1262
+ : app.solidPicture(r, g, b, a);
1263
+ }
1264
+
1063
1265
  _stylePicture(value) {
1064
- if (typeof value === "string") {
1065
- // by the string, where the app keeps that index (App#solidPictureOf);
1066
- // an app that only answers solidPicture — a host's own, a test's
1067
- // stand-in — takes the long way below
1068
- const app = this.window.app;
1069
- if (app.solidPictureOf) return app.solidPictureOf(value);
1070
- }
1071
1266
  if (typeof value === "string" || Array.isArray(value)) {
1267
+ // the app's, which it lets go as colours animate (App#styleSolid) —
1268
+ // the style stays here, to ask by again. An app that only answers
1269
+ // solidPicture — a host's own, a test's stand-in — takes the long way
1270
+ const app = this.window.app;
1271
+ if (app.styleSolid) return app.styleSolid(value);
1072
1272
  const c = parseColor(value);
1073
1273
  return this.createSolidPicture(c[0], c[1], c[2], c[3]);
1074
1274
  }
@@ -1086,7 +1286,7 @@ class RenderingContext2d {
1086
1286
  // the picture first: a value that throws leaves the style as it was
1087
1287
  const picture = this._stylePicture(value);
1088
1288
  this._fillStyle = value;
1089
- this._backgroundPicture = picture;
1289
+ this._fillSource = picture;
1090
1290
  }
1091
1291
 
1092
1292
  get fillStyle() {
@@ -1097,13 +1297,35 @@ class RenderingContext2d {
1097
1297
  // the picture first: a value that throws leaves the style as it was
1098
1298
  const picture = this._stylePicture(value);
1099
1299
  this._strokeStyle = value;
1100
- this._strokePicture = picture;
1300
+ this._strokeSource = picture;
1101
1301
  }
1102
1302
 
1103
1303
  get strokeStyle() {
1104
1304
  return this._strokeStyle;
1105
1305
  }
1106
1306
 
1307
+ /**
1308
+ * What a fill paints with: the gradient, pattern or picture `fillStyle`
1309
+ * is, or the app's solid of its colour. The app lets a solid go once more
1310
+ * colours than it keeps have been asked for since (App#styleSolid), and
1311
+ * once it is freed its id can come back in another colour — so one found
1312
+ * let go is asked for again, by the style. Read it for the drawing at
1313
+ * hand: what it returns is good for the rest of the job, and past that
1314
+ * only through here.
1315
+ */
1316
+ get _backgroundPicture() {
1317
+ const p = this._fillSource;
1318
+ if (!p?._evicted) return p;
1319
+ return (this._fillSource = this._stylePicture(this._fillStyle));
1320
+ }
1321
+
1322
+ /** What a stroke paints with, from `strokeStyle`, as `_backgroundPicture`. */
1323
+ get _strokePicture() {
1324
+ const p = this._strokeSource;
1325
+ if (!p?._evicted) return p;
1326
+ return (this._strokeSource = this._stylePicture(this._strokeStyle));
1327
+ }
1328
+
1107
1329
  /**
1108
1330
  * Canvas-spec dash list: values are distances (user-space units) of
1109
1331
  * alternating dashes and gaps. An empty list means solid; an odd-length
@@ -1358,8 +1580,10 @@ class RenderingContext2d {
1358
1580
  * as they do to the drawing it belongs to.
1359
1581
  */
1360
1582
  _paintShadow(surface, dx, dy) {
1583
+ // put back as it was: a solid the app let go of meanwhile is asked for
1584
+ // again when next read (_backgroundPicture)
1361
1585
  const style = this._fillStyle;
1362
- const picture = this._backgroundPicture;
1586
+ const source = this._fillSource;
1363
1587
  this.fillStyle = this._shadowColor;
1364
1588
  try {
1365
1589
  this._drawCoverage(
@@ -1376,7 +1600,7 @@ class RenderingContext2d {
1376
1600
  );
1377
1601
  } finally {
1378
1602
  this._fillStyle = style;
1379
- this._backgroundPicture = picture;
1603
+ this._fillSource = source;
1380
1604
  }
1381
1605
  }
1382
1606
 
@@ -1769,8 +1993,9 @@ class RenderingContext2d {
1769
1993
  );
1770
1994
  if (!tile) return false;
1771
1995
  const picture = tile.picture(app);
1996
+ // put back as it was, as _paintShadow does
1772
1997
  const style = this._fillStyle;
1773
- const background = this._backgroundPicture;
1998
+ const source = this._fillSource;
1774
1999
  this.fillStyle = this._shadowColor;
1775
2000
  try {
1776
2001
  const op = this._op();
@@ -1791,7 +2016,7 @@ class RenderingContext2d {
1791
2016
  }
1792
2017
  } finally {
1793
2018
  this._fillStyle = style;
1794
- this._backgroundPicture = background;
2019
+ this._fillSource = source;
1795
2020
  }
1796
2021
  return true;
1797
2022
  }
@@ -2379,10 +2604,116 @@ class RenderingContext2d {
2379
2604
  return this._boundedOps.has(op);
2380
2605
  }
2381
2606
 
2607
+ /**
2608
+ * Whether a drawing with `op` has to have the clip applied after the op
2609
+ * (`_compositeClipAfter`) rather than folded into its mask.
2610
+ *
2611
+ * The canvas never changes a pixel outside the clip, and at an
2612
+ * antialiased clip edge takes a pixel as far from what it was towards
2613
+ * what the op makes of it as the clip covers it. Folded into the mask,
2614
+ * the clip scales the source instead, which for an op that leaves the
2615
+ * destination alone where the mask is zero is that same pixel: `Over`
2616
+ * through a mask of a half is half of the way to `Over`. The rest write
2617
+ * where the mask is zero — through it, `copy` is the source *times* the
2618
+ * mask — so folded in, a clip would clear every pixel of the drawing's
2619
+ * box it leaves out. A rectangular clip never needs this: the box is cut
2620
+ * to it, or the server clips to it, and neither touches a pixel outside.
2621
+ */
2622
+ _clipAfter(op) {
2623
+ return this._hasPolyClip && !this._maskBounded(op);
2624
+ }
2625
+
2626
+ /**
2627
+ * `Composite` of `src` through `mask` onto this context: straight onto its
2628
+ * picture, or, `after`, with the clip applied after the op
2629
+ * (`_compositeClipAfter`), when `mask` does not hold the clip. The rest
2630
+ * of the arguments are Composite's own, from the source's coordinates on.
2631
+ */
2632
+ _compositeOnto(after, op, src, mask, ...coords) {
2633
+ if (after) {
2634
+ this._compositeClipAfter(op, src, mask, ...coords);
2635
+ return;
2636
+ }
2637
+ this.Render.Composite(op, src, mask, this._dst(), ...coords);
2638
+ }
2639
+
2640
+ /**
2641
+ * A `Composite` onto this context, with the clip the a8 clip mask holds
2642
+ * applied after the op, as the canvas applies one: each pixel goes from
2643
+ * what it was towards what the op makes of it by as much as the clip
2644
+ * covers it. Inside the clip that is the op, outside it nothing at all,
2645
+ * and along an antialiased edge the blend between the two.
2646
+ *
2647
+ * RENDER has no operator for that. The op is done on a copy of the box,
2648
+ * in the destination's own format, and the copy comes back through the
2649
+ * clip's coverage `c` in two composites: `OutReverse` leaves `d·(1 − c)`
2650
+ * of the destination and `Add` puts `t·c` of the copy into it. A pixel
2651
+ * the clip covers wholly, or not at all, comes out exact.
2652
+ *
2653
+ * The arguments are the ones the Composite request would have carried with
2654
+ * no clip, and `mask` holds none. The box is cut to the clip's extents —
2655
+ * outside them the clip covers nothing, so nothing there is read or
2656
+ * written — on the whole pixels the wire takes, so it samples what the
2657
+ * one composite would have.
2658
+ */
2659
+ _compositeClipAfter(op, src, mask, srcX, srcY, maskX, maskY, dstX, dstY, w, h) {
2660
+ const R = this.Render;
2661
+ // each coordinate truncated on its own, as the 16-bit wire fields are
2662
+ const left = Math.trunc(dstX);
2663
+ const top = Math.trunc(dstY);
2664
+ const ext = this._clipExtents() ?? {
2665
+ x: 0,
2666
+ y: 0,
2667
+ w: this.width,
2668
+ h: this.height,
2669
+ };
2670
+ const x = Math.max(left, ext.x);
2671
+ const y = Math.max(top, ext.y);
2672
+ const bw = Math.min(left + Math.trunc(w), ext.x + ext.w) - x;
2673
+ const bh = Math.min(top + Math.trunc(h), ext.y + ext.h) - y;
2674
+ if (!(bw > 0 && bh > 0)) return;
2675
+ const clipMask = this._requireClipMask().id;
2676
+ const dst = this._dst();
2677
+ const app = this.window.app;
2678
+ const pixmap = new Pixmap(app, {
2679
+ depth: this._targetPixels().depth,
2680
+ width: bw,
2681
+ height: bh,
2682
+ });
2683
+ const copy = new Picture(app, {
2684
+ drawable: pixmap,
2685
+ format: this._picture.format,
2686
+ });
2687
+ try {
2688
+ R.Composite(R.PictOp.Src, dst, 0, copy.id, x, y, 0, 0, 0, 0, bw, bh);
2689
+ R.Composite(
2690
+ op,
2691
+ src,
2692
+ mask,
2693
+ copy.id,
2694
+ Math.trunc(srcX) + x - left,
2695
+ Math.trunc(srcY) + y - top,
2696
+ Math.trunc(maskX) + x - left,
2697
+ Math.trunc(maskY) + y - top,
2698
+ 0,
2699
+ 0,
2700
+ bw,
2701
+ bh,
2702
+ );
2703
+ R.Composite(R.PictOp.OutReverse, clipMask, 0, dst, x, y, 0, 0, x, y, bw, bh);
2704
+ R.Composite(R.PictOp.Add, copy.id, clipMask, dst, 0, 0, x, y, x, y, bw, bh);
2705
+ } finally {
2706
+ // queued behind the composites that read it
2707
+ copy.destroy();
2708
+ pixmap.destroy();
2709
+ }
2710
+ }
2711
+
2382
2712
  /**
2383
2713
  * The tail every masked fill and stroke ends in: coverage for one box into
2384
2714
  * the scratch a8 mask, scaled by `alpha`, intersected with the clip, and
2385
- * the source composited through it.
2715
+ * the source composited through it — or, for an op that writes where the
2716
+ * mask is zero, the clip applied after it (`_clipAfter`).
2386
2717
  *
2387
2718
  * `job` is what a Rasterizer takes minus the box (docs/context-2d.md) —
2388
2719
  * `{ polys, rule }` or `{ triangles }`; `server` rasterizes the same
@@ -2430,7 +2761,8 @@ class RenderingContext2d {
2430
2761
  [out.x, out.y, out.w, out.h],
2431
2762
  );
2432
2763
  }
2433
- if (this.clipMask) {
2764
+ const after = this._clipAfter(op);
2765
+ if (this.clipMask && !after) {
2434
2766
  // clipMask is surface-aligned, so it is sampled at the same offset
2435
2767
  R.Composite(
2436
2768
  R.PictOp.In,
@@ -2450,11 +2782,11 @@ class RenderingContext2d {
2450
2782
  // src is either a 1x1 repeating solid (offset irrelevant) or a
2451
2783
  // gradient/pattern, sampled at the same offset from its own origin
2452
2784
  const [ox, oy] = sourceOrigin(src);
2453
- R.Composite(
2785
+ this._compositeOnto(
2786
+ after,
2454
2787
  op,
2455
2788
  src.id,
2456
2789
  this.fillMask.id,
2457
- this._dst(),
2458
2790
  out.x - ox,
2459
2791
  out.y - oy,
2460
2792
  out.x,
@@ -3139,8 +3471,12 @@ class RenderingContext2d {
3139
3471
  * instead of shaping.
3140
3472
  *
3141
3473
  * @param {number} op Render.PictOp (`ctx.Render.PictOp.Over` for normal text)
3142
- * @param {Picture} src source picture the glyphs paint with — a solid
3143
- * (`ctx.createSolidPicture(r, g, b, a)`, premultiplied 0..1) or a gradient
3474
+ * @param {string|number[]|null|Picture|CanvasGradient} src what the glyphs
3475
+ * paint with: a colour — a CSS colour string, or a premultiplied
3476
+ * `[r, g, b, a]` in 0..1 — or `null` for the fill style; or a picture —
3477
+ * a solid (`ctx.createSolidPicture(r, g, b, a)`) or a gradient. A colour
3478
+ * paints with a solid the app lets go of as colours change, where each
3479
+ * of `createSolidPicture`'s is held until the app closes (`_sourceOf`)
3144
3480
  * @param {Array<{run, x, y, textRendering?}>} positioned runs in visual
3145
3481
  * order; `x`/`y` is the run's baseline origin in **user space** — the
3146
3482
  * current transform applies to it, as it does to every other drawing
@@ -3175,6 +3511,8 @@ class RenderingContext2d {
3175
3511
  */
3176
3512
  drawGlyphs(op, src, positioned) {
3177
3513
  if (this.globalAlpha <= 0) return;
3514
+ // before the shadow: a colour that is not one throws with nothing drawn
3515
+ src = this._sourceOf(src, "drawGlyphs");
3178
3516
  const m = this._m;
3179
3517
  if (!matIsIdentity(m)) {
3180
3518
  positioned = positioned.map((p) => {
@@ -3205,13 +3543,23 @@ class RenderingContext2d {
3205
3543
  * in its colour, as `fillRect`'s does, and the glyphs go the way they
3206
3544
  * always went; a gradient or a pattern has no one colour to fold it
3207
3545
  * into, and paints through the scratch mask with the alpha in there.
3546
+ *
3547
+ * And it composites each glyph on its own, over that glyph's box. For an
3548
+ * op that leaves the destination alone where the mask is zero, that draws
3549
+ * what one composite through all of them would, but where two glyphs'
3550
+ * ink overlaps and takes the op twice. For `copy`, `source-in`,
3551
+ * `destination-in`, `source-out` and `destination-atop` it is nothing
3552
+ * like: each glyph's box is cleared outside its ink, a neighbour's ink
3553
+ * with it, and the rest of the drawing's box is left alone. Those paint
3554
+ * through the scratch mask too, once, over the box round every glyph's
3555
+ * ink — the box a fill of the same coverage clears (`_paintThroughMask`).
3208
3556
  */
3209
3557
  _drawGlyphsDevice(op, src, positioned) {
3210
3558
  if (this.globalAlpha <= 0) return;
3211
3559
  const app = this.window.app;
3212
3560
  if (!prepareStyle(src, this._m, this.width, this.height)) return;
3213
3561
  const faded = this._fadedSource(src);
3214
- if (faded && !this._hasPolyClip) {
3562
+ if (faded && !this._hasPolyClip && this._maskBounded(op)) {
3215
3563
  // Fast path: a rectangular clip is something the server can do itself.
3216
3564
  // Two small requests around the ordinary glyph composite, instead of
3217
3565
  // clearing and compositing a full-surface a8 mask three times — which
@@ -3236,11 +3584,19 @@ class RenderingContext2d {
3236
3584
  this._markDirty();
3237
3585
  return;
3238
3586
  }
3587
+ // every glyph's, whether or not it is sent: a glyph too far outside the
3588
+ // clip to be drawn still widens the box an op like `copy` clears
3589
+ const ink = positionedRunsInk(positioned);
3590
+ if (!ink) return; // a run of spaces inks nothing
3239
3591
  const R = this.Render;
3240
3592
  const alpha = faded ? 1 : this.globalAlpha;
3241
- const surface = { x: 0, y: 0, w: this.width, h: this.height };
3242
- this._paintThroughScratch(op, faded ?? src, alpha, (white, mask) =>
3243
- drawGlyphRuns(app, R.PictOp.Over, white, mask, positioned, surface),
3593
+ this._paintThroughScratch(
3594
+ op,
3595
+ faded ?? src,
3596
+ alpha,
3597
+ this._rawBox(ink.minX, ink.minY, ink.maxX, ink.maxY),
3598
+ (white, mask, bounds) =>
3599
+ drawGlyphRuns(app, R.PictOp.Over, white, mask, positioned, bounds),
3244
3600
  );
3245
3601
  }
3246
3602
 
@@ -3249,14 +3605,19 @@ class RenderingContext2d {
3249
3605
  * are already trapezoidized (KaTeX radicals and rules go this way).
3250
3606
  * Without it they composite straight to the destination picture and spill
3251
3607
  * out of clipped boxes, which is what glyphs used to do. `globalAlpha`
3252
- * applies as it does to glyphs, so a radical fades with the formula.
3608
+ * applies as it does to glyphs, so a radical fades with the formula, and
3609
+ * so does `op`: one that writes where the mask is zero clears the box
3610
+ * round the trapezoids, with the same pixel of slack, on either route.
3611
+ * `src` is what `drawGlyphs` takes: a colour, `null` for the fill style,
3612
+ * or a picture.
3253
3613
  */
3254
3614
  drawTraps(op, src, traps) {
3255
3615
  if (!traps.length || this.globalAlpha <= 0) return;
3616
+ src = this._sourceOf(src, "drawTraps");
3256
3617
  const app = this.window.app;
3257
3618
  if (!prepareStyle(src, this._m, this.width, this.height)) return;
3258
3619
  const faded = this._fadedSource(src);
3259
- if (faded && !this._hasPolyClip) {
3620
+ if (faded && !this._hasPolyClip && this._maskBounded(op)) {
3260
3621
  const rect = this._clipRect();
3261
3622
  if (rect) {
3262
3623
  if (rect.w === 0 || rect.h === 0) return;
@@ -3276,9 +3637,48 @@ class RenderingContext2d {
3276
3637
  this._markDirty();
3277
3638
  return;
3278
3639
  }
3640
+ const ink = trapsInk(traps);
3641
+ if (!ink) return;
3279
3642
  const alpha = faded ? 1 : this.globalAlpha;
3280
- this._paintThroughScratch(op, faded ?? src, alpha, (white, mask) =>
3281
- compositeTraps(app, this.Render.PictOp.Over, white, mask, traps),
3643
+ this._paintThroughScratch(
3644
+ op,
3645
+ faded ?? src,
3646
+ alpha,
3647
+ this._rawBox(ink.minX, ink.minY, ink.maxX, ink.maxY),
3648
+ (white, mask) =>
3649
+ compositeTraps(app, this.Render.PictOp.Over, white, mask, traps),
3650
+ );
3651
+ }
3652
+
3653
+ /**
3654
+ * The picture a `drawGlyphs` or `drawTraps` source paints with. A colour
3655
+ * — a CSS colour string or a premultiplied `[r, g, b, a]` — is the app's
3656
+ * solid of it, from the LRU the colour styles share (App#styleSolid):
3657
+ * good for the rest of the job, then let go of once enough other colours
3658
+ * have been asked for. That is why a caller passes one rather than a
3659
+ * solid from `createSolidPicture`, which is held until the app closes:
3660
+ * a terminal showing truecolor output, or a colour animated through a
3661
+ * tween, made one of those for every colour it ever drew. `null` is the
3662
+ * fill style's. Anything else is a picture or a style of the caller's,
3663
+ * and goes as it came.
3664
+ */
3665
+ _sourceOf(src, call) {
3666
+ if (src == null) return this._backgroundPicture;
3667
+ if (typeof src === "string" || Array.isArray(src)) {
3668
+ return this._stylePicture(src);
3669
+ }
3670
+ if (typeof src === "object") return src;
3671
+ // a grid keeps its colours as 0xRRGGBB numbers, and one handed over as
3672
+ // it is went to the server as a picture id, which it refused later
3673
+ const hex =
3674
+ Number.isInteger(src) && src >= 0 && src <= 0xffffff
3675
+ ? ` ('#${src.toString(16).padStart(6, "0")}' is that number read as 0xRRGGBB)`
3676
+ : "";
3677
+ throw new TypeError(
3678
+ `${call}: src is ${typeof src === "number" ? src : `a ${typeof src}`}, neither a ` +
3679
+ `colour nor a picture. Pass a CSS colour string${hex}, a premultiplied ` +
3680
+ "[r, g, b, a] in 0..1, null for the fillStyle, or a picture such as " +
3681
+ "createSolidPicture makes",
3282
3682
  );
3283
3683
  }
3284
3684
 
@@ -3296,78 +3696,83 @@ class RenderingContext2d {
3296
3696
  if (a >= 1) return src;
3297
3697
  const c = src._rgba;
3298
3698
  if (!c) return null;
3299
- return this.createSolidPicture(c[0] * a, c[1] * a, c[2] * a, c[3] * a);
3699
+ return this._transientSolid(c[0] * a, c[1] * a, c[2] * a, c[3] * a);
3300
3700
  }
3301
3701
 
3302
3702
  /**
3303
3703
  * Paint `src` through coverage the server draws into the scratch a8 mask:
3304
3704
  * how glyphs and trapezoids reach the surface when their own composite
3305
- * cannot carry what applies to it — a clip that is not a rectangle, or a
3306
- * `globalAlpha` with no colour to fold into.
3705
+ * cannot carry what applies to it — a clip that is not a rectangle, a
3706
+ * `globalAlpha` with no colour to fold into, or an op that writes where
3707
+ * they have no ink.
3708
+ *
3709
+ * `box` is the drawing's own box in device pixels, its ink with a pixel
3710
+ * of slack (`_rawBox`), and everything here is bounded to the part of it
3711
+ * the surface and the clip let through, as a fill's mask is
3712
+ * (`_paintThroughMask`): the clip's rectangle, or the extents of a stack
3713
+ * with a path in it. For most ops that only saves pixel work. For the
3714
+ * ones that clear where the mask is zero it is what they clear — the
3715
+ * rest of the drawing's box inside the clip, not the rest of the surface.
3307
3716
  *
3308
- * `drawCoverage(white, mask)` composites the coverage into the mask, with
3309
- * a solid white source: what that leaves in an a8 picture is exactly the
3310
- * coverage. `alpha` scales it, the clip cuts it — the clip mask for a
3311
- * stack with a path in it, the picture's clip rectangle for one of
3312
- * rectangles — and `src` is composited through the result.
3717
+ * `drawCoverage(white, mask, bounds)` composites the coverage into the
3718
+ * mask, with a solid white source: what that leaves in an a8 picture is
3719
+ * exactly the coverage. Only `bounds` of it is read, so a glyph that
3720
+ * cannot reach it need not be sent. `alpha` scales the coverage, the clip
3721
+ * mask cuts it, and `src` is composited through the result — or, for an
3722
+ * op that writes where the mask is zero, the clip is applied after it
3723
+ * (`_clipAfter`).
3313
3724
  */
3314
- _paintThroughScratch(op, src, alpha, drawCoverage) {
3725
+ _paintThroughScratch(op, src, alpha, box, drawCoverage) {
3315
3726
  const R = this.Render;
3316
- const rect = this._clipRect();
3317
- if (rect && (rect.w === 0 || rect.h === 0)) return;
3318
- const clipMask = this._hasPolyClip ? this._requireClipMask() : null;
3727
+ const clip = this._hasPolyClip ? this._clipExtents() : this._clipRect();
3728
+ let out = this._onSurface(box);
3729
+ if (out && clip) out = intersectBox(out, clip);
3730
+ // off the surface, clipped away, or not a number at all — the box of a
3731
+ // glyph whose origin is at Infinity
3732
+ if (!(out?.w > 0 && out.h > 0)) return;
3733
+ const after = this._clipAfter(op);
3734
+ const clipMask = this._hasPolyClip && !after ? this._requireClipMask() : null;
3319
3735
  this._ensureFillMask();
3320
3736
  this._glyphSource ??= this.createSolidPicture(1, 1, 1, 1);
3321
- R.FillRectangles(
3322
- R.PictOp.Src,
3323
- this.fillMask.id,
3324
- [0, 0, 0, 0],
3325
- [0, 0, this.width, this.height],
3326
- );
3327
- drawCoverage(this._glyphSource.id, this.fillMask.id);
3737
+ const rect = [out.x, out.y, out.w, out.h];
3738
+ R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], rect);
3739
+ drawCoverage(this._glyphSource.id, this.fillMask.id, out);
3328
3740
  if (alpha < 1) {
3329
3741
  // In with a constant color scales the a8 coverage by that alpha
3330
- R.FillRectangles(
3331
- R.PictOp.In,
3332
- this.fillMask.id,
3333
- [0, 0, 0, alpha],
3334
- [0, 0, this.width, this.height],
3335
- );
3742
+ R.FillRectangles(R.PictOp.In, this.fillMask.id, [0, 0, 0, alpha], rect);
3336
3743
  }
3337
3744
  if (clipMask) {
3745
+ // clipMask is surface-aligned, so it is sampled at the same offset
3338
3746
  R.Composite(
3339
3747
  R.PictOp.In,
3340
3748
  clipMask.id,
3341
3749
  0,
3342
3750
  this.fillMask.id,
3751
+ out.x,
3752
+ out.y,
3343
3753
  0,
3344
3754
  0,
3345
- 0,
3346
- 0,
3347
- 0,
3348
- 0,
3349
- this.width,
3350
- this.height,
3755
+ out.x,
3756
+ out.y,
3757
+ out.w,
3758
+ out.h,
3351
3759
  );
3352
- } else if (rect) {
3353
- this._setPictureClip(rect);
3354
3760
  }
3355
3761
  const [ox, oy] = sourceOrigin(src);
3356
- R.Composite(
3762
+ this._compositeOnto(
3763
+ after,
3357
3764
  op,
3358
3765
  src.id,
3359
3766
  this.fillMask.id,
3360
- this._dst(),
3361
- -ox,
3362
- -oy,
3363
- 0,
3364
- 0,
3365
- 0,
3366
- 0,
3367
- this.width,
3368
- this.height,
3767
+ out.x - ox,
3768
+ out.y - oy,
3769
+ out.x,
3770
+ out.y,
3771
+ out.x,
3772
+ out.y,
3773
+ out.w,
3774
+ out.h,
3369
3775
  );
3370
- if (rect) this._invalidatePictureClip();
3371
3776
  this._markDirty();
3372
3777
  }
3373
3778
 
@@ -3377,20 +3782,18 @@ class RenderingContext2d {
3377
3782
  * a8 mask anywhere (issue #307).
3378
3783
  *
3379
3784
  * Neither thing `_compositeMask()` puts in a mask is really mask work.
3380
- * A **rectangular clip** is a smaller composite box: for an op that leaves
3381
- * the destination alone where the mask is zero, zeroing the mask outside
3382
- * the rectangle and never compositing there are the same picture — and a
3383
- * rect clip is an integer rectangle stamped as binary coverage, so the two
3384
- * agree pixel for pixel rather than approximately. **`globalAlpha`** is a
3385
- * 1x1 solid, which is that alpha everywhere the server samples it, for the
3386
- * price of no pixels at all. A **region** clip needs neither: the picture
3387
- * carries it, and it applies to the composite like any other.
3785
+ * A **rectangular clip** is a smaller composite box: the canvas changes
3786
+ * nothing outside the clip, and never compositing there is exactly that,
3787
+ * for every op — `copy` and the other ops that write where the mask is
3788
+ * zero included, which through a mask cleared the box outside the clip.
3789
+ * Inside it a rect clip is binary coverage of 1, so the two agree pixel
3790
+ * for pixel rather than approximately. **`globalAlpha`** is a 1x1 solid,
3791
+ * which is that alpha everywhere the server samples it, for the price of
3792
+ * no pixels at all. A **region** clip needs neither: the picture carries
3793
+ * it, and it applies to the composite like any other.
3388
3794
  *
3389
3795
  * What is left needing a mask is what genuinely needs one: a
3390
- * non-rectangular clip entry, or an op that writes where the mask is zero
3391
- * — `copy`, `source-in`, `destination-in`, `source-out` and
3392
- * `destination-atop` cover the whole composite box, so a shrunk box would
3393
- * leave behind the pixels the mask path clears.
3796
+ * non-rectangular clip entry (`_compositeMasked`).
3394
3797
  *
3395
3798
  * `box` is the composite's destination box in device coordinates. Callers
3396
3799
  * pass their own arguments through, so it is only intersected when it is
@@ -3401,14 +3804,13 @@ class RenderingContext2d {
3401
3804
  * @returns {{box: ?object, mask: number}|null} the box to composite over —
3402
3805
  * null when the clip rejects it whole and nothing should be drawn — and
3403
3806
  * the mask slot for it; or null when this route does not apply and the
3404
- * caller has to fall back to `_compositeMask()`.
3807
+ * caller has to fall back to `_compositeMasked()`.
3405
3808
  */
3406
- _boxedComposite(box, op) {
3809
+ _boxedComposite(box) {
3407
3810
  if (this._hasPolyClip) return null;
3408
3811
  let out = box;
3409
3812
  const clip = this._clipRect();
3410
3813
  if (clip) {
3411
- if (!this._maskBounded(op)) return null;
3412
3814
  if (![box.x, box.y, box.w, box.h].every(isIntegral)) return null;
3413
3815
  out = intersectBox(box, clip);
3414
3816
  }
@@ -3423,7 +3825,7 @@ class RenderingContext2d {
3423
3825
  */
3424
3826
  _alphaMask() {
3425
3827
  if (this.globalAlpha >= 1) return 0;
3426
- return this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
3828
+ return this._transientSolid(0, 0, 0, this.globalAlpha).id;
3427
3829
  }
3428
3830
 
3429
3831
  /**
@@ -3454,7 +3856,9 @@ class RenderingContext2d {
3454
3856
  // The fallback, not the common case: `_boxedComposite` above answers for a
3455
3857
  // rect-only clip stack without materializing anything, and every caller
3456
3858
  // here asks it first. A **region** clip needs nothing either — the picture
3457
- // carries it, and the mask must not try to hold it.
3859
+ // carries it, and the mask must not try to hold it. Nor is it for an op
3860
+ // that writes where the mask is zero: that takes the clip after the op
3861
+ // (`_clipAfter`).
3458
3862
  //
3459
3863
  // `box` is the destination box of the composite the mask is for, in device
3460
3864
  // coordinates. The composite samples the mask over that box and nowhere
@@ -3496,6 +3900,33 @@ class RenderingContext2d {
3496
3900
  return this.fillMask.id;
3497
3901
  }
3498
3902
 
3903
+ /**
3904
+ * The direct composite `_boxedComposite` has no cheap form for: `src`
3905
+ * over `box`, sampled from (`srcX`, `srcY`), under a clip the a8 clip mask
3906
+ * holds — a path, or a rectangle a box off the pixel grid cannot be cut
3907
+ * to. For an op that leaves the destination alone where the mask is zero
3908
+ * the clip and `globalAlpha` are one mask (`_compositeMask`). The rest
3909
+ * would clear every pixel of the box the clip leaves out, so for them the
3910
+ * mask is the alpha alone and the clip goes after the op (`_clipAfter`).
3911
+ */
3912
+ _compositeMasked(op, src, srcX, srcY, box) {
3913
+ const after = !this._maskBounded(op);
3914
+ this._compositeOnto(
3915
+ after,
3916
+ op,
3917
+ src,
3918
+ after ? this._alphaMask() : this._compositeMask(box),
3919
+ srcX,
3920
+ srcY,
3921
+ box.x,
3922
+ box.y,
3923
+ box.x,
3924
+ box.y,
3925
+ box.w,
3926
+ box.h,
3927
+ );
3928
+ }
3929
+
3499
3930
  // ------------------------------------------------------------------
3500
3931
  // drawing
3501
3932
 
@@ -3608,24 +4039,28 @@ class RenderingContext2d {
3608
4039
  // coordinates all shift with the box, so a gradient or a pattern stays
3609
4040
  // where it was and only the ink outside the clip goes away.
3610
4041
  const direct =
3611
- w > 0 && h > 0 ? this._boxedComposite({ x, y, w, h }, op) : null;
4042
+ w > 0 && h > 0 ? this._boxedComposite({ x, y, w, h }) : null;
3612
4043
  if (direct && !direct.box) return; // the clip rejects the fill whole
3613
- const box = direct ? direct.box : { x, y, w, h };
3614
4044
  const [ox, oy] = sourceOrigin(style);
3615
- this.Render.Composite(
3616
- op,
3617
- style.id,
3618
- direct ? direct.mask : this._compositeMask(box),
3619
- this._dst(),
3620
- box.x - ox,
3621
- box.y - oy,
3622
- box.x,
3623
- box.y,
3624
- box.x,
3625
- box.y,
3626
- box.w,
3627
- box.h,
3628
- );
4045
+ if (direct) {
4046
+ const box = direct.box;
4047
+ this.Render.Composite(
4048
+ op,
4049
+ style.id,
4050
+ direct.mask,
4051
+ this._dst(),
4052
+ box.x - ox,
4053
+ box.y - oy,
4054
+ box.x,
4055
+ box.y,
4056
+ box.x,
4057
+ box.y,
4058
+ box.w,
4059
+ box.h,
4060
+ );
4061
+ } else {
4062
+ this._compositeMasked(op, style.id, x - ox, y - oy, { x, y, w, h });
4063
+ }
3629
4064
  this._markDirty();
3630
4065
  return;
3631
4066
  }
@@ -4196,7 +4631,9 @@ class RenderingContext2d {
4196
4631
  });
4197
4632
  }
4198
4633
  const encoded = encodeGlyphItems(items, page.bits);
4199
- const src = app.solidPicture(color[0], color[1], color[2], color[3]);
4634
+ // the style's own solid at full opacity; under a fade, one with the
4635
+ // alpha folded in, made for this composite
4636
+ const src = this._transientSolid(color[0], color[1], color[2], color[3]);
4200
4637
  R.CompositeGlyphs(
4201
4638
  encoded.bits,
4202
4639
  R.PictOp.Over,
@@ -4738,6 +5175,11 @@ class RenderingContext2d {
4738
5175
  * The current transform's translation applies to the anchor point;
4739
5176
  * glyphs themselves are not rotated/scaled by the transform (size the
4740
5177
  * font via `ctx.font` instead).
5178
+ *
5179
+ * The glyphs composite with `globalCompositeOperation`, as a fill does
5180
+ * and as their shadow always did: drawn source-over whatever it was, a
5181
+ * shadowed `destination-out` label erased with its shadow and then
5182
+ * painted its letters back in.
4741
5183
  */
4742
5184
  fillText(text, x, y) {
4743
5185
  text = String(text ?? "");
@@ -4766,11 +5208,7 @@ class RenderingContext2d {
4766
5208
  }
4767
5209
  // already device space: the anchor went through the matrix above, and
4768
5210
  // the offsets and advances added to it are device pixels
4769
- this._drawGlyphsDevice(
4770
- this.Render.PictOp.Over,
4771
- this._backgroundPicture,
4772
- positioned,
4773
- );
5211
+ this._drawGlyphsDevice(this._op(), this._backgroundPicture, positioned);
4774
5212
  }
4775
5213
 
4776
5214
  /**
@@ -5183,19 +5621,6 @@ class RenderingContext2d {
5183
5621
  });
5184
5622
  }
5185
5623
 
5186
- /**
5187
- * Draw an image, with the three canvas signatures:
5188
- *
5189
- * ctx.drawImage(image, dx, dy)
5190
- * ctx.drawImage(image, dx, dy, dWidth, dHeight)
5191
- * ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
5192
- *
5193
- * `image` is an ntk `Image` (see `loadImage()` — PNG/JPEG decoded
5194
- * client-side, uploaded to the server once and composited from there;
5195
- * scaling is server-side with bilinear filtering), another
5196
- * RenderingContext2d, or a node-canvas-like object. Images honor the
5197
- * current transform (server-side, via the picture transform).
5198
- */
5199
5624
  /**
5200
5625
  * Clip and mask for a direct composite, as a bracket around it.
5201
5626
  *
@@ -5207,23 +5632,29 @@ class RenderingContext2d {
5207
5632
  *
5208
5633
  * Uniform alpha needs no surface-sized mask either: a 1x1 repeating
5209
5634
  * picture is the same thing to the server, with no pixel work at all.
5635
+ *
5636
+ * `after` says the composite takes the clip after the op rather than in
5637
+ * its mask (`_clipAfter`), through `_compositeOnto`.
5210
5638
  */
5211
- _beginDirectComposite(box) {
5639
+ _beginDirectComposite(box, op) {
5212
5640
  const rect = this._clipRect();
5213
5641
  if (!rect) {
5214
5642
  // a poly clip is the only thing left that needs a mask; without one
5215
5643
  // there is nothing to apply here but the alpha. The mask it does need
5216
5644
  // is bounded to `box`, the destination box of the composite it is for
5217
- // — the only pixels of it the server samples (issue #313).
5218
- const mask = this._hasPolyClip
5219
- ? this._compositeMask(box)
5220
- : this._alphaMask();
5221
- return { mask, clipped: false, empty: false };
5645
+ // — the only pixels of it the server samples (issue #313). An op that
5646
+ // writes where the mask is zero takes the clip after it instead.
5647
+ const after = this._clipAfter(op);
5648
+ const mask =
5649
+ this._hasPolyClip && !after
5650
+ ? this._compositeMask(box)
5651
+ : this._alphaMask();
5652
+ return { mask, after, clipped: false, empty: false };
5222
5653
  }
5223
5654
  if (rect.w === 0 || rect.h === 0)
5224
- return { mask: 0, clipped: false, empty: true };
5655
+ return { mask: 0, after: false, clipped: false, empty: true };
5225
5656
  this._setPictureClip(rect);
5226
- return { mask: this._alphaMask(), clipped: true, empty: false };
5657
+ return { mask: this._alphaMask(), after: false, clipped: true, empty: false };
5227
5658
  }
5228
5659
 
5229
5660
  _endDirectComposite(state) {
@@ -5247,7 +5678,7 @@ class RenderingContext2d {
5247
5678
  return this._backgroundPicture;
5248
5679
  }
5249
5680
  const c = this._foldedColor(style);
5250
- return this.createSolidPicture(c[0], c[1], c[2], c[3]);
5681
+ return this._transientSolid(c[0], c[1], c[2], c[3]);
5251
5682
  }
5252
5683
 
5253
5684
  /**
@@ -5258,6 +5689,10 @@ class RenderingContext2d {
5258
5689
  * monochrome drawing rendered once as coverage can then be painted in any
5259
5690
  * colour, so a hover, a disabled state and a theme change all reuse the one
5260
5691
  * rendered copy instead of each needing their own.
5692
+ *
5693
+ * The rectangles are device pixels, whatever the transform: a shadow's
5694
+ * coverage is composited through here as it is. Under a transform,
5695
+ * `drawImage` places coverage with `_drawCoverageTurned` instead.
5261
5696
  */
5262
5697
  _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
5263
5698
  // Coverage the surface has none of is drawn as nothing. X takes the
@@ -5266,10 +5701,9 @@ class RenderingContext2d {
5266
5701
  // threw out of the paint. A NaN destination fails the test too.
5267
5702
  if (!(dx < this.width && dy < this.height && dx + dw > 0 && dy + dh > 0)) return;
5268
5703
  const R = this.Render;
5269
- // the coverage is painted in the current fillStyle, on both branches
5270
- // below — a gradient/pattern whose transform collapsed paints nothing
5271
- const style = this._backgroundPicture;
5272
- if (!prepareStyle(style, this._m, this.width, this.height)) return;
5704
+ // the coverage is painted in the current fillStyle — a gradient/pattern
5705
+ // whose transform collapsed paints nothing
5706
+ if (!prepareStyle(this._backgroundPicture, this._m, this.width, this.height)) return;
5273
5707
  const scaled = dw !== sw || dh !== sh;
5274
5708
  if (scaled) {
5275
5709
  R.SetPictureTransform(picture.id, [
@@ -5288,15 +5722,36 @@ class RenderingContext2d {
5288
5722
  R.ChangePicture(picture.id, { repeat: 2 }); // Repeat.Pad
5289
5723
  }
5290
5724
  // with a transform in place the mask is already sampled from (sx, sy)
5291
- const mx = scaled ? 0 : sx;
5292
- const my = scaled ? 0 : sy;
5725
+ this._paintCoverage(picture, scaled ? 0 : sx, scaled ? 0 : sy, dx, dy, dw, dh, op);
5726
+
5727
+ if (scaled) {
5728
+ // restore defaults so the cached surface stays reusable as-is
5729
+ R.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
5730
+ picture.setFilter("nearest");
5731
+ R.ChangePicture(picture.id, { repeat: 0 }); // Repeat.None
5732
+ }
5733
+ }
5293
5734
 
5735
+ /**
5736
+ * The current `fillStyle` painted through coverage over the destination
5737
+ * box (dx, dy, dw, dh): `picture`, an a8 picture, is the mask, sampled
5738
+ * from (mx, my) at the box's corner through whatever transform its caller
5739
+ * has put on it — a scale (`_drawCoverage`) or a turn
5740
+ * (`_drawCoverageTurned`). The caller has prepared the style for this
5741
+ * paint (`prepareStyle`).
5742
+ */
5743
+ _paintCoverage(picture, mx, my, dx, dy, dw, dh, op) {
5744
+ const R = this.Render;
5745
+ const style = this._backgroundPicture;
5294
5746
  const rect = this._clipRect();
5295
5747
  // the mask slot can hold exactly one picture, so anything that cannot be
5296
5748
  // folded into the colour or handed to the server as a clip rectangle has
5297
- // to be intersected into the scratch mask first
5749
+ // to be intersected into the scratch mask first — a path clip, unless
5750
+ // it is applied after the op (`_clipAfter`), and an alpha a gradient
5751
+ // has no colour to take
5752
+ const after = this._clipAfter(op);
5298
5753
  const scratch =
5299
- this._hasPolyClip ||
5754
+ (this._hasPolyClip && !after) ||
5300
5755
  (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
5301
5756
 
5302
5757
  if (scratch) {
@@ -5324,7 +5779,7 @@ class RenderingContext2d {
5324
5779
  if (this.globalAlpha < 1) {
5325
5780
  R.Composite(
5326
5781
  R.PictOp.InReverse,
5327
- this.createSolidPicture(0, 0, 0, this.globalAlpha).id,
5782
+ this._transientSolid(0, 0, 0, this.globalAlpha).id,
5328
5783
  0,
5329
5784
  this.fillMask.id,
5330
5785
  0,
@@ -5337,7 +5792,7 @@ class RenderingContext2d {
5337
5792
  this.height,
5338
5793
  );
5339
5794
  }
5340
- if (this._hasPolyClip) {
5795
+ if (this._hasPolyClip && !after) {
5341
5796
  const clipMask = this._requireClipMask();
5342
5797
  R.Composite(
5343
5798
  R.PictOp.In,
@@ -5365,11 +5820,11 @@ class RenderingContext2d {
5365
5820
  // part of a full-surface picture: the region just cleared to zero, so
5366
5821
  // nothing paints at all unless dx and dy are both zero.
5367
5822
  const [ox, oy] = sourceOrigin(style);
5368
- R.Composite(
5823
+ this._compositeOnto(
5824
+ after,
5369
5825
  op,
5370
5826
  style.id,
5371
5827
  this.fillMask.id,
5372
- this._dst(),
5373
5828
  dx - ox,
5374
5829
  dy - oy,
5375
5830
  dx,
@@ -5387,11 +5842,11 @@ class RenderingContext2d {
5387
5842
  // exactly as in _fillPolys
5388
5843
  const src = this._coverageSource();
5389
5844
  const [ox, oy] = sourceOrigin(src);
5390
- R.Composite(
5845
+ this._compositeOnto(
5846
+ after,
5391
5847
  op,
5392
5848
  src.id,
5393
5849
  picture.id,
5394
- this._dst(),
5395
5850
  dx - ox,
5396
5851
  dy - oy,
5397
5852
  mx,
@@ -5403,229 +5858,410 @@ class RenderingContext2d {
5403
5858
  );
5404
5859
  if (rect) this._invalidatePictureClip();
5405
5860
  }
5406
-
5407
- if (scaled) {
5408
- // restore defaults so the cached surface stays reusable as-is
5409
- R.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
5410
- picture.setFilter("nearest");
5411
- R.ChangePicture(picture.id, { repeat: 0 }); // Repeat.None
5412
- }
5413
5861
  this._markDirty();
5414
5862
  }
5415
5863
 
5416
- drawImage(image, ...args) {
5417
- if (this._shadowed()) this._shadowOfImage(image, args);
5418
- if (isPictureSource(image)) {
5419
- let sx = 0;
5420
- let sy = 0;
5421
- let sw = image.width;
5422
- let sh = image.height;
5423
- let dx = 0;
5424
- let dy = 0;
5425
- let dw;
5426
- let dh;
5427
- if (args.length >= 8) {
5428
- [sx, sy, sw, sh, dx, dy, dw, dh] = args;
5429
- } else if (args.length >= 4) {
5430
- [dx, dy, dw, dh] = args;
5431
- } else {
5432
- [dx = 0, dy = 0] = args;
5433
- dw = sw;
5434
- dh = sh;
5435
- }
5436
- if (!(dw > 0) || !(dh > 0) || !(sw > 0) || !(sh > 0)) return;
5437
-
5438
- const picture = image.picture(this.window.app);
5439
- const op = this._op();
5864
+ /**
5865
+ * Coverage under a transform, where `_drawImageTransformed` puts it: over
5866
+ * `box`, the part of the whole-pixel box round the turned rectangle that
5867
+ * the surface has, with `transform` taking that box's pixels back into the
5868
+ * image from its corner. The turned coverage is the mask, and the fill
5869
+ * style is painted through it as `_drawCoverage` paints it: clip,
5870
+ * `globalAlpha` and the composite op included, and a gradient or a
5871
+ * pattern under the transform in force, as for a fill.
5872
+ *
5873
+ * It used to be composited as the source, as an image is, and RENDER reads
5874
+ * an a8 source as alpha over black: a mask drawn under any transform, a
5875
+ * translate included, came out black whatever the fill style was.
5876
+ */
5877
+ _drawCoverageTurned(image, box, transform, op) {
5878
+ // Nothing to paint: a clip rectangle the box misses, which clips every
5879
+ // op on the server, or a style that paints nothing (`_drawCoverage`).
5880
+ // Decided before the transform is installed, so there is none to put
5881
+ // back.
5882
+ const rect = this._clipRect();
5883
+ if (rect && !intersectBox(box, rect)) return;
5884
+ if (!prepareStyle(this._backgroundPicture, this._m, this.width, this.height)) return;
5885
+ const R = this.Render;
5886
+ const picture = image.picture(this.window.app);
5887
+ R.SetPictureTransform(picture.id, transform);
5888
+ picture.setFilter("bilinear");
5889
+ this._paintCoverage(picture, 0, 0, box.x, box.y, box.w, box.h, op);
5890
+ R.SetPictureTransform(picture.id, IDENTITY_TRANSFORM);
5891
+ picture.setFilter("nearest");
5892
+ }
5440
5893
 
5441
- if (!matIsIdentity(this._m)) {
5442
- this._drawImageTransformed(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
5443
- return;
5894
+ /**
5895
+ * Draw an image, with the three canvas signatures:
5896
+ *
5897
+ * ctx.drawImage(image, dx, dy)
5898
+ * ctx.drawImage(image, dx, dy, dWidth, dHeight)
5899
+ * ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
5900
+ *
5901
+ * `image` is an ntk `Image` (see `loadImage()` — PNG/JPEG decoded
5902
+ * client-side, uploaded to the server once and composited from there), a
5903
+ * `Surface`, anything else with a size and a `picture(app)`, another
5904
+ * RenderingContext2d, or a node-canvas-like object. Every one of them is
5905
+ * drawn the same way: cropped, scaled server-side with bilinear filtering,
5906
+ * under the current transform, clipped, faded by `globalAlpha` and
5907
+ * composited with `globalCompositeOperation`.
5908
+ */
5909
+ drawImage(image, ...args) {
5910
+ // A context and a node-canvas are drawn from a picture of their pixels,
5911
+ // as a Surface is. A context used to be composited whole at (0, 0), with
5912
+ // Over, whatever the call asked for.
5913
+ if (image instanceof RenderingContext2d) {
5914
+ image = contextSource(image, this.window.app);
5915
+ } else if (!isPictureSource(image) && isCanvasLike(image)) {
5916
+ const source = this._canvasSource(image);
5917
+ try {
5918
+ this.drawImage(source, ...args);
5919
+ } finally {
5920
+ source.destroy();
5444
5921
  }
5922
+ return;
5923
+ }
5924
+ if (!isPictureSource(image)) return;
5925
+ const rects = imageRects(image, args);
5926
+ const cut = this._spilledCrop(image, rects);
5927
+ if (cut || this._drawsFromCopy(image, rects)) {
5928
+ this._drawFromCopy(image, rects, cut);
5929
+ return;
5930
+ }
5931
+ if (this._shadowed()) this._shadowOfImage(image, args);
5445
5932
 
5446
- if (image.format === "a8") {
5447
- this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
5448
- return;
5449
- }
5933
+ const [sx, sy, sw, sh, dx, dy, dw, dh] = rects;
5934
+ if (!(dw > 0) || !(dh > 0) || !(sw > 0) || !(sh > 0)) return;
5935
+ const app = this.window.app;
5936
+ const op = this._op();
5450
5937
 
5451
- const state = this._beginDirectComposite({ x: dx, y: dy, w: dw, h: dh });
5452
- if (state.empty) return;
5453
- const mask = state.mask;
5454
- const scaled = dw !== sw || dh !== sh;
5455
- if (scaled) {
5456
- // the picture transform maps composite coordinates into source
5457
- // samples: dest pixel (i, j) reads (sx + i*sw/dw, sy + j*sh/dh)
5458
- this.Render.SetPictureTransform(picture.id, [
5459
- sw / dw,
5460
- 0,
5461
- sx,
5462
- 0,
5463
- sh / dh,
5464
- sy,
5465
- 0,
5466
- 0,
5467
- 1,
5468
- ]);
5469
- picture.setFilter("bilinear");
5470
- // Bilinear sampling at the rim reads half a source pixel past the
5471
- // image, and a picture's default repeat reads transparent there: an
5472
- // upscaled image faded into whatever it was drawn over across its
5473
- // outer pixels — three of them for a 15-pixel swatch drawn at 96.
5474
- // The canvas spec's drawImage clamps to the image's edge, and so do
5475
- // browsers; RepeatPad is that. Only on this axis-aligned path, whose
5476
- // composite box is the destination rectangle exactly: under a
5477
- // rotation the box is the bounding box, and pad would paint its
5478
- // corners.
5479
- this.Render.ChangePicture(picture.id, { repeat: 2 }); // Repeat.Pad
5480
- this.Render.Composite(
5481
- op,
5482
- picture.id,
5483
- mask,
5484
- this._dst(),
5485
- 0,
5486
- 0,
5487
- dx,
5488
- dy,
5489
- dx,
5490
- dy,
5491
- dw,
5492
- dh,
5493
- );
5494
- // restore defaults so the cached upload can be reused as-is
5495
- this.Render.SetPictureTransform(
5496
- picture.id,
5497
- [1, 0, 0, 0, 1, 0, 0, 0, 1],
5498
- );
5499
- picture.setFilter("nearest");
5500
- this.Render.ChangePicture(picture.id, { repeat: 0 }); // Repeat.None
5501
- } else {
5502
- this.Render.Composite(
5503
- op,
5504
- picture.id,
5505
- mask,
5506
- this._dst(),
5507
- sx,
5508
- sy,
5509
- dx,
5510
- dy,
5511
- dx,
5512
- dy,
5513
- dw,
5514
- dh,
5515
- );
5516
- }
5517
- this._endDirectComposite(state);
5518
- this._markDirty();
5938
+ if (!matIsIdentity(this._m)) {
5939
+ this._drawImageTransformed(image, sx, sy, sw, sh, dx, dy, dw, dh, op);
5519
5940
  return;
5520
5941
  }
5521
5942
 
5522
- const sWidth = image.width;
5523
- const sHeight = image.height;
5524
- const sx = 0;
5525
- const sy = 0;
5943
+ if (image.format === "a8") {
5944
+ const picture = image.picture(app);
5945
+ this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
5946
+ return;
5947
+ }
5526
5948
 
5527
- // Drawables reach the branch above by way of Surface, which is what a
5528
- // Window/Pixmap/Picture source actually needs: a size and a Picture.
5529
- if (image instanceof RenderingContext2d) {
5530
- // TODO: if need to scale, set transform
5531
- // TODO: respect global compositing blend mode
5532
- const full = { x: 0, y: 0, w: this.width, h: this.height };
5533
- // source and destination are both surface-aligned here, so a clip
5534
- // rectangle is just a smaller box to copy (issue #307)
5535
- const direct = this._boxedComposite(full, this.Render.PictOp.Over);
5536
- if (direct && !direct.box) return;
5537
- const box = direct ? direct.box : full;
5538
- this.Render.Composite(
5539
- this.Render.PictOp.Over,
5540
- image.picture.id,
5541
- direct ? direct.mask : this._compositeMask(box),
5542
- this._dst(),
5543
- box.x,
5544
- box.y,
5545
- box.x,
5546
- box.y,
5547
- box.x,
5548
- box.y,
5549
- box.w,
5550
- box.h,
5949
+ const state = this._beginDirectComposite({ x: dx, y: dy, w: dw, h: dh }, op);
5950
+ if (state.empty) return;
5951
+ // only now that the clip has had its say: a node-canvas uploads here
5952
+ const picture = image.picture(app);
5953
+ const scaled = dw !== sw || dh !== sh;
5954
+ if (scaled) {
5955
+ // the picture transform maps composite coordinates into source
5956
+ // samples: dest pixel (i, j) reads (sx + i*sw/dw, sy + j*sh/dh)
5957
+ this.Render.SetPictureTransform(picture.id, [
5958
+ sw / dw,
5959
+ 0,
5960
+ sx,
5961
+ 0,
5962
+ sh / dh,
5963
+ sy,
5964
+ 0,
5965
+ 0,
5966
+ 1,
5967
+ ]);
5968
+ picture.setFilter("bilinear");
5969
+ // Bilinear sampling at the rim reads half a source pixel past the
5970
+ // image, and a picture's default repeat reads transparent there: an
5971
+ // upscaled image faded into whatever it was drawn over across its
5972
+ // outer pixels — three of them for a 15-pixel swatch drawn at 96.
5973
+ // The canvas spec's drawImage clamps to the image's edge, and so do
5974
+ // browsers; RepeatPad is that. Only on this axis-aligned path, whose
5975
+ // composite box is the destination rectangle exactly: under a
5976
+ // rotation the box is the bounding box, and pad would paint its
5977
+ // corners.
5978
+ this.Render.ChangePicture(picture.id, { repeat: 2 }); // Repeat.Pad
5979
+ this._compositeOnto(
5980
+ state.after,
5981
+ op,
5982
+ picture.id,
5983
+ state.mask,
5984
+ 0,
5985
+ 0,
5986
+ dx,
5987
+ dy,
5988
+ dx,
5989
+ dy,
5990
+ dw,
5991
+ dh,
5551
5992
  );
5552
- this._markDirty();
5553
- } else if (
5554
- image &&
5555
- image.context &&
5556
- typeof image.context.getImageData === "function"
5557
- ) {
5558
- // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
5559
- // What the clip leaves of the composite, decided before anything is
5560
- // uploaded: a clip that rejects it whole makes the upload below dead
5561
- // work (issue #307)
5562
- const full = { x: 0, y: 0, w: this.width, h: this.height };
5563
- const direct = this._boxedComposite(full, this.Render.PictOp.Over);
5564
- if (direct && !direct.box) return;
5565
- const box = direct ? direct.box : full;
5566
- const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
5567
- // node-canvas hands over straight RGBA, and the rgba32 picture below is
5568
- // premultiplied — this used to swap the channels and stop there, which
5569
- // composited translucent images at full brightness
5570
- const data = fromStraightRgba(
5571
- imageData.data,
5572
- pixelLayout(this.display, 32),
5573
- sWidth,
5574
- sHeight,
5993
+ // restore defaults so the cached upload can be reused as-is
5994
+ this.Render.SetPictureTransform(
5995
+ picture.id,
5996
+ [1, 0, 0, 0, 1, 0, 0, 0, 1],
5997
+ );
5998
+ picture.setFilter("nearest");
5999
+ this.Render.ChangePicture(picture.id, { repeat: 0 }); // Repeat.None
6000
+ } else {
6001
+ this._compositeOnto(
6002
+ state.after,
6003
+ op,
6004
+ picture.id,
6005
+ state.mask,
6006
+ sx,
6007
+ sy,
6008
+ dx,
6009
+ dy,
6010
+ dx,
6011
+ dy,
6012
+ dw,
6013
+ dh,
5575
6014
  );
6015
+ }
6016
+ this._endDirectComposite(state);
6017
+ this._markDirty();
6018
+ }
5576
6019
 
5577
- // One upload GC per app rather than one per call, the same sharing
5578
- // Image.picture does: a GC is valid for any drawable of the same screen
5579
- // and depth. This used to allocate a GC, a pixmap and a picture every
5580
- // time and free none of them, so drawing a node-canvas source in an
5581
- // animation loop leaked three server resources per frame.
5582
- const app = this.window.app;
5583
- const pixmap = new Pixmap(app, {
5584
- depth: 32,
5585
- width: sWidth,
5586
- height: sHeight,
5587
- });
5588
- const picture = new Picture(app, {
5589
- drawable: pixmap,
5590
- format: this.Render.rgba32,
5591
- });
5592
- try {
6020
+ /**
6021
+ * A node-canvas-like source as a picture source: its pixels are uploaded
6022
+ * on first use, which is after the clip has had its say, so a draw the clip
6023
+ * rejects uploads nothing (issue #307). `destroy()` frees the upload once
6024
+ * the composites that read it are queued — X executes requests in order, so
6025
+ * the server is done with it by the time it reads the frees.
6026
+ */
6027
+ _canvasSource(image) {
6028
+ const app = this.window.app;
6029
+ const { width, height } = image;
6030
+ let pixmap = null;
6031
+ let picture = null;
6032
+ return {
6033
+ width,
6034
+ height,
6035
+ picture: () => {
6036
+ if (picture) return picture;
6037
+ const imageData = image.context.getImageData(0, 0, width, height);
6038
+ // node-canvas hands over straight RGBA, and the rgba32 picture below
6039
+ // is premultiplied — this used to swap the channels and stop there,
6040
+ // which composited translucent images at full brightness
6041
+ const data = fromStraightRgba(
6042
+ imageData.data,
6043
+ pixelLayout(this.display, 32),
6044
+ width,
6045
+ height,
6046
+ );
6047
+ pixmap = new Pixmap(app, { depth: 32, width, height });
6048
+ picture = new Picture(app, {
6049
+ drawable: pixmap,
6050
+ format: this.Render.rgba32,
6051
+ });
6052
+ // One upload GC per app rather than one per call, the same sharing
6053
+ // Image.picture does: a GC is valid for any drawable of the same
6054
+ // screen and depth. This used to allocate a GC, a pixmap and a
6055
+ // picture every time and free none of them, so drawing a node-canvas
6056
+ // source in an animation loop leaked three server resources a frame.
5593
6057
  let gc = app._imageUploadGC;
5594
6058
  if (!gc) {
5595
6059
  gc = app._imageUploadGC = this.X.AllocID();
5596
6060
  this.X.CreateGC(gc, pixmap.id);
5597
6061
  }
5598
- // the crop already happened in getImageData, so it lands at the
5599
- // pixmap's origin — sx/sy here wrote it off the edge
5600
- this.X.PutImage(2, pixmap.id, gc, sWidth, sHeight, 0, 0, 0, 32, data);
6062
+ this.X.PutImage(2, pixmap.id, gc, width, height, 0, 0, 0, 32, data);
6063
+ return picture;
6064
+ },
6065
+ destroy: () => {
6066
+ picture?.destroy();
6067
+ pixmap?.destroy();
6068
+ },
6069
+ };
6070
+ }
5601
6071
 
5602
- this.Render.Composite(
5603
- this.Render.PictOp.Over,
5604
- picture.id,
5605
- direct ? direct.mask : this._compositeMask(box),
5606
- this._dst(),
5607
- box.x,
5608
- box.y,
5609
- box.x,
5610
- box.y,
5611
- box.x,
5612
- box.y,
5613
- box.w,
5614
- box.h,
5615
- );
5616
- this._markDirty();
5617
- } finally {
5618
- // the composite is queued, and X executes requests in order, so the
5619
- // server is done with these by the time it reads the frees
5620
- picture.destroy();
5621
- pixmap.destroy();
5622
- }
6072
+ /**
6073
+ * Whether `drawImage` has to read a copy of the image rather than its
6074
+ * picture, because the picture holds pixels the draw must not read.
6075
+ *
6076
+ * Its own, first: the drawable this context draws into — the context
6077
+ * itself, another context on the same drawable, or a Surface drawn from
6078
+ * inside its own `render()`. A composite that reads the pixels it writes
6079
+ * sees its own output on the servers that work row by row — pixman, so
6080
+ * Xvfb and XQuartz, and glamor too, which hands a picture drawn onto its
6081
+ * own pixmap to pixman — and anything drawn lower than it was read from
6082
+ * came out smeared, each row read after a row above it had been written
6083
+ * there. A canvas draws from the pixels as they were before the call.
6084
+ *
6085
+ * Then a window's, past its edge: the backing pixmap is larger than the
6086
+ * window, and a draw that scales the image up, or puts it under a
6087
+ * transform, filters across the edge into the background colour there,
6088
+ * where the canvas clamps to the edge pixel (see the scaled path above).
6089
+ */
6090
+ _drawsFromCopy(image, rects) {
6091
+ const drawable = image instanceof Surface ? image.pixmap : image._drawable;
6092
+ if (!drawable) return false;
6093
+ if (drawable.id === this._target?.id) return true;
6094
+ const [, , sw, sh, , , dw, dh] = rects;
6095
+ const acrossEdge = dw > sw || dh > sh || !matIsIdentity(this._m);
6096
+ return (
6097
+ acrossEdge &&
6098
+ (drawable.width > image.width || drawable.height > image.height)
6099
+ );
6100
+ }
6101
+
6102
+ /**
6103
+ * The part of the image `drawImage` has to draw from a copy of, because
6104
+ * drawn from the image itself it would show pixels beyond the crop; null
6105
+ * when it would not.
6106
+ *
6107
+ * Under a transform the composite covers the whole-pixel box around the
6108
+ * turned destination rectangle (`turnedBox`), and samples the image through
6109
+ * the inverse transform across all of it. Turned or skewed, the box's
6110
+ * corners are outside the rectangle, and so are the pixels along a side
6111
+ * that falls between two pixels; they sample the image outside the source
6112
+ * rectangle. Past the image's own edge that reads transparent, so a whole
6113
+ * image is drawn right — but past a crop it reads the rest of the image,
6114
+ * which came out at the box's corners: a crop of the left half of an image
6115
+ * turned 30° drew a wedge of its right half beside it, and one translated
6116
+ * by a tenth of a pixel drew a column of it.
6117
+ *
6118
+ * Only a crop that leaves a whole pixel of the image out needs the copy,
6119
+ * and only under a transform whose box is wider than its rectangle: the
6120
+ * axis-aligned paths composite over the destination rectangle and nowhere
6121
+ * else, and so does an axis-aligned transform that puts its corners on
6122
+ * whole pixels.
6123
+ *
6124
+ * The copy is of the crop's own pixels and of no more (`_drawFromCopy`),
6125
+ * and of them only the ones the composite can sample: the device box the
6126
+ * surface and a clip leave of it, mapped back into the image, with two
6127
+ * pixels to spare for the bilinear filter and the 16.16 transform. A draw
6128
+ * that lands nowhere copies nothing; one mostly off the surface, or
6129
+ * repainted through a small clip, copies the part it shows. A shadow
6130
+ * samples the image somewhere else, so a shadowed draw copies the crop
6131
+ * whole.
6132
+ */
6133
+ _spilledCrop(image, rects) {
6134
+ if (matIsIdentity(this._m)) return null;
6135
+ const [sx, sy, sw, sh, dx, dy, dw, dh] = rects;
6136
+ if (!(dw > 0) || !(dh > 0) || !(sw > 0) || !(sh > 0)) return null;
6137
+ // the pixels the crop overlaps by more than a rounding error
6138
+ const crop = {
6139
+ x0: Math.max(0, Math.floor(sx + SNAP)),
6140
+ y0: Math.max(0, Math.floor(sy + SNAP)),
6141
+ x1: Math.min(image.width, Math.ceil(sx + sw - SNAP)),
6142
+ y1: Math.min(image.height, Math.ceil(sy + sh - SNAP)),
6143
+ };
6144
+ const cropped =
6145
+ crop.x0 > 0 ||
6146
+ crop.y0 > 0 ||
6147
+ crop.x1 < image.width ||
6148
+ crop.y1 < image.height;
6149
+ if (!cropped) return null;
6150
+ const turned = turnedBox(this._m, dx, dy, dw, dh);
6151
+ if (turned.fills) return null;
6152
+ // Too small for the transform to say, or nowhere the draw can show:
6153
+ // `_drawImageTransformed` draws nothing, so nothing is copied for it.
6154
+ const none = { x0: 0, y0: 0, x1: 0, y1: 0 };
6155
+ const inv = matInvert(
6156
+ matMultiply(this._m, [dw / sw, 0, 0, dh / sh, dx, dy]),
6157
+ );
6158
+ if (!inv || !fitsFixed(inv.slice(0, 4))) return none;
6159
+ if (this._shadowed()) return crop;
6160
+
6161
+ // Nothing outside the clip is written, whatever the op, so nothing there
6162
+ // needs reading (`_boxedComposite`, `_clipAfter`).
6163
+ let box = intersectBox(
6164
+ {
6165
+ x: turned.x0,
6166
+ y: turned.y0,
6167
+ w: turned.x1 - turned.x0,
6168
+ h: turned.y1 - turned.y0,
6169
+ },
6170
+ { x: 0, y: 0, w: this.width, h: this.height },
6171
+ );
6172
+ const clip = this._clipExtents();
6173
+ if (box && clip) box = intersectBox(box, clip);
6174
+ if (!box) return none;
6175
+ const us = [];
6176
+ const vs = [];
6177
+ for (const [x, y] of [
6178
+ [box.x, box.y],
6179
+ [box.x + box.w, box.y],
6180
+ [box.x, box.y + box.h],
6181
+ [box.x + box.w, box.y + box.h],
6182
+ ]) {
6183
+ const [u, v] = matApply(inv, x, y);
6184
+ us.push(sx + u);
6185
+ vs.push(sy + v);
6186
+ }
6187
+ return {
6188
+ x0: Math.max(crop.x0, Math.floor(Math.min(...us)) - 2),
6189
+ y0: Math.max(crop.y0, Math.floor(Math.min(...vs)) - 2),
6190
+ x1: Math.min(crop.x1, Math.ceil(Math.max(...us)) + 2),
6191
+ y1: Math.min(crop.y1, Math.ceil(Math.max(...vs)) + 2),
6192
+ };
6193
+ }
6194
+
6195
+ /**
6196
+ * `drawImage` from a copy of the part of the image it reads, `cut`: by
6197
+ * default the source rectangle and the pixel around it bilinear filtering
6198
+ * reads inside the destination, cut at the image's edge, so the copy draws
6199
+ * what the image itself would have. The copy is taken before a shadow is
6200
+ * drawn, which would otherwise land on the pixels it is a shadow of. It is
6201
+ * argb32, which holds any source of 8 bits a channel exactly, or a8 for
6202
+ * coverage.
6203
+ *
6204
+ * A crop that spills (`_spilledCrop`) is cut with no pixel around it:
6205
+ * beyond the copy's edge the transformed composite reads transparent, so a
6206
+ * crop turned or skewed is drawn as the same pixels cut out into an image
6207
+ * of their own would be, its edges fading out over a pixel as the image's
6208
+ * own edges do. A pixel around it would come back as a band of what is
6209
+ * beside the crop, all along the outside of its edge.
6210
+ */
6211
+ _drawFromCopy(image, rects, cut = null) {
6212
+ const [sx, sy, sw, sh, dx, dy, dw, dh] = rects;
6213
+ if (!(dw > 0) || !(dh > 0) || !(sw > 0) || !(sh > 0)) return;
6214
+ const { x0, y0, x1, y1 } = cut ?? {
6215
+ x0: Math.max(0, Math.floor(sx) - 1),
6216
+ y0: Math.max(0, Math.floor(sy) - 1),
6217
+ x1: Math.min(image.width, Math.ceil(sx + sw) + 1),
6218
+ y1: Math.min(image.height, Math.ceil(sy + sh) + 1),
6219
+ };
6220
+ // the crop holds none of the image, or none the draw can show
6221
+ if (!(x1 > x0 && y1 > y0)) return;
6222
+ const width = x1 - x0;
6223
+ const height = y1 - y0;
6224
+ const app = this.window.app;
6225
+ const R = this.Render;
6226
+ const coverage = image.format === "a8";
6227
+ const pixmap = new Pixmap(app, { depth: coverage ? 8 : 32, width, height });
6228
+ const picture = new Picture(app, {
6229
+ drawable: pixmap,
6230
+ format: coverage ? R.a8 : R.rgba32,
6231
+ });
6232
+ try {
6233
+ R.Composite(
6234
+ R.PictOp.Src,
6235
+ image.picture(app).id,
6236
+ 0,
6237
+ picture.id,
6238
+ x0,
6239
+ y0,
6240
+ 0,
6241
+ 0,
6242
+ 0,
6243
+ 0,
6244
+ width,
6245
+ height,
6246
+ );
6247
+ const copy = {
6248
+ width,
6249
+ height,
6250
+ format: image.format,
6251
+ picture: () => picture,
6252
+ };
6253
+ this.drawImage(copy, sx - x0, sy - y0, sw, sh, dx, dy, dw, dh);
6254
+ } finally {
6255
+ // queued behind the composites that read it
6256
+ picture.destroy();
6257
+ pixmap.destroy();
5623
6258
  }
5624
6259
  }
5625
6260
 
5626
6261
  // general-affine drawImage: fold CTM + dest rect + source offset into the
5627
- // source picture transform and composite over the transformed bbox
5628
- _drawImageTransformed(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
6262
+ // source picture transform and composite over the transformed bbox — or,
6263
+ // for coverage, into the mask's (`_drawCoverageTurned`)
6264
+ _drawImageTransformed(image, sx, sy, sw, sh, dx, dy, dw, dh, op) {
5629
6265
  // maps source-local (u, v) in [0..sw)x[0..sh) to device coordinates
5630
6266
  const M = matMultiply(this._m, [dw / sw, 0, 0, dh / sh, dx, dy]);
5631
6267
  const inv = matInvert(M);
@@ -5636,23 +6272,14 @@ class RenderingContext2d {
5636
6272
  // pixel across for any image X can hold, and nothing to see.
5637
6273
  if (!fitsFixed(inv.slice(0, 4))) return;
5638
6274
 
5639
- // device bbox of the transformed dest rect, clamped to the canvas
5640
- const corners = [
5641
- matApply(this._m, dx, dy),
5642
- matApply(this._m, dx + dw, dy),
5643
- matApply(this._m, dx, dy + dh),
5644
- matApply(this._m, dx + dw, dy + dh),
5645
- ];
5646
- const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
5647
- const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
5648
- const x1 = Math.min(
5649
- this.width,
5650
- Math.ceil(Math.max(...corners.map((c) => c[0]))),
5651
- );
5652
- const y1 = Math.min(
5653
- this.height,
5654
- Math.ceil(Math.max(...corners.map((c) => c[1]))),
5655
- );
6275
+ // device bbox of the transformed dest rect, clamped to the canvas. A crop
6276
+ // has been cut out of the image by now wherever this box is wider than
6277
+ // the rectangle (`_spilledCrop`).
6278
+ const turned = turnedBox(this._m, dx, dy, dw, dh);
6279
+ const x0 = Math.max(0, turned.x0);
6280
+ const y0 = Math.max(0, turned.y0);
6281
+ const x1 = Math.min(this.width, turned.x1);
6282
+ const y1 = Math.min(this.height, turned.y1);
5656
6283
  if (x1 <= x0 || y1 <= y0) return;
5657
6284
 
5658
6285
  // Device -> source sample, measured from the bounding box's corner: the
@@ -5673,30 +6300,42 @@ class RenderingContext2d {
5673
6300
  // rather than thrown out of the paint.
5674
6301
  if (!fitsFixed([e, f])) return;
5675
6302
 
6303
+ const bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
6304
+ const transform = [a, c, e, b, d, f, 0, 0, 1];
6305
+ // coverage is the mask, and the fill style the source
6306
+ if (image.format === "a8") {
6307
+ this._drawCoverageTurned(image, bbox, transform, op);
6308
+ return;
6309
+ }
6310
+
5676
6311
  // A rectangular clip shrinks the box instead of becoming a mask (issue
5677
6312
  // #307). Decided before the picture transform is installed, so a draw
5678
6313
  // the clip rejects does not have to put it back.
5679
- const bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
5680
- const direct = this._boxedComposite(bbox, op);
6314
+ const direct = this._boxedComposite(bbox);
5681
6315
  if (direct && !direct.box) return;
5682
- const box = direct ? direct.box : bbox;
5683
6316
 
5684
- this.Render.SetPictureTransform(picture.id, [a, c, e, b, d, f, 0, 0, 1]);
6317
+ const picture = image.picture(this.window.app);
6318
+ this.Render.SetPictureTransform(picture.id, transform);
5685
6319
  picture.setFilter("bilinear");
5686
- this.Render.Composite(
5687
- op,
5688
- picture.id,
5689
- direct ? direct.mask : this._compositeMask(box),
5690
- this._dst(),
5691
- box.x - x0,
5692
- box.y - y0,
5693
- box.x,
5694
- box.y,
5695
- box.x,
5696
- box.y,
5697
- box.w,
5698
- box.h,
5699
- );
6320
+ if (direct) {
6321
+ const box = direct.box;
6322
+ this.Render.Composite(
6323
+ op,
6324
+ picture.id,
6325
+ direct.mask,
6326
+ this._dst(),
6327
+ box.x - x0,
6328
+ box.y - y0,
6329
+ box.x,
6330
+ box.y,
6331
+ box.x,
6332
+ box.y,
6333
+ box.w,
6334
+ box.h,
6335
+ );
6336
+ } else {
6337
+ this._compositeMasked(op, picture.id, 0, 0, bbox);
6338
+ }
5700
6339
  this.Render.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
5701
6340
  picture.setFilter("nearest");
5702
6341
  this._markDirty();