@mark1russell7/typeset 0.1.2 → 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.
@@ -1,8 +1,31 @@
1
1
  import { a as measureWidth, c as Axis, l as Instance, n as MeasureOptions, o as readFont, r as ReadOptions, t as Font } from "./font.js";
2
2
  import { i as isEngineName, n as EngineRules, r as engines, t as EngineName } from "./engines.js";
3
3
  import { t as Decompress } from "./sfnt.js";
4
+ /** The limit of `widthBound` for the width of a text over a declared set of characters. It needs no measurement. */
5
+ interface WidthBound {
6
+ /** The largest total advance of one character with all its expansions, in font units. */
7
+ readonly advance: number;
8
+ /** The largest positive kerning of one glyph, in font units. */
9
+ readonly kerning: number;
10
+ /** The largest count of glyphs of one character. */
11
+ readonly glyphs: number;
12
+ /**
13
+ * This function gives the limit in px for a text of `length` UTF-16 units at the size `size`. It takes the length of a
14
+ * string, so it does not read the text. The limit is for a text with only the characters of the set.
15
+ */
16
+ width(length: number, size: number): number;
17
+ }
18
+ /**
19
+ * This function gives a limit for the width of each text over the characters `characters`, with no measurement. For each such
20
+ * text, `measureWidth(font, text, size, options)` is at most `width(text.length, size)` of the result. Give the same `options`.
21
+ * A text with another character has no limit from this function.
22
+ *
23
+ * The limit is sound, not tight. It follows the widest character of the set. It includes each glyph of the GSUB closure, each
24
+ * dotted circle, each part or composite of the Indic normalization, and the largest positive kerning.
25
+ */
26
+ declare function widthBound(font: Font, characters: string, options?: MeasureOptions): WidthBound;
4
27
  declare namespace fonts_d_exports {
5
- export { Axis, Decompress, EngineName, EngineRules, Font, Instance, MeasureOptions, ReadOptions, engines, isEngineName, measureWidth, readFont };
28
+ export { Axis, Decompress, EngineName, EngineRules, Font, Instance, MeasureOptions, ReadOptions, WidthBound, engines, isEngineName, measureWidth, readFont, widthBound };
6
29
  }
7
- export { fonts_d_exports as t };
30
+ export { WidthBound as n, widthBound as r, fonts_d_exports as t };
8
31
  //# sourceMappingURL=fonts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fonts.d.ts","names":[],"sources":["../../src/fonts.ts"],"mappings":""}
1
+ {"version":3,"file":"fonts.d.ts","names":[],"sources":["../../src/fonts/bound.ts","../../src/fonts.ts"],"mappings":";;;;UAMiB;;WAEN;;WAEA;;WAEA;;;;;EAKT,MAAM,gBAAgB;;;;;;;;;;iBA6FR,WAAW,MAAM,MAAM,oBAAoB,UAAS,iBAAsB"}
@@ -1,13 +1,150 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime.js";
2
- import { n as measureWidth, r as readFont } from "./font.js";
2
+ import { c as indicFeatures, i as shapingOf, l as indicForms, n as measureWidth, r as readFont, s as joiningStages } from "./font.js";
3
3
  import { n as isEngineName, t as engines } from "./engines.js";
