ntk 2.1.1 → 3.0.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 (48) hide show
  1. package/README.md +115 -110
  2. package/lib/app.js +48 -15
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +10 -16
  5. package/lib/events_map.js +81 -87
  6. package/lib/fontconfig.js +122 -14
  7. package/lib/glyphset.js +36 -18
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +110 -52
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +76 -38
  12. package/lib/pixmap.js +34 -25
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +994 -517
  15. package/lib/renderingcontext_opengl.js +38 -31
  16. package/lib/renderingcontext_x11.js +56 -64
  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 +568 -246
  33. package/package.json +35 -32
  34. package/.npmignore +0 -15
  35. package/examples/example-await.js +0 -23
  36. package/examples/example-co.js +0 -13
  37. package/examples/example.js +0 -14
  38. package/examples/fill-styles.js +0 -39
  39. package/examples/glclock.js +0 -100
  40. package/examples/glxpixmap.js +0 -12
  41. package/examples/gradient.js +0 -41
  42. package/examples/package.json +0 -17
  43. package/examples/simpletex.js +0 -72
  44. package/examples/teapot.js +0 -104
  45. package/examples/teapot.json +0 -6
  46. package/examples/text-rendering.js +0 -44
  47. package/lib/fontface.js +0 -109
  48. package/lib/widgets/webview.js +0 -33
@@ -1,601 +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');
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
+ };
5
43
 
6
- var DEFAULT_FONT = '20px sans-serif';
7
- var _fonts = {};
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' };
8
47
 
9
- module.exports = function(window, callback) {
10
- return new RenderingContext2d(window);
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]];
11
52
  }
12
53
 
13
- function RenderingContext2d(window) {
14
- var X = window.X;
15
- this.X = X;
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
+ }
16
89
 
17
- // TODO lazily create via getter
18
- if (!window._gc) {
19
- window._gc = X.AllocID();
20
- X.CreateGC(window._gc, window.id);
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;
21
113
  }
22
114
 
23
- this.window = window;
24
- this.Render = this.window.app.display.Render;
25
- var format;
26
- // TODO: support a8
27
- if (window.depth === 32)
28
- format = this.Render.rgba32;
29
- else
30
- format = this.Render.rgb24;
115
+ _bindTarget() {
116
+ const target = this.window._backing || this.window;
117
+ if (this._target === target) return;
118
+ this._target = target;
31
119
 
32
- this.picture = new Picture(window.app, { drawable: window, format: format, polyEdge: 1, polyMode: 1 });
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
+ }
33
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
+ }
34
153
 
35
- this.fillMask = null;
36
- this.fillMaskDrawable = null;
37
- this.clipMask = null;
38
- this.clipMaskDrawable = null;
154
+ // notify a double-buffered window that its backing content changed
155
+ _markDirty() {
156
+ if (typeof this.window._markDirty === 'function') this.window._markDirty();
157
+ }
39
158
 
40
- this.fillStyle = 'white';
41
- this.strokeStyle = 'black';
42
- this.lineWidth = 1;
43
- };
159
+ // html context2d compatibility: canvas.getContext('2d').canvas == canvas
160
+ get canvas() {
161
+ return this.window;
162
+ }
44
163
 
45
- var RC = RenderingContext2d.prototype;
164
+ get width() {
165
+ return this.window.width;
166
+ }
46
167
 
47
- // TODO: remove, add drawImage
48
- RC.putImageData = function(data, x, y) {
49
- // todo: use constants
50
- this.X.PutImage(2, this.window.id, this.window._gc, data.width, data.height, x, y, 0, 24, data.data);
51
- };
168
+ get height() {
169
+ return this.window.height;
170
+ }
52
171
 
53
- // TODO: use WeakMap
54
- var _solidPix = new WeakMap();
55
- RC.createSolidPicture = function(r, g, b, a) {
56
- var key = [r, g, b, a].join('|');
57
- if (_solidPix[key]) {
58
- return _solidPix[key];
59
- }
60
- // TODO: use window.createPixmap
61
- var pixmap = new Pixmap(this.window.app, {depth: 32, width: 1, height: 1});
62
- var pictSolidPix = this.window.X.AllocID();
63
- this.Render.CreatePicture(pictSolidPix, pixmap.id, this.Render.rgba32, {repeat: 1});
64
- this.Render.FillRectangles(1, pictSolidPix, [r, g, b, a], [0, 0, 100, 100]);
65
- var p = new Picture(this.window.app, { id: pictSolidPix });
66
- _solidPix[key] = p;
67
- return p;
68
- };
172
+ _op() {
173
+ return this.Render.PictOp[GCO_TO_PICTOP[this._gco] || 'Over'];
174
+ }
69
175
 
