ntk 8.23.1 → 8.23.3

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.
@@ -805,6 +805,10 @@ class RenderingContext2d {
805
805
  this.fillMaskDrawable = null;
806
806
  this.clipMask = null;
807
807
  this.clipMaskDrawable = null;
808
+ // a clip mask a restore let go of, for the next path clip (`_rebuildClipMask`)
809
+ this._keptClipMask = null;
810
+ // where the clip mask may hold coverage, [x, y, w, h]; null for nowhere
811
+ this._clipMaskInk = null;
808
812
  this._textStyle = null;
809
813
  this._lastFontString = null;
810
814
  this.textAlign = "start";
@@ -1224,6 +1228,8 @@ class RenderingContext2d {
1224
1228
  this.clipMaskDrawable.destroy();
1225
1229
  this.clipMask = this.clipMaskDrawable = null;
1226
1230
  }
1231
+ // one kept is the size the surface was
1232
+ this._dropKeptClipMask();
1227
1233
  // clip paths are device-space and survive the resize; rebuild the mask
1228
1234
  if (this._clips && this._clips.length) this._rebuildClipMask();
1229
1235
  }
@@ -2361,6 +2367,16 @@ class RenderingContext2d {
2361
2367
 
2362
2368
  _ensureClipMask() {
2363
2369
  if (this.clipMask) return;
2370
+ const kept = this._keptClipMask;
2371
+ if (kept) {
2372
+ // the one a restore let go of: what it holds is cleared where it was
2373
+ // written, by `_materializeClipMask`
2374
+ this._keptClipMask = null;
2375
+ this.clipMask = kept.mask;
2376
+ this.clipMaskDrawable = kept.drawable;
2377
+ this._clipMaskInk = kept.ink;
2378
+ return;
2379
+ }
2364
2380
  this.clipMaskDrawable = new Pixmap(this.window.app, {
2365
2381
  depth: 8,
2366
2382
  width: this.width,
@@ -2370,6 +2386,8 @@ class RenderingContext2d {
2370
2386
  drawable: this.clipMaskDrawable,
2371
2387
  format: this.Render.a8,
2372
2388
  });
2389
+ // a fresh pixmap's contents are undefined, all of them
2390
+ this._clipMaskInk = [0, 0, this.width, this.height];
2373
2391
  }
2374
2392
 
2375
2393
  /**
@@ -4900,12 +4918,16 @@ class RenderingContext2d {
4900
4918
  _materializeClipMask() {
4901
4919
  const R = this.Render;
4902
4920
  this._ensureClipMask();
4903
- R.FillRectangles(
4904
- R.PictOp.Src,
4905
- this.clipMask.id,
4906
- [0, 0, 0, 0],
4907
- [0, 0, this.width, this.height],
4908
- );
4921
+ // Cleared where it may hold coverage, not all over: the mask is the size
4922
+ // of the surface, and a clip a rounded row of a list puts on a window
4923
+ // cleared the whole window's worth of it for each row of each frame.
4924
+ // What it holds is never outside its first shape's box, since what is
4925
+ // intersected with it only takes coverage away (`_clipMaskInk`).
4926
+ const ink = this._clipMaskInk;
4927
+ if (ink && ink[2] > 0 && ink[3] > 0) {
4928
+ R.FillRectangles(R.PictOp.Src, this.clipMask.id, [0, 0, 0, 0], ink);
4929
+ }
4930
+ this._clipMaskInk = null;
4909
4931
  // region entries are the picture's clip, not the mask's business
4910
4932
  const shapes = this._clips.filter((entry) => !entry.region);
4911
4933
  if (!shapes.length) {
@@ -4915,6 +4937,7 @@ class RenderingContext2d {
4915
4937
  [0, 0, 0, 1],
4916
4938
  [0, 0, this.width, this.height],
4917
4939
  );
4940
+ this._clipMaskInk = [0, 0, this.width, this.height];
4918
4941
  return;
4919
4942
  }
4920
4943
  const first = shapes[0];
@@ -4931,9 +4954,20 @@ class RenderingContext2d {
4931
4954
  [0, 0, 0, 1],
4932
4955
  [x, y, right - x, bottom - y],
4933
4956
  );
4957
+ this._clipMaskInk = [x, y, right - x, bottom - y];
4934
4958
  }
4935
4959
  } else {
4936
4960
  this._applyPolyClip(first, R.PictOp.Src);
4961
+ const bb = this._polysBBox(first.polys);
4962
+ if (bb && bb.w > 0 && bb.h > 0) {
4963
+ const x = Math.max(0, Math.floor(bb.x));
4964
+ const y = Math.max(0, Math.floor(bb.y));
4965
+ const right = Math.min(this.width, Math.ceil(bb.x + bb.w));
4966
+ const bottom = Math.min(this.height, Math.ceil(bb.y + bb.h));
4967
+ if (right > x && bottom > y) {
4968
+ this._clipMaskInk = [x, y, right - x, bottom - y];
4969
+ }
4970
+ }
4937
4971
  }
4938
4972
  for (let i = 1; i < shapes.length; i++) this._intersectClip(shapes[i]);
4939
4973
  }
@@ -5084,9 +5118,18 @@ class RenderingContext2d {
5084
5118
  if (!rasterizer) return false;
5085
5119
  const { shapes, edges } = fillableShapes(entry.polys);
5086
5120
  if (!shapes.length) return false;
5087
- // routed by the clip's own extent, as a fill is (`_rawBox`), and
5088
- // rasterized over the part of it on the surface
5089
- if (this._routeFor(this._polysExtent(entry.polys) ?? bb, edges) !== "local")
5121
+ // A clip's coverage is rasterized here up to the policy's byte bound,
5122
+ // whatever its edges, where a fill is routed by its cost (`_routeFor`).
5123
+ // The server route is AddTraps into the pixmap the coverage lands on,
5124
+ // and AddTraps adds into a picture that exists: glamor — Xwayland's,
5125
+ // and Xorg's on most GPUs — has no GPU path for it, and reads the
5126
+ // pixmap back, rasterizes in software and uploads it again, a GPU sync
5127
+ // for every clip. A rounded row clipped that way took 5.6 ms on a virgl
5128
+ // Xwayland, where its raster here and a PutImage took 0.14; pixman,
5129
+ // which adds the traps itself, takes 0.03 ms less for it.
5130
+ const extent = this._polysExtent(entry.polys) ?? bb;
5131
+ const area = Math.min(extent.w, this.width) * Math.min(extent.h, this.height);
5132
+ if (!(area > 0 && area <= this.window.app.rasterPolicy.maxBytes) || !edges)
5090
5133
  return false;
5091
5134
 
5092
5135
  const coverage = rasterizer.rasterize({
@@ -5148,17 +5191,33 @@ class RenderingContext2d {
5148
5191
  }
5149
5192
 
5150
5193
  _rebuildClipMask() {
5151
- // The stack changed shape (restore, resize): drop the mask rather than
5194
+ // The stack changed shape (restore, resize): let the mask go rather than
5152
5195
  // rebuild it eagerly. A stack that went back to rectangles never needs
5153
5196
  // one again, and one that still holds a poly rebuilds on first demand
5154
5197
  // through _requireClipMask — same work, paid only if something draws.
5198
+ // It is kept, not destroyed, for the next path clip to fill again: a
5199
+ // pixmap the size of the surface was made and freed for every rounded
5200
+ // clip, sixteen a frame for a list of eight rounded rows, where the fill
5201
+ // mask beside it is kept for the context's life.
5155
5202
  if (this.clipMask) {
5156
- this.clipMask.destroy();
5157
- this.clipMaskDrawable.destroy();
5203
+ this._dropKeptClipMask();
5204
+ this._keptClipMask = {
5205
+ mask: this.clipMask,
5206
+ drawable: this.clipMaskDrawable,
5207
+ ink: this._clipMaskInk,
5208
+ };
5158
5209
  this.clipMask = this.clipMaskDrawable = null;
5159
5210
  }
5160
5211
  }
5161
5212
 
5213
+ _dropKeptClipMask() {
5214
+ const kept = this._keptClipMask;
5215
+ if (!kept) return;
5216
+ this._keptClipMask = null;
5217
+ kept.mask.destroy();
5218
+ kept.drawable.destroy();
5219
+ }
5220
+
5162
5221
  isPointInPath(...args) {
5163
5222
  let path = null;
5164
5223
  let rest = args;
@@ -2,7 +2,7 @@ import LineBreaker from 'linebreak';
2
2
 
3
3
  import { rasterizePolys } from '../rasterize.js';
4
4
  import { drawGlyphRuns } from './glyphs.js';
5
- import { embeddingLevels, normalizedLevels, reorderRuns } from './shape.js';
5
+ import { embeddingLevels, levelZeroThroughout, normalizedLevels, reorderRuns } from './shape.js';
6
6
  import { worthKeeping } from './paragraphs.js';
7
7
 
8
8
  // The white space a line may end on and hang past its edge: spaces and tabs,
@@ -307,57 +307,29 @@ export class TextLayout {
307
307
  const kept = worthKeeping(content) ? fonts._paragraphs : undefined;
308
308
  const key = kept ? kept.keyOf(content) : null;
309
309
  let paragraph = kept?.find(key, options.direction, content, style);
310
- if (!paragraph) {
311
- paragraph = this._prepare(content, style, options.direction);
312
- kept?.keep(key, options.direction, content, style, paragraph);
310
+ const round = itemRounding(options);
311
+ // the lines of a paragraph asked for its first few, filled from the start
312
+ // of it alone (`_firstLines`) — the start a layout before this one kept,
313
+ // where it holds them — or null
314
+ let first = null;
315
+ if (!paragraph || paragraph.partial) {
316
+ first = this._firstLines(content, style, options, maxWidth, round, paragraph);
317
+ if (first) {
318
+ if (first.paragraph !== paragraph) kept?.keep(key, options.direction, content, style, first.paragraph);
319
+ paragraph = first.paragraph;
320
+ } else {
321
+ paragraph = this._prepare(content, style, options.direction);
322
+ kept?.keep(key, options.direction, content, style, paragraph);
323
+ }
313
324
  }
314
325
  const { spans, text, tokens } = paragraph;
315
- this._text = text;
326
+ // the whole text, which a caret is placed in, however much was prepared
327
+ this._text = first ? first.text : text;
316
328
  this._cpOffsets = null; // lazy code-point index -> code-unit offset table
317
329
  this.baseLevel = paragraph.baseLevel;
318
330
 
319
- // ---- greedy line fill ----
320
- const lineTokens = [];
321
- // a word wider than the line stays whole, past the line's end, where
322
- // the options say so; cut inside itself where they do not — and in a
323
- // script written without spaces, where the breaker's "word" is a phrase
324
- // it had no dictionary to cut, cut anyway (CSS Text 3, 5.1)
325
- const whole = options.overflowWrap === 'normal';
326
- // text that does not wrap is a line to each forced break, however wide
327
331
  const wraps = options.wrap !== false;
328
- const round = itemRounding(options);
329
- {
330
- let cur = [];
331
- const fit = new LineFit(round);
332
- const flush = () => {
333
- lineTokens.push(cur);
334
- cur = [];
335
- fit.reset();
336
- };
337
- for (let token of tokens) {
338
- while (
339
- wraps &&
340
- fit.with(token, cur.length ? kernBefore(token) : 0) > maxWidth + FIT_SLACK
341
- ) {
342
- if (cur.length > 0) {
343
- flush();
344
- } else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
345
- break;
346
- } else {
347
- // single token wider than the container: force-break it
348
- const [head, rest] = this._forceBreak(token, maxWidth);
349
- if (!head) break; // not even one cluster fits: let it overflow
350
- cur.push(head);
351
- flush();
352
- token = rest;
353
- }
354
- }
355
- fit.add(token, cur.length ? kernBefore(token) : 0);
356
- cur.push(token);
357
- if (token.required) flush();
358
- }
359
- if (cur.length) lineTokens.push(cur);
360
- }
332
+ const lineTokens = first ? first.lines : this._fill(tokens, text, maxWidth, options, round);
361
333
 
362
334
  // ---- cap the line count ----
363
335
  // The cut happens here, between filling and assembly, so the dropped
@@ -575,6 +547,112 @@ export class TextLayout {
575
547
  }
576
548
  }
577
549
 
550
+ /**
551
+ * The greedy line fill: `tokens` into lines no wider than `maxWidth`, a
552
+ * line ended early at each forced break.
553
+ */
554
+ _fill(tokens, text, maxWidth, options, round) {
555
+ const lineTokens = [];
556
+ // a word wider than the line stays whole, past the line's end, where
557
+ // the options say so; cut inside itself where they do not — and in a
558
+ // script written without spaces, where the breaker's "word" is a phrase
559
+ // it had no dictionary to cut, cut anyway (CSS Text 3, 5.1)
560
+ const whole = options.overflowWrap === 'normal';
561
+ // text that does not wrap is a line to each forced break, however wide
562
+ const wraps = options.wrap !== false;
563
+ {
564
+ let cur = [];
565
+ const fit = new LineFit(round);
566
+ const flush = () => {
567
+ lineTokens.push(cur);
568
+ cur = [];
569
+ fit.reset();
570
+ };
571
+ for (let token of tokens) {
572
+ while (
573
+ wraps &&
574
+ fit.with(token, cur.length ? kernBefore(token) : 0) > maxWidth + FIT_SLACK
575
+ ) {
576
+ if (cur.length > 0) {
577
+ flush();
578
+ } else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
579
+ break;
580
+ } else {
581
+ // single token wider than the container: force-break it
582
+ const [head, rest] = this._forceBreak(token, maxWidth);
583
+ if (!head) break; // not even one cluster fits: let it overflow
584
+ cur.push(head);
585
+ flush();
586
+ token = rest;
587
+ }
588
+ }
589
+ fit.add(token, cur.length ? kernBefore(token) : 0);
590
+ cur.push(token);
591
+ if (token.required) flush();
592
+ }
593
+ if (cur.length) lineTokens.push(cur);
594
+ }
595
+ return lineTokens;
596
+ }
597
+
598
+ /**
599
+ * A layout asked for its first lines (`maxLines`) of a paragraph it has
600
+ * not prepared: the start of the text prepared and filled, where every
601
+ * line kept is decided before the start ends. `<Html>` lays a paragraph
602
+ * out beside a float a line at a time, the rest of the paragraph and one
603
+ * line of it, so each line prepared the whole rest of the paragraph again
604
+ * — the paragraph's square — at every width of a resize.
605
+ *
606
+ * Two lines past the last kept one are filled before the start is trusted:
607
+ * what a cut can change — the break opportunities and the kerning a
608
+ * character or two either side of it, the token it falls in — is in the
609
+ * line it falls in, and no kept line starts or ends there. The levels it
610
+ * could change are 0 throughout in a text nothing can make right-to-left
611
+ * (`levelZeroThroughout`), which is the only text this is tried on; other
612
+ * text, and a start that fills too few lines at every length short of the
613
+ * whole, is prepared whole, as before.
614
+ *
615
+ * The start is kept with the paragraph's (`ParagraphCache`), marked
616
+ * `partial`, for the next layout of the same text to fill — the same line
617
+ * of a paragraph at the next width of a drag — or to prepare further, or
618
+ * whole, where it does not hold that layout's lines.
619
+ *
620
+ * @param {object} [known] the start a layout before this one kept
621
+ * @returns {{paragraph, lines, text}|null} the start prepared, its lines
622
+ * filled, and the whole text; null where the whole is to be prepared
623
+ */
624
+ _firstLines(content, style, options, maxWidth, round, known) {
625
+ const maxLines = options.maxLines;
626
+ if (!Number.isFinite(maxLines) || maxLines < 0) return null;
627
+ if (options.wrap === false || !Number.isFinite(maxWidth) || !(maxWidth > 0)) return null;
628
+ const text = typeof content === 'string' ? content : contentText(content);
629
+ if (!levelZeroThroughout(text, options.direction)) return null;
630
+ const keep = Math.floor(maxLines);
631
+ const enough = (paragraph) => {
632
+ const lines = this._fill(paragraph.tokens, paragraph.text, maxWidth, options, round);
633
+ return lines.length > keep + 2 ? { paragraph, lines, text } : null;
634
+ };
635
+ if (known) {
636
+ const held = enough(known);
637
+ if (held) return held;
638
+ }
639
+ // a guess at how much text the lines take — a quarter of an em a
640
+ // character — four times larger at each try that falls short
641
+ let length = Math.ceil(((keep + 3) * maxWidth) / (0.25 * smallestSize(content, style)));
642
+ if (known) length = Math.max(length, known.text.length * 4);
643
+ while (length < text.length) {
644
+ // a surrogate pair is not cut
645
+ const code = text.charCodeAt(length - 1);
646
+ if (code >= 0xd800 && code < 0xdc00) length++;
647
+ const paragraph = this._prepare(cutContent(content, length), style, options.direction);
648
+ paragraph.partial = true;
649
+ const held = enough(paragraph);
650
+ if (held) return held;
651
+ length *= 4;
652
+ }
653
+ return null;
654
+ }
655
+
578
656
  /**
579
657
  * The part of a layout the width does not decide: the spans normalised
580
658
  * and their faces resolved, the embedding levels, and the text cut into
@@ -723,7 +801,7 @@ export class TextLayout {
723
801
  const after = text.codePointAt(at) > 0xffff ? 2 : 1;
724
802
  const key = normalizedLevels(levels, at - before, at + after);
725
803
  if (key.includes(',') || Number(key) & 1) return 0;
726
- return this.fonts._kernAcross(text.slice(at - before, at + after), before, shaping, key);
804
+ return this.fonts._kernAcross(text.slice(at - before, at + after), before, spacedByOne(shaping), key);
727
805
  }
728
806
 
729
807
  /**
@@ -1622,6 +1700,28 @@ const MAX_SHAPING_STYLES = 16;
1622
1700
  * element's own letter spacing is a change of formatting a browser breaks
1623
1701
  * the shaping at.
1624
1702
  */
1703
+ /**
1704
+ * The shaping a pair's kern is asked under: spaced by one pixel where it is
1705
+ * spaced at all. How far spacing sets two letters apart is no part of the
1706
+ * kern they make — it is added to every glyph's advance (`spaceGlyphs`), to
1707
+ * the pair shaped whole and to each half alike, and drops out of
1708
+ * `_kernAcross`'s difference — but whether they are spaced is, since
1709
+ * spacing turns optional ligatures off. A justified line spaces each of its
1710
+ * spaces by its own share, and a share is new at each line and each width,
1711
+ * so the kern memo, keyed by the shaping's spacing, missed at every pair of
1712
+ * every justified line laid out at a new width, and shaped three strings
1713
+ * for each: most of what laying out a justified paragraph beside a float
1714
+ * cost at each step of a resize.
1715
+ */
1716
+ function spacedByOne(shaping) {
1717
+ if (!shaping.letterSpacing || shaping.letterSpacing === 1) return shaping;
1718
+ let one = SPACED_BY_ONE.get(shaping);
1719
+ if (!one) SPACED_BY_ONE.set(shaping, (one = { ...shaping, letterSpacing: 1 }));
1720
+ return one;
1721
+ }
1722
+
1723
+ const SPACED_BY_ONE = new WeakMap();
1724
+
1625
1725
  function pairShaping(fa, fb) {
1626
1726
  const a = fa.shaping;
1627
1727
  const b = fb.shaping;
@@ -1689,3 +1789,38 @@ function shapingStyleOf(known, span, given, fonts) {
1689
1789
  if (known.length < MAX_SHAPING_STYLES) known.push(k);
1690
1790
  return k;
1691
1791
  }
1792
+
1793
+ /** A span list's text, joined (`_firstLines`). */
1794
+ function contentText(spans) {
1795
+ let text = '';
1796
+ for (const span of spans) text += String(span.text ?? '');
1797
+ return text;
1798
+ }
1799
+
1800
+ /** The first `length` code units of a layout's content: a string, or the
1801
+ * spans that hold them, the last cut short (`_firstLines`). */
1802
+ function cutContent(content, length) {
1803
+ if (typeof content === 'string') return content.slice(0, length);
1804
+ const out = [];
1805
+ let at = 0;
1806
+ for (const span of content) {
1807
+ if (at >= length) break;
1808
+ const text = String(span.text ?? '');
1809
+ out.push(at + text.length <= length ? span : { ...span, text: text.slice(0, length - at) });
1810
+ at += text.length;
1811
+ }
1812
+ return out;
1813
+ }
1814
+
1815
+ /** The smallest size any of a layout's text is set at, for a guess at how
1816
+ * much of it a line takes (`_firstLines`). */
1817
+ function smallestSize(content, style) {
1818
+ const base = style.size ?? 16;
1819
+ if (typeof content === 'string') return base > 0 ? base : 16;
1820
+ let least = Infinity;
1821
+ for (const span of content) {
1822
+ const size = span.size ?? base;
1823
+ if (size > 0 && size < least) least = size;
1824
+ }
1825
+ return least < Infinity ? least : 16;
1826
+ }
@@ -86,10 +86,19 @@ export class ParagraphCache {
86
86
  }
87
87
 
88
88
  /** Keep a paragraph `TextLayout` made, with copies of what it was made
89
- * from: nothing done to the inputs afterwards can change what finds it. */
89
+ * from: nothing done to the inputs afterwards can change what finds it.
90
+ * One kept from the same inputs already — the start of the paragraph
91
+ * alone, prepared for its first lines (`partial`) — is replaced. */
90
92
  keep(key, direction, content, style, paragraph) {
93
+ const dir = direction ?? '';
94
+ const known = pick(this._now.get(dir)?.get(key), content, style);
95
+ if (known) {
96
+ this._chars += paragraph.text.length - known.paragraph.text.length;
97
+ known.paragraph = paragraph;
98
+ return;
99
+ }
91
100
  const spans = typeof content === 'string' ? content : content.map((span) => ({ ...span }));
92
- this._add(direction ?? '', key, { spans, style: { ...style }, paragraph });
101
+ this._add(dir, key, { spans, style: { ...style }, paragraph });
93
102
  }
94
103
 
95
104
  /** Drop everything — the faces a paragraph resolved may not be the ones
package/lib/text/shape.js CHANGED
@@ -41,7 +41,7 @@ export function embeddingLevels(text, direction = 'auto') {
41
41
  // Text that nothing can make right-to-left is level 0 throughout, which
42
42
  // is what the algorithm would work out a character at a time: every
43
43
  // layout asks, and most text is Latin, CJK or digits with no bidi at all.
44
- if (explicit !== 'rtl' && !MAY_REVERSE.test(text)) {
44
+ if (levelZeroThroughout(text, explicit)) {
45
45
  return {
46
46
  levels: new Uint8Array(text.length),
47
47
  paragraphs: [{ start: 0, end: text.length - 1, level: 0 }]
@@ -61,6 +61,17 @@ export function embeddingLevels(text, direction = 'auto') {
61
61
  const MAY_REVERSE =
62
62
  /[\u0590-\u08FF\u200F\u202A-\u202E\u2066-\u2069\uFB1D-\uFDFF\uFE70-\uFEFF]|[\uD802\uD803\uD83A\uD83B]/;
63
63
 
64
+ /**
65
+ * Whether every character of `text` is at embedding level 0 in a paragraph
66
+ * of this direction: one not right-to-left by request, with nothing in it
67
+ * that can raise a level (`MAY_REVERSE`). So is every part of such a text,
68
+ * wherever it is cut — which is what lets a layout prepare only the start
69
+ * of one (`TextLayout`'s `_firstLines`).
70
+ */
71
+ export function levelZeroThroughout(text, direction) {
72
+ return direction !== 'rtl' && !MAY_REVERSE.test(text);
73
+ }
74
+
64
75
  /**
65
76
  * Compact embedding-levels encoding for the shaping memo key: a single
66
77
  * number when uniform over [start, end) — the common case — else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.23.1",
3
+ "version": "8.23.3",
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",