ntk 8.12.0 → 8.12.2

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.
@@ -94,6 +94,51 @@ function intersectBox(a, b) {
94
94
  return { x, y, w: right - x, h: bottom - y };
95
95
  }
96
96
 
97
+ /**
98
+ * Whether no two of a flat `[x, y, w, h, …]` list of whole-pixel rectangles
99
+ * share a pixel — touching edges do not. A sweep along x holding the
100
+ * rectangles it is inside as disjoint y spans in order, so a list that is
101
+ * disjoint, which is the list this is asked about, costs a binary search
102
+ * per rectangle: a chart's per-column spans, an occupancy grid's cells.
103
+ */
104
+ function disjointRects(rects) {
105
+ const n = rects.length / 4;
106
+ if (n < 2) return true;
107
+ // events: where a rectangle starts and where it ends, ends first at a tie
108
+ const events = [];
109
+ for (let i = 0; i < n; i++) {
110
+ events.push([rects[i * 4], 1, i], [rects[i * 4] + rects[i * 4 + 2], 0, i]);
111
+ }
112
+ events.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
113
+ // the y spans the sweep is inside, sorted by their top, as rectangle ids
114
+ const active = [];
115
+ const top = (i) => rects[i * 4 + 1];
116
+ const bottom = (i) => rects[i * 4 + 1] + rects[i * 4 + 3];
117
+ const find = (y) => {
118
+ let lo = 0;
119
+ let hi = active.length;
120
+ while (lo < hi) {
121
+ const mid = (lo + hi) >> 1;
122
+ if (top(active[mid]) < y) lo = mid + 1;
123
+ else hi = mid;
124
+ }
125
+ return lo;
126
+ };
127
+ for (const [, starts, i] of events) {
128
+ if (!starts) {
129
+ let at = find(top(i));
130
+ while (active[at] !== i) at++;
131
+ active.splice(at, 1);
132
+ continue;
133
+ }
134
+ const at = find(top(i));
135
+ if (at > 0 && bottom(active[at - 1]) > top(i)) return false;
136
+ if (at < active.length && top(active[at]) < bottom(i)) return false;
137
+ active.splice(at, 0, i);
138
+ }
139
+ return true;
140
+ }
141
+
97
142
  /** The device-space ink bounds of a flattened path, unclamped. */