70
- RC.setBackground = function(pic) {
71
- this._background = pic;
72
- };
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
+ }
73
199
 
74
- RC.__defineGetter__('_backgound', function() {
75
- if (this._backgroundPicture)
76
- return this._backgroundPicture;
77
- else {
78
- this._backgroundPicture = this.createSolidPicture(1, 1, 1, 1);
79
- return this._backgroundPicture;
80
- }
81
- });
82
-
83
- RC.__defineSetter__('fillStyle', function(value) {
84
- if (typeof value === 'string' || Array.isArray(value) ) {
85
- var c = parseColor(value);
86
- this._backgroundPicture = this.createSolidPicture(c[0], c[1], c[2], c[3]);
87
- return this._backgroundPicture;
88
- } else if (value.constructor === Picture || value.constructor === CanvasGradient) {
89
- this._backgroundPicture = value;
90
- return this._backgroundPicture;
91
- } else {
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
+ }
92
208
  throw new Error('Unknown fill style');
93
209
  }
94
- });
95
-
96
- RC.__defineSetter__('strokeStyle', function(value) {
97
- if (typeof value === 'string' || Array.isArray(value) ) {
98
- var c;
99
- if (Array.isArray(value))
100
- c = value;
101
- else
102
- c = parseColor(value);
103
- this._strokePicture = this.createSolidPicture(c[0], c[1], c[2], c[3]);
104
- return this._strokePicture;
105
- } else if (value.constructor === Picture || value.constructor === CanvasGradient) {
106
- this._strokePicture = value;
107
- return this._strokePicture;
108
- } else {
109
- throw new Error('Unknown stroke style');
110
- }
111
- });
112
-
113
-
114
- // html context2d compatibility: canvas.getContext('2d').canvas == canvas
115
- RC.__defineGetter__('canvas', function(){
116
- return this.window;
117
- });
118
-
119
- RC.__defineGetter__('width', function(){
120
- return this.window.width;
121
- });
122
-
123
- RC.__defineGetter__('height', function(){
124
- return this.window.height;
125
- });
126
-
127
- RC.clearRect = function(x, y, w, h) {
128
- this.Render.FillRectangles(this.Render.PictOp.Src, this.picture.id, [1, 1, 1, 1], [x, y, w, h])
129
- };
130
210
 
131
- RC.fillRect = function(x, y, w, h) {
132
- var mask = 0;
133
- if (this.clipMask)
134
- mask = this.clipMask.id;
135
- // ext.Composite = function(op, src, mask, dst, srcX, srcY, maskX, maskY, dstX, dstY, width, height)
136
- this.Render.Composite(this.Render.PictOp.Over, this._backgroundPicture.id, mask, this.picture.id, x, y, x, y, x, y, w, h);
137
- }
211
+ set fillStyle(value) {
212
+ this._fillStyle = value;
213
+ this._backgroundPicture = this._stylePicture(value);
214
+ }
138
215
 
139
- RC.fillText = function(text, x, y) {
140
- var Render = this.Render;
141
- text = text || '';
142
- if (!this._glyphSet)
143
- this.font = DEFAULT_FONT;
144
- var ff = this._glyphSet.fontface;
145
- var glyphs = String.fromCharCode.apply(null, text.split('').map( function(c) { return ff.charcode2glyph[c.charCodeAt(0)] }));
146
- Render.CompositeGlyphs32(3, this._backgroundPicture.id, this.picture.id, 0, this._glyphSet.id, 0, 0, [ [x, y, glyphs ] ] );
147
- };
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
+ }
148
228
 