4
+ // src/fonts/bound.ts
5
+ /**
6
+ * The largest count of passes over the lookups of a shaper with stages. A pass covers one stage, and no shaper has more stages:
7
+ * the Indic shaper has 13 and the joining has 11. A rule that expands its own glyph has no fixed point, so it stops at this count.
8
+ */
9
+ const ROUNDS = 16;
10
+ /** The relative error of one rounding to single precision. */
11
+ const FLOAT32 = 2 ** -24;
12
+ /** The relative error of one rounding to double precision. */
13
+ const FLOAT64 = 2 ** -53;
14
+ /** The largest size of a kerning value of a `ValueRecord`, and of its variation delta, in font units: the range of `int16`. */
15
+ const KERNING_RANGE = 2 ** 15;
16
+ function larger(a, b) {
17
+ return {
18
+ advance: Math.max(a.advance, b.advance),
19
+ glyphs: Math.max(a.glyphs, b.glyphs)
20
+ };
21
+ }
22
+ function sum(a, b) {
23
+ return {
24
+ advance: a.advance + b.advance,
25
+ glyphs: a.glyphs + b.glyphs
26
+ };
27
+ }
28
+ /** This function tells if a rule can apply to the glyphs `reached`. A ligature needs its first glyph and all its components. */
29
+ function applies(rule, reached) {
30
+ return reached.has(rule.from) && (rule.components ?? []).every((glyph) => reached.has(glyph));
31
+ }
32
+ /** This function adds the glyphs that the rules can give to `reached`. */
33
+ function reach(reached, rules) {
34
+ for (let size = -1; size !== reached.size;) {
35
+ size = reached.size;
36
+ for (const rule of rules) if (applies(rule, reached)) for (const glyph of rule.to) reached.add(glyph);
37
+ }
38
+ }
39
+ /**
40
+ * This function applies one list of rules to the expansions. Each rule uses the values from before the list. A lookup applies
41
+ * once at a position, so its output does not go back into it. The function tells if a value changed.
42
+ */
43
+ function relax(rules, expansions) {
44
+ const changes = [];
45
+ for (const { from, to } of rules) {
46
+ const before = expansions.get(from);
47
+ const after = to.reduce((total, glyph) => sum(total, expansions.get(glyph)), {
48
+ advance: 0,
49
+ glyphs: 0
50
+ });
51
+ if (after.advance > before.advance || after.glyphs > before.glyphs) changes.push([from, after]);
52
+ }
53
+ for (const [from, after] of changes) expansions.set(from, larger(expansions.get(from), after));
54
+ return changes.length > 0;
55
+ }
56
+ /**
57
+ * This function gives the largest expansion of each glyph of `reached`. Each ligature charges its first glyph, so each output
58
+ * glyph comes from one input glyph. Each pass goes backward over the lookups: a glyph then gets the expansions of the lookups
59
+ * after the lookup that makes it. A shaper with stages goes over the list again for each stage, so the passes repeat.
60
+ *
61
+ * One context rule can apply its nested lookups in any order. So the lists of a lookup with nested lookups repeat until no
62
+ * value changes, with the same limit. A value that stops changing holds for each order.
63
+ */
64
+ function expansionsOf(font, reached, lookups, rounds) {
65
+ const expansions = /* @__PURE__ */ new Map();
66
+ for (const glyph of reached) expansions.set(glyph, {
67
+ advance: Math.max(0, font.advance(glyph)),
68
+ glyphs: 1
69
+ });
70
+ const active = lookups.map((lists) => lists.map((rules) => rules.filter((rule) => applies(rule, reached))));
71
+ for (let round = 0; round < rounds; round++) {
72
+ let changed = false;
73
+ for (let i = active.length - 1; i >= 0; i--) {
74
+ const lists = active[i];
75
+ for (let step = 0; step < (lists.length > 1 ? ROUNDS : 1); step++) {
76
+ let moved = false;
77
+ for (const rules of lists) moved = relax(rules, expansions) || moved;
78
+ if (!moved) break;
79
+ changed = true;
80
+ }
81
+ }
82
+ if (!changed) break;
83
+ }
84
+ return expansions;
85
+ }
86
+ /**
87
+ * This function gives a limit for the width of each text over the characters `characters`, with no measurement. For each such
88
+ * text, `measureWidth(font, text, size, options)` is at most `width(text.length, size)` of the result. Give the same `options`.
89
+ * A text with another character has no limit from this function.
90
+ *
91
+ * The limit is sound, not tight. It follows the widest character of the set. It includes each glyph of the GSUB closure, each
92
+ * dotted circle, each part or composite of the Indic normalization, and the largest positive kerning.
93
+ */
94
+ function widthBound(font, characters, options = {}) {
95
+ const { script, features, shaper, kerning: pairLookups } = shapingOf(font, options);
96
+ const set = new Set(Array.from(characters, (character) => character.codePointAt(0)));
97
+ const indic = shaper === "indic" ? indicForms(set, (codePoint) => font.glyph(codePoint) !== 0) : void 0;
98
+ const reached = new Set([...indic?.codePoints ?? set].map((codePoint) => font.glyph(codePoint)));
99
+ const tags = shaper === "indic" ? indicFeatures(features) : shaper === "joining" ? joiningStages(features, true).flatMap((stage) => stage.features) : features;
100
+ const lookups = font.substitutions(script, tags).map((lookup) => lookup.rules());
101
+ reach(reached, lookups.flat(2));
102
+ const expansions = expansionsOf(font, reached, lookups, shaper === "plain" ? 1 : ROUNDS);
103
+ const form = (codePoint) => (indic?.composites.get(codePoint) ?? []).reduce((best, composite) => larger(best, form(composite)), expansions.get(font.glyph(codePoint)));
104
+ const inserted = indic === void 0 ? {
105
+ advance: 0,
106
+ glyphs: 0
107
+ } : expansions.get(font.glyph(9676));
108
+ let largest = {
109
+ advance: 0,
110
+ glyphs: 0
111
+ };
112
+ for (const codePoint of set) {
113
+ const parts = indic?.parts.get(codePoint) ?? [codePoint];
114
+ largest = larger(largest, parts.reduce((total, part) => sum(total, sum(form(part), inserted)), {
115
+ advance: 0,
116
+ glyphs: 0
117
+ }));
118
+ }
119
+ let kerning = 0;
120
+ for (const lookup of pairLookups) {
121
+ const { first, second } = lookup.largest(reached);
122
+ kerning += first + second;
123
+ }
124
+ const { advance, glyphs } = largest;
125
+ const pixel = options.rounding === "pixel";
126
+ const float32 = options.sum === "float32" && !pixel;
127
+ const pixelMargin = pixel ? .5078125 + (kerning > 0 ? .5 : 0) : 0;
128
+ const positions = pairLookups.length * 2;
129
+ const positionMargin = float32 ? positions * (1.5 + 2 * KERNING_RANGE / font.unitsPerEm) / 65536 : 0;
130
+ const kerningMargin = float32 ? positions * 5 * FLOAT32 * KERNING_RANGE : 0;
131
+ return {
132
+ advance,
133
+ kerning,
134
+ glyphs,
135
+ width(length, size) {
136
+ return length * ((advance + glyphs * (kerning + kerningMargin)) * (size / font.unitsPerEm) + glyphs * (pixelMargin + positionMargin)) * (float32 ? (1 + FLOAT32) ** (length + 6) : 1) * (1 + (8192 + length) * FLOAT64);
137
+ }
138
+ };
139
+ }
4
140
  // src/fonts.ts
5
141
  var fonts_exports = /* @__PURE__ */ __exportAll({
6
142
  engines: () => engines,
7
143
  isEngineName: () => isEngineName,
8
144
  measureWidth: () => measureWidth,
9
- readFont: () => readFont
145
+ readFont: () => readFont,
146
+ widthBound: () => widthBound
10
147
  });
