ntk 2.2.0 → 3.1.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +64 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +129 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +691 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
@@ -1,695 +1,1078 @@
1
- var Picture = require('./picture.js');
2
- var FontFace = require('./fontface.js');
3
- var GlyphSet = require('./glyphset.js');
4
- var Pixmap = require('./pixmap.js');
5
-
6
- var DEFAULT_FONT = '20px sans-serif';
7
- var _fonts = {};
8
-
9
- module.exports = function(window, callback) {
10
- return new RenderingContext2d(window);
11
- };
12
-
13
- function RenderingContext2d(window) {
14
- var X = window.X;
15
- this.X = X;
16
-
17
- // TODO lazily create via getter
18
- if (!window._gc) {
19
- window._gc = X.AllocID();
20
- X.CreateGC(window._gc, window.id);
21
- }
22
-
23
- this.window = window;
24
- this.Render = this.window.app.display.Render;
25
- var format;
26
- // TODO: support a8
27
- if (window.depth === 32) format = this.Render.rgba32;
28
- else format = this.Render.rgb24;
29
-
30
- this.picture = new Picture(window.app, {
31
- drawable: window,
32
- format: format,
33
- polyEdge: 1,
34
- polyMode: 1
35
- });
36
-
37
- this.fillMask = null;
38
- this.fillMaskDrawable = null;
39
- this.clipMask = null;
40
- this.clipMaskDrawable = null;
41
-
42
- this.fillStyle = 'white';
43
- this.strokeStyle = 'black';
44
- this.lineWidth = 1;
45
- }
46
-
47
- var RC = RenderingContext2d.prototype;
48
-
49
- // TODO: remove, add drawImage
50
- RC.putImageData = function(data, x, y) {
51
- // todo: use constants
52
- this.X.PutImage(
53
- 2,
54
- this.window.id,
55
- this.window._gc,
56
- data.width,
57
- data.height,
58
- x,
59
- y,
60
- 0,
61
- 24,
62
- data.data
63
- );
64
- };
65
-
66
- var _solidPix = new WeakMap();
67
- RC.createSolidPicture = function(r, g, b, a) {
68
- var key = [r, g, b, a].join('|');
69
- if (_solidPix[key]) {
70
- return _solidPix[key];
71
- }
72
- // TODO: use window.createPixmap
73
- var pixmap = new Pixmap(this.window.app, { depth: 32, width: 1, height: 1 });
74
- var pictSolidPix = this.window.X.AllocID();
75
- this.Render.CreatePicture(pictSolidPix, pixmap.id, this.Render.rgba32, { repeat: 1 });
76
- this.Render.FillRectangles(1, pictSolidPix, [r, g, b, a], [0, 0, 100, 100]);
77
- var p = new Picture(this.window.app, { id: pictSolidPix });
78
- _solidPix[key] = p;
79
- return p;
80
- };
81
-
82
- RC.setBackground = function(pic) {
83
- this._background = pic;
84
- };
85
-
86
- RC.__defineGetter__('_backgound', function() {
87
- if (this._backgroundPicture) return this._backgroundPicture;
88
- else {
89
- this._backgroundPicture = this.createSolidPicture(1, 1, 1, 1);
90
- return this._backgroundPicture;
91
- }
92
- });
93
-
94
- RC.__defineSetter__('fillStyle', function(value) {
95
- if (typeof value === 'string' || Array.isArray(value)) {
96
- var c = parseColor(value);
97
- this._backgroundPicture = this.createSolidPicture(c[0], c[1], c[2], c[3]);
98
- return this._backgroundPicture;
99
- } else if (value.constructor === Picture || value.constructor === CanvasGradient) {
100
- this._backgroundPicture = value;
101
- return this._backgroundPicture;
102
- } else {
103
- throw new Error('Unknown fill style');
104
- }
105
- });
106
-
107
- RC.__defineSetter__('strokeStyle', function(value) {
108
- if (typeof value === 'string' || Array.isArray(value)) {
109
- var c;
110
- if (Array.isArray(value)) c = value;
111
- else c = parseColor(value);
112
- this._strokePicture = this.createSolidPicture(c[0], c[1], c[2], c[3]);
113
- return this._strokePicture;
114
- } else if (value.constructor === Picture || value.constructor === CanvasGradient) {
115
- this._strokePicture = value;
116
- return this._strokePicture;
117
- } else {
118
- throw new Error('Unknown stroke style');
119
- }
120
- });
121
-
122
- // html context2d compatibility: canvas.getContext('2d').canvas == canvas
123
- RC.__defineGetter__('canvas', function() {
124
- return this.window;
125
- });
126
-
127
- RC.__defineGetter__('width', function() {
128
- return this.window.width;
129
- });
130
-
131
- RC.__defineGetter__('height', function() {
132
- return this.window.height;
133
- });
134
-
135
- RC.clearRect = function(x, y, w, h) {
136
- this.Render.FillRectangles(this.Render.PictOp.Src, this.picture.id, [1, 1, 1, 1], [x, y, w, h]);
137
- };
138
-
139
- RC.fillRect = function(x, y, w, h) {
140
- var mask = 0;
141
- if (this.clipMask) mask = this.clipMask.id;
142
- // ext.Composite = function(op, src, mask, dst, srcX, srcY, maskX, maskY, dstX, dstY, width, height)
143
- this.Render.Composite(
144
- this.Render.PictOp.Over,
145
- this._backgroundPicture.id,
146
- mask,
147
- this.picture.id,
148
- x,
149
- y,
150
- x,
151
- y,
152
- x,
153
- y,
154
- w,
155
- h
156
- );
157
- };
158
-
159
- RC.fillText = function(text, x, y) {
160
- var Render = this.Render;
161
- text = text || '';
162
- if (!this._glyphSet) this.font = DEFAULT_FONT;
163
- var ff = this._glyphSet.fontface;
164
- var glyphs = String.fromCharCode.apply(
165
- null,
166
- text.split('').map(function(c) {
167
- return ff.charcode2glyph[c.charCodeAt(0)];
168
- })
169
- );
170
- Render.CompositeGlyphs32(
171
- 3,
172
- this._backgroundPicture.id,
173
- this.picture.id,
174
- 0,
175
- this._glyphSet.id,
176
- 0,
177
- 0,
178
- [[x, y, glyphs]]
179
- );
180
- };
181
-
182
- RC.measureText = function(text) {
183
- var w = 0;
184
- var h = 0;
185
- var ch, g;
186
- var ff = this._glyphSet.fontface;
187
- for (var i = 0; i < text.length; ++i) {
188
- ch = text.charCodeAt(i);
189
- g = ff.charcode2glyphObj[ch];
190
- if (!g) {
191
- //console.log('Unknown character!', [text[i]], ch);
192
- //w += 10;
193
- //
194
- continue;
195
- //g = ff.charcode2glyphObj['n'.charCodeAt(0)];
196
- }
197
- w += g.offX;
198
- if (g.height > h) h = g.height;
199
- }
200
- //console.log('measureText', text, w, h)
201
- return {
202
- width: w,
203
- height: h
204
- };
205
- };
206
-
207
- RC.beginPath = function() {
208
- this._currentPoly = null;
209
- this._currentPath = [];
210
- };
211
-
212
- RC.moveTo = function(x, y) {
213
- if (this._currentPoly) {
214
- this._currentPath.push(this._currentPoly);
215
- }
216
- this._currentPoly = [{ x: x, y: y }];
217
- };
218
-
219
- RC.lineTo = function(x, y) {
220
- if (this._currentPoly) {
221
- var pt = this._currentPoly[this._currentPoly.length - 1];
222
- var d = Math.abs(pt.x - x) + Math.abs(pt.y - y);
223
- if (d > 0.5) this._currentPoly.push({ x: x, y: y });
224
- } else {
225
- throw new Error('lineTo called before moveTo()');
226
- }
227
- };
228
-
229
- // TODO: this is my heuristic. Do some more research
230
- function bcurveStep(x0, y0, x1, y1, x2, y2, x, y) {
231
- var minX = Math.min(x, x0, x1, x2);
232
- var maxX = Math.max(x, x0, x1, x2);
233
- var minY = Math.min(y, y0, y1, y2);
234
- var maxY = Math.max(y, y0, y1, y2);
235
- var dt = 1 / Math.max(maxX - minX, maxY - minY);
236
- if (dt > 0.2) dt = 0.2;
237
- return dt;
238
- }
239
-
240
- RC.bezierCurveTo = function(x1, y1, x2, y2, x, y) {
241
- var lastPt = this._currentPoly[this._currentPoly.length - 1];
242
- var cubic = require('bezier').prepare(4);
243
- var x = [lastPt.x, x1, x2, x];
244
- var y = [lastPt.y, y1, y2, y];
245
- var step = bcurveStep(x[0], y[0], x[1], y[1], x[2], y[2], x[3], y[3]);
246
- for (var t = 0; t < 1; t += step) {
247
- this.lineTo(cubic(x, t), cubic(y, t));
248
- }
249
- };
250
-
251
- RC.closePath = function() {
252
- if (this._currentPoly) this._currentPath.push(this._currentPoly);
253
- this._currentPoly = null;
254
- };
255
-
256
- RC.scale = function(s) {
257
- var m = [s, 0, 0, 0, s, 0, 0, 0, 1];
258
- this.Render.SetPictureTransform(this.picture.id, m);
259
- };
260
-
261
- // from https://github.com/jahting/pnltri.js/blob/master/test/TestHelpers.js#L132
262
- function xCoord_of_segment_at_Y(inSegment, inCrossYPt) {
263
- if (inSegment.vTo.y == inSegment.vFrom.y) return inCrossYPt.x;
264
- return (xCoord =
265
- inSegment.vFrom.x +
266
- ((inSegment.vTo.x - inSegment.vFrom.x) * (inCrossYPt.y - inSegment.vFrom.y)) /
267
- (inSegment.vTo.y - inSegment.vFrom.y));
268
- }
269
-
270
- RC._rasterizePath = function(picture) {
271
- // TODO: compare with https://www.npmjs.com/package/cdt2d
272
- var PNLTRI = require('pnltri');
273
-
274
- var max_width = 65535;
275
- var path = this._currentPath;
276
-
277
- var trapezoider = new PNLTRI.Trapezoider(new PNLTRI.PolygonData(path));
278
- trapezoider.trapezoide_polygon();
279
- var traps = trapezoider.queryStructure.trapArray;
280
- var trapSegments = [];
281
- traps.forEach(function(inTrapezoid) {
282
- var high = [-max_width, max_width, inTrapezoid.vHigh.y];
283
- var low = [-max_width, max_width, inTrapezoid.vLow.y];
284
- if (inTrapezoid.lseg) {
285
- high[0] = xCoord_of_segment_at_Y(inTrapezoid.lseg, inTrapezoid.vHigh);
286
- low[0] = xCoord_of_segment_at_Y(inTrapezoid.lseg, inTrapezoid.vLow);
287
- }
288
- if (inTrapezoid.rseg) {
289
- high[1] = xCoord_of_segment_at_Y(inTrapezoid.rseg, inTrapezoid.vHigh);
290
- low[1] = xCoord_of_segment_at_Y(inTrapezoid.rseg, inTrapezoid.vLow);
291
- }
292
- if (
293
- inTrapezoid.depth == 1 &&
294
- high[1] != max_width &&
295
- low[1] != max_width &&
296
- high[0] != -max_width &&
297
- low[1] != -max_width
298
- ) {
299
- trapSegments = trapSegments.concat(low);
300
- trapSegments = trapSegments.concat(high);
301
- }
302
- });
303
- this.Render.AddTraps(picture.id, 0, 0, trapSegments);
304
- return;
305
-
306
- /*
307
- triangulate: note that XRender does not stich edges properly, this code going yp be removed
308
- var pts = [];
309
- path.forEach(function(p) {
310
- pts = pts.concat(p)
311
- });
312
- var myTriangulator = new PNLTRI.Triangulator();
313
- var triangList = myTriangulator.triangulate_polygon( path );
314
- var t = [];
315
- triangList.forEach(function(tri) {
316
- for(var i=0; i < 3; ++i) {
317
- t.push(pts[tri[i]].x);
318
- t.push(pts[tri[i]].y);
319
- }
320
- });
321
- this.Render.Triangles(this.Render.PictOp.Over, this._backgroundPicture.id, 0, 0, this.picture.id, 0, t);
322
- */
323
- };
324
-
325
- RC.stroke = function() {
326
- this.closePath();
327
-
328
- var t = [];
329
- var stroke = require('extrude-polyline')({
330
- thickness: this.lineWidth,
331
- //cap: 'square',
332
- //join: 'bevel',
333
- miterLimit: 10
334
- });
335
-
336
- for (var p = 0; p < this._currentPath.length; ++p) {
337
- //var polyline = [ [25, 25], [15, 60] ]
338
- var polyline = this._currentPath[p].map(pt => [pt.x, pt.y]);
339
- var mesh = stroke.build(polyline);
340
- mesh.cells.forEach(function(tri) {
341
- for (var i = 0; i < 3; ++i) {
342
- t.push(mesh.positions[tri[i]][0]);
343
- t.push(mesh.positions[tri[i]][1]);
344
- }
345
- });
346
- }
347
- // TODO: mask
348
- //this.Render.Triangles(this.Render.PictOp.Over, this._strokePicture.id, 0, 0, this.picture.id, 0, t);
349
- this.Render.Triangles(
350
- this.Render.PictOp.Over,
351
- this._strokePicture.id,
352
- 0,
353
- 0,
354
- this.picture.id,
355
- this.Render.a8,
356
- t
357
- );
358
- };
359
-
360
- RC.clip = function() {
361
- if (!this.clipMask) {
362
- this.clipMaskDrawable = new Pixmap(this.window.app, {
363
- depth: 8,
364
- width: this.width,
365
- height: this.height
366
- });
367
- this.clipMask = new Picture(this.window.app, {
368
- drawable: this.clipMaskDrawable,
369
- format: this.Render.a8
370
- });
371
- }
372
- this.Render.FillRectangles(1, this.clipMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
373
- this._rasterizePath(this.clipMask);
374
- };
375
-
376
- RC.fill = function() {
377
- this.closePath();
378
-
379
- // TODO: perf: use shape bounding box instead of compositing over whole width x height canvas
380
-
381
- if (!this.fillMask) {
382
- this.fillMaskDrawable = new Pixmap(this.window.app, {
383
- depth: 8,
384
- width: this.width,
385
- height: this.height
386
- });
387
- this.fillMask = new Picture(this.window.app, {
388
- drawable: this.fillMaskDrawable,
389
- format: this.Render.a8
390
- });
391
- }
392
- // TODO: apply global alpha here?
393
- this.Render.FillRectangles(1, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
394
- this._rasterizePath(this.fillMask);
395
-
396
- // TODO apply transformation to background position?
397
-
398
- // intersect with clipping area
399
- if (this.clipMask)
400
- this.Render.Composite(
401
- this.Render.PictOp.In,
402
- this.clipMask.id,
403
- 0,
404
- this.fillMask.id,
405
- 0,
406
- 0,
407
- 0,
408
- 0,
409
- 0,
410
- 0,
411
- this.width,
412
- this.height
413
- );
414
- // draw background using resulting image as mask
415
- this.Render.Composite(
416
- this.Render.PictOp.Over,
417
- this._backgroundPicture.id,
418
- this.fillMask.id,
419
- this.picture.id,
420
- 0,
421
- 0,
422
- 0,
423
- 0,
424
- 0,
425
- 0,
426
- this.width,
427
- this.height
428
- );
429
- };
430
-
431
- var baselines = ['alphabetic', 'top', 'bottom', 'middle', 'ideographic', 'hanging'];
432
- var parseFontStyle = require('canvas-fontstyle');
433
-
434
- RC._uploadGlyphSet = function(face, size, hdpi, vdpi) {
435
- var gs = face.upload(size, hdpi, vdpi);
436
- gs.fontface = face;
437
- return gs;
438
- };
439
-
440
- RC._setFace = function(face, size) {
441
- var glyphSet = this._uploadGlyphSet(face, size, 96, 96);
442
- this._glyphSet = glyphSet;
443
- };
444
-
445
- RC._setFont = function(weight, style, size, unit, family) {
446
- var fc = require('./fontconfig.js');
447
- var pattern = {
448
- family: family,
449
- style: style,
450
- weight: weight
451
- };
452
- var match = fc.listFontsSync(pattern);
453
-
454
- var face = new FontFace(this.window.app);
455
- face.loadFont(match.path);
456
- if (!_fonts[family]) _fonts[family] = {};
457
- _fonts[family][weight + '-' + style] = face;
458
- this._setFace(face, size);
459
- };
460
-
461
- RC.addFont = function(font) {
462
- this._fonts = this._fonts || {};
463
- if (this._fonts[font.name]) return;
464
- this._fonts[font.name] = font;
465
- };
466
-
467
- RC.__defineSetter__('font', function(val) {
468
- if (!val) return;
469
- if ('string' == typeof val) {
470
- var font;
471
- if ((font = parseFontStyle(val))) {
472
- this._lastFontString = val;
473
- if (_fonts && _fonts[font.family]) {
474
- var fontObj = _fonts[font.family];
475
- var type = font.weight + '-' + font.style;
476
- var fontFace = fontObj[type];
477
- if (fontFace) return this._setFace(fontFace, font.size);
478
- }
479
- this._setFont(font.weight, font.style, font.size, font.unit, font.family);
480
- }
481
- }
482
- });
483
-
484
- RC.loadFont = function(name, size, hdpi, vdpi) {
485
- var face = new FontFace(this.window.app);
486
- face.loadFont(name);
487
- var gs = face.upload(size, hdpi, vdpi);
488
- gs.fontface = face;
489
- return gs;
490
- };
491
-
492
- RC.setFont = function(glyphSet) {
493
- this._glyphSet = glyphSet;
494
- };
495
-
496
- RC.__defineGetter__('font', function() {
497
- return this._lastFontString || DEFAULT_FONT;
498
- });
499
-
500
- function CanvasGradient(type, ctx, p0, p1, p2, p3, p4, p5) {
501
- this.type = type;
502
- this.stops = [];
503
- this.ctx = ctx;
504
- this._id = null;
505
-
506
- this.x0 = p0;
507
- this.y0 = p1;
508
- if (type == 'linear' || type == 'radial') {
509
- this.x1 = p2;
510
- this.y1 = p3;
511
- this.r0 = p4;
512
- this.r1 = p5;
513
- } else {
514
- this.angle = p2;
515
- }
516
- // TODO: check if ChangePictureAttrubute works on gradients ( set repeat edge pixels flag )
517
- }
518
-
519
- //var gradientsCache = new WeakMap();
520
- CanvasGradient.prototype.__defineGetter__('id', function() {
521
- if (this._id !== null) return this._id;
522
-
523
- var Render = this.ctx.Render;
524
- var X = this.ctx.X;
525
-
526
- //var key = [this.x0, this.y0, this.x1, this.y1, this.r0, this.r1, this.angle, this.stops].join(',');
527
- //if (gradientsCache[key])
528
- // return gradientsCache[key].id;
529
-
530
- this._id = X.AllocID();
531
- switch (this.type) {
532
- case 'linear':
533
- Render.LinearGradient(this._id, [this.x0, this.y0], [this.x1, this.y1], this.stops);
534
- break;
535
- case 'radial':
536
- Render.RadialGradient(
537
- this._id,
538
- [this.x0, this.y0],
539
- [this.x1, this.y1],
540
- this.r0,
541
- this.r1,
542
- this.stops
543
- );
544
- break;
545
- case 'conical':
546
- Render.ConicalGradient(this._id, [this.x0, this.y0], this.angle, this.stops);
547
- break;
548
- default:
549
- throw new Error('unknown gradient type');
550
- }
551
- // wrap with picture so FreePicture is invoked on gc via weakref
552
- this._picture = new Picture(this.ctx.window.app, { id: this._id });
553
- //gradientsCache[key] = this;
554
- return this._id;
555
- });
556
-
557
- function parseColor(str) {
558
- if (Array.isArray(str)) return str;
559
- var parseColor_ = require('parse-color');
560
- var c = parseColor_(str);
561
- return [c.rgba[0] / 255, c.rgba[1] / 255, c.rgba[2] / 255, c.rgba[3]];
562
- }
563
-
564
- CanvasGradient.prototype.addColorStop = function(offset, color) {
565
- var colorArr = parseColor(color);
566
- this.stops.push([offset, colorArr]);
567
- return this;
568
- };
569
-
570
- RC.createLinearGradient = function(x0, y0, x1, y1) {
571
- return new CanvasGradient('linear', this, x0, y0, x1, y1);
572
- };
573
-
574
- RC.createRadialGradient = function(x0, y0, r0, x1, y1, r1) {
575
- return new CanvasGradient('radial', this, x0, y0, x1, y1, r0, r1);
576
- };
577
-
578
- RC.createConicalGradient = function(x0, y0, angle) {
579
- return new CanvasGradient('conical', this, x0, y0, angle);
580
- };
581
-
582
- RC.createImageData = function(w, h) {
583
- return {
584
- width: w,
585
- height: h,
586
- data: Buffer.alloc(w * h * 4)
587
- };
588
- };
589
-
590
- RC.getImageData = function(x, y, w, h, cb) {
591
- // X.GetImage( format, drawable, x, y, w, h, planeMask )
592
- console.log('GetImage(2', [this.window.id, x, y, w, h, 0xffffffff]);
593
- //var win = this.window.app.root().id;
594
- var win = this.window.id;
595
- this.X.GetImage(2, win, x, y, w, h, 0xffffffff, function(err, data) {
596
- cb(err, data);
597
- });
598
- };
599
-
600
- // void ctx.drawImage(image, dx, dy);
601
- // void ctx.drawImage(image, dx, dy, dWidth, dHeight);
602
- // void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
603
- // image: drawable, node-canvas Canvas, node-canvas Image
604
- RC.drawImage = function(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) {
605
- //image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight
606
- //if (typeof dHeight == 'undefined' && )
607
-
608
- sWidth = image.width;
609
- sHeight = image.height;
610
- sx = 0;
611
- sy = 0;
612
-
613
- // TODO: allow to draw Canvas.Image, Window, Pixmap, Picture
614
- if (image.constructor == RenderingContext2d) {
615
- // TODO: if need to scale, set transform
616
- // TODO: respect globalAlpha ( use solid transparent mask )
617
- // TODO: respect global compositing blend mode
618
- this.Render.Composite(
619
- this.Render.PictOp.Over,
620
- image.picture.id,
621
- 0,
622
- this.picture.id,
623
- 0,
624
- 0,
625
- 0,
626
- 0,
627
- 0,
628
- 0,
629
- this.width,
630
- this.height
631
- );
632
- } else if (image && image.context && typeof image.context.getImageData == 'function') {
633
- // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
634
- var imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
635
- var data = Buffer.alloc(imageData.data.length);
636
- for (var i = 0; i < data.length; i += 4) {
637
- data[i + 2] = imageData.data[i];
638
- data[i + 1] = imageData.data[i + 1];
639
- data[i] = imageData.data[i + 2];
640
- data[i + 3] = imageData.data[i + 3]; // multiply source alpha by context globalAlpha?
641
- // or maybe just blend using corresponding operation + composite with transparent rectangle
642
- }
643
-
644
- var pixmap = new Pixmap(this.window.app, { depth: 32, width: sWidth, height: sHeight });
645
- var picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32 });
646
- pixmap._gc = this.X.AllocID();
647
- this.X.CreateGC(pixmap._gc, pixmap.id);
648
- this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, sx, sy, 0, 32, data);
649
-
650
- // TODO: figure out scale factor if sWidth !== dWith or sHeight !== dHeight
651
- //this.Render.SetPictureFilter(picture.id, 'best');
652
- var s = 1;
653
- var m = [s, 0, 0, 0, s, 0, 0, 0, 1];
654
- //this.Render.SetPictureTransform(picture.id, m);
655
-
656
- // TODO: respect globalCompositeOperation and globalAlpha
657
- // examples http://blogs.adobe.com/webplatform/2014/02/24/using-blend-modes-in-html-canvas/
658
- var mask = 0;
659
- if (this.clipMask) mask = this.clipMask.id;
660
- this.Render.Composite(
661
- 3,
662
- picture.id,
663
- mask,
664
- this.picture.id,
665
- 0,
666
- 0,
667
- 0,
668
- 0,
669
- 0,
670
- 0,
671
- this.width,
672
- this.height
673
- );
674
- }
675
- };
676
-
677
- RC.arc = function() {
678
- //console.log('canvas2d.arc() not implemented!!!');
679
- //console.log(arguments);
680
- };
681
-
682
- RC.restore = function() {
683
- //console.log('canvas2d.restore() not implemented!!!');
684
- };
685
-
686
- RC.save = function() {
687
- //console.log('canvas2d.save() not implemented!!!');
688
- };
689
-
690
- RC.translate = function(x, y) {
691
- //console.log('canvas2d.translate() not implemented!!!');
692
- };
693
-
694
- // register context
695
- require('./drawable').prototype.renderingContextFactory['2d'] = module.exports;
1
+ import parseFontStyle from 'canvas-fontstyle';
2
+ import extrudePolyline from 'extrude-polyline';
3
+ import parseColorRaw from 'parse-color';
4
+
5
+ import Drawable from './drawable.js';
6
+ import { Image } from './image.js';
7
+ import {
8
+ Path2D,
9
+ flattenPath,
10
+ transformCommands,
11
+ ellipseSegments,
12
+ polysContain,
13
+ matApply,
14
+ matInvert,
15
+ matIsIdentity,
16
+ matMultiply
17
+ } from './path.js';
18
+ import Picture from './picture.js';
19
+ import Pixmap from './pixmap.js';
20
+ import { drawGlyphRuns } from './text/glyphs.js';
21
+ import { TextLayout } from './text/layout.js';
22
+ import { reorderRuns } from './text/shape.js';
23
+ import { trapezoidize } from './trapezoid.js';
24
+
25
+ const DEFAULT_FONT = '20px sans-serif';
26
+
27
+ // canvas globalCompositeOperation -> XRender PictOp name. Porter-Duff ops
28
+ // map directly; with a clip/shape mask active the op only applies inside
29
+ // the mask coverage (outside pixels are left untouched).
30
+ const GCO_TO_PICTOP = {
31
+ 'source-over': 'Over',
32
+ copy: 'Src',
33
+ 'destination-over': 'OverReverse',
34
+ 'source-in': 'In',
35
+ 'destination-in': 'InReverse',
36
+ 'source-out': 'Out',
37
+ 'destination-out': 'OutReverse',
38
+ 'source-atop': 'Atop',
39
+ 'destination-atop': 'AtopReverse',
40
+ xor: 'Xor',
41
+ lighter: 'Add'
42
+ };
43
+
44
+ // extrude-polyline has no round caps/joins; degrade to the closest shape
45
+ const LINE_CAP = { butt: 'butt', square: 'square', round: 'square' };
46
+ const LINE_JOIN = { miter: 'miter', bevel: 'bevel', round: 'bevel' };
47
+
48
+ function parseColor(value) {
49
+ if (Array.isArray(value)) return value;
50
+ const c = parseColorRaw(value);
51
+ return [c.rgba[0] / 255, c.rgba[1] / 255, c.rgba[2] / 255, c.rgba[3]];
52
+ }
53
+
54
+ /**
55
+ * Canvas-like 2d rendering context backed by the XRender extension: most
56
+ * operations (composition, gradients, text composition) happen server-side.
57
+ *
58
+ * Paths follow the HTML canvas model: the default path records points with
59
+ * the current transform applied at command time; `Path2D` objects are
60
+ * transformed when filled/stroked/clipped. See docs/context-2d.md for the
61
+ * supported surface and the differences from the browser canvas.
62
+ */
63
+ class RenderingContext2d {
64
+ constructor(window) {
65
+ const X = window.X;
66
+ this.X = X;
67
+
68
+ this.window = window;
69
+ this.Render = window.app.display.Render;
70
+
71
+ // draw into the window's backing pixmap when it has one (double
72
+ // buffering); re-bind when the backing pixmap is reallocated on resize
73
+ this._target = null;
74
+ this._gc = null;
75
+ this.picture = null;
76
+ this._bindTarget();
77
+ if (typeof window.on === 'function') {
78
+ window.on('_backing', () => this._bindTarget());
79
+ // masks are sized to the drawable — recreate them after a resize
80
+ window.on('resize', () => this._dropMasks());
81
+ // window-backed pictures are freed server-side with the window
82
+ window.on('_destroyed', () => {
83
+ if (this.picture && this._target === this.window) {
84
+ this.picture.forget();
85
+ this.picture = null;
86
+ }
87
+ });
88
+ }
89
+
90
+ this._solidPictures = new Map();
91
+ this.fillMask = null;
92
+ this.fillMaskDrawable = null;
93
+ this.clipMask = null;
94
+ this.clipMaskDrawable = null;
95
+ this._textStyle = null;
96
+ this._lastFontString = null;
97
+ this.textAlign = 'start';
98
+ this.textBaseline = 'alphabetic';
99
+
100
+ this._path = new Path2D();
101
+ this._m = [1, 0, 0, 1, 0, 0];
102
+ this._stack = [];
103
+ this._clips = []; // [{ polys, rule }] in device space, already stacked
104
+ this._gco = 'source-over';
105
+ this.globalAlpha = 1;
106
+ this.lineCap = 'butt';
107
+ this.lineJoin = 'miter';
108
+ this.miterLimit = 10;
109
+
110
+ this.fillStyle = 'white';
111
+ this.strokeStyle = 'black';
112
+ this.lineWidth = 1;
113
+ }
114
+
115
+ _bindTarget() {
116
+ const target = this.window._backing || this.window;
117
+ if (this._target === target) return;
118
+ this._target = target;
119
+
120
+ if (!this._gc) {
121
+ // one GC is enough: it stays valid for any drawable of the same
122
+ // screen and depth (the backing pixmap matches the window depth)
123
+ this._gc = this.X.AllocID();
124
+ this.X.CreateGC(this._gc, target.id);
125
+ }
126
+ if (this.picture) this.picture.destroy();
127
+ // TODO: support a8
128
+ const depth = target.depth ?? this.window.depth;
129
+ const format = depth === 32 ? this.Render.rgba32 : this.Render.rgb24;
130
+ this.picture = new Picture(this.window.app, {
131
+ drawable: target,
132
+ format,
133
+ polyEdge: 1,
134
+ polyMode: 1
135
+ });
136
+ this._dropMasks();
137
+ }
138
+
139
+ _dropMasks() {
140
+ if (this.fillMask) {
141
+ this.fillMask.destroy();
142
+ this.fillMaskDrawable.destroy();
143
+ this.fillMask = this.fillMaskDrawable = null;
144
+ }
145
+ if (this.clipMask) {
146
+ this.clipMask.destroy();
147
+ this.clipMaskDrawable.destroy();
148
+ this.clipMask = this.clipMaskDrawable = null;
149
+ }
150
+ // clip paths are device-space and survive the resize; rebuild the mask
151
+ if (this._clips && this._clips.length) this._rebuildClipMask();
152
+ }
153
+
154
+ // notify a double-buffered window that its backing content changed
155
+ _markDirty() {
156
+ if (typeof this.window._markDirty === 'function') this.window._markDirty();
157
+ }
158
+
159
+ // html context2d compatibility: canvas.getContext('2d').canvas == canvas
160
+ get canvas() {
161
+ return this.window;
162
+ }
163
+
164
+ get width() {
165
+ return this.window.width;
166
+ }
167
+
168
+ get height() {
169
+ return this.window.height;
170
+ }
171
+
172
+ _op() {
173
+ return this.Render.PictOp[GCO_TO_PICTOP[this._gco] || 'Over'];
174
+ }
175
+
176
+ set globalCompositeOperation(value) {
177
+ if (value in GCO_TO_PICTOP) this._gco = value;
178
+ }
179
+
180
+ get globalCompositeOperation() {
181
+ return this._gco;
182
+ }
183
+
184
+ createSolidPicture(r, g, b, a) {
185
+ const key = [r, g, b, a].join('|');
186
+ let p = this._solidPictures.get(key);
187
+ if (p) return p;
188
+
189
+ // TODO: use window.createPixmap
190
+ const pixmap = new Pixmap(this.window.app, { depth: 32, width: 1, height: 1 });
191
+ const pictSolidPix = this.window.X.AllocID();
192
+ this.Render.CreatePicture(pictSolidPix, pixmap.id, this.Render.rgba32, { repeat: 1 });
193
+ this.Render.FillRectangles(1, pictSolidPix, [r, g, b, a], [0, 0, 100, 100]);
194
+ p = new Picture(this.window.app, { id: pictSolidPix });
195
+ p._sourcePixmap = pixmap; // keep the 1x1 pixmap alive alongside the picture
196
+ this._solidPictures.set(key, p);
197
+ return p;
198
+ }
199
+
200
+ _stylePicture(value) {
201
+ if (typeof value === 'string' || Array.isArray(value)) {
202
+ const c = parseColor(value);
203
+ return this.createSolidPicture(c[0], c[1], c[2], c[3]);
204
+ }
205
+ if (value instanceof Picture || value instanceof CanvasGradient) {
206
+ return value;
207
+ }
208
+ throw new Error('Unknown fill style');
209
+ }
210
+
211
+ set fillStyle(value) {
212
+ this._fillStyle = value;
213
+ this._backgroundPicture = this._stylePicture(value);
214
+ }
215
+
216
+ get fillStyle() {
217
+ return this._fillStyle;
218
+ }
219
+
220
+ set strokeStyle(value) {
221
+ this._strokeStyle = value;
222
+ this._strokePicture = this._stylePicture(value);
223
+ }
224
+
225
+ get strokeStyle() {
226
+ return this._strokeStyle;
227
+ }
228
+
229
+ // ------------------------------------------------------------------
230
+ // state
231
+
232
+ save() {
233
+ this._stack.push({
234
+ fillStyle: this._fillStyle,
235
+ strokeStyle: this._strokeStyle,
236
+ lineWidth: this.lineWidth,
237
+ lineCap: this.lineCap,
238
+ lineJoin: this.lineJoin,
239
+ miterLimit: this.miterLimit,
240
+ globalAlpha: this.globalAlpha,
241
+ gco: this._gco,
242
+ textStyle: this._textStyle,
243
+ fontString: this._lastFontString,
244
+ textAlign: this.textAlign,
245
+ textBaseline: this.textBaseline,
246
+ m: this._m.slice(),
247
+ clips: this._clips
248
+ });
249
+ }
250
+
251
+ restore() {
252
+ const s = this._stack.pop();
253
+ if (!s) return;
254
+ this.fillStyle = s.fillStyle;
255
+ this.strokeStyle = s.strokeStyle;
256
+ this.lineWidth = s.lineWidth;
257
+ this.lineCap = s.lineCap;
258
+ this.lineJoin = s.lineJoin;
259
+ this.miterLimit = s.miterLimit;
260
+ this.globalAlpha = s.globalAlpha;
261
+ this._gco = s.gco;
262
+ this._textStyle = s.textStyle;
263
+ this._lastFontString = s.fontString;
264
+ this.textAlign = s.textAlign;
265
+ this.textBaseline = s.textBaseline;
266
+ this._m = s.m;
267
+ if (s.clips !== this._clips) {
268
+ this._clips = s.clips;
269
+ this._rebuildClipMask();
270
+ }
271
+ }
272
+
273
+ // ------------------------------------------------------------------
274
+ // transform
275
+
276
+ translate(x, y) {
277
+ this._m = matMultiply(this._m, [1, 0, 0, 1, x, y]);
278
+ }
279
+
280
+ rotate(angle) {
281
+ const c = Math.cos(angle);
282
+ const s = Math.sin(angle);
283
+ this._m = matMultiply(this._m, [c, s, -s, c, 0, 0]);
284
+ }
285
+
286
+ scale(x, y = x) {
287
+ this._m = matMultiply(this._m, [x, 0, 0, y, 0, 0]);
288
+ }
289
+
290
+ transform(a, b, c, d, e, f) {
291
+ this._m = matMultiply(this._m, [a, b, c, d, e, f]);
292
+ }
293
+
294
+ setTransform(a, b, c, d, e, f) {
295
+ if (typeof a === 'object' && a !== null) {
296
+ const m = a;
297
+ this._m = Array.isArray(m) ? m.slice(0, 6) : [m.a, m.b, m.c, m.d, m.e, m.f];
298
+ return;
299
+ }
300
+ if (a === undefined) return this.resetTransform();
301
+ this._m = [a, b, c, d, e, f];
302
+ }
303
+
304
+ resetTransform() {
305
+ this._m = [1, 0, 0, 1, 0, 0];
306
+ }
307
+
308
+ getTransform() {
309
+ const [a, b, c, d, e, f] = this._m;
310
+ return { a, b, c, d, e, f };
311
+ }
312
+
313
+ // ------------------------------------------------------------------
314
+ // path building (default path is recorded in device space, per spec)
315
+
316
+ beginPath() {
317
+ this._path = new Path2D();
318
+ }
319
+
320
+ closePath() {
321
+ this._path.closePath();
322
+ }
323
+
324
+ moveTo(x, y) {
325
+ const [dx, dy] = matApply(this._m, x, y);
326
+ this._path.moveTo(dx, dy);
327
+ }
328
+
329
+ lineTo(x, y) {
330
+ const [dx, dy] = matApply(this._m, x, y);
331
+ this._path.lineTo(dx, dy);
332
+ }
333
+
334
+ bezierCurveTo(x1, y1, x2, y2, x, y) {
335
+ const [dx1, dy1] = matApply(this._m, x1, y1);
336
+ const [dx2, dy2] = matApply(this._m, x2, y2);
337
+ const [dx, dy] = matApply(this._m, x, y);
338
+ this._path.bezierCurveTo(dx1, dy1, dx2, dy2, dx, dy);
339
+ }
340
+
341
+ quadraticCurveTo(x1, y1, x, y) {
342
+ const [dx1, dy1] = matApply(this._m, x1, y1);
343
+ const [dx, dy] = matApply(this._m, x, y);
344
+ this._path.quadraticCurveTo(dx1, dy1, dx, dy);
345
+ }
346
+
347
+ // append user-space segments ({start, cmds}) through the current transform
348
+ _appendUserSegments({ start, cmds }) {
349
+ const [dsx, dsy] = matApply(this._m, start.x, start.y);
350
+ if (this._path._x === null) this._path.moveTo(dsx, dsy);
351
+ else this._path.lineTo(dsx, dsy);
352
+ this._path._append(transformCommands(cmds, this._m));
353
+ }
354
+
355
+ arc(x, y, r, startAngle, endAngle, counterclockwise = false) {
356
+ if (r < 0) throw new RangeError('arc: negative radius');
357
+ this._appendUserSegments(ellipseSegments(x, y, r, r, 0, startAngle, endAngle, counterclockwise));
358
+ }
359
+
360
+ ellipse(x, y, rx, ry, rotation, startAngle, endAngle, counterclockwise = false) {
361
+ if (rx < 0 || ry < 0) throw new RangeError('ellipse: negative radius');
362
+ this._appendUserSegments(ellipseSegments(x, y, rx, ry, rotation, startAngle, endAngle, counterclockwise));
363
+ }
364
+
365
+ arcTo(x1, y1, x2, y2, r) {
366
+ if (this._path._x === null) return this.moveTo(x1, y1);
367
+ const inv = matInvert(this._m);
368
+ if (!inv) return;
369
+ const [ux, uy] = matApply(inv, this._path._x, this._path._y);
370
+ const tmp = new Path2D();
371
+ tmp.moveTo(ux, uy);
372
+ tmp.arcTo(x1, y1, x2, y2, r);
373
+ // drop the seed moveTo: the current point is already there
374
+ this._path._append(transformCommands(tmp._cmds.slice(1), this._m));
375
+ }
376
+
377
+ rect(x, y, w, h) {
378
+ const tmp = new Path2D();
379
+ tmp.rect(x, y, w, h);
380
+ this._path.addPath(tmp, this._m);
381
+ }
382
+
383
+ roundRect(x, y, w, h, radii) {
384
+ const tmp = new Path2D();
385
+ tmp.roundRect(x, y, w, h, radii);
386
+ this._path.addPath(tmp, this._m);
387
+ }
388
+
389
+ // args: ([path], [fillRule]) — returns device-space polys + rule
390
+ _polysFor(args) {
391
+ if (args[0] instanceof Path2D) {
392
+ return {
393
+ polys: flattenPath(args[0]._cmds, this._m),
394
+ rule: args[1] === 'evenodd' ? 'evenodd' : 'nonzero'
395
+ };
396
+ }
397
+ return {
398
+ polys: flattenPath(this._path._cmds, null),
399
+ rule: args[0] === 'evenodd' ? 'evenodd' : 'nonzero'
400
+ };
401
+ }
402
+
403
+ // ------------------------------------------------------------------
404
+ // rasterization plumbing
405
+
406
+ _ensureFillMask() {
407
+ if (this.fillMask) return;
408
+ this.fillMaskDrawable = new Pixmap(this.window.app, {
409
+ depth: 8,
410
+ width: this.width,
411
+ height: this.height
412
+ });
413
+ this.fillMask = new Picture(this.window.app, {
414
+ drawable: this.fillMaskDrawable,
415
+ format: this.Render.a8
416
+ });
417
+ }
418
+
419
+ _ensureClipMask() {
420
+ if (this.clipMask) return;
421
+ this.clipMaskDrawable = new Pixmap(this.window.app, {
422
+ depth: 8,
423
+ width: this.width,
424
+ height: this.height
425
+ });
426
+ this.clipMask = new Picture(this.window.app, {
427
+ drawable: this.clipMaskDrawable,
428
+ format: this.Render.a8
429
+ });
430
+ }
431
+
432
+ _rasterizePolys(picture, polys, rule) {
433
+ const flat = [];
434
+ for (const p of polys) if (p.pts.length >= 6) flat.push(p.pts);
435
+ if (!flat.length) return;
436
+ const traps = trapezoidize(flat, 0, 0, [], rule);
437
+ // stay under the server's maximum request size
438
+ const chunk = 4000 * 6;
439
+ for (let i = 0; i < traps.length; i += chunk) {
440
+ this.Render.AddTraps(picture.id, 0, 0, traps.slice(i, i + chunk));
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Core fill: rasterize device-space polys into the scratch a8 mask,
446
+ * scale by globalAlpha, intersect with the clip, composite the source.
447
+ */
448
+ _fillPolys(polys, rule, { src = null, op = null, alpha = null } = {}) {
449
+ if (!polys.length) return;
450
+ src = src ?? this._backgroundPicture;
451
+ op = op ?? this._op();
452
+ alpha = alpha ?? this.globalAlpha;
453
+ if (alpha <= 0) return;
454
+
455
+ this._ensureFillMask();
456
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
457
+ this._rasterizePolys(this.fillMask, polys, rule);
458
+
459
+ if (alpha < 1) {
460
+ // In with a constant color scales the a8 coverage by that alpha
461
+ this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, alpha], [0, 0, this.width, this.height]);
462
+ }
463
+ if (this.clipMask) {
464
+ this.Render.Composite(
465
+ this.Render.PictOp.In,
466
+ this.clipMask.id,
467
+ 0,
468
+ this.fillMask.id,
469
+ 0, 0, 0, 0, 0, 0,
470
+ this.width,
471
+ this.height
472
+ );
473
+ }
474
+ this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
475
+ this._markDirty();
476
+ }
477
+
478
+ _strokePolys(polys, { src = null } = {}) {
479
+ src = src ?? this._strokePicture;
480
+ if (this.globalAlpha <= 0) return;
481
+ // approximate transform-aware line width by the average scale factor
482
+ const det = this._m[0] * this._m[3] - this._m[1] * this._m[2];
483
+ const thickness = this.lineWidth * (Math.sqrt(Math.abs(det)) || 1);
484
+ const stroke = extrudePolyline({
485
+ thickness,
486
+ cap: LINE_CAP[this.lineCap] || 'butt',
487
+ join: LINE_JOIN[this.lineJoin] || 'miter',
488
+ miterLimit: this.miterLimit
489
+ });
490
+
491
+ const tris = [];
492
+ for (const poly of polys) {
493
+ // drop consecutive (near-)duplicate points — zero-length segments make
494
+ // extrude-polyline emit NaN joins that turn into spikes at the origin
495
+ const pts = [];
496
+ let lx = Infinity;
497
+ let ly = Infinity;
498
+ for (let i = 0; i < poly.pts.length; i += 2) {
499
+ const x = poly.pts[i];
500
+ const y = poly.pts[i + 1];
501
+ if (!(Math.abs(x - lx) > 1e-6 || Math.abs(y - ly) > 1e-6)) continue;
502
+ pts.push([x, y]);
503
+ lx = x;
504
+ ly = y;
505
+ }
506
+ if (pts.length >= 2 && poly.closed) {
507
+ const [fx, fy] = pts[0];
508
+ if (Math.abs(fx - lx) > 1e-6 || Math.abs(fy - ly) > 1e-6) pts.push([fx, fy]);
509
+ }
510
+ if (pts.length < 2) continue;
511
+ const mesh = stroke.build(pts);
512
+ for (const tri of mesh.cells) {
513
+ for (let i = 0; i < 3; ++i) {
514
+ tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
515
+ }
516
+ }
517
+ }
518
+ if (!tris.length) return;
519
+
520
+ const op = this._op();
521
+ const direct = this.globalAlpha >= 1 && !this.clipMask && op === this.Render.PictOp.Over;
522
+ const chunk = 4000 * 6;
523
+ if (direct) {
524
+ for (let i = 0; i < tris.length; i += chunk) {
525
+ this.Render.Triangles(op, src.id, 0, 0, this.picture.id, this.Render.a8, tris.slice(i, i + chunk));
526
+ }
527
+ this._markDirty();
528
+ return;
529
+ }
530
+
531
+ // render coverage into the scratch mask, then composite through it so
532
+ // the stroke honors clip / globalAlpha / composite op
533
+ this._ensureFillMask();
534
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
535
+ const opaque = this.createSolidPicture(0, 0, 0, 1);
536
+ for (let i = 0; i < tris.length; i += chunk) {
537
+ this.Render.Triangles(this.Render.PictOp.Add, opaque.id, 0, 0, this.fillMask.id, this.Render.a8, tris.slice(i, i + chunk));
538
+ }
539
+ if (this.globalAlpha < 1) {
540
+ this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, this.globalAlpha], [0, 0, this.width, this.height]);
541
+ }
542
+ if (this.clipMask) {
543
+ this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
544
+ }
545
+ this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
546
+ this._markDirty();
547
+ }
548
+
549
+ // combined clip ∩ globalAlpha mask for direct composites (rect/image
550
+ // fast paths); returns a picture id or 0. Reuses the fill scratch mask.
551
+ _compositeMask() {
552
+ if (this.globalAlpha >= 1) return this.clipMask ? this.clipMask.id : 0;
553
+ this._ensureFillMask();
554
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, this.globalAlpha], [0, 0, this.width, this.height]);
555
+ if (this.clipMask) {
556
+ this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
557
+ }
558
+ return this.fillMask.id;
559
+ }
560
+
561
+ // ------------------------------------------------------------------
562
+ // drawing
563
+
564
+ clearRect(x, y, w, h) {
565
+ if (matIsIdentity(this._m) && !this.clipMask) {
566
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.picture.id, [1, 1, 1, 1], [x, y, w, h]);
567
+ this._markDirty();
568
+ return;
569
+ }
570
+ const tmp = new Path2D();
571
+ tmp.rect(x, y, w, h);
572
+ // clear = opaque white, ignoring alpha and the composite op (but
573
+ // honoring the clip), matching the identity fast path above
574
+ this._fillPolys(flattenPath(tmp._cmds, this._m), 'nonzero', {
575
+ src: this.createSolidPicture(1, 1, 1, 1),
576
+ op: this.Render.PictOp.Over,
577
+ alpha: 1
578
+ });
579
+ }
580
+
581
+ fillRect(x, y, w, h) {
582
+ if (matIsIdentity(this._m)) {
583
+ const mask = this._compositeMask();
584
+ this.Render.Composite(
585
+ this._op(),
586
+ this._backgroundPicture.id,
587
+ mask,
588
+ this.picture.id,
589
+ x, y,
590
+ x, y,
591
+ x, y,
592
+ w, h
593
+ );
594
+ this._markDirty();
595
+ return;
596
+ }
597
+ const tmp = new Path2D();
598
+ tmp.rect(x, y, w, h);
599
+ this._fillPolys(flattenPath(tmp._cmds, this._m), 'nonzero');
600
+ }
601
+
602
+ strokeRect(x, y, w, h) {
603
+ const tmp = new Path2D();
604
+ tmp.rect(x, y, w, h);
605
+ this._strokePolys(flattenPath(tmp._cmds, this._m));
606
+ }
607
+
608
+ fill(...args) {
609
+ const { polys, rule } = this._polysFor(args);
610
+ this._fillPolys(polys, rule);
611
+ }
612
+
613
+ stroke(...args) {
614
+ const polys =
615
+ args[0] instanceof Path2D
616
+ ? flattenPath(args[0]._cmds, this._m)
617
+ : flattenPath(this._path._cmds, null);
618
+ this._strokePolys(polys);
619
+ }
620
+
621
+ /**
622
+ * Intersect the clip region with the given path (or the current path).
623
+ * Cleared by restore() to the state at the matching save() — like the
624
+ * browser canvas, there is no other way to widen the clip again.
625
+ */
626
+ clip(...args) {
627
+ const { polys, rule } = this._polysFor(args);
628
+ const entry = { polys, rule };
629
+ // copy-on-write: earlier save() snapshots keep their own clip list
630
+ this._clips = this._clips.concat([entry]);
631
+
632
+ if (this._clips.length === 1) {
633
+ this._ensureClipMask();
634
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.clipMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
635
+ this._rasterizePolys(this.clipMask, polys, rule);
636
+ return;
637
+ }
638
+ this._intersectClip(entry);
639
+ }
640
+
641
+ // rasterize one clip entry into a temp a8 and In-composite it onto the mask
642
+ _intersectClip(entry) {
643
+ const tmpPixmap = new Pixmap(this.window.app, { depth: 8, width: this.width, height: this.height });
644
+ const tmpMask = new Picture(this.window.app, { drawable: tmpPixmap, format: this.Render.a8 });
645
+ this.Render.FillRectangles(this.Render.PictOp.Src, tmpMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
646
+ this._rasterizePolys(tmpMask, entry.polys, entry.rule);
647
+ this.Render.Composite(this.Render.PictOp.In, tmpMask.id, 0, this.clipMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
648
+ tmpMask.destroy();
649
+ tmpPixmap.destroy();
650
+ }
651
+
652
+ _rebuildClipMask() {
653
+ if (!this._clips.length) {
654
+ if (this.clipMask) {
655
+ this.clipMask.destroy();
656
+ this.clipMaskDrawable.destroy();
657
+ this.clipMask = this.clipMaskDrawable = null;
658
+ }
659
+ return;
660
+ }
661
+ this._ensureClipMask();
662
+ this.Render.FillRectangles(this.Render.PictOp.Src, this.clipMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
663
+ this._rasterizePolys(this.clipMask, this._clips[0].polys, this._clips[0].rule);
664
+ for (let i = 1; i < this._clips.length; i++) this._intersectClip(this._clips[i]);
665
+ }
666
+
667
+ isPointInPath(...args) {
668
+ let path = null;
669
+ let rest = args;
670
+ if (args[0] instanceof Path2D) {
671
+ path = args[0];
672
+ rest = args.slice(1);
673
+ }
674
+ const [x, y, rule] = rest;
675
+ const polys = path ? flattenPath(path._cmds, this._m) : flattenPath(this._path._cmds, null);
676
+ return polysContain(polys, x, y, rule === 'evenodd' ? 'evenodd' : 'nonzero');
677
+ }
678
+
679
+ // ------------------------------------------------------------------
680
+ // text
681
+
682
+ _resolvedTextStyle() {
683
+ if (!this._textStyle) this.font = DEFAULT_FONT;
684
+ return this._textStyle;
685
+ }
686
+
687
+ // horizontal offset of the alignment point, given the shaped width
688
+ _alignOffset(shaped) {
689
+ let align = this.textAlign;
690
+ if (align === 'start') align = shaped.baseLevel & 1 ? 'right' : 'left';
691
+ if (align === 'end') align = shaped.baseLevel & 1 ? 'left' : 'right';
692
+ if (align === 'center') return -shaped.width / 2;
693
+ if (align === 'right') return -shaped.width;
694
+ return 0;
695
+ }
696
+
697
+ // vertical distance from the requested y to the baseline
698
+ _baselineOffset(metrics) {
699
+ switch (this.textBaseline) {
700
+ case 'top':
701
+ return metrics.ascent;
702
+ case 'hanging':
703
+ return metrics.ascent * 0.8;
704
+ case 'middle':
705
+ return (metrics.ascent - metrics.descent) / 2;
706
+ case 'bottom':
707
+ case 'ideographic':
708
+ return -metrics.descent;
709
+ default:
710
+ // 'alphabetic'
711
+ return 0;
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Draw text with full shaping: OpenType kerning/ligatures, complex-script
717
+ * contextual forms, bidi reordering and automatic font fallback.
718
+ * Glyphs are uploaded to the server once per (face, size) and referenced
719
+ * by 1–2 byte ids afterwards — see docs/text.md.
720
+ *
721
+ * The current transform's translation applies to the anchor point;
722
+ * glyphs themselves are not rotated/scaled by the transform (size the
723
+ * font via `ctx.font` instead).
724
+ */
725
+ fillText(text, x, y) {
726
+ text = String(text ?? '');
727
+ if (!text) return;
728
+ const style = this._resolvedTextStyle();
729
+ const app = this.window.app;
730
+ const shaped = app.fonts.shape(text, style);
731
+ const [tx, ty] = matApply(this._m, x, y);
732
+ const ox = tx + this._alignOffset(shaped);
733
+ const oy = ty + this._baselineOffset(style.font.metrics(style.size));
734
+
735
+ const positioned = [];
736
+ let cursor = ox;
737
+ for (const run of reorderRuns(shaped.runs)) {
738
+ positioned.push({ run, x: cursor, y: oy });
739
+ cursor += run.width;
740
+ }
741
+ drawGlyphRuns(app, this.Render.PictOp.Over, this._backgroundPicture.id, this.picture.id, positioned);
742
+ this._markDirty();
743
+ }
744
+
745
+ /**
746
+ * Measure shaped text. Returns a canvas-style TextMetrics object:
747
+ * `width` (advance), actual bounding box (ink extents relative to the
748
+ * origin), and font bounding box (from font metrics).
749
+ */
750
+ measureText(text) {
751
+ const style = this._resolvedTextStyle();
752
+ const shaped = this.window.app.fonts.shape(String(text ?? ''), style);
753
+ let minX = 0;
754
+ let maxX = 0;
755
+ let minY = 0;
756
+ let maxY = 0;
757
+ let cursor = 0;
758
+ for (const run of reorderRuns(shaped.runs)) {
759
+ for (const g of run.glyphs) {
760
+ const e = run.font.glyphExtents(g.id, run.size);
761
+ const gx = cursor + g.dx;
762
+ const gy = -g.dy;
763
+ if (gx + e.minX < minX) minX = gx + e.minX;
764
+ if (gx + e.maxX > maxX) maxX = gx + e.maxX;
765
+ if (gy + e.minY < minY) minY = gy + e.minY;
766
+ if (gy + e.maxY > maxY) maxY = gy + e.maxY;
767
+ cursor += g.ax;
768
+ }
769
+ }
770
+ const m = style.font.metrics(style.size);
771
+ return {
772
+ width: shaped.width,
773
+ actualBoundingBoxLeft: -minX,
774
+ actualBoundingBoxRight: maxX,
775
+ actualBoundingBoxAscent: -minY,
776
+ actualBoundingBoxDescent: maxY,
777
+ fontBoundingBoxAscent: m.ascent,
778
+ fontBoundingBoxDescent: m.descent,
779
+ emHeightAscent: m.ascent,
780
+ emHeightDescent: m.descent,
781
+ // legacy ntk field: ink height
782
+ height: maxY - minY
783
+ };
784
+ }
785
+
786
+ /**
787
+ * Lay out (possibly styled) text for a target width without drawing it —
788
+ * returns a TextLayout (lines, metrics) that can be inspected and then
789
+ * drawn with `layout.draw(ctx, x, y)`. The current `ctx.font` is the base
790
+ * style; see docs/text.md for spans and options.
791
+ *
792
+ * @param {string|Array} content plain text or spans [{ text, ...style }]
793
+ * @param {object} [options] { maxWidth, align, lineHeight, direction }
794
+ */
795
+ layoutText(content, options = {}) {
796
+ const style = this._resolvedTextStyle();
797
+ return new TextLayout(this.window.app.fonts, content, style, options);
798
+ }
799
+
800
+ /**
801
+ * CSS-style font shorthand, e.g. `'bold italic 40px "DejaVu Sans", serif'`.
802
+ * Resolution goes through `app.fonts`: fonts registered with
803
+ * `app.fonts.load()` win over system (fontconfig) lookup.
804
+ */
805
+ set font(val) {
806
+ if (!val || typeof val !== 'string') return;
807
+ const parsed = parseFontStyle(val);
808
+ if (!parsed) return;
809
+ const style = {
810
+ family: parsed.family,
811
+ weight: parsed.weight,
812
+ style: parsed.style,
813
+ size: parsed.size
814
+ };
815
+ style.font = this.window.app.fonts.match(style.family, style);
816
+ this._lastFontString = val;
817
+ this._textStyle = style;
818
+ }
819
+
820
+ get font() {
821
+ return this._lastFontString || DEFAULT_FONT;
822
+ }
823
+
824
+ // ------------------------------------------------------------------
825
+ // gradients / images
826
+
827
+ createLinearGradient(x0, y0, x1, y1) {
828
+ return new CanvasGradient('linear', this, x0, y0, x1, y1);
829
+ }
830
+
831
+ createRadialGradient(x0, y0, r0, x1, y1, r1) {
832
+ return new CanvasGradient('radial', this, x0, y0, x1, y1, r0, r1);
833
+ }
834
+
835
+ createConicalGradient(x0, y0, angle) {
836
+ return new CanvasGradient('conical', this, x0, y0, angle);
837
+ }
838
+
839
+ createImageData(w, h) {
840
+ return {
841
+ width: w,
842
+ height: h,
843
+ data: Buffer.alloc(w * h * 4)
844
+ };
845
+ }
846
+
847
+ // TODO: remove, add drawImage
848
+ putImageData(data, x, y) {
849
+ // todo: use constants
850
+ this.X.PutImage(2, this._target.id, this._gc, data.width, data.height, x, y, 0, 24, data.data);
851
+ this._markDirty();
852
+ }
853
+
854
+ getImageData(x, y, w, h, cb) {
855
+ // reads the backing pixmap on double-buffered windows: always valid,
856
+ // even where the window is occluded on screen
857
+ this.X.GetImage(2, this._target.id, x, y, w, h, 0xffffffff, cb);
858
+ }
859
+
860
+ /**
861
+ * Draw an image, with the three canvas signatures:
862
+ *
863
+ * ctx.drawImage(image, dx, dy)
864
+ * ctx.drawImage(image, dx, dy, dWidth, dHeight)
865
+ * ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
866
+ *
867
+ * `image` is an ntk `Image` (see `loadImage()` — PNG/JPEG decoded
868
+ * client-side, uploaded to the server once and composited from there;
869
+ * scaling is server-side with bilinear filtering), another
870
+ * RenderingContext2d, or a node-canvas-like object. Images honor the
871
+ * current transform (server-side, via the picture transform).
872
+ */
873
+ drawImage(image, ...args) {
874
+ if (image instanceof Image) {
875
+ let sx = 0;
876
+ let sy = 0;
877
+ let sw = image.width;
878
+ let sh = image.height;
879
+ let dx = 0;
880
+ let dy = 0;
881
+ let dw;
882
+ let dh;
883
+ if (args.length >= 8) {
884
+ [sx, sy, sw, sh, dx, dy, dw, dh] = args;
885
+ } else if (args.length >= 4) {
886
+ [dx, dy, dw, dh] = args;
887
+ } else {
888
+ [dx = 0, dy = 0] = args;
889
+ dw = sw;
890
+ dh = sh;
891
+ }
892
+ if (!(dw > 0) || !(dh > 0) || !(sw > 0) || !(sh > 0)) return;
893
+
894
+ const picture = image.picture(this.window.app);
895
+ const op = this._op();
896
+
897
+ if (!matIsIdentity(this._m)) {
898
+ this._drawImageTransformed(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
899
+ return;
900
+ }
901
+
902
+ const mask = this._compositeMask();
903
+ const scaled = dw !== sw || dh !== sh;
904
+ if (scaled) {
905
+ // the picture transform maps composite coordinates into source
906
+ // samples: dest pixel (i, j) reads (sx + i*sw/dw, sy + j*sh/dh)
907
+ this.Render.SetPictureTransform(picture.id, [sw / dw, 0, sx, 0, sh / dh, sy, 0, 0, 1]);
908
+ picture.setFilter('bilinear');
909
+ this.Render.Composite(
910
+ op,
911
+ picture.id,
912
+ mask,
913
+ this.picture.id,
914
+ 0, 0,
915
+ dx, dy,
916
+ dx, dy,
917
+ dw, dh
918
+ );
919
+ // restore defaults so the cached upload can be reused as-is
920
+ this.Render.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
921
+ picture.setFilter('nearest');
922
+ } else {
923
+ this.Render.Composite(
924
+ op,
925
+ picture.id,
926
+ mask,
927
+ this.picture.id,
928
+ sx, sy,
929
+ dx, dy,
930
+ dx, dy,
931
+ dw, dh
932
+ );
933
+ }
934
+ this._markDirty();
935
+ return;
936
+ }
937
+
938
+ const sWidth = image.width;
939
+ const sHeight = image.height;
940
+ const sx = 0;
941
+ const sy = 0;
942
+
943
+ // TODO: allow to draw Window, Pixmap, Picture
944
+ if (image instanceof RenderingContext2d) {
945
+ // TODO: if need to scale, set transform
946
+ // TODO: respect global compositing blend mode
947
+ this.Render.Composite(
948
+ this.Render.PictOp.Over,
949
+ image.picture.id,
950
+ this._compositeMask(),
951
+ this.picture.id,
952
+ 0, 0, 0, 0, 0, 0,
953
+ this.width,
954
+ this.height
955
+ );
956
+ this._markDirty();
957
+ } else if (image && image.context && typeof image.context.getImageData === 'function') {
958
+ // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
959
+ const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
960
+ // rgba -> bgra
961
+ const data = Buffer.alloc(imageData.data.length);
962
+ for (let i = 0; i < data.length; i += 4) {
963
+ data[i + 2] = imageData.data[i];
964
+ data[i + 1] = imageData.data[i + 1];
965
+ data[i] = imageData.data[i + 2];
966
+ data[i + 3] = imageData.data[i + 3]; // multiply source alpha by context globalAlpha?
967
+ }
968
+
969
+ const pixmap = new Pixmap(this.window.app, { depth: 32, width: sWidth, height: sHeight });
970
+ const picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32 });
971
+ pixmap._gc = this.X.AllocID();
972
+ this.X.CreateGC(pixmap._gc, pixmap.id);
973
+ this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, sx, sy, 0, 32, data);
974
+
975
+ this.Render.Composite(
976
+ this.Render.PictOp.Over,
977
+ picture.id,
978
+ this._compositeMask(),
979
+ this.picture.id,
980
+ 0, 0, 0, 0, 0, 0,
981
+ this.width,
982
+ this.height
983
+ );
984
+ this._markDirty();
985
+ }
986
+ }
987
+
988
+ // general-affine drawImage: fold CTM + dest rect + source offset into the
989
+ // source picture transform and composite over the transformed bbox
990
+ _drawImageTransformed(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
991
+ // maps source-local (u, v) in [0..sw)x[0..sh) to device coordinates
992
+ const M = matMultiply(this._m, [dw / sw, 0, 0, dh / sh, dx, dy]);
993
+ const inv = matInvert(M);
994
+ if (!inv) return;
995
+
996
+ // device bbox of the transformed dest rect, clamped to the canvas
997
+ const corners = [
998
+ matApply(this._m, dx, dy),
999
+ matApply(this._m, dx + dw, dy),
1000
+ matApply(this._m, dx, dy + dh),
1001
+ matApply(this._m, dx + dw, dy + dh)
1002
+ ];
1003
+ const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
1004
+ const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
1005
+ const x1 = Math.min(this.width, Math.ceil(Math.max(...corners.map((c) => c[0]))));
1006
+ const y1 = Math.min(this.height, Math.ceil(Math.max(...corners.map((c) => c[1]))));
1007
+ if (x1 <= x0 || y1 <= y0) return;
1008
+
1009
+ // device -> source sample (adding the source-rect origin)
1010
+ const srcM = [inv[0], inv[1], inv[2], inv[3], inv[4] + sx, inv[5] + sy];
1011
+ this.Render.SetPictureTransform(picture.id, [srcM[0], srcM[2], srcM[4], srcM[1], srcM[3], srcM[5], 0, 0, 1]);
1012
+ picture.setFilter('bilinear');
1013
+ const mask = this._compositeMask();
1014
+ this.Render.Composite(op, picture.id, mask, this.picture.id, x0, y0, x0, y0, x0, y0, x1 - x0, y1 - y0);
1015
+ this.Render.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
1016
+ picture.setFilter('nearest');
1017
+ this._markDirty();
1018
+ }
1019
+ }
1020
+
1021
+ class CanvasGradient {
1022
+ constructor(type, ctx, p0, p1, p2, p3, p4, p5) {
1023
+ this.type = type;
1024
+ this.stops = [];
1025
+ this.ctx = ctx;
1026
+ this._id = null;
1027
+ this._picture = null;
1028
+
1029
+ this.x0 = p0;
1030
+ this.y0 = p1;
1031
+ if (type === 'linear' || type === 'radial') {
1032
+ this.x1 = p2;
1033
+ this.y1 = p3;
1034
+ this.r0 = p4;
1035
+ this.r1 = p5;
1036
+ } else {
1037
+ this.angle = p2;
1038
+ }
1039
+ // TODO: check if ChangePictureAttribute works on gradients ( set repeat edge pixels flag )
1040
+ }
1041
+
1042
+ addColorStop(offset, color) {
1043
+ this.stops.push([offset, parseColor(color)]);
1044
+ return this;
1045
+ }
1046
+
1047
+ // gradient pictures are created lazily, on first use as a fill/stroke style
1048
+ get id() {
1049
+ if (this._id !== null) return this._id;
1050
+
1051
+ const Render = this.ctx.Render;
1052
+ const X = this.ctx.X;
1053
+
1054
+ this._id = X.AllocID();
1055
+ switch (this.type) {
1056
+ case 'linear':
1057
+ Render.LinearGradient(this._id, [this.x0, this.y0], [this.x1, this.y1], this.stops);
1058
+ break;
1059
+ case 'radial':
1060
+ Render.RadialGradient(this._id, [this.x0, this.y0], [this.x1, this.y1], this.r0, this.r1, this.stops);
1061
+ break;
1062
+ case 'conical':
1063
+ Render.ConicalGradient(this._id, [this.x0, this.y0], this.angle, this.stops);
1064
+ break;
1065
+ default:
1066
+ throw new Error('unknown gradient type');
1067
+ }
1068
+ // wrap with picture so FreePicture is invoked on gc via FinalizationRegistry
1069
+ this._picture = new Picture(this.ctx.window.app, { id: this._id });
1070
+ return this._id;
1071
+ }
1072
+ }
1073
+
1074
+ // register context
1075
+ Drawable.renderingContextFactory['2d'] = (window) => new RenderingContext2d(window);
1076
+
1077
+ export default RenderingContext2d;
1078
+ export { CanvasGradient };