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.
- package/lib/renderingcontext_2d.js +71 -12
- package/lib/text/layout.js +182 -47
- package/lib/text/paragraphs.js +11 -2
- package/lib/text/shape.js +12 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
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
|
-
//
|
|
5088
|
-
//
|
|
5089
|
-
|
|
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):
|
|
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.
|
|
5157
|
-
this.
|
|
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;
|
package/lib/text/layout.js
CHANGED
|
@@ -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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/text/paragraphs.js
CHANGED
|
@@ -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(
|
|
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 (
|
|
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
|