ntk 5.2.0 → 5.4.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/lib/app.js +4 -2
- package/lib/clipboard.js +652 -83
- package/lib/fontconfig.js +99 -12
- package/lib/image.js +13 -19
- package/lib/imagedata.js +388 -0
- package/lib/index.js +32 -2
- package/lib/renderingcontext_2d.js +416 -42
- package/lib/surface.js +118 -0
- package/lib/text/fontmanager.js +27 -4
- package/lib/text/fontsource.js +255 -3
- package/lib/widgets/svgview.js +118 -2
- package/lib/window.js +137 -1
- package/package.json +1 -1
package/lib/text/fontmanager.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import Font from './font.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createFontSource,
|
|
4
|
+
defaultFontSource,
|
|
5
|
+
detectStyle,
|
|
6
|
+
numericWeight as numWeight
|
|
7
|
+
} from './fontsource.js';
|
|
3
8
|
import { shapeText } from './shape.js';
|
|
4
9
|
import { TextLayout } from './layout.js';
|
|
5
10
|
|
|
@@ -21,11 +26,14 @@ import { TextLayout } from './layout.js';
|
|
|
21
26
|
*
|
|
22
27
|
* All system lookup goes through a pluggable FontSource (see
|
|
23
28
|
* text/fontsource.js) — pass `{ source }` to use something other than
|
|
24
|
-
* fontconfig, e.g. a StaticFontSource in a browser bundle.
|
|
29
|
+
* fontconfig, e.g. a StaticFontSource in a browser bundle. `source` also
|
|
30
|
+
* takes a font spec: a path, a directory of faces, or font bytes.
|
|
25
31
|
*/
|
|
26
32
|
export default class FontManager {
|
|
27
33
|
constructor({ source } = {}) {
|
|
28
|
-
|
|
34
|
+
// coerced here rather than in the getter, which is on the match path;
|
|
35
|
+
// null stays null so the process-wide default can still be set late
|
|
36
|
+
this._source = createFontSource(source) ?? null;
|
|
29
37
|
this._fonts = new Map(); // candidate key -> Font
|
|
30
38
|
this._matches = new Map(); // family|weight|style -> Font
|
|
31
39
|
this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
|
|
@@ -146,6 +154,13 @@ export default class FontManager {
|
|
|
146
154
|
* font doesn't. Registered fonts first, then the source's fallback chain
|
|
147
155
|
* (filtered by the source's coverage data — font files are only opened to
|
|
148
156
|
* confirm). Returns null when nothing on the system covers the codepoint.
|
|
157
|
+
*
|
|
158
|
+
* "Nothing covers it" includes "this environment has no system fonts at
|
|
159
|
+
* all". An app that loaded its own faces still reaches here for the first
|
|
160
|
+
* character they lack — a bullet, a curly quote — and before this returned
|
|
161
|
+
* null there, a fontconfig-less box crashed mid-shape on that character,
|
|
162
|
+
* arbitrarily far from anything about fonts. `shapeText` renders .notdef
|
|
163
|
+
* for a null, which is the right answer to "no font has this glyph".
|
|
149
164
|
*/
|
|
150
165
|
fallbackFor(codepoint, family = 'sans-serif', opts = {}) {
|
|
151
166
|
const cacheKey = `${family}|${numWeight(opts.weight)}|${opts.style || ''}`;
|
|
@@ -165,7 +180,15 @@ export default class FontManager {
|
|
|
165
180
|
}
|
|
166
181
|
if (!found) {
|
|
167
182
|
const source = this.source;
|
|
168
|
-
|
|
183
|
+
let candidates;
|
|
184
|
+
try {
|
|
185
|
+
candidates = source.matchSorted({ family, weight: opts.weight, style: opts.style });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// no system fonts to fall back to is an answer, not a crash; a source
|
|
188
|
+
// that failed for any other reason is a real bug and still propagates
|
|
189
|
+
if (err.code !== 'ERR_NTK_NO_FONTS') throw err;
|
|
190
|
+
candidates = [];
|
|
191
|
+
}
|
|
169
192
|
for (const c of candidates) {
|
|
170
193
|
if (source.covers && !source.covers(c, codepoint)) continue;
|
|
171
194
|
try {
|
package/lib/text/fontsource.js
CHANGED
|
@@ -23,7 +23,18 @@
|
|
|
23
23
|
// The default source shells out to fc-match (fontconfig) — the behavior ntk
|
|
24
24
|
// always had. Swap it per-app (`createClient({ fontSource })`), per-manager
|
|
25
25
|
// (`new FontManager({ source })`) or globally (`setDefaultFontSource()`).
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// All three of those also accept a **font spec** — a path, a directory, font
|
|
28
|
+
// bytes, or a list of them — which `createFontSource` turns into a
|
|
29
|
+
// StaticFontSource. That is the whole answer for an environment with no
|
|
30
|
+
// fontconfig: ntk ships no fonts, so the app has to hand over the ones it
|
|
31
|
+
// ships, and the spec is the short way to say so.
|
|
32
|
+
//
|
|
33
|
+
// node:fs is fetched through `getBuiltinModule` inside the path branch rather
|
|
34
|
+
// than imported, because this file is the one the browser bundle keeps — its
|
|
35
|
+
// whole purpose is running the text stack without a filesystem. Do not turn
|
|
36
|
+
// that into a static import (test/packaging.test.js fails if you do).
|
|
37
|
+
import { charsetHas, matchSortedSync, noFontsError, supported } from '../fontconfig.js';
|
|
27
38
|
import Font from './font.js';
|
|
28
39
|
|
|
29
40
|
const WEIGHTS = { normal: 400, bold: 700 };
|
|
@@ -53,6 +64,23 @@ export function detectStyle(font) {
|
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
// A font is monospaced if it says so, or if it draws a period as wide as a W.
|
|
68
|
+
// The probe is the part that carries its weight — `post.isFixedPitch` is 0 on
|
|
69
|
+
// plenty of genuinely fixed-pitch faces.
|
|
70
|
+
const PROBE = ['i', 'M', 'W', '.'].map((c) => c.codePointAt(0));
|
|
71
|
+
|
|
72
|
+
function isMonospaced(font) {
|
|
73
|
+
if (font.fk.post?.isFixedPitch) return true;
|
|
74
|
+
let width = null;
|
|
75
|
+
for (const cp of PROBE) {
|
|
76
|
+
if (!font.hasGlyph(cp)) return false;
|
|
77
|
+
const advance = font.fk.glyphForCodePoint(cp).advanceWidth;
|
|
78
|
+
if (width === null) width = advance;
|
|
79
|
+
else if (advance !== width) return false;
|
|
80
|
+
}
|
|
81
|
+
return width !== null && width > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/** split a CSS font-family list into normalized lowercase names */
|
|
57
85
|
export function parseFamilies(family) {
|
|
58
86
|
return String(family ?? '')
|
|
@@ -95,6 +123,8 @@ export class StaticFontSource {
|
|
|
95
123
|
this._faces = []; // { font, family, weight, italic, candidate }
|
|
96
124
|
this._aliases = new Map(); // 'sans-serif' -> 'dejavu sans'
|
|
97
125
|
this._n = 0;
|
|
126
|
+
/** files a font spec could not parse: [{ file, error }] */
|
|
127
|
+
this.skipped = [];
|
|
98
128
|
}
|
|
99
129
|
|
|
100
130
|
/**
|
|
@@ -129,9 +159,55 @@ export class StaticFontSource {
|
|
|
129
159
|
this._aliases.set(name.toLowerCase(), family.toLowerCase());
|
|
130
160
|
}
|
|
131
161
|
|
|
162
|
+
/** what the generic families currently resolve to, for inspection */
|
|
163
|
+
get aliases() {
|
|
164
|
+
return Object.fromEntries(this._aliases);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Point `sans-serif`, `serif` and `monospace` at added faces.
|
|
169
|
+
*
|
|
170
|
+
* This is not cosmetic. Every widget default in the toolkit is
|
|
171
|
+
* `sans-serif`, and `code`/`pre` ask for `monospace`; with no alias for
|
|
172
|
+
* them `matchSorted` ranks every face equally and breaks the tie on weight
|
|
173
|
+
* distance and then insertion order, so `monospace` silently renders in a
|
|
174
|
+
* proportional face. Inference happens automatically for a source built
|
|
175
|
+
* from a font spec; a hand-built source calls this when it wants it.
|
|
176
|
+
*
|
|
177
|
+
* A generic is only aliased on positive evidence, and a generic with none
|
|
178
|
+
* is deliberately left out — its absence from `aliases` is the signal to
|
|
179
|
+
* pass an explicit one. Monospace is decided structurally, because fonts
|
|
180
|
+
* lie about it: KaTeX_Typewriter reports `isFixedPitch = 0` while every
|
|
181
|
+
* advance is 525. Sans/serif read the font's own family name, the
|
|
182
|
+
* convention DejaVu, Noto, Liberation, Source and IBM Plex all follow —
|
|
183
|
+
* never the filename, which loses on `Inter_18pt-SemiBold.ttf`.
|
|
184
|
+
*
|
|
185
|
+
* Explicit aliases are never overridden.
|
|
186
|
+
*/
|
|
187
|
+
inferGenerics() {
|
|
188
|
+
const mono = [];
|
|
189
|
+
const rest = [];
|
|
190
|
+
for (const face of this._faces) (isMonospaced(face.font) ? mono : rest).push(face);
|
|
191
|
+
|
|
192
|
+
// "sans" is checked first and its matches are then excluded, because
|
|
193
|
+
// "SansSerif" and "Sans Serif" contain "serif" — without that, the one
|
|
194
|
+
// family named for both wins the generic it is least suited to
|
|
195
|
+
const sans = rest.filter((f) => /sans/i.test(f.font.familyName || ''));
|
|
196
|
+
const serif = rest.filter((f) => !sans.includes(f) && /serif/i.test(f.font.familyName || ''));
|
|
197
|
+
const evidence = {
|
|
198
|
+
monospace: mono[0]?.family,
|
|
199
|
+
'sans-serif': sans[0]?.family,
|
|
200
|
+
serif: serif[0]?.family
|
|
201
|
+
};
|
|
202
|
+
for (const [generic, family] of Object.entries(evidence)) {
|
|
203
|
+
if (family && !this._aliases.has(generic)) this._aliases.set(generic, family);
|
|
204
|
+
}
|
|
205
|
+
return this;
|
|
206
|
+
}
|
|
207
|
+
|
|
132
208
|
matchSorted(pattern = {}) {
|
|
133
209
|
if (this._faces.length === 0) {
|
|
134
|
-
throw
|
|
210
|
+
throw noFontsError('this font source has no fonts added');
|
|
135
211
|
}
|
|
136
212
|
const families = parseFamilies(pattern.family).map((f) => this._aliases.get(f) ?? f);
|
|
137
213
|
const weight = numericWeight(pattern.weight);
|
|
@@ -160,6 +236,179 @@ export class StaticFontSource {
|
|
|
160
236
|
}
|
|
161
237
|
}
|
|
162
238
|
|
|
239
|
+
// A directory of an app's own faces is a handful of files; a system font
|
|
240
|
+
// tree is hundreds, and every one of them is parsed and then retained for
|
|
241
|
+
// the life of the FontManager (one macOS emoji collection alone is 188 MB).
|
|
242
|
+
// Rather than document that and hope, the walk stops and names the option —
|
|
243
|
+
// an app that really means it says so.
|
|
244
|
+
const MAX_FILES = 64;
|
|
245
|
+
const MAX_DEPTH = 8;
|
|
246
|
+
|
|
247
|
+
// Said whenever ntk is handed something it cannot read as fonts. It names
|
|
248
|
+
// every accepted shape rather than the one that was wrong, and states the
|
|
249
|
+
// premise — ntk has no fonts of its own — because the spec that brings people
|
|
250
|
+
// here is `'bundled'`, from an issue proposing a font package that does not
|
|
251
|
+
// exist. Naming that value in the matcher would enshrine an API that never
|
|
252
|
+
// shipped; this answers the question without making it real.
|
|
253
|
+
const ACCEPTED =
|
|
254
|
+
". Pass 'system', a path to a font file or directory, font bytes, an array of " +
|
|
255
|
+
'those, { fonts, alias }, or a FontSource. ntk ships no fonts of its own — see ' +
|
|
256
|
+
'docs/fonts.md';
|
|
257
|
+
|
|
258
|
+
function fs() {
|
|
259
|
+
const mod = globalThis.process?.getBuiltinModule?.('node:fs');
|
|
260
|
+
if (!mod) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'ntk: a font path can only be read in node. In a browser, fetch the font ' +
|
|
263
|
+
'bytes yourself and pass them: fontSource: [bytes] — see docs/fonts.md'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return mod;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** stat a spec'd path, reporting a missing one as the spec mistake it is
|
|
270
|
+
* rather than as a bare ENOENT from somewhere inside ntk */
|
|
271
|
+
function statOf(path) {
|
|
272
|
+
try {
|
|
273
|
+
return fs().statSync(path);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err.code === 'ENOENT') {
|
|
276
|
+
// A bare word is far more likely a spec someone invented — 'bundled' is
|
|
277
|
+
// the one the issue asks for — than a path they mistyped. Answer the
|
|
278
|
+
// question they were actually asking.
|
|
279
|
+
const guess = /[/\\.]/.test(path) ? '' : ACCEPTED;
|
|
280
|
+
throw new Error(`ntk: no such font file or directory: "${path}"${guess}`, { cause: err });
|
|
281
|
+
}
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** every font file in a directory, sorted — never readdir order, which would
|
|
287
|
+
* let the filesystem pick which face `sans-serif` lands on */
|
|
288
|
+
function fontFilesIn(dir, recursive, budget, depth = 0) {
|
|
289
|
+
const { readdirSync } = fs();
|
|
290
|
+
const found = [];
|
|
291
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
292
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const path = `${dir}/${entry.name}`;
|
|
295
|
+
// isDirectory() is false for a symlink, so this skips linked directories
|
|
296
|
+
// and with them any chance of a cycle
|
|
297
|
+
if (entry.isDirectory()) {
|
|
298
|
+
if (recursive && depth < MAX_DEPTH) found.push(...fontFilesIn(path, true, budget, depth + 1));
|
|
299
|
+
} else if (supported.test(entry.name)) {
|
|
300
|
+
found.push(path);
|
|
301
|
+
if (++budget.count > budget.max) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`ntk: more than ${budget.max} font files under "${budget.root}". That is a font ` +
|
|
304
|
+
"tree rather than an app's own faces, and every one of them would be parsed and " +
|
|
305
|
+
'kept in memory for the life of the process. List the faces you want, or raise ' +
|
|
306
|
+
`the limit deliberately: { fonts: '${budget.root}', maxFiles: N } — see docs/fonts.md`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return found;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** does this look like a face rather than a typo? */
|
|
315
|
+
function isFace(face) {
|
|
316
|
+
return (
|
|
317
|
+
typeof face === 'string' ||
|
|
318
|
+
ArrayBuffer.isView(face) ||
|
|
319
|
+
face instanceof ArrayBuffer ||
|
|
320
|
+
(!!face && typeof face === 'object' && (typeof face.path === 'string' || face.data != null))
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function addFace(source, face, skipped) {
|
|
325
|
+
try {
|
|
326
|
+
if (typeof face === 'string') return source.add(Font.loadSync(face));
|
|
327
|
+
if (ArrayBuffer.isView(face) || face instanceof ArrayBuffer) return source.add(face);
|
|
328
|
+
const { path, data, ...opts } = face;
|
|
329
|
+
return source.add(path ? Font.loadSync(path, opts.postscriptName) : data, opts);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// one unparseable file should not take a whole directory down; a spec
|
|
332
|
+
// that yields no usable face at all still throws, below
|
|
333
|
+
skipped.push({ file: typeof face === 'string' ? face : face?.path, error: err });
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function describe(value) {
|
|
339
|
+
if (typeof value === 'string') return `"${value}"`;
|
|
340
|
+
if (Array.isArray(value)) return 'that array';
|
|
341
|
+
return value === null ? 'null' : typeof value;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a **font spec** to a FontSource.
|
|
346
|
+
*
|
|
347
|
+
* ```
|
|
348
|
+
* spec := FontSource | 'system' | null | undefined
|
|
349
|
+
* | faces | { fonts: faces, alias?, recursive?, maxFiles? }
|
|
350
|
+
* faces := face | face[]
|
|
351
|
+
* face := string (path to a font FILE or DIRECTORY)
|
|
352
|
+
* | Uint8Array | Buffer | ArrayBuffer
|
|
353
|
+
* | { path | data, family?, weight?, style?, postscriptName? }
|
|
354
|
+
* ```
|
|
355
|
+
*
|
|
356
|
+
* Idempotent — a FontSource passes straight through, which is what lets
|
|
357
|
+
* `createClient`, `FontManager` and `setDefaultFontSource` all coerce
|
|
358
|
+
* without caring whether someone already did.
|
|
359
|
+
*
|
|
360
|
+
* Anything built from faces is a `StaticFontSource`, so there is one
|
|
361
|
+
* matching implementation to reason about rather than two. Generic families
|
|
362
|
+
* are inferred (see `inferGenerics`) with an explicit `alias` map winning.
|
|
363
|
+
*
|
|
364
|
+
* @param {*} spec
|
|
365
|
+
* @returns {object|null|undefined} a FontSource
|
|
366
|
+
*/
|
|
367
|
+
export function createFontSource(spec) {
|
|
368
|
+
if (spec == null) return spec; // preserves setDefaultFontSource(null)'s reset
|
|
369
|
+
if (typeof spec.matchSorted === 'function') return spec;
|
|
370
|
+
if (spec === 'system') return new FontconfigFontSource();
|
|
371
|
+
|
|
372
|
+
const configured = !Array.isArray(spec) && typeof spec === 'object' && 'fonts' in spec;
|
|
373
|
+
const { fonts, alias, recursive = false, maxFiles = MAX_FILES } = configured ? spec : { fonts: spec };
|
|
374
|
+
const list = Array.isArray(fonts) ? fonts : [fonts];
|
|
375
|
+
|
|
376
|
+
if (list.length === 0 || !list.every(isFace)) {
|
|
377
|
+
throw new Error(`ntk: ${describe(spec)} is not a font source${ACCEPTED}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const source = new StaticFontSource();
|
|
381
|
+
const skipped = [];
|
|
382
|
+
const budget = { count: 0, max: maxFiles, root: null };
|
|
383
|
+
let added = 0;
|
|
384
|
+
for (const face of list) {
|
|
385
|
+
if (typeof face === 'string' && statOf(face).isDirectory()) {
|
|
386
|
+
budget.root = face;
|
|
387
|
+
const files = fontFilesIn(face, recursive, budget);
|
|
388
|
+
if (files.length === 0) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`ntk: no font files in "${face}" — looked for .ttf/.otf/.woff/.woff2/.ttc/.dfont` +
|
|
391
|
+
(recursive ? '' : '. Pass { fonts, recursive: true } to walk subdirectories')
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
for (const file of files) if (addFace(source, file, skipped)) added++;
|
|
395
|
+
} else if (addFace(source, face, skipped)) {
|
|
396
|
+
added++;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
source.skipped = skipped;
|
|
401
|
+
if (added === 0) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`ntk: none of the ${skipped.length} font file(s) given could be parsed — ` +
|
|
404
|
+
`${skipped[0]?.file ?? 'the first'}: ${skipped[0]?.error?.message ?? 'unknown error'}`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const [generic, family] of Object.entries(alias ?? {})) source.alias(generic, family);
|
|
409
|
+
return source.inferGenerics();
|
|
410
|
+
}
|
|
411
|
+
|
|
163
412
|
let _default = null;
|
|
164
413
|
|
|
165
414
|
/** the process-wide default FontSource (fontconfig unless overridden) */
|
|
@@ -173,7 +422,10 @@ export function defaultFontSource() {
|
|
|
173
422
|
* created afterwards without an explicit source — including the ones
|
|
174
423
|
* widgets create internally. The primary hook for browser playgrounds:
|
|
175
424
|
* call it once with a StaticFontSource before creating any app/window.
|
|
425
|
+
*
|
|
426
|
+
* Takes a font spec as well as a source, so pointing the whole process at a
|
|
427
|
+
* directory is one line. `null` restores the fontconfig default.
|
|
176
428
|
*/
|
|
177
429
|
export function setDefaultFontSource(source) {
|
|
178
|
-
_default = source;
|
|
430
|
+
_default = createFontSource(source);
|
|
179
431
|
}
|
package/lib/widgets/svgview.js
CHANGED
|
@@ -202,6 +202,95 @@ function pathBBox(path) {
|
|
|
202
202
|
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* How many distinct colours a document actually commits to, decided once at
|
|
207
|
+
* parse time.
|
|
208
|
+
*
|
|
209
|
+
* `mono` means every fill and stroke that reaches a shape is `none` or the
|
|
210
|
+
* *same* paint — one literal colour, or `currentColor` throughout. Such a
|
|
211
|
+
* drawing is really a coverage mask plus a colour, so a caller can render it
|
|
212
|
+
* once and recolour it on every draw. `multi` is everything else: a second
|
|
213
|
+
* distinct paint, or a gradient/pattern reference, whose colours belong to
|
|
214
|
+
* the drawing rather than to the UI around it.
|
|
215
|
+
*
|
|
216
|
+
* Opacity does not enter into it: `opacity`, `fill-opacity` and
|
|
217
|
+
* `stroke-opacity` scale coverage, which a mask carries perfectly well.
|
|
218
|
+
*
|
|
219
|
+
* The walk mirrors `_style` and `_renderNode` — fill and stroke inherit,
|
|
220
|
+
* inline `style` beats the presentation attribute, the initial fill is black
|
|
221
|
+
* and the initial stroke is none, `<line>` never fills, `<use>` paints its
|
|
222
|
+
* target with the *use* element's style, and non-rendered subtrees
|
|
223
|
+
* contribute nothing. It starts at the root's children rather than the root,
|
|
224
|
+
* because `draw` does: presentation attributes on the root `<svg>` are not
|
|
225
|
+
* applied, and a scan that applied them would disagree with what is painted.
|
|
226
|
+
*
|
|
227
|
+
* @returns {{ kind: 'mono'|'multi', solo: string|null }} `solo` is the one
|
|
228
|
+
* paint a `mono` document uses — a colour, or the literal `'currentColor'`
|
|
229
|
+
* when the document defers to its caller — and null when nothing paints.
|
|
230
|
+
*/
|
|
231
|
+
function scanPaints(root, ids) {
|
|
232
|
+
const paints = new Set();
|
|
233
|
+
let multi = false;
|
|
234
|
+
|
|
235
|
+
const paintOf = (node, name, inherited) => {
|
|
236
|
+
let v = attr(node, name);
|
|
237
|
+
for (const decl of (node.attribs?.style || '').split(';')) {
|
|
238
|
+
const idx = decl.indexOf(':');
|
|
239
|
+
if (idx > 0 && decl.slice(0, idx).trim() === name) v = decl.slice(idx + 1);
|
|
240
|
+
}
|
|
241
|
+
if (v === undefined || v === '' || v === 'inherit') return inherited;
|
|
242
|
+
return String(v).trim();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const note = (paint) => {
|
|
246
|
+
if (paint === 'none') return;
|
|
247
|
+
if (/^url\(/i.test(paint)) multi = true;
|
|
248
|
+
else paints.add(paint);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const kids = (node, fill, stroke, depth) => {
|
|
252
|
+
for (const child of node.children || []) {
|
|
253
|
+
if (child.type === 'tag') visit(child, fill, stroke, depth + 1);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const visit = (node, fill, stroke, depth) => {
|
|
258
|
+
if (multi || depth > 32) return;
|
|
259
|
+
const name = tag(node);
|
|
260
|
+
if (NON_RENDERED.has(name)) return;
|
|
261
|
+
const f = paintOf(node, 'fill', fill);
|
|
262
|
+
const s = paintOf(node, 'stroke', stroke);
|
|
263
|
+
switch (name) {
|
|
264
|
+
case 'svg':
|
|
265
|
+
case 'g':
|
|
266
|
+
case 'a':
|
|
267
|
+
kids(node, f, s, depth);
|
|
268
|
+
return;
|
|
269
|
+
case 'use': {
|
|
270
|
+
const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
|
|
271
|
+
const target = href.startsWith('#') ? ids.get(href.slice(1)) : null;
|
|
272
|
+
if (!target) return;
|
|
273
|
+
// <symbol> is non-rendered on its own but renders through <use>
|
|
274
|
+
if (tag(target) === 'symbol') kids(target, f, s, depth);
|
|
275
|
+
else visit(target, f, s, depth + 1);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
case 'text':
|
|
279
|
+
note(f);
|
|
280
|
+
return;
|
|
281
|
+
default:
|
|
282
|
+
if (name !== 'line') note(f);
|
|
283
|
+
note(s);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
kids(root, INHERITED.fill, INHERITED.stroke, 0);
|
|
288
|
+
return {
|
|
289
|
+
kind: multi || paints.size > 1 ? 'multi' : 'mono',
|
|
290
|
+
solo: paints.size === 1 ? [...paints][0] : null
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
205
294
|
/**
|
|
206
295
|
* Widget rendering a static SVG document into a window (or any 2d context
|
|
207
296
|
* via `draw()`). Scripting, CSS stylesheets, filters, masks and external
|
|
@@ -213,12 +302,22 @@ export default class SvgView {
|
|
|
213
302
|
this.theme = { background: 'white', ...(opts.theme || {}) };
|
|
214
303
|
/** fit mode in window mode: 'contain' (default) | 'fill' */
|
|
215
304
|
this.fit = opts.fit || 'contain';
|
|
305
|
+
/**
|
|
306
|
+
* What `currentColor` resolves to, for documents that defer their colour
|
|
307
|
+
* to the surrounding UI the way an icon set does. Per-draw `opts.color`
|
|
308
|
+
* overrides it; both fall back to the CSS initial value, black.
|
|
309
|
+
*/
|
|
310
|
+
this.color = opts.color ?? INHERITED.color;
|
|
216
311
|
|
|
217
312
|
this._root = null;
|
|
218
313
|
this._ids = new Map();
|
|
219
314
|
this.naturalWidth = 0;
|
|
220
315
|
this.naturalHeight = 0;
|
|
221
316
|
this.viewBox = null;
|
|
317
|
+
/** see `scanPaints`: 'mono' | 'multi', and the single paint of a mono
|
|
318
|
+
* document — `'currentColor'` when it defers its colour to the caller */
|
|
319
|
+
this.paintKind = 'mono';
|
|
320
|
+
this.soloPaint = null;
|
|
222
321
|
|
|
223
322
|
if (this.window) {
|
|
224
323
|
this._ctx = this.window.getContext('2d');
|
|
@@ -263,6 +362,10 @@ export default class SvgView {
|
|
|
263
362
|
};
|
|
264
363
|
collect(this._root);
|
|
265
364
|
|
|
365
|
+
const scanned = scanPaints(this._root, this._ids);
|
|
366
|
+
this.paintKind = scanned.kind;
|
|
367
|
+
this.soloPaint = scanned.solo;
|
|
368
|
+
|
|
266
369
|
const a = this._root.attribs || {};
|
|
267
370
|
const vb = (attr(this._root, 'viewBox') || '')
|
|
268
371
|
.split(/[\s,]+/)
|
|
@@ -305,8 +408,15 @@ export default class SvgView {
|
|
|
305
408
|
/**
|
|
306
409
|
* Draw the document into any 2d context. `w`/`h` default to the
|
|
307
410
|
* document's natural size; the viewBox (when present) is scaled to fit.
|
|
411
|
+
*
|
|
412
|
+
* `opts.color` is what `fill="currentColor"` and `stroke="currentColor"`
|
|
413
|
+
* resolve to for this draw, overriding the view's own `color`. That is how
|
|
414
|
+
* an icon takes its colour from the UI around it — the document itself
|
|
415
|
+
* names no colour, so the same parsed document paints in whatever the
|
|
416
|
+
* caller is using, and a caller caching the result can recolour a cached
|
|
417
|
+
* `paintKind === 'mono'` drawing without re-rendering it.
|
|
308
418
|
*/
|
|
309
|
-
draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight) {
|
|
419
|
+
draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight, opts = {}) {
|
|
310
420
|
if (!this._root) return;
|
|
311
421
|
ctx.save();
|
|
312
422
|
ctx.translate(x, y);
|
|
@@ -319,7 +429,13 @@ export default class SvgView {
|
|
|
319
429
|
} else if (this.naturalWidth > 0 && this.naturalHeight > 0) {
|
|
320
430
|
ctx.scale(w / this.naturalWidth, h / this.naturalHeight);
|
|
321
431
|
}
|
|
322
|
-
this._renderChildren(
|
|
432
|
+
this._renderChildren(
|
|
433
|
+
this._root,
|
|
434
|
+
ctx,
|
|
435
|
+
{ ...INHERITED, color: opts.color ?? this.color },
|
|
436
|
+
1,
|
|
437
|
+
0
|
|
438
|
+
);
|
|
323
439
|
ctx.restore();
|
|
324
440
|
}
|
|
325
441
|
|