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.
- package/lib/app.js +4 -2
- package/lib/clipboard.js +652 -83
- package/lib/fontconfig.js +99 -12
- package/lib/image.js +13 -19
- package/lib/imagedata.js +388 -0
- package/lib/index.js +32 -2
- package/lib/renderingcontext_2d.js +416 -42
- package/lib/surface.js +118 -0
- package/lib/text/fontmanager.js +27 -4
- package/lib/text/fontsource.js +255 -3
- package/lib/widgets/svgview.js +118 -2
- package/lib/window.js +137 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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;
|