ntk 8.14.3 → 8.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cliprect.js CHANGED
@@ -101,9 +101,19 @@ export function clipTrianglesToRect(tris, x0, y0, x1, y1) {
101
101
  }
102
102
 
103
103
  /**
104
- * A flat `[x, y, w, h, …]` list of rectangles cut to `[0, width) × [0,
105
- * height)`, dropping the ones with nothing left: core X's rectangles are 16
106
- * bits. The list itself when every rectangle is already on the surface.
104
+ * A flat `[x, y, w, h, …]` list of rectangles on whole pixels and cut to
105
+ * `[0, width) × [0, height)`, dropping the ones with nothing left: core X's
106
+ * rectangles are 16 bits. The list itself when every rectangle is already
107
+ * whole and on the surface.
108
+ *
109
+ * Each edge is rounded on its own. The wire truncates a rectangle's x and
110
+ * its width separately, so two rectangles meeting at a fractional edge —
111
+ * the selection bands of two lines whose height is the face's, 15.13px —
112
+ * left a row of neither between them every seventh or eighth line, and one
113
+ * that crossed the surface's edge was cut a pixel differently from the
114
+ * same rectangle moved: a scroll's copy and a repaint disagreed. Rounded
115
+ * edges meet whoever they are shared with, and move with a whole-pixel
116
+ * shift.
107
117
  */
