ntk 5.2.0 → 5.3.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/clipboard.js +576 -74
- package/lib/image.js +10 -17
- package/lib/imagedata.js +388 -0
- package/lib/index.js +16 -0
- package/lib/renderingcontext_2d.js +362 -28
- package/lib/surface.js +118 -0
- package/lib/widgets/svgview.js +118 -2
- package/lib/window.js +136 -0
- 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,32 @@ import { trapezoidize } from './trapezoid.js';
|
|
|
25
27
|
|
|
26
28
|
const DEFAULT_FONT = '20px sans-serif';
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* What `drawImage` takes as a server-side source: anything that knows its own
|
|
32
|
+
* size and can hand over a `Picture` for this connection.
|
|
33
|
+
*
|
|
34
|
+
* `Image` is client pixels uploaded once; `Surface` is pixels the server drew
|
|
35
|
+
* itself. Neither is special — the contract is the two members, so a caller
|
|
36
|
+
* with its own cache of rendered things can satisfy it without ntk knowing
|
|
37
|
+
* the type. A `RenderingContext2d` deliberately does not match: its `picture`
|
|
38
|
+
* is a property rather than a method, and it keeps its own branch below.
|
|
39
|
+
*/
|
|
40
|
+
/** a fillStyle with a single colour behind it, as opposed to a gradient or a
|
|
41
|
+
* caller-supplied Picture */
|
|
42
|
+
function isPlainColor(style) {
|
|
43
|
+
return typeof style === 'string' || Array.isArray(style);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPictureSource(image) {
|
|
47
|
+
return (
|
|
48
|
+
image instanceof Image ||
|
|
49
|
+
(image != null &&
|
|
50
|
+
typeof image.picture === 'function' &&
|
|
51
|
+
Number.isFinite(image.width) &&
|
|
52
|
+
Number.isFinite(image.height))
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
// canvas globalCompositeOperation -> XRender PictOp name. Porter-Duff ops
|
|
29
57
|
// map directly; with a clip/shape mask active the op only applies inside
|
|
30
58
|
// the mask coverage (outside pixels are left untouched).
|
|
@@ -148,7 +176,9 @@ class RenderingContext2d {
|
|
|
148
176
|
this.X = X;
|
|
149
177
|
|
|
150
178
|
this.window = window;
|
|
151
|
-
this.
|
|
179
|
+
this.display = window.app.display;
|
|
180
|
+
this.Render = this.display.Render;
|
|
181
|
+
this._layoutCache = null;
|
|
152
182
|
|
|
153
183
|
// draw into the window's backing pixmap when it has one (double
|
|
154
184
|
// buffering); re-bind when the backing pixmap is reallocated on resize
|
|
@@ -200,6 +230,7 @@ class RenderingContext2d {
|
|
|
200
230
|
const target = this.window._backing || this.window;
|
|
201
231
|
if (this._target === target) return;
|
|
202
232
|
this._target = target;
|
|
233
|
+
this._layoutCache = null; // the new target may be a different depth
|
|
203
234
|
|
|
204
235
|
if (!this._gc) {
|
|
205
236
|
// one GC is enough: it stays valid for any drawable of the same
|
|
@@ -208,9 +239,16 @@ class RenderingContext2d {
|
|
|
208
239
|
this.X.CreateGC(this._gc, target.id);
|
|
209
240
|
}
|
|
210
241
|
if (this.picture) this.picture.destroy();
|
|
211
|
-
//
|
|
242
|
+
// a8 targets are coverage, not colour: drawing into one and using the
|
|
243
|
+
// result as a mask is how a monochrome drawing gets rendered once and
|
|
244
|
+
// recoloured on every use (see Surface).
|
|
212
245
|
const depth = target.depth ?? this.window.depth;
|
|
213
|
-
const format =
|
|
246
|
+
const format =
|
|
247
|
+
depth === 32
|
|
248
|
+
? this.Render.rgba32
|
|
249
|
+
: depth === 8
|
|
250
|
+
? this.Render.a8
|
|
251
|
+
: this.Render.rgb24;
|
|
214
252
|
this.picture = new Picture(this.window.app, {
|
|
215
253
|
drawable: target,
|
|
216
254
|
format,
|
|
@@ -220,6 +258,59 @@ class RenderingContext2d {
|
|
|
220
258
|
this._dropMasks();
|
|
221
259
|
}
|
|
222
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Release everything this context allocated server-side. Idempotent, and
|
|
263
|
+
* the context must not be drawn with afterwards.
|
|
264
|
+
*
|
|
265
|
+
* A context bound to a window normally lives as long as the window and the
|
|
266
|
+
* connection outlives both, which is why this went missing for so long (see
|
|
267
|
+
* issue #156). It matters as soon as contexts are created *dynamically* —
|
|
268
|
+
* one per offscreen `Surface`, say — because without it each one
|
|
269
|
+
* permanently costs a GC, a Picture, and a 1x1 pixmap per fill colour used.
|
|
270
|
+
*
|
|
271
|
+
* `_backgroundPicture` and `_glyphSource` are deliberately not freed here:
|
|
272
|
+
* both are either aliases into `_solidPictures`, already freed below, or a
|
|
273
|
+
* `Picture`/`CanvasGradient` the caller passed in through `fillStyle`,
|
|
274
|
+
* which is not ours to free.
|
|
275
|
+
*/
|
|
276
|
+
destroy() {
|
|
277
|
+
if (this._destroyed) return;
|
|
278
|
+
this._destroyed = true;
|
|
279
|
+
|
|
280
|
+
this._clips = [];
|
|
281
|
+
this._dropMasks();
|
|
282
|
+
for (const gc of [this._gc, this._fillMaskGC]) {
|
|
283
|
+
if (!gc) continue;
|
|
284
|
+
safeRelease(this.X, () => {
|
|
285
|
+
this.X.FreeGC(gc);
|
|
286
|
+
this.X.ReleaseID(gc);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
this._gc = this._fillMaskGC = null;
|
|
290
|
+
|
|
291
|
+
for (const picture of this._solidPictures.values()) {
|
|
292
|
+
picture.destroy();
|
|
293
|
+
picture._sourcePixmap?.destroy();
|
|
294
|
+
}
|
|
295
|
+
this._solidPictures.clear();
|
|
296
|
+
this._backgroundPicture = this._glyphSource = null;
|
|
297
|
+
|
|
298
|
+
if (this.picture) {
|
|
299
|
+
// a picture on a *window* is freed by the server with the window;
|
|
300
|
+
// asking again would raise BadPicture. Same rule as `_destroyed`.
|
|
301
|
+
if (this._target === this.window && !(this.window instanceof Pixmap)) {
|
|
302
|
+
this.picture.forget();
|
|
303
|
+
} else {
|
|
304
|
+
this.picture.destroy();
|
|
305
|
+
}
|
|
306
|
+
this.picture = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
[Symbol.dispose]() {
|
|
311
|
+
this.destroy();
|
|
312
|
+
}
|
|
313
|
+
|
|
223
314
|
_dropMasks() {
|
|
224
315
|
if (this.fillMask) {
|
|
225
316
|
this.fillMask.destroy();
|
|
@@ -1320,25 +1411,137 @@ class RenderingContext2d {
|
|
|
1320
1411
|
return new CanvasGradient('conical', this, x0, y0, angle);
|
|
1321
1412
|
}
|
|
1322
1413
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
}
|
|
1414
|
+
/** the pixel layout of whatever this context currently draws into */
|
|
1415
|
+
get _layout() {
|
|
1416
|
+
const depth = this._target.depth ?? this.window.depth ?? this.display.screen[0].root_depth;
|
|
1417
|
+
if (!this._layoutCache || this._layoutCache.depth !== depth) {
|
|
1418
|
+
this._layoutCache = pixelLayout(this.display, depth);
|
|
1419
|
+
}
|
|
1420
|
+
return this._layoutCache;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* A blank `ImageData`, or a copy of one — both canvas forms:
|
|
1425
|
+
*
|
|
1426
|
+
* createImageData(width, height)
|
|
1427
|
+
* createImageData(imagedata)
|
|
1428
|
+
*/
|
|
1429
|
+
createImageData(a, b) {
|
|
1430
|
+
if (typeof a === 'number') return new ImageData(a, b);
|
|
1431
|
+
if (a && typeof a.width === 'number' && a.data) return new ImageData(a.width, a.height);
|
|
1432
|
+
throw new TypeError('createImageData: pass (width, height) or an ImageData');
|
|
1329
1433
|
}
|
|
1330
1434
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1435
|
+
/**
|
|
1436
|
+
* Write straight RGBA pixels into the drawable at `(x, y)`.
|
|
1437
|
+
*
|
|
1438
|
+
* `data` is an `ImageData` or anything shaped like one; its bytes are
|
|
1439
|
+
* non-premultiplied RGBA, and they are converted to the drawable's own
|
|
1440
|
+
* pixel layout on the way out. The optional `dirty*` rectangle limits the
|
|
1441
|
+
* write to part of the source, as in the canvas spec.
|
|
1442
|
+
*/
|
|
1443
|
+
putImageData(data, x, y, dirtyX = 0, dirtyY = 0, dirtyWidth, dirtyHeight) {
|
|
1444
|
+
const { width, height } = data;
|
|
1445
|
+
const src = data.data;
|
|
1446
|
+
if (!src || src.length !== width * height * 4) {
|
|
1447
|
+
throw new Error(
|
|
1448
|
+
`putImageData: data must be ${width * height * 4} RGBA bytes for ${width}x${height}`
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
dirtyWidth ??= width;
|
|
1452
|
+
dirtyHeight ??= height;
|
|
1453
|
+
// the spec normalises negative extents by moving the origin
|
|
1454
|
+
if (dirtyWidth < 0) { dirtyX += dirtyWidth; dirtyWidth = -dirtyWidth; }
|
|
1455
|
+
if (dirtyHeight < 0) { dirtyY += dirtyHeight; dirtyHeight = -dirtyHeight; }
|
|
1456
|
+
const sx = Math.max(0, dirtyX);
|
|
1457
|
+
const sy = Math.max(0, dirtyY);
|
|
1458
|
+
const sw = Math.min(width, dirtyX + dirtyWidth) - sx;
|
|
1459
|
+
const sh = Math.min(height, dirtyY + dirtyHeight) - sy;
|
|
1460
|
+
if (!(sw > 0) || !(sh > 0)) return;
|
|
1461
|
+
|
|
1462
|
+
let rgba = src;
|
|
1463
|
+
if (sx !== 0 || sy !== 0 || sw !== width || sh !== height) {
|
|
1464
|
+
const cropped = new Uint8ClampedArray(sw * sh * 4);
|
|
1465
|
+
for (let row = 0; row < sh; row++) {
|
|
1466
|
+
cropped.set(
|
|
1467
|
+
src.subarray((sy + row) * width * 4 + sx * 4, (sy + row) * width * 4 + (sx + sw) * 4),
|
|
1468
|
+
row * sw * 4
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
rgba = cropped;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const layout = this._layout;
|
|
1475
|
+
const bytes = fromStraightRgba(rgba, layout, sw, sh);
|
|
1476
|
+
// stay under the server's maximum request size by uploading row bands
|
|
1477
|
+
const stride = sw * 4;
|
|
1478
|
+
const maxBytes = ((this.display.max_request_length ?? 65535) - 8) * 4;
|
|
1479
|
+
const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
|
|
1480
|
+
for (let row = 0; row < sh; row += rowsPerBand) {
|
|
1481
|
+
const rows = Math.min(rowsPerBand, sh - row);
|
|
1482
|
+
this.X.PutImage(
|
|
1483
|
+
2, // ZPixmap
|
|
1484
|
+
this._target.id,
|
|
1485
|
+
this._gc,
|
|
1486
|
+
sw, rows,
|
|
1487
|
+
x + sx, y + sy + row,
|
|
1488
|
+
0,
|
|
1489
|
+
layout.depth,
|
|
1490
|
+
bytes.subarray(row * stride, (row + rows) * stride)
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1335
1493
|
this._markDirty();
|
|
1336
1494
|
}
|
|
1337
1495
|
|
|
1496
|
+
/**
|
|
1497
|
+
* Read pixels back as canvas `ImageData` — straight (non-premultiplied)
|
|
1498
|
+
* RGBA in a `Uint8ClampedArray`, exactly like the browser's.
|
|
1499
|
+
*
|
|
1500
|
+
* Reads the backing pixmap on double-buffered windows, so it is valid even
|
|
1501
|
+
* where the window is occluded. Returns a promise; a trailing
|
|
1502
|
+
* `cb(err, imageData)` is still accepted.
|
|
1503
|
+
*
|
|
1504
|
+
* The drawable's own bytes are none of those things — see
|
|
1505
|
+
* `lib/imagedata.js` — so this costs a pass over the pixels. `readPixels()`
|
|
1506
|
+
* is the way to skip that when you want the server's layout.
|
|
1507
|
+
*/
|
|
1338
1508
|
getImageData(x, y, w, h, cb) {
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1509
|
+
const promise = this.readPixels(x, y, w, h).then(
|
|
1510
|
+
(raw) => new ImageData(toStraightRgba(raw.data, raw.layout, w, h), w, h)
|
|
1511
|
+
);
|
|
1512
|
+
if (typeof cb === 'function') {
|
|
1513
|
+
promise.then((data) => cb(null, data), cb);
|
|
1514
|
+
return undefined;
|
|
1515
|
+
}
|
|
1516
|
+
return promise;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Read pixels in the server's own layout, with no conversion.
|
|
1521
|
+
*
|
|
1522
|
+
* The escape hatch under `getImageData` for code that wants to hand the
|
|
1523
|
+
* bytes straight back to `PutImage`, feed a codec, or do its own unpacking.
|
|
1524
|
+
* Unlike a bare `GetImage` it says what the bytes mean:
|
|
1525
|
+
*
|
|
1526
|
+
* { width, height, data, depth, bitsPerPixel, byteOrder, masks,
|
|
1527
|
+
* premultiplied }
|
|
1528
|
+
*
|
|
1529
|
+
* `byteOrder` is `'lsb'` or `'msb'` and is the *server's* pixel order,
|
|
1530
|
+
* which is a different handshake field from the one this connection
|
|
1531
|
+
* speaks. `masks` gives the bit position of each channel inside a pixel
|
|
1532
|
+
* word, `alpha` being 0 when the drawable has no alpha channel — in which
|
|
1533
|
+
* case the spare byte is undefined padding, not opacity.
|
|
1534
|
+
*
|
|
1535
|
+
* @returns {Promise<object>}
|
|
1536
|
+
*/
|
|
1537
|
+
readPixels(x, y, w, h) {
|
|
1538
|
+
const layout = this._layout;
|
|
1539
|
+
return new Promise((resolve, reject) => {
|
|
1540
|
+
this.X.GetImage(2, this._target.id, x, y, w, h, 0xffffffff, (err, img) => {
|
|
1541
|
+
if (err) return reject(err);
|
|
1542
|
+
resolve({ width: w, height: h, data: img.data, layout, ...layout });
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1342
1545
|
}
|
|
1343
1546
|
|
|
1344
1547
|
/**
|
|
@@ -1354,8 +1557,127 @@ class RenderingContext2d {
|
|
|
1354
1557
|
* RenderingContext2d, or a node-canvas-like object. Images honor the
|
|
1355
1558
|
* current transform (server-side, via the picture transform).
|
|
1356
1559
|
*/
|
|
1560
|
+
/**
|
|
1561
|
+
* Clip and mask for a direct composite, as a bracket around it.
|
|
1562
|
+
*
|
|
1563
|
+
* A rectangular clip is something the server can do itself: two small
|
|
1564
|
+
* requests around the composite instead of intersecting a full-surface a8
|
|
1565
|
+
* mask, which costs the same on the wire and many times the pixel work.
|
|
1566
|
+
* This is the fast path `drawGlyphs` already has — and a renderer that
|
|
1567
|
+
* clips to a damage rect makes it the common case, not a rare one.
|
|
1568
|
+
*
|
|
1569
|
+
* Uniform alpha needs no surface-sized mask either: a 1x1 repeating
|
|
1570
|
+
* picture is the same thing to the server, with no pixel work at all.
|
|
1571
|
+
*/
|
|
1572
|
+
_beginDirectComposite() {
|
|
1573
|
+
const rect = this.clipMask ? this._clipRect() : null;
|
|
1574
|
+
if (!rect) return { mask: this._compositeMask(), clipped: false, empty: false };
|
|
1575
|
+
if (rect.w === 0 || rect.h === 0) return { mask: 0, clipped: false, empty: true };
|
|
1576
|
+
this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
|
|
1577
|
+
const mask =
|
|
1578
|
+
this.globalAlpha >= 1 ? 0 : this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
|
|
1579
|
+
return { mask, clipped: true, empty: false };
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
_endDirectComposite(state) {
|
|
1583
|
+
if (!state.clipped) return;
|
|
1584
|
+
this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** The source colour for a coverage composite, with `globalAlpha` folded
|
|
1588
|
+
* into it. The mask slot is taken by the coverage, so the alpha has to go
|
|
1589
|
+
* somewhere — and a 1x1 solid is free, where a surface-sized alpha mask is
|
|
1590
|
+
* not. Falls back to the fill picture for gradients, which have no single
|
|
1591
|
+
* colour to fold into; `_drawCoverage` routes those through the scratch. */
|
|
1592
|
+
_coverageSource() {
|
|
1593
|
+
const style = this._fillStyle;
|
|
1594
|
+
if (this.globalAlpha >= 1 || !isPlainColor(style)) return this._backgroundPicture;
|
|
1595
|
+
const c = parseColor(style);
|
|
1596
|
+
return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Composite a coverage source: the a8 picture is the *mask* and the current
|
|
1601
|
+
* `fillStyle` is what gets painted through it.
|
|
1602
|
+
*
|
|
1603
|
+
* This is the same shape as `drawGlyphs`, and for the same reason — a
|
|
1604
|
+
* monochrome drawing rendered once as coverage can then be painted in any
|
|
1605
|
+
* colour, so a hover, a disabled state and a theme change all reuse the one
|
|
1606
|
+
* rendered copy instead of each needing their own.
|
|
1607
|
+
*/
|
|
1608
|
+
_drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
|
|
1609
|
+
const R = this.Render;
|
|
1610
|
+
const scaled = dw !== sw || dh !== sh;
|
|
1611
|
+
if (scaled) {
|
|
1612
|
+
R.SetPictureTransform(picture.id, [sw / dw, 0, sx, 0, sh / dh, sy, 0, 0, 1]);
|
|
1613
|
+
picture.setFilter('bilinear');
|
|
1614
|
+
}
|
|
1615
|
+
// with a transform in place the mask is already sampled from (sx, sy)
|
|
1616
|
+
const mx = scaled ? 0 : sx;
|
|
1617
|
+
const my = scaled ? 0 : sy;
|
|
1618
|
+
|
|
1619
|
+
const rect = this.clipMask ? this._clipRect() : null;
|
|
1620
|
+
// the mask slot can hold exactly one picture, so anything that cannot be
|
|
1621
|
+
// folded into the colour or handed to the server as a clip rectangle has
|
|
1622
|
+
// to be intersected into the scratch mask first
|
|
1623
|
+
const scratch =
|
|
1624
|
+
(this.clipMask && !rect) || (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
|
|
1625
|
+
|
|
1626
|
+
if (scratch) {
|
|
1627
|
+
this._ensureFillMask();
|
|
1628
|
+
R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
|
|
1629
|
+
R.Composite(R.PictOp.Src, picture.id, 0, this.fillMask.id, mx, my, 0, 0, dx, dy, dw, dh);
|
|
1630
|
+
if (this.globalAlpha < 1) {
|
|
1631
|
+
R.Composite(
|
|
1632
|
+
R.PictOp.InReverse,
|
|
1633
|
+
this.createSolidPicture(0, 0, 0, this.globalAlpha).id,
|
|
1634
|
+
0,
|
|
1635
|
+
this.fillMask.id,
|
|
1636
|
+
0, 0, 0, 0, 0, 0,
|
|
1637
|
+
this.width, this.height
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
if (this.clipMask) {
|
|
1641
|
+
R.Composite(R.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
|
|
1642
|
+
}
|
|
1643
|
+
R.Composite(
|
|
1644
|
+
op,
|
|
1645
|
+
this._backgroundPicture.id,
|
|
1646
|
+
this.fillMask.id,
|
|
1647
|
+
this.picture.id,
|
|
1648
|
+
0, 0, 0, 0,
|
|
1649
|
+
dx, dy,
|
|
1650
|
+
dw, dh
|
|
1651
|
+
);
|
|
1652
|
+
} else if (!rect || (rect.w > 0 && rect.h > 0)) {
|
|
1653
|
+
if (rect) {
|
|
1654
|
+
R.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
|
|
1655
|
+
}
|
|
1656
|
+
R.Composite(
|
|
1657
|
+
op,
|
|
1658
|
+
this._coverageSource().id,
|
|
1659
|
+
picture.id,
|
|
1660
|
+
this.picture.id,
|
|
1661
|
+
0, 0,
|
|
1662
|
+
mx, my,
|
|
1663
|
+
dx, dy,
|
|
1664
|
+
dw, dh
|
|
1665
|
+
);
|
|
1666
|
+
if (rect) {
|
|
1667
|
+
R.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
if (scaled) {
|
|
1672
|
+
// restore defaults so the cached surface stays reusable as-is
|
|
1673
|
+
R.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
|
|
1674
|
+
picture.setFilter('nearest');
|
|
1675
|
+
}
|
|
1676
|
+
this._markDirty();
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1357
1679
|
drawImage(image, ...args) {
|
|
1358
|
-
if (image
|
|
1680
|
+
if (isPictureSource(image)) {
|
|
1359
1681
|
let sx = 0;
|
|
1360
1682
|
let sy = 0;
|
|
1361
1683
|
let sw = image.width;
|
|
@@ -1383,7 +1705,14 @@ class RenderingContext2d {
|
|
|
1383
1705
|
return;
|
|
1384
1706
|
}
|
|
1385
1707
|
|
|
1386
|
-
|
|
1708
|
+
if (image.format === 'a8') {
|
|
1709
|
+
this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const state = this._beginDirectComposite();
|
|
1714
|
+
if (state.empty) return;
|
|
1715
|
+
const mask = state.mask;
|
|
1387
1716
|
const scaled = dw !== sw || dh !== sh;
|
|
1388
1717
|
if (scaled) {
|
|
1389
1718
|
// the picture transform maps composite coordinates into source
|
|
@@ -1415,6 +1744,7 @@ class RenderingContext2d {
|
|
|
1415
1744
|
dw, dh
|
|
1416
1745
|
);
|
|
1417
1746
|
}
|
|
1747
|
+
this._endDirectComposite(state);
|
|
1418
1748
|
this._markDirty();
|
|
1419
1749
|
return;
|
|
1420
1750
|
}
|
|
@@ -1424,7 +1754,8 @@ class RenderingContext2d {
|
|
|
1424
1754
|
const sx = 0;
|
|
1425
1755
|
const sy = 0;
|
|
1426
1756
|
|
|
1427
|
-
//
|
|
1757
|
+
// Drawables reach the branch above by way of Surface, which is what a
|
|
1758
|
+
// Window/Pixmap/Picture source actually needs: a size and a Picture.
|
|
1428
1759
|
if (image instanceof RenderingContext2d) {
|
|
1429
1760
|
// TODO: if need to scale, set transform
|
|
1430
1761
|
// TODO: respect global compositing blend mode
|
|
@@ -1441,20 +1772,23 @@ class RenderingContext2d {
|
|
|
1441
1772
|
} else if (image && image.context && typeof image.context.getImageData === 'function') {
|
|
1442
1773
|
// node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
|
|
1443
1774
|
const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1775
|
+
// node-canvas hands over straight RGBA, and the rgba32 picture below is
|
|
1776
|
+
// premultiplied — this used to swap the channels and stop there, which
|
|
1777
|
+
// composited translucent images at full brightness
|
|
1778
|
+
const data = fromStraightRgba(
|
|
1779
|
+
imageData.data,
|
|
1780
|
+
pixelLayout(this.display, 32),
|
|
1781
|
+
sWidth,
|
|
1782
|
+
sHeight
|
|
1783
|
+
);
|
|
1452
1784
|
|
|
1453
1785
|
const pixmap = new Pixmap(this.window.app, { depth: 32, width: sWidth, height: sHeight });
|
|
1454
1786
|
const picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32 });
|
|
1455
1787
|
pixmap._gc = this.X.AllocID();
|
|
1456
1788
|
this.X.CreateGC(pixmap._gc, pixmap.id);
|
|
1457
|
-
|
|
1789
|
+
// the crop already happened in getImageData, so it lands at the pixmap's
|
|
1790
|
+
// origin — sx/sy here wrote it off the edge
|
|
1791
|
+
this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, 0, 0, 0, 32, data);
|
|
1458
1792
|
|
|
1459
1793
|
this.Render.Composite(
|
|
1460
1794
|
this.Render.PictOp.Over,
|
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;
|