ntk 5.0.0 → 5.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.
- package/lib/app.js +30 -2
- package/lib/index.js +14 -0
- package/lib/rasterize.js +333 -63
- package/lib/renderingcontext_2d.js +102 -11
- package/lib/text/font.js +2 -3
- package/package.json +1 -1
package/lib/app.js
CHANGED
|
@@ -2,6 +2,7 @@ import Clipboard from './clipboard.js';
|
|
|
2
2
|
import { CursorCache } from './cursor.js';
|
|
3
3
|
import { chooseGLXConfig } from './glx.js';
|
|
4
4
|
import Pixmap from './pixmap.js';
|
|
5
|
+
import { DEFAULT_RASTER_POLICY, defaultRasterizer } from './rasterize.js';
|
|
5
6
|
import FontManager from './text/fontmanager.js';
|
|
6
7
|
import Window from './window.js';
|
|
7
8
|
|
|
@@ -13,8 +14,9 @@ export default class App {
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {object} display node-x11 display
|
|
15
16
|
* @param {object} [options] environment hooks: { fontSource (see
|
|
16
|
-
* text/fontsource.js),
|
|
17
|
-
* instead of querying the
|
|
17
|
+
* text/fontsource.js), rasterizer and rasterPolicy (see rasterize.js),
|
|
18
|
+
* glxVisual (visual id for getContext('opengl') instead of querying the
|
|
19
|
+
* server for one) }
|
|
18
20
|
*/
|
|
19
21
|
constructor(display, options = {}) {
|
|
20
22
|
// ntk decodes property payloads and atom lists with little-endian
|
|
@@ -38,6 +40,7 @@ export default class App {
|
|
|
38
40
|
this._fonts = null;
|
|
39
41
|
this._clipboard = null;
|
|
40
42
|
this._cursors = null;
|
|
43
|
+
this._rasterizer = undefined;
|
|
41
44
|
// node-x11 emits X errors it cannot route to a request callback as
|
|
42
45
|
// 'error' on the client — from inside its packet parser. With no
|
|
43
46
|
// listener that emit throws and the parser never re-arms, silently
|
|
@@ -56,6 +59,31 @@ export default class App {
|
|
|
56
59
|
return this._fonts;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The Rasterizer small fills and strokes go through instead of the server's
|
|
64
|
+
* trapezoid path (see lib/rasterize.js and docs/context-2d.md). Defaults to
|
|
65
|
+
* the process-wide default; `createClient({ rasterizer: null })` sends every
|
|
66
|
+
* drawing to the server.
|
|
67
|
+
*/
|
|
68
|
+
get rasterizer() {
|
|
69
|
+
if (this._rasterizer === undefined) {
|
|
70
|
+
this._rasterizer =
|
|
71
|
+
'rasterizer' in this.options ? this.options.rasterizer : defaultRasterizer();
|
|
72
|
+
}
|
|
73
|
+
return this._rasterizer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
set rasterizer(value) {
|
|
77
|
+
this._rasterizer = value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** thresholds for that decision, merged over DEFAULT_RASTER_POLICY */
|
|
81
|
+
get rasterPolicy() {
|
|
82
|
+
return this.options.rasterPolicy
|
|
83
|
+
? { ...DEFAULT_RASTER_POLICY, ...this.options.rasterPolicy }
|
|
84
|
+
: DEFAULT_RASTER_POLICY;
|
|
85
|
+
}
|
|
86
|
+
|
|
59
87
|
/** selection/clipboard transfer: write()/read() text (docs/clipboard.md) */
|
|
60
88
|
get clipboard() {
|
|
61
89
|
if (!this._clipboard) this._clipboard = new Clipboard(this);
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,13 @@ import {
|
|
|
18
18
|
defaultFontSource,
|
|
19
19
|
setDefaultFontSource
|
|
20
20
|
} from './text/fontsource.js';
|
|
21
|
+
import {
|
|
22
|
+
CoverageAccumulator,
|
|
23
|
+
DEFAULT_RASTER_POLICY,
|
|
24
|
+
ScanlineRasterizer,
|
|
25
|
+
defaultRasterizer,
|
|
26
|
+
setDefaultRasterizer
|
|
27
|
+
} from './rasterize.js';
|
|
21
28
|
import { TextLayout } from './text/layout.js';
|
|
22
29
|
import HtmlView from './widgets/htmlview.js';
|
|
23
30
|
import SvgView from './widgets/svgview.js';
|
|
@@ -54,6 +61,8 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
|
|
|
54
61
|
* @param {object} [options] passed through to x11.createClient
|
|
55
62
|
* (e.g. { display: ':1' }); ntk additionally understands
|
|
56
63
|
* { fontSource } — a pluggable system-font lookup (see docs/fonts.md) —
|
|
64
|
+
* { rasterizer, rasterPolicy } — where small fills and strokes are
|
|
65
|
+
* rasterized, and the thresholds for that choice (see docs/context-2d.md) —
|
|
57
66
|
* { glxVisual } — a visual id for getContext('opengl') to use instead of
|
|
58
67
|
* querying the server for one — and
|
|
59
68
|
* { onXError } — called with X protocol errors no request callback
|
|
@@ -151,6 +160,11 @@ export {
|
|
|
151
160
|
StaticFontSource,
|
|
152
161
|
defaultFontSource,
|
|
153
162
|
setDefaultFontSource,
|
|
163
|
+
CoverageAccumulator,
|
|
164
|
+
ScanlineRasterizer,
|
|
165
|
+
defaultRasterizer,
|
|
166
|
+
setDefaultRasterizer,
|
|
167
|
+
DEFAULT_RASTER_POLICY,
|
|
154
168
|
TextLayout,
|
|
155
169
|
HtmlView,
|
|
156
170
|
SvgView,
|
package/lib/rasterize.js
CHANGED
|
@@ -1,23 +1,254 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Pure-JS coverage rasterization: outlines and polygons in, 8-bit alpha out.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Two consumers, one algorithm:
|
|
4
|
+
//
|
|
5
|
+
// rasterizePath() glyph outlines -> a8 bitmap for XRender AddGlyphs
|
|
6
|
+
// rasterizePolys() device-space path/stroke geometry -> a8 coverage mask,
|
|
7
|
+
// uploaded by the 2d context instead of asking the server
|
|
8
|
+
// to rasterize trapezoids (see routeRaster below)
|
|
9
|
+
//
|
|
10
|
+
// The algorithm is signed-area accumulation, the one font-rs and
|
|
11
|
+
// stb_truetype v2 use. Each edge deposits the exact area it sweeps through
|
|
12
|
+
// every pixel row it crosses into a float accumulator; a prefix sum along the
|
|
13
|
+
// row turns those deltas into winding-weighted coverage. There is no
|
|
14
|
+
// supersampling and no per-scanline crossing sort — coverage is analytic, so
|
|
15
|
+
// the result is closer to a reference rasterizer than 4x4 supersampling was,
|
|
16
|
+
// at a fraction of the cost (~25 microseconds for a 20px icon against ~600).
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Antialiased coverage accumulator over a width x height pixel grid.
|
|
20
|
+
*
|
|
21
|
+
* Geometry is in y-down device space with the grid's top-left at (0, 0);
|
|
22
|
+
* callers translate into that space through the `dx`/`dy` arguments. Add as
|
|
23
|
+
* many polygons as the drawing needs, then read the result with `toAlpha`.
|
|
24
|
+
*/
|
|
25
|
+
export class CoverageAccumulator {
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} width
|
|
28
|
+
* @param {number} height
|
|
29
|
+
*/
|
|
30
|
+
constructor(width, height) {
|
|
31
|
+
this.width = width;
|
|
32
|
+
this.height = height;
|
|
33
|
+
// one column of slack: an edge that ends exactly on the right border
|
|
34
|
+
// deposits its last fraction one cell past the last pixel
|
|
35
|
+
this.stride = width + 2;
|
|
36
|
+
this.acc = new Float32Array(this.stride * height);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** forget accumulated coverage, keeping the buffer (reuse across draws) */
|
|
40
|
+
reset() {
|
|
41
|
+
this.acc.fill(0);
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Accumulate one directed edge. Horizontal edges contribute nothing and
|
|
47
|
+
* are skipped; everything else deposits `dy * direction` of signed area,
|
|
48
|
+
* split across the pixels the edge passes through.
|
|
49
|
+
*/
|
|
50
|
+
edge(ax, ay, bx, by) {
|
|
51
|
+
if (ay === by) return;
|
|
52
|
+
let dir = 1;
|
|
53
|
+
let x0 = ax;
|
|
54
|
+
let y0 = ay;
|
|
55
|
+
let x1 = bx;
|
|
56
|
+
let y1 = by;
|
|
57
|
+
if (ay > by) {
|
|
58
|
+
dir = -1;
|
|
59
|
+
x0 = bx;
|
|
60
|
+
y0 = by;
|
|
61
|
+
x1 = ax;
|
|
62
|
+
y1 = ay;
|
|
63
|
+
}
|
|
64
|
+
const { width, stride, acc, height } = this;
|
|
65
|
+
const dxdy = (x1 - x0) / (y1 - y0);
|
|
66
|
+
let x = x0;
|
|
67
|
+
if (y0 < 0) {
|
|
68
|
+
// enter the grid at row 0 rather than accumulating rows nobody reads
|
|
69
|
+
x -= y0 * dxdy;
|
|
70
|
+
y0 = 0;
|
|
71
|
+
}
|
|
72
|
+
const rowEnd = Math.min(height, Math.ceil(y1));
|
|
73
|
+
for (let y = y0 | 0; y < rowEnd; ++y) {
|
|
74
|
+
const line = y * stride;
|
|
75
|
+
const dy = Math.min(y + 1, y1) - Math.max(y, y0);
|
|
76
|
+
const xnext = x + dxdy * dy;
|
|
77
|
+
const d = dy * dir;
|
|
78
|
+
// the row's x span, clamped into the grid — geometry outside the
|
|
79
|
+
// bitmap piles its coverage onto the border column, which is what a
|
|
80
|
+
// clipped fill should look like
|
|
81
|
+
let xa = x < xnext ? x : xnext;
|
|
82
|
+
let xb = x < xnext ? xnext : x;
|
|
83
|
+
if (xa < 0) xa = 0;
|
|
84
|
+
else if (xa > width) xa = width;
|
|
85
|
+
if (xb < 0) xb = 0;
|
|
86
|
+
else if (xb > width) xb = width;
|
|
87
|
+
|
|
88
|
+
const xaFloor = Math.floor(xa);
|
|
89
|
+
const x0i = xaFloor | 0;
|
|
90
|
+
const xbCeil = Math.ceil(xb);
|
|
91
|
+
const x1i = xbCeil | 0;
|
|
92
|
+
if (x1i <= x0i + 1) {
|
|
93
|
+
// the whole span sits inside one pixel: split by the midpoint
|
|
94
|
+
const xmf = 0.5 * (xa + xb) - xaFloor;
|
|
95
|
+
acc[line + x0i] += d - d * xmf;
|
|
96
|
+
acc[line + x0i + 1] += d * xmf;
|
|
97
|
+
} else {
|
|
98
|
+
// trapezoidal split across the pixels the span crosses
|
|
99
|
+
const s = 1 / (xb - xa);
|
|
100
|
+
const x0f = xa - xaFloor;
|
|
101
|
+
const a0 = 0.5 * s * (1 - x0f) * (1 - x0f);
|
|
102
|
+
const x1f = xb - xbCeil + 1;
|
|
103
|
+
const am = 0.5 * s * x1f * x1f;
|
|
104
|
+
acc[line + x0i] += d * a0;
|
|
105
|
+
if (x1i === x0i + 2) {
|
|
106
|
+
acc[line + x0i + 1] += d * (1 - a0 - am);
|
|
107
|
+
} else {
|
|
108
|
+
const a1 = s * (1.5 - x0f);
|
|
109
|
+
acc[line + x0i + 1] += d * (a1 - a0);
|
|
110
|
+
for (let xi = x0i + 2; xi < x1i - 1; ++xi) acc[line + xi] += d * s;
|
|
111
|
+
const a2 = a1 + (x1i - x0i - 3) * s;
|
|
112
|
+
acc[line + x1i - 1] += d * (1 - a2 - am);
|
|
113
|
+
}
|
|
114
|
+
acc[line + x1i] += d * am;
|
|
115
|
+
}
|
|
116
|
+
x = xnext;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Accumulate a closed polygon.
|
|
122
|
+
* @param {ArrayLike<number>} pts flat [x0,y0,x1,y1,…]; the closing edge is implied
|
|
123
|
+
* @param {number} [dx] translation applied to every x
|
|
124
|
+
* @param {number} [dy] translation applied to every y
|
|
125
|
+
*/
|
|
126
|
+
polygon(pts, dx = 0, dy = 0) {
|
|
127
|
+
const n = pts.length;
|
|
128
|
+
if (n < 6) return this;
|
|
129
|
+
let px = pts[n - 2] + dx;
|
|
130
|
+
let py = pts[n - 1] + dy;
|
|
131
|
+
for (let i = 0; i < n; i += 2) {
|
|
132
|
+
const x = pts[i] + dx;
|
|
133
|
+
const y = pts[i + 1] + dy;
|
|
134
|
+
this.edge(px, py, x, y);
|
|
135
|
+
px = x;
|
|
136
|
+
py = y;
|
|
137
|
+
}
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Accumulate a triangle soup as a *union*: every triangle is wound the same
|
|
143
|
+
* way before it is added, so overlapping triangles reinforce rather than
|
|
144
|
+
* cancel and the clamped non-zero result is their union. This is what a
|
|
145
|
+
* stroke needs — extruded segments, caps and joins overlap constantly, and
|
|
146
|
+
* accumulating them without normalization would punch holes at the seams.
|
|
147
|
+
*
|
|
148
|
+
* @param {ArrayLike<number>} tris flat x,y pairs, three points per triangle
|
|
149
|
+
*/
|
|
150
|
+
triangles(tris, dx = 0, dy = 0) {
|
|
151
|
+
for (let i = 0; i + 5 < tris.length; i += 6) {
|
|
152
|
+
const ax = tris[i] + dx;
|
|
153
|
+
const ay = tris[i + 1] + dy;
|
|
154
|
+
let bx = tris[i + 2] + dx;
|
|
155
|
+
let by = tris[i + 3] + dy;
|
|
156
|
+
let cx = tris[i + 4] + dx;
|
|
157
|
+
let cy = tris[i + 5] + dy;
|
|
158
|
+
const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
|
|
159
|
+
if (area === 0) continue;
|
|
160
|
+
if (area < 0) {
|
|
161
|
+
const tx = bx;
|
|
162
|
+
const ty = by;
|
|
163
|
+
bx = cx;
|
|
164
|
+
by = cy;
|
|
165
|
+
cx = tx;
|
|
166
|
+
cy = ty;
|
|
167
|
+
}
|
|
168
|
+
this.edge(ax, ay, bx, by);
|
|
169
|
+
this.edge(bx, by, cx, cy);
|
|
170
|
+
this.edge(cx, cy, ax, ay);
|
|
171
|
+
}
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Integrate the accumulator into 8-bit coverage.
|
|
177
|
+
*
|
|
178
|
+
* @param {Uint8Array|Buffer} [out] destination, `stride * height` bytes
|
|
179
|
+
* @param {'nonzero'|'evenodd'} [rule] fill rule
|
|
180
|
+
* @param {number} [stride] destination row stride (defaults to width; the
|
|
181
|
+
* X wire format wants rows padded to 4 bytes)
|
|
182
|
+
*/
|
|
183
|
+
toAlpha(out, rule = 'nonzero', stride = this.width) {
|
|
184
|
+
const { width, height, acc } = this;
|
|
185
|
+
const dst = out ?? new Uint8Array(stride * height);
|
|
186
|
+
const evenodd = rule === 'evenodd';
|
|
187
|
+
for (let y = 0; y < height; ++y) {
|
|
188
|
+
let sum = 0;
|
|
189
|
+
const line = y * this.stride;
|
|
190
|
+
const orow = y * stride;
|
|
191
|
+
for (let x = 0; x < width; ++x) {
|
|
192
|
+
sum += acc[line + x];
|
|
193
|
+
let c = sum < 0 ? -sum : sum;
|
|
194
|
+
if (evenodd) {
|
|
195
|
+
// fold the winding number through a triangle wave: 0,1,2,3 ->
|
|
196
|
+
// 0,1,0,1 with the fractional parts still antialiased
|
|
197
|
+
c %= 2;
|
|
198
|
+
if (c > 1) c = 2 - c;
|
|
199
|
+
} else if (c > 1) {
|
|
200
|
+
c = 1;
|
|
201
|
+
}
|
|
202
|
+
dst[orow + x] = (c * 255 + 0.5) | 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return dst;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
7
208
|
|
|
8
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Rasterize closed polygons into an 8-bit coverage bitmap.
|
|
211
|
+
*
|
|
212
|
+
* @param {Array<ArrayLike<number>>} polys flat [x0,y0,…] polygons, device space
|
|
213
|
+
* @param {number} width
|
|
214
|
+
* @param {number} height
|
|
215
|
+
* @param {'nonzero'|'evenodd'} [rule]
|
|
216
|
+
* @param {object} [opts] { dx, dy, out, stride }
|
|
217
|
+
* @returns {Uint8Array|Buffer} `width * height` bytes (or `stride * height`)
|
|
218
|
+
*/
|
|
219
|
+
export function rasterizePolys(polys, width, height, rule = 'nonzero', opts = {}) {
|
|
220
|
+
const { dx = 0, dy = 0, out, stride = width } = opts;
|
|
221
|
+
const acc = new CoverageAccumulator(width, height);
|
|
222
|
+
for (const p of polys) acc.polygon(p, dx, dy);
|
|
223
|
+
return acc.toAlpha(out, rule, stride);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Rasterize a triangle soup into an 8-bit coverage bitmap, unioning overlaps
|
|
228
|
+
* (see CoverageAccumulator#triangles).
|
|
229
|
+
*/
|
|
230
|
+
export function rasterizeTriangles(tris, width, height, opts = {}) {
|
|
231
|
+
const { dx = 0, dy = 0, out, stride = width } = opts;
|
|
232
|
+
const acc = new CoverageAccumulator(width, height);
|
|
233
|
+
acc.triangles(tris, dx, dy);
|
|
234
|
+
return acc.toAlpha(out, 'nonzero', stride);
|
|
235
|
+
}
|
|
9
236
|
|
|
10
237
|
/**
|
|
238
|
+
* Glyph outlines -> a8 bitmap for XRender AddGlyphs.
|
|
239
|
+
*
|
|
11
240
|
* @param {Array} commands path commands ({type: M|L|Q|C|Z, x, y, x1, y1, x2, y2})
|
|
12
|
-
*
|
|
13
|
-
*
|
|
241
|
+
* in y-down pixel coordinates with the origin at the glyph baseline
|
|
242
|
+
* @param {number} [_ss] ignored; kept so callers that used to ask for a
|
|
243
|
+
* supersampling factor keep working. Coverage is analytic now, so there is
|
|
244
|
+
* no quality/speed dial to turn.
|
|
14
245
|
* @returns {{width, height, left, top, data: Buffer}|null}
|
|
15
246
|
* left/top are offsets of the bitmap's top-left corner relative to the
|
|
16
247
|
* glyph origin (left is typically small, top is typically negative —
|
|
17
|
-
* above the baseline).
|
|
248
|
+
* above the baseline). `width` is the row stride, padded to 4 bytes as the
|
|
249
|
+
* AddGlyphs wire format requires. Returns null for empty outlines.
|
|
18
250
|
*/
|
|
19
|
-
export function rasterizePath(commands,
|
|
20
|
-
const SS = ss;
|
|
251
|
+
export function rasterizePath(commands, _ss) {
|
|
21
252
|
const polys = flatten(commands);
|
|
22
253
|
if (polys.length === 0) return null;
|
|
23
254
|
|
|
@@ -40,60 +271,9 @@ export function rasterizePath(commands, ss = DEFAULT_SS) {
|
|
|
40
271
|
const height = Math.ceil(maxY) - top;
|
|
41
272
|
if (width <= 0 || height <= 0) return null;
|
|
42
273
|
|
|
43
|
-
// pad rows to 4 bytes as required by the AddGlyphs wire format
|
|
44
274
|
const stride = (width + 3) & ~3;
|
|
45
|
-
const coverage = new Uint16Array(stride * height);
|
|
46
|
-
|
|
47
|
-
// closed edges in supersampled bitmap space
|
|
48
|
-
const edges = [];
|
|
49
|
-
for (const poly of polys) {
|
|
50
|
-
const n = poly.length / 2;
|
|
51
|
-
for (let i = 0; i < n; ++i) {
|
|
52
|
-
const j = (i + 1) % n;
|
|
53
|
-
const x0 = (poly[i * 2] - left) * SS;
|
|
54
|
-
const y0 = (poly[i * 2 + 1] - top) * SS;
|
|
55
|
-
const x1 = (poly[j * 2] - left) * SS;
|
|
56
|
-
const y1 = (poly[j * 2 + 1] - top) * SS;
|
|
57
|
-
if (y0 !== y1) edges.push([x0, y0, x1, y1]);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const crossings = [];
|
|
62
|
-
for (let row = 0; row < height * SS; ++row) {
|
|
63
|
-
const y = row + 0.5;
|
|
64
|
-
crossings.length = 0;
|
|
65
|
-
for (const [x0, y0, x1, y1] of edges) {
|
|
66
|
-
if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) {
|
|
67
|
-
const x = x0 + ((y - y0) * (x1 - x0)) / (y1 - y0);
|
|
68
|
-
crossings.push([x, y1 > y0 ? 1 : -1]);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
crossings.sort((a, b) => a[0] - b[0]);
|
|
72
|
-
|
|
73
|
-
let winding = 0;
|
|
74
|
-
let spanStart = 0;
|
|
75
|
-
const py = (row / SS) | 0;
|
|
76
|
-
for (const [x, dir] of crossings) {
|
|
77
|
-
if (winding === 0) spanStart = x;
|
|
78
|
-
winding += dir;
|
|
79
|
-
if (winding === 0) {
|
|
80
|
-
// fill supersample cells whose centers are inside [spanStart, x)
|
|
81
|
-
const from = Math.max(0, Math.ceil(spanStart - 0.5));
|
|
82
|
-
const to = Math.min(width * SS - 1, Math.floor(x - 0.5));
|
|
83
|
-
for (let cell = from; cell <= to; ++cell) {
|
|
84
|
-
coverage[py * stride + ((cell / SS) | 0)]++;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
275
|
const data = Buffer.alloc(stride * height);
|
|
91
|
-
|
|
92
|
-
for (let i = 0; i < coverage.length; ++i) {
|
|
93
|
-
const c = coverage[i];
|
|
94
|
-
data[i] = c >= full ? 255 : ((c * 255) / full) | 0;
|
|
95
|
-
}
|
|
96
|
-
|
|
276
|
+
rasterizePolys(polys, width, height, 'nonzero', { dx: -left, dy: -top, out: data, stride });
|
|
97
277
|
return { width: stride, height, left, top, data };
|
|
98
278
|
}
|
|
99
279
|
|
|
@@ -176,3 +356,93 @@ function curveSteps(...coords) {
|
|
|
176
356
|
const size = Math.max(maxX - minX, maxY - minY);
|
|
177
357
|
return Math.min(24, Math.max(3, Math.ceil(size / 3)));
|
|
178
358
|
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// the pluggable seam
|
|
362
|
+
//
|
|
363
|
+
// A Rasterizer answers one question — "give me the coverage of this geometry
|
|
364
|
+
// on a width x height grid" — and the 2d context uploads the answer as an a8
|
|
365
|
+
// mask. Everything downstream (globalAlpha, clip, composite op, gradient and
|
|
366
|
+
// solid sources) is unchanged, because what we hand the server is *coverage*,
|
|
367
|
+
// not colour.
|
|
368
|
+
//
|
|
369
|
+
// Rasterizer interface (duck-typed):
|
|
370
|
+
//
|
|
371
|
+
// rasterize(job) -> Uint8Array | Buffer | null
|
|
372
|
+
// `job` is { polys, triangles, width, height, rule, dx, dy }:
|
|
373
|
+
// - exactly one of `polys` (closed polygons, filled by `rule`) and
|
|
374
|
+
// `triangles` (a stroke's triangle soup, unioned regardless of rule)
|
|
375
|
+
// - `dx`/`dy` must be *added to every coordinate*. Geometry arrives in
|
|
376
|
+
// device space and the grid covers the drawing's bounding box, so the
|
|
377
|
+
// offset is what maps one to the other. It is passed rather than
|
|
378
|
+
// pre-applied because pre-applying means copying every point of every
|
|
379
|
+
// path on every frame, which is the cost this whole route exists to
|
|
380
|
+
// avoid.
|
|
381
|
+
// Return `width * height` bytes of 8-bit coverage, row-major and
|
|
382
|
+
// unpadded, or null to decline — declining routes the drawing back to
|
|
383
|
+
// the server's trapezoid path, so a partial implementation is safe.
|
|
384
|
+
// A rasterizer that only handles, say, non-zero fills can return null
|
|
385
|
+
// for everything else and stay correct.
|
|
386
|
+
|
|
387
|
+
/** the in-repo rasterizer: CoverageAccumulator, no dependencies */
|
|
388
|
+
export class ScanlineRasterizer {
|
|
389
|
+
rasterize({ polys, triangles, width, height, rule, dx = 0, dy = 0 }) {
|
|
390
|
+
const acc = new CoverageAccumulator(width, height);
|
|
391
|
+
if (triangles) acc.triangles(triangles, dx, dy);
|
|
392
|
+
else for (const p of polys) acc.polygon(p, dx, dy);
|
|
393
|
+
return acc.toAlpha(undefined, triangles ? 'nonzero' : rule);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let _defaultRasterizer = null;
|
|
398
|
+
|
|
399
|
+
/** the process-wide default Rasterizer */
|
|
400
|
+
export function defaultRasterizer() {
|
|
401
|
+
if (!_defaultRasterizer) _defaultRasterizer = new ScanlineRasterizer();
|
|
402
|
+
return _defaultRasterizer;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Replace the process-wide default Rasterizer. Affects every app created
|
|
407
|
+
* afterwards without an explicit one. Pass null to send every drawing to the
|
|
408
|
+
* server, which is the behaviour ntk had before local rasterization.
|
|
409
|
+
*/
|
|
410
|
+
export function setDefaultRasterizer(rasterizer) {
|
|
411
|
+
_defaultRasterizer = rasterizer;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* When a drawing is cheaper to rasterize here and upload than to hand the
|
|
416
|
+
* server as trapezoids.
|
|
417
|
+
*
|
|
418
|
+
* Coverage upload costs one byte per pixel of the bounding box and grows with
|
|
419
|
+
* *area*; the trapezoid path costs per-request overhead plus 40 bytes per
|
|
420
|
+
* trapezoid and grows with *geometric complexity*. So the threshold is a
|
|
421
|
+
* ratio, not a size — measured against XQuartz with two shapes that bracket
|
|
422
|
+
* the range (a rounded rectangle at 14-79 trapezoids, a stroked icon at
|
|
423
|
+
* 181-1053), local rasterization wins whenever `area / edges` is under ~150
|
|
424
|
+
* and loses above ~220, consistently for both.
|
|
425
|
+
*
|
|
426
|
+
* - `maxArea` — at or below this bounding-box area, always local. No shape
|
|
427
|
+
* measured lost here, so small drawings never pay for the check.
|
|
428
|
+
* - `bytesPerEdge` — above that, local while `area <= bytesPerEdge * edges`.
|
|
429
|
+
* Edge count is free (we just flattened the path); trapezoid count would
|
|
430
|
+
* be a better predictor but costs a trapezoidize to learn.
|
|
431
|
+
* - `maxBytes` — never upload more coverage than this in one go.
|
|
432
|
+
*/
|
|
433
|
+
export const DEFAULT_RASTER_POLICY = {
|
|
434
|
+
maxArea: 64 * 64,
|
|
435
|
+
bytesPerEdge: 150,
|
|
436
|
+
maxBytes: 1 << 20
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @returns {'local'|'server'} where a drawing of this size and complexity
|
|
441
|
+
* should be rasterized
|
|
442
|
+
*/
|
|
443
|
+
export function routeRaster(width, height, edges, policy = DEFAULT_RASTER_POLICY) {
|
|
444
|
+
const area = width * height;
|
|
445
|
+
if (area <= 0 || area > policy.maxBytes) return 'server';
|
|
446
|
+
if (area <= policy.maxArea) return 'local';
|
|
447
|
+
return area <= policy.bytesPerEdge * edges ? 'local' : 'server';
|
|
448
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './path.js';
|
|
18
18
|
import Picture from './picture.js';
|
|
19
19
|
import Pixmap from './pixmap.js';
|
|
20
|
+
import { routeRaster } from './rasterize.js';
|
|
20
21
|
import { compositeTraps, drawGlyphRuns } from './text/glyphs.js';
|
|
21
22
|
import { TextLayout } from './text/layout.js';
|
|
22
23
|
import { reorderRuns } from './text/shape.js';
|
|
@@ -560,6 +561,54 @@ class RenderingContext2d {
|
|
|
560
561
|
}
|
|
561
562
|
}
|
|
562
563
|
|
|
564
|
+
// GC for uploading coverage into the scratch a8 mask
|
|
565
|
+
_maskGC() {
|
|
566
|
+
if (!this._fillMaskGC) {
|
|
567
|
+
this._fillMaskGC = this.X.AllocID();
|
|
568
|
+
this.X.CreateGC(this._fillMaskGC, this.fillMaskDrawable.id);
|
|
569
|
+
}
|
|
570
|
+
return this._fillMaskGC;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Rasterize `job` here and PutImage the coverage into the scratch mask at
|
|
575
|
+
* the drawing's bounding box, instead of asking the server to rasterize
|
|
576
|
+
* trapezoids. Returns false when the app has no rasterizer, when the policy
|
|
577
|
+
* routes this drawing to the server, or when the rasterizer declines — in
|
|
578
|
+
* every one of those cases the caller falls back to the trapezoid path.
|
|
579
|
+
*
|
|
580
|
+
* PutImage writes with Src semantics, so this replaces the mask clear as
|
|
581
|
+
* well as the AddTraps: two requests become one, and the one that is left
|
|
582
|
+
* touches only the bounding box.
|
|
583
|
+
*/
|
|
584
|
+
_uploadCoverage(job, b, edges) {
|
|
585
|
+
const rasterizer = this.window.app.rasterizer;
|
|
586
|
+
if (!rasterizer) return false;
|
|
587
|
+
if (routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy) !== 'local') return false;
|
|
588
|
+
|
|
589
|
+
const coverage = rasterizer.rasterize({ ...job, width: b.w, height: b.h });
|
|
590
|
+
if (!coverage) return false;
|
|
591
|
+
|
|
592
|
+
// X wants scanlines padded to 4 bytes; the rasterizer contract is
|
|
593
|
+
// unpadded rows. When the width is already a multiple of 4 — which every
|
|
594
|
+
// power-of-two icon box is — the coverage goes out as a view over the
|
|
595
|
+
// rasterizer's own bytes, with no copy at all.
|
|
596
|
+
const stride = (b.w + 3) & ~3;
|
|
597
|
+
let data;
|
|
598
|
+
if (stride === b.w) {
|
|
599
|
+
data = Buffer.isBuffer(coverage)
|
|
600
|
+
? coverage
|
|
601
|
+
: Buffer.from(coverage.buffer, coverage.byteOffset, coverage.length);
|
|
602
|
+
} else {
|
|
603
|
+
data = Buffer.alloc(stride * b.h);
|
|
604
|
+
for (let y = 0; y < b.h; ++y) {
|
|
605
|
+
Buffer.from(coverage.buffer, coverage.byteOffset + y * b.w, b.w).copy(data, y * stride);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
this.X.PutImage(2, this.fillMaskDrawable.id, this._maskGC(), b.w, b.h, b.x, b.y, 0, 8, data);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
|
|
563
612
|
/**
|
|
564
613
|
* Core fill: rasterize device-space polys into the scratch a8 mask,
|
|
565
614
|
* scale by globalAlpha, intersect with the clip, composite the source.
|
|
@@ -584,6 +633,27 @@ class RenderingContext2d {
|
|
|
584
633
|
}
|
|
585
634
|
}
|
|
586
635
|
if (maxX === -Infinity) return null;
|
|
636
|
+
return this._clampBBox(minX, minY, maxX, maxY);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** the same, for a flat triangle soup (stroke geometry) */
|
|
640
|
+
_trisBBox(tris) {
|
|
641
|
+
let minX = Infinity;
|
|
642
|
+
let minY = Infinity;
|
|
643
|
+
let maxX = -Infinity;
|
|
644
|
+
let maxY = -Infinity;
|
|
645
|
+
for (let i = 0; i < tris.length; i += 2) {
|
|
646
|
+
if (tris[i] < minX) minX = tris[i];
|
|
647
|
+
if (tris[i] > maxX) maxX = tris[i];
|
|
648
|
+
if (tris[i + 1] < minY) minY = tris[i + 1];
|
|
649
|
+
if (tris[i + 1] > maxY) maxY = tris[i + 1];
|
|
650
|
+
}
|
|
651
|
+
if (maxX === -Infinity) return null;
|
|
652
|
+
return this._clampBBox(minX, minY, maxX, maxY);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// a pixel of slack for the antialiased edge, clamped to the surface
|
|
656
|
+
_clampBBox(minX, minY, maxX, maxY) {
|
|
587
657
|
const x = Math.max(0, Math.floor(minX) - 1);
|
|
588
658
|
const y = Math.max(0, Math.floor(minY) - 1);
|
|
589
659
|
const w = Math.min(this.width, Math.ceil(maxX) + 1) - x;
|
|
@@ -610,8 +680,17 @@ class RenderingContext2d {
|
|
|
610
680
|
const R = this.Render;
|
|
611
681
|
|
|
612
682
|
this._ensureFillMask();
|
|
613
|
-
|
|
614
|
-
|
|
683
|
+
const flat = [];
|
|
684
|
+
let edges = 0;
|
|
685
|
+
for (const p of polys) {
|
|
686
|
+
if (p.pts.length < 6) continue;
|
|
687
|
+
flat.push(p.pts);
|
|
688
|
+
edges += p.pts.length / 2;
|
|
689
|
+
}
|
|
690
|
+
if (!this._uploadCoverage({ polys: flat, rule, dx: -b.x, dy: -b.y }, b, edges)) {
|
|
691
|
+
R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [b.x, b.y, b.w, b.h]);
|
|
692
|
+
this._rasterizePolys(this.fillMask, polys, rule);
|
|
693
|
+
}
|
|
615
694
|
|
|
616
695
|
if (alpha < 1) {
|
|
617
696
|
// In with a constant color scales the a8 coverage by that alpha
|
|
@@ -763,21 +842,33 @@ class RenderingContext2d {
|
|
|
763
842
|
return;
|
|
764
843
|
}
|
|
765
844
|
|
|
766
|
-
//
|
|
767
|
-
// the stroke honors clip / globalAlpha / composite op
|
|
845
|
+
// Render coverage into the scratch mask, then composite through it so
|
|
846
|
+
// the stroke honors clip / globalAlpha / composite op.
|
|
847
|
+
//
|
|
848
|
+
// Bounded to the stroke's bounding box, for the same reason _fillPolys
|
|
849
|
+
// is: on the wire a Composite is the same size either way, but this
|
|
850
|
+
// branch runs once per stroke, and a wall of 400 round-capped icons is
|
|
851
|
+
// 3200 of them. Clearing and compositing the whole surface each time was
|
|
852
|
+
// ~6 Gpx a frame and took 1.9 s on XQuartz where the bounded version
|
|
853
|
+
// takes 37 ms (react-x11#148). Stale mask content outside the box is
|
|
854
|
+
// never composited, so clearing only the box is safe.
|
|
768
855
|
this._ensureFillMask();
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
this.Render.
|
|
856
|
+
const b = this._trisBBox(tris);
|
|
857
|
+
if (!b) return;
|
|
858
|
+
if (!this._uploadCoverage({ triangles: tris, dx: -b.x, dy: -b.y }, b, tris.length / 2)) {
|
|
859
|
+
this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [b.x, b.y, b.w, b.h]);
|
|
860
|
+
const opaque = this.createSolidPicture(0, 0, 0, 1);
|
|
861
|
+
for (let i = 0; i < tris.length; i += chunk) {
|
|
862
|
+
this.Render.Triangles(this.Render.PictOp.Add, opaque.id, 0, 0, this.fillMask.id, this.Render.a8, tris.slice(i, i + chunk));
|
|
863
|
+
}
|
|
773
864
|
}
|
|
774
865
|
if (this.globalAlpha < 1) {
|
|
775
|
-
this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, this.globalAlpha], [
|
|
866
|
+
this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, this.globalAlpha], [b.x, b.y, b.w, b.h]);
|
|
776
867
|
}
|
|
777
868
|
if (this.clipMask) {
|
|
778
|
-
this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id,
|
|
869
|
+
this.Render.Composite(this.Render.PictOp.In, this.clipMask.id, 0, this.fillMask.id, b.x, b.y, 0, 0, b.x, b.y, b.w, b.h);
|
|
779
870
|
}
|
|
780
|
-
this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id,
|
|
871
|
+
this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id, b.x, b.y, b.x, b.y, b.x, b.y, b.w, b.h);
|
|
781
872
|
this._markDirty();
|
|
782
873
|
}
|
|
783
874
|
|
package/lib/text/font.js
CHANGED
|
@@ -143,11 +143,10 @@ export default class Font {
|
|
|
143
143
|
/**
|
|
144
144
|
* Rasterize a glyph to an 8-bit alpha bitmap (or null for blank glyphs).
|
|
145
145
|
* Converts fontkit's y-up font-unit path to the y-down pixel commands the
|
|
146
|
-
* scanline rasterizer expects.
|
|
147
|
-
* 2x2 — visually indistinguishable at that size, 4x cheaper to rasterize.
|
|
146
|
+
* scanline rasterizer expects.
|
|
148
147
|
*/
|
|
149
148
|
rasterize(glyphId, size) {
|
|
150
|
-
return rasterizePath(this._pathCommands(glyphId, size)
|
|
149
|
+
return rasterizePath(this._pathCommands(glyphId, size));
|
|
151
150
|
}
|
|
152
151
|
|
|
153
152
|
/**
|