ntk 8.8.2 → 8.9.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.
@@ -43,6 +43,26 @@ function opticalSizeFor(style) {
43
43
  return typeof size === 'number' && Number.isFinite(size) ? size : undefined;
44
44
  }
45
45
 
46
+ /**
47
+ * Cache-key fragment for what shapes a run beyond its face and size: the
48
+ * OpenType features (either form fontkit takes — an array of tags, or an
49
+ * object of tag → on/off), the language and the letter spacing. `''` for a
50
+ * style with none of them, which is nearly every style.
51
+ */
52
+ function shapingKeyOf(style) {
53
+ const { features, language, letterSpacing } = style;
54
+ if (!features && !language && !letterSpacing) return '';
55
+ let feats = '';
56
+ if (Array.isArray(features)) feats = [...features].sort().join(',');
57
+ else if (features) {
58
+ feats = Object.keys(features)
59
+ .sort()
60
+ .map((tag) => `${tag}=${features[tag] ? 1 : 0}`)
61
+ .join(',');
62
+ }
63
+ return `${feats}|${language ?? ''}|${letterSpacing || 0}`;
64
+ }
65
+
46
66
  /** Cache-key fragment for the optical size, `''` when the axis is left alone. */
47
67
  function opticalKeyOf(style) {
48
68
  return opticalSizeFor(style) ?? '';
@@ -346,11 +366,16 @@ export default class FontManager {
346
366
  // variations and optical-size fragments only earn their keep on the
347
367
  // family path — where two points of one axis would otherwise share a
348
368
  // shaped run, and the second would be drawn with the first's advances.
369
+ // Features, language and spacing change the glyphs a word shapes to, so
370
+ // they are part of the key on both paths: without them a word shaped once
371
+ // plain answered every later request for it with the plain glyphs, and a
372
+ // `tnum` asked for after that point was silently ignored.
373
+ const shaping = shapingKeyOf(style);
349
374
  const key = font
350
- ? `${font.key}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`
375
+ ? `${font.key}|${style.size}|${style.weight}|${style.style}|${shaping}|${levelsKey}|${text}`
351
376
  : `${style.family}|${style.size}|${style.weight}|${style.style}|${variationsKeyOf(
352
377
  style.variations
353
- )}|${opticalKeyOf(style)}|${levelsKey}|${text}`;
378
+ )}|${opticalKeyOf(style)}|${shaping}|${levelsKey}|${text}`;
354
379
  let shaped = this._shapeCache.get(key);
355
380
  if (shaped) {
356
381
  // Map iterates in insertion order: re-inserting a hit moves it to the
@@ -390,13 +415,10 @@ export default class FontManager {
390
415
  * levels cannot recover it (RTL text in an RTL paragraph carries level 1,
391
416
  * which reads as an even base) and start/end alignment depends on it.
392
417
  *
393
- * `features` and `language` change shaped output but are not part of the
394
- * memo key (styles reaching it via TextLayout never carry them into the
395
- * key either), so a style with either shapes uncached rather than
396
- * poisoning entries other callers share.
418
+ * `features`, `language` and `letterSpacing` are part of the memo key, so
419
+ * a style with any of them has entries of its own.
397
420
  */
398
421
  _shapeCachedWhole(text, style) {
399
- if (style.features || style.language) return shapeText(this, text, style);
400
422
  const emb = embeddingLevels(text, style.direction);
401
423
  const baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level : 0;
402
424
  const shaped = this._shapeCached(text, style, normalizedLevels(emb.levels, 0, text.length));
@@ -21,7 +21,8 @@ function isWsGlyph(g) {
21
21
  * across relayouts), and bidi reordering is applied per line (UAX#9 L2).
22
22
  *
23
23
  * Content is a plain string or an array of spans
24
- * `{ text, family?, size?, weight?, style?, features?, language?, color? }`;
24
+ * `{ text, family?, size?, weight?, style?, features?, language?,
25
+ * letterSpacing?, color? }`;
25
26
  * span fields override the base style. Options:
26
27
  *
27
28
  * - `maxWidth` — target container width (default: unlimited)
@@ -72,6 +73,7 @@ export class TextLayout {
72
73
  textRendering: s.textRendering ?? style.textRendering,
73
74
  features: s.features ?? style.features,
74
75
  language: s.language ?? style.language,
76
+ letterSpacing: s.letterSpacing ?? style.letterSpacing,
75
77
  color: s.color ?? style.color ?? null
76
78
  };
77
79
  merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
@@ -322,7 +324,9 @@ export class TextLayout {
322
324
  const m = TRAILING_WS.exec(last.text);
323
325
  if (m) {
324
326
  const spaceGlyph = last.span.font.fk.glyphForCodePoint(0x20);
325
- wsWidth = m[0].length * spaceGlyph.advanceWidth * last.span.font.scale(last.span.size);
327
+ const spacing = Number.isFinite(last.span.letterSpacing) ? last.span.letterSpacing : 0;
328
+ wsWidth =
329
+ m[0].length * (spaceGlyph.advanceWidth * last.span.font.scale(last.span.size) + spacing);
326
330
  }
327
331
  }
328
332
  return { fragments, width, wsWidth, required, start, end };
package/lib/text/shape.js CHANGED
@@ -45,6 +45,62 @@ export function normalizedLevels(levels, start, end) {
45
45
  return Array.prototype.slice.call(levels, start, end).join(',');
46
46
  }
47
47
 
48
+ /**
49
+ * The ligatures a font applies by default and a reader can do without. CSS
50
+ * turns them off wherever letters are spaced apart, since an `fi` drawn as
51
+ * one glyph cannot be spaced in its middle: the gap would open on one side of
52
+ * the pair and not the other. Required ones — Arabic joining forms, `rlig` —
53
+ * stay on.
54
+ */
55
+ const OPTIONAL_LIGATURES = ['liga', 'clig', 'dlig', 'hlig'];
56
+
57
+ /**
58
+ * The features a letter-spaced run shapes with: the optional ligatures off,
59
+ * underneath whatever the caller asked for — a style that names `liga`
60
+ * itself still gets it. `features` is either form fontkit takes, an array of
61
+ * tags to turn on or an object of tag → on/off.
62
+ */
63
+ /**
64
+ * A copy of `features` for fontkit, which **adds to the object it is handed**
65
+ * — `rvrn` and the like, as it plans the shaping. Handed a caller's own
66
+ * object that is a style quietly growing keys, which changes the memo key it
67
+ * is filed under next time, and a frozen one throws.
68
+ */
69
+ function ownFeatures(features) {
70
+ if (!features) return features;
71
+ return Array.isArray(features) ? [...features] : { ...features };
72
+ }
73
+
74
+ function spacedFeatures(features) {
75
+ const off = Object.fromEntries(OPTIONAL_LIGATURES.map((tag) => [tag, false]));
76
+ if (!features) return off;
77
+ if (Array.isArray(features)) {
78
+ for (const tag of features) off[tag] = true;
79
+ return off;
80
+ }
81
+ return { ...off, ...features };
82
+ }
83
+
84
+ /**
85
+ * Space a shaped run's glyphs apart by `spacing` px: added to the advance of
86
+ * every glyph that has one, so a combining mark keeps sitting on its base.
87
+ * An rtl run stores its glyphs in visual order, and its spacing belongs on
88
+ * the *reading* side of each glyph — the left — so there the glyph is drawn
89
+ * that much further along its advance as well.
90
+ */
91
+ function spaceGlyphs(shaped, spacing) {
92
+ const rtl = shaped.direction === 'rtl';
93
+ let width = 0;
94
+ for (const g of shaped.glyphs) {
95
+ if (g.ax > 0) {
96
+ g.ax += spacing;
97
+ if (rtl) g.dx += spacing;
98
+ }
99
+ width += g.ax;
100
+ }
101
+ shaped.width = width;
102
+ }
103
+
48
104
  /**
49
105
  * Shape a string through the full pipeline:
50
106
  *
@@ -61,7 +117,7 @@ export function normalizedLevels(levels, start, end) {
61
117
  * @param {FontManager} fonts
62
118
  * @param {string} text
63
119
  * @param {object} style { font | family/weight/style, size, features,
64
- * language, direction: 'ltr'|'rtl'|'auto' }
120
+ * language, letterSpacing, direction: 'ltr'|'rtl'|'auto' }
65
121
  * @param {ArrayLike<number>} [levels] precomputed embedding levels for `text`
66
122
  * @returns {{text, width, baseLevel, runs: Array<{font, size, direction,
67
123
  * level, width, glyphs, text, start, end}>}}
@@ -108,6 +164,11 @@ export function shapeText(fonts, text, style, levels) {
108
164
  i += ch.length;
109
165
  }
110
166
 
167
+ // px added after every glyph, CSS's `letter-spacing`; 0 leaves the run
168
+ // exactly as the font shapes it, ligatures and all
169
+ const spacing = Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0;
170
+ const features = spacing ? spacedFeatures(style.features) : ownFeatures(style.features);
171
+
111
172
  const runs = [];
112
173
  let width = 0;
113
174
  for (const item of items) {
@@ -115,9 +176,10 @@ export function shapeText(fonts, text, style, levels) {
115
176
  const sub = text.slice(item.start, item.end);
116
177
  const shaped = item.font.shape(sub, size, {
117
178
  direction,
118
- features: style.features,
179
+ features,
119
180
  language: style.language
120
181
  });
182
+ if (spacing) spaceGlyphs(shaped, spacing);
121
183
  shaped.level = item.level;
122
184
  shaped.text = sub;
123
185
  shaped.start = item.start;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.8.2",
3
+ "version": "8.9.0",
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",