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,179 @@
|
|
|
1
|
+
// Pluggable system-font lookup — the seam that lets the text stack run in
|
|
2
|
+
// environments without fontconfig or a filesystem (browser bundles, hermetic
|
|
3
|
+
// tests). A font source answers "which fonts match this pattern, in fallback
|
|
4
|
+
// order?" and hands the FontManager something it can open.
|
|
5
|
+
//
|
|
6
|
+
// FontSource interface (duck-typed):
|
|
7
|
+
//
|
|
8
|
+
// matchSorted({ family, weight, style }) -> candidate[]
|
|
9
|
+
// Full match list for a pattern, best first — the fallback chain.
|
|
10
|
+
// `family` may be a CSS-style comma-separated list. Must return at
|
|
11
|
+
// least one candidate or throw. A candidate is openable:
|
|
12
|
+
// { key?, path?, data?, font?, postscriptName? }
|
|
13
|
+
// - `font`: an already-open Font instance (preferred when available)
|
|
14
|
+
// - `data`: font file bytes (Uint8Array/Buffer) for fontkit
|
|
15
|
+
// - `path`: font file path (node only)
|
|
16
|
+
// - `key`: stable cache key (defaults to `${path}#${postscriptName}`)
|
|
17
|
+
//
|
|
18
|
+
// covers(candidate, codepoint) -> boolean [optional]
|
|
19
|
+
// Cheap coverage pre-filter used during per-codepoint fallback, ideally
|
|
20
|
+
// without opening the font. When absent, candidates are opened and
|
|
21
|
+
// checked with `font.hasGlyph()`.
|
|
22
|
+
//
|
|
23
|
+
// The default source shells out to fc-match (fontconfig) — the behavior ntk
|
|
24
|
+
// always had. Swap it per-app (`createClient({ fontSource })`), per-manager
|
|
25
|
+
// (`new FontManager({ source })`) or globally (`setDefaultFontSource()`).
|
|
26
|
+
import { charsetHas, matchSortedSync } from '../fontconfig.js';
|
|
27
|
+
import Font from './font.js';
|
|
28
|
+
|
|
29
|
+
const WEIGHTS = { normal: 400, bold: 700 };
|
|
30
|
+
|
|
31
|
+
/** css-ish weight ('bold' | 400 | '600') -> number, default 400 */
|
|
32
|
+
export function numericWeight(weight) {
|
|
33
|
+
if (weight === undefined) return 400;
|
|
34
|
+
if (WEIGHTS[weight] !== undefined) return WEIGHTS[weight];
|
|
35
|
+
const n = parseInt(weight, 10);
|
|
36
|
+
return Number.isNaN(n) ? 400 : n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect a Font's weight/italic from its tables. Fonts are sloppy about
|
|
41
|
+
* italics — check the fsSelection bit, then italicAngle, then the
|
|
42
|
+
* subfamily name (KaTeX's italic faces set nothing but the name).
|
|
43
|
+
*/
|
|
44
|
+
export function detectStyle(font) {
|
|
45
|
+
const os2 = font.fk['OS/2'];
|
|
46
|
+
return {
|
|
47
|
+
weight: os2 ? os2.usWeightClass : 400,
|
|
48
|
+
italic: !!(
|
|
49
|
+
(os2 && os2.fsSelection.italic) ||
|
|
50
|
+
font.fk.italicAngle ||
|
|
51
|
+
/italic|oblique/i.test(font.fk.subfamilyName || '')
|
|
52
|
+
)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** split a CSS font-family list into normalized lowercase names */
|
|
57
|
+
export function parseFamilies(family) {
|
|
58
|
+
return String(family ?? '')
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((f) => f.trim().replace(/^["']|["']$/g, '').toLowerCase())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The default source: fontconfig via the fc-match CLI, font files opened
|
|
66
|
+
* from disk. Node-only (needs shell access); see StaticFontSource for a
|
|
67
|
+
* self-contained alternative.
|
|
68
|
+
*/
|
|
69
|
+
export class FontconfigFontSource {
|
|
70
|
+
matchSorted(pattern) {
|
|
71
|
+
return matchSortedSync(pattern);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
covers(candidate, codepoint) {
|
|
75
|
+
return charsetHas(candidate, codepoint);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A font source backed entirely by fonts you hand it — no fontconfig, no
|
|
81
|
+
* filesystem. Works anywhere fontkit runs, including browser bundles:
|
|
82
|
+
*
|
|
83
|
+
* const source = new StaticFontSource();
|
|
84
|
+
* source.add(bytes, { family: 'DejaVu Sans' });
|
|
85
|
+
* source.alias('sans-serif', 'DejaVu Sans');
|
|
86
|
+
* const app = await createClient({ fontSource: source });
|
|
87
|
+
*
|
|
88
|
+
* Matching mirrors fontconfig semantics: every added face is a fallback
|
|
89
|
+
* candidate; faces matching a requested family (in list order) rank first,
|
|
90
|
+
* then closest weight, then style. Coverage checks use the real font
|
|
91
|
+
* tables, so per-codepoint fallback works exactly like the system path.
|
|
92
|
+
*/
|
|
93
|
+
export class StaticFontSource {
|
|
94
|
+
constructor() {
|
|
95
|
+
this._faces = []; // { font, family, weight, italic, candidate }
|
|
96
|
+
this._aliases = new Map(); // 'sans-serif' -> 'dejavu sans'
|
|
97
|
+
this._n = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register a font.
|
|
102
|
+
* @param {Uint8Array|Buffer|Font} data font file bytes (or an open Font)
|
|
103
|
+
* @param {object} [opts] { family (alias for matching; defaults to the
|
|
104
|
+
* font's family name), weight, style, postscriptName (face of a .ttc) }
|
|
105
|
+
* @returns {Font}
|
|
106
|
+
*/
|
|
107
|
+
add(data, opts = {}) {
|
|
108
|
+
const font =
|
|
109
|
+
data instanceof Font
|
|
110
|
+
? data
|
|
111
|
+
: Font.fromData(data, {
|
|
112
|
+
key: `static:${this._n++}:${opts.family || opts.postscriptName || ''}`,
|
|
113
|
+
postscriptName: opts.postscriptName
|
|
114
|
+
});
|
|
115
|
+
const detected = detectStyle(font);
|
|
116
|
+
this._faces.push({
|
|
117
|
+
font,
|
|
118
|
+
family: (opts.family || font.familyName || '').toLowerCase(),
|
|
119
|
+
weight: opts.weight !== undefined ? numericWeight(opts.weight) : detected.weight,
|
|
120
|
+
italic:
|
|
121
|
+
opts.style !== undefined ? String(opts.style).includes('italic') : detected.italic,
|
|
122
|
+
candidate: null
|
|
123
|
+
});
|
|
124
|
+
return font;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** map a generic family ('sans-serif', 'monospace', …) to an added family */
|
|
128
|
+
alias(name, family) {
|
|
129
|
+
this._aliases.set(name.toLowerCase(), family.toLowerCase());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
matchSorted(pattern = {}) {
|
|
133
|
+
if (this._faces.length === 0) {
|
|
134
|
+
throw new Error('StaticFontSource: no fonts added');
|
|
135
|
+
}
|
|
136
|
+
const families = parseFamilies(pattern.family).map((f) => this._aliases.get(f) ?? f);
|
|
137
|
+
const weight = numericWeight(pattern.weight);
|
|
138
|
+
const italic = !!(pattern.style && String(pattern.style).includes('italic'));
|
|
139
|
+
|
|
140
|
+
const scored = this._faces.map((face) => {
|
|
141
|
+
let rank = families.indexOf(face.family);
|
|
142
|
+
if (rank === -1) rank = families.length; // non-matching faces trail
|
|
143
|
+
return {
|
|
144
|
+
face,
|
|
145
|
+
score:
|
|
146
|
+
rank * 1e6 + (face.italic !== italic ? 1e4 : 0) + Math.abs(face.weight - weight)
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
scored.sort((a, b) => a.score - b.score);
|
|
150
|
+
return scored.map(({ face }) => {
|
|
151
|
+
if (!face.candidate) {
|
|
152
|
+
face.candidate = { key: face.font.key, font: face.font };
|
|
153
|
+
}
|
|
154
|
+
return face.candidate;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
covers(candidate, codepoint) {
|
|
159
|
+
return candidate.font.hasGlyph(codepoint);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let _default = null;
|
|
164
|
+
|
|
165
|
+
/** the process-wide default FontSource (fontconfig unless overridden) */
|
|
166
|
+
export function defaultFontSource() {
|
|
167
|
+
if (!_default) _default = new FontconfigFontSource();
|
|
168
|
+
return _default;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replace the process-wide default FontSource. Affects every FontManager
|
|
173
|
+
* created afterwards without an explicit source — including the ones
|
|
174
|
+
* widgets create internally. The primary hook for browser playgrounds:
|
|
175
|
+
* call it once with a StaticFontSource before creating any app/window.
|
|
176
|
+
*/
|
|
177
|
+
export function setDefaultFontSource(source) {
|
|
178
|
+
_default = source;
|
|
179
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import GlyphSet from '../glyphset.js';
|
|
2
|
+
import Picture from '../picture.js';
|
|
3
|
+
import Pixmap from '../pixmap.js';
|
|
4
|
+
import { trapezoidize } from '../trapezoid.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Text rendering policy — the bitmap/vector routing thresholds and the
|
|
8
|
+
* server-side glyph cache budget. Override per app via `app.textPolicy`
|
|
9
|
+
* (partial objects are fine, e.g. `app.textPolicy = { vectorFrom: Infinity }`
|
|
10
|
+
* to opt out of the vector path entirely). See docs/text.md.
|
|
11
|
+
*
|
|
12
|
+
* - `bitmapMax` — at or below this pixel size glyphs always render through
|
|
13
|
+
* cached server-side bitmaps (upload once, ~1 byte per glyph per draw).
|
|
14
|
+
* - `vectorFrom` — above this size glyphs render as trapezoids every draw:
|
|
15
|
+
* bitmap uploads scale with size² (a full Latin set at 256px is ~2.4MB of
|
|
16
|
+
* server cache per face) while outlines scale with ~√size.
|
|
17
|
+
* - Between the two, bitmaps are used unless the size is fractional or the
|
|
18
|
+
* face shows no size reuse across recent draws (continuous zoom/pinch
|
|
19
|
+
* animation) — the cases where per-size caches never amortize.
|
|
20
|
+
* - `cacheBytes` — LRU budget for uploaded glyph bitmaps per connection;
|
|
21
|
+
* least-recently-drawn (face, size) pages are freed server-side
|
|
22
|
+
* (FreeGlyphSet) so transient sizes don't accumulate.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_TEXT_POLICY = {
|
|
25
|
+
bitmapMax: 128,
|
|
26
|
+
vectorFrom: 256,
|
|
27
|
+
cacheBytes: 8 << 20
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function policyOf(app) {
|
|
31
|
+
return app.textPolicy ? { ...DEFAULT_TEXT_POLICY, ...app.textPolicy } : DEFAULT_TEXT_POLICY;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Server-side glyph cache for one (font face, pixel size) pair.
|
|
36
|
+
*
|
|
37
|
+
* Wire-efficiency design (see docs/text.md):
|
|
38
|
+
*
|
|
39
|
+
* - Glyph ids inside an XRender glyphset are client-assigned. We assign
|
|
40
|
+
* **compact sequential ids** (0, 1, 2, …) in upload order instead of using
|
|
41
|
+
* font glyph indices, so CompositeGlyphs can use the 8-bit encoding
|
|
42
|
+
* (1 byte per glyph) for the first 256 distinct glyphs of a face/size —
|
|
43
|
+
* which covers virtually all Latin text — and 16-bit after that.
|
|
44
|
+
* - Each glyph's rounded nominal advance is baked into the glyph itself
|
|
45
|
+
* (`offX`), so the server advances the pen automatically and runs of
|
|
46
|
+
* unkerned text need no per-glyph position data at all.
|
|
47
|
+
* - Bitmaps upload lazily, once per glyph, batched into one AddGlyphs
|
|
48
|
+
* request per draw that introduces new glyphs.
|
|
49
|
+
*/
|
|
50
|
+
export class GlyphPage {
|
|
51
|
+
constructor(app, font, size) {
|
|
52
|
+
this.app = app;
|
|
53
|
+
this.font = font;
|
|
54
|
+
this.size = size;
|
|
55
|
+
this.glyphset = new GlyphSet(app);
|
|
56
|
+
this.entries = new Map(); // font glyph id -> { lid, adv }
|
|
57
|
+
this.bytes = 0; // uploaded bitmap bytes, for the LRU budget
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** number of bits per glyph needed to address this page's ids */
|
|
61
|
+
get bits() {
|
|
62
|
+
return this.entries.size <= 256 ? 8 : 16;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure all glyphs of a shaped run are uploaded; returns nothing.
|
|
67
|
+
* New glyphs are rasterized and sent in a single AddGlyphs request.
|
|
68
|
+
*/
|
|
69
|
+
ensure(glyphs) {
|
|
70
|
+
let batch = null;
|
|
71
|
+
for (const g of glyphs) {
|
|
72
|
+
if (this.entries.has(g.id)) continue;
|
|
73
|
+
const lid = this.entries.size;
|
|
74
|
+
if (lid >= 65536) {
|
|
75
|
+
// 2^16 distinct glyphs at one size — not reachable in practice
|
|
76
|
+
// (fonts cap at 65535 glyphs) but fail loudly rather than corrupt
|
|
77
|
+
throw new Error('glyph page overflow');
|
|
78
|
+
}
|
|
79
|
+
const adv = Math.round(this.font.advanceOf(g.id, this.size));
|
|
80
|
+
const bitmap = this.font.rasterize(g.id, this.size);
|
|
81
|
+
this.entries.set(g.id, { lid, adv });
|
|
82
|
+
if (!batch) batch = [];
|
|
83
|
+
batch.push({
|
|
84
|
+
id: lid,
|
|
85
|
+
width: bitmap ? bitmap.width : 0,
|
|
86
|
+
height: bitmap ? bitmap.height : 0,
|
|
87
|
+
// XRender GLYPHINFO places the image at origin - (x, y); node-x11
|
|
88
|
+
// packs -x and +y, so x here is the bitmap's left bearing and y its
|
|
89
|
+
// (negated, y-down) top — i.e. the ascent above the baseline
|
|
90
|
+
x: bitmap ? bitmap.left : 0,
|
|
91
|
+
y: bitmap ? -bitmap.top : 0,
|
|
92
|
+
// node-x11 AddGlyphs expects 26.6 fixed point and divides by 64
|
|
93
|
+
offX: adv * 64,
|
|
94
|
+
offY: 0,
|
|
95
|
+
image: bitmap ? bitmap.data : Buffer.alloc(0)
|
|
96
|
+
});
|
|
97
|
+
this.bytes += bitmap ? bitmap.data.length : 0;
|
|
98
|
+
}
|
|
99
|
+
if (batch) this.glyphset.addGlyphs(batch);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
entry(fontGlyphId) {
|
|
103
|
+
return this.entries.get(fontGlyphId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** free the server-side glyphset (LRU eviction / shutdown) */
|
|
107
|
+
destroy() {
|
|
108
|
+
this.glyphset.destroy();
|
|
109
|
+
this.entries.clear();
|
|
110
|
+
this.bytes = 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** per-app page cache: pages (and their server glyphsets) are shared by all
|
|
115
|
+
* windows/pixmaps of a connection. Access refreshes LRU order. */
|
|
116
|
+
export function getGlyphPage(app, font, size) {
|
|
117
|
+
if (!app._glyphPages) app._glyphPages = new Map();
|
|
118
|
+
const key = `${font.key}@${size}`;
|
|
119
|
+
let page = app._glyphPages.get(key);
|
|
120
|
+
if (page) {
|
|
121
|
+
// Map iteration order is insertion order — re-insert to mark recent
|
|
122
|
+
app._glyphPages.delete(key);
|
|
123
|
+
} else {
|
|
124
|
+
page = new GlyphPage(app, font, size);
|
|
125
|
+
}
|
|
126
|
+
app._glyphPages.set(key, page);
|
|
127
|
+
return page;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Evict least-recently-used glyph pages until uploaded bitmaps fit the
|
|
132
|
+
* policy budget. `inUse` pages (referenced by requests queued this draw)
|
|
133
|
+
* are never evicted. Freeing is safe request-ordering-wise: FreeGlyphSet is
|
|
134
|
+
* queued after any CompositeGlyphs already issued on this connection.
|
|
135
|
+
*/
|
|
136
|
+
export function trimGlyphPages(app, policy = policyOf(app), inUse = null) {
|
|
137
|
+
const pages = app._glyphPages;
|
|
138
|
+
if (!pages) return;
|
|
139
|
+
let total = 0;
|
|
140
|
+
for (const page of pages.values()) total += page.bytes;
|
|
141
|
+
for (const [key, page] of pages) {
|
|
142
|
+
if (total <= policy.cacheBytes) break;
|
|
143
|
+
if (inUse && inUse.has(page)) continue;
|
|
144
|
+
pages.delete(key);
|
|
145
|
+
total -= page.bytes;
|
|
146
|
+
page.destroy();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const MAX_ELT_GLYPHS = 254; // 255 is the glyphset-switch marker
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encode positioned glyphs into a minimal CompositeGlyphs elt list.
|
|
154
|
+
*
|
|
155
|
+
* `items` are visual-order glyphs with absolute integer device positions:
|
|
156
|
+
* { gs: glyphsetId, lid: localGlyphId, adv: storedIntAdvance, x, y }
|
|
157
|
+
*
|
|
158
|
+
* The encoder tracks the server-side pen (which auto-advances by each
|
|
159
|
+
* glyph's stored `adv`) and emits position data only when the desired
|
|
160
|
+
* position deviates from it — i.e. at the start, on kerning/mark offsets,
|
|
161
|
+
* on subpixel-rounding drift, and at glyphset switches. Plain text costs
|
|
162
|
+
* 1 byte per glyph (8-bit ids) plus a single 8-byte elt header.
|
|
163
|
+
*
|
|
164
|
+
* @returns {{ gsid, bits, elts }|null} arguments for Render.CompositeGlyphs;
|
|
165
|
+
* elts strings ride on the initial pen, arrays are [dx, dy, string],
|
|
166
|
+
* numbers switch glyphsets (node-x11 wire format). Null when empty.
|
|
167
|
+
*/
|
|
168
|
+
export function encodeGlyphItems(items, bits) {
|
|
169
|
+
if (items.length === 0) return null;
|
|
170
|
+
const elts = [];
|
|
171
|
+
const gsid = items[0].gs;
|
|
172
|
+
let curGs = gsid;
|
|
173
|
+
let penX = 0;
|
|
174
|
+
let penY = 0;
|
|
175
|
+
let cur = null; // { dx, dy, str }
|
|
176
|
+
|
|
177
|
+
for (const item of items) {
|
|
178
|
+
if (item.gs !== curGs) {
|
|
179
|
+
if (cur) {
|
|
180
|
+
elts.push([cur.dx, cur.dy, cur.str]);
|
|
181
|
+
cur = null;
|
|
182
|
+
}
|
|
183
|
+
elts.push(item.gs);
|
|
184
|
+
curGs = item.gs;
|
|
185
|
+
}
|
|
186
|
+
if (!cur || item.x !== penX || item.y !== penY || cur.str.length >= MAX_ELT_GLYPHS) {
|
|
187
|
+
if (cur) elts.push([cur.dx, cur.dy, cur.str]);
|
|
188
|
+
cur = { dx: item.x - penX, dy: item.y - penY, str: '' };
|
|
189
|
+
penX = item.x;
|
|
190
|
+
penY = item.y;
|
|
191
|
+
}
|
|
192
|
+
cur.str += String.fromCharCode(item.lid);
|
|
193
|
+
penX += item.adv;
|
|
194
|
+
}
|
|
195
|
+
if (cur) elts.push([cur.dx, cur.dy, cur.str]);
|
|
196
|
+
return { gsid, bits, elts };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Compute device positions for shaped runs — the single source of truth for
|
|
201
|
+
* where each glyph's origin lands, shared by the renderer and by tests that
|
|
202
|
+
* verify server output pixel-by-pixel.
|
|
203
|
+
*
|
|
204
|
+
* `positioned` is an array of { run, x, y } where `run` comes from
|
|
205
|
+
* Font.shape()/shapeText() (already in visual order) and x/y is the run's
|
|
206
|
+
* baseline origin in device space.
|
|
207
|
+
*
|
|
208
|
+
* @returns {Array<{run, glyph, x, y}>} integer glyph-origin positions
|
|
209
|
+
*/
|
|
210
|
+
export function positionGlyphs(positioned) {
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const { run, x, y } of positioned) {
|
|
213
|
+
let cursor = x;
|
|
214
|
+
for (const g of run.glyphs) {
|
|
215
|
+
out.push({ run, glyph: g, x: Math.round(cursor + g.dx), y: Math.round(y - g.dy) });
|
|
216
|
+
cursor += g.ax;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Decide how a (face, size) renders: cached bitmap glyphs or per-draw
|
|
224
|
+
* trapezoids. See DEFAULT_TEXT_POLICY for the reasoning; the middle band
|
|
225
|
+
* additionally watches a small ring of recently drawn sizes per face — a
|
|
226
|
+
* continuously animated size (zoom/pinch) never repeats, so it routes to
|
|
227
|
+
* vector where per-size caches would leak a glyphset every frame.
|
|
228
|
+
*
|
|
229
|
+
* @returns {'bitmap'|'vector'}
|
|
230
|
+
*/
|
|
231
|
+
export function routeGlyphSize(app, font, size, policy = policyOf(app)) {
|
|
232
|
+
if (size <= policy.bitmapMax) return 'bitmap';
|
|
233
|
+
if (size > policy.vectorFrom) return 'vector';
|
|
234
|
+
|
|
235
|
+
if (!app._sizeRings) app._sizeRings = new Map();
|
|
236
|
+
let ring = app._sizeRings.get(font.key);
|
|
237
|
+
if (!ring) {
|
|
238
|
+
ring = [];
|
|
239
|
+
app._sizeRings.set(font.key, ring);
|
|
240
|
+
}
|
|
241
|
+
const reused = ring.includes(size);
|
|
242
|
+
// dedupe consecutive entries so one frame drawing many runs at one size
|
|
243
|
+
// occupies a single slot — the ring then spans ~8 distinct frames
|
|
244
|
+
if (ring[ring.length - 1] !== size) {
|
|
245
|
+
ring.push(size);
|
|
246
|
+
if (ring.length > 8) ring.shift();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!Number.isInteger(size)) return 'vector';
|
|
250
|
+
if (!reused && new Set(ring).size >= 6) return 'vector'; // size churn: animating
|
|
251
|
+
return 'bitmap';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Draw shaped runs. Routes each run through the bitmap path (cached
|
|
256
|
+
* server-side glyphs, one CompositeGlyphs request) or the vector path
|
|
257
|
+
* (one AddTraps + Composite through a shared scratch a8 mask) according to
|
|
258
|
+
* `routeGlyphSize`.
|
|
259
|
+
*
|
|
260
|
+
* @param {App} app
|
|
261
|
+
* @param {number} op Render.PictOp
|
|
262
|
+
* @param {number} srcId source picture (fill color/gradient)
|
|
263
|
+
* @param {number} dstId destination picture
|
|
264
|
+
* @param {Array<{run, x, y}>} positioned visual-order runs with baseline origins
|
|
265
|
+
*/
|
|
266
|
+
export function drawGlyphRuns(app, op, srcId, dstId, positioned) {
|
|
267
|
+
const policy = policyOf(app);
|
|
268
|
+
let bitmap = positioned;
|
|
269
|
+
let vector = null;
|
|
270
|
+
for (let i = 0; i < positioned.length; i++) {
|
|
271
|
+
const { run } = positioned[i];
|
|
272
|
+
if (routeGlyphSize(app, run.font, run.size, policy) === 'vector') {
|
|
273
|
+
if (!vector) {
|
|
274
|
+
vector = [];
|
|
275
|
+
bitmap = positioned.slice(0, i);
|
|
276
|
+
}
|
|
277
|
+
vector.push(positioned[i]);
|
|
278
|
+
} else if (vector) {
|
|
279
|
+
bitmap.push(positioned[i]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (bitmap.length) drawBitmapGlyphRuns(app, op, srcId, dstId, bitmap, policy);
|
|
283
|
+
if (vector) drawVectorGlyphRuns(app, op, srcId, dstId, vector);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy) {
|
|
287
|
+
const Render = app.display.Render;
|
|
288
|
+
const items = [];
|
|
289
|
+
let bits = 8;
|
|
290
|
+
const pages = new Map();
|
|
291
|
+
for (const { run } of positioned) {
|
|
292
|
+
const page = getGlyphPage(app, run.font, run.size);
|
|
293
|
+
page.ensure(run.glyphs);
|
|
294
|
+
pages.set(run, page);
|
|
295
|
+
}
|
|
296
|
+
for (const page of pages.values()) {
|
|
297
|
+
if (page.bits > bits) bits = page.bits;
|
|
298
|
+
}
|
|
299
|
+
for (const pos of positionGlyphs(positioned)) {
|
|
300
|
+
const page = pages.get(pos.run);
|
|
301
|
+
const e = page.entry(pos.glyph.id);
|
|
302
|
+
items.push({ gs: page.glyphset.id, lid: e.lid, adv: e.adv, x: pos.x, y: pos.y });
|
|
303
|
+
}
|
|
304
|
+
const encoded = encodeGlyphItems(items, bits);
|
|
305
|
+
if (!encoded) return;
|
|
306
|
+
// srcX/srcY align the source with the FIRST glyph's origin (RENDER spec);
|
|
307
|
+
// passing that origin itself makes source coordinates equal destination
|
|
308
|
+
// coordinates, so gradient fill styles line up with canvas space
|
|
309
|
+
Render.CompositeGlyphs(
|
|
310
|
+
encoded.bits,
|
|
311
|
+
op,
|
|
312
|
+
srcId,
|
|
313
|
+
dstId,
|
|
314
|
+
0,
|
|
315
|
+
encoded.gsid,
|
|
316
|
+
items[0].x,
|
|
317
|
+
items[0].y,
|
|
318
|
+
encoded.elts
|
|
319
|
+
);
|
|
320
|
+
trimGlyphPages(app, policy, new Set(pages.values()));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// AddTraps length field is a plain 16-bit request length (no BigReq in
|
|
324
|
+
// node-x11's encoder): stay well under 65535 4-byte units per request
|
|
325
|
+
const MAX_TRAPS_PER_REQUEST = 6000;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Vector glyph path (issue #45): flatten outlines at the exact (fractional)
|
|
329
|
+
* size, trapezoidate, accumulate every glyph of the draw into one scratch a8
|
|
330
|
+
* mask with a single batched AddTraps, then one Composite to the target.
|
|
331
|
+
* Nothing is cached server-side, so continuously animated sizes cost no
|
|
332
|
+
* cache memory; wire bytes scale with outline complexity (~√size), which
|
|
333
|
+
* beats size²-scaling bitmap uploads above ~128-256px.
|
|
334
|
+
*
|
|
335
|
+
* Positions are intentionally NOT rounded to whole pixels — fractional
|
|
336
|
+
* advances and origins keep zoom animations smooth.
|
|
337
|
+
*/
|
|
338
|
+
function drawVectorGlyphRuns(app, op, srcId, dstId, positioned) {
|
|
339
|
+
// unrounded glyph origins + ink bounding box
|
|
340
|
+
const placed = [];
|
|
341
|
+
let minX = Infinity;
|
|
342
|
+
let minY = Infinity;
|
|
343
|
+
for (const { run, x, y } of positioned) {
|
|
344
|
+
let cursor = x;
|
|
345
|
+
for (const g of run.glyphs) {
|
|
346
|
+
const gx = cursor + g.dx;
|
|
347
|
+
const gy = y - g.dy;
|
|
348
|
+
cursor += g.ax;
|
|
349
|
+
const e = run.font.glyphExtents(g.id, run.size);
|
|
350
|
+
if (!Number.isFinite(e.minX) || e.maxX <= e.minX) continue; // blank glyph
|
|
351
|
+
placed.push({ run, glyph: g, x: gx, y: gy });
|
|
352
|
+
if (gx + e.minX < minX) minX = gx + e.minX;
|
|
353
|
+
if (gy + e.minY < minY) minY = gy + e.minY;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (placed.length === 0) return;
|
|
357
|
+
|
|
358
|
+
const bx = Math.floor(minX) - 1;
|
|
359
|
+
const by = Math.floor(minY) - 1;
|
|
360
|
+
|
|
361
|
+
const traps = [];
|
|
362
|
+
for (const p of placed) {
|
|
363
|
+
trapezoidize(p.run.font.outline(p.glyph.id, p.run.size), p.x - bx, p.y - by, traps);
|
|
364
|
+
}
|
|
365
|
+
compositeTraps(app, op, srcId, dstId, traps, bx, by);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Rasterize trapezoids (relative to (bx, by) device coordinates) into the
|
|
370
|
+
* shared scratch a8 mask with batched AddTraps requests, then Composite the
|
|
371
|
+
* source through it onto the destination. Shared by the vector glyph path
|
|
372
|
+
* and by widgets that fill arbitrary outlines (e.g. TeX radicals).
|
|
373
|
+
*
|
|
374
|
+
* When bx/by are omitted they are derived from the trapezoid bounds (the
|
|
375
|
+
* trap coordinates are then treated as absolute device coordinates).
|
|
376
|
+
*/
|
|
377
|
+
export function compositeTraps(app, op, srcId, dstId, traps, bx, by) {
|
|
378
|
+
if (traps.length === 0) return;
|
|
379
|
+
const Render = app.display.Render;
|
|
380
|
+
|
|
381
|
+
let maxX = -Infinity;
|
|
382
|
+
let maxY = -Infinity;
|
|
383
|
+
if (bx === undefined) {
|
|
384
|
+
let minX = Infinity;
|
|
385
|
+
let minY = Infinity;
|
|
386
|
+
for (let i = 0; i < traps.length; i += 6) {
|
|
387
|
+
if (traps[i] < minX) minX = traps[i];
|
|
388
|
+
if (traps[i + 3] < minX) minX = traps[i + 3];
|
|
389
|
+
if (traps[i + 1] > maxX) maxX = traps[i + 1];
|
|
390
|
+
if (traps[i + 4] > maxX) maxX = traps[i + 4];
|
|
391
|
+
if (traps[i + 2] < minY) minY = traps[i + 2];
|
|
392
|
+
if (traps[i + 5] > maxY) maxY = traps[i + 5];
|
|
393
|
+
}
|
|
394
|
+
bx = Math.floor(minX);
|
|
395
|
+
by = Math.floor(minY);
|
|
396
|
+
const shifted = new Array(traps.length);
|
|
397
|
+
for (let i = 0; i < traps.length; i += 6) {
|
|
398
|
+
shifted[i] = traps[i] - bx;
|
|
399
|
+
shifted[i + 1] = traps[i + 1] - bx;
|
|
400
|
+
shifted[i + 2] = traps[i + 2] - by;
|
|
401
|
+
shifted[i + 3] = traps[i + 3] - bx;
|
|
402
|
+
shifted[i + 4] = traps[i + 4] - bx;
|
|
403
|
+
shifted[i + 5] = traps[i + 5] - by;
|
|
404
|
+
}
|
|
405
|
+
traps = shifted;
|
|
406
|
+
maxX -= bx;
|
|
407
|
+
maxY -= by;
|
|
408
|
+
} else {
|
|
409
|
+
for (let i = 0; i < traps.length; i += 6) {
|
|
410
|
+
if (traps[i + 1] > maxX) maxX = traps[i + 1];
|
|
411
|
+
if (traps[i + 4] > maxX) maxX = traps[i + 4];
|
|
412
|
+
if (traps[i + 5] > maxY) maxY = traps[i + 5];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const bw = Math.max(1, Math.ceil(maxX));
|
|
416
|
+
const bh = Math.max(1, Math.ceil(maxY));
|
|
417
|
+
|
|
418
|
+
const mask = scratchMask(app, bw, bh);
|
|
419
|
+
Render.FillRectangles(Render.PictOp.Src, mask.picture.id, [0, 0, 0, 0], [0, 0, bw, bh]);
|
|
420
|
+
for (let i = 0; i < traps.length; i += MAX_TRAPS_PER_REQUEST * 6) {
|
|
421
|
+
Render.AddTraps(mask.picture.id, 0, 0, traps.slice(i, i + MAX_TRAPS_PER_REQUEST * 6));
|
|
422
|
+
}
|
|
423
|
+
// src coords = dst coords (see drawBitmapGlyphRuns) so gradients line up
|
|
424
|
+
Render.Composite(op, srcId, mask.picture.id, dstId, bx, by, 0, 0, bx, by, bw, bh);
|
|
425
|
+
releaseScratchMask(app);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// One connection-wide scratch a8 pixmap for trap masks, grown as needed and
|
|
429
|
+
// dropped again when a huge draw would otherwise pin megabytes server-side.
|
|
430
|
+
const SCRATCH_KEEP_AREA = 1 << 21; // ~2MB of a8
|
|
431
|
+
|
|
432
|
+
function scratchMask(app, w, h) {
|
|
433
|
+
let s = app._trapScratch;
|
|
434
|
+
if (!s || s.width < w || s.height < h) {
|
|
435
|
+
if (s) {
|
|
436
|
+
s.picture.destroy();
|
|
437
|
+
s.pixmap.destroy();
|
|
438
|
+
}
|
|
439
|
+
const width = Math.max(w, s ? s.width : 0);
|
|
440
|
+
const height = Math.max(h, s ? s.height : 0);
|
|
441
|
+
const pixmap = new Pixmap(app, { depth: 8, width, height });
|
|
442
|
+
const picture = new Picture(app, { drawable: pixmap, format: app.display.Render.a8 });
|
|
443
|
+
s = { pixmap, picture, width, height };
|
|
444
|
+
app._trapScratch = s;
|
|
445
|
+
}
|
|
446
|
+
return s;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function releaseScratchMask(app) {
|
|
450
|
+
const s = app._trapScratch;
|
|
451
|
+
if (s && s.width * s.height > SCRATCH_KEEP_AREA) {
|
|
452
|
+
s.picture.destroy();
|
|
453
|
+
s.pixmap.destroy();
|
|
454
|
+
app._trapScratch = null;
|
|
455
|
+
}
|
|
456
|
+
}
|