ntk 5.2.0 → 5.4.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.
@@ -1,9 +1,11 @@
1
1
  import parseFontStyle from 'canvas-fontstyle';
2
2
  import extrudePolyline from 'extrude-polyline';
3
3
 
4
+ import { safeRelease } from './cleanup.js';
4
5
  import { cssColor } from './color.js';
5
6
  import Drawable from './drawable.js';
6
7
  import { Image } from './image.js';
8
+ import { ImageData, fromStraightRgba, pixelLayout, toStraightRgba } from './imagedata.js';
7
9
  import {
8
10
  Path2D,
9
11
  flattenPath,
@@ -25,6 +27,48 @@ import { trapezoidize } from './trapezoid.js';
25
27
 
26
28
  const DEFAULT_FONT = '20px sans-serif';
27
29
 
30
+ /**
31
+ * Fallback for a context dropped without `destroy()`, matching Pixmap,
32
+ * Picture and GlyphSet. Only the GCs are freed here: everything else a
33
+ * context owns is a Pixmap or a Picture, which carry their own finalizers,
34
+ * and reaching into them from this one would race those.
35
+ */
36
+ const gcRegistry = new FinalizationRegistry(({ X, gcs }) => {
37
+ safeRelease(X, () => {
38
+ for (const gc of gcs) {
39
+ if (!gc) continue;
40
+ X.FreeGC(gc);
41
+ X.ReleaseID(gc);
42
+ }
43
+ });
44
+ });
45
+
46
+ /**
47
+ * What `drawImage` takes as a server-side source: anything that knows its own
48
+ * size and can hand over a `Picture` for this connection.
49
+ *
50
+ * `Image` is client pixels uploaded once; `Surface` is pixels the server drew
51
+ * itself. Neither is special — the contract is the two members, so a caller
52
+ * with its own cache of rendered things can satisfy it without ntk knowing
53
+ * the type. A `RenderingContext2d` deliberately does not match: its `picture`
54
+ * is a property rather than a method, and it keeps its own branch below.
55
+ */
56
+ /** a fillStyle with a single colour behind it, as opposed to a gradient or a
57
+ * caller-supplied Picture */
58
+ function isPlainColor(style) {
59
+ return typeof style === 'string' || Array.isArray(style);
60
+ }
61
+
62
+ function isPictureSource(image) {
63
+ return (
64
+ image instanceof Image ||
65
+ (image != null &&
66
+ typeof image.picture === 'function' &&
67
+ Number.isFinite(image.width) &&
68
+ Number.isFinite(image.height))
69
+ );
70
+ }
71
+
28
72
  // canvas globalCompositeOperation -> XRender PictOp name. Porter-Duff ops
29
73
  // map directly; with a clip/shape mask active the op only applies inside
30
74
  // the mask coverage (outside pixels are left untouched).
@@ -146,9 +190,15 @@ class RenderingContext2d {
146
190
  constructor(window) {
147
191
  const X = window.X;
148
192
  this.X = X;
193
+ // ids land here as they are allocated; the finalizer holds this array
194
+ // rather than the context, so registering cannot keep the context alive
195
+ this._gcs = [];
196
+ gcRegistry.register(this, { X, gcs: this._gcs }, this);
149
197
 
150
198
  this.window = window;
151
- this.Render = window.app.display.Render;
199
+ this.display = window.app.display;
200
+ this.Render = this.display.Render;
201
+ this._layoutCache = null;
152
202
 
153
203
  // draw into the window's backing pixmap when it has one (double
154
204
  // buffering); re-bind when the backing pixmap is reallocated on resize
@@ -200,17 +250,26 @@ class RenderingContext2d {
200
250
  const target = this.window._backing || this.window;
201
251
  if (this._target === target) return;
202
252
  this._target = target;
253
+ this._layoutCache = null; // the new target may be a different depth
203
254
 
204
255
  if (!this._gc) {
205
256
  // one GC is enough: it stays valid for any drawable of the same
206
257
  // screen and depth (the backing pixmap matches the window depth)
207
258
  this._gc = this.X.AllocID();
259
+ this._gcs.push(this._gc);
208
260
  this.X.CreateGC(this._gc, target.id);
209
261
  }
210
262
  if (this.picture) this.picture.destroy();
211
- // TODO: support a8
263
+ // a8 targets are coverage, not colour: drawing into one and using the
264
+ // result as a mask is how a monochrome drawing gets rendered once and
265
+ // recoloured on every use (see Surface).
212
266
  const depth = target.depth ?? this.window.depth;
213
- const format = depth === 32 ? this.Render.rgba32 : this.Render.rgb24;
267
+ const format =
268
+ depth === 32
269
+ ? this.Render.rgba32
270
+ : depth === 8
271
+ ? this.Render.a8
272
+ : this.Render.rgb24;
214
273
  this.picture = new Picture(this.window.app, {
215
274
  drawable: target,
216
275
  format,
@@ -220,6 +279,61 @@ class RenderingContext2d {
220
279
  this._dropMasks();
221
280
  }
222
281
 
282
+ /**
283
+ * Release everything this context allocated server-side. Idempotent, and
284
+ * the context must not be drawn with afterwards.
285
+ *
286
+ * A context bound to a window normally lives as long as the window and the
287
+ * connection outlives both, which is why this went missing for so long (see
288
+ * issue #156). It matters as soon as contexts are created *dynamically* —
289
+ * one per offscreen `Surface`, say — because without it each one
290
+ * permanently costs a GC, a Picture, and a 1x1 pixmap per fill colour used.
291
+ *
292
+ * `_backgroundPicture` and `_glyphSource` are deliberately not freed here:
293
+ * both are either aliases into `_solidPictures`, already freed below, or a
294
+ * `Picture`/`CanvasGradient` the caller passed in through `fillStyle`,
295
+ * which is not ours to free.
296
+ */
297
+ destroy() {
298
+ if (this._destroyed) return;
299
+ this._destroyed = true;
300
+ gcRegistry.unregister(this);
301
+
302
+ this._clips = [];
303
+ this._dropMasks();
304
+ for (const gc of [this._gc, this._fillMaskGC]) {
305
+ if (!gc) continue;
306
+ safeRelease(this.X, () => {
307
+ this.X.FreeGC(gc);
308
+ this.X.ReleaseID(gc);
309
+ });
310
+ }
311
+ this._gc = this._fillMaskGC = null;
312
+ this._gcs.length = 0;
313
+
314
+ for (const picture of this._solidPictures.values()) {
315
+ picture.destroy();
316
+ picture._sourcePixmap?.destroy();
317
+ }
318
+ this._solidPictures.clear();
319
+ this._backgroundPicture = this._glyphSource = null;
320
+
321
+ if (this.picture) {
322
+ // a picture on a *window* is freed by the server with the window;
323
+ // asking again would raise BadPicture. Same rule as `_destroyed`.
324
+ if (this._target === this.window && !(this.window instanceof Pixmap)) {
325
+ this.picture.forget();
326
+ } else {
327
+ this.picture.destroy();
328
+ }
329
+ this.picture = null;
330
+ }
331
+ }
332
+
333
+ [Symbol.dispose]() {
334
+ this.destroy();
335
+ }
336
+
223
337
  _dropMasks() {
224
338
  if (this.fillMask) {
225
339
  this.fillMask.destroy();
@@ -565,6 +679,7 @@ class RenderingContext2d {
565
679
  _maskGC() {
566
680
  if (!this._fillMaskGC) {
567
681
  this._fillMaskGC = this.X.AllocID();
682
+ this._gcs.push(this._fillMaskGC);
568
683
  this.X.CreateGC(this._fillMaskGC, this.fillMaskDrawable.id);
569
684
  }
570
685
  return this._fillMaskGC;
@@ -1320,25 +1435,137 @@ class RenderingContext2d {
1320
1435
  return new CanvasGradient('conical', this, x0, y0, angle);
1321
1436
  }
1322
1437
 
1323
- createImageData(w, h) {
1324
- return {
1325
- width: w,
1326
- height: h,
1327
- data: Buffer.alloc(w * h * 4)
1328
- };
1438
+ /** the pixel layout of whatever this context currently draws into */
1439
+ get _layout() {
1440
+ const depth = this._target.depth ?? this.window.depth ?? this.display.screen[0].root_depth;
1441
+ if (!this._layoutCache || this._layoutCache.depth !== depth) {
1442
+ this._layoutCache = pixelLayout(this.display, depth);
1443
+ }
1444
+ return this._layoutCache;
1445
+ }
1446
+
1447
+ /**
1448
+ * A blank `ImageData`, or a copy of one — both canvas forms:
1449
+ *
1450
+ * createImageData(width, height)
1451
+ * createImageData(imagedata)
1452
+ */
1453
+ createImageData(a, b) {
1454
+ if (typeof a === 'number') return new ImageData(a, b);
1455
+ if (a && typeof a.width === 'number' && a.data) return new ImageData(a.width, a.height);
1456
+ throw new TypeError('createImageData: pass (width, height) or an ImageData');
1329
1457
  }
1330
1458
 
1331
- // TODO: remove, add drawImage
1332
- putImageData(data, x, y) {
1333
- // todo: use constants
1334
- this.X.PutImage(2, this._target.id, this._gc, data.width, data.height, x, y, 0, 24, data.data);
1459
+ /**
1460
+ * Write straight RGBA pixels into the drawable at `(x, y)`.
1461
+ *
1462
+ * `data` is an `ImageData` or anything shaped like one; its bytes are
1463
+ * non-premultiplied RGBA, and they are converted to the drawable's own
1464
+ * pixel layout on the way out. The optional `dirty*` rectangle limits the
1465
+ * write to part of the source, as in the canvas spec.
1466
+ */
1467
+ putImageData(data, x, y, dirtyX = 0, dirtyY = 0, dirtyWidth, dirtyHeight) {
1468
+ const { width, height } = data;
1469
+ const src = data.data;
1470
+ if (!src || src.length !== width * height * 4) {
1471
+ throw new Error(
1472
+ `putImageData: data must be ${width * height * 4} RGBA bytes for ${width}x${height}`
1473
+ );
1474
+ }
1475
+ dirtyWidth ??= width;
1476
+ dirtyHeight ??= height;
1477
+ // the spec normalises negative extents by moving the origin
1478
+ if (dirtyWidth < 0) { dirtyX += dirtyWidth; dirtyWidth = -dirtyWidth; }
1479
+ if (dirtyHeight < 0) { dirtyY += dirtyHeight; dirtyHeight = -dirtyHeight; }
1480
+ const sx = Math.max(0, dirtyX);
1481
+ const sy = Math.max(0, dirtyY);
1482
+ const sw = Math.min(width, dirtyX + dirtyWidth) - sx;
1483
+ const sh = Math.min(height, dirtyY + dirtyHeight) - sy;
1484
+ if (!(sw > 0) || !(sh > 0)) return;
1485
+
1486
+ let rgba = src;
1487
+ if (sx !== 0 || sy !== 0 || sw !== width || sh !== height) {
1488
+ const cropped = new Uint8ClampedArray(sw * sh * 4);
1489
+ for (let row = 0; row < sh; row++) {
1490
+ cropped.set(
1491
+ src.subarray((sy + row) * width * 4 + sx * 4, (sy + row) * width * 4 + (sx + sw) * 4),
1492
+ row * sw * 4
1493
+ );
1494
+ }
1495
+ rgba = cropped;
1496
+ }
1497
+
1498
+ const layout = this._layout;
1499
+ const bytes = fromStraightRgba(rgba, layout, sw, sh);
1500
+ // stay under the server's maximum request size by uploading row bands
1501
+ const stride = sw * 4;
1502
+ const maxBytes = ((this.display.max_request_length ?? 65535) - 8) * 4;
1503
+ const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
1504
+ for (let row = 0; row < sh; row += rowsPerBand) {
1505
+ const rows = Math.min(rowsPerBand, sh - row);
1506
+ this.X.PutImage(
1507
+ 2, // ZPixmap
1508
+ this._target.id,
1509
+ this._gc,
1510
+ sw, rows,
1511
+ x + sx, y + sy + row,
1512
+ 0,
1513
+ layout.depth,
1514
+ bytes.subarray(row * stride, (row + rows) * stride)
1515
+ );
1516
+ }
1335
1517
  this._markDirty();
1336
1518
  }
1337
1519
 
1520
+ /**
1521
+ * Read pixels back as canvas `ImageData` — straight (non-premultiplied)
1522
+ * RGBA in a `Uint8ClampedArray`, exactly like the browser's.
1523
+ *
1524
+ * Reads the backing pixmap on double-buffered windows, so it is valid even
1525
+ * where the window is occluded. Returns a promise; a trailing
1526
+ * `cb(err, imageData)` is still accepted.
1527
+ *
1528
+ * The drawable's own bytes are none of those things — see
1529
+ * `lib/imagedata.js` — so this costs a pass over the pixels. `readPixels()`
1530
+ * is the way to skip that when you want the server's layout.
1531
+ */
1338
1532
  getImageData(x, y, w, h, cb) {
1339
- // reads the backing pixmap on double-buffered windows: always valid,
1340
- // even where the window is occluded on screen
1341
- this.X.GetImage(2, this._target.id, x, y, w, h, 0xffffffff, cb);
1533
+ const promise = this.readPixels(x, y, w, h).then(
1534
+ (raw) => new ImageData(toStraightRgba(raw.data, raw.layout, w, h), w, h)
1535
+ );
1536
+ if (typeof cb === 'function') {
1537
+ promise.then((data) => cb(null, data), cb);
1538
+ return undefined;
1539
+ }
1540
+ return promise;
1541
+ }
1542
+
1543
+ /**
1544
+ * Read pixels in the server's own layout, with no conversion.
1545
+ *
1546
+ * The escape hatch under `getImageData` for code that wants to hand the
1547
+ * bytes straight back to `PutImage`, feed a codec, or do its own unpacking.
1548
+ * Unlike a bare `GetImage` it says what the bytes mean:
1549
+ *
1550
+ * { width, height, data, depth, bitsPerPixel, byteOrder, masks,
1551
+ * premultiplied }
1552
+ *
1553
+ * `byteOrder` is `'lsb'` or `'msb'` and is the *server's* pixel order,
1554
+ * which is a different handshake field from the one this connection
1555
+ * speaks. `masks` gives the bit position of each channel inside a pixel
1556
+ * word, `alpha` being 0 when the drawable has no alpha channel — in which
1557
+ * case the spare byte is undefined padding, not opacity.
1558
+ *
1559
+ * @returns {Promise<object>}
1560
+ */
1561
+ readPixels(x, y, w, h) {
1562
+ const layout = this._layout;
1563
+ return new Promise((resolve, reject) => {
1564
+ this.X.GetImage(2, this._target.id, x, y, w, h, 0xffffffff, (err, img) => {
1565
+ if (err) return reject(err);
1566
+ resolve({ width: w, height: h, data: img.data, layout, ...layout });
1567
+ });
1568
+ });
1342
1569
  }
1343
1570
 
1344
1571
  /**
@@ -1354,8 +1581,127 @@ class RenderingContext2d {
1354
1581
  * RenderingContext2d, or a node-canvas-like object. Images honor the
1355
1582
  * current transform (server-side, via the picture transform).
1356
1583
  */
1584
+ /**
1585
+ * Clip and mask for a direct composite, as a bracket around it.
1586
+ *
1587
+ * A rectangular clip is something the server can do itself: two small
1588
+ * requests around the composite instead of intersecting a full-surface a8
1589
+ * mask, which costs the same on the wire and many times the pixel work.
1590
+ * This is the fast path `drawGlyphs` already has — and a renderer that
1591
+ * clips to a damage rect makes it the common case, not a rare one.
1592
+ *
1593
+ * Uniform alpha needs no surface-sized mask either: a 1x1 repeating
1594
+ * picture is the same thing to the server, with no pixel work at all.
1595
+ */
1596
+ _beginDirectComposite() {
1597
+ const rect = this.clipMask ? this._clipRect() : null;
1598
+ if (!rect) return { mask: this._compositeMask(), clipped: false, empty: false };
1599
+ if (rect.w === 0 || rect.h === 0) return { mask: 0, clipped: false, empty: true };
1600
+ this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
1601
+ const mask =
1602
+ this.globalAlpha >= 1 ? 0 : this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
1603
+ return { mask, clipped: true, empty: false };
1604
+ }
1605
+
1606
+ _endDirectComposite(state) {
1607
+ if (!state.clipped) return;
1608
+ this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
1609
+ }
1610
+
1611
+ /** The source colour for a coverage composite, with `globalAlpha` folded
1612
+ * into it. The mask slot is taken by the coverage, so the alpha has to go
1613
+ * somewhere — and a 1x1 solid is free, where a surface-sized alpha mask is
1614
+ * not. Falls back to the fill picture for gradients, which have no single
1615
+ * colour to fold into; `_drawCoverage` routes those through the scratch. */
1616
+ _coverageSource() {
1617
+ const style = this._fillStyle;
1618
+ if (this.globalAlpha >= 1 || !isPlainColor(style)) return this._backgroundPicture;
1619
+ const c = parseColor(style);
1620
+ return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
1621
+ }
1622
+
1623
+ /**
1624
+ * Composite a coverage source: the a8 picture is the *mask* and the current
1625
+ * `fillStyle` is what gets painted through it.
1626
+ *
1627
+ * This is the same shape as `drawGlyphs`, and for the same reason — a
1628
+ * monochrome drawing rendered once as coverage can then be painted in any
1629
+ * colour, so a hover, a disabled state and a theme change all reuse the one
1630
+ * rendered copy instead of each needing their own.
1631
+ */
1632
+ _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
1633
+ const R = this.Render;
1634
+ const scaled = dw !== sw || dh !== sh;
1635
+ if (scaled) {
1636
+ R.SetPictureTransform(picture.id, [sw / dw, 0, sx, 0, sh / dh, sy, 0, 0, 1]);
1637
+ picture.setFilter('bilinear');
1638
+ }
1639
+ // with a transform in place the mask is already sampled from (sx, sy)
1640
+ const mx = scaled ? 0 : sx;
1641
+ const my = scaled ? 0 : sy;
1642
+
1643
+ const rect = this.clipMask ? this._clipRect() : null;
1644
+ // the mask slot can hold exactly one picture, so anything that cannot be
1645
+ // folded into the colour or handed to the server as a clip rectangle has
1646
+ // to be intersected into the scratch mask first
1647
+ const scratch =
1648
+ (this.clipMask && !rect) || (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
1649
+
1650
+ if (scratch) {
1651
+ this._ensureFillMask();
1652
+ R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
1653
+ R.Composite(R.PictOp.Src, picture.id, 0, this.fillMask.id, mx, my, 0, 0, dx, dy, dw, dh);
1654
+ if (this.globalAlpha < 1) {
1655
+ R.Composite(
1656
+ R.PictOp.InReverse,
1657
+ this.createSolidPicture(0, 0, 0, this.globalAlpha).id,
1658
+ 0,
1659
+ this.fillMask.id,
1660
+ 0, 0, 0, 0, 0, 0,
1661
+ this.width, this.height
1662
+ );
1663
+ }
1664
+ if (this.clipMask) {
1665
+ R.Composite(R.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
1666
+ }
1667
+ R.Composite(
1668
+ op,
1669
+ this._backgroundPicture.id,
1670
+ this.fillMask.id,
1671
+ this.picture.id,
1672
+ 0, 0, 0, 0,
1673
+ dx, dy,
1674
+ dw, dh
1675
+ );
1676
+ } else if (!rect || (rect.w > 0 && rect.h > 0)) {
1677
+ if (rect) {
1678
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
1679
+ }
1680
+ R.Composite(
1681
+ op,
1682
+ this._coverageSource().id,
1683
+ picture.id,
1684
+ this.picture.id,
1685
+ 0, 0,
1686
+ mx, my,
1687
+ dx, dy,
1688
+ dw, dh
1689
+ );
1690
+ if (rect) {
1691
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
1692
+ }
1693
+ }
1694
+
1695
+ if (scaled) {
1696
+ // restore defaults so the cached surface stays reusable as-is
1697
+ R.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
1698
+ picture.setFilter('nearest');
1699
+ }
1700
+ this._markDirty();
1701
+ }
1702
+
1357
1703
  drawImage(image, ...args) {
1358
- if (image instanceof Image) {
1704
+ if (isPictureSource(image)) {
1359
1705
  let sx = 0;
1360
1706
  let sy = 0;
1361
1707
  let sw = image.width;
@@ -1383,7 +1729,14 @@ class RenderingContext2d {
1383
1729
  return;
1384
1730
  }
1385
1731
 
1386
- const mask = this._compositeMask();
1732
+ if (image.format === 'a8') {
1733
+ this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
1734
+ return;
1735
+ }
1736
+
1737
+ const state = this._beginDirectComposite();
1738
+ if (state.empty) return;
1739
+ const mask = state.mask;
1387
1740
  const scaled = dw !== sw || dh !== sh;
1388
1741
  if (scaled) {
1389
1742
  // the picture transform maps composite coordinates into source
@@ -1415,6 +1768,7 @@ class RenderingContext2d {
1415
1768
  dw, dh
1416
1769
  );
1417
1770
  }
1771
+ this._endDirectComposite(state);
1418
1772
  this._markDirty();
1419
1773
  return;
1420
1774
  }
@@ -1424,7 +1778,8 @@ class RenderingContext2d {
1424
1778
  const sx = 0;
1425
1779
  const sy = 0;
1426
1780
 
1427
- // TODO: allow to draw Window, Pixmap, Picture
1781
+ // Drawables reach the branch above by way of Surface, which is what a
1782
+ // Window/Pixmap/Picture source actually needs: a size and a Picture.
1428
1783
  if (image instanceof RenderingContext2d) {
1429
1784
  // TODO: if need to scale, set transform
1430
1785
  // TODO: respect global compositing blend mode
@@ -1441,31 +1796,50 @@ class RenderingContext2d {
1441
1796
  } else if (image && image.context && typeof image.context.getImageData === 'function') {
1442
1797
  // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
1443
1798
  const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
1444
- // rgba -> bgra
1445
- const data = Buffer.alloc(imageData.data.length);
1446
- for (let i = 0; i < data.length; i += 4) {
1447
- data[i + 2] = imageData.data[i];
1448
- data[i + 1] = imageData.data[i + 1];
1449
- data[i] = imageData.data[i + 2];
1450
- data[i + 3] = imageData.data[i + 3]; // multiply source alpha by context globalAlpha?
1451
- }
1799
+ // node-canvas hands over straight RGBA, and the rgba32 picture below is
1800
+ // premultiplied — this used to swap the channels and stop there, which
1801
+ // composited translucent images at full brightness
1802
+ const data = fromStraightRgba(
1803
+ imageData.data,
1804
+ pixelLayout(this.display, 32),
1805
+ sWidth,
1806
+ sHeight
1807
+ );
1452
1808
 
1453
- const pixmap = new Pixmap(this.window.app, { depth: 32, width: sWidth, height: sHeight });
1454
- const picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32 });
1455
- pixmap._gc = this.X.AllocID();
1456
- this.X.CreateGC(pixmap._gc, pixmap.id);
1457
- this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, sx, sy, 0, 32, data);
1809
+ // One upload GC per app rather than one per call, the same sharing
1810
+ // Image.picture does: a GC is valid for any drawable of the same screen
1811
+ // and depth. This used to allocate a GC, a pixmap and a picture every
1812
+ // time and free none of them, so drawing a node-canvas source in an
1813
+ // animation loop leaked three server resources per frame.
1814
+ const app = this.window.app;
1815
+ const pixmap = new Pixmap(app, { depth: 32, width: sWidth, height: sHeight });
1816
+ const picture = new Picture(app, { drawable: pixmap, format: this.Render.rgba32 });
1817
+ try {
1818
+ let gc = app._imageUploadGC;
1819
+ if (!gc) {
1820
+ gc = app._imageUploadGC = this.X.AllocID();
1821
+ this.X.CreateGC(gc, pixmap.id);
1822
+ }
1823
+ // the crop already happened in getImageData, so it lands at the
1824
+ // pixmap's origin — sx/sy here wrote it off the edge
1825
+ this.X.PutImage(2, pixmap.id, gc, sWidth, sHeight, 0, 0, 0, 32, data);
1458
1826
 
1459
- this.Render.Composite(
1460
- this.Render.PictOp.Over,
1461
- picture.id,
1462
- this._compositeMask(),
1463
- this.picture.id,
1464
- 0, 0, 0, 0, 0, 0,
1465
- this.width,
1466
- this.height
1467
- );
1468
- this._markDirty();
1827
+ this.Render.Composite(
1828
+ this.Render.PictOp.Over,
1829
+ picture.id,
1830
+ this._compositeMask(),
1831
+ this.picture.id,
1832
+ 0, 0, 0, 0, 0, 0,
1833
+ this.width,
1834
+ this.height
1835
+ );
1836
+ this._markDirty();
1837
+ } finally {
1838
+ // the composite is queued, and X executes requests in order, so the
1839
+ // server is done with these by the time it reads the frees
1840
+ picture.destroy();
1841
+ pixmap.destroy();
1842
+ }
1469
1843
  }
1470
1844
  }
1471
1845
 
package/lib/surface.js ADDED
@@ -0,0 +1,118 @@
1
+ import Picture from './picture.js';
2
+ import Pixmap from './pixmap.js';
3
+
4
+ /**
5
+ * An offscreen drawing surface: a pixmap, its Picture, and enough of the
6
+ * `Image` shape that `ctx.drawImage` takes it as a source.
7
+ *
8
+ * const surface = new Surface(app, { width: 20, height: 20 });
9
+ * surface.render((ctx) => icon.draw(ctx, 0, 0, 20, 20));
10
+ * ctx.drawImage(surface, x, y); // one server-side composite
11
+ *
12
+ * The point is drawing something once and compositing it many times. An
13
+ * `Image` already does that for decoded PNG/JPEG pixels uploaded from the
14
+ * client; a Surface is the same contract for pixels drawn *by* the server,
15
+ * so nothing crosses the wire but the composite.
16
+ *
17
+ * ### Coverage surfaces
18
+ *
19
+ * `format: 'a8'` makes a surface that stores coverage instead of colour.
20
+ * Drawn through `drawImage` it acts as a mask for the current `fillStyle`,
21
+ * so one rendered copy of a monochrome drawing serves every colour it is
22
+ * ever asked for — the trick the glyph cache already runs on text, applied
23
+ * to arbitrary drawings. See `SvgView.paintKind` for deciding when a drawing
24
+ * qualifies.
25
+ *
26
+ * ### Lifetime
27
+ *
28
+ * `destroy()` frees the pixmap and the picture; both of those carry their own
29
+ * finalizers, so a dropped surface still releases. `render()` builds a
30
+ * context, hands it over, and
31
+ * destroys it again, which keeps the steady-state cost of a surface to two
32
+ * server objects — a context is much heavier than it looks, and holding one
33
+ * per surface is what issue #156 is about.
34
+ */
35
+ export class Surface {
36
+ constructor(app, { width, height, format = 'argb32' } = {}) {
37
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
38
+ throw new Error('Surface: width and height must be positive integers');
39
+ }
40
+ if (format !== 'argb32' && format !== 'a8') {
41
+ throw new Error(`Surface: unknown format ${JSON.stringify(format)} (argb32 or a8)`);
42
+ }
43
+ this.app = app;
44
+ this.width = width;
45
+ this.height = height;
46
+ this.format = format;
47
+ this.depth = format === 'a8' ? 8 : 32;
48
+
49
+ this.pixmap = new Pixmap(app, { depth: this.depth, width, height });
50
+ this._picture = new Picture(app, {
51
+ drawable: this.pixmap,
52
+ format: format === 'a8' ? app.display.Render.a8 : app.display.Render.rgba32
53
+ });
54
+ // a fresh pixmap's contents are undefined per the protocol, so a surface
55
+ // that is only partly drawn would otherwise composite server garbage
56
+ this.clear();
57
+ }
58
+
59
+ /** bytes of server-side storage — what a cache budgets against */
60
+ get bytes() {
61
+ return this.width * this.height * (this.depth === 8 ? 1 : 4);
62
+ }
63
+
64
+ /** The server-side Picture, on the app this surface belongs to. Mirrors
65
+ * `Image.picture(app)` so `drawImage` can take either without asking which
66
+ * it has. */
67
+ picture(app) {
68
+ if (app && app !== this.app) {
69
+ throw new Error('Surface: belongs to a different X connection');
70
+ }
71
+ return this._picture;
72
+ }
73
+
74
+ /** reset every pixel to fully transparent */
75
+ clear() {
76
+ const R = this.app.display.Render;
77
+ R.FillRectangles(R.PictOp.Src, this._picture.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
78
+ return this;
79
+ }
80
+
81
+ /**
82
+ * Draw into the surface through a 2d context, in surface-local coordinates
83
+ * where (0, 0) is its top-left corner.
84
+ *
85
+ * The context is created for the call and destroyed after it, so a surface
86
+ * kept in a cache holds no context of its own. Callers doing many draws
87
+ * into the same surface should `getContext('2d')` once and destroy it
88
+ * themselves instead.
89
+ */
90
+ render(fn) {
91
+ const ctx = this.getContext('2d');
92
+ try {
93
+ fn(ctx);
94
+ } finally {
95
+ ctx.destroy();
96
+ }
97
+ return this;
98
+ }
99
+
100
+ /** a rendering context on the backing pixmap — the caller owns it, and
101
+ * owes it a `destroy()` */
102
+ getContext(name, ...args) {
103
+ return this.pixmap.getContext(name, ...args);
104
+ }
105
+
106
+ destroy() {
107
+ if (this._destroyed) return;
108
+ this._destroyed = true;
109
+ this._picture.destroy();
110
+ this.pixmap.destroy();
111
+ }
112
+
113
+ [Symbol.dispose]() {
114
+ this.destroy();
115
+ }
116
+ }
117
+
118
+ export default Surface;