149
- RC.measureText = function(text) {
150
- var w = 0;
151
- var h = 0;
152
- var ch, g;
153
- var ff = this._glyphSet.fontface;
154
- for (var i=0; i < text.length; ++i)
155
- {
156
- ch = text.charCodeAt(i);
157
- g = ff.charcode2glyphObj[ch];
158
- if (!g) {
159
- //console.log('Unknown character!', [text[i]], ch);
160
- //w += 10;
161
- //
162
- continue;
163
- //g = ff.charcode2glyphObj['n'.charCodeAt(0)];
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();
164
270
  }
165
- w += g.offX;
166
- if (g.height > h)
167
- h = g.height;
168
- }
169
- //console.log('measureText', text, w, h)
170
- return {
171
- width: w,
172
- height: h
173
- };
174
- }
271
+ }
175
272
 
176
- RC.beginPath = function() {
177
- this._currentPoly = null;
178
- this._currentPath = [];
179
- }
273
+ // ------------------------------------------------------------------
274
+ // transform
180
275
 
181
- RC.moveTo = function(x, y) {
182
- //console.log('moveTo', [x, y])
183
- if (this._currentPoly)
184
- this._currentPath.push(this._currentPoly)
185
- this._currentPoly = [{x: x, y: y}];
186
- }
276
+ translate(x, y) {
277
+ this._m = matMultiply(this._m, [1, 0, 0, 1, x, y]);
278
+ }
187
279
 
188
- RC.lineTo = function(x, y) {
189
- //if (x < 0) x = 0;
190
- //if (y < 0) y = 0;
191
- //console.log('lineTo', [x, y])
192
- if (this._currentPoly) {
193
- var pt = this._currentPoly[this._currentPoly.length - 1];
194
- var d = Math.abs(pt.x - x) + Math.abs(pt.y - y);
195
- if (d > 0.5)
196
- this._currentPoly.push({x: x, y: y});
197
- } else {
198
- throw new Error('lineTo called before moveTo()');
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]);
199
284
  }
200
- }
201
285
 
202
- // TODO: this is my heuristic. Do some more research
203
- function bcurveStep(x0, y0, x1, y1, x2, y2, x, y) {
204
- var minX = Math.min(x, x0, x1, x2);
205
- var maxX = Math.max(x, x0, x1, x2);
206
- var minY = Math.min(y, y0, y1, y2);
207
- var maxY = Math.max(y, y0, y1, y2);
208
- var dt = 1/Math.max(maxX - minX, maxY - minY);
209
- if (dt > 0.2)
210
- dt = 0.2;
211
- return dt;
212
- }
286
+ scale(x, y = x) {
287
+ this._m = matMultiply(this._m, [x, 0, 0, y, 0, 0]);
288
+ }
213
289
 
214
- RC.bezierCurveTo = function(x1, y1, x2, y2, x, y) {
215
- var lastPt = this._currentPoly[this._currentPoly.length - 1];
216
- var cubic = require('bezier').prepare(4);
217
- var x = [lastPt.x, x1, x2, x];
218
- var y = [lastPt.y, y1, y2, y];
219
- var step = bcurveStep(x[0], y[0], x[1], y[1], x[2], y[2], x[3], y[3]);
220
- for (var t = 0; t < 1; t += step) {
221
- this.lineTo(cubic(x, t), cubic(y, t))
290
+ transform(a, b, c, d, e, f) {
291
+ this._m = matMultiply(this._m, [a, b, c, d, e, f]);
222
292
  }
223
- }
224
293
 
225
- RC.closePath = function() {
226
- if (this._currentPoly)
227
- this._currentPath.push(this._currentPoly)
228
- this._currentPoly = null;
229
- }
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
+ }
230
303
 
231
- RC.scale = function(s) {
232
- var m = [
233
- s, 0, 0,
234
- 0, s, 0,
235
- 0, 0, 1
236
- ];
237
- this.Render.SetPictureTransform(this.picture.id, m)
238
- }
304
+ resetTransform() {
305
+ this._m = [1, 0, 0, 1, 0, 0];
306
+ }
239
307
 
240
- // from https://github.com/jahting/pnltri.js/blob/master/test/TestHelpers.js#L132
241
- function xCoord_of_segment_at_Y( inSegment, inCrossYPt ) {
242
- if ( inSegment.vTo.y == inSegment.vFrom.y ) return inCrossYPt.x;
243
- return xCoord = inSegment.vFrom.x + (inSegment.vTo.x - inSegment.vFrom.x) *
244
- ( inCrossYPt.y - inSegment.vFrom.y ) / ( inSegment.vTo.y - inSegment.vFrom.y );
245
- }
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
+ }
246
359
 
