ntk 8.18.0 → 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
@@ -3169,8 +3505,14 @@ class RenderingContext2d {
3169
3505
  * relative positions, painted under the glyphs (issue #283). A paragraph
3170
3506
  * whose spans change colour draws as several calls, and — as several
3171
3507
  * `fillText`s would — casts a shadow per call.
3508
+ *
3509
+ * So does `globalAlpha`, to the glyphs and their shadow both, as it does
3510
+ * to every fill: at 0 nothing is drawn, not even the shadow.
3172
3511
  */
3173
3512
  drawGlyphs(op, src, positioned) {
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");
3174
3516
  const m = this._m;
3175
3517
  if (!matIsIdentity(m)) {
3176
3518
  positioned = positioned.map((p) => {
@@ -3195,12 +3537,29 @@ class RenderingContext2d {
3195
3537
  * instead, intersect that with the clip, and paint the real source
3196
3538
  * through the result — the same shape as _fillPolys. A rectangular clip
3197
3539
  * takes the server-side fast path below instead of the mask.
3540
+ *
3541
+ * Nor has it a mask slot for `globalAlpha`, which is why text used to be
3542
+ * drawn at full opacity whatever it was. A solid source takes the alpha
3543
+ * in its colour, as `fillRect`'s does, and the glyphs go the way they
3544
+ * always went; a gradient or a pattern has no one colour to fold it
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`).
3198
3556
  */
3199
3557
  _drawGlyphsDevice(op, src, positioned) {
3558
+ if (this.globalAlpha <= 0) return;
3200
3559
  const app = this.window.app;
3201
- const R = this.Render;
3202
3560
  if (!prepareStyle(src, this._m, this.width, this.height)) return;
3203
- if (!this._hasPolyClip) {
3561
+ const faded = this._fadedSource(src);
3562
+ if (faded && !this._hasPolyClip && this._maskBounded(op)) {
3204
3563
  // Fast path: a rectangular clip is something the server can do itself.
3205
3564
  // Two small requests around the ordinary glyph composite, instead of
3206
3565
  // clearing and compositing a full-surface a8 mask three times — which
@@ -3215,77 +3574,50 @@ class RenderingContext2d {
3215
3574
  drawGlyphRuns(
3216
3575
  app,
3217
3576
  op,
3218
- src.id,
3577
+ faded.id,
3219
3578
  this._dst(),
3220
3579
  positioned,
3221
3580
  rect ?? { x: 0, y: 0, w: this.width, h: this.height },
3222
- sourceOrigin(src),
3581
+ sourceOrigin(faded),
3223
3582
  );
3224
3583
  if (rect) this._invalidatePictureClip();
3225
3584
  this._markDirty();
3226
3585
  return;
3227
3586
  }
3228
- const clipMask = this._requireClipMask();
3229
- this._ensureFillMask();
3230
- this._glyphSource ??= this.createSolidPicture(1, 1, 1, 1);
3231
- R.FillRectangles(
3232
- R.PictOp.Src,
3233
- this.fillMask.id,
3234
- [0, 0, 0, 0],
3235
- [0, 0, this.width, this.height],
3236
- );
3237
- // solid white through the glyphs leaves their coverage in the a8 mask
3238
- drawGlyphRuns(
3239
- app,
3240
- R.PictOp.Over,
3241
- this._glyphSource.id,
3242
- this.fillMask.id,
3243
- positioned,
3244
- { x: 0, y: 0, w: this.width, h: this.height },
3245
- );
3246
- R.Composite(
3247
- R.PictOp.In,
3248
- clipMask.id,
3249
- 0,
3250
- this.fillMask.id,
3251
- 0,
3252
- 0,
3253
- 0,
3254
- 0,
3255
- 0,
3256
- 0,
3257
- this.width,
3258
- this.height,
3259
- );
3260
- const [ox, oy] = sourceOrigin(src);
3261
- R.Composite(
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
3591
+ const R = this.Render;
3592
+ const alpha = faded ? 1 : this.globalAlpha;
3593
+ this._paintThroughScratch(
3262
3594
  op,
3263
- src.id,
3264
- this.fillMask.id,
3265
- this._dst(),
3266
- -ox,
3267
- -oy,
3268
- 0,
3269
- 0,
3270
- 0,
3271
- 0,
3272
- this.width,
3273
- this.height,
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),
3274
3600
  );
3275
- this._markDirty();
3276
3601
  }
3277
3602
 
3278
3603
  /**
3279
3604
  * Trapezoid coverage under the clip — `drawGlyphs` for vector shapes that
3280
3605
  * are already trapezoidized (KaTeX radicals and rules go this way).
3281
3606
  * Without it they composite straight to the destination picture and spill
3282
- * out of clipped boxes, which is what glyphs used to do.
3607
+ * out of clipped boxes, which is what glyphs used to do. `globalAlpha`
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.
3283
3613
  */
3284
3614
  drawTraps(op, src, traps) {
3285
- if (!traps.length) return;
3615
+ if (!traps.length || this.globalAlpha <= 0) return;
3616
+ src = this._sourceOf(src, "drawTraps");
3286
3617
  const app = this.window.app;
3287
- const R = this.Render;
3288
- if (!this._hasPolyClip) {
3618
+ if (!prepareStyle(src, this._m, this.width, this.height)) return;
3619
+ const faded = this._fadedSource(src);
3620
+ if (faded && !this._hasPolyClip && this._maskBounded(op)) {
3289
3621
  const rect = this._clipRect();
3290
3622
  if (rect) {
3291
3623
  if (rect.w === 0 || rect.h === 0) return;
@@ -3294,61 +3626,152 @@ class RenderingContext2d {
3294
3626
  compositeTraps(
3295
3627
  app,
3296
3628
  op,
3297
- src.id,
3629
+ faded.id,
3298
3630
  this._dst(),
3299
3631
  traps,
3300
3632
  undefined,
3301
3633
  undefined,
3302
- sourceOrigin(src),
3634
+ sourceOrigin(faded),
3303
3635
  );
3304
3636
  if (rect) this._invalidatePictureClip();
3305
3637
  this._markDirty();
3306
3638
  return;
3307
3639
  }
3308
- const clipMask = this._requireClipMask();
3309
- this._ensureFillMask();
3310
- this._glyphSource ??= this.createSolidPicture(1, 1, 1, 1);
3311
- R.FillRectangles(
3312
- R.PictOp.Src,
3313
- this.fillMask.id,
3314
- [0, 0, 0, 0],
3315
- [0, 0, this.width, this.height],
3316
- );
3317
- compositeTraps(
3318
- app,
3319
- R.PictOp.Over,
3320
- this._glyphSource.id,
3321
- this.fillMask.id,
3322
- traps,
3640
+ const ink = trapsInk(traps);
3641
+ if (!ink) return;
3642
+ const alpha = faded ? 1 : this.globalAlpha;
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),
3323
3650
  );
3324
- R.Composite(
3325
- R.PictOp.In,
3326
- clipMask.id,
3327
- 0,
3328
- this.fillMask.id,
3329
- 0,
3330
- 0,
3331
- 0,
3332
- 0,
3333
- 0,
3334
- 0,
3335
- this.width,
3336
- this.height,
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",
3337
3682
  );
3683
+ }
3684
+
3685
+ /**
3686
+ * `src` with `globalAlpha` folded into it: the source itself at full
3687
+ * opacity, and at less, a solid of its colour with all four premultiplied
3688
+ * components scaled — which is compositing it at that opacity, for any op
3689
+ * (`_foldedColor`). Null when there is an alpha to apply and no colour to
3690
+ * fold it into: a gradient, a pattern, a picture of the caller's, or a
3691
+ * solid from a host whose `solidPicture` does not say what it paints with
3692
+ * (App#solidPicture does).
3693
+ */
3694
+ _fadedSource(src) {
3695
+ const a = this.globalAlpha;
3696
+ if (a >= 1) return src;
3697
+ const c = src._rgba;
3698
+ if (!c) return null;
3699
+ return this._transientSolid(c[0] * a, c[1] * a, c[2] * a, c[3] * a);
3700
+ }
3701
+
3702
+ /**
3703
+ * Paint `src` through coverage the server draws into the scratch a8 mask:
3704
+ * how glyphs and trapezoids reach the surface when their own composite
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.
3716
+ *
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`).
3724
+ */
3725
+ _paintThroughScratch(op, src, alpha, box, drawCoverage) {
3726
+ const R = this.Render;
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;
3735
+ this._ensureFillMask();
3736
+ this._glyphSource ??= this.createSolidPicture(1, 1, 1, 1);
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);
3740
+ if (alpha < 1) {
3741
+ // In with a constant color scales the a8 coverage by that alpha
3742
+ R.FillRectangles(R.PictOp.In, this.fillMask.id, [0, 0, 0, alpha], rect);
3743
+ }
3744
+ if (clipMask) {
3745
+ // clipMask is surface-aligned, so it is sampled at the same offset
3746
+ R.Composite(
3747
+ R.PictOp.In,
3748
+ clipMask.id,
3749
+ 0,
3750
+ this.fillMask.id,
3751
+ out.x,
3752
+ out.y,
3753
+ 0,
3754
+ 0,
3755
+ out.x,
3756
+ out.y,
3757
+ out.w,
3758
+ out.h,
3759
+ );
3760
+ }
3338
3761
  const [ox, oy] = sourceOrigin(src);
3339
- R.Composite(
3762
+ this._compositeOnto(
3763
+ after,
3340
3764
  op,
3341
3765
  src.id,
3342
3766
  this.fillMask.id,
3343
- this._dst(),
3344
- -ox,
3345
- -oy,
3346
- 0,
3347
- 0,
3348
- 0,
3349
- 0,
3350
- this.width,
3351
- 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,
3352
3775
  );
3353
3776
  this._markDirty();
3354
3777
  }
@@ -3359,20 +3782,18 @@ class RenderingContext2d {
3359
3782
  * a8 mask anywhere (issue #307).
3360
3783
  *
3361
3784
  * Neither thing `_compositeMask()` puts in a mask is really mask work.
3362
- * A **rectangular clip** is a smaller composite box: for an op that leaves
3363
- * the destination alone where the mask is zero, zeroing the mask outside
3364
- * the rectangle and never compositing there are the same picture — and a
3365
- * rect clip is an integer rectangle stamped as binary coverage, so the two
3366
- * agree pixel for pixel rather than approximately. **`globalAlpha`** is a
3367
- * 1x1 solid, which is that alpha everywhere the server samples it, for the
3368
- * price of no pixels at all. A **region** clip needs neither: the picture
3369
- * 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.
3370
3794
  *
3371
3795
  * What is left needing a mask is what genuinely needs one: a
3372
- * non-rectangular clip entry, or an op that writes where the mask is zero
3373
- * — `copy`, `source-in`, `destination-in`, `source-out` and
3374
- * `destination-atop` cover the whole composite box, so a shrunk box would
3375
- * leave behind the pixels the mask path clears.
3796
+ * non-rectangular clip entry (`_compositeMasked`).
3376
3797
  *
3377
3798
  * `box` is the composite's destination box in device coordinates. Callers
3378
3799
  * pass their own arguments through, so it is only intersected when it is
@@ -3383,14 +3804,13 @@ class RenderingContext2d {
3383
3804
  * @returns {{box: ?object, mask: number}|null} the box to composite over —
3384
3805
  * null when the clip rejects it whole and nothing should be drawn — and
3385
3806
  * the mask slot for it; or null when this route does not apply and the
3386
- * caller has to fall back to `_compositeMask()`.
3807
+ * caller has to fall back to `_compositeMasked()`.
3387
3808
  */
3388
- _boxedComposite(box, op) {
3809
+ _boxedComposite(box) {
3389
3810
  if (this._hasPolyClip) return null;
3390
3811
  let out = box;
3391
3812
  const clip = this._clipRect();
3392
3813
  if (clip) {
3393
- if (!this._maskBounded(op)) return null;
3394
3814
  if (![box.x, box.y, box.w, box.h].every(isIntegral)) return null;
3395
3815
  out = intersectBox(box, clip);
3396
3816
  }
@@ -3405,7 +3825,7 @@ class RenderingContext2d {
3405
3825
  */
3406
3826
  _alphaMask() {
3407
3827
  if (this.globalAlpha >= 1) return 0;
3408
- return this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
3828
+ return this._transientSolid(0, 0, 0, this.globalAlpha).id;
3409
3829
  }
3410
3830
 
3411
3831
  /**
@@ -3436,7 +3856,9 @@ class RenderingContext2d {
3436
3856
  // The fallback, not the common case: `_boxedComposite` above answers for a
3437
3857
  // rect-only clip stack without materializing anything, and every caller
3438
3858
  // here asks it first. A **region** clip needs nothing either — the picture
3439
- // 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`).
3440
3862
  //
3441
3863
  // `box` is the destination box of the composite the mask is for, in device
3442
3864
  // coordinates. The composite samples the mask over that box and nowhere
@@ -3478,6 +3900,33 @@ class RenderingContext2d {
3478
3900
  return this.fillMask.id;
3479
3901
  }
3480
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
+
3481
3930
  // ------------------------------------------------------------------
3482
3931
  // drawing
3483
3932
 
@@ -3590,24 +4039,28 @@ class RenderingContext2d {
3590
4039
  // coordinates all shift with the box, so a gradient or a pattern stays
3591
4040
  // where it was and only the ink outside the clip goes away.
3592
4041
  const direct =
3593
- w > 0 && h > 0 ? this._boxedComposite({ x, y, w, h }, op) : null;
4042
+ w > 0 && h > 0 ? this._boxedComposite({ x, y, w, h }) : null;
3594
4043
  if (direct && !direct.box) return; // the clip rejects the fill whole
3595
- const box = direct ? direct.box : { x, y, w, h };
3596
4044
  const [ox, oy] = sourceOrigin(style);
3597
- this.Render.Composite(
3598
- op,
3599
- style.id,
3600
- direct ? direct.mask : this._compositeMask(box),
3601
- this._dst(),
3602
- box.x - ox,
3603
- box.y - oy,
3604
- box.x,
3605
- box.y,
3606
- box.x,
3607
- box.y,
3608
- box.w,
3609
- box.h,
3610
- );
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
+ }
3611
4064
  this._markDirty();
3612
4065
  return;
3613
4066
  }
@@ -4178,7 +4631,9 @@ class RenderingContext2d {
4178
4631
  });
4179
4632
  }
4180
4633
  const encoded = encodeGlyphItems(items, page.bits);
4181
- 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]);
4182
4637
  R.CompositeGlyphs(
4183
4638
  encoded.bits,
4184
4639
  R.PictOp.Over,
@@ -4720,10 +5175,16 @@ class RenderingContext2d {
4720
5175
  * The current transform's translation applies to the anchor point;
4721
5176
  * glyphs themselves are not rotated/scaled by the transform (size the
4722
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.
4723
5183
  */
4724
5184
  fillText(text, x, y) {
4725
5185
  text = String(text ?? "");
4726
- if (!text) return;
5186
+ // nothing to draw, and at globalAlpha 0 nothing to shape it for
5187
+ if (!text || this.globalAlpha <= 0) return;
4727
5188
  if (this._shadowed()) this._shadowOfText(text, x, y);
4728
5189
  const style = this._resolvedTextStyle();
4729
5190
  const app = this.window.app;
@@ -4747,11 +5208,7 @@ class RenderingContext2d {
4747
5208
  }
4748
5209
  // already device space: the anchor went through the matrix above, and
4749
5210
  // the offsets and advances added to it are device pixels
4750
- this._drawGlyphsDevice(
4751
- this.Render.PictOp.Over,
4752
- this._backgroundPicture,
4753
- positioned,
4754
- );
5211
+ this._drawGlyphsDevice(this._op(), this._backgroundPicture, positioned);
4755
5212
  }
4756
5213
 
4757
5214
  /**
@@ -5164,19 +5621,6 @@ class RenderingContext2d {
5164
5621
  });
5165
5622
  }
5166
5623
 
5167
- /**
5168
- * Draw an image, with the three canvas signatures:
5169
- *
5170
- * ctx.drawImage(image, dx, dy)
5171
- * ctx.drawImage(image, dx, dy, dWidth, dHeight)
5172
- * ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
5173
- *
5174
- * `image` is an ntk `Image` (see `loadImage()` — PNG/JPEG decoded
5175
- * client-side, uploaded to the server once and composited from there;
5176
- * scaling is server-side with bilinear filtering), another
5177
- * RenderingContext2d, or a node-canvas-like object. Images honor the
5178
- * current transform (server-side, via the picture transform).
5179
- */
5180
5624
  /**
5181
5625
  * Clip and mask for a direct composite, as a bracket around it.
5182
5626
  *
@@ -5188,23 +5632,29 @@ class RenderingContext2d {
5188
5632
  *
5189
5633
  * Uniform alpha needs no surface-sized mask either: a 1x1 repeating
5190
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`.
5191
5638
  */
5192
- _beginDirectComposite(box) {
5639
+ _beginDirectComposite(box, op) {
5193
5640
  const rect = this._clipRect();
5194
5641
  if (!rect) {
5195
5642
  // a poly clip is the only thing left that needs a mask; without one
5196
5643
  // there is nothing to apply here but the alpha. The mask it does need
5197
5644
  // is bounded to `box`, the destination box of the composite it is for
5198
- // — the only pixels of it the server samples (issue #313).
5199
- const mask = this._hasPolyClip
5200
- ? this._compositeMask(box)
5201
- : this._alphaMask();
5202
- 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 };
5203
5653
  }
5204
5654
  if (rect.w === 0 || rect.h === 0)
5205
- return { mask: 0, clipped: false, empty: true };
5655
+ return { mask: 0, after: false, clipped: false, empty: true };
5206
5656
  this._setPictureClip(rect);
5207
- return { mask: this._alphaMask(), clipped: true, empty: false };
5657
+ return { mask: this._alphaMask(), after: false, clipped: true, empty: false };
5208
5658
  }
5209
5659
 
5210
5660
  _endDirectComposite(state) {
@@ -5216,14 +5666,19 @@ class RenderingContext2d {
5216
5666
  * into it. The mask slot is taken by the coverage, so the alpha has to go
5217
5667
  * somewhere — and a 1x1 solid is free, where a surface-sized alpha mask is
5218
5668
  * not. Falls back to the fill picture for gradients, which have no single
5219
- * colour to fold into; `_drawCoverage` routes those through the scratch. */
5669
+ * colour to fold into; `_drawCoverage` routes those through the scratch.
5670
+ *
5671
+ * All four components are scaled, not the alpha alone: the colour is
5672
+ * premultiplied, and one whose alpha shrank under unchanged components is
5673
+ * brighter than it can be — `#0000ff` at 0.5 over black came out
5674
+ * `#0000ff`, where `fillRect` draws `#000080`. */
5220
5675
  _coverageSource() {
5221
5676
  const style = this._fillStyle;
5222
5677
  if (this.globalAlpha >= 1 || !isPlainColor(style)) {
5223
5678
  return this._backgroundPicture;
5224
5679
  }
5225
- const c = parseColor(style);
5226
- return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
5680
+ const c = this._foldedColor(style);
5681
+ return this._transientSolid(c[0], c[1], c[2], c[3]);
5227
5682
  }
5228
5683
 
5229
5684
  /**
@@ -5234,6 +5689,10 @@ class RenderingContext2d {
5234
5689
  * monochrome drawing rendered once as coverage can then be painted in any
5235
5690
  * colour, so a hover, a disabled state and a theme change all reuse the one
5236
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.
5237
5696
  */
5238
5697
  _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
5239
5698
  // Coverage the surface has none of is drawn as nothing. X takes the
@@ -5242,10 +5701,9 @@ class RenderingContext2d {
5242
5701
  // threw out of the paint. A NaN destination fails the test too.
5243
5702
  if (!(dx < this.width && dy < this.height && dx + dw > 0 && dy + dh > 0)) return;
5244
5703
  const R = this.Render;
5245
- // the coverage is painted in the current fillStyle, on both branches
5246
- // below — a gradient/pattern whose transform collapsed paints nothing
5247
- const style = this._backgroundPicture;
5248
- 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;
5249
5707
  const scaled = dw !== sw || dh !== sh;
5250
5708
  if (scaled) {
5251
5709
  R.SetPictureTransform(picture.id, [
@@ -5264,15 +5722,36 @@ class RenderingContext2d {
5264
5722
  R.ChangePicture(picture.id, { repeat: 2 }); // Repeat.Pad
5265
5723
  }
5266
5724
  // with a transform in place the mask is already sampled from (sx, sy)
5267
- const mx = scaled ? 0 : sx;
5268
- 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
+ }
5269
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;
5270
5746
  const rect = this._clipRect();
5271
5747
  // the mask slot can hold exactly one picture, so anything that cannot be
5272
5748
  // folded into the colour or handed to the server as a clip rectangle has
5273
- // 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);
5274
5753
  const scratch =
5275
- this._hasPolyClip ||
5754
+ (this._hasPolyClip && !after) ||
5276
5755
  (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
5277
5756
 
5278
5757
  if (scratch) {
@@ -5300,7 +5779,7 @@ class RenderingContext2d {
5300
5779
  if (this.globalAlpha < 1) {
5301
5780
  R.Composite(
5302
5781
  R.PictOp.InReverse,
5303
- this.createSolidPicture(0, 0, 0, this.globalAlpha).id,
5782
+ this._transientSolid(0, 0, 0, this.globalAlpha).id,
5304
5783
  0,
5305
5784
  this.fillMask.id,
5306
5785
  0,
@@ -5313,7 +5792,7 @@ class RenderingContext2d {
5313
5792
  this.height,
5314
5793
  );
5315
5794
  }
5316
- if (this._hasPolyClip) {
5795
+ if (this._hasPolyClip && !after) {
5317
5796
  const clipMask = this._requireClipMask();
5318
5797
  R.Composite(
5319
5798
  R.PictOp.In,
@@ -5341,11 +5820,11 @@ class RenderingContext2d {
5341
5820
  // part of a full-surface picture: the region just cleared to zero, so
5342
5821
  // nothing paints at all unless dx and dy are both zero.
5343
5822
  const [ox, oy] = sourceOrigin(style);
5344
- R.Composite(
5823
+ this._compositeOnto(
5824
+ after,
5345
5825
  op,
5346
5826
  style.id,
5347
5827
  this.fillMask.id,
5348
- this._dst(),
5349
5828
  dx - ox,
5350
5829
  dy - oy,
5351
5830
  dx,
@@ -5363,11 +5842,11 @@ class RenderingContext2d {
5363
5842
  // exactly as in _fillPolys
5364
5843
  const src = this._coverageSource();
5365
5844
  const [ox, oy] = sourceOrigin(src);
5366
- R.Composite(
5845
+ this._compositeOnto(
5846
+ after,
5367
5847
  op,
5368
5848
  src.id,
5369
5849
  picture.id,
5370
- this._dst(),
5371
5850
  dx - ox,
5372
5851
  dy - oy,
5373
5852
  mx,
@@ -5379,229 +5858,410 @@ class RenderingContext2d {
5379
5858
  );
5380
5859
  if (rect) this._invalidatePictureClip();
5381
5860
  }
5382
-
5383
- if (scaled) {
5384
- // restore defaults so the cached surface stays reusable as-is
5385
- R.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
5386
- picture.setFilter("nearest");
5387
- R.ChangePicture(picture.id, { repeat: 0 }); // Repeat.None
5388
- }
5389
5861
  this._markDirty();
5390
5862
  }
5391
5863
 
5392
- drawImage(image, ...args) {
5393
- if (this._shadowed()) this._shadowOfImage(image, args);
5394
- if (isPictureSource(image)) {
5395
- let sx = 0;
5396
- let sy = 0;
5397
- let sw = image.width;
5398
- let sh = image.height;
5399
- let dx = 0;
5400
- let dy = 0;
5401
- let dw;
5402
- let dh;
5403
- if (args.length >= 8) {
5404
- [sx, sy, sw, sh, dx, dy, dw, dh] = args;
5405
- } else if (args.length >= 4) {
5406
- [dx, dy, dw, dh] = args;
5407
- } else {
5408
- [dx = 0, dy = 0] = args;
5409
- dw = sw;
5410
- dh = sh;
5411
- }
5412
- if (!(dw > 0) || !(dh > 0) || !(sw > 0) || !(sh > 0)) return;
5413
-
5414
- const picture = image.picture(this.window.app);
5415
- 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
+ }
5416
5893
 
5417
- if (!matIsIdentity(this._m)) {
5418
- this._drawImageTransformed(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
5419
- 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();
5420
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);
5421
5932
 
5422
- if (image.format === "a8") {
5423
- this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
5424
- return;
5425
- }
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();
5426
5937
 
5427
- const state = this._beginDirectComposite({ x: dx, y: dy, w: dw, h: dh });
5428
- if (state.empty) return;
5429
- const mask = state.mask;
5430
- const scaled = dw !== sw || dh !== sh;
5431
- if (scaled) {
5432
- // the picture transform maps composite coordinates into source
5433
- // samples: dest pixel (i, j) reads (sx + i*sw/dw, sy + j*sh/dh)
5434
- this.Render.SetPictureTransform(picture.id, [
5435
- sw / dw,
5436
- 0,
5437
- sx,
5438
- 0,
5439
- sh / dh,
5440
- sy,
5441
- 0,
5442
- 0,
5443
- 1,
5444
- ]);
5445
- picture.setFilter("bilinear");
5446
- // Bilinear sampling at the rim reads half a source pixel past the
5447
- // image, and a picture's default repeat reads transparent there: an
5448
- // upscaled image faded into whatever it was drawn over across its
5449
- // outer pixels — three of them for a 15-pixel swatch drawn at 96.
5450
- // The canvas spec's drawImage clamps to the image's edge, and so do
5451
- // browsers; RepeatPad is that. Only on this axis-aligned path, whose
5452
- // composite box is the destination rectangle exactly: under a
5453
- // rotation the box is the bounding box, and pad would paint its
5454
- // corners.
5455
- this.Render.ChangePicture(picture.id, { repeat: 2 }); // Repeat.Pad
5456
- this.Render.Composite(
5457
- op,
5458
- picture.id,
5459
- mask,
5460
- this._dst(),
5461
- 0,
5462
- 0,
5463
- dx,
5464
- dy,
5465
- dx,
5466
- dy,
5467
- dw,
5468
- dh,
5469
- );
5470
- // restore defaults so the cached upload can be reused as-is
5471
- this.Render.SetPictureTransform(
5472
- picture.id,
5473
- [1, 0, 0, 0, 1, 0, 0, 0, 1],
5474
- );
5475
- picture.setFilter("nearest");
5476
- this.Render.ChangePicture(picture.id, { repeat: 0 }); // Repeat.None
5477
- } else {
5478
- this.Render.Composite(
5479
- op,
5480
- picture.id,
5481
- mask,
5482
- this._dst(),
5483
- sx,
5484
- sy,
5485
- dx,
5486
- dy,
5487
- dx,
5488
- dy,
5489
- dw,
5490
- dh,
5491
- );
5492
- }
5493
- this._endDirectComposite(state);
5494
- this._markDirty();
5938
+ if (!matIsIdentity(this._m)) {
5939
+ this._drawImageTransformed(image, sx, sy, sw, sh, dx, dy, dw, dh, op);
5495
5940
  return;
5496
5941
  }
5497
5942
 
5498
- const sWidth = image.width;
5499
- const sHeight = image.height;
5500
- const sx = 0;
5501
- 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
+ }
5502
5948
 
5503
- // Drawables reach the branch above by way of Surface, which is what a
5504
- // Window/Pixmap/Picture source actually needs: a size and a Picture.
5505
- if (image instanceof RenderingContext2d) {
5506
- // TODO: if need to scale, set transform
5507
- // TODO: respect global compositing blend mode
5508
- const full = { x: 0, y: 0, w: this.width, h: this.height };
5509
- // source and destination are both surface-aligned here, so a clip
5510
- // rectangle is just a smaller box to copy (issue #307)
5511
- const direct = this._boxedComposite(full, this.Render.PictOp.Over);
5512
- if (direct && !direct.box) return;
5513
- const box = direct ? direct.box : full;
5514
- this.Render.Composite(
5515
- this.Render.PictOp.Over,
5516
- image.picture.id,
5517
- direct ? direct.mask : this._compositeMask(box),
5518
- this._dst(),
5519
- box.x,
5520
- box.y,
5521
- box.x,
5522
- box.y,
5523
- box.x,
5524
- box.y,
5525
- box.w,
5526
- 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,
5527
5992
  );
5528
- this._markDirty();
5529
- } else if (
5530
- image &&
5531
- image.context &&
5532
- typeof image.context.getImageData === "function"
5533
- ) {
5534
- // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
5535
- // What the clip leaves of the composite, decided before anything is
5536
- // uploaded: a clip that rejects it whole makes the upload below dead
5537
- // work (issue #307)
5538
- const full = { x: 0, y: 0, w: this.width, h: this.height };
5539
- const direct = this._boxedComposite(full, this.Render.PictOp.Over);
5540
- if (direct && !direct.box) return;
5541
- const box = direct ? direct.box : full;
5542
- const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
5543
- // node-canvas hands over straight RGBA, and the rgba32 picture below is
5544
- // premultiplied — this used to swap the channels and stop there, which
5545
- // composited translucent images at full brightness
5546
- const data = fromStraightRgba(
5547
- imageData.data,
5548
- pixelLayout(this.display, 32),
5549
- sWidth,
5550
- 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],
5551
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,
6014
+ );
6015
+ }
6016
+ this._endDirectComposite(state);
6017
+ this._markDirty();
6018
+ }
5552
6019
 
5553
- // One upload GC per app rather than one per call, the same sharing
5554
- // Image.picture does: a GC is valid for any drawable of the same screen
5555
- // and depth. This used to allocate a GC, a pixmap and a picture every
5556
- // time and free none of them, so drawing a node-canvas source in an
5557
- // animation loop leaked three server resources per frame.
5558
- const app = this.window.app;
5559
- const pixmap = new Pixmap(app, {
5560
- depth: 32,
5561
- width: sWidth,
5562
- height: sHeight,
5563
- });
5564
- const picture = new Picture(app, {
5565
- drawable: pixmap,
5566
- format: this.Render.rgba32,
5567
- });
5568
- 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.
5569
6057
  let gc = app._imageUploadGC;
5570
6058
  if (!gc) {
5571
6059
  gc = app._imageUploadGC = this.X.AllocID();
5572
6060
  this.X.CreateGC(gc, pixmap.id);
5573
6061
  }
5574
- // the crop already happened in getImageData, so it lands at the
5575
- // pixmap's origin — sx/sy here wrote it off the edge
5576
- 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
+ }
5577
6071
 
5578
- this.Render.Composite(
5579
- this.Render.PictOp.Over,
5580
- picture.id,
5581
- direct ? direct.mask : this._compositeMask(box),
5582
- this._dst(),
5583
- box.x,
5584
- box.y,
5585
- box.x,
5586
- box.y,
5587
- box.x,
5588
- box.y,
5589
- box.w,
5590
- box.h,
5591
- );
5592
- this._markDirty();
5593
- } finally {
5594
- // the composite is queued, and X executes requests in order, so the
5595
- // server is done with these by the time it reads the frees
5596
- picture.destroy();
5597
- pixmap.destroy();
5598
- }
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();
5599
6258
  }
5600
6259
  }
5601
6260
 
5602
6261
  // general-affine drawImage: fold CTM + dest rect + source offset into the
5603
- // source picture transform and composite over the transformed bbox
5604
- _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) {
5605
6265
  // maps source-local (u, v) in [0..sw)x[0..sh) to device coordinates
5606
6266
  const M = matMultiply(this._m, [dw / sw, 0, 0, dh / sh, dx, dy]);
5607
6267
  const inv = matInvert(M);
@@ -5612,23 +6272,14 @@ class RenderingContext2d {
5612
6272
  // pixel across for any image X can hold, and nothing to see.
5613
6273
  if (!fitsFixed(inv.slice(0, 4))) return;
5614
6274
 
5615
- // device bbox of the transformed dest rect, clamped to the canvas
5616
- const corners = [
5617
- matApply(this._m, dx, dy),
5618
- matApply(this._m, dx + dw, dy),
5619
- matApply(this._m, dx, dy + dh),
5620
- matApply(this._m, dx + dw, dy + dh),
5621
- ];
5622
- const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
5623
- const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
5624
- const x1 = Math.min(
5625
- this.width,
5626
- Math.ceil(Math.max(...corners.map((c) => c[0]))),
5627
- );
5628
- const y1 = Math.min(
5629
- this.height,
5630
- Math.ceil(Math.max(...corners.map((c) => c[1]))),
5631
- );
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);
5632
6283
  if (x1 <= x0 || y1 <= y0) return;
5633
6284
 
5634
6285
  // Device -> source sample, measured from the bounding box's corner: the
@@ -5649,30 +6300,42 @@ class RenderingContext2d {
5649
6300
  // rather than thrown out of the paint.
5650
6301
  if (!fitsFixed([e, f])) return;
5651
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
+
5652
6311
  // A rectangular clip shrinks the box instead of becoming a mask (issue
5653
6312
  // #307). Decided before the picture transform is installed, so a draw
5654
6313
  // the clip rejects does not have to put it back.
5655
- const bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
5656
- const direct = this._boxedComposite(bbox, op);
6314
+ const direct = this._boxedComposite(bbox);
5657
6315
  if (direct && !direct.box) return;
5658
- const box = direct ? direct.box : bbox;
5659
6316
 
5660
- 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);
5661
6319
  picture.setFilter("bilinear");
5662
- this.Render.Composite(
5663
- op,
5664
- picture.id,
5665
- direct ? direct.mask : this._compositeMask(box),
5666
- this._dst(),
5667
- box.x - x0,
5668
- box.y - y0,
5669
- box.x,
5670
- box.y,
5671
- box.x,
5672
- box.y,
5673
- box.w,
5674
- box.h,
5675
- );
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
+ }
5676
6339
  this.Render.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
5677
6340
  picture.setFilter("nearest");
5678
6341
  this._markDirty();