@mark1russell7/typeset 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/build/chunks/engines.d.ts +32 -0
- package/build/chunks/engines.d.ts.map +1 -0
- package/build/chunks/engines.js +35 -0
- package/build/chunks/engines.js.map +1 -0
- package/build/chunks/font.d.ts +228 -0
- package/build/chunks/font.d.ts.map +1 -0
- package/build/chunks/font.js +3779 -0
- package/build/chunks/font.js.map +1 -0
- package/build/chunks/fonts.d.ts +31 -0
- package/build/chunks/fonts.d.ts.map +1 -0
- package/build/chunks/fonts.js +150 -0
- package/build/chunks/fonts.js.map +1 -0
- package/build/chunks/measurer.d.ts +67 -0
- package/build/chunks/measurer.d.ts.map +1 -0
- package/build/chunks/measurer.js +277 -0
- package/build/chunks/measurer.js.map +1 -0
- package/build/chunks/sfnt.d.ts +22 -0
- package/build/chunks/sfnt.d.ts.map +1 -0
- package/build/chunks/text.js.map +1 -1
- package/build/fonts-inspect.d.ts +10 -0
- package/build/fonts-inspect.d.ts.map +1 -0
- package/build/fonts-inspect.js +1 -0
- package/build/fonts.d.ts +5 -0
- package/build/fonts.js +4 -0
- package/build/index.d.ts +3 -1
- package/build/index.js +3 -1
- package/build/measurer.d.ts +2 -0
- package/build/measurer.js +1 -0
- package/package.json +18 -4
- package/src/fonts/bidi.ts +125 -0
- package/src/fonts/binary.ts +41 -0
- package/src/fonts/bound.ts +167 -0
- package/src/fonts/engines.ts +38 -0
- package/src/fonts/font.ts +458 -0
- package/src/fonts/gvar.ts +220 -0
- package/src/fonts/index.ts +11 -0
- package/src/fonts/indic.ts +626 -0
- package/src/fonts/joining.ts +141 -0
- package/src/fonts/kerning.ts +278 -0
- package/src/fonts/sfnt.ts +102 -0
- package/src/fonts/substitution.ts +686 -0
- package/src/fonts/tables.ts +162 -0
- package/src/fonts/variations.ts +175 -0
- package/src/fonts/words.ts +119 -0
- package/src/fonts-inspect.ts +8 -0
- package/src/fonts.ts +9 -0
- package/src/index.ts +2 -0
- package/src/measurer/advance.ts +307 -0
- package/src/measurer.ts +5 -0
- package/src/text/rope.ts +1 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The advance function of the font model: the width of a text in a CSS font, as the measurer of
|
|
3
|
+
* `@mark1russell7/layout-contract/text` asks for it. The host gives the font files as faces. The function finds the face
|
|
4
|
+
* of each run of the text with the matching rules of CSS. It finds the script of the run, and measures the run with the
|
|
5
|
+
* rules of one text engine.
|
|
6
|
+
*/
|
|
7
|
+
import { engines, isEngineName, type EngineName, type EngineRules } from "../fonts/engines.ts";
|
|
8
|
+
import { measureWidth, readFont, type Font } from "../fonts/font.ts";
|
|
9
|
+
import type { Decompress } from "../fonts/sfnt.ts";
|
|
10
|
+
|
|
11
|
+
/** One font file of the host, with the family, the weight and the style that CSS matches. */
|
|
12
|
+
export interface FontFace {
|
|
13
|
+
/** The family name, as the CSS font names it. The match ignores the case. */
|
|
14
|
+
readonly family: string;
|
|
15
|
+
/** The bytes of a TrueType, OpenType or WOFF2 file. */
|
|
16
|
+
readonly data: Uint8Array;
|
|
17
|
+
/**
|
|
18
|
+
* The weight of the face: one value, or the range of a variable font. The default is the range of the `wght` axis of the
|
|
19
|
+
* font, or 400 for a font with no `wght` axis.
|
|
20
|
+
*/
|
|
21
|
+
readonly weight?: number | readonly [number, number];
|
|
22
|
+
/** The style of the face. The default is `normal`. */
|
|
23
|
+
readonly style?: "normal" | "italic" | "oblique";
|
|
24
|
+
/** The Brotli decompressor of a WOFF2 file. */
|
|
25
|
+
readonly decompress?: Decompress;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The options of `fontAdvance`. */
|
|
29
|
+
export interface FontAdvanceOptions {
|
|
30
|
+
/** The font files of the host. The order sets the choice between two faces with the same match. */
|
|
31
|
+
readonly faces: readonly FontFace[];
|
|
32
|
+
/** The text engine: a row name of `engines`, or the rules of a row. The default is `freetype`, as Chromium on Linux. */
|
|
33
|
+
readonly engine?: EngineName | EngineRules;
|
|
34
|
+
/**
|
|
35
|
+
* The shaping of `measureWidth`. The default is `canvas`, as `canvasAdvance` of layout-contract measures each segment
|
|
36
|
+
* with `measureText`. Use `dom` for the widths of a text in a page.
|
|
37
|
+
*/
|
|
38
|
+
readonly shaping?: "dom" | "canvas";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The parts of a CSS font shorthand that change a width. */
|
|
42
|
+
export interface FontSpec {
|
|
43
|
+
readonly style: "normal" | "italic" | "oblique";
|
|
44
|
+
/** The angle of an oblique style in degrees. CSS gives 14 when the font names no angle. */
|
|
45
|
+
readonly angle: number;
|
|
46
|
+
/** The weight from 1 to 1000. */
|
|
47
|
+
readonly weight: number;
|
|
48
|
+
/** The width of the font as a percent of the normal width, from the keyword of `font-stretch`. */
|
|
49
|
+
readonly stretch: number;
|
|
50
|
+
/** The size in px. */
|
|
51
|
+
readonly size: number;
|
|
52
|
+
/** The family names in the order of the font, with no quotes. */
|
|
53
|
+
readonly families: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stretches: Readonly<Record<string, number>> = {
|
|
57
|
+
"ultra-condensed": 50,
|
|
58
|
+
"extra-condensed": 62.5,
|
|
59
|
+
condensed: 75,
|
|
60
|
+
"semi-condensed": 87.5,
|
|
61
|
+
"semi-expanded": 112.5,
|
|
62
|
+
expanded: 125,
|
|
63
|
+
"extra-expanded": 150,
|
|
64
|
+
"ultra-expanded": 200,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Each number pattern matches a text in one way only. Thus a long run of digits does not make the match slow.
|
|
68
|
+
const sizePattern = /^((?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?)(px|pt)(\/.*)?$/i;
|
|
69
|
+
const anglePattern = /^(-?(?:\d+(?:\.\d+)?|\.\d+))deg$/i;
|
|
70
|
+
const weightPattern = /^(?:\d+(?:\.\d+)?|\.\d+)$/;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* This function reads a CSS font shorthand, for example `italic 700 16px/1.2 "Noto Sans", sans-serif`. The size must be in
|
|
74
|
+
* `px` or `pt`. The function refuses `small-caps`, the system font keywords and the relative sizes with a `RangeError`.
|
|
75
|
+
*/
|
|
76
|
+
export function parseFont(font: string): FontSpec {
|
|
77
|
+
const tokens = font.trim().split(/\s+/);
|
|
78
|
+
let style: FontSpec["style"] = "normal";
|
|
79
|
+
let angle = 14;
|
|
80
|
+
let weight = 400;
|
|
81
|
+
let stretch = 100;
|
|
82
|
+
let index = 0;
|
|
83
|
+
for (; index < tokens.length; index++) {
|
|
84
|
+
const token = tokens[index]!;
|
|
85
|
+
const lower = token.toLowerCase();
|
|
86
|
+
const size = sizePattern.exec(token);
|
|
87
|
+
if (size !== null) break;
|
|
88
|
+
if (lower === "normal") continue;
|
|
89
|
+
if (lower === "italic") style = "italic";
|
|
90
|
+
else if (lower === "oblique") {
|
|
91
|
+
style = "oblique";
|
|
92
|
+
const next = anglePattern.exec(tokens[index + 1] ?? "");
|
|
93
|
+
if (next !== null) {
|
|
94
|
+
angle = Number(next[1]);
|
|
95
|
+
index++;
|
|
96
|
+
}
|
|
97
|
+
} else if (lower === "bold" || lower === "bolder") weight = 700;
|
|
98
|
+
else if (lower === "lighter") weight = 100;
|
|
99
|
+
else if (weightPattern.test(token) && Number(token) >= 1 && Number(token) <= 1000) weight = Number(token);
|
|
100
|
+
else if (Object.hasOwn(stretches, lower)) stretch = stretches[lower]!;
|
|
101
|
+
else throw new RangeError(`the font "${font}" has the part "${token}", which the measurer does not know`);
|
|
102
|
+
}
|
|
103
|
+
const size = sizePattern.exec(tokens[index] ?? "");
|
|
104
|
+
if (size === null) throw new RangeError(`the font "${font}" has no size in px or pt`);
|
|
105
|
+
let rest = tokens.slice(index + 1).join(" ");
|
|
106
|
+
// A line height after a space: "16px / 1.2" or "16px /1.2"
|
|
107
|
+
if (size[3] === undefined && rest.startsWith("/")) rest = rest.slice(1).trim().replace(/^\S+\s*/, "");
|
|
108
|
+
const families = rest
|
|
109
|
+
.split(",")
|
|
110
|
+
.map((family) => family.trim().replace(/^(["'])(.*)\1$/, "$2").trim())
|
|
111
|
+
.filter((family) => family.length > 0);
|
|
112
|
+
if (families.length === 0) throw new RangeError(`the font "${font}" has no family`);
|
|
113
|
+
const value = Number(size[1]);
|
|
114
|
+
return { style, angle, weight, stretch, size: size[2]!.toLowerCase() === "pt" ? (value * 4) / 3 : value, families };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The Unicode scripts and their OpenType tags. A script that is not in the list gets `DFLT`. */
|
|
118
|
+
const scriptPatterns: readonly (readonly [RegExp, string])[] = [
|
|
119
|
+
[/\p{sc=Latin}/u, "latn"],
|
|
120
|
+
[/\p{sc=Greek}/u, "grek"],
|
|
121
|
+
[/\p{sc=Cyrillic}/u, "cyrl"],
|
|
122
|
+
[/\p{sc=Armenian}/u, "armn"],
|
|
123
|
+
[/\p{sc=Hebrew}/u, "hebr"],
|
|
124
|
+
[/\p{sc=Arabic}/u, "arab"],
|
|
125
|
+
[/\p{sc=Syriac}/u, "syrc"],
|
|
126
|
+
[/\p{sc=Thaana}/u, "thaa"],
|
|
127
|
+
[/\p{sc=Devanagari}/u, "deva"],
|
|
128
|
+
[/\p{sc=Bengali}/u, "beng"],
|
|
129
|
+
[/\p{sc=Gurmukhi}/u, "guru"],
|
|
130
|
+
[/\p{sc=Gujarati}/u, "gujr"],
|
|
131
|
+
[/\p{sc=Oriya}/u, "orya"],
|
|
132
|
+
[/\p{sc=Tamil}/u, "taml"],
|
|
133
|
+
[/\p{sc=Telugu}/u, "telu"],
|
|
134
|
+
[/\p{sc=Kannada}/u, "knda"],
|
|
135
|
+
[/\p{sc=Malayalam}/u, "mlym"],
|
|
136
|
+
[/\p{sc=Sinhala}/u, "sinh"],
|
|
137
|
+
[/\p{sc=Thai}/u, "thai"],
|
|
138
|
+
[/\p{sc=Lao}/u, "lao "],
|
|
139
|
+
[/\p{sc=Tibetan}/u, "tibt"],
|
|
140
|
+
[/\p{sc=Myanmar}/u, "mymr"],
|
|
141
|
+
[/\p{sc=Georgian}/u, "geor"],
|
|
142
|
+
[/\p{sc=Hangul}/u, "hang"],
|
|
143
|
+
[/\p{sc=Ethiopic}/u, "ethi"],
|
|
144
|
+
[/\p{sc=Khmer}/u, "khmr"],
|
|
145
|
+
[/\p{sc=Mongolian}/u, "mong"],
|
|
146
|
+
[/[\p{sc=Hiragana}\p{sc=Katakana}]/u, "kana"],
|
|
147
|
+
[/\p{sc=Bopomofo}/u, "bopo"],
|
|
148
|
+
[/\p{sc=Han}/u, "hani"],
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const neutral = /[\p{sc=Common}\p{sc=Inherited}\p{sc=Unknown}]/u;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* This function gives the OpenType script tag of a text, as HarfBuzz guesses it. The tag is the script of the first
|
|
155
|
+
* character that is not of the scripts Common, Inherited or Unknown. A text of only those characters, for example emoji, gets `DFLT`.
|
|
156
|
+
*/
|
|
157
|
+
export function scriptTag(text: string): string {
|
|
158
|
+
for (const character of text) {
|
|
159
|
+
if (neutral.test(character)) continue;
|
|
160
|
+
for (const [pattern, tag] of scriptPatterns) if (pattern.test(character)) return tag;
|
|
161
|
+
return "DFLT";
|
|
162
|
+
}
|
|
163
|
+
return "DFLT";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A character that stays in the run of the character before it: a mark, a joiner, a variation selector, a tag or a skin tone. */
|
|
167
|
+
const attached = /^[\p{M}︀-️\u{e0020}-\u{e007f}\u{e0100}-\u{e01ef}\u{1f3fb}-\u{1f3ff}]$/u;
|
|
168
|
+
|
|
169
|
+
const styleOrder: Readonly<Record<FontSpec["style"], readonly FontSpec["style"][]>> = {
|
|
170
|
+
normal: ["normal", "oblique", "italic"],
|
|
171
|
+
italic: ["italic", "oblique", "normal"],
|
|
172
|
+
oblique: ["oblique", "italic", "normal"],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** A face with its font, read at the first use. */
|
|
176
|
+
interface Loaded {
|
|
177
|
+
readonly face: FontFace;
|
|
178
|
+
readonly index: number;
|
|
179
|
+
readonly font: Font;
|
|
180
|
+
readonly range: readonly [number, number];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* This function gives the rank of a face for the weight `weight`, with the rules of CSS Fonts 4. A lower rank is a
|
|
185
|
+
* better match. A face whose range holds the weight has the rank 0.
|
|
186
|
+
*/
|
|
187
|
+
function weightRank(range: readonly [number, number], weight: number): number {
|
|
188
|
+
const [min, max] = range;
|
|
189
|
+
if (min <= weight && weight <= max) return 0;
|
|
190
|
+
const above = min > weight;
|
|
191
|
+
// The groups of CSS: 1 is the first choice after an exact match, 3 is the last
|
|
192
|
+
if (weight >= 400 && weight <= 500) {
|
|
193
|
+
if (above && min <= 500) return 1000 + min;
|
|
194
|
+
if (!above) return 3000 - max;
|
|
195
|
+
return 5000 + min;
|
|
196
|
+
}
|
|
197
|
+
if (weight < 400) return above ? 3000 + min : 1000 - max;
|
|
198
|
+
return above ? 1000 + min : 3000 - max;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* This function gives the advance function of the faces of the host: the width in px of a text in a CSS font. Each run
|
|
203
|
+
* of the text gets the first family of the font with a glyph for its first character. When no family has a glyph, the
|
|
204
|
+
* run gets the first family that has a face. The width of a run is `measureWidth` with the size of the font, the script
|
|
205
|
+
* of `scriptTag` and the rules of the engine. The instance of a variable face takes the weight and the stretch as `wdth`.
|
|
206
|
+
* It takes the style as `ital` or `slnt`, and the size as `opsz`, as `font-optical-sizing: auto` does.
|
|
207
|
+
*/
|
|
208
|
+
export function fontAdvance(options: FontAdvanceOptions): (text: string, font: string) => number {
|
|
209
|
+
const engine = options.engine ?? "freetype";
|
|
210
|
+
if (typeof engine === "string" && !isEngineName(engine)) throw new RangeError(`the engine "${engine}" is not a row of engines`);
|
|
211
|
+
const rules: EngineRules = typeof engine === "string" ? engines[engine] : engine;
|
|
212
|
+
const shaping = options.shaping ?? "canvas";
|
|
213
|
+
const faces = options.faces;
|
|
214
|
+
const loaded = new Map<number, Loaded>();
|
|
215
|
+
const specs = new Map<string, FontSpec>();
|
|
216
|
+
const instances = new Map<string, Font>();
|
|
217
|
+
const families = new Map<string, readonly Font[] | undefined>();
|
|
218
|
+
|
|
219
|
+
const load = (index: number): Loaded => {
|
|
220
|
+
let entry = loaded.get(index);
|
|
221
|
+
if (entry === undefined) {
|
|
222
|
+
const face = faces[index]!;
|
|
223
|
+
const font = readFont(face.data, { deltas: rules.deltas, metrics: rules.metrics, ...(face.decompress === undefined ? {} : { decompress: face.decompress }) });
|
|
224
|
+
const axis = font.axes.find((value) => value.tag === "wght");
|
|
225
|
+
const declared = face.weight;
|
|
226
|
+
const range: readonly [number, number] = typeof declared === "number" ? [declared, declared] : (declared ?? (axis === undefined ? [400, 400] : [axis.min, axis.max]));
|
|
227
|
+
entry = { face, index, font, range };
|
|
228
|
+
loaded.set(index, entry);
|
|
229
|
+
}
|
|
230
|
+
return entry;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** This function gives the font of the best face of a family for the spec, or `undefined` when the family has no face. */
|
|
234
|
+
const fontOf = (family: string, spec: FontSpec): Font | undefined => {
|
|
235
|
+
const name = family.toLowerCase();
|
|
236
|
+
const candidates: Loaded[] = [];
|
|
237
|
+
for (let i = 0; i < faces.length; i++) if (faces[i]!.family.toLowerCase() === name) candidates.push(load(i));
|
|
238
|
+
if (candidates.length === 0) return undefined;
|
|
239
|
+
const style = styleOrder[spec.style].find((value) => candidates.some((entry) => (entry.face.style ?? "normal") === value))!;
|
|
240
|
+
let best: Loaded | undefined;
|
|
241
|
+
let bestRank = Infinity;
|
|
242
|
+
for (const entry of candidates) {
|
|
243
|
+
if ((entry.face.style ?? "normal") !== style) continue;
|
|
244
|
+
const rank = weightRank(entry.range, spec.weight);
|
|
245
|
+
if (rank < bestRank) {
|
|
246
|
+
best = entry;
|
|
247
|
+
bestRank = rank;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const chosen = best!;
|
|
251
|
+
const tags = new Set(chosen.font.axes.map((axis) => axis.tag));
|
|
252
|
+
const instance: Record<string, number> = {};
|
|
253
|
+
if (tags.has("wght")) instance["wght"] = Math.min(chosen.range[1], Math.max(chosen.range[0], spec.weight));
|
|
254
|
+
if (tags.has("wdth")) instance["wdth"] = spec.stretch;
|
|
255
|
+
if (tags.has("opsz")) instance["opsz"] = spec.size;
|
|
256
|
+
if (tags.has("ital")) instance["ital"] = spec.style === "italic" ? 1 : 0;
|
|
257
|
+
if (tags.has("slnt")) instance["slnt"] = spec.style === "oblique" ? -spec.angle : 0;
|
|
258
|
+
const key = `${chosen.index}|${JSON.stringify(instance)}`;
|
|
259
|
+
let font = instances.get(key);
|
|
260
|
+
if (font === undefined) {
|
|
261
|
+
font = Object.keys(instance).length === 0 ? chosen.font : chosen.font.at(instance);
|
|
262
|
+
instances.set(key, font);
|
|
263
|
+
}
|
|
264
|
+
return font;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const fontsOf = (font: string): { readonly spec: FontSpec; readonly fonts: readonly Font[] } => {
|
|
268
|
+
let spec = specs.get(font);
|
|
269
|
+
if (spec === undefined) {
|
|
270
|
+
spec = parseFont(font);
|
|
271
|
+
if (specs.size >= 256) specs.clear();
|
|
272
|
+
specs.set(font, spec);
|
|
273
|
+
}
|
|
274
|
+
let fonts = families.get(font);
|
|
275
|
+
if (fonts === undefined) {
|
|
276
|
+
fonts = spec.families.map((family) => fontOf(family, spec)).filter((value) => value !== undefined);
|
|
277
|
+
if (fonts.length === 0) throw new RangeError(`no face of the host has a family of the font "${font}"`);
|
|
278
|
+
if (families.size >= 256) families.clear();
|
|
279
|
+
families.set(font, fonts);
|
|
280
|
+
}
|
|
281
|
+
return { spec, fonts };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return (text: string, font: string): number => {
|
|
285
|
+
const { spec, fonts } = fontsOf(font);
|
|
286
|
+
const measure = (run: string, face: Font): number => measureWidth(face, run, spec.size, { script: scriptTag(run), shaping, rounding: rules.rounding, sum: rules.sum });
|
|
287
|
+
if (fonts.length === 1) return measure(text, fonts[0]!);
|
|
288
|
+
let width = 0;
|
|
289
|
+
let run = "";
|
|
290
|
+
let current: Font | undefined;
|
|
291
|
+
let joined = false;
|
|
292
|
+
for (const character of text) {
|
|
293
|
+
const code = character.codePointAt(0)!;
|
|
294
|
+
const stays = current !== undefined && (joined || attached.test(character));
|
|
295
|
+
const face = stays ? current! : (fonts.find((value) => value.glyph(code) !== 0) ?? fonts[0]!);
|
|
296
|
+
if (face !== current && run.length > 0) {
|
|
297
|
+
width += measure(run, current!);
|
|
298
|
+
run = "";
|
|
299
|
+
}
|
|
300
|
+
current = face;
|
|
301
|
+
run += character;
|
|
302
|
+
joined = code === 0x200d;
|
|
303
|
+
}
|
|
304
|
+
if (run.length > 0) width += measure(run, current!);
|
|
305
|
+
return width;
|
|
306
|
+
};
|
|
307
|
+
}
|
package/src/measurer.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subpath `./measurer`: the advance function of a layout engine, from the font model of typeset. The measurer of
|
|
3
|
+
* `@mark1russell7/layout-contract/text` takes it as its `AdvanceFn`.
|
|
4
|
+
*/
|
|
5
|
+
export { fontAdvance, parseFont, scriptTag, type FontAdvanceOptions, type FontFace, type FontSpec } from "./measurer/advance.ts";
|
package/src/text/rope.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* rope uses it only for the leaves and for the windows at the seams. Thus an edit uses it of the order of `log n` times.
|
|
5
5
|
*
|
|
6
6
|
* The seams are exact when the width function obeys the separator law. For each text `p + s + q` with a separator `s`:
|
|
7
|
-
* `width(p + s + q) = width(p + s) + width(s + q) - width(s)`. The model of `@typeset/fonts` obeys it for a space and a
|
|
7
|
+
* `width(p + s + q) = width(p + s) + width(s + q) - width(s)`. The model of `@mark1russell7/typeset/fonts` obeys it for a space and a
|
|
8
8
|
* tab. It shapes each word alone, and its kerning goes only between two adjacent glyphs.
|
|
9
9
|
*/
|
|
10
10
|
import type { WidthOf } from "../breaking/paragraph.ts";
|