247
- RC._rasterizePath = function(picture) {
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
+ }
248
364
 
249
- // TODO: compare with https://www.npmjs.com/package/cdt2d
250
- var PNLTRI = require('pnltri');
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
+ }
251
376
 
252
- var max_width = 65535;
253
- var path = this._currentPath;
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
+ }
254
382
 
255
- var trapezoider = new PNLTRI.Trapezoider( new PNLTRI.PolygonData(path) );
256
- trapezoider.trapezoide_polygon();
257
- var traps = trapezoider.queryStructure.trapArray;
258
- var trapSegments = [];
259
- traps.forEach(function(inTrapezoid) {
260
- var high = [-max_width, max_width, inTrapezoid.vHigh.y];
261
- var low = [-max_width, max_width, inTrapezoid.vLow.y];
262
- if (inTrapezoid.lseg) {
263
- high[0] = xCoord_of_segment_at_Y( inTrapezoid.lseg, inTrapezoid.vHigh );
264
- low[0] = xCoord_of_segment_at_Y( inTrapezoid.lseg, inTrapezoid.vLow );
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
+ };
265
396
  }
266
- if (inTrapezoid.rseg) {
267
- high[1] = xCoord_of_segment_at_Y( inTrapezoid.rseg, inTrapezoid.vHigh );
268
- low[1] = xCoord_of_segment_at_Y( inTrapezoid.rseg, inTrapezoid.vLow );
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));
269
441
  }
270
- if (inTrapezoid.depth == 1 && high[1] != max_width && low[1] != max_width && high[0] != -max_width && low[1] != -max_width) {
271
- trapSegments = trapSegments.concat(low);
272
- trapSegments = trapSegments.concat(high);
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]);
273
462
  }
274
- });
275
- this.Render.AddTraps(picture.id, 0, 0, trapSegments);
276
- return;
277
-
278
- /*
279
- triangulate: note that XRender does not stich edges properly, this code going yp be removed
280
- var pts = [];
281
- path.forEach(function(p) {
282
- pts = pts.concat(p)
283
- });
284
- var myTriangulator = new PNLTRI.Triangulator();
285
- var triangList = myTriangulator.triangulate_polygon( path );
286
- var t = [];
287
- triangList.forEach(function(tri) {
288
- for(var i=0; i < 3; ++i) {
289
- t.push(pts[tri[i]].x);
290
- t.push(pts[tri[i]].y);
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
+ );
291
473
  }
292
- });
293
- this.Render.Triangles(this.Render.PictOp.Over, this._backgroundPicture.id, 0, 0, this.picture.id, 0, t);
294
- */
295
- }
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
+ });
296
490
 
297
- RC.stroke = function() {
298
- this.closePath();
299
-
300
- var t = [];
301
- var stroke = require('extrude-polyline')({
302
- thickness: this.lineWidth,
303
- //cap: 'square',
304
- //join: 'bevel',
305
- miterLimit: 10
306
- });
307
-
308
- for (var p=0; p < this._currentPath.length; ++p) {
309
- //var polyline = [ [25, 25], [15, 60] ]
310
- var polyline = this._currentPath[p].map( (pt) => [pt.x, pt.y] )
311
- var mesh = stroke.build(polyline);
312
- mesh.cells.forEach(function(tri) {
313
- for(var i=0; i < 3; ++i) {
314
- t.push(mesh.positions[tri[i]][0]);
315
- t.push(mesh.positions[tri[i]][1]);
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;
316
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
317
578
  });
318
579
  }
319
- // TODO: mask
320
- //this.Render.Triangles(this.Render.PictOp.Over, this._strokePicture.id, 0, 0, this.picture.id, 0, t);
321
- this.Render.Triangles(this.Render.PictOp.Over, this._strokePicture.id, 0, 0, this.picture.id, this.Render.a8, t);
322
- }
323
580
 
324
- RC.clip = function() {
325
- if (!this.clipMask) {
326
- this.clipMaskDrawable = new Pixmap(this.window.app, {depth: 8, width: this.width, height: this.height});
327
- this.clipMask = new Picture(this.window.app, { drawable: this.clipMaskDrawable, format: this.Render.a8});
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');
328
600
  }
329
- this.Render.FillRectangles(1, this.clipMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
330
- this._rasterizePath(this.clipMask);
331
- }
332
601
 
333
- RC.fill = function() {
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
+ }
334
607
 
335
- this.closePath();
608
+ fill(...args) {
609
+ const { polys, rule } = this._polysFor(args);
610
+ this._fillPolys(polys, rule);
611
+ }
336
612
 
337
- // TODO: perf: use shape bounding box instead of compositing over whole width x height canvas
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
+ }
338
620
 
339
- if (!this.fillMask) {
340
- this.fillMaskDrawable = new Pixmap(this.window.app, {depth: 8, width: this.width, height: this.height});
341
- this.fillMask = new Picture(this.window.app, { drawable: this.fillMaskDrawable, format: this.Render.a8});
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);
342
639
  }
