@brianmsm/quarto-bento 0.0.0-stage → 0.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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/bin/qbento-npm.mjs +62 -0
  4. package/distribution/LICENSE +21 -0
  5. package/distribution/PACKAGING.md +59 -0
  6. package/distribution/bin/qbento +5 -0
  7. package/distribution/bin/qbento.cmd +4 -0
  8. package/distribution/manifest.json +720 -0
  9. package/distribution/runtime/LICENSE +21 -0
  10. package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
  11. package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
  12. package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
  13. package/distribution/runtime/_extensions/qbento/post.ts +2 -0
  14. package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
  15. package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
  16. package/distribution/runtime/src/bridge/browser.js +168 -0
  17. package/distribution/runtime/src/bridge/isolation.ts +55 -0
  18. package/distribution/runtime/src/browser/chromium.ts +218 -0
  19. package/distribution/runtime/src/cli.ts +219 -0
  20. package/distribution/runtime/src/core/agent.ts +565 -0
  21. package/distribution/runtime/src/core/annotations.ts +384 -0
  22. package/distribution/runtime/src/core/capture.ts +203 -0
  23. package/distribution/runtime/src/core/component-actions.ts +550 -0
  24. package/distribution/runtime/src/core/component-frames.ts +59 -0
  25. package/distribution/runtime/src/core/component-sync.ts +290 -0
  26. package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
  27. package/distribution/runtime/src/core/design-registry.ts +287 -0
  28. package/distribution/runtime/src/core/graph-relations.ts +354 -0
  29. package/distribution/runtime/src/core/graph-scope.ts +63 -0
  30. package/distribution/runtime/src/core/graph-sync.ts +228 -0
  31. package/distribution/runtime/src/core/io.ts +27 -0
  32. package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
  33. package/distribution/runtime/src/core/layouts.ts +245 -0
  34. package/distribution/runtime/src/core/project.ts +759 -0
  35. package/distribution/runtime/src/core/reconcile.ts +384 -0
  36. package/distribution/runtime/src/core/recovery.ts +1255 -0
  37. package/distribution/runtime/src/core/slide-actions.ts +158 -0
  38. package/distribution/runtime/src/core/source-scope.ts +50 -0
  39. package/distribution/runtime/src/core/sync.ts +2423 -0
  40. package/distribution/runtime/src/core/widgets.ts +300 -0
  41. package/distribution/runtime/src/integration/install.ts +98 -0
  42. package/distribution/runtime/src/integration/invocation.ts +100 -0
  43. package/distribution/runtime/src/integration/quarto.ts +457 -0
  44. package/distribution/runtime/src/render/assets.ts +66 -0
  45. package/distribution/runtime/src/render/capture.lua +8 -0
  46. package/distribution/runtime/src/render/chart.ts +165 -0
  47. package/distribution/runtime/src/render/components.ts +199 -0
  48. package/distribution/runtime/src/render/design.ts +820 -0
  49. package/distribution/runtime/src/render/diagnostics.ts +36 -0
  50. package/distribution/runtime/src/render/graphs.ts +747 -0
  51. package/distribution/runtime/src/render/html.ts +82 -0
  52. package/distribution/runtime/src/render/images.ts +268 -0
  53. package/distribution/runtime/src/render/inline.ts +259 -0
  54. package/distribution/runtime/src/render/kpi.ts +385 -0
  55. package/distribution/runtime/src/render/lists.ts +183 -0
  56. package/distribution/runtime/src/render/math.ts +413 -0
  57. package/distribution/runtime/src/render/measure.ts +185 -0
  58. package/distribution/runtime/src/render/native-layout.ts +174 -0
  59. package/distribution/runtime/src/render/producer-capture.ts +319 -0
  60. package/distribution/runtime/src/render/producer-environment.R +49 -0
  61. package/distribution/runtime/src/render/producer-environment.ts +96 -0
  62. package/distribution/runtime/src/render/producer-profile.R +91 -0
  63. package/distribution/runtime/src/render/producer-site-profile.R +18 -0
  64. package/distribution/runtime/src/render/project.ts +1806 -0
  65. package/distribution/runtime/src/render/quarto.ts +127 -0
  66. package/distribution/runtime/src/render/registry.ts +233 -0
  67. package/distribution/runtime/src/render/resources.ts +292 -0
  68. package/distribution/runtime/src/render/shell.ts +110 -0
  69. package/distribution/runtime/src/render/source-inline.ts +169 -0
  70. package/distribution/runtime/src/render/table.ts +220 -0
  71. package/distribution/runtime/src/render/text-overflow.ts +208 -0
  72. package/distribution/runtime/src/render/types.ts +71 -0
  73. package/distribution/runtime/src/render/validate.ts +572 -0
  74. package/distribution/runtime/src/rich/project.ts +117 -0
  75. package/distribution/runtime/src/rich/table.ts +374 -0
  76. package/distribution/runtime/src/source/annotations.ts +490 -0
  77. package/distribution/runtime/src/source/attributes.ts +415 -0
  78. package/distribution/runtime/src/source/chart.ts +165 -0
  79. package/distribution/runtime/src/source/code-language.ts +277 -0
  80. package/distribution/runtime/src/source/component-insertion.ts +160 -0
  81. package/distribution/runtime/src/source/deletion.ts +278 -0
  82. package/distribution/runtime/src/source/graphs.ts +517 -0
  83. package/distribution/runtime/src/source/identities.ts +256 -0
  84. package/distribution/runtime/src/source/identity-id.ts +6 -0
  85. package/distribution/runtime/src/source/image.ts +50 -0
  86. package/distribution/runtime/src/source/kpi.ts +196 -0
  87. package/distribution/runtime/src/source/mod.ts +876 -0
  88. package/distribution/runtime/src/source/notes.ts +308 -0
  89. package/distribution/runtime/src/source/structure.ts +647 -0
  90. package/distribution/runtime/src/source/table.ts +290 -0
  91. package/distribution/runtime/src/source/text-conversion.ts +146 -0
  92. package/distribution/runtime/src/state/components.ts +281 -0
  93. package/distribution/runtime/src/state/graphs.ts +200 -0
  94. package/distribution/runtime/src/state/json.ts +162 -0
  95. package/distribution/runtime/src/state/merge.ts +1034 -0
  96. package/distribution/runtime/src/state/mod.ts +5 -0
  97. package/distribution/runtime/src/state/store.ts +226 -0
  98. package/distribution/runtime/src/state/types.ts +151 -0
  99. package/distribution/runtime/src/studio/client.js +790 -0
  100. package/distribution/runtime/src/studio/components.js +188 -0
  101. package/distribution/runtime/src/studio/index.html +278 -0
  102. package/distribution/runtime/src/studio/layouts.js +170 -0
  103. package/distribution/runtime/src/studio/registry.js +283 -0
  104. package/distribution/runtime/src/studio/relations.js +169 -0
  105. package/distribution/runtime/src/studio/server.ts +433 -0
  106. package/distribution/runtime/src/widgets/deck.ts +39 -0
  107. package/distribution/runtime/src/widgets/plotly.ts +354 -0
  108. package/distribution/runtime/src/widgets/presenter.ts +367 -0
  109. package/distribution/runtime/src/widgets/snapshot.ts +116 -0
  110. package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
  111. package/distribution/runtime/vendor/bento/LICENSE +21 -0
  112. package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
  113. package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
  114. package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
  115. package/distribution/runtime/vendor/bento/pin.json +69 -0
  116. package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
  117. package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
  118. package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
  119. package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
  120. package/distribution/runtime/vendor/bento/shell.html +269 -0
  121. package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
  122. package/package.json +40 -4
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Bounded authored display-LaTeX codec for the pinned Bento text renderer.
3
+ * Stores source text, never MathML, scripts, computed output or renderer state.
4
+ */
5
+ import { StateError } from "../state/mod.ts";
6
+ import { escapeHtml } from "./html.ts";
7
+
8
+ export const MATH_CODEC_VERSION = 1;
9
+ const MAX_TEX = 16_000;
10
+ const MAX_LINES = 16;
11
+ const MAX_DEPTH = 64;
12
+ function fail(message: string): never {
13
+ throw new StateError("UNSUPPORTED_MATH", message);
14
+ }
15
+ const symbols = new Set((
16
+ "alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota kappa lambda mu nu xi pi varpi rho varrho sigma varsigma tau upsilon phi varphi chi psi omega " +
17
+ "Gamma Delta Theta Lambda Xi Pi Sigma Upsilon Phi Psi Omega " +
18
+ "cdot times div pm mp le ge leq geq ne neq approx equiv sim simeq cong to rightarrow leftarrow leftrightarrow Rightarrow Leftarrow Leftrightarrow mapsto " +
19
+ "in notin ni subset supset subseteq supseteq cup cap emptyset varnothing forall exists neg land lor infty partial nabla ell hbar Re Im " +
20
+ "sum prod coprod int iint iiint oint sin cos tan cot sec csc sinh cosh tanh exp log ln lim min max sup inf det Pr " +
21
+ "quad qquad ldots cdots vdots ddots angle perp parallel mid lvert rvert lVert rVert langle rangle"
22
+ ).split(" "));
23
+ const argumentCounts: Record<string, number> = {
24
+ frac: 2,
25
+ dfrac: 2,
26
+ tfrac: 2,
27
+ binom: 2,
28
+ sqrt: 1,
29
+ text: 1,
30
+ textrm: 1,
31
+ mathrm: 1,
32
+ mathbf: 1,
33
+ mathit: 1,
34
+ mathsf: 1,
35
+ mathtt: 1,
36
+ mathbb: 1,
37
+ mathcal: 1,
38
+ mathfrak: 1,
39
+ boldsymbol: 1,
40
+ operatorname: 1,
41
+ hat: 1,
42
+ widehat: 1,
43
+ bar: 1,
44
+ overline: 1,
45
+ vec: 1,
46
+ dot: 1,
47
+ ddot: 1,
48
+ tilde: 1,
49
+ widetilde: 1,
50
+ underline: 1,
51
+ underbrace: 1,
52
+ overbrace: 1,
53
+ };
54
+ const environments = new Set([
55
+ "aligned",
56
+ "gathered",
57
+ "matrix",
58
+ "pmatrix",
59
+ "bmatrix",
60
+ "Bmatrix",
61
+ "vmatrix",
62
+ "Vmatrix",
63
+ "cases",
64
+ "smallmatrix",
65
+ ]);
66
+ const escaped = new Set([
67
+ "{",
68
+ "}",
69
+ "$",
70
+ "%",
71
+ "_",
72
+ "&",
73
+ "#",
74
+ " ",
75
+ ",",
76
+ ";",
77
+ ":",
78
+ "!",
79
+ "|",
80
+ ]);
81
+ type Token = { value: string; command: boolean };
82
+ /** A deliberately small syntax vocabulary, not an independent TeX renderer. */
83
+ function validateTex(tex: string): void {
84
+ if (
85
+ !tex.trim() || tex.length > MAX_TEX || tex.split("\n").length > MAX_LINES ||
86
+ Array.from(tex).some((c) => {
87
+ const point = c.codePointAt(0)!;
88
+ return (point < 32 && point !== 9 && point !== 10) || point === 127 ||
89
+ point === 0xE000;
90
+ }) ||
91
+ !tex.isWellFormed() || /\{\{[<%]/.test(tex) ||
92
+ /\{\{\s*(?:page|pages|title|date|time|author|company|subject|event)(?::[^}]*)?\s*\}\}/i
93
+ .test(tex) ||
94
+ /^(?:\s*typst:)/.test(tex) || /[`\u00a0]/.test(tex)
95
+ ) {
96
+ fail(
97
+ "Display math must be bounded literal LaTeX without fields, controls, Typst or executable Markdown.",
98
+ );
99
+ }
100
+ const tokens: Token[] = [];
101
+ for (let i = 0; i < tex.length;) {
102
+ const c = tex[i];
103
+ if (/\s/.test(c)) {
104
+ i++;
105
+ continue;
106
+ }
107
+ if (c === "$" || c === "%") {
108
+ fail(
109
+ "Unescaped dollars and TeX comments are outside the display-math subset.",
110
+ );
111
+ }
112
+ if (c !== "\\") {
113
+ tokens.push({ value: c, command: false });
114
+ i++;
115
+ continue;
116
+ }
117
+ const match = /^\\([A-Za-z]+|[^\n])/u.exec(tex.slice(i));
118
+ if (!match) fail("A LaTeX command is incomplete.");
119
+ tokens.push({ value: match[1], command: true });
120
+ i += match[0].length;
121
+ }
122
+ let at = 0, depth = 0;
123
+ const left: { depth: number; environments: number }[] = [];
124
+ const opened: { name: string; depth: number; left: number }[] = [];
125
+ function group(): void {
126
+ if (tokens[at]?.command || tokens[at]?.value !== "{") {
127
+ fail("This LaTeX command requires a braced argument.");
128
+ }
129
+ at++;
130
+ if (++depth > MAX_DEPTH) fail("LaTeX groups are nested too deeply.");
131
+ const start = at;
132
+ sequence(true);
133
+ if (at === start + 1) {
134
+ fail("Empty LaTeX groups need explicit source review.");
135
+ }
136
+ depth--;
137
+ }
138
+ function textGroup(): void {
139
+ if (tokens[at]?.command || tokens[at]?.value !== "{") {
140
+ fail("Text in math requires a braced argument.");
141
+ }
142
+ at++;
143
+ let nested = 1, count = 0;
144
+ while (tokens[at] && nested) {
145
+ const token = tokens[at++];
146
+ if (token.command) {
147
+ fail(
148
+ "Literal math text supports plain Unicode only; the pinned renderer does not faithfully expand text commands or escapes.",
149
+ );
150
+ } else if (token.value === "{") {
151
+ if (++nested + depth > MAX_DEPTH) {
152
+ fail("Math text is nested too deeply.");
153
+ }
154
+ } else if (token.value === "}") nested--;
155
+ count++;
156
+ }
157
+ if (nested || count === 1) fail("Math text is empty or unclosed.");
158
+ }
159
+ function environmentName(): string {
160
+ if (tokens[at]?.command || tokens[at]?.value !== "{") {
161
+ fail("A math environment needs a braced name.");
162
+ }
163
+ at++;
164
+ let name = "";
165
+ while (
166
+ tokens[at] && !tokens[at].command && /^[A-Za-z]$/.test(tokens[at].value)
167
+ ) name += tokens[at++].value;
168
+ if (
169
+ tokens[at]?.command || tokens[at]?.value !== "}" ||
170
+ !environments.has(name)
171
+ ) fail("Unsupported or malformed math environment.");
172
+ at++;
173
+ return name;
174
+ }
175
+ function atom(script = false): boolean {
176
+ const token = tokens[at++];
177
+ if (!token || (!token.command && ["}", "^", "_"].includes(token.value))) {
178
+ fail("A LaTeX group or script is incomplete.");
179
+ }
180
+ if (!token.command) {
181
+ if (token.value === "{") {
182
+ at--;
183
+ group();
184
+ }
185
+ if (
186
+ token.value === "&" &&
187
+ (!opened.length || opened.at(-1)!.depth !== depth ||
188
+ opened.at(-1)!.left !== left.length)
189
+ ) fail("Math alignment is allowed only in a supported environment cell.");
190
+ if (token.value === "&" && script) {
191
+ fail("A math cell separator cannot be a script argument.");
192
+ }
193
+ return token.value !== "&";
194
+ }
195
+ const command = token.value;
196
+ if (command === "begin" || command === "end") {
197
+ if (script) fail("A math environment cannot be a script argument.");
198
+ const name = environmentName();
199
+ if (command === "begin") opened.push({ name, depth, left: left.length });
200
+ else {
201
+ const previous = opened.pop();
202
+ if (!previous || previous.name !== name || previous.depth !== depth) {
203
+ fail("Math environment boundaries do not match.");
204
+ }
205
+ }
206
+ return command === "end";
207
+ }
208
+ if (command === "left" || command === "right") {
209
+ if (script) fail("A scalable delimiter cannot be a script argument.");
210
+ const delimiter = tokens[at++];
211
+ if (
212
+ !delimiter || !(delimiter.command
213
+ ? [
214
+ "{",
215
+ "}",
216
+ "|",
217
+ "langle",
218
+ "rangle",
219
+ ].includes(delimiter.value)
220
+ : ["(", ")", "[", "]", "|", "."].includes(delimiter.value))
221
+ ) fail("Unsupported scalable math delimiter.");
222
+ if (command === "left") left.push({ depth, environments: opened.length });
223
+ else {
224
+ const previous = left.pop();
225
+ if (
226
+ !previous || previous.depth !== depth ||
227
+ previous.environments !== opened.length
228
+ ) {
229
+ fail(
230
+ "Scalable delimiters cannot cross a group or environment boundary.",
231
+ );
232
+ }
233
+ }
234
+ return command === "right";
235
+ }
236
+ if (command === "\\") {
237
+ if (
238
+ script || !opened.length || opened.at(-1)!.depth !== depth ||
239
+ opened.at(-1)!.left !== left.length
240
+ ) {
241
+ fail("Math row breaks require a supported environment.");
242
+ }
243
+ return false;
244
+ }
245
+ if (["text", "textrm", "operatorname"].includes(command)) {
246
+ textGroup();
247
+ return true;
248
+ }
249
+ if (["textbf", "textit"].includes(command)) {
250
+ fail(
251
+ `LaTeX \\${command} is processed as a mathematical font by the pinned renderer and cannot preserve literal word spacing; use native text typography instead.`,
252
+ );
253
+ }
254
+ const args = argumentCounts[command];
255
+ if (args) {
256
+ for (let i = 0; i < args; i++) group();
257
+ return true;
258
+ }
259
+ if (!symbols.has(command) && !escaped.has(command)) {
260
+ fail(
261
+ `LaTeX command \\${command} needs an explicit supported projection.`,
262
+ );
263
+ }
264
+ return true;
265
+ }
266
+ function sequence(inGroup = false): void {
267
+ while (at < tokens.length) {
268
+ if (!tokens[at].command && tokens[at].value === "}") {
269
+ if (!inGroup) fail("A LaTeX closing brace has no matching opener.");
270
+ if (
271
+ left.some((frame) => frame.depth >= depth) ||
272
+ opened.some((frame) => frame.depth >= depth)
273
+ ) {
274
+ fail(
275
+ "A math group cannot close before its delimiter or environment.",
276
+ );
277
+ }
278
+ at++;
279
+ return;
280
+ }
281
+ const scriptable = atom();
282
+ const scripts = new Set<string>();
283
+ while (
284
+ tokens[at] && !tokens[at].command &&
285
+ ["^", "_"].includes(tokens[at].value)
286
+ ) {
287
+ if (!scriptable) {
288
+ fail(
289
+ "A math script needs a real atom, not an environment opening, delimiter opening, row break or cell separator.",
290
+ );
291
+ }
292
+ const script = tokens[at++].value;
293
+ if (scripts.has(script)) fail("A math atom has duplicate scripts.");
294
+ scripts.add(script);
295
+ atom(true);
296
+ }
297
+ }
298
+ if (inGroup) fail("A LaTeX group is unclosed.");
299
+ }
300
+ sequence();
301
+ if (opened.length || left.length) {
302
+ fail("A math environment or scalable delimiter is unclosed.");
303
+ }
304
+ }
305
+ function normalizeTex(tex: string): { tex: string; normalized: boolean } {
306
+ if (tex.length > MAX_TEX) fail("LaTeX exceeds the supported size.");
307
+ const normalized = tex.replace(/\r\n|\r/g, "\n").trim();
308
+ validateTex(normalized);
309
+ return { tex: normalized, normalized: normalized !== tex };
310
+ }
311
+ function decodeEntities(text: string): string {
312
+ return text.replace(
313
+ /&(?:([^;\s]+);|(?=[A-Za-z#]))/g,
314
+ (whole, name?: string) => {
315
+ const entities: Record<string, string> = {
316
+ amp: "&",
317
+ lt: "<",
318
+ gt: ">",
319
+ quot: '"',
320
+ apos: "'",
321
+ nbsp: "\u00a0",
322
+ };
323
+ if (name && Object.hasOwn(entities, name)) return entities[name];
324
+ if (name && /^#(?:[0-9]+|x[0-9a-f]+)$/i.test(name)) {
325
+ const point = name[1].toLowerCase() === "x"
326
+ ? parseInt(name.slice(2), 16)
327
+ : parseInt(name.slice(1), 10);
328
+ if (
329
+ point > 0 && point <= 0x10ffff &&
330
+ !(point >= 0xd800 && point <= 0xdfff)
331
+ ) return String.fromCodePoint(point);
332
+ }
333
+ return fail(`Unsupported or invalid math HTML entity ${whole}.`);
334
+ },
335
+ );
336
+ }
337
+ /** Accept native editor line breaks, never attributes or other HTML content. */
338
+ function lineText(html: string): string {
339
+ if (html.length > MAX_TEX * 8) fail("Math HTML exceeds the supported size.");
340
+ let text = "", kind = "", open = "", count = 0;
341
+ for (const token of html.match(/<[^>]*>|[^<]+|</g) ?? []) {
342
+ if (!token.startsWith("<")) {
343
+ text += decodeEntities(token);
344
+ continue;
345
+ }
346
+ if (++count > 40) fail("Math HTML contains too many line-break tags.");
347
+ if (/^<br\s*\/?\s*>$/i.test(token)) {
348
+ text += "\n";
349
+ continue;
350
+ }
351
+ const block = /^<(\/?)(div|p)>$/i.exec(token);
352
+ if (!block) {
353
+ fail(
354
+ "Math sync accepts only one formula and attribute-free br/div/p line breaks.",
355
+ );
356
+ }
357
+ const tag = block[2].toLowerCase();
358
+ if (kind && kind !== tag) {
359
+ fail("A formula cannot mix div and p line blocks.");
360
+ }
361
+ kind = tag;
362
+ if (!block[1]) {
363
+ if (open) fail("Nested math line blocks need explicit source review.");
364
+ open = tag;
365
+ if (text && !text.endsWith("\n")) text += "\n";
366
+ } else {
367
+ if (open !== tag) fail("Math line blocks are unbalanced.");
368
+ open = "";
369
+ if (!text.endsWith("\n")) text += "\n";
370
+ }
371
+ }
372
+ if (open) fail("Math line blocks are unclosed.");
373
+ return text;
374
+ }
375
+ export interface NativeDisplayMath {
376
+ /** Formula body suitable for an existing SourceBlock.contentSpan patch. */
377
+ tex: string;
378
+ /** Canonical native text.html, still containing literal LaTeX. */
379
+ html: string;
380
+ /** Complete QMD block for an explicitly reviewed insertion. */
381
+ source: string;
382
+ /** Boundary whitespace/line-end changes require the caller's normal review. */
383
+ normalized: boolean;
384
+ }
385
+ export function mathToNativeHtml(tex: string, display = true): string {
386
+ if (!display) fail("Inline math is outside this display-object codec.");
387
+ const literal = normalizeTex(tex).tex;
388
+ return `$$${escapeHtml(literal).replaceAll("\n", "<br>")}$$`;
389
+ }
390
+ export function parseNativeDisplayMath(html: string): NativeDisplayMath {
391
+ const text = lineText(html).replace(/\r\n|\r/g, "\n").trim();
392
+ let body: string;
393
+ if (text.startsWith("$$") && text.endsWith("$$")) body = text.slice(2, -2);
394
+ else if (text.startsWith("\\[") && text.endsWith("\\]")) {
395
+ body = text.slice(2, -2);
396
+ } else {fail(
397
+ "Math sync requires exactly one standalone display formula ($$…$$ or \\[…\\]).",
398
+ );}
399
+ // These token pairs can close the native delimiter or broaden the source block.
400
+ if (/\\[()[\]]/.test(body)) {
401
+ fail("Nested or alternate math delimiters need explicit source review.");
402
+ }
403
+ const literal = normalizeTex(body);
404
+ return {
405
+ tex: literal.tex,
406
+ html: mathToNativeHtml(literal.tex),
407
+ source: `$$\n${literal.tex}\n$$`,
408
+ normalized: literal.normalized || /\r/.test(html),
409
+ };
410
+ }
411
+ export function nativeDisplayMathToSource(html: string): string {
412
+ return parseNativeDisplayMath(html).source;
413
+ }
@@ -0,0 +1,185 @@
1
+ import { loadBentoShell } from "./shell.ts";
2
+ import { withHeadlessChromium } from "../browser/chromium.ts";
3
+ import { spliceDocument } from "./html.ts";
4
+ import {
5
+ ISOLATION_BOOTSTRAP,
6
+ OFFLINE_CSP,
7
+ prepareIsolatedDocument,
8
+ } from "../bridge/isolation.ts";
9
+ import { canonicalInlineHtml } from "./inline.ts";
10
+ import { DesignError, designTheme } from "./design.ts";
11
+ import type { BentoDoc, Element } from "./types.ts";
12
+ function fail(code: string, message: string): never {
13
+ throw new DesignError(code, message);
14
+ }
15
+ export interface NativeTextMeasurement {
16
+ /** Preferred height includes symmetric slack for vertically centered text. */
17
+ height: number;
18
+ width: number;
19
+ lineBoxHeight: number;
20
+ /** Conservative fragment count when native CSS line boxes collapse. */
21
+ lineCount: number;
22
+ overhangTop: number;
23
+ overhangBottom: number;
24
+ overhangLeft: number;
25
+ overhangRight: number;
26
+ }
27
+ /** Measure the pinned native text inner style with the installed local browser.
28
+ * Text fields and the native inline-code rule match Bento revision 3d6f7f1.
29
+ * No runtime/font is downloaded; document.fonts uses the same local fallback
30
+ * as the native editor. Range text rectangles also bound font overhang outside
31
+ * CSS line boxes. This does not claim arbitrary painted effects or rotation.
32
+ * Preferred height is safe only when the consumer checks native alignment and
33
+ * multiline overlap; allocating height alone does not move top-aligned glyphs.
34
+ */
35
+ export async function measureNativeText(
36
+ elements: Element[],
37
+ resources?: BentoDoc,
38
+ ): Promise<NativeTextMeasurement[]> {
39
+ if (!elements.length) return [];
40
+ const data = elements.map((element) => {
41
+ const html = canonicalInlineHtml(String(element.html));
42
+ const plain = html.replace(/<[^>]*>/g, "");
43
+ if (
44
+ /\{\{\s*(?:page|pages|title|date|time|author|company|subject|event)(?::[^}]*?)?\s*\}\}/i
45
+ .test(html) ||
46
+ /\$\$[\s\S]*?\$\$|\$[^$\n]+\$|\\\([\s\S]*?\\\)|\\\[[\s\S]*?\\\]/.test(
47
+ plain,
48
+ )
49
+ ) {
50
+ fail(
51
+ "NATIVE_TEXT_TRANSFORM_UNSUPPORTED",
52
+ "Native mathematical or document-field markers transform text semantics; use supported literal text or an explicit faithful adapter.",
53
+ );
54
+ }
55
+ const fontSize = Number(element.fontSize ?? 16),
56
+ w = Number(element.w),
57
+ lineHeight = element.lineHeight == null
58
+ ? "normal"
59
+ : Number(element.lineHeight),
60
+ letterSpacing = Number(element.letterSpacing ?? 0);
61
+ if (
62
+ ![fontSize, w, letterSpacing].every(Number.isFinite) ||
63
+ fontSize <= 0 || w <= 0 ||
64
+ (typeof lineHeight === "number" &&
65
+ (!Number.isFinite(lineHeight) || lineHeight < 0))
66
+ ) {
67
+ fail(
68
+ "INVALID_TEXT_MEASUREMENT",
69
+ "Native text requires finite positive geometry/font size and nonnegative line-height.",
70
+ );
71
+ }
72
+ return {
73
+ html,
74
+ w,
75
+ fontSize,
76
+ lineHeight,
77
+ letterSpacing,
78
+ fontFamily: String(
79
+ element.fontFamily ?? resources?.theme?.fontFamily ??
80
+ designTheme().fontFamily,
81
+ ),
82
+ fontWeight: Number(element.fontWeight ?? 400),
83
+ align: String(element.align ?? "start"),
84
+ };
85
+ });
86
+ const resourceFields = resources as unknown as
87
+ | Record<string, unknown>
88
+ | undefined;
89
+ const metricsDocument = prepareIsolatedDocument({
90
+ format: "bento/slides",
91
+ version: 1,
92
+ docId: "native-text-metrics",
93
+ title: "Native text metrics",
94
+ size: { width: 1280, height: 720 },
95
+ theme: resources?.theme ?? designTheme(),
96
+ slides: [{
97
+ id: "metrics",
98
+ background: "#ffffff",
99
+ transition: "fade",
100
+ notes: "",
101
+ elements: [],
102
+ }],
103
+ ...(resourceFields?.fonts ? { fonts: resourceFields.fonts } : {}),
104
+ ...(resourceFields?.assets ? { assets: resourceFields.assets } : {}),
105
+ });
106
+ const shell = await loadBentoShell();
107
+ const html = spliceDocument(
108
+ shell,
109
+ metricsDocument as unknown as BentoDoc,
110
+ ISOLATION_BOOTSTRAP,
111
+ OFFLINE_CSP,
112
+ );
113
+ return await withHeadlessChromium({}, async (browser) => {
114
+ const tree = await browser.send<{ frameTree: { frame: { id: string } } }>(
115
+ "Page.getFrameTree",
116
+ );
117
+ await browser.send("Page.setDocumentContent", {
118
+ frameId: tree.frameTree.frame.id,
119
+ html,
120
+ });
121
+ const result = await browser.send<
122
+ {
123
+ result: { value?: NativeTextMeasurement[] };
124
+ exceptionDetails?: unknown;
125
+ }
126
+ >("Runtime.evaluate", {
127
+ expression: `(async () => {
128
+ for(let attempt=0;attempt<200&&!window.bento?.loadDoc;attempt++) await new Promise(resolve=>setTimeout(resolve,25));
129
+ if(!window.bento?.loadDoc) throw new Error('Pinned Bento text runtime did not load');
130
+ document.body.style.margin='0';
131
+ const metrics=${JSON.stringify(data)};
132
+ const probes=[];
133
+ const nodes=metrics.map((e,index)=>{
134
+ const outer=document.createElement('div');outer.className='bento-el bento-el-text';
135
+ Object.assign(outer.style,{position:'absolute',width:e.w+'px',overflowWrap:'break-word',display:'flex',flexDirection:'column',contain:'layout style'});
136
+ const inner=document.createElement('div');inner.className='bento-text-inner';inner.dir='auto';
137
+ Object.assign(inner.style,{width:'100%',fontSize:e.fontSize+'px',fontFamily:e.fontFamily,fontWeight:String(e.fontWeight),textAlign:e.align,lineHeight:String(e.lineHeight),letterSpacing:e.letterSpacing+'px'});
138
+ inner.innerHTML=e.html;outer.appendChild(inner);document.body.appendChild(outer);
139
+ if(e.lineHeight==='normal'){
140
+ const probe=inner.cloneNode(false);probe.textContent='M';outer.appendChild(probe);probes[index]=probe;
141
+ }
142
+ return inner;
143
+ });
144
+ nodes.forEach(node=>node.getBoundingClientRect());
145
+ probes.forEach(node=>node.getBoundingClientRect());
146
+ await document.fonts.ready;
147
+ return nodes.map((node,index)=>{
148
+ const box=node.getBoundingClientRect();
149
+ const range=document.createRange();range.selectNodeContents(node);
150
+ const rects=[...range.getClientRects()].filter(rect=>rect.width>0||rect.height>0);
151
+ const top=Math.min(box.top,...rects.map(rect=>rect.top));
152
+ const bottom=Math.max(box.bottom,...rects.map(rect=>rect.bottom));
153
+ const left=Math.min(box.left,...rects.map(rect=>rect.left));
154
+ const right=Math.max(box.right,...rects.map(rect=>rect.right));
155
+ const overhangTop=box.top-top,overhangBottom=bottom-box.bottom;
156
+ const overhangLeft=box.left-left,overhangRight=right-box.right;
157
+ const lineStep=metrics[index].lineHeight==='normal'
158
+ ? probes[index].getBoundingClientRect().height
159
+ : metrics[index].fontSize*metrics[index].lineHeight;
160
+ const lineCount=Math.max(1,node.querySelectorAll('br').length+1,
161
+ lineStep>0?Math.round(box.height/lineStep):0,box.height===0?rects.length:0);
162
+ return {height:box.height+2*Math.max(overhangTop,overhangBottom)+4,
163
+ width:Math.max(node.scrollWidth,right-left),lineBoxHeight:box.height,
164
+ lineCount,
165
+ overhangTop,overhangBottom,overhangLeft,overhangRight};
166
+ });
167
+ })()`,
168
+ awaitPromise: true,
169
+ returnByValue: true,
170
+ });
171
+ const measured = result.result?.value;
172
+ if (
173
+ result.exceptionDetails || !measured || measured.length !== data.length ||
174
+ measured.some((row) =>
175
+ Object.values(row).some((value) => !Number.isFinite(value) || value < 0)
176
+ )
177
+ ) {
178
+ fail(
179
+ "TEXT_MEASUREMENT_FAILED",
180
+ "The installed browser could not measure the complete native text.",
181
+ );
182
+ }
183
+ return measured;
184
+ });
185
+ }