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.
@@ -1,5 +1,10 @@
1
1
  import Font from './font.js';
2
- import { defaultFontSource, detectStyle, numericWeight as numWeight } from './fontsource.js';
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
- this._source = source ?? null; // resolved lazily so the process-wide default can be set late
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
- const candidates = source.matchSorted({ family, weight: opts.weight, style: opts.style });
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 {
@@ -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
- import { charsetHas, matchSortedSync } from '../fontconfig.js';
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 new Error('StaticFontSource: no fonts added');
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
  }
@@ -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(this._root, ctx, { ...INHERITED }, 1, 0);
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