108
118
  export function rectsOnSurface(flat, width, height) {
109
119
  let out = null;
@@ -112,16 +122,21 @@ export function rectsOnSurface(flat, width, height) {
112
122
  const y = flat[i + 1];
113
123
  const w = flat[i + 2];
114
124
  const h = flat[i + 3];
125
+ const whole =
126
+ Number.isInteger(x) &&
127
+ Number.isInteger(y) &&
128
+ Number.isInteger(w) &&
129
+ Number.isInteger(h);
115
130
  const inside = x >= 0 && y >= 0 && x + w <= width && y + h <= height;
116
- if (inside) {
131
+ if (whole && inside) {
117
132
  if (out) out.push(x, y, w, h);
118
133
  continue;
119
134
  }
120
135
  if (!out) out = flat.slice(0, i);
121
- const x0 = Math.max(x, 0);
122
- const y0 = Math.max(y, 0);
123
- const x1 = Math.min(x + w, width);
124
- const y1 = Math.min(y + h, height);
136
+ const x0 = Math.max(Math.round(x), 0);
137
+ const y0 = Math.max(Math.round(y), 0);
138
+ const x1 = Math.min(Math.round(x + w), width);
139
+ const y1 = Math.min(Math.round(y + h), height);
125
140
  if (x1 > x0 && y1 > y0) out.push(x0, y0, x1 - x0, y1 - y0);
126
141
  }
127
142
  return out ?? flat;
package/lib/path.js CHANGED
@@ -491,13 +491,19 @@ function normalizeRadii(w, h, radii) {
491
491
  br = take(radii[2]);
492
492
  bl = take(radii[3]);
493
493
  }
494
- // scale down so opposite corners never overlap
494
+ // Scale down so opposite corners never overlap: by the side's length over
495
+ // the radii along it, which is 0 for a side of no length, and the radii go
496
+ // with it. Only radii of nothing leave a side alone — `|| 1` guarded that
497
+ // 0 / 0 and took a real 0 for one as well, so a box of no height kept its
498
+ // corners, and a clip to it was two lobes of arc a radius above and below
499
+ // it that let what it should have hidden through.
500
+ const fit = (side, radii) => (radii > 0 ? side / radii : 1);
495
501
  const s = Math.min(
496
502
  1,
497
- w / (tl.x + tr.x) || 1,
498
- h / (tr.y + br.y) || 1,
499
- w / (br.x + bl.x) || 1,
500
- h / (bl.y + tl.y) || 1
503
+ fit(w, tl.x + tr.x),
504
+ fit(h, tr.y + br.y),
505
+ fit(w, br.x + bl.x),
506
+ fit(h, bl.y + tl.y)
501
507
  );
502
508
  const f = (r) => ({ x: Math.max(0, r.x * s), y: Math.max(0, r.y * s) });
503
509
  return [f(tl), f(tr), f(br), f(bl)];
@@ -803,6 +809,62 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
803
809
  addCubic(pts, mx, my, bcx, bcy, cx, cy, x3, y3, tol2, depth + 1);
804
810
  }
805
811
 
812
+ /**
813
+ * Which way a cubic leaves (x0, y0), or arrives at (x, y) with `end`: the
814
+ * control point next to that end, or the next one along where it sits on
815
+ * the end itself. Null for a cubic that is a single point.
816
+ */
817
+ function cubicTangent(x0, y0, x1, y1, x2, y2, x, y, end) {
818
+ if (end) {
819
+ if (x !== x2 || y !== y2) return [x - x2, y - y2];
820
+ if (x !== x1 || y !== y1) return [x - x1, y - y1];
821
+ return x !== x0 || y !== y0 ? [x - x0, y - y0] : null;
822
+ }
823
+ if (x1 !== x0 || y1 !== y0) return [x1 - x0, y1 - y0];
824
+ if (x2 !== x0 || y2 !== y0) return [x2 - x0, y2 - y0];
825
+ return x !== x0 || y !== y0 ? [x - x0, y - y0] : null;
826
+ }
827
+
828
+ /**
829
+ * The tangent at a curve command's end or start, in the flattened
830
+ * polyline's space; (x0, y0) is where the command begins, already there.
831
+ */
832
+ function commandTangent(c, x0, y0, m, end) {
833
+ const p = (x, y) => (m ? matApply(m, x, y) : [x, y]);
834
+ const [x, y] = p(c.x, c.y);
835
+ if (c.type === 'Q') {
836
+ const [qx, qy] = p(c.x1, c.y1);
837
+ return cubicTangent(
838
+ x0, y0,
839
+ x0 + (2 / 3) * (qx - x0), y0 + (2 / 3) * (qy - y0),
840
+ x + (2 / 3) * (qx - x), y + (2 / 3) * (qy - y),
841
+ x, y,
842
+ end
843
+ );
844
+ }
845
+ const [x1, y1] = p(c.x1, c.y1);
846
+ const [x2, y2] = p(c.x2, c.y2);
847
+ return cubicTangent(x0, y0, x1, y1, x2, y2, x, y, end);
848
+ }
849
+
850
+ /**
851
+ * The tangents a flattened subpath leaves its first point and arrives at its
852
+ * last with, where that end is a curve — null where it is a line, whose
853
+ * chord already says which way the path heads. Worked out on demand from the
854
+ * commands `flattenPath` kept: only a stroke whose ends meet asks.
855
+ */
856
+ export function subpathHead(poly) {
857
+ return poly.head
858
+ ? commandTangent(poly.head, poly.pts[0], poly.pts[1], poly.m, false)
859
+ : null;
860
+ }
861
+
862
+ export function subpathTail(poly) {
863
+ return poly.tail
864
+ ? commandTangent(poly.tail, poly.tailX, poly.tailY, poly.m, true)
865
+ : null;
866
+ }
867
+
806
868
  /**
807
869
  * Chords of the arc a tagged cubic stands for, appended to `pts`. Returns
808
870
  * false — flatten the cubic instead — when the path's current point is not
@@ -858,6 +920,13 @@ function addArcChords(pts, cmd, m, tol, x0, y0) {
858
920
  * geometry (see addArcChords); genuine beziers — hand-built paths, SVG curve
859
921
  * commands, font outlines — take the adaptive bisection route.
860
922
  *
923
+ * A subpath that begins or ends on a curve also keeps the command it began
924
+ * or ended with, for `subpathHead` and `subpathTail`: flattening turns a
925
+ * curve's end into a chord, which points half a chord's angle away from the
926
+ * curve, and the stroker asks for the curve's own tangent where that
927
+ * difference shows (a ring's seam). Kept rather than worked out, because a
928
+ * fill never asks.
929
+ *
861
930
  * @returns {Array<{pts: number[], closed: boolean}>} flat [x0,y0,x1,y1,…]
862
931
  */
863
932
  export function flattenPath(cmds, m = null, tol = FLATTEN_TOLERANCE) {
@@ -872,19 +941,24 @@ export function flattenPath(cmds, m = null, tol = FLATTEN_TOLERANCE) {
872
941
  case 'M': {
873
942
  if (pts && pts.pts.length >= 4) polys.push(pts);
874
943
  const [x, y] = p(c.x, c.y);
875
- pts = { pts: [x, y], closed: false };
944
+ pts = { pts: [x, y], closed: false, head: null, tail: null, tailX: 0, tailY: 0, m };
876
945
  break;
877
946
  }
878
947
  case 'L': {
879
948
  if (!pts) break;
880
949
  const [x, y] = p(c.x, c.y);
881
950
  pts.pts.push(x, y);
951
+ pts.tail = null;
882
952
  break;
883
953
  }
884
954
  case 'C': {
885
955
  if (!pts) break;
886
956
  const x0 = pts.pts[pts.pts.length - 2];
887
957
  const y0 = pts.pts[pts.pts.length - 1];
958
+ if (pts.pts.length === 2) pts.head = c;
959
+ pts.tail = c;
960
+ pts.tailX = x0;
961
+ pts.tailY = y0;
888
962
  if (c.arc && addArcChords(pts.pts, c, m, tol, x0, y0)) break;
889
963
  const [x1, y1] = p(c.x1, c.y1);
890
964
  const [x2, y2] = p(c.x2, c.y2);
@@ -896,6 +970,10 @@ export function flattenPath(cmds, m = null, tol = FLATTEN_TOLERANCE) {
896
970
  if (!pts) break;
897
971
  const x0 = pts.pts[pts.pts.length - 2];
898
972
  const y0 = pts.pts[pts.pts.length - 1];
973
+ if (pts.pts.length === 2) pts.head = c;
974
+ pts.tail = c;
975
+ pts.tailX = x0;
976
+ pts.tailY = y0;
899
977
  const [qx, qy] = p(c.x1, c.y1);
900
978
  const [x, y] = p(c.x, c.y);
901
979
  // degree elevation: quadratic -> cubic
@@ -916,7 +994,7 @@ export function flattenPath(cmds, m = null, tol = FLATTEN_TOLERANCE) {
916
994
  polys.push(pts);
917
995
  }
918
996
  // a new subpath continues from the subpath start
919
- pts = { pts: [pts.pts[0], pts.pts[1]], closed: false };
997
+ pts = { pts: [pts.pts[0], pts.pts[1]], closed: false, head: null, tail: null, tailX: 0, tailY: 0, m };
920
998
  }
921
999
  break;
922
1000
  }
@@ -22,6 +22,8 @@ import {
22
22
  matInvert,
23
23
  matIsIdentity,
24
24
  matMultiply,
25
+ subpathHead,
26
+ subpathTail,
25
27
  } from "./path.js";
26
28
  import Picture from "./picture.js";
27
29
  import { formatForDepth } from "./pictformat.js";
@@ -524,16 +526,23 @@ class RenderingContext2d {
524
526
  );
525
527
  }
526
528
  if (typeof window.on === "function") {
527
- window.on("_backing", () => this._bindTarget());
529
+ // Kept, so `destroy` can take them off again: a surface drawn through
530
+ // `render()` gets a context a call, and each one left three listeners
531
+ // on the pixmap — holding the dead context — for as long as the
532
+ // surface lived. A fade redraws its group surface every frame.
533
+ this._onBacking = () => this._bindTarget();
528
534
  // masks are sized to the drawable — recreate them after a resize
529
- window.on("resize", () => this._dropMasks());
535
+ this._onResize = () => this._dropMasks();
530
536
  // window-backed pictures are freed server-side with the window
531
- window.on("_destroyed", () => {
537
+ this._onDestroyed = () => {
532
538
  if (this._picture && this._target === this.window) {
533
539
  this._picture.forget();
534
540
  this._picture = null;
535
541
  }
536
- });
542
+ };
543
+ window.on("_backing", this._onBacking);
544
+ window.on("resize", this._onResize);
545
+ window.on("_destroyed", this._onDestroyed);
537
546
  }
538
547
 
539
548
  this.fillMask = null;
@@ -876,6 +885,13 @@ class RenderingContext2d {
876
885
  if (this._destroyed) return;
877
886
  this._destroyed = true;
878
887
  gcRegistry.unregister(this);
888
+ const drawable = this.window;
889
+ if (this._onBacking && typeof drawable?.removeListener === "function") {
890
+ drawable.removeListener("_backing", this._onBacking);
891
+ drawable.removeListener("resize", this._onResize);
892
+ drawable.removeListener("_destroyed", this._onDestroyed);
893
+ this._onBacking = this._onResize = this._onDestroyed = null;
894
+ }
879
895
 
880
896
  this._setClips([]);
881
897
  this._dropMasks();
@@ -2401,6 +2417,32 @@ class RenderingContext2d {
2401
2417
  addDisk(pts[pts.length - 1][0], pts[pts.length - 1][1]);
2402
2418
  }
2403
2419
  };
2420
+ // An open subpath that ends where it began, heading the way it began —
2421
+ // a full circle from arc(), which leaves its subpath open. Stroked
2422
+ // open, each end got a cap square to its own chord, and a flattened
2423
+ // curve's first and last chords point a chord's angle apart: a wedge
2424
+ // of ink went missing outside the seam, a hairline across every
2425
+ // stroked ring, and doubled inside it. Where the two ends meet head to
2426
+ // tail the caps and a join are the same ink, so it is stroked as the
2427
+ // loop it is. A path that comes back to its start at a corner keeps
2428
+ // its caps, as the spec gives them.
2429
+ const smoothLoop = (poly, pts) => {
2430
+ const n = pts.length;
2431
+ if (n < 4) return false;
2432
+ const [fx, fy] = pts[0];
2433
+ const [lx, ly] = pts[n - 1];
2434
+ if (Math.abs(fx - lx) > 1e-6 || Math.abs(fy - ly) > 1e-6) return false;
2435
+ const [hx, hy] = (poly.head && subpathHead(poly)) ?? [
2436
+ pts[1][0] - fx,
2437
+ pts[1][1] - fy,
2438
+ ];
2439
+ const [tx, ty] = (poly.tail && subpathTail(poly)) ?? [
2440
+ lx - pts[n - 2][0],
2441
+ ly - pts[n - 2][1],
2442
+ ];
2443
+ const lengths = Math.hypot(hx, hy) * Math.hypot(tx, ty);
2444
+ return lengths > 0 && (hx * tx + hy * ty) / lengths > 1 - 1e-9;
2445
+ };
2404
2446
  // dash boundaries can duplicate run endpoints; collapse them
2405
2447
  const cleanRun = (run) => {
2406
2448
  const out = [run[0]];
@@ -2433,12 +2475,13 @@ class RenderingContext2d {
2433
2475
  pts.push([fx, fy]);
2434
2476
  }
2435
2477
  if (pts.length < 2) continue;
2478
+ const closed = poly.closed || smoothLoop(poly, pts);
2436
2479
 
2437
2480
  if (!dash) {
2438
- extrudeRun(pts, poly.closed);
2481
+ extrudeRun(pts, closed);
2439
2482
  continue;
2440
2483
  }
2441
- const dashed = dashPolyline(pts, poly.closed, dash, dashOffset, view);
2484
+ const dashed = dashPolyline(pts, closed, dash, dashOffset, view);
2442
2485
  if (!dashed) {
2443
2486
  extrudeRun(pts, poly.closed);
2444
2487
  } else if (dashed.closedLoop) {
@@ -3056,6 +3099,25 @@ class RenderingContext2d {
3056
3099
  * for the whole list and then draws the rectangles through here */
3057
3100
  _fillRect(x, y, w, h) {
3058
3101
  if (matIsIdentity(this._m)) {
3102
+ // On whole pixels, each edge rounded on its own (see rectsOnSurface):
3103
+ // the wire truncates x and width separately, so rectangles that met
3104
+ // at a fractional edge left a row of neither between them. Whole, a
3105
+ // rectangle under a rectangular clip also keeps the bounded
3106
+ // composite below instead of building a mask.
3107
+ if (
3108
+ !Number.isInteger(x) ||
3109
+ !Number.isInteger(y) ||
3110
+ !Number.isInteger(w) ||
3111
+ !Number.isInteger(h)
3112
+ ) {
3113
+ const x0 = Math.round(x);
3114
+ const y0 = Math.round(y);
3115
+ w = Math.round(x + w) - x0;
3116
+ h = Math.round(y + h) - y0;
3117
+ x = x0;
3118
+ y = y0;
3119
+ if (!(w > 0 && h > 0)) return;
3120
+ }
3059
3121
  // X coordinates and sizes are 16 bits, and a coordinate past 32,767
3060
3122
  // throws out of the paint (lib/cliprect.js): a rectangle that reaches
3061
3123
  // past the surface is filled as the part of it the surface has.
@@ -3224,13 +3286,14 @@ class RenderingContext2d {
3224
3286
  let x1 = -Infinity;
3225
3287
  let y1 = -Infinity;
3226
3288
  for (let i = 0; i < flat.length; i += 4) {
3227
- // the part of it on the surface: the wire's coordinates are 16 bits
3228
- const left = Math.trunc(flat[i]);
3229
- const top = Math.trunc(flat[i + 1]);
3289
+ // on whole pixels, each edge rounded as `_fillRect` rounds it, and the
3290
+ // part of it on the surface: the wire's coordinates are 16 bits
3291
+ const left = Math.round(flat[i]);
3292
+ const top = Math.round(flat[i + 1]);
3230
3293
  const x = Math.max(0, left);
3231
3294
  const y = Math.max(0, top);
3232
- const w = Math.min(this.width, left + Math.trunc(flat[i + 2])) - x;
3233
- const h = Math.min(this.height, top + Math.trunc(flat[i + 3])) - y;
3295
+ const w = Math.min(this.width, Math.round(flat[i] + flat[i + 2])) - x;
3296
+ const h = Math.min(this.height, Math.round(flat[i + 1] + flat[i + 3])) - y;
3234
3297
  if (w <= 0 || h <= 0) continue;
3235
3298
  rects.push(x, y, w, h);
3236
3299
  if (x < x0) x0 = x;
@@ -42,14 +42,20 @@ const RPC_TIMEOUT = 2000;
42
42
  * The token naming one shared font page: content-addressed, because the
43
43
  * generator's input is a font file far too big to name literally.
44
44
  *
45
- * ntkg1:<sha1 of font file bytes>:<variation coords>:<px size>:<format>
45
+ * ntkg2:<sha1 of font file bytes>:<face>:<variation coords>:<px size>:<format>
46
46
  *
47
- * The `ntkg1` version prefix is load-bearing, not hygiene: advances are
47
+ * The `ntkg2` version prefix is load-bearing, not hygiene: advances are
48
48
  * baked into glyphs at AddGlyphs time and idempotent duplicate uploads
49
49
  * assume byte-identical rasterization, both of which hold only among
50
50
  * processes running the same rasterizer and rounding. Change either — bump
51
51
  * the version, and two ntk versions on one display simply share nothing.
52
52
  *
53
+ * `<face>` is the face's PostScript name, because a file is not a face. A
54
+ * collection — Helvetica.ttc, a .dfont — holds several, which number their
55
+ * glyphs alike, and `ntkg1` named a page by the file alone: Helvetica and
56
+ * Helvetica-Bold were one page, and whichever face uploaded a glyph first
57
+ * was what both drew, in every ntk app on the display.
58
+ *
53
59
  * The sha1 is computed from the loaded font buffer, lazily on first shared
54
60
  * use, and cached on the base face (a variation instance hashes the same
55
61
  * file its base was cut from). `null` — no buffer to hash, or no crypto in
@@ -68,7 +74,8 @@ export function fontPageToken(font, size) {
68
74
  base._ntkSha1 = crypto ? crypto.createHash('sha1').update(buffer).digest('hex') : null;
69
75
  }
70
76
  if (!base._ntkSha1) return null;
71
- return `ntkg1:${base._ntkSha1}:${variationKey(font.variationCoords)}:${size}:a8`;
77
+ const face = base.fk?.postscriptName ?? '';
78
+ return `ntkg2:${base._ntkSha1}:${face}:${variationKey(font.variationCoords)}:${size}:a8`;
72
79
  }
73
80
 
74
81
  // ---------------------------------------------------------------------------
package/lib/text/font.js CHANGED
@@ -111,6 +111,28 @@ function answerWithCharacters(fk) {
111
111
  Object.defineProperty(fk, '__ntkCharacters', { value: true });
112
112
  }
113
113
 
114
+ /**
115
+ * A face's cap height or x-height, as its OS/2 table states it, or else the
116
+ * top of its `H` or `x`. A table older than version 2 has neither field, and
117
+ * a face with no OS/2 table at all — Apple's Courier.ttc — has no table to
118
+ * state them: fontkit answered 0 for one and nothing for the other, and an
119
+ * `ex` in either came out half an em (CSS Values 4, 6.1.1 lets the height
120
+ * of a lowercase glyph stand in, as browsers measure it). NaN where the face
121
+ * has no such glyph either, which is the "half an em" case the spec keeps.
122
+ */
123
+ function statedOrGlyph(fk, field, codePoint) {
124
+ const os2 = fk['OS/2'];
125
+ const stated = os2 && os2.version >= 2 ? os2[field] : undefined;
126
+ if (stated > 0) return stated;
127
+ try {
128
+ if (!fk.hasGlyphForCodePoint(codePoint)) return NaN;
129
+ const top = fk.glyphForCodePoint(codePoint).bbox.maxY;
130
+ return top > 0 ? top : NaN;
131
+ } catch {
132
+ return NaN;
133
+ }
134
+ }
135
+
114
136
  export default class Font {
115
137
  constructor(fkFont, path, postscriptName) {
116
138
  answerWithCharacters(fkFont);
@@ -286,8 +308,8 @@ export default class Font {
286
308
  ascent: this.fk.ascent,
287
309
  descent: -this.fk.descent,
288
310
  lineGap: this.fk.lineGap,
289
- capHeight: this.fk.capHeight,
290
- xHeight: this.fk.xHeight
311
+ capHeight: statedOrGlyph(this.fk, 'capHeight', 0x48),
312
+ xHeight: statedOrGlyph(this.fk, 'xHeight', 0x78)
291
313
  });
292
314
  const s = this.scale(size);
293
315
  const ascent = v.ascent * s;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.14.3",
3
+ "version": "8.14.5",
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",