ntk 8.1.1 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,9 @@ import {
25
25
  matMultiply,
26
26
  } from "./path.js";
27
27
  import Picture from "./picture.js";
28
+ import { formatForDepth } from "./pictformat.js";
28
29
  import Pixmap from "./pixmap.js";
30
+ import { REGION_DOCS, regionId } from "./region.js";
29
31
  import { routeRaster } from "./rasterize.js";
30
32
  import {
31
33
  blurCoverage,
@@ -52,6 +54,8 @@ import {
52
54
  compositeTraps,
53
55
  drawGlyphRuns,
54
56
  encodeGlyphItems,
57
+ positionedRunsInk,
58
+ runId,
55
59
  } from "./text/glyphs.js";
56
60
  import { TextLayout } from "./text/layout.js";
57
61
  import { reorderRuns } from "./text/shape.js";
@@ -73,6 +77,11 @@ function fillableShapes(polys) {
73
77
  return { shapes, edges };
74
78
  }
75
79
 
80
+ /** Whether `v` is a whole number, give or take the slack a transform adds. */
81
+ function isIntegral(v) {
82
+ return Math.abs(v - Math.round(v)) < 1e-6;
83
+ }
84
+
76
85
  /** The overlap of two {x, y, w, h} boxes, or null when they have none. */
77
86
  function intersectBox(a, b) {
78
87
  const x = Math.max(a.x, b.x);
@@ -150,13 +159,17 @@ function variationsKey(variations) {
150
159
  * context owns is a Pixmap or a Picture, which carry their own finalizers,
151
160
  * and reaching into them from this one would race those.
152
161
  */
153
- const gcRegistry = new FinalizationRegistry(({ X, gcs }) => {
162
+ const gcRegistry = new FinalizationRegistry(({ X, gcs, regions }) => {
154
163
  safeRelease(X, () => {
155
164
  for (const gc of gcs) {
156
165
  if (!gc) continue;
157
166
  X.FreeGC(gc);
158
167
  X.ReleaseID(gc);
159
168
  }
169
+ for (const { fixes, id } of regions) {
170
+ fixes.DestroyRegion(id);
171
+ X.ReleaseID(id);
172
+ }
160
173
  });
161
174
  });
162
175
 
@@ -189,6 +202,63 @@ const REPEAT_MODES = {
189
202
  const PATTERN_DOCS =
190
203
  "https://github.com/sidorares/ntk/blob/master/docs/context-2d.md#patterns";
191
204
 
205
+ /**
206
+ * `clipRegion()` before anything loaded XFIXES.
207
+ *
208
+ * Not the same failure as a server that *has* no XFIXES (`app.fixes()` throws
209
+ * that one, with a code): here the extension is simply not on the connection
210
+ * yet, and it cannot be fetched from under a synchronous call that has to land
211
+ * in request order with the drawing around it. One await fixes it, and the
212
+ * call that hands back a region is already that await.
213
+ */
214
+ function needXFixesError() {
215
+ return new Error(
216
+ "ntk: ctx.clipRegion() needs XFIXES loaded on this connection first.\n" +
217
+ "\n" +
218
+ "Making the region through ntk is that step:\n" +
219
+ "\n" +
220
+ " const region = await app.createRegion([{ x: 0, y: 0, width: 100, height: 100 }]);\n" +
221
+ " ctx.clipRegion(region);\n" +
222
+ "\n" +
223
+ "For a region you built through node-x11 yourself, one `await app.fixes()`\n" +
224
+ "anywhere before the first clipRegion() call is enough.\n" +
225
+ "\n" +
226
+ REGION_DOCS,
227
+ );
228
+ }
229
+
230
+ /**
231
+ * A drawable whose depth nothing has established yet, handed to
232
+ * createPattern.
233
+ *
234
+ * Three drawables are like this, and they want different answers. A window
235
+ * or a pixmap adopted by id knows nothing until the request ntk sent for it
236
+ * replies, and `ready` is the wait for exactly that — the visual a window
237
+ * tile's format really comes from among them. A window ntk created with the
238
+ * default `depth: 0` — CopyFromParent — has no reply pending at all, because
239
+ * only the server ever resolved that 0, so its depth has to be asked for.
240
+ * `getGeometry()` covers all of them, which is why it leads.
241
+ */
242
+ function unknownDepthError() {
243
+ return new Error(
244
+ "createPattern: the tile's depth is not known yet, so there is no " +
245
+ "picture\nformat to read it through.\n" +
246
+ "\n" +
247
+ "Ask the server for it — the answer is written back to the tile:\n" +
248
+ "\n" +
249
+ " await tile.getGeometry();\n" +
250
+ " const pattern = ctx.createPattern(tile, 'repeat');\n" +
251
+ "\n" +
252
+ "For a window or a pixmap adopted by id — `new Window(app, { id })`,\n" +
253
+ "`new Pixmap(app, { id })`, or `ev.window` in a window manager — ntk\n" +
254
+ "has already sent that request: `await tile.ready` waits for the reply\n" +
255
+ "it is expecting rather than asking again, and resolves immediately on\n" +
256
+ "one ntk created. `Pixmap.adopt(app, id)` hands the pixmap over with\n" +
257
+ "this already done. A Surface carries its own depth and needs no wait.\n" +
258
+ `\n${PATTERN_DOCS}`,
259
+ );
260
+ }
261
+
192
262
  /**
193
263
  * What a pattern tiles: a drawable holding the tile plus the picture format
194
264
  * to read it through. A repeating source Picture is created over it rather
@@ -197,13 +267,12 @@ const PATTERN_DOCS =
197
267
  */
198
268
  function patternSourceOf(app, source) {
199
269
  const Render = app.display.Render;
200
- const formatFor = (depth) =>
201
- depth === 32 ? Render.rgba32 : depth === 8 ? "a8" : Render.rgb24;
202
270
 
203
271
  let drawable = null;
204
272
  let width;
205
273
  let height;
206
274
  let format;
275
+ let coverage = false; // an a8 tile: no colour to paint with (see below)
207
276
  if (source instanceof Surface) {
208
277
  if (source.app !== app) {
209
278
  throw new Error(
@@ -211,7 +280,8 @@ function patternSourceOf(app, source) {
211
280
  );
212
281
  }
213
282
  drawable = source.pixmap;
214
- format = source.format === "a8" ? "a8" : Render.rgba32;
283
+ coverage = source.format === "a8";
284
+ format = coverage ? Render.a8 : Render.rgba32;
215
285
  ({ width, height } = source);
216
286
  } else if (source instanceof Image) {
217
287
  drawable = source.pixmap(app);
@@ -222,12 +292,12 @@ function patternSourceOf(app, source) {
222
292
  // is where a double-buffered window's current pixels actually are)
223
293
  drawable = source._backing || source;
224
294
  const depth = drawable.depth ?? source.depth;
225
- if (!depth) {
226
- throw new Error(
227
- "createPattern: the drawable's depth is not known yet — await the window's geometry, or pass a Surface",
228
- );
229
- }
230
- format = formatFor(depth);
295
+ if (!depth) throw unknownDepthError();
296
+ // the tile's visual is what names its format; the depth is the fallback
297
+ // for a drawable that has none (issue #295)
298
+ const visual = drawable.visualId || source.visualId;
299
+ coverage = depth === 8;
300
+ format = app._knownPictFormat?.(visual, depth) ?? formatForDepth(Render, depth);
231
301
  width = drawable.width ?? source.width;
232
302
  height = drawable.height ?? source.height;
233
303
  } else {
@@ -236,7 +306,7 @@ function patternSourceOf(app, source) {
236
306
  (source === null ? "null" : typeof source),
237
307
  );
238
308
  }
239
- if (format === "a8") {
309
+ if (coverage) {
240
310
  throw new Error(
241
311
  "createPattern: a coverage (a8) tile has no colour to paint with — XRender would " +
242
312
  "sample it as black. Draw the tile into an argb32 Surface and tile that, or keep " +
@@ -422,7 +492,10 @@ class RenderingContext2d {
422
492
  // ids land here as they are allocated; the finalizer holds this array
423
493
  // rather than the context, so registering cannot keep the context alive
424
494
  this._gcs = [];
425
- gcRegistry.register(this, { X, gcs: this._gcs }, this);
495
+ // same for the scratch regions the region-clip path allocates: the
496
+ // finalizer holds the arrays, never the context
497
+ this._regions = [];
498
+ gcRegistry.register(this, { X, gcs: this._gcs, regions: this._regions }, this);
426
499
 
427
500
  this.window = window;
428
501
  this.display = window.app.display;
@@ -433,17 +506,40 @@ class RenderingContext2d {
433
506
  // buffering); re-bind when the backing pixmap is reallocated on resize
434
507
  this._target = null;
435
508
  this._gc = null;
436
- this.picture = null;
509
+ this._picture = null;
437
510
  this._bindTarget();
511
+ // A window or pixmap adopted by id knows neither its depth nor its
512
+ // visual until the requests its constructor sent reply, and an unknown
513
+ // depth binds rgb24 — so a context taken on a depth-32 drawable before
514
+ // they land would silently drop the alpha channel, which is the ordinary
515
+ // case for a compositor taking the overlay window, its clients and their
516
+ // named pixmaps as bare ids (issues #293, #291). Awaiting `ready` first
517
+ // is the explicit fix; re-binding when the answer changes the format is
518
+ // the one that does not need to be known about.
519
+ if (!window.depth && typeof window.ready?.then === "function") {
520
+ window.ready.then(() => this._refreshFormat());
521
+ }
522
+ // The same, for the visual -> format table: it is one round trip on the
523
+ // connection (issue #295), taken while the connection is still being set
524
+ // up, so it is normally here already — this is the case where it is not,
525
+ // and the binding above took the depth's standard format. Where the
526
+ // visual names a different one — a 5:6:5 window, a 10-bit one, a BGR one
527
+ // — that binding is wrong and this is what puts it right.
528
+ if (window.app && !window.app._pictFormats) {
529
+ window.app.pictFormats?.().then(
530
+ () => this._refreshFormat(),
531
+ () => {},
532
+ );
533
+ }
438
534
  if (typeof window.on === "function") {
439
535
  window.on("_backing", () => this._bindTarget());
440
536
  // masks are sized to the drawable — recreate them after a resize
441
537
  window.on("resize", () => this._dropMasks());
442
538
  // window-backed pictures are freed server-side with the window
443
539
  window.on("_destroyed", () => {
444
- if (this.picture && this._target === this.window) {
445
- this.picture.forget();
446
- this.picture = null;
540
+ if (this._picture && this._target === this.window) {
541
+ this._picture.forget();
542
+ this._picture = null;
447
543
  }
448
544
  });
449
545
  }
@@ -470,7 +566,26 @@ class RenderingContext2d {
470
566
  // drawings that took more than one.
471
567
  this.maskStats = { masks: 0, pixels: 0, split: 0 };
472
568
  this._stack = [];
473
- this._clips = []; // [{ polys, rule }] in device space, already stacked
569
+ // [{ polys, rule, rect } | { region }] in device space, already stacked.
570
+ // Never mutated in place — save() shares the array with the snapshot and
571
+ // clip() concats a new one — so the two summary flags below only have to
572
+ // be recomputed where it is replaced (_setClips).
573
+ this._clips = [];
574
+ this._hasPolyClip = false; // any entry the a8 mask has to rasterize
575
+ this._hasRegionClip = false; // any XFIXES region entry
576
+ // XFIXES, once clipRegion() has been called on this context, and what
577
+ // the picture is currently clipped by. "No clip" is a state ntk tracks
578
+ // rather than a rectangle it stamps: a caller's region has to survive a
579
+ // drawing that only meant to narrow to a box (issue #292).
580
+ this._fixes = null;
581
+ this._pictureClipped = false;
582
+ // the rectangle the slot holds, when it is a plain one (no region in it),
583
+ // so a second drawing under the same clip can skip re-stamping it; and
584
+ // whether a reset is owed but not yet sent (issue #308)
585
+ this._pictureClipRect = null;
586
+ this._clipStale = false;
587
+ this._regionScratch = null; // region entries intersected, when >1
588
+ this._regionBox = null; // that ∩ the rectangular clip
474
589
  this._gco = "source-over";
475
590
  this.globalAlpha = 1;
476
591
  this.lineCap = "butt";
@@ -493,9 +608,46 @@ class RenderingContext2d {
493
608
  this.lineWidth = 1;
494
609
  }
495
610
 
496
- _bindTarget() {
611
+ /**
612
+ * The picture format the target drawable is read and written through.
613
+ *
614
+ * The visual is what names it — depth alone does not (issue #295) — and
615
+ * the depth is the fallback for a drawable that has no visual to name
616
+ * (a pixmap, an `a8` coverage surface) or whose visual has not been
617
+ * answered for yet.
618
+ */
619
+ _formatFor(depth, visual) {
620
+ return (
621
+ this.window.app?._knownPictFormat?.(visual, depth) ?? formatForDepth(this.Render, depth)
622
+ );
623
+ }
624
+
625
+ /** The depth and visual of whatever the context is currently bound to. */
626
+ _targetPixels() {
627
+ const target = this._target;
628
+ return {
629
+ depth: target?.depth ?? this.window.depth,
630
+ // a backing pixmap carries the window's visual; nothing else about a
631
+ // pixmap says what its pixels mean
632
+ visual: target?.visualId || this.window.visualId,
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Re-bind if what is now known about the target's pixels names a different
638
+ * format than the picture was built with. Both answers arrive late on an
639
+ * adopted window, and neither costs anything when it changes nothing.
640
+ */
641
+ _refreshFormat() {
642
+ const target = this.window._backing || this.window;
643
+ if (!this._picture || this._target !== target) return;
644
+ const { depth, visual } = this._targetPixels();
645
+ if (this._formatFor(depth, visual) !== this._picture.format) this._bindTarget(true);
646
+ }
647
+
648
+ _bindTarget(force = false) {
497
649
  const target = this.window._backing || this.window;
498
- if (this._target === target) return;
650
+ if (this._target === target && !force) return;
499
651
  this._target = target;
500
652
  this._layoutCache = null; // the new target may be a different depth
501
653
 
@@ -506,36 +658,210 @@ class RenderingContext2d {
506
658
  this._gcs.push(this._gc);
507
659
  this.X.CreateGC(this._gc, target.id);
508
660
  }
509
- if (this.picture) this.picture.destroy();
661
+ if (this._picture) this._picture.destroy();
510
662
  // a8 targets are coverage, not colour: drawing into one and using the
511
663
  // result as a mask is how a monochrome drawing gets rendered once and
512
664
  // recoloured on every use (see Surface).
513
- const depth = target.depth ?? this.window.depth;
514
- const format =
515
- depth === 32
516
- ? this.Render.rgba32
517
- : depth === 8
518
- ? this.Render.a8
519
- : this.Render.rgb24;
665
+ const { depth, visual } = this._targetPixels();
666
+ const format = this._formatFor(depth, visual);
520
667
  // Does the target have a real alpha channel? Only then is "transparent"
521
668
  // a colour it can hold, which is what clearRect turns on.
522
669
  this._hasAlpha = depth === 32;
523
- this.picture = new Picture(this.window.app, {
670
+ this._picture = new Picture(this.window.app, {
524
671
  drawable: target,
525
672
  format,
526
673
  polyEdge: 1,
527
674
  polyMode: 1,
528
675
  });
676
+ // a new picture carries no clip; a region one is context state and has to
677
+ // be re-installed on it (this runs on every backing-pixmap reallocation)
678
+ this._pictureClipped = false;
679
+ this._pictureClipRect = null;
680
+ this._invalidatePictureClip();
529
681
  this._dropMasks();
530
682
  }
531
683
 
532
- // Undo a rectangular server-side clip. "No clip" has to be stamped as a
533
- // rectangle that outlives window growth: the backing pixmap has headroom
534
- // past the window and growing into it does not rebind the picture, so a
535
- // restore at today's window size would keep clipping tomorrow's pixels.
536
- // Coordinates are INT16 — one rect at their maximum covers any drawable.
537
- _resetPictureClip() {
538
- this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, 0x7fff, 0x7fff]);
684
+ // ------------------------------------------------------------------
685
+ // the picture's clip
686
+ //
687
+ // Two things narrow a drawing server-side: a rectangle from the clip stack
688
+ // (SetPictureClipRectangles, the fast path text and rounded boxes take) and
689
+ // an XFIXES region from clipRegion(). Both land on the same one slot — a
690
+ // Picture holds exactly one client clip — so they go through here, and here
691
+ // is also what puts the slot back afterwards.
692
+ //
693
+ // "Back" is the load-bearing word (issue #292). Undoing a narrow used to
694
+ // mean stamping a full-plane rectangle, which is a clip, not the absence of
695
+ // one: it overwrote whatever else was in the slot. Now the slot's contents
696
+ // are state the context tracks, so a region clip survives a fill or a glyph
697
+ // run that only meant to narrow to a box.
698
+ //
699
+ // Tracking the contents also makes the stamping *lazy* (issue #308). Every
700
+ // rect-clipped fast path brackets its own drawing — set, draw, reset — so
701
+ // two drawings under one clip used to emit a reset immediately followed by
702
+ // an identical set, with nothing in between that reads the slot. Instead
703
+ // the reset is only recorded as owed, `_dst()` flushes it before any
704
+ // drawing that does *not* set its own clip, and a set whose rectangle is
705
+ // already in the slot supersedes it and sends nothing. A rounded box under
706
+ // a damage clip goes from four stamps to one, and a repaint of the same
707
+ // rectangle next frame to none.
708
+
709
+ /** Narrow the picture to `rect`, intersected with any region clip. */
710
+ _setPictureClip(rect) {
711
+ const region = this._effectiveRegion();
712
+ if (region === null) {
713
+ const held = this._pictureClipRect;
714
+ // The owed reset (if any) is superseded either way: what follows draws
715
+ // under exactly this rectangle, which is what the slot is about to hold.
716
+ this._clipStale = false;
717
+ if (
718
+ held &&
719
+ held.x === rect.x &&
720
+ held.y === rect.y &&
721
+ held.w === rect.w &&
722
+ held.h === rect.h
723
+ ) {
724
+ return; // already stamped, and nothing since has read the slot
725
+ }
726
+ this.Render.SetPictureClipRectangles(this._picture.id, 0, 0, [
727
+ rect.x,
728
+ rect.y,
729
+ rect.w,
730
+ rect.h,
731
+ ]);
732
+ this._pictureClipped = true;
733
+ // a copy: the caller owns `rect` and _clipRect() hands out fresh ones,
734
+ // but nothing here should depend on that
735
+ this._pictureClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
736
+ return;
737
+ }
738
+ // region ∩ rectangle, computed by the server: three requests and no round
739
+ // trip, against a full-surface a8 mask for the same answer.
740
+ //
741
+ // Always re-emitted, never skipped: the region is the caller's and they
742
+ // may have edited it — server-side, invisibly to us — since the last
743
+ // drawing, for the same reason _effectiveRegion() is recomputed.
744
+ const box = this._scratchRegion("_regionBox");
745
+ this._fixes.SetRegion(box, [
746
+ { x: rect.x, y: rect.y, width: rect.w, height: rect.h },
747
+ ]);
748
+ this._fixes.IntersectRegion(region, box, box);
749
+ this._fixes.SetPictureClipRegion(this._picture.id, box, 0, 0);
750
+ this._pictureClipped = true;
751
+ this._pictureClipRect = null; // a region is in there, not a plain rect
752
+ this._clipStale = false;
753
+ }
754
+
755
+ /**
756
+ * Note that the picture's clip no longer matches what the clip stack says,
757
+ * without sending anything: the next drawing either sets its own clip
758
+ * (which supersedes this) or goes through `_dst()`, which flushes it first.
759
+ *
760
+ * This is what every rect-clipped fast path calls after its drawing, and
761
+ * also what installs a region in the first place — `clipRegion()` and a
762
+ * `restore()` that changes the stack both come through here, so the region
763
+ * is on the picture for every route, including the ones that composite an
764
+ * a8 mask and never touch a clip rectangle.
765
+ */
766
+ _invalidatePictureClip() {
767
+ this._clipStale = true;
768
+ }
769
+
770
+ /**
771
+ * Put the picture's clip back to what the clip stack says it is between
772
+ * drawings: the region clip if there is one, nothing otherwise. Sends
773
+ * nothing when the slot already says that.
774
+ */
775
+ _flushPictureClip() {
776
+ if (!this._clipStale) return;
777
+ this._clipStale = false;
778
+ if (!this._picture) return; // destroyed, or a window that went away
779
+ const region = this._effectiveRegion();
780
+ if (region !== null) {
781
+ this._fixes.SetPictureClipRegion(this._picture.id, region, 0, 0);
782
+ this._pictureClipped = true;
783
+ this._pictureClipRect = null;
784
+ return;
785
+ }
786
+ if (!this._pictureClipped) return; // nothing of ours is in the slot
787
+ if (this._fixes) {
788
+ // None is the real "no clip"; a rectangle would only be a wider one
789
+ this._fixes.SetPictureClipRegion(this._picture.id, 0, 0, 0);
790
+ } else {
791
+ // Without XFIXES the widest rectangle is all there is, and it has to
792
+ // outlive window growth: the backing pixmap has headroom past the
793
+ // window and growing into it does not rebind the picture, so a reset at
794
+ // today's window size would keep clipping tomorrow's pixels.
795
+ // Coordinates are INT16 — one rect at their maximum covers any drawable.
796
+ this.Render.SetPictureClipRectangles(this._picture.id, 0, 0, [0, 0, 0x7fff, 0x7fff]);
797
+ }
798
+ this._pictureClipped = false;
799
+ this._pictureClipRect = null;
800
+ }
801
+
802
+ /**
803
+ * The destination picture id for a drawing request, with any owed clip
804
+ * reset flushed first.
805
+ *
806
+ * Everything that draws into this context's own picture goes through here
807
+ * — including the composites that bypass the set/reset bracket entirely
808
+ * (`_fillRect`, the `clearRect` fast path, the a8-mask routes) — because
809
+ * that is what keeps a lazily-owed reset from leaking a narrower clip into
810
+ * a drawing that never asked for one.
811
+ */
812
+ _dst() {
813
+ this._flushPictureClip();
814
+ return this._picture.id;
815
+ }
816
+
817
+ /**
818
+ * The RENDER Picture this context draws through — a real server-side
819
+ * picture a caller can composite from, or hang a clip region on
820
+ * (docs/context-2d.md). Reading it settles the clip slot first, so what an
821
+ * outside request sees is what the clip stack says, not whatever the last
822
+ * fast path left behind.
823
+ */
824
+ get picture() {
825
+ this._flushPictureClip();
826
+ return this._picture;
827
+ }
828
+
829
+ /**
830
+ * The region the clip stack currently means, or null when it holds no
831
+ * region entry. One entry is used as it is; several are intersected into a
832
+ * scratch region owned by this context.
833
+ *
834
+ * Recomputed rather than cached: the intersection is of regions the caller
835
+ * owns and may edit under us, and nesting region clips is rare enough that
836
+ * a few requests are the cheaper mistake to make.
837
+ */
838
+ _effectiveRegion() {
839
+ if (!this._hasRegionClip) return null;
840
+ let first = null;
841
+ let dst = null;
842
+ for (const entry of this._clips) {
843
+ if (!entry.region) continue;
844
+ if (first === null) {
845
+ first = entry.region;
846
+ continue;
847
+ }
848
+ if (dst === null) {
849
+ dst = this._scratchRegion("_regionScratch");
850
+ this._fixes.CopyRegion(first, dst);
851
+ }
852
+ this._fixes.IntersectRegion(dst, entry.region, dst);
853
+ }
854
+ return dst === null ? first : dst;
855
+ }
856
+
857
+ /** One of this context's two scratch regions, allocated on first use. */
858
+ _scratchRegion(slot) {
859
+ if (this[slot] !== null) return this[slot];
860
+ const id = this.X.AllocID();
861
+ this._fixes.CreateRegion(id, []);
862
+ this[slot] = id;
863
+ this._regions.push({ fixes: this._fixes, id });
864
+ return id;
539
865
  }
540
866
 
541
867
  /**
@@ -559,8 +885,16 @@ class RenderingContext2d {
559
885
  this._destroyed = true;
560
886
  gcRegistry.unregister(this);
561
887
 
562
- this._clips = [];
888
+ this._setClips([]);
563
889
  this._dropMasks();
890
+ for (const { fixes, id } of this._regions) {
891
+ safeRelease(this.X, () => {
892
+ fixes.DestroyRegion(id);
893
+ this.X.ReleaseID(id);
894
+ });
895
+ }
896
+ this._regions.length = 0;
897
+ this._regionScratch = this._regionBox = null;
564
898
  for (const gc of [this._gc, this._fillMaskGC, this._clipMaskGC]) {
565
899
  if (!gc) continue;
566
900
  safeRelease(this.X, () => {
@@ -573,15 +907,15 @@ class RenderingContext2d {
573
907
 
574
908
  this._backgroundPicture = this._glyphSource = null;
575
909
 
576
- if (this.picture) {
910
+ if (this._picture) {
577
911
  // a picture on a *window* is freed by the server with the window;
578
912
  // asking again would raise BadPicture. Same rule as `_destroyed`.
579
913
  if (this._target === this.window && !(this.window instanceof Pixmap)) {
580
- this.picture.forget();
914
+ this._picture.forget();
581
915
  } else {
582
- this.picture.destroy();
916
+ this._picture.destroy();
583
917
  }
584
- this.picture = null;
918
+ this._picture = null;
585
919
  }
586
920
  }
587
921
 
@@ -613,7 +947,7 @@ class RenderingContext2d {
613
947
  // a hover repaint of two tab headers used to blit the whole window. A
614
948
  // context with no rectangular clip reports nothing, and the window falls
615
949
  // back to a full blit, which is what any drawing outside a clip needs.
616
- this.window._markDirty(this._clips.length ? this._clipRect() : null);
950
+ this.window._markDirty(this._clipRect());
617
951
  }
618
952
 
619
953
  // html context2d compatibility: canvas.getContext('2d').canvas == canvas
@@ -762,8 +1096,12 @@ class RenderingContext2d {
762
1096
  this._shadowOffsetY = s.shadowOffsetY;
763
1097
  this._m = s.m;
764
1098
  if (s.clips !== this._clips) {
765
- this._clips = s.clips;
1099
+ const hadRegion = this._hasRegionClip;
1100
+ this._setClips(s.clips);
766
1101
  this._rebuildClipMask();
1102
+ // a region clip going out of scope (or coming back into it) is a change
1103
+ // to the picture's clip, and nothing else will notice it
1104
+ if (hadRegion || this._hasRegionClip) this._invalidatePictureClip();
767
1105
  }
768
1106
  }
769
1107
 
@@ -1099,6 +1437,110 @@ class RenderingContext2d {
1099
1437
  );
1100
1438
  }
1101
1439
 
1440
+ /**
1441
+ * The shadow of positioned glyph runs, cached — what `drawGlyphs`, and
1442
+ * therefore every `TextLayout.draw`, casts (issue #283).
1443
+ *
1444
+ * A paragraph gets **one** coverage surface, not one per line: the runs
1445
+ * already carry their own baselines, so they all go into the same surface
1446
+ * exactly as they all go into the same glyph composite. Nothing is
1447
+ * re-shaped — the caller handed us the runs, which is why this path is
1448
+ * cheaper than `_shadowOfText`, not dearer.
1449
+ *
1450
+ * The cached copy is position-independent, as `fillText`'s is: geometry is
1451
+ * stored relative to the first run's origin and the composite carries it
1452
+ * to wherever the text is drawn. The key is that relative geometry plus
1453
+ * the identity of each run, so the same string laid out to two widths —
1454
+ * same runs, different line origins — is two shadows, and re-drawing one
1455
+ * layout is one lookup no matter how many glyphs are in it.
1456
+ *
1457
+ * @param {Array<{run, x, y, textRendering?}>} positioned device-space runs
1458
+ */
1459
+ _shadowOfGlyphs(positioned) {
1460
+ if (!positioned.length) return;
1461
+ const app = this.window.app;
1462
+ const policy = shadowPolicyOf(app);
1463
+ const sigma = shadowSigma(this._shadowBlur, policy);
1464
+ const reach = shadowReach(sigma);
1465
+
1466
+ // Run origins relative to the first, rounded: whole-pixel offsets are
1467
+ // what the bitmap glyph path draws at anyway, and they keep the key
1468
+ // stable as the paragraph moves — `(x + a) - (x + b)` is not exactly
1469
+ // `a - b` in floating point, and an origin-dependent key would miss the
1470
+ // cache on every scroll.
1471
+ const ax = positioned[0].x;
1472
+ const ay = positioned[0].y;
1473
+ const local = positioned.map((p) => ({
1474
+ run: p.run,
1475
+ x: Math.round(p.x - ax),
1476
+ y: Math.round(p.y - ay),
1477
+ textRendering: p.textRendering,
1478
+ }));
1479
+ const key = `${local
1480
+ .map((p) => `${runId(p.run)},${p.x},${p.y},${p.textRendering ?? ""}`)
1481
+ .join("\u0000")}\u0000${sigma}`;
1482
+
1483
+ let ink = null;
1484
+ const surface = cachedShadow(app, key, () => {
1485
+ ink = positionedRunsInk(local);
1486
+ if (!ink) return null; // a line of spaces inks nothing
1487
+ const box = {
1488
+ x: Math.floor(ink.minX) - 1 - reach,
1489
+ y: Math.floor(ink.minY) - 1 - reach,
1490
+ };
1491
+ box.w = Math.ceil(ink.maxX) + 1 + reach - box.x;
1492
+ box.h = Math.ceil(ink.maxY) + 1 + reach - box.y;
1493
+ if (box.w * box.h > policy.maxPixels) return null;
1494
+ let coverage = new Surface(app, {
1495
+ width: box.w,
1496
+ height: box.h,
1497
+ format: "a8",
1498
+ });
1499
+ coverage.render((sctx) => {
1500
+ this._loadShadowState(sctx, 0, 0);
1501
+ // the origins are already device-space and placed by hand
1502
+ sctx._m = [1, 0, 0, 1, 0, 0];
1503
+ sctx._drawGlyphsDevice(
1504
+ this.Render.PictOp.Over,
1505
+ sctx._backgroundPicture,
1506
+ local.map((p) => ({ ...p, x: p.x - box.x, y: p.y - box.y })),
1507
+ );
1508
+ });
1509
+ if (sigma > 0) coverage = blurCoverage(coverage, sigma);
1510
+ // where the anchor sits inside the surface — whole pixels, so the
1511
+ // composite below can carry it anywhere
1512
+ coverage._shadowOrigin = { x: -box.x, y: -box.y };
1513
+ return coverage;
1514
+ });
1515
+ if (surface) {
1516
+ const origin = surface._shadowOrigin;
1517
+ this._paintShadow(
1518
+ surface,
1519
+ Math.round(ax + this._shadowOffsetX) - origin.x,
1520
+ Math.round(ay + this._shadowOffsetY) - origin.y,
1521
+ );
1522
+ return;
1523
+ }
1524
+ if (!ink) return;
1525
+ // Padded ink larger than a shadow surface may be: fall back to the
1526
+ // clipped, uncached path, which sizes itself to the part of the shadow
1527
+ // that can actually be seen — the same escape `_shadowOfText` takes.
1528
+ this._shadowOfDrawing(
1529
+ {
1530
+ minX: ax + ink.minX,
1531
+ maxX: ax + ink.maxX,
1532
+ minY: ay + ink.minY,
1533
+ maxY: ay + ink.maxY,
1534
+ },
1535
+ (sctx, dx, dy) =>
1536
+ sctx._drawGlyphsDevice(
1537
+ this.Render.PictOp.Over,
1538
+ sctx._backgroundPicture,
1539
+ positioned.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
1540
+ ),
1541
+ );
1542
+ }
1543
+
1102
1544
  /** the shadow of a `fill()`/`stroke()`-shaped call, from its arguments */
1103
1545
  _shadowOfPath(args, stroke) {
1104
1546
  if (stroke) {
@@ -1525,16 +1967,19 @@ class RenderingContext2d {
1525
1967
  // A rectangular clip narrows where the coverage is *composited*; the
1526
1968
  // mask content outside it is stale by the same argument as outside `b`,
1527
1969
  // and a box the clip rejects outright needs no coverage at all. A stack
1528
- // whose mask was dropped by restore() may still hold a poly — _clipRect
1529
- // says null then, and the mask comes back on demand.
1970
+ // whose mask was dropped by restore() may still hold a poly, and the mask
1971
+ // comes back on demand. A region clip needs nothing here: the picture
1972
+ // carries it, and it applies to the composite below like any other.
1530
1973
  let out = b;
1531
- if (this._clips.length && !this.clipMask) {
1532
- const cr = this._clipRect();
1533
- if (cr) {
1534
- out = intersectBox(b, cr);
1535
- if (!out) return false;
1536
- } else {
1974
+ if (!this.clipMask) {
1975
+ if (this._hasPolyClip) {
1537
1976
  this._requireClipMask();
1977
+ } else {
1978
+ const cr = this._clipRect();
1979
+ if (cr) {
1980
+ out = intersectBox(b, cr);
1981
+ if (!out) return false;
1982
+ }
1538
1983
  }
1539
1984
  }
1540
1985
  if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, edges)) {
@@ -1578,7 +2023,7 @@ class RenderingContext2d {
1578
2023
  op,
1579
2024
  src.id,
1580
2025
  this.fillMask.id,
1581
- this.picture.id,
2026
+ this._dst(),
1582
2027
  out.x,
1583
2028
  out.y,
1584
2029
  out.x,
@@ -1976,7 +2421,10 @@ class RenderingContext2d {
1976
2421
  const direct =
1977
2422
  !hasRound &&
1978
2423
  this.globalAlpha >= 1 &&
1979
- !this._clips.length &&
2424
+ // a region clip stays on the picture and needs nothing here; a
2425
+ // rectangle or a mask does
2426
+ !this._hasPolyClip &&
2427
+ !this._clipRect() &&
1980
2428
  op === this.Render.PictOp.Over;
1981
2429
  const chunk = 4000 * 6;
1982
2430
  if (direct) {
@@ -1994,7 +2442,7 @@ class RenderingContext2d {
1994
2442
  src.id,
1995
2443
  Math.floor(batch[0]),
1996
2444
  Math.floor(batch[1]),
1997
- this.picture.id,
2445
+ this._dst(),
1998
2446
  this.Render.a8,
1999
2447
  batch,
2000
2448
  );
@@ -2152,6 +2600,12 @@ class RenderingContext2d {
2152
2600
  * is not at the window's origin — landed at the untransformed coordinates
2153
2601
  * and was then cut by the clip, while the neighbouring `fillRect` and
2154
2602
  * `drawImage` moved (issue #280).
2603
+ *
2604
+ * The shadow state applies too, as it does to `fillText`: one blurred
2605
+ * coverage surface for the whole call, cached on the runs' identity and
2606
+ * relative positions, painted under the glyphs (issue #283). A paragraph
2607
+ * whose spans change colour draws as several calls, and — as several
2608
+ * `fillText`s would — casts a shadow per call.
2155
2609
  */
2156
2610
  drawGlyphs(op, src, positioned) {
2157
2611
  const m = this._m;
@@ -2161,6 +2615,7 @@ class RenderingContext2d {
2161
2615
  return { ...p, x, y };
2162
2616
  });
2163
2617
  }
2618
+ if (this._shadowed()) this._shadowOfGlyphs(positioned);
2164
2619
  this._drawGlyphsDevice(op, src, positioned);
2165
2620
  }
2166
2621
 
@@ -2182,26 +2637,20 @@ class RenderingContext2d {
2182
2637
  const app = this.window.app;
2183
2638
  const R = this.Render;
2184
2639
  if (!prepareStyle(src, this._m)) return;
2185
- if (!this._clips.length) {
2186
- drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
2187
- this._markDirty();
2188
- return;
2189
- }
2190
- // Fast path: a rectangular clip is something the server can do itself.
2191
- // Two small requests around the ordinary glyph composite, instead of
2192
- // clearing and compositing a full-surface a8 mask three times — which
2193
- // costs the same on the wire but many times the pixel work.
2194
- const rect = this._clipRect();
2195
- if (rect) {
2196
- if (rect.w === 0 || rect.h === 0) return;
2197
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2198
- rect.x,
2199
- rect.y,
2200
- rect.w,
2201
- rect.h,
2202
- ]);
2203
- drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
2204
- this._resetPictureClip();
2640
+ if (!this._hasPolyClip) {
2641
+ // Fast path: a rectangular clip is something the server can do itself.
2642
+ // Two small requests around the ordinary glyph composite, instead of
2643
+ // clearing and compositing a full-surface a8 mask three times — which
2644
+ // costs the same on the wire but many times the pixel work. No clip
2645
+ // stack at all, or only a region one, needs neither: the picture is
2646
+ // already carrying whatever applies.
2647
+ const rect = this._clipRect();
2648
+ if (rect) {
2649
+ if (rect.w === 0 || rect.h === 0) return;
2650
+ this._setPictureClip(rect);
2651
+ }
2652
+ drawGlyphRuns(app, op, src.id, this._dst(), positioned);
2653
+ if (rect) this._invalidatePictureClip();
2205
2654
  this._markDirty();
2206
2655
  return;
2207
2656
  }
@@ -2240,7 +2689,7 @@ class RenderingContext2d {
2240
2689
  op,
2241
2690
  src.id,
2242
2691
  this.fillMask.id,
2243
- this.picture.id,
2692
+ this._dst(),
2244
2693
  0,
2245
2694
  0,
2246
2695
  0,
@@ -2263,22 +2712,14 @@ class RenderingContext2d {
2263
2712
  if (!traps.length) return;
2264
2713
  const app = this.window.app;
2265
2714
  const R = this.Render;
2266
- if (!this._clips.length) {
2267
- compositeTraps(app, op, src.id, this.picture.id, traps);
2268
- this._markDirty();
2269
- return;
2270
- }
2271
- const rect = this._clipRect();
2272
- if (rect) {
2273
- if (rect.w === 0 || rect.h === 0) return;
2274
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2275
- rect.x,
2276
- rect.y,
2277
- rect.w,
2278
- rect.h,
2279
- ]);
2280
- compositeTraps(app, op, src.id, this.picture.id, traps);
2281
- this._resetPictureClip();
2715
+ if (!this._hasPolyClip) {
2716
+ const rect = this._clipRect();
2717
+ if (rect) {
2718
+ if (rect.w === 0 || rect.h === 0) return;
2719
+ this._setPictureClip(rect);
2720
+ }
2721
+ compositeTraps(app, op, src.id, this._dst(), traps);
2722
+ if (rect) this._invalidatePictureClip();
2282
2723
  this._markDirty();
2283
2724
  return;
2284
2725
  }
@@ -2316,7 +2757,7 @@ class RenderingContext2d {
2316
2757
  op,
2317
2758
  src.id,
2318
2759
  this.fillMask.id,
2319
- this.picture.id,
2760
+ this._dst(),
2320
2761
  0,
2321
2762
  0,
2322
2763
  0,
@@ -2329,19 +2770,111 @@ class RenderingContext2d {
2329
2770
  this._markDirty();
2330
2771
  }
2331
2772
 
2773
+ /**
2774
+ * The cheap form of a direct composite, when there is one: the box to
2775
+ * composite over and what belongs in its mask slot, with no surface-sized
2776
+ * a8 mask anywhere (issue #307).
2777
+ *
2778
+ * Neither thing `_compositeMask()` puts in a mask is really mask work.
2779
+ * A **rectangular clip** is a smaller composite box: for an op that leaves
2780
+ * the destination alone where the mask is zero, zeroing the mask outside
2781
+ * the rectangle and never compositing there are the same picture — and a
2782
+ * rect clip is an integer rectangle stamped as binary coverage, so the two
2783
+ * agree pixel for pixel rather than approximately. **`globalAlpha`** is a
2784
+ * 1x1 solid, which is that alpha everywhere the server samples it, for the
2785
+ * price of no pixels at all. A **region** clip needs neither: the picture
2786
+ * carries it, and it applies to the composite like any other.
2787
+ *
2788
+ * What is left needing a mask is what genuinely needs one: a
2789
+ * non-rectangular clip entry, or an op that writes where the mask is zero
2790
+ * — `copy`, `source-in`, `destination-in`, `source-out` and
2791
+ * `destination-atop` cover the whole composite box, so a shrunk box would
2792
+ * leave behind the pixels the mask path clears.
2793
+ *
2794
+ * `box` is the composite's destination box in device coordinates. Callers
2795
+ * pass their own arguments through, so it is only intersected when it is
2796
+ * integral: clip rectangles are integers and caller coordinates are not
2797
+ * necessarily, and rounding one here could land a pixel away from where
2798
+ * the mask path draws.
2799
+ *
2800
+ * @returns {{box: ?object, mask: number}|null} the box to composite over —
2801
+ * null when the clip rejects it whole and nothing should be drawn — and
2802
+ * the mask slot for it; or null when this route does not apply and the
2803
+ * caller has to fall back to `_compositeMask()`.
2804
+ */
2805
+ _boxedComposite(box, op) {
2806
+ if (this._hasPolyClip) return null;
2807
+ let out = box;
2808
+ const clip = this._clipRect();
2809
+ if (clip) {
2810
+ if (!this._maskBounded(op)) return null;
2811
+ if (![box.x, box.y, box.w, box.h].every(isIntegral)) return null;
2812
+ out = intersectBox(box, clip);
2813
+ }
2814
+ return { box: out, mask: this._alphaMask() };
2815
+ }
2816
+
2817
+ /**
2818
+ * `globalAlpha` as something to put in a mask slot: a 1x1 solid, which is
2819
+ * that alpha over any box the server samples it across, or 0 when there is
2820
+ * no alpha to apply. The surface-sized a8 `_compositeMask()` fills for the
2821
+ * same answer is pure pixel work.
2822
+ */
2823
+ _alphaMask() {
2824
+ if (this.globalAlpha >= 1) return 0;
2825
+ return this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
2826
+ }
2827
+
2828
+ /**
2829
+ * The composite's own box, as somewhere to put pixels: `box` grown to
2830
+ * whole pixels and clamped to the surface, or the whole surface when the
2831
+ * caller has no box to give.
2832
+ *
2833
+ * Growing outward rather than rounding is the safe direction — the mask
2834
+ * has to cover every pixel the composite samples, and a pixel of slack
2835
+ * outside it is never read.
2836
+ */
2837
+ _maskBox(box) {
2838
+ // no box, or one the caller never normalized: the whole surface, which
2839
+ // is what this always used to be
2840
+ if (!box || !(box.w > 0) || !(box.h > 0))
2841
+ return { x: 0, y: 0, w: this.width, h: this.height };
2842
+ const x = Math.max(0, Math.floor(box.x));
2843
+ const y = Math.max(0, Math.floor(box.y));
2844
+ const right = Math.min(this.width, Math.ceil(box.x + box.w));
2845
+ const bottom = Math.min(this.height, Math.ceil(box.y + box.h));
2846
+ if (right <= x || bottom <= y) return { x: 0, y: 0, w: 0, h: 0 };
2847
+ return { x, y, w: right - x, h: bottom - y };
2848
+ }
2849
+
2332
2850
  // combined clip ∩ globalAlpha mask for direct composites (rect/image
2333
2851
  // fast paths); returns a picture id or 0. Reuses the fill scratch mask.
2334
- _compositeMask() {
2335
- // reached only when the clip is not a rectangle (those went through the
2336
- // SetPictureClipRectangles path), so a clip here always means the mask
2337
- const clipMask = this._clips.length ? this._requireClipMask() : null;
2852
+ //
2853
+ // The fallback, not the common case: `_boxedComposite` above answers for a
2854
+ // rect-only clip stack without materializing anything, and every caller
2855
+ // here asks it first. A **region** clip needs nothing either — the picture
2856
+ // carries it, and the mask must not try to hold it.
2857
+ //
2858
+ // `box` is the destination box of the composite the mask is for, in device
2859
+ // coordinates. The composite samples the mask over that box and nowhere
2860
+ // else, so both writes here take it instead of the whole surface (issue
2861
+ // #313): a translucent fill inside a rounded-corner clip used to stamp the
2862
+ // alpha across the full surface and intersect the clip across it again,
2863
+ // per fill, for pixels nothing reads. Stale content outside the box is
2864
+ // fine — every caller re-stamps the scratch unconditionally, so there is
2865
+ // no reuse to preserve.
2866
+ _compositeMask(box) {
2867
+ const clipMask =
2868
+ this._hasPolyClip || this._clipRect() ? this._requireClipMask() : null;
2338
2869
  if (this.globalAlpha >= 1) return clipMask ? clipMask.id : 0;
2339
2870
  this._ensureFillMask();
2871
+ const b = this._maskBox(box);
2872
+ if (b.w === 0 || b.h === 0) return this.fillMask.id; // nothing is sampled
2340
2873
  this.Render.FillRectangles(
2341
2874
  this.Render.PictOp.Src,
2342
2875
  this.fillMask.id,
2343
2876
  [0, 0, 0, this.globalAlpha],
2344
- [0, 0, this.width, this.height],
2877
+ [b.x, b.y, b.w, b.h],
2345
2878
  );
2346
2879
  if (clipMask) {
2347
2880
  this.Render.Composite(
@@ -2349,14 +2882,14 @@ class RenderingContext2d {
2349
2882
  clipMask.id,
2350
2883
  0,
2351
2884
  this.fillMask.id,
2885
+ b.x,
2886
+ b.y,
2352
2887
  0,
2353
2888
  0,
2354
- 0,
2355
- 0,
2356
- 0,
2357
- 0,
2358
- this.width,
2359
- this.height,
2889
+ b.x,
2890
+ b.y,
2891
+ b.w,
2892
+ b.h,
2360
2893
  );
2361
2894
  }
2362
2895
  return this.fillMask.id;
@@ -2373,10 +2906,10 @@ class RenderingContext2d {
2373
2906
  // and what every caller predating ARGB windows expects.
2374
2907
  clearRect(x, y, w, h) {
2375
2908
  const alpha = this._hasAlpha;
2376
- if (matIsIdentity(this._m) && !this._clips.length) {
2909
+ if (matIsIdentity(this._m) && !this._hasPolyClip && !this._clipRect()) {
2377
2910
  this.Render.FillRectangles(
2378
2911
  this.Render.PictOp.Src,
2379
- this.picture.id,
2912
+ this._dst(),
2380
2913
  alpha ? [0, 0, 0, 0] : [1, 1, 1, 1],
2381
2914
  [x, y, w, h],
2382
2915
  );
@@ -2411,20 +2944,30 @@ class RenderingContext2d {
2411
2944
  _fillRect(x, y, w, h) {
2412
2945
  if (matIsIdentity(this._m)) {
2413
2946
  if (!prepareStyle(this._backgroundPicture, this._m)) return;
2414
- const mask = this._compositeMask();
2947
+ const op = this._op();
2948
+ // A rectangular clip is a smaller rectangle to fill, not a mask — this
2949
+ // was the one drawing path that still built a surface-sized a8 for one
2950
+ // (issue #307), and a renderer filling a window background inside a
2951
+ // damage clip pays it every frame. Source, mask and destination
2952
+ // coordinates all shift with the box, so a gradient or a pattern stays
2953
+ // where it was and only the ink outside the clip goes away.
2954
+ const direct =
2955
+ w > 0 && h > 0 ? this._boxedComposite({ x, y, w, h }, op) : null;
2956
+ if (direct && !direct.box) return; // the clip rejects the fill whole
2957
+ const box = direct ? direct.box : { x, y, w, h };
2415
2958
  this.Render.Composite(
2416
- this._op(),
2959
+ op,
2417
2960
  this._backgroundPicture.id,
2418
- mask,
2419
- this.picture.id,
2420
- x,
2421
- y,
2422
- x,
2423
- y,
2424
- x,
2425
- y,
2426
- w,
2427
- h,
2961
+ direct ? direct.mask : this._compositeMask(box),
2962
+ this._dst(),
2963
+ box.x,
2964
+ box.y,
2965
+ box.x,
2966
+ box.y,
2967
+ box.x,
2968
+ box.y,
2969
+ box.w,
2970
+ box.h,
2428
2971
  );
2429
2972
  this._markDirty();
2430
2973
  return;
@@ -2480,11 +3023,11 @@ class RenderingContext2d {
2480
3023
  this._shadowOfPolys(flattenPath(rectPath._cmds, this._m), {});
2481
3024
  }
2482
3025
 
2483
- const clip = this._clips.length ? this._clipRect() : null;
3026
+ const clip = this._clipRect();
2484
3027
  if (
2485
3028
  !matIsIdentity(this._m) ||
2486
3029
  !isPlainColor(this._fillStyle) ||
2487
- (this._clips.length && !clip)
3030
+ this._hasPolyClip
2488
3031
  ) {
2489
3032
  for (let i = 0; i < flat.length; i += 4)
2490
3033
  this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
@@ -2492,14 +3035,7 @@ class RenderingContext2d {
2492
3035
  }
2493
3036
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
2494
3037
  const R = this.Render;
2495
- if (clip) {
2496
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2497
- clip.x,
2498
- clip.y,
2499
- clip.w,
2500
- clip.h,
2501
- ]);
2502
- }
3038
+ if (clip) this._setPictureClip(clip);
2503
3039
  // globalAlpha folds into the premultiplied colour — scaling all four
2504
3040
  // components is exactly what compositing at that opacity means, for
2505
3041
  // any composite op (the mask slot would only scale the source the same
@@ -2510,12 +3046,12 @@ class RenderingContext2d {
2510
3046
  for (let i = 0; i < flat.length; i += chunk) {
2511
3047
  R.FillRectangles(
2512
3048
  this._op(),
2513
- this.picture.id,
3049
+ this._dst(),
2514
3050
  color,
2515
3051
  i === 0 && flat.length <= chunk ? flat : flat.slice(i, i + chunk),
2516
3052
  );
2517
3053
  }
2518
- if (clip) this._resetPictureClip();
3054
+ if (clip) this._invalidatePictureClip();
2519
3055
  this._markDirty();
2520
3056
  }
2521
3057
 
@@ -2628,8 +3164,8 @@ class RenderingContext2d {
2628
3164
  // change what it covers — the rule argument needs no inspection)
2629
3165
  if (!isPlainColor(this._fillStyle)) return this._shapeMiss("gradient");
2630
3166
  if (this._gco !== "source-over") return this._shapeMiss("composite-op");
2631
- const clip = this._clips.length ? this._clipRect() : null;
2632
- if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
3167
+ if (this._hasPolyClip) return this._shapeMiss("clip-mask");
3168
+ const clip = this._clipRect();
2633
3169
 
2634
3170
  const { x, y, w, h, radii } = box;
2635
3171
  if (
@@ -2735,8 +3271,8 @@ class RenderingContext2d {
2735
3271
  if (!isPlainColor(this._strokeStyle)) return this._shapeMiss("gradient");
2736
3272
  if (this._gco !== "source-over") return this._shapeMiss("composite-op");
2737
3273
  if (this._lineDash.length) return this._shapeMiss("dashes");
2738
- const clip = this._clips.length ? this._clipRect() : null;
2739
- if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
3274
+ if (this._hasPolyClip) return this._shapeMiss("clip-mask");
3275
+ const clip = this._clipRect();
2740
3276
 
2741
3277
  const bw = this.lineWidth;
2742
3278
  if (!Number.isFinite(bw) || bw <= 0) return this._shapeMiss("geometry");
@@ -2866,22 +3402,16 @@ class RenderingContext2d {
2866
3402
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
2867
3403
  const R = this.Render;
2868
3404
  const app = this.window.app;
2869
- if (clip) {
2870
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2871
- clip.x,
2872
- clip.y,
2873
- clip.w,
2874
- clip.h,
2875
- ]);
2876
- }
3405
+ if (clip) this._setPictureClip(clip);
2877
3406
  if (corners.length) {
2878
3407
  const page = getShapeGlyphPage(app);
2879
3408
  page.ensure(corners);
2880
3409
  const items = [];
2881
3410
  for (const spec of corners) {
3411
+ const e = page.entry(spec.key);
2882
3412
  items.push({
2883
- gs: page.glyphset.id,
2884
- lid: page.entry(spec.key).lid,
3413
+ gs: e.gs,
3414
+ lid: e.lid,
2885
3415
  adv: 0,
2886
3416
  x: spec.px,
2887
3417
  y: spec.py,
@@ -2893,7 +3423,7 @@ class RenderingContext2d {
2893
3423
  encoded.bits,
2894
3424
  R.PictOp.Over,
2895
3425
  src.id,
2896
- this.picture.id,
3426
+ this._dst(),
2897
3427
  0,
2898
3428
  encoded.gsid,
2899
3429
  items[0].x,
@@ -2903,9 +3433,9 @@ class RenderingContext2d {
2903
3433
  trimShapeGlyphs(app);
2904
3434
  }
2905
3435
  if (rects.length) {
2906
- R.FillRectangles(R.PictOp.Over, this.picture.id, color, rects);
3436
+ R.FillRectangles(R.PictOp.Over, this._dst(), color, rects);
2907
3437
  }
2908
- if (clip) this._resetPictureClip();
3438
+ if (clip) this._invalidatePictureClip();
2909
3439
  this._markDirty();
2910
3440
  }
2911
3441
 
@@ -2940,8 +3470,7 @@ class RenderingContext2d {
2940
3470
  // be honoured by rounding — which would differ from the mask path's
2941
3471
  // antialiased edge by up to a pixel. Rare, so leave those to the mask
2942
3472
  // rather than quietly changing what they look like.
2943
- const integral = (v) => Math.abs(v - Math.round(v)) < 1e-6;
2944
- if (![x, y, w, h].every(integral)) return null;
3473
+ if (![x, y, w, h].every(isIntegral)) return null;
2945
3474
  return {
2946
3475
  x: Math.round(x),
2947
3476
  y: Math.round(y),
@@ -2950,14 +3479,21 @@ class RenderingContext2d {
2950
3479
  };
2951
3480
  }
2952
3481
 
2953
- /** Intersection of the whole clip stack, or null if any part is not a
2954
- * rectangle (or the stack is empty). */
3482
+ /**
3483
+ * Intersection of the rectangular part of the clip stack, or null when
3484
+ * there is none — either because the stack is empty or because something
3485
+ * in it is not a rectangle, and then the a8 mask owns the whole stack.
3486
+ *
3487
+ * Region entries are transparent here. They are not rectangles and never
3488
+ * become one: the picture carries them, which is a different slot from
3489
+ * this and composes with it (see _setPictureClip).
3490
+ */
2955
3491
  _clipRect() {
2956
- if (!this._clips.length) return null;
3492
+ if (this._hasPolyClip) return null;
2957
3493
  let out = null;
2958
3494
  for (const entry of this._clips) {
2959
3495
  const r = entry.rect;
2960
- if (!r) return null;
3496
+ if (!r) continue; // a region entry
2961
3497
  if (!out) {
2962
3498
  out = { ...r };
2963
3499
  continue;
@@ -2968,6 +3504,7 @@ class RenderingContext2d {
2968
3504
  const bottom = Math.min(out.y + out.h, r.y + r.h);
2969
3505
  out = { x, y, w: Math.max(0, right - x), h: Math.max(0, bottom - y) };
2970
3506
  }
3507
+ if (!out) return null;
2971
3508
  // clamp into the surface: the server rejects out-of-range rectangles
2972
3509
  const x = Math.max(0, Math.min(out.x, this.width));
2973
3510
  const y = Math.max(0, Math.min(out.y, this.height));
@@ -2976,11 +3513,31 @@ class RenderingContext2d {
2976
3513
  return { x, y, w, h };
2977
3514
  }
2978
3515
 
3516
+ /**
3517
+ * Replace the clip stack and re-derive what kinds of entry are in it.
3518
+ *
3519
+ * The two flags are what the drawing paths branch on, so they are computed
3520
+ * once per change rather than scanned per drawing — and the stack is only
3521
+ * ever replaced, never appended to in place, because save() hands the same
3522
+ * array to its snapshot.
3523
+ */
3524
+ _setClips(next) {
3525
+ this._clips = next;
3526
+ let poly = false;
3527
+ let region = false;
3528
+ for (const entry of next) {
3529
+ if (entry.region) region = true;
3530
+ else if (!entry.rect) poly = true;
3531
+ }
3532
+ this._hasPolyClip = poly;
3533
+ this._hasRegionClip = region;
3534
+ }
3535
+
2979
3536
  clip(...args) {
2980
3537
  const { polys, rule } = this._polysFor(args);
2981
3538
  const entry = { polys, rule, rect: RenderingContext2d._rectOfPolys(polys) };
2982
3539
  // copy-on-write: earlier save() snapshots keep their own clip list
2983
- this._clips = this._clips.concat([entry]);
3540
+ this._setClips(this._clips.concat([entry]));
2984
3541
 
2985
3542
  // A stack of rectangles stays virtual: consumers apply the intersected
2986
3543
  // rectangle server-side (SetPictureClipRectangles, bounded composites),
@@ -2997,6 +3554,36 @@ class RenderingContext2d {
2997
3554
  this._intersectClip(entry);
2998
3555
  }
2999
3556
 
3557
+ /**
3558
+ * Intersect the clip with a server-side XFIXES region — a rectangle set the
3559
+ * X server owns, which is how X describes damage from an expose, a window's
3560
+ * SHAPE, or the area a compositor has left after subtracting the windows in
3561
+ * front of this one. `app.createRegion(rects)` makes one (lib/region.js).
3562
+ *
3563
+ * Scoped like `clip()`: `restore()` takes it off again, and nothing else
3564
+ * does. It intersects with rectangular and path clips in either order.
3565
+ *
3566
+ * The region is in **device pixels** and ignores the current transform —
3567
+ * unlike `clip()`, whose path goes through it. A region is a set of integer
3568
+ * rectangles, so there is no honest way to rotate or scale one; a caller who
3569
+ * wants it moved can `region.translate(dx, dy)`, which the server does.
3570
+ *
3571
+ * Unlike installing the region on `ctx.picture` yourself, this is a clip ntk
3572
+ * knows about: the fast paths that narrow the picture to a rectangle around
3573
+ * a glyph run or a fill restore *to* it afterwards, instead of overwriting
3574
+ * it with a full-plane rectangle (issue #292).
3575
+ *
3576
+ * @param {Region|number|{id: number}} region
3577
+ */
3578
+ clipRegion(region) {
3579
+ const id = regionId(region);
3580
+ const fixes = this._fixes || this.window.app._fixes;
3581
+ if (!fixes) throw needXFixesError();
3582
+ this._fixes = fixes;
3583
+ this._setClips(this._clips.concat([{ region: id }]));
3584
+ this._invalidatePictureClip();
3585
+ }
3586
+
3000
3587
  /**
3001
3588
  * Build the a8 mask from the whole clip stack, on first demand. Rect
3002
3589
  * entries are plain fills; only genuinely non-rectangular entries
@@ -3012,7 +3599,18 @@ class RenderingContext2d {
3012
3599
  [0, 0, 0, 0],
3013
3600
  [0, 0, this.width, this.height],
3014
3601
  );
3015
- const first = this._clips[0];
3602
+ // region entries are the picture's clip, not the mask's business
3603
+ const shapes = this._clips.filter((entry) => !entry.region);
3604
+ if (!shapes.length) {
3605
+ R.FillRectangles(
3606
+ R.PictOp.Src,
3607
+ this.clipMask.id,
3608
+ [0, 0, 0, 1],
3609
+ [0, 0, this.width, this.height],
3610
+ );
3611
+ return;
3612
+ }
3613
+ const first = shapes[0];
3016
3614
  if (first.rect) {
3017
3615
  const r = first.rect;
3018
3616
  const x = Math.max(0, Math.min(r.x, this.width));
@@ -3030,8 +3628,7 @@ class RenderingContext2d {
3030
3628
  } else {
3031
3629
  this._applyPolyClip(first, R.PictOp.Src);
3032
3630
  }
3033
- for (let i = 1; i < this._clips.length; i++)
3034
- this._intersectClip(this._clips[i]);
3631
+ for (let i = 1; i < shapes.length; i++) this._intersectClip(shapes[i]);
3035
3632
  }
3036
3633
 
3037
3634
  /** The live mask, built if the stack has not needed one yet. Callers on
@@ -3739,28 +4336,27 @@ class RenderingContext2d {
3739
4336
  * Uniform alpha needs no surface-sized mask either: a 1x1 repeating
3740
4337
  * picture is the same thing to the server, with no pixel work at all.
3741
4338
  */
3742
- _beginDirectComposite() {
3743
- const rect = this._clips.length ? this._clipRect() : null;
3744
- if (!rect)
3745
- return { mask: this._compositeMask(), clipped: false, empty: false };
4339
+ _beginDirectComposite(box) {
4340
+ const rect = this._clipRect();
4341
+ if (!rect) {
4342
+ // a poly clip is the only thing left that needs a mask; without one
4343
+ // there is nothing to apply here but the alpha. The mask it does need
4344
+ // is bounded to `box`, the destination box of the composite it is for
4345
+ // — the only pixels of it the server samples (issue #313).
4346
+ const mask = this._hasPolyClip
4347
+ ? this._compositeMask(box)
4348
+ : this._alphaMask();
4349
+ return { mask, clipped: false, empty: false };
4350
+ }
3746
4351
  if (rect.w === 0 || rect.h === 0)
3747
4352
  return { mask: 0, clipped: false, empty: true };
3748
- this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [
3749
- rect.x,
3750
- rect.y,
3751
- rect.w,
3752
- rect.h,
3753
- ]);
3754
- const mask =
3755
- this.globalAlpha >= 1
3756
- ? 0
3757
- : this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
3758
- return { mask, clipped: true, empty: false };
4353
+ this._setPictureClip(rect);
4354
+ return { mask: this._alphaMask(), clipped: true, empty: false };
3759
4355
  }
3760
4356
 
3761
4357
  _endDirectComposite(state) {
3762
4358
  if (!state.clipped) return;
3763
- this._resetPictureClip();
4359
+ this._invalidatePictureClip();
3764
4360
  }
3765
4361
 
3766
4362
  /** The source colour for a coverage composite, with `globalAlpha` folded
@@ -3810,12 +4406,12 @@ class RenderingContext2d {
3810
4406
  const mx = scaled ? 0 : sx;
3811
4407
  const my = scaled ? 0 : sy;
3812
4408
 
3813
- const rect = this._clips.length ? this._clipRect() : null;
4409
+ const rect = this._clipRect();
3814
4410
  // the mask slot can hold exactly one picture, so anything that cannot be
3815
4411
  // folded into the colour or handed to the server as a clip rectangle has
3816
4412
  // to be intersected into the scratch mask first
3817
4413
  const scratch =
3818
- (this._clips.length && !rect) ||
4414
+ this._hasPolyClip ||
3819
4415
  (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
3820
4416
 
3821
4417
  if (scratch) {
@@ -3856,7 +4452,7 @@ class RenderingContext2d {
3856
4452
  this.height,
3857
4453
  );
3858
4454
  }
3859
- if (this._clips.length && !rect) {
4455
+ if (this._hasPolyClip) {
3860
4456
  const clipMask = this._requireClipMask();
3861
4457
  R.Composite(
3862
4458
  R.PictOp.In,
@@ -3875,12 +4471,7 @@ class RenderingContext2d {
3875
4471
  } else if (rect) {
3876
4472
  // scratch was forced by alpha-over-gradient, not by the clip: the
3877
4473
  // rectangle still applies, server-side, around the final composite
3878
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
3879
- rect.x,
3880
- rect.y,
3881
- rect.w,
3882
- rect.h,
3883
- ]);
4474
+ this._setPictureClip(rect);
3884
4475
  }
3885
4476
  // The scratch mask is surface-sized and surface-aligned — the coverage
3886
4477
  // went into it at (dx, dy) — and so is a gradient source, so both are
@@ -3892,7 +4483,7 @@ class RenderingContext2d {
3892
4483
  op,
3893
4484
  this._backgroundPicture.id,
3894
4485
  this.fillMask.id,
3895
- this.picture.id,
4486
+ this._dst(),
3896
4487
  dx,
3897
4488
  dy,
3898
4489
  dx,
@@ -3902,23 +4493,9 @@ class RenderingContext2d {
3902
4493
  dw,
3903
4494
  dh,
3904
4495
  );
3905
- if (rect) {
3906
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
3907
- 0,
3908
- 0,
3909
- this.width,
3910
- this.height,
3911
- ]);
3912
- }
4496
+ if (rect) this._invalidatePictureClip();
3913
4497
  } else if (!rect || (rect.w > 0 && rect.h > 0)) {
3914
- if (rect) {
3915
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
3916
- rect.x,
3917
- rect.y,
3918
- rect.w,
3919
- rect.h,
3920
- ]);
3921
- }
4498
+ if (rect) this._setPictureClip(rect);
3922
4499
  // the source is a 1x1 repeating solid (offset irrelevant) or the fill
3923
4500
  // picture, which for a gradient is surface-aligned: same offset as the
3924
4501
  // destination, exactly as in _fillPolys
@@ -3926,7 +4503,7 @@ class RenderingContext2d {
3926
4503
  op,
3927
4504
  this._coverageSource().id,
3928
4505
  picture.id,
3929
- this.picture.id,
4506
+ this._dst(),
3930
4507
  dx,
3931
4508
  dy,
3932
4509
  mx,
@@ -3936,9 +4513,7 @@ class RenderingContext2d {
3936
4513
  dw,
3937
4514
  dh,
3938
4515
  );
3939
- if (rect) {
3940
- this._resetPictureClip();
3941
- }
4516
+ if (rect) this._invalidatePictureClip();
3942
4517
  }
3943
4518
 
3944
4519
  if (scaled) {
@@ -3984,7 +4559,7 @@ class RenderingContext2d {
3984
4559
  return;
3985
4560
  }
3986
4561
 
3987
- const state = this._beginDirectComposite();
4562
+ const state = this._beginDirectComposite({ x: dx, y: dy, w: dw, h: dh });
3988
4563
  if (state.empty) return;
3989
4564
  const mask = state.mask;
3990
4565
  const scaled = dw !== sw || dh !== sh;
@@ -4007,7 +4582,7 @@ class RenderingContext2d {
4007
4582
  op,
4008
4583
  picture.id,
4009
4584
  mask,
4010
- this.picture.id,
4585
+ this._dst(),
4011
4586
  0,
4012
4587
  0,
4013
4588
  dx,
@@ -4028,7 +4603,7 @@ class RenderingContext2d {
4028
4603
  op,
4029
4604
  picture.id,
4030
4605
  mask,
4031
- this.picture.id,
4606
+ this._dst(),
4032
4607
  sx,
4033
4608
  sy,
4034
4609
  dx,
@@ -4054,19 +4629,25 @@ class RenderingContext2d {
4054
4629
  if (image instanceof RenderingContext2d) {
4055
4630
  // TODO: if need to scale, set transform
4056
4631
  // TODO: respect global compositing blend mode
4632
+ const full = { x: 0, y: 0, w: this.width, h: this.height };
4633
+ // source and destination are both surface-aligned here, so a clip
4634
+ // rectangle is just a smaller box to copy (issue #307)
4635
+ const direct = this._boxedComposite(full, this.Render.PictOp.Over);
4636
+ if (direct && !direct.box) return;
4637
+ const box = direct ? direct.box : full;
4057
4638
  this.Render.Composite(
4058
4639
  this.Render.PictOp.Over,
4059
4640
  image.picture.id,
4060
- this._compositeMask(),
4061
- this.picture.id,
4062
- 0,
4063
- 0,
4064
- 0,
4065
- 0,
4066
- 0,
4067
- 0,
4068
- this.width,
4069
- this.height,
4641
+ direct ? direct.mask : this._compositeMask(box),
4642
+ this._dst(),
4643
+ box.x,
4644
+ box.y,
4645
+ box.x,
4646
+ box.y,
4647
+ box.x,
4648
+ box.y,
4649
+ box.w,
4650
+ box.h,
4070
4651
  );
4071
4652
  this._markDirty();
4072
4653
  } else if (
@@ -4075,6 +4656,13 @@ class RenderingContext2d {
4075
4656
  typeof image.context.getImageData === "function"
4076
4657
  ) {
4077
4658
  // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
4659
+ // What the clip leaves of the composite, decided before anything is
4660
+ // uploaded: a clip that rejects it whole makes the upload below dead
4661
+ // work (issue #307)
4662
+ const full = { x: 0, y: 0, w: this.width, h: this.height };
4663
+ const direct = this._boxedComposite(full, this.Render.PictOp.Over);
4664
+ if (direct && !direct.box) return;
4665
+ const box = direct ? direct.box : full;
4078
4666
  const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
4079
4667
  // node-canvas hands over straight RGBA, and the rgba32 picture below is
4080
4668
  // premultiplied — this used to swap the channels and stop there, which
@@ -4114,16 +4702,16 @@ class RenderingContext2d {
4114
4702
  this.Render.Composite(
4115
4703
  this.Render.PictOp.Over,
4116
4704
  picture.id,
4117
- this._compositeMask(),
4118
- this.picture.id,
4119
- 0,
4120
- 0,
4121
- 0,
4122
- 0,
4123
- 0,
4124
- 0,
4125
- this.width,
4126
- this.height,
4705
+ direct ? direct.mask : this._compositeMask(box),
4706
+ this._dst(),
4707
+ box.x,
4708
+ box.y,
4709
+ box.x,
4710
+ box.y,
4711
+ box.x,
4712
+ box.y,
4713
+ box.w,
4714
+ box.h,
4127
4715
  );
4128
4716
  this._markDirty();
4129
4717
  } finally {
@@ -4162,6 +4750,14 @@ class RenderingContext2d {
4162
4750
  );
4163
4751
  if (x1 <= x0 || y1 <= y0) return;
4164
4752
 
4753
+ // A rectangular clip shrinks the box instead of becoming a mask (issue
4754
+ // #307). Decided before the picture transform is installed, so a draw
4755
+ // the clip rejects does not have to put it back.
4756
+ const bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
4757
+ const direct = this._boxedComposite(bbox, op);
4758
+ if (direct && !direct.box) return;
4759
+ const box = direct ? direct.box : bbox;
4760
+
4165
4761
  // device -> source sample (adding the source-rect origin)
4166
4762
  const srcM = [inv[0], inv[1], inv[2], inv[3], inv[4] + sx, inv[5] + sy];
4167
4763
  this.Render.SetPictureTransform(picture.id, [
@@ -4176,20 +4772,19 @@ class RenderingContext2d {
4176
4772
  1,
4177
4773
  ]);
4178
4774
  picture.setFilter("bilinear");
4179
- const mask = this._compositeMask();
4180
4775
  this.Render.Composite(
4181
4776
  op,
4182
4777
  picture.id,
4183
- mask,
4184
- this.picture.id,
4185
- x0,
4186
- y0,
4187
- x0,
4188
- y0,
4189
- x0,
4190
- y0,
4191
- x1 - x0,
4192
- y1 - y0,
4778
+ direct ? direct.mask : this._compositeMask(box),
4779
+ this._dst(),
4780
+ box.x,
4781
+ box.y,
4782
+ box.x,
4783
+ box.y,
4784
+ box.x,
4785
+ box.y,
4786
+ box.w,
4787
+ box.h,
4193
4788
  );
4194
4789
  this.Render.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
4195
4790
  picture.setFilter("nearest");