@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,747 @@
1
+ /** Source graph projection into the pinned native rect/text and anchored line model. */
2
+ import { canonical } from "../state/json.ts";
3
+ import {
4
+ type GraphChild,
5
+ graphEdgeId,
6
+ graphFlowSignature,
7
+ graphNodeId,
8
+ type SourceGraph,
9
+ sourceGraph,
10
+ sourceGraphs,
11
+ timelineLinkId,
12
+ } from "../source/graphs.ts";
13
+ import { type SourceDocument } from "../source/mod.ts";
14
+ import {
15
+ type DesignDefaults,
16
+ DesignError,
17
+ designTheme,
18
+ type Frame,
19
+ type PandocAttr,
20
+ } from "./design.ts";
21
+ import { renderInline } from "./inline.ts";
22
+ import { measureNativeText, type NativeTextMeasurement } from "./measure.ts";
23
+ import type { Element, Json, Node, Origin, Projection } from "./types.ts";
24
+ import { graphReceipt } from "../state/graphs.ts";
25
+ import type { ProvenanceMap } from "../state/types.ts";
26
+
27
+ type Fields = NonNullable<Projection["design"]>[string];
28
+ export interface GraphProjection {
29
+ elements: Element[];
30
+ origins: Record<string, Origin>;
31
+ fields: Fields;
32
+ frame: Frame;
33
+ }
34
+ interface Point {
35
+ x: number;
36
+ y: number;
37
+ }
38
+ const geometry = ["x", "y", "w", "h"];
39
+ const textFields = [
40
+ "fontSize",
41
+ "fontFamily",
42
+ "fontWeight",
43
+ "lineHeight",
44
+ "letterSpacing",
45
+ "color",
46
+ "align",
47
+ "valign",
48
+ ];
49
+ const shapeFields = ["fill", "stroke", "strokeWidth", "radius"];
50
+ function typographyFits(
51
+ label: Element,
52
+ measured: NativeTextMeasurement,
53
+ ): boolean {
54
+ const verticalOverhang = Math.max(
55
+ measured.overhangTop,
56
+ measured.overhangBottom,
57
+ );
58
+ return measured.height <= Number(label.h) + .01 &&
59
+ measured.width <= Number(label.w) + 1 &&
60
+ !(measured.lineCount > 1 && verticalOverhang > .1) &&
61
+ !((label.valign ?? "top") === "top" && measured.overhangTop > .1) &&
62
+ !((label.valign ?? "top") === "bottom" && measured.overhangBottom > .1);
63
+ }
64
+ function fail(code: string, message: string): never {
65
+ throw new DesignError(code, message);
66
+ }
67
+ function exactAttr(actual: PandocAttr, expected: PandocAttr) {
68
+ const normalize = (
69
+ attr: PandocAttr,
70
+ ) => [
71
+ attr[0],
72
+ [...attr[1]].sort(),
73
+ [...attr[2]].sort((a, b) => a[0].localeCompare(b[0])),
74
+ ];
75
+ if (canonical(normalize(actual)) !== canonical(normalize(expected))) {
76
+ fail(
77
+ "UNMAPPED_GRAPH_SOURCE",
78
+ "Executed component attributes disagree with the exact original graph declaration.",
79
+ );
80
+ }
81
+ }
82
+ /** Original fragment reader only: no execution, package environment or Pandoc writer. */
83
+ export async function originalGraphNode(graph: SourceGraph): Promise<Node> {
84
+ const child = new Deno.Command("quarto", {
85
+ args: ["pandoc", "--from", "markdown", "--to", "json"],
86
+ stdin: "piped",
87
+ stdout: "piped",
88
+ stderr: "piped",
89
+ }).spawn();
90
+ const output = child.output(), writer = child.stdin.getWriter();
91
+ await writer.write(new TextEncoder().encode(graph.wrapper.raw));
92
+ await writer.close();
93
+ const result = await output;
94
+ if (!result.success) {
95
+ fail(
96
+ "UNMAPPED_GRAPH_SOURCE",
97
+ "Pandoc could not verify the original graph fragment.",
98
+ );
99
+ }
100
+ const ast = JSON.parse(new TextDecoder().decode(result.stdout));
101
+ if (
102
+ !Array.isArray(ast.blocks) || ast.blocks.length !== 1 ||
103
+ ast.blocks[0].t !== "Div"
104
+ ) {
105
+ fail(
106
+ "UNMAPPED_GRAPH_SOURCE",
107
+ "Original graph does not parse to its one declared div.",
108
+ );
109
+ }
110
+ return ast.blocks[0];
111
+ }
112
+ function labels(node: Node, graph: SourceGraph): Map<string, string> {
113
+ if (node.t !== "Div") {
114
+ fail("UNMAPPED_GRAPH_SOURCE", "Graph projection needs a div.");
115
+ }
116
+ const [attr, children] = node.c as [PandocAttr, Node[]];
117
+ exactAttr(attr, graph.attr);
118
+ const sourceChildren = [...graph.nodes, ...graph.edges].sort((a, b) =>
119
+ a.block.span.start - b.block.span.start
120
+ );
121
+ if (children.length !== sourceChildren.length) {
122
+ fail(
123
+ "UNMAPPED_GRAPH_SOURCE",
124
+ "Executed graph children differ from exact original membership.",
125
+ );
126
+ }
127
+ const result = new Map<string, string>();
128
+ children.forEach((child, i) => {
129
+ if (child.t !== "Div") {
130
+ fail(
131
+ "UNMAPPED_GRAPH_SOURCE",
132
+ "Graph membership must remain direct div children.",
133
+ );
134
+ }
135
+ const [childAttr, body] = child.c as [PandocAttr, Node[]],
136
+ original = sourceChildren[i];
137
+ exactAttr(childAttr, original.attr);
138
+ if (
139
+ body.length !== (original.label ? 1 : 0) ||
140
+ body.some((block) => block.t !== "Para")
141
+ ) {
142
+ fail(
143
+ "GRAPH_LABEL_STRUCTURE",
144
+ "Graph labels need their original one authored paragraph.",
145
+ );
146
+ }
147
+ if (original.label) {
148
+ result.set(original.block.id, renderInline(body[0].c as Node[]));
149
+ }
150
+ });
151
+ return result;
152
+ }
153
+ function frameFor(graph: SourceGraph, base: Frame): Frame {
154
+ const frame = { ...base };
155
+ for (const field of geometry as (keyof Frame)[]) {
156
+ if (graph.declaration.properties[field] !== undefined) {
157
+ frame[field] = Number(graph.declaration.properties[field]);
158
+ }
159
+ }
160
+ if (
161
+ frame.x < 0 || frame.y < 0 || frame.w <= 0 || frame.h <= 0 ||
162
+ frame.x + frame.w > 1280 || frame.y + frame.h > 720
163
+ ) {
164
+ fail(
165
+ "GRAPH_FRAME_OVERFLOW",
166
+ "The graph frame must fit the 1280x720 canvas; use explicit source dimensions or a separate slide.",
167
+ );
168
+ }
169
+ return frame;
170
+ }
171
+ function border(box: Frame, toward: Point): Point {
172
+ // The same unrotated rectangle border ray used at pinned Bento geom.ts.
173
+ const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
174
+ const dx = toward.x - cx, dy = toward.y - cy;
175
+ const scale = Math.min(
176
+ dx ? box.w / 2 / Math.abs(dx) : Infinity,
177
+ dy ? box.h / 2 / Math.abs(dy) : Infinity,
178
+ );
179
+ return { x: cx + dx * scale, y: cy + dy * scale };
180
+ }
181
+ const center = (box: Frame): Point => ({
182
+ x: box.x + box.w / 2,
183
+ y: box.y + box.h / 2,
184
+ });
185
+ function segmentHits(a: Point, b: Point, box: Frame): boolean {
186
+ let t0 = 0, t1 = 1;
187
+ const dx = b.x - a.x, dy = b.y - a.y;
188
+ const values = [[-dx, a.x - box.x], [dx, box.x + box.w - a.x], [
189
+ -dy,
190
+ a.y - box.y,
191
+ ], [dy, box.y + box.h - a.y]];
192
+ for (const [p, q] of values) {
193
+ if (!p) {
194
+ if (q < 0) return false;
195
+ continue;
196
+ }
197
+ const ratio = q / p;
198
+ if (p < 0) t0 = Math.max(t0, ratio);
199
+ else t1 = Math.min(t1, ratio);
200
+ if (t0 > t1) return false;
201
+ }
202
+ return true;
203
+ }
204
+ function overlap(a: Frame, b: Frame, margin = 0): boolean {
205
+ return a.x < b.x + b.w + margin && a.x + a.w + margin > b.x &&
206
+ a.y < b.y + b.h + margin && a.y + a.h + margin > b.y;
207
+ }
208
+ function within(box: Frame, frame: Frame) {
209
+ return box.x >= frame.x - .01 && box.y >= frame.y - .01 &&
210
+ box.x + box.w <= frame.x + frame.w + .01 &&
211
+ box.y + box.h <= frame.y + frame.h + .01;
212
+ }
213
+ function metadata(
214
+ source: SourceDocument,
215
+ graph: SourceGraph,
216
+ child: GraphChild | undefined,
217
+ role: string,
218
+ base: Frame,
219
+ from?: string,
220
+ to?: string,
221
+ ): Json {
222
+ return {
223
+ version: 1,
224
+ kind: graph.kind,
225
+ componentId: graph.wrapper.id,
226
+ role,
227
+ sourceId: child?.block.id ?? graph.wrapper.id,
228
+ nodeIds: graph.nodes.map((node) => node.block.id),
229
+ base: { ...base },
230
+ prefix: graphFlowSignature(source, graph),
231
+ ...(child?.label ? { labelId: child.label.id } : {}),
232
+ ...(from ? { from } : {}),
233
+ ...(to ? { to } : {}),
234
+ };
235
+ }
236
+ /** Native labels share the existing paragraph codec and IDs; shapes have reserved suffixes. */
237
+ export async function projectGraph(
238
+ source: SourceDocument,
239
+ node: Node,
240
+ base: Frame,
241
+ defaults: DesignDefaults = {},
242
+ ): Promise<GraphProjection> {
243
+ const attr = (node.c as [PandocAttr, Node[]])[0],
244
+ graph = sourceGraph(source, attr[0]);
245
+ const actualLabels = labels(node, graph),
246
+ originalLabels = labels(await originalGraphNode(graph), graph);
247
+ if (canonical([...actualLabels]) !== canonical([...originalLabels])) {
248
+ fail(
249
+ "UNMAPPED_GRAPH_LABEL",
250
+ "Executed labels disagree with exact original authored paragraphs.",
251
+ );
252
+ }
253
+ const frame = frameFor(graph, base), theme = designTheme(defaults);
254
+ if (graph.declaration.properties.rotation !== undefined) {
255
+ fail(
256
+ "GRAPH_ROTATION_UNSUPPORTED",
257
+ "Apply graph frame attributes without rotation; native rotations remain individual visual design.",
258
+ );
259
+ }
260
+ const result: GraphProjection = {
261
+ elements: [],
262
+ origins: {},
263
+ fields: {},
264
+ frame,
265
+ };
266
+ const nodeBoxes = new Map<string, Frame>(), nodeLabels: Element[] = [];
267
+ const padding = 18, gap = graph.kind === "timeline" ? 40 : 120;
268
+ const columns = graph.layers.length;
269
+ const slotWidth = (frame.w - (columns - 1) * gap) / columns;
270
+ const defaultWidth = Math.min(
271
+ graph.kind === "timeline" ? 280 : 300,
272
+ slotWidth,
273
+ );
274
+ const maximumRows = Math.max(...graph.layers.map((layer) => layer.length));
275
+ const defaultHeight = Math.min(
276
+ 160,
277
+ (frame.h - (maximumRows - 1) * 48) / maximumRows,
278
+ );
279
+ if (defaultWidth <= 2 * padding + 36 || defaultHeight <= 2 * padding + 26) {
280
+ fail(
281
+ "GRAPH_CONTENT_OVERFLOW",
282
+ "The final node slots are too small; no automatic text shrink is applied.",
283
+ );
284
+ }
285
+ function own(
286
+ element: Element,
287
+ field: string,
288
+ child?: GraphChild,
289
+ binding = true,
290
+ ) {
291
+ const fields = result.fields[element.id] ??= {
292
+ ownedPaths: [],
293
+ bindings: {},
294
+ };
295
+ if (!fields.ownedPaths.includes(field)) fields.ownedPaths.push(field);
296
+ if (child && binding) {
297
+ fields.bindings[field] = {
298
+ sourceId: child.block.id,
299
+ attribute: child.declaration.bindings[field],
300
+ };
301
+ }
302
+ }
303
+ function style(
304
+ element: Element,
305
+ child: GraphChild,
306
+ allowed: string[],
307
+ direct = true,
308
+ ) {
309
+ for (const field of allowed) {
310
+ const ownValue = child.declaration.properties[field],
311
+ inherited = graph.declaration.properties[field];
312
+ if (ownValue !== undefined) {
313
+ element[field] = ownValue;
314
+ own(element, field, child, direct);
315
+ } else if (inherited !== undefined) {
316
+ element[field] = inherited;
317
+ own(element, field);
318
+ }
319
+ }
320
+ }
321
+ for (const [column, layer] of graph.layers.entries()) {
322
+ for (const [row, id] of layer.entries()) {
323
+ const child = graph.nodes.find((child) => child.block.id === id)!;
324
+ if (child.declaration.properties.rotation !== undefined) {
325
+ fail(
326
+ "GRAPH_ROTATION_UNSUPPORTED",
327
+ "Source node rotation needs a coupled shape/label adapter.",
328
+ );
329
+ }
330
+ const box: Frame = {
331
+ x: frame.x + column * (slotWidth + gap) +
332
+ (slotWidth - defaultWidth) / 2,
333
+ y: frame.y +
334
+ (frame.h - layer.length * defaultHeight - (layer.length - 1) * 48) /
335
+ 2 +
336
+ row * (defaultHeight + 48),
337
+ w: defaultWidth,
338
+ h: defaultHeight,
339
+ };
340
+ for (const field of geometry as (keyof Frame)[]) {
341
+ if (child.declaration.properties[field] !== undefined) {
342
+ box[field] = Number(child.declaration.properties[field]);
343
+ }
344
+ }
345
+ if (
346
+ !within(box, frame) || box.w <= padding * 2 + 36 ||
347
+ box.h <= padding * 2 + 26 ||
348
+ [...nodeBoxes.values()].some((other) => overlap(box, other, 12))
349
+ ) {
350
+ fail(
351
+ "GRAPH_CONTENT_OVERFLOW",
352
+ "Explicit node boxes must be readable, disjoint and contained by the graph frame.",
353
+ );
354
+ }
355
+ nodeBoxes.set(id, box);
356
+ const shape: Element = {
357
+ id: graphNodeId(id),
358
+ type: "shape",
359
+ shape: "rect",
360
+ ...box,
361
+ rotation: 0,
362
+ opacity: 1,
363
+ fill: "#f1f7f8",
364
+ stroke: theme.accent,
365
+ strokeWidth: 2,
366
+ radius: 14,
367
+ groupId: id + "/qb-group",
368
+ };
369
+ style(shape, child, shapeFields);
370
+ style(shape, child, ["opacity"], false);
371
+ for (const field of geometry) {
372
+ if (child.declaration.properties[field] !== undefined) {
373
+ own(shape, field, child);
374
+ } else if (
375
+ geometry.some((field) =>
376
+ graph.declaration.properties[field] !== undefined
377
+ )
378
+ ) own(shape, field);
379
+ }
380
+ const label: Element = {
381
+ id: child.label!.id,
382
+ type: "text",
383
+ html: actualLabels.get(id)!,
384
+ x: box.x + padding,
385
+ y: box.y + padding,
386
+ w: box.w - padding * 2,
387
+ h: box.h - padding * 2,
388
+ rotation: 0,
389
+ opacity: 1,
390
+ fontSize: graph.kind === "timeline" ? 22 : 24,
391
+ fontFamily: theme.fontFamily,
392
+ fontWeight: 600,
393
+ lineHeight: 1.25,
394
+ color: theme.color,
395
+ align: "center",
396
+ valign: "middle",
397
+ groupId: shape.groupId,
398
+ };
399
+ style(label, child, textFields);
400
+ style(label, child, ["opacity"], false);
401
+ if (
402
+ geometry.some((field) =>
403
+ field in child.declaration.properties ||
404
+ field in graph.declaration.properties
405
+ )
406
+ ) {
407
+ for (const field of geometry) own(label, field);
408
+ }
409
+ result.elements.push(shape, label);
410
+ result.origins[shape.id] = {
411
+ origin: "authored",
412
+ sourceId: child.block.id,
413
+ graph: metadata(
414
+ source,
415
+ graph,
416
+ child,
417
+ graph.kind === "timeline" ? "milestone" : "node",
418
+ base,
419
+ ),
420
+ };
421
+ result.origins[label.id] = {
422
+ origin: "authored",
423
+ sourceId: child.label!.id,
424
+ };
425
+ nodeLabels.push(label);
426
+ }
427
+ }
428
+ const links = graph.kind === "diagram"
429
+ ? graph.edges.map((child) => ({ child, from: child.from, to: child.to }))
430
+ : graph.nodes.slice(1).map((child, i) => ({
431
+ child: undefined,
432
+ from: graph.nodes[i].block.id,
433
+ to: child.block.id,
434
+ }));
435
+ const segments: { a: Point; b: Point; from: string; to: string }[] = [],
436
+ edgeLabels: Element[] = [];
437
+ for (const link of links) {
438
+ const fromBox = nodeBoxes.get(link.from)!, toBox = nodeBoxes.get(link.to)!;
439
+ const a = border(fromBox, center(toBox)),
440
+ b = border(toBox, center(fromBox));
441
+ for (const [id, box] of nodeBoxes) {
442
+ if (
443
+ id !== link.from && id !== link.to &&
444
+ segmentHits(a, b, {
445
+ x: box.x - 8,
446
+ y: box.y - 8,
447
+ w: box.w + 16,
448
+ h: box.h + 16,
449
+ })
450
+ ) {
451
+ fail(
452
+ "GRAPH_TOPOLOGY_UNSUPPORTED",
453
+ "A straight source edge would traverse an unrelated node; use a topology with clear direct routing.",
454
+ );
455
+ }
456
+ }
457
+ for (const previous of segments) {
458
+ const shared = [link.from, link.to].some((id) =>
459
+ [previous.from, previous.to].includes(id)
460
+ );
461
+ if (shared) continue;
462
+ if (
463
+ Math.max(a.x, b.x) < Math.min(previous.a.x, previous.b.x) ||
464
+ Math.max(previous.a.x, previous.b.x) < Math.min(a.x, b.x) ||
465
+ Math.max(a.y, b.y) < Math.min(previous.a.y, previous.b.y) ||
466
+ Math.max(previous.a.y, previous.b.y) < Math.min(a.y, b.y)
467
+ ) continue;
468
+ const orientation = (p: Point, q: Point, r: Point) =>
469
+ (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
470
+ if (
471
+ orientation(a, b, previous.a) * orientation(a, b, previous.b) <= 0 &&
472
+ orientation(previous.a, previous.b, a) *
473
+ orientation(previous.a, previous.b, b) <= 0
474
+ ) {
475
+ fail(
476
+ "GRAPH_TOPOLOGY_UNSUPPORTED",
477
+ "Crossing unrelated direct edges need a separate faithful routing adapter.",
478
+ );
479
+ }
480
+ }
481
+ segments.push({ a, b, from: link.from, to: link.to });
482
+ const length = Math.hypot(b.x - a.x, b.y - a.y),
483
+ cx = (a.x + b.x) / 2,
484
+ cy = (a.y + b.y) / 2;
485
+ const connector: Element = {
486
+ id: link.child
487
+ ? graphEdgeId(link.child.block.id)
488
+ : timelineLinkId(graph.wrapper.id, link.from, link.to),
489
+ type: "shape",
490
+ shape: "line",
491
+ x: cx - length / 2,
492
+ y: cy - 2,
493
+ w: Math.max(1, length),
494
+ h: 4,
495
+ rotation: Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI,
496
+ opacity: 1,
497
+ fill: theme.accent,
498
+ strokeWidth: 3,
499
+ lineEnd: "arrow",
500
+ from: { el: graphNodeId(link.from), side: "auto" },
501
+ to: { el: graphNodeId(link.to), side: "auto" },
502
+ };
503
+ const lineAllowed = ["fill", "strokeWidth", "opacity"];
504
+ if (link.child) {
505
+ if (
506
+ Object.keys(link.child.declaration.properties).some((field) =>
507
+ ![...lineAllowed, ...textFields].includes(field)
508
+ )
509
+ ) {
510
+ fail(
511
+ "GRAPH_EDGE_STYLE_UNSUPPORTED",
512
+ "Edge attributes support line fill/stroke-width/opacity and label typography; native geometry and anchors are visual design.",
513
+ );
514
+ }
515
+ style(connector, link.child, ["fill", "strokeWidth"]);
516
+ style(connector, link.child, ["opacity"], !link.child.label);
517
+ }
518
+ own(connector, "from/el");
519
+ own(connector, "to/el");
520
+ own(connector, "shape");
521
+ result.elements.unshift(connector);
522
+ result.origins[connector.id] = {
523
+ origin: "authored",
524
+ sourceId: link.child?.block.id ?? graph.wrapper.id,
525
+ graph: metadata(
526
+ source,
527
+ graph,
528
+ link.child,
529
+ link.child ? "edge" : "timeline-link",
530
+ base,
531
+ link.from,
532
+ link.to,
533
+ ),
534
+ };
535
+ if (link.child?.label) {
536
+ const width = Math.min(160, Math.max(72, length - 28));
537
+ const label: Element = {
538
+ id: link.child.label.id,
539
+ type: "text",
540
+ html: actualLabels.get(link.child.block.id)!,
541
+ x: cx - width / 2,
542
+ y: cy - 75,
543
+ w: width,
544
+ h: 64,
545
+ rotation: 0,
546
+ opacity: 1,
547
+ fontSize: 18,
548
+ fontFamily: theme.fontFamily,
549
+ fontWeight: 400,
550
+ lineHeight: 1.25,
551
+ color: theme.color,
552
+ align: "center",
553
+ valign: "middle",
554
+ };
555
+ style(label, link.child, textFields);
556
+ style(label, link.child, ["opacity"], false);
557
+ result.elements.push(label);
558
+ result.origins[label.id] = {
559
+ origin: "authored",
560
+ sourceId: link.child.label.id,
561
+ };
562
+ edgeLabels.push(label);
563
+ }
564
+ }
565
+ const allLabels = [...nodeLabels, ...edgeLabels],
566
+ measurements = await measureNativeText(allLabels);
567
+ allLabels.forEach((label, i) => {
568
+ if (!typographyFits(label, measurements[i])) {
569
+ fail(
570
+ "GRAPH_CONTENT_OVERFLOW",
571
+ "Actual native glyph metrics exceed the final label box for " +
572
+ label.id +
573
+ "; shorten the label or declare a larger source frame/font.",
574
+ );
575
+ }
576
+ });
577
+ edgeLabels.forEach((label, index) => {
578
+ const height = measurements[nodeLabels.length + index].height;
579
+ const previousY = Number(label.y);
580
+ label.y = previousY + 64 - height;
581
+ label.h = height;
582
+ const box = label as Element & Frame;
583
+ if (
584
+ !within(box, frame) || [...nodeBoxes.values()].some((node) =>
585
+ overlap(box, node, 4)
586
+ ) ||
587
+ edgeLabels.slice(0, index).some((other) =>
588
+ overlap(box, other as Element & Frame, 4)
589
+ )
590
+ ) {
591
+ fail(
592
+ "GRAPH_EDGE_LABEL_OVERFLOW",
593
+ "A measured edge label overlaps content or exits the graph frame; remove it or use shorter text.",
594
+ );
595
+ }
596
+ });
597
+ return result;
598
+ }
599
+
600
+ /** A semantic sync result must pass graph topology, literal-label and typography gates before source writes. */
601
+ export async function preflightGraphSource(
602
+ source: SourceDocument,
603
+ bases: ReadonlyMap<string, Frame> = new Map(),
604
+ defaults: DesignDefaults = {},
605
+ ): Promise<void> {
606
+ for (const graph of sourceGraphs(source)) {
607
+ await projectGraph(
608
+ source,
609
+ await originalGraphNode(graph),
610
+ bases.get(graph.wrapper.id) ?? { x: 80, y: 166, w: 1120, h: 470 },
611
+ defaults,
612
+ );
613
+ }
614
+ }
615
+ /** Revalidate final retained native typography, including saved offline font faces. */
616
+ export async function assertGraphTypography(
617
+ document: import("./types.ts").BentoDoc,
618
+ provenance: ProvenanceMap,
619
+ ): Promise<void> {
620
+ const labels: Element[] = [];
621
+ for (const [key, source] of Object.entries(provenance)) {
622
+ const graph = graphReceipt(source);
623
+ if (!graph || typeof graph.labelId !== "string") continue;
624
+ const [slideId] = JSON.parse(key) as [string];
625
+ const label = document.slides.find((slide) => slide.id === slideId)
626
+ ?.elements.find((element) => element.id === graph.labelId);
627
+ if (label?.type === "text" && !labels.some((known) => known === label)) {
628
+ labels.push(label);
629
+ }
630
+ }
631
+ const measurements = await measureNativeText(labels, document);
632
+ labels.forEach((label, i) => {
633
+ if (!typographyFits(label, measurements[i])) {
634
+ fail(
635
+ "GRAPH_CONTENT_OVERFLOW",
636
+ "Retained native typography clips source graph label " + label.id +
637
+ "; enlarge its box or adjust the saved typography before publishing.",
638
+ );
639
+ }
640
+ });
641
+ }
642
+ /** Closed provenance verification: this does not exempt arbitrary divs/shapes. */
643
+ export function validateGraphOrigin(
644
+ source: SourceDocument,
645
+ origin: Origin,
646
+ element: Element,
647
+ nativeSlideId: string,
648
+ sourceSlideId = nativeSlideId,
649
+ ): void {
650
+ const proof = origin.graph;
651
+ if (!proof || typeof proof !== "object" || Array.isArray(proof)) {
652
+ fail("UNMAPPED_GRAPH_PROVENANCE", "Missing graph provenance.");
653
+ }
654
+ const graph = sourceGraph(source, String(proof.componentId));
655
+ if (
656
+ proof.version !== 1 || proof.kind !== graph.kind ||
657
+ proof.sourceId !== origin.sourceId ||
658
+ graph.wrapper.slideId !== sourceSlideId ||
659
+ (proof.nativeSlideId !== undefined &&
660
+ proof.nativeSlideId !== nativeSlideId) ||
661
+ (proof.sourceSlideId !== undefined &&
662
+ proof.sourceSlideId !== sourceSlideId) ||
663
+ proof.prefix !== graphFlowSignature(source, graph) ||
664
+ origin.origin !== "authored" || Object.keys(proof).some((key) =>
665
+ ![
666
+ "version",
667
+ "kind",
668
+ "componentId",
669
+ "role",
670
+ "sourceId",
671
+ "nodeIds",
672
+ "base",
673
+ "prefix",
674
+ "labelId",
675
+ "from",
676
+ "to",
677
+ "nativeSlideId",
678
+ "sourceSlideId",
679
+ ].includes(key)
680
+ ) ||
681
+ canonical(proof.nodeIds) !==
682
+ canonical(graph.nodes.map((child) => child.block.id))
683
+ ) {
684
+ fail(
685
+ "UNMAPPED_GRAPH_PROVENANCE",
686
+ "Graph receipt disagrees with exact source membership, role or slide scope.",
687
+ );
688
+ }
689
+ const base = proof.base as unknown as Frame;
690
+ if (
691
+ !base ||
692
+ !geometry.every((field) =>
693
+ Number.isFinite((base as unknown as Record<string, unknown>)[field])
694
+ )
695
+ ) {
696
+ fail(
697
+ "UNMAPPED_GRAPH_PROVENANCE",
698
+ "Graph layout base is missing or malformed.",
699
+ );
700
+ }
701
+ if (proof.role === "node" || proof.role === "milestone") {
702
+ const child = graph.nodes.find((child) =>
703
+ child.block.id === proof.sourceId
704
+ );
705
+ if (
706
+ !child ||
707
+ proof.role !== (graph.kind === "diagram" ? "node" : "milestone") ||
708
+ proof.labelId !== child.label?.id || proof.from !== undefined ||
709
+ proof.to !== undefined ||
710
+ element.id !== graphNodeId(child.block.id) || element.type !== "shape" ||
711
+ element.shape !== "rect"
712
+ ) {
713
+ fail(
714
+ "UNMAPPED_GRAPH_PROVENANCE",
715
+ "Native node shape lacks its exact original direct node proof.",
716
+ );
717
+ }
718
+ return;
719
+ }
720
+ const edge = graph.edges.find((edge) => edge.block.id === proof.sourceId);
721
+ const index = graph.nodes.findIndex((child) => child.block.id === proof.from);
722
+ const from = graph.kind === "diagram"
723
+ ? edge?.from
724
+ : graph.nodes[index]?.block.id;
725
+ const to = graph.kind === "diagram"
726
+ ? edge?.to
727
+ : graph.nodes[index + 1]?.block.id;
728
+ const id = graph.kind === "diagram" && edge
729
+ ? graphEdgeId(edge.block.id)
730
+ : timelineLinkId(graph.wrapper.id, String(from), String(to));
731
+ const expectedRole = graph.kind === "diagram" ? "edge" : "timeline-link";
732
+ if (
733
+ proof.role !== expectedRole || !from || !to || proof.from !== from ||
734
+ proof.to !== to || element.id !== id ||
735
+ proof.labelId !== edge?.label?.id ||
736
+ (graph.kind === "timeline" && proof.sourceId !== graph.wrapper.id) ||
737
+ element.type !== "shape" || element.shape !== "line" ||
738
+ canonical(element.from) !==
739
+ canonical({ el: graphNodeId(from), side: "auto" }) ||
740
+ canonical(element.to) !== canonical({ el: graphNodeId(to), side: "auto" })
741
+ ) {
742
+ fail(
743
+ "UNMAPPED_GRAPH_PROVENANCE",
744
+ "Native connector lacks its exact original endpoint or derived timeline adjacency proof.",
745
+ );
746
+ }
747
+ }