ntk 8.17.4 → 8.17.5

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/fontconfig.js CHANGED
@@ -93,18 +93,29 @@ export function noFontsError(reason, cause) {
93
93
  return err;
94
94
  }
95
95
 
96
- // css weight -> fontconfig weight constants
97
- const cssToFcWeight = {
98
- 100: 0, // thin
99
- 200: 40, // extralight
100
- 300: 50, // light
101
- 400: 80, // regular
102
- 500: 100, // medium
103
- 600: 180, // demibold
104
- 700: 200, // bold
105
- 800: 205, // extrabold
106
- 900: 210 // black
107
- };
96
+ // css weight -> fontconfig weight: fontconfig's own table
97
+ // (FcWeightFromOpenTypeDouble), the one it reads every face's OS/2 weight
98
+ // through, so a pattern's weight sits where a face of that CSS weight does
99
+ const cssToFcWeight = [
100
+ [0, 0],
101
+ [100, 0], // thin
102
+ [200, 40], // extralight
103
+ [300, 50], // light
104
+ [350, 55], // demilight
105
+ [380, 75], // book
106
+ [400, 80], // regular
107
+ [500, 100], // medium
108
+ [600, 180], // demibold
109
+ [700, 200], // bold
110
+ [800, 205], // extrabold
111
+ [900, 210], // black
112
+ [1000, 215] // extrablack
113
+ ];
114
+
115
+ // CSS's 400 and 500, and the heaviest weight there is, on fontconfig's scale
116
+ const REGULAR = 80;
117
+ const MEDIUM = 100;
118
+ const HEAVIEST = 215;
108
119
 
109
120
  // formats fontkit can parse. Exported so the font-spec resolver filters a
110
121
  // directory listing by exactly the same rule fc-match output is filtered by —
@@ -239,17 +250,27 @@ function fcName(name) {
239
250
  return name.replace(/[\\\-:,]/g, '\\$&');
240
251
  }
241
252
 