11
- export { fonts_exports as t };
148
+ export { widthBound as n, fonts_exports as t };
12
149
 
13
150
  //# sourceMappingURL=fonts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"fonts.js","names":[],"sources":["../../src/fonts.ts"],"sourcesContent":["/**\n * The subpath `./fonts`: the font model of typeset. It reads the tables of a TrueType, OpenType or WOFF2 file. It gives\n * the width of a text as the text engine of a browser gives it. The host gives the bytes of the font files.\n */\nexport { measureWidth, readFont, type Font, type MeasureOptions, type ReadOptions } from \"./fonts/font.ts\";\nexport { engines, isEngineName, type EngineName, type EngineRules } from \"./fonts/engines.ts\";\nexport type { Decompress } from \"./fonts/sfnt.ts\";\nexport type { Axis, Instance } from \"./fonts/variations.ts\";\n"],"mappings":""}
1
+ {"version":3,"file":"fonts.js","names":[],"sources":["../../src/fonts/bound.ts","../../src/fonts.ts"],"sourcesContent":["import { shapingOf, type Font, type MeasureOptions } from \"./font.ts\";\nimport { indicFeatures, indicForms } from \"./indic.ts\";\nimport { joiningStages } from \"./joining.ts\";\nimport type { SubstitutionRule } from \"./substitution.ts\";\n\n/** The limit of `widthBound` for the width of a text over a declared set of characters. It needs no measurement. */\nexport interface WidthBound {\n /** The largest total advance of one character with all its expansions, in font units. */\n readonly advance: number;\n /** The largest positive kerning of one glyph, in font units. */\n readonly kerning: number;\n /** The largest count of glyphs of one character. */\n readonly glyphs: number;\n /**\n * This function gives the limit in px for a text of `length` UTF-16 units at the size `size`. It takes the length of a\n * string, so it does not read the text. The limit is for a text with only the characters of the set.\n */\n width(length: number, size: number): number;\n}\n\n/**\n * The largest count of passes over the lookups of a shaper with stages. A pass covers one stage, and no shaper has more stages:\n * the Indic shaper has 13 and the joining has 11. A rule that expands its own glyph has no fixed point, so it stops at this count.\n */\nconst ROUNDS = 16;\n/** The relative error of one rounding to single precision. */\nconst FLOAT32 = 2 ** -24;\n/** The relative error of one rounding to double precision. */\nconst FLOAT64 = 2 ** -53;\n/** The largest size of a kerning value of a `ValueRecord`, and of its variation delta, in font units: the range of `int16`. */\nconst KERNING_RANGE = 2 ** 15;\n\n/** The largest expansion of a glyph: the sum of its advances, and the count of its glyphs. */\ninterface Expansion {\n readonly advance: number;\n readonly glyphs: number;\n}\n\nfunction larger(a: Expansion, b: Expansion): Expansion {\n return { advance: Math.max(a.advance, b.advance), glyphs: Math.max(a.glyphs, b.glyphs) };\n}\n\nfunction sum(a: Expansion, b: Expansion): Expansion {\n return { advance: a.advance + b.advance, glyphs: a.glyphs + b.glyphs };\n}\n\n/** This function tells if a rule can apply to the glyphs `reached`. A ligature needs its first glyph and all its components. */\nfunction applies(rule: SubstitutionRule, reached: ReadonlySet<number>): boolean {\n return reached.has(rule.from) && (rule.components ?? []).every((glyph) => reached.has(glyph));\n}\n\n/** This function adds the glyphs that the rules can give to `reached`. */\nfunction reach(reached: Set<number>, rules: readonly SubstitutionRule[]): void {\n for (let size = -1; size !== reached.size; ) {\n size = reached.size;\n for (const rule of rules) if (applies(rule, reached)) for (const glyph of rule.to) reached.add(glyph);\n }\n}\n\n/**\n * This function applies one list of rules to the expansions. Each rule uses the values from before the list. A lookup applies\n * once at a position, so its output does not go back into it. The function tells if a value changed.\n */\nfunction relax(rules: readonly SubstitutionRule[], expansions: Map<number, Expansion>): boolean {\n const changes: (readonly [number, Expansion])[] = [];\n for (const { from, to } of rules) {\n const before = expansions.get(from)!;\n const after = to.reduce((total, glyph) => sum(total, expansions.get(glyph)!), { advance: 0, glyphs: 0 });\n if (after.advance > before.advance || after.glyphs > before.glyphs) changes.push([from, after]);\n }\n for (const [from, after] of changes) expansions.set(from, larger(expansions.get(from)!, after));\n return changes.length > 0;\n}\n\n/**\n * This function gives the largest expansion of each glyph of `reached`. Each ligature charges its first glyph, so each output\n * glyph comes from one input glyph. Each pass goes backward over the lookups: a glyph then gets the expansions of the lookups\n * after the lookup that makes it. A shaper with stages goes over the list again for each stage, so the passes repeat.\n *\n * One context rule can apply its nested lookups in any order. So the lists of a lookup with nested lookups repeat until no\n * value changes, with the same limit. A value that stops changing holds for each order.\n */\nfunction expansionsOf(font: Font, reached: ReadonlySet<number>, lookups: readonly (readonly (readonly SubstitutionRule[])[])[], rounds: number): Map<number, Expansion> {\n const expansions = new Map<number, Expansion>();\n for (const glyph of reached) expansions.set(glyph, { advance: Math.max(0, font.advance(glyph)), glyphs: 1 });\n const active = lookups.map((lists) => lists.map((rules) => rules.filter((rule) => applies(rule, reached))));\n for (let round = 0; round < rounds; round++) {\n let changed = false;\n for (let i = active.length - 1; i >= 0; i--) {\n const lists = active[i]!;\n for (let step = 0; step < (lists.length > 1 ? ROUNDS : 1); step++) {\n let moved = false;\n for (const rules of lists) moved = relax(rules, expansions) || moved;\n if (!moved) break;\n changed = true;\n }\n }\n if (!changed) break;\n }\n return expansions;\n}\n\n/**\n * This function gives a limit for the width of each text over the characters `characters`, with no measurement. For each such\n * text, `measureWidth(font, text, size, options)` is at most `width(text.length, size)` of the result. Give the same `options`.\n * A text with another character has no limit from this function.\n *\n * The limit is sound, not tight. It follows the widest character of the set. It includes each glyph of the GSUB closure, each\n * dotted circle, each part or composite of the Indic normalization, and the largest positive kerning.\n */\nexport function widthBound(font: Font, characters: string, options: MeasureOptions = {}): WidthBound {\n const { script, features, shaper, kerning: pairLookups } = shapingOf(font, options);\n const set = new Set(Array.from(characters, (character) => character.codePointAt(0)!));\n const indic = shaper === \"indic\" ? indicForms(set, (codePoint) => font.glyph(codePoint) !== 0) : undefined;\n\n // The roots: the glyph of each code point that the shaper can see. A character with no glyph gives the glyph 0\n const reached = new Set([...(indic?.codePoints ?? set)].map((codePoint) => font.glyph(codePoint)));\n // A union of the features gives the same lookups as the union of the stages. Each stage then counts, in the rounds\n const tags = shaper === \"indic\" ? indicFeatures(features) : shaper === \"joining\" ? joiningStages(features, true).flatMap((stage) => stage.features) : features;\n const lookups = font.substitutions(script, tags).map((lookup) => lookup.rules());\n reach(reached, lookups.flat(2));\n // The plain shaper is one stage: it applies each lookup once, in the order of the list\n const expansions = expansionsOf(font, reached, lookups, shaper === \"plain\" ? 1 : ROUNDS);\n\n const form = (codePoint: number): Expansion => (indic?.composites.get(codePoint) ?? []).reduce((best, composite) => larger(best, form(composite)), expansions.get(font.glyph(codePoint))!);\n // The Indic shaper can insert a dotted circle before each code point. Without a glyph, the code point U+25CC gives the glyph 0.\n // One code point gets at most one: a circle of the vowel constraints takes the next matra, so that syllable is not broken\n const inserted = indic === undefined ? { advance: 0, glyphs: 0 } : expansions.get(font.glyph(0x25cc))!;\n let largest: Expansion = { advance: 0, glyphs: 0 };\n for (const codePoint of set) {\n const parts = indic?.parts.get(codePoint) ?? [codePoint];\n largest = larger(largest, parts.reduce((total, part) => sum(total, sum(form(part), inserted)), { advance: 0, glyphs: 0 }));\n }\n\n // One glyph gets at most one first value and one second value from each lookup, and each subtable of a lookup is an alternative\n let kerning = 0;\n for (const lookup of pairLookups) {\n const { first, second } = lookup.largest(reached);\n kerning += first + second;\n }\n\n const { advance, glyphs } = largest;\n const pixel = options.rounding === \"pixel\";\n const float32 = options.sum === \"float32\" && !pixel;\n // Pixel: an advance gets at most 1/128 px from the grid of 26.6, and then 0.5 px. A kerning sum gets at most 0.5 px\n const pixelMargin = pixel ? 0.5 + 1 / 128 + (kerning > 0 ? 0.5 : 0) : 0;\n // Float32: each lookup gives a glyph two positions, in units of 1/65536 px. A negative value gets no help from the relative\n // factor below, so it has its own margin. The floor of a value and the round of a delta give 1 unit, the floor of the\n // multiplier gives 0.5 units, and the cut of the scale gives KERNING_RANGE / unitsPerEm units to each of the two parts.\n // The single precision gives FLOAT32 to the value and 4 FLOAT32 to the delta\n const positions = pairLookups.length * 2;\n const positionMargin = float32 ? (positions * (1.5 + (2 * KERNING_RANGE) / font.unitsPerEm)) / 65536 : 0;\n const kerningMargin = float32 ? positions * 5 * FLOAT32 * KERNING_RANGE : 0;\n return {\n advance,\n kerning,\n glyphs,\n width(length: number, size: number): number {\n const total = length * ((advance + glyphs * (kerning + kerningMargin)) * (size / font.unitsPerEm) + glyphs * (pixelMargin + positionMargin));\n // Float32: the fround of a glyph or 4 steps of a positive delta, the fround of a segment, and one step of the sum for\n // each segment. A text has at most one segment for each UTF-16 unit\n const single = float32 ? (1 + FLOAT32) ** (length + 6) : 1;\n // The double precision: the scale and a few products, and one rounding for each step of a sum of fractions\n return total * single * (1 + (2 ** 13 + length) * FLOAT64);\n },\n };\n}\n","/**\n * The subpath `./fonts`: the font model of typeset. It reads the tables of a TrueType, OpenType or WOFF2 file. It gives\n * the width of a text as the text engine of a browser gives it. The host gives the bytes of the font files.\n */\nexport { measureWidth, readFont, type Font, type MeasureOptions, type ReadOptions } from \"./fonts/font.ts\";\nexport { widthBound, type WidthBound } from \"./fonts/bound.ts\";\nexport { engines, isEngineName, type EngineName, type EngineRules } from \"./fonts/engines.ts\";\nexport type { Decompress } from \"./fonts/sfnt.ts\";\nexport type { Axis, Instance } from \"./fonts/variations.ts\";\n"],"mappings":"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"}
@@ -1 +1 @@
1
- export { a as canvasWords, o as defaultFeatures, t as glyphAdvances, s as readTables, i as shapingWords } from "./chunks/font.js";
1
+ export { o as canvasWords, u as defaultFeatures, t as glyphAdvances, d as readTables, a as shapingWords } from "./chunks/font.js";
package/build/fonts.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as Decompress } from "./chunks/sfnt.js";
2
2
  import { a as measureWidth, c as Axis, l as Instance, n as MeasureOptions, o as readFont, r as ReadOptions, t as Font } from "./chunks/font.js";