343
- // TODO: apply global alpha here?
344
- this.Render.FillRectangles(1, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
345
- this._rasterizePath(this.fillMask);
346
640
 
347
- // TODO apply transformation to background position?
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
+ }
348
651
 
349
- // intersect with clipping area
350
- if (this.clipMask)
351
- this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
352
- // draw background using resulting image as mask
353
- this.Render.Composite(this.Render.PictOp.Over, this._backgroundPicture.id, this.fillMask.id, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
354
- }
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
+ }
355
678
 
356
- var baselines = ['alphabetic', 'top', 'bottom', 'middle', 'ideographic', 'hanging'];
357
- var parseFontStyle = require('canvas-fontstyle');
679
+ // ------------------------------------------------------------------
680
+ // text
358
681
 
359
- RC._uploadGlyphSet = function(face, size, hdpi, vdpi) {
360
- var gs = face.upload(size, hdpi, vdpi);
361
- gs.fontface = face;
362
- return gs;
363
- };
682
+ _resolvedTextStyle() {
683
+ if (!this._textStyle) this.font = DEFAULT_FONT;
684
+ return this._textStyle;
685
+ }
364
686
 
365
- RC._setFace = function(face, size) {
366
- var glyphSet = this._uploadGlyphSet(face, size, 96, 96)
367
- this._glyphSet = glyphSet;
368
- };
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
+ }
369
696
 
370
- RC._setFont = function(weight, style, size, unit, family) {
371
- var fc = require('./fontconfig.js');
372
- var pattern = {
373
- family: family,
374
- style: style,
375
- weight: weight
376
- };
377
- var match = fc.listFontsSync(pattern);
378
-
379
- var face = new FontFace(this.window.app);
380
- face.loadFont(match.path);
381
- if (!_fonts[family])
382
- _fonts[family] = {};
383
- _fonts[family][weight + '-' + style] = face;
384
- this._setFace(face, size);
385
- };
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
+ }
386
714
 
387
- RC.addFont = function(font) {
388
- this._fonts = this._fonts || {};
389
- if (this._fonts[font.name]) return;
390
- this._fonts[font.name] = font;
391
- };
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
+ }
392
744
 
393
- RC.__defineSetter__('font', function(val) {
394
- if (!val) return;
395
- if ('string' == typeof val) {
396
- var font;
397
- if (font = parseFontStyle(val)) {
398
- this._lastFontString = val;
399
- if (_fonts && _fonts[font.family]) {
400
- var fontObj = _fonts[font.family];
401
- var type = font.weight + '-' + font.style;
402
- var fontFace = fontObj[type];
403
- if (fontFace)
404
- return this._setFace(fontFace, font.size);
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;
405
768
  }
406
- this._setFont(
407
- font.weight
408
- , font.style
409
- , font.size
410
- , font.unit
411
- , font.family);
412
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
+ };
413
784
  }
414
- });
415
785
 
416
- RC.loadFont = function(name, size, hdpi, vdpi) {
417
- var face = new FontFace(this.window.app);
418
- face.loadFont(name);
419
- var gs = face.upload(size, hdpi, vdpi);
420
- gs.fontface = face;
421
- return gs;
422
- };
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
+ }
423
799
 
424
- RC.setFont = function(glyphSet) {
425
- this._glyphSet = glyphSet;
426
- };
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
+ }
427
819
 