98
143
  function polysInk(polys) {
99
144
  let minX = Infinity;
@@ -1854,42 +1899,47 @@ class RenderingContext2d {
1854
1899
  * well as the AddTraps: two requests become one, and the one that is left
1855
1900
  * touches only the bounding box.
1856
1901
  */
1857
- _uploadCoverage(job, b, edges) {
1902
+ _uploadCoverage(job, b, out, edges) {
1858
1903
  const rasterizer = this.window.app.rasterizer;
1859
1904
  if (!rasterizer) return false;
1860
1905
  if (routeRaster(b.w, b.h, edges, this.window.app.rasterPolicy) !== "local")
1861
1906
  return false;
1862
1907
 
1908
+ // Rasterized over the whole box, as always, so a drawing that clips
1909
+ // split across passes — a pan's strips, a full repaint — gets the same
1910
+ // coverage in every one of them, byte for byte. Only `out`, the part the
1911
+ // clip lets through, goes on the wire (issue #372).
1863
1912
  const coverage = rasterizer.rasterize({ ...job, width: b.w, height: b.h });
1864
1913
  if (!coverage) return false;
1865
1914
 
1866
1915
  // X wants scanlines padded to 4 bytes; the rasterizer contract is
1867
- // unpadded rows. When the width is already a multiple of 4 — which every
1868
- // power-of-two icon box is — the coverage goes out as a view over the
1869
- // rasterizer's own bytes, with no copy at all.
1870
- const stride = (b.w + 3) & ~3;
1916
+ // unpadded rows. When the part sent is the whole box and its width is
1917
+ // already a multiple of 4 — which every power-of-two icon box is — the
1918
+ // coverage goes out as a view over the rasterizer's own bytes, with no
1919
+ // copy at all.
1920
+ const stride = (out.w + 3) & ~3;
1871
1921
  let data;
1872
- if (stride === b.w) {
1922
+ if (out.w === b.w && out.h === b.h && stride === b.w) {
1873
1923
  data = Buffer.isBuffer(coverage)
1874
1924
  ? coverage
1875
1925
  : Buffer.from(coverage.buffer, coverage.byteOffset, coverage.length);
1876
1926
  } else {
1877
- data = Buffer.alloc(stride * b.h);
1878
- for (let y = 0; y < b.h; ++y) {
1879
- Buffer.from(coverage.buffer, coverage.byteOffset + y * b.w, b.w).copy(
1880
- data,
1881
- y * stride,
1882
- );
1927
+ data = Buffer.alloc(stride * out.h);
1928
+ const left = out.x - b.x;
1929
+ const top = out.y - b.y;
1930
+ for (let y = 0; y < out.h; ++y) {
1931
+ const from = coverage.byteOffset + (top + y) * b.w + left;
1932
+ Buffer.from(coverage.buffer, from, out.w).copy(data, y * stride);
1883
1933
  }
1884
1934
  }
1885
1935
  this.X.PutImage(
1886
1936
  2,
1887
1937
  this.fillMaskDrawable.id,
1888
1938
  this._maskGC(),
1889
- b.w,
1890
- b.h,
1891
- b.x,
1892
- b.y,
1939
+ out.w,
1940
+ out.h,
1941
+ out.x,
1942
+ out.y,
1893
1943
  0,
1894
1944
  8,
1895
1945
  data,
@@ -1982,30 +2032,29 @@ class RenderingContext2d {
1982
2032
  */
1983
2033
  _paintThroughMask(b, job, { edges, src, op, alpha, server }) {
1984
2034
  const R = this.Render;
1985
- // A rectangular clip narrows where the coverage is *composited*; the
1986
- // mask content outside it is stale by the same argument as outside `b`,
1987
- // and a box the clip rejects outright needs no coverage at all. A stack
1988
- // whose mask was dropped by restore() may still hold a poly, and the mask
1989
- // comes back on demand. A region clip needs nothing here: the picture
1990
- // carries it, and it applies to the composite below like any other.
2035
+ // Everything is bounded to the part of the box the clip lets through:
2036
+ // the clip's rectangle, or the extents of a stack with a path in it —
2037
+ // the coverage rasterized and uploaded, the clip applied, the composite.
2038
+ // A stroke across a rounded pane used to upload its whole box through
2039
+ // the mask to paint a few rows of it (issue #372). The mask content
2040
+ // outside `out` is stale by the same argument as outside `b`, and a box
2041
+ // the clip rejects outright needs no coverage at all. A stack whose mask
2042
+ // was dropped by restore() may still hold a poly, and the mask comes
2043
+ // back on demand. A region clip needs nothing here: the picture carries
2044
+ // it, and it applies to the composite below like any other.
2045
+ const clip = this._hasPolyClip ? this._clipExtents() : this._clipRect();
1991
2046
  let out = b;
1992
- if (!this.clipMask) {
1993
- if (this._hasPolyClip) {
1994
- this._requireClipMask();
1995
- } else {
1996
- const cr = this._clipRect();
1997
- if (cr) {
1998
- out = intersectBox(b, cr);
1999
- if (!out) return false;
2000
- }
2001
- }
2047
+ if (clip) {
2048
+ out = intersectBox(b, clip);
2049
+ if (!out) return false;
2002
2050
  }
2003
- if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, edges)) {
2051
+ if (this._hasPolyClip) this._requireClipMask();
2052
+ if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, out, edges)) {
2004
2053
  R.FillRectangles(
2005
2054
  R.PictOp.Src,
2006
2055
  this.fillMask.id,
2007
2056
  [0, 0, 0, 0],
2008
- [b.x, b.y, b.w, b.h],
2057
+ [out.x, out.y, out.w, out.h],
2009
2058
  );
2010
2059
  server();
2011
2060
  }
@@ -2667,7 +2716,14 @@ class RenderingContext2d {
2667
2716
  if (rect.w === 0 || rect.h === 0) return;
2668
2717
  this._setPictureClip(rect);
2669
2718
  }
2670
- drawGlyphRuns(app, op, src.id, this._dst(), positioned);
2719
+ drawGlyphRuns(
2720
+ app,
2721
+ op,
2722
+ src.id,
2723
+ this._dst(),
2724
+ positioned,
2725
+ rect ?? { x: 0, y: 0, w: this.width, h: this.height },
2726
+ );
2671
2727
  if (rect) this._invalidatePictureClip();
2672
2728
  this._markDirty();
2673
2729
  return;
@@ -2688,6 +2744,7 @@ class RenderingContext2d {
2688
2744
  this._glyphSource.id,
2689
2745
  this.fillMask.id,
2690
2746
  positioned,
2747
+ { x: 0, y: 0, w: this.width, h: this.height },
2691
2748
  );
2692
2749
  R.Composite(
2693
2750
  R.PictOp.In,
@@ -3009,9 +3066,12 @@ class RenderingContext2d {
3009
3066
  * A solid-colour fillStyle under an identity transform and a rectangular
3010
3067
  * (or absent) clip compiles the whole list into a single
3011
3068
  * `Render.FillRectangles`, where N `fillRect` calls cost N composites.
3012
- * Gradient/Picture styles, transforms and non-rectangular clips fall
3013
- * back to that `fillRect` loop, so the answer is always right and only
3014
- * the request count varies.
3069
+ * Under a clip that is not a rectangle, rectangles that do not overlap
3070
+ * still cost three requests however many there are
3071
+ * (`_fillRectsThroughClip`). Gradient/Picture styles, transforms, and
3072
+ * overlapping rectangles or `copy` under a non-rectangular clip fall back
3073
+ * to that `fillRect` loop, so the answer is always right and only the
3074
+ * request count varies.
3015
3075
  */
3016
3076
  fillRects(rects) {
3017
3077
  if (!rects || !rects.length || this.globalAlpha <= 0) return;
@@ -3045,12 +3105,13 @@ class RenderingContext2d {
3045
3105
  if (
3046
3106
  !matIsIdentity(this._m) ||
3047
3107
  !isPlainColor(this._fillStyle) ||
3048
- this._hasPolyClip
3108
+ (this._hasPolyClip && !this._fillRectsThroughClip(flat))
3049
3109
  ) {
3050
3110
  for (let i = 0; i < flat.length; i += 4)
3051
3111
  this._fillRect(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]);
3052
3112
  return;
3053
3113
  }
3114
+ if (this._hasPolyClip) return;
3054
3115
  if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
3055
3116
  const R = this.Render;
3056
3117
  if (clip) this._setPictureClip(clip);
@@ -3073,6 +3134,102 @@ class RenderingContext2d {
3073
3134
  this._markDirty();
3074
3135
  }
3075
3136
 
3137
+ /**
3138
+ * `fillRects` under a clip that is not a rectangle — a rounded card, a
3139
+ * path: every rectangle's coverage into the scratch a8 mask with one
3140
+ * `FillRectangles`, `globalAlpha` and the clip applied to it once each,
3141
+ * and the style composited through it once. One rectangle at a time is a
3142
+ * composite through the clip each, and a chart's marks inside a rounded
3143
+ * box a zoom had put off the pixel grid were sixty thousand of them a
3144
+ * frame (issue #374).
3145
+ *
3146
+ * Only where that is the loop's picture. The rectangles must not overlap:
3147
+ * the loop blends a pixel once per rectangle over it, at the clip's
3148
+ * coverage each time, so where two overlap on the clip's antialiased edge
3149
+ * it is darker than one blend through the mask — and where they do not,
3150
+ * every pixel is blended once either way, whatever the colour's alpha.
3151
+ * And the operator must leave the destination alone where the mask is
3152
+ * zero: `copy` clears every pixel of its box the mask misses, the gaps
3153
+ * between the rectangles here. The rectangles reach the mask as the wire
3154
+ * carries every box, in whole pixels truncated toward zero, which is what
3155
+ * `_fillRect`'s composites are cut to as well.
3156
+ *
3157
+ * @returns {boolean} whether the rectangles were drawn here
3158
+ */
3159
+ _fillRectsThroughClip(flat) {
3160
+ const R = this.Render;
3161
+ const op = this._op();
3162
+ if (!this._maskBounded(op)) return false;
3163
+ const rects = [];
3164
+ let x0 = Infinity;
3165
+ let y0 = Infinity;
3166
+ let x1 = -Infinity;
3167
+ let y1 = -Infinity;
3168
+ for (let i = 0; i < flat.length; i += 4) {
3169
+ const x = Math.trunc(flat[i]);
3170
+ const y = Math.trunc(flat[i + 1]);
3171
+ const w = Math.trunc(flat[i + 2]);
3172
+ const h = Math.trunc(flat[i + 3]);
3173
+ if (w <= 0 || h <= 0) continue;
3174
+ rects.push(x, y, w, h);
3175
+ if (x < x0) x0 = x;
3176
+ if (y < y0) y0 = y;
3177
+ if (x + w > x1) x1 = x + w;
3178
+ if (y + h > y1) y1 = y + h;
3179
+ }
3180
+ if (!disjointRects(rects)) return false;
3181
+ if (!prepareStyle(this._backgroundPicture, this._m)) return true;
3182
+ // the box they cover, on the surface: nothing outside it is sampled
3183
+ const bx = Math.max(0, x0);
3184
+ const by = Math.max(0, y0);
3185
+ const bw = Math.min(this.width, x1) - bx;
3186
+ const bh = Math.min(this.height, y1) - by;
3187
+ if (!(bw > 0 && bh > 0)) return true;
3188
+ this._ensureFillMask();
3189
+ const mask = this.fillMask.id;
3190
+ R.FillRectangles(R.PictOp.Src, mask, [0, 0, 0, 0], [bx, by, bw, bh]);
3191
+ // stay under the server's maximum request size
3192
+ const chunk = 10000 * 4;
3193
+ for (let i = 0; i < rects.length; i += chunk) {
3194
+ R.FillRectangles(
3195
+ R.PictOp.Src,
3196
+ mask,
3197
+ [0, 0, 0, this.globalAlpha < 1 ? this.globalAlpha : 1],
3198
+ i === 0 && rects.length <= chunk ? rects : rects.slice(i, i + chunk),
3199
+ );
3200
+ }
3201
+ R.Composite(
3202
+ R.PictOp.In,
3203
+ this._requireClipMask().id,
3204
+ 0,
3205
+ mask,
3206
+ bx,
3207
+ by,
3208
+ 0,
3209
+ 0,
3210
+ bx,
3211
+ by,
3212
+ bw,
3213
+ bh,
3214
+ );
3215
+ R.Composite(
3216
+ op,
3217
+ this._backgroundPicture.id,
3218
+ mask,
3219
+ this._dst(),
3220
+ bx,
3221
+ by,
3222
+ bx,
3223
+ by,
3224
+ bx,
3225
+ by,
3226
+ bw,
3227
+ bh,
3228
+ );
3229
+ this._markDirty();
3230
+ return true;
3231
+ }
3232
+
3076
3233
  strokeRect(x, y, w, h) {
3077
3234
  if (this._shadowed()) this._shadowOfRect(x, y, w, h, true);
3078
3235
  const m = this._m;
@@ -3531,6 +3688,40 @@ class RenderingContext2d {
3531
3688
  return { x, y, w, h };
3532
3689
  }
3533
3690
 
3691
+ /**
3692
+ * The box the clip stack lets anything through, in device pixels on the
3693
+ * surface: the intersection of its rectangles and of its paths' bounding
3694
+ * boxes. Null when the stack holds no shape — region entries are the
3695
+ * picture's, as in `_clipRect` — and an empty box when it holds a path
3696
+ * that lands nowhere. What a drawing through the a8 mask is bounded to
3697
+ * (issue #372). Cached against the stack, which is replaced on every
3698
+ * change rather than edited.
3699
+ */
3700
+ _clipExtents() {
3701
+ if (this._extentsOf === this._clips) return this._extents;
3702
+ let out = null;
3703
+ for (const entry of this._clips) {
3704
+ if (entry.region) continue;
3705
+ const box = entry.rect ?? (entry.bbox ??= this._polysBBox(entry.polys));
3706
+ const next = box && (out ? intersectBox(out, box) : box);
3707
+ if (!next) {
3708
+ out = { x: 0, y: 0, w: 0, h: 0 };
3709
+ break;
3710
+ }
3711
+ out = next;
3712
+ }
3713
+ if (out) {
3714
+ const x = Math.max(0, Math.min(out.x, this.width));
3715
+ const y = Math.max(0, Math.min(out.y, this.height));
3716
+ const w = Math.max(0, Math.min(out.x + out.w, this.width) - x);
3717
+ const h = Math.max(0, Math.min(out.y + out.h, this.height) - y);
3718
+ out = { x, y, w, h };
3719
+ }
3720
+ this._extentsOf = this._clips;
3721
+ this._extents = out;
3722
+ return out;
3723
+ }
3724
+
3534
3725
  /**
3535
3726
  * Replace the clip stack and re-derive what kinds of entry are in it.
3536
3727
  *
@@ -571,8 +571,11 @@ export function routeGlyphSize(app, font, size, policy = policyOf(app), textRend
571
571
  * @param {number} srcId source picture (fill color/gradient)
572
572
  * @param {number} dstId destination picture
573
573
  * @param {Array<{run, x, y}>} positioned visual-order runs with baseline origins
574
+ * @param {{x: number, y: number, w: number, h: number}} [bounds] the part of
575
+ * the destination that can show anything — the clip, or the surface. A
576
+ * glyph wholly outside it is not sent (`visibleGlyph`).
574
577
  */
575
- export function drawGlyphRuns(app, op, srcId, dstId, positioned) {
578
+ export function drawGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
576
579
  const policy = policyOf(app);
577
580
  let bitmap = positioned;
578
581
  let vector = null;
@@ -590,11 +593,35 @@ export function drawGlyphRuns(app, op, srcId, dstId, positioned) {
590
593
  bitmap.push(positioned[i]);
591
594
  }
592
595
  }
593
- if (bitmap.length) drawBitmapGlyphRuns(app, op, srcId, dstId, bitmap, policy);
594
- if (vector) drawVectorGlyphRuns(app, op, srcId, dstId, vector);
596
+ if (bitmap.length) drawBitmapGlyphRuns(app, op, srcId, dstId, bitmap, policy, bounds);
597
+ if (vector) drawVectorGlyphRuns(app, op, srcId, dstId, vector, bounds);
595
598
  }
596
599
 
597
- function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy) {
600
+ /**
601
+ * Whether a glyph whose origin is at (x, y) can put any ink inside `bounds`,
602
+ * give or take two ems — no glyph reaches further from its origin than that,
603
+ * swashes and all.
604
+ *
605
+ * Not only a saving: the glyphs of a line wider than 32767 device pixels —
606
+ * a minified file in a code editor, a long label in a narrow cell — have
607
+ * origins no request can carry. CompositeGlyphs places its first glyph and
608
+ * every element after it in 16-bit coordinates, and the vector path's trap
609
+ * mask would span the whole line, so drawing such a line threw a RangeError
610
+ * out of the paint however little of it was on screen. What is left after
611
+ * the cull sits inside a surface, and every coordinate in it fits.
612
+ */
613
+ function visibleGlyph(bounds, x, y, size) {
614
+ if (bounds === null) return true;
615
+ const reach = size * 2;
616
+ return (
617
+ x + reach > bounds.x &&
618
+ x - reach < bounds.x + bounds.w &&
619
+ y + reach > bounds.y &&
620
+ y - reach < bounds.y + bounds.h
621
+ );
622
+ }
623
+
624
+ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy, bounds) {
598
625
  const Render = app.display.Render;
599
626
  const items = [];
600
627
  let bits = 8;
@@ -608,6 +635,7 @@ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy) {
608
635
  if (page.bits > bits) bits = page.bits;
609
636
  }
610
637
  for (const pos of positionGlyphs(positioned)) {
638
+ if (!visibleGlyph(bounds, pos.x, pos.y, pos.run.size)) continue;
611
639
  const page = pages.get(pos.run);
612
640
  const e = page.entry(pos.glyph.id);
613
641
  items.push({ gs: e.gs, lid: e.lid, adv: e.adv, x: pos.x, y: pos.y });
@@ -646,7 +674,7 @@ const MAX_TRAPS_PER_REQUEST = 6000;
646
674
  * Positions are intentionally NOT rounded to whole pixels — fractional
647
675
  * advances and origins keep zoom animations smooth.
648
676
  */
649
- function drawVectorGlyphRuns(app, op, srcId, dstId, positioned) {
677
+ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
650
678
  // unrounded glyph origins + ink bounding box
651
679
  const placed = [];
652
680
  let minX = Infinity;
@@ -657,6 +685,7 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned) {
657
685
  const gx = cursor + g.dx;
658
686
  const gy = y - g.dy;
659
687
  cursor += g.ax;
688
+ if (!visibleGlyph(bounds, gx, gy, run.size)) continue;
660
689
  const e = run.font.glyphExtents(g.id, run.size);
661
690
  if (!Number.isFinite(e.minX) || e.maxX <= e.minX) continue; // blank glyph
662
691
  placed.push({ run, glyph: g, x: gx, y: gy });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.12.0",
3
+ "version": "8.12.2",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",