3
+ import { n as WidthBound, r as widthBound } from "./chunks/fonts.js";
3
4
  import { i as isEngineName, n as EngineRules, r as engines, t as EngineName } from "./chunks/engines.js";
4
- import "./chunks/fonts.js";
5
- export { type Axis, type Decompress, type EngineName, type EngineRules, type Font, type Instance, type MeasureOptions, type ReadOptions, engines, isEngineName, measureWidth, readFont };
5
+ export { type Axis, type Decompress, type EngineName, type EngineRules, type Font, type Instance, type MeasureOptions, type ReadOptions, type WidthBound, engines, isEngineName, measureWidth, readFont, widthBound };
package/build/fonts.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { n as measureWidth, r as readFont } from "./chunks/font.js";
2
+ import { n as widthBound } from "./chunks/fonts.js";
2
3
  import { n as isEngineName, t as engines } from "./chunks/engines.js";
3
- import "./chunks/fonts.js";
4
- export { engines, isEngineName, measureWidth, readFont };
4
+ export { engines, isEngineName, measureWidth, readFont, widthBound };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/typeset",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "description": "Text measurement and line breaking. The subpath ./fonts reads font tables and gives widths, ./measurer gives the advance function of a layout, ./breaking has the line breakers, and ./text has a rope.",
6
6
  "keywords": [
7
7
  "typeset",
@@ -68,7 +68,7 @@
68
68
  "@mark1russell7/rigor-generators": "^0.1.0"
69
69
  },
