ntk 7.6.0 → 7.7.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/image.js CHANGED
@@ -79,6 +79,18 @@ export class Image {
79
79
  return picture;
80
80
  }
81
81
 
82
+ /**
83
+ * The Pixmap those pixels live in on `app` (uploading on first use, like
84
+ * `picture`). What it is for is building a *second* Picture over the same
85
+ * upload — `ctx.createPattern` needs a repeating one, and changing the
86
+ * cached picture's attributes instead would change how `drawImage` samples
87
+ * this image everywhere else.
88
+ */
89
+ pixmap(app) {
90
+ this.picture(app);
91
+ return this._uploads.get(app).pixmap;
92
+ }
93
+
82
94
  /** free server-side copies of this image (safe to draw again afterwards) */
83
95
  destroy() {
84
96
  for (const { pixmap, picture } of this._uploads.values()) {
package/lib/index.js CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  defaultRasterizer,
48
48
  setDefaultRasterizer
49
49
  } from './rasterize.js';
50
+ import { DEFAULT_MASK_POLICY } from './maskcluster.js';
50
51
  import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
51
52
  import { TextLayout } from './text/layout.js';
52
53
  import HtmlView from './widgets/htmlview.js';
@@ -61,7 +62,7 @@ import { cssColor, cssLength } from './widgets/css.js';
61
62
  // comes last on purpose: it wraps the 'opengl' factory the indirect one just
62
63
  // registered, so that the backend-neutral name can dispatch on glPolicy.
63
64
  import './renderingcontext_x11.js';
64
- import './renderingcontext_2d.js';
65
+ import { CanvasGradient, CanvasPattern } from './renderingcontext_2d.js';
65
66
  import './renderingcontext_opengl.js';
66
67
  import './renderingcontext_gles.js';
67
68
 
@@ -237,6 +238,10 @@ export {
237
238
  Pixmap,
238
239
  Picture,
239
240
  Surface,
241
+ // fill/stroke styles the 2d context hands back (docs/context-2d.md) —
242
+ // exported for `instanceof`, not to be constructed directly
243
+ CanvasGradient,
244
+ CanvasPattern,
240
245
  Image,
241
246
  ImageData,
242
247
  pixelLayout,
@@ -260,6 +265,7 @@ export {
260
265
  defaultRasterizer,
261
266
  setDefaultRasterizer,
262
267
  DEFAULT_RASTER_POLICY,
268
+ DEFAULT_MASK_POLICY,
263
269
  DEFAULT_SHAPE_POLICY,
264
270
  TextLayout,
265
271
  HtmlView,
@@ -0,0 +1,191 @@
1
+ // Splitting one drawing's coverage mask into a few, when its pieces are
2
+ // scattered (issue #264).
3
+ //
4
+ // A fill or stroke rasterizes coverage into one a8 mask sized to the
5
+ // drawing's ink bounding box, and that mask costs width x height whatever
6
+ // the coverage inside it is. For one shape the bound is right. For a path
7
+ // holding N disjoint subpaths the bound is their *union*, so batching N
8
+ // draws into one path trades N small masks for one big one — which wins
9
+ // when the pieces span the box anyway (long edges of a graph) and loses
10
+ // badly when they do not (its handle dots): at 1100x700, 735 batched edge
11
+ // strokes cost 3.9 MB -> 1.3 MB of mask and 53% less frame time, while 19
12
+ // edges plus 40 small discs batched the same way cost ~0.75 MB *more* and
13
+ // 40% more frame time than drawing them singly.
14
+ //
15
+ // Rather than leave that cliff to every caller, the boxes go through here
16
+ // first and the mask is emitted once per cluster. The partition is by gaps
17
+ // only: a cut is legal where nothing straddles it, which is what keeps
18
+ // every cluster box disjoint from every other. Disjoint boxes are what make
19
+ // the split invisible — no pixel is composited twice (a translucent colour
20
+ // would blend twice at any overlap), and the winding number a fill asks for
21
+ // is unchanged, because a closed subpath contributes nothing to the winding
22
+ // of a point outside its own box.
23
+ //
24
+ // Cutting is not free: each extra mask is a few more requests. So a cut has
25
+ // to pay for itself in mask area, and the cheapest useful unit of area is
26
+ // the policy's `minSaving`. That single rule bounds the outcome from both
27
+ // sides — every cut removes at least `minSaving` pixels of mask, so the
28
+ // number of clusters can never exceed the union area divided by it.
29
+
30
+ /**
31
+ * When one drawing's mask is worth splitting into several.
32
+ *
33
+ * - `minSaving` — mask pixels a cut has to remove to be worth the extra
34
+ * mask pass it costs. 64x64 is the same "small enough not to think about"
35
+ * box `DEFAULT_RASTER_POLICY.maxArea` uses: below it, an extra pass is
36
+ * dearer than the area it would save.
37
+ * - `maxMasks` — hard cap on clusters per drawing, so a pathological path
38
+ * (thousands of scattered dots) cannot turn one drawing into thousands of
39
+ * request groups. Cuts are taken most-valuable-first, so the cap keeps the
40
+ * ones that matter.
41
+ *
42
+ * `{ maxMasks: 1 }` — or `minSaving: Infinity` — disables the split, which
43
+ * is what a composite op that writes outside its coverage gets.
44
+ */
45
+ export const DEFAULT_MASK_POLICY = {
46
+ minSaving: 64 * 64,
47
+ maxMasks: 32
48
+ };
49
+
50
+ /** the policy for one app, merged over the defaults */
51
+ export function maskPolicyOf(app) {
52
+ return app.maskPolicy
53
+ ? { ...DEFAULT_MASK_POLICY, ...app.maskPolicy }
54
+ : DEFAULT_MASK_POLICY;
55
+ }
56
+
57
+ const area = (b) => b.w * b.h;
58
+
59
+ /** The smallest {x, y, w, h} box holding both of two boxes. */
60
+ export function unionBox(a, b) {
61
+ const x = Math.min(a.x, b.x);
62
+ const y = Math.min(a.y, b.y);
63
+ return {
64
+ x,
65
+ y,
66
+ w: Math.max(a.x + a.w, b.x + b.w) - x,
67
+ h: Math.max(a.y + a.h, b.y + b.h) - y
68
+ };
69
+ }
70
+
71
+ function unionOf(boxes, order) {
72
+ let out = { ...boxes[order[0]] };
73
+ for (let i = 1; i < order.length; ++i) out = unionBox(out, boxes[order[i]]);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The most valuable gap cut of one group along one axis, or null when the
79
+ * group has no gap on it.
80
+ *
81
+ * `order` is the group's members sorted by that axis' start. A cut between
82
+ * members i and i+1 is legal only where nothing straddles it — the next box
83
+ * has to start at or past the far edge of every box before it, which for a
84
+ * union box is just its own far edge. What the cut is worth is the mask area
85
+ * it removes: the parent box less the two halves.
86
+ */
87
+ function bestGap(boxes, order, box, axis) {
88
+ const n = order.length;
89
+ const start = axis === 'x' ? 'x' : 'y';
90
+ const size = axis === 'x' ? 'w' : 'h';
91
+
92
+ // suffix[i] is the union box of order[i..n-1]; the prefix is carried
93
+ // along the scan below
94
+ const suffix = new Array(n);
95
+ suffix[n - 1] = { ...boxes[order[n - 1]] };
96
+ for (let i = n - 2; i >= 0; --i) {
97
+ suffix[i] = unionBox(boxes[order[i]], suffix[i + 1]);
98
+ }
99
+
100
+ let best = null;
101
+ let prefix = { ...boxes[order[0]] };
102
+ for (let i = 0; i + 1 < n; ++i) {
103
+ if (i > 0) prefix = unionBox(prefix, boxes[order[i]]);
104
+ const next = boxes[order[i + 1]];
105
+ if (next[start] < prefix[start] + prefix[size]) continue; // straddled
106
+ const saving = area(box) - area(prefix) - area(suffix[i + 1]);
107
+ if (!best || saving > best.saving) {
108
+ best = { axis, at: i, saving, left: prefix, right: suffix[i + 1] };
109
+ }
110
+ }
111
+ return best;
112
+ }
113
+
114
+ /** a group of pieces, its box, and the best cut available to it */
115
+ function makeNode(boxes, byX, byY, box) {
116
+ const node = { box: box ?? unionOf(boxes, byX), byX, byY, split: null };
117
+ if (byX.length > 1) {
118
+ const x = bestGap(boxes, byX, node.box, 'x');
119
+ const y = bestGap(boxes, byY, node.box, 'y');
120
+ node.split = !x ? y : !y || x.saving >= y.saving ? x : y;
121
+ }
122
+ return node;
123
+ }
124
+
125
+ /** the two halves of `node`, each with its own next-best cut */
126
+ function splitNode(boxes, node) {
127
+ const { axis, at, left, right } = node.split;
128
+ const cut = axis === 'x' ? node.byX : node.byY;
129
+ const other = axis === 'x' ? node.byY : node.byX;
130
+ const inLeft = new Set(cut.slice(0, at + 1));
131
+ // the other axis' order survives the filter, so neither half is re-sorted
132
+ const leftOther = other.filter((i) => inLeft.has(i));
133
+ const rightOther = other.filter((i) => !inLeft.has(i));
134
+ const leftCut = cut.slice(0, at + 1);
135
+ const rightCut = cut.slice(at + 1);
136
+ return axis === 'x'
137
+ ? [
138
+ makeNode(boxes, leftCut, leftOther, left),
139
+ makeNode(boxes, rightCut, rightOther, right)
140
+ ]
141
+ : [
142
+ makeNode(boxes, leftOther, leftCut, left),
143
+ makeNode(boxes, rightOther, rightCut, right)
144
+ ];
145
+ }
146
+
147
+ /**
148
+ * Partition one drawing's pieces into mask clusters.
149
+ *
150
+ * @param {Array<{x, y, w, h}>} boxes one integer box per piece — a fill's
151
+ * subpaths, a stroke's islands of triangles — already clamped to the
152
+ * surface. Boxes may overlap; overlapping ones always end up in the same
153
+ * cluster.
154
+ * @param {{minSaving: number, maxMasks: number}} [policy]
155
+ * @returns {Array<{x, y, w, h, items: number[]}>} clusters, each with the
156
+ * indices of the pieces it holds. Cluster boxes are pairwise disjoint,
157
+ * and their union is the union of `boxes`.
158
+ */
159
+ export function clusterBoxes(boxes, policy = DEFAULT_MASK_POLICY) {
160
+ const n = boxes.length;
161
+ if (!n) return [];
162
+ // merged here as well as in maskPolicyOf, so that a partial policy from a
163
+ // direct caller ({ maxMasks: 1 }) cannot silently zero the other field
164
+ const { minSaving, maxMasks: cap } = { ...DEFAULT_MASK_POLICY, ...policy };
165
+ const maxMasks = Math.max(1, cap | 0);
166
+ const all = boxes.map((_, i) => i);
167
+ if (n === 1 || maxMasks === 1 || !(minSaving < Infinity)) {
168
+ return [{ ...unionOf(boxes, all), items: all }];
169
+ }
170
+
171
+ const byX = all.slice().sort((a, b) => boxes[a].x - boxes[b].x || a - b);
172
+ const byY = all.slice().sort((a, b) => boxes[a].y - boxes[b].y || a - b);
173
+ const nodes = [makeNode(boxes, byX, byY)];
174
+ // most-valuable cut first, so a maxMasks cap keeps the cuts that matter
175
+ while (nodes.length < maxMasks) {
176
+ let pick = -1;
177
+ let best = minSaving;
178
+ for (let i = 0; i < nodes.length; ++i) {
179
+ const split = nodes[i].split;
180
+ if (split && split.saving >= best) {
181
+ best = split.saving;
182
+ pick = i;
183
+ }
184
+ }
185
+ if (pick < 0) break;
186
+ const [a, b] = splitNode(boxes, nodes[pick]);
187
+ nodes[pick] = a;
188
+ nodes.push(b);
189
+ }
190
+ return nodes.map((node) => ({ ...node.box, items: node.byX }));
191
+ }
@@ -11,6 +11,7 @@ import {
11
11
  pixelLayout,
12
12
  toStraightRgba,
13
13
  } from "./imagedata.js";
14
+ import { clusterBoxes, maskPolicyOf, unionBox } from "./maskcluster.js";
14
15
  import {
15
16
  Path2D,
16
17
  arcSegmentCount,
@@ -26,6 +27,7 @@ import {
26
27
  import Picture from "./picture.js";
27
28
  import Pixmap from "./pixmap.js";
28
29
  import { routeRaster } from "./rasterize.js";
30
+ import { Surface } from "./surface.js";
29
31
  import {
30
32
  BL,
31
33
  BR,
@@ -48,6 +50,22 @@ import { TextLayout } from "./text/layout.js";
48
50
  import { reorderRuns } from "./text/shape.js";
49
51
  import { trapezoidize } from "./trapezoid.js";
50
52
 
53
+ /**
54
+ * The subpaths of a flattened path that can enclose area, as the flat
55
+ * `[x0, y0, …]` lists both the local rasterizer and the trapezoidizer take,
56
+ * with the edge count the routing policy asks for.
57
+ */
58
+ function fillableShapes(polys) {
59
+ const shapes = [];
60
+ let edges = 0;
61
+ for (const p of polys) {
62
+ if (p.pts.length < 6) continue;
63
+ shapes.push(p.pts);
64
+ edges += p.pts.length / 2;
65
+ }
66
+ return { shapes, edges };
67
+ }
68
+
51
69
  /** The overlap of two {x, y, w, h} boxes, or null when they have none. */
52
70
  function intersectBox(a, b) {
53
71
  const x = Math.max(a.x, b.x);
@@ -103,12 +121,92 @@ const gcRegistry = new FinalizationRegistry(({ X, gcs }) => {
103
121
  * the type. A `RenderingContext2d` deliberately does not match: its `picture`
104
122
  * is a property rather than a method, and it keeps its own branch below.
105
123
  */
106
- /** a fillStyle with a single colour behind it, as opposed to a gradient or a
107
- * caller-supplied Picture */
124
+ /** a fillStyle with a single colour behind it, as opposed to a gradient, a
125
+ * pattern or a caller-supplied Picture */
108
126
  function isPlainColor(style) {
109
127
  return typeof style === "string" || Array.isArray(style);
110
128
  }
111
129
 
130
+ // `createPattern` repetitions -> XRender Repeat modes. The canvas spec's
131
+ // per-axis 'repeat-x'/'repeat-y' have no mode here (see createPattern);
132
+ // 'pad' and 'reflect' are the two XRender modes the spec has no name for.
133
+ const REPEAT_MODES = {
134
+ repeat: 1, // Repeat.Normal
135
+ "no-repeat": 0, // Repeat.None
136
+ pad: 2, // clamp to the edge pixels
137
+ reflect: 3, // mirror every other tile
138
+ };
139
+
140
+ const PATTERN_DOCS =
141
+ "https://github.com/sidorares/ntk/blob/master/docs/context-2d.md#patterns";
142
+
143
+ /**
144
+ * What a pattern tiles: a drawable holding the tile plus the picture format
145
+ * to read it through. A repeating source Picture is created over it rather
146
+ * than the source's own picture being changed, so tiling a `Surface` leaves
147
+ * `drawImage` of that same surface exactly as it was.
148
+ */
149
+ function patternSourceOf(app, source) {
150
+ const Render = app.display.Render;
151
+ const formatFor = (depth) =>
152
+ depth === 32 ? Render.rgba32 : depth === 8 ? "a8" : Render.rgb24;
153
+
154
+ let drawable = null;
155
+ let width;
156
+ let height;
157
+ let format;
158
+ if (source instanceof Surface) {
159
+ if (source.app !== app) {
160
+ throw new Error(
161
+ "createPattern: the Surface belongs to a different X connection",
162
+ );
163
+ }
164
+ drawable = source.pixmap;
165
+ format = source.format === "a8" ? "a8" : Render.rgba32;
166
+ ({ width, height } = source);
167
+ } else if (source instanceof Image) {
168
+ drawable = source.pixmap(app);
169
+ format = Render.rgba32;
170
+ ({ width, height } = source);
171
+ } else if (source && typeof source.id === "number") {
172
+ // a Drawable: a Pixmap, or a Window (through its backing pixmap, which
173
+ // is where a double-buffered window's current pixels actually are)
174
+ drawable = source._backing || source;
175
+ const depth = drawable.depth ?? source.depth;
176
+ if (!depth) {
177
+ throw new Error(
178
+ "createPattern: the drawable's depth is not known yet — await the window's geometry, or pass a Surface",
179
+ );
180
+ }
181
+ format = formatFor(depth);
182
+ width = drawable.width ?? source.width;
183
+ height = drawable.height ?? source.height;
184
+ } else {
185
+ throw new Error(
186
+ "createPattern: expected a Surface, an Image, a Pixmap or a Window as the tile, got " +
187
+ (source === null ? "null" : typeof source),
188
+ );
189
+ }
190
+ if (format === "a8") {
191
+ throw new Error(
192
+ "createPattern: a coverage (a8) tile has no colour to paint with — XRender would " +
193
+ "sample it as black. Draw the tile into an argb32 Surface and tile that, or keep " +
194
+ `the a8 one and use ctx.drawImage, which paints it in the current fillStyle. ${PATTERN_DOCS}`,
195
+ );
196
+ }
197
+ return { drawable, format, width, height };
198
+ }
199
+
200
+ /**
201
+ * Point a pattern's picture transform at the transform in force for this
202
+ * fill — patterns are painted in user space, so the CTM is part of the
203
+ * mapping. Returns false when nothing would be painted (a singular matrix),
204
+ * true for anything else, including every non-pattern style.
205
+ */
206
+ function preparePattern(src, m) {
207
+ return src instanceof CanvasPattern ? src._sync(m) : true;
208
+ }
209
+
112
210
  function isPictureSource(image) {
113
211
  return (
114
212
  image instanceof Image ||
@@ -136,6 +234,30 @@ const GCO_TO_PICTOP = {
136
234
  lighter: "Add",
137
235
  };
138
236
 
237
+ // The ops whose result is the destination wherever the mask is zero. Only
238
+ // those may have a drawing's mask split into several boxes (maskcluster.js):
239
+ // what the split gives up is the gaps between the boxes, and for these ops
240
+ // the single-box version would not have changed those pixels either. `copy`,
241
+ // `source-in`, `destination-in`, `source-out` and `destination-atop` write
242
+ // the source — or nothing — across the whole box, so they keep one.
243
+ const MASK_BOUNDED_OPS = [
244
+ "Over",
245
+ "OverReverse",
246
+ "OutReverse",
247
+ "Atop",
248
+ "Xor",
249
+ "Add",
250
+ ];
251
+
252
+ // and what they are clustered with instead: one mask, exactly as before
253
+ const ONE_MASK = { minSaving: Infinity, maxMasks: 1 };
254
+
255
+ // How far apart two of a stroke's triangles have to be to start a new piece
256
+ // (_trisPieces). Two mask boxes a pixel or two apart are not worth
257
+ // splitting — each carries a pixel of antialiasing slack of its own — so
258
+ // this only has to be small next to anything `minSaving` would pay for.
259
+ const PIECE_SLACK = 2;
260
+
139
261
  // extrude-polyline has no round caps/joins: 'round' extrudes as butt/bevel
140
262
  // and the missing coverage is unioned in afterwards as triangle-fan disks
141
263
  // (see _strokePolys)
@@ -284,6 +406,12 @@ class RenderingContext2d {
284
406
  // a silent perf cliff, so hits and misses-by-reason are always counted.
285
407
  // NTK_DEBUG_SHAPES=1 prints the process-wide aggregate at exit.
286
408
  this.shapeStats = { hits: 0, misses: {} };
409
+ // Mask cost, for the same reason (issue #264): a drawing whose pieces
410
+ // are scattered pays for the box around all of them unless the mask is
411
+ // split, and neither the pixels nor the split show up anywhere else.
412
+ // `masks` counts mask passes, `pixels` their total area, `split` the
413
+ // drawings that took more than one.
414
+ this.maskStats = { masks: 0, pixels: 0, split: 0 };
287
415
  this._stack = [];
288
416
  this._clips = []; // [{ polys, rule }] in device space, already stacked
289
417
  this._gco = "source-over";
@@ -458,7 +586,11 @@ class RenderingContext2d {
458
586
  const c = parseColor(value);
459
587
  return this.createSolidPicture(c[0], c[1], c[2], c[3]);
460
588
  }
461
- if (value instanceof Picture || value instanceof CanvasGradient) {
589
+ if (
590
+ value instanceof Picture ||
591
+ value instanceof CanvasGradient ||
592
+ value instanceof CanvasPattern
593
+ ) {
462
594
  return value;
463
595
  }
464
596
  throw new Error("Unknown fill style");
@@ -763,11 +895,9 @@ class RenderingContext2d {
763
895
  });
764
896
  }
765
897
 
766
- _rasterizePolys(picture, polys, rule, dx = 0, dy = 0) {
767
- const flat = [];
768
- for (const p of polys) if (p.pts.length >= 6) flat.push(p.pts);
769
- if (!flat.length) return;
770
- const traps = trapezoidize(flat, dx, dy, [], rule);
898
+ _rasterizePolys(picture, shapes, rule, dx = 0, dy = 0) {
899
+ if (!shapes.length) return;
900
+ const traps = trapezoidize(shapes, dx, dy, [], rule);
771
901
  // stay under the server's maximum request size
772
902
  const chunk = 4000 * 6;
773
903
  for (let i = 0; i < traps.length; i += chunk) {
@@ -840,46 +970,34 @@ class RenderingContext2d {
840
970
  }
841
971
 
842
972
  /**
843
- * Core fill: rasterize device-space polys into the scratch a8 mask,
844
- * scale by globalAlpha, intersect with the clip, composite the source.
845
- */
846
- /**
847
- * Device-space bounding box of flattened polys, with a pixel of slack for
848
- * the antialiased edge, clamped to the surface. Null when nothing lands
849
- * on it.
973
+ * Device-space bounding box of one flat `[x0, y0, …]` point list, with a
974
+ * pixel of slack for the antialiased edge, clamped to the surface. Null
975
+ * when nothing lands on it.
850
976
  */
851
- _polysBBox(polys) {
977
+ _pointsBBox(pts) {
852
978
  let minX = Infinity;
853
979
  let minY = Infinity;
854
980
  let maxX = -Infinity;
855
981
  let maxY = -Infinity;
856
- for (const poly of polys) {
857
- const pts = poly.pts;
858
- for (let i = 0; i < pts.length; i += 2) {
859
- if (pts[i] < minX) minX = pts[i];
860
- if (pts[i] > maxX) maxX = pts[i];
861
- if (pts[i + 1] < minY) minY = pts[i + 1];
862
- if (pts[i + 1] > maxY) maxY = pts[i + 1];
863
- }
982
+ for (let i = 0; i < pts.length; i += 2) {
983
+ if (pts[i] < minX) minX = pts[i];
984
+ if (pts[i] > maxX) maxX = pts[i];
985
+ if (pts[i + 1] < minY) minY = pts[i + 1];
986
+ if (pts[i + 1] > maxY) maxY = pts[i + 1];
864
987
  }
865
988
  if (maxX === -Infinity) return null;
866
989
  return this._clampBBox(minX, minY, maxX, maxY);
867
990
  }
868
991
 
869
- /** the same, for a flat triangle soup (stroke geometry) */
870
- _trisBBox(tris) {
871
- let minX = Infinity;
872
- let minY = Infinity;
873
- let maxX = -Infinity;
874
- let maxY = -Infinity;
875
- for (let i = 0; i < tris.length; i += 2) {
876
- if (tris[i] < minX) minX = tris[i];
877
- if (tris[i] > maxX) maxX = tris[i];
878
- if (tris[i + 1] < minY) minY = tris[i + 1];
879
- if (tris[i + 1] > maxY) maxY = tris[i + 1];
992
+ /** the same over every subpath of a flattened path, as one box */
993
+ _polysBBox(polys) {
994
+ let out = null;
995
+ for (const poly of polys) {
996
+ const b = this._pointsBBox(poly.pts);
997
+ if (!b) continue;
998
+ out = out ? unionBox(out, b) : b;
880
999
  }
881
- if (maxX === -Infinity) return null;
882
- return this._clampBBox(minX, minY, maxX, maxY);
1000
+ return out;
883
1001
  }
884
1002
 
885
1003
  // a pixel of slack for the antialiased edge, clamped to the surface
@@ -892,57 +1010,74 @@ class RenderingContext2d {
892
1010
  return { x, y, w, h };
893
1011
  }
894
1012
 
895
- _fillPolys(polys, rule, { src = null, op = null, alpha = null } = {}) {
896
- if (!polys.length) return;
897
- src = src ?? this._backgroundPicture;
898
- op = op ?? this._op();
899
- alpha = alpha ?? this.globalAlpha;
900
- if (alpha <= 0) return;
901
-
902
- // Everything below is bounded to the shape's bounding box rather than
903
- // the whole surface. On the wire it makes no difference — a Composite
904
- // request is the same size either way — but it is the difference
905
- // between the server touching a 34x34 box and a 400x400 one per fill.
906
- // Stale mask content outside the box is never composited, so clearing
907
- // only the box is safe.
908
- const b = this._polysBBox(polys);
909
- if (!b) return;
910
- const R = this.Render;
1013
+ /**
1014
+ * The mask boxes one drawing's pieces are painted through: one per cluster
1015
+ * of them, whose union is the drawing's bounding box.
1016
+ *
1017
+ * Everything the mask does is bounded to those boxes rather than to the
1018
+ * whole surface. On the wire it makes no difference — a Composite request
1019
+ * is the same size either way — but it is the difference between the
1020
+ * server touching a 34x34 box and a 400x400 one per fill. Where the pieces
1021
+ * are *scattered*, their union is a poor bound in the same way, and
1022
+ * `clusterBoxes` cuts it into the few boxes the ink is actually in (see
1023
+ * maskcluster.js). Stale mask content outside a box is never composited,
1024
+ * so clearing only the boxes is safe.
1025
+ */
1026
+ _maskClusters(pieces, op) {
1027
+ const clusters = clusterBoxes(
1028
+ pieces,
1029
+ this._maskBounded(op) ? maskPolicyOf(this.window.app) : ONE_MASK,
1030
+ );
1031
+ if (clusters.length > 1) this.maskStats.split++;
1032
+ return clusters;
1033
+ }
911
1034
 
912
- this._ensureFillMask();
913
- const flat = [];
914
- let edges = 0;
915
- for (const p of polys) {
916
- if (p.pts.length < 6) continue;
917
- flat.push(p.pts);
918
- edges += p.pts.length / 2;
919
- }
920
- if (
921
- !this._uploadCoverage({ polys: flat, rule, dx: -b.x, dy: -b.y }, b, edges)
922
- ) {
923
- R.FillRectangles(
924
- R.PictOp.Src,
925
- this.fillMask.id,
926
- [0, 0, 0, 0],
927
- [b.x, b.y, b.w, b.h],
928
- );
929
- this._rasterizePolys(this.fillMask, polys, rule);
930
- }
1035
+ /** whether `op` leaves the destination alone where the mask is zero */
1036
+ _maskBounded(op) {
1037
+ this._boundedOps ??= new Set(
1038
+ MASK_BOUNDED_OPS.map((name) => this.Render.PictOp[name]),
1039
+ );
1040
+ return this._boundedOps.has(op);
1041
+ }
931
1042
 
932
- // a rectangular clip narrows where the coverage is *composited*; the
933
- // mask content outside it is stale by the same argument as outside `b`.
934
- // A stack whose mask was dropped by restore() may still hold a poly —
935
- // _clipRect says null then, and the mask comes back on demand.
1043
+ /**
1044
+ * The tail every masked fill and stroke ends in: coverage for one box into
1045
+ * the scratch a8 mask, scaled by `alpha`, intersected with the clip, and
1046
+ * the source composited through it.
1047
+ *
1048
+ * `job` is what a Rasterizer takes minus the box (docs/context-2d.md) —
1049
+ * `{ polys, rule }` or `{ triangles }`; `server` rasterizes the same
1050
+ * geometry into the mask server-side, for when the local rasterizer is not
1051
+ * the cheaper route or declines.
1052
+ *
1053
+ * @returns {boolean} whether anything was composited
1054
+ */
1055
+ _paintThroughMask(b, job, { edges, src, op, alpha, server }) {
1056
+ const R = this.Render;
1057
+ // A rectangular clip narrows where the coverage is *composited*; the
1058
+ // mask content outside it is stale by the same argument as outside `b`,
1059
+ // and a box the clip rejects outright needs no coverage at all. A stack
1060
+ // whose mask was dropped by restore() may still hold a poly — _clipRect
1061
+ // says null then, and the mask comes back on demand.
936
1062
  let out = b;
937
1063
  if (this._clips.length && !this.clipMask) {
938
1064
  const cr = this._clipRect();
939
1065
  if (cr) {
940
1066
  out = intersectBox(b, cr);
941
- if (!out) return;
1067
+ if (!out) return false;
942
1068
  } else {
943
1069
  this._requireClipMask();
944
1070
  }
945
1071
  }
1072
+ if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, edges)) {
1073
+ R.FillRectangles(
1074
+ R.PictOp.Src,
1075
+ this.fillMask.id,
1076
+ [0, 0, 0, 0],
1077
+ [b.x, b.y, b.w, b.h],
1078
+ );
1079
+ server();
1080
+ }
946
1081
  if (alpha < 1) {
947
1082
  // In with a constant color scales the a8 coverage by that alpha
948
1083
  R.FillRectangles(
@@ -985,7 +1120,59 @@ class RenderingContext2d {
985
1120
  out.w,
986
1121
  out.h,
987
1122
  );
988
- this._markDirty();
1123
+ this.maskStats.masks++;
1124
+ this.maskStats.pixels += out.w * out.h;
1125
+ return true;
1126
+ }
1127
+
1128
+ /**
1129
+ * Core fill: rasterize device-space polys into the scratch a8 mask,
1130
+ * scale by globalAlpha, intersect with the clip, composite the source.
1131
+ */
1132
+ _fillPolys(polys, rule, { src = null, op = null, alpha = null } = {}) {
1133
+ if (!polys.length) return;
1134
+ src = src ?? this._backgroundPicture;
1135
+ op = op ?? this._op();
1136
+ alpha = alpha ?? this.globalAlpha;
1137
+ if (alpha <= 0) return;
1138
+ if (!preparePattern(src, this._m)) return;
1139
+
1140
+ // one box per subpath, so a path holding disjoint ones can be masked as
1141
+ // the pieces it is rather than as the box around all of them
1142
+ const shapes = [];
1143
+ const pieces = [];
1144
+ for (const p of polys) {
1145
+ if (p.pts.length < 6) continue;
1146
+ const b = this._pointsBBox(p.pts);
1147
+ if (!b) continue;
1148
+ shapes.push(p.pts);
1149
+ pieces.push(b);
1150
+ }
1151
+ if (!shapes.length) return;
1152
+
1153
+ this._ensureFillMask();
1154
+ let painted = false;
1155
+ for (const cluster of this._maskClusters(pieces, op)) {
1156
+ const flat = [];
1157
+ let edges = 0;
1158
+ for (const i of cluster.items) {
1159
+ flat.push(shapes[i]);
1160
+ edges += shapes[i].length / 2;
1161
+ }
1162
+ painted =
1163
+ this._paintThroughMask(
1164
+ cluster,
1165
+ { polys: flat, rule },
1166
+ {
1167
+ edges,
1168
+ src,
1169
+ op,
1170
+ alpha,
1171
+ server: () => this._rasterizePolys(this.fillMask, flat, rule),
1172
+ },
1173
+ ) || painted;
1174
+ }
1175
+ if (painted) this._markDirty();
989
1176
  }
990
1177
 
991
1178
  _strokePolys(polys, { src = null } = {}) {
@@ -1106,10 +1293,23 @@ class RenderingContext2d {
1106
1293
  // a segment that is already at least r long
1107
1294
  if (dot >= 0 && r * r <= shortest) continue;
1108
1295
  const cross = ax * by - ay * bx;
1109
- // r·tan(φ/2) > min(|a|, |b|), squared. A non-positive denominator is
1110
- // the reversal the tangent is infinite at.
1111
- const denom = Math.sqrt(la * lb) + dot;
1112
- if (denom <= 0 || (r * cross) ** 2 > shortest * denom * denom) {
1296
+ // r·tan(φ/2) > min(|a|, |b|), squared. A non-positive denominator
1297
+ // is the reversal the tangent is infinite at — but near one the
1298
+ // denominator is pure cancellation (√(la·lb) and -dot agree to ~15
1299
+ // digits), so an exact double-back lands a few ulps to either side
1300
+ // of 0. The same-x bursts of issue #259 have cross exactly 0 too,
1301
+ // so when denom rounded positive neither test here fired and the
1302
+ // extruder met the reversal itself, normalizing a zero-length
1303
+ // tangent into NaN join vertices. Below its own noise floor denom
1304
+ // only means "within microradians of a reversal", where the true
1305
+ // tangent exceeds a million and no segment can hold the join: cut
1306
+ // unconditionally.
1307
+ const ab = Math.sqrt(la * lb);
1308
+ const denom = ab + dot;
1309
+ if (
1310
+ denom <= ab * 1e-12 ||
1311
+ (r * cross) ** 2 > shortest * denom * denom
1312
+ ) {
1113
1313
  (cuts ??= []).push(i);
1114
1314
  }
1115
1315
  }
@@ -1277,6 +1477,27 @@ class RenderingContext2d {
1277
1477
  for (const run of dashed.runs) extrudeRun(cleanRun(run), false);
1278
1478
  }
1279
1479
  }
1480
+ // Nothing non-finite may reach the server: FIXED encoding turns NaN
1481
+ // into 0, so one poisoned vertex renders as a wedge to the origin on a
1482
+ // real display — while the in-process server quietly drops it, which
1483
+ // is why no headless pixel test can see this for us (issue #259). The
1484
+ // cuts above are meant to keep the extruder off that path; this keeps
1485
+ // a future miss from being catastrophic. A sum is finite iff every
1486
+ // term is.
1487
+ let w = 0;
1488
+ for (let i = 0; i < tris.length; i += 6) {
1489
+ const sum =
1490
+ tris[i] +
1491
+ tris[i + 1] +
1492
+ tris[i + 2] +
1493
+ tris[i + 3] +
1494
+ tris[i + 4] +
1495
+ tris[i + 5];
1496
+ if (!Number.isFinite(sum)) continue;
1497
+ if (w < i) for (let k = 0; k < 6; k++) tris[w + k] = tris[i + k];
1498
+ w += 6;
1499
+ }
1500
+ if (w < tris.length) tris.length = w;
1280
1501
  if (!tris.length) return;
1281
1502
 
1282
1503
  const op = this._op();
@@ -1291,14 +1512,22 @@ class RenderingContext2d {
1291
1512
  const chunk = 4000 * 6;
1292
1513
  if (direct) {
1293
1514
  for (let i = 0; i < tris.length; i += chunk) {
1515
+ const batch = tris.slice(i, i + chunk);
1516
+ // RENDER aligns the source with the *first triangle's first vertex*,
1517
+ // not with the destination: the source is sampled at
1518
+ // (srcX + x - floor(tris[0].x)). Passing that vertex back is what
1519
+ // makes source coordinates equal destination coordinates, the same
1520
+ // convention drawGlyphRuns and compositeTraps use — without it every
1521
+ // non-constant stroke style (a gradient, a pattern) is offset by
1522
+ // wherever the stroke happens to start, and shifts as it moves.
1294
1523
  this.Render.Triangles(
1295
1524
  op,
1296
1525
  src.id,
1297
- 0,
1298
- 0,
1526
+ Math.floor(batch[0]),
1527
+ Math.floor(batch[1]),
1299
1528
  this.picture.id,
1300
1529
  this.Render.a8,
1301
- tris.slice(i, i + chunk),
1530
+ batch,
1302
1531
  );
1303
1532
  }
1304
1533
  this._markDirty();
@@ -1308,91 +1537,117 @@ class RenderingContext2d {
1308
1537
  // Render coverage into the scratch mask, then composite through it so
1309
1538
  // the stroke honors clip / globalAlpha / composite op.
1310
1539
  //
1311
- // Bounded to the stroke's bounding box, for the same reason _fillPolys
1312
- // is: on the wire a Composite is the same size either way, but this
1313
- // branch runs once per stroke, and a wall of 400 round-capped icons is
1314
- // 3200 of them. Clearing and compositing the whole surface each time was
1315
- // ~6 Gpx a frame and took 1.9 s on XQuartz where the bounded version
1316
- // takes 37 ms (react-x11#148). Stale mask content outside the box is
1317
- // never composited, so clearing only the box is safe.
1540
+ // Bounded to the stroke's islands, for the same reason _fillPolys is: on
1541
+ // the wire a Composite is the same size either way, but this branch runs
1542
+ // once per stroke, and a wall of 400 round-capped icons is 3200 of them.
1543
+ // Clearing and compositing the whole surface each time was ~6 Gpx a frame
1544
+ // and took 1.9 s on XQuartz where the bounded version takes 37 ms
1545
+ // (react-x11#148). Stale mask content outside the boxes is never
1546
+ // composited, so clearing only them is safe.
1318
1547
  this._ensureFillMask();
1319
- const b = this._trisBBox(tris);
1320
- if (!b) return;
1321
- if (
1322
- !this._uploadCoverage(
1323
- { triangles: tris, dx: -b.x, dy: -b.y },
1324
- b,
1325
- tris.length / 2,
1326
- )
1327
- ) {
1328
- this.Render.FillRectangles(
1329
- this.Render.PictOp.Src,
1330
- this.fillMask.id,
1331
- [0, 0, 0, 0],
1332
- [b.x, b.y, b.w, b.h],
1333
- );
1334
- const opaque = this.createSolidPicture(0, 0, 0, 1);
1335
- for (let i = 0; i < tris.length; i += chunk) {
1336
- this.Render.Triangles(
1337
- this.Render.PictOp.Add,
1338
- opaque.id,
1339
- 0,
1340
- 0,
1341
- this.fillMask.id,
1342
- this.Render.a8,
1343
- tris.slice(i, i + chunk),
1344
- );
1345
- }
1346
- }
1347
- let out = b;
1348
- if (this._clips.length && !this.clipMask) {
1349
- const cr = this._clipRect();
1350
- if (cr) {
1351
- out = intersectBox(b, cr);
1352
- if (!out) return;
1353
- } else {
1354
- this._requireClipMask();
1548
+ const pieces = this._trisPieces(tris);
1549
+ if (!pieces.length) return;
1550
+ // the soup itself when it is one piece holding every triangle — the
1551
+ // common case (one polyline is one piece), and worth not copying
1552
+ const whole =
1553
+ pieces.length === 1 &&
1554
+ pieces[0].start === 0 &&
1555
+ pieces[0].end === tris.length;
1556
+ let painted = false;
1557
+ for (const cluster of this._maskClusters(pieces, op)) {
1558
+ const batch = whole ? tris : this._trisOf(tris, pieces, cluster.items);
1559
+ painted =
1560
+ this._paintThroughMask(
1561
+ cluster,
1562
+ { triangles: batch },
1563
+ {
1564
+ edges: batch.length / 2,
1565
+ src,
1566
+ op,
1567
+ alpha: this.globalAlpha,
1568
+ server: () => {
1569
+ const opaque = this.createSolidPicture(0, 0, 0, 1);
1570
+ for (let i = 0; i < batch.length; i += chunk) {
1571
+ this.Render.Triangles(
1572
+ this.Render.PictOp.Add,
1573
+ opaque.id,
1574
+ 0,
1575
+ 0,
1576
+ this.fillMask.id,
1577
+ this.Render.a8,
1578
+ batch.slice(i, i + chunk),
1579
+ );
1580
+ }
1581
+ },
1582
+ },
1583
+ ) || painted;
1584
+ }
1585
+ if (painted) this._markDirty();
1586
+ }
1587
+
1588
+ /**
1589
+ * A stroke's triangle soup as the islands it is made of: consecutive
1590
+ * triangles are coalesced while they stay within `PIECE_SLACK` of each
1591
+ * other, which collapses a polyline — body, caps, join disks and all —
1592
+ * into one piece and leaves a batch of separate strokes as one piece each.
1593
+ * Each piece carries the `[start, end)` span of `tris` it owns.
1594
+ *
1595
+ * Coalescing is always safe: merging boxes can only widen a mask, while
1596
+ * splitting overlapping coverage apart is what would double-blend it —
1597
+ * and `clusterBoxes` cannot split overlapping boxes anyway. So this is a
1598
+ * linear pre-pass whose only job is to hand the clustering a handful of
1599
+ * boxes instead of thousands of triangles.
1600
+ *
1601
+ * A piece that misses the surface entirely is dropped, leaving a hole in
1602
+ * the spans — which is why the caller gathers a batch from the spans it
1603
+ * has rather than assuming they tile `tris`.
1604
+ */
1605
+ _trisPieces(tris) {
1606
+ const pieces = [];
1607
+ let open = null;
1608
+ const close = () => {
1609
+ if (!open) return;
1610
+ const b = this._clampBBox(open.minX, open.minY, open.maxX, open.maxY);
1611
+ if (b) pieces.push({ ...b, start: open.start, end: open.end });
1612
+ open = null;
1613
+ };
1614
+ for (let i = 0; i < tris.length; i += 6) {
1615
+ const minX = Math.min(tris[i], tris[i + 2], tris[i + 4]);
1616
+ const maxX = Math.max(tris[i], tris[i + 2], tris[i + 4]);
1617
+ const minY = Math.min(tris[i + 1], tris[i + 3], tris[i + 5]);
1618
+ const maxY = Math.max(tris[i + 1], tris[i + 3], tris[i + 5]);
1619
+ if (
1620
+ open &&
1621
+ minX - PIECE_SLACK <= open.maxX &&
1622
+ open.minX - PIECE_SLACK <= maxX &&
1623
+ minY - PIECE_SLACK <= open.maxY &&
1624
+ open.minY - PIECE_SLACK <= maxY
1625
+ ) {
1626
+ if (minX < open.minX) open.minX = minX;
1627
+ if (maxX > open.maxX) open.maxX = maxX;
1628
+ if (minY < open.minY) open.minY = minY;
1629
+ if (maxY > open.maxY) open.maxY = maxY;
1630
+ open.end = i + 6;
1631
+ continue;
1355
1632
  }
1633
+ close();
1634
+ open = { minX, minY, maxX, maxY, start: i, end: i + 6 };
1356
1635
  }
1357
- if (this.globalAlpha < 1) {
1358
- this.Render.FillRectangles(
1359
- this.Render.PictOp.In,
1360
- this.fillMask.id,
1361
- [0, 0, 0, this.globalAlpha],
1362
- [out.x, out.y, out.w, out.h],
1363
- );
1364
- }
1365
- if (this.clipMask) {
1366
- this.Render.Composite(
1367
- this.Render.PictOp.In,
1368
- this.clipMask.id,
1369
- 0,
1370
- this.fillMask.id,
1371
- out.x,
1372
- out.y,
1373
- 0,
1374
- 0,
1375
- out.x,
1376
- out.y,
1377
- out.w,
1378
- out.h,
1379
- );
1636
+ close();
1637
+ return pieces;
1638
+ }
1639
+
1640
+ /** the triangles of the given pieces, gathered into one soup */
1641
+ _trisOf(tris, pieces, items) {
1642
+ let n = 0;
1643
+ for (const i of items) n += pieces[i].end - pieces[i].start;
1644
+ const out = new Array(n);
1645
+ let w = 0;
1646
+ for (const i of items) {
1647
+ const piece = pieces[i];
1648
+ for (let k = piece.start; k < piece.end; ++k) out[w++] = tris[k];
1380
1649
  }
1381
- this.Render.Composite(
1382
- op,
1383
- src.id,
1384
- this.fillMask.id,
1385
- this.picture.id,
1386
- out.x,
1387
- out.y,
1388
- out.x,
1389
- out.y,
1390
- out.x,
1391
- out.y,
1392
- out.w,
1393
- out.h,
1394
- );
1395
- this._markDirty();
1650
+ return out;
1396
1651
  }
1397
1652
 
1398
1653
  /**
@@ -1429,6 +1684,7 @@ class RenderingContext2d {
1429
1684
  drawGlyphs(op, src, positioned) {
1430
1685
  const app = this.window.app;
1431
1686
  const R = this.Render;
1687
+ if (!preparePattern(src, this._m)) return;
1432
1688
  if (!this._clips.length) {
1433
1689
  drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
1434
1690
  this._markDirty();
@@ -1650,6 +1906,7 @@ class RenderingContext2d {
1650
1906
 
1651
1907
  fillRect(x, y, w, h) {
1652
1908
  if (matIsIdentity(this._m)) {
1909
+ if (!preparePattern(this._backgroundPicture, this._m)) return;
1653
1910
  const mask = this._compositeMask();
1654
1911
  this.Render.Composite(
1655
1912
  this._op(),
@@ -2356,7 +2613,13 @@ class RenderingContext2d {
2356
2613
  [0, 0, 0, 0],
2357
2614
  [0, 0, bb.w, bb.h],
2358
2615
  );
2359
- this._rasterizePolys(tmpMask, entry.polys, entry.rule, -bb.x, -bb.y);
2616
+ this._rasterizePolys(
2617
+ tmpMask,
2618
+ fillableShapes(entry.polys).shapes,
2619
+ entry.rule,
2620
+ -bb.x,
2621
+ -bb.y,
2622
+ );
2360
2623
  }
2361
2624
  R.Composite(
2362
2625
  op,
@@ -2395,14 +2658,8 @@ class RenderingContext2d {
2395
2658
  _uploadClipCoverage(tmpPixmap, entry, bb) {
2396
2659
  const rasterizer = this.window.app.rasterizer;
2397
2660
  if (!rasterizer) return false;
2398
- const flat = [];
2399
- let edges = 0;
2400
- for (const p of entry.polys) {
2401
- if (p.pts.length < 6) continue;
2402
- flat.push(p.pts);
2403
- edges += p.pts.length / 2;
2404
- }
2405
- if (!flat.length) return false;
2661
+ const { shapes, edges } = fillableShapes(entry.polys);
2662
+ if (!shapes.length) return false;
2406
2663
  if (
2407
2664
  routeRaster(bb.w, bb.h, edges, this.window.app.rasterPolicy) !== "local"
2408
2665
  ) {
@@ -2410,7 +2667,7 @@ class RenderingContext2d {
2410
2667
  }
2411
2668
 
2412
2669
  const coverage = rasterizer.rasterize({
2413
- polys: flat,
2670
+ polys: shapes,
2414
2671
  rule: entry.rule,
2415
2672
  dx: -bb.x,
2416
2673
  dy: -bb.y,
@@ -2714,6 +2971,35 @@ class RenderingContext2d {
2714
2971
  return new CanvasGradient("conical", this, x0, y0, angle);
2715
2972
  }
2716
2973
 
2974
+ /**
2975
+ * A tiled paint: `source` repeated across whatever it fills, by the server,
2976
+ * in the one composite the fill already costs (issue #263).
2977
+ *
2978
+ * const tile = new Surface(app, { width: 24, height: 24 });
2979
+ * tile.render((c) => { c.fillStyle = '#333'; c.fillRect(0, 0, 1, 1); });
2980
+ * ctx.fillStyle = ctx.createPattern(tile, 'repeat');
2981
+ * ctx.fillRect(0, 0, ctx.width, ctx.height); // one request, no mask
2982
+ *
2983
+ * That is the difference between a background grid costing one repeating
2984
+ * picture and costing a pane-sized coverage mask: drawn as thousands of
2985
+ * tiny subpaths, a dot grid rasterizes client-side into an a8 mask the
2986
+ * size of its own bounding box — which for a background *is* the pane —
2987
+ * then uploads and composites it, every frame.
2988
+ *
2989
+ * `source` is a `Surface` (pixels the server drew), an `Image` (pixels
2990
+ * uploaded from the client), a `Pixmap` or a `Window`.
2991
+ *
2992
+ * `repetition` is `'repeat'` (the default), `'no-repeat'`, or the two
2993
+ * XRender modes the canvas spec has no name for: `'pad'` (clamp to the
2994
+ * edge pixels) and `'reflect'` (mirror every other tile). The spec's
2995
+ * per-axis `'repeat-x'`/`'repeat-y'` are not among them — XRender repeats
2996
+ * a source picture on both axes or on neither — and asking for one throws
2997
+ * with the clip-to-a-strip equivalent.
2998
+ */
2999
+ createPattern(source, repetition = "repeat") {
3000
+ return new CanvasPattern(this.window.app, source, repetition);
3001
+ }
3002
+
2717
3003
  /** the pixel layout of whatever this context currently draws into */
2718
3004
  get _layout() {
2719
3005
  const depth =
@@ -2952,8 +3238,13 @@ class RenderingContext2d {
2952
3238
  * colour to fold into; `_drawCoverage` routes those through the scratch. */
2953
3239
  _coverageSource() {
2954
3240
  const style = this._fillStyle;
2955
- if (this.globalAlpha >= 1 || !isPlainColor(style))
2956
- return this._backgroundPicture;
3241
+ if (this.globalAlpha >= 1 || !isPlainColor(style)) {
3242
+ // a pattern whose transform collapsed paints nothing, and "nothing" as
3243
+ // a source is transparent black — no call site here has to know
3244
+ return preparePattern(this._backgroundPicture, this._m)
3245
+ ? this._backgroundPicture
3246
+ : this.createSolidPicture(0, 0, 0, 0);
3247
+ }
2957
3248
  const c = parseColor(style);
2958
3249
  return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
2959
3250
  }
@@ -3444,9 +3735,167 @@ class CanvasGradient {
3444
3735
  }
3445
3736
  }
3446
3737
 
3738
+ /**
3739
+ * The fill/stroke style `ctx.createPattern` returns: a tile and how it
3740
+ * repeats, backed by one repeating XRender source picture.
3741
+ *
3742
+ * The picture is created on first use and freed by `destroy()` (or by the
3743
+ * GC, through `Picture`'s finalizer). The tile it reads is *not* the
3744
+ * pattern's to free: destroying the `Surface`/`Image` it came from is safe
3745
+ * while the pattern lives — X keeps pixmap storage alive as long as a
3746
+ * picture references it — but the pixels stop tracking anything drawn after.
3747
+ *
3748
+ * A pattern is bound to the connection, not to the context that made it, so
3749
+ * one grid tile serves every window on the app.
3750
+ */
3751
+ class CanvasPattern {
3752
+ constructor(app, source, repetition = "repeat") {
3753
+ const name = repetition ?? "repeat";
3754
+ const repeat = REPEAT_MODES[name];
3755
+ if (repeat === undefined) {
3756
+ const known = Object.keys(REPEAT_MODES)
3757
+ .map((k) => `'${k}'`)
3758
+ .join(", ");
3759
+ const axis = name === "repeat-x" || name === "repeat-y";
3760
+ throw new Error(
3761
+ `createPattern: unsupported repetition ${JSON.stringify(name)}` +
3762
+ (axis
3763
+ ? " — XRender repeats a source picture on both axes or on neither, with no" +
3764
+ " per-axis mode to map this one to. Tile with 'repeat' and bound the fill" +
3765
+ " to the one row/column of tiles instead: ctx.fillRect(x, y, w, tile.height)" +
3766
+ " repeats horizontally and nowhere else."
3767
+ : "") +
3768
+ `. Supported: ${known}. ${PATTERN_DOCS}`,
3769
+ );
3770
+ }
3771
+ const { drawable, format, width, height } = patternSourceOf(app, source);
3772
+
3773
+ this.app = app;
3774
+ this.Render = app.display.Render;
3775
+ this.source = source;
3776
+ this.repetition = name;
3777
+ this.width = width;
3778
+ this.height = height;
3779
+ this._repeat = repeat;
3780
+ this._drawable = drawable;
3781
+ this._format = format;
3782
+ // pattern space -> user space, the canvas `CanvasPattern.setTransform`
3783
+ // matrix. The picture transform is its inverse, composed with the CTM.
3784
+ this._m = [1, 0, 0, 1, 0, 0];
3785
+ this._picture = null;
3786
+ // what the server currently holds: a fresh picture is untransformed and
3787
+ // filtered nearest, so an untransformed fill costs no extra request
3788
+ this._applied = [1, 0, 0, 1, 0, 0];
3789
+ this._filter = "nearest";
3790
+ }
3791
+
3792
+ /**
3793
+ * Position/scale/rotate the tile, canvas-style: `matrix` maps pattern
3794
+ * space to user space, as `[a, b, c, d, e, f]` or a DOMMatrix-shaped
3795
+ * `{a, b, c, d, e, f}`. Translating by the scroll offset is what keeps a
3796
+ * grid glued to the content under it.
3797
+ */
3798
+ setTransform(matrix) {
3799
+ const m = Array.isArray(matrix)
3800
+ ? matrix
3801
+ : [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f];
3802
+ if (m.length < 6 || m.some((v) => !Number.isFinite(Number(v)))) {
3803
+ throw new Error(
3804
+ "CanvasPattern.setTransform: expected [a, b, c, d, e, f] or {a, b, c, d, e, f} of finite numbers",
3805
+ );
3806
+ }
3807
+ this._m = m.slice(0, 6).map(Number);
3808
+ return this;
3809
+ }
3810
+
3811
+ /** the repeating source Picture, created on first use */
3812
+ get picture() {
3813
+ if (!this._picture) {
3814
+ this._picture = new Picture(this.app, {
3815
+ drawable: this._drawable,
3816
+ format: this._format,
3817
+ repeat: this._repeat,
3818
+ });
3819
+ }
3820
+ return this._picture;
3821
+ }
3822
+
3823
+ /** the Picture id, which is all a fill needs of a style */
3824
+ get id() {
3825
+ return this.picture.id;
3826
+ }
3827
+
3828
+ /**
3829
+ * Make the server-side mapping match `ctm ∘ patternMatrix`. XRender's
3830
+ * picture transform runs the other way — it takes a coordinate in the
3831
+ * composite's source space (which every fill here keeps equal to device
3832
+ * space) to a texel — so it is the inverse.
3833
+ *
3834
+ * Returns false when that composition collapses (a zero scale), which
3835
+ * paints nothing, exactly as the canvas spec says.
3836
+ */
3837
+ _sync(ctm) {
3838
+ const m = matMultiply(ctm, this._m);
3839
+ const inv = matInvert(m);
3840
+ if (!inv) return false;
3841
+ const a = this._applied;
3842
+ if (
3843
+ inv[0] !== a[0] ||
3844
+ inv[1] !== a[1] ||
3845
+ inv[2] !== a[2] ||
3846
+ inv[3] !== a[3] ||
3847
+ inv[4] !== a[4] ||
3848
+ inv[5] !== a[5]
3849
+ ) {
3850
+ this.Render.SetPictureTransform(this.id, [
3851
+ inv[0],
3852
+ inv[2],
3853
+ inv[4],
3854
+ inv[1],
3855
+ inv[3],
3856
+ inv[5],
3857
+ 0,
3858
+ 0,
3859
+ 1,
3860
+ ]);
3861
+ this._applied = inv;
3862
+ }
3863
+ // A tile landing on whole pixels wants its own pixels back, not a blend
3864
+ // of them: nearest is both exact and cheaper. Anything else is resampled.
3865
+ const filter =
3866
+ m[0] === 1 &&
3867
+ m[1] === 0 &&
3868
+ m[2] === 0 &&
3869
+ m[3] === 1 &&
3870
+ Number.isInteger(m[4]) &&
3871
+ Number.isInteger(m[5])
3872
+ ? "nearest"
3873
+ : "bilinear";
3874
+ if (filter !== this._filter) {
3875
+ this.picture.setFilter(filter);
3876
+ this._filter = filter;
3877
+ }
3878
+ return true;
3879
+ }
3880
+
3881
+ /** free the repeating picture; the tile it read is the caller's */
3882
+ destroy() {
3883
+ if (this._picture) {
3884
+ this._picture.destroy();
3885
+ this._picture = null;
3886
+ }
3887
+ this._applied = [1, 0, 0, 1, 0, 0];
3888
+ this._filter = "nearest";
3889
+ }
3890
+
3891
+ [Symbol.dispose]() {
3892
+ this.destroy();
3893
+ }
3894
+ }
3895
+
3447
3896
  // register context
3448
3897
  Drawable.renderingContextFactory["2d"] = (window) =>
3449
3898
  new RenderingContext2d(window);
3450
3899
 
3451
3900
  export default RenderingContext2d;
3452
- export { CanvasGradient };
3901
+ export { CanvasGradient, CanvasPattern };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "7.6.0",
3
+ "version": "7.7.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",