@mark1russell7/typeset 0.1.0 → 0.1.2

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.
Files changed (61) hide show
  1. package/README.md +38 -0
  2. package/build/breaking.d.ts +2 -1
  3. package/build/chunks/breaking.d.ts +2 -77
  4. package/build/chunks/breaking.d.ts.map +1 -1
  5. package/build/chunks/engines.d.ts +32 -0
  6. package/build/chunks/engines.d.ts.map +1 -0
  7. package/build/chunks/engines.js +35 -0
  8. package/build/chunks/engines.js.map +1 -0
  9. package/build/chunks/font.d.ts +203 -0
  10. package/build/chunks/font.d.ts.map +1 -0
  11. package/build/chunks/font.js +3550 -0
  12. package/build/chunks/font.js.map +1 -0
  13. package/build/chunks/fonts.d.ts +8 -0
  14. package/build/chunks/fonts.d.ts.map +1 -0
  15. package/build/chunks/fonts.js +13 -0
  16. package/build/chunks/fonts.js.map +1 -0
  17. package/build/chunks/measurer.d.ts +67 -0
  18. package/build/chunks/measurer.d.ts.map +1 -0
  19. package/build/chunks/measurer.js +277 -0
  20. package/build/chunks/measurer.js.map +1 -0
  21. package/build/chunks/paragraph.d.ts +78 -0
  22. package/build/chunks/paragraph.d.ts.map +1 -0
  23. package/build/chunks/sfnt.d.ts +22 -0
  24. package/build/chunks/sfnt.d.ts.map +1 -0
  25. package/build/chunks/text.d.ts +48 -0
  26. package/build/chunks/text.d.ts.map +1 -0
  27. package/build/chunks/text.js +237 -0
  28. package/build/chunks/text.js.map +1 -0
  29. package/build/fonts-inspect.d.ts +10 -0
  30. package/build/fonts-inspect.d.ts.map +1 -0
  31. package/build/fonts-inspect.js +1 -0
  32. package/build/fonts.d.ts +5 -0
  33. package/build/fonts.js +4 -0
  34. package/build/index.d.ts +4 -1
  35. package/build/index.js +5 -1
  36. package/build/measurer.d.ts +2 -0
  37. package/build/measurer.js +1 -0
  38. package/build/text.d.ts +2 -0
  39. package/build/text.js +1 -0
  40. package/package.json +22 -3
  41. package/src/fonts/bidi.ts +125 -0
  42. package/src/fonts/binary.ts +41 -0
  43. package/src/fonts/engines.ts +38 -0
  44. package/src/fonts/font.ts +442 -0
  45. package/src/fonts/gvar.ts +220 -0
  46. package/src/fonts/index.ts +10 -0
  47. package/src/fonts/indic.ts +585 -0
  48. package/src/fonts/joining.ts +141 -0
  49. package/src/fonts/kerning.ts +217 -0
  50. package/src/fonts/sfnt.ts +102 -0
  51. package/src/fonts/substitution.ts +586 -0
  52. package/src/fonts/tables.ts +140 -0
  53. package/src/fonts/variations.ts +175 -0
  54. package/src/fonts/words.ts +119 -0
  55. package/src/fonts-inspect.ts +8 -0
  56. package/src/fonts.ts +8 -0
  57. package/src/index.ts +3 -0
  58. package/src/measurer/advance.ts +307 -0
  59. package/src/measurer.ts +5 -0
  60. package/src/text/rope.ts +291 -0
  61. package/src/text.ts +1 -0