70
70
  "devDependencies": {
71
- "@mark1russell7/cue": "^1.0.2",
71
+ "@mark1russell7/cue": "^2.0.0",
72
72
  "@mark1russell7/layout-contract": "^0.3.2",
73
73
  "@types/node": "^26.6.5",
74
74
  "@typeset/corpus": "0.1.0",
@@ -0,0 +1,167 @@
1
+ import { shapingOf, type Font, type MeasureOptions } from "./font.ts";
2
+ import { indicFeatures, indicForms } from "./indic.ts";
3
+ import { joiningStages } from "./joining.ts";
4
+ import type { SubstitutionRule } from "./substitution.ts";
5
+
6
+ /** The limit of `widthBound` for the width of a text over a declared set of characters. It needs no measurement. */
7
+ export interface WidthBound {
8
+ /** The largest total advance of one character with all its expansions, in font units. */
9
+ readonly advance: number;
10
+ /** The largest positive kerning of one glyph, in font units. */
11
+ readonly kerning: number;
12
+ /** The largest count of glyphs of one character. */
13
+ readonly glyphs: number;
14
+ /**
15
+ * This function gives the limit in px for a text of `length` UTF-16 units at the size `size`. It takes the length of a
16
+ * string, so it does not read the text. The limit is for a text with only the characters of the set.
17
+ */
18
+ width(length: number, size: number): number;
19
+ }
20
+
21
+ /**
22
+ * The largest count of passes over the lookups of a shaper with stages. A pass covers one stage, and no shaper has more stages:
23
+ * the Indic shaper has 13 and the joining has 11. A rule that expands its own glyph has no fixed point, so it stops at this count.
24
+ */
25
+ const ROUNDS = 16;
26
+ /** The relative error of one rounding to single precision. */
27
+ const FLOAT32 = 2 ** -24;
28
+ /** The relative error of one rounding to double precision. */
29
+ const FLOAT64 = 2 ** -53;
30
+ /** The largest size of a kerning value of a `ValueRecord`, and of its variation delta, in font units: the range of `int16`. */
31
+ const KERNING_RANGE = 2 ** 15;
32
+
33
+ /** The largest expansion of a glyph: the sum of its advances, and the count of its glyphs. */
34
+ interface Expansion {
35
+ readonly advance: number;
36
+ readonly glyphs: number;
37
+ }
38
+
39
+ function larger(a: Expansion, b: Expansion): Expansion {
40
+ return { advance: Math.max(a.advance, b.advance), glyphs: Math.max(a.glyphs, b.glyphs) };
41
+ }
42
+
43
+ function sum(a: Expansion, b: Expansion): Expansion {
44
+ return { advance: a.advance + b.advance, glyphs: a.glyphs + b.glyphs };
45
+ }
46
+
47
+ /** This function tells if a rule can apply to the glyphs `reached`. A ligature needs its first glyph and all its components. */
48
+ function applies(rule: SubstitutionRule, reached: ReadonlySet<number>): boolean {
49
+ return reached.has(rule.from) && (rule.components ?? []).every((glyph) => reached.has(glyph));
50
+ }
51
+
52
+ /** This function adds the glyphs that the rules can give to `reached`. */
53
+ function reach(reached: Set<number>, rules: readonly SubstitutionRule[]): void {
54
+ for (let size = -1; size !== reached.size; ) {
55
+ size = reached.size;
56
+ for (const rule of rules) if (applies(rule, reached)) for (const glyph of rule.to) reached.add(glyph);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * This function applies one list of rules to the expansions. Each rule uses the values from before the list. A lookup applies
62
+ * once at a position, so its output does not go back into it. The function tells if a value changed.
63
+ */
64
+ function relax(rules: readonly SubstitutionRule[], expansions: Map<number, Expansion>): boolean {
65
+ const changes: (readonly [number, Expansion])[] = [];
66
+ for (const { from, to } of rules) {
67
+ const before = expansions.get(from)!;
68
+ const after = to.reduce((total, glyph) => sum(total, expansions.get(glyph)!), { advance: 0, glyphs: 0 });
69
+ if (after.advance > before.advance || after.glyphs > before.glyphs) changes.push([from, after]);
70
+ }
71
+ for (const [from, after] of changes) expansions.set(from, larger(expansions.get(from)!, after));
72
+ return changes.length > 0;
73
+ }
74
+
75
+ /**
76
+ * This function gives the largest expansion of each glyph of `reached`. Each ligature charges its first glyph, so each output
77
+ * glyph comes from one input glyph. Each pass goes backward over the lookups: a glyph then gets the expansions of the lookups
78
+ * after the lookup that makes it. A shaper with stages goes over the list again for each stage, so the passes repeat.
79
+ *
80
+ * One context rule can apply its nested lookups in any order. So the lists of a lookup with nested lookups repeat until no
81
+ * value changes, with the same limit. A value that stops changing holds for each order.
82
+ */
83
+ function expansionsOf(font: Font, reached: ReadonlySet<number>, lookups: readonly (readonly (readonly SubstitutionRule[])[])[], rounds: number): Map<number, Expansion> {
84
+ const expansions = new Map<number, Expansion>();
85
+ for (const glyph of reached) expansions.set(glyph, { advance: Math.max(0, font.advance(glyph)), glyphs: 1 });
86
+ const active = lookups.map((lists) => lists.map((rules) => rules.filter((rule) => applies(rule, reached))));
87
+ for (let round = 0; round < rounds; round++) {
88
+ let changed = false;
89
+ for (let i = active.length - 1; i >= 0; i--) {
90
+ const lists = active[i]!;
91
+ for (let step = 0; step < (lists.length > 1 ? ROUNDS : 1); step++) {
92
+ let moved = false;
93
+ for (const rules of lists) moved = relax(rules, expansions) || moved;
94
+ if (!moved) break;
95
+ changed = true;
96
+ }
97
+ }
98
+ if (!changed) break;
99
+ }
100
+ return expansions;
101
+ }
102
+
103
+ /**
104
+ * This function gives a limit for the width of each text over the characters `characters`, with no measurement. For each such
105
+ * text, `measureWidth(font, text, size, options)` is at most `width(text.length, size)` of the result. Give the same `options`.
106
+ * A text with another character has no limit from this function.
107
+ *
108
+ * The limit is sound, not tight. It follows the widest character of the set. It includes each glyph of the GSUB closure, each
109
+ * dotted circle, each part or composite of the Indic normalization, and the largest positive kerning.
110
+ */
111
+ export function widthBound(font: Font, characters: string, options: MeasureOptions = {}): WidthBound {
112
+ const { script, features, shaper, kerning: pairLookups } = shapingOf(font, options);
113
+ const set = new Set(Array.from(characters, (character) => character.codePointAt(0)!));
114
+ const indic = shaper === "indic" ? indicForms(set, (codePoint) => font.glyph(codePoint) !== 0) : undefined;
115
+
116
+ // The roots: the glyph of each code point that the shaper can see. A character with no glyph gives the glyph 0
117
+ const reached = new Set([...(indic?.codePoints ?? set)].map((codePoint) => font.glyph(codePoint)));
118
+ // A union of the features gives the same lookups as the union of the stages. Each stage then counts, in the rounds
119
+ const tags = shaper === "indic" ? indicFeatures(features) : shaper === "joining" ? joiningStages(features, true).flatMap((stage) => stage.features) : features;
120
+ const lookups = font.substitutions(script, tags).map((lookup) => lookup.rules());
121
+ reach(reached, lookups.flat(2));
122
+ // The plain shaper is one stage: it applies each lookup once, in the order of the list
123
+ const expansions = expansionsOf(font, reached, lookups, shaper === "plain" ? 1 : ROUNDS);
124
+
125
+ const form = (codePoint: number): Expansion => (indic?.composites.get(codePoint) ?? []).reduce((best, composite) => larger(best, form(composite)), expansions.get(font.glyph(codePoint))!);
126
+ // The Indic shaper can insert a dotted circle before each code point. Without a glyph, the code point U+25CC gives the glyph 0.
127
+ // One code point gets at most one: a circle of the vowel constraints takes the next matra, so that syllable is not broken
128
+ const inserted = indic === undefined ? { advance: 0, glyphs: 0 } : expansions.get(font.glyph(0x25cc))!;
129
+ let largest: Expansion = { advance: 0, glyphs: 0 };
130
+ for (const codePoint of set) {
131
+ const parts = indic?.parts.get(codePoint) ?? [codePoint];
132
+ largest = larger(largest, parts.reduce((total, part) => sum(total, sum(form(part), inserted)), { advance: 0, glyphs: 0 }));
133
+ }
134
+
135
+ // One glyph gets at most one first value and one second value from each lookup, and each subtable of a lookup is an alternative
136
+ let kerning = 0;
137
+ for (const lookup of pairLookups) {
138
+ const { first, second } = lookup.largest(reached);
139
+ kerning += first + second;
140
+ }
141
+
142
+ const { advance, glyphs } = largest;
143
+ const pixel = options.rounding === "pixel";
144
+ const float32 = options.sum === "float32" && !pixel;
145
+ // Pixel: an advance gets at most 1/128 px from the grid of 26.6, and then 0.5 px. A kerning sum gets at most 0.5 px
146
+ const pixelMargin = pixel ? 0.5 + 1 / 128 + (kerning > 0 ? 0.5 : 0) : 0;
147
+ // Float32: each lookup gives a glyph two positions, in units of 1/65536 px. A negative value gets no help from the relative
148
+ // factor below, so it has its own margin. The floor of a value and the round of a delta give 1 unit, the floor of the
149
+ // multiplier gives 0.5 units, and the cut of the scale gives KERNING_RANGE / unitsPerEm units to each of the two parts.
150
+ // The single precision gives FLOAT32 to the value and 4 FLOAT32 to the delta
151
+ const positions = pairLookups.length * 2;
152
+ const positionMargin = float32 ? (positions * (1.5 + (2 * KERNING_RANGE) / font.unitsPerEm)) / 65536 : 0;
153
+ const kerningMargin = float32 ? positions * 5 * FLOAT32 * KERNING_RANGE : 0;
154
+ return {
155
+ advance,
156
+ kerning,
157
+ glyphs,
158
+ width(length: number, size: number): number {
159
+ const total = length * ((advance + glyphs * (kerning + kerningMargin)) * (size / font.unitsPerEm) + glyphs * (pixelMargin + positionMargin));
160
+ // Float32: the fround of a glyph or 4 steps of a positive delta, the fround of a segment, and one step of the sum for
161
+ // each segment. A text has at most one segment for each UTF-16 unit
162
+ const single = float32 ? (1 + FLOAT32) ** (length + 6) : 1;
163
+ // The double precision: the scale and a few products, and one rounding for each step of a sum of fractions
164
+ return total * single * (1 + (2 ** 13 + length) * FLOAT64);
165
+ },
166
+ };
167
+ }
package/src/fonts/font.ts CHANGED
@@ -293,13 +293,29 @@ export function shapingWords(text: string): string[] {
293
293
  return words;
294
294
  }
295
295
 
296
- /** The GSUB pass of one set of options: a function that gives the glyphs of one word, and the kerning lookups. */
297
- function shaperOf(font: Font, options: MeasureOptions): { substitute: (word: string) => number[]; kerning: readonly PairLookup[] } {
296
+ /** The choices that a set of options makes for the GSUB pass and for the kerning. */
297
+ export interface Shaping {
298
+ readonly script: string;
299
+ /** The features of the option `features`, or `defaultFeatures`. */
300
+ readonly features: readonly string[];
301
+ readonly shaper: "indic" | "joining" | "plain";
302
+ readonly kerning: readonly PairLookup[];
303
+ }
304
+
305
+ /** This function gives the choices of a set of options: the script, the features, the shaper and the kerning lookups. */
306
+ export function shapingOf(font: Font, options: MeasureOptions): Shaping {
298
307
  const script = options.script ?? "DFLT";
299
308
  const features = options.features ?? defaultFeatures;
300
309
  const kerning = options.kerning === false ? [] : font.kerning(script);
301
- if (options.indic !== false && indicScripts.includes(script)) return { substitute: indicShaper(font, script, features), kerning };
302
- const joins = options.joining !== false && joiningScripts.includes(script);
310
+ if (options.indic !== false && indicScripts.includes(script)) return { script, features, shaper: "indic", kerning };
311
+ return { script, features, shaper: options.joining !== false && joiningScripts.includes(script) ? "joining" : "plain", kerning };
312
+ }
313
+
314
+ /** The GSUB pass of one set of options: a function that gives the glyphs of one word, and the kerning lookups. */
315
+ function shaperOf(font: Font, options: MeasureOptions): { substitute: (word: string) => number[]; kerning: readonly PairLookup[] } {
316
+ const { script, features, shaper, kerning } = shapingOf(font, options);
317
+ if (shaper === "indic") return { substitute: indicShaper(font, script, features), kerning };
318
+ const joins = shaper === "joining";
303
319
  const lookups = joins ? [] : font.substitutions(script, features);
304
320
  const stages = joins ? joiningStages(features, features.includes("rclt") && font.substitutions(script, ["rclt"]).length > 0).map((stage) => ({ stage, lookups: font.substitutions(script, stage.features) })) : [];
305
321
  const substitute = (word: string): number[] => {
@@ -1,4 +1,5 @@
1
1
  export { readFont, measureWidth, glyphAdvances, shapingWords, type AdvanceSource, type Font, type ReadOptions, type MeasureOptions } from "./font.ts";
2
+ export { widthBound, type WidthBound } from "./bound.ts";
2
3
  export { canvasSegments, canvasWords, isCjkIdeographOrSymbol } from "./words.ts";
3
4
  export { bidiLevels, visualOrder } from "./bidi.ts";
4
5
  export { indicScripts, indicShaper, indicSyllables, type IndicSyllableType } from "./indic.ts";
@@ -142,17 +142,7 @@ function vowelConstraints(codePoints: readonly number[]): number[] {
142
142
  */
143
143
  function normalize(codePoints: readonly number[], hasGlyph: (codePoint: number) => boolean): number[] {
144
144
  const allSimple = !codePoints.some((codePoint, i) => i > 0 && isMark(codePoint));
145
- const decomposed: number[] = [];
146
- for (const codePoint of codePoints) {
147
- const parts = codePoint === 0x931 ? [codePoint] : [...String.fromCodePoint(codePoint).normalize("NFD")].map((part) => part.codePointAt(0)!);
148
- if (parts.length > 1 || parts[0] !== codePoint) {
149
- if (parts.every(hasGlyph)) {
150
- decomposed.push(...parts);
151
- continue;
152
- }
153
- }
154
- decomposed.push(codePoint);
155
- }
145
+ const decomposed = codePoints.flatMap((codePoint) => decomposition(codePoint, hasGlyph));
156
146
  if (allSimple) return decomposed;
157
147
  // A stable sort of each run of marks with a class that is not 0
158
148
  for (let i = 1; i < decomposed.length; i++) {
@@ -180,6 +170,48 @@ function normalize(codePoints: readonly number[], hasGlyph: (codePoint: number)
180
170
  return out;
181
171
  }
182
172
 
173
+ /** This function gives the code points of a character after the decomposition of `normalize`. */
174
+ function decomposition(codePoint: number, hasGlyph: (codePoint: number) => boolean): number[] {
175
+ const parts = codePoint === 0x931 ? [codePoint] : [...String.fromCodePoint(codePoint).normalize("NFD")].map((part) => part.codePointAt(0)!);
176
+ return (parts.length > 1 || parts[0] !== codePoint) && parts.every(hasGlyph) ? parts : [codePoint];
177
+ }
178
+
179
+ /** The code points that `normalize` and `vowelConstraints` can give for a set of characters. */
180
+ export interface IndicForms {
181
+ /** The parts of each character of the set after the decomposition. A character with no decomposition is its one part. */
182
+ readonly parts: ReadonlyMap<number, readonly number[]>;
183
+ /** The composites of each code point: the code points that it becomes when a mark composes with it. */
184
+ readonly composites: ReadonlyMap<number, readonly number[]>;
185
+ /** All the code points that the shaper can see: the set, its parts, the dotted circle and each composite. */
186
+ readonly codePoints: ReadonlySet<number>;
187
+ }
188
+
189
+ /**
190
+ * This function gives the code points that the normalization can give for the characters `characters`. A composite is
191
+ * not always in the set: a nukta that follows U+0928 gives U+0929. The function repeats until no new composite appears.
192
+ */
193
+ export function indicForms(characters: ReadonlySet<number>, hasGlyph: (codePoint: number) => boolean): IndicForms {
194
+ const parts = new Map([...characters].map((codePoint) => [codePoint, decomposition(codePoint, hasGlyph)]));
195
+ const codePoints = new Set([...parts.values()].flat());
196
+ for (const codePoint of characters) codePoints.add(codePoint);
197
+ codePoints.add(0x25cc);
198
+ const composites = new Map<number, number[]>();
199
+ for (let size = 0; size !== codePoints.size; ) {
200
+ size = codePoints.size;
201
+ const marks = [...codePoints].filter(isMark);
202
+ for (const starter of codePoints) {
203
+ for (const mark of marks) {
204
+ const composed = compose(starter, mark);
205
+ if (composed === undefined || !hasGlyph(composed)) continue;
206
+ const known = composites.get(starter) ?? [];
207
+ if (!known.includes(composed)) composites.set(starter, [...known, composed]);
208
+ codePoints.add(composed);
209
+ }
210
+ }
211
+ }
212
+ return { parts, composites, codePoints };
213
+ }
214
+
183
215
  /** This function gives the canonical composite of two code points, or `undefined`. A mark does not compose with a mark. */
184
216
  function compose(first: number, second: number): number | undefined {
185
217
  if (isMark(first)) return undefined;
@@ -288,6 +320,16 @@ const basicFeatures: readonly (readonly [string, boolean])[] = [
288
320
  ];
289
321
  /** The features of the last stage, after the final reordering. They match only in a syllable. */
290
322
  const finalFeatures: readonly string[] = ["init", "pres", "abvs", "blws", "psts", "haln"];
323
+ /** This function gives the features of the page that the last stage applies. */
324
+ function pageFeatures(features: readonly string[]): string[] {
325
+ return features.filter((tag) => !["ccmp", "locl", "liga"].includes(tag) && !finalFeatures.includes(tag));
326
+ }
327
+
328
+ /** This function gives all the features that `indicShaper` applies for the features `features` of the page. */
329
+ export function indicFeatures(features: readonly string[]): string[] {
330
+ return ["locl", "ccmp", ...basicFeatures.map(([tag]) => tag), ...finalFeatures, ...pageFeatures(features)];
331
+ }
332
+
291
333
  /** The mask bit of the global features. Each glyph has it. */
292
334
  const GLOBAL = 1;
293
335
  /** The mask bits of the features that apply only to some glyphs of a syllable. */
@@ -352,8 +394,7 @@ export function indicShaper(font: Font, script: string, features: readonly strin
352
394
  };
353
395
  const first = lookupsOf(["locl", "ccmp"]);
354
396
  const basic = basicFeatures.map(([tag, global]) => ({ lookups: lookupsOf([tag]), mask: global ? GLOBAL : featureBits.get(tag)! }));
355
- const extra = features.filter((tag) => !["ccmp", "locl", "liga"].includes(tag) && !finalFeatures.includes(tag));
356
- const last = lookupsOf([...finalFeatures, ...extra]).map((lookup) => ({
397
+ const last = lookupsOf([...finalFeatures, ...pageFeatures(features)]).map((lookup) => ({
357
398
  lookup,
358
399
  mask: lookup.features.reduce((mask, tag) => mask | (tag === "init" ? initMask : GLOBAL), 0),
359
400
  perSyllable: lookup.features.some((tag) => finalFeatures.includes(tag)),