ntk 2.2.0 → 3.0.0
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/README.md +156 -151
- package/lib/app.js +54 -21
- package/lib/cleanup.js +14 -0
- package/lib/drawable.js +12 -18
- package/lib/events_map.js +92 -98
- package/lib/fontconfig.js +123 -17
- package/lib/glyphset.js +40 -21
- package/lib/image.js +138 -0
- package/lib/index.js +123 -57
- package/lib/path.js +734 -0
- package/lib/picture.js +84 -41
- package/lib/pixmap.js +50 -39
- package/lib/rasterize.js +178 -0
- package/lib/renderingcontext_2d.js +1078 -695
- package/lib/renderingcontext_opengl.js +53 -46
- package/lib/renderingcontext_x11.js +59 -67
- package/lib/text/font.js +196 -0
- package/lib/text/fontmanager.js +233 -0
- package/lib/text/fontsource.js +179 -0
- package/lib/text/glyphs.js +456 -0
- package/lib/text/layout.js +356 -0
- package/lib/text/shape.js +141 -0
- package/lib/trapezoid.js +131 -0
- package/lib/widgets/css.js +676 -0
- package/lib/widgets/highlight.js +97 -0
- package/lib/widgets/htmlview.js +879 -0
- package/lib/widgets/markdown.js +148 -0
- package/lib/widgets/markdownview.js +487 -0
- package/lib/widgets/mermaid.js +720 -0
- package/lib/widgets/svgview.js +528 -0
- package/lib/widgets/tex.js +681 -0
- package/lib/window.js +642 -308
- package/package.json +60 -56
- package/examples/example-await.js +0 -23
- package/examples/example-co.js +0 -13
- package/examples/example.js +0 -14
- package/examples/fill-styles.js +0 -39
- package/examples/glclock.js +0 -100
- package/examples/glxpixmap.js +0 -12
- package/examples/gradient.js +0 -41
- package/examples/package.json +0 -17
- package/examples/simpletex.js +0 -72
- package/examples/teapot.js +0 -104
- package/examples/teapot.json +0 -6
- package/examples/text-rendering.js +0 -44
- package/lib/fontface.js +0 -103
- package/lib/widgets/webview.js +0 -33
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import LineBreaker from 'linebreak';
|
|
2
|
+
|
|
3
|
+
import { drawGlyphRuns } from './glyphs.js';
|
|
4
|
+
import { embeddingLevels, reorderRuns } from './shape.js';
|
|
5
|
+
|
|
6
|
+
const WS = new Set([0x20, 0x09, 0xa0]);
|
|
7
|
+
const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
|
|
8
|
+
const TRAILING_WS = /[ \t\u00a0]+$/;
|
|
9
|
+
|
|
10
|
+
function isWsGlyph(g) {
|
|
11
|
+
return g.codePoints.length > 0 && g.codePoints.every((cp) => WS.has(cp));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Multi-line text layout: breaks (possibly styled) text into lines for a
|
|
16
|
+
* target container width, with full shaping — kerning, ligatures, complex
|
|
17
|
+
* scripts, bidi and font fallback all apply.
|
|
18
|
+
*
|
|
19
|
+
* Line-break opportunities follow UAX#14 (via the `linebreak` package);
|
|
20
|
+
* shaping happens per inter-break segment (so results are cached and reused
|
|
21
|
+
* across relayouts), and bidi reordering is applied per line (UAX#9 L2).
|
|
22
|
+
*
|
|
23
|
+
* Content is a plain string or an array of spans
|
|
24
|
+
* `{ text, family?, size?, weight?, style?, features?, language?, color? }`;
|
|
25
|
+
* span fields override the base style. Options:
|
|
26
|
+
*
|
|
27
|
+
* - `maxWidth` — target container width (default: unlimited)
|
|
28
|
+
* - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
|
|
29
|
+
* - `lineHeight` — multiplier over natural font line height (default 1)
|
|
30
|
+
* - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
|
|
31
|
+
*
|
|
32
|
+
* The result is inspectable before/without drawing: `width`, `height`, and
|
|
33
|
+
* `lines[] = { x, y, baseline, width, ascent, descent, runs }` with
|
|
34
|
+
* `runs[] = { x, width, run, span }` in visual order.
|
|
35
|
+
*/
|
|
36
|
+
export class TextLayout {
|
|
37
|
+
constructor(fonts, content, style = {}, options = {}) {
|
|
38
|
+
this.fonts = fonts;
|
|
39
|
+
this.options = options;
|
|
40
|
+
const maxWidth = options.maxWidth ?? Infinity;
|
|
41
|
+
|
|
42
|
+
// ---- normalize spans, resolve fonts eagerly ----
|
|
43
|
+
// unknown span fields ride along untouched (widgets attach markers like
|
|
44
|
+
// MarkdownView's _deco/_href and read them back from line runs)
|
|
45
|
+
const spans = (typeof content === 'string' ? [{ text: content }] : content).map((s) => {
|
|
46
|
+
const merged = {
|
|
47
|
+
...s,
|
|
48
|
+
text: String(s.text ?? ''),
|
|
49
|
+
family: s.family ?? style.family ?? 'sans-serif',
|
|
50
|
+
size: s.size ?? style.size ?? 16,
|
|
51
|
+
weight: s.weight ?? style.weight,
|
|
52
|
+
style: s.style ?? style.style,
|
|
53
|
+
features: s.features ?? style.features,
|
|
54
|
+
language: s.language ?? style.language,
|
|
55
|
+
color: s.color ?? style.color ?? null
|
|
56
|
+
};
|
|
57
|
+
merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
|
|
58
|
+
return merged;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const text = spans.map((s) => s.text).join('');
|
|
62
|
+
const emb = embeddingLevels(text, options.direction);
|
|
63
|
+
const levels = emb.levels;
|
|
64
|
+
this.baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
|
|
65
|
+
|
|
66
|
+
// span boundaries, for binary search by char position
|
|
67
|
+
const spanStarts = [];
|
|
68
|
+
{
|
|
69
|
+
let pos = 0;
|
|
70
|
+
for (const s of spans) {
|
|
71
|
+
spanStarts.push(pos);
|
|
72
|
+
pos += s.text.length;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const spanAt = (pos) => {
|
|
76
|
+
let lo = 0;
|
|
77
|
+
let hi = spanStarts.length - 1;
|
|
78
|
+
while (lo < hi) {
|
|
79
|
+
const mid = (lo + hi + 1) >> 1;
|
|
80
|
+
if (spanStarts[mid] <= pos) lo = mid;
|
|
81
|
+
else hi = mid - 1;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
span: spans[lo],
|
|
85
|
+
end: lo + 1 < spanStarts.length ? spanStarts[lo + 1] : text.length
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ---- tokenize at UAX#14 break opportunities ----
|
|
90
|
+
const tokens = [];
|
|
91
|
+
{
|
|
92
|
+
const breaker = new LineBreaker(text);
|
|
93
|
+
let prev = 0;
|
|
94
|
+
let bk;
|
|
95
|
+
while ((bk = breaker.nextBreak())) {
|
|
96
|
+
tokens.push(this._makeToken(text, prev, bk.position, levels, spanAt, bk.required));
|
|
97
|
+
prev = bk.position;
|
|
98
|
+
}
|
|
99
|
+
if (prev < text.length || tokens.length === 0) {
|
|
100
|
+
tokens.push(this._makeToken(text, prev, text.length, levels, spanAt, false));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- greedy line fill ----
|
|
105
|
+
const lineTokens = [];
|
|
106
|
+
{
|
|
107
|
+
let cur = [];
|
|
108
|
+
let curWidth = 0;
|
|
109
|
+
const flush = () => {
|
|
110
|
+
lineTokens.push(cur);
|
|
111
|
+
cur = [];
|
|
112
|
+
curWidth = 0;
|
|
113
|
+
};
|
|
114
|
+
for (let token of tokens) {
|
|
115
|
+
while (curWidth + token.width - token.wsWidth > maxWidth) {
|
|
116
|
+
if (cur.length > 0) {
|
|
117
|
+
flush();
|
|
118
|
+
} else {
|
|
119
|
+
// single token wider than the container: force-break it
|
|
120
|
+
const [head, rest] = this._forceBreak(token, maxWidth);
|
|
121
|
+
if (!head) break; // not even one cluster fits: let it overflow
|
|
122
|
+
cur.push(head);
|
|
123
|
+
flush();
|
|
124
|
+
token = rest;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
cur.push(token);
|
|
128
|
+
curWidth += token.width;
|
|
129
|
+
if (token.required) flush();
|
|
130
|
+
}
|
|
131
|
+
if (cur.length) lineTokens.push(cur);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- assemble lines: strip trailing ws, bidi-reorder, position ----
|
|
135
|
+
const baseSpan = spans[0];
|
|
136
|
+
const lineHeightMul = options.lineHeight ?? 1;
|
|
137
|
+
this.lines = [];
|
|
138
|
+
let y = 0;
|
|
139
|
+
let layoutWidth = 0;
|
|
140
|
+
|
|
141
|
+
for (const toks of lineTokens) {
|
|
142
|
+
// entries carry .level so reorderRuns can order them (UAX#9 L2)
|
|
143
|
+
let entries = [];
|
|
144
|
+
for (const token of toks) {
|
|
145
|
+
for (const frag of token.fragments) {
|
|
146
|
+
for (const run of frag.shaped.runs) {
|
|
147
|
+
entries.push({ run, span: frag.span, level: run.level });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
stripTrailingWhitespace(entries);
|
|
152
|
+
entries = reorderRuns(entries);
|
|
153
|
+
|
|
154
|
+
let ascent = 0;
|
|
155
|
+
let descent = 0;
|
|
156
|
+
let natural = 0;
|
|
157
|
+
const runs = [];
|
|
158
|
+
let x = 0;
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
runs.push({ x, width: e.run.width, run: e.run, span: e.span });
|
|
161
|
+
x += e.run.width;
|
|
162
|
+
const m = e.run.font.metrics(e.run.size);
|
|
163
|
+
if (m.ascent > ascent) ascent = m.ascent;
|
|
164
|
+
if (m.descent > descent) descent = m.descent;
|
|
165
|
+
if (m.lineHeight > natural) natural = m.lineHeight;
|
|
166
|
+
}
|
|
167
|
+
if (runs.length === 0) {
|
|
168
|
+
// empty line (blank paragraph): base style metrics
|
|
169
|
+
const m = baseSpan.font.metrics(baseSpan.size);
|
|
170
|
+
ascent = m.ascent;
|
|
171
|
+
descent = m.descent;
|
|
172
|
+
natural = m.lineHeight;
|
|
173
|
+
}
|
|
174
|
+
if (x > layoutWidth) layoutWidth = x;
|
|
175
|
+
this.lines.push({ x: 0, y, baseline: y + ascent, width: x, ascent, descent, runs });
|
|
176
|
+
y += natural * lineHeightMul;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.width = layoutWidth;
|
|
180
|
+
this.height = y;
|
|
181
|
+
|
|
182
|
+
// ---- horizontal alignment ----
|
|
183
|
+
const container = Number.isFinite(maxWidth) ? maxWidth : this.width;
|
|
184
|
+
let align = options.align ?? 'start';
|
|
185
|
+
if (align === 'start') align = this.baseLevel ? 'right' : 'left';
|
|
186
|
+
if (align === 'end') align = this.baseLevel ? 'left' : 'right';
|
|
187
|
+
if (align !== 'left') {
|
|
188
|
+
for (const line of this.lines) {
|
|
189
|
+
const free = container - line.width;
|
|
190
|
+
line.x = align === 'right' ? free : free / 2;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_makeToken(text, start, end, levels, spanAt, required) {
|
|
196
|
+
const fragments = [];
|
|
197
|
+
let width = 0;
|
|
198
|
+
let pos = start;
|
|
199
|
+
while (pos < end) {
|
|
200
|
+
const { span, end: spanEnd } = spanAt(pos);
|
|
201
|
+
const fragEnd = Math.min(end, spanEnd);
|
|
202
|
+
// hard-break controls only terminate the line; never shape them
|
|
203
|
+
const fragText = text.slice(pos, fragEnd).replace(HARD_BREAKS, '');
|
|
204
|
+
if (fragText.length > 0) {
|
|
205
|
+
const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
|
|
206
|
+
const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
|
|
207
|
+
fragments.push({ text: fragText, span, shaped });
|
|
208
|
+
width += shaped.width;
|
|
209
|
+
}
|
|
210
|
+
pos = fragEnd;
|
|
211
|
+
}
|
|
212
|
+
// trailing whitespace does not count against the container width
|
|
213
|
+
let wsWidth = 0;
|
|
214
|
+
const last = fragments[fragments.length - 1];
|
|
215
|
+
if (last) {
|
|
216
|
+
const m = TRAILING_WS.exec(last.text);
|
|
217
|
+
if (m) {
|
|
218
|
+
const spaceGlyph = last.span.font.fk.glyphForCodePoint(0x20);
|
|
219
|
+
wsWidth = m[0].length * spaceGlyph.advanceWidth * last.span.font.scale(last.span.size);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { fragments, width, wsWidth, required };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// split an over-wide token at the widest cluster boundary that fits
|
|
226
|
+
_forceBreak(token, maxWidth) {
|
|
227
|
+
const headFrags = [];
|
|
228
|
+
let used = 0;
|
|
229
|
+
for (let i = 0; i < token.fragments.length; i++) {
|
|
230
|
+
const frag = token.fragments[i];
|
|
231
|
+
if (used + frag.shaped.width <= maxWidth) {
|
|
232
|
+
headFrags.push(frag);
|
|
233
|
+
used += frag.shaped.width;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
// binary search the longest codepoint prefix of this fragment that fits
|
|
237
|
+
const cps = Array.from(frag.text);
|
|
238
|
+
let lo = 0;
|
|
239
|
+
let hi = cps.length - 1;
|
|
240
|
+
let best = null;
|
|
241
|
+
while (lo <= hi) {
|
|
242
|
+
const mid = (lo + hi) >> 1;
|
|
243
|
+
const prefix = cps.slice(0, mid + 1).join('');
|
|
244
|
+
const shaped = this.fonts._shapeCached(prefix, frag.span, '0');
|
|
245
|
+
if (used + shaped.width <= maxWidth) {
|
|
246
|
+
best = { len: prefix.length, shaped, text: prefix };
|
|
247
|
+
lo = mid + 1;
|
|
248
|
+
} else {
|
|
249
|
+
hi = mid - 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (best) headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped });
|
|
253
|
+
|
|
254
|
+
const restFrags = [];
|
|
255
|
+
const restText = frag.text.slice(best ? best.len : 0);
|
|
256
|
+
if (restText) {
|
|
257
|
+
restFrags.push({
|
|
258
|
+
text: restText,
|
|
259
|
+
span: frag.span,
|
|
260
|
+
shaped: this.fonts._shapeCached(restText, frag.span, '0')
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
restFrags.push(...token.fragments.slice(i + 1));
|
|
264
|
+
const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
|
|
265
|
+
const head = headFrags.length
|
|
266
|
+
? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false }
|
|
267
|
+
: null;
|
|
268
|
+
const rest = {
|
|
269
|
+
fragments: restFrags,
|
|
270
|
+
width: sum(restFrags),
|
|
271
|
+
wsWidth: token.wsWidth,
|
|
272
|
+
required: token.required
|
|
273
|
+
};
|
|
274
|
+
return [head, rest];
|
|
275
|
+
}
|
|
276
|
+
// everything fit after all (float rounding): no split needed
|
|
277
|
+
return [
|
|
278
|
+
{ ...token, required: false },
|
|
279
|
+
{ fragments: [], width: 0, wsWidth: 0, required: token.required }
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Draw onto a 2d context at (x, y) = top-left of the layout box.
|
|
285
|
+
* Span `color`s override the context fillStyle; consecutive same-color
|
|
286
|
+
* runs are batched into single CompositeGlyphs requests.
|
|
287
|
+
*/
|
|
288
|
+
draw(ctx, x = 0, y = 0) {
|
|
289
|
+
const app = ctx.window.app;
|
|
290
|
+
const Render = app.display.Render;
|
|
291
|
+
for (const line of this.lines) {
|
|
292
|
+
let batch = [];
|
|
293
|
+
let batchColor;
|
|
294
|
+
const flush = () => {
|
|
295
|
+
if (batch.length === 0) return;
|
|
296
|
+
const src = batchColor ? ctx._stylePicture(batchColor) : ctx._backgroundPicture;
|
|
297
|
+
drawGlyphRuns(app, Render.PictOp.Over, src.id, ctx.picture.id, batch);
|
|
298
|
+
batch = [];
|
|
299
|
+
};
|
|
300
|
+
for (const r of line.runs) {
|
|
301
|
+
const color = r.span.color;
|
|
302
|
+
if (batch.length && color !== batchColor) flush();
|
|
303
|
+
batchColor = color;
|
|
304
|
+
batch.push({ run: r.run, x: x + line.x + r.x, y: y + line.baseline });
|
|
305
|
+
}
|
|
306
|
+
flush();
|
|
307
|
+
}
|
|
308
|
+
ctx._markDirty();
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Drop trailing whitespace glyphs from the logical end of a line.
|
|
314
|
+
// Shaped runs are shared via the shaping cache — clone instead of mutating.
|
|
315
|
+
function stripTrailingWhitespace(entries) {
|
|
316
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
317
|
+
const run = entries[i].run;
|
|
318
|
+
// rtl runs store glyphs in visual order: their logical end is index 0
|
|
319
|
+
const fromFront = run.direction === 'rtl';
|
|
320
|
+
let count = 0;
|
|
321
|
+
let wsWidth = 0;
|
|
322
|
+
while (count < run.glyphs.length) {
|
|
323
|
+
const g = run.glyphs[fromFront ? count : run.glyphs.length - 1 - count];
|
|
324
|
+
if (!isWsGlyph(g)) break;
|
|
325
|
+
count++;
|
|
326
|
+
wsWidth += g.ax;
|
|
327
|
+
}
|
|
328
|
+
if (count === 0) return;
|
|
329
|
+
if (count === run.glyphs.length) {
|
|
330
|
+
entries.splice(i, 1);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
entries[i] = {
|
|
334
|
+
...entries[i],
|
|
335
|
+
run: {
|
|
336
|
+
...run,
|
|
337
|
+
glyphs: fromFront ? run.glyphs.slice(count) : run.glyphs.slice(0, -count),
|
|
338
|
+
width: run.width - wsWidth
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// compact levels key for the shaping cache: single char when uniform
|
|
346
|
+
function normalizedLevels(levels, start, end) {
|
|
347
|
+
let uniform = true;
|
|
348
|
+
for (let i = start + 1; i < end; i++) {
|
|
349
|
+
if (levels[i] !== levels[start]) {
|
|
350
|
+
uniform = false;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (uniform) return String(levels[start] ?? 0);
|
|
355
|
+
return Array.prototype.slice.call(levels, start, end).join(',');
|
|
356
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import bidiFactory from 'bidi-js';
|
|
2
|
+
|
|
3
|
+
const bidi = bidiFactory();
|
|
4
|
+
|
|
5
|
+
// characters that never force a font switch: whitespace and default-ignorable
|
|
6
|
+
// format controls stay with the surrounding font
|
|
7
|
+
function neverSwitches(cp) {
|
|
8
|
+
return (
|
|
9
|
+
cp === 0x20 ||
|
|
10
|
+
cp === 0x09 ||
|
|
11
|
+
cp === 0x0a ||
|
|
12
|
+
cp === 0x0d ||
|
|
13
|
+
cp === 0xa0 ||
|
|
14
|
+
(cp >= 0x200b && cp <= 0x200f) || // zw space/nonjoiner/joiner, lrm/rlm
|
|
15
|
+
(cp >= 0x202a && cp <= 0x202e) || // bidi embedding controls
|
|
16
|
+
(cp >= 0x2066 && cp <= 0x2069) || // bidi isolate controls
|
|
17
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
18
|
+
cp === 0xfeff
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* UAX#9 embedding levels for a string. `direction` is 'ltr', 'rtl' or 'auto'
|
|
24
|
+
* (first-strong detection). Returns bidi-js's result object.
|
|
25
|
+
*/
|
|
26
|
+
export function embeddingLevels(text, direction = 'auto') {
|
|
27
|
+
const explicit = direction === 'ltr' || direction === 'rtl' ? direction : null;
|
|
28
|
+
return bidi.getEmbeddingLevels(text, explicit);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shape a string through the full pipeline:
|
|
33
|
+
*
|
|
34
|
+
* 1. bidi resolution (UAX#9, bidi-js) — unless precomputed `levels` are
|
|
35
|
+
* passed in (TextLayout resolves whole paragraphs at once)
|
|
36
|
+
* 2. itemization: split into maximal runs of same bidi level and same font,
|
|
37
|
+
* switching to a fallback font for codepoints the primary font lacks
|
|
38
|
+
* 3. OpenType shaping per run (fontkit): kerning, ligatures, contextual
|
|
39
|
+
* forms; RTL runs come back in visual glyph order
|
|
40
|
+
*
|
|
41
|
+
* Runs are returned in **logical order**, each tagged with its bidi `level`;
|
|
42
|
+
* use `reorderRuns()` to get visual order for drawing.
|
|
43
|
+
*
|
|
44
|
+
* @param {FontManager} fonts
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @param {object} style { font | family/weight/style, size, features,
|
|
47
|
+
* language, direction: 'ltr'|'rtl'|'auto' }
|
|
48
|
+
* @param {ArrayLike<number>} [levels] precomputed embedding levels for `text`
|
|
49
|
+
* @returns {{text, width, baseLevel, runs: Array<{font, size, direction,
|
|
50
|
+
* level, width, glyphs, text, start, end}>}}
|
|
51
|
+
*/
|
|
52
|
+
export function shapeText(fonts, text, style, levels) {
|
|
53
|
+
const size = style.size ?? 16;
|
|
54
|
+
const family = style.family ?? 'sans-serif';
|
|
55
|
+
const baseFont = style.font ?? fonts.match(family, style);
|
|
56
|
+
|
|
57
|
+
let baseLevel = 0;
|
|
58
|
+
if (!levels) {
|
|
59
|
+
const emb = embeddingLevels(text, style.direction);
|
|
60
|
+
levels = emb.levels;
|
|
61
|
+
baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level : 0;
|
|
62
|
+
} else {
|
|
63
|
+
baseLevel = levels.length ? levels[0] & ~1 : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// itemize: walk codepoints, splitting where bidi level or font changes
|
|
67
|
+
const items = []; // { font, level, start, end }
|
|
68
|
+
let cur = null;
|
|
69
|
+
let curFont = baseFont;
|
|
70
|
+
let i = 0;
|
|
71
|
+
for (const ch of text) {
|
|
72
|
+
const cp = ch.codePointAt(0);
|
|
73
|
+
const level = levels[i] ?? baseLevel;
|
|
74
|
+
let font = curFont;
|
|
75
|
+
if (!neverSwitches(cp)) {
|
|
76
|
+
// the primary font always wins when it covers the char, so fallback
|
|
77
|
+
// runs stay as short as possible
|
|
78
|
+
if (baseFont.hasGlyph(cp)) font = baseFont;
|
|
79
|
+
else if (!font.hasGlyph(cp)) font = fonts.fallbackFor(cp, family, style) ?? baseFont;
|
|
80
|
+
}
|
|
81
|
+
if (cur && cur.font === font && cur.level === level) {
|
|
82
|
+
cur.end = i + ch.length;
|
|
83
|
+
} else {
|
|
84
|
+
cur = { font, level, start: i, end: i + ch.length };
|
|
85
|
+
items.push(cur);
|
|
86
|
+
}
|
|
87
|
+
curFont = font;
|
|
88
|
+
i += ch.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const runs = [];
|
|
92
|
+
let width = 0;
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
const direction = item.level & 1 ? 'rtl' : 'ltr';
|
|
95
|
+
const sub = text.slice(item.start, item.end);
|
|
96
|
+
const shaped = item.font.shape(sub, size, {
|
|
97
|
+
direction,
|
|
98
|
+
features: style.features,
|
|
99
|
+
language: style.language
|
|
100
|
+
});
|
|
101
|
+
shaped.level = item.level;
|
|
102
|
+
shaped.text = sub;
|
|
103
|
+
shaped.start = item.start;
|
|
104
|
+
shaped.end = item.end;
|
|
105
|
+
width += shaped.width;
|
|
106
|
+
runs.push(shaped);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { text, width, baseLevel, runs };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reorder logical runs into visual (left-to-right drawing) order —
|
|
114
|
+
* UAX#9 rule L2 applied at run granularity.
|
|
115
|
+
*/
|
|
116
|
+
export function reorderRuns(runs) {
|
|
117
|
+
if (runs.length < 2) return runs.slice();
|
|
118
|
+
let max = 0;
|
|
119
|
+
let minOdd = Infinity;
|
|
120
|
+
for (const r of runs) {
|
|
121
|
+
if (r.level > max) max = r.level;
|
|
122
|
+
if (r.level & 1 && r.level < minOdd) minOdd = r.level;
|
|
123
|
+
}
|
|
124
|
+
const out = runs.slice();
|
|
125
|
+
for (let level = max; level >= minOdd; level--) {
|
|
126
|
+
let start = -1;
|
|
127
|
+
for (let j = 0; j <= out.length; j++) {
|
|
128
|
+
if (j < out.length && out[j].level >= level) {
|
|
129
|
+
if (start === -1) start = j;
|
|
130
|
+
} else if (start !== -1) {
|
|
131
|
+
for (let a = start, b = j - 1; a < b; a++, b--) {
|
|
132
|
+
const tmp = out[a];
|
|
133
|
+
out[a] = out[b];
|
|
134
|
+
out[b] = tmp;
|
|
135
|
+
}
|
|
136
|
+
start = -1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
package/lib/trapezoid.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Trapezoidal decomposition of flattened closed polygons (non-zero winding
|
|
2
|
+
// by default, even-odd optionally), producing XRender AddTraps data.
|
|
3
|
+
//
|
|
4
|
+
// A horizontal slab sweep: slab boundaries are the sorted unique vertex y
|
|
5
|
+
// coordinates, so edges never cross inside a slab (self-intersecting input —
|
|
6
|
+
// e.g. overlapping composite-glyph contours — degrades gracefully to a
|
|
7
|
+
// near-correct fill instead of failing). Spans where the winding number is
|
|
8
|
+
// non-zero become trapezoids; a span whose left/right edges continue through
|
|
9
|
+
// consecutive slabs is merged into a single tall trapezoid, which is what
|
|
10
|
+
// keeps the output at roughly one trapezoid per outline edge.
|
|
11
|
+
//
|
|
12
|
+
// Output is the flat number list node-x11's Render.AddTraps expects:
|
|
13
|
+
// 6 values per trapezoid — top spanfix (left x, right x, y) then bottom
|
|
14
|
+
// spanfix (left x, right x, y), y-down, top.y < bottom.y.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Array<Array<number>>} polys closed polygons as flat [x0,y0,x1,y1,…]
|
|
18
|
+
* @param {number} [dx] translation applied to every x
|
|
19
|
+
* @param {number} [dy] translation applied to every y
|
|
20
|
+
* @param {Array<number>} [out] existing array to append trapezoid data to
|
|
21
|
+
* @param {'nonzero'|'evenodd'} [rule] fill rule (default non-zero winding)
|
|
22
|
+
* @returns {Array<number>} `out`
|
|
23
|
+
*/
|
|
24
|
+
export function trapezoidize(polys, dx = 0, dy = 0, out = [], rule = 'nonzero') {
|
|
25
|
+
const evenodd = rule === 'evenodd';
|
|
26
|
+
// collect non-horizontal edges, normalized to y0 < y1 with winding dir
|
|
27
|
+
const edges = [];
|
|
28
|
+
const ys = [];
|
|
29
|
+
for (const poly of polys) {
|
|
30
|
+
const n = poly.length / 2;
|
|
31
|
+
for (let i = 0; i < n; ++i) {
|
|
32
|
+
const j = (i + 1) % n;
|
|
33
|
+
const ax = poly[i * 2] + dx;
|
|
34
|
+
const ay = poly[i * 2 + 1] + dy;
|
|
35
|
+
const bx = poly[j * 2] + dx;
|
|
36
|
+
const by = poly[j * 2 + 1] + dy;
|
|
37
|
+
if (ay === by) continue;
|
|
38
|
+
edges.push(
|
|
39
|
+
ay < by
|
|
40
|
+
? { x0: ax, y0: ay, x1: bx, y1: by, dir: 1, xa: 0, xb: 0 }
|
|
41
|
+
: { x0: bx, y0: by, x1: ax, y1: ay, dir: -1, xa: 0, xb: 0 }
|
|
42
|
+
);
|
|
43
|
+
ys.push(ay, by);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (edges.length === 0) return out;
|
|
47
|
+
|
|
48
|
+
ys.sort((a, b) => a - b);
|
|
49
|
+
edges.sort((a, b) => a.y0 - b.y0);
|
|
50
|
+
|
|
51
|
+
const active = [];
|
|
52
|
+
let nextEdge = 0;
|
|
53
|
+
// spans open from the previous slab, for vertical merging:
|
|
54
|
+
// { l: leftEdge, r: rightEdge, at: index of the trap in `out` }
|
|
55
|
+
let prevSpans = [];
|
|
56
|
+
|
|
57
|
+
for (let yi = 0; yi < ys.length - 1; ++yi) {
|
|
58
|
+
const ya = ys[yi];
|
|
59
|
+
const yb = ys[yi + 1];
|
|
60
|
+
if (yb === ya) continue;
|
|
61
|
+
|
|
62
|
+
// update the active edge list for this slab
|
|
63
|
+
for (let i = active.length - 1; i >= 0; --i) {
|
|
64
|
+
if (active[i].y1 <= ya) active.splice(i, 1);
|
|
65
|
+
}
|
|
66
|
+
while (nextEdge < edges.length && edges[nextEdge].y0 <= ya) {
|
|
67
|
+
if (edges[nextEdge].y1 > ya) active.push(edges[nextEdge]);
|
|
68
|
+
nextEdge++;
|
|
69
|
+
}
|
|
70
|
+
if (active.length === 0) {
|
|
71
|
+
prevSpans = [];
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// x at slab top/bottom for each active edge; sort left-to-right
|
|
76
|
+
for (const e of active) {
|
|
77
|
+
const inv = (e.x1 - e.x0) / (e.y1 - e.y0);
|
|
78
|
+
e.xa = e.x0 + (ya - e.y0) * inv;
|
|
79
|
+
e.xb = e.x0 + (yb - e.y0) * inv;
|
|
80
|
+
}
|
|
81
|
+
active.sort((a, b) => a.xa + a.xb - (b.xa + b.xb));
|
|
82
|
+
|
|
83
|
+
// emit a span per maximal interior interval; interiority is decided by
|
|
84
|
+
// the fill rule (non-zero winding, or crossing parity for even-odd)
|
|
85
|
+
const spans = [];
|
|
86
|
+
let winding = 0;
|
|
87
|
+
let crossings = 0;
|
|
88
|
+
let left = null;
|
|
89
|
+
for (const e of active) {
|
|
90
|
+
const wasInside = evenodd ? (crossings & 1) === 1 : winding !== 0;
|
|
91
|
+
winding += e.dir;
|
|
92
|
+
crossings++;
|
|
93
|
+
const isInside = evenodd ? (crossings & 1) === 1 : winding !== 0;
|
|
94
|
+
if (!wasInside && isInside) {
|
|
95
|
+
left = e;
|
|
96
|
+
} else if (wasInside && !isInside && left) {
|
|
97
|
+
// merge with a span from the previous slab bounded by the same edges
|
|
98
|
+
let merged = false;
|
|
99
|
+
for (const p of prevSpans) {
|
|
100
|
+
if (p.l === left && p.r === e && out[p.at + 5] === ya) {
|
|
101
|
+
out[p.at + 3] = left.xb;
|
|
102
|
+
out[p.at + 4] = e.xb;
|
|
103
|
+
out[p.at + 5] = yb;
|
|
104
|
+
spans.push(p);
|
|
105
|
+
merged = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!merged && (e.xa > left.xa || e.xb > left.xb)) {
|
|
110
|
+
spans.push({ l: left, r: e, at: out.length });
|
|
111
|
+
out.push(left.xa, e.xa, ya, left.xb, e.xb, yb);
|
|
112
|
+
}
|
|
113
|
+
left = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
prevSpans = spans;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** area covered by a trapezoid list (useful for tests/diagnostics) */
|
|
122
|
+
export function trapArea(traps) {
|
|
123
|
+
let area = 0;
|
|
124
|
+
for (let i = 0; i < traps.length; i += 6) {
|
|
125
|
+
const [tl, tr, ty, bl, br, by] = [
|
|
126
|
+
traps[i], traps[i + 1], traps[i + 2], traps[i + 3], traps[i + 4], traps[i + 5]
|
|
127
|
+
];
|
|
128
|
+
area += ((tr - tl + (br - bl)) / 2) * (by - ty);
|
|
129
|
+
}
|
|
130
|
+
return area;
|
|
131
|
+
}
|