242
- function patternFor({ family, weight, style }) {
243
- let fc = String(platformFamilies(family || 'sans-serif'))
253
+ /**
254
+ * A pattern as fc-match is handed it (`fc`, which is also its cache key),
255
+ * with what CSS's matching needs of it besides: the family names it asks
256
+ * for, the weight on fontconfig's scale (its default, regular, where none
257
+ * is asked for) and whether it asks for italic.
258
+ */
259
+ function specFor({ family, weight, style }) {
260
+ const names = String(platformFamilies(family || 'sans-serif'))
244
261
  .split(',')
245
262
  .map((name) => name.trim().replace(/^["']|["']$/g, ''))
246
- .filter(Boolean)
247
- .map(fcName)
248
- .join(',');
263
+ .filter(Boolean);
264
+ let fc = names.map(fcName).join(',');
249
265
  const fcWeight = normalizeWeight(weight);
250
266
  if (fcWeight !== undefined) fc += `:weight=${fcWeight}`;
251
- if (style && style.includes('italic')) fc += ':slant=italic';
252
- return fc;
267
+ const italic = Boolean(style && style.includes('italic'));
268
+ if (italic) fc += ':slant=italic';
269
+ return { fc, names, weight: fcWeight ?? REGULAR, italic };
270
+ }
271
+
272
+ function patternFor(pattern) {
273
+ return specFor(pattern).fc;
253
274
  }
254
275
 
255
276
  // One command shared by the sync and async paths, so a prewarmed cache entry
@@ -259,52 +280,317 @@ function patternFor({ family, weight, style }) {
259
280
  // localized aliases included (`Hiragino Sans`, `ヒラギノ角ゴシック`, and the
260
281
  // style-suffixed forms of both are one face), and `--format` joins them with
261
282
  // commas. The fields are tab-separated so a comma inside one costs nothing,
262
- // and `charset` stays last because it is by far the longest.
283
+ // and `charset` stays last because it is by far the longest. Weight, slant
284
+ // and width are what CSS's matching reads (`cssFace`).
263
285
  const fcMatchArgs = [
264
286
  '-s',
265
287
  '--format',
266
- '%{file}\t%{postscriptname}\t%{family}\t%{charset}\n'
288
+ '%{file}\t%{postscriptname}\t%{family}\t%{weight}\t%{slant}\t%{width}\t%{charset}\n'
267
289
  ];
268
290
  const fcMatchOpts = { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
269
291
 
292
+ /**
293
+ * A weight, slant or width as fontconfig prints it, as `[lo, hi]`: a number
294
+ * is both ends, and a variable font's range, `[80 200]`, its own. Null where
295
+ * there is none.
296
+ */
297
+ function valueRange(text) {
298
+ const n = text ? Number(text) : NaN;
299
+ if (!Number.isNaN(n)) return [n, n];
300
+ const values = String(text ?? '')
301
+ .replace(/[[\]]/g, ' ')
302
+ .split(/[\s,]+/)
303
+ .filter(Boolean)
304
+ .map(Number);
305
+ if (values.length === 0 || values.some(Number.isNaN)) return null;
306
+ return [Math.min(...values), Math.max(...values)];
307
+ }
308
+
309
+ /**
310
+ * One face fontconfig named, as a candidate. `charset` is null where it was
311
+ * not asked for (the census's faces) — the face's coverage is not known, and
312
+ * `charsetHas` answers yes, so a fallback opens it and asks the font.
313
+ */
314
+ function candidate(path, postscriptName, family, weight, slant, width, charset) {
315
+ // `families` keeps fontconfig's whole list, in its order; `family` is
316
+ // the first of them, which is the name fontconfig leads with for the
317
+ // current locale and the one to show in a UI.
318
+ const families = family ? family.split(',').filter(Boolean) : [];
319
+ return {
320
+ path,
321
+ postscriptName,
322
+ family: families[0] || '',
323
+ families,
324
+ charset,
325
+ _ranges: null,
326
+ _style: [weight, slant, width]
327
+ };
328
+ }
329
+
330
+ /**
331
+ * A candidate's weight, slant and width as ranges (`valueRange`), read the
332
+ * first time they are asked for — a chain's head is, the 150 faces behind
333
+ * it never are. Null where fontconfig did not say.
334
+ */
335
+ function styleOf(face) {
336
+ if (Array.isArray(face._style)) {
337
+ const [weight, slant, width] = face._style.map(valueRange);
338
+ face._style = weight && slant && width ? { weight, slant, width } : null;
339
+ }
340
+ return face._style;
341
+ }
342
+
270
343
  /** fc-match -s output -> candidates, filtered to formats fontkit can parse */
271
344
  function parseMatches(out) {
272
345
  const list = [];
273
346
  for (const line of out.split('\n')) {
274
- const [path, postscriptName, family, charset] = line.split('\t');
347
+ const [path, postscriptName, family, weight, slant, width, charset] = line.split('\t');
275
348
  if (path && supported.test(path)) {
276
- // `families` keeps fontconfig's whole list, in its order; `family` is
277
- // the first of them, which is the name fontconfig leads with for the
278
- // current locale and the one to show in a UI.
279
- const families = family ? family.split(',').filter(Boolean) : [];
280
- list.push({
281
- path,
282
- postscriptName,
283
- family: families[0] || '',
284
- families,
285
- charset: charset || '',
286
- _ranges: null
287
- });
349
+ list.push(candidate(path, postscriptName, family, weight, slant, width, charset || ''));
288
350
  }
289
351
  }
290
352
  return list;
291
353
  }
292
354
 
293
355
  /**
294
- * fc-match output -> the cached candidate list for a pattern. Throws for
295
- * output that parses to nothing usable, which is a fontconfig answer rather
296
- * than a fontconfig failure and so is diagnosed separately.
356
+ * fc-match output -> the cached candidate list for a pattern, its head the
357
+ * face CSS picks (`cssChain`). Throws for output that parses to nothing
358
+ * usable, which is a fontconfig answer rather than a fontconfig failure and
359
+ * so is diagnosed separately.
297
360
  */
298
- function cacheMatches(fc, out) {
361
+ function cacheMatches(spec, out) {
299
362
  const list = parseMatches(out);
300
363
  if (list.length === 0) {
301
364
  throw noFontsError(
302
- `fontconfig matched no font ntk can parse for "${fc}" (needs ` +
365
+ `fontconfig matched no font ntk can parse for "${spec.fc}" (needs ` +
303
366
  '.ttf/.otf/.woff/.woff2/.ttc/.dfont — bitmap .pcf/.bdf fonts are not usable)'
304
367
  );
305
368
  }
306
- sortedCache.set(fc, list);
307
- return list;
369
+ const chain = cssChain(spec, list);
370
+ sortedCache.set(spec.fc, chain);
371
+ return chain;
372
+ }
373
+
374
+ // The census: every face fontconfig knows, a line each — file, PostScript
375
+ // name, families, weight, slant and width — from one fc-list a process,
376
+ // started beside the first prewarm's fc-matches (`spawnToFiles`) and read
377
+ // the first time a weight needs it (`cssFace`). 357 KB for 1,900 faces on a
378
+ // Mac, 45 ms to list. It is kept as the bytes it was read as and a family's
379
+ // lines found in it by a byte search: decoded whole, it was a millisecond of
380
+ // the layout that first needed it, and a family is a dozen of its lines.
381
+ const CENSUS_FORMAT = '%{file}\t%{postscriptname}\t%{family}\t%{weight}\t%{slant}\t%{width}\n';
382
+
383
+ // undefined until a prewarm starts it; then its prewarm entry, with `bytes`
384
+ // (undefined until read, null where there is none to read) and `faces`, the
385
+ // family -> faces lookups already made in it
386
+ let census;
387
+
388
+ /**
389
+ * The census's bytes — waited for synchronously while its child runs. Null
390
+ * where it failed, took too long, or never ran (no shell to run it in), and
391
+ * then for good: a face answered without it must not be answered otherwise
392
+ * a moment later.
393
+ */
394
+ function censusBytes() {
395
+ if (!census) return null;
396
+ if (census.bytes !== undefined) return census.bytes;
397
+ const deadline = performance.now() + PREWARM_WAIT_MS;
398
+ let status = prewarmStatus(census.base);
399
+ while (status === null && !census.exited && performance.now() < deadline) {
400
+ Atomics.wait(nap, 0, 0, 1);
401
+ status = prewarmStatus(census.base);
402
+ }
403
+ census.bytes = null;
404
+ if (status === 0) {
405
+ try {
406
+ census.bytes = builtin('node:fs').readFileSync(`${census.base}.out`);
407
+ } catch {
408
+ // gone from under us: no census, as if it had failed
409
+ }
410
+ }
411
+ removePrewarm(census.base);
412
+ return census.bytes;
413
+ }
414
+
415
+ /** once the census can be read without waiting: for a caller that awaits */
416
+ function censusSettled() {
417
+ return census && census.bytes === undefined && !census.exited ? census.promise : undefined;
418
+ }
419
+
420
+ // a family name as fontconfig compares one: case and blanks aside
421
+ const folded = (name) => name.replace(/\s+/g, '').toLowerCase();
422
+
423
+ /**
424
+ * The faces of the family fontconfig matched a pattern to, as the census has
425
+ * them — those ntk can open. Null where there is no census.
426
+ *
427
+ * The family is the name `head` was matched by: the first of the pattern's
428
+ * that the face answers to, or — for a generic, or a family fontconfig put
429
+ * another in place of — the name it leads with. Which name matters: `Avenir
430
+ * Next Ultra Light` is a family of two faces, and `Avenir Next` one of
431
+ * twelve that holds both.
432
+ */
433
+ function familyFaces(names, head) {
434
+ const bytes = censusBytes();
435
+ if (bytes === null) return null;
436
+ let name;
437
+ for (const asked of names.map(folded)) {
438
+ name = head.families.find((f) => folded(f) === asked);
439
+ if (name) break;
440
+ }
441
+ name ??= head.families[0];
442
+ if (!name) return null;
443
+ const key = folded(name);
444
+ let faces = census.faces.get(key);
445
+ if (faces) return faces;
446
+ faces = [];
447
+ // fontconfig spells a name alike in both answers, so a search for it finds
448
+ // every line it is on, and the line's own family list says whether it is
449
+ // one of the face's names or only part of a longer one
450
+ for (let at = bytes.indexOf(name); at !== -1; ) {
451
+ const start = bytes.lastIndexOf(10, at) + 1;
452
+ let end = bytes.indexOf(10, at);
453
+ if (end === -1) end = bytes.length;
454
+ const line = bytes.toString('utf8', start, end);
455
+ const [path, postscriptName, family, weight, slant, width] = line.split('\t');
456
+ const face = candidate(path, postscriptName, family, weight, slant, width, null);
457
+ if (path && supported.test(path) && face.families.some((f) => folded(f) === key)) faces.push(face);
458
+ at = bytes.indexOf(name, end);
459
+ }
460
+ census.faces.set(key, faces);
461
+ return faces;
462
+ }
463
+
464
+ // CSS Fonts 4 §5.2's order, as ranks on fontconfig's scale, lowest first.
465
+ // Width (font-stretch, normal being 100): the normal width, then narrower
466
+ // ones nearest first, then wider ones.
467
+ function widthRank([lo, hi]) {
468
+ if (lo <= 100 && hi >= 100) return 0;
469
+ return hi < 100 ? 100 - hi : 1000 + lo - 100;
470
+ }
471
+
472
+ // Slant: the one asked for, then oblique (fontconfig's 110), then the other.
473
+ function slantRank(italic, [lo, hi]) {
474
+ const asked = italic ? 100 : 0;
475
+ if (lo <= asked && hi >= asked) return 0;
476
+ return lo >= 105 ? 1 : 2;
477
+ }
478
+
479
+ // Weight, for `weight` asked: between 400 and 500, up to 500 ascending, then
480
+ // lighter descending, then past 500 ascending; below 400 lighter first, above
481
+ // 500 heavier first. A variable face's range holds every weight in it.
482
+ function weightRank(weight, [lo, hi]) {
483
+ if (lo <= weight && hi >= weight) return 0;
484
+ const x = hi < weight ? hi : lo;
485
+ if (weight >= REGULAR && weight <= MEDIUM) {
486
+ if (x > weight && x <= MEDIUM) return x - weight;
487
+ return x < weight ? 1000 + weight - x : 2000 + x - weight;
488
+ }
489
+ if (weight < REGULAR) return x < weight ? weight - x : 1000 + x - weight;
490
+ return x > weight ? x - weight : 1000 + weight - x;
491
+ }
492
+
493
+ /**
494
+ * Is `head`, the face fontconfig picked, the one CSS picks — whatever else
495
+ * the family holds?
496
+ *
497
+ * Fontconfig picks the face nearest in weight, after the slant: so no face
498
+ * of the head's slant is nearer the weight asked for than the head, and that
499
+ * is all one answer says about the family. The head is CSS's pick when every
500
+ * weight CSS would try before the head's lies nearer — 500 in Helvetica,
501
+ * whose regular is 80 against 100 asked, where CSS tries 500 alone first.
502
+ * 450 in Arial is not: 500 is as near as its regular, so a medium could be
503
+ * there, and CSS would take it. Then the census decides.
504
+ *
505
+ * Fontconfig ranks width after weight and CSS before it, so a head of
506
+ * another width than normal is never shown to be CSS's: 900 in Helvetica
507
+ * Neue is its condensed black. Nor is an italic head for an upright ask,
508
+ * where CSS tries oblique first.
509
+ */
510
+ function provenPick(weight, italic, head) {
511
+ const style = styleOf(head);
512
+ if (!style) return false;
513
+ if (widthRank(style.width) !== 0) return false;
514
+ if (!italic && slantRank(false, style.slant) === 2) return false;
515
+ const [lo, hi] = style.weight;
516
+ if (lo <= weight && hi >= weight) return true;
517
+ const x = hi < weight ? hi : lo;
518
+ const near = Math.abs(x - weight);
519
+ // the weights CSS tries before x: from, whether it is open, to, whether it is
520
+ let from, fromOpen, to, toOpen;
521
+ if (weight >= REGULAR && weight <= MEDIUM) {
522
+ if (x > weight && x <= MEDIUM) [from, fromOpen, to, toOpen] = [weight, false, x, true];
523
+ else if (x < weight) [from, fromOpen, to, toOpen] = [x, true, MEDIUM, false];
524
+ else [from, fromOpen, to, toOpen] = [0, false, x, true];
525
+ } else if (weight < REGULAR) {
526
+ if (x < weight) [from, fromOpen, to, toOpen] = [x, true, weight, false];
527
+ else [from, fromOpen, to, toOpen] = [0, false, x, true];
528
+ } else if (x > weight) [from, fromOpen, to, toOpen] = [weight, false, x, true];
529
+ else [from, fromOpen, to, toOpen] = [x, true, HEAVIEST, false];
530
+ return (
531
+ (from > weight - near || (fromOpen && from >= weight - near)) &&
532
+ (to < weight + near || (toOpen && to <= weight + near))
533
+ );
534
+ }
535
+
536
+ /** a < b, comparing [width, slant, weight] ranks in turn */
537
+ function ranksBefore(a, b) {
538
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] < b[i];
539
+ return false;
540
+ }
541
+
542
+ /**
543
+ * The face CSS's matching (CSS Fonts 4 §5.2) picks for a pattern, given
544
+ * `head`, the face fontconfig picked: the head itself where it can be shown
545
+ * to be CSS's (`provenPick`), or where there is no census to say otherwise,
546
+ * and the family's best face by width, slant and weight in that order where
547
+ * there is one. Fontconfig picks the face nearest in weight, after the
548
+ * slant and before the width, so 520 in Arial was the regular, which a
549
+ * browser sets in the bold.
550
+ *
551
+ * The head wins ties: a face like it is fontconfig's to choose between. So
552
+ * does a head that is a variable font's instance where CSS picks the font —
553
+ * a weight its axis holds is set on the axis (`instantiate`) whichever face
554
+ * of the file it is opened through.
555
+ *
556
+ * The head is ranked as the census lists it, because an answer describes
557
+ * the face as fontconfig will render it: an upright face matched for an
558
+ * italic ask comes back oblique (90-synthetic.conf slants it), and ranked
559
+ * by that it beat its own family's upright faces — W1 of Hiragino Sans,
560
+ * which has no italics, at 160, where CSS takes W0.
561
+ */
562
+ function cssFace(spec, head) {
563
+ if (!head || provenPick(spec.weight, spec.italic, head)) return head;
564
+ const faces = familyFaces(spec.names, head);
565
+ if (!faces || faces.length === 0) return head;
566
+ const ranks = (face) => {
567
+ const style = styleOf(face);
568
+ return style && [widthRank(style.width), slantRank(spec.italic, style.slant), weightRank(spec.weight, style.weight)];
569
+ };
570
+ const listed = faces.find((f) => f.path === head.path && f.postscriptName === head.postscriptName);
571
+ let best = null;
572
+ let bestRanks = ranks(listed ?? head);
573
+ for (const face of faces) {
574
+ const r = ranks(face);
575
+ if (r && (bestRanks === null || ranksBefore(r, bestRanks))) [best, bestRanks] = [face, r];
576
+ }
577
+ if (best === null) return head;
578
+ const [lo, hi] = styleOf(best).weight;
579
+ if (lo < hi && best.path === head.path) return head;
580
+ return best;
581
+ }
582
+
583
+ /**
584
+ * A fallback chain with the face CSS picks at its head (`cssFace`). Where
585
+ * that is not fontconfig's head it is moved up from where the chain has it,
586
+ * or, as `-s` trims a face whose coverage the faces before it already give —
587
+ * a family's bold, behind its regular — put there from the census.
588
+ */
589
+ function cssChain(spec, list) {
590
+ const face = cssFace(spec, list[0]);
591
+ if (face === list[0]) return list;
592
+ const same = (c) => c.path === face.path && c.postscriptName === face.postscriptName;
593
+ return [list.find(same) ?? face, ...list.filter((c) => !same(c))];
308
594
  }
309
595
 
310
596
  // fc -> { promise, base }: one child per pattern however many callers ask.
@@ -356,14 +642,18 @@ function runFcMatch(fc) {
356
642
  // flag first), then a file base, a kind and a pattern for each job: `sorted`
357
643
  // asks for the whole fallback chain, `best` for the one face fontconfig
358
644
  // would pick, which it answers in half the time, since it sorts nothing and
359
- // writes one line (`bestOfPrewarm`). The shell exits once every job has.
360
- // Nothing but shell builtins runs besides fc-match, so a PATH that holds
361
- // fc-match alone is enough.
645
+ // writes one line (`bestOfPrewarm`). `census` lists every face, with the
646
+ // format in the pattern's place (`CENSUS_FORMAT`). The shell exits once
647
+ // every job has. Nothing but shell builtins runs besides fc-match and
648
+ // fc-list, so a PATH that holds those is enough; without fc-list there is no
649
+ // census, and fontconfig's own picks stand.
362
650
  const PREWARM_SCRIPT = [
363
651
  ...fcMatchArgs.map((_, i) => `a${i}=\${${i + 1}}`),
364
652
  `shift ${fcMatchArgs.length}`,
365
653
  'while [ $# -gt 2 ]; do ' +
366
- 'if [ "$2" = best ]; then ' +
654
+ 'if [ "$2" = census ]; then ' +
655
+ '(fc-list --format "$3" > "$1.out" 2> "$1.err"; echo $? > "$1.done") & ' +
656
+ 'elif [ "$2" = best ]; then ' +
367
657
  `(fc-match ${fcMatchArgs
368
658
  .slice(1)
369
659
  .map((_, i) => `"$a${i + 1}"`)
@@ -472,16 +762,22 @@ function removePrewarm(base) {
472
762
  * set up: 634 KB apiece, most of it the coverage of every face fontconfig
473
763
  * sorts behind the first.
474
764
  *
765
+ * The first child also lists every face, once a process (the census, see
766
+ * `CENSUS_FORMAT`): a job of its own beside the matches, so a weight CSS
767
+ * matches differently from fontconfig costs no spawn of the app's.
768
+ *
475
769
  * An entry a pattern, in order; null where files cannot be used, and the
476
770
  * caller prewarms the old way.
477
771
  */
478
772
  function spawnToFiles(fcs, bests = []) {
479
773
  const cp = childProcess();
480
774
  const all = [...fcs, ...bests];
481
- const bases = cp ? all.map(() => prewarmBase()) : [];
775
+ const listing = census === undefined;
776
+ const bases = cp ? Array.from({ length: all.length + (listing ? 1 : 0) }, () => prewarmBase()) : [];
482
777
  if (bases.length === 0 || bases[0] === null) return null;
483
778
  const jobs = [];
484
779
  all.forEach((fc, i) => jobs.push(bases[i], i < fcs.length ? 'sorted' : 'best', fc));
780
+ if (listing) jobs.push(bases[all.length], 'census', CENSUS_FORMAT);
485
781
  let child;
486
782
  try {
487
783
  child = cp.spawn('/bin/sh', ['-c', PREWARM_SCRIPT, 'ntk-fc-match', ...fcMatchArgs, ...jobs], {
@@ -492,14 +788,22 @@ function spawnToFiles(fcs, bests = []) {
492
788
  }
493
789
  // `promise` resolves once the child is gone, with what it left in the
494
790
  // files; `exited` tells a synchronous caller there is nothing to wait for
495
- const entries = all.map((fc, i) => {
496
- const entry = { promise: null, base: bases[i], answer: undefined, exited: false, gone: null };
497
- entry.promise = new Promise((resolve) => {
498
- entry.gone = resolve;
791
+ const entry = (base) => {
792
+ const e = { promise: null, base, answer: undefined, exited: false, gone: null };
793
+ e.promise = new Promise((resolve) => {
794
+ e.gone = resolve;
499
795
  });
500
- (i < fcs.length ? inflight : bestInflight).set(fc, entry);
501
- return entry;
796
+ return e;
797
+ };
798
+ const entries = all.map((fc, i) => {
799
+ const e = entry(bases[i]);
800
+ (i < fcs.length ? inflight : bestInflight).set(fc, e);
801
+ return e;
502
802
  });
803
+ if (listing) {
804
+ census = Object.assign(entry(bases[all.length]), { bytes: undefined, faces: new Map() });
805
+ entries.push(census);
806
+ }
503
807
  const gone = () => {
504
808
  for (const entry of entries) {
505
809
  entry.exited = true;
@@ -551,8 +855,10 @@ function answerSync(fc, entry) {
551
855
  * layout setting text in the face needs its first line. The rest waits in
552
856
  * the file for the first character that falls back, which Latin text in a
553
857
  * face that covers it never has.
858
+ *
859
+ * The face is the one CSS picks (`cssFace`), as the chain's head will be.
554
860
  */
555
- function firstOfPrewarm(entry) {
861
+ function firstOfPrewarm(spec, entry) {
556
862
  if (entry.first !== undefined) return entry.first;
557
863
  const deadline = performance.now() + PREWARM_WAIT_MS;
558
864
  let status = prewarmStatus(entry.base);
@@ -562,7 +868,7 @@ function firstOfPrewarm(entry) {
562
868
  }
563
869
  // not answered, or not yet: the whole read is what decides, and reports
564
870
  if (status !== 0) return null;
565
- entry.first = firstCandidate(`${entry.base}.out`);
871
+ entry.first = cssFace(spec, firstCandidate(`${entry.base}.out`));
566
872
  return entry.first;
567
873
  }
568
874
 
@@ -576,9 +882,10 @@ function firstOfPrewarm(entry) {
576
882
  * in about half the time the whole chain takes (15 ms against 30 on a Linux
577
883
  * desktop): no sort, one line. A layout that asks for a family warmed while
578
884
  * its component rendered waits on the prewarm, and the chain was most of
579
- * that wait.
885
+ * that wait. Fontconfig's face, that is: the one CSS picks may be another
886
+ * (`cssFace`).
580
887
  */
581
- function bestOfPrewarm(entry) {
888
+ function bestOfPrewarm(spec, entry) {
582
889
  if (entry.first !== undefined) return entry.first;
583
890
  const deadline = performance.now() + PREWARM_WAIT_MS;
584
891
  let left = readPrewarm(entry.base);
@@ -589,7 +896,7 @@ function bestOfPrewarm(entry) {
589
896
  if (left === null) return null;
590
897
  removePrewarm(entry.base);
591
898
  const [best] = left.status === 0 ? parseMatches(left.out) : [];
592
- entry.first = best ?? null;
899
+ entry.first = best ? cssFace(spec, best) : null;
593
900
  return entry.first;
594
901
  }
595
902
 
@@ -665,7 +972,7 @@ function firstCandidate(file) {
665
972
  * attempt abandoned
666
973
  */
667
974
  export function prewarm(pattern = {}) {
668
- return warm([patternFor(pattern)])[0];
975
+ return warm([specFor(pattern)])[0];
669
976
  }
670
977
 
671
978
  /**
@@ -677,36 +984,39 @@ export function prewarm(pattern = {}) {
677
984
  * up on; never rejects
678
985
  */
679
986
  export function prewarmPatterns(patterns) {
680
- return Promise.all(warm(patterns.map(patternFor))).then(() => {});
987
+ return Promise.all(warm(patterns.map(specFor))).then(() => {});
681
988
  }
682
989
 
683
990
  /**
684
- * `prewarm` for a list of fontconfig patterns: the ones neither cached nor
991
+ * `prewarm` for a list of patterns (`specFor`): the ones neither cached nor
685
992
  * already running start together, in one child (`spawnToFiles`). A promise a
686
993
  * pattern, resolving once its answer is ready or given up on; none rejects.
687
994
  */
688
- function warm(fcs, best = null) {
689
- if (unavailable) return fcs.map(() => Promise.resolve());
995
+ function warm(specs, best = null) {
996
+ if (unavailable) return specs.map(() => Promise.resolve());
997
+ const fcs = specs.map((spec) => spec.fc);
690
998
  const fresh = [...new Set(fcs)].filter((fc) => !sortedCache.has(fc) && !inflight.has(fc));
691
999
  // the best face only beside its own chain: once that is running or read,
692
1000
  // the chain's head answers as soon as a best job would
693
1001
  const bests = best !== null && fresh.includes(best) && !bestInflight.has(best) ? [best] : [];
694
1002
  if (fresh.length > 0) spawnToFiles(fresh, bests);
695
- return fcs.map((fc) => {
1003
+ return specs.map((spec) => {
1004
+ const { fc } = spec;
696
1005
  if (sortedCache.has(fc)) return Promise.resolve();
697
1006
  // a prewarm's answer waits in its files for the first ask
698
1007
  const running = inflight.get(fc);
699
1008
  if (running?.base) return running.promise;
700
1009
  const pending = running?.promise ?? runFcMatch(fc);
701
- return pending.then(
702
- (out) => {
1010
+ return pending
1011
+ .then(async (out) => {
1012
+ // the census decides the head: read once it is in, never waited on
1013
+ await censusSettled();
703
1014
  if (!sortedCache.has(fc)) {
704
1015
  const list = parseMatches(out);
705
- if (list.length > 0) sortedCache.set(fc, list);
1016
+ if (list.length > 0) sortedCache.set(fc, cssChain(spec, list));
706
1017
  }
707
- },
708
- () => {}
709
- );
1018
+ })
1019
+ .catch(() => {});
710
1020
  });
711
1021
  }
712
1022
 
@@ -736,7 +1046,7 @@ export function prewarmFaces(pattern = {}) {
736
1046
  // the one being asked for, or, warmed ahead, the regular
737
1047
  const first = patternFor(pattern.weight === undefined && pattern.style === undefined ? { family, weight: 400, style: 'normal' } : pattern);
738
1048
  warm(
739
- FACES.map(([weight, style]) => patternFor({ family, weight, style })),
1049
+ FACES.map(([weight, style]) => specFor({ family, weight, style })),
740
1050
  first
741
1051
  );
742
1052
  }
@@ -757,14 +1067,18 @@ export function prewarmFaces(pattern = {}) {
757
1067
  * sync throws do.
758
1068
  *
759
1069
  * @returns {Promise<Array<{path, postscriptName, family: string,
760
- * families: string[], charset: string}>>}
1070
+ * families: string[], charset: string|null}>>}
761
1071
  */
762
1072
  export async function matchSorted(pattern = {}) {
763
- const fc = patternFor(pattern);
1073
+ const spec = specFor(pattern);
1074
+ const { fc } = spec;
764
1075
  const cached = sortedCache.get(fc);
765
1076
  if (cached) return cached;
766
1077
  if (unavailable) throw noFontsError(unavailable);
767
1078
 
1079
+ // A caller that awaits may be the first to ask anything — a font picker
1080
+ // with no source constructed — and nothing else would start the census
1081
+ if (census === undefined) spawnToFiles([]);
768
1082
  let out;
769
1083
  const pending = inflight.get(fc);
770
1084
  if (pending?.base) {
@@ -780,17 +1094,22 @@ export async function matchSorted(pattern = {}) {
780
1094
  throw fcMatchError(err);
781
1095
  }
782
1096
  }
1097
+ // the census decides the head where fontconfig's own cannot be shown to be
1098
+ // CSS's (`cssChain`): read once it is in, so that this never waits on it
1099
+ await censusSettled();
783
1100
  // A sync call may have answered this pattern while the child ran. Its list
784
1101
  // is the cached one, and candidates memoize their parsed charset, so hand
785
1102
  // back what everyone else already holds rather than a fresh copy.
786
- return sortedCache.get(fc) ?? cacheMatches(fc, out);
1103
+ return sortedCache.get(fc) ?? cacheMatches(spec, out);
787
1104
  }
788
1105
 
789
1106
  /**
790
1107
  * Full fontconfig match list for a pattern, best match first — this is the
791
- * system's font fallback chain. Each candidate carries the unicode coverage
792
- * fontconfig knows about (`charset`, lazily parsed via `charsetHas`), so a
793
- * fallback font for a codepoint can be chosen without opening font files —
1108
+ * system's font fallback chain, headed by the face CSS's font matching picks
1109
+ * (`cssChain`). Each candidate carries the unicode coverage fontconfig knows
1110
+ * about (`charset`, lazily parsed via `charsetHas`; null for a head the
1111
+ * chain did not have), so a fallback font for a codepoint can be chosen
1112
+ * without opening font files —
794
1113
  * and the family name fontconfig already knows, so a *list* of matches can be
795
1114
  * shown without opening them either (issue #273: `sans-serif` returns 139
796
1115
  * candidates here, and `Font.loadSync` is ~1.2ms a file).
@@ -800,10 +1119,11 @@ export async function matchSorted(pattern = {}) {
800
1119
  * a caller that can should use `matchSorted` and not block on the spawn.
801
1120
  *
802
1121
  * @returns {Array<{path, postscriptName, family: string, families: string[],
803
- * charset: string}>}
1122
+ * charset: string|null}>}
804
1123
  */
805
1124
  export function matchSortedSync(pattern) {
806
- const fc = patternFor(pattern);
1125
+ const spec = specFor(pattern);
1126
+ const { fc } = spec;
807
1127
  const cached = sortedCache.get(fc);
808
1128
  if (cached) return cached;
809
1129
  if (unavailable) throw noFontsError(unavailable);
@@ -814,7 +1134,7 @@ export function matchSortedSync(pattern) {
814
1134
  const pending = inflight.get(fc);
815
1135
  if (pending?.base) {
816
1136
  const out = answerSync(fc, pending);
817
- if (out !== null) return sortedCache.get(fc) ?? cacheMatches(fc, out);
1137
+ if (out !== null) return sortedCache.get(fc) ?? cacheMatches(spec, out);
818
1138
  }
819
1139
  let out;
820
1140
  try {
@@ -822,7 +1142,7 @@ export function matchSortedSync(pattern) {
822
1142
  } catch (err) {
823
1143
  throw fcMatchError(err);
824
1144
  }
825
- return cacheMatches(fc, out);
1145
+ return cacheMatches(spec, out);
826
1146
  }
827
1147
 
828
1148
  /**
@@ -832,22 +1152,23 @@ export function matchSortedSync(pattern) {
832
1152
  * the first character it has to fall back for reads the chain whole.
833
1153
  *
834
1154
  * @returns {{path, postscriptName, family: string, families: string[],
835
- * charset: string}}
1155
+ * charset: string|null}}
836
1156
  */
837
1157
  export function matchFirstSync(pattern) {
838
- const fc = patternFor(pattern);
1158
+ const spec = specFor(pattern);
1159
+ const { fc } = spec;
839
1160
  const cached = sortedCache.get(fc);
840
1161
  if (cached) return cached[0];
841
1162
  if (unavailable) throw noFontsError(unavailable);
842
1163
  prewarmFaces(pattern);
843
1164
  const best = bestInflight.get(fc);
844
1165
  if (best) {
845
- const first = bestOfPrewarm(best);
1166
+ const first = bestOfPrewarm(spec, best);
846
1167
  if (first) return first;
847
1168
  }
848
1169
  const pending = inflight.get(fc);
849
1170
  if (pending?.base) {
850
- const first = firstOfPrewarm(pending);
1171
+ const first = firstOfPrewarm(spec, pending);
851
1172
  if (first) return first;
852
1173
  }
853
1174
  return matchSortedSync(pattern)[0];
@@ -873,8 +1194,12 @@ export function listFontsSync(pattern) {
873
1194
  /**
874
1195
  * Does a fc-match candidate's charset cover a codepoint?
875
1196
  * The charset string is fontconfig's range format: "20-7e a0-ff 131 ...".
1197
+ * A face whose coverage is not known here — one CSS's matching put at a
1198
+ * chain's head, which `-s` had trimmed (`cssChain`) — may: it is opened and
1199
+ * the font asked.
876
1200
  */
877
1201
  export function charsetHas(candidate, codepoint) {
1202
+ if (candidate.charset === null) return true;
878
1203
  if (candidate._ranges === null) {
879
1204
  const ranges = [];
880
1205
  for (const part of candidate.charset.split(' ')) {
@@ -903,15 +1228,18 @@ export function charsetHas(candidate, codepoint) {
903
1228
 
904
1229
  /**
905
1230
  * A CSS font weight on fontconfig's scale, which is not CSS's: regular is 80
906
- * there and black is 210. The hundreds are the table's; a weight between two
907
- * of them is as far between their fontconfig weights, so 450 is 90, halfway
908
- * from regular to medium. CSS takes 1 to 1000, and what is outside the table
909
- * is its nearest end.
1231
+ * there and black is 210. The table is fontconfig's own, and a weight between
1232
+ * two of its entries is as far between their fontconfig weights, so 450 is
1233
+ * 90, halfway from regular to medium. CSS takes 1 to 1000.
910
1234
  *
911
1235
  * Handed over as it was, 450 was a weight past twice black, and fontconfig
912
1236
  * answered with the heaviest face a family has: Arial Bold, where a browser
913
- * sets 450 in Arial Regular. At 90 fontconfig picks the face nearest in
914
- * weight, which is the regular's 80 and not the bold's 200.
1237
+ * sets 450 in Arial Regular.
1238
+ *
1239
+ * Fontconfig's table rather than one of the hundreds alone, because it is
1240
+ * the one a face's own weight went through: a face whose OS/2 weight is 350
1241
+ * is 55 in every fc-match answer, and only the same table puts a pattern
1242
+ * asking for 340 on the lighter side of it (`cssFace`).
915
1243
  *
916
1244
  * A whole number, because a pattern is a cache key and an fc-match of its
917
1245
  * own: the weights an axis is animated through then share the ones they
@@ -919,13 +1247,14 @@ export function charsetHas(candidate, codepoint) {
919
1247
  */
920
1248
  function normalizeWeight(weight) {
921
1249
  if (weight === undefined) return undefined;
922
- if (weight === 'normal') return cssToFcWeight[400];
923
- if (weight === 'bold') return cssToFcWeight[700];
1250
+ if (weight === 'normal') return REGULAR;
1251
+ if (weight === 'bold') return 200;
924
1252
  const n = parseInt(weight, 10);
925
1253
  if (Number.isNaN(n)) return undefined;
926
- const css = Math.min(900, Math.max(100, n));
927
- const below = Math.floor(css / 100) * 100;
928
- const lighter = cssToFcWeight[below];
929
- if (css === below) return lighter;
930
- return Math.round(lighter + ((cssToFcWeight[below + 100] - lighter) * (css - below)) / 100);
1254
+ const css = Math.min(1000, Math.max(1, n));
1255
+ let i = 1;
1256
+ while (css > cssToFcWeight[i][0]) i++;
1257
+ const [lighterCss, lighter] = cssToFcWeight[i - 1];
1258
+ const [heavierCss, heavier] = cssToFcWeight[i];
1259
+ return Math.round(lighter + ((heavier - lighter) * (css - lighterCss)) / (heavierCss - lighterCss));
931
1260
  }
@@ -387,15 +387,52 @@ function patternSourceOf(app, source) {
387
387
  * after a `translate` moves with the fill, and one created under a translate
388
388
  * and filled without it does not).
389
389
  *
390
- * Returns false when nothing would be painted (a singular matrix), true for
391
- * anything else, including every plain-colour style.
390
+ * Returns false when nothing would be painted (a singular matrix, or one
391
+ * the wire cannot carry), true for anything else, including every
392
+ * plain-colour style. `width` and `height` are the surface's: the style's
393
+ * source origin is kept on it (`sourceOrigin`).
392
394
  */
393
- function prepareStyle(src, m) {
395
+ function prepareStyle(src, m, width, height) {
394
396
  return src instanceof CanvasPattern || src instanceof CanvasGradient
395
- ? src._sync(m)
397
+ ? src._sync(m, width, height)
396
398
  : true;
397
399
  }
398
400
 
401
+ const NO_ORIGIN = Object.freeze([0, 0]);
402
+
403
+ /**
404
+ * Where a composite samples `src` from, as a device point: each composite
405
+ * that reads a style at its destination's own coordinates passes those
406
+ * coordinates less this as its source offset.
407
+ *
408
+ * A gradient's or a pattern's picture transform is the inverse of the CTM,
409
+ * and sampling it at device coordinates put the CTM's translation, times
410
+ * its downscale, into the transform: a box a fortieth of its size at x
411
+ * 2,200 asked for 88,000, past what 16.16 fixed point carries. `_sync`
412
+ * folds this origin into the transform instead, and puts it where the
413
+ * style's own origin lands, so the translation is small wherever on the
414
+ * surface the style is painted. Everything else — a solid, a picture —
415
+ * samples at device coordinates, from (0, 0).
416
+ */
417
+ function sourceOrigin(src) {
418
+ return src instanceof CanvasPattern || src instanceof CanvasGradient
419
+ ? src._origin
420
+ : NO_ORIGIN;
421
+ }
422
+
423
+ /**
424
+ * The origin `_sync` picks for a style whose own origin lands at device
425
+ * (x, y): the nearest whole pixel, kept on the surface. On it, a composite's
426
+ * source offset — its destination's, less this — is within the surface's
427
+ * size of 0, which the 16-bit field carries whatever the transform; and the
428
+ * transform's translation is the style's coordinate there, which for a
429
+ * style painted on the surface is one the fill samples anyway.
430
+ */
431
+ function originOn(x, y, width, height) {
432
+ const on = (v, size) => (v > 0 ? Math.min(Math.round(v), size) : 0);
433
+ return [on(x, width), on(y, height)];
434
+ }
435
+
399
436
  /**
400
437
  * Whether every entry can be written as XRender's 16.16 fixed point, whose
401
438
  * whole part is a signed 16-bit number. A picture transform is written that
@@ -2387,14 +2424,15 @@ class RenderingContext2d {
2387
2424
  );
2388
2425
  }
2389
2426
  // src is either a 1x1 repeating solid (offset irrelevant) or a
2390
- // surface-aligned gradient, so it is sampled at the same offset too
2427
+ // gradient/pattern, sampled at the same offset from its own origin
2428
+ const [ox, oy] = sourceOrigin(src);
2391
2429
  R.Composite(
2392
2430
  op,
2393
2431
  src.id,
2394
2432
  this.fillMask.id,
2395
2433
  this._dst(),
2396
- out.x,
2397
- out.y,
2434
+ out.x - ox,
2435
+ out.y - oy,
2398
2436
  out.x,
2399
2437
  out.y,
2400
2438
  out.x,
@@ -2417,7 +2455,7 @@ class RenderingContext2d {
2417
2455
  op = op ?? this._op();
2418
2456
  alpha = alpha ?? this.globalAlpha;
2419
2457
  if (alpha <= 0) return;
2420
- if (!prepareStyle(src, this._m)) return;
2458
+ if (!prepareStyle(src, this._m, this.width, this.height)) return;
2421
2459
 
2422
2460
  // one box per subpath, so a path holding disjoint ones can be masked as
2423
2461
  // the pieces it is rather than as the box around all of them — each the
@@ -2473,7 +2511,7 @@ class RenderingContext2d {
2473
2511
  _strokePolys(polys, { src = null } = {}) {
2474
2512
  src = src ?? this._strokePicture;
2475
2513
  if (this.globalAlpha <= 0) return;
2476
- if (!prepareStyle(src, this._m)) return;
2514
+ if (!prepareStyle(src, this._m, this.width, this.height)) return;
2477
2515
  // approximate transform-aware line width by the average scale factor
2478
2516
  const det = this._m[0] * this._m[3] - this._m[1] * this._m[2];
2479
2517
  const scale = Math.sqrt(Math.abs(det)) || 1;
@@ -2841,6 +2879,7 @@ class RenderingContext2d {
2841
2879
  op === this.Render.PictOp.Over;
2842
2880
  const chunk = 4000 * 6;
2843
2881
  if (direct) {
2882
+ const [ox, oy] = sourceOrigin(src);
2844
2883
  this._cullTris(tris);
2845
2884
  if (!tris.length) return;
2846
2885
  const cut = this._wireCut(tris);
@@ -2858,11 +2897,12 @@ class RenderingContext2d {
2858
2897
  // convention drawGlyphRuns and compositeTraps use — without it every
2859
2898
  // non-constant stroke style (a gradient, a pattern) is offset by
2860
2899
  // wherever the stroke happens to start, and shifts as it moves.
2900
+ // Less the style's own origin, as every fill samples it.
2861
2901
  this.Render.Triangles(
2862
2902
  op,
2863
2903
  src.id,
2864
- Math.floor(batch[0]),
2865
- Math.floor(batch[1]),
2904
+ Math.floor(batch[0]) - ox,
2905
+ Math.floor(batch[1]) - oy,
2866
2906
  this._dst(),
2867
2907
  this.Render.a8,
2868
2908
  batch,
@@ -3128,7 +3168,7 @@ class RenderingContext2d {
3128
3168
  _drawGlyphsDevice(op, src, positioned) {
3129
3169
  const app = this.window.app;
3130
3170
  const R = this.Render;
3131
- if (!prepareStyle(src, this._m)) return;
3171
+ if (!prepareStyle(src, this._m, this.width, this.height)) return;
3132
3172
  if (!this._hasPolyClip) {
3133
3173
  // Fast path: a rectangular clip is something the server can do itself.
3134
3174
  // Two small requests around the ordinary glyph composite, instead of
@@ -3148,6 +3188,7 @@ class RenderingContext2d {
3148
3188
  this._dst(),
3149
3189
  positioned,
3150
3190
  rect ?? { x: 0, y: 0, w: this.width, h: this.height },
3191
+ sourceOrigin(src),
3151
3192
  );
3152
3193
  if (rect) this._invalidatePictureClip();
3153
3194
  this._markDirty();
@@ -3185,13 +3226,14 @@ class RenderingContext2d {
3185
3226
  this.width,
3186
3227
  this.height,
3187
3228
  );
3229
+ const [ox, oy] = sourceOrigin(src);
3188
3230
  R.Composite(
3189
3231
  op,
3190
3232
  src.id,
3191
3233
  this.fillMask.id,
3192
3234
  this._dst(),
3193
- 0,
3194
- 0,
3235
+ -ox,
3236
+ -oy,
3195
3237
  0,
3196
3238
  0,
3197
3239
  0,
@@ -3218,7 +3260,16 @@ class RenderingContext2d {
3218
3260
  if (rect.w === 0 || rect.h === 0) return;
3219
3261
  this._setPictureClip(rect);
3220
3262
  }
3221
- compositeTraps(app, op, src.id, this._dst(), traps);
3263
+ compositeTraps(
3264
+ app,
3265
+ op,
3266
+ src.id,
3267
+ this._dst(),
3268
+ traps,
3269
+ undefined,
3270
+ undefined,
3271
+ sourceOrigin(src),
3272
+ );
3222
3273
  if (rect) this._invalidatePictureClip();
3223
3274
  this._markDirty();
3224
3275
  return;
@@ -3253,13 +3304,14 @@ class RenderingContext2d {
3253
3304
  this.width,
3254
3305
  this.height,
3255
3306
  );
3307
+ const [ox, oy] = sourceOrigin(src);
3256
3308
  R.Composite(
3257
3309
  op,
3258
3310
  src.id,
3259
3311
  this.fillMask.id,
3260
3312
  this._dst(),
3261
- 0,
3262
- 0,
3313
+ -ox,
3314
+ -oy,
3263
3315
  0,
3264
3316
  0,
3265
3317
  0,
@@ -3497,7 +3549,8 @@ class RenderingContext2d {
3497
3549
  w = x1 - x0;
3498
3550
  h = y1 - y0;
3499
3551
  }
3500
- if (!prepareStyle(this._backgroundPicture, this._m)) return;
3552
+ const style = this._backgroundPicture;
3553
+ if (!prepareStyle(style, this._m, this.width, this.height)) return;
3501
3554
  const op = this._op();
3502
3555
  // A rectangular clip is a smaller rectangle to fill, not a mask — this
3503
3556
  // was the one drawing path that still built a surface-sized a8 for one
@@ -3509,13 +3562,14 @@ class RenderingContext2d {
3509
3562
  w > 0 && h > 0 ? this._boxedComposite({ x, y, w, h }, op) : null;
3510
3563
  if (direct && !direct.box) return; // the clip rejects the fill whole
3511
3564
  const box = direct ? direct.box : { x, y, w, h };
3565
+ const [ox, oy] = sourceOrigin(style);
3512
3566
  this.Render.Composite(
3513
3567
  op,
3514
- this._backgroundPicture.id,
3568
+ style.id,
3515
3569
  direct ? direct.mask : this._compositeMask(box),
3516
3570
  this._dst(),
3517
- box.x,
3518
- box.y,
3571
+ box.x - ox,
3572
+ box.y - oy,
3519
3573
  box.x,
3520
3574
  box.y,
3521
3575
  box.x,
@@ -3667,7 +3721,8 @@ class RenderingContext2d {
3667
3721
  if (y + h > y1) y1 = y + h;
3668
3722
  }
3669
3723
  if (!disjointRects(rects)) return false;
3670
- if (!prepareStyle(this._backgroundPicture, this._m)) return true;
3724
+ const style = this._backgroundPicture;
3725
+ if (!prepareStyle(style, this._m, this.width, this.height)) return true;
3671
3726
  // the box they cover, on the surface: nothing outside it is sampled
3672
3727
  const bx = Math.max(0, x0);
3673
3728
  const by = Math.max(0, y0);
@@ -3701,13 +3756,14 @@ class RenderingContext2d {
3701
3756
  bw,
3702
3757
  bh,
3703
3758
  );
3759
+ const [ox, oy] = sourceOrigin(style);
3704
3760
  R.Composite(
3705
3761
  op,
3706
- this._backgroundPicture.id,
3762
+ style.id,
3707
3763
  mask,
3708
3764
  this._dst(),
3709
- bx,
3710
- by,
3765
+ bx - ox,
3766
+ by - oy,
3711
3767
  bx,
3712
3768
  by,
3713
3769
  bx,
@@ -5156,7 +5212,8 @@ class RenderingContext2d {
5156
5212
  const R = this.Render;
5157
5213
  // the coverage is painted in the current fillStyle, on both branches
5158
5214
  // below — a gradient/pattern whose transform collapsed paints nothing
5159
- if (!prepareStyle(this._backgroundPicture, this._m)) return;
5215
+ const style = this._backgroundPicture;
5216
+ if (!prepareStyle(style, this._m, this.width, this.height)) return;
5160
5217
  const scaled = dw !== sw || dh !== sh;
5161
5218
  if (scaled) {
5162
5219
  R.SetPictureTransform(picture.id, [
@@ -5246,18 +5303,19 @@ class RenderingContext2d {
5246
5303
  this._setPictureClip(rect);
5247
5304
  }
5248
5305
  // The scratch mask is surface-sized and surface-aligned — the coverage
5249
- // went into it at (dx, dy) — and so is a gradient source, so both are
5250
- // sampled at the destination offset. Reading either from (0, 0) draws a
5251
- // dw x dh window out of the wrong part of a full-surface picture: for
5252
- // the mask that is the region just cleared to zero, so nothing paints at
5253
- // all unless dx and dy are both zero.
5306
+ // went into it at (dx, dy) — so it is sampled at the destination
5307
+ // offset, and a gradient or pattern at that offset from its origin.
5308
+ // Reading the mask from (0, 0) draws a dw x dh window out of the wrong
5309
+ // part of a full-surface picture: the region just cleared to zero, so
5310
+ // nothing paints at all unless dx and dy are both zero.
5311
+ const [ox, oy] = sourceOrigin(style);
5254
5312
  R.Composite(
5255
5313
  op,
5256
- this._backgroundPicture.id,
5314
+ style.id,
5257
5315
  this.fillMask.id,
5258
5316
  this._dst(),
5259
- dx,
5260
- dy,
5317
+ dx - ox,
5318
+ dy - oy,
5261
5319
  dx,
5262
5320
  dy,
5263
5321
  dx,
@@ -5269,15 +5327,17 @@ class RenderingContext2d {
5269
5327
  } else if (!rect || (rect.w > 0 && rect.h > 0)) {
5270
5328
  if (rect) this._setPictureClip(rect);
5271
5329
  // the source is a 1x1 repeating solid (offset irrelevant) or the fill
5272
- // picture, which for a gradient is surface-aligned: same offset as the
5273
- // destination, exactly as in _fillPolys
5330
+ // style, sampled at the destination's offset from its own origin,
5331
+ // exactly as in _fillPolys
5332
+ const src = this._coverageSource();
5333
+ const [ox, oy] = sourceOrigin(src);
5274
5334
  R.Composite(
5275
5335
  op,
5276
- this._coverageSource().id,
5336
+ src.id,
5277
5337
  picture.id,
5278
5338
  this._dst(),
5279
- dx,
5280
- dy,
5339
+ dx - ox,
5340
+ dy - oy,
5281
5341
  mx,
5282
5342
  my,
5283
5343
  dx,
@@ -5618,6 +5678,9 @@ class CanvasGradient {
5618
5678
  // what the server currently holds: a fresh gradient picture is
5619
5679
  // untransformed, so an untransformed fill costs no extra request
5620
5680
  this._applied = [1, 0, 0, 1, 0, 0];
5681
+ // the device point fills sample it from (`sourceOrigin`), set with the
5682
+ // transform it belongs with
5683
+ this._origin = NO_ORIGIN;
5621
5684
 
5622
5685
  this.x0 = p0;
5623
5686
  this.y0 = p1;
@@ -5707,13 +5770,14 @@ class CanvasGradient {
5707
5770
  /**
5708
5771
  * Make the server-side mapping match the CTM this paint runs under. The
5709
5772
  * gradient's own coordinates are user space and every fill samples the
5710
- * source at device coordinates, so the picture transform — which takes a
5711
- * source coordinate to a gradient one — is the CTM's inverse.
5773
+ * source at device coordinates less `_origin`, so the picture transform —
5774
+ * which takes a source coordinate to a gradient one — is the CTM's
5775
+ * inverse, from that origin.
5712
5776
  *
5713
5777
  * Returns false when the CTM collapses (a zero scale), which paints
5714
5778
  * nothing, exactly as the canvas spec says.
5715
5779
  */
5716
- _sync(ctm) {
5780
+ _sync(ctm, width = 0, height = 0) {
5717
5781
  let inv = matInvert(ctm);
5718
5782
  if (!inv) return false;
5719
5783
  const id = this.id; // lazily creates the picture
@@ -5721,9 +5785,13 @@ class CanvasGradient {
5721
5785
  // much nearer its origin
5722
5786
  const k = this._scale;
5723
5787
  if (k !== 1) inv = inv.map((v) => v / k);
5788
+ // sampled from where user space's origin lands (`sourceOrigin`)
5789
+ const origin = originOn(ctm[4], ctm[5], width, height);
5790
+ inv = matMultiply(inv, [1, 0, 0, 1, origin[0], origin[1]]);
5724
5791
  // the transform is 16.16 fixed point too: one that cannot be carried
5725
5792
  // fills nothing, rather than throwing the rest of the paint away
5726
5793
  if (!fitsFixed(inv)) return false;
5794
+ this._origin = origin;
5727
5795
  const a = this._applied;
5728
5796
  if (
5729
5797
  inv[0] !== a[0] ||
@@ -5801,6 +5869,7 @@ class CanvasPattern {
5801
5869
  // what the server currently holds: a fresh picture is untransformed and
5802
5870
  // filtered nearest, so an untransformed fill costs no extra request
5803
5871
  this._applied = [1, 0, 0, 1, 0, 0];
5872
+ this._origin = NO_ORIGIN;
5804
5873
  this._filter = "nearest";
5805
5874
  }
5806
5875
 
@@ -5844,20 +5913,36 @@ class CanvasPattern {
5844
5913
  * Make the server-side mapping match `ctm ∘ patternMatrix`. XRender's
5845
5914
  * picture transform runs the other way — it takes a coordinate in the
5846
5915
  * composite's source space (which every fill here keeps equal to device
5847
- * space) to a texel — so it is the inverse.
5916
+ * space less `_origin`) to a texel — so it is the inverse, from that
5917
+ * origin.
5848
5918
  *
5849
5919
  * Returns false when that composition collapses (a zero scale), which
5850
5920
  * paints nothing, exactly as the canvas spec says.
5851
5921
  */
5852
- _sync(ctm) {
5922
+ _sync(ctm, width = 0, height = 0) {
5853
5923
  const m = matMultiply(ctm, this._m);
5854
- const inv = matInvert(m);
5924
+ let inv = matInvert(m);
5855
5925
  if (!inv) return false;
5856
- // 16.16 fixed point, as a gradient's is: a tile scaled down far from
5857
- // the origin puts its device position times the downscale into the
5858
- // translation, and one that cannot be carried fills nothing rather than
5859
- // throwing the rest of the paint away
5926
+ // sampled from where the tile's origin lands (`sourceOrigin`)
5927
+ const origin = originOn(m[4], m[5], width, height);
5928
+ inv = matMultiply(inv, [1, 0, 0, 1, origin[0], origin[1]]);
5929
+ // A tile that repeats is the same a whole number of tiles along, so the
5930
+ // translation — the texel the origin samples — is taken to the first
5931
+ // tile. A grid scrolled 100,000 pixels has its origin far off the
5932
+ // surface and samples texels 100,000 along; from the first tile they
5933
+ // are in reach of the wire.
5934
+ const period =
5935
+ this._repeat === 1 ? 1 : this._repeat === 3 ? 2 : 0; // Normal, Reflect
5936
+ if (period && this.width > 0 && this.height > 0) {
5937
+ const pw = this.width * period;
5938
+ const ph = this.height * period;
5939
+ inv[4] -= pw * Math.floor(inv[4] / pw);
5940
+ inv[5] -= ph * Math.floor(inv[5] / ph);
5941
+ }
5942
+ // 16.16 fixed point, as a gradient's is: one that cannot be carried
5943
+ // fills nothing rather than throwing the rest of the paint away
5860
5944
  if (!fitsFixed(inv)) return false;
5945
+ this._origin = origin;
5861
5946
  const a = this._applied;
5862
5947
  if (
5863
5948
  inv[0] !== a[0] ||
@@ -583,8 +583,12 @@ export function routeGlyphSize(app, font, size, policy = policyOf(app), textRend
583
583
  * @param {{x: number, y: number, w: number, h: number}} [bounds] the part of
584
584
  * the destination that can show anything — the clip, or the surface. A
585
585
  * glyph wholly outside it is not sent (`visibleGlyph`).
586
+ * @param {[number, number]} [srcOrigin] the destination point the source's
587
+ * (0, 0) is aligned with. A gradient or pattern the 2d context has
588
+ * transformed samples from its own origin; anything else is aligned with
589
+ * the destination, from (0, 0).
586
590
  */
587
- export function drawGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
591
+ export function drawGlyphRuns(app, op, srcId, dstId, positioned, bounds = null, srcOrigin = [0, 0]) {
588
592
  const policy = policyOf(app);
589
593
  let bitmap = positioned;
590
594
  let vector = null;
@@ -602,8 +606,8 @@ export function drawGlyphRuns(app, op, srcId, dstId, positioned, bounds = null)
602
606
  bitmap.push(positioned[i]);
603
607
  }
604
608
  }
605
- if (bitmap.length) drawBitmapGlyphRuns(app, op, srcId, dstId, bitmap, policy, bounds);
606
- if (vector) drawVectorGlyphRuns(app, op, srcId, dstId, vector, bounds);
609
+ if (bitmap.length) drawBitmapGlyphRuns(app, op, srcId, dstId, bitmap, policy, bounds, srcOrigin);
610
+ if (vector) drawVectorGlyphRuns(app, op, srcId, dstId, vector, bounds, srcOrigin);
607
611
  }
608
612
 
609
613
  /**
@@ -630,7 +634,7 @@ function visibleGlyph(bounds, x, y, size) {
630
634
  );
631
635
  }
632
636
 
633
- function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy, bounds) {
637
+ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy, bounds, srcOrigin) {
634
638
  const Render = app.display.Render;
635
639
  const items = [];
636
640
  let bits = 8;
@@ -652,8 +656,9 @@ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy, bounds)
652
656
  const encoded = encodeGlyphItems(items, bits);
653
657
  if (!encoded) return;
654
658
  // srcX/srcY align the source with the FIRST glyph's origin (RENDER spec);
655
- // passing that origin itself makes source coordinates equal destination
656
- // coordinates, so gradient fill styles line up with canvas space
659
+ // passing that origin itself, less the source's own, makes source
660
+ // coordinates destination coordinates from there, so gradient fill styles
661
+ // line up with canvas space
657
662
  Render.CompositeGlyphs(
658
663
  encoded.bits,
659
664
  op,
@@ -661,8 +666,8 @@ function drawBitmapGlyphRuns(app, op, srcId, dstId, positioned, policy, bounds)
661
666
  dstId,
662
667
  0,
663
668
  encoded.gsid,
664
- items[0].x,
665
- items[0].y,
669
+ items[0].x - srcOrigin[0],
670
+ items[0].y - srcOrigin[1],
666
671
  encoded.elts
667
672
  );
668
673
  trimGlyphPages(app, policy, new Set(pages.values()));
@@ -683,7 +688,7 @@ const MAX_TRAPS_PER_REQUEST = 6000;
683
688
  * Positions are intentionally NOT rounded to whole pixels — fractional
684
689
  * advances and origins keep zoom animations smooth.
685
690
  */
686
- function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
691
+ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null, srcOrigin = [0, 0]) {
687
692
  // unrounded glyph origins + ink bounding box
688
693
  const placed = [];
689
694
  let minX = Infinity;
@@ -742,7 +747,7 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
742
747
  bx = x0;
743
748
  by = y0;
744
749
  }
745
- compositeTraps(app, op, srcId, dstId, traps, bx, by);
750
+ compositeTraps(app, op, srcId, dstId, traps, bx, by, srcOrigin);
746
751
  }
747
752
 
748
753
  /**
@@ -753,8 +758,10 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
753
758
  *
754
759
  * When bx/by are omitted they are derived from the trapezoid bounds (the
755
760
  * trap coordinates are then treated as absolute device coordinates).
761
+ * `srcOrigin` is the destination point the source's (0, 0) is aligned with,
762
+ * as for `drawGlyphRuns`.
756
763
  */
757
- export function compositeTraps(app, op, srcId, dstId, traps, bx, by) {
764
+ export function compositeTraps(app, op, srcId, dstId, traps, bx, by, srcOrigin = [0, 0]) {
758
765
  if (traps.length === 0) return;
759
766
  const Render = app.display.Render;
760
767
 
@@ -800,8 +807,22 @@ export function compositeTraps(app, op, srcId, dstId, traps, bx, by) {
800
807
  for (let i = 0; i < traps.length; i += MAX_TRAPS_PER_REQUEST * 6) {
801
808
  Render.AddTraps(mask.picture.id, 0, 0, traps.slice(i, i + MAX_TRAPS_PER_REQUEST * 6));
802
809
  }
803
- // src coords = dst coords (see drawBitmapGlyphRuns) so gradients line up
804
- Render.Composite(op, srcId, mask.picture.id, dstId, bx, by, 0, 0, bx, by, bw, bh);
810
+ // src coords = dst coords from the source's origin (see
811
+ // drawBitmapGlyphRuns) so gradients line up
812
+ Render.Composite(
813
+ op,
814
+ srcId,
815
+ mask.picture.id,
816
+ dstId,
817
+ bx - srcOrigin[0],
818
+ by - srcOrigin[1],
819
+ 0,
820
+ 0,
821
+ bx,
822
+ by,
823
+ bw,
824
+ bh
825
+ );
805
826
  releaseScratchMask(app);
806
827
  }
807
828
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.17.4",
3
+ "version": "8.17.5",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",