428
- RC.__defineGetter__('font', function(){
429
- return this._lastFontString || DEFAULT_FONT;
430
- });
431
-
432
- function CanvasGradient(type, ctx, p0, p1, p2, p3, p4, p5) {
433
- this.type = type;
434
- this.stops = [];
435
- this.ctx = ctx;
436
- this._id = null;
437
-
438
- this.x0 = p0;
439
- this.y0 = p1;
440
- if (type == 'linear' || type == 'radial') {
441
- this.x1 = p2;
442
- this.y1 = p3;
443
- this.r0 = p4;
444
- this.r1 = p5;
445
- } else {
446
- this.angle = p2;
447
- }
448
- // TODO: check if ChangePictureAttrubute works on gradients ( set repeat edge pixels flag )
449
- };
820
+ get font() {
821
+ return this._lastFontString || DEFAULT_FONT;
822
+ }
450
823
 
451
- //var gradientsCache = new WeakMap();
452
- CanvasGradient.prototype.__defineGetter__('id', function() {
453
- if (this._id !== null)
454
- return this._id;
824
+ // ------------------------------------------------------------------
825
+ // gradients / images
455
826
 
456
- var Render = this.ctx.Render;
457
- var X = this.ctx.X;
458
-
459
- //var key = [this.x0, this.y0, this.x1, this.y1, this.r0, this.r1, this.angle, this.stops].join(',');
460
- //if (gradientsCache[key])
461
- // return gradientsCache[key].id;
462
-
463
- this._id = X.AllocID();
464
- switch (this.type) {
465
- case 'linear':
466
- Render.LinearGradient(this._id, [this.x0, this.y0], [this.x1, this.y1], this.stops);
467
- break;
468
- case 'radial':
469
- Render.RadialGradient(this._id, [this.x0, this.y0], [this.x1, this.y1], this.r0, this.r1, this.stops);
470
- break;
471
- case 'conical':
472
- Render.ConicalGradient(this._id, [this.x0, this.y0], this.angle, this.stops);
473
- break;
474
- default:
475
- throw new Error('unknown gradient type');
476
- }
477
- // wrap with picture so FreePicture is invoked on gc via weakref
478
- this._picture = new Picture(this.ctx.window.app, { id: this._id });
479
- //gradientsCache[key] = this;
480
- return this._id;
481
- });
482
-
483
- function parseColor(str) {
484
- if (Array.isArray(str))
485
- return str;
486
- var parseColor_ = require('parse-color');
487
- var c = parseColor_(str);
488
- return [c.rgba[0]/255, c.rgba[1]/255, c.rgba[2]/255, c.rgba[3]];
489
- }
827
+ createLinearGradient(x0, y0, x1, y1) {
828
+ return new CanvasGradient('linear', this, x0, y0, x1, y1);
829
+ }
490
830
 
491
- CanvasGradient.prototype.addColorStop = function(offset, color) {
492
- var colorArr = parseColor(color);
493
- this.stops.push([offset, colorArr]);
494
- return this;
495
- }
831
+ createRadialGradient(x0, y0, r0, x1, y1, r1) {
832
+ return new CanvasGradient('radial', this, x0, y0, x1, y1, r0, r1);
833
+ }
496
834
 
497
- RC.createLinearGradient = function(x0, y0, x1, y1) {
498
- return new CanvasGradient('linear', this, x0, y0, x1, y1);
499
- };
835
+ createConicalGradient(x0, y0, angle) {
836
+ return new CanvasGradient('conical', this, x0, y0, angle);
837
+ }
500
838
 
501
- RC.createRadialGradient = function(x0, y0, r0, x1, y1, r1) {
502
- return new CanvasGradient('radial', this, x0, y0, x1, y1, r0, r1);
503
- };
839
+ createImageData(w, h) {
840
+ return {
841
+ width: w,
842
+ height: h,
843
+ data: Buffer.alloc(w * h * 4)
844
+ };
845
+ }
504
846
 
505
- RC.createConicalGradient = function(x0, y0, angle) {
506
- return new CanvasGradient('conical', this, x0, y0, angle);
507
- };
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
+ }
508
853
 
509
- RC.createImageData = function(w, h) {
510
- return {
511
- width: w,
512
- height: h,
513
- data: new Buffer(w*h*4)
514
- };
515
- };
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
+ }
516
859
 
