ntk 8.2.0 → 8.3.1

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,
@@ -75,6 +77,11 @@ function fillableShapes(polys) {
75
77
  return { shapes, edges };
76
78
  }
77
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
+
78
85
  /** The overlap of two {x, y, w, h} boxes, or null when they have none. */
79
86
  function intersectBox(a, b) {
80
87
  const x = Math.max(a.x, b.x);
@@ -152,13 +159,17 @@ function variationsKey(variations) {
152
159
  * context owns is a Pixmap or a Picture, which carry their own finalizers,
153
160
  * and reaching into them from this one would race those.
154
161
  */
155
- const gcRegistry = new FinalizationRegistry(({ X, gcs }) => {
162
+ const gcRegistry = new FinalizationRegistry(({ X, gcs, regions }) => {
156
163
  safeRelease(X, () => {
157
164
  for (const gc of gcs) {
158
165
  if (!gc) continue;
159
166
  X.FreeGC(gc);
160
167
  X.ReleaseID(gc);
161
168
  }
169
+ for (const { fixes, id } of regions) {
170
+ fixes.DestroyRegion(id);
171
+ X.ReleaseID(id);
172
+ }
162
173
  });
163
174
  });
164
175
 
@@ -191,6 +202,63 @@ const REPEAT_MODES = {
191
202
  const PATTERN_DOCS =
192
203
  "https://github.com/sidorares/ntk/blob/master/docs/context-2d.md#patterns";
193
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
+
194
262
  /**
195
263
  * What a pattern tiles: a drawable holding the tile plus the picture format
196
264
  * to read it through. A repeating source Picture is created over it rather
@@ -199,13 +267,12 @@ const PATTERN_DOCS =
199
267
  */
200
268
  function patternSourceOf(app, source) {
201
269
  const Render = app.display.Render;
202
- const formatFor = (depth) =>
203
- depth === 32 ? Render.rgba32 : depth === 8 ? "a8" : Render.rgb24;
204
270
 
205
271
  let drawable = null;
206
272
  let width;
207
273
  let height;
208
274
  let format;
275
+ let coverage = false; // an a8 tile: no colour to paint with (see below)
209
276
  if (source instanceof Surface) {
210
277
  if (source.app !== app) {
211
278
  throw new Error(
@@ -213,7 +280,8 @@ function patternSourceOf(app, source) {
213
280
  );
214
281
  }
215
282
  drawable = source.pixmap;
216
- format = source.format === "a8" ? "a8" : Render.rgba32;
283
+ coverage = source.format === "a8";
284
+ format = coverage ? Render.a8 : Render.rgba32;
217
285
  ({ width, height } = source);
218
286
  } else if (source instanceof Image) {
219
287
  drawable = source.pixmap(app);
@@ -224,12 +292,12 @@ function patternSourceOf(app, source) {
224
292
  // is where a double-buffered window's current pixels actually are)
225
293
  drawable = source._backing || source;
226
294
  const depth = drawable.depth ?? source.depth;
227
- if (!depth) {
228
- throw new Error(
229
- "createPattern: the drawable's depth is not known yet — await the window's geometry, or pass a Surface",
230
- );
231
- }
232
- 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);
233
301
  width = drawable.width ?? source.width;
234
302
  height = drawable.height ?? source.height;
235
303
  } else {
@@ -238,7 +306,7 @@ function patternSourceOf(app, source) {
238
306
  (source === null ? "null" : typeof source),
239
307
  );
240
308
  }
241
- if (format === "a8") {
309
+ if (coverage) {
242
310
  throw new Error(
243
311
  "createPattern: a coverage (a8) tile has no colour to paint with — XRender would " +
244
312
  "sample it as black. Draw the tile into an argb32 Surface and tile that, or keep " +
@@ -424,7 +492,10 @@ class RenderingContext2d {
424
492
  // ids land here as they are allocated; the finalizer holds this array
425
493
  // rather than the context, so registering cannot keep the context alive
426
494
  this._gcs = [];
427
- 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);
428
499
 
429
500
  this.window = window;
430
501
  this.display = window.app.display;
@@ -435,17 +506,40 @@ class RenderingContext2d {
435
506
  // buffering); re-bind when the backing pixmap is reallocated on resize
436
507
  this._target = null;
437
508
  this._gc = null;
438
- this.picture = null;
509
+ this._picture = null;
439
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
+ }
440
534
  if (typeof window.on === "function") {
441
535
  window.on("_backing", () => this._bindTarget());
442
536
  // masks are sized to the drawable — recreate them after a resize
443
537
  window.on("resize", () => this._dropMasks());
444
538
  // window-backed pictures are freed server-side with the window
445
539
  window.on("_destroyed", () => {
446
- if (this.picture && this._target === this.window) {
447
- this.picture.forget();
448
- this.picture = null;
540
+ if (this._picture && this._target === this.window) {
541
+ this._picture.forget();
542
+ this._picture = null;
449
543
  }
450
544
  });
451
545
  }
@@ -472,7 +566,26 @@ class RenderingContext2d {
472
566
  // drawings that took more than one.
473
567
  this.maskStats = { masks: 0, pixels: 0, split: 0 };
474
568
  this._stack = [];
475
- 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
476
589
  this._gco = "source-over";
477
590
  this.globalAlpha = 1;
478
591
  this.lineCap = "butt";
@@ -495,9 +608,46 @@ class RenderingContext2d {
495
608
  this.lineWidth = 1;
496
609
  }
497
610
 
498
- _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) {
499
649
  const target = this.window._backing || this.window;
500
- if (this._target === target) return;
650
+ if (this._target === target && !force) return;
501
651
  this._target = target;
502
652
  this._layoutCache = null; // the new target may be a different depth
503
653
 
@@ -508,36 +658,210 @@ class RenderingContext2d {
508
658
  this._gcs.push(this._gc);
509
659
  this.X.CreateGC(this._gc, target.id);
510
660
  }
511
- if (this.picture) this.picture.destroy();
661
+ if (this._picture) this._picture.destroy();
512
662
  // a8 targets are coverage, not colour: drawing into one and using the
513
663
  // result as a mask is how a monochrome drawing gets rendered once and
514
664
  // recoloured on every use (see Surface).
515
- const depth = target.depth ?? this.window.depth;
516
- const format =
517
- depth === 32
518
- ? this.Render.rgba32
519
- : depth === 8
520
- ? this.Render.a8
521
- : this.Render.rgb24;
665
+ const { depth, visual } = this._targetPixels();
666
+ const format = this._formatFor(depth, visual);
522
667
  // Does the target have a real alpha channel? Only then is "transparent"
523
668
  // a colour it can hold, which is what clearRect turns on.
524
669
  this._hasAlpha = depth === 32;
525
- this.picture = new Picture(this.window.app, {
670
+ this._picture = new Picture(this.window.app, {
526
671
  drawable: target,
527
672
  format,
528
673
  polyEdge: 1,
529
674
  polyMode: 1,
530
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();
531
681
  this._dropMasks();
532
682
  }
533
683
 
534
- // Undo a rectangular server-side clip. "No clip" has to be stamped as a
535
- // rectangle that outlives window growth: the backing pixmap has headroom
536
- // past the window and growing into it does not rebind the picture, so a
537
- // restore at today's window size would keep clipping tomorrow's pixels.
538
- // Coordinates are INT16 — one rect at their maximum covers any drawable.
539
- _resetPictureClip() {
540
- 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;
541
865
  }
542
866
 
543
867
  /**
@@ -561,8 +885,16 @@ class RenderingContext2d {
561
885
  this._destroyed = true;
562
886
  gcRegistry.unregister(this);
563
887
 
564
- this._clips = [];
888
+ this._setClips([]);
565
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;
566
898
  for (const gc of [this._gc, this._fillMaskGC, this._clipMaskGC]) {
567
899
  if (!gc) continue;
568
900
  safeRelease(this.X, () => {
@@ -575,15 +907,15 @@ class RenderingContext2d {
575
907
 
576
908
  this._backgroundPicture = this._glyphSource = null;
577
909
 
578
- if (this.picture) {
910
+ if (this._picture) {
579
911
  // a picture on a *window* is freed by the server with the window;
580
912
  // asking again would raise BadPicture. Same rule as `_destroyed`.
581
913
  if (this._target === this.window && !(this.window instanceof Pixmap)) {
582
- this.picture.forget();
914
+ this._picture.forget();
583
915
  } else {
584
- this.picture.destroy();
916
+ this._picture.destroy();
585
917
  }
586
- this.picture = null;
918
+ this._picture = null;
587
919
  }
588
920
  }
589
921
 
@@ -615,7 +947,7 @@ class RenderingContext2d {
615
947
  // a hover repaint of two tab headers used to blit the whole window. A
616
948
  // context with no rectangular clip reports nothing, and the window falls
617
949
  // back to a full blit, which is what any drawing outside a clip needs.
618
- this.window._markDirty(this._clips.length ? this._clipRect() : null);
950
+ this.window._markDirty(this._clipRect());
619
951
  }
620
952
 
621
953
  // html context2d compatibility: canvas.getContext('2d').canvas == canvas
@@ -764,8 +1096,12 @@ class RenderingContext2d {
764
1096
  this._shadowOffsetY = s.shadowOffsetY;
765
1097
  this._m = s.m;
766
1098
  if (s.clips !== this._clips) {
767
- this._clips = s.clips;
1099
+ const hadRegion = this._hasRegionClip;
1100
+ this._setClips(s.clips);
768
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();
769
1105
  }
770
1106
  }
771
1107
 
@@ -1631,16 +1967,19 @@ class RenderingContext2d {
1631
1967
  // A rectangular clip narrows where the coverage is *composited*; the
1632
1968
  // mask content outside it is stale by the same argument as outside `b`,
1633
1969
  // and a box the clip rejects outright needs no coverage at all. A stack
1634
- // whose mask was dropped by restore() may still hold a poly — _clipRect
1635
- // 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.
1636
1973
  let out = b;
1637
- if (this._clips.length && !this.clipMask) {
1638
- const cr = this._clipRect();
1639
- if (cr) {
1640
- out = intersectBox(b, cr);
1641
- if (!out) return false;
1642
- } else {
1974
+ if (!this.clipMask) {
1975
+ if (this._hasPolyClip) {
1643
1976
  this._requireClipMask();
1977
+ } else {
1978
+ const cr = this._clipRect();
1979
+ if (cr) {
1980
+ out = intersectBox(b, cr);
1981
+ if (!out) return false;
1982
+ }
1644
1983
  }
1645
1984
  }
1646
1985
  if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, edges)) {
@@ -1684,7 +2023,7 @@ class RenderingContext2d {
1684
2023
  op,
1685
2024
  src.id,
1686
2025
  this.fillMask.id,
1687
- this.picture.id,
2026
+ this._dst(),
1688
2027
  out.x,
1689
2028
  out.y,
1690
2029
  out.x,
@@ -2082,7 +2421,10 @@ class RenderingContext2d {
2082
2421
  const direct =
2083
2422
  !hasRound &&
2084
2423
  this.globalAlpha >= 1 &&
2085
- !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() &&
2086
2428
  op === this.Render.PictOp.Over;
2087
2429
  const chunk = 4000 * 6;
2088
2430
  if (direct) {
@@ -2100,7 +2442,7 @@ class RenderingContext2d {
2100
2442
  src.id,
2101
2443
  Math.floor(batch[0]),
2102
2444
  Math.floor(batch[1]),
2103
- this.picture.id,
2445
+ this._dst(),
2104
2446
  this.Render.a8,
2105
2447
  batch,
2106
2448
  );
@@ -2295,26 +2637,20 @@ class RenderingContext2d {
2295
2637
  const app = this.window.app;
2296
2638
  const R = this.Render;
2297
2639
  if (!prepareStyle(src, this._m)) return;
2298
- if (!this._clips.length) {
2299
- drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
2300
- this._markDirty();
2301
- return;
2302
- }
2303
- // Fast path: a rectangular clip is something the server can do itself.
2304
- // Two small requests around the ordinary glyph composite, instead of
2305
- // clearing and compositing a full-surface a8 mask three times — which
2306
- // costs the same on the wire but many times the pixel work.
2307
- const rect = this._clipRect();
2308
- if (rect) {
2309
- if (rect.w === 0 || rect.h === 0) return;
2310
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2311
- rect.x,
2312
- rect.y,
2313
- rect.w,
2314
- rect.h,
2315
- ]);
2316
- drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
2317
- 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();
2318
2654
  this._markDirty();
2319
2655
  return;
2320
2656
  }
@@ -2353,7 +2689,7 @@ class RenderingContext2d {
2353
2689
  op,
2354
2690
  src.id,
2355
2691
  this.fillMask.id,
2356
- this.picture.id,
2692
+ this._dst(),
2357
2693
  0,
2358
2694
  0,
2359
2695
  0,
@@ -2376,22 +2712,14 @@ class RenderingContext2d {
2376
2712
  if (!traps.length) return;
2377
2713
  const app = this.window.app;
2378
2714
  const R = this.Render;
2379
- if (!this._clips.length) {
2380
- compositeTraps(app, op, src.id, this.picture.id, traps);
2381
- this._markDirty();
2382
- return;
2383
- }
2384
- const rect = this._clipRect();
2385
- if (rect) {
2386
- if (rect.w === 0 || rect.h === 0) return;
2387
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2388
- rect.x,
2389
- rect.y,
2390
- rect.w,
2391
- rect.h,
2392
- ]);
2393
- compositeTraps(app, op, src.id, this.picture.id, traps);
2394
- 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();
2395
2723
  this._markDirty();
2396
2724
  return;
2397
2725
  }
@@ -2429,7 +2757,7 @@ class RenderingContext2d {
2429
2757
  op,
2430
2758
  src.id,
2431
2759
  this.fillMask.id,
2432
- this.picture.id,
2760
+ this._dst(),
2433
2761
  0,
2434
2762
  0,
2435
2763
  0,
@@ -2442,19 +2770,111 @@ class RenderingContext2d {
2442
2770
  this._markDirty();
2443
2771
  }
2444
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
+
2445
2850
  // combined clip ∩ globalAlpha mask for direct composites (rect/image
2446
2851
  // fast paths); returns a picture id or 0. Reuses the fill scratch mask.
2447
- _compositeMask() {
2448
- // reached only when the clip is not a rectangle (those went through the
2449
- // SetPictureClipRectangles path), so a clip here always means the mask
2450
- 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;
2451
2869
  if (this.globalAlpha >= 1) return clipMask ? clipMask.id : 0;
2452
2870
  this._ensureFillMask();
2871
+ const b = this._maskBox(box);
2872
+ if (b.w === 0 || b.h === 0) return this.fillMask.id; // nothing is sampled
2453
2873
  this.Render.FillRectangles(
2454
2874
  this.Render.PictOp.Src,
2455
2875
  this.fillMask.id,
2456
2876
  [0, 0, 0, this.globalAlpha],
2457
- [0, 0, this.width, this.height],
2877
+ [b.x, b.y, b.w, b.h],
2458
2878
  );
2459
2879
  if (clipMask) {
2460
2880
  this.Render.Composite(
@@ -2462,14 +2882,14 @@ class RenderingContext2d {
2462
2882
  clipMask.id,
2463
2883
  0,
2464
2884
  this.fillMask.id,
2885
+ b.x,
2886
+ b.y,
2465
2887
  0,
2466
2888
  0,
2467
- 0,
2468
- 0,
2469
- 0,
2470
- 0,
2471
- this.width,
2472
- this.height,
2889
+ b.x,
2890
+ b.y,
2891
+ b.w,
2892
+ b.h,
2473
2893
  );
2474
2894
  }
2475
2895
  return this.fillMask.id;
@@ -2486,10 +2906,10 @@ class RenderingContext2d {
2486
2906
  // and what every caller predating ARGB windows expects.
2487
2907
  clearRect(x, y, w, h) {
2488
2908
  const alpha = this._hasAlpha;
2489
- if (matIsIdentity(this._m) && !this._clips.length) {
2909
+ if (matIsIdentity(this._m) && !this._hasPolyClip && !this._clipRect()) {
2490
2910
  this.Render.FillRectangles(
2491
2911
  this.Render.PictOp.Src,
2492
- this.picture.id,
2912
+ this._dst(),
2493
2913
  alpha ? [0, 0, 0, 0] : [1, 1, 1, 1],
2494
2914
  [x, y, w, h],
2495
2915
  );
@@ -2524,20 +2944,30 @@ class RenderingContext2d {
2524
2944
  _fillRect(x, y, w, h) {
2525
2945
  if (matIsIdentity(this._m)) {
2526
2946
  if (!prepareStyle(this._backgroundPicture, this._m)) return;
2527
- 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 };
2528
2958
  this.Render.Composite(
2529
- this._op(),
2959
+ op,
2530
2960
  this._backgroundPicture.id,
2531
- mask,
2532
- this.picture.id,
2533
- x,
2534
- y,
2535
- x,
2536
- y,
2537
- x,
2538
- y,
2539
- w,
2540
- 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,
2541
2971
  );
2542
2972
  this._markDirty();
2543
2973
  return;
@@ -2593,11 +3023,11 @@ class RenderingContext2d {
2593
3023
  this._shadowOfPolys(flattenPath(rectPath._cmds, this._m), {});
2594
3024
  }
2595
3025
 
2596
- const clip = this._clips.length ? this._clipRect() : null;
3026
+ const clip = this._clipRect();
2597
3027
  if (
2598
3028
  !matIsIdentity(this._m) ||
2599
3029
  !isPlainColor(this._fillStyle) ||
2600
- (this._clips.length && !clip)
3030
+ this._hasPolyClip
2601
3031
  ) {
2602
3032
  for (let i = 0; i < flat.length; i += 4)
2603
3033
  this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
@@ -2605,14 +3035,7 @@ class RenderingContext2d {
2605
3035
  }
2606
3036
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
2607
3037
  const R = this.Render;
2608
- if (clip) {
2609
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2610
- clip.x,
2611
- clip.y,
2612
- clip.w,
2613
- clip.h,
2614
- ]);
2615
- }
3038
+ if (clip) this._setPictureClip(clip);
2616
3039
  // globalAlpha folds into the premultiplied colour — scaling all four
2617
3040
  // components is exactly what compositing at that opacity means, for
2618
3041
  // any composite op (the mask slot would only scale the source the same
@@ -2623,12 +3046,12 @@ class RenderingContext2d {
2623
3046
  for (let i = 0; i < flat.length; i += chunk) {
2624
3047
  R.FillRectangles(
2625
3048
  this._op(),
2626
- this.picture.id,
3049
+ this._dst(),
2627
3050
  color,
2628
3051
  i === 0 && flat.length <= chunk ? flat : flat.slice(i, i + chunk),
2629
3052
  );
2630
3053
  }
2631
- if (clip) this._resetPictureClip();
3054
+ if (clip) this._invalidatePictureClip();
2632
3055
  this._markDirty();
2633
3056
  }
2634
3057
 
@@ -2741,8 +3164,8 @@ class RenderingContext2d {
2741
3164
  // change what it covers — the rule argument needs no inspection)
2742
3165
  if (!isPlainColor(this._fillStyle)) return this._shapeMiss("gradient");
2743
3166
  if (this._gco !== "source-over") return this._shapeMiss("composite-op");
2744
- const clip = this._clips.length ? this._clipRect() : null;
2745
- if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
3167
+ if (this._hasPolyClip) return this._shapeMiss("clip-mask");
3168
+ const clip = this._clipRect();
2746
3169
 
2747
3170
  const { x, y, w, h, radii } = box;
2748
3171
  if (
@@ -2848,8 +3271,8 @@ class RenderingContext2d {
2848
3271
  if (!isPlainColor(this._strokeStyle)) return this._shapeMiss("gradient");
2849
3272
  if (this._gco !== "source-over") return this._shapeMiss("composite-op");
2850
3273
  if (this._lineDash.length) return this._shapeMiss("dashes");
2851
- const clip = this._clips.length ? this._clipRect() : null;
2852
- if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
3274
+ if (this._hasPolyClip) return this._shapeMiss("clip-mask");
3275
+ const clip = this._clipRect();
2853
3276
 
2854
3277
  const bw = this.lineWidth;
2855
3278
  if (!Number.isFinite(bw) || bw <= 0) return this._shapeMiss("geometry");
@@ -2979,22 +3402,16 @@ class RenderingContext2d {
2979
3402
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
2980
3403
  const R = this.Render;
2981
3404
  const app = this.window.app;
2982
- if (clip) {
2983
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
2984
- clip.x,
2985
- clip.y,
2986
- clip.w,
2987
- clip.h,
2988
- ]);
2989
- }
3405
+ if (clip) this._setPictureClip(clip);
2990
3406
  if (corners.length) {
2991
3407
  const page = getShapeGlyphPage(app);
2992
3408
  page.ensure(corners);
2993
3409
  const items = [];
2994
3410
  for (const spec of corners) {
3411
+ const e = page.entry(spec.key);
2995
3412
  items.push({
2996
- gs: page.glyphset.id,
2997
- lid: page.entry(spec.key).lid,
3413
+ gs: e.gs,
3414
+ lid: e.lid,
2998
3415
  adv: 0,
2999
3416
  x: spec.px,
3000
3417
  y: spec.py,
@@ -3006,7 +3423,7 @@ class RenderingContext2d {
3006
3423
  encoded.bits,
3007
3424
  R.PictOp.Over,
3008
3425
  src.id,
3009
- this.picture.id,
3426
+ this._dst(),
3010
3427
  0,
3011
3428
  encoded.gsid,
3012
3429
  items[0].x,
@@ -3016,9 +3433,9 @@ class RenderingContext2d {
3016
3433
  trimShapeGlyphs(app);
3017
3434
  }
3018
3435
  if (rects.length) {
3019
- R.FillRectangles(R.PictOp.Over, this.picture.id, color, rects);
3436
+ R.FillRectangles(R.PictOp.Over, this._dst(), color, rects);
3020
3437
  }
3021
- if (clip) this._resetPictureClip();
3438
+ if (clip) this._invalidatePictureClip();
3022
3439
  this._markDirty();
3023
3440
  }
3024
3441
 
@@ -3053,8 +3470,7 @@ class RenderingContext2d {
3053
3470
  // be honoured by rounding — which would differ from the mask path's
3054
3471
  // antialiased edge by up to a pixel. Rare, so leave those to the mask
3055
3472
  // rather than quietly changing what they look like.
3056
- const integral = (v) => Math.abs(v - Math.round(v)) < 1e-6;
3057
- if (![x, y, w, h].every(integral)) return null;
3473
+ if (![x, y, w, h].every(isIntegral)) return null;
3058
3474
  return {
3059
3475
  x: Math.round(x),
3060
3476
  y: Math.round(y),
@@ -3063,14 +3479,21 @@ class RenderingContext2d {
3063
3479
  };
3064
3480
  }
3065
3481
 
3066
- /** Intersection of the whole clip stack, or null if any part is not a
3067
- * 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
+ */
3068
3491
  _clipRect() {
3069
- if (!this._clips.length) return null;
3492
+ if (this._hasPolyClip) return null;
3070
3493
  let out = null;
3071
3494
  for (const entry of this._clips) {
3072
3495
  const r = entry.rect;
3073
- if (!r) return null;
3496
+ if (!r) continue; // a region entry
3074
3497
  if (!out) {
3075
3498
  out = { ...r };
3076
3499
  continue;
@@ -3081,6 +3504,7 @@ class RenderingContext2d {
3081
3504
  const bottom = Math.min(out.y + out.h, r.y + r.h);
3082
3505
  out = { x, y, w: Math.max(0, right - x), h: Math.max(0, bottom - y) };
3083
3506
  }
3507
+ if (!out) return null;
3084
3508
  // clamp into the surface: the server rejects out-of-range rectangles
3085
3509
  const x = Math.max(0, Math.min(out.x, this.width));
3086
3510
  const y = Math.max(0, Math.min(out.y, this.height));
@@ -3089,11 +3513,31 @@ class RenderingContext2d {
3089
3513
  return { x, y, w, h };
3090
3514
  }
3091
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
+
3092
3536
  clip(...args) {
3093
3537
  const { polys, rule } = this._polysFor(args);
3094
3538
  const entry = { polys, rule, rect: RenderingContext2d._rectOfPolys(polys) };
3095
3539
  // copy-on-write: earlier save() snapshots keep their own clip list
3096
- this._clips = this._clips.concat([entry]);
3540
+ this._setClips(this._clips.concat([entry]));
3097
3541
 
3098
3542
  // A stack of rectangles stays virtual: consumers apply the intersected
3099
3543
  // rectangle server-side (SetPictureClipRectangles, bounded composites),
@@ -3110,6 +3554,36 @@ class RenderingContext2d {
3110
3554
  this._intersectClip(entry);
3111
3555
  }
3112
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
+
3113
3587
  /**
3114
3588
  * Build the a8 mask from the whole clip stack, on first demand. Rect
3115
3589
  * entries are plain fills; only genuinely non-rectangular entries
@@ -3125,7 +3599,18 @@ class RenderingContext2d {
3125
3599
  [0, 0, 0, 0],
3126
3600
  [0, 0, this.width, this.height],
3127
3601
  );
3128
- 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];
3129
3614
  if (first.rect) {
3130
3615
  const r = first.rect;
3131
3616
  const x = Math.max(0, Math.min(r.x, this.width));
@@ -3143,8 +3628,7 @@ class RenderingContext2d {
3143
3628
  } else {
3144
3629
  this._applyPolyClip(first, R.PictOp.Src);
3145
3630
  }
3146
- for (let i = 1; i < this._clips.length; i++)
3147
- this._intersectClip(this._clips[i]);
3631
+ for (let i = 1; i < shapes.length; i++) this._intersectClip(shapes[i]);
3148
3632
  }
3149
3633
 
3150
3634
  /** The live mask, built if the stack has not needed one yet. Callers on
@@ -3852,28 +4336,27 @@ class RenderingContext2d {
3852
4336
  * Uniform alpha needs no surface-sized mask either: a 1x1 repeating
3853
4337
  * picture is the same thing to the server, with no pixel work at all.
3854
4338
  */
3855
- _beginDirectComposite() {
3856
- const rect = this._clips.length ? this._clipRect() : null;
3857
- if (!rect)
3858
- 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
+ }
3859
4351
  if (rect.w === 0 || rect.h === 0)
3860
4352
  return { mask: 0, clipped: false, empty: true };
3861
- this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [
3862
- rect.x,
3863
- rect.y,
3864
- rect.w,
3865
- rect.h,
3866
- ]);
3867
- const mask =
3868
- this.globalAlpha >= 1
3869
- ? 0
3870
- : this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
3871
- return { mask, clipped: true, empty: false };
4353
+ this._setPictureClip(rect);
4354
+ return { mask: this._alphaMask(), clipped: true, empty: false };
3872
4355
  }
3873
4356
 
3874
4357
  _endDirectComposite(state) {
3875
4358
  if (!state.clipped) return;
3876
- this._resetPictureClip();
4359
+ this._invalidatePictureClip();
3877
4360
  }
3878
4361
 
3879
4362
  /** The source colour for a coverage composite, with `globalAlpha` folded
@@ -3923,12 +4406,12 @@ class RenderingContext2d {
3923
4406
  const mx = scaled ? 0 : sx;
3924
4407
  const my = scaled ? 0 : sy;
3925
4408
 
3926
- const rect = this._clips.length ? this._clipRect() : null;
4409
+ const rect = this._clipRect();
3927
4410
  // the mask slot can hold exactly one picture, so anything that cannot be
3928
4411
  // folded into the colour or handed to the server as a clip rectangle has
3929
4412
  // to be intersected into the scratch mask first
3930
4413
  const scratch =
3931
- (this._clips.length && !rect) ||
4414
+ this._hasPolyClip ||
3932
4415
  (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
3933
4416
 
3934
4417
  if (scratch) {
@@ -3969,7 +4452,7 @@ class RenderingContext2d {
3969
4452
  this.height,
3970
4453
  );
3971
4454
  }
3972
- if (this._clips.length && !rect) {
4455
+ if (this._hasPolyClip) {
3973
4456
  const clipMask = this._requireClipMask();
3974
4457
  R.Composite(
3975
4458
  R.PictOp.In,
@@ -3988,12 +4471,7 @@ class RenderingContext2d {
3988
4471
  } else if (rect) {
3989
4472
  // scratch was forced by alpha-over-gradient, not by the clip: the
3990
4473
  // rectangle still applies, server-side, around the final composite
3991
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
3992
- rect.x,
3993
- rect.y,
3994
- rect.w,
3995
- rect.h,
3996
- ]);
4474
+ this._setPictureClip(rect);
3997
4475
  }
3998
4476
  // The scratch mask is surface-sized and surface-aligned — the coverage
3999
4477
  // went into it at (dx, dy) — and so is a gradient source, so both are
@@ -4005,7 +4483,7 @@ class RenderingContext2d {
4005
4483
  op,
4006
4484
  this._backgroundPicture.id,
4007
4485
  this.fillMask.id,
4008
- this.picture.id,
4486
+ this._dst(),
4009
4487
  dx,
4010
4488
  dy,
4011
4489
  dx,
@@ -4015,23 +4493,9 @@ class RenderingContext2d {
4015
4493
  dw,
4016
4494
  dh,
4017
4495
  );
4018
- if (rect) {
4019
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
4020
- 0,
4021
- 0,
4022
- this.width,
4023
- this.height,
4024
- ]);
4025
- }
4496
+ if (rect) this._invalidatePictureClip();
4026
4497
  } else if (!rect || (rect.w > 0 && rect.h > 0)) {
4027
- if (rect) {
4028
- R.SetPictureClipRectangles(this.picture.id, 0, 0, [
4029
- rect.x,
4030
- rect.y,
4031
- rect.w,
4032
- rect.h,
4033
- ]);
4034
- }
4498
+ if (rect) this._setPictureClip(rect);
4035
4499
  // the source is a 1x1 repeating solid (offset irrelevant) or the fill
4036
4500
  // picture, which for a gradient is surface-aligned: same offset as the
4037
4501
  // destination, exactly as in _fillPolys
@@ -4039,7 +4503,7 @@ class RenderingContext2d {
4039
4503
  op,
4040
4504
  this._coverageSource().id,
4041
4505
  picture.id,
4042
- this.picture.id,
4506
+ this._dst(),
4043
4507
  dx,
4044
4508
  dy,
4045
4509
  mx,
@@ -4049,9 +4513,7 @@ class RenderingContext2d {
4049
4513
  dw,
4050
4514
  dh,
4051
4515
  );
4052
- if (rect) {
4053
- this._resetPictureClip();
4054
- }
4516
+ if (rect) this._invalidatePictureClip();
4055
4517
  }
4056
4518
 
4057
4519
  if (scaled) {
@@ -4097,7 +4559,7 @@ class RenderingContext2d {
4097
4559
  return;
4098
4560
  }
4099
4561
 
4100
- const state = this._beginDirectComposite();
4562
+ const state = this._beginDirectComposite({ x: dx, y: dy, w: dw, h: dh });
4101
4563
  if (state.empty) return;
4102
4564
  const mask = state.mask;
4103
4565
  const scaled = dw !== sw || dh !== sh;
@@ -4120,7 +4582,7 @@ class RenderingContext2d {
4120
4582
  op,
4121
4583
  picture.id,
4122
4584
  mask,
4123
- this.picture.id,
4585
+ this._dst(),
4124
4586
  0,
4125
4587
  0,
4126
4588
  dx,
@@ -4141,7 +4603,7 @@ class RenderingContext2d {
4141
4603
  op,
4142
4604
  picture.id,
4143
4605
  mask,
4144
- this.picture.id,
4606
+ this._dst(),
4145
4607
  sx,
4146
4608
  sy,
4147
4609
  dx,
@@ -4167,19 +4629,25 @@ class RenderingContext2d {
4167
4629
  if (image instanceof RenderingContext2d) {
4168
4630
  // TODO: if need to scale, set transform
4169
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;
4170
4638
  this.Render.Composite(
4171
4639
  this.Render.PictOp.Over,
4172
4640
  image.picture.id,
4173
- this._compositeMask(),
4174
- this.picture.id,
4175
- 0,
4176
- 0,
4177
- 0,
4178
- 0,
4179
- 0,
4180
- 0,
4181
- this.width,
4182
- 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,
4183
4651
  );
4184
4652
  this._markDirty();
4185
4653
  } else if (
@@ -4188,6 +4656,13 @@ class RenderingContext2d {
4188
4656
  typeof image.context.getImageData === "function"
4189
4657
  ) {
4190
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;
4191
4666
  const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
4192
4667
  // node-canvas hands over straight RGBA, and the rgba32 picture below is
4193
4668
  // premultiplied — this used to swap the channels and stop there, which
@@ -4227,16 +4702,16 @@ class RenderingContext2d {
4227
4702
  this.Render.Composite(
4228
4703
  this.Render.PictOp.Over,
4229
4704
  picture.id,
4230
- this._compositeMask(),
4231
- this.picture.id,
4232
- 0,
4233
- 0,
4234
- 0,
4235
- 0,
4236
- 0,
4237
- 0,
4238
- this.width,
4239
- 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,
4240
4715
  );
4241
4716
  this._markDirty();
4242
4717
  } finally {
@@ -4275,6 +4750,14 @@ class RenderingContext2d {
4275
4750
  );
4276
4751
  if (x1 <= x0 || y1 <= y0) return;
4277
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
+
4278
4761
  // device -> source sample (adding the source-rect origin)
4279
4762
  const srcM = [inv[0], inv[1], inv[2], inv[3], inv[4] + sx, inv[5] + sy];
4280
4763
  this.Render.SetPictureTransform(picture.id, [
@@ -4289,20 +4772,19 @@ class RenderingContext2d {
4289
4772
  1,
4290
4773
  ]);
4291
4774
  picture.setFilter("bilinear");
4292
- const mask = this._compositeMask();
4293
4775
  this.Render.Composite(
4294
4776
  op,
4295
4777
  picture.id,
4296
- mask,
4297
- this.picture.id,
4298
- x0,
4299
- y0,
4300
- x0,
4301
- y0,
4302
- x0,
4303
- y0,
4304
- x1 - x0,
4305
- 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,
4306
4788
  );
4307
4789
  this.Render.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
4308
4790
  picture.setFilter("nearest");