@@ -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
+ }
@@ -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";
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The `Text` rope: a persistent balanced tree of strings with the width and the length of each subtree. An edit makes a
3
+ * new rope. The new rope shares the subtrees that do not change. A width function gives the width of a run of text. The
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
+ *
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 `@mark1russell7/typeset/fonts` obeys it for a space and a
8
+ * tab. It shapes each word alone, and its kerning goes only between two adjacent glyphs.
9
+ */
10
+ import type { WidthOf } from "../breaking/paragraph.ts";
11
+
12
+ /** The options of a rope. */
13
+ export interface TextOptions {
14
+ /**
15
+ * The separators of the separator law: each character is one UTF-16 code unit. The default is a space and a tab. An
16
+ * empty string makes each seam measure the full text of the two sides.
17
+ */
18
+ readonly separators?: string;
19
+ /** The largest count of UTF-16 code units in a leaf. The default is 256. */
20
+ readonly chunk?: number;
21
+ }
22
+
23
+ /** The summary of a subtree: its length, its width, and the two ends that a seam measures again. */
24
+ interface Summary {
25
+ /** The count of UTF-16 code units. */
26
+ readonly length: number;
27
+ readonly width: number;
28
+ /** This value is `true` when the text has a separator. */
29
+ readonly separated: boolean;
30
+ /** The text through the first separator, or all the text when the text has no separator. */
31
+ readonly head: string;
32
+ readonly headWidth: number;
33
+ /** The text from the last separator, or all the text when the text has no separator. */
34
+ readonly tail: string;
35
+ readonly tailWidth: number;
36
+ }
37
+
38
+ interface Leaf extends Summary {
39
+ readonly kind: "leaf";
40
+ readonly text: string;
41
+ readonly height: 0;
42
+ }
43
+
44
+ interface Branch extends Summary {
45
+ readonly kind: "branch";
46
+ readonly left: Node;
47
+ readonly right: Node;
48
+ readonly height: number;
49
+ }
50
+
51
+ type Node = Leaf | Branch;
52
+
53
+ /** The shared state of the ropes of one width function and one set of options. */
54
+ interface Context {
55
+ readonly width: WidthOf;
56
+ readonly separators: string;
57
+ readonly chunk: number;
58
+ }
59
+
60
+ function isSeparator(context: Context, code: number): boolean {
61
+ for (let i = 0; i < context.separators.length; i++) if (context.separators.charCodeAt(i) === code) return true;
62
+ return false;
63
+ }
64
+
65
+ function leafOf(context: Context, text: string): Leaf {
66
+ const width = context.width(text);
67
+ let first = -1;
68
+ let last = -1;
69
+ for (let i = 0; i < text.length; i++) {
70
+ if (!isSeparator(context, text.charCodeAt(i))) continue;
71
+ if (first < 0) first = i;
72
+ last = i;
73
+ }
74
+ if (first < 0) return { kind: "leaf", text, height: 0, length: text.length, width, separated: false, head: text, headWidth: width, tail: text, tailWidth: width };
75
+ const head = text.slice(0, first + 1);
76
+ const tail = text.slice(last);
77
+ return { kind: "leaf", text, height: 0, length: text.length, width, separated: true, head, headWidth: context.width(head), tail, tailWidth: context.width(tail) };
78
+ }
79
+
80
+ /**
81
+ * This function makes the branch of two subtrees. It measures one window: the tail of the left side and the head of the right
82
+ * side. The separator law then gives the width of all the text from the widths of the two sides.
83
+ */
84
+ function branchOf(context: Context, left: Node, right: Node): Branch {
85
+ const seam = left.tail + right.head;
86
+ const seamWidth = context.width(seam);
87
+ let width: number;
88
+ if (!left.separated && !right.separated) width = seamWidth;
89
+ else if (!left.separated) width = right.width - right.headWidth + seamWidth;
90
+ else if (!right.separated) width = left.width - left.tailWidth + seamWidth;
91
+ else width = left.width + right.width - left.tailWidth - right.headWidth + seamWidth;
92
+ return {
93
+ kind: "branch",
94
+ left,
95
+ right,
96
+ height: Math.max(left.height, right.height) + 1,
97
+ length: left.length + right.length,
98
+ width,
99
+ separated: left.separated || right.separated,
100
+ head: left.separated ? left.head : seam,
101
+ headWidth: left.separated ? left.headWidth : seamWidth,
102
+ tail: right.separated ? right.tail : seam,
103
+ tailWidth: right.separated ? right.tailWidth : seamWidth,
104
+ };
105
+ }
106
+
107
+ /** This function joins two trees in the order of the text and keeps the heights of the two children within 1. */
108
+ function join(context: Context, left: Node | undefined, right: Node | undefined): Node | undefined {
109
+ if (left === undefined) return right;
110
+ if (right === undefined) return left;
111
+ if (left.kind === "leaf" && right.kind === "leaf" && left.length + right.length <= context.chunk) return leafOf(context, left.text + right.text);
112
+ if (left.height > right.height + 1) {
113
+ const branch = left as Branch;
114
+ const joined = join(context, branch.right, right)!;
115
+ if (joined.height <= branch.left.height + 1) return branchOf(context, branch.left, joined);
116
+ return rotateLeft(context, branch.left, joined as Branch);
117
+ }
118
+ if (right.height > left.height + 1) {
119
+ const branch = right as Branch;
120
+ const joined = join(context, left, branch.left)!;
121
+ if (joined.height <= branch.right.height + 1) return branchOf(context, joined, branch.right);
122
+ return rotateRight(context, joined as Branch, branch.right);
123
+ }
124
+ return branchOf(context, left, right);
125
+ }
126
+
127
+ /** This function balances the branch of `left` and `heavy`, when `heavy` is 2 levels higher than `left`. */
128
+ function rotateLeft(context: Context, left: Node, heavy: Branch): Branch {
129
+ if (heavy.left.height <= heavy.right.height) return branchOf(context, branchOf(context, left, heavy.left), heavy.right);
130
+ const middle = heavy.left as Branch;
131
+ return branchOf(context, branchOf(context, left, middle.left), branchOf(context, middle.right, heavy.right));
132
+ }
133
+
134
+ /** This function balances the branch of `heavy` and `right`, when `heavy` is 2 levels higher than `right`. */
135
+ function rotateRight(context: Context, heavy: Branch, right: Node): Branch {
136
+ if (heavy.right.height <= heavy.left.height) return branchOf(context, heavy.left, branchOf(context, heavy.right, right));
137
+ const middle = heavy.right as Branch;
138
+ return branchOf(context, branchOf(context, heavy.left, middle.left), branchOf(context, middle.right, right));
139
+ }
140
+
141
+ /** This function divides a tree at the UTF-16 offset `at` into the text before and the text after. */
142
+ function split(context: Context, node: Node | undefined, at: number): [Node | undefined, Node | undefined] {
143
+ if (node === undefined) return [undefined, undefined];
144
+ if (at <= 0) return [undefined, node];
145
+ if (at >= node.length) return [node, undefined];
146
+ if (node.kind === "leaf") return [leafOf(context, node.text.slice(0, at)), leafOf(context, node.text.slice(at))];
147
+ if (at === node.left.length) return [node.left, node.right];
148
+ if (at < node.left.length) {
149
+ const [before, after] = split(context, node.left, at);
150
+ return [before, join(context, after, node.right)];
151
+ }
152
+ const [before, after] = split(context, node.right, at - node.left.length);
153
+ return [join(context, node.left, before), after];
154
+ }
155
+
156
+ /** This function gives the end of the next leaf of `text` from `start`: after a separator when it can, and not in a surrogate pair. */
157
+ function chunkEnd(context: Context, text: string, start: number): number {
158
+ const limit = start + context.chunk;
159
+ if (limit >= text.length) return text.length;
160
+ for (let i = limit - 1; i >= start + context.chunk / 2; i--) if (isSeparator(context, text.charCodeAt(i))) return i + 1;
161
+ const code = text.charCodeAt(limit - 1);
162
+ return code >= 0xd800 && code <= 0xdbff ? limit - 1 : limit;
163
+ }
164
+
165
+ /** This function makes a balanced tree of a text: leaves of at most `chunk` code units, and then a level of branches at a time. */
166
+ function build(context: Context, text: string): Node | undefined {
167
+ let level: Node[] = [];
168
+ for (let start = 0; start < text.length; ) {
169
+ const end = chunkEnd(context, text, start);
170
+ level.push(leafOf(context, text.slice(start, end)));
171
+ start = end;
172
+ }
173
+ if (level.length === 0) return undefined;
174
+ while (level.length > 1) {
175
+ const next: Node[] = [];
176
+ for (let i = 0; i + 1 < level.length; i += 2) next.push(branchOf(context, level[i]!, level[i + 1]!));
177
+ if (level.length % 2 === 1) next.push(level[level.length - 1]!);
178
+ level = next;
179
+ }
180
+ return level[0];
181
+ }
182
+
183
+ function leaves(node: Node | undefined, out: string[]): string[] {
184
+ if (node === undefined) return out;
185
+ const stack: Node[] = [node];
186
+ while (stack.length > 0) {
187
+ const top = stack.pop()!;
188
+ if (top.kind === "leaf") out.push(top.text);
189
+ else stack.push(top.right, top.left);
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * A text with its width: a persistent balanced rope. Each edit gives a new `Text` and does not change this one. The offsets
196
+ * are in UTF-16 code units, as the offsets of a JavaScript string.
197
+ */
198
+ export class Text {
199
+ readonly #context: Context;
200
+ readonly #root: Node | undefined;
201
+
202
+ private constructor(context: Context, root: Node | undefined) {
203
+ this.#context = context;
204
+ this.#root = root;
205
+ }
206
+
207
+ /** This function makes the rope of `text` with the width function `width`. */
208
+ static from(text: string, width: WidthOf, options: TextOptions = {}): Text {
209
+ const separators = options.separators ?? " \t";
210
+ const chunk = options.chunk ?? 256;
211
+ if (!Number.isInteger(chunk) || chunk < 2) throw new RangeError(`the chunk is ${chunk}, not an integer of 2 or more`);
212
+ for (let i = 0; i < separators.length; i++) {
213
+ const code = separators.charCodeAt(i);
214
+ if (code >= 0xd800 && code <= 0xdfff) throw new RangeError("a separator is one UTF-16 code unit, not a half of a surrogate pair");
215
+ }
216
+ const context: Context = { width, separators, chunk };
217
+ return new Text(context, build(context, text));
218
+ }
219
+
220
+ /** The count of UTF-16 code units of the text. */
221
+ get length(): number {
222
+ return this.#root?.length ?? 0;
223
+ }
224
+
225
+ /** The width of the text. It is equal to the width of the flat string when the width function obeys the separator law. */
226
+ get width(): number {
227
+ return this.#root?.width ?? 0;
228
+ }
229
+
230
+ /** The height of the tree: 0 for a text of one leaf. The tests use it to examine the balance. */
231
+ get height(): number {
232
+ return this.#root?.height ?? 0;
233
+ }
234
+
235
+ /** This function gives the flat string. */
236
+ toString(): string {
237
+ return leaves(this.#root, []).join("");
238
+ }
239
+
240
+ /** This function gives the strings of the leaves in the order of the text. */
241
+ chunks(): string[] {
242
+ return leaves(this.#root, []);
243
+ }
244
+
245
+ /** This function gives the text from `start` to `end`, as a new rope. */
246
+ slice(start: number, end: number = this.length): Text {
247
+ this.#check(start, end);
248
+ const [, after] = split(this.#context, this.#root, start);
249
+ const [middle] = split(this.#context, after, end - start);
250
+ return new Text(this.#context, middle);
251
+ }
252
+
253
+ /** This function gives the width of the text from `start` to `end`. */
254
+ widthOf(start: number, end: number = this.length): number {
255
+ return this.slice(start, end).width;
256
+ }
257
+
258
+ /** This function gives this text and then `other`. The two ropes must have the same width function and options. */
259
+ concat(other: Text): Text {
260
+ const context = other.#context;
261
+ if (context.width !== this.#context.width || context.separators !== this.#context.separators || context.chunk !== this.#context.chunk) {
262
+ throw new TypeError("the two ropes have different width functions or options");
263
+ }
264
+ return new Text(this.#context, join(this.#context, this.#root, other.#root));
265
+ }
266
+
267
+ /** This function puts `text` at the offset `at`. */
268
+ insert(at: number, text: string): Text {
269
+ return this.replace(at, at, text);
270
+ }
271
+
272
+ /** This function removes the text from `start` to `end`. */
273
+ remove(start: number, end: number): Text {
274
+ return this.replace(start, end, "");
275
+ }
276
+
277
+ /** This function puts `text` in place of the text from `start` to `end`. */
278
+ replace(start: number, end: number, text: string): Text {
279
+ this.#check(start, end);
280
+ const [before, rest] = split(this.#context, this.#root, start);
281
+ const [, after] = split(this.#context, rest, end - start);
282
+ const middle = build(this.#context, text);
283
+ return new Text(this.#context, join(this.#context, join(this.#context, before, middle), after));
284
+ }
285
+
286
+ #check(start: number, end: number): void {
287
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > this.length) {
288
+ throw new RangeError(`the range ${start} to ${end} is not in the text of length ${this.length}`);
289
+ }
290
+ }
291
+ }
package/src/text.ts ADDED
@@ -0,0 +1 @@
1
+ export { Text, type TextOptions } from "./text/rope.ts";