517
- RC.getImageData = function(x, y, w, h, cb) {
518
- // X.GetImage( format, drawable, x, y, w, h, planeMask )
519
- console.log("GetImage(2", [this.window.id, x, y, w, h, 0xffffffff]);
520
- //var win = this.window.app.root().id;
521
- var win = this.window.id;
522
- this.X.GetImage(2, win, x, y, w, h, 0xffffffff, function(err, data) {
523
- cb(err, data);
524
- });
525
- };
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();
526
896
 
527
- // void ctx.drawImage(image, dx, dy);
528
- // void ctx.drawImage(image, dx, dy, dWidth, dHeight);
529
- // void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
530
- // image: drawable, node-canvas Canvas, node-canvas Image
531
- RC.drawImage = function(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) {
532
- //image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight
533
- //if (typeof dHeight == 'undefined' && )
534
-
535
- sWidth = image.width;
536
- sHeight = image.height;
537
- sx = 0;
538
- sy = 0;
539
-
540
- // TODO: allow to draw Canvas.Image, Window, Pixmap, Picture
541
- if (image.constructor == RenderingContext2d) {
542
- // TODO: if need to scale, set transform
543
- // TODO: respect globalAlpha ( use solid transparent mask )
544
- // TODO: respect global compositing blend mode
545
- this.Render.Composite(this.Render.PictOp.Over, image.picture.id, 0, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
546
- } else if (image && image.context && typeof image.context.getImageData == 'function') {
547
- // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
548
- var imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
549
- var data = new Buffer(imageData.data.length);
550
- for (var i=0; i < data.length; i+=4) {
551
- data[i+2] = imageData.data[i];
552
- data[i+1] = imageData.data[i+1];
553
- data[i] = imageData.data[i+2];
554
- data[i+3] = imageData.data[i+3]; // multiply source alpha by context globalAlpha?
555
- // or maybe just blend using corresponding operation + composite with transparent rectangle
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;
556
936
  }
557
937
 
558
- var pixmap = new Pixmap(this.window.app, {depth: 32, width: sWidth, height: sHeight});
559
- var picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32});
560
- pixmap._gc = this.X.AllocID();
561
- this.X.CreateGC(pixmap._gc, pixmap.id);
562
- this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, sx, sy, 0, 32, data);
563
-
564
- // TODO: figure out scale factor if sWidth !== dWith or sHeight !== dHeight
565
- //this.Render.SetPictureFilter(picture.id, 'best');
566
- var s = 1;
567
- var m = [
568
- s, 0, 0,
569
- 0, s, 0,
570
- 0, 0, 1
571
- ];
572
- //this.Render.SetPictureTransform(picture.id, m);
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
+ }
573
968
 
574
- // TODO: respect globalCompositeOperation and globalAlpha
575
- // examples http://blogs.adobe.com/webplatform/2014/02/24/using-blend-modes-in-html-canvas/
576
- var mask = 0;
577
- if (this.clipMask)
578
- mask = this.clipMask.id
579
- this.Render.Composite(3, picture.id, mask, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
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
+ }
580
986
  }
581
- };
582
987
 
583
- RC.arc = function() {
584
- //console.log('canvas2d.arc() not implemented!!!');
585
- //console.log(arguments);
586
- };
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
+ }
587
1020
 
588
- RC.restore = function() {
589
- //console.log('canvas2d.restore() not implemented!!!');
590
- };
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
+ }
591
1041
 
592
- RC.save = function() {
593
- //console.log('canvas2d.save() not implemented!!!');
594
- };
1042
+ addColorStop(offset, color) {
1043
+ this.stops.push([offset, parseColor(color)]);
1044
+ return this;
1045
+ }
595
1046
 
596
- RC.translate = function(x, y) {
597
- //console.log('canvas2d.translate() not implemented!!!');
598
- };
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
+ }
599
1073
 
600
1074
  // register context
601
- require('./drawable').prototype.renderingContextFactory['2d'] = module.exports;
1075
+ Drawable.renderingContextFactory['2d'] = (window) => new RenderingContext2d(window);
1076
+
1077
+ export default RenderingContext2d;
1078
+ export { CanvasGradient };