ntk 8.14.4 → 8.14.6

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;
package/lib/text/font.js CHANGED
@@ -3,6 +3,7 @@ import * as fontkit from 'fontkit';
3
3
  import { flatten, rasterizePath } from '../rasterize.js';
4
4
  import { NO_ANCHOR, tolerateAnchors, watchAnchors } from './anchors.js';
5
5
  import { marksCover, standInMarks } from './marks.js';
6
+ import { pairGlyphs, pairsCover } from './pairs.js';
6
7
 
7
8
  // Axis coordinates are rounded to this many decimals before anything is
8
9
  // instantiated or cached. Every distinct coordinate is a font in its own
@@ -111,6 +112,28 @@ function answerWithCharacters(fk) {
111
112
  Object.defineProperty(fk, '__ntkCharacters', { value: true });
112
113
  }
113
114
 
115
+ /**
116
+ * A face's cap height or x-height, as its OS/2 table states it, or else the
117
+ * top of its `H` or `x`. A table older than version 2 has neither field, and
118
+ * a face with no OS/2 table at all — Apple's Courier.ttc — has no table to
119
+ * state them: fontkit answered 0 for one and nothing for the other, and an
120
+ * `ex` in either came out half an em (CSS Values 4, 6.1.1 lets the height
121
+ * of a lowercase glyph stand in, as browsers measure it). NaN where the face
122
+ * has no such glyph either, which is the "half an em" case the spec keeps.
123
+ */
124
+ function statedOrGlyph(fk, field, codePoint) {
125
+ const os2 = fk['OS/2'];
126
+ const stated = os2 && os2.version >= 2 ? os2[field] : undefined;
127
+ if (stated > 0) return stated;
128
+ try {
129
+ if (!fk.hasGlyphForCodePoint(codePoint)) return NaN;
130
+ const top = fk.glyphForCodePoint(codePoint).bbox.maxY;
131
+ return top > 0 ? top : NaN;
132
+ } catch {
133
+ return NaN;
134
+ }
135
+ }
136
+
114
137
  export default class Font {
115
138
  constructor(fkFont, path, postscriptName) {
116
139
  answerWithCharacters(fkFont);
@@ -132,6 +155,8 @@ export default class Font {
132
155
  // whether the face's mark attachment is watched for a NULL anchor
133
156
  // (./anchors.js): set at its first shaping, false once one has been met
134
157
  this._anchors = false;
158
+ // the glyphs its pairs can move (./pairs.js), read when first asked
159
+ this._pairs = undefined;
135
160
  }
136
161
 
137
162
  static loadSync(path, postscriptName) {
@@ -286,8 +311,8 @@ export default class Font {
286
311
  ascent: this.fk.ascent,
287
312
  descent: -this.fk.descent,
288
313
  lineGap: this.fk.lineGap,
289
- capHeight: this.fk.capHeight,
290
- xHeight: this.fk.xHeight
314
+ capHeight: statedOrGlyph(this.fk, 'capHeight', 0x48),
315
+ xHeight: statedOrGlyph(this.fk, 'xHeight', 0x78)
291
316
  });
292
317
  const s = this.scale(size);
293
318
  const ascent = v.ascent * s;
@@ -334,6 +359,17 @@ export default class Font {
334
359
  return this._drawable;
335
360
  }
336
361
 
362
+ /**
363
+ * Whether two glyphs side by side can be set otherwise than apart: the
364
+ * left one moved against what follows it, or the right one against what
365
+ * comes before it (./pairs.js). True where the tables cannot tell.
366
+ */
367
+ mayKern(left, right) {
368
+ if (this._pairs === undefined) this._pairs = pairGlyphs(this.fk);
369
+ const pairs = this._pairs;
370
+ return pairs === null || pairsCover(pairs.left, left) || pairsCover(pairs.right, right);
371
+ }
372
+
337
373
  hasGlyph(codepoint) {
338
374
  return this.drawable && this.fk.hasGlyphForCodePoint(codepoint);
339
375
  }
@@ -235,6 +235,8 @@ export default class FontManager {
235
235
  this._shapeCount = 0;
236
236
  // whether two letters shape otherwise together than apart (`_meets`)
237
237
  this._meetings = new Map();
238
+ // how much two letters kern across a break opportunity (`_kernAcross`)
239
+ this._kerns = new Map();
238
240
  // a paragraph's layout up to the line fill, for its next width
239
241
  // (text/paragraphs.js, TextLayout)
240
242
  this._paragraphs = new ParagraphCache();
@@ -580,6 +582,28 @@ export default class FontManager {
580
582
  return meets;
581
583
  }
582
584
 
585
+ /**
586
+ * How much wider the two letters of `pair`, cut at `cut`, are set
587
+ * together than apart in this style: their kerning, negative for a pair
588
+ * set closer. 0 where they do not kern, and where shaping them together
589
+ * changes a glyph — a ligature, a contextual form — which no offset
590
+ * between the two stands for. Kept, as `_meets` keeps its answers.
591
+ */
592
+ _kernAcross(pair, cut, style, levelsKey = '0') {
593
+ const key = `${shapeGroupOf(style, levelsKey)}|${cut}|${pair}`;
594
+ let kern = this._kerns.get(key);
595
+ if (kern !== undefined) return kern;
596
+ const whole = this._shapeCached(pair, style, levelsKey);
597
+ const left = this._shapeCached(pair.slice(0, cut), style, sliceKey(levelsKey, 0, cut));
598
+ const right = this._shapeCached(pair.slice(cut), style, sliceKey(levelsKey, cut, pair.length));
599
+ const ids = (...shaped) => shaped.flatMap((s) => s.runs.flatMap((r) => r.glyphs.map((g) => g.id))).join(',');
600
+ kern = whole.width - left.width - right.width;
601
+ if (Math.abs(kern) <= 1e-6 || ids(whole) !== ids(left, right)) kern = 0;
602
+ if (this._kerns.size >= SHAPE_GENERATION) this._kerns.clear();
603
+ this._kerns.set(key, kern);
604
+ return kern;
605
+ }
606
+
583
607
  _shapeCached(text, style, levelsKey = '0') {
584
608
  // A hit in this generation is the whole cost of a word a paragraph has
585
609
  // shaped before — a relayout at a new width asks for every word again,
@@ -21,10 +21,16 @@ const UNSPACED =
21
21
  /** How wide a line's tokens are, the white space it ends on left out. */
22
22
  function tokensWidth(toks) {
23
23
  let width = 0;
24
- for (const token of toks) width += token.width;
24
+ for (let i = 0; i < toks.length; i++) width += toks[i].width + (i ? kernBefore(toks[i]) : 0);
25
25
  return toks.length ? width - toks[toks.length - 1].wsWidth : 0;
26
26
  }
27
27
 
28
+ /** The kerning between a token and the one before it on its line
29
+ * (`TextLayout._kernBefore`); none for a token a line starts with. */
30
+ function kernBefore(token) {
31
+ return token.kernBefore ?? 0;
32
+ }
33
+
28
34
  function isWsGlyph(g) {
29
35
  return g.codePoints.length > 0 && g.codePoints.every((cp) => WS.has(cp));
30
36
  }
@@ -121,7 +127,7 @@ export class TextLayout {
121
127
  curWidth = 0;
122
128
  };
123
129
  for (let token of tokens) {
124
- while (wraps && curWidth + token.width - token.wsWidth > maxWidth) {
130
+ while (wraps && curWidth + (cur.length ? kernBefore(token) : 0) + token.width - token.wsWidth > maxWidth) {
125
131
  if (cur.length > 0) {
126
132
  flush();
127
133
  } else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
@@ -135,8 +141,8 @@ export class TextLayout {
135
141
  token = rest;
136
142
  }
137
143
  }
144
+ curWidth += token.width + (cur.length ? kernBefore(token) : 0);
138
145
  cur.push(token);
139
- curWidth += token.width;
140
146
  if (token.required) flush();
141
147
  }
142
148
  if (cur.length) lineTokens.push(cur);
@@ -185,16 +191,24 @@ export class TextLayout {
185
191
  // .start/.end are absolute code-unit ranges into the full text, kept
186
192
  // for caret positioning (caretPosition / indexAt)
187
193
  let entries = [];
188
- for (const token of toks) {
194
+ for (let t = 0; t < toks.length; t++) {
195
+ const token = toks[t];
196
+ // the kerning with the token before it goes before its first run
197
+ let kern = t ? kernBefore(token) : 0;
189
198
  for (const frag of token.fragments) {
190
199
  for (const run of frag.shaped.runs) {
191
- entries.push({
200
+ const entry = {
192
201
  run,
193
202
  span: frag.span,
194
203
  level: run.level,
195
204
  start: frag.start + run.start,
196
205
  end: frag.start + run.end
197
- });
206
+ };
207
+ if (kern) {
208
+ entry.kern = kern;
209
+ kern = 0;
210
+ }
211
+ entries.push(entry);
198
212
  }
199
213
  }
200
214
  }
@@ -234,6 +248,7 @@ export class TextLayout {
234
248
  const runs = [];
235
249
  let x = 0;
236
250
  for (const e of entries) {
251
+ if (e.kern) x += e.kern;
237
252
  const positioned = { x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end };
238
253
  if (e.ellipsis) positioned.ellipsis = true;
239
254
  runs.push(positioned);
@@ -396,10 +411,48 @@ export class TextLayout {
396
411
  tokens.push(this._makeToken(text, prev, text.length, levels, spanAt, false));
397
412
  }
398
413
  }
414
+ for (let i = 1; i < tokens.length; i++) {
415
+ const kern = this._kernBefore(text, tokens[i - 1], tokens[i], levels);
416
+ if (kern) tokens[i].kernBefore = kern;
417
+ }
399
418
 
400
419
  return { spans, text, baseLevel, tokens };
401
420
  }
402
421
 
422
+ /**
423
+ * The kerning between the last letter of one token and the first of the
424
+ * next, where the two are on one line. Each token is shaped apart, so a
425
+ * pair either side of a break opportunity was never kerned — Trebuchet MS
426
+ * sets a space closer to an A, a T or a Y — and a line of it came out
427
+ * wider than a browser's, which shapes a line whole: shaping breaks only
428
+ * at an inline box's margin, border or padding (CSS Text 3, 7.3). Only
429
+ * between letters shaped alike at one left-to-right level; a line that
430
+ * breaks there never meets it.
431
+ */
432
+ _kernBefore(text, prev, next, levels) {
433
+ if (prev.required) return 0;
434
+ const a = prev.fragments[prev.fragments.length - 1];
435
+ const b = next.fragments[0];
436
+ if (!a || !b || a.shaping !== b.shaping || a.span.shapeApart || b.span.shapeApart) return 0;
437
+ const at = a.start + a.text.length;
438
+ if (at !== b.start) return 0;
439
+ // two glyphs the face's tables cannot move against each other are set
440
+ // together as apart, and asked about no further: most faces kern no
441
+ // space, and a paragraph meets one at every word
442
+ const l = a.shaped.runs[a.shaped.runs.length - 1];
443
+ const r = b.shaped.runs[0];
444
+ if (!l || !r || l.font !== r.font) return 0;
445
+ const lg = l.glyphs[l.glyphs.length - 1];
446
+ const rg = r.glyphs[0];
447
+ if (!lg || !rg || (l.font.mayKern && !l.font.mayKern(lg.id, rg.id))) return 0;
448
+ const low = text.charCodeAt(at - 1);
449
+ const before = low >= 0xdc00 && low < 0xe000 && at >= 2 ? 2 : 1;
450
+ const after = text.codePointAt(at) > 0xffff ? 2 : 1;
451
+ const key = normalizedLevels(levels, at - before, at + after);
452
+ if (key.includes(',') || Number(key) & 1) return 0;
453
+ return this.fonts._kernAcross(text.slice(at - before, at + after), before, a.shaping, key);
454
+ }
455
+
403
456
  /**
404
457
  * Whether the letters that meet at an edge between two spans of a group
405
458
  * shape otherwise together than apart: their glyphs or their advances
@@ -537,9 +590,10 @@ export class TextLayout {
537
590
  for (const token of toks) {
538
591
  // trailing whitespace does not count against the budget, exactly as it
539
592
  // does not count during the greedy fill
540
- if (used + token.width - token.wsWidth <= budget) {
593
+ const kern = kept.length ? kernBefore(token) : 0;
594
+ if (used + kern + token.width - token.wsWidth <= budget) {
541
595
  kept.push(token);
542
- used += token.width;
596
+ used += kern + token.width;
543
597
  continue;
544
598
  }
545
599
  const [head] = this._forceBreak(token, budget - used);
package/lib/text/marks.js CHANGED
@@ -102,7 +102,7 @@ export function marksCover(bits, id) {
102
102
  * base)` reads the 16-bit offset stored at `pos` and answers where it
103
103
  * points, `base` being what the format measures it from.
104
104
  */
105
- function table(fk, tag) {
105
+ export function table(fk, tag) {
106
106
  const entry = fk.directory?.tables?.[tag];
107
107
  if (!entry) return null;
108
108
  const stream = fk._getTableStream(tag);
@@ -163,7 +163,7 @@ function coverLookup(t, lookupList, index, bits) {
163
163
  * Where the coverage a subtable is entered from starts, or -1 for a
164
164
  * subtable this does not know.
165
165
  */
166
- function firstCoverage(t, type, sub) {
166
+ export function firstCoverage(t, type, sub) {
167
167
  const format = t.u16(sub);
168
168
  switch (type) {
169
169
  case 1: // single adjustment
@@ -191,7 +191,7 @@ function firstCoverage(t, type, sub) {
191
191
  }
192
192
  }
193
193
 
194
- function addCoverage(t, coverage, bits) {
194
+ export function addCoverage(t, coverage, bits) {
195
195
  const format = t.u16(coverage);
196
196
  const count = t.u16(coverage + 2);
197
197
  if (format === 1) {
@@ -0,0 +1,88 @@
1
+ // The glyphs a face can set closer or further from the glyph beside them,
2
+ // read from its tables' bytes the way ./marks.js reads its marks: a pair of
3
+ // letters that neither can be is set together as it is set apart, and the
4
+ // layout need not shape the two to find that out (`TextLayout._kernBefore`).
5
+ //
6
+ // A pair's left glyph can be moved by a pair adjustment it enters, by a
7
+ // cursive attachment, by a context it is the first input of, or by the
8
+ // pairs of a legacy `kern` table; its right glyph only by a context it is
9
+ // the first input of, with the left glyph behind it. Every GPOS lookup is
10
+ // read, whatever feature names it — a superset of what fontkit applies,
11
+ // which only widens what counts.
12
+ //
13
+ // Null — ask the shaper — for a face shaped through AAT's morx, a `kern`
14
+ // table in a format this does not read, or tables it cannot read to the end.
15
+
16
+ import { addCoverage, firstCoverage, table } from './marks.js';
17
+
18
+ /**
19
+ * @param {object} fk a fontkit font
20
+ * @returns {{ left: Uint8Array, right: Uint8Array }|null} a bit per glyph id,
21
+ * set for every glyph that can be moved against the glyph after it
22
+ * (`left`) or before it (`right`); null where this cannot tell
23
+ */
24
+ export function pairGlyphs(fk) {
25
+ try {
26
+ if (fk.directory?.tables?.morx) return null;
27
+ const left = new Uint8Array(8192);
28
+ const right = new Uint8Array(8192);
29
+ const gpos = table(fk, 'GPOS');
30
+ if (gpos) {
31
+ const lookupList = gpos.offset(8, 0);
32
+ for (let index = 0, n = gpos.u16(lookupList); index < n; index++) {
33
+ if (!coverPairs(gpos, lookupList, index, left, right)) return null;
34
+ }
35
+ }
36
+ const kern = table(fk, 'kern');
37
+ if (kern && !kernLefts(kern, left)) return null;
38
+ return { left, right };
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Whether `bits` (from `pairGlyphs`) has glyph `id`. */
45
+ export function pairsCover(bits, id) {
46
+ return (bits[id >> 3] & (1 << (id & 7))) !== 0;
47
+ }
48
+
49
+ /** Adds what a lookup can move against a neighbour; false for one it cannot read. */
50
+ function coverPairs(t, lookupList, index, left, right) {
51
+ const lookup = t.offset(lookupList + 2 + index * 2, lookupList);
52
+ const type = t.u16(lookup);
53
+ for (let i = 0, n = t.u16(lookup + 4); i < n; i++) {
54
+ let sub = t.offset(lookup + 6 + i * 2, lookup);
55
+ let subType = type;
56
+ if (subType === 9) {
57
+ if (t.u16(sub) !== 1) return false;
58
+ subType = t.u16(sub + 2);
59
+ sub += t.u32(sub + 4);
60
+ }
61
+ // a single adjustment moves a glyph whatever is beside it, and a mark
62
+ // attaches with no advance of its own
63
+ if (subType !== 2 && subType !== 3 && subType !== 7 && subType !== 8) continue;
64
+ const coverage = firstCoverage(t, subType, sub);
65
+ if (coverage < 0) return false;
66
+ addCoverage(t, coverage, left);
67
+ if (subType === 7 || subType === 8) addCoverage(t, coverage, right);
68
+ }
69
+ return true;
70
+ }
71
+
72
+ /** Adds the left glyphs of a Microsoft `kern` table's pairs; false for a
73
+ * table or subtable format this does not read. */
74
+ function kernLefts(t, left) {
75
+ if (t.u16(0) !== 0) return false;
76
+ let at = 4;
77
+ for (let i = 0, n = t.u16(2); i < n; i++) {
78
+ const length = t.u16(at + 2);
79
+ const format = t.u16(at + 4) >> 8;
80
+ if (format !== 0) return false;
81
+ for (let j = 0, pairs = t.u16(at + 6); j < pairs; j++) {
82
+ const id = t.u16(at + 14 + j * 6);
83
+ left[id >> 3] |= 1 << (id & 7);
84
+ }
85
+ at += length;
86
+ }
87
+ return true;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.14.4",
3
+ "version": "8.14.6",
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",