@dojo-video-engine/layout 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +2109 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +371 -0
- package/dist/schema.js.map +1 -0
- package/dist/solver.d.ts +6 -0
- package/dist/solver.d.ts.map +1 -0
- package/dist/solver.js +1736 -0
- package/dist/solver.js.map +1 -0
- package/dist/text.d.ts +136 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +1359 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -3
package/dist/text.js
ADDED
|
@@ -0,0 +1,1359 @@
|
|
|
1
|
+
import { resolveTextLineMetrics, worstMeasurementQuality, } from "@dojo-video-engine/core";
|
|
2
|
+
import { LayoutCompileError } from "./types.js";
|
|
3
|
+
export const LAYOUT_TEXT_PLAN_VERSION = 1;
|
|
4
|
+
/** Pinned table scope; changes require text-plan compatibility evidence. */
|
|
5
|
+
export const LAYOUT_TEXT_UNICODE_TABLE_VERSION = "dojo-video-engine-layout-text-1/unicode-15.1-subset";
|
|
6
|
+
const MAX_LAYOUT_TEXT_SCALAR = 10_000_000;
|
|
7
|
+
const MAX_LAYOUT_TEXT_FONT_SIZE = 1_000_000;
|
|
8
|
+
const MIN_LAYOUT_TEXT_QUANTUM = 0.000001;
|
|
9
|
+
export const defaultLayoutTextBudgets = Object.freeze({
|
|
10
|
+
maxTextCodeUnits: 1_000_000,
|
|
11
|
+
maxGraphemes: 100_000,
|
|
12
|
+
maxLines: 4_096,
|
|
13
|
+
maxMeasureCalls: 100_000,
|
|
14
|
+
maxBreakSearchPasses: 64,
|
|
15
|
+
});
|
|
16
|
+
const layoutTextBudgetKeys = [
|
|
17
|
+
"maxTextCodeUnits",
|
|
18
|
+
"maxGraphemes",
|
|
19
|
+
"maxLines",
|
|
20
|
+
"maxMeasureCalls",
|
|
21
|
+
"maxBreakSearchPasses",
|
|
22
|
+
];
|
|
23
|
+
const layoutTextBudgetCeilings = Object.freeze({
|
|
24
|
+
maxTextCodeUnits: MAX_LAYOUT_TEXT_SCALAR,
|
|
25
|
+
maxGraphemes: 1_000_000,
|
|
26
|
+
maxLines: 100_000,
|
|
27
|
+
maxMeasureCalls: 1_000_000,
|
|
28
|
+
maxBreakSearchPasses: 1_024,
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* Projects a completed v2 plain-text plan into stable Scene v1 paint inputs.
|
|
32
|
+
* Explicit newlines and the resolved font/direction prevent Scene v1 from
|
|
33
|
+
* repeating shrink/truncate decisions. Geometry remains owned by the solver.
|
|
34
|
+
*/
|
|
35
|
+
export function projectLayoutTextToSceneV1(node, measurement, options = {}) {
|
|
36
|
+
const value = node.text ?? node.runs?.map((run) => run.text).join("") ?? "";
|
|
37
|
+
assertBoundedNumericTree(measurement, node.id, "text plan");
|
|
38
|
+
if (measurement.version !== LAYOUT_TEXT_PLAN_VERSION ||
|
|
39
|
+
measurement.nodeId !== node.id ||
|
|
40
|
+
measurement.value !== value) {
|
|
41
|
+
throw new LayoutCompileError("invalid_geometry", `Layout text plan does not match node ${JSON.stringify(node.id)}.`, ["items", node.id], { id: node.id });
|
|
42
|
+
}
|
|
43
|
+
if ((node.runs?.length ?? 0) > 0) {
|
|
44
|
+
throw textError(node.id, "rich_text_unsafe", `Layout v2 cannot project shaped rich runs for ${JSON.stringify(node.id)} into Scene v1 plain text.`);
|
|
45
|
+
}
|
|
46
|
+
if (measurement.fit.status === "impossible" && !options.allowImpossible) {
|
|
47
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Layout text ${JSON.stringify(node.id)} has an impossible fit and cannot be projected as bounded Scene v1 text.`, ["items", node.id], { id: node.id });
|
|
48
|
+
}
|
|
49
|
+
const { maxLines: _maxLines, minFontSize: _minFontSize, textFit: _textFit, textOverflow: _textOverflow, ...style } = structuredClone(node.style ?? {});
|
|
50
|
+
const projection = {
|
|
51
|
+
id: node.id,
|
|
52
|
+
type: "text",
|
|
53
|
+
text: measurement.lines.map((line) => line.text).join("\n"),
|
|
54
|
+
style: {
|
|
55
|
+
...style,
|
|
56
|
+
direction: measurement.baseDirection,
|
|
57
|
+
textAlign: measurement.physicalAlign,
|
|
58
|
+
fontSize: measurement.fontSize,
|
|
59
|
+
lineHeight: `${measurement.lineHeight}px`,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
assertBoundedNumericTree(projection, node.id, "Scene text projection");
|
|
63
|
+
return projection;
|
|
64
|
+
}
|
|
65
|
+
// Pinned ranges cover base-plus-mark clusters for Latin/Greek/Cyrillic,
|
|
66
|
+
// Hebrew/Arabic, major Indic and Southeast Asian scripts, Tibetan, Mongolian,
|
|
67
|
+
// symbol marks, and supplementary music marks. This deliberately is not full
|
|
68
|
+
// UAX #29: modern Indic conjunct chains without explicit ZWJ and decomposed
|
|
69
|
+
// Hangul Jamo syllables are outside table v1; use shaped/precomposed text.
|
|
70
|
+
const combiningMarkRanges = [
|
|
71
|
+
[0x0300, 0x036f],
|
|
72
|
+
[0x0483, 0x0489],
|
|
73
|
+
[0x0591, 0x05bd],
|
|
74
|
+
[0x05bf, 0x05bf],
|
|
75
|
+
[0x05c1, 0x05c2],
|
|
76
|
+
[0x05c4, 0x05c5],
|
|
77
|
+
[0x05c7, 0x05c7],
|
|
78
|
+
[0x0610, 0x061a],
|
|
79
|
+
[0x064b, 0x065f],
|
|
80
|
+
[0x0670, 0x0670],
|
|
81
|
+
[0x06d6, 0x06dc],
|
|
82
|
+
[0x06df, 0x06e4],
|
|
83
|
+
[0x06e7, 0x06e8],
|
|
84
|
+
[0x06ea, 0x06ed],
|
|
85
|
+
[0x0711, 0x0711],
|
|
86
|
+
[0x0730, 0x074a],
|
|
87
|
+
[0x07a6, 0x07b0],
|
|
88
|
+
[0x07eb, 0x07f3],
|
|
89
|
+
[0x0816, 0x0819],
|
|
90
|
+
[0x081b, 0x0823],
|
|
91
|
+
[0x0825, 0x0827],
|
|
92
|
+
[0x0829, 0x082d],
|
|
93
|
+
[0x0859, 0x085b],
|
|
94
|
+
[0x08d3, 0x08e1],
|
|
95
|
+
[0x08e3, 0x0903],
|
|
96
|
+
[0x093a, 0x093c],
|
|
97
|
+
[0x093e, 0x094f],
|
|
98
|
+
[0x0951, 0x0957],
|
|
99
|
+
[0x0962, 0x0963],
|
|
100
|
+
[0x0981, 0x0983],
|
|
101
|
+
[0x09bc, 0x09bc],
|
|
102
|
+
[0x09be, 0x09c4],
|
|
103
|
+
[0x09c7, 0x09c8],
|
|
104
|
+
[0x09cb, 0x09cd],
|
|
105
|
+
[0x09d7, 0x09d7],
|
|
106
|
+
[0x09e2, 0x09e3],
|
|
107
|
+
[0x0a01, 0x0a03],
|
|
108
|
+
[0x0a3c, 0x0a3c],
|
|
109
|
+
[0x0a3e, 0x0a42],
|
|
110
|
+
[0x0a47, 0x0a48],
|
|
111
|
+
[0x0a4b, 0x0a4d],
|
|
112
|
+
[0x0a51, 0x0a51],
|
|
113
|
+
[0x0a70, 0x0a71],
|
|
114
|
+
[0x0a75, 0x0a75],
|
|
115
|
+
[0x0a81, 0x0a83],
|
|
116
|
+
[0x0abc, 0x0abc],
|
|
117
|
+
[0x0abe, 0x0ac5],
|
|
118
|
+
[0x0ac7, 0x0ac9],
|
|
119
|
+
[0x0acb, 0x0acd],
|
|
120
|
+
[0x0ae2, 0x0ae3],
|
|
121
|
+
[0x0b01, 0x0b03],
|
|
122
|
+
[0x0b3c, 0x0b3c],
|
|
123
|
+
[0x0b3e, 0x0b44],
|
|
124
|
+
[0x0b47, 0x0b48],
|
|
125
|
+
[0x0b4b, 0x0b4d],
|
|
126
|
+
[0x0b55, 0x0b57],
|
|
127
|
+
[0x0b62, 0x0b63],
|
|
128
|
+
[0x0b82, 0x0b82],
|
|
129
|
+
[0x0bbe, 0x0bc2],
|
|
130
|
+
[0x0bc6, 0x0bc8],
|
|
131
|
+
[0x0bca, 0x0bcd],
|
|
132
|
+
[0x0bd7, 0x0bd7],
|
|
133
|
+
[0x0c00, 0x0c04],
|
|
134
|
+
[0x0c3c, 0x0c44],
|
|
135
|
+
[0x0c46, 0x0c48],
|
|
136
|
+
[0x0c4a, 0x0c4d],
|
|
137
|
+
[0x0c55, 0x0c56],
|
|
138
|
+
[0x0c62, 0x0c63],
|
|
139
|
+
[0x0c81, 0x0c83],
|
|
140
|
+
[0x0cbc, 0x0cbc],
|
|
141
|
+
[0x0cbe, 0x0cc4],
|
|
142
|
+
[0x0cc6, 0x0cc8],
|
|
143
|
+
[0x0cca, 0x0ccd],
|
|
144
|
+
[0x0cd5, 0x0cd6],
|
|
145
|
+
[0x0ce2, 0x0ce3],
|
|
146
|
+
[0x0d00, 0x0d03],
|
|
147
|
+
[0x0d3b, 0x0d44],
|
|
148
|
+
[0x0d46, 0x0d48],
|
|
149
|
+
[0x0d4a, 0x0d4d],
|
|
150
|
+
[0x0d57, 0x0d57],
|
|
151
|
+
[0x0d62, 0x0d63],
|
|
152
|
+
[0x0e31, 0x0e31],
|
|
153
|
+
[0x0e34, 0x0e3a],
|
|
154
|
+
[0x0e47, 0x0e4e],
|
|
155
|
+
[0x0eb1, 0x0eb1],
|
|
156
|
+
[0x0eb4, 0x0ebc],
|
|
157
|
+
[0x0ec8, 0x0ecd],
|
|
158
|
+
[0x0f18, 0x0f19],
|
|
159
|
+
[0x0f35, 0x0f35],
|
|
160
|
+
[0x0f37, 0x0f37],
|
|
161
|
+
[0x0f39, 0x0f39],
|
|
162
|
+
[0x0f3e, 0x0f3f],
|
|
163
|
+
[0x0f71, 0x0f84],
|
|
164
|
+
[0x0f86, 0x0f87],
|
|
165
|
+
[0x0f8d, 0x0fbc],
|
|
166
|
+
[0x102b, 0x103e],
|
|
167
|
+
[0x1056, 0x1059],
|
|
168
|
+
[0x105e, 0x1060],
|
|
169
|
+
[0x1062, 0x1064],
|
|
170
|
+
[0x1067, 0x106d],
|
|
171
|
+
[0x1071, 0x1074],
|
|
172
|
+
[0x1082, 0x108d],
|
|
173
|
+
[0x108f, 0x108f],
|
|
174
|
+
[0x109a, 0x109d],
|
|
175
|
+
[0x17b4, 0x17d3],
|
|
176
|
+
[0x17dd, 0x17dd],
|
|
177
|
+
[0x180b, 0x180d],
|
|
178
|
+
[0x1885, 0x1886],
|
|
179
|
+
[0x1ab0, 0x1aff],
|
|
180
|
+
[0x1dc0, 0x1dff],
|
|
181
|
+
[0x20d0, 0x20ff],
|
|
182
|
+
[0xfe20, 0xfe2f],
|
|
183
|
+
[0x1d165, 0x1d169],
|
|
184
|
+
[0x1d16d, 0x1d172],
|
|
185
|
+
[0x1d17b, 0x1d182],
|
|
186
|
+
[0x1d185, 0x1d18b],
|
|
187
|
+
[0x1d1aa, 0x1d1ad],
|
|
188
|
+
];
|
|
189
|
+
const ltrLetterRanges = [
|
|
190
|
+
[0x0041, 0x005a],
|
|
191
|
+
[0x0061, 0x007a],
|
|
192
|
+
[0x00c0, 0x02e4],
|
|
193
|
+
[0x0370, 0x052f],
|
|
194
|
+
[0x0531, 0x0588],
|
|
195
|
+
[0x0904, 0x0dff],
|
|
196
|
+
[0x0e01, 0x0e46],
|
|
197
|
+
[0x0e81, 0x0ec6],
|
|
198
|
+
[0x0f40, 0x0f6c],
|
|
199
|
+
[0x1000, 0x102a],
|
|
200
|
+
[0x10a0, 0x10ff],
|
|
201
|
+
[0x1200, 0x137c],
|
|
202
|
+
[0x1780, 0x17b3],
|
|
203
|
+
[0x1e00, 0x1fff],
|
|
204
|
+
];
|
|
205
|
+
const rtlLetterRanges = [
|
|
206
|
+
[0x05d0, 0x05ea],
|
|
207
|
+
[0x05ef, 0x05f2],
|
|
208
|
+
[0x0620, 0x063f],
|
|
209
|
+
[0x0641, 0x064a],
|
|
210
|
+
[0x066e, 0x066f],
|
|
211
|
+
[0x0671, 0x06d3],
|
|
212
|
+
[0x06d5, 0x06d5],
|
|
213
|
+
[0x06e5, 0x06e6],
|
|
214
|
+
[0x06ee, 0x06ef],
|
|
215
|
+
[0x06fa, 0x06fc],
|
|
216
|
+
[0x06ff, 0x06ff],
|
|
217
|
+
[0x0710, 0x0710],
|
|
218
|
+
[0x0712, 0x072f],
|
|
219
|
+
[0x074d, 0x07a5],
|
|
220
|
+
[0x07b1, 0x07b1],
|
|
221
|
+
[0x07ca, 0x07ea],
|
|
222
|
+
[0x0800, 0x0815],
|
|
223
|
+
[0x0840, 0x0858],
|
|
224
|
+
[0x0860, 0x086a],
|
|
225
|
+
[0x0870, 0x0887],
|
|
226
|
+
[0x08a0, 0x08c9],
|
|
227
|
+
[0xfb1d, 0xfdff],
|
|
228
|
+
[0xfe70, 0xfefc],
|
|
229
|
+
[0x10840, 0x10fff],
|
|
230
|
+
[0x1e800, 0x1eeff],
|
|
231
|
+
];
|
|
232
|
+
const pictographicRanges = [
|
|
233
|
+
[0x00a9, 0x00a9],
|
|
234
|
+
[0x00ae, 0x00ae],
|
|
235
|
+
[0x203c, 0x203c],
|
|
236
|
+
[0x2049, 0x2049],
|
|
237
|
+
[0x2122, 0x2122],
|
|
238
|
+
[0x2139, 0x2139],
|
|
239
|
+
[0x2194, 0x21ff],
|
|
240
|
+
[0x2300, 0x23ff],
|
|
241
|
+
[0x24c2, 0x24c2],
|
|
242
|
+
[0x25aa, 0x27bf],
|
|
243
|
+
[0x2934, 0x2935],
|
|
244
|
+
[0x2b05, 0x2bff],
|
|
245
|
+
[0x3030, 0x3030],
|
|
246
|
+
[0x303d, 0x303d],
|
|
247
|
+
[0x3297, 0x3297],
|
|
248
|
+
[0x3299, 0x3299],
|
|
249
|
+
[0x1f000, 0x1faff],
|
|
250
|
+
];
|
|
251
|
+
const openingPunctuation = new Set(Array.from("([{([{〈《「『【〔〖〘〚‘“«‹"));
|
|
252
|
+
const closingPunctuation = new Set(Array.from(")]}})]〉》」』】〕〗〙〛、。,.!?:;%‰’”»›…"));
|
|
253
|
+
/**
|
|
254
|
+
* Bundled, single-pass segmentation for the table-v1 scope above. It does not
|
|
255
|
+
* call Intl.Segmenter, whose Unicode data/version is host-dependent. UTF-16
|
|
256
|
+
* source ranges make edits and ellipsis ownership exact. Modern Indic
|
|
257
|
+
* conjunct chains without explicit ZWJ and decomposed Hangul Jamo are not
|
|
258
|
+
* claimed as atomic clusters by this version.
|
|
259
|
+
*/
|
|
260
|
+
export function segmentLayoutTextGraphemes(text) {
|
|
261
|
+
const output = [];
|
|
262
|
+
let index = 0;
|
|
263
|
+
while (index < text.length) {
|
|
264
|
+
const start = index;
|
|
265
|
+
const first = readCodePoint(text, index);
|
|
266
|
+
index = first.end;
|
|
267
|
+
if (first.value === 0x0d && text.charCodeAt(index) === 0x0a) {
|
|
268
|
+
index += 1;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
if (isRegionalIndicator(first.value)) {
|
|
272
|
+
const next = index < text.length ? readCodePoint(text, index) : null;
|
|
273
|
+
if (next && isRegionalIndicator(next.value))
|
|
274
|
+
index = next.end;
|
|
275
|
+
}
|
|
276
|
+
index = consumeClusterExtenders(text, index);
|
|
277
|
+
while (index < text.length) {
|
|
278
|
+
const joiner = readCodePoint(text, index);
|
|
279
|
+
if (joiner.value !== 0x200d)
|
|
280
|
+
break;
|
|
281
|
+
index = joiner.end;
|
|
282
|
+
if (index >= text.length)
|
|
283
|
+
break;
|
|
284
|
+
index = readCodePoint(text, index).end;
|
|
285
|
+
index = consumeClusterExtenders(text, index);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
output.push({
|
|
289
|
+
text: text.slice(start, index),
|
|
290
|
+
sourceStart: start,
|
|
291
|
+
sourceEnd: index,
|
|
292
|
+
synthetic: false,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return output;
|
|
296
|
+
}
|
|
297
|
+
/** Resolves explicit direction or a deterministic Unicode first-strong value. */
|
|
298
|
+
export function resolveLayoutTextBaseDirection(text, requested) {
|
|
299
|
+
if (requested === "ltr" || requested === "rtl")
|
|
300
|
+
return requested;
|
|
301
|
+
for (const { text: grapheme } of segmentLayoutTextGraphemes(text)) {
|
|
302
|
+
for (const character of grapheme) {
|
|
303
|
+
const value = character.codePointAt(0) ?? 0;
|
|
304
|
+
if (isRtlStrong(value))
|
|
305
|
+
return "rtl";
|
|
306
|
+
if (isLtrLetter(value) || isCjkCodePoint(value))
|
|
307
|
+
return "ltr";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return "ltr";
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Compile-time text planner for Layout Contract v1. It is intentionally not
|
|
314
|
+
* used by core's Scene v1 layout path, so evolving this contract cannot alter
|
|
315
|
+
* existing Scene pixels.
|
|
316
|
+
*/
|
|
317
|
+
export function measureLayoutText(node, options = {}) {
|
|
318
|
+
const style = structuredClone(node.style ?? {});
|
|
319
|
+
const diagnostics = [];
|
|
320
|
+
const hasRichText = (node.runs?.length ?? 0) > 0;
|
|
321
|
+
if (hasRichText && (options.richTextPolicy ?? "reject") !== "legacy-unsafe") {
|
|
322
|
+
throw textError(node.id, "rich_text_unsafe", `Layout v2 cannot fit rich text node ${JSON.stringify(node.id)} without a shaping engine; independent Canvas runs do not preserve graphemes, bidi, or ligatures.`);
|
|
323
|
+
}
|
|
324
|
+
if (hasRichText) {
|
|
325
|
+
diagnostics.push({
|
|
326
|
+
severity: "warning",
|
|
327
|
+
code: "unsafe_legacy_rich_text",
|
|
328
|
+
message: "Rich runs were concatenated for intrinsic measurement; rendered run geometry may differ.",
|
|
329
|
+
nodeId: node.id,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const value = node.text ?? node.runs?.map((run) => run.text).join("") ?? "";
|
|
333
|
+
if (style.direction === "inherit" &&
|
|
334
|
+
(options.inheritDirectionPolicy ?? "reject") !== "legacy-unsafe") {
|
|
335
|
+
throw textError(node.id, "text_direction_required", `Layout v2 text ${JSON.stringify(node.id)} cannot inherit direction from host Canvas or DOM state. Use "ltr", "rtl", or omit direction for deterministic first-strong resolution.`);
|
|
336
|
+
}
|
|
337
|
+
if (style.direction === "inherit") {
|
|
338
|
+
diagnostics.push({
|
|
339
|
+
severity: "warning",
|
|
340
|
+
code: "unsafe_legacy_direction",
|
|
341
|
+
message: "Inherited direction was replaced by Layout v2 first-strong direction for measurement.",
|
|
342
|
+
nodeId: node.id,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
const budgets = resolveTextBudgets(options, node.id);
|
|
346
|
+
if (value.length > budgets.maxTextCodeUnits) {
|
|
347
|
+
throw budgetError(node.id, "text code units", value.length, budgets.maxTextCodeUnits);
|
|
348
|
+
}
|
|
349
|
+
const graphemes = segmentLayoutTextGraphemes(value);
|
|
350
|
+
if (graphemes.length > budgets.maxGraphemes) {
|
|
351
|
+
throw budgetError(node.id, "graphemes", graphemes.length, budgets.maxGraphemes);
|
|
352
|
+
}
|
|
353
|
+
const direction = resolveLayoutTextBaseDirection(value, style.direction === "inherit" ? undefined : style.direction);
|
|
354
|
+
style.direction = direction;
|
|
355
|
+
const physicalAlign = style.textAlign ?? (direction === "rtl" ? "right" : "left");
|
|
356
|
+
const availableWidth = optionalDimension(options.availableWidth, "availableWidth", node.id);
|
|
357
|
+
const availableHeight = optionalDimension(options.availableHeight, "availableHeight", node.id);
|
|
358
|
+
const baseFontSize = readFontSize(style.fontSize, node.id);
|
|
359
|
+
const minFontSize = Math.min(baseFontSize, readMinFontSize(style.minFontSize, node.id));
|
|
360
|
+
const quantum = Math.min(resolveQuantum(options.fontSizeQuantum, node.id), baseFontSize, minFontSize);
|
|
361
|
+
const paragraphs = splitParagraphs(graphemes, value.length);
|
|
362
|
+
const context = {
|
|
363
|
+
nodeId: node.id,
|
|
364
|
+
style,
|
|
365
|
+
provider: options.provider,
|
|
366
|
+
direction,
|
|
367
|
+
budgets,
|
|
368
|
+
state: {
|
|
369
|
+
calls: 0,
|
|
370
|
+
cache: new Map(),
|
|
371
|
+
reasons: new Set(),
|
|
372
|
+
quality: "exact",
|
|
373
|
+
widthReasons: new Set(),
|
|
374
|
+
widthQuality: "exact",
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
const fitMode = style.textFit ?? "wrap";
|
|
378
|
+
let resolvedFontSize = quantizeDown(baseFontSize, quantum, node.id);
|
|
379
|
+
let raw = layoutAtSize(fitMode, paragraphs, resolvedFontSize, availableWidth, rawLineLimit(style, availableHeight, resolvedFontSize, budgets, node.id), context);
|
|
380
|
+
let resolvedByShrink = false;
|
|
381
|
+
if (fitMode === "shrink" &&
|
|
382
|
+
!rawFits(raw, style, availableWidth, availableHeight, resolvedFontSize, node.id)) {
|
|
383
|
+
const minTick = Math.ceil(minFontSize / quantum - 1e-9);
|
|
384
|
+
let low = minTick;
|
|
385
|
+
let high = Math.floor(baseFontSize / quantum + 1e-9);
|
|
386
|
+
let best;
|
|
387
|
+
const minSize = tickSize(minTick, quantum, node.id);
|
|
388
|
+
const minRaw = layoutAtSize("wrap", paragraphs, minSize, availableWidth, rawLineLimit(style, availableHeight, minSize, budgets, node.id), context);
|
|
389
|
+
if (rawFits(minRaw, style, availableWidth, availableHeight, minSize, node.id)) {
|
|
390
|
+
best = { tick: minTick, raw: minRaw };
|
|
391
|
+
let shrinkPasses = 0;
|
|
392
|
+
while (low <= high) {
|
|
393
|
+
shrinkPasses = takeSearchPass(shrinkPasses, context);
|
|
394
|
+
const middle = Math.floor((low + high) / 2);
|
|
395
|
+
const size = tickSize(middle, quantum, node.id);
|
|
396
|
+
const candidate = layoutAtSize("wrap", paragraphs, size, availableWidth, rawLineLimit(style, availableHeight, size, budgets, node.id), context);
|
|
397
|
+
if (rawFits(candidate, style, availableWidth, availableHeight, size, node.id)) {
|
|
398
|
+
best = { tick: middle, raw: candidate };
|
|
399
|
+
low = middle + 1;
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
high = middle - 1;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (best) {
|
|
407
|
+
resolvedFontSize = tickSize(best.tick, quantum, node.id);
|
|
408
|
+
raw = best.raw;
|
|
409
|
+
resolvedByShrink =
|
|
410
|
+
resolvedFontSize < quantizeDown(baseFontSize, quantum, node.id);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
resolvedFontSize = minSize;
|
|
414
|
+
raw = minRaw;
|
|
415
|
+
resolvedByShrink =
|
|
416
|
+
resolvedFontSize < quantizeDown(baseFontSize, quantum, node.id);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const lineHeight = resolveLineHeight(style.lineHeight, resolvedFontSize, node.id);
|
|
420
|
+
const constrained = constrainRawLines(raw, style, fitMode, availableWidth, availableHeight, lineHeight, resolvedFontSize, context);
|
|
421
|
+
const measuredLines = buildMeasuredLines(constrained.lines, resolvedFontSize, lineHeight, availableWidth, physicalAlign, context);
|
|
422
|
+
const maxLineWidth = measuredLines.reduce((maximum, line) => Math.max(maximum, line.width), 0);
|
|
423
|
+
const horizontalOverflow = raw.horizontalOverflow ||
|
|
424
|
+
(availableWidth !== undefined &&
|
|
425
|
+
measuredLines.some((line) => line.width > availableWidth + 1e-7));
|
|
426
|
+
const verticalOverflow = availableHeight !== undefined &&
|
|
427
|
+
measuredLines.length * lineHeight > availableHeight + 1e-7;
|
|
428
|
+
const impossible = horizontalOverflow ||
|
|
429
|
+
verticalOverflow ||
|
|
430
|
+
(value.length > 0 &&
|
|
431
|
+
availableHeight !== undefined &&
|
|
432
|
+
lineHeight > availableHeight);
|
|
433
|
+
const truncated = constrained.truncated || !raw.complete;
|
|
434
|
+
const status = impossible
|
|
435
|
+
? "impossible"
|
|
436
|
+
: truncated
|
|
437
|
+
? "clamped"
|
|
438
|
+
: "fit";
|
|
439
|
+
const content = measureIntrinsicWidths(paragraphs, resolvedFontSize, context);
|
|
440
|
+
const width = availableWidth === undefined
|
|
441
|
+
? maxLineWidth
|
|
442
|
+
: Math.min(availableWidth, maxLineWidth);
|
|
443
|
+
const height = measuredLines.length * lineHeight;
|
|
444
|
+
const measurement = measuredLines.reduce((quality, line) => worstMeasurementQuality(quality, line.measurement), context.state.quality);
|
|
445
|
+
const reason = Array.from(new Set([
|
|
446
|
+
...context.state.reasons,
|
|
447
|
+
...measuredLines.map((line) => line.reason),
|
|
448
|
+
]))
|
|
449
|
+
.sort()
|
|
450
|
+
.join("; ") || "empty text; deterministic Layout v2 metrics";
|
|
451
|
+
const widthReason = Array.from(context.state.widthReasons).sort().join("; ") || reason;
|
|
452
|
+
const result = {
|
|
453
|
+
version: LAYOUT_TEXT_PLAN_VERSION,
|
|
454
|
+
nodeId: node.id,
|
|
455
|
+
value,
|
|
456
|
+
baseDirection: direction,
|
|
457
|
+
physicalAlign,
|
|
458
|
+
width,
|
|
459
|
+
height,
|
|
460
|
+
minContentWidth: content.min,
|
|
461
|
+
maxContentWidth: content.max,
|
|
462
|
+
fontSize: resolvedFontSize,
|
|
463
|
+
lineHeight,
|
|
464
|
+
lineCount: measuredLines.length,
|
|
465
|
+
lines: measuredLines,
|
|
466
|
+
fit: {
|
|
467
|
+
status,
|
|
468
|
+
horizontalOverflow,
|
|
469
|
+
verticalOverflow,
|
|
470
|
+
truncated,
|
|
471
|
+
resolvedByShrink,
|
|
472
|
+
},
|
|
473
|
+
measurement,
|
|
474
|
+
reason,
|
|
475
|
+
fieldMeasurements: {
|
|
476
|
+
width: {
|
|
477
|
+
measurement: context.state.widthQuality,
|
|
478
|
+
reason: widthReason,
|
|
479
|
+
},
|
|
480
|
+
height: {
|
|
481
|
+
measurement,
|
|
482
|
+
reason: `${reason}; deterministic Layout v2 line-height`,
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
measureCalls: context.state.calls,
|
|
486
|
+
graphemeCount: graphemes.length,
|
|
487
|
+
diagnostics,
|
|
488
|
+
};
|
|
489
|
+
assertBoundedNumericTree(result, node.id, "text plan");
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
function layoutAtSize(mode, paragraphs, fontSize, availableWidth, lineLimit, context) {
|
|
493
|
+
if (mode === "truncate") {
|
|
494
|
+
const joined = joinParagraphs(paragraphs);
|
|
495
|
+
if (availableWidth === undefined) {
|
|
496
|
+
return {
|
|
497
|
+
lines: [{ graphemes: joined, emptyOffset: 0 }],
|
|
498
|
+
complete: true,
|
|
499
|
+
horizontalOverflow: false,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const fitted = fitClusterPrefix(joined, availableWidth, fontSize, context);
|
|
503
|
+
return {
|
|
504
|
+
lines: [{ graphemes: fitted.prefix, emptyOffset: 0 }],
|
|
505
|
+
complete: fitted.complete,
|
|
506
|
+
horizontalOverflow: fitted.horizontalOverflow,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
const lines = [];
|
|
510
|
+
let complete = true;
|
|
511
|
+
let horizontalOverflow = false;
|
|
512
|
+
for (const paragraph of paragraphs) {
|
|
513
|
+
if (lines.length >= lineLimit.limit) {
|
|
514
|
+
complete = false;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (paragraph.graphemes.length === 0) {
|
|
518
|
+
lines.push({ graphemes: [], emptyOffset: paragraph.emptyOffset });
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (availableWidth === undefined) {
|
|
522
|
+
lines.push({
|
|
523
|
+
graphemes: trimOuterSpaces(normalizeParagraphWhitespace(paragraph.graphemes)),
|
|
524
|
+
emptyOffset: paragraph.emptyOffset,
|
|
525
|
+
});
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const wrapped = wrapParagraph(paragraph, availableWidth, fontSize, Math.max(1, lineLimit.limit - lines.length), context);
|
|
529
|
+
lines.push(...wrapped.lines);
|
|
530
|
+
complete &&= wrapped.complete;
|
|
531
|
+
horizontalOverflow ||= wrapped.horizontalOverflow;
|
|
532
|
+
}
|
|
533
|
+
if (lineLimit.enforceBudget &&
|
|
534
|
+
(lines.length > context.budgets.maxLines || !complete)) {
|
|
535
|
+
throw budgetError(context.nodeId, "planned lines", Math.max(lines.length, context.budgets.maxLines + 1), context.budgets.maxLines);
|
|
536
|
+
}
|
|
537
|
+
return { lines, complete, horizontalOverflow };
|
|
538
|
+
}
|
|
539
|
+
function wrapParagraph(paragraph, maxWidth, fontSize, lineLimit, context) {
|
|
540
|
+
const tokens = tokenizeParagraph(paragraph.graphemes);
|
|
541
|
+
if (tokens.every((token) => token.kind === "space")) {
|
|
542
|
+
return {
|
|
543
|
+
lines: [
|
|
544
|
+
{
|
|
545
|
+
graphemes: [],
|
|
546
|
+
emptyOffset: paragraph.graphemes[0]?.sourceStart ?? paragraph.emptyOffset,
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
complete: true,
|
|
550
|
+
horizontalOverflow: false,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const legalEnds = tokens
|
|
554
|
+
.map((_token, index) => index + 1)
|
|
555
|
+
.filter((end) => canBreakTokens(tokens, end));
|
|
556
|
+
const lines = [];
|
|
557
|
+
let tokenIndex = 0;
|
|
558
|
+
let clusterOffset = 0;
|
|
559
|
+
let horizontalOverflow = false;
|
|
560
|
+
while (tokenIndex < tokens.length && lines.length < lineLimit) {
|
|
561
|
+
while (tokens[tokenIndex]?.kind === "space")
|
|
562
|
+
tokenIndex += 1;
|
|
563
|
+
if (tokenIndex >= tokens.length)
|
|
564
|
+
break;
|
|
565
|
+
const first = tokens[tokenIndex];
|
|
566
|
+
if (clusterOffset > 0) {
|
|
567
|
+
const split = fitClusterRange(first.graphemes, clusterOffset, maxWidth, fontSize, context);
|
|
568
|
+
const prefix = first.graphemes.slice(clusterOffset, split.end);
|
|
569
|
+
const width = measureText(clusterText(prefix), fontSize, context).width;
|
|
570
|
+
horizontalOverflow ||= width > maxWidth + 1e-7;
|
|
571
|
+
lines.push({ graphemes: prefix, emptyOffset: paragraph.emptyOffset });
|
|
572
|
+
clusterOffset += prefix.length;
|
|
573
|
+
if (clusterOffset >= first.graphemes.length) {
|
|
574
|
+
tokenIndex += 1;
|
|
575
|
+
clusterOffset = 0;
|
|
576
|
+
}
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const firstWidth = measureText(clusterText(first.graphemes), fontSize, context).width;
|
|
580
|
+
if (first.kind === "word" &&
|
|
581
|
+
firstWidth > maxWidth + 1e-7 &&
|
|
582
|
+
first.graphemes.length > 1) {
|
|
583
|
+
const split = fitClusterRange(first.graphemes, 0, maxWidth, fontSize, context);
|
|
584
|
+
const prefix = first.graphemes.slice(0, split.end);
|
|
585
|
+
const width = measureText(clusterText(prefix), fontSize, context).width;
|
|
586
|
+
horizontalOverflow ||= width > maxWidth + 1e-7;
|
|
587
|
+
lines.push({ graphemes: prefix, emptyOffset: paragraph.emptyOffset });
|
|
588
|
+
clusterOffset = prefix.length;
|
|
589
|
+
if (clusterOffset >= first.graphemes.length) {
|
|
590
|
+
tokenIndex += 1;
|
|
591
|
+
clusterOffset = 0;
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const end = fitTokenEnd(tokens, legalEnds, tokenIndex, maxWidth, fontSize, context);
|
|
596
|
+
const candidate = trimTrailingSpaces(collectTokenClusters(tokens, tokenIndex, end));
|
|
597
|
+
if (candidate.length === 0) {
|
|
598
|
+
tokenIndex = Math.max(tokenIndex + 1, end);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const candidateWidth = measureText(clusterText(candidate), fontSize, context).width;
|
|
602
|
+
horizontalOverflow ||= candidateWidth > maxWidth + 1e-7;
|
|
603
|
+
lines.push({ graphemes: candidate, emptyOffset: paragraph.emptyOffset });
|
|
604
|
+
tokenIndex = Math.max(tokenIndex + 1, end);
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
lines,
|
|
608
|
+
complete: tokenIndex >= tokens.length,
|
|
609
|
+
horizontalOverflow,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
function constrainRawLines(raw, style, fitMode, availableWidth, availableHeight, lineHeight, fontSize, context) {
|
|
613
|
+
const heightLines = availableHeight === undefined
|
|
614
|
+
? Number.POSITIVE_INFINITY
|
|
615
|
+
: Math.max(0, Math.floor((availableHeight + 1e-7) / lineHeight));
|
|
616
|
+
const configuredLines = fitMode === "truncate" ? 1 : (style.maxLines ?? Infinity);
|
|
617
|
+
const visibleCount = Math.min(raw.lines.length, configuredLines, heightLines);
|
|
618
|
+
let lines = raw.lines.slice(0, visibleCount).map((line) => ({
|
|
619
|
+
graphemes: [...line.graphemes],
|
|
620
|
+
emptyOffset: line.emptyOffset,
|
|
621
|
+
}));
|
|
622
|
+
const truncated = !raw.complete || visibleCount < raw.lines.length;
|
|
623
|
+
if (truncated &&
|
|
624
|
+
style.textOverflow === "ellipsis" &&
|
|
625
|
+
lines.length > 0 &&
|
|
626
|
+
availableWidth !== undefined) {
|
|
627
|
+
const last = lines.at(-1);
|
|
628
|
+
lines = [
|
|
629
|
+
...lines.slice(0, -1),
|
|
630
|
+
{
|
|
631
|
+
...last,
|
|
632
|
+
graphemes: appendOwnedEllipsis(last.graphemes, availableWidth, fontSize, context),
|
|
633
|
+
},
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
return { lines, truncated };
|
|
637
|
+
}
|
|
638
|
+
function buildMeasuredLines(rawLines, fontSize, lineHeight, availableWidth, align, context) {
|
|
639
|
+
const pending = rawLines.map((line) => ({
|
|
640
|
+
line,
|
|
641
|
+
metrics: measureText(clusterText(line.graphemes), fontSize, context),
|
|
642
|
+
}));
|
|
643
|
+
const measuredWidth = pending.reduce((maximum, entry) => Math.max(maximum, entry.metrics.width), 0);
|
|
644
|
+
const boxWidth = availableWidth ?? measuredWidth;
|
|
645
|
+
return pending.map(({ line, metrics }, index) => {
|
|
646
|
+
const vertical = normalizeVerticalMetrics(metrics, fontSize);
|
|
647
|
+
const x = align === "center"
|
|
648
|
+
? (boxWidth - metrics.width) / 2
|
|
649
|
+
: align === "right"
|
|
650
|
+
? boxWidth - metrics.width
|
|
651
|
+
: 0;
|
|
652
|
+
const baselineY = index * lineHeight + lineHeight / 2;
|
|
653
|
+
const source = sourceRange(line.graphemes, line.emptyOffset);
|
|
654
|
+
return {
|
|
655
|
+
index,
|
|
656
|
+
text: clusterText(line.graphemes),
|
|
657
|
+
sourceStart: source.start,
|
|
658
|
+
sourceEnd: source.end,
|
|
659
|
+
graphemes: line.graphemes.map((cluster) => ({ ...cluster })),
|
|
660
|
+
width: metrics.width,
|
|
661
|
+
ascent: vertical.ascent,
|
|
662
|
+
descent: vertical.descent,
|
|
663
|
+
baselineY,
|
|
664
|
+
lineBox: {
|
|
665
|
+
x: 0,
|
|
666
|
+
y: index * lineHeight,
|
|
667
|
+
width: boxWidth,
|
|
668
|
+
height: lineHeight,
|
|
669
|
+
},
|
|
670
|
+
inkBounds: {
|
|
671
|
+
x,
|
|
672
|
+
y: baselineY - vertical.ascent,
|
|
673
|
+
width: metrics.width,
|
|
674
|
+
height: vertical.ascent + vertical.descent,
|
|
675
|
+
},
|
|
676
|
+
measurement: vertical.measurement,
|
|
677
|
+
reason: vertical.reason,
|
|
678
|
+
fieldMeasurements: {
|
|
679
|
+
width: { ...metrics.fieldMeasurements.width },
|
|
680
|
+
ascent: { ...vertical.ascentProvenance },
|
|
681
|
+
descent: { ...vertical.descentProvenance },
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
function measureIntrinsicWidths(paragraphs, fontSize, context) {
|
|
687
|
+
let min = 0;
|
|
688
|
+
let max = 0;
|
|
689
|
+
for (const paragraph of paragraphs) {
|
|
690
|
+
const normalized = normalizeParagraphWhitespace(paragraph.graphemes);
|
|
691
|
+
max = Math.max(max, measureText(clusterText(normalized), fontSize, context).width);
|
|
692
|
+
for (const token of tokenizeParagraph(paragraph.graphemes)) {
|
|
693
|
+
if (token.kind === "space")
|
|
694
|
+
continue;
|
|
695
|
+
if (token.kind === "word" && token.graphemes.length > 1) {
|
|
696
|
+
for (const cluster of token.graphemes) {
|
|
697
|
+
min = Math.max(min, measureText(cluster.text, fontSize, context).width);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
min = Math.max(min, measureText(clusterText(token.graphemes), fontSize, context).width);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return { min, max };
|
|
706
|
+
}
|
|
707
|
+
function appendOwnedEllipsis(graphemes, maxWidth, fontSize, context) {
|
|
708
|
+
const source = graphemes.filter(isSourceCluster);
|
|
709
|
+
const ellipsisWidth = measureText("…", fontSize, context).width;
|
|
710
|
+
if (ellipsisWidth > maxWidth + 1e-7)
|
|
711
|
+
return [];
|
|
712
|
+
let low = 0;
|
|
713
|
+
let high = source.length;
|
|
714
|
+
let passes = 0;
|
|
715
|
+
while (low < high) {
|
|
716
|
+
passes = takeSearchPass(passes, context);
|
|
717
|
+
const middle = Math.ceil((low + high) / 2);
|
|
718
|
+
const candidate = `${clusterText(source.slice(0, middle))}…`;
|
|
719
|
+
if (measureText(candidate, fontSize, context).width <= maxWidth + 1e-7) {
|
|
720
|
+
low = middle;
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
high = middle - 1;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const kept = source.slice(0, low);
|
|
727
|
+
const owner = kept.at(-1) ?? source[0];
|
|
728
|
+
const boundary = owner?.sourceEnd ?? 0;
|
|
729
|
+
return [
|
|
730
|
+
...kept,
|
|
731
|
+
{
|
|
732
|
+
text: "…",
|
|
733
|
+
sourceStart: null,
|
|
734
|
+
sourceEnd: null,
|
|
735
|
+
synthetic: "ellipsis",
|
|
736
|
+
ownerSourceStart: owner?.sourceStart ?? boundary,
|
|
737
|
+
ownerSourceEnd: owner?.sourceEnd ?? boundary,
|
|
738
|
+
},
|
|
739
|
+
];
|
|
740
|
+
}
|
|
741
|
+
function fitClusterPrefix(graphemes, maxWidth, fontSize, context) {
|
|
742
|
+
const fitted = fitClusterRange(graphemes, 0, maxWidth, fontSize, context);
|
|
743
|
+
return {
|
|
744
|
+
prefix: graphemes.slice(0, fitted.end),
|
|
745
|
+
complete: fitted.end === graphemes.length,
|
|
746
|
+
horizontalOverflow: fitted.horizontalOverflow,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function fitClusterRange(graphemes, start, maxWidth, fontSize, context) {
|
|
750
|
+
if (start >= graphemes.length) {
|
|
751
|
+
return { end: start, horizontalOverflow: false };
|
|
752
|
+
}
|
|
753
|
+
const fitsCount = (count) => measureText(clusterText(graphemes.slice(start, start + count)), fontSize, context).width <=
|
|
754
|
+
maxWidth + 1e-7;
|
|
755
|
+
let passes = takeSearchPass(0, context);
|
|
756
|
+
if (!fitsCount(1)) {
|
|
757
|
+
return { end: start + 1, horizontalOverflow: true };
|
|
758
|
+
}
|
|
759
|
+
const remaining = graphemes.length - start;
|
|
760
|
+
let lowCount = 1;
|
|
761
|
+
let step = 2;
|
|
762
|
+
let highCount = 1;
|
|
763
|
+
while (lowCount < remaining) {
|
|
764
|
+
passes = takeSearchPass(passes, context);
|
|
765
|
+
const candidate = Math.min(remaining, step);
|
|
766
|
+
if (!fitsCount(candidate)) {
|
|
767
|
+
highCount = candidate;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
lowCount = candidate;
|
|
771
|
+
if (candidate === remaining) {
|
|
772
|
+
return { end: graphemes.length, horizontalOverflow: false };
|
|
773
|
+
}
|
|
774
|
+
step *= 2;
|
|
775
|
+
}
|
|
776
|
+
let low = lowCount + 1;
|
|
777
|
+
let high = highCount - 1;
|
|
778
|
+
let best = lowCount;
|
|
779
|
+
while (low <= high) {
|
|
780
|
+
passes = takeSearchPass(passes, context);
|
|
781
|
+
const middle = Math.floor((low + high) / 2);
|
|
782
|
+
if (fitsCount(middle)) {
|
|
783
|
+
best = middle;
|
|
784
|
+
low = middle + 1;
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
high = middle - 1;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return { end: start + best, horizontalOverflow: false };
|
|
791
|
+
}
|
|
792
|
+
function measureText(value, fontSize, context) {
|
|
793
|
+
const key = `${fontSize}\u0000${context.direction}\u0000${value}`;
|
|
794
|
+
const cached = context.state.cache.get(key);
|
|
795
|
+
if (cached)
|
|
796
|
+
return structuredClone(cached);
|
|
797
|
+
context.state.calls += 1;
|
|
798
|
+
if (context.state.calls > context.budgets.maxMeasureCalls) {
|
|
799
|
+
throw budgetError(context.nodeId, "text metric calls", context.state.calls, context.budgets.maxMeasureCalls);
|
|
800
|
+
}
|
|
801
|
+
let result;
|
|
802
|
+
if (context.provider) {
|
|
803
|
+
result = resolveTextLineMetrics({
|
|
804
|
+
nodeId: context.nodeId,
|
|
805
|
+
text: value,
|
|
806
|
+
style: context.style,
|
|
807
|
+
fontSize,
|
|
808
|
+
}, context.provider);
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
const reason = "Layout v2 portable code-point upper-bound heuristic";
|
|
812
|
+
const provenance = { measurement: "estimated", reason };
|
|
813
|
+
result = {
|
|
814
|
+
width: Array.from(value).length * fontSize * 0.58,
|
|
815
|
+
ascent: fontSize / 2,
|
|
816
|
+
descent: fontSize / 2,
|
|
817
|
+
measurement: "estimated",
|
|
818
|
+
reason,
|
|
819
|
+
fieldMeasurements: {
|
|
820
|
+
width: { ...provenance },
|
|
821
|
+
ascent: { ...provenance },
|
|
822
|
+
descent: { ...provenance },
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
assertMetricScalar(result.width, "width", context.nodeId);
|
|
827
|
+
if (result.ascent !== undefined) {
|
|
828
|
+
assertMetricScalar(result.ascent, "ascent", context.nodeId);
|
|
829
|
+
}
|
|
830
|
+
if (result.descent !== undefined) {
|
|
831
|
+
assertMetricScalar(result.descent, "descent", context.nodeId);
|
|
832
|
+
}
|
|
833
|
+
context.state.quality = worstMeasurementQuality(context.state.quality, result.measurement);
|
|
834
|
+
context.state.reasons.add(result.reason);
|
|
835
|
+
context.state.widthQuality = worstMeasurementQuality(context.state.widthQuality, result.fieldMeasurements.width.measurement);
|
|
836
|
+
context.state.widthReasons.add(result.fieldMeasurements.width.reason);
|
|
837
|
+
context.state.cache.set(key, structuredClone(result));
|
|
838
|
+
return structuredClone(result);
|
|
839
|
+
}
|
|
840
|
+
function normalizeVerticalMetrics(metrics, fontSize) {
|
|
841
|
+
const fallback = {
|
|
842
|
+
measurement: "estimated",
|
|
843
|
+
reason: "Layout v2 middle-baseline font-size fallback",
|
|
844
|
+
};
|
|
845
|
+
const ascent = metrics.ascent ?? fontSize / 2;
|
|
846
|
+
const descent = metrics.descent ?? fontSize / 2;
|
|
847
|
+
const ascentProvenance = metrics.fieldMeasurements.ascent ?? fallback;
|
|
848
|
+
const descentProvenance = metrics.fieldMeasurements.descent ?? fallback;
|
|
849
|
+
const measurement = worstMeasurementQuality(metrics.fieldMeasurements.width.measurement, ascentProvenance.measurement, descentProvenance.measurement);
|
|
850
|
+
const reason = Array.from(new Set([
|
|
851
|
+
metrics.fieldMeasurements.width.reason,
|
|
852
|
+
ascentProvenance.reason,
|
|
853
|
+
descentProvenance.reason,
|
|
854
|
+
]))
|
|
855
|
+
.sort()
|
|
856
|
+
.join("; ");
|
|
857
|
+
return {
|
|
858
|
+
ascent,
|
|
859
|
+
descent,
|
|
860
|
+
ascentProvenance,
|
|
861
|
+
descentProvenance,
|
|
862
|
+
measurement,
|
|
863
|
+
reason,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
function tokenizeParagraph(graphemes) {
|
|
867
|
+
const normalized = normalizeParagraphWhitespace(graphemes);
|
|
868
|
+
const tokens = [];
|
|
869
|
+
for (const grapheme of normalized) {
|
|
870
|
+
const kind = classifyTokenGrapheme(grapheme.text);
|
|
871
|
+
const previous = tokens.at(-1);
|
|
872
|
+
if (kind === "word" && previous?.kind === "word") {
|
|
873
|
+
previous.graphemes.push(grapheme);
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
tokens.push({ kind, graphemes: [grapheme] });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const boundBoundaries = new Set();
|
|
880
|
+
for (let boundary = 1; boundary < tokens.length; boundary += 1) {
|
|
881
|
+
const left = tokens[boundary - 1];
|
|
882
|
+
const right = tokens[boundary];
|
|
883
|
+
if ((left.kind === "nonbreaking-space" && right.kind !== "space") ||
|
|
884
|
+
(right.kind === "nonbreaking-space" && left.kind !== "space")) {
|
|
885
|
+
boundBoundaries.add(boundary);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const consolidated = [];
|
|
889
|
+
let start = 0;
|
|
890
|
+
while (start < tokens.length) {
|
|
891
|
+
let end = start + 1;
|
|
892
|
+
while (boundBoundaries.has(end))
|
|
893
|
+
end += 1;
|
|
894
|
+
const component = tokens.slice(start, end);
|
|
895
|
+
if (component.some((token) => token.kind === "nonbreaking-space")) {
|
|
896
|
+
consolidated.push({
|
|
897
|
+
kind: "nonbreaking-group",
|
|
898
|
+
graphemes: component.flatMap((token) => token.graphemes),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
consolidated.push(...component);
|
|
903
|
+
}
|
|
904
|
+
start = end;
|
|
905
|
+
}
|
|
906
|
+
return consolidated;
|
|
907
|
+
}
|
|
908
|
+
function normalizeParagraphWhitespace(graphemes) {
|
|
909
|
+
const output = [];
|
|
910
|
+
for (const grapheme of graphemes) {
|
|
911
|
+
if (!isWhitespace(grapheme.text)) {
|
|
912
|
+
output.push({ ...grapheme });
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
const previous = output.at(-1);
|
|
916
|
+
if (previous?.text === " ") {
|
|
917
|
+
previous.sourceEnd = grapheme.sourceEnd;
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
output.push({ ...grapheme, text: " " });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return output;
|
|
924
|
+
}
|
|
925
|
+
function splitParagraphs(graphemes, textLength) {
|
|
926
|
+
const paragraphs = [];
|
|
927
|
+
let current = [];
|
|
928
|
+
let emptyOffset = 0;
|
|
929
|
+
for (const grapheme of graphemes) {
|
|
930
|
+
if (!isNewline(grapheme.text)) {
|
|
931
|
+
current.push(grapheme);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
paragraphs.push({ graphemes: current, emptyOffset });
|
|
935
|
+
current = [];
|
|
936
|
+
emptyOffset = grapheme.sourceEnd;
|
|
937
|
+
}
|
|
938
|
+
paragraphs.push({
|
|
939
|
+
graphemes: current,
|
|
940
|
+
emptyOffset: current[0]?.sourceStart ?? emptyOffset,
|
|
941
|
+
});
|
|
942
|
+
if (paragraphs.length === 0) {
|
|
943
|
+
paragraphs.push({ graphemes: [], emptyOffset: textLength });
|
|
944
|
+
}
|
|
945
|
+
return paragraphs;
|
|
946
|
+
}
|
|
947
|
+
function joinParagraphs(paragraphs) {
|
|
948
|
+
const output = [];
|
|
949
|
+
paragraphs.forEach((paragraph, index) => {
|
|
950
|
+
if (index > 0) {
|
|
951
|
+
const previous = output.at(-1);
|
|
952
|
+
const next = paragraph.graphemes[0];
|
|
953
|
+
const start = previous?.sourceEnd ?? paragraph.emptyOffset;
|
|
954
|
+
const end = next?.sourceStart ?? paragraph.emptyOffset;
|
|
955
|
+
output.push({
|
|
956
|
+
text: " ",
|
|
957
|
+
sourceStart: start,
|
|
958
|
+
sourceEnd: Math.max(start, end),
|
|
959
|
+
synthetic: false,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
output.push(...normalizeParagraphWhitespace(paragraph.graphemes));
|
|
963
|
+
});
|
|
964
|
+
return trimOuterSpaces(output);
|
|
965
|
+
}
|
|
966
|
+
function classifyTokenGrapheme(value) {
|
|
967
|
+
if (value === " ")
|
|
968
|
+
return "space";
|
|
969
|
+
if (isNonbreakingSpace(value))
|
|
970
|
+
return "nonbreaking-space";
|
|
971
|
+
if (openingPunctuation.has(value))
|
|
972
|
+
return "opening-punctuation";
|
|
973
|
+
if (closingPunctuation.has(value))
|
|
974
|
+
return "closing-punctuation";
|
|
975
|
+
if (isCjk(value) || isPictographic(value)) {
|
|
976
|
+
return "wrap-unit";
|
|
977
|
+
}
|
|
978
|
+
return "word";
|
|
979
|
+
}
|
|
980
|
+
function canBreakTokens(tokens, end) {
|
|
981
|
+
if (end <= 0 || end >= tokens.length)
|
|
982
|
+
return true;
|
|
983
|
+
const left = tokens[end - 1];
|
|
984
|
+
const right = tokens[end];
|
|
985
|
+
if (left.kind === "opening-punctuation")
|
|
986
|
+
return false;
|
|
987
|
+
if (right.kind === "closing-punctuation")
|
|
988
|
+
return false;
|
|
989
|
+
return (left.kind === "space" ||
|
|
990
|
+
left.kind === "wrap-unit" ||
|
|
991
|
+
left.kind === "closing-punctuation" ||
|
|
992
|
+
right.kind === "wrap-unit" ||
|
|
993
|
+
right.kind === "opening-punctuation");
|
|
994
|
+
}
|
|
995
|
+
function collectTokenClusters(tokens, start, end) {
|
|
996
|
+
const output = [];
|
|
997
|
+
for (let index = start; index < end; index += 1) {
|
|
998
|
+
output.push(...tokens[index].graphemes);
|
|
999
|
+
}
|
|
1000
|
+
return output;
|
|
1001
|
+
}
|
|
1002
|
+
function fitTokenEnd(tokens, legalEnds, start, maxWidth, fontSize, context) {
|
|
1003
|
+
const firstPosition = lowerBound(legalEnds, start + 1, context);
|
|
1004
|
+
const firstEnd = legalEnds[firstPosition] ?? tokens.length;
|
|
1005
|
+
const fits = (end) => measureText(clusterText(trimTrailingSpaces(collectTokenClusters(tokens, start, end))), fontSize, context).width <=
|
|
1006
|
+
maxWidth + 1e-7;
|
|
1007
|
+
let passes = takeSearchPass(0, context);
|
|
1008
|
+
if (!fits(firstEnd))
|
|
1009
|
+
return firstEnd;
|
|
1010
|
+
let lowPosition = firstPosition;
|
|
1011
|
+
let step = 1;
|
|
1012
|
+
let highPosition = firstPosition;
|
|
1013
|
+
while (highPosition < legalEnds.length - 1) {
|
|
1014
|
+
passes = takeSearchPass(passes, context);
|
|
1015
|
+
const candidatePosition = Math.min(legalEnds.length - 1, firstPosition + step);
|
|
1016
|
+
const candidateEnd = legalEnds[candidatePosition];
|
|
1017
|
+
if (!fits(candidateEnd)) {
|
|
1018
|
+
highPosition = candidatePosition;
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
lowPosition = candidatePosition;
|
|
1022
|
+
if (candidatePosition === legalEnds.length - 1)
|
|
1023
|
+
return candidateEnd;
|
|
1024
|
+
step *= 2;
|
|
1025
|
+
}
|
|
1026
|
+
if (highPosition === lowPosition)
|
|
1027
|
+
return legalEnds[lowPosition];
|
|
1028
|
+
let low = lowPosition + 1;
|
|
1029
|
+
let high = highPosition - 1;
|
|
1030
|
+
let best = lowPosition;
|
|
1031
|
+
while (low <= high) {
|
|
1032
|
+
passes = takeSearchPass(passes, context);
|
|
1033
|
+
const middle = Math.floor((low + high) / 2);
|
|
1034
|
+
if (fits(legalEnds[middle])) {
|
|
1035
|
+
best = middle;
|
|
1036
|
+
low = middle + 1;
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
high = middle - 1;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return legalEnds[best];
|
|
1043
|
+
}
|
|
1044
|
+
function lowerBound(values, target, context) {
|
|
1045
|
+
let low = 0;
|
|
1046
|
+
let high = values.length;
|
|
1047
|
+
let passes = 0;
|
|
1048
|
+
while (low < high) {
|
|
1049
|
+
passes = takeSearchPass(passes, context);
|
|
1050
|
+
const middle = Math.floor((low + high) / 2);
|
|
1051
|
+
if (values[middle] < target)
|
|
1052
|
+
low = middle + 1;
|
|
1053
|
+
else
|
|
1054
|
+
high = middle;
|
|
1055
|
+
}
|
|
1056
|
+
return low;
|
|
1057
|
+
}
|
|
1058
|
+
function rawFits(raw, style, _availableWidth, availableHeight, fontSize, nodeId) {
|
|
1059
|
+
if (!raw.complete || raw.horizontalOverflow)
|
|
1060
|
+
return false;
|
|
1061
|
+
const lineHeight = resolveLineHeight(style.lineHeight, fontSize, nodeId);
|
|
1062
|
+
if (style.maxLines !== undefined && raw.lines.length > style.maxLines) {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
return !(availableHeight !== undefined &&
|
|
1066
|
+
raw.lines.length * lineHeight > availableHeight + 1e-7);
|
|
1067
|
+
}
|
|
1068
|
+
function rawLineLimit(style, availableHeight, fontSize, budgets, nodeId) {
|
|
1069
|
+
const lineHeight = resolveLineHeight(style.lineHeight, fontSize, nodeId);
|
|
1070
|
+
const heightLimit = availableHeight === undefined
|
|
1071
|
+
? Infinity
|
|
1072
|
+
: Math.floor((availableHeight + 1e-7) / lineHeight) + 1;
|
|
1073
|
+
const semanticLimit = (style.maxLines ?? Infinity) + 1;
|
|
1074
|
+
const semanticSentinel = Math.min(heightLimit, semanticLimit);
|
|
1075
|
+
const budgetSentinel = budgets.maxLines + 1;
|
|
1076
|
+
return {
|
|
1077
|
+
// One sentinel line distinguishes semantic truncation from exhausting the
|
|
1078
|
+
// planner's structural budget. Semantic sentinels do not count as output.
|
|
1079
|
+
limit: Math.max(1, Math.min(budgetSentinel, semanticSentinel)),
|
|
1080
|
+
enforceBudget: semanticSentinel > budgetSentinel,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
function sourceRange(graphemes, emptyOffset) {
|
|
1084
|
+
const source = graphemes.filter(isSourceCluster);
|
|
1085
|
+
return {
|
|
1086
|
+
start: source[0]?.sourceStart ?? emptyOffset,
|
|
1087
|
+
end: source.at(-1)?.sourceEnd ?? emptyOffset,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
function isSourceCluster(grapheme) {
|
|
1091
|
+
return grapheme.synthetic === false;
|
|
1092
|
+
}
|
|
1093
|
+
function clusterText(graphemes) {
|
|
1094
|
+
return graphemes.map((grapheme) => grapheme.text).join("");
|
|
1095
|
+
}
|
|
1096
|
+
function trimTrailingSpaces(graphemes) {
|
|
1097
|
+
let end = graphemes.length;
|
|
1098
|
+
while (end > 0 && graphemes[end - 1]?.text === " ")
|
|
1099
|
+
end -= 1;
|
|
1100
|
+
return graphemes.slice(0, end);
|
|
1101
|
+
}
|
|
1102
|
+
function trimOuterSpaces(graphemes) {
|
|
1103
|
+
let start = 0;
|
|
1104
|
+
let end = graphemes.length;
|
|
1105
|
+
while (start < end && graphemes[start]?.text === " ")
|
|
1106
|
+
start += 1;
|
|
1107
|
+
while (end > start && graphemes[end - 1]?.text === " ")
|
|
1108
|
+
end -= 1;
|
|
1109
|
+
return graphemes.slice(start, end);
|
|
1110
|
+
}
|
|
1111
|
+
function isWhitespace(value) {
|
|
1112
|
+
if (isNewline(value))
|
|
1113
|
+
return false;
|
|
1114
|
+
const codePoint = value.codePointAt(0) ?? -1;
|
|
1115
|
+
return (codePoint === 0x0009 ||
|
|
1116
|
+
codePoint === 0x000b ||
|
|
1117
|
+
codePoint === 0x000c ||
|
|
1118
|
+
codePoint === 0x0020 ||
|
|
1119
|
+
codePoint === 0x1680 ||
|
|
1120
|
+
(codePoint >= 0x2000 && codePoint <= 0x200a) ||
|
|
1121
|
+
codePoint === 0x205f ||
|
|
1122
|
+
codePoint === 0x3000 ||
|
|
1123
|
+
codePoint === 0xfeff);
|
|
1124
|
+
}
|
|
1125
|
+
function isNewline(value) {
|
|
1126
|
+
return (value === "\n" ||
|
|
1127
|
+
value === "\r" ||
|
|
1128
|
+
value === "\r\n" ||
|
|
1129
|
+
value === "\u2028" ||
|
|
1130
|
+
value === "\u2029");
|
|
1131
|
+
}
|
|
1132
|
+
function isNonbreakingSpace(value) {
|
|
1133
|
+
return value === "\u00a0" || value === "\u202f";
|
|
1134
|
+
}
|
|
1135
|
+
function isCjk(value) {
|
|
1136
|
+
const codePoint = value.codePointAt(0) ?? 0;
|
|
1137
|
+
return isCjkCodePoint(codePoint);
|
|
1138
|
+
}
|
|
1139
|
+
function isCjkCodePoint(codePoint) {
|
|
1140
|
+
return ((codePoint >= 0x2e80 && codePoint <= 0x2fff) ||
|
|
1141
|
+
(codePoint >= 0x3040 && codePoint <= 0x30ff) ||
|
|
1142
|
+
(codePoint >= 0x3100 && codePoint <= 0x312f) ||
|
|
1143
|
+
(codePoint >= 0x31a0 && codePoint <= 0x31bf) ||
|
|
1144
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
|
|
1145
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
|
|
1146
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7af) ||
|
|
1147
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
1148
|
+
(codePoint >= 0x20000 && codePoint <= 0x323af));
|
|
1149
|
+
}
|
|
1150
|
+
function consumeClusterExtenders(text, start) {
|
|
1151
|
+
let index = start;
|
|
1152
|
+
while (index < text.length) {
|
|
1153
|
+
const next = readCodePoint(text, index);
|
|
1154
|
+
if (!isCombiningMark(next.value) &&
|
|
1155
|
+
!isVariationSelector(next.value) &&
|
|
1156
|
+
!isEmojiModifier(next.value) &&
|
|
1157
|
+
!isEmojiTag(next.value)) {
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
index = next.end;
|
|
1161
|
+
}
|
|
1162
|
+
return index;
|
|
1163
|
+
}
|
|
1164
|
+
function readCodePoint(text, index) {
|
|
1165
|
+
const value = text.codePointAt(index);
|
|
1166
|
+
if (value === undefined)
|
|
1167
|
+
return { value: 0, end: index };
|
|
1168
|
+
return { value, end: index + (value > 0xffff ? 2 : 1) };
|
|
1169
|
+
}
|
|
1170
|
+
function isVariationSelector(value) {
|
|
1171
|
+
return ((value >= 0xfe00 && value <= 0xfe0f) ||
|
|
1172
|
+
(value >= 0xe0100 && value <= 0xe01ef));
|
|
1173
|
+
}
|
|
1174
|
+
function isEmojiModifier(value) {
|
|
1175
|
+
return value >= 0x1f3fb && value <= 0x1f3ff;
|
|
1176
|
+
}
|
|
1177
|
+
function isEmojiTag(value) {
|
|
1178
|
+
return value >= 0xe0020 && value <= 0xe007f;
|
|
1179
|
+
}
|
|
1180
|
+
function isRegionalIndicator(value) {
|
|
1181
|
+
return value >= 0x1f1e6 && value <= 0x1f1ff;
|
|
1182
|
+
}
|
|
1183
|
+
function isRtlStrong(value) {
|
|
1184
|
+
return inCodePointRanges(value, rtlLetterRanges);
|
|
1185
|
+
}
|
|
1186
|
+
function isLtrLetter(value) {
|
|
1187
|
+
if (value === 0x00d7 || value === 0x00f7)
|
|
1188
|
+
return false;
|
|
1189
|
+
return inCodePointRanges(value, ltrLetterRanges);
|
|
1190
|
+
}
|
|
1191
|
+
function isCombiningMark(value) {
|
|
1192
|
+
return inCodePointRanges(value, combiningMarkRanges);
|
|
1193
|
+
}
|
|
1194
|
+
function isPictographic(value) {
|
|
1195
|
+
return inCodePointRanges(value.codePointAt(0) ?? 0, pictographicRanges);
|
|
1196
|
+
}
|
|
1197
|
+
function inCodePointRanges(value, ranges) {
|
|
1198
|
+
return ranges.some(([start, end]) => value >= start && value <= end);
|
|
1199
|
+
}
|
|
1200
|
+
function resolveTextBudgets(options, nodeId) {
|
|
1201
|
+
const supplied = options.budgets;
|
|
1202
|
+
if (supplied !== undefined &&
|
|
1203
|
+
(typeof supplied !== "object" ||
|
|
1204
|
+
supplied === null ||
|
|
1205
|
+
Array.isArray(supplied))) {
|
|
1206
|
+
throw new LayoutCompileError("invalid_geometry", "Layout v2 text budgets must be a strict object.", ["items", nodeId], { id: nodeId });
|
|
1207
|
+
}
|
|
1208
|
+
if (supplied !== undefined) {
|
|
1209
|
+
const prototype = Object.getPrototypeOf(supplied);
|
|
1210
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1211
|
+
throw new LayoutCompileError("invalid_geometry", "Layout v2 text budgets must be a plain strict object.", ["items", nodeId], { id: nodeId });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const record = (supplied ?? {});
|
|
1215
|
+
const ownKeys = Reflect.ownKeys(record);
|
|
1216
|
+
const unknownKeys = ownKeys
|
|
1217
|
+
.filter((key) => typeof key !== "string" ||
|
|
1218
|
+
!layoutTextBudgetKeys.includes(key))
|
|
1219
|
+
.map(String);
|
|
1220
|
+
if (unknownKeys.length > 0) {
|
|
1221
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text budgets contain unsupported keys: ${unknownKeys.sort().join(", ")}.`, ["items", nodeId], { id: nodeId, keys: unknownKeys.sort() });
|
|
1222
|
+
}
|
|
1223
|
+
const merged = {
|
|
1224
|
+
maxTextCodeUnits: record.maxTextCodeUnits ??
|
|
1225
|
+
defaultLayoutTextBudgets.maxTextCodeUnits,
|
|
1226
|
+
maxGraphemes: record.maxGraphemes ??
|
|
1227
|
+
defaultLayoutTextBudgets.maxGraphemes,
|
|
1228
|
+
maxLines: record.maxLines ??
|
|
1229
|
+
defaultLayoutTextBudgets.maxLines,
|
|
1230
|
+
maxMeasureCalls: options.maxMeasureCalls ??
|
|
1231
|
+
record.maxMeasureCalls ??
|
|
1232
|
+
defaultLayoutTextBudgets.maxMeasureCalls,
|
|
1233
|
+
maxBreakSearchPasses: record.maxBreakSearchPasses ??
|
|
1234
|
+
defaultLayoutTextBudgets.maxBreakSearchPasses,
|
|
1235
|
+
};
|
|
1236
|
+
for (const name of layoutTextBudgetKeys) {
|
|
1237
|
+
const value = merged[name];
|
|
1238
|
+
const ceiling = layoutTextBudgetCeilings[name];
|
|
1239
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
|
|
1240
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text budget ${name} must be a positive safe integer no greater than ${ceiling}.`, ["items", nodeId], { id: nodeId });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return merged;
|
|
1244
|
+
}
|
|
1245
|
+
function optionalDimension(value, name, nodeId) {
|
|
1246
|
+
if (value === undefined)
|
|
1247
|
+
return undefined;
|
|
1248
|
+
if (!Number.isFinite(value) || value < 0 || value > MAX_LAYOUT_TEXT_SCALAR) {
|
|
1249
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text ${name} for ${JSON.stringify(nodeId)} must be finite, non-negative, and no greater than ${MAX_LAYOUT_TEXT_SCALAR}.`, ["items", nodeId], { id: nodeId });
|
|
1250
|
+
}
|
|
1251
|
+
return value;
|
|
1252
|
+
}
|
|
1253
|
+
function readFontSize(value, id) {
|
|
1254
|
+
return readAbsoluteSize(value, 24, "fontSize", id);
|
|
1255
|
+
}
|
|
1256
|
+
function readMinFontSize(value, id) {
|
|
1257
|
+
return readAbsoluteSize(value, 12, "minFontSize", id);
|
|
1258
|
+
}
|
|
1259
|
+
function readAbsoluteSize(value, fallback, property, id) {
|
|
1260
|
+
if (value === undefined)
|
|
1261
|
+
return fallback;
|
|
1262
|
+
const parsed = typeof value === "number"
|
|
1263
|
+
? value
|
|
1264
|
+
: value.endsWith("px")
|
|
1265
|
+
? Number.parseFloat(value)
|
|
1266
|
+
: Number.NaN;
|
|
1267
|
+
if (Number.isFinite(parsed) &&
|
|
1268
|
+
parsed >= MIN_LAYOUT_TEXT_QUANTUM &&
|
|
1269
|
+
parsed <= MAX_LAYOUT_TEXT_FONT_SIZE) {
|
|
1270
|
+
return parsed;
|
|
1271
|
+
}
|
|
1272
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 intrinsic text ${JSON.stringify(id)} needs an absolute positive ${property}; percentages are cyclic before the text box exists.`, ["items", id, "style", property], { id });
|
|
1273
|
+
}
|
|
1274
|
+
function resolveLineHeight(value, fontSize, id) {
|
|
1275
|
+
let result;
|
|
1276
|
+
if (value === undefined)
|
|
1277
|
+
result = fontSize * 1.25;
|
|
1278
|
+
else if (typeof value === "number")
|
|
1279
|
+
result = value * fontSize;
|
|
1280
|
+
else if (value.endsWith("px"))
|
|
1281
|
+
result = Number.parseFloat(value);
|
|
1282
|
+
else if (value.endsWith("%")) {
|
|
1283
|
+
result = (Number.parseFloat(value) / 100) * fontSize;
|
|
1284
|
+
}
|
|
1285
|
+
else
|
|
1286
|
+
result = Number.NaN;
|
|
1287
|
+
if (Number.isFinite(result) &&
|
|
1288
|
+
result > 0 &&
|
|
1289
|
+
result <= MAX_LAYOUT_TEXT_SCALAR) {
|
|
1290
|
+
return result;
|
|
1291
|
+
}
|
|
1292
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text ${JSON.stringify(id)} has an invalid lineHeight.`, ["items", id, "style", "lineHeight"], { id });
|
|
1293
|
+
}
|
|
1294
|
+
function resolveQuantum(value, id) {
|
|
1295
|
+
const quantum = value ?? 1 / 64;
|
|
1296
|
+
if (!Number.isFinite(quantum) ||
|
|
1297
|
+
quantum < MIN_LAYOUT_TEXT_QUANTUM ||
|
|
1298
|
+
quantum > 1000) {
|
|
1299
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text ${JSON.stringify(id)} has an invalid fontSizeQuantum.`, ["items", id], { id });
|
|
1300
|
+
}
|
|
1301
|
+
return quantum;
|
|
1302
|
+
}
|
|
1303
|
+
function quantizeDown(value, quantum, nodeId) {
|
|
1304
|
+
return tickSize(Math.max(1, Math.floor(value / quantum + 1e-9)), quantum, nodeId);
|
|
1305
|
+
}
|
|
1306
|
+
function tickSize(tick, quantum, nodeId) {
|
|
1307
|
+
if (!Number.isSafeInteger(tick) || tick < 1) {
|
|
1308
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text ${JSON.stringify(nodeId)} exceeds the safe font-size tick range.`, ["items", nodeId], { id: nodeId });
|
|
1309
|
+
}
|
|
1310
|
+
const size = Number((tick * quantum).toFixed(9));
|
|
1311
|
+
if (!Number.isFinite(size) ||
|
|
1312
|
+
size < MIN_LAYOUT_TEXT_QUANTUM ||
|
|
1313
|
+
size > MAX_LAYOUT_TEXT_FONT_SIZE) {
|
|
1314
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 text ${JSON.stringify(nodeId)} resolved an out-of-bounds font size.`, ["items", nodeId], { id: nodeId });
|
|
1315
|
+
}
|
|
1316
|
+
return size;
|
|
1317
|
+
}
|
|
1318
|
+
function textError(id, code, message) {
|
|
1319
|
+
return new LayoutCompileError(code, message, ["items", id], { id });
|
|
1320
|
+
}
|
|
1321
|
+
function budgetError(id, subject, actual, limit) {
|
|
1322
|
+
return new LayoutCompileError("budget_exceeded", `Layout v2 text ${JSON.stringify(id)} has ${actual} ${subject}, exceeding the ${limit} limit.`, ["items", id], { id, actual, limit });
|
|
1323
|
+
}
|
|
1324
|
+
function takeSearchPass(current, context) {
|
|
1325
|
+
const next = current + 1;
|
|
1326
|
+
if (next > context.budgets.maxBreakSearchPasses) {
|
|
1327
|
+
throw budgetError(context.nodeId, "break search passes", next, context.budgets.maxBreakSearchPasses);
|
|
1328
|
+
}
|
|
1329
|
+
return next;
|
|
1330
|
+
}
|
|
1331
|
+
function assertMetricScalar(value, field, nodeId) {
|
|
1332
|
+
if (!Number.isFinite(value) || value < 0 || value > MAX_LAYOUT_TEXT_SCALAR) {
|
|
1333
|
+
throw new LayoutCompileError("invalid_geometry", `TextMetricsProvider ${field} for ${JSON.stringify(nodeId)} must be finite, non-negative, and no greater than ${MAX_LAYOUT_TEXT_SCALAR}.`, ["items", nodeId], { id: nodeId, field });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function assertBoundedNumericTree(value, nodeId, label, path = [], seen = new Set()) {
|
|
1337
|
+
if (typeof value === "number") {
|
|
1338
|
+
if (!Number.isFinite(value) || Math.abs(value) > MAX_LAYOUT_TEXT_SCALAR) {
|
|
1339
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 ${label} numeric field ${path.join(".") || "<root>"} for ${JSON.stringify(nodeId)} is outside the finite ${MAX_LAYOUT_TEXT_SCALAR} bound.`, ["items", nodeId], { id: nodeId, field: path.join(".") || "<root>" });
|
|
1340
|
+
}
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (typeof value !== "object" || value === null)
|
|
1344
|
+
return;
|
|
1345
|
+
if (seen.has(value)) {
|
|
1346
|
+
throw new LayoutCompileError("invalid_geometry", `Layout v2 ${label} for ${JSON.stringify(nodeId)} contains a cycle.`, ["items", nodeId], { id: nodeId });
|
|
1347
|
+
}
|
|
1348
|
+
seen.add(value);
|
|
1349
|
+
if (Array.isArray(value)) {
|
|
1350
|
+
value.forEach((entry, index) => assertBoundedNumericTree(entry, nodeId, label, [...path, String(index)], seen));
|
|
1351
|
+
}
|
|
1352
|
+
else {
|
|
1353
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1354
|
+
assertBoundedNumericTree(entry, nodeId, label, [...path, key], seen);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
seen.delete(value);
|
|
1358
|
+
}
|
|
1359
|
+
//# sourceMappingURL